diff --git a/docs/ANALYSIS.md b/docs/ANALYSIS.md new file mode 100644 index 0000000..efb2cb7 --- /dev/null +++ b/docs/ANALYSIS.md @@ -0,0 +1,971 @@ +# 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 `0xa1aa8–0xc28c44` 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 [-disableMipmapUnpack] [-disableDATAObjectsUnpack]` — run from a directory containing `HCStructFileArray.json` (copy from the tool's own repo root) alongside the target file; output is `.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/.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` (`0xa1aa8`–`0xc28c44`, ~11.7 MB), `.ARM.extab`/`.ARM.exidx` (unwind tables), `.rodata` (`0xc81010`–`0xd6fbc0`), `.data.rel.ro.local` (`0xd71558`–`0xd77ce4`), `.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>...>`) | +| `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`) | + +### 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`** @ `0x5640ec` — `FindClass("com/ea/ironmonkey/BitmapGraphics")` + `GetMethodID("", "(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 `memcpy`s 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//`, `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 toolkit** — `unProtect` (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` against RTTI `im::components::Component` → `im::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/.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/.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 38–40, 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_cast` — **this 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_cast`s each entry to `im::app::car::Health` — **confirms `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_33C284` → `sub_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>` (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 asynchronous** — `sub_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 changes** — `ip 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`: `applicationId` → `com.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...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.so` — **zero 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_to` → `type_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** (`0xaa78ec`–`0xaa7930`) — 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::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): +```c +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 = 0x38` — **exactly 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, 80–300s+ 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 scene** — `point_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_2` … `timetrialcheckpoint_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_2` … `timetrialcheckpoint_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_2`at 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`, `newyork` — **no `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 `.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(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` 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/7000` → `99999/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-flag` — `master` 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_6753FC` → `sub_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`** - 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 `TierItem`s) 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: `), 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 `RaceEvent`s 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(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` 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 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 `RaceEvent`s, 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` 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`-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_back`s 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_cast`s 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 `RaceEvent`s match its own `TrackId` from a shared registry. + +**Concrete implementation path for the user's actual goal** (inject a synthetic `"LAN: "` 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 prefabs has no entry with ID "` — 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` (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: "` 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: "`, `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: "`, `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+` 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` | `Zone` — **computed**, 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` 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: "`) 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`), 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`, 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_cast`s 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.** + +## 6bb. `RaceEvent`↔`CashReward` link resolved: sibling components on the same `Actor`, each deserialized independently per-race from a shared prefab property table — explains why every race has its own distinct reward + +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()`** — 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()` — 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()` 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_233b4c` → `sub_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 - ``"). 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 `int` — **the 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_back`s 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`, 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 `OpponentCollection`s 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_CopSpawnScheduler`→`Hook_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_33D734` — `TrafficCarSpawner`'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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..2b9ea7a --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,328 @@ +# ARCHITECTURE.md — Multiplayer Mod for NFS Most Wanted (2012) Android + +Living document. Revisit and edit whenever a design decision changes — do not let this drift out of sync with reality. + +--- + +## 1. Goal recap + +Layer local + dedicated-server multiplayer onto the existing singleplayer game, without breaking singleplayer. Lobby-based flow (map marker → enter lobby → player list → native car-select → ready-up → countdown → race), LAN discovery (SAMP-style broadcast) + internet servers, RakNet-based network layer with a separate headless C++ server (not a repurposed game runtime). + +--- + +## 2. Integration layers (top to bottom) + +``` +┌───────────────────────────────────────────────────────────────────┐ +│ launcher (Android APK wrapper) │ +│ - ships the original game_cache unmodified │ +│ - ships modified native libraries (or hooks them at runtime) │ +│ - hosts the lobby UI (approach TBD-per-screen, see §4) │ +└───────────────────────────────────────────────────────────────────┘ + │ loads + ▼ +┌───────────────────────────────────────────────────────────────────┐ +│ mpcore (native module, JNI_OnLoad entry point) │ +│ - loaded AFTER libapp.so and friends (per task brief) │ +│ - resolves libapp.so base via FindLibrary() │ +│ - installs inline hooks (Thumb ARM32, armhook.cpp toolkit) │ +│ - owns the RakNet CLIENT instance (peer, connects to a server) │ +│ - bridges native <-> Java for lobby UI + car-select result │ +└───────────────────────────────────────────────────────────────────┘ + │ hooks into + ▼ +┌───────────────────────────────────────────────────────────────────┐ +│ libapp.so (game engine, Iron Monkey, C++) │ +│ - RaceLoaderTask / OpponentCollection / RaceStartingGrid / │ +│ TrackNavigator / InRaceState (race lifecycle — see ANALYSIS.md) │ +│ - Flow-machine (SB-scripted UI/state flow) │ +│ - EAMText/GLES native text+UI renderer │ +│ - PlayerCarController / AICarController family (car physics/AI) │ +└───────────────────────────────────────────────────────────────────┘ + │ (future, separate process/machine) + ▼ +┌───────────────────────────────────────────────────────────────────┐ +│ Dedicated server (standalone, NOT libapp.so) │ +│ - headless C++, links RakNet │ +│ - authoritative-ish relay: lobby state, ready-check, position sync│ +│ - local discovery: UDP broadcast responder (SAMP-style) │ +│ - internet: direct connect / master-list registration (TBD) │ +└───────────────────────────────────────────────────────────────────┘ +``` + +Key architectural principle carried over from `ANALYSIS.md` §1.1: **we never originate `RaceLoaderTask`/race-start ourselves.** The player always starts a race through the game's own menu → Flow-machine → RaceLoaderTask path (any existing event works — see subtask plan). Our hooks intercept that *already-in-context* flow at specific points (opponent list build, starting grid, track navigator) to substitute multiplayer data. This keeps singleplayer's own code paths completely intact when no lobby session is active — hooks should be written to no-op/pass-through cleanly when there's no active multiplayer session. + +--- + +## 3. Where hooks live and how they're installed + +- **Injection point**: `mpcore`'s `JNI_OnLoad`, invoked after `libapp.so`/`libfmodex.so`/`libfmodevent.so`/`libnimble.so` are already loaded (confirmed as the intended design in the task brief and matches the existing `mpcore` draft's `FindLibrary("libapp.so")` call). +- **Hook mechanism**: reuse and extend `launcher/mpcore/src/main/cpp/util/armhook.cpp`. It already implements Thumb-mode (16-bit) inline hooking (`InstallHook`/`InstallJMPHook`/`InstallBLXHook`), vtable-slot overwrite (`InstallMethodHook`), and a register-copy code-injection stub (`CodeInject`), backed by a fixed trampoline region inside `libapp.so`'s own address space (`APP_ADDR(0x000A1B44)` sized `0x1A36`) plus an `mmap`'d PROT_RWX scratch page for original-bytes preservation. This is a working foundation, not a rewrite target — see ANALYSIS.md §6 for what's already verified. +- **Addressing**: `APP_ADDR(offset)` = `libapp_base + offset`, and the IDA database's addresses are already base-`0x0`-relative (i.e., IDA addresses == file offsets == `APP_ADDR` argument, no translation needed). Verified this session via the `0xC8C9D8` string anchor (see ANALYSIS.md §6). +- **Two hook strategies available depending on the subsystem** (decide per hook site once its real vtable/function is located): + 1. **Inline/trampoline hook** on a free-function or a specific vtable-slot's target — for functions we want to wrap (call original, then modify results), e.g. `OpponentCollection::Build`. + 2. **Direct vtable-slot replacement** (`InstallMethodHook`) — simpler when we want full control and don't need the original implementation, e.g. potentially `TrackNavigator`'s position resolver if we decide to fully own opponent positioning instead of post-processing it. + +--- + +## 3a. Working hook reference: `RaceLoaderTask_BuildTrackScenePath` (arbitrary track substitution) + +The first fully working, validated hook (mechanically and visually confirmed — see `ANALYSIS.md` §6g–§6o and `PROGRESS.md`'s 2026-08-05 entries). Lives entirely in `launcher/mpcore/src/main/cpp/main.cpp`, ~95 lines, no changes anywhere else. Use this as the template for future hooks (opponent list, starting grid, coordinate sync) — same trampoline pattern, same "read the struct, repoint a couple of pointers, call through to the original" shape. + +**What it does**: whichever race event the player starts through the normal menu flow, this hook makes it load a *different*, hardcoded track's geometry instead of the event's real one — while everything else about the event (rewards, opponent count, car-class restriction, HUD) stays as designed. Confirmed with a live A/B test: same event, same car, same opponents — different city, different road layout, different time of day, purely by flipping the hook on/off. + +**Target function**: `RaceLoaderTask_BuildTrackScenePath`, `libapp_base + 0x2a8424` (`BUILDTRACKSCENEPATH_OFFSET`). ARM-mode (`PUSH {R4-R11,LR}; ADD R11,SP,#0x1C`), both displaced instructions are position-independent, so it uses a **trampoline hook**: an `mmap`'d RWX page holds the two displaced instructions followed by a jump back to `target+8`, and the function's first 8 bytes are overwritten with `LDR PC,[PC,#-4]` + the hook's address. `Hook_BuildTrackScenePath` runs first, then always calls through to `orig_BuildTrackScenePath` — this is a wrap, not a replace. + +**`RaceDefinition` struct offsets used** (confirmed live, not guessed — see `ANALYSIS.md` §6j/§6n for how each was found): + +| Offset from `raceDef` | Field | Notes | +|---|---|---| +| (a1+32 → deref) | `raceDef` pointer itself | `a1` is the `RaceLoaderTask`; `raceDef = *(void**)(a1+32)` | +| `+72` / `+76` | track name `{begin,end}` char pointers | e.g. `"region1_foothills_track4"` — this hook overwrites both | +| `+100` / `+104` | environment name `{begin,end}` char pointers | e.g. `"foothills"`-style — **not** the same string as the track name; see naming convention below | +| `+164` / `+168` | `m_StartLine` actor name `{begin,end}` | generic across tracks (`"start"`) — safe | +| `+180` / `+184` | `m_FinishLine` actor name `{begin,end}` | **generic for regular races** (`"finish"`), but **per-event custom** for time-trial events (e.g. `"event_02_finish"`) — this is why time-trial events are out of scope, see below | +| `+196` / `+200` | `m_EndOfTrack` actor name `{begin,end}` | generic (`"end_of_track"`) | +| `+212` / `+216` | checkpoint-container actor name `{begin,end}` | empty for regular races; per-event custom for time-trial (e.g. `"checkpoints_timetrial_event_2"`) | + +Only `+72/+76` and `+100/+104` are overridden by this hook. The original string buffers are deliberately leaked (never freed) — `BuildTrackScenePath` only reads them, and avoiding a free sidesteps needing to know an unconfirmed capacity/allocator-owner field. + +**Environment-name gotcha**: environment prefabs are **per-track-numbered** files (`chicago1.prefabs.sb` … `chicago6.prefabs.sb`, matching `region4_chicago_track1`…`track6`), not one generic `.prefabs.sb` per city. Setting the env name to just `"chicago"` fails to open the prefab database and crashes immediately. The env-name string must match the specific track number, not just the region (`"chicago4"` for `region4_chicago_track4`). The one region that doesn't follow this convention (`region3`/colorado, shipped as a single un-numbered `colorado.prefabs.sb`) turned out to be cut/incomplete content with no loadable geometry at all — see `ANALYSIS.md` §6n before picking a new target track. + +**Picking a different target track**: edit `kOverrideTrackName`/`kOverrideEnvName` near the top of `main.cpp` to any `region{N}_{city}_track{M}` / `{city}{M}` pair that's confirmed to exist under `game_cache/published/prefabs/tracks/` and `game_cache/published/prefabs/environments/` respectively (and has a `game_cache/published/models/environments/{city}/` folder — the colorado lesson). + +**Toggle flag**: `kEnableTrackSubstitutionHook` (`constexpr bool`, just above `JNI_OnLoad`) — `true` installs the hook, `false` runs the game completely unmodified. Flip, rebuild (`./gradlew :app:assembleDebug`), reinstall — no other code changes needed. Exists specifically so before/after comparisons don't require repeated edits to `JNI_OnLoad` itself. + +**Known limitation — regular races only** (explicit project-scope decision, not a bug): time-trial and single-opponent/pursuit-style events reference their finish-line and checkpoint-group actors by the per-event custom names shown in the table above, which only exist in that event's *original* scene. Substituting the track for those event types either does nothing useful or crashes on a missing-null-check in `RaceLoaderTask_ResetStartingLine` (`GetComponent()` on a NULL actor lookup — a genuine pre-existing engine bug, not ours). This hook is only intended to run for regular point-to-point races. See the `track-substitution-scope` memory and `ANALYSIS.md` §6n for the full trace. + +**Standing gap, not yet closed**: this hook currently fires for *every* race load, gated only by the build-time `kEnableTrackSubstitutionHook` flag (default `false` since `PROGRESS.md` cont.66's fresh-save prologue crash) — it has no runtime check for "is a multiplayer session actually active." Before this can run as part of normal multiplayer play, it needs to be re-scoped to only substitute the track when a real lobby session is active, not left as an always-on-or-always-off toggle. + +--- + +## 3b. Subtask 2 — opponent substitution, starting grid, and traffic removal + +Scoped 2026-08-25; all four open questions answered via RE 2026-08-26 (`ANALYSIS.md` §6hh — full addresses/offsets/decompiles there, this section is the implementation-facing summary). **[RE complete, no code written/tested yet]** — this is the actual "make N real players race together" mechanism; everything proven so far (§3a's track substitution, the car/color/class/upgrade capture in `car_selection.h`/`mod_slot_tracking.h`) is supporting infrastructure around a single local player's own car-select flow, not this. + +**Mode scope**: street race only, consistent with §3a's "regular races only" decision. + +### 2.1 — Opponent substitution **[mechanism CONFIRMED LIVE — cont.70]** +`OpponentCollection::PopulateFromProperties` (`sub_2B649C`) builds a vector of `Opponent` objects (80 bytes each) from `RaceDefinition` SB data, one per AI opponent (always 5 for a regular race — confirmed across 15+ events, every track). Live `Opponent` layout: `+20/+24/+28` = `CarDescriptionName` (eastl string, same idiom as `RaceEvent.TrackName`), `+36` = `ColourIndex` (plain int). Implemented and live-tested (`opponent_substitution.h`): hooking `sub_2B649C`, letting it build the normal AI list unmodified, then overwriting an entry's `CarDescriptionName`/`ColourIndex` **is confirmed to reach the actual spawned, rendered car at the starting grid** — screenshotted a forced Ford Focus RS500 replacing a real opponent's original car on a real (non-synthetic) event replay. This holds despite `StreetRaceStartingGrid`'s own placement vector (2.2) being confirmed, live, to hold **different heap objects** than `OpponentCollection`'s vector — there's an unidentified intermediate spawn step between them, but it reads car/color off these same `Opponent` objects (or a copy taken after `Populate` already ran), so hooking here is sufficient; the intermediate step itself doesn't need to be found. See `ANALYSIS.md` §6ii for the full live-test writeup. **What's left**: this is currently a blanket test (forces opponent-slot-0 of every race to the same fixed test car, gated off by default via `g_enableBlanketOpponentSubstitutionTest`) — turning it into the real feature needs a lobby/session data source (which race is this, which real player's car/color goes in which slot), which doesn't exist yet since no lobby UI/data model is built. + +### 2.2 — Starting grid **[RE complete — turned out simpler than expected]** +Both open questions resolved, and favorably: the grid is **not per-track data** — `StreetRaceStartingGrid`'s 5 spacing/speed parameters (`MinDistanceBetweenRacers`/`MaxDistanceBetweenRacers`/`MaxTrackWidthFraction`/`PlayerStartingSpeed`/`OpponentsStartingSpeed`) are hardcoded C++ constants, identical for every regular street race regardless of track (confirmed: no event's SB data ever overrides them). Each track only supplies a single `"start"` locator actor (confirmed via two full track scenes, no per-slot/numbered spawn actors exist) — `TrackNavigator` resolves that plus a computed `(distance, lateralOffset)` into a world position (see 2.5 below), so the same code naturally adapts to any track's geometry with zero special-casing needed. + +"Player always last" is **not a fixed index** — it's a structural side effect of the placement algorithm (`sub_2B88BC`): it loops over the opponents vector first (accumulating randomized spacing between each), *then* places the player once, at the tail, in a separate call. **Practical upshot: multiplayer doesn't need to fight this rule at all.** Real players substituted into the opponents vector (2.1) get completely normal, correctly-spaced slots via the same code path as any AI opponent — only each device's own local player keeps the dedicated "always last" slot, which is fine since the local player is already a distinct, privileged entity (input/camera) regardless of multiplayer. + +**Cont.73 pitfall, worth remembering**: a test reimplementation that inserted the player mid-sequence (instead of at the tail) reproduced a real spawn-inside-another-car collision live. Root cause: `TrackNavigator::Resolve`'s lane bounds are symmetric around 0, so the player's hardcoded lateral offset (0, dead center) exactly coincides with an opponent's own lane-cycle position whenever that opponent's index is `≡ 0 (mod 3)`. Vanilla is safe only because the player is always maximally far (past the whole accumulated distance) from every opponent — never adjacent. Any future code that changes *where* the player is inserted into this sequence must widen the longitudinal gap around that insertion point, not just reuse the normal opponent-to-opponent spacing (sized for adjacent-different-lane cars, not same-lane). + +### 2.3 — Spawn order policy **[unchanged — still a design decision, not an RE question]** +Since 2.1/2.2 both resolve to "just insert real players as opponent-vector entries," the policy question becomes simpler: which N of the lobby's players get inserted, and their relative order within the vector (which determines lane-zigzag assignment and grid-row spacing, not "who's on the grid at all" — everyone inserted gets a normal slot). Default for the first version: alphabetical by display name, implemented as an isolated `ResolveGridOrder(lobbyPlayers) -> orderedList` function, swappable later without touching the hook itself. + +### 2.4 — Remove police and civilian traffic **[CONFIRMED LIVE, both cops and traffic]** +**Cops — CONFIRMED LIVE** (cont.74): the original `sub_F7E9C` hook missed a second spawn path. Real shape: `sub_F5EA4` is a per-tick dispatcher (called only from `sub_F5BB4`, CopManager's broader Update - too broad to skip wholesale since it also runs unrelated bust-timer bookkeeping) that branches on a live flag to either `sub_F7E9C` (the originally-hooked leaf) or `sub_F8078` (a second, previously-unhooked leaf - a distance-sorted-candidate-list scheduler). Both leaves call the same `SpawnCopCar`/`sub_F85B8` (confirmed exactly 2 callers via `xrefs_to`). Hooking the dispatcher (`sub_F5EA4`) itself covers both leaves with one hook, without touching `sub_F5BB4`'s other per-tick maintenance. **Live A/B proof**: same event replayed 3x - flag off hit a full marked police roadblock mid-race (screenshotted: light bars, "POLICE" livery, cops standing in the road) on a plain street race with no Most-Wanted branding; flag on (dispatcher hook), same route, dispatcher fired 7x in a tight burst at the same point and the road was completely clear. Gated off by default (`g_enableCopSpawnSkipTest`). + +**Civilian traffic — CONFIRMED LIVE, zero traffic achieved** (cont.75, resolving cont.74's open item): the `sub_33D734` candidate-list hook (below) can only reduce traffic to a structural minimum, never zero, so the real fix needed the actor's own placement path, not its candidate-list population. Traced `sub_C26A0` (`CarReset` - own assert strings confirm "foundTrackInfo"/"Reset") - resolves a spline distance + lateral offset to a world position and writes it via the same `sub_10B09C`/`sub_D5138` transform-write pair used by `PlaceCar` (2.2) and `SpawnCopCar` (2.4/cops) - i.e. the actual moment a traffic car's position becomes real, analogous to those two. It has exactly two callers: `sub_2A0470` (a `"ResetLine"` event handler, registered per traffic car via `sub_31AAEC` - same idiom `SpawnCopCar` uses for its own `"EndOfTrack"` event) and `sub_C201C`, the traffic car's own **per-tick controller** (second parameter carries a delta-time value; calls `sub_C26A0` once when an idle/wander timer expires, once to retry a previously-deferred reset stored in fields `sub_C26A0` itself writes on its "can't resolve yet" fallback path - i.e. genuinely traffic-car-specific, not a generic car utility shared with the player/opponents). First hooked `sub_2A0470` on the assumption it also handled initial placement - **live-tested and disproven**: its own unconditional diagnostic log never fired once across a full race replay, yet traffic was clearly visible and moving the whole time, so it isn't on the path that matters for a normal-length race (left in, harmless, but not the fix). Hooking `sub_C201C` instead (skipping its body wholesale, `g_enableTrafficControllerSkipTest`) **is** the fix: **live-confirmed zero traffic across a full 2+ minute drive** on the same route that previously showed a persistent red pickup truck and a blue sedan - clean road throughout, no crash, hook fired continuously (~50+ times, log budget exhausted) as expected for a per-tick controller. + +**`sub_33D734` do-while structural-minimum hook** (superseded as the primary fix by the above, but harmless to keep): `TrafficCarSpawner`'s populate function (own assert string confirms the class name), called twice per race from `sub_33C020` (once per traffic direction), max-count read from a different object (`*(a1[3]+16)`/`*(a1[3]+20)`) than `RaceEvent.TrafficCarCount`. Forcing that max-count to 0 (`g_enableTrafficSpawnerZeroTest`) can't reach zero alone because the populate loop is a `do { ... } while`, unconditionally queuing at least one candidate per track waypoint regardless of the max-count parameter - this is what motivated tracing one level further to `sub_C26A0`/`sub_C201C` above. + +### 2.5 — Bonus finding: `TrackNavigator`'s real spline→world resolver (relevant to subtask 4) +Found while tracing grid placement: `PlaceCar` (`sub_2914DC`) calls `sub_3261B0(navigator, outBuffer, distanceAlongSpline, lateralOffset)` — the real, working `TrackNavigator::Resolve` implementation §5 already predicted subtask 4 would need. Only the forward direction (spline→world) is confirmed; the inverse (world position → spline distance, needed to read a moving car's *current* position for network sync) hasn't been located — worth checking `TrackNavigator`'s other vtable slots when subtask 4 starts. See `ANALYSIS.md` §6hh Q4. + +### Remaining before implementation +1. Directly confirm `sub_2B88BC`'s opponent-vector argument is really `OpponentCollection`'s own built vector (high confidence from matching shapes, not yet byte-traced). +2. Only if a substituted real-player slot needs it: map `Opponent`'s AI-tuning sub-object (`+40..+75`) to explicitly suppress AI behavior — may not be necessary if a real player's own input naturally overrides it. + +--- + +## 4. Lobby UI & flow — see LOBBY_UI_DESIGN.md + +Everything about the lobby overlay's screens, flow, data schema, player identity/profile, IP:port addressing, rewards validation, and ready-state UX now lives in a dedicated file — **[LOBBY_UI_DESIGN.md](LOBBY_UI_DESIGN.md)** — kept separate so this document stays focused on native/engine integration. Clickable prototype: **[LOBBY_PROTOTYPE.html](LOBBY_PROTOTYPE.html)**. + +One decision from that doc is load-bearing here too, since it's really a native-engine question: the wait for slow-loading peers happens **at the engine level**, not on a custom Compose screen — a client that's ready sits in the game's own native pre-race starting-grid scene (looping, not our UI) with only a thin overlay status strip on top, and the real 3-2-1-GO countdown is held until everyone's in (or the 45s hard timeout). See LOBBY_UI_DESIGN.md §7 for the full reasoning and the still-open hook work this needs. + +
+Archived: original §4-4c content (superseded by LOBBY_UI_DESIGN.md, kept here for history) + +### 4. Lobby UI decision (original) + +**Revised recommendation (updated after correcting ANALYSIS.md §3.3): use a hybrid.** Full interactive lobby screens (player list, ready-up, countdown) still go through option (a) — an overlay Android `View` on top of the game's `GLSurfaceView` — because that pipeline has no native concept of touch-driven widgets. But simple in-world/in-menu indicators (the green/red map markers for active lobbies) should go through the **`BitmapGraphics` native text/atlas bridge** instead of a separate overlay, since it's verified, already trusted by the game's own renderer, and requires no new Flow/SB authoring. + +Corrected reasoning (this session confirmed, via decompiling the actual JNI call sites, that `com.ea.ironmonkey.BitmapGraphics` — already reverse-engineered as `launcher/app/src/main/java/com/ea/ironmonkey/BitmapGraphics.kt` — is a real, live native→Java upcall bridge: `Canvas.drawText` into an offscreen `Bitmap`, read back via `AndroidBitmap_lockPixels`, and blitted into a native GLES font-atlas texture every time UI text is drawn. See ANALYSIS.md §3.3 for the full call chain and renamed IDA functions): + +| Concern | Map markers → `BitmapGraphics` extension | Full lobby screens → Android overlay `View` | +|---|---|---| +| Mechanism | Add a method (e.g. `drawMarker(x, y, color)` via `canvas.drawCircle`) to `BitmapGraphics.kt`; call it from a hook alongside the existing `drawString` JNI call site (`0x5625a0`) | Standard Android `View`/Compose overlay added to the activity's view hierarchy above/around the `GLSurfaceView` | +| Implementation cost | Low — one new Kotlin method, one new hook call from `mpcore` at the point we know a lobby is active | Low — standard Android UI, fast iteration | +| Visual integration | Genuinely native — rendered through the exact same atlas-blit path as the game's own UI text, in the game's own coordinate space | Good but technically a separate layer composited over/under the GL surface | +| Touch/interaction support | None — this pipeline only produces pixels in a texture; no input handling | Full Android input handling, exactly what's needed for player list + ready buttons | +| Risk to singleplayer | Low — purely additive draw call, no-ops when no lobby is active | None — overlay is purely additive, toggled only when a multiplayer session exists | +| Car/upgrade display integration | N/A | We don't render the car ourselves either way — invoke the game's existing native car-select screen (same one singleplayer uses) as a sub-flow, our overlay only wraps around it | + +Both are additive and don't touch Flow/SB authoring, so neither destabilizes the shared menu system. This split (markers via the verified `BitmapGraphics` path, interactive screens via overlay `View`) is still a **preliminary** call — revisit once the "Choose Car" native hand-off and a first marker hook are actually implemented. Record any reversal here with reasoning. + +Still open: whether `EAMText`/`GlyphMeshGLES`/FreeType (also present in the binary, see ANALYSIS.md §3.3) plays any role that would interfere with or duplicate a `BitmapGraphics`-based marker — not yet determined, low priority until the marker hook is actually attempted. + +--- + +## 4a. Lobby flow decision and data schema + +**Flow decision:** Scenario 1 — Compose/View overlay for lobby creation and management, native car-select (filtered by the lobby's class setting) as a sub-flow, ready-checkmark to confirm. The "lobbies as cards on real street pins" idea (Scenario 2) is shelved: it depends on hooking into visible-street `MapTrack` event population, which the [[visible-streets-investigation]] found no viable path for after exhausting `AddEvent`/`Career`/`Progression`/property-registry. Scenario 1 needs no further RE — arbitrary track loading (§3a) and the native car-select screen already work or are known-reachable. + +**Data schema** (pure data shape, no UI/transport concerns): + +``` +Lobby { + id: string + hostPlayerId: string + trackId: string // arbitrary track, see §3a BuildTrackScenePath hook + classRestriction: int | null // car class filter applied to native car-select; null = any + rewards: { // reuses the CashReward shape found on RaceEvent, ANALYSIS.md §6aa + bronze: int + silver: int + gold: int + } + players: [LobbyPlayer] +} + +LobbyPlayer { + playerId: string + displayName: string // from the local PlayerProfile, see §4c — sent to peers on join + avatarId: string // preset id ("color+icon"), see §4c — not a photo/upload + isLeader: bool // explicit flag, not inferred from hostPlayerId match — + // keeps leader-ness a first-class, UI-visible fact instead of + // something every screen has to re-derive by comparison + ready: ReadyState + carId: string + colourIndex: int + mods: [int | null, int | null] // exactly 2 upgrade slots; each is a CarMod ModType id + // (or null = "НЕТ"/empty). Purely for display — other players + // resolve name/icon/description client-side from the car's own + // CarDescription.Mods catalog, already loaded for the garage + // screen. No numeric value/balance computation server-side. +} + +enum ReadyState { + SetsUpLobby // leader only — track/class/rewards configuration + ChoosingCar + ChoosingRace + Loading // race-start fired, client is loading its own RaceLoaderTask + NotReady + Ready +} +``` + +Confirmed against the live in-game upgrade screen (2 slots, `ВЫБОР УЛУЧШЕНИЯ` list) and the `CarMod` struct in `alfaromeo_4c_2012_desc.prefabs.sb` — 7/7 checked entries matched by price (`ModType` 1/4/5/6/7/8 confirmed, `TYRES_REINFLATING`/`CHASSIS_REINFORCED`/`BODY_IMPACT`/`POWERTRAIN_POWERPACK`/`NITROUS_BURN`/`NITROUS_EARN`). `ModType` is a unique per-option id, not a shared category grouping — an earlier hypothesis to that effect is superseded. The separate `im::app::car::CarPart`/`UpgradeParts` catalog (with `skipCost`/`orderTimeMinutes` timer-gating fields) has no live instance data in any checked file and appears unused in this build — not part of this schema. + +**Resolved (2026-08-18, see §4b for reasoning):** +- Host-leaves-lobby: auto-promote the longest-connected remaining `LobbyPlayer` to leader; dissolve only when the lobby becomes empty. Keeps a session alive across a flaky host connection instead of punishing everyone else. +- `classRestriction`: **enforced explicitly, at the overlay layer, not the native carousel** (revised 2026-08-19) — car_select has no known native class-filter hook (`RACEEVENT_CATEGORYTAG_OFFSET` was tried and is a confirmed false lead, PROGRESS.md cont.58), and rather than leave that as an excuse to only show the restriction as a label, the client-side flow explicitly checks the chosen `carId`'s class against `classRestriction` right after `onCarSelected` fires: a mismatch **blocks** `ready` (visible error state, forces the player back into car_select) instead of just being advisory. Revisit as a true hard filter — blocking the native carousel itself before a mismatched pick is even possible — if the real class-filter mechanism is ever found (cont.59/60's open leads). + +**Still open, needs a product decision:** the straggler/timeout policy during the loading phase — see §4b step 5. + +--- + +## 4b. Full lobby flow (designed 2026-08-18) + +Every step below is tagged **[proven]** (already implemented and live-tested on device per PROGRESS.md), **[wired, needs new UI]** (the native hook/JNI plumbing exists, only the lobby overlay code calling it is missing), or **[open]** (real engineering work not started). This distinction matters more than usual here because two of this project's biggest proven results — the track-substitution hook (§3a) and the car/color/upgrade capture (§3a of PROGRESS.md cont.58-63b) — were each built against a *different* entry point (a real, normal race event) than the one the synthetic "LAN: ``" event currently reaches, and those two paths are not yet joined. See step 5 for why that matters. + +### Phase 0 — Entry point **[open, trivial]** +A small persistent button (e.g. bottom-corner Compose overlay, always present once `onMapLoaded` has fired at least once) opens the lobby overlay full-screen on top of `GameGLSurfaceView`. Same `FrameLayout`-stacking pattern already used for `buildCarSelectionOverlay()` (`GameActivityMain.kt`) — the GL surface keeps rendering underneath (map stays visible/animated), our Compose content just takes over touch input while open. + +### Phase 1 — Create or join **[open — no networking yet]** +- **Create**: leader picks lobby name, track (any `region{N}_{city}_track{M}` confirmed to exist per §3a's own caveats — reuse that same validated list, do not hand-author a new one), optional `classRestriction`, reward tier (`CashReward`, ANALYSIS.md §6aa — real engine struct, not invented). Leader becomes `LobbyPlayer{isLeader=true, ready=SetsUpLobby}`. +- **Join**: LAN broadcast discovery (ARCHITECTURE §5, SAMP-style UDP) or direct IP:port. Neither transport exists yet — RakNet integration hasn't started (§5). The prototype (see chat) stands this phase up with **local mock state only** (no real second device) until RakNet lands. + +### Phase 2 — Player list / waiting room **[open UI, wired data underneath]** +Real-time roster: name, ready-state icon, chosen car icon + colour swatch (same visual as the existing `CarSelectionBadge`, just one per row instead of a single corner badge), leader crown. Leader additionally sees "Начать гонку", disabled until every `LobbyPlayer.ready == Ready`. + +### Phase 3 — Native car-select hand-off **[wired, needs new UI]** +This is the one phase where the hard native work is already done: +- Tapping "Выбрать машину" calls `MultiplayerCore.triggerTrueDirectCarSelectJump()` — **[proven]**, jumps straight into the real native car_select screen for our synthetic event, no map navigation needed (PROGRESS.md cont.48/57). Fallback to `MultiplayerCore.triggerCarSelectTest()` (opens EventDetails first, then auto-skips — also **[proven]**) if the true-jump's own warm-up precondition (`g_realEventDetailsVisitHappened`, i.e. at least one real event_detail→car_select transition already happened this session) hasn't been satisfied yet — the "cold session" gap noted in cont.55/57, still open. The lobby overlay should shrink to a small corner badge (not fully dismiss) while this runs, matching how `CarSelectionBadge` already coexists with the game's own UI. +- Player interacts with the fully native, unmodified car_select + loadout screens (real rendering, real touch — by design, ARCHITECTURE §4 already decided this stays native). +- `onCarSelected(carId, colorName, r,g,b,a)` — **[proven, wired to Kotlin]** — fires the moment car_select's own CONTINUE is tapped (cont.61/63b). +- `onUpgradesAccepted(slotIds, carMods)` — **[proven, wired to Kotlin]** — fires when the loadout screen's controlled BACK×3 exit lands back on the map (cont.39-41). +- The lobby overlay listens for `onUpgradesAccepted` (or, if the player has no mods to set and the loadout screen is skipped, the underlying "back on map" signal that same hook chain already detects) as the "player is done, back to lobby" trigger, restores itself to full view, and fills that player's roster row from the already-captured `CarSelectionState`. Player then taps a "Готов" toggle → `ready = Ready`. + +### Phase 4 — Ready-check & start **[open — needs RakNet]** +Leader's "Начать гонку" broadcasts the final race definition (track id, per-player car/colour/mods) once every player is `Ready`. No transport exists yet to carry this — it's a data-shape problem only right now (the `Lobby`/`LobbyPlayer` schema above already covers what needs to go over the wire). + +### Phase 5 — Loading, and the "someone's still loading" problem **[mostly open — the real gap in this whole flow]** +Two unresolved issues sit here, and they're different in kind: + +1. **Which race event actually carries the load.** The synthetic `RaceEvent` used to reach car_select on demand (step 3) has its loadout-confirm **deliberately redirected back to the map today**, specifically because it has no real track/scene reference — letting it proceed into `RaceLoaderTask` crashes on a NULL start-line lookup (PROGRESS.md cont.36, confirmed root-caused via cont.41's A/B: a *real* event's loadout-confirm sails through to an actual playable race with zero crashes, using the exact same code path). Giving the synthetic event a real track/scene reference is flagged in cont.37/41 as "a materially bigger investigation than anything solved so far." **Recommended path, not yet implemented**: don't fight that — have the lobby's "start" instead re-target a *real*, existing race event as the vessel (any regular point-to-point event works, since §3a's track-substitution hook already proves the geometry can be swapped after the fact), and let that real event's own loadout-confirm proceed normally instead of being redirected. This sidesteps the missing-track-scene crash entirely instead of solving it. Needs its own short investigation (mostly wiring, since both halves — real-event loadout-confirm passthrough, and track substitution — already independently work) before it can be marked proven. +2. **Holding a fast-loading client at the start line.** Nothing hooks the moment between "this client's `RaceLoaderTask` finished" (candidate signal: `InRaceState` construction, RTTI-confirmed §3.1, or `RaceLoaderTask_DispatchInitialFSMEvents`'s final event, §6b stage 8 — either is a real, already-located anchor, just not yet hooked for this purpose) and "gameplay actually starts ticking." **This needs new hook work**: freeze input/sim on that client once loaded, show a small "Ожидание игроков (2/4)" overlay (same Compose layer, minimized), and only release once the host broadcasts "everyone's in." `RaceLoaderTask_ExecuteLoadSequence`'s own `SetLoadProgress(this, N)` calls (§6a slot 4, fractions 0.1–0.8) are a good source for a real, granular per-client progress percentage to report to peers, rather than inventing a synthetic one. +3. **Straggler/timeout policy — resolved 2026-08-18**: hard timeout, auto-start. Default 45s (configurable) counted from when the first client reports fully loaded; whoever hasn't reported loaded by then is left behind and the race starts without them. Simplest to implement/reason about, no host-facing decision UI needed — accepted tradeoff is occasionally starting a race short a player on a slow device. Still needs the actual per-client "loaded" signal (point 2 above) and a decision on how a left-behind player is represented (spectator vs. rubber-band-in-when-ready) — lower priority than the timeout mechanism itself. + +--- + +## 4c. Player identity (name + avatar) — added 2026-08-18 + +Raised as a gap while prototyping the lobby screens: every player row needs *something* to display before any of them have picked a car, and it can't come from an EA account — ARCHITECTURE §8 already rules out the Synergy backend for anything multiplayer-related, and this project has no login system of its own. + +**`PlayerProfile`**, stored **locally on-device only** (Android `SharedPreferences`/DataStore — no server round-trip, no RE work, plain new Kotlin), edited once on first run and editable any time after from a chip in the lobby browser header: + +``` +PlayerProfile { + displayName: string // free text, local default e.g. "ROOKIE_01" + avatarId: string // one of a small fixed set of {color, glyph} presets +} +``` + +**Avatar is a preset, not a photo** — a small fixed palette of colour+glyph combinations (e.g. 🏎️/⚡/🔥/🏁/★/🌙/⚙/💀 over a themed accent colour), picked from a grid. Deliberately not a camera/gallery photo upload: no permissions prompt, no image storage/transport concerns once RakNet is carrying lobby state, and it matches the HUD-badge visual language already established by `CarSelectionBadge`/the roster rows. Revisit only if the game ever needs to show a real player photo somewhere — nothing in the current design requires it. + +`PlayerProfile` is copied into `LobbyPlayer.displayName`/`avatarId` (schema above) at join/create time and travels with the rest of the lobby state once RakNet exists — no separate sync mechanism needed. + +
+ +--- + +## 4d. Debug menu (Compose) — see DEBUG_MENU.md + +An on-map, developer-only debug overlay, added 2026-08-19 — full detail (activation mechanism, enable flag, panel inventory) now lives in a dedicated file, **[DEBUG_MENU.md](DEBUG_MENU.md)**, kept separate for the same reason as the lobby UI split (§4). One decision worth surfacing here: the enable/disable switch (`DebugFeatures.debugMenuEnabled`) deliberately lives in `mpcore`, not `app` — a direct instruction, so debug-tooling gating stays in the shared module rather than duplicated per-Activity. + +--- + +## 5. Network layer (RakNet) — high-level plan (later stages, laid out now) + +- **Client**: embedded inside `mpcore` (same process as the game). Owns a RakNet `RakPeerInterface`, connects to either a LAN-discovered server or a manually-entered/internet server address. +- **Server**: standalone headless C++ binary/service, links RakNet directly — explicitly **not** a copy of `libapp.so`'s game runtime. Responsibilities: lobby membership, ready-check aggregation, countdown trigger, relaying position/state updates between clients (topology TBD: pure relay vs. light authority — default to relay first, revisit if cheating/desync becomes a concern). +- **LAN discovery**: SAMP-style UDP broadcast query/response on a fixed port, server replies with lobby metadata (name, player count, track/event). Reference implementation pattern: `GTASA_examples/` (any version — wrappers differ, core discovery/RakNet integration pattern is what matters). +- **Internet servers**: direct-connect by IP:port initially; a master-server/list mechanism is a later refinement (not required for first working version). +- **Position sync**: builds directly on subtask 4 (coordinate read/write hook on the car/track-navigator layer) — the RakNet layer's job is just to move `(distance_along_spline, lateral_offset, height, or raw Vector3+rotation)` tuples between peers at some tick rate; the actual "make the opponent car appear at X" mechanism is the native hook, not the network code. + +No RakNet integration work has started yet — this section exists so later native-layer decisions (e.g., what a hooked position-update struct looks like) are made with the eventual network payload shape in mind. + +--- + +## 6. Build & deploy pipeline for native patches + +Confirmed this session: + +- `native_lib/` (top-level) is **not under any version control** (`git rev-parse` fails there — the repo root itself has no `.git`). It is the sole safety net for "what the game originally shipped" and the file the IDA `.i64` was built from. **Rule: `native_lib/libapp.so` (and siblings) must never be overwritten in place.** Any patched binary is a *new* file. +- `launcher/` is its own separate git repository, and — checked via `git ls-files` — `launcher/app/src/main/jniLibs/armeabi-v7a/*.so` **is tracked in git** (not gitignored), including `libapp.so`. Currently byte-identical to `native_lib/libapp.so` (same SHA-256). This means the copy that actually feeds the APK build has its own independent safety net (`git diff`/`git log`/`git checkout` inside `launcher/`), on top of our manual changelog. +- **Workflow for every native patch, going forward**: + 1. Produce the patched bytes into a new file under `native_lib/patched/` (created this session, empty for now — no patches exist yet), never touching `native_lib/libapp.so` itself. + 2. Log the change in `PROGRESS.md` (offset, old/new bytes or hook description, why, which patched-file snapshot it corresponds to). + 3. Copy the patched file to `launcher/app/src/main/jniLibs/armeabi-v7a/libapp.so` (overwriting the build input — safe, since that copy is git-tracked and diffable). + 4. Build the APK (Gradle, already configured per the task brief) — user installs/tests on their own device or WayDroid (see §7). +- `mpcore` itself is the other half of "the magic": it does no static patching of `libapp.so` — instead, at `JNI_OnLoad` time, it resolves `libapp.so`'s **runtime base address** and installs hooks against `base + offset` (the `APP_ADDR()` macro, already in `armhook.cpp`). So there are two independent things to track in `PROGRESS.md`, and they should be logged distinctly: (a) actual byte patches to the shipped `.so` file itself (rare — e.g. removing an anti-tamper check), and (b) runtime hooks installed by `mpcore` (the common case for all the race/opponent/coordinate hooks in the subtask plan) — these don't modify the `.so` on disk at all, only memory at runtime, but should still be documented (what address, what it does, why) since they're just as capable of breaking singleplayer if wrong. + +## 7. On-device testing capability (WayDroid) + +Checked this session: `adb` and `waydroid` CLI are both installed on this machine and reachable from this session's shell. Attempted `waydroid session start` — **it failed**: `Wayland socket '/run/user/1000/wayland-0' doesn't exist; are you running a Wayland compositor?`. Confirmed cause: the active desktop session here is GNOME-on-**Xorg** (`DISPLAY=:1`, real `Xorg`/`gnome-shell` processes, `WAYLAND_DISPLAY` unset) — WayDroid requires a genuine Wayland compositor and cannot render under plain X11. + +**Fix (user action needed)**: log out and pick a Wayland session at the GDM login screen (e.g. "GNOME" without the "(X11)" suffix), then `waydroid session start` should succeed. + +**Once a Wayland session is active, this session can plausibly**: `waydroid app install `, `adb connect`/`adb shell` for logcat and file access, `adb shell screencap` piped to a file and viewed via the image-reading tool for visual verification, and even blind UI interaction via `adb shell input tap/swipe` driven off those screenshots (same pattern as the browser-automation tool, just for Android). This would close most of the test loop without needing the user present for every check — still recommend the user do final verification on real hardware, since WayDroid's GPU/driver stack can behave differently than a real device for this kind of engine. + +## 8. Non-goals / explicit constraints carried from the task brief + +- Singleplayer must keep working with the mod installed and multiplayer inactive — every hook must be conditional/pass-through when no session is active. +- The dedicated server is a separate C++ service, not a headless instance of the Android game runtime. +- No use of any legacy EA "Synergy" backend endpoints (leaderboards/IAP/DRM) for multiplayer — confirmed in ANALYSIS.md §3.4 this is unrelated infrastructure and likely defunct server-side anyway. + +--- + +## 9. ARM64-only device support (Android 14+ AArch32-less hardware) — see ARM64_TRANSLATION_LAYER.md + +Everything about running `libapp.so` (ARM32) on newer devices whose CPU cores dropped AArch32 execution entirely now lives in a dedicated file — **[ARM64_TRANSLATION_LAYER.md](ARM64_TRANSLATION_LAYER.md)** — kept separate so this document stays focused on the mod's own integration architecture. Pure theory as of 2026-08-19, nothing implemented: the short version is an in-process, dynamic CPU-level translation layer (Unicorn/QEMU-TCG-class embeddable ARM32 core, Hangover/Box64-style API-boundary breakout for libc/JNI/GLES/audio) rather than a full-VM (VMOS-style) or a static ARM32→ARM64 recompile — the latter would invalidate every offset in `ANALYSIS.md`/§3a's RE work, the former discards all of this project's native-process integration for no reason. diff --git a/docs/ARM64_TRANSLATION_LAYER.md b/docs/ARM64_TRANSLATION_LAYER.md new file mode 100644 index 0000000..d6fdc69 --- /dev/null +++ b/docs/ARM64_TRANSLATION_LAYER.md @@ -0,0 +1,3637 @@ +# ARM64_TRANSLATION_LAYER.md — Running `libapp.so` (ARM32) on AArch32-less ARM64 devices + +Living document, split out from [ARCHITECTURE.md](ARCHITECTURE.md) §9 on 2026-08-19 per explicit instruction ("по этой эмуляции веди отдельный файл"). Pure theory so far — **nothing implemented, nothing prototyped**. Revisit and edit whenever a design decision here changes; don't let this drift out of sync with reality, same rule as every other doc in this repo. + +--- + +## 1. Problem + +`libapp.so` (and its native siblings — FMOD, Nimble, our own `mpcore`) is `armeabi-v7a` (AArch32). Newer Android 14+ devices increasingly ship SoCs whose performance cores dropped AArch32 execution entirely — ARM's own roadmap: Cortex-A710 was the last core generation with AArch32 EL0 support, everything from Cortex-A715/X3 onward (and every Cortex-X4/A720+ successor) is AArch64-only. Some 2023+ chips (e.g. Snapdragon 8 Gen 2) kept a few AArch32-capable little cores around for compatibility, but that's a vendor courtesy, not an ISA guarantee, and later designs remove it entirely. On a genuinely AArch32-less chip there is **no hardware execution mode** that can run ARM32 machine code, full stop — not even as a plain `armeabi-v7a` child process. This is a fundamentally different problem from "our library happens to be 32-bit," and confirmed (via research this session) to have no existing off-the-shelf solution for Android specifically — the only shipping consumer answer today is full-VM apps (VPhoneGaGa-style), i.e. exactly the VMOS-style approach the user wants to avoid. + +## 2. Why the obvious alternatives are wrong for this project + +**Full-VM (VMOS-style)**: re-executes the *entire* guest OS (Android system services, Zygote, the whole 64-bit-capable Kotlin/Compose launcher we already wrote) inside a nested virtual device, discarding all the native-process integration this project depends on (real `GLSurfaceView`, direct JNI, `mpcore` living in the same address space as `libapp.so`). It also usually means running the *host's* 64-bit-capable code through 32-bit emulation too, for no reason — pure waste and a much bigger performance/compatibility hit than necessary. This is the thing the user explicitly wants to avoid, and the reasoning above is why avoiding it is also technically the right call, not just a preference. + +**Static/ahead-of-time recompilation** (ARM32 machine code → real ARM64 machine code, once, at build time — a real technique used elsewhere, e.g. some legacy console-preservation projects): wrong for *this specific codebase*, even though it's legitimate in general. Every hook, struct offset, and vtable-slot number in `ANALYSIS.md`/`ARCHITECTURE.md` §3a (the `RaceDefinition` field table, `RACEEVENT_CARCLASS_OFFSET`, etc.) is derived from `libapp.so`'s **original 32-bit memory layout** — 4-byte pointers, 32-bit vtable slots, AAPCS32 calling convention. A static recompiler that widens pointers to 8 bytes and re-lays-out every struct would invalidate essentially the entire body of RE work this project has built up over dozens of sessions, and require re-deriving it from scratch against a different binary. Static binary recompilation of a real-world 1990s–2010s-style C++ engine (indirect calls, vtables, a scripted Flow-machine VM, hand-tuned Thumb code) is also a genuinely unsolved problem in general — not a viable foundation to bet a solo project on. + +## 3. Recommended direction: in-process, dynamic CPU-level translation with an API-boundary breakout + +Not a full-OS/full-syscall emulation. The closest real, working precedent is the **Hangover** project (runs Windows x86/x86_64 binaries on ARM64 Linux by pairing Wine with an embedded CPU emulator): its core insight is *"emulate the application's own instruction stream only; the moment execution reaches a Windows/Wine API call, break out of emulation and run that call natively"* — i.e. don't emulate an OS, emulate a CPU, and reimplement/forward the API surface the guest code actually calls. Box64/Box86 (x86-on-ARM) use the same "wrapped libraries" idea for the same reason. The equivalent shape for this project: + +1. **CPU core**: an embeddable ARM32 (AArch32) instruction emulator with a real JIT (block-translate-and-cache, not pure interpretation — a 60fps racing game's physics/render-command hot path needs near-native speed). Candidates to evaluate, not build from scratch: + - **Unicorn Engine** (MIT-licensed, QEMU-TCG-derived, explicitly designed to be embedded as a library and driven by custom host code) — best fit: gives raw instruction execution + memory-mapping + basic-block/instruction hook callbacks, and nothing else, so we build exactly the "OS" we need on top rather than inheriting one. + - **QEMU linux-user (`qemu-arm`) embedded as a library** — more complete (real syscall translation) but heavier, and its natural shape is a whole separate process image rather than something sharing our JNI/GL state in-process; would push toward a separate-process design (see §5) rather than in-process. +2. **ELF32 loader / mini dynamic linker**: load `libapp.so` (and any other ARM32-only dependency — need to check which of FMOD/Nimble/etc. actually ship an `arm64-v8a` build already, since those could stay native ARM64 and only `libapp.so` itself would need translation) into guest-addressable memory, resolve its imports against our own shim table instead of a real 32-bit libc/libdl. +3. **API-boundary shims (the actual bulk of the work), one per subsystem the guest calls out to**, each translating 32-bit-ABI arguments (pointer width, struct layout) to a real native ARM64 host call in the *same process*: + - **libc/libm/pthread** — mostly mechanical 1:1 forwarding to the host's real bionic (malloc/free, string funcs, math, mutexes); threading needs a host thread + fresh emulator context per guest thread, and correct TLS-register emulation (AArch32 `TPIDRURO` vs AArch64 `TPIDR_EL0`). + - **JNI upcalls** — bounded and mechanical: the guest's `JNIEnv*` function-pointer table points at trampoline stubs that marshal `jint`/`jfloat`/`jlong` (already same-size both sides) and pass `jobject`/`jclass`/`jstring` handles through opaquely. This is the *least* risky part of the whole design — it's exactly the kind of thing already proven to work for ARM-on-x86 Android translators (`libhoudini`-class tools). + - **GLES/EGL** — the hard part isn't the translation, it's that on a genuinely AArch32-less device there is no 32-bit GPU driver to link against at all, at any layer, so this **must** be a thin API-level shim (à la Hangover), never a driver/syscall-level one: guest calls to `glBufferData`/`glVertexAttribPointer`/`glShaderSource`-style calls (anything taking a guest pointer to a data blob) need a guest→host address-translation step (standard in every such translator — Unicorn exposes guest memory as ordinary host memory, so this is a base-address add, not a copy) before forwarding to the real, already-linked ARM64 GLES functions running in the same GL context. + - **Audio (FMOD/OpenSL ES)** — same shim pattern as GLES, smaller surface. + - **`mpcore`'s own hooks** — this is a genuine simplification, not just a porting cost. The existing `armhook.cpp` Thumb-mode byte-patch/trampoline system (ARCHITECTURE §3) exists only because we don't control code generation and have to patch raw instruction bytes in place. Once `mpcore` is the thing *driving* the emulator, hooking becomes "register a callback for guest PC == target address" (Unicorn's `UC_HOOK_CODE`, or the QEMU-TCG equivalent) with full read/write access to guest registers/memory and the ability to skip/replace instructions — strictly more capable and far less fragile than manual trampoline construction. `mpcore` itself should become plain native ARM64 code (we own its source, nothing stops us recompiling it for `arm64-v8a`) that owns and drives the embedded ARM32 core, rather than being ARM32 code living inside it. + +## 4. What does and doesn't change + +**What stays valid**: every offset/struct-layout fact already recorded in `ANALYSIS.md` and `ARCHITECTURE.md` §3a remains valid, because `libapp.so`'s own bytes and internal memory layout are never altered — only the *instruction decode/dispatch* is intercepted by the emulator. This is the strongest argument for this approach over static recompilation: it's additive to the existing RE work, not a reset of it. + +**What stays native**: the Kotlin/Compose launcher, `GameActivityMain`'s `GLSurfaceView`, the real JNI environment, and `mpcore`'s own control/RakNet-client logic all run as ordinary native ARM64 code in a normal 64-bit process — there is no nested Android instance, no second Zygote, no VM. Only `libapp.so`'s instruction stream (plus whichever of its native dependencies turn out to have no `arm64-v8a` build) executes through the embedded translation core, in the *same* process and address space as everything else. This is the literal answer to "emulate only the 32-bit part, not the whole APK." + +## 5. Open risks to validate empirically before committing real effort + +In rough order of "would kill the approach if wrong": + +- **JIT throughput on real target hardware.** No amount of design reasoning substitutes for a spike: embed Unicorn in a trivial ARM64 Android app, JIT-execute a handful of representative `libapp.so` functions (a hot per-frame one like `RaceLoaderTask`'s tick, not just a toy loop) from a real JNI call, and measure. If this can't get within striking distance of 60fps on mid-range 2024+ hardware, the whole direction needs rethinking (e.g. only translating cold-path code and finding a way to ship the genuinely hot per-frame loop as separately-recompiled/hand-ported ARM64 — a much bigger undertaking) before any shim layer gets built. +- **VFP/NEON correctness** — Unicorn/QEMU-TCG both claim support, but this engine's physics/graphics math needs to be bit-for-bit sane, not just "runs without crashing." Worth a targeted correctness test, not just a perf one. +- **Which native dependencies actually need translation vs. can stay native ARM64** — check today whether FMOD/Nimble/etc. ship `arm64-v8a` builds; every one that does shrinks the shim surface and removes a whole subsystem (audio, most likely) from the translation problem entirely. +- **Threading model complexity** — how many native threads does `libapp.so` actually spawn, and does any of them do anything timing-sensitive enough that host-thread/guest-context scheduling jitter would matter (audio callback, physics tick)? + +## 6. Fallback shape if in-process embedding proves impractical + +A real, separate ARM32 process (real `qemu-arm` linux-user, since there's no hardware to fall back to) hosting `libapp.so` + a minimal syscall/JNI shim, talking to the main 64-bit process over IPC (socket/Binder/shared memory) for rendering/audio/input — more isolated (a guest crash doesn't take down the whole app) and easier to bring up incrementally, but loses in-process GL-context sharing (cross-process EGL context sharing on Android is itself a nontrivial problem — `SurfaceTexture`/`BufferQueue`-based tricks, not a free win) and adds IPC latency to every frame's worth of GL calls. Treat as the "if in-process turns out too hard" plan B, not the starting point. + +## 7. Status + +**2026-08-19: first working prototype, built and validated off-device, not yet run on hardware.** + +Built in an isolated git worktree/branch of the launcher repo (`arm64-translation-poc`, branched from the most feature-complete committed hook state, `lan-event-injection-poc`, at `/home/megboyzz/AndroidStudioProjects/NFSMostWanted128-arm64-poc` — kept separate from the main worktree other sessions were actively using). Package `com.ea.games.nfs13_arm` (vs the real mod's `com.ea.games.nfs13_mod`), so it installs side by side rather than overwriting. + +**What's real and confirmed, not just designed:** +- Before writing any Android/NDK code, the core ELF-load + Unicorn-hook mechanism was validated against the REAL `native_lib/libapp.so` bytes via a desktop Python/`unicorn`/`pyelftools` spike (this session's own scratch script) — confirmed byte-identical mapping at two independently-documented function offsets, and a `UC_HOOK_CODE` hook firing exactly at a real target address. +- `mpcore` itself moved from `armeabi-v7a` to `arm64-v8a` (native ARM64, per §3's own design point) and now vendors Unicorn 2.1.4 (ARM backend only, static) built via CMake as part of the normal NDK build — confirmed compiling cleanly against NDK r27 with no source changes to Unicorn needed. +- A real ELF32 loader (`mpcore/src/main/cpp/emu/guest_engine.cpp`) maps `libapp.so`'s `PT_LOAD` segments into one Unicorn-backed host buffer at guest address 0 (matching the ELF's own preferred base — confirmed via `readelf`/`pyelftools` this session, `ET_DYN` with base 0), processes `.rel.dyn`/`.rel.plt` (skips `R_ARM_RELATIVE` as a deliberate no-op since load bias is always 0 by construction; resolves `R_ARM_GLOB_DAT`/`R_ARM_JUMP_SLOT`/`R_ARM_ABS32` against either the locally-defined symbol or a per-symbol import stub), and this design choice means `libapp_base + OFFSET` — the addressing convention already used by every existing offset in this codebase — is unchanged and still just works. +- A generic re-entrant `CallGuestFunction` + `GuestFn`/`InstallTrampolineHook<...>` template layer (`emu/guest_fn.h`) reproduces this project's old `InstallArmTrampolineHook`/`orig_X(...)` calling convention almost verbatim, just backed by a `UC_HOOK_CODE` dispatch instead of live byte-patching — meaning hook BODY code barely has to change at all. +- **One full, real hook ported end-to-end**: `RaceLoaderTask_BuildTrackScenePath` (ARCHITECTURE.md §3a's own validated track-substitution hook) and the `MapScreen` ctor trace hook now install and call through via the new engine, with their existing bodies essentially untouched. +- A small, explicitly-bounded set of import shims (`emu/import_shims.cpp`): `__aeabi_mem*`, `malloc`/`free`/`calloc`/`realloc` (via a real first-fit `GuestHeap`), `strlen`/`strcmp`/`strcpy`, `__cxa_guard_*`, `abort` — everything else gets a stub that logs "unresolved import" once and returns 0 rather than crashing. +- `./gradlew :mpcore:assembleDebug` and `:app:assembleDebug` both succeed; the resulting APK (`aapt2 dump badging`) reports `native-code: 'arm64-v8a'` only, ships `lib/arm64-v8a/libmpcore.so` and `assets/native_probe/libapp.so` (the original ARM32 binary as a raw asset, not `jniLibs` — confirmed not repackaged/stripped). + +**What's deliberately NOT done, and why** (see `main.cpp`'s own comment above its now-excluded `lan_event_injection.h` include for the full version): the LAN event-injection subsystem (`lan_event_injection.h` + `car_selection.h`/`mod_slot_tracking.h`/`crash_workarounds.h`, ~2300 lines) is preserved as source but not wired into this build. Its guest-function calls are the easy part (same `GuestFn` treatment as the two ported hooks); several of its call sites pass a pointer to a **host C++ stack local** as an "out parameter" for the guest function to fill in (`ResolveHandle(&res, ...)`, `HashInsert(&insertResult, ...)`, `InternString(&nameSlot, ...)`) — Unicorn can only read/write memory inside the mapped guest region, so each of those needs its own guest-scratch-buffer marshaling, by hand, verified case by case. Doing that blind, with no device or way to test this subsystem's own live-tuned wall-clock timing assumptions, was judged too likely to ship silent breakage — left for a follow-up pass once real testing is available. + +**Also NOT attempted this session** (same "don't claim what wasn't built" principle): actually booting the game's own native init/render loop through the emulator. `GameActivityMain`'s 16 `external fun native*` lifecycle/GL callbacks (previously implemented inside the real `libapp.so`) are stubbed as harmless no-ops (`game_lifecycle_stubs.cpp`) purely so the app doesn't crash on the first Activity callback — seeing them log confirms the native side loaded correctly, but the game itself does not render or tick. FMOD/Nimble were confirmed this session to have no `arm64-v8a` build at all (only `armeabi-v7a`/`x86` shipped) — they too would need the same emulation/shim treatment as `libapp.so`, not just be recompiled, which §5's own "which dependencies need translation" open question already anticipated. No VFP/float-register ABI support exists yet (only r0-r3 integer/pointer args) — flagged in `import_shims.cpp`'s own top comment. + +**2026-08-28: first real on-device run — Xiaomi 14 (Snapdragon 8 Gen 3, codename "pineapple"), `ro.product.cpu.abilist=arm64-v8a` only (no `armeabi-v7a` listed at all by the OS — as close to a confirmed AArch32-EL0-less device as this project has had hands on).** Fresh install, real OBB (`main.1003128.com.ea.games.nfs13_row.obb`, renamed to match this build's package/versionCode, placed at the standard `/sdcard/Android/obb//` path — same mechanism this project's own PROGRESS.md already documented working for the real armeabi-v7a build). Confirmed via logcat, verbatim: + +``` +GuestEngine: .rel.dyn: 48533 entries, 2831 import(s) resolved to stubs, 45699 RELATIVE skipped (bias=0), 0 unknown reloc type +GuestEngine: .rel.plt: 515 entries, 512 import(s) resolved to stubs, 0 RELATIVE skipped (bias=0), 0 unknown reloc type +GuestEngine: loaded .../libapp_armeabi_v7a.so - image_end=0xb16000 heap=[0xb16000,0x4b16000) stack=[0x4b36000,0x5336000) region_size=0x5337000 +loadEmulatedLibapp: libapp.so loaded into the emulation core, host base=0x6e6f433000 +``` + +`image_end`/heap/stack numbers are byte-identical to the earlier desktop Python spike's own output on completely different hardware/OS — real, load-bearing confirmation that the ELF32-load-plus-relocation design (§3) works correctly on real ARMv9 silicon, not just in an x86_64 desktop harness. + +Getting this far required stubbing considerably more native surface than the 16 `GameActivityMain` lifecycle callbacks originally anticipated: `EAIO.StartupNativeImpl`, `StorageDirectory.{Startup,Shutdown}NativeImpl`, `RunLoop.nativeOnRunLoopTick`, `MogaController`'s 3 controller callbacks (all Kotlin `external fun`, found by grepping the whole app source tree, not just one file), plus a second category found only by hitting them live — **Java-style `native` methods** (a different declaration syntax the first grep pass missed entirely): FMOD's `fmodGetInfo`/`fmodProcess` (normally `libfmodex.so`, confirmed this session to have no `arm64-v8a` build), and EA's Nimble analytics/lifecycle bridge (`NimbleCppApplicationLifeCycle`, `NimbleCppComponentRegistrar$NimbleCppComponent`, `BaseNativeCallback`, normally `libNimble.so`, same situation). All now stubbed as no-ops in `game_lifecycle_stubs{,_extra,_extra2}.cpp`. + +**End state at that point**: the app is stable — no crash, process stays alive, renders its own Kotlin-drawn EA splash screen (not the native engine — `nativeSurfaceCreated`/`nativeSurfaceChanged` are still no-ops) — then spins calling the stubbed `nativeRestoreContext()` (always returns `false`) at the render loop's own polling rate, waiting for a real engine that was never booted. + +**Same day, continued: real JNI bridge built, the game's own boot sequence now genuinely executes.** Picked the "boot the game" phase back up immediately rather than stopping there: + +- **Guest-visible JNIEnv/JavaVM** (`emu/jni_shim.*`): a real 233-slot `JNINativeInterface` table (exact slot order extracted from this NDK's own `jni.h`, not guessed) plus an 8-slot `JNIInvokeInterface` (`JavaVM*`), each slot a guest stub dispatched through the same mechanism as import stubs. A `JniHandleTable` translates 32-bit guest handles to/from real 64-bit ART references (jobject/jclass/jmethodID/jfieldID all share one table). ~30 of the 233 slots have real implementations (FindClass, GetMethodID family, Call*Method/Call*MethodV for Void/Object/Boolean/Int via signature-driven argument marshaling cached at GetMethodID time, NewObject(V), strings, refs, exceptions) - everything else traps to a logged no-op, same philosophy as the import-stub layer. +- **libapp.so's own real native entry points are now called for real**, not stubbed: found via a second `.dynsym` pass (`Java_com_ea_ironmonkey_GameActivityMain_*`, `Java_com_ea_EAIO_EAIO_*`, `Java_com_ea_EAMIO_StorageDirectory_*`, `Java_com_ea_nimble_bridge_NimbleCppApplicationLifeCycle_*`, `JNI_OnLoad`, etc. - see `real_native_offsets.h`) and wired through `CallRealNative()` (`real_native_call.h`). Confirmed live: `JNI_OnLoad` executes and returns a real JNI version constant, `nativeOnCreate`/`nativeOnStart`/`nativeOnResume`/`nativeSurfaceCreated`/`nativeSurfaceChanged`/`nativeOnPause`/`nativeOnStop` all run real guest code end to end. +- **Two real, live-hit JNI/Android compatibility bugs found and fixed** in the process (both in the shim layer, not the engine core): (1) `EA::Nimble::findClass` (genuinely external to `libapp.so`, reimplemented directly against `JniShim` rather than emulating the missing `libNimble.so`) needed dot-to-slash class-name conversion before calling the real `FindClass`. (2) The classic cross-thread JNI gotcha - `FindClass` only sees app classes on the thread that loaded the native library; the real engine's own `GLThread` calls it directly for at least one class (`android.view.ViewRoot`, itself a genuinely removed/renamed internal Android class circa API 30+) - fixed with the standard cached-`ClassLoader`-plus-`loadClass()`-fallback pattern (`JniShim::CacheClassLoader`/`FindClassWithFallback`), plus defensive null-handle guards on `GetMethodID`/`GetFieldID`/`Call*Method`/`NewObject` so a failed lookup degrades to a logged no-op instead of a hard `CheckJNI` abort. +- **Confirmed stable end state**: process survives the full boot sequence (`onCreate` through `onStop`) with zero crashes on real hardware (Xiaomi 14). Screen is black - expected and explained, not a bug: none of the ~150 unresolved `gl*`/EGL/`AndroidBitmap`/`FMOD::*` imports the boot sequence reached have real implementations yet (all safely no-op, logged once each), so nothing actually draws. This is exactly the GLES/audio bridging work this doc already flagged as a separate, larger phase - now a concretely-scoped one: the exact list of needed `gl*` entry points is known from live logs, not guessed. + +**2026-08-29: real GLES2 shim built, full clean build on the first attempt.** `emu/gles_shim.*` covers the full 142-function GLES2 core API (`GLES2/gl2.h`, 139 functions auto-generated from the NDK's own header via a small Python codegen script to avoid hand-transcription errors, plus 3 hand-written for pointer-indirection/return-ownership reasons - `glGetString`, `glShaderSource`, `glGetVertexAttribPointerv`) plus `AndroidBitmap_{getInfo,lockPixels,unlockPixels}` (texture loading from Android `Bitmap` objects). Same mechanism as every other shim layer: forwards to the REAL, already-linked host GLES2 functions on whichever real thread is executing the guest code (Android's own `GLSurfaceView` machinery has already made a real, current EGL context current on that thread before any of this runs) - not a translated/virtual GPU driver. Also confirmed along the way that `armeabi-v7a` uses the **softfp** calling convention (float/double args pass through r0-r3 as raw bit patterns, not VFP S/D registers) - corrected an earlier overly-pessimistic assumption in `import_shims.cpp`'s own comments; this simplified `GLfloat` marshaling everywhere to a plain `memcpy` bit-reinterpret. Before ever invoking Gradle, every changed file was manually reviewed for `ImportShimFn` signature consistency across all ~53 registered shims (a Python script cross-checking defined vs. registered names caught 12 apparent mismatches that turned out to be macro-generated functions a naive regex couldn't see), duplicate definitions, and a real latent bug in `AndroidBitmap_lockPixels`'s pixel-buffer translation (fixed proactively via a new `GuestEngine::IsHostPointerInRegion` bounds check, since a driver-owned pixel buffer is never actually inside the guest region and blindly `H2G`-translating it would silently produce a garbage address) - both `:mpcore:assembleDebug` and `:app:assembleDebug` then succeeded with zero errors and zero warnings in project code on the first attempt. + +**Same day, on-device: reached deep into real engine/SDK execution, then found a genuine architectural gap - the engine is single-threaded.** Reinstalled on the same Xiaomi 14. Logs showed real Nimble SDK execution running end-to-end (`NIMBLE VERSION 1.40.0.39`, `loadConfiguration`, etc. - all real guest code, no crash), then logging silently stopped. `top`/`dumpsys cpuinfo` showed the process pinned at 96-99% CPU - not blocked/idle, genuinely spinning. Root cause: `pthread_create` was a no-op stub (never actually spawned a thread) and `pthread_cond_wait` was entirely unresolved (fell through to the generic "unresolved import, return 0" handler, i.e. returned immediately instead of blocking) - so whichever guest thread called `pthread_cond_wait` in a `while(!predicate) pthread_cond_wait(...)` loop just spun forever re-checking a predicate that a worker thread which was never actually created could never satisfy. This is a real, load-bearing finding, not a corner case: `GuestEngine` as designed through the GLES-shim milestone was fundamentally single-threaded (one `uc_engine` == one CPU register set, full stop), which was fine for a boot sequence that only exercises the main/UI thread but breaks the instant real engine code depends on its own worker threads - exactly the class of gap §5's own "Threading model complexity" open risk anticipated. + +**2026-09-01: real multithreading implemented - per-thread `uc_engine`s sharing one guest memory region, real host `pthread_create`/mutex/cond.** `GuestEngine` now gives every real host thread its own `uc_engine*` (`thread_local`, created on first use by `EnsureThreadEngine()`), all mapped via `uc_mem_map_ptr` onto the SAME shared `host_region_` buffer - exactly mirroring how real OS threads share one process's memory but keep separate register/stack state. Every `UC_HOOK_CODE` ever installed (import stubs, the 233 JNI slots, the 142 GLES slots, trampoline hooks) is recorded in a `hook_registrations_` list at install time and replayed onto each new thread's engine (`ReplayHooksOnEngine`), since Unicorn hooks are per-engine, not shared. Each thread also gets its own freshly-carved 8MB guest stack out of a shared arena (`CarveThreadStack`, headroom for 16 concurrent guest threads - `libapp.so` is not expected to come close to that). `emu/pthread_shim.*` (new file) replaces the old no-op fakes: `pthread_create` spawns a genuine host `std::thread` that calls `EnsureThreadEngine()` then runs the guest `start_routine` via `CallGuestFunction`; `pthread_mutex_t`/`pthread_cond_t` are backed by real `std::recursive_mutex`/`std::condition_variable_any` objects keyed by the guest ADDRESS of the mutex/cond object itself (stable for its lifetime); `pthread_cond_wait`/`pthread_cond_timedwait` use the standard `unique_lock(mtx, std::adopt_lock)` + `wait()` + `lock.release()` pattern to correctly interop POSIX's "caller already holds the lock, atomically unlocked while waiting, re-locked on return" contract with `std::condition_variable_any`'s RAII requirement. `GuestHeap` and `JniHandleTable` (both previously unguarded shared state) got mutexes for the same reason; the pthread TLS shim's value storage became genuinely `thread_local` (was a single flat array that would have let one guest thread's `pthread_setspecific` clobber every other thread's value). Known, documented gap: `JniShim`'s real `JNIEnv*` is still a single global slot, not per-thread - safe today because every JNI-touching call site in this codebase still runs on the main/UI thread, but the first thing to fix if a spawned guest worker thread ever calls into JNI itself. Both `:mpcore:assembleDebug` and `:app:assembleDebug` succeeded with zero errors/warnings in project code on the first attempt after a full manual signature/consistency review, per this project's established "fix everything before invoking Gradle" discipline. + +**2026-09-01, on-device: threading fix confirmed correct, but exposed §5's own JIT-throughput risk as real, not hypothetical.** Reinstalled and retested on the same Xiaomi 14. First observation looked alarming - CPU pinned near 100% on the main thread for minutes, with `top -H` confirming the MAIN thread specifically (not a background worker) was the one spinning, and MIUIScout's own ANR-style hang detector caught it stuck inside `NimbleCppApplicationLifeCycle.onApplicationLaunch(Native Method)`. Two diagnostic steps clarified what was actually happening, not a regression from the threading work: + +- Added a small diagnostic to `Shim_pthread_create` (`pthread_shim.cpp`) logging the real guest caller address (LR, read directly off the firing hook's register state) and whether `startRoutine` falls inside `GuestEngine::image_end()` (real code) or beyond it (one of this engine's own stub arenas). Confirmed live: the one guest thread spawned during this run got a `startRoutine` *outside* the loaded image - i.e. an unresolved-import stub address, not real code. Given essentially the entire FMOD API surface logs as unresolved (`_ZN4FMOD*`, dozens of entries just before this point in the log), this is almost certainly FMOD's own internal audio worker thread - its real entry point lives in `libfmodex.so`, which was never loaded (no `arm64-v8a` build, confirmed back on 2026-08-28). The "thread" instantly hit the stub's log-once-and-return-0 path and finished in under 10ms - not a bug in the new threading code, a direct, expected consequence of the already-documented FMOD gap. +- Left the device alone rather than force-stopping it prematurely this time (previous session's habit, worth breaking - see PerfMonitor's own numbers below). `PerfMonitor: Slow Operation: ... onCreate took 115276ms` - **`onCreate` (and therefore the synchronous `onApplicationLaunch` chain inside it) genuinely completed, after ~115 seconds, not never.** `top -H` afterward showed 0% CPU, all 29 threads sleeping - a real idle state, not a disguised spin. Subsequent lifecycle calls were each independently logged as "slow" too (`onStart` 2.5s, `onResume` 5.0s, `onPause` 2.6s, `onStop` 2.5s) - consistent with every real lifecycle entry point now executing genuine, full guest code paths (unlike the pre-threading builds, where the same calls were fast because the guest code's own threading-dependent init short-circuited almost immediately into stubbed no-ops). + +**Conclusion**: the pthread/mutex/cond threading work is verified correct on real hardware - `EnsureThreadEngine` created a second per-thread `uc_engine` sharing guest memory exactly as designed, ran real guest code, and returned; the main thread's own synchronous calls now run to real completion rather than spinning forever. What's exposed instead is squarely the risk §5 already named and explicitly deferred measuring back on 2026-08-19 ("JIT throughput on real target hardware... no amount of design reasoning substitutes for a spike... measure") - `~115s` for a single activity-creation call is unusably slow for anything resembling real gameplay, and the likely causes are exactly what §5 anticipated: (a) genuine per-instruction JIT/interpretation overhead across whatever this call chain's real instruction count is, and (b) this session's own import-stub dispatch mechanism (`UC_HOOK_CODE` → full C++ callback → register read/write per hit) being comparatively expensive, multiplied by however many hundreds/thousands of now-real (not short-circuited) calls a full init path makes. Not yet measured or root-caused further this session - the immediate value was confirming this is a performance problem to solve, not a correctness regression from the threading work. + +**Same day, continued: profiler built, and it led straight to a real crash - traced to a structural flaw, not a one-off bug.** A `UC_HOOK_BLOCK` sampling profiler (`emu/profiler.h/.cpp`, time-gated ~2ms/thread to keep overhead low, dumps top hot PCs to logcat every 3s) confirmed real, varied execution (thousands of distinct blocks touched) plus at least one genuine ~45s *blocking* stall (sample count froze solid, `top -H` showed 0% CPU - a real wait, not a hidden spin) consistent with the new mutex/cond machinery correctly blocking on a signal the FMOD worker thread (whose real entry point is missing, see the earlier gap) can never send. Adding a `UC_HOOK_MEM_UNMAPPED` diagnostic (`mem_fault_hook_cb`) to see exact fault addresses turned up something more serious: the guest `JNIEnv`'s own vtable pointer was reading back as the literal ASCII bytes of the string `"/tmp"` - the permanently-cached JNIEnv (built once by `JniShim::BuildGuestJNIEnv`, then reused for the rest of the process's life) was getting physically overwritten by unrelated data. Root cause: it was allocated from the SAME general-purpose `GuestHeap` arena that also backs every `malloc`/`free`/`calloc` call the real guest game code makes plus every short-lived JNI string buffer - a single wrong `free()` or overflow anywhere in that shared churn could (and did) corrupt it, and because it's cached globally, that one corruption event silently poisoned every subsequent native call (`onStart`/`onResume`/`onPause`/`onStop`/`onMusicPlayerStateChanged` all then faulted identically, retried every ~2.5s forever, pinning ~99% CPU until Android's ANR watchdog killed the process - `Killing ...: bg anr`, confirmed via `adb logcat`, not a MIUI battery-management artifact as first suspected). + +**2026-09-01: interface-layer hardening pass - stopped patching individual fault sites, fixed the architecture instead.** Per-crash patches (like the `Impl_GetStringUTFChars` null-guard added earlier that day) are real but don't converge - the shared-heap design allows an unbounded set of "guest code does something unexpected → shared heap corrupted → cached JNIEnv poisoned → everything downstream breaks identically" failure modes. Five structural changes instead, all in `mpcore/src/main/cpp/emu/`: +- **`GuestEngine::AllocPermanent`** (guest_engine.h/.cpp): a new tiny, never-freed bump arena (16KB, same bump-only pattern as the existing stub arenas) reserved for permanent, safety-critical control structures. `JniShim::BuildGuestJNIEnv`/`BuildGuestJavaVM` (jni_shim.cpp) now allocate from here instead of the general heap - nothing here can ever be reached by a stray guest `free()` again, structurally, not by convention. +- **`GuestHeap` corruption canary** (guest_heap.h/.cpp): `BlockHeader` gained a magic-number field, checked on every `Free()` - a wrong/stale/already-freed address is now rejected and logged instead of silently corrupting whatever bytes precede it (the exact mechanism the real crash exploited). +- **Fail-fast on fault** (`GuestEngine::CallGuestFunction`): a fault-class `uc_err` now sets a `crashed_` flag; every subsequent `CallGuestFunction` call refuses to re-enter guest code (logged once) instead of retrying forever - kills the "silently retried until ANR-killed" pattern at the root; the app now fails once, loudly, in milliseconds, instead of burning CPU for minutes. +- **Guard pages between every arena** (image/heap/trampoline/import-stub/misc-stub/control/thread-stacks): a real `kGuardPageSize` (4KB) gap between each, mapped `UC_PROT_NONE` on every thread's engine. A wild pointer walking off the end of one arena now faults immediately at the bad access (`UC_MEM_*_PROT`, also now caught by `mem_fault_hook_cb` via `UC_HOOK_MEM_INVALID`, not just `UC_HOOK_MEM_UNMAPPED`) instead of silently landing in the next arena and surfacing as a mystery failure somewhere else much later. +- **`JniShim::real_env_` → `thread_local`**: closes a documented-but-until-now-hypothetical gap now that real pthreads exist; today's call sites are all still main-thread-only so this changes nothing observable yet, but a guest worker thread that starts touching JNI can no longer silently steal the main thread's env. + +**New: a repeatable test environment**, replacing this session's several rounds of manual `adb shell` archaeology: +- `mpcore/scripts/run_heap_tests.sh` + `emu/tests/guest_heap_test.cpp` - a small desktop-only C++ binary (no Unicorn, no Android, no APK) linking `guest_heap.cpp` directly. Confirms the canary catches a double-free and a wrong-address free without corrupting a live neighbor, and that exhaustion degrades cleanly. Runs in under a second; 9/9 checks passing as of this change. +- `mpcore/scripts/test_on_device.sh` - installs, launches, watches logcat for a bounded window, and automatically reports `onCreate` timing, `MEM FAULT` count, whether the fail-fast circuit breaker tripped, and (if the process died) the exact cause pulled from `ActivityManager` logs - one command instead of a fresh investigation each time. + +Both `:mpcore:assembleDebug` and `:app:assembleDebug` build clean (a stale `.cxx` incremental-build artifact caused one unrelated link failure against the vendored Unicorn archive; a plain `rm -rf mpcore/.cxx` resolved it, not a code issue). + +**Next step**: phone was disconnected when this pass was implemented - `mpcore/scripts/test_on_device.sh` needs to run for real once it reconnects, to confirm the fail-fast breaker actually stops the retry-storm/ANR-kill pattern live and that no new `MEM FAULT` shows up during `onCreate`. Beyond that, the original §5 JIT-throughput/frame-rate question and the `glVertexAttribPointer`/`glDrawElements` VBO-offset-vs-pointer ambiguity (documented in `gles_shim.h`) remain open, unblocked by this pass but not addressed by it either. + +**2026-09-02: root-caused and fixed a real ELF-relocation-addend bug, then went through EVERY still-unresolved import systematically instead of patching symptoms one at a time.** Prompted by a sharp, correct challenge to a prior working theory ("how would a wild jump to `image_end_` be *legitimate*, even on real hardware?") - the "free() GOT slot corrupted" hypothesis from the previous entry didn't survive scrutiny: live diagnostics (a `UC_HOOK_MEM_WRITE` watchpoint on the slot, a `G2H()` call-site interceptor) both came back completely negative - nothing ever wrote the bad value through any path this engine's own code could see. The real bug was upstream, in the loader itself: `ProcessRelocations`' `R_ARM_ABS32`/`GLOB_DAT`/`JUMP_SLOT` handling computed `resolved = symbol_address` and wrote it directly, **silently discarding the relocation's implicit addend** (this ELF uses `Elf32_Rel`, not `Elf32_Rela` - for `S+A`-style relocations, `A` is whatever raw bytes already sit at the target before relocation, not a separate field). GOT/PLT slots almost always store a zero addend by convention, which is exactly why this went unnoticed through the entire JNI/threading/heap-hardening pass - it only bites relocations that legitimately carry a non-zero one, the textbook example being an RTTI `type_info` object's own vtable-pointer field, laid out at compile time as `vtable_symbol + 2*sizeof(void*)` (the Itanium ABI's offset-to-top/rtti-slot skip). Fixed with a five-line change: read the pre-existing bytes at the relocation target and add them to the resolved value, for all three relocation types. All the temporary diagnostics from that investigation (a background GOT-slot poll thread, two watchpoints, a debug log in the relocation loop) were removed once root-caused - see git history, not left as permanent cruft. + +That fix was necessary but not sufficient on its own - it's what makes real RTTI *possible* to implement correctly, not a fix for the underlying disease. The actual push this session was a direct, explicit instruction: stop patching individual unresolved-import crashes as they surface and go through *all* of them, because every one that silently returns 0 instead of a real pointer/handle is undefined behavior from that call onward - this is the same root mechanism that caused the JNIEnv corruption bug several entries back, just wearing a different disguise each time it resurfaces. `readelf -sD`/`--dyn-syms` gave the complete, authoritative list: **453 unique undefined symbols**, of which 319 fell through to the generic "log once, return 0" handler. Closed essentially all of them, in four passes: + +- **Category A - basic libc/POSIX** (`emu/libc_shims.cpp`, new, ~195 symbols): `string.h`/`ctype.h`/`math.h` (including real `double`-returning functions - `ceil`/`floor`/`fmod`/`pow`/`modf` needed a genuine ABI extension, see below), `stdio.h` (`FILE*` via a small handle table, same shape as `JniHandleTable`), POSIX file I/O/`dirent.h`/`time.h`, process/signal (`exit`/`kill`/`sigaction`/`setjmp` - the ones that can't be honestly supported without a guest stack unwinder are explicit, logged no-ops, not silent ones), `dlopen` family (only `dlopen(NULL)` - "handle to myself" - gets a real answer; loading a second guest library isn't supported and says so), `mmap`/`mprotect` (no guest-address-space allocator beyond `GuestHeap` exists, logged), network/socket (direct `sockaddr` pass-through - stable layout across 32/64-bit ARM, no marshaling needed), pthread extras (`pthread_once`, rwlocks - reuse `pthread_shim.cpp`'s own recursive-mutex-backed pattern), `sem_*` (a real counting semaphore via `condition_variable_any` + a guarded int, since this NDK level predates C++20's `std::counting_semaphore`), and the seven `__aeabi_*` ARM EABI float/`long long` conversion helpers the compiler emits calls to instead of inlining. +- **Category B - RTTI/exceptions** (`emu/rtti_shims.cpp`, new): a real `__dynamic_cast` - walks the `type_info` hierarchy directly over its data fields (base-class pointers, offsets) rather than through real virtual dispatch, so it doesn't need to guess at libc++abi's actual vtable slot layout; handles the common `__class_type_info`/`__si_class_type_info`/`__vmi_class_type_info` shapes, documents virtual-inheritance diamonds as a known gap rather than getting them silently wrong. Real primitive `type_info` objects (`_ZTIa`/`_ZTIf`/`_ZTIi`/`_ZTIs`/`_ZTIt`) and `__cxxabiv1` vtable "identity marker" blobs, built as genuine DATA in `GuestEngine`'s permanent arena - which needed a new resolution path (`GuestEngine::RegisterDataSymbol`/`RegisterDataSymbolSetup`) alongside the existing callable-stub one, since the ELF loader previously had no way to resolve an external symbol to anything other than a `UC_HOOK_CODE` stub, and reading struct fields through one of those is exactly the "wrong-shaped address" bug class this whole session keeps finding. Minimal but real `std::exception`/`logic_error`/`runtime_error`/`bad_alloc` (a simplified-but-internally-consistent object layout, not byte-identical to libc++'s actual `__libcpp_refstring`-backed one - safe because libc++ itself was never statically linked here to independently read those bytes), `std::uncaught_exception` (always false - no exception is genuinely ever "in flight"), and a partial `__shared_weak_count` (real atomic-free refcounting for `shared_ptr` copy/move/reset; the zero-refcount cleanup callback is deliberately *not* invoked - a real, documented leak, judged safer than guessing at a vtable slot index with no way to confirm it). +- **Category C - libc++ iostream/locale** (added to `rtti_shims.cpp`): `ios_base`/`locale` init/clear/destructor/`getloc` as safe no-ops/trivial objects, the `ctype`/`num_get`/`num_put` facet `id` statics as inert data (nothing ever successfully completes a facet lookup through them - `use_facet` logs once and returns NULL rather than handing back a fake facet object a subsequent real virtual call would crash through), `std::cerr` as a generously-oversized inert blob (real formatted output was never implemented), and libc++'s own internal `std::mutex` (real - reuses the address-keyed real-mutex-table technique `pthread_shim.cpp` already established for guest `pthread_mutex_t`). A handful of libc-adjacent data symbols from the same sweep (`__stack_chk_guard`, `timezone`/`tzname` mirrored from the real host globals on every `tzset()`, `__sF` mapped transparently onto the same `FILE*` handle table `fopen` uses) landed in `libc_shims.cpp` instead, via the same new `SetupLibcDataSymbols` hook. +- **Category D - FMOD Ex "Event System" API** (`emu/fmod_shims.cpp`, new, 28 symbols): confirmed this session that no `arm64-v8a` build of this specific, long-deprecated FMOD generation exists to link against for real (asked the user to check their own FMOD account for an archived SDK - came back genuinely not found). Every `FMOD_RESULT`-returning function returns `FMOD_OK` so the *game's* own logic proceeds instead of stalling on an audio-readiness gate; every `T**`-shaped output parameter gets a small, valid, non-null fake handle instead of NULL; getters write plausible neutral defaults (volume 1.0, not paused, neutral pitch, no active state flags) instead of leaving output params untouched; the one stable, version-independent FMOD struct (`FMOD_VECTOR`, 3 floats) gets zeroed, the uncertain larger ones (`FMOD_EVENT_INFO`, `FMOD_REVERB_PROPERTIES`, `FMOD_CREATESOUNDEXINFO`) are deliberately left untouched rather than memset to a guessed size that could overflow into adjacent guest memory. No real audio. + +Final coverage check (`readelf` list vs. every `RegisterImportShim`/`RegisterDataSymbol` call across the whole `emu/` tree): every one of the 453 originally-undefined symbols is now either genuinely resolved or an *explicit*, logged, documented gap - none are silently falling through to the generic "unresolved, return 0" handler anymore. Both `:mpcore:assembleDebug` and `:app:assembleDebug` (including a full `clean` rebuild) succeed with zero errors and zero warnings in project code. + +**Next step**: device was disconnected for this whole pass - needs a real `mpcore/scripts/test_on_device.sh` run to confirm the import work actually moves execution further than before (the `fread`/`fseek`/RTTI-symbol burst that used to precede memory corruption should now resolve to real behavior instead of a burst of "unresolved import" log lines) and to see what the *next* thing reached looks like now that this entire layer is real instead of stubbed. + +**2026-09-02/03: on-device verification of the import pass turned up a second, more severe relocation bug - a permanent hang with zero faults - root-caused and fixed.** First real run after the Category A-D work: `onCreate` dropped from ~104-116s to 43s and `MEM FAULT` count dropped from a multi-fault cascade to exactly one - a genuine, large improvement, but that one fault (`READ_PROT` inside `EAIO.StartupNativeImpl`, guest PC sitting *inside the guest stack itself* rather than at a real code address) needed its own investigation. Added SP/r0/r1 plus a forensic byte-dump-at-PC and stack-dump-at-SP to `mem_fault_hook_cb`, and traced the call chain via IDA (`Java_com_ea_EAIO_EAIO_StartupNativeImpl` at `0x6bc7d4` tail-calls `sub_6BA7A8`, which does `env->GetJavaVM()` then two `env->GetStringUTFChars()` calls before a chain of `strncpy`/`strcat` into fixed-size globals) - both JNI calls checked out correct against our shim implementations. + +Rerunning on-device with that instrumentation produced a *different* symptom entirely: zero `MEM FAULT` lines, but a **permanent hang** (confirmed still alive and looping 7+ minutes later) - the `UC_HOOK_BLOCK` profiler froze solid at exactly 4 blocks, never incrementing again, meaning the guest CPU had stopped executing *any* new instructions anywhere, on any thread - not a spin loop (which would keep incrementing a block's hit count), a genuine stall with `uc_emu_start` itself stuck. A temporary `UC_HOOK_CODE` instruction tracer over `sub_6BA7A8`'s setup range (0x6ba7a8-0x6ba8f8) pinned it exactly: the last instruction ever reached was `0x6ba830: BL strncpy`, and `strncpy` never returns. Dumping the live GOT slot for `strncpy` (`0xac7dbc`) right before the call showed it held `0x4ba0d28` - a **guest stack address**, not a code address; `LDR PC, [GOT]` in `strncpy`'s own PLT stub was jumping straight into the middle of the main thread's stack, where the CPU proceeded to decode stack garbage as instructions forever (all within mapped memory, so nothing ever faults). + +Root cause: the addend-implicit-read fix from the previous entry was *correct for `R_ARM_ABS32`* but wrong for `R_ARM_JUMP_SLOT`. Per the ARM ELF ABI, a `JUMP_SLOT` relocation is `S`-only, never `S+A` - the bytes a static linker pre-stores in a PLT's GOT slot are a lazy-binding artifact (commonly the PLT stub's own file address, so an unresolved call can bounce back into the dynamic linker's resolver), not a genuine addend to preserve. The math confirms it exactly: `0x4ba0d28 (bad GOT value) - 0x678ac (strncpy's own PLT stub address, i.e. the addend that got wrongly added) = 0x4b39a7c`, a perfectly ordinary stub address just past `heap_end` (`0x4b17000`). Fixed by skipping the addend read specifically for `R_ARM_JUMP_SLOT` in `ProcessRelocations`' `applyRelTable` (`guest_engine.cpp`) - `GLOB_DAT`/`ABS32` keep it, `JUMP_SLOT` always fully overwrites with `S`. All temporary diagnostics (the instruction tracer, the pre-call GOT dump, verbose ENTER/EXIT logging around `GetStringUTFChars`) were removed once root-caused; the general SP/r0/r1/memory-dump forensics added to `mem_fault_hook_cb` were kept as permanent, generically-useful diagnostics. + +Verified: rerunning after the fix, execution sails straight through `sub_6BA7A8` and well beyond - into EA's Nimble SDK init (`NIMBLE VERSION 1.40.0.39`), several more unresolved-but-gracefully-logged gaps (JNI slot 172 `NewObjectArray`, `setjmp`/`longjmp`, `std::locale::use_facet`, guest `abort()`), reaching genuinely new territory never exercised before this session. It then hits a **new, distinct failure**: a real host-level `SIGSEGV` (`SEGV_ACCERR`) inside `__memcpy_aarch64_simd`, called from our own shim code via Unicorn's `helper_uc_tracecode` hook-dispatch path (`memcpy(dst=0x77df7a8000, src=, size=4)` - the destination decodes to guest address `0x4f800000`, once again far outside the mapped region, i.e. another bad-address write, but this time from *our* C++ code, not a relocation). It happens immediately after `Shim_printf_family_unsupported` logs its "not implemented" warning for the first time (`printf`/`sprintf`/`snprintf`/`sscanf`/`vfprintf`/`vsnprintf` are *deliberately* unimplemented - real format-string-driven varargs marshaling was explicitly scoped out of Category A, see the previous entry) - `Shim_printf_family_unsupported` itself touches no guest memory, so the crash is in whatever runs immediately after it returns 0, not yet identified. + +**Next step**: implement real printf-family varargs marshaling (parse the format string, walk the guest-stack-resident variadic args per libc's standard ARM32 EABI layout, produce a real formatted string into the destination buffer for `sprintf`/`snprintf`, and a real return value) - the current explicit no-op is very likely what's one or two frames upstream of the new `SEGV_ACCERR` crash, given how tightly it follows in the log. Not yet proven which specific call site does the bad `memcpy`, so confirm via the same instruction-tracer technique before assuming. + +**2026-09-03: root-caused the `SEGV_ACCERR` as a general architectural gap, not a `Shim_time`-specific bug - fixed at the single choke point instead of patching the one call site.** Symbolicated the tombstone against the unstripped `.so` (`mpcore/build/intermediates/cxx/Debug/*/obj/arm64-v8a/libmpcore.so`, `nm -C` + address lookup - no `llvm-addr2line` available on this machine, `nm` sufficed): `#00 __memcpy_aarch64_simd ← #01 Shim_time() ← #02 import_stub_dispatch_cb ← #03 helper_uc_tracecode`. `Shim_time`'s `memcpy(eng.G2H(tPtr), &v, 4)` got a garbage `tPtr` from guest code and `G2H()` did plain unchecked pointer arithmetic, handing back a wild host pointer straight into `memcpy` - a real host SIGSEGV, not a graceful guest-level fault (that path only exists for actual emulated-ARM32 memory accesses, which go through Unicorn's own protection and `mem_fault_hook_cb`; this one bypasses it entirely since it's host C++ code dereferencing directly). A `grep` audit found `G2H()` is the single translation point behind **267 call sites** across `emu/*.cpp` (109 of them raw `memcpy` writes) - every one of them was an identical landmine, so patching `Shim_time` alone would have been exactly the reactive whack-a-mole this session already learned not to do (caught by the user mid-investigation: "чиним одно - калечим другое", correctly pushing for a plan before more ad-hoc patching). + +Fixed at the choke point instead: `G2H()` (`guest_engine.h`) now bounds-checks `addr < region_size_` (the single mmap backing `host_region_` is exactly `region_size_` bytes, covering every real arena - anything past that is unconditionally invalid, no ambiguity) and, on failure, logs (rate-limited to the first 20 occurrences globally, each with the calling guest `LR` when available, then a final "suppressed" notice - different bad addresses likely mean different bugs, worth seeing up to a bound rather than a single one-shot flag) and hands back a small `thread_local` scratch buffer instead of a wild host pointer - bad reads get harmless zeroed garbage, bad writes vanish harmlessly instead of corrupting real memory or crashing the process. Also removed a leftover dead diagnostic (a `dladdr`-based watch on the literal address `0xAC78F8`, from the earlier "free() GOT slot" investigation that the JUMP_SLOT relocation fix superseded) that had never been cleaned up - a small, concrete example of exactly the stale-patch-cruft risk being guarded against here. + +Verified on-device: **zero host crashes**, `onCreate` actually **completes** (15.8s, previously either hung forever or segfaulted before finishing), and the `UC_HOOK_BLOCK` profiler shows real, sustained forward progress (252 → 1022 → 1789+ distinct blocks touched across multiple seconds, vs. frozen solid at exactly 4 blocks in the pre-fix hang). The new guard fired immediately and repeatedly right where expected - `G2H(0x4f800000)`, `G2H(0xffffffff)`, `G2H(0x3f800000)`, all from the same guest `LR=0x963131`, all within the same burst as the earlier-logged `printf-family ... not implemented` warning - strong corroborating evidence for the standing hypothesis that unimplemented `printf`/`sprintf`/`snprintf` varargs marshaling is the actual upstream source feeding garbage pointers downstream. That same burst is immediately followed by a real guest-level `__android_log_assert` (a genuine assertion failure inside the game's own code - itself likely fallout from the same broken varargs path), a `pthread_create` (spawns a real background thread), and eventually a `MEM FAULT FETCH_PROT guest_addr=0xb16000` (`== image_end_` - the classic "wild jump to the image boundary" signature from the very first investigation at the start of this whole session, now finally reproducible again since execution gets far enough to hit it). Traced `LR=0x963131` via IDA to `sub_963120` (a tiny `if (flag) free(ptr)` conditional-destructor helper, calling `sub_3D0C04` which is a bare `if(p) free(p)`) - neither function does anything printf/pthread/fault-related itself, so this LR is almost certainly stale leftover register state from an earlier call in the chain (a computed jump/BX doesn't necessarily refresh LR), not a reliable pointer to the actual misbehaving function. The fail-fast `crashed_` flag correctly tripped on the `FETCH_PROT` fault and every subsequent `CallGuestFunction` (including the real `nativeOnCreate` JNI entry point) safely refused to re-enter guest code instead of corrupting further or hanging - exactly the designed behavior, and why the Activity lifecycle (`onResume`/`onPause`/`onStop`) then proceeds normally on the Java side instead of getting stuck. + +**Next step**: implement real printf-family varargs marshaling - now well-corroborated (not just suspected) as the actual upstream cause of the garbage pointers reaching `Shim_time` and friends, given the tight correlation between the "not implemented" log line, the G2H out-of-range bursts, the assertion failure, and the eventual image-boundary fault, all in one tight cluster right after Nimble SDK init. Needs its own planning pass before implementation (parsing the format string, walking guest-stack-resident variadic args per the standard ARM32 EABI layout, producing a real formatted string for `sprintf`/`snprintf`, a real return value) rather than another reactive patch. + +**2026-09-03: implemented real printf-family varargs marshaling (planned first, per explicit user request after an earlier ad-hoc-patching false step) - and it disproved its own working hypothesis, pointing at a deeper, pre-existing bug instead.** `printf`/`fprintf`/`sprintf`/`snprintf`/`vfprintf`/`vsnprintf` (`libc_shims.cpp` - `sscanf` stays an explicit stub, genuinely different work, out of scope by design) now have real implementations: a shared `FormatGuestPrintf` core walks the format string and hands each conversion to the real host `snprintf` as a single-conversion sub-format with a correctly-typed value (`AppendFormatted`, a two-pass `snprintf(nullptr,0,...)`-then-fill template) rather than reimplementing printf's own formatting - this file's usual "call the real host function" pattern. Two argument cursors supply the values: `Aapcs32Cursor` (register+stack, via the existing `ReadIncomingArg`, tracking a running *global* AAPCS32 slot index from the very first fixed argument so 8-byte types round up to even parity correctly) for direct calls, and `VaListCursor` (walks guest memory directly from an already-built `va_list` pointer, aligning the pointer's own byte address to 8 before a 64-bit read) for the `v`-prefixed ones. Length-modifier normalization matters here specifically because guest `long`/`size_t` are 32-bit but the HOST (arm64) `long` is 64-bit: any non-8-byte conversion drops the original length modifier and passes a plain `int32_t`/`uint32_t` (4 bytes on both ABIs, no promotion mismatch), the `ll`/`j` bucket always normalizes to a hardcoded `ll` + `int64_t`/`uint64_t`, floats always drop `L` and pass `double` (bionic ARM32 has no true `long double`). `printf`'s own output routes to logcat (`__android_log_write`, tag `"libapp"`) rather than real `stdout` (invisible on Android anyway) - arguably a debugging feature for this project, not just a substitute. Built clean on the first attempt (no `-Wformat-nonliteral` issues from the runtime sub-format strings). + +Verified on-device - and the working hypothesis from the previous entry (that unimplemented printf-family was the upstream source of `Shim_time`'s garbage pointer) turned out to be **wrong**: the `printf-family ... not implemented` log line is confirmed gone (real implementations are being called - `printf`'s own logcat output was observed firing, tag `"libapp"`), but the exact same `G2H() out-of-range` burst, `__android_log_assert` failure, and `MEM FAULT FETCH_PROT guest_addr=0xb16000` (`== image_end_`) still happen, at the identical guest `LR=0x963131`, identical `SP=0x5343dd8`, in the identical position in the boot sequence. Extended `LogOutOfRangeG2H`'s diagnostics with a `__builtin_return_address(1)`-based host-caller identifier (dladdr resolves nothing useful for anonymous-namespace/static functions - not surprising, most of this shim layer is exactly that - so it prints a raw `so+0x...` offset instead, symbolicated post-hoc via `nm -C` against the matching unstripped `.so`, same technique as the earlier tombstone) and re-ran: the six garbage-argument calls in the burst are `GuestCStr`, `Shim_time`, `Shim_perror`, `Shim_strrchr`, `Shim_qsort`, and `Shim_android_log_assert` - six *different*, unrelated libc functions, none of them printf-family, all receiving corrupted pointer arguments (several of the garbage values are recognizable IEEE-754 single-float bit patterns - `0x3f800000` = 1.0f, `0x7fc00000` = qNaN - not addresses at all) within the same few instructions of guest execution. + +This rules out "one specific unimplemented function feeds garbage downstream" as the explanation - six unrelated functions all seeing corrupted arguments in one tight burst points at something upstream corrupting registers or the stack before any of them run, not a bug in any one of them. This is very plausibly the same still-unsolved phenomenon flagged at the very start of this session (the original "wild jump to `image_end_`" investigation, before the addend bug was found and the session pivoted to systematic import coverage) - the `MEM FAULT` at `guest_addr=0xb16000 == image_end_` closing out this exact burst is the same signature. The printf-family work was not wasted (it's real, correct, needed regardless, and definitively eliminates one hypothesis) but the actual blocker is upstream of it. + +**Next step**: investigate the register/stack-corruption source feeding this burst - needs its own planning pass. `LR=0x963131` (`sub_963120`, a trivial `if(flag) free(ptr)` wrapper) is almost certainly stale leftover state, not the actual culprit context, so a fresh instruction-level trace (the same technique used for the `strncpy`/JUMP_SLOT investigation) from further back - ideally starting from `pthread_create`'s own call site, since a background thread gets spawned in this exact window too - is likely needed to find where the corruption actually originates. The temporary `__builtin_return_address(1)`+`nm` diagnostic added to `LogOutOfRangeG2H` this pass was left in place (still useful for that next investigation) rather than removed. + +**2026-09-03: `pthread_create` hardened against out-of-image `startRoutine`; traced the burst all the way to its literal jump target via a ring-buffer instruction tracer; found (and enforced) a real, missing security mechanism (GNU_RELRO) along the way - but it turned out to be treating a symptom, not the cause.** + +`Shim_pthread_create` (`pthread_shim.cpp`) now rejects `startRoutine >= image_end()` immediately instead of spawning a thread that would misbehave unpredictably - confirmed live: `REFUSING pthread_create - startRoutine=0x4b3a860 is not real image code (in region_size but between known arenas (gap/guard page?) at 0x4b3a860)`. This is real, permanent hardening (a `pthread_create` with a garbage function pointer should never have been allowed to spawn a real host thread that jumps into arbitrary emulator-internal memory), but critically **it changed nothing about the final `MEM FAULT`** - identical `guest_addr`/`LR`/`SP` with or without the thread spawn. This directly disproved the "runaway/uninitialized background thread" hypothesis: `pthread_create` is just another *victim* of the same upstream corruption, called with a garbage `startRoutine` like everything else in the burst, not a contributor to it. + +A temporary per-thread ring-buffer instruction tracer (`TraceRingHookCb`, recording the last 1024 guest PCs reached anywhere in the real image) then showed the mechanism precisely: execution was "fetching" a perfectly monotonic `+2`-per-step march through **`.bss`** (`0xad2940`-`0xb15f5c`, confirmed via `readelf -S` - real zero-initialized data, never code) all the way to `image_end_` - all-zero Thumb halfwords decode as a harmless `MOVS r0,r0` no-op, so nothing faults until the march runs off the end of `.bss`, which is the `FETCH_PROT` we'd been seeing all along. A second hook firing on the very first fetch inside `.bss` caught the actual wild jump: `PC=0xad2940` with `r12=0xac78f8` - exactly `free_ptr_0`, a *second*, separate GOT slot for `free@LIBC` (`readelf -r` confirmed two real relocations for the same symbol: `free_ptr`@`0xac7250` is `R_ARM_GLOB_DAT`, `free_ptr_0`@`0xac78f8` is `R_ARM_JUMP_SLOT` - IDA's `_0` suffix was a genuine name collision, not a labeling artifact). The crash was `LDR PC,[R12,#offset]!` in a `free()`-calling PLT-style trampoline, reading a GOT slot that currently held `0xad2940` instead of a valid stub address. + +Critically, **the relocation itself was proven correct** via a targeted log inside `applyRelTable`: our loader resolves this exact relocation to `symIndex=170`, `name='free'`, `st_shndx=0` (UND), `resolved=0x4b3a030` (a legitimate stub address) - and per the earlier JUMP_SLOT fix, that value is written with no addend. So `free_ptr_0` was loaded *correctly* at relocation time; **something overwrites it during execution**, and the fact that a from-scratch resolved-stub value (`0x4b3a030`) got replaced by exactly `.bss`'s own start address (`0xad2940`) smells like a wrong-offset pointer-store bug somewhere, not random corruption. + +This ELF has a real `PT_GNU_RELRO` segment (`readelf -l`: vaddr `0xa8f190`, filesz `0x38e70`, covering `.data.rel.ro`/`.init_array`/`.got` - `free_ptr_0` sits well inside it) that a real Android dynamic linker would `mprotect(PROT_READ)` after relocations finish; this engine never enforced it, so a wild write there silently succeeded instead of faulting at the point of corruption. Implemented properly: `MapSegments` now parses `PT_GNU_RELRO` (new `PT_GNU_RELRO = 0x6474e552` constant) and stores the range conservatively page-aligned inward (round start up, end down - a new `AlignDown` helper alongside the existing `AlignUp` - never over-protects into an adjacent legitimately-writable segment, matching real linker behavior); `EnsureThreadEngine` applies `uc_mem_protect(UC_PROT_READ)` over it on every engine, right alongside the existing arena guard pages (same per-engine-protection-bits-aren't-shared-via-`host_region_` reasoning). `ProcessRelocations`' own writes into `.got` go through plain host-side `memcpy`, bypassing Unicorn's emulated-CPU write path entirely, so this is safe to apply before relocations run - no ordering dependency. All three now-spent temporary diagnostics (the ring-buffer tracer, the `.bss`-entry hook, the targeted `applyRelTable` debug log) were removed once they'd found what they were built to find, per this session's established cleanup discipline. + +**Verified on-device - and this is where it got interesting.** The six-function garbage-argument burst (`GuestCStr`/`Shim_time`/`Shim_perror`/`Shim_strrchr`/`Shim_qsort`/`Shim_android_log_assert`, the `__android_log_assert` failure, the `pthread_create` rejection) is **byte-for-byte identical** with RELRO enforced - proving conclusively that `free()`/its GOT slot was never actually the root cause, just a downstream casualty. But the *final* fault address **changed**: `guest_addr=0xa90000` (`FETCH_PROT`, still `LR=0x963131`, still `SP=0x5343dd8`) instead of the old `0xb16000` - `0xa90000` sits inside the newly-protected RELRO range itself (within `.data.rel.ro`'s first sub-segment, `0xa8f190`-`0xa9b238`), and `bytes at PC(0xa90000)` read back as all-zero. Exactly one `MEM FAULT` total (no `WRITE_PROT` preceded it), so whatever changed the final jump target did so without RELRO itself ever intercepting a write - meaning the wild-jump computation reads from *some* location whose effective value differs with RELRO active, not that RELRO directly blocked anything in this particular run. + +**New, more specific lead**: several of the burst's "corrupted pointer" arguments are exact IEEE-754 single-precision bit patterns, not just coincidentally large numbers - `0x3f800000` = **1.0f**, `0x4f800000` = **4294967296.0f** (2^32), `0x7fc00000` and `0xffffffff` = **NaN**. All six calls share the same guest `LR`/`SP` context. This is much more consistent with a **type-confused read** (something reading a `float`/`double`-holding memory location and using its bits as if they were a pointer/integer argument - e.g. a wrong struct-field offset, a vtable slot miscounted in float-vs-pointer-sized units, or a variadic-argument marshaling bug treating a float argument's slot as a pointer slot) than with plain uninitialized/garbage memory, which wouldn't be expected to consistently decode into recognizable float constants like `1.0`. + +**Next step**: chase the float-bit-pattern lead specifically - find what memory location holds these values at `LR=0x963131`'s context and why it's being read as 6 different function pointers/arguments in sequence. Needs its own planning pass, per this session's now-established practice of planning before further ad-hoc instrumentation. + +**2026-09-03: found and fixed the ACTUAL root cause of the whole "float-bit-pattern burst" - not guest-code corruption at all, but a real bug in this engine's own stub-dispatch return mechanism, present since the very first successful import call this session but only now manifesting visibly.** + +Chasing the float-bit-pattern lead with targeted per-dispatch logging (`import_stub_dispatch_cb` logging the exact stub address + full `r0-r3` whenever `LR==0x963131`, plus a ring-buffer instruction tracer dumped once on the first such dispatch) produced the real evidence: **204+ consecutive dispatches at stub addresses exactly 4 bytes apart** - `0x4b3a030`(`free`)→`0x4b3a034`(`realloc`)→`0x4b3a038`(`fread`)→`0x4b3a03c`(`fseek`)→...→`longjmp`→libc++ `__shared_weak_count` internals→dozens of GLES functions, all in exact stub-*allocation* order, with `r2`/`r3`/`sp` completely constant throughout. Not a corrupted vtable (a real one wouldn't happen to be laid out identically to this engine's own internal bump-allocation order) - a mechanical march straight through the entire `misc_stub`/`import_stub` arena. + +The first hit (`free(0xb17048)`, `LR=0x963131`) is a real, correct, unremarkable call - `0xb17048` is a real guest heap buffer, `LR=0x963131` is `sub_963120`'s own return address (it does `if(flag) sub_3D0C04(ptr)`, and `sub_3D0C04` is `if(p) free(p)` via what's almost certainly a compiler tail-call to `free()`, which never touches `LR`, explaining why `LR` reflects `sub_963120`'s frame rather than `sub_3D0C04`'s own). The bug is in what happens *immediately after*: `import_stub_dispatch_cb` writes `PC=LR` via `uc_reg_write` expecting Unicorn to resume at `0x963131` (real code), but the very next dispatch lands on `realloc`'s stub instead - the PC write never actually redirected execution. + +Root cause: `AllocCodeStub`'s stub addresses are bump-allocated scratch memory with a `UC_HOOK_CODE` registered on them but **no real instruction bytes ever written there** - correctness depends entirely on the hook firing before any "real" fetch. In every one of the thousands of prior successful dispatches this session, whatever follows a stub's `PC=LR` redirect is real compiled code with its own nearby branches, so Unicorn's TCG "running one translation block ahead of a mid-block register write" (a known category of Unicorn/QEMU-TCG hook-callback pitfall - modifying `PC` inside a `UC_HOOK_CODE` callback isn't guaranteed to take effect immediately without also calling `uc_emu_stop()`) self-corrects almost immediately or never manifests. But the *entire* `misc_stub` arena is one large contiguous run of zero-initialized memory with no real instructions and no branches anywhere in it - an all-zero Thumb halfword decodes as a harmless `MOVS r0,r0` no-op. A translation block compiled across this unbroken run just keeps "executing" (PC += 2 per no-op) long past our redirect, landing on every subsequent stub's own 4-byte-spaced hook address in turn - exactly the observed march. Confirmed only two call sites use this "write PC=LR and hope" pattern (`grep` for `uc_reg_write(..., UC_ARM_REG_PC, &lr)`): `import_stub_dispatch_cb` (`guest_engine.cpp`) and `JniSlotDispatch` (`jni_shim.cpp`) - both built on the same empty-scratch-memory foundation, both latently vulnerable (JNI slots simply hadn't hit a long enough unbroken zero-run yet to show it). + +Fixed with the standard, documented Unicorn pattern for this exact situation: both dispatch callbacks now call `uc_emu_stop(uc)` right after writing `PC=LR`, forcing the *current* `uc_emu_start()` to end immediately and cleanly instead of trusting implicit TCG re-fetch behavior. `GuestEngine::CallGuestFunction` - previously a single `uc_emu_start(eng, target, kCallReturnSentinel, 0, 5000000)` call, implicitly assuming the entire nested call chain resolves within it - is now a loop: after each `uc_emu_start()` returns `UC_ERR_OK`, check the live `PC`; `kCallReturnSentinel` means the top-level call genuinely completed (unchanged from before); anything else means it stopped early via our own `uc_emu_stop()` from a stub redirect, so loop and re-enter fresh from the new `PC`. A generous iteration cap (`100000`, pure safety net) guards against non-convergence; each iteration gets its own fresh instruction budget (a documented, accepted per-iteration-vs-total tradeoff, not a security boundary for this prototype). + +**A second, related bug surfaced immediately during verification and was fixed in the same pass**: the first build of this fix traded the stub-arena march for a new, different failure - `UC_ERR_INSN_INVALID` at a guest PC that IDA showed was a perfectly valid Thumb instruction (`MOV R2,R4`, inside a real `std::string`-style short-string-optimization helper). Cause: `uc_reg_read(UC_ARM_REG_PC)` returns the real fetch address with bit0 always 0 (matching actual ARM hardware - the Thumb/ARM mode lives in `CPSR.T`, not in `PC` itself), but `uc_emu_start()`'s own `begin` parameter uses bit0 as the ARM/Thumb selector, same convention as `uc_reg_write(PC,...)`. The new loop was passing the raw (always-Thumb-bit-clear) `PC` straight to `uc_emu_start()` on each re-entry, silently restarting in ARM mode and misdecoding real Thumb bytes. Fixed by reading `UC_ARM_REG_CPSR` and re-encoding bit0 from `CPSR.T` (bit `0x20`) before each re-entry. + +**Verified on-device, both fixes together**: `onCreate` now completes in **~1.8-1.9 seconds** (down from 10-15s beforehand, itself already down from the original 104-116s at the very start of this session) with **zero** `BURST dispatch` lines and **zero** stub-arena marches. Execution now reaches genuinely new territory - `Java_com_ea_nimble_bridge_NimbleCppApplicationLifeCycle_onApplicationLaunch`, a real Nimble SDK lifecycle entry point never exercised before. It then hits a **new, different, well-defined** fault: `MEM FAULT READ_UNMAPPED guest_addr=0x464c457f` - which decodes (little-endian) to the literal bytes `7f 45 4c 46` = `"\x7fELF"`, the ELF magic number - meaning some code is reading the raw ELF file header's own bytes and using them as a guest address, not a guest-corruption issue at all. Removed the now-fully-spent temporary diagnostics (the burst per-dispatch log, the ring-buffer tracer) once confirmed root-caused, per this session's established cleanup discipline. + +**Next step**: investigate the `"\x7fELF"`-as-address read - likely something doing its own ELF-parsing/`dlopen`-style validation (Nimble SDK code checking a loaded library's header?) that's reading from the wrong location or treating file content as a pointer. A fresh, well-scoped lead, reached only because the stub-dispatch fix is now solid ground to build on. + +**2026-09-03: root-caused the "\x7fELF"-as-address fault - `.init_array` (C++ global/static constructors) was never executed, confirmed and fixed. Immediately hit the next real, well-scoped issue: VFP floating-point instructions aren't emulated.** + +Traced `sub_426830`'s fault site (`PC=0x426874`, `LDR R0, [R5]`) in IDA: a classic hash-bucket linked-list walk (`R5 = R5->next` at offset 8, then dereference `R5` for the key). `R5` was `NULL` at some point in the chain - and because this engine deliberately maps the loaded image starting at guest vaddr `0` (matching the ELF's own preferred base, confirmed via `readelf -l`'s first `PT_LOAD` genuinely starting at `0x00000000`), a NULL dereference doesn't fault the way it would on real hardware - it silently reads the ELF header's own magic bytes (`0x464c457f` = `"\x7fELF"`) as if they were a valid pointer, which is what faults two hops later. Not fixable with a simple guard page (real code legitimately starts at address 0 in this design) - the actual bug is why a hash table has an unconstructed NULL head pointer at all. + +Found the real cause via `grep`: `CallGuestFunction` is only ever *defined* in `guest_engine.cpp`, never invoked over an init-array loop anywhere in the loader - despite a comment in `main.cpp` claiming `.init_array` was "already run by `GuestEngine::LoadImage`." That comment was simply wrong. `readelf -d`: `INIT_ARRAY 0xac5d60`, `INIT_ARRAYSZ 756` bytes = **189 real C++ constructor function pointers**, none of which this engine had ever called - every global object with a non-trivial constructor in the whole binary (hash tables, registries, RTTI bookkeeping, static `std::string`/`std::map` instances) had sat as raw zero-filled `mmap` memory for the entire session. + +Fixed with the standard ELF loading step every real dynamic linker performs: `ProcessRelocations` (`guest_engine.cpp`) now parses `DT_INIT_ARRAY`/`DT_INIT_ARRAYSZ` (tags `25`/`27` - no legacy `DT_INIT` in this binary) alongside its existing dynamic-tag loop, then after relocations complete (so any GOT/vtable reference inside a constructor's own code is already correct - matching real linker ordering), walks the array and calls each entry via the existing `CallGuestFunction`, skipping `0`/`0xFFFFFFFF` sentinel values. Stops early and logs which index failed if a constructor leaves the engine `crashed_`, rather than continuing 188 more calls against a possibly-corrupted engine. Corrected the stale comment in `main.cpp`. + +**Verified on-device**: `.init_array: 189 entries` logged, and **11 real constructors now execute successfully for the first time this entire session** - genuine progress, not a regression, confirming the fix works as designed. Constructor **#12** (`0x68be0`, a tiny 5-instruction trampoline calling `sub_63E5E0`) then hits a *new*, different, well-defined fault: `UC_ERR_INSN_INVALID` at `PC=0x63e60c`. Decompiled in IDA: `sub_63E5E0` is a hash-table grow/resize helper (the *exact* function `sub_426830`'s lookup chain also calls - directly connecting this to the earlier fault, confirming the diagnosis) that computes a load-factor ratio using real **VFP (hardware floating point) instructions** - `VMOV S0,R0`, `VCVT.F32.U32`, `VDIV.F32` etc. The specific instruction that faults (`VMOV S0, R0`, moving an integer register into a VFP scalar register) is genuinely valid ARM Thumb-2/VFP code, not corrupted or misdecoded - Unicorn is very likely rejecting it because this engine never enables VFP/NEON coprocessor access (`CPACR`/`FPEXC`), which a real OS kernel configures during boot and this engine, running with no boot sequence, has never had a reason to touch before now (nothing hit real VFP-using code until `.init_array` started actually running). + +**Next step**: enable VFP/NEON coprocessor access on every guest `uc_engine` (likely a `CPACR`/`FPEXC`-style register write during `EnsureThreadEngine`, alongside the existing guard-page/RELRO setup) - needs its own planning pass to confirm the exact Unicorn API/register semantics before implementing, per this session's established practice. + +**2026-09-03: VFP/NEON enabled - all 189 `.init_array` constructors now run cleanly, and execution reaches `nativeOnCreate` itself for the first time this entire session.** + +Confirmed via the vendored Unicorn headers (`third_party/unicorn/include/unicorn/arm.h`) that no lower-level QEMU digging was needed: `UC_ARM_REG_FPEXC` is a plain directly-writable register, and the generic `UC_ARM_REG_CP_REG` mechanism (paired with a `uc_arm_cp_reg{cp,is64,sec,crn,crm,opc1,opc2,val}` struct) covers arbitrary CP15 registers including `CPACR`. `GuestEngine::EnsureThreadEngine` now writes both, on every new engine (main thread's first one and any later `pthread_create`d ones, same "CPU/coprocessor state isn't shared across engines" reasoning as the existing guard-page/RELRO setup), matching exactly what a real Linux kernel does once during boot: `CPACR` (CP15 `c1,c0,2`) bits 20-23 set to `0x00F00000` (full access to CP10/CP11, the VFP/NEON coprocessors), then `FPEXC` bit 30 (`0x40000000`, the `EN` bit) to actually enable the FPU. + +**Verified on-device**: the `.init_array` loop that previously stopped at constructor #12/189 (the VFP-using hash-table resize helper) now completes **all 189 entries with zero crashes** - no `"constructor N/189 crashed"` log line at all this run. Execution then proceeds well past `.init_array` into `Java_com_ea_ironmonkey_GameActivityMain_nativeOnCreate` itself (`CallGuestFunction(0x54c6e0)`) - the actual game's real native `onCreate` implementation, never reached before this point in the entire session. It hits a new fault there: `MEM FAULT READ_UNMAPPED guest_addr=0x464c459b ... r1=0x464c457f` - `r1` holds the *exact* `"\x7fELF"`-derived bad pointer from the earlier fault (`0x464c457f`), and the current read is `[r1+0x1c]` (`0x464c459b - 0x464c457f = 0x1c`) - a field access on that same still-uninitialized object, one level deeper into whatever's walking it. Same root symptom family as before (something still ends up with a NULL/never-constructed pointer somewhere), but now happening from a different call site (`sub_79CD4`, called from real `nativeOnCreate` at `LR=0x79d68`) - worth its own fresh look rather than assuming it's identical to the already-fixed `.init_array` gap, since `.init_array` itself is now confirmed fully clean. + +**Next step**: investigate the new `nativeOnCreate`-path NULL-pointer chain (`sub_79CD4`, `PC=0x79d6c`) - likely a *different* never-initialized global/object than the one `.init_array` was missing (that gap is now closed), or possibly a legitimate not-yet-implemented shim/data symbol still returning `0` where real code expects a real pointer. Needs its own planning pass. + +**2026-09-03: built a real, callable `ctype` facet - the `sub_79CD4`/`do_widen` crash is gone, confirmed via a genuinely different failure now appearing in its place.** + +Traced `sub_79CD4`'s fault precisely in IDA: it's libc++'s `basic_ostream::sentry`-style helper, calling `std::locale::use_facet(&ctype::id)` then a **virtual call through the returned facet at vtable byte-offset 28**. `Shim_use_facet` (`rtti_shims.cpp`) was a deliberate stub always returning `NULL` - its own comment already predicted this exact crash. Cross-referenced `ctype::id` across the whole binary (**50+ xrefs**) and sampled 4 structurally different call sites (`sub_79CD4`, `sub_751DC`, `sub_752EC`, `sub_7C1F8` - covering C-string, single-char, and numeric `ostream::operator<<`) - **all four** call the exact same offset `28` with a single scalar `char` argument, unambiguously `ctype::do_widen(char)`, which the C++ standard guarantees is an identity function for `char` (not locale-dependent, not a guess). + +Built a real, permanent `ctype` facet object in `rtti_shims.cpp` (`SetupRttiDataSymbols`): a guest-memory object with a real 24-slot vtable, each slot a genuine `AllocCodeStub`-backed dispatch point (same mechanism `jni_shim.cpp`'s JNI slots and the import-stub resolver already use, including the `uc_emu_stop()` fix from earlier this session). Slot `7` (byte offset `28`) gets the real, standard-mandated `do_widen(char c) -> c` implementation. Every other slot - deliberately *not* guessed from memory, since a wrong guess would misdirect a real virtual call to the wrong method, a worse failure than the NULL crash it replaces - routes to a shared stub that logs exactly which byte offset was called and returns `0`, turning any future gap in this same facet into a direct "implement offset N" lead. `Shim_use_facet` now recognizes `ctype::id` specifically and returns the real facet; every other facet id keeps the original safe NULL-and-log behavior, unchanged. + +**Verified on-device**: the `sub_79CD4` crash is gone. Execution proceeds further and hits a **new, different** fault - same `"\x7fELF"`-derived-NULL symptom family (`r0=0x464c457f`, the familiar bad value, now at `+0x20` instead of `+0x1c`), but this time the log clearly identifies it as a *different* facet: `"std::locale::use_facet() for an id other than ctype (0x4b3f290)"` - confirming the new logging correctly attributes the gap instead of silently NULL-crashing, and pointing at a second facet type (likely `num_get`/`num_put`, matching the numeric-formatting call site sampled earlier) needing the same treatment. + +**Next step**: identify which facet `id` `0x4b3f290` corresponds to (cross-reference against the `num_get`/`num_put` data-symbol addresses already registered in `SetupRttiDataSymbols`) and repeat the same evidence-driven process - sample real call sites to confirm which vtable offset(s) are actually exercised before implementing anything. Needs its own planning pass. + +**2026-09-03: built a real `num_put` facet - confirmed the crash it fixes is gone, and execution now reaches dramatically further (3753 blocks executed vs. a small fraction of that before) before hitting a new, unrelated, well-understood gap: `mmap()`.** + +Confirmed via `entity_query`/`xrefs_to` on both facet-id import slots that the mystery facet id from the previous entry was `num_put` (9 real xrefs) not `num_get` (only 2, one of them just its own GOT self-reference - no evidence `num_get` is ever actually reached). Sampled 5 of `num_put::id`'s 9 real call sites in IDA (`sub_8E8F4`, `sub_232550`, `sub_2323B8`, `sub_AA298`, `sub_A3128` - all `ostream::operator<<` for some numeric type) and found 5 distinct vtable byte-offsets, each with a distinct argument shape: `16`/`24`/`40` (three different single-32-bit-value overloads - among `bool`/`long`/`unsigned long`, not distinguishable from the call site alone), `20` (a 64-bit value), `32` (confirmed `float`/`double`, from `ostream::operator<<(float)`). + +Found the character-output mechanism for free while investigating: `sub_7A0CC` (called by every `ostream::sentry`-adjacent function sampled this session) is *real, already-compiled guest code*, not an external symbol - decompiling it revealed the exact `streambuf` ABI (byte offset `24` = `pptr`/next-write-position, `28` = `epptr`/buffer-end; write-and-advance if there's room, otherwise call the real virtual `overflow()` at the streambuf's own vtable offset `52`). Built `WriteCharToStreambuf` (`rtti_shims.cpp`) replicating this exact logic host-side, calling the *real guest* `overflow()` via the existing `CallGuestFunction` when the fast path doesn't apply - reusing real guest code for buffer-growth semantics instead of reimplementing them, same principle as everywhere else in this session. + +Implemented the 5 confirmed `do_put` overloads using real host `snprintf` formatting (same "offload formatting to real libc" pattern as the printf-family fix) - deliberately *not* reading/honoring `ios_base`'s formatting flags (width/fill/base/precision/uppercase - not at a known field offset), an explicit, documented scope cut, same pragmatism already accepted for `%p` formatting and the FMOD stubs. Every other vtable slot: the same generic logged-stub safety net as `ctype`. `Shim_use_facet` now recognizes both real facets. + +**Verified on-device**: no `num_put`-related crash or log line at all this run - genuinely unblocked. The `UC_HOOK_BLOCK` profiler shows **3753 total samples across 1376 distinct blocks** - dramatically more code executed than any prior run this session, real cumulative progress from the whole stack of fixes (stub-dispatch, `.init_array`, VFP, `ctype`, now `num_put`). Execution proceeds well past the previous stopping point and hits a **new, different, clearly-understood** issue: a background thread (`tid` distinct from the main thread - a real `pthread_create`d worker) repeatedly calls `mmap()`, which this engine has always explicitly refused ("`mmap() not supported - no guest-address-space allocator beyond GuestHeap` - returning `MAP_FAILED`", logged each attempt) - after several retries it faults (`FETCH_UNMAPPED` at a guest PC near `kCallReturnSentinel`, suggesting a bad return address computed after the repeated allocation failures). Also observed, on the same background thread before the `mmap` loop: `ctype` vtable offset `12` (a still-unimplemented slot, logged-and-continued safely per the existing safety net - not itself fatal, just noted as the next `ctype` gap if it turns out to matter). + +**Next step**: `mmap()` needs a real (even if minimal) guest-address-space allocator - currently an explicit, hard "not supported" per `libc_shims.cpp`'s own long-standing comment. Needs its own planning pass to scope what a minimal-but-real implementation looks like (e.g. carving anonymous mappings from a new dedicated arena, mirroring how `GuestHeap`/`AllocPermanent`/the stub arenas already each own a slice of `region_size_`) versus whether the specific caller can be satisfied more cheaply once identified. + +**2026-09-03: implemented real anonymous `mmap()` - the background-thread crash is gone, confirmed by that same thread now completing cleanly, and execution proceeds into new, previously-unreached territory.** + +Added a new dedicated arena (`mmap_cursor_`/`mmap_end_`, `kMmapArenaSize = 32MB`), following the exact same convention as every other arena in `guest_engine.h`/`.cpp` (a `kGuardPageSize` gap before it, chained after `thread_stacks_end_` - the previously-last arena - in `MapSegments`, its own guard page reapplied in `EnsureThreadEngine`'s `guardStarts[]` loop). `GuestEngine::AllocMmap(uint32_t length)` is a simple mutex-guarded bump allocator, directly mirroring `AllocPermanent`'s existing implementation (page-aligned instead of 8-byte-aligned, zero-filled, no `Free()` - `munmap()` stays a no-op, matching the same "acceptable to leak for a prototype" tolerance `GuestHeap`/`AllocPermanent` already accept). + +`Shim_mmap` (`libc_shims.cpp`) now actually reads its real AAPCS32 arguments (`addr,length,prot,flags` from `r0-r3`, `fd,offset` from the stack via the existing `ReadIncomingArg(4/5,...)`) instead of ignoring them. The classic anonymous case (`addr==0`, `MAP_ANONYMOUS` set) bump-allocates from the new arena and returns real, usable guest memory. File-backed mmap (a real `fd`, no `MAP_ANONYMOUS`) stays an explicit, loud, logged failure - no evidence yet anything needs it; the new detailed logging (full args) makes that the next well-scoped lead if it ever surfaces, instead of a blind guess. + +**Verified on-device**: zero `mmap() not supported` log lines this run (every call this run took the anonymous fast path silently, as designed), and - most tellingly - the background thread that previously crashed after several failed `mmap()` retries now logs `pthread_shim: guest thread (handle 1) start_routine returned 0x0`, i.e. it ran to completion and returned normally. The old `FETCH_UNMAPPED` fault near `kCallReturnSentinel` is gone. Execution proceeds further on the main thread and hits a **new, different, well-defined** fault: `UC_ERR_INSN_INVALID` (guest-side invalid-instruction) at `PC=0xad2b3c`, reached via `GuestEngine::CallGuestFunction(0x54e100)` - a genuinely different code path than any previous fault this session, not a regression of anything already fixed. + +**Next step**: investigate the `UC_ERR_INSN_INVALID` fault at `0xad2b3c` - decompile the containing function in IDA to determine whether it's a real Thumb/ARM mode-bit mismatch (as the earlier `CallGuestFunction` loop bug turned out to be), a genuinely unsupported/malformed instruction sequence, or execution having wandered into non-code data (matching the "empty scratch memory decodes as no-ops" pattern from the stub-dispatch investigation) once again. Needs its own planning pass. + +**2026-09-03: diagnosed the `UC_ERR_INSN_INVALID` fault via two rounds of live instrumentation - the register-based diagnostic proved unreliable, but the SECOND diagnostic (a ring-buffer instruction tracer) made the crash disappear entirely as a side effect of being installed, before it ever needed to fire. `nativeOnRunLoopTick` now runs cleanly at real 60fps.** + +**Round 1** (register snapshot at the fault, `CallGuestFunction`'s fault branch): captured `R0`/`LR`/`[R0]`/`[[R0]+68]`, reconstructing the theorized `sub_3F8648` listener-dispatch mechanism (a real, IDA-confirmed 16-slot Itanium-ABI virtual-call dispatcher used by `nativeOnRunLoopTick` and 8 other lifecycle entry points). The reconstruction didn't hold up: `LR=0x51434a` decodes to an address that IDA shows is **inside a literal-pool data table**, not reachable by any real `BL`/`BLX` - proving `LR` was stale garbage from an unrelated earlier call, not live state from the actual fault. `[[R0]+68]` computed to `0x100`, not the real fault target `0xad2b3c`, and cross-checking against what a genuine `BLX` from `sub_3F8648`'s own dispatch site would set `LR` to (`0x3f8686`) confirmed `sub_3F8648` wasn't even the function that faulted - disproving the original hypothesis outright. Static analysis (`entity_query`/`xrefs_to` on `0xad2b3c` itself) also dead-ended: no named global, no code xrefs - anonymous, undifferentiated `.bss`. + +**Round 2**: added a `thread_local` ring buffer (`TraceRingHookCb`/`g_traceRing`/`DumpTraceRing`, `guest_engine.cpp`) recording the last 64 executed block addresses via a dedicated `UC_HOOK_BLOCK` over `[0, image_end_)`, registered in `EnsureThreadEngine` alongside the existing `memFaultHook`/`profileHook`, dumped from the same `CallGuestFunction` fault branch as Round 1's diagnostic. **The fault never fired again** - `crashed_` stayed false across two separate on-device runs, and the app now renders continuously at real 60fps (`onDrawFrame state=7`, 2000-3000+ frames per run, zero faults) - the furthest this engine has ever run, well past `nativeOnRunLoopTick`'s first tick. + +**Why**: adding a second `UC_HOOK_BLOCK` over the same range Unicorn's TCG uses for translation-block compilation almost certainly changed block-boundary/merging decisions during JIT compilation - the exact same category of Unicorn/QEMU-TCG behavior already root-caused earlier this session for the stub-dispatch bug (a large unbroken instruction run getting compiled into one oversized translation block that "ran ahead" past an intended stopping point). It's very plausible `0xad2b3c`'s fault was a similar TCG block-merging artifact - real, valid guest code nearby got compiled together with something it shouldn't have been, and forcing a hook callback at every block's start (this tracer's only side effect, since it does no actual redirection) was enough to break that merging apart and let execution take the correct path instead. + +**Honest caveat**: this is an empirical, reproducible fix, not a fully explained one - the ring buffer never actually needed dumping (the fault stopped happening before hitting the point where `DumpTraceRing()` would run), so there's no concrete instruction-level proof of the exact TCG mechanism, only the strong circumstantial match to the already-confirmed stub-dispatch precedent. The diagnostic hook is currently still labeled/commented as "TEMP" in the source but is now load-bearing for this fix - needs a decision on whether to keep it permanently (as a real stabilization measure, possibly documented and renamed accordingly) or investigate further to find a more targeted, understood fix. + +**Decision (user)**: keep the ring-buffer tracer permanently rather than chase the exact TCG mechanism further - comments in `guest_engine.cpp` updated to reflect this is now a known, intentional (if not fully explained) workaround, not a temporary diagnostic. + +**Extended verification**: a full 2-minute on-device run (up from the initial ~1-minute checks) confirms this is genuinely stable, not a lucky short window - **6790 `onDrawFrame` frames at a steady 60fps, zero faults, zero crashes** for the entire run. The only log lines beyond normal gameplay noise are harmless OS-level telemetry (Xiaomi's `MiuiPreloadClassImpl`/`ActivityThread` "invoke error" lines - unrelated to this engine) and one already-safely-handled JNI gap (`GetMethodID called with a NULL jclass` - logged and returns 0 instead of aborting, per its own existing comment). This is by far the deepest and most sustained this engine has ever run - a continuous, real render loop, not just a single successful tick. + +**Next step**: with a stable 60fps checkpoint reached, the natural next investigation is functional/gameplay-level - what's actually being rendered (is the screen blank, a menu, real 3D geometry?), and whether any real input/gameplay logic beyond the render loop itself is exercised yet. Needs a fresh look (likely starting with a screen capture / visual check) rather than more log-diagnosis, now that the engine is running continuously. + +**2026-09-04: live visual check found the screen is genuinely frozen on the EA splash - root-caused as far as "the game's own render pipeline is never exercised at all," not a presentation/graphics-driver issue.** + +Two screenshots 5 seconds apart (screen kept explicitly awake/unlocked via `adb shell locksettings set-disabled true` + a longer `screen_off_timeout`, after first ruling out an unrelated finding - the device's own lockscreen was stealing window focus during headless `adb`-only testing, which independently explains why earlier automated runs looked "stuck" for a different reason) are pixel-identical - confirming the user's own report exactly (frozen splash, phone heating from real, sustained CPU work). + +Traced the Kotlin state machine (`app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt`) precisely via live logcat: `state::game` logs confirm `SPLASH(0)` -> `SPLASH_PROCESS(1)` -> `RESTORE_CONTEXT(7)` -> `GAME_START(8)`, then **stop entirely** after 122 lines - the expected, correct point at which `gameRenderer.setDrawFrameListener(null)` fires once `nativeRestoreContext()` succeeds. Critically, `GameRenderer.onDrawFrame` (`app/src/main/java/com/ea/ironmonkey/GameRenderer.java:47-53`) itself never stops - once the listener is null it calls `getRunLoop().onRunLoopTick()` every frame instead, meaning **`nativeOnRunLoopTick` is the real, continuous per-frame driver from this point on**, not a one-shot call. The engine's own `UC_HOOK_BLOCK` profiler confirms real, substantial work every frame (15000+ samples/30s) - not a spin-wait, not a crash - tracing to Clang's soft 64-bit division runtime helpers (140+ call sites binary-wide, too generic to pin down statically since the real per-frame dispatch goes through `sub_3F8648`'s virtual listener-table call, invisible to static call-graph analysis). + +Added real GLES call counters (`gles_shim.cpp`: `g_clearCalls`/`g_drawArraysCalls`/`g_drawElementsCalls`/`g_useProgramCalls`, incremented in the existing real pass-through shims, dumped every 3s via `StartGlesCounterDumpThread` mirroring `profiler.cpp`'s own dump-thread pattern) to settle whether the render pipeline is being exercised at all. **Result, confirmed on-device**: `glClear=0`, `glDrawArrays=0`, `glDrawElements=0` in every single 3-second window for the whole run - genuinely zero draw calls, ever, after the splash-to-game transition. `glUseProgram` fired exactly 5 times, once, early on (real shader program setup/linking - a legitimate one-time initialization step) and then never again. + +**Conclusion**: this rules out a presentation/graphics-driver/EGL-surface problem entirely - the native game logic itself never reaches the point of issuing a `glClear`+draw for a real frame, despite doing real, sustained per-frame computation. The blocker is upstream of rendering - most likely something `nativeOnRunLoopTick`'s call chain polls every frame (an asset/resource "is it loaded yet" check, a condition/counter tied to a background loading thread, or similar) that never becomes true, matching the exact "shim silently succeeds with empty/fake data instead of either doing real work or failing loudly" pattern that was the root cause of several earlier bugs this session. + +**Next step**: identify what `nativeOnRunLoopTick`'s real per-frame logic is actually waiting on before it will draw - likely needs a targeted live trace of what happens between the 5 `glUseProgram` calls and the frame-lock (e.g., checking file I/O/asset-loading shim activity, or a background-thread completion flag) rather than more static call-graph analysis, since the real dispatch is virtual/indirect and invisible to IDA's static xrefs. Needs its own planning pass. + +**2026-09-04: implemented real `RegisterNatives` support (a genuine, confirmed-working new capability - guest code can now register real callbacks Java can call back into) as a lead from the Galaxy A9 native trace, but confirmed it is NOT the cause of the current render-stall symptom.** + +The native ARM32 ground-truth trace (real, unmodified `libapp.so` running natively via a new LD_PRELOAD/JNIEnv-table-patching tracing harness on the Galaxy A9 - see the harness's own section below) showed real `GetMethodID("getBitmap", ...)` JNI activity alongside a steady stream of real `glClear`/`glDrawElements`/`glUseProgram` calls every frame - the real game reaches actual rendering, ours doesn't. `jni_shim.cpp`'s own top comment documented `Impl_RegisterNatives` as a known gap: it logged registration attempts but never actually wired guest function pointers to be callable from real Java - a plausible explanation if the game's real "ready to render" signal is such a callback. + +Implemented a real reverse bridge: a 128-slot pool of genuinely distinct host trampoline functions (`template jobject JniTrampoline(...)`, generated via `std::index_sequence` - no macro repetition), each forwarding into `TrampolineBody`, which marshals real Java arguments (read via `va_arg` against a signature parsed at registration time, reusing the existing `ParseParamTypes` from the forward direction) into a guest call via the same `GuestEngine::CallGuestFunction` every other reverse-call site already uses. Scope-limited to `I/Z/B/C/S/L` param/return types (rejecting `F/D/J`, logged not silent) - a real C-variadic trampoline reading real ART-supplied args is only ABI-safe for AAPCS64's integer/pointer register class. `Impl_RegisterNatives` now builds real `JNINativeMethod` entries pointing at these trampolines and calls the real `env->RegisterNatives()`. + +**Verified on-device**: confirmed genuinely working - `RegisterNatives: wired 3/3 methods (0 skipped) -> real JNI result=0`. But the 3 registered methods are `nativeOnKeyEvent`/`nativeOnMotionEvent`/`nativeOnStateEvent` from `com.bda.controller.*` - the **Moga Bluetooth game-controller SDK**, entirely unrelated to rendering or asset loading. `TrampolineBody` never fired once (0 invocations, added its own diagnostic log to confirm) - expected, since no Moga controller is connected. GLES counters remained at zero the entire run - this real, working fix does not explain the current render-stall symptom. + +**Process note**: the first two on-device verification attempts silently tested a stale build - `mpcore/scripts/test_on_device.sh` hardcoded the pre-flavor-split APK path (`app/build/outputs/apk/debug/app-debug.apk`), which still existed as a leftover, never-updated file after this session's `native32`/`translated` product-flavor split (see the harness section below) and was silently reinstalled over the correct build on every run. Fixed the script to point at `app/build/outputs/apk/translated/debug/app-translated-debug.apk` and removed the stale file - a real process gap, not a code bug, but one worth fixing since it cost real debugging time and would silently bite the next test too. + +**Next step**: the `getBitmap()`/rendering blocker is still unexplained. With `RegisterNatives` ruled out, the next lead should come from directly comparing the two traces at the call-sequence level (Galaxy A9 native trace vs. what our engine's own `CallGuestFunction`/profiler logs show at the equivalent point) rather than guessing at another JNI-shim gap - needs its own planning pass. + +**Addendum, same verification pass**: with the test-script fix (below) finally exercising a genuinely current build, the full (unfiltered) logcat shows real, deeper Nimble/graphics init than previously confirmed - `[NimbleWrapper] InitNimble()`, `[Graphics] OpenGLES20::OpenGLES20()`/`OpenGLES20Ext::LoadExtensions()`, telemetry setup - all real EA SDK log output (`EAStdC.Printf`, routed through this session's own printf-family fix). This runs on a **different thread** (tid 16015) than the render-loop/profiler thread (tid 16020) - confirmed by comparing log line thread IDs, not assumed - so it's concurrent background SDK init, not a sequential blocker in the render loop's own call chain. `glUseProgram` still fires exactly 5 times (the same 5 shader programs from the earlier GLES-counter finding) and then nothing - the core diagnosis (5 programs set up, then zero draws, forever) is unchanged; this is additional visibility into a parallel init path, not a new lead on the actual blocker. + +**2026-09-04: added a periodic, cross-thread live instruction-trace dump (`LiveTraceRingHookCb`/`StartLiveTraceDumpThread`, `guest_engine.cpp` - a global, non-`thread_local` sibling of the existing fault-only `TraceRingHookCb`) and found the render-stall is genuinely non-deterministic between runs - two distinct stuck states, not one.** + +**Run A**: `PROFILE`'s total sample count went completely flat (`817` samples, unchanged across 5 dumps spanning ~13s) - the guest thread had **stopped executing new instructions entirely**, confirmed by the live trace ring showing the exact same ~21-block sequence every dump. The last real activity was genuine libc++ cleanup (`std::ios_base::~ios_base()`, `std::locale::~locale()` - `sub_48D880`, called from a guarded run-once init `sub_267244`). Root-caused by reading `pthread_shim.cpp` directly: `Shim_pthread_cond_wait` is a real, correctly-implemented blocking wait (`cv->wait(lock)` on a real `std::condition_variable_any`) - fine only if some other thread eventually signals it. Added LR/condvar-address logging to `Shim_pthread_cond_wait`/`_signal`/`_broadcast` to confirm; `Shim_pthread_create` (already unconditionally logs every call, success or refusal) produced **zero** log lines this run - no second guest thread was ever created, attempted, or refused. If the real game expects a background worker thread to eventually signal this condvar (an async load completing, etc.) and that thread never gets created, the wait blocks forever - a real, permanent deadlock, not a shim bug (the condvar/mutex implementation itself behaves correctly). + +**Run B** (same build, different run): reached noticeably further - real `NimbleWrapper`/`Graphics`/telemetry init all completed (matching Run A's own endpoint and beyond), several real `pthread_cond_signal` calls fired (on the same condvar address seen deadlocked in Run A, `0xb2c394` - confirming that condvar *is* signaled under some conditions) - but then diverged into a **different** stuck state: `PROFILE` kept growing (12191 -> 18062 samples over 12s, a genuine busy loop, not a block) with the live trace showing heavy activity in `sub_3F51D8` (a generic path-join/concatenation helper - normalizes a `/` separator between two path fragments, 68 call sites binary-wide) and `sub_15891C` (its underlying string-growth/realloc helper) - consistent with the game repeatedly building file paths (a directory scan or repeated lookup pattern), too generic to attribute to one specific caller statically, same limitation as the earlier GL-extension-hash investigation. + +**Conclusion**: this is a real race/timing-dependent bug, not a single deterministic gap - which specific stuck state is hit depends on run-to-run timing (plausibly relative ordering between `.init_array` constructors, JNI setup, and whatever decides to spawn - or not spawn - a background worker thread). Both endpoints share the same ultimate symptom (zero draw calls, ever). + +**Next step**: the `pthread_cond_wait` deadlock (Run A) is the more clearly actionable lead - it's a real, unambiguous bug (a wait with no possible future signal) rather than an ambiguous hot-function list. Needs a live capture that actually hits Run A's path (may require several attempts given the non-determinism) with the new LR logging active, to identify exactly which function calls `pthread_cond_wait` and why the corresponding worker thread never gets created. + +### Native ARM32 tracing harness (Galaxy A9) - built this session for ground-truth comparison + +Built a standalone tracing setup to observe the REAL, unmodified `libapp.so` running natively (no emulation) on the Galaxy A9 (real 32-bit-capable hardware), for direct comparison against the emulated engine's own behavior at the same point: +- **`app` module**: new `native32` Gradle product flavor (`armeabi-v7a`-only - confirmed live that shipping it alongside `arm64-v8a` makes Android launch via 64-bit `app_process64`, which can never load a 32-bit `.so` at all; `mpcore`'s own always-`arm64-v8a` native build had to be stripped from the packaged APK post-build via `zip -d` + `zipalign` + `apksigner` re-sign, since AGP's dependency-native-lib merging doesn't respect a consuming app flavor's own `abiFilters`), restoring the original (pre-emulation) `System.loadLibrary(...)` calls in `GameActivityMain.kt` behind `BuildConfig.NATIVE32`. +- **`trace_agent/`** (new standalone directory, deliberately outside the Gradle build): `libtrace_agent.so`, built via a plain NDK/CMake `build.sh`, injected via Android's per-app `wrap.` debuggable-app `LD_PRELOAD` mechanism (no root needed to set once `adb root`/`setprop` configures it). Two pieces: + - `libc_gles_trace.cpp`: classic `dlsym(RTLD_NEXT, ...)` interposition for `open`/`openat`/`fopen`/`stat`/`access` and `glClear`/`glDrawArrays`/`glDrawElements`/`glUseProgram`. Confirmed live that interposing `mmap()` deadlocks the whole process (the dynamic linker uses `mmap()` to map every library, including this one, mid-load - a bootstrap chicken-and-egg hazard) and that logging *every* libc call (thousands during ART's own dex/oat startup) intermittently blows past ActivityManager's process-attach timeout - fixed with an `IsInteresting()` path filter (game-relevant keywords only) and dropping `read()` interposition entirely (highest-frequency, no path info anyway). + - `jni_trace.cpp`: patches the real `JNINativeInterface` table in place (via `mprotect` + direct pointer overwrite, not swapping which struct `JNIEnv->functions` points to) - since ART shares ONE table across every thread's `JNIEnv`, this covers every thread automatically, not just whichever one calls the installer. Installed from a new `TraceApplication`/`TraceAgentBridge` (`nfs.mod.traceagent`, gated behind `BuildConfig.NATIVE32`, inert no-op on the normal flavor). +- Real deployment gotchas fully resolved this session, each confirmed via direct evidence, not guessed: SELinux Enforcing rejects executing a `shell_data_file`-labeled `.so` from `/data/local/tmp` (fixed: `run-as`-copy into the app's own `app_data_file`-labeled private storage instead); the OBB file needed migrating from the pre-existing `com.ea.games.nfs13_mod` package's real save data to the new `com.ea.games.nfs13_arm` package's expected path (`ObbHelper.kt`'s naming convention); a real, interactive photo/media permission dialog (not a hang) blocks first-run until tapped. +- **Result**: the real game runs completely end-to-end on this hardware - splash → real EULA/ToS confirmation screen → a full, real 3D "ПЕРЕД ВАМИ FAIRHAVEN" gameplay intro cinematic with real lighting/textures/vehicle rendering. This is the ground-truth reference the emulated engine's own remaining gaps are now being diagnosed against. + +**2026-09-04: pivoted strategy - instead of continuing to guess what to instrument next inside the translation layer, built a native ARM32 tracing harness to get ground-truth reference behavior from the real, unmodified `libapp.so` running natively on the Galaxy A9 (real 32-bit hardware). Work done on a new branch (`native-arm32-trace-harness`), isolated from the emulation-layer work above.** + +New `native32` Gradle product flavor (`app/build.gradle.kts`) adds real `armeabi-v7a` support and a `BuildConfig.NATIVE32` flag; `GameActivityMain.kt`'s `onCreate` now branches on it to call the *original*, pre-emulation `System.loadLibrary("fmodex"/"fmodevent"/"c++_shared"/Global.NIMBLE_ID/"app")` sequence (previously commented out, restored verbatim) instead of `loadCore()`/`loadEmulatedLibappFromAssets()` - the real native-loading code path this project's Kotlin layer already had, just never exercised since the emulation approach was adopted. The 5 real `.so` files this needs were already sitting in `app/src/main/jniLibs/armeabi-v7a/` unused (confirmed - no copying needed), just excluded by the previous `arm64-v8a`-only ABI filter. + +Built a standalone tracing agent (`trace_agent/`, deliberately outside the Gradle/CMake build graph - a plain `build.sh` driving the NDK's toolchain directly, producing `libtrace_agent.so` for `armeabi-v7a`, meant to be `adb push`'d to `/data/local/tmp/` and injected via Android's per-app `wrap.` debuggable-app mechanism, no root needed): +- **libc + GLES** (`libc_gles_trace.cpp`): classic `dlsym(RTLD_NEXT, ...)` interposition for `open`/`openat`/`fopen`/`read`/`stat`/`access`/`mmap` (covers whatever the game does to read its `.obb` data, since that ultimately goes through these same syscalls) and `glClear`/`glDrawArrays`/`glDrawElements`/`glUseProgram` (the exact same 4 entry points already counted in this session's own `gles_shim.cpp`, for direct comparison against real behavior) - logs path/args/result via `__android_log_print` under a `TRACE_AGENT` tag, then calls straight through to the real implementation. +- **JNI** (`jni_trace.cpp`): JNI calls go through the `JNIEnv->functions` pointer table, not the dynamic symbol table, so `LD_PRELOAD` interposition alone can't see them. Patches the table's *contents* in place (`mprotect` to `PROT_READ|PROT_WRITE`, overwrite selected slots, restore `PROT_READ`) rather than swapping which struct `env->functions` points to - real Android ART shares one `JNINativeInterface` table across every thread's `JNIEnv`, so this covers every thread automatically (including whichever one actually drives the game's real `onDrawFrame`/`RunLoop`-tick GL thread) once installed from any single thread. Curated subset (`FindClass`/`Get*MethodID`/`Get*FieldID`/`RegisterNatives`/`NewStringUTF`/`GetStringUTFChars`/`ExceptionCheck`/`ExceptionDescribe`), not the full ~230-entry table - same pragmatic scoping principle as everywhere else in this project. +- Installed via a new, minimal `TraceApplication` (`app/src/main/java/nfs/mod/traceagent/`, wired into `AndroidManifest.xml`) - `attachBaseContext`, the earliest available hook point, checks `BuildConfig.NATIVE32` and whether `/data/local/tmp/libtrace_agent.so` actually exists before doing anything, so it's a complete no-op on the normal (`translated`) flavor - confirmed both flavors still build clean after adding it. + +Also fixed the `state::game` log-spam (`GameActivityMain.kt`'s `onDrawFrame`) - now logs only on actual state changes (reusing the existing `laststate` tracking already present for the `TAG`-tagged log right next to it), not every single frame - was making every capture this session harder to read. + +**Verified so far**: `trace_agent/build.sh` builds `libtrace_agent.so` clean for `armeabi-v7a` with all 12 interposition symbols + the `Java_nfs_mod_traceagent_TraceAgentBridge_install` JNI export confirmed present (`nm -D`/`readelf -d`). Both the `translated` and `native32` app flavors build clean with `TraceApplication` wired in. **Not yet runtime-verified** - the Galaxy A9 was disconnected for this session; deployment (Phase 4: install the `native32` APK, `adb push` + `wrap.` inject the agent, capture a real trace through the same splash-to-game transition) is the next step once it's reconnected. + +**Next step**: reconnect the Galaxy A9, run Phase 4 (deploy + `wrap.sh` injection + capture), and compare the real `TRACE_AGENT`-tagged trace against the emulated engine's own diagnostics to find the actual behavioral gap causing the render-stall. + +**2026-09-04, later same day: found the actual render-stall root cause - a genuine engine-vs-real-hardware behavioral divergence, confirmed on both sides, not a guess.** + +Added unconditional path logging to the real file-open shims (`Shim_fopen`/`Shim_open`/`Shim_access`/`Shim_stat`/`Shim_opendir`, `libc_shims.cpp` - all were silent real pass-throughs before). First Xiaomi 14 run immediately showed the guest thread (tid 23153, the only guest execution thread that exists this run - see below) stuck in a tight, unbroken cycle: `opendir("/storage/emulated/0/Android/data/com.ea.games.nfs13_arm/files")` (with and without a trailing slash) + 2 `stat()` calls on the same path, repeating roughly every ~45-110ms, ~600+ times over a 100s window, never once advancing. Confirmed on-device (`adb shell ls -la`) that directory is completely empty (`.`/`..` only) - not a permissions or path-typo issue, genuinely nothing there. + +Decompiled the loop's own hot addresses from the existing live-trace ring (`0x97dd08`-`0x97e0b4`, `0x616090`-`0x616440`) via the IDA MCP session against `native_lib/libapp.so.i64` - **not** the generic "path-join helper" guessed at in the earlier Run B finding above. `sub_615D44` is a Gregorian date/time decomposition function (`switch(a2)`: case 1 = day-of-year, 2 = month, 3 = week, 5 = day-of-month, 6 = days-in-month, 7 = day-of-week, 8/9/10 = hour/min/sec, 11 = year), built on `sub_97DD08`/`sub_97DE10` (the 64-bit signed-division helpers - the actual identity of the "generic 64-bit division helper with 140+ call sites" flagged back in the GLES-counter investigation). So the loop is: decompose the current time into calendar fields, scan the (empty) external-files directory, find nothing, repeat - a real polling loop, not a CPU-bound spin. + +**Cross-checked directly against real hardware** (Galaxy A9, `trace_agent` - extended `libc_gles_trace.cpp` with an `opendir()` interposer, since it wasn't hooked before; no filter change needed, `"nfs13"` is already a substring of the package's own external-files path) - **the real, unmodified game never touches that directory at all.** Its only `opendir()` call all run is a single, one-shot hit on `cache/Nimble/live/temp`. The equivalent `enable.telemetry`/`var1/last_version.txt`/`var1/adcEvents` activity (same 4 file operations our engine's guest thread also performs) happens exactly once, in under 10ms, and - critically - on a **separate thread** (tid 9501) from the main thread (tid 9466), which proceeds immediately and concurrently into real Nimble persistence setup and then full rendering (237 `glClear`/383 `glUseProgram`/252 `glDrawElements`/127 `glDrawArrays` captured in the same 45s window). + +**Root cause, now narrowed precisely**: real hardware spawns a background worker thread to do this telemetry/version-check/directory-scan work, which finishes and gets out of the way; our engine's guest code never calls `pthread_create` at all (confirmed again this run - zero log lines from `Shim_pthread_create`, which unconditionally logs every attempt including refusals) and instead runs this same work **inline on the one and only guest thread**, which is presumably also the thread that's supposed to reach the render loop. Since the directory it's polling for is (correctly) empty and nothing will ever populate it from inside that same stuck thread, it polls forever and the render loop is never reached. This is not a missing-file bug and not a wrong-path bug - it's a missing-thread-spawn bug: whatever real-hardware code path decides "do this asynchronously, in a new thread" is, on our engine, taking a different branch that does it synchronously instead, blocking everything behind it. + +**Next step**: find why that branch differs - most likely a capability/environment probe (CPU core count, `sysconf`, an Android API/feature-detection call, or similar) that legitimately returns a different value under emulation than on real hardware and steers the code down the synchronous path. Needs its own planning pass: locate the actual caller that decides sync-vs-threaded (one level above the `sub_615D44`/`opendir` loop, not yet identified), and instrument whatever condition it's branching on. + +**2026-09-04, later still: ruled out "reached but bails early" - the whole call path is simply never entered, at every level checked, three levels deep.** + +Found `sub_54BAF0` (0x54baf0) via IDA: a tiny, unconditional `pthread_create(&t, nullptr, sub_54BB20, arg)` wrapper - exactly the function `Shim_pthread_create`'s own logging (zero hits) implied must exist somewhere. It has 6 real static call sites. Added a single-address `UC_HOOK_CODE` probe (`ThreadSpawnProbeHookCb`, `guest_engine.cpp`) at 0x54baf0 and rebuilt/reran on the Xiaomi 14 - **zero hits**, confirming this isn't "reached but something inside bails before the `pthread_create` call" (the function has no branch before that call at all, so entry would guarantee the call happens) - it's simply never entered. + +Climbed the static call graph one level: of the 6 callers, two resolve to real, findable static call sites - `sub_69E7E4` (a constructor for what's very likely a telemetry/event client class, given the sibling `sub_69E5FC` builds an `"http://eamel-0034-midgard-paradise"` URL right before its own `sub_54BAF0` call - "eamel" reads as an EA Mobile telemetry endpoint) and `sub_1C14A4` (a state-machine `switch` whose `case 1` transition calls into the `sub_54BAF0` path - likely a lifecycle/state controller for that same client). The other 4 either have zero static callers (`sub_205268`, dead code or vtable-only) or are only referenced from a **data** address (`sub_408D3C`, at `0xab1f80` - a vtable/function-pointer slot, consistent with being a polymorphic event-listener callback for Nimble's own `im::BaseEvent<9,&im::_LayerInsertEventName>` "layer insert" event, dispatched indirectly and therefore invisible to a static call-graph search - the same "listener call invisible to static analysis" limitation flagged earlier this session for `sub_3F8648`). + +Widened the probe to all 8 addresses at once (`sub_54BAF0`, `sub_69E7E4`, `sub_1C14A4`, and their own 4 real static callers - `0x2681a0`/`0x26e27c`/`0x39c1d8`/`0x1c134c`/`0x1c1444`) and reran - **still zero hits across the board**, while the directory-scan loop itself ran unchanged (2500+ matching log lines, same as every prior run). So the unreachability isn't localized to one branch decision - it's at least 3 static call-graph levels deep, on every path checked so far. + +**Reframed hypothesis**: this smells less like "a condition inside `libapp.so`'s own code takes the wrong branch" and more like "the entire subsystem this belongs to (Nimble's telemetry/event-layer machinery) is never kicked off from outside `libapp.so` at all" - i.e. a **Java-side trigger never reaches native code**, not a native-side logic bug. This session's much earlier boot-sequence work already found (2026-08-28 entry above) that Nimble's own JNI lifecycle bridge (`NimbleCppApplicationLifeCycle`, `NimbleCppComponentRegistrar$NimbleCppComponent`, `BaseNativeCallback`) is entirely stubbed as no-ops in `game_lifecycle_stubs{,_extra,_extra2}.cpp` (no `libNimble.so` exists for `arm64-v8a`, so this was a deliberate, previously-documented gap, not new). If one of those stubbed callbacks is what real hardware's Java-side Nimble SDK uses to tell native code "start your telemetry/event layer now," stubbing it out would explain a native-side unreachability this deep and this consistent, without needing any wrong-branch theory at all. + +**Next step**: audit `game_lifecycle_stubs{,_extra,_extra2}.cpp` for exactly which stubbed Nimble/lifecycle entry point(s) a real `NimbleCppApplicationLifeCycle`/`BaseNativeCallback` implementation would normally drive, and check whether reimplementing any of them for real (calling through to the real guest function instead of no-opping) reaches `sub_1C14A4`/`sub_69E7E4`. Needs its own planning pass before touching code - this would be adding real behavior to a previously-deliberate stub, not a bugfix, and should be scoped carefully given how much of the Nimble bridge is stub surface. + +**Correction, same pass, checking thread IDs directly rather than assuming**: the `opendir`/directory-scan loop and `[NimbleWrapper] NimbleWrapper::Init()`/`FinishInitialization()` are confirmed on the **same thread** (tid 27897 this run) - a Java-spawned worker thread (not guest-`pthread_create`-spawned; Java calls into native code on its own thread and `EnsureThreadEngine` transparently gives that host thread its own guest engine, which is exactly why `Shim_pthread_create` sees zero calls - no guest code needs to spawn anything here, Java already did). This matches this doc's own much earlier (2026-08-28 continuation) finding that this same Nimble-init activity runs on a **different thread than the render/profiler thread**, concurrently, not as a sequential blocker in the render loop's own call chain. So the `sub_54BAF0` unreachability investigation above, while real and now well-understood, is diagnosing a **wasteful background retry loop that real hardware seems to avoid or resolve quickly** - it is likely NOT the actual reason draw calls never happen. Real Java-side signal from this same run supports a *specific* file-loading failure, not just a generic missing-thread gap: `E Nimble: NIM_ERROR>Tracking> Exception loading EASP tracking file` fired on the **main thread** at 22:36:56.896, ~12s before `NimbleWrapper` even constructs on its own thread - worth checking directly whether that's the same file this native retry loop is waiting on. + +**The actual render-blocking thread, found by checking directly**: the main/UI thread (tid 23247 this run) completes real boot activity (JNI env setup, `libapp.so` load, `RegisterNatives` wiring the 3 Moga controller methods, `RunLoop.state = 1`) and then produces **zero further `mpcore_log` output for the rest of the 90s window** - no crash, process stays alive, but nothing more happens on it. The last handful of lines before it goes silent are three separate `jni_shim: Call*MethodV called with a NULL receiver/methodID` failures, interleaved with several `unresolved JNI slot` no-ops (`NewObjectArray`, `CallBooleanMethodV`, `CallFloatMethodV`, `CallStaticBooleanMethodV`, `NewWeakGlobalRef`, `GetBooleanField`/`GetObjectField`/`SetBooleanField`, multiple `GetFieldID called with a NULL jclass`) - real, logged gaps in the JNI shim's method/field lookup coverage, right at the point this thread stops producing any further activity. This is a materially different, more specific lead than the Nimble background-thread investigation above, and is the one that plausibly actually explains "zero draw calls, ever." + +**Next step (supersedes the previous one for priority)**: identify exactly which `GetMethodID`/`GetFieldID` call returned null right before boot activity on the main thread stops - needs the same kind of targeted LR-logging probe already used successfully for `pthread_cond_wait` and the thread-spawn investigation above, this time on `Impl_GetMethodID`/`Impl_GetFieldID`'s null-return paths (`jni_shim.cpp`), to find which class/method/field lookup is failing and why (missing class, wrong signature, or a genuinely absent Android API on this device/API level). + +**2026-09-04, final pass this session: full root cause found and confirmed, correcting an earlier wrong inference.** + +Added guest-LR logging to `Impl_GetMethodID`/`Impl_GetFieldID`'s null-jclass paths and `DoCall`/`DoCallV`'s null-receiver/methodID paths (`jni_shim.cpp`), plus to the generic "unresolved JNI slot" dispatcher. Rebuilt and reran - the null-jclass/null-receiver failures on the main thread (field names `mIsBoundZ`/`mContext`/`mServiceConnection`, all guest LRs in the `0x265xxx` range) turned out to belong entirely to the **Moga Bluetooth controller SDK's own init** (matches this session's earlier `RegisterNatives` finding - same 3 Moga callback methods registered right after) - a real, harmless, already-understood gap (no Moga controller connected), unrelated to rendering. Following the main thread further (not just `mpcore_log` lines, the full logcat) showed it doing completely normal, successful Android `Activity`/`Window`/`SurfaceView` setup (Insets, first vsync, window focus) - it was never actually stuck, just quiet because there was nothing further for the JNI shim layer to log. + +The real discovery came from checking `onDrawFrame`, which **does** fire - `state::game: onDrawFrame state=7/0/1/8`, but on a **separate thread** from the main UI thread: `EnsureThreadEngine: new engine for this thread` confirms this is the real, distinct GLThread that `GameGLSurfaceView` spawns (standard Android GLSurfaceView architecture). Tracing this thread's own full activity end to end (not just `mpcore_log` lines) showed the entire previously-investigated sequence happening on it, directly, synchronously: `OpenGLES20Ext::LoadExtensions()` → `NimbleWrapper::InitNimble()` → `enable.telemetry`/`last_version.txt`/`adcEvents` checks → straight into the `opendir`/`stat` retry loop on the empty external-files directory - **forever**, on this exact thread, which is also the one thread that would ever call `glClear`/`glDrawElements`. + +**This corrects an earlier finding in this doc** (the 2026-08-28-continuation entry claiming Nimble init runs on tid 16015, "different from the render-loop/profiler thread... concurrent, not a sequential blocker") - that inference was based only on comparing Nimble's own log-line thread IDs against the profiler's, without ever confirming which thread the real GL calls come from. Cross-checked directly against the Galaxy A9 native trace to settle it for certain: **on real hardware, the equivalent `enable.telemetry`/version-check activity (tid 9501) and the actual GL calls (`glClear`/`glDrawElements`, tid 9490) are on two genuinely different OS threads** - confirming real hardware really does keep this work off the render thread, while the emulated engine's build does not. + +**Found the exact call site**: `GameActivityMain.kt`'s `onDrawFrame` (`app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt:797`, `STATE_GAME_START` case) calls the real, JNI-bridged `nativeRestoreContext()` **directly and synchronously on the GLThread** - no background dispatch. `nativeRestoreContext()` is the real guest function that (transitively) reaches `NimbleWrapper::InitNimble()` and its stuck directory-scan loop. Since it never returns, `onDrawFrame` never completes its first meaningful call, and no later frame - and therefore no `glClear`/`glDrawArrays`/`glDrawElements`/`glUseProgram` call beyond the initial extension-loading ones - ever happens. This is the actual, complete, confirmed root cause of the render-stall this entire multi-day investigation has been chasing: **a genuinely blocking native call, invoked synchronously on the one thread responsible for rendering, that never returns because of a directory-scan retry loop with no working exit condition** (the directory it polls, `/storage/emulated/0/Android/data/com.ea.games.nfs13_arm/files`, is confirmed empty and nothing in this run's own execution would ever populate it). + +**Two independent, non-conflicting angles for an actual fix, not yet chosen or implemented**: +1. **Fix the retry loop's own exit condition** - find what real hardware's native code does differently that lets it stop polling (give up after N attempts, check a different/already-populated path, or receive a signal from elsewhere) - the "real" fix, but needs more RE work to pin down precisely (the loop's caller is itself unreached by direct static analysis per the `sub_54BAF0` investigation above, since call is presumably via a vtable/generic retry-with-backoff helper, not a literal named loop). +2. **Stop calling `nativeRestoreContext()` synchronously on the GLThread** (`GameActivityMain.kt:797`) - dispatch it to a background thread/coroutine instead, matching what real hardware's own architecture does structurally. Lower-risk, more surgical, and directly informed by the Galaxy A9 comparison (two genuinely separate threads) rather than a guess - would very likely unblock rendering even before the underlying retry-loop bug (angle 1) is understood or fixed, since the loop would then be stuck on its own thread instead of the render thread. Does not fix the retry loop itself, so Nimble telemetry may still never fully initialize - an acceptable, explicitly-flagged tradeoff for actually seeing the game render, matching this whole investigation's own original goal. + +**Next step**: decide between (or sequence) the two fix angles above with the user, then plan the chosen one properly (this is now a real code change, not a diagnostic) before touching `GameActivityMain.kt` or the native Nimble/EAIO call chain. + +**2026-09-04, angle 2 implemented and verified working**: user chose the background-dispatch fix (lower risk, faster to see any rendering). `GameActivityMain.kt`'s `STATE_GAME_START` case (`onDrawFrame`) no longer calls `nativeRestoreContext()` synchronously - it's dispatched once to a new daemon `Thread`, guarded by a `restoreContextThreadStarted` flag so it's only ever kicked off once; `onDrawFrame` polls `restoreContextDone`/`restoreContextResult` (both `@Volatile`) each frame instead of blocking. Verified real, not just built: added explicit tid logging around the dispatch (`Thread.currentThread().id` - initially mistaken for the OS-level tid shown in logcat's own PID/TID columns, which is a *different* ID space; re-verified via the logcat prefix's own tid instead) - confirmed the call genuinely runs on a separate OS thread (caller tid 731, background thread tid 995 in the verification run) from the GLThread. + +Given this whole investigation's own established non-determinism (whether the directory-scan retry loop is hit at all varies run to run), the very next run happened to land on a "lucky" path: `nativeRestoreContext()` returned `true` after ~3.7s (not stuck), `onDrawFrame` proceeded to `nativeOnStart()`/`nativeOnResume()`, and rendering advanced measurably further than any point reached earlier in the render-stall investigation - `glUseProgram=5` (the same 5-shader-compile endpoint this whole investigation has repeatedly hit, first noted from the very first GLES-counter diagnostic pass, long before today's file-I/O/thread work) was reached, though `glClear`/`glDrawArrays`/`glDrawElements` are still 0. This confirms the fix does what it was scoped to do (stop the GLThread from permanently freezing on this specific blocking call) without needing the underlying retry-loop bug (angle 1) fixed first - real, verified progress, not just a theoretical improvement. + +**What's still open**: the pre-existing "5 shader programs compiled, then zero draw calls, forever" wall - documented and unexplained since this investigation's earliest GLES-counter diagnostic pass, well before today's file-I/O/thread-spawn/JNI-null-return work - remains the next blocker once `nativeRestoreContext()` does succeed. The directory-scan retry loop itself (angle 1) is also still unfixed - it will still run to completion (or not) on its own background thread now rather than the GLThread, but Nimble telemetry/config still won't fully initialize while it's stuck. + +**2026-09-04, later still: the directory-scan retry loop and `Java_com_ea_ironmonkey_RunLoop_nativeOnRunLoopTick` (`RunLoop.nativeOnRunLoopTick`) are the SAME still-running call, not two separate things - and the loop's real effect turns out to be worse than an infinite hang.** + +With the background-thread fix (above) in place, two subsequent runs on the Xiaomi 14 hit a genuinely new outcome: `engine crashed (fail-fast tripped): yes`, from `GuestEngine::CallGuestFunction(0x54e100): exceeded 100000 stub-redirect iterations without reaching kCallReturnSentinel (last PC=0x414c90)`. `0x54e100` resolves (IDA-confirmed) to `Java_com_ea_ironmonkey_RunLoop_nativeOnRunLoopTick` itself - the real per-frame tick handler getting invoked (progress - it hadn't been directly implicated before), stuck 87+ seconds into its own single call. + +Traced the call chain from `0x414c90` (inside `sub_414A18`, an event-marshal-and-dispatch helper reached via `sub_414808`/`sub_412FD4` - confirmed via IDA these are plain, linear (non-looping) string/struct-copy boilerplate, no loop construct in any of them) - decompiling each one in turn found nothing that could itself account for a sustained multi-second loop. Added a rate-limited entry probe (`ThreadSpawnProbeHookCb`, repurposed from the earlier thread-spawn investigation, `guest_engine.cpp`) on `sub_414A18`'s own entry (`0x414a18`) - it fired **exactly once** across the whole 87-second stall, ruling out "this function is being re-entered thousands of times from outside" as the mechanism. + +The real explanation was hiding in how the `kMaxCallIterations` safety cap actually counts: `import_stub_dispatch_cb` (`guest_engine.cpp`) - the dispatch path for **every** libc/import call, including `opendir`/`stat`/`malloc`/`free` - always ends the current `uc_emu_start()` early via `uc_emu_stop()`, and `CallGuestFunction`'s own loop (`guest_engine.cpp:963`) counts every one of those early-endings as one "iteration" toward the 100,000 cap. Cross-checked directly: the crashing thread (tid 14660 this run) was still actively logging `opendir`/`stat` calls on the already-diagnosed empty `/storage/emulated/0/Android/data/com.ea.games.nfs13_arm/files` directory **right up to the crash's own timestamp** - not a separate, unrelated busy thread, but literally the same still-in-progress `CallGuestFunction(0x54e100)` call, whose real guest code (somewhere in `sub_414A18`'s own call graph - plausibly the actual registered listener notified via `sub_414F20`'s event dispatch, which this session's earlier `sub_3F8648`-related finding already flagged as invisible to static call-graph analysis since it's an indirect/virtual call) reaches the same EAIO/Nimble directory-scan retry loop already characterized above. Roughly one `opendir`+`stat` retry cycle every ~45-110ms, each contributing several stub redirects (2 `opendir` + 2 `stat` + internal `malloc`/`free` from the C library's own directory-handle bookkeeping), comfortably accumulates past 100,000 over ~87 seconds - the numbers line up. + +**This means the retry loop (angle 1, still unfixed) has a second, worse failure mode than previously known**: given enough real wall-clock time, it doesn't just hang forever - it eventually trips `kMaxCallIterations` and marks the **entire engine permanently crashed** (`crashed_ = true` in `guest_engine.h`, refusing every subsequent guest call for the rest of the process's life), rather than leaving just the one stuck call/thread blocked. The background-thread dispatch fix (angle 2, already implemented) still does its job - the GLThread itself stays unaffected and keeps rendering - but the background thread's own work now has a hard, observed upper bound (~87s to ~100000 iterations) before the WHOLE engine goes down, not just that one call. + +**This also finally connects `nativeOnRunLoopTick` itself into the picture** - previously an opaque, never-directly-implicated function; now confirmed as the actual entry point whose call graph leads to the stuck retry loop, giving `sub_414A18`/`sub_414F20`/`sub_414808`/`sub_412FD4` as concrete, named landmarks for the next RE pass (finding the actual indirect listener-notify call site) instead of the vaguer "somewhere in Nimble/EAIO" from earlier today. + +**Next step**: fixing the retry loop's own exit condition (angle 1) is now clearly the priority - not just for correctness, but because leaving it as-is means the app has a real, timed self-destruct (~87s after `nativeRestoreContext()` starts) built into this specific code path. Finding the actual indirect listener call reached from `sub_414F20`'s dispatch (the concrete next RE target) would nail down exactly which real callback is doing the directory scan and why. + +**2026-09-05: traced the directory-scan retry loop to a concrete, named EAIO subsystem, and the conclusion changes what "fixing" it should mean.** + +Added guest-LR logging to `Shim_opendir`/`Shim_stat` (`libc_shims.cpp`) - the real call site is `0x582c3c`, inside `sub_582C24` (IDA-confirmed): a plain `boost::function`-based "for each entry in this real directory, invoke a callback" enumerator - `opendir()` → loop `readdir()` → skip `.`/`..` → invoke the callback per real entry → `closedir()`. Since the target directory is confirmed empty, this function's own `readdir()` loop runs **zero** iterations and returns almost immediately - it is not itself the retry loop. + +`sub_582C24`'s own static xrefs resolve only to a data slot (`0xab8328`, itself with zero further xrefs - a vtable/function-pointer entry, not a direct call), confirming it's reached polymorphically. A live entry probe on `sub_582C24` (repurposing the existing `ThreadSpawnProbeHookCb` infrastructure) caught the real caller's LR directly: two alternating addresses, `0x40f8d8` and `0x412ac0`/`0x40fb1c`, both inside (or called from) `sub_40F68C` - a real, **named-via-its-own-debug-strings** EAIO function: `sub_4128D0(..., "Overlay \"", pathA, "\" on \"", pathB, "\"")`, with branches printing `" -- split and merge"`, `" -- merge"`, `" -- trivial: replacing mount"`, and `" -- trivial: setting mount on empty node"`. This is EAIO's **virtual-filesystem mount-overlay merge** - literally overlaying one mounted directory tree on top of another (the mechanism a modding-support VFS would use to let external/user content override or extend the base game's asset tree - directly relevant to this whole project's own "NFSMW Online" mod-support premise). `sub_40F68C` is called from `sub_40F110` - a real `Mount("...", "...")` function (same debug-string pattern), itself statically called from **11 separate places** across the binary. + +**The key finding, from reading `sub_40F68C`'s own full decompiled body (not truncated this time)**: it is a **single-pass, branch-only function with no loop construct anywhere in it**. For the "merge" case actually taken here (the VFS node already has other, non-empty content mounted - from the OBB/APK-backed base asset tree, unrelated to whether the *real host directory* itself has files), it builds a callback object (`sub_412B40`/`sub_412FD4`, the same generic marshal-and-dispatch boilerplate identified earlier today) whose actual per-entry callback is `sub_412E20`, then invokes a **virtual "for-each-entry" method** on the target node (vtable slot +28) - which, for a real-filesystem-backed node, resolves to exactly `sub_582C24` (confirmed: this is the call our probe caught at `0x40fb18`/`0x40f8d4`). Since `sub_582C24` itself also has no loop and the directory is empty, this single call to `sub_40F68C` completes and returns normally, doing real but bounded work (a few `opendir`/`stat`/`malloc`/`readdir`/`closedir` calls, no more). + +**This means the ~45-110ms "retry" cadence observed all day is not a stuck loop inside this call chain at all - it's `Mount()`/`Overlay()` (or whatever drives it) being invoked fresh, roughly once per engine tick**, most plausibly a legitimate, intentional "check the external/mod content directory for changes" feature - directly fitting a mod-support VFS's normal design, not an obvious bug. Each such tick is cheap on real 32-bit ARM silicon; under Unicorn-based instruction-level emulation, the same real syscalls plus their surrounding marshal/dispatch/cleanup boilerplate (confirmed real, not stub-only, per `import_stub_dispatch_cb`'s design) are enough slower, per tick, that they accumulate toward `CallGuestFunction`'s `kMaxCallIterations` safety cap over real wall-clock minutes rather than never being noticed at all on real hardware. + +**Reframes the priority set earlier today**: "fix the retry loop's own exit condition" (this morning's "angle 1") may not be the right framing at all - there may be no bug to fix in the game's own logic here; it may legitimately re-check this directory every tick by design, indefinitely, same as real hardware presumably does. The two changes that would actually matter for THIS specific finding: (a) the `kMaxCallIterations` safety cap (`guest_engine.cpp`) is an emulator-authored constant with no counterpart in real hardware behavior - hitting it marks the whole engine permanently crashed rather than just slowing this one repeated call, which is arguably the wrong failure mode for "a real per-tick feature is just slow under emulation," not an actual runaway/corrupted loop; (b) if this per-tick VFS overlay-check turns out to be avoidable/cacheable (e.g., only needs to run once at startup, or only when the directory's mtime actually changes, matching normal "watch a directory for changes" implementations elsewhere), that would be the real performance fix - but confirming that needs reading `sub_40F110`'s 11 call sites to see which one drives this per-tick, not assumed. + +**Next step**: needs a decision, not more RE by default - either (1) find which of `sub_40F110`'s 11 callers drives the per-tick re-check, to confirm "once per tick, by design" versus "something else is calling Mount() in a genuine bug loop," or (2) treat this as good enough evidence already and address the `kMaxCallIterations` cap/failure-mode directly (raise it, or make hitting it non-fatal to the whole engine) as a pragmatic, immediately actionable fix regardless of which is true. + +**2026-09-05, both done: raised `kMaxCallIterations` (100000 → 2000000, `guest_engine.cpp`, verified builds and runs clean), then traced all 9 distinct callers of `sub_40F110` (`Mount()`) - and the "per-tick" hypothesis was wrong. This is a one-time startup cost, not a recurring one, which changes what "fixed" means.** + +Checked strings/xrefs for all 9 caller functions: 7 are unambiguously one-time or rare-event init routines - locale/string-pack mounting (`/published/data/locales.sb`, `/published/strings/nfsmw_android.sb`), Akamai CDN download-cache mounting (`/akamai/caches/`, `/akamai/downloads/manifest.sb`), and SKU/DLC-pack mounting (`"Mounting SKU: "` debug string) - none plausibly tied to a per-tick RunLoop path. The 8th, `sub_7B6458`, stood out: it directly references `/var1/last_version.txt` - the exact file checked at the very start of every run this whole session - and is large (80 basic blocks, 3 internal loops found via back-edge analysis: `0x7b6834`, `0x7b69c8`, `0x7b6b38`). Its own single call into `Mount()` (at `0x7b6600`) sits in a straight-line section *before* any of those three loops, though - so `sub_7B6458` itself calls `Mount()` exactly once per invocation, not in a loop either. + +Traced one level further: `sub_7B6458`'s own (sole) caller is `sub_7AEA30` - which is, confirmed directly from its own embedded debug strings, **`im::nimble::Init(EA::Allocator::ICoreAllocator*)`** (the literal string is right there in the decompiled code, plus `"enable.telemetry"`, `"rb"`/`"yes"`/`"no"` - the exact filenames and log values already seen in every run's very first log lines). Critically, `sub_7AEA30`'s entry is guarded by a real C++ static-initialization guard (`_cxa_guard_acquire`/`byte_B12E1C`, the standard Itanium ABI "run this exactly once" pattern) - **`im::nimble::Init()` runs exactly once per process**, not once per tick, not in any repeating loop. + +**Conclusion, correcting this morning's "per-tick, by design" framing**: the entire `im::nimble::Init()` → version-check → `Mount()` → `Overlay()` → directory-scan chain runs exactly **once** at startup. The reason it looked like a fast-repeating retry loop (`opendir`/`stat` every ~45-110ms for 80+ real seconds) is that this one-time call transitively walks the **existing, already-populated base-asset mount tree** (the real game's own OBB/APK-backed data - plausibly hundreds to thousands of existing mount nodes, matching the scale already seen in this project's own `.rel.dyn`/`.rel.plt` relocation counts), doing one "overlay the external/mod directory onto this node" check per existing node - `sub_40F68C`'s own two internal dispatch sites (`0x40f8d8`, and via `sub_412A1C`'s virtual call at `0x412ac0`) are exactly the two ways an already-merged tree node gets processed. A real, bounded, finite amount of work - just enough of it, at Unicorn's real per-instruction emulation overhead versus native ARM silicon, to take 80+ real seconds and rack up 100000+ stub redirects once. + +**This validates today's `kMaxCallIterations` raise as the actually-correct fix for this specific finding**, not just a stopgap: since the work is genuinely bounded and finite (a real, if large, existing tree - not an unbounded/non-terminating loop), giving it enough iteration budget to actually finish (2000000, ~20x headroom) should let `im::nimble::Init()` complete for real, rather than merely delaying an inevitable crash. The directory-scan loop's own cadence and empty-directory finding from earlier today remain accurate and unchanged - only the "why does it repeat so many times" explanation was wrong until this trace (assumed per-tick; it's actually per-existing-tree-node, once, at startup). + +**Next step**: verify directly - run again with the raised cap for long enough (several minutes, not just the ~90s windows used so far) to confirm `im::nimble::Init()` actually completes rather than merely hitting the new, higher ceiling later. If it completes, this specific investigation is closed; if it still hits the (much higher) cap, that would mean the base mount tree is unexpectedly large or something in the merge logic doesn't terminate per-node the way assumed here, and would need its own follow-up. + +**2026-09-05, correction after user pushback (rightly skeptical of the "bounded, will finish" theory) - verified directly, found the theory was wrong, and traced the real trigger to a different, more precisely-identified function.** + +A 5-minute verification run (raised cap in place) showed the directory-scan loop is **not bounded** - a separate 180s run confirmed `opendir` firing continuously for the entire window (2700 calls, still going at the last logged line before the window closed, no sign of self-termination). The "walks a large but finite existing mount tree, so raising the cap lets it finish" theory from the previous entry does not hold up empirically - correctly flagged by the user rather than taken on faith, matching this project's own `[[feedback_verify_ingame_before_re_theory]]` principle. + +Following the user's own suggestion, extended the real-hardware comparison instead of continuing to guess from the emulated side alone: reran the Galaxy A9 `trace_agent` capture for a full 180s (vs. the earlier 45s window). Result, clean and unambiguous: real hardware makes exactly **15 total `opendir` calls in 3 minutes** (14 of them on `/data/user/0/com.ea.games.nfs13_arm/files/var/published` - confirmed via `ls`/`run-as` that this directory **does not exist** on the A9 at all), all clustered in a ~2-second burst that ends with several calls firing mere milliseconds apart, then **stops** - a real, working, bounded retry-then-give-up pattern, not a probe that happens to succeed. Meanwhile real rendering is fully active the whole time (1.44M `glDrawElements`, 416K `glUseProgram`, 39.5K `glClear` over the same 180s). Confirmed the whole `Android/data/com.ea.games.nfs13_arm/` tree doesn't exist on the A9 either - real hardware never touches external app storage for this purpose at all, targeting a completely different (internal, non-existent) path instead of "the same path, different existence state." + +Chased the "why external vs internal" question down two now-ruled-out avenues before finding the real one: (1) `WRITE_EXTERNAL_STORAGE` permission grant state - checked via `dumpsys package` on both devices - is **identically `granted=false`** on the A9 (Android 10/API 29) and the Xiaomi 14 (Android 16/API 36), so a permission-grant difference cannot be what's steering the two devices onto different code paths, ruling out the user's own (reasonable, and initially promising) scoped-storage-permission hypothesis as the *direct* cause, even though the underlying instinct (a capability check resolves differently) turned out to be right in spirit. (2) No permission-check or `access()`-probe call is visible in the emulated engine's own logs anywhere near this sequence, so the decision isn't happening through a logged JNI/libc path either - it's native-side logic not yet pinned down. + +**Found the real trigger by probing `sub_40F110` (`Mount()`) directly, not by guessing from its callers' debug strings** (the previous entry's mistake - `sub_7B6458`/`im::nimble::Init()` was a plausible-looking guess from a nearby `/var1/last_version.txt` string reference, never actually confirmed as the specific call whose *own* `Overlay()` reaches the external directory). A live LR probe on `sub_40F110`'s entry (`0x40f110`, `guest_engine.cpp`) caught **4 distinct, one-shot `Mount()` calls at boot**, in order: `0x3d5bc4`/`0x3d5d1c` (both inside `sub_3D5AD0`, itself only reachable via 4 data/vtable references - a registered callback, not a direct call site), then `0x7b6604` (`sub_7B6458`/`im::nimble::Init()` chain - confirmed real, but **not** the one that triggers the stall), then `0x76e98` (inside `sub_76E00`, itself called from `sub_75E40`). The directory-scan spam (`sub_582C24` entry hits) starts **immediately after this 4th call**, not the 3rd - `im::nimble::Init()`'s own `Mount()` call was a red herring. + +`sub_75E40` is EAIO's **locale/string-pack mounting routine** (its own debug strings: `/published/data/locales.sb`, `/published/data/regions.sb`, `/published/stringdata`, `/published/strings`, `/published/strings/nfsmw_android.sb` - `nfsmw_android.sb` is this game's own localized-string archive). `sub_76E00` is a shared "mount one path" helper it calls. This reframes the whole investigation one more time, consistently with everything already found: this is very plausibly a **translation/mod-override mount** - after loading the base language pack, EAIO tries to overlay a matching path from external storage (letting a modder or the user's own placed file override in-game strings) - a real, working, by-design feature, called once at boot, not per-tick and not part of Nimble telemetry at all. + +**Still open, correctly**: why this specific overlay attempt resolves to the external storage root on the emulated engine but to a different, internal `files/var/published`-style path on real hardware remains unconfirmed - two candidate mechanisms (permission grant, JNI-visible access probe) have now been directly ruled out by live comparison, not assumed. The next step is tracing `sub_75E40`'s own body (not yet decompiled/read in this investigation) to find exactly which of the 4 `EAIO.StartupNativeImpl` arguments (`dataPath`/`filesDirPath`/`externalPath`) - or some other native-side condition - it uses to build this specific overlay target, and why that resolves differently under emulation. + +**2026-09-05, resolved: full decompile of `sub_75E40` plus a live argument-dumping probe on `Mount()` itself found the exact mechanism - not a guess, the real source/target strings for every boot-time mount.** + +`sub_75E40` decompiled in full (18648 chars, not truncated this time) turns out to be the literal **whole application bootstrap sequence** - it calls `sub_7AEA30` (`im::nimble::Init()`) as one step, then `sub_76E00(v13)` as the *very next* step (explaining why LR hits #3 and #4 from the earlier probe landed only ~0.2s apart - both fire from within this same top-level function, not two unrelated subsystems that happen to run close together), then goes on to mount locale/string/font/layout data packs, wire up debug leak-tracking hooks, and configure graphics settings (`Global/Enable Fogging`/`Global/Enable Specular`) - a genuine, single, one-shot init routine, confirming (again) this has nothing to do with per-tick behavior. + +Added a live probe (`MountArgProbeHookCb`, replacing the LR-only one, `guest_engine.cpp`) that dereferences `Mount()`'s real `source`/`target` string arguments (both are this codebase's own standard `{begin,end}` EASTL-string-style structs) at its entry (`0x40f110`) and dumps their actual content. Result - all 4 real boot-time `Mount()` calls, in order: +1. `Mount("/data/user/0/com.ea.games.nfs13_arm/files/var", "/var")` - internal, normal. +2. `Mount("/storage/emulated/0/Android/data/com.ea.games.nfs13_arm/tmp", "/tmp")` - external, but for a `/tmp` scratch mount, presumably by design. +3. `Mount("/data/user/0/com.ea.games.nfs13_arm/files/var1", "/var1")` - internal, normal (matches the `var1/last_version.txt`/`var1/adcEvents` files referenced all day). +4. **`Mount("/storage/emulated/0/Android/data/com.ea.games.nfs13_arm/files", "/")`** - external storage root, mounted at the **virtual filesystem root itself**. + +**This is the real mechanism, not a guess**: mounting an overlay at VFS root `"/"` means the merge logic (`sub_40F68C`/`sub_412A1C`) has to check *every single already-mounted node in the entire tree* (every locale/string/font/layout/`/var`/`/var1`/etc. mount already registered) for a matching override under the new external-storage overlay - explaining the thousands of `opendir` calls and 80+ real seconds directly, with no remaining mystery about "why does it repeat so many times." This was never an infinite or stuck loop in the traditional sense; it's a real, bounded, one-time tree-wide merge - just triggered against the wrong source directory. + +Cross-referencing against the Galaxy A9's own real target (`files/var/published` - i.e., `/var` + `/published`, and `/var` is exactly what mount #1 registers) strongly suggests real hardware's own mount #4 uses the **same internal `/var` mount's own `published` subdirectory** as its source, not the raw external storage root - i.e., the *target* (`"/"`, whole-tree overlay) is very plausibly correct and shared with real hardware; only the **source path** differs, and the divergence is a genuine path-construction bug (or config resolution difference) in whatever builds `sub_76E00`'s argument in `sub_75E40` (`sub_3DE694(sub_4BA190(sub_4BA048(sub_52CC0C(a1 + 122))))` - a 4-call getter chain on a config/environment field, not yet traced further). + +**Next step**: decompile that 4-call getter chain (`sub_52CC0C`/`sub_4BA048`/`sub_4BA190`/`sub_3DE694`) to find exactly where it decides "external storage root" instead of "internal files/var/published," and why. This is now a precisely-scoped, well-evidenced target for the actual fix - no longer a guess about which subsystem or why it repeats. + +**2026-09-05, found the actual decision point - a real Java call whose cached result gates the whole thing.** + +Correction while tracing the getter chain above: `sub_52CC0C`/`sub_4BA048`/`sub_3DE694` turned out to be an unrelated sequence of one-time setup calls (the last one is literally `FMOD_Memory_Initialize()` - the audio memory allocator, its return value just happens to become `sub_76E00`'s int argument, which `sub_76E00` never actually uses for path construction) - a dead end, not the real chain. + +Read `sub_76E00`'s own full body directly instead (previously only seen truncated). It builds two strings from a shared platform/SKU-like object (`v0 = sub_3F7C88()`, later compared via `sub_5482C4(v0)` against constants `10`/`13`/`14`/`17` - a SKU/store-variant enum), then makes **exactly the Mount() call our probe already captured**: `sub_40F110(v1, &v16, v26, &v22)` where `v22` is the literal single-character string `"/"` (the observed target) and `v26` - the observed source, the external storage path - comes from `sub_5476D8(v26, v0)`. + +`sub_5476D8` has a clean, direct branch: `if (sub_54F1E8(a1)) { return EMPTY_STRING; } else { return JOIN(dword_ADFC58, ); }` - i.e., the entire external-path construction is **conditionally skipped** based on `sub_54F1E8()`'s own boolean result. + +`sub_54F1E8()` itself is a real JNI call, not a native-only check: it calls `JavaVM->GetEnv()` (offset `+24` in the cached `JavaVM` vtable at `dword_ADFE80` - matches the real `JNIInvokeInterface::GetEnv` slot) to get the current thread's `JNIEnv`, then calls a **cached Java method** via a generic JNI-call helper (`sub_2659CC(env, dword_ADFE84 /* jclass */, dword_ADFEA0 /* jmethodID */)`), checks `ExceptionOccurred()`/`ExceptionClear()` (JNI slots 15/17, matching the offsets `+60`/`+68` exactly), and returns `true` only if the Java call's own int result `== 1` and no exception is pending. + +**The mechanism, precisely**: if this cached Java method call returns `1` (and doesn't throw), `sub_5476D8` returns an empty path and the whole external-storage `Mount()`/`Overlay()` at `"/"` is presumably skipped or made trivial; if it returns anything else (or throws), the real external path gets built and mounted at `"/"`, triggering the whole-tree merge this entire day's investigation has been chasing. Confirmed empirically that our engine takes the "build the real path" branch (matches the observed non-empty source string) - meaning either this cached Java call returns the wrong value under our JNI shim, or the underlying Android API it wraps genuinely answers differently on our test device than on the A9, or the call fails/throws and gets silently treated as "false" by a path our engine reaches differently than real hardware would. + +**Not yet done**: identifying which Java class/method `dword_ADFE84`/`dword_ADFEA0` actually cache (requires finding where they're populated - almost certainly a one-time `FindClass`+`GetMethodID`/`GetStaticMethodID` pair cached at an earlier point in this same boot sequence) and what real Android API it corresponds to. This is now a precisely-anchored, single JNI call site - the natural next target once resumed, but a substantial amount of ground has already been covered this session and this is a reasonable, well-documented stopping point. + +**2026-09-05, FOUND AND FIXED - real root cause was a missing JNI shim implementation, not a game-logic or path-construction bug at all.** + +Traced `dword_ADFEA0` to its population site: `Java_com_ea_ironmonkey_GameActivityMain_nativeOnCreate` caches it via `GetMethodID(GameActivityMain, "useAssetsFileSystem", "()Z")` - a real method already in this launcher's own `GameActivityMain.kt:174`: `fun useAssetsFileSystem(): Boolean { return mAssetLocationType != AssetLocationType.EXTERNAL }`. + +Extended `trace_agent` (`jni_trace.cpp`) with `CallBooleanMethod`/`CallBooleanMethodV`/`CallBooleanMethodA` interposition, keyed to the cached `useAssetsFileSystem` jmethodID, and ran it live on the Galaxy A9 (same `native32` flavor, same `GameActivityMain.kt` source as the emulated/translated flavor - not a different "real" implementation, the identical Kotlin code). Real result: **`useAssetsFileSystem()` returns `true`, every single time, dozens of calls observed** - confirming `mAssetLocationType` is *not* `EXTERNAL` there. + +Traced why: `onCreate()` (line 263-271, *before* `nativeOnCreate()` at line 348) probes `getResources().assets.open("obb.size")` - if it succeeds, `mAssetLocationType` becomes `OBB`. Verified the `obb.size` asset file is present in *both* the `translated` and `native32` flavor's merged assets, and added direct Kotlin-side logging confirming **the open succeeds and `mAssetLocationType` correctly becomes `OBB` on the emulated engine too** - so the Kotlin logic itself, and its real runtime state, both agree with real hardware. Yet the emulated engine's own native-side behavior (per this whole investigation) acted as if `useAssetsFileSystem()` returned `false`. + +**The actual bug, found by adding the same call-tracing to `useAssetsFileSystem()` itself and finding it never once logged on the emulated engine despite `mAssetLocationType` being correctly `OBB`**: `jni_shim.cpp`'s own logs showed `unresolved JNI slot 38 (CallBooleanMethodV) called - returning 0`. **`Impl_CallBooleanMethodV` and `Impl_CallStaticBooleanMethodV` simply did not exist** - `DoCallV`'s own `switch` already handled `CallKind::kBoolean` correctly (used by the already-real non-V `CallBooleanMethod`/`CallStaticBooleanMethod`), but nobody had added the two thin `*MethodV` wrapper functions or registered them in the JNI slot table, so every guest call to `CallBooleanMethodV` (confirmed via the same A9 trace to be the exact variant real native code uses to invoke boolean-returning Java methods) silently fell through to the generic "unresolved slot" stub and always returned `0`/`false` - never running the real Kotlin method at all, regardless of what it would have correctly returned. + +**Fix applied** (`jni_shim.cpp`): added `Impl_CallBooleanMethodV`/`Impl_CallStaticBooleanMethodV` (identical pattern to the already-working `Impl_CallIntMethodV`/`Impl_CallStaticIntMethodV`, just calling `DoCallV(...,CallKind::kBoolean)`) and registered both in the JNI slot table. **Verified fixed**: rebuilt, reran on the Xiaomi 14 for 60s - zero `opendir` calls on the external `Android/data/.../files` path (previously thousands per run) - the whole-VFS-tree external-storage-overlay-at-root cascade this entire day's investigation has been chasing is completely gone. + +**A new, different bottleneck was immediately uncovered** (expected, not a regression - `useAssetsFileSystem()` returning `true` now correctly routes the engine into the real OBB-based asset-loading path, previously never reached in a clean run): `PROFILE` shows steadily growing sample counts at a new set of hot addresses (`0x415694` at ~16%, `0x3de430` ~14%, `0x66a78` ~7%, plus `0x4158a0`/`0x415558`/`0x3f5f60`/`0x3f5f54`/`0x415878`/`0x64587c`/`0x3d0550`/`0x6458a8`/`0x3de154`/`0x3f5fb0`/`0x667d8`/`0x3f5f48`) - a genuinely new, separate investigation, out of scope for today's finding but a natural next target given the engine now gets meaningfully further into real OBB asset loading than it ever has before. + +**Status of this specific investigation: closed.** Root cause (missing `CallBooleanMethodV`/`CallStaticBooleanMethodV` JNI shim implementations, causing every guest call to a boolean-returning Java method via the `*MethodV` calling convention to silently and incorrectly return `false`) identified, fixed, and empirically verified via before/after comparison on real hardware and the emulated engine both. + +**2026-09-05, identified: the gate is a real Java method already in this launcher's own Kotlin source, `GameActivityMain.useAssetsFileSystem()`.** + +`dword_ADFEA0` (the jmethodID `sub_54F1E8()` calls) is populated in `Java_com_ea_ironmonkey_GameActivityMain_nativeOnCreate` (IDA-confirmed, full decompile) via `GetMethodID(dword_ADFE88 /* GameActivityMain class, cached from the same function */, "useAssetsFileSystem", "()Z")` - a real method on this project's own `GameActivityMain` class, not an Android framework API. Same function also caches `forEach`, `getAssetSize`, `getObbFullPath`, `isAnyMusicPlaying`, `isObbAssets`, `isFullApkAssets` - a small family of asset-location-strategy queries, all real methods this launcher's own Kotlin source already implements. + +`GameActivityMain.kt:174`: `fun useAssetsFileSystem(): Boolean { return mAssetLocationType != AssetLocationType.EXTERNAL }`. `mAssetLocationType` defaults to `AssetLocationType.EXTERNAL` (`GameActivityMain.kt`'s own companion object) - this launcher's own deliberate, load-bearing design choice for loading the real game's OBB-backed asset data, not an accident. So `useAssetsFileSystem()` returns `false` here, `sub_54F1E8()` returns `false` (its own `== 1` check fails), and `sub_5476D8` takes the "build the real external path" branch - matching everything observed today, mechanism fully closed. + +**Important, deliberately-flagged uncertainty, not glossed over**: this does *not* yet prove `mAssetLocationType`/`useAssetsFileSystem()` is itself the cross-device divergence. The real, unmodified game almost certainly *also* loads its (large, OBB-distributed) assets externally rather than bundling them in the APK, so real hardware's own equivalent check may *also* evaluate to "don't use assets filesystem" - in which case the actual divergence is one level further downstream, in whatever populates `dword_ADFC58` (the cached base path `sub_5476D8`'s else-branch joins its suffix onto) - plausibly which of `EAIO.Startup()`'s own `filesDirPath`/`externalPath` arguments that cache is seeded from, or how. Simply flipping `mAssetLocationType` to "not EXTERNAL" is **not** being proposed as the fix without first confirming real hardware's own `useAssetsFileSystem()`-equivalent result - this flag very plausibly gates other, more fundamental asset-loading behavior elsewhere in the boot sequence too, and changing it blind risks breaking real, working asset loading to fix a comparatively minor startup-time cost. + +**Next step**: find where `dword_ADFC58` gets its value (its own xrefs, not yet checked) to see whether it's seeded from `filesDirPath` or `externalPath`, and cross-check against a real-hardware trace of the equivalent native call (or Java-side logging of `useAssetsFileSystem()`'s own real return value on the A9) before considering any actual code change here. + +**2026-09-05, traced `dword_ADFC58` to its source - a real, independent JNI reflection call to Android's own `Environment` API, not the `EAIO.Startup()`-passed path at all.** + +`dword_ADFC58`'s own xrefs led to `sub_71B8C` (just C++ static-initializer boilerplate setting a group of 8 sibling globals to the shared empty-string sentinel at module load, registering `__cxa_atexit` destructors - not where the real value comes from) and `sub_5463D8` (4000 bytes, the real populator, 2 static call sites). `sub_5463D8`'s own embedded strings are unambiguous: `"android/os/Environment"`, `"getExternalStorageDirectory"`, `"()Ljava/io/File;"`, `"java/io/File"`, `"getAbsolutePath"`, `"()Ljava/lang/String;"`, plus `"getFilesDir"`, `"getPackageName"`, `"getPerformanceScore"`. This function does its own **independent, raw JNI reflection** - `FindClass("android/os/Environment")` → `GetStaticMethodID("getExternalStorageDirectory", "()Ljava/io/File;")` → `CallStaticObjectMethod` → `FindClass("java/io/File")` → `GetMethodID("getAbsolutePath", "()Ljava/lang/String;")` → `CallObjectMethod` - to get the external storage root directly, **not** via the `filesDirPath`/`externalPath` strings `EAIO.Startup()` already passed down. `getFilesDir()` is queried the same way, populating one of the 7 sibling cached-path globals alongside it. This is a real, standard Android API call - it correctly returns `/storage/emulated/0` on any device, real or emulated; something downstream (not yet located precisely) appends `/Android/data//files` to it manually, matching the well-known pre-scoped-storage manual-path-construction convention typical of this game's own 2012-era vintage. + +**This closes out what pure static tracing can answer.** The full mechanism, top to bottom, is now concretely understood: `sub_75E40` (app bootstrap) → `sub_76E00` → gated by `useAssetsFileSystem()==false` (`GameActivityMain.kt:174`, currently `false` here because `mAssetLocationType` defaults to `EXTERNAL`) → `sub_5476D8` builds the real path by directly calling Android's own `Environment.getExternalStorageDirectory()` + `File.getAbsolutePath()` via JNI reflection, independent of the `EAIO.Startup()`-passed paths → `Mount(that path, "/")` → the whole-existing-tree merge (`sub_40F68C`/`sub_412A1C`/`sub_582C24`) that this entire investigation has been chasing since first noticing the repeating `opendir` calls. + +**The one remaining question that only a live A9 comparison can answer**: does the real, unmodified game's own equivalent of `useAssetsFileSystem()` return `true` (skipping this whole path) or `false` (same as here, meaning real hardware *also* builds and mounts this path but something else - not yet found - makes its own merge short-circuit quickly)? Given the real game also distributes assets via OBB (matching this whole project's own long-standing "OBB-based data loading" premise, not bundled APK assets), a `false` result on both devices is at least as plausible as a `true`/`false` split - meaning `useAssetsFileSystem()` may be a red herring for the *cross-device* difference even though it's confirmed, exactly, as the local gate. Answering this needs either the real (unmodified) game's own equivalent Kotlin/Java source (if available) or a `trace_agent` JNI hook on the A9 specifically logging this method's real invocation and return value - not more IDA reading of the ARM32 side, which has now been traced as far as it usefully can be without that data point. + +**2026-09-06: answered directly - `useAssetsFileSystem()` returns `true` on the Galaxy A9 (native32, real unmodified `libapp.so`), and the mechanism turns out to be independent of the real OBB file's presence at all.** + +Built out `trace_agent`'s JNI tracer from a curated dozen entries to a much broader ~65-entry `JNINativeInterface` patch (all `Call*Method`/`Get*Field`/`Set*Field` families, object lifecycle, exceptions, local frames - `trace_agent/jni_trace.cpp`), resolving `jmethodID`/`jfieldID` back to real names via a cache populated from `Get*MethodID`/`Get*FieldID` so the trace reads as method/field names rather than bare pointers, and added a shared file-backed logger (`trace_agent/trace_log.h`) so a full run's trace survives past logcat's own rate-limiting/ring-buffer wraparound - motivated by wanting a genuine ground-truth execution trace instead of continuing to add one-off diagnostic hooks per crash. Also discovered and fixed a real self-deadlock in the new logger: `InitFileLog()` was calling `fopen()` while holding the log mutex, and bionic's `fopen()` internally calls the exported `open()` symbol - which, since this library is `LD_PRELOAD`'d, gets re-intercepted by the tracer's own `open()` wrapper, which tries to log too, re-entering the same non-reentrant mutex on the same thread. Fixed by moving `fopen()` outside the lock and adding a `thread_local` reentrancy guard in the shared log function as a second, independent safeguard. + +Also found (separately, mid-session) that the real `com.ea.games.nfs13_arm` package's OBB file was missing from `/storage/emulated/0/Android/obb/` on the A9 (empty directory) despite an earlier session's migration - fixed by copying the sibling `com.ea.games.nfs13_mod` package's real OBB (identical 623,470,192-byte file) to the `_arm` package's expected path/filename per `ObbHelper.kt`'s naming convention. + +Captured a full ~2-minute run through real splash → EULA → into actual rendered 3D gameplay: 1.24M trace lines, confirming genuine reference behavior (884k `glDrawElements`, 268965 `glUseProgram`, 29602 `glClear`, 128 `glDrawArrays` - real per-frame draw activity, not a stall). Grepping this trace for `useAssetsFileSystem` gives a direct, unambiguous answer: **`mAssetLocationType=OBB, result=true`**, first hit ~3ms after `GetMethodID` resolves it, then called repeatedly (once per `onDrawFrame`, matching `GameActivityMain.kt`'s `STATE_GAME_START` case) for the rest of the run. + +**Reading `GameActivityMain.kt`'s own `onCreate()` shows this has nothing to do with the real OBB file I just pushed**: the flip from `EXTERNAL` to `OBB` (line ~265-277) is gated on `getResources().assets.open("obb.size")` succeeding - opening a tiny 9-byte marker file bundled directly in the **APK's own `assets/` folder** (`app/src/main/assets/obb.size`, confirmed present via `unzip -l` on both the `native32` and `translated` flavor APKs - it's shared, flavor-independent source, not something either build excludes). This check runs well before any native library load (`System.loadLibrary`/`loadCore`/`loadEmulatedLibappFromAssets` are all later in the same `onCreate()`), so by the time any native code first calls back into `useAssetsFileSystem()` via JNI, `mAssetLocationType` should already be `OBB` **on both flavors identically** - this isn't a real-hardware-vs-emulated divergence in the Kotlin logic at all, and doesn't depend on whether the real 594 MB OBB expansion file is actually present on the SD card. + +**This sharpens, rather than closes, the original question**: since the exact same `onCreate()` source runs on the emulated (Xiaomi) flavor too, and should reach the same `true` result through the same code path, the earlier "`useAssetsFileSystem()` returns `false` here" finding for the emulated engine (this doc, 2026-09-05) is now suspicious - either that finding is stale (predates the `obb.size` asset marker being added), or the emulated engine's own `AssetManager.open("obb.size")` call is itself failing for an emulation-specific reason (a gap in whatever backs `AssetManager`/`Resources` for the guest environment) that real hardware doesn't hit. **Next step**: re-run the same check on the Xiaomi emulated engine (log `mAssetLocationType`/the `obb.size` open attempt's outcome directly, same as this session did for A9) before assuming anything about *why* the two diverge - the mechanism is now precisely scoped to one Kotlin-side `AssetManager.open()` call instead of a vague cross-device behavioral difference. + +**2026-09-06, same day: checked immediately - the emulated engine (Xiaomi) ALSO returns `true`, identically to A9. This invalidates the earlier (2026-09-05) "returns `false` here" finding and, with it, the whole `useAssetsFileSystem()`-as-the-cross-device-differentiator framing this multi-day thread was built on.** + +Installed the current `translated` (emulated) flavor APK on the Xiaomi and grepped plain logcat (no new instrumentation needed - `GameActivityMain.kt`'s own existing `Log.d` calls at the `obb.size` check and inside `useAssetsFileSystem()` already cover this): `obb.size check: mAssetLocationType before = EXTERNAL` → `obb.size check: opened OK, mAssetLocationType now OBB` → every subsequent `useAssetsFileSystem() called, mAssetLocationType=OBB, result=true, thread=GLThread ...` - **the exact same sequence and result as A9**, confirming the analysis above (the `assets/obb.size` marker is shared, flavor-independent source, checked before any native load) applies identically on both. + +**Checked whether this changed anything for rendering - it didn't**: `gles_shim.cpp`'s own periodic counters on this exact run still show `glClear=0 glDrawArrays=0 glDrawElements=0 glUseProgram=0` for the entire ~75s window watched, no crash, process alive throughout. So `useAssetsFileSystem()` returning `true`/`false` is **not** the thing separating real rendering from zero draw calls - it's `true` on both the working (A9) and non-working (Xiaomi) sides alike. + +**This means the entire causal chain built on the 2026-09-05 "returns `false`" finding - `sub_76E00` → `sub_5476D8`'s JNI-reflection external-path build → `Mount()` → the whole-tree merge (`sub_40F68C`/`sub_412A1C`/`sub_582C24`) → the `opendir`/`stat` retry cadence - describes a code path that isn't actually the one either device takes now** (both take the `OBB`-gated branch instead). Not necessarily wrong as a description of what that specific branch's code does internally (the static RE of `sub_40F68C` etc. was verified by reading real decompiled code, not guessed), but wrong as an explanation for *why rendering stalls on the emulated engine specifically*, since real hardware evidently doesn't need that branch at all to render successfully, and the emulated engine isn't reaching it either (same gate value) yet still renders nothing. + +**Reframes task #18 ("why do GLES draw calls stay at zero") back to genuinely open** - the actual blocker is still unidentified after this angle. The `trace_agent` full-trace capability built earlier today (file-backed, ~65-entry JNI table, real call/field names) is now the natural tool to point at the Xiaomi side too, if a comparable instrumentation path exists there (the emulated engine's own equivalent is `jni_shim.cpp`'s real `Impl_Call*Method` implementations, which already call through to real ART - the missing piece is a live, ordered comparison of what real Java-side calls happen after `useAssetsFileSystem()` returns `true` on A9 vs. on Xiaomi, to find the first point where the two sequences actually diverge, rather than continuing to assume it's this specific gate. + +**Next step**: pull a comparable slice of the Xiaomi run's own JNI activity (this session's existing `jni_shim.cpp` already has extensive real-call plumbing; check whether it already logs enough to reconstruct the same kind of ordered Java-call sequence `trace_agent` produced for A9) and diff it against the A9 trace's own post-`useAssetsFileSystem` call sequence, looking for the first genuine divergence - not another single-variable hypothesis. + +**2026-09-06, same day: found the actual divergence, directly, via a new correlated trace - not another single-variable hypothesis.** + +Built `guest_trace.h`/`.cpp` (new module) + `jni_shim.cpp` instrumentation: a full, file-backed `UC_HOOK_BLOCK` trace of every executed guest basic block (`guest_trace.log`) and a full log of every real JNI call the guest makes - `Call*Method` via `InvokeCall`, `Get/Set*Field` via `DoGetField`/`DoSetField`, `GetMethodID`/`GetFieldID`/`RegisterNatives`/`FindClass` - resolved to real method/field names via a pointer-keyed cache (`jni_trace.log`), both timestamped against one shared `CLOCK_MONOTONIC` epoch so the two files line up directly. Independently gated from `ProfilingEnabled()` (a separate `EnableFullGuestTrace()` flag) so this doesn't resurrect the earlier three-hooks-stacked regression. + +Ran on the Xiaomi 14 for ~107s. **The last real JNI call in the entire run happens at t=47079ms** (`DisplayMetrics.heightPixels`, repeated a few times, then nothing - no more `Call*Method`/`Get*Field`/`FindClass` activity for the remaining 60+ seconds). Cross-checked the process was NOT hung: `guest_trace.log` shows **3.66 million more basic blocks executed on that same thread** after t=47000ms, across only 6381 unique addresses - a real, tight, still-running loop, not a wait/deadlock. Resolved the hottest addresses via the IDA database: + +- `0x66a78` = **`fread`** itself, 200104 hits - the single hottest address in the whole post-47s window. +- `0x3de430` (200104 hits, a 16-byte thin wrapper) and `0x415694` (200102 hits) - the direct call chain around `fread`, matching count-for-count. +- `0x415810`/`0x3f5f1c` (122905/163927/166974/122904 hits) - decompiled: `sub_3F5F1C` is a plain `/`-delimited path-segment tokenizer (splits one path component per call); `sub_415810` iterates a value list, tokenizes a path via `sub_3F5F1C`, and does a linear `memcmp`-based scan against a small on-the-fly list before inserting a new node - a real VFS tree-node lookup/insert pattern, structurally identical to the `Mount()`/`Overlay()` merge machinery already root-caused earlier in this doc's 2026-09-05 entries (`sub_40F68C`/`sub_582C24`), just at the level of building/growing the tree during initial index parsing rather than the later per-tick overlay check. +- `malloc`/`free` (44492/38823 hits) round out the picture - consistent with per-entry node allocation during this same tree-building work. + +**Conclusion: this is not a stuck loop, a wrong branch, or a missing thread - it's the real, correct OBB-index-parsing/VFS-tree-population work (the same category of work A9 also does, per its own `mAssetLocationType=OBB` path), running at a throughput this emulated engine cannot get through in any reasonable test window.** 200,000+ single-byte `fread()` calls plus their surrounding per-entry path-tokenize-and-insert work, each paying Unicorn's real per-instruction interpretation cost, is a fundamentally different cost model than real ARM32 silicon executing the same bytes natively - no logic divergence needed to explain "zero draw calls, ever," just raw throughput. This directly matches (and now provides hard evidence for) the exact concern the user raised this session: real hardware and the emulated engine are not remotely time-synchronized, and the render-stall investigation's entire multi-day thread (Mount/Overlay, `useAssetsFileSystem`, thread-spawn gaps) was chasing *logic* explanations for what may fundamentally be a *throughput* problem in this one hot path. + +**On "can this be synchronized" (the user's own question): no, not via a clock/time-dilation trick - and it's important to be precise about why.** Virtualizing the guest's perceived clock (making its own `gettimeofday`/`clock_gettime` reads think less time has passed) only helps when something is racing against a *deadline* - a guest-internal watchdog, or a host-side timeout waiting on a slow guest call. Neither applies here: nothing has timed out, no ANR, no `kMaxCallIterations` trip this run, the process stays alive and the thread keeps making genuine forward progress - it is **CPU-bound**, not blocked. Dilating time changes nothing about how many real ARM32 instructions still need emulating to finish parsing the index. The only levers that could actually help are ones that reduce the real emulation cost of this specific hot loop: +1. **Unicorn/QEMU-TCG's translation-block cache** - this session's earlier (pre-compaction) fread-rate investigation found a clean step-function slowdown (~9-10 KB/s for the first ~200,000 calls, then ~3.5-3.8 KB/s after) and hypothesized TB-cache exhaustion/thrashing once the run's total translated working set exceeds Unicorn's default cache capacity - never tested (no `uc_ctl`/tb-size configuration exists in the codebase yet, confirmed again via `grep` this session). The **200,104-call figure found independently today via this brand-new tracer lands almost exactly on that same ~200,000-call threshold** - two unrelated measurements agreeing is a real signal, not a coincidence to wave off. This is now the single most promising, concrete, still-actionable lever. +2. Reducing per-call overhead further in `Shim_fread` itself (e.g. a host-side read-ahead buffer so repeated 1-byte guest reads don't each pay full shim/marshaling cost) - worth checking whether `Shim_fread` already delegates to a buffered host `FILE*` (it should, real libc `fread` already buffers) or is doing something less efficient underneath. + +**Next step**: needs a decision, not more tracing by default - either (1) try configuring Unicorn's TB cache size (`uc_ctl`, if this Unicorn version exposes it) and re-measure the exact same fread-rate step-function to see if the ~200k-call cliff moves or disappears, or (2) accept this as a real, structural throughput limit of instruction-level CPU emulation for this specific workload and instead look at whether the index-parsing work itself can be short-circuited/cached (e.g. pre-parse the OBB index once on the host side outside Unicorn entirely, since it's pure data lookup with no real guest-side side effects needed until an actual asset read is requested) - a more invasive change than anything tried so far, but potentially the only way to get this specific workload within a usable time budget. + +**2026-09-06, same day: ruled out MIUI/vendor-specific behavior directly, on a second real device.** + +User raised a fair, specific alternative before committing to the TB-cache theory: could this be a Xiaomi/MIUI-specific bug (memory management, background-process throttling, or something else vendor-specific), not a universal Unicorn/emulation limit? Installed both `com.ea.games.nfs13_mod` (the real reference APK, pulled from an existing WayDroid install of it) and this project's own `translated` (emulated) flavor on a Pixel 6a running GrapheneOS - a completely different vendor, SoC (Google Tensor vs Xiaomi's Snapdragon), and OS (near-stock GrapheneOS vs MIUI/HyperOS). `_arm`'s OBB was missing here too (same recurring gap as every fresh install this session) - fixed the same way, copying `_mod`'s real OBB with the renamed filename. + +Ran the same `translated` build there: **identical qualitative and near-identical quantitative behavior**. Same `glUseProgram=5` shader-compile ceiling with zero draw calls thereafter (the same signature seen on Xiaomi and documented since this investigation's earliest GLES-counter pass). Same `fread` step-function, at the **same ~200,000-call threshold**: ~6800 and ~6400 calls/sec for the first two 50k-call intervals, dropping to ~3200-4800 calls/sec immediately after crossing 200,000 - a real, reproducible ~1.5-2x slowdown landing on the identical call count as Xiaomi's own (~2844 calls/sec post-200k, from the same-day measurement above), just with different absolute throughput (faster hardware, same shape). + +**This is decisive**: a MIUI-specific bug would not be expected to reproduce an identical numeric threshold on a different vendor's silicon and a near-stock AOSP-based OS. The ~200,000-call cliff is a property of this project's own emulation stack (Unicorn/QEMU-TCG), not of Xiaomi's software. Directly confirms the TB-cache-exhaustion hypothesis is the right thing to test next, and closes off the MIUI-specific-bug alternative the user raised - a real, testable question, and it came back negative, not just asserted away. + +**Next step, now well-justified**: implement the Unicorn `uc_ctl` TB-cache-size experiment from the entry above - this is now the highest-confidence remaining lever, having survived a genuine cross-vendor falsification test rather than being adopted on the strength of one coincidental number. + +**2026-09-06, same day: TB-cache theory tested and falsified directly. Real root cause found instead - this project's own `GuestHeap::Alloc()`, not Unicorn, not the game, not MIUI.** + +Bumped `DEFAULT_CODE_GEN_BUFFER_SIZE_1` (`third_party/unicorn/qemu/accel/tcg/translate-all.c`) from the upstream 1 GiB default to the aarch64 host's own `MAX_CODE_GEN_BUFFER_SIZE` (2 GiB) and re-ran the identical fread-rate measurement on the Pixel 6a. **The ~200,000-call cliff did not move at all** - same threshold, same ~1.5-2x magnitude drop (pre: ~6000-6800 calls/sec, post: ~3200-4800 calls/sec), byte-for-byte consistent with the un-bumped baseline. Reverted the change - this Unicorn/QEMU-TCG subsystem is not the cause. + +Rather than guess again, decompiled the actual driving loop (`sub_415284`, IDA-confirmed): it's a real `ZipFileSystem` constructor - opens the OBB as a ZIP stream (`sub_6451F4`, "Error opening ZIP stream" on failure) and loops its central directory, reading each entry's filename, logging `"Indexing \""`, and calling `sub_415810` (the path-tokenize-and-VFS-insert function from the earlier entry above) once per entry. The `"Indexing"` log itself never reaches logcat - almost certainly swallowed by this same session's earlier `dword_ADCAA0` "no-op vtable" crash fix, since it routes through that same debug-stream object. + +Added a rate-limited `UC_HOOK_CODE` probe (`ZipIndexProbeHookCb`, `guest_engine.cpp`) directly on `sub_415810`'s entry (reading `r1`'s `{begin,end}` string-view struct - same technique as the existing `MountArgProbeHookCb`) to read real entry names without depending on the swallowed log path. Confirmed: this project's own OBB (`published/...`, `.sba`/`.prefabs.sb`/`.fev`/`.fsb` game assets - textures, sound banks, track prefabs) has on the order of several thousand entries. Cross-referencing entry numbers against the fread call-count milestones showed **nothing structurally unusual at the ~200,000-call point** - just ordinary texture entries (`textures/cars/pagani_zonda_r/...`), no depth change, no size anomaly, no different file type. This ruled out a data-dependent trigger in the archive's own content. + +**That absence of a content-side cause pointed at the emulator's own infrastructure instead of the game's algorithm.** `guest_heap.h`'s own class comment already flags exactly this risk, unprompted, as known and documented: `GuestHeap::Alloc()` (backing every guest `malloc`/`calloc`/`realloc`, used by both the game's own code and this shim layer's own JNI marshaling buffers) is a **first-fit allocator that scans linearly from the arena start through every block ever carved - free or in-use - on every single call, with no coalescing and no free-list**. Added temporary instrumentation (call count + blocks-scanned + elapsed ns, rate-limited to the first 20 calls and every 20,000th thereafter) and measured directly: call #1-20 scan 0-14 blocks (~100-300ns); **call #20,000 scans 4,269 blocks (13.7μs)**; **call #40,000 scans 9,294 blocks (30.7μs)**; **call #60,000 scans 9,260 blocks (30.6μs)** - scan length (and cost) ramps up roughly 7x over the first 40,000 calls, then plateaus around ~9,300 blocks rather than growing further - matching the fread-rate data's own shape exactly (one step-like slowdown, then a stable-but-slower rate, not a continuously-accelerating blowup) rather than unbounded quadratic growth, because the arena's free/used block population reaches a steady churn equilibrium once the ZIP-indexing workload's own allocate/free pattern stabilizes. + +**This is the real, confirmed, measured root cause**: a documented-but-previously-unquantified allocator limitation in this project's own code, not Unicorn/TCG, not the game's binary, and not MIUI - directly explaining every observation that didn't fit those other theories: reproducible at the same *call count* (not wall-clock time) regardless of host speed or vendor, since it's driven by total allocations made, not CPU frequency; zero effect from the TB-cache-size experiment, since it's an entirely separate subsystem; and no anomaly in the archive's own content, since the cost is intrinsic to the allocator's own scan, not to what's being indexed. + +**Next step**: this is now a real, scoped fix, not a diagnostic - replace `GuestHeap::Alloc()`'s O(n) linear scan with a proper free-list (segregated by size class, or even a single doubly-linked free-list threaded through free blocks so `Alloc()` only walks free blocks instead of every block ever carved) so allocation cost stops scaling with total historical allocation count. `guest_heap.h`'s own comment already anticipated this as necessary follow-up work ("a long-running instance could fragment... Documented gap, not a silent one") - this investigation supplies the first real, quantified evidence of exactly how much that gap costs in practice, and narrows the fix to a single, well-understood function rather than a vague architectural concern. + +**2026-09-06, same day: implemented the GuestHeap free-list fix (170x faster on the allocator itself, confirmed) - but it did NOT move the overall fread-rate cliff at all. The allocator was never the (sole) real cause.** + +Replaced `GuestHeap::Alloc()`/`Free()` with a size-class-segregated free list (power-of-two buckets, 8B-64KiB, O(1) pop/push via a "next" pointer threaded through each free block's own payload; oversized requests still bump-allocate fresh and are never reused, same as before for that rare case). Verified directly: call #20000 now takes 81ns (was 13,672ns) - a genuine ~170x improvement, no crashes, no `GuestHeap::Free: rejected` lines. **But re-measured the same fread-rate step-function on the Pixel 6a afterward and it was statistically unchanged** (~5500-6100/sec pre-200k, ~3000-4700/sec post-200k) - the fix is real and worth keeping, but it was a genuine, minor contributor at most, not the dominant driver this investigation was chasing. Recorded honestly rather than declared a win it didn't earn. + +**User pushback, directly on point: "ты хочешь свалить ответственность на гостевой код, но факт в том что на нативе всё работает отлично"** - a fair correction of framing. The game's own algorithm is not at fault; it runs fine on real hardware precisely because real ARM32 silicon executes normal-cost work at normal speed. Everything slow here is intrinsic to Unicorn's instruction-level emulation multiplying real, unremarkable work by a large constant factor - the question was never "is the game's code bad," it was "which specific piece of normal work is disproportionately expensive under this specific emulation model." + +**A 4.5-minute long-run test (prompted by the user's own sharp question - "is it actually impossible, or does it just need more time?") gave a decisive, non-obvious answer: progress is real, not stuck - but a SECOND, far worse cliff exists past the first one.** Entries climbed steadily to ~5459 within ~80s, then nearly halted: ~5498 by 108s, unchanged for the next ~110 seconds (135s-216s polls all read 5498), then crawled forward at roughly 1-2 entries per 27-30s. This directly falsifies "it's fundamentally impossible" (the process keeps making genuine forward progress, is never deadlocked, never crashes) while also falsifying "it's just the same one cliff from before" (this is a distinct, much more severe slowdown on top of the already-characterized one). + +**Traced the second cliff to specific individual entries, not a smooth function of any counter.** Correlating `ZipIndexProbe` timestamps against entry names (attributing each inter-entry gap to the entry that STARTS at the earlier timestamp, since the probe fires at `sub_415810`'s entry) found the gap is not evenly distributed: `data/regions.sb` (~1.5s), `stringdata/RUS_RU/nfsmw_android.sb` (~28s), `published/layouts/layouts.sb` (~122s), `published/flow/update_check.sb` (~45s) - while every neighboring `.sba`/`.ttf`/`.fev`/`.fsb` entry in between processed in the normal ~15-50ms range. **Every single catastrophically-slow entry has the `.sb` extension; every fast entry does not** - a clean, exception-free split, not a coincidence. + +First hypothesis (a size-class-doubling reallocation of `sub_415810`'s own sibling-array, per its own decompiled body) was tested directly with a filtered `UC_HOOK_CODE` probe pinned to the exact call site (`sub_3DE038` entry, filtered to `LR==0x4159a8` - the specific return address for that one call site, since `sub_3DE038` is a generic `malloc()` wrapper called from dozens of unrelated places and an unfiltered probe drowned in noise) - **zero hits during the entire stall window**. Ruled out cleanly, not just abandoned. + +**Real root cause, found via the existing `guest_trace.h` full-block tracer** (temporarily re-enabled for one targeted capture, then disabled again immediately after - see the standing `[[feedback_uc_hook_block_opt_in]]` lesson): filtered the trace to the exact wall-clock window of the `layouts.sb`→`flow/default.sb` stall and found one address responsible for **1,612,811 of roughly 3 million total block hits in that window - 5x the next-hottest address**. Resolved via IDA: `sub_4EDAD4`, a five-instruction loop implementing textbook **FNV-1a hashing** (`hash = (16777619 * hash) ^ byte`, one byte per iteration - `16777619` is the standard FNV prime). Its caller, `sub_4F54CC`, settles the question completely: it iterates a list of data "sections," hashes each section's raw bytes through `sub_4EDAD4`, accumulates a running size and checksum, and logs (own embedded strings, IDA-confirmed) **`"Writing section ; size = ; checksum = "`**. + +**This is not a read/index step at all - it's a first-run BUILD/COMPILE step for `.sb`-format structured data bundles**, computing a content checksum over the bundle's own bytes as it's assembled, section by section. Ordinary assets (`.sba` textures, `.ttf` fonts, `.fev`/`.fsb` sound banks) never go through this path at all - only `.sb` bundles do, explaining the clean, total split between catastrophic and normal entries with no in-between cases. On real ARM32 silicon, hashing a multi-megabyte buffer this way costs low milliseconds; under Unicorn's per-instruction emulation, the same byte count costs tens of seconds to minutes - a real, well-understood, and now precisely located throughput problem, not a logic bug, not a MIUI issue, and (per the earlier falsification) not `GuestHeap`'s fault either. + +**This finally gives a genuinely safe, narrow fix, in contrast to every earlier, riskier idea discussed this session** (replicating `ZipFileSystem`'s internal object layout on the host, or re-implementing the whole ZIP-directory read loop): `sub_4EDAD4` is a small, pure, well-defined, side-effect-free function (input: byte pointer + length + running hash state; output: updated hash state - textbook FNV-1a, no game-specific behavior to preserve beyond the algorithm itself). It's an ideal shim-interception candidate - replace calls to it with a host-native loop computing the byte-for-byte identical FNV-1a result at real hardware memory-bandwidth speed instead of Unicorn's per-instruction rate, with zero risk to any other game logic since the function has no observable behavior beyond its numeric return value. + +**Next step**: shim `sub_4EDAD4` itself (a single, generic-import-style interception, matching this project's existing `RegisterImportShim`-style pattern used throughout `import_shims.cpp`/`libc_shims.cpp`) - marshal the guest `(hashState*, byte*, count)` arguments, compute the identical FNV-1a hash via a tight host C loop (or even `std::hash`-free manual unrolling) over the guest bytes via `G2H()`, write the result back to the guest's hash-state pointer, and return - bypassing Unicorn entirely for this one hot function while leaving every calling and called function around it as real, unmodified guest code. Verify byte-for-byte identical output against a few real `.sb` files before trusting it broadly (a wrong checksum could plausibly make the game reject its own freshly-built bundle as corrupt). + +**2026-09-06, same day: implemented the FNV-1a shim (`FnvHashAccelHookCb`, `guest_engine.cpp`, hooked directly at `sub_4EDAD4`'s entry with the "old-style" PC=LR+`uc_emu_stop()` skip technique, appropriate here since it fires once per call not once per byte). Ruled out the sibling-array-doubling theory cleanly first** (a filtered probe pinned to the exact call site, `sub_3DE038` entry filtered to `LR==0x4159a8` - zero hits during the entire stall window) **before committing to the FNV fix, so this wasn't another unverified guess.** + +**Result: real, but smaller than expected.** Re-tested the exact same `layouts.sb` → `flow/default.sb` transition the 1,612,811-hit measurement came from: **93.6s, down from 122.3s** (~23% faster) - a genuine, positive, measured improvement, not nothing. But nowhere near the reduction a naive reading of "this one function was 1.6M of ~3M total block hits" would predict. Likely explanation, stated plainly rather than glossed over: that 1.6M-hit measurement was itself captured *while* `EnableFullGuestTrace()`'s own per-block `clock_gettime()`+`gettid()` overhead was active (the same diagnostic this doc's own `[[feedback_uc_hook_block_opt_in]]` entry warns never to leave on) - a fixed per-block tax inflates the apparent weight of many-cheap-iterations (this hash loop's block is tiny: load, multiply, xor, store) relative to fewer, larger-bodied blocks elsewhere, so "block count" and "real wall-clock cost" aren't the same thing when the measurement tool itself has non-uniform per-block overhead. The fix is real and worth keeping, but `sub_4EDAD4` was evidently a significant contributor, not the dominant one. + +**Separately, `nfsmw_android.sb` (localization strings) was completely unaffected by this fix** (~26.5s before and after) - confirming its own bottleneck is something else entirely, not FNV hashing, and needs its own independent diagnosis rather than assuming every `.sb` file's slowness has the same cause. + +**Next step**: the same full-block-trace technique (this time captured without the per-block-timing distortion this entry just identified - e.g. record only addresses, no `clock_gettime()` per hit, and derive timing from the periodic dump-thread cadence instead) would give a cleaner picture of what else is hot during both the `layouts.sb` and `nfsmw_android.sb` windows. The two remaining candidates already surfaced in the first (distorted) trace - `sub_6674FC` (0x6b0 bytes, 321,319+263,093+263,059+... hits across several of its own block addresses) and `sub_65F6C8` (50,403+50,378 hits) - are worth resolving and checking next, the same rigorous way `sub_4EDAD4` was: confirm via decompile what they actually do before assuming they're shimmable, since not every hot function will turn out to be as safely side-effect-free as this one was. + +**2026-09-06, same day: user directly challenged the "instruction-level emulation is inherently slow" framing with a concrete, well-informed counter-example - libhoudini (Google/Intel's real ARM32->x86_64 translator) runs this exact game well, and does an even more drastic ISA crossing than our ARM32->ARM64 translation. This was a fair, important correction, not just a rhetorical objection.** + +The key insight that resolves the apparent contradiction: libhoudini is not "OS emulation" either - it's a binary translator/JIT that shims the JNI/syscall boundary exactly like this project does, translating only the app's own CPU instructions. Architecturally, this project already matches that design. So if libhoudini achieves good real-world throughput doing a *harder* ISA crossing, "instruction-level translation is inherently ~10-100x slower" cannot be the right blanket explanation - the real question is whether Unicorn/QEMU-TCG specifically (a general-purpose, portable, debuggable emulation core, not a game-compatibility-tuned production JIT like Houdini) has room to perform much better than what this session's real-world measurements have been showing. + +**Tested directly rather than argued about further.** Built `tcg_bench.h`/`.cpp` (new, temporary, standalone files) - a one-shot benchmark that opens a brand-new `uc_engine` with zero relationship to `GuestEngine`, the loaded game image, or any of this project's own hooks/shims, writes the *exact real machine-code bytes* of `sub_4EDAD4` (copied byte-for-byte from `libapp.so` via IDA) into a tiny isolated memory region, and times `uc_emu_start()` running it over a 16 MiB buffer (representative of a real `.sb` bundle section size) via `std::chrono`. Called once from `LoadEmulatedLibapp`, logged under a dedicated `TCG_BENCH` tag, verified on the Pixel 6a. + +**Result: bare Unicorn/TCG runs this exact loop at 15,690,433 iterations/sec** (16,777,216 bytes in 1.0693s). At that rate, the ~800,000-1,600,000 real iterations the `layouts.sb` hash work plausibly represents would cost **~0.05-0.1 seconds** - not the tens of seconds observed in the real run, even after this session's own FNV shim already cut real measured time by ~23%. **This confirms the user's point completely: Unicorn/QEMU-TCG itself is not the bottleneck for this class of tight loop.** Something specific to the real game's execution *context* - not the translation engine in the abstract - is responsible for the gap between "16.7M bytes/sec in isolation" and "tens of seconds for a plausibly similar byte count in situ." + +**This reframes today's whole investigation.** Every fix found so far (`uc_emu_stop()` round-trips, `GuestHeap`'s O(n) scan, the FNV-1a shim) was real and individually justified, but this benchmark suggests they may have been treating symptoms of one or more *context-specific* costs (TCG translation-cache pressure from the full ~9+ MB loaded binary's worth of distinct executed code, unlike this benchmark's tiny isolated 56-byte region; some other always-on per-access overhead; concurrent guest-thread activity; or genuinely more surrounding real work per hash call than assumed - `sub_4F54CC`'s own loop does virtual "Write" calls and other bookkeeping around each `sub_4EDAD4` call, not just the hash itself) rather than the instruction-emulation cost of the hot function itself, which this benchmark now shows was never actually the limiting factor in isolation. + +**Next step, the cleanest possible follow-up experiment**: re-run the *exact same 16 MiB throughput measurement*, but this time inside the REAL, fully-loaded `GuestEngine` context - call the real `sub_4EDAD4` at its real address (0x4edad4) within a thread that has the whole game image loaded and every other hook this project registers still active (temporarily disabling only `FnvHashAccelHookCb`'s own skip-and-substitute behavior for this one measurement), timed the same way. If that measurement comes back close to the bare-Unicorn number, the "TCG itself in this specific loaded-binary context" is not to blame and the extra cost is genuinely surrounding work (marshaling, allocations, other per-section bookkeeping) that just hadn't been separately measured yet. If it comes back dramatically slower than bare Unicorn despite executing the identical bytes, that would be direct, first-of-its-kind evidence of a real context-dependent Unicorn/TCG performance cliff (translation-cache pressure being the leading candidate) - a finding that would matter far beyond this one function, potentially explaining much of today's whole investigation at once instead of one function at a time. + +**2026-09-06, same day: ran it. The result is unambiguous and reframes the entire day's investigation.** + +Added `RunTcgBenchmarkInRealContext(GuestEngine&)` (`tcg_bench.cpp`) - allocates a fresh scratch copy of the *exact same* `sub_4EDAD4` bytes via `GuestEngine::AllocPermanent` (so it does NOT hit `FnvHashAccelHookCb`'s address-pinned skip), a 4 MiB data buffer via the real `GuestHeap`, and invokes it through `CallGuestFunction()` - the same call path every other real guest invocation in this codebase uses - called once, right after the real `JNI_OnLoad` completes (matching the point real ZIP-indexing activity would normally begin). Verified on the Pixel 6a. + +**Result: 53,428 bytes/sec, versus 15,173,151 bytes/sec for the identical bytes in the isolated benchmark - a ~284x slowdown**, on code with *zero relationship to the game's own logic* (a freshly-allocated scratch copy, never touched by any game code). This rules out every content-specific theory at once: it's not this game's algorithm, not this specific function, not the size or nature of the `.sb` data - identical machine code, running in the real engine context instead of an isolated one, is ~284x slower for no reason connected to what the code actually does. + +**Leading suspect, with real numbers behind it**: counted every individually-registered `UC_HOOK_CODE` hook this project installs per engine - `RegisterImportShim`/`RegisterDataSymbol` calls alone (each backed by its own `AllocCodeStub` → its own individual `uc_hook_add(..., addr, addr)` with a narrow one-address range) total **541** across `fmod_shims.cpp` (63), `pthread_shim.cpp` (13), `rtti_shims.cpp` (39), `import_shims.cpp` (66), `gles_shim.cpp` (145), `libc_shims.cpp` (211), plus `guest_engine.cpp`'s own 4 - on top of the several single-address diagnostic probes (`VarargProbeHookCb`, `MountArgProbeHookCb`, `ZipIndexProbeHookCb`, `FnvHashAccelHookCb`) still active. JNI's own 233 slots go through a single shared dispatcher covering one address *range* (a different, likely cheaper pattern) rather than one hook per slot, but the import/data-symbol side alone means **every engine has upward of 540 separate, individually-registered hook ranges** scattered across the guest address space. + +**Working hypothesis, not yet proven**: if Unicorn/QEMU's own internal hook-range check (run for every translated basic block, anywhere in the address space, to decide whether that block needs instrumentation) isn't O(1) in the number of registered hooks - a linear scan or an otherwise non-ideal data structure over ~540+ ranges - this would be paid on *every single block translated anywhere*, including a freshly-allocated scratch address with no relationship to any hook's range, exactly matching what was just measured. This would also explain why this reframes the whole day rather than just this one function: it's not that `sub_4EDAD4`, `sub_6674FC`, `sub_65F6C8`, or the `GuestHeap` scan were each individually cursed - the entire engine may be paying a large, constant, per-block tax that makes *everything* slower, and today's individual fixes were each real but were chasing symptoms of one shared root cause. + +**Next step**: this needs confirming, not assuming - (1) check whether this project's specific Unicorn/QEMU version's hook-dispatch implementation is actually O(n) in registered hook count (read `uc_hook_add`'s own internal data structure in the vendored `third_party/unicorn` source), and (2) if so, the architectural fix is to consolidate the ~540 individual import/data-symbol hooks into the SAME "one shared range, one dispatcher, internal address→handler lookup table" pattern JNI's own 233-slot dispatch already uses (matching `JniSlotDispatch`'s own design) - one `uc_hook_add` call covering the whole misc-stub arena instead of 540+ separate ones, with the dispatcher doing its own fast (e.g. hash-map or sorted-array binary-search) lookup internally instead of asking Unicorn to do 540+ range checks per block. This is a real, scoped, high-confidence-value architectural change, not another one-off function shim - if the hypothesis holds, it could be the single highest-leverage fix this entire investigation has found. + +**2026-09-06, same day: confirmed via source (not just measurement) and implemented. Result: the single biggest win this entire multi-day investigation has found.** + +Checked `third_party/unicorn/include/uc_priv.h` directly: same-type hooks live in `struct list hook[UC_HOOK_MAX]` - a genuine linked list, walked via `HOOK_FOREACH` for every translated block anywhere in the address space to decide whether that block needs instrumentation. With ~600+ `UC_HOOK_CODE` entries registered (233 JNI slots + ~380-540 import/data-symbol stubs, both created through the same `GuestEngine::AllocCodeStub`), this is a real, confirmed O(n) tax paid on every block, everywhere - not a guess. + +**Implemented the consolidation exactly as planned** (`guest_engine.h`/`.cpp`): `AllocCodeStub` no longer calls `uc_hook_add` per stub - it pushes `{callback, userData}` onto a new `misc_stub_dispatch_table_` vector instead (index = allocation order = address order, since the arena is a pure sequential 4-byte bump allocator with no frees). One new static `MiscStubDispatch` function computes `(address - misc_stub_arena_start_) / 4` and looks up the real callback in O(1), then delegates to it - byte-identical behavior to before, just reached via one array index instead of Unicorn's own hook-list walk. `EnsureThreadEngine` registers this ONE dispatcher once per new engine (`uc_hook_add(..., misc_stub_arena_start_, misc_stub_end_)`) instead of replaying 600+ individual hooks. No caller (`jni_shim.cpp`, `import_shims.cpp`, every `*_shims.cpp`) needed to change at all. + +**Verified on the Pixel 6a**: `RunTcgBenchmarkInRealContext` went from **53,428 bytes/sec to 7,903,674 bytes/sec** - a ~148x improvement, closing the gap to the isolated baseline (17,092,367 bytes/sec this run) from ~284x down to ~2.2x. The hook-list hypothesis is confirmed, not just plausible. + +**And then something genuinely new happened**: for the first time in this entire multi-day investigation, execution reached real rendering code. The very next guest call after the benchmark (`CallGuestFunction(0x54e100)`, `RunLoop.nativeOnRunLoopTick`) ran far enough to hit `sub_56962C` - a real draw-call-submission function calling `glEnableVertexAttribArray`/`glVertexAttribPointer`/`glUseProgram`/`glUniformMatrix4fv`/**`glDrawElements`**/`glDisableVertexAttribArray` (IDA-confirmed via decompile) - territory task #18 ("why do GLES draw calls stay at zero") never once reached in weeks of prior investigation, because the engine was never fast enough to get this far before some earlier bottleneck dominated the test window. + +It then hit a new, different fault: `MEM FAULT READ_UNMAPPED guest_addr=0x20000200` at guest PC≈0x569b40, inside `sub_56962C`'s own body - reading through a computed pointer (`v56 = (_DWORD*)(v55 + 28*v54)`, itself already null-checked and non-null, but pointing at an invalid address once dereferenced) while iterating render-state "commands" (`v38`) to bind uniforms before the `glDrawElements` call. **This is not a regression from today's fix** - it's a new, deeper, previously-unreachable bug, reached only because the engine now runs fast enough to get this far in a normal test window. Task #18 was never really "draw calls always stay at zero" as a permanent property - it was "the engine was too slow to ever reach the code that submits them," and that framing is now obsolete. + +**Next step**: this is a genuinely new investigation, not a continuation of today's performance work - find what `v55`/`v54`/`v56` represent in `sub_56962C`'s real render-state-command iteration (the loop starting at `sub_56962C+0x430`-ish per the decompile, walking `v38[3]` entries via `v38[6]` as an array of `{type, index, ...}` triples) and why the computed address `v55 + 28*v54` lands outside any mapped guest region for this specific command. Update `ARM64_TRANSLATION_LAYER.md`'s task list to reflect that task #18 is now "actively reachable and debuggable" rather than "blocked on performance." + +**2026-09-07: root-caused the `sub_56962C` draw-call crash down to an empty shader source string, not a pointer/index corruption in our translation layer.** + +Reopened the crash with real disassembly (`sub_56962C` @ `loc_569B38`/`0x569b40`, the faulting `LDR R2, [R1,R2,LSL#5]!`). Traced the two register inputs back through the function's own decompile: +- `R1` (base, null in the crash) = `*(v84 + 44)` where `v84 = v26[2]`, and `v26 = sub_567344(v83+76, a4[5], v91, &v89)` - a shader-variant cache-entry lookup. +- The struct `v26` itself has offset+24 (`v26[6]`) checked right before use, with a real, in-game `if (!v26[6]) printf("ZOMG");` warning that fires but does NOT stop execution - a genuine EA-authored "not ready, proceed anyway" pattern. + +Added three targeted `UC_HOOK_CODE` probes (`guest_engine.cpp`: `ShaderVariantProbeHookCb` @ `0x5698c0`, `RenderCrashProbeHookCb` @ `0x569b38`, `ShaderCompileResultProbeHookCb` @ `0x567394`/`0x5673c4`) to dump the real struct contents live on the Pixel 6a instead of guessing further from static analysis alone. Result: **not just offset+24 but also offset+16 (the shader program handle) was zero** - `sub_5673F8` (the real, synchronous GLSL compile-and-link pipeline this struct's offset+16 depends on - confirmed via its own decompile: embeds a literal fallback pixel shader and "Vertex/Pixel shader compile failed" log strings) was failing on every call, but neither failure string ever reached logcat. + +Since `glShaderSource`/`glCompileShader`/`glLinkProgram` in `gles_shim.cpp` already forward straight to the **real host GLES driver** (not emulated - this game's shaders are, and always were, compiled by the actual GPU driver, not by anything in our translation layer), added real `GL_COMPILE_STATUS`/`GL_LINK_STATUS` + `glGet{Shader,Program}InfoLog` checks directly in those two shims. The real host driver's own answer: `glCompileShader(15) FAILED: 0:1: L0009: Missing main() function for shader` - a genuine compiler error, not a translation bug on its face. + +Then dumped the raw shader-source pointer/length reaching `glShaderSource`: `strGuestPtr=0xae1a0c`, `length[0]=0`. Address `0xae1a0c` is `dword_AE1A0C` - a real, named global in `sub_5673F8`'s own decompile, used explicitly as the "assembled source string came out empty" fallback: +```c +if (v13 < 2) { // computed shader-source length < 2 chars + v14 = &dword_AE1A0C; // fall back to this near-empty sentinel buffer + v15 = (char *)&dword_AE1A0C + 1; +} +``` +**This is the game's own code correctly detecting and substituting for an empty buffer - not a pointer-translation bug in our shim.** The real bug is one step further upstream: whatever step is supposed to write the actual GLSL source text into the buffer that `sub_5673F8` scans (before this null-terminator-length check) produced an empty result in our run. Given this whole session's dominant theme (`.sb`-bundle resource loading issues, silently-swallowed debug-stream logs), the leading hypothesis is that the real GLSL source text is meant to come from a loaded resource/bundle that our emulated run either never loaded or loaded empty - not a register/pointer corruption bug in the CPU-translation layer itself. + +**Next step**: identify what populates the buffer `sub_5673F8` scans before the `v13 < 2` check (the functions called before it in `sub_5673F8`'s own body - `sub_46E8BC`/`sub_46F530`/`sub_43FDE0`/`sub_46FD30` - one of these is responsible for supplying the real shader source text, likely from a `.sb`-bundle-backed shader-source resource), and check whether that resource load path is silently failing under our emulation the same way earlier `.sb`-related loads were found to stall/fail this session. This is now a resource-loading investigation, not a CPU-emulation-correctness one - task #18 remains "actively reachable and debuggable," now one level deeper. + +**2026-09-07, same day: found the actual trigger - a missing vertex attribute, not a bundle/resource load failure.** + +Traced one level further: `sub_43FDE0` (called from `sub_5673F8` before the null-terminator-length check) walks the mesh's vertex declaration looking for attributes the shader expects (type codes 2 and 3 checked via `v18[2]==0`). If the walk exhausts the declaration without finding what it needs, it takes the "not found" branch, which builds and (attempts to) log a real, EA-authored diagnostic string: +``` +"ERROR: Shader attribute '{0}' index {1} not present in vertex declaration. Error shader substituted." +``` +then explicitly substitutes an "error shader" - and it's this substitution path, not bundle/resource loading, that ends up producing the empty `dword_AE1A0C`-backed buffer `sub_5673F8` later detects and (already) diagnosed via `ShaderCompileResultProbeHookCb`. The log string itself never reached logcat (routes through `sub_3EA194`/`dword_ADCB80`, gated by `byte_ADCAEC` - the same class of debug-stream object this session already found silently swallowing output once before, for the ZIP-indexing "Indexing" log). + +So the root trigger is: **this specific mesh's vertex declaration is missing an attribute this specific shader variant expects.** Whether that's a genuine, rare content edge case that real hardware also hits (and silently recovers from via a *working* error-shader substitution we don't yet have), or a symptom of the mesh/vertex-declaration data itself loading incompletely under our emulation, is not yet determined - `v34 = v16[6]` / `v36 = v16[7]` (the attribute name/index actually reported missing) were not yet captured live. + +**Next step**: hook `sub_43FDE0`'s missing-attribute branch (around `0x440134`-`0x440158`, where `v16[6]`/`v16[7]` are read) to log which attribute/index is actually being reported missing - this tells us whether it's a "real" attribute (position/normal/texcoord - expected sometimes, and the fix is a *working* error-shader fallback, not chasing why it's missing) or something that points back at a specific earlier resource-load defect. + +**2026-09-07, same day: the "missing vertex attribute" hypothesis was WRONG - ruled out live, not assumed.** + +Hooked `sub_43FDE0`'s missing-attribute branch directly (`ShaderMissingAttrProbeHookCb` @ `0x440200`) and re-ran on the Pixel 6a. **The probe never fired**, even though the crash (with the same empty `dword_AE1A0C`-sentinel source) reproduced identically. This rules out the vertex-declaration-walk-exhausted path entirely: `sub_43FDE0` is instead taking its OTHER early-exit branch (`v10[*(a4+112)] == v11` true immediately after finding the first non-null cache-registry entry, jumping straight to `LABEL_23`) - a path that assembles its attribute list via two direct lookups, `sub_478168(a2, 3, 0)` and `sub_478168(a2, 2, 0)`, without ever walking a vertex declaration or hitting the "not present" diagnostic at all. + +So the empty shader source is not caused by a genuinely-missing mesh attribute - it's caused by something in this *other*, cache/registry-lookup-driven path (`sub_478168`'s two calls, keyed by type constants 2 and 3, and the subsequent `dword_ADBFB8`-keyed structural-hash dedup cache checked at `LABEL_54`) producing an empty or short result under our engine specifically. Not yet root-caused further this session - `sub_478168` itself, and what `a2`/`dword_ADBFB8` actually represent at this point, are the next things to inspect. + +**Status at end of this investigation arc**: root cause narrowed from "a crash in GLES draw-call code" (task #18's original framing) down to "an empty/near-empty shader source reaching the real host GLSL compiler, produced somewhere inside sub_567344's LABEL_23 cache-lookup fast path (via sub_478168), not the vertex-declaration-walk path this session initially suspected." All five diagnostic probes added this session (`ShaderVariantProbeHookCb`, `RenderCrashProbeHookCb`, `ShaderCompileResultProbeHookCb`, `ShaderMissingAttrProbeHookCb`, plus the `Shim_glCompileShader`/`Shim_glLinkProgram`/`Shim_glShaderSource` GL-status/source-dump additions in `gles_shim.cpp`) remain in place and enabled by default - low-overhead (each fires only on specific narrow addresses/calls, not per-block), useful for the next continuation of this specific investigation, but should be removed once root-caused (per this session's own "remove spent diagnostics" discipline, not yet done). + +**2026-09-07, same day: sub_43FDE0 fails 100% of the time - and even repeats never hit cache.** + +Hooked sub_5673F8's own call site of sub_43FDE0 directly (`ShaderFastPathResultProbeHookCb` @ `0x56746c`, right after `BL sub_43FDE0`/before the `CMP R0,#0` that decides fast-path-success vs. fall-through-to-manual-assembly). Result: **10/10 calls returned R0=0** during the crash-reproduction window - `sub_43FDE0` never once succeeds. Notably, several shader-variant addresses repeat (`0x47595a4`, `0x4759a74`, `0x4759ddc`, `0x475a228` each requested twice) with the *same* `a2=0x434548c` (a shared vertex-declaration/material-property object) both times, and **both attempts fail identically** - if any caching/dedup were working even partially, the second identical request should hit it. This, combined with `ShaderMissingAttrProbeHookCb` never firing (ruling out the vertex-declaration-walk "attribute not present" path from the previous entry), narrows things to: `sub_43FDE0`'s *other* branch (the `v10[*(a4+112)]==v11` early-exit / `dword_ADBFB8`-keyed structural-hash cache path) is being taken every time and failing every time - either the attribute-list assembly (`v42 = sub_478A18(...)`) comes back empty, or the `dword_ADBFB8` cache lookup never finds/creates a usable entry (`v43` stays 0). + +Separately confirmed this is specific to this procedural/variant-shader assembly path (`sub_567344`/`sub_5673F8`/`sub_43FDE0`) and not a general host-GLSL-compile problem: shader IDs 4-9, compiled earlier via a *different*, simpler path (real, hand-authored GLSL source visible via `Shim_glShaderSource`'s own dump - `attribute vec2 inVertexPosition; ... void main(void) { ... }`), compiled and linked successfully with no issues. + +**Status**: root cause is now narrowed to a specific, small function (`sub_43FDE0`'s `LABEL_23`/`dword_ADBFB8` cache-lookup branch) with a 100%, always-reproducible failure signature - a good, tightly-scoped target, but not yet fully traced to a single faulting instruction the way the earlier `0x569b40` crash was. This is a deep, multi-layer investigation (5 probes deep from the original crash) with clear, verified progress at each layer - a reasonable point to pause and decide whether to keep drilling into `sub_43FDE0`'s `dword_ADBFB8` cache path specifically, or take stock of the session so far. + +**2026-09-16: WayDroid detour concludes - real root cause found for WayDroid's OWN issue, but it does NOT explain our engine's crash.** + +Investigated whether the same game+OBB would work under a completely different, real ARM translation layer (libhoudini on WayDroid) as a sanity check ("куда подсмотреть с похожей трансляцией"). After extensive WayDroid environment troubleshooting (adb auth churn, OBB wiped by `pm uninstall`, storage permission grants, a full `/data` factory reset with user-provided root access), root-caused WayDroid's own stuck-at-splash-screen symptom precisely: + +- The game selects a texture-compression **SKU** at startup based on detected GPU capabilities (`AddSKU: ` / `Mounting SKU: to /published`). +- On the Galaxy A9 (real Adreno GPU): `AddSKU: texture_atc` → `splash_1500.sba` → correct, complete asset set, game runs. +- On WayDroid (Houdini + virtualized Mesa/desktop GPU): `AddSKU: texture_dxt3` → `splash_1775.sba` → a SKU variant that exists inside the byte-identical OBB (confirmed via MD5: `ff8e7b9fb7b7dabaf61aba7f5ba7dde0`, matching the A9's real `_mod` OBB exactly - there is no "wrong OBB"/"missing mod assets" issue, that earlier theory was disproven) but whose individual sprite entries are effectively empty/incomplete for this mobile-targeted build. +- This is a genuine, real bug - but it's specific to WayDroid's virtualized GPU reporting desktop-style compressed-texture extensions, unrelated to CPU/ISA translation (Houdini) at all. + +**Directly tested whether this same mechanism explains our own engine's crash - it does not.** Ran the real `_arm` build on the Xiaomi 14 (real Adreno GPU, same chip family as the A9) and confirmed via `MountArgProbeHookCb`'s own log: our engine also selects **`texture_atc`** (`Mount(source="/published.texture_atc", ...)`) - the correct, same-as-A9 SKU. This is expected since `Shim_glGetString` (`gles_shim.cpp`) is a pure, unfiltered passthrough to the real host GPU driver, and we're running on real mobile hardware, not a virtualized desktop-style GPU. **The WayDroid SKU-selection bug and our engine's `sub_43FDE0` crash are two unrelated problems that happened to surface during the same investigation.** + +Along the way, added a pthread_cond_wait investigation (`SequencerLockEntryProbeHookCb`/`SequencerLockSignalProbeHookCb` @ `sub_54BD54`'s entry/signal sites, `guest_engine.cpp`) after a ~2-minute apparent stall on the Xiaomi 14 looked like it might be a new deadlock. Turned out to be a false alarm: `sub_54BD54` is a generic turn-based sequencer lock used by a producer/consumer job queue (`sub_6A2178`), and the specific blocking waits observed were normal queue-empty sleep cycles, not a hang - the run simply needed more wall-clock time before reaching the same, already-diagnosed `sub_56962C`/`ZOMG`/`MEM FAULT` crash (confirmed via `ShaderVariantProbeHookCb`/`RenderCrashProbeHookCb` firing with the identical signature: `v26+16=0`, `v26+24=0`, `addr=0x20000200`). These two new probes remain in the codebase (low-overhead, address-pinned) but aren't currently pointing at anything actionable. + +**Status**: back to the original open question - why does `sub_43FDE0`'s cache/fast-path (`LABEL_23`, keyed through `dword_ADBFB8`) return 0 (fail) on every single call for this shader variant, on real hardware, under our translation layer specifically. Not yet re-approached after this detour. + +**2026-09-16: definitive native-vs-emulated comparison - the empty shader source NEVER happens on real hardware.** + +Per the user's suggestion, extended `trace_agent/libc_gles_trace.cpp` (the LD_PRELOAD-based ground-truth tracer for the real, unmodified `libapp.so` on the Galaxy A9) with `glShaderSource`/`glCompileShader`/`glLinkProgram` hooks - the same real-GL-status/source-dump diagnostics already added to our own `gles_shim.cpp` this session. Captured a full native run (94,787 logcat lines, real gameplay with thousands of `glDrawElements` calls and dozens of real shader compiles). + +**Result: zero `glCompileShader`/`glLinkProgram` failures, zero empty (`len=0`) shader sources, across the entire run.** Every shader - including the exact numbered slots (15, 16, etc.) that reliably fail with an empty source under our translation layer - compiles from real, substantial GLSL text (`"//VERTEX SHADER..."`, hundreds of bytes) and links successfully. This is not a "sometimes" condition on real hardware; it never occurs. + +This closes the open question from earlier in the day (does the same shader-variant-cache scenario happen on any real ARM translation, ruling out our engine specifically): it does not. The WayDroid/Houdini SKU-selection bug was real but unrelated (already established as a separate issue). This new native trace confirms, directly and unambiguously, that `sub_43FDE0`'s fast-path failure - and the resulting empty-buffer fallback via `dword_AE1A0C` - is a genuine bug specific to our own `GuestEngine`, not a property of the game itself under any circumstances. + +**This significantly re-scopes the remaining investigation.** The earlier register-tracing chain (a5 -> sub_5673F8's a3 -> sub_567344's a3 -> sub_56962C's v91, with contradictory readings at each hop - `RenderFlagsInjectionProbeHookCb` showed v91 stays 0 after its own internal vcall, yet `a5` was still observed as `0xaa8ab8` downstream) was still unresolved when this comparison was run. Given the bug is now conclusively confirmed to be engine-specific, the next productive step is almost certainly in `GuestEngine` itself (stack/register marshaling for multi-argument calls, `CallGuestFunction`'s handling of stack-passed arguments beyond the first 4, or a plain register-clobbering bug somewhere in the many shim/hook call sites between `sub_56962C` and `sub_43FDE0`) rather than further static tracing of the game's own ARM32 code, which real hardware has now proven behaves correctly. + +**2026-09-16: synthetic unit tests (user's idea) - found a real, reproducible deadlock in reentrant CallGuestFunction.** + +Per the user's suggestion ("не в код будем глядеть а сделаем что-то вроде юнит тестов" - stop reading code, write something like unit tests), built a small synthetic-test framework in `tcg_bench.cpp`/`tcg_bench.h` with a minimal hand-assembled Thumb/Thumb-2 encoder (`EmitPushR4toR7Lr`, `EmitMovsImm8`, `EmitStrImm5`, `EmitBlxReg`, `EmitMovWT`/`EmitLoadAddr32`, `EmitBxLr` - just the handful of encodings needed, each with its bit-layout spelled out in a comment). + +**Test 1 (`RunCalleeSavedRegisterTest`): PASS.** Sets r4-r7 to known sentinels, calls a stub registered via the EXACT same `AllocCodeStub`/`MiscStubDispatch` mechanism every real GLESv2/libc import shim uses (not a simplified stand-in), then verifies r4-r7 (AAPCS32 callee-saved) survived. All four registers came back intact - the basic import-stub dispatch mechanism does NOT clobber callee-saved registers in a single, flat, non-reentrant call. This rules out the "shim dispatch clobbers R4-R11" hypothesis this session was chasing via register-tracing through real game code. + +**Test 2 (`RunReentrantCallRegisterTest`): the stub callback itself calls `GuestEngine::CallGuestFunction()` reentrantly** (matching exactly what a JNI upcall - or any "shim needs to call back into guest code" - path does), one level deep, from a completely clean starting point. **Result: hangs indefinitely.** Confirmed live on the Pixel 6a - the outer test call (itself invoked from `LoadEmulatedLibapp`, not nested in anything else) never returns, never logs a result, never crashes; the process stays alive (GLES counter thread keeps ticking on a separate thread) but the calling thread is genuinely stuck with no progress for minutes. + +**Root cause, read directly from `CallGuestFunction`'s own implementation (`guest_engine.cpp`)**: the function calls `uc_emu_start(eng, pc, kCallReturnSentinel, 0, 5000000)` on the CURRENT thread's `uc_engine*` (line ~1839). If `CallGuestFunction` is invoked from within a `UC_HOOK_CODE` callback that is itself executing DURING an already-active `uc_emu_start()` call on the SAME engine (exactly what happens when a stub-dispatch callback reenters the guest), this calls `uc_emu_start()` reentrantly on the same `uc_engine*` - which this Unicorn build does not handle correctly, at least not in this configuration. The existing "reentrant call reuses the current live SP" logic (lines ~1757-1769) shows the code is AWARE reentrant calls happen and tries to handle the stack correctly, but the underlying `uc_emu_start()` reentrancy itself was never verified to actually work - this synthetic test is the first direct proof it does not (it hangs, rather than corrupting-and-continuing). + +**Important caveat**: this confirmed deadlock is NOT the same *symptom* as the `sub_43FDE0`/`ZOMG`/`MEM FAULT` crash this session has been chasing (that one crashes quickly, doesn't hang). Whether the real game code path between `sub_56962C` and `sub_43FDE0` ever actually triggers a reentrant `CallGuestFunction` call (would need to be confirmed - e.g. via a probe on `CallGuestFunction`'s own entry checking whether `t_state_`'s "already inside uc_emu_start" state is set) is not yet established. But this is a real, serious, independently-confirmed engine bug regardless of whether it's the SAME root cause as the shader-crash investigation - CallGuestFunction's reentrant path is used by real code (JNI upcalls, `Shim_pthread_once` per that function's own comment) and currently deadlocks instead of working correctly. + +**Next step**: (1) determine whether the real crash path actually triggers this exact mechanism (add a probe/assert in `CallGuestFunction` itself logging whenever it's invoked while `t_state_` shows an already-active call on this thread), and (2) fix the reentrancy - likely needs either a genuinely nested-uc_emu_start-safe pattern (if Unicorn supports one via a specific API/flag not currently used) or restructuring reentrant guest-into-guest calls to avoid a second `uc_emu_start()` on the same engine entirely (e.g., a trampoline/continuation approach). Given how many places in this codebase already call `CallGuestFunction` (`Shim_pthread_once`, the JNI reverse-bridge `TrampolineBodyWide`, this same synthetic test), this could be a significant, previously-invisible source of instability across the whole engine, not just this one investigation. + +**2026-09-16, later same day: reentrancy probe run on the REAL crash path - CONFIRMED it fires, and it correlates directly with the known `sub_56962C` MEM FAULT.** + +Per the user's direct instruction ("Ставь пробник в CallGuestFunction и гони на реальном пути"), added a log-once reentrancy probe directly in `CallGuestFunction` (`t_state_.callDepth`, incremented/decremented around the whole function body) that fires whenever a call is made while another `CallGuestFunction` is already active on the same thread - plus a follow-up classifier that resolves the reentrant call's target against every arena (`misc_stub_dispatch_table_` for import/JNI/facet stubs, `hook_registrations_` for trampoline-hooked addresses, and the plain image/heap/control/thread-stack/mmap boundaries) so the log identifies not just *that* a reentrant call happened but *what* it was calling. + +**First attempt was invalid**: `main.cpp` still unconditionally ran the previous session's synthetic benchmarks (`RunTcgBenchmarkInRealContext`, `RunCalleeSavedRegisterTest`, `RunReentrantCallRegisterTest`) at startup, before real gameplay ever begins. Since `RunReentrantCallRegisterTest` is the exact synthetic reentrant call already known to hang forever, it was deadlocking the main thread before the real game ever ran - the two "real path" launches that showed a 60,000+/sec REENTRANT log burst were re-discovering that same synthetic hang, not anything from real game code. Removed all three calls from `main.cpp` (their questions are already answered and documented above) so real gameplay could actually proceed. + +**With the synthetic tests removed, a genuine real-path hit was captured**, on the GL thread (`GLThread 415`, not the main thread): + +``` +19:17:49.929 CallGuestFunction(0x4b3aca9): REENTRANT call detected - depth=2 + target kind=[misc stub (JNI slot / facet slot / other)] detail=[cb=0x... userData=0x0] +``` + +Cross-referencing the call site pattern (userData=null, trivial no-op-style vtable slot) against the codebase, the most plausible source is `WriteCharToStreambuf` (`rtti_shims.cpp:411`): real guest code invokes a `libc++` facet's virtual method (e.g. `num_put`/`ctype`) through its vtable, which lands in a `FacetSlotDispatch`/similar `UC_HOOK_CODE` stub callback (already running *during* an active `uc_emu_start()`); that callback's C++ body then calls `eng.CallGuestFunction(overflowFn, sb, c)` to invoke the streambuf's real guest-code `overflow()` virtual method - a second, nested `uc_emu_start()` on the same engine, the exact mechanism `RunReentrantCallRegisterTest` proved hangs. + +**Except this time it did NOT hang** - checking `/proc//task//stat` a few seconds later showed the GL thread in state `S` (sleeping), `utime` unchanged, `stime` barely moving - i.e. it recovered and moved on, rather than spinning or blocking forever. This is an important refinement: reentrant `CallGuestFunction` does not deterministically hang every time - it's timing/state-dependent (plausibly depending on exactly what point in Unicorn's internal dispatch/translation-cache state the reentrant `uc_emu_start()` call lands on), sometimes hanging (the synthetic test's exact scenario) and sometimes "succeeding" but likely leaving corrupted CPU/host state behind rather than cleanly returning. + +**~16 seconds later, on the SAME thread (GLThread 415), the long-chased crash happened**: + +``` +19:18:05.600 GuestEngine: MEM FAULT READ_UNMAPPED guest_addr=0x20000200 size=4 + at guest PC=0x569b40 LR=0x569b5c SP=0x5b43c30 r0=0x6d r1=0x0 +19:18:05.600 CallGuestFunction(0x54e100): uc_emu_start returned 6 at guest PC=0x569b40 - marking engine crashed +``` + +Resolved via IDA: **`0x54e100` is `Java_com_ea_ironmonkey_RunLoop_nativeOnRunLoopTick`** (the per-frame JNI entry point driving the whole render loop), and **`0x569b40` is inside `sub_56962C`** - the exact top of this session's long-chased crash chain (`sub_56962C` -> `sub_567344` -> `sub_5673F8` -> `sub_43FDE0`, `ZOMG`/empty-shader-source territory). The faulting instruction, `LDR R2, [R1,R2,LSL#5]!`, is part of a data-structure walk keyed off **`dword_AE00D8`** - the SAME global this session's much-earlier (and previously inconclusive) register-tracing investigation had already flagged as suspicious (the disproven "`a5 = *dword_AE00D8`" hypothesis, and the watchpoint that showed only one write, value 0, before the crash window). `guest_addr=0x20000200` is far outside every known arena (image/heap/trampoline/import/misc-stub/control/thread-stack/mmap all top out under `0xeb45000` on this run) - a textbook wild-pointer dereference, not a legitimate out-of-bounds-by-a-little access. + +**This is now a well-evidenced (not yet 100%-proven) causal chain**: a reentrant `CallGuestFunction` call - reachable from real gameplay via the `libc++` facet/streambuf virtual-call pattern, on the GL thread - "succeeds" without hanging but plausibly leaves some piece of CPU or engine state corrupted (a register, or a value derived from one, that survives across many subsequent per-frame `nativeOnRunLoopTick` invocations); several frames later, `sub_56962C` reads that corrupted value (directly or via `dword_AE00D8`-relative arithmetic) as a pointer and dereferences a wild address, faulting. This directly answers the `/loop` task's question - **yes, the real crash path does exercise this exact reentrancy mechanism** - and gives the register-corruption mystery chased all session its first concrete, timing-correlated lead rather than a dead end. + +**Not yet proven**: exact causality (correlation across ~16 seconds and many frames is strong circumstantial evidence, not a smoking-gun single-step trace). The natural next step is a **new, separate investigation**: instrument the specific reentrant call site (`WriteCharToStreambuf`'s nested `CallGuestFunction`) to snapshot the full register file immediately before and after, and compare against what `sub_56962C`'s crash-site code expects, to confirm (or rule out) that this exact reentrant call is what corrupts the value `dword_AE00D8`-relative arithmetic later dereferences. This is a distinct, larger-scoped task from "does the probe fire on the real path" (now answered) and was not attempted in this session. + +**2026-09-16, later still: register-diff instrumentation on `WriteCharToStreambuf` - the leading hypothesis is REFUTED for this call site.** + +Per the user's follow-up instruction, added `LogRegSnapshotDiff` (`rtti_shims.cpp`) directly around `WriteCharToStreambuf`'s nested `eng.CallGuestFunction(overflowFn, sb, c)` call: snapshots all 17 core registers (r0-r12, sp, lr, pc, cpsr) via `uc_reg_read` immediately before and after, logs any that differ. + +Rebuilt, redeployed to the Pixel 6a (after an unrelated USB dropout mid-session and a stale-`ActivityResolver`-table issue post-reconnect that required a clean uninstall/reinstall to clear), and reproduced the exact same crash again (`MEM FAULT READ_UNMAPPED guest_addr=0x20000200` at guest `PC=0x569b40`, inside `sub_56962C`, ~4.4 seconds after the last streambuf write - the crash signature is 100% consistent across every reproduction this session). + +**Result: `WriteCharToStreambuf`'s nested call fired 8 times during this run (writing single ASCII digit characters - almost certainly a version string like "1.3.128" being formatted through the facet machinery), and every single one logged "reg diffs after nested CallGuestFunction: (none)".** The caller's own register file is completely unaffected by this specific reentrant call, every time, with no exceptions. + +**Mechanistic explanation, not just an empirical shrug**: cross-referencing `overflowFn`'s value (`0x4b3aca9`) against the arena classifier built for the previous probe shows this is the SAME address the earlier run's generic `CallGuestFunction`-entry reentrancy probe classified as `misc stub (JNI slot / facet slot / other)` - i.e. **`0x4b3aca9` is not real ARM32 guest code at all, it's one of our OWN host-side `AllocCodeStub` stubs** (almost certainly one of the trivial "return 0" placeholder slots this codebase builds for `libc++` facet vtable methods it doesn't fully implement - see this same file's `ctype`/`num_put` comments). So despite the `WriteCharToStreambuf` comment's claim of "call the REAL guest virtual `overflow()`", in this actual run it's calling one of our own no-op stand-ins, which just writes `R0=0` and returns via `BX LR` - a call with essentially nothing to corrupt. This explains the clean diffs mechanistically, not just as a lucky empirical result. + +**Also notable**: the generic `CallGuestFunction`-entry reentrancy probe (`t_state_.callDepth`-based, log-once via `reentrancy_logged_`) did **not** fire at all during this run, despite this same nested call happening 8 times - a discrepancy from the previous run (where it fired once, on the same target address, at the same point in the loading sequence). Not yet explained; possible causes not yet investigated: this activity (`ZipIndexProbe` entries #5502+, mid-way through loading `sounds/` files) may run under a different top-level call chain than last time (timing/ordering between loader threads is not guaranteed identical run-to-run), or there may be a gap in `callDepth` tracking for whichever driver function is on the stack at this specific point. Worth revisiting if the reentrancy angle is picked up again. + +**Conclusion: the leading hypothesis from the previous entry - that this specific `WriteCharToStreambuf` reentrant call is what corrupts the state `sub_56962C` later dereferences as a wild pointer - is REFUTED.** The reentrancy mechanism itself remains real and confirmed-reachable from gameplay (established two entries up), and the synthetic test still proves `CallGuestFunction` reentrancy CAN hang under the right conditions - but THIS specific, repeatedly-observed instance of it is provably harmless (trivial stub target, zero register impact, confirmed 8/8). The actual cause of `sub_56962C`'s wild `dword_AE00D8`-relative pointer read remains open. The register-corruption mystery chased all session does not yet have a confirmed mechanism - only a still-unexplained, no-longer-leading correlation (reentrancy fires on the GL thread at some point before the crash) and the original, still-true fact that real hardware never exhibits this failure at all (the native trace_agent comparison, documented earlier). Next steps, not yet attempted: look for OTHER reentrant call sites that might sit closer in time/frames to the actual crash (the log-once gate on the generic probe only ever shows the FIRST such call per process lifetime - removing that gate, at the cost of more log volume, would reveal every occurrence between load and crash, not just the first); or abandon the reentrancy angle and go back to tracing `dword_AE00D8`'s value and the loop-index arithmetic (`v54`/`v43`/`v45` in the `sub_56962C` decompilation) directly at the crash site instead. + +**2026-09-16, later still: removed the one-shot log gate - found TWO reentrant calls into REAL guest code, not just the harmless stub.** + +Per the user's direct instruction ("Убери one-shot gate и запусти снова" - remove the one-shot gate and run again), replaced `reentrancy_logged_` (a one-shot `atomic`) with `reentrancy_log_count_` (an `atomic`) plus a `kReentrancyLogCap = 200` ceiling in `guest_engine.h`/`.cpp` - logs every reentrant occurrence up to the cap (not just the first), with the cap kept as a safety net against a repeat of the earlier ~6000/sec synthetic-test-hang flood, not removed entirely. + +Rebuilt, redeployed to the Pixel 6a (device connectivity was fine this time), reproduced the identical crash again (`MEM FAULT READ_UNMAPPED guest_addr=0x20000200` at `PC=0x569b40` inside `sub_56962C` - the fourth independent reproduction this session with the exact same signature). **15 reentrant `CallGuestFunction` calls fired before the crash** (well under the 200 cap, so nothing was suppressed) - not 60,000/sec this time, since the earlier flood was the now-removed synthetic test's own infinite retry, not a real-path characteristic. + +Of the 15: **13 were the same already-refuted `WriteCharToStreambuf`/`0x4b3aca9` harmless no-op stub** (calls #1-5, #8-15, all `target kind=[misc stub...]`) - consistent with the previous entry. + +**But calls #6 and #7 were new and different: `target kind=[real image code]`, at `0x87b968` and `0x88ccd0` respectively - actual ARM32 guest functions, not host-side stubs.** Resolved via IDA: + +- `sub_87B968` (`0x87b968`): a tiny (0x20-byte) function, `sub_8BA55C(&unk_B13F2C, 0)` then stores the result to `dword_B13F30` - the classic shape of a pthread_once-guarded C++ function-local-static initializer (`__cxa_guard_acquire`/construct/`__cxa_guard_release` pattern, `sub_8BA55C` almost certainly being the constructor call). **`0x87b968` is the exact same address this session's earlier, already-`completed` task ("Find root cause of stack corruption in pthread_once init routine 0x87b968") investigated** - meaning this reentrant call, into this specific function, is not new; it was already known to be reachable via `pthread_once`, just not previously confirmed as a `CallGuestFunction` reentrancy case with this probe. +- `sub_88CCD0` (`0x88ccd0`): a similar but larger (0x58-byte) lazy-static-initializer pattern - constructs an object, registers an `atexit`-style cleanup (`sub_7433C()`) if construction succeeds, matches the same `pthread_once`/static-init shape. +- Both are referenced as **data** (not direct call instructions) from two large host functions (`sub_87C3D4`, `sub_88D014`) - i.e. their addresses are stored as function-pointer values in a `pthread_once_t`-style control structure and invoked *indirectly*, matching exactly how `Shim_pthread_once` (already flagged in this doc as a known `CallGuestFunction`-reentrancy caller) would reach them: real game code calls `pthread_once()`, which our shim intercepts and invokes the guest's real init routine via a *reentrant* `CallGuestFunction` call. + +**Why this matters more than the streambuf case**: unlike the trivial `R0=0`-and-return host stub, `sub_87B968`/`sub_88CCD0` are genuine ARM32 code that runs for real (calls further into `sub_8BA55C`, `sub_75030`, `sub_8BA488`, `sub_7433C`) - a call with real potential to touch the CPU register file, guest heap, and global state, executing *while an outer `uc_emu_start()` is still suspended mid-flight on the same engine*. This is a far more plausible corruption vector than the `WriteCharToStreambuf` case already ruled out, and it happens on the exact same thread (the GL thread, tid matching every crash reproduction this session) at `04:59:06.709` - about 14 seconds before the `04:59:20.427` crash, comparable in scale to the ~16-second gap observed in the earlier reproduction. + +**Not yet done**: the same register-diff technique already built and validated for `WriteCharToStreambuf` (`LogRegSnapshotDiff`) has not yet been applied to `Shim_pthread_once`'s own `CallGuestFunction` call site. Given `sub_87B968` is independently already a "known suspicious function" from an earlier, separately-completed investigation this session, and this call genuinely executes real guest code (unlike the ruled-out stub case), this is now the strongest concrete lead for the register-corruption mystery - a natural next step, but a distinct piece of instrumentation work not yet attempted. + +**2026-09-16, later still: `Shim_pthread_once` instrumented too - REFUTED as well. Both leading candidates now cleared.** + +Per the user's direct instruction ("Инструментируй Shim_pthread_once, сними регистры до/после вызова"), added the same register-diff technique (`LogPthreadOnceRegSnapshotDiff`, `libc_shims.cpp`) directly around `Shim_pthread_once`'s `eng.CallGuestFunction(initRoutine)` call - snapshots all 17 core registers before and after, logs any differences, wrapping the SAME log lines (`"pthread_once running init routine"`/`"...finished"`) that were already there from an earlier (2026-09-05) investigation into this exact function. + +Rebuilt, redeployed to the Pixel 6a, reproduced the identical crash a **fifth** time (`MEM FAULT READ_UNMAPPED guest_addr=0x20000200` at `PC=0x569b40` inside `sub_56962C` - signature unchanged across every single reproduction this session). Full timeline captured this run: + +- Calls #1-5 (05:24:35.583-.674): `WriteCharToStreambuf`/`0x4b3aca9` - the already-refuted harmless stub. +- **Calls #6-7 (05:24:36.735-.736): `Shim_pthread_once` invoking the real init routines - `0x87b968` and `0x88ccd0` - both logged `reg diffs after CallGuestFunction: (none)`.** Zero register impact on the caller, for both of the exact functions flagged as the strongest remaining lead in the previous entry. +- Calls #8-15 (05:24:45.988-.46.159): eight more `WriteCharToStreambuf` occurrences, same harmless pattern. +- 05:24:50.056: the crash, ~3.3 seconds after the last reentrant call and ~13.3 seconds after the `pthread_once` calls specifically - comparable timing to every prior reproduction. + +**Both leading hypotheses for the register-corruption mystery are now refuted.** Across two separate call sites, spanning a harmless host stub and two genuine, non-trivial guest init routines, every single reentrant `CallGuestFunction` invocation observed this session (23 total across the two most-instrumented runs) leaves the calling thread's own register file completely unchanged. `CallGuestFunction`'s own save/restore machinery (built specifically for the reentrant case, per its own long-standing comment) appears to genuinely hold up at the register level for every real-path call site tested so far - the earlier-proven "reentrancy CAN hang" finding (the synthetic test) and "reentrancy fires on the real path" finding (the generic probe) both still stand, but neither has yet been shown to corrupt anything a caller's registers would reveal. + +**This meaningfully re-scopes the investigation.** If the reentrancy angle is still worth pursuing, the corruption - if any - is not visible at the immediate caller's register boundary and would need a different kind of check: e.g. hashing/snapshotting relevant GUEST MEMORY (the specific globals `sub_56962C` reads, especially `dword_AE00D8` and whatever `v54`/`v43`/`v45`'s source data is) immediately before and after each reentrant call, rather than registers - corruption could just as easily be a stray heap/global write inside `sub_87B968`/`sub_88CCD0`'s own body (real code, calling `sub_8BA55C`/`sub_75030`/`sub_8BA488`/`sub_7433C`, any of which could write somewhere unexpected) that only manifests when unrelated code reads that same memory much later. Alternatively, the reentrancy angle itself may simply be an unrelated, real-but-tangential bug (it demonstrably exists and can hang, per the synthetic test) that happens to co-occur with the `sub_56962C` crash without being its cause - in which case the productive path forward is to drop the reentrancy angle and go back to directly tracing `dword_AE00D8`'s value and the loop-index arithmetic at the crash site itself, as suggested two entries up. Neither has been attempted yet. + +**2026-09-16, later still: memory-window check around `dword_AE00D8` - ALSO clean. The reentrancy angle, for every call site found so far, is now exhausted.** + +Per the user's direct instruction ("Проверь память вокруг dword_AE00D8 до и после вызова"), added a targeted guest-memory snapshot/diff (`SnapshotAE00D8Window`/`LogAE00D8WindowDiff`, duplicated in both `rtti_shims.cpp` and `libc_shims.cpp`) covering `[0xae00d8, 0xae0108)` - a 48-byte window chosen from `sub_56962C`'s own decompilation, since the crash-site code reads `dword_AE00D8`, `dword_AE00DC`, `dword_AE00E0`, and (further down the same function) `dword_AE00FC` **directly as fixed-offset global data, not through any pointer `dword_AE00D8` itself holds** - i.e. this is a small cluster of plain globals sitting contiguously in `.bss`/`.data`, not a heap object reached via indirection. Wired into both existing register-diff probes (`WriteCharToStreambuf` and `Shim_pthread_once`), snapshotting this window alongside the registers at the exact same before/after points. + +Rebuilt, redeployed, reproduced the crash a **sixth** time (identical signature, as always). **All 15 reentrant calls this run - the same mix of the harmless `WriteCharToStreambuf` stub and the two real `Shim_pthread_once` init routines - logged `dword_AE00D8 window [0xae00d8,0xae0108) diffs: (none)`.** Zero byte changed in this window across every single reentrant call observed. + +**Combined with the register-diff results, this is now a triple-negative finding**: for every reentrant `CallGuestFunction` call site discovered this session (the harmless stub AND both real `pthread_once` init routines), neither the caller's own register file NOR this specific memory window shows any change whatsoever, across two independent full runs. The reentrancy angle - for every call site this probe has been able to find - is now thoroughly exhausted as an explanation for `sub_56962C`'s wild-pointer crash. Two possibilities remain: (1) there's a reentrant call site this probe hasn't caught yet (the probe only catches calls that go through `CallGuestFunction` itself - a corruption mechanism outside that boundary, e.g. a genuine ARM32 semantic bug in the translated code's own arithmetic, or a bug in Unicorn's instruction emulation itself, wouldn't show up here at all), or (2) the reentrancy findings (real, demonstrated to hang under the synthetic test, demonstrated to fire on the real path) are simply unrelated to this specific crash - two true-but-separate facts about the engine that happened to co-occur in time during every reproduction. **Recommended next step: abandon the reentrancy angle for this specific crash and trace `dword_AE00D8`'s actual value and the `v54`/`v43`/`v45` loop-index arithmetic directly at the `sub_56962C` crash site instead** (a `UC_HOOK_CODE` probe at `0x569b40` itself, logging `R1`/`R2` and the computed effective address on every hit, would show exactly what value produces `0x20000200` and where it comes from - a much more direct approach than continuing to chase reentrancy call sites that keep coming back clean). + +**2026-09-16, later still: native ground-truth confirmation on the Galaxy A9 - `pthread_once` touches the SAME `dword_AE00D8` window, and it's ALSO clean there. Real hardware corroborates the emulated engine's own (clean) finding.** + +Per the user's request ("расширь trace_agent" - extend trace_agent), extended `trace_agent/libc_gles_trace.cpp` with a `pthread_once()` interposer mirroring the emulated engine's own probe: resolves the real, live ASLR base of the native `libapp.so` (`LibappBase()`, via `dl_iterate_phdr`, since this agent runs inside the real process and needs no guest/host address translation at all), and snapshots the identical `[0xae00d8, 0xae0108)` memory window immediately before/after each real `pthread_once()` call. + +**This surfaced a genuinely deep, three-layer deployment/bootstrap rabbit hole**, each layer confirmed via a live tombstone before being fixed, not guessed: + +1. **Deployment mechanism itself didn't work at all initially.** The documented `wrap.` loose-file mechanism (`/data/local/tmp/wrap.com.ea.games.nfs13_arm`) was confirmed - via a canary marker file the script was supposed to `touch`, which never appeared across many relaunches, with and without root, with SELinux Enforcing and an attempted (Knox-blocked - `setenforce 0` silently no-ops as root on this Samsung device, matching this session's already-known Knox quirks) Permissive switch - to simply never be invoked by zygote on this specific Samsung/Knox build. Switched to the OTHER officially-documented NDK mechanism instead: a `wrap.sh` bundled inside the APK's own `lib/armeabi-v7a/` directory (`app/src/main/jniLibs/armeabi-v7a/wrap.sh`, new file). AGP's own native-library merge/strip pipeline turned out to silently drop any non-`*.so` file from that directory (confirmed: present after `mergeNative32DebugJniLibFolders`, gone after `stripNative32DebugDebugSymbols`) - worked around by hand-injecting `wrap.sh` into the built APK via `zip`/`zipalign`/`apksigner` directly. That produced `INSTALL_FAILED_INVALID_APK: Failed to extract native libraries, res=-2` under the default `extractNativeLibs=false` packaging (PackageManager validates every `lib//` entry as a loadable library when it plans to `mmap` straight from the APK, and a plain shell script fails that check) - fixed properly, in `build.gradle.kts`, by switching the `native32` flavor specifically to `packaging.jniLibs.useLegacyPackaging = true` (extracted-to-disk libraries, no longer strictly validated at install time). + +2. **Once `wrap.sh` genuinely activated** (confirmed: `open()`/`glShaderSource()`/etc. all started firing, vs. zero hits under the earlier `System.load()`-only activation, which - now confirmed directly - never gave `libc_gles_trace.cpp`'s interposed libc/GLES symbols real process-wide visibility at all, only `jni_trace.cpp`'s separate `JNINativeInterface`-table-patching mechanism worked before this), a NEW crash appeared: a tombstone resolved (`llvm-addr2line`) to `pthread_once` itself, called from `__emutls_get_address`. This toolchain/ABI implements C++'s thread-safe function-local-static initialization via emulated TLS, which internally calls `pthread_once()` to lazily set up the TLS key - since we're now LD_PRELOAD'd from `exec()` itself (not lazily via `System.load()` well after zygote fork, as before), that internal call gets intercepted by our OWN wrapper, which then tries to lazily-initialize its OWN `static auto real = RealSym<...>(...)` the exact same way - infinite self-recursion, stack overflow, on literally the first call. Fixed by resolving the real `pthread_once` pointer eagerly, via a genuine ELF constructor (`__attribute__((constructor))`) into a plain global, sidestepping the lazy-static-guard machinery entirely for that one symbol. + +3. **One level deeper still**: with (2) fixed, a further tombstone showed the SAME recursion shifted one frame down - `pthread_once`'s own diagnostic body calling `LOGI(...)`/`TraceLog()`, which touches `trace_log.h`'s `InTraceLog()` reentrancy guard, a genuinely `thread_local` variable needing its OWN one-time emutls/pthread_once setup the first time ANY thread reaches it - which nests back into our same wrapper. Fixed with a reentrancy depth guard that itself cannot need emutls: a plain namespace-scope `std::atomic` with constant (not lazy/guarded) initialization, skipping all diagnostic work entirely on any nested/reentrant call. (`PastBootstrap()`'s own flag was hardened the same way, from a lazy function-local static to a namespace-scope atomic, out of caution rather than a confirmed second failure there.) + +**A fourth, non-crashing bug** surfaced once the agent was finally stable: `LibappBase()` cached its `dl_iterate_phdr` result on the very FIRST call, including a "not found" result - and the first several `pthread_once()` calls (other libraries' own early static init, well before `libapp.so` itself gets `dlopen`'d) permanently poisoned that cache with `base=0`, so every SUBSEQUENT `pthread_once` log line - including real gameplay well after `glClear()` was already firing every frame - kept reporting "base unresolved" and never actually checked the `dword_AE00D8` window for calls into real game code. Fixed by only caching a successful resolution, retrying on every call until `libapp.so` is actually found. + +**With all four fixed, the real, native, unmodified `pthread_once()` call chain finally reached both target functions** - `libapp.so+0x87b968` (`sub_87B968`) and `libapp.so+0x88ccd0` (`sub_88CCD0`), the exact same pthread_once-guarded static initializers this session's emulated-engine probe already flagged - and **every single one logged `dword_AE00D8 window [0xae00d8,0xae0108) diffs: (none)`**. Real native hardware confirms exactly what the emulated engine's own probe already found: `pthread_once` calling these two specific functions does not touch this memory window, at all, ever. Process remained fully stable throughout (no crash, no hang, real gameplay - `glClear` per frame, later interactive racing confirmed via screenshot). + +**This is now a genuinely well-corroborated, cross-platform negative result, not just a single engine's self-report.** Two independent execution environments (Unicorn-emulated ARM64 host and real, unmodified ARM32 hardware), running the exact same compiled `libapp.so` (MD5-confirmed identical), both show `pthread_once`'s calls into `sub_87B968`/`sub_88CCD0` leaving `dword_AE00D8`'s memory window completely untouched. The reentrancy/`pthread_once` angle for this specific crash is about as thoroughly ruled out as this investigation can make it. The `0x569b40`-probe approach recommended in the previous entry (trace `R1`/`R2` and the computed effective address directly at `sub_56962C`'s own faulting instruction) remains the clear next step - not yet attempted. + +**2026-09-16, later still: ran the `0x569b40` probe (already built, `RenderCrashProbeHookCb` - turns out to have existed since 2026-09-07) - it closes the loop, and confirms today's whole reentrancy detour was chasing an unrelated tangent.** + +Per the user's direct instruction ("Поставь пробник на 0x569b40, гони ещё раз"), went to add a `UC_HOOK_CODE` probe at the crash site - and found one already exists (`RenderCrashProbeHookCb` @ `0x569b38`, `guest_engine.cpp`, installed since the original 2026-09-07 `sub_56962C` investigation, still wired in). Rebuilt (no code changes needed) and reran on the Pixel 6a: + +``` +RenderCrashProbe: at loc_569B38: R1=0x0 R2=0x0 ... (harmless earlier iteration) +RenderCrashProbe: at loc_569B38: R1=0x0 R2=0x1000010 R3=0x0 R4=0x1 + SP+var_5C=0x5b43c50 [SP+var_5C]=0x4359750(ok) [that+0x2C]=0x0(ok) + -> about to fault at 0x569b40 with addr=R1+R2*32=0x20000200 +``` + +**This exactly reproduces the identical R1=0/R2=0x1000010/addr=0x20000200 signature already root-caused in the 2026-09-07 entries above** (see `R1 (base, null in the crash) = *(v84 + 44) where v84 = v26[2], and v26 = sub_567344(...)` - a shader-variant cache-entry lookup). The new information here is the enriched breakdown: `[SP+var_5C] = 0x4359750` (i.e. `v84`) is itself a **valid, non-null, plausible guest pointer** - not corrupted, not garbage. It's specifically the field 44 bytes (`0x2C`) INTO that otherwise-healthy object that's null. This matches a genuinely uninitialized/never-populated field, not a wild pointer or stray overwrite - exactly the shape this session's 2026-09-07 entries already predicted and traced to `sub_567344`'s `v26` cache entry (whose own offset+16 and offset+24 fields were separately already confirmed zero, downstream of `sub_43FDE0`'s cache-lookup fast path failing on literally every call that entire day). + +**Conclusion: this is not a new bug, and it is not connected to today's reentrancy investigation.** The R1=0/R2=0x1000010 crash signature is 100% consistent across every single reproduction this session (at least six separate times, spanning multiple builds, multiple devices, before AND after every reentrancy-related code change made today) - it was never affected by any of today's `WriteCharToStreambuf`/`Shim_pthread_once`/`dword_AE00D8`-window changes, because it isn't caused by them. Today's whole reentrancy arc (proving `CallGuestFunction` reentrancy is real, can hang under a synthetic test, fires on the real path via two distinct call sites, and - now cross-platform-confirmed on real hardware too - never touches this specific memory window or any caller's registers) stands as real, valuable, and now-closed engine-robustness work in its own right - but it was a tangent from this specific crash, not its cause. + +**The actual next step was already identified on 2026-09-07 and was never actually acted on**: `v26[2]`'s own `+0x2C` field being null is a THIRD, previously-unnoticed symptom of the same failure already traced to `sub_567344`'s cache lookup (via `sub_43FDE0`, which the 2026-09-07 entries already showed fails 100% of the time, returning R0=0 on every single call during the crash-reproduction window - "10/10 calls returned R0=0 - sub_43FDE0 never once succeeds"). The productive path forward is picking that investigation back up exactly where it was left off: `sub_43FDE0`'s `LABEL_23`/`dword_ADBFB8`-keyed structural-hash cache-lookup branch, and specifically `sub_478A18`'s attribute-list assembly (mentioned in that entry as the next unexamined step) - not anything in the `CallGuestFunction`/`pthread_once`/reentrancy space, which today's work has now thoroughly exonerated. + +**2026-09-16, final entry: dug into `sub_43FDE0`/`sub_478A18` as directed ("Копай sub_43FDE0 и sub_478A18, гони ещё раз") - found the exact stuck field, traced it to its writer, and it connects directly back to the empty-shader-source root cause from 2026-09-07. The full causal chain from root cause to crash symptom is now closed.** + +Static analysis first: decompiled both functions fresh. **`sub_478A18` (the "attribute-list assembly" flagged as the next unexamined step) turns out to be a red herring - it has no failure path at all.** Every branch through it returns a valid, non-null, freshly-`malloc`'d cache-entry object (`v22`); the only way it could return null is `malloc` itself failing, which isn't even checked. So `sub_43FDE0`'s `v42 = sub_478A18(...)` should almost always be non-zero - the actual "always returns 0" behavior has to come from somewhere else in `sub_43FDE0`'s own tail logic. + +Traced that tail logic (`0x440400`-`0x440498` in the real disasm) down to exactly three checkpoints, each independently able to force the eventual `return 0`: +1. `0x440424` (`CMP R4,#0`): is `v42` (from `sub_478A18`) itself zero? +2. `0x440470` (`CMP R0,R2`): did `sub_440508` find an EXISTING entry in the `dword_ADBFB8` hash table for this exact signature, or is this the first time? +3. `0x440480` (`CMP R0,#0`): is `v43` - a field read through TWO levels of indirection off whatever entry was found - itself zero? Only a non-zero value here reaches the function's one and only `return 1`. + +Added `AttrCacheProbeHookCb` (`guest_engine.cpp`, three single-address `UC_HOOK_CODE` hooks, one per checkpoint) and reran on the Pixel 6a. **Empirical result, 10 calls captured**: +- Checkpoint 1: **`v42` was non-zero on all 10/10 calls** - confirms `sub_478A18` never fails, exactly as the static analysis predicted. +- Checkpoint 2: split roughly 50/50 between "not found" (first time seeing this signature - unremarkable) and "found" (5/10 calls, all sharing the same `v42=0x4359718`/`found=0x4359abc` pair - i.e. genuinely the SAME cache entry, hit repeatedly). +- Checkpoint 3: **every single one of those 5 "found" hits showed `v43=0`.** Not once did a repeat lookup for an already-cached signature see a non-zero value. This is the actual, concrete failure point - not `sub_478A18`, not the cache-lookup mechanism itself (which works correctly, finding the SAME entry every time), but this one specific field that's supposed to get populated after the entry is created and never does. + +**Traced who's actually supposed to write that field - and it's not inside `sub_43FDE0` at all.** `sub_441D80` (which creates the cache entry on first sight) sets its own internal fields to 0, and `sub_43FDE0` immediately afterward stores `v23` (its own `a1` parameter - the calling context) into the entry's forward-reference slot. So a later lookup's `v43 = *(_DWORD*)(v50[4]+16)` actually reads `*(_DWORD*)(a1_original + 16)` - offset+16 of whatever context object FIRST created this cache entry. `sub_43FDE0`'s own body never writes that offset itself (only *reads* it, to decide success). Checked `sub_43FDE0`'s only caller, `sub_5673F8` (already read in full back on 2026-09-07, but not with this specific field in mind) - and there it is: when `sub_43FDE0` returns 0, `sub_5673F8` falls through to the REAL, synchronous vertex+fragment shader compile-and-link pipeline (`sub_550FD0` with `GL_VERTEX_SHADER`/`GL_FRAGMENT_SHADER`, `sub_550BD4` for linking) - and **only if that real compile+link succeeds** (`v30` truthy) does it write `*(_DWORD*)(v35 + 16) = v31;` where `v35 = a1` - exactly the field `sub_43FDE0` reads back later as `v43`. + +**This closes the loop all the way back to the 2026-09-07 findings.** `sub_5673F8`'s real compile step is EXACTLY the one already shown, that same day, to fail on the real host GLSL compiler with `"Missing main() function for shader"` - because the shader source text it assembles comes out empty (the `dword_AE1A0C` near-empty-sentinel fallback, `v13 < 2` check). Since that real compile legitimately fails, `v30` stays false, `*(a1+16)` never gets written, and the field this session's fresh probe caught reading 0 was *never going to become non-zero* - not due to any engine bug in the cache mechanism itself, but as a direct, correctly-behaving downstream consequence of the shader source coming out empty in the first place. + +**The full causal chain, now traced end-to-end with live data at every link**: +1. Something upstream produces an empty/near-empty shader source string for this specific shader variant (root cause - still not found; `sub_46FD30`, called by `sub_5673F8` right after `sub_43FDE0` fails, is the function that actually assembles this string - not yet instrumented, and NOT the same as the "missing vertex attribute" path already ruled out via `ShaderMissingAttrProbeHookCb` never firing). +2. `sub_5673F8`'s real compile-and-link legitimately fails against that empty source (`"Missing main() function"` - real host GLSL compiler, already confirmed 2026-09-07). +3. Because it fails, `*(a1+16)` is never written. +4. Every later `sub_43FDE0` cache lookup for the same shader-variant signature reads that same permanently-zero field (`v43`, confirmed live today, 5/5 repeat hits) and returns 0 ("sub_43FDE0 never once succeeds" - already known 2026-09-07, now mechanistically explained). +5. Deep inside `sub_56962C`, code that depends on a successfully-populated shader-variant object (`v26[2]`, whose own `+0x2C` field traces back through this same chain - see the entry above) reads a null base pointer, computes a wild address with a garbage index, and faults (`MEM FAULT READ_UNMAPPED 0x20000200`). + +**Next step, clearly scoped for the first time**: instrument `sub_46FD30` (`sub_5673F8`'s shader-source-assembly call, `0x5674c8` in the real disasm) to find out WHY the string it builds comes out empty - this is the one remaining unexplained link in an otherwise now-fully-traced chain from root cause to crash. + +**2026-09-16, one more entry: probed `sub_46FD30` as directed - the result string pointer IS the `dword_AE1A0C` sentinel itself, deterministically, every single call.** + +`sub_46FD30` turned out to be a thin 3-instruction wrapper (`*a1 = a3; sub_46FD58(); return sub_4702D8(a1, a2);`) - the real work is in `sub_4702D8`, a genuine C++ `std::ostringstream`-based text builder that writes `"//FRAGMENT SHADER\n//===========\n\n"`, conditionally `"#extension GL_EXT_shader_framebuffer_fetch : require\n"` / `"precision highp float;\n"`, `"void main()\n{\n"`, then one `"\t;\n"` line per entry in an attribute-expression range (`a1[71]`..`a1[72]`), and finally `"}\n"` - a real, substantial boilerplate that should never legitimately come out near-empty even if the per-line loop body is skipped. + +Found the exact stack layout in the real disasm (`sub_5673F8` @ `0x5674c8`-`0x5674cc`, right after `BL sub_46FD30`): the resulting string pointer lands at `[SP+0x124]`, immediately following the 184-byte `v45` builder-object buffer - i.e. a field of that same object, not a separate local. Added `ShaderSourceAssemblyProbeHookCb` (`guest_engine.cpp`, single `UC_HOOK_CODE` hook at `0x5674cc`) to dump that pointer plus up to 200 bytes of its content, right after `sub_46FD30` returns. + +**Result, every single hit (10/10) identical**: `result string ptr=0xae1a0c len>=0 content=""`. The pointer isn't just pointing at an empty-looking buffer - it's the literal address of `dword_AE1A0C`, the same near-empty sentinel global this session already identified back on 2026-09-07 as the fallback substituted when an assembled shader source comes out shorter than 2 characters. This means the empty-string condition isn't happening later, downstream of a normally-built (but coincidentally short) string - `sub_46FD30`/`sub_4702D8` itself is *already* falling into its own internal "too short, substitute the sentinel" branch, on every single call, deterministically (not data/timing-dependent). + +**This narrows the search further but doesn't yet close it.** Given the boilerplate text alone (`"//FRAGMENT SHADER\n...\nvoid main()\n{\n"` + `"}\n"`) is well over 2 characters regardless of whether the per-attribute-line loop ever executes, `sub_4702D8` substituting the sentinel on every call suggests something upstream of the boilerplate write itself is short-circuiting - most likely `sub_470830`/`sub_471878` (the two calls between `"void main()\n{\n"` being written and the per-line loop reading `a1[71]`/`a1[72]`, not yet examined) failing in a way that empties or invalidates the stream/object entirely, rather than the per-line loop specifically producing nothing. Also still open: whether this is genuinely internal to `sub_4702D8`, or whether the object `a1` (`v45`) it operates on was ALREADY in a bad state coming in from `sub_46E8BC`/`sub_46F530` (called earlier in `sub_5673F8`, before `sub_43FDE0`) - not yet distinguished. **Next step**: instrument `sub_470830`/`sub_471878` (or step back further and check `a1[71]`/`a1[72]` themselves right before the per-line loop) to find the actual point where the stream/object ends up empty. + +**2026-09-16, one more entry: probed `sub_470830`/`sub_471878` as directed - "sections skipped" is DEFINITIVELY ruled out. Real content is being written to the stream, yet the final extracted string is still empty. The bug is in extraction, not assembly.** + +Decompiled both functions fully. Confirmed both are gated behind a null-check on one of their own fields (`a1[21]`/offset `0x54` for `sub_470830`'s `"//Uniforms"` section, `a1[57]`/offset `0xE4` for `sub_471878`'s `"//Varying"` section) - a null field makes either function a complete no-op, not even writing its own section header. Found the exact `CMP`/`BEQ` decision point in each in the real disasm (`sub_470830` @ `0x470844`-`0x470848`, `sub_471878` @ `0x47188c`-`0x471890`) and added `ShaderSectionProbeHookCb` (`guest_engine.cpp`, two single-address `UC_HOOK_CODE` hooks) to log the field value at each. + +Reran on the Pixel 6a, this time with the earlier `ShaderSourceAssemblyProbeHookCb` still active too, so both sets of hits could be correlated directly. **Result**: `a1[21]` (uniforms) was **non-zero on every single hit** (values `1`, `2`, `3` across different calls) - `sub_470830` genuinely writes its `"//Uniforms\n//========\n"` section plus real per-uniform content every time, never a no-op. `a1[57]` (varying) DID vary - non-zero on roughly half the calls, zero (no-op) on the other half - but even on calls where `sub_471878` was a confirmed no-op, `sub_470830` had still written real content moments earlier, and the boilerplate (`"//FRAGMENT SHADER..."`, `"void main()\n{\n"`) was written before either of them. **Yet the very same call, correlated by timestamp, still produced `ShaderSourceAssemblyProbe`'s `result string ptr=0xae1a0c content=""`** - the dword_AE1A0C sentinel, every time, regardless of how much real content demonstrably went into the stream first. + +**This conclusively rules out "a section got skipped" as the cause.** The stream genuinely accumulates real, non-trivial text (confirmed live, not assumed) - the empty result has to come from somewhere AFTER all the writing is done: specifically, `sub_4702D8`'s own extraction step. Found the exact call site in the real disasm: `BL sub_27160C` @ `0x4706d0` (R0 = address of a local `std::string`-shaped object on the stack - a 1-byte SSO flag plus a 2-word short/long buffer pair, matching libc++'s small-string-optimization layout; R1 = R10, the persistent register holding the actual `std::ostringstream`/`ostream` object used for every `sub_79CD4` write throughout the function) - almost certainly this codebase's own `ostringstream::str()`-equivalent. **Next step, now fully concrete**: probe `sub_27160C` itself, or at minimum hook right after it returns (`0x4706d4`) and dump the resulting SSO-string's flag/length/content, to determine whether the extraction call itself is what's producing the empty result (a bug in `sub_27160C` or in whatever underlying stream-buffer mechanism it reads from), or whether it correctly extracts non-empty content that gets discarded somewhere in the remaining tail copy logic (`memmove` into `a1[50]`/`a1[51]`) instead. + +**2026-09-16, methodology pivot and final entry for this investigation: isolated, standalone `std::ostringstream` repro built and run - PASS, twice, confound-free. This is a general-vs-specific bug question this session's whole day of `sub_43FDE0`/`sub_4702D8`/`sub_27160C` address-chasing could never have answered by itself, and now has a definitive answer: NOT a general GuestEngine bug.** + +Per the user's explicit, direct pushback on continuing to probe more hardcoded `libapp.so` addresses ("ты сейчас пытаешься подогнать эмуляцию к одному единственному бинарнику, это не правильный подход" - you're trying to fit the emulation to one single binary, that's the wrong approach): even a fully successful `sub_27160C` probe would only ever have told us where in ONE game's ONE binary the empty string appears - never whether this is a property of GuestEngine's own C++-runtime translation (which would matter for any future app built on this engine) or something narrower. Built a real, independent, standalone repro instead, isolating the exact write-then-extract shape from every other line of game code. + +**What got built:** + +1. `ostream_repro/` (new top-level directory, sibling to `trace_agent/` - same "standalone armeabi-v7a artifact, built via its own `build.sh` driving the NDK's CMake toolchain directly, deliberately NOT wired into the main Gradle build" pattern `trace_agent/` already established). `ostream_repro.cpp` exports one `extern "C"` entry point, `TestOstreamAssembly(char* outBuf, int outBufSize)`, that constructs a `std::ostringstream`, writes 4 SEPARATE string literals via `<<` (`"//FRAGMENT SHADER\n"`, `"//===========\n\n"`, `"void main()\n{\n"`, `"}\n"` - deliberately several distinct writes before one extraction, matching the real game's own `sub_4702D8` pattern rather than a single combined write), calls `.str()`, and writes the resulting length + content into the caller-supplied buffer. + +2. **Build-flag investigation, done empirically rather than guessed**: first built with the NDK CMake toolchain's plain defaults, which turned out to be `ANDROID_STL=c++_static` (confirmed by directly reading `android.toolchain.cmake` - NOT `c++_shared` as initially assumed) - that build had ZERO libc++-internal undefined symbols (`llvm-readelf --dyn-syms`, only bionic libc functions), meaning it tests a completely self-contained code path unlike libapp.so's own dynamic-libc++_shared linkage (evidenced by `rtti_shims.cpp`'s own `RegisterRttiImportShims` list of hand-shimmed `_ZNSt6__ndk1*` symbols - locale, ios_base, ctype, num_put, `__shared_weak_count`, `std::mutex`). Rebuilt with `-DANDROID_STL=c++_shared` explicitly to match - but this pulled `basic_stringbuf`/`basic_ostream`/`basic_ios`/`basic_ostringstream`'s own VTABLES AND CONSTRUCTORS in as undefined imports (this NDK's libc++ headers `extern template`-declare them), which GuestEngine has zero shims for - would have made the test fail for an uninteresting, unrelated reason (unimplemented-vtable-slot stubs returning 0), not a real signal. Settled on `ANDROID_STL=c++_static` as the better match: it compiles `basic_stringbuf`'s own write/extract logic directly into the test `.so`'s own `.text`, the same character libapp.so's own `sub_79CD4`/`sub_27160C` evidently have (real, compiled, fixed-address code, never external imports for THOSE specific functions - confirmed by this session's own IDA work above). `ostream_repro/build.sh` documents this reasoning inline. + +3. **`GuestEngine::LoadSecondaryImage(path, entrySymbol)`** (new public method, `guest_engine.h`/`.cpp`) - a second, independent ELF32 loader, NOT a second call to `LoadImage`/`MapSegments` (which is a genuinely single-image design: `host_region_` is one `mmap` sized around exactly one image's own `image_end_`, and depends on that image's preferred `ET_DYN` base being guest address 0 - calling it twice would re-`mmap` the buffer out from under the already-loaded `libapp.so`, losing its heap/hooks/relocations entirely). Instead, carves space for the WHOLE second image out of the existing `AllocMmap()` arena (already part of the same `host_region_` every engine thread maps - 32 MiB, mostly unused since real `Shim_mmap()` guest calls are rare) and repeats `MapSegments`/`ProcessRelocations`' own logic with a REAL, non-zero bias added to every relocation - the primary loader's `R_ARM_RELATIVE` no-op shortcut only works because its own bias is always exactly 0 (see `ProcessRelocations`' own comment); this one actually adds `base` to each `R_ARM_RELATIVE` target and to every locally-defined symbol's `st_value`. External symbol references resolve through the EXACT SAME `ResolveOrCreateImportStub`/`RegisterImportShim` table `libapp.so`'s own imports already use - no new engine-side shim plumbing needed by construction. The exported entry symbol is located via the file's own section headers (`.dynsym`/`.dynstr` - survive a normal `strip`, unlike `.symtab`, and give a real symbol COUNT that `DT_SYMTAB` alone doesn't carry). Confirmed live: `GuestEngine::LoadSecondaryImage: .rel.dyn: 3303 entries, 3 import(s) resolved to stubs, 1389 RELATIVE biased by 0xcb45000, 0 unknown reloc type` / `.rel.plt: 345 entries, 105 import(s) resolved to stubs` / `loaded .../ostream_repro.so at base=0xcb45000 span=0x94000 - entry 'TestOstreamAssembly'=0xcb80825`. + +4. **`emu/ostream_repro_test.h`/`.cpp`** (new files, added to `CMakeLists.txt`) - the driver: calls `LoadSecondaryImage`, allocates a small result buffer via `AllocPermanent`, calls the entry point via `CallGuestFunction`, and compares the extracted content byte-for-byte against the known-correct literal, logging an unambiguous PASS/FAIL under tag `OSTREAM_REPRO`. Wired into `LoadEmulatedLibapp` (`main.cpp`) for one test run, deployed via `adb push` to `/data/local/tmp/` then `run-as com.ea.games.nfs13_arm cp ... files/ostream_repro.so` (this app's own internal files dir - same directory `GameActivityMain.kt`'s asset-extraction path already uses, just for a file this build never bundles as an asset itself). + +**First run surfaced a real, previously-invisible engine gap - not the bug under test, but a genuine confound that had to be fixed before the result could be trusted**: `GuestEngine: unresolved import 'newlocale' called...` and `GuestEngine: unresolved import '__memcpy_chk' called...`. `__memcpy_chk` (bionic's `_FORTIFY_SOURCE=2` wrapper clang emits for `memcpy` whenever it can prove the destination's compile-time size - a LIBC symbol, never previously needed because every earlier engine investigation only ever called ALREADY-compiled, ALREADY-relocated code inside `libapp.so` itself, never linked a fresh bionic-facing binary from scratch) being unresolved meant every one of `TestOstreamAssembly`'s own `memcpy()` calls into the result buffer silently no-op'd (the generic "unresolved import, return 0" fallback doesn't copy anything) - the result buffer stayed all-zero, which LOOKED EXACTLY like a genuine extraction-returns-empty bug (`len=0`) until traced back to the missing shim. The one piece of evidence that gave this away as a harness gap rather than a real repro: `retVal=49` (the function's own `return len;`, via R0, never goes through `memcpy` at all) was ALREADY CORRECT even in this broken first run - proving `oss.str()`'s length computation itself worked; only the content-copy-out was silently eaten. Added `Shim_memcpy_chk` (real bounds-aware memcpy, logs rather than aborting on an overflow) plus minimal `newlocale`/`uselocale`/`freelocale` bionic-locale-family shims (`import_shims.cpp` - a statically-linked libc++abi's classic-locale singleton touches these; none had ever been needed before since every previous locale-touching call site went through `rtti_shims.cpp`'s own `_ZNSt6__ndk1*`-prefixed libc++-INTERNAL shims instead, which never call down into bionic's locale layer at all) to close the gap. + +**Second run, clean, reproduced twice (two independent `adb shell am start` launches, both identical, zero `unresolved import` log lines in either run's window):** + +``` +GuestEngine::LoadSecondaryImage: loaded /data/data/com.ea.games.nfs13_arm/files/ostream_repro.so at base=0xcb45000 span=0x94000 - entry 'TestOstreamAssembly'=0xcb80825 +OSTREAM_REPRO: RunOstreamAssemblyReproTest: PASS - extracted std::ostringstream content matches exactly what was written (len=49, retVal=49). The isolated write-then-extract pattern works correctly under GuestEngine in total isolation from libapp.so - whatever breaks the real game's sub_4702D8/sub_27160C path is NOT a general ostringstream/basic_stringbuf-extraction bug in this engine. +``` + +**Definitive result: PASS.** A real, compiler-generated, unmodified `std::basic_stringbuf`/`std::ostringstream` implementation - written to 4 separate times via `operator<<`, then extracted via `.str()` - runs correctly under `GuestEngine`'s ARM32-on-ARM64 translation, in total isolation from every other line of game code, with the extracted string matching the written content byte-for-byte (49/49 bytes) on both independent runs. **This rules out a general C++-runtime/`basic_stringbuf`-extraction correctness bug in `GuestEngine` itself as the cause of the real game's empty-shader-source symptom.** Whatever is actually wrong is specific to `libapp.so`'s own state or control flow reaching `sub_4702D8`/`sub_27160C` - not a property of this engine's translation of the underlying C++ mechanism in general, and therefore not something that would affect any OTHER app run under this same engine. This directly and conclusively answers the question the whole day's `sub_43FDE0`→`sub_4702D8`→`sub_27160C` address-chasing chain could never have answered by itself, exactly per the user's own stated reasoning for the pivot. + +**What this does NOT do**: it doesn't identify what specifically IS wrong in `libapp.so`'s own path. Plausible remaining directions, not yet investigated: (1) the specific stack/object layout `sub_4702D8` uses (a 184-byte builder object at a particular `[SP+...]` offset, per the 2026-09-16 `sub_46FD30` entry above) might not match what THIS repro's simpler, directly-declared `std::ostringstream` local produces - i.e. a real layout/offset mismatch specific to how the real game's own (possibly hand-rolled or differently-templated) builder object interacts with a genuinely-shared stream state across many separate call sites in a much larger function, something a minimal repro with one local variable and one call site can't surface; (2) `sub_43FDE0`'s OWN cache/`dword_ADBFB8` mechanism (root-caused down to `v43`'s permanently-zero field earlier the same day) might be corrupting or aliasing something `sub_4702D8` depends on BEFORE it even starts, upstream of the ostringstream mechanism entirely; (3) something in the specific CALL CHAIN leading into `sub_4702D8` (register/stack state left over from `sub_43FDE0`'s own failed cache lookup, or from whichever caller invokes `sub_46FD30`) rather than the ostringstream object's own construction. Given real engine hardware time invested and the definitive general-vs-specific answer now in hand, this investigation is being set aside here rather than continuing further down the `libapp.so`-specific path - consistent with the user's own stated preference for spending further effort on GENERAL engine correctness over chasing this one binary's remaining specifics. + +**Housekeeping**: per this project's "remove spent diagnostics once their question is answered" discipline (same as the `RunCalleeSavedRegisterTest`/`RunReentrantCallRegisterTest` removal earlier this day), the `RunOstreamAssemblyReproTest(engine)` call site in `main.cpp`'s `LoadEmulatedLibapp` was removed after capturing this result (confirmed the app still boots cleanly afterward - `JNI_OnLoad returned 0x10002`, no regression). The infrastructure itself stays in the tree as real, reusable capability, not deleted: `GuestEngine::LoadSecondaryImage` (a genuinely general "load a second small ELF into this engine" primitive, useful for any FUTURE isolated-repro test, not just this one), `emu/ostream_repro_test.{h,cpp}`, and `ostream_repro/` (the standalone artifact's own source + build script, rebuildable any time via `ostream_repro/build.sh`). Two new, permanent, general-purpose shims (`Shim_memcpy_chk`, `newlocale`/`uselocale`/`freelocale`) also stay - real gaps this session found and closed, independent of this one test's own fate. + +**2026-09-16, same day, run in parallel with the above (per the user's explicit "покрой синтетикой весь свой код" directive): two more `CallGuestFunction` synthetic unit tests, both PASS.** + +While the isolated `std::ostringstream` repro (above) was being built, added two more tests to `tcg_bench.h`/`.cpp` targeting areas of `CallGuestFunction` that had never had a dedicated correctness test of their own, despite being explicitly flagged earlier this session ("Копай CallGuestFunction и маршалинг stack-аргументов") and never actually followed up on: + +- **`RunStackArgMarshalingTest`**: `CallGuestFunction(target, args, argCount)` marshals `argCount>4` by writing `args[4..]` onto the guest stack per AAPCS32 (`args[4]` at `[sp+0]`, `args[5]` at `[sp+4]`, ...) - every existing register-preservation test only ever exercised the ≤4-arg register-only path. Built a minimal leaf guest function reading `r0-r3` (register-passed) plus `[sp+0]`/`[sp+4]`/`[sp+8]`/`[sp+12]` (stack-passed) directly, writing all 8 values to a results buffer, called with 8 known sentinels (`0x10`..`0x17`) via the array-taking overload. Added one new hand-assembler primitive for this, `EmitLdrSpImm8` (Thumb-16 `LDR Rt,[SP,#imm8*4]`, encoding `10011 ttt iiiiiiii`). **Result: PASS - all 8/8 args (both the register-passed and stack-marshaled halves) arrived correctly.** +- **`RunSequentialCallStateLeakTest`**: every existing test checks ONE call in isolation. This checks whether state leaks ACROSS sequential (not nested/reentrant - each call fully completes before the next starts) calls through the SAME `AllocCodeStub`-dispatched stub, on the same thread - e.g. a caching bug in `CallGuestFunction`'s own save/restore bookkeeping or `MiscStubDispatch`'s table lookup that only manifests on a second or third call. Three separate code buffers, each with its OWN distinct sentinel set (`0x21..0x24`, `0x31..0x34`, `0x41..0x44`), results buffer poisoned with `0xDEADBEEF` between rounds so a leftover-value false PASS is structurally impossible. **Result: PASS - all three rounds saw only their own sentinels, zero cross-call leakage.** + +Deployed and run on the Pixel 6a (real, fully-loaded `GuestEngine`, same as every other synthetic test this session): `RunStackArgMarshalingTest: result=PASS ... arg0=0x10(OK) arg1=0x11(OK) ... arg7=0x17(OK)` / `RunSequentialCallStateLeakTest: result=PASS ... round0=OK[0x21,0x22,0x23,0x24] round1=OK[0x31,0x32,0x33,0x34] round2=OK[0x41,0x42,0x43,0x44]`. Both call sites removed from `main.cpp` afterward per the same "remove spent diagnostics" discipline (confirmed the build still compiles clean); the test functions themselves stay in `tcg_bench.h`/`.cpp` for reuse if either area is ever suspected again. + +**Running tally of `CallGuestFunction`/engine-correctness properties now verified via dedicated synthetic tests this session**: callee-saved register preservation across a flat stub call (PASS), the exact same across a nested/reentrant stub call (the ONE test that found a real bug - reentrant calls can hang), stack-argument marshaling for `argCount>4` (PASS), cross-call state isolation for sequential calls (PASS), and `std::ostringstream` write+extract correctness in isolation from `libapp.so` (PASS). Every one of these except the reentrancy test came back clean - `CallGuestFunction`'s core mechanisms are, so far, holding up well under targeted synthetic pressure; the one confirmed-real bug (reentrant `uc_emu_start()` hangs) remains unfixed but is now the only synthetically-confirmed engine-level defect on record, everything else chased this session having turned out to be either `libapp.so`-specific or not yet isolated. + +**2026-09-16, same day, follow-up: the reentrant `uc_emu_start()` hang is FIXED.** + +Per the user's explicit instruction ("Возьмись за пофикс реентерабельного uc_emu_start" - take on fixing the reentrant uc_emu_start), fixed the one remaining confirmed-real engine bug from the running tally above, rather than investigating further. + +**Root cause, precisely**: `CallGuestFunction` always resolved its `uc_engine*` via `uc()` (`t_state_.uc`, the ONE engine this host thread ever creates). When a `UC_HOOK_CODE` callback running during an already-active `uc_emu_start()` itself calls `CallGuestFunction` again (a JNI upcall, or any "shim needs to call back into guest code" path), the nested call tried to start a SECOND `uc_emu_start()` on the exact SAME `uc_engine*` that was still mid-execution one C stack frame up - not a safely reentrant operation in this Unicorn build, and it hangs forever (the exact mechanism `RunReentrantCallRegisterTest` was built to isolate). + +**Fix: one `uc_engine*` per reentrancy depth, not one per thread.** Depth 0 (the overwhelmingly common, non-reentrant case) is completely unchanged. Depth>0 calls now run on their OWN, separate `uc_engine*` instead of re-entering the depth-0 one - Unicorn/QEMU-TCG's own per-engine state isn't designed to be re-entered on one handle mid-`uc_emu_start`, but running two independent `uc_engine*` objects nested in the C call stack is a normal, fully-supported pattern (that's the entire point of `uc_open()` returning an opaque handle - nothing stops a process from running two completely unrelated emulators concurrently). Guest MEMORY stays byte-identical across every engine regardless of depth (every engine maps the same shared `host_region_` via `uc_mem_map_ptr` - this was already true for the existing one-engine-per-thread design), so this fix only changes which CPU-state container executes, never what memory it can see. + +**Implementation** (`guest_engine.h`/`.cpp`): + +1. **`GuestEngine::CreateConfiguredEngine()`** (new private method) - extracted out of `EnsureThreadEngine`'s own body verbatim: `uc_open`, mapping `host_region_` onto the new engine, guard-page/RELRO protection, VFP/NEON enable (CPACR+FPEXC), `ReplayHooksOnEngine`, the one `MiscStubDispatch` hook, `mem_fault_hook_cb`, the conditional profiling/trace hooks, and every one-off diagnostic probe hook this session accumulated - identical setup regardless of which caller needs a fresh engine. Returns `nullptr` (logging its own reason) on any hard failure; deliberately does NOT touch `t_state_` or carve a guest stack, since a nested engine doesn't get its own stack range (see below). +2. **`EnsureThreadEngine()`** - now just: early-return if `t_state_.uc` is already set, else call `CreateConfiguredEngine()`, then carve a stack and populate `t_state_` (its own unchanged tail). Zero behavior change for the depth-0 path. +3. **`ThreadState::nestedEngines[kMaxNestedEngines]`** (new field, `guest_engine.h`; `kMaxNestedEngines = 8`, a generous safety cap - deepest depth actually observed live was 2, matching `kMaxCallIterations`' own "safety net, not expected limit" philosophy) - one lazily-created, thread-local `uc_engine*` per reentrancy depth beyond the outermost. Created once per depth per thread and kept for the thread's lifetime (same "leaked deliberately, cheap to keep forever" pattern the depth-0 engine itself already uses - no `uc_close` anywhere in this design), not recreated per call. +4. **`GuestEngine::GetOrCreateNestedEngine(uint32_t depth)`** (new private method) - `depth-1` indexes `nestedEngines[]`; lazily creates via `CreateConfiguredEngine()` and caches on first use for that depth; returns the cached engine on every subsequent call at that same depth. Returns `nullptr` (logging) if `depth` exceeds `kMaxNestedEngines`. +5. **`CallGuestFunction`'s prologue** - `callDepthAtEntry` is now read BEFORE resolving the engine (it used to be read later, purely for the existing reentrancy-probe log); `eng` is now `t_state_.uc` at depth 0 or `GetOrCreateNestedEngine(callDepthAtEntry)` at depth>0, instead of always `uc()`. +6. **`EngineSwapGuard`** (new, scoped to `CallGuestFunction`'s body via RAII, same pattern as the existing `CallDepthGuard`) - the ONE subtlety this fix needed. Several shim call sites across the codebase (`DispatchCall` in `guest_fn.h`, plus call sites in `libc_shims.cpp`/`jni_shim.cpp`/`pthread_shim.cpp`/`rtti_shims.cpp` - confirmed via a full `grep` sweep for `.uc()` usage before writing this fix) read registers via `eng.uc()` (i.e. `t_state_.uc`) instead of the `uc_engine*` Unicorn itself hands their hook callback as a parameter. Those call sites can run reentrantly from within a NESTED engine's own hook callbacks, and would otherwise incorrectly read/write the depth-0 engine's registers instead of the nested engine actually executing. `EngineSwapGuard` saves `t_state_.uc`, sets it to the resolved `eng` for the scope of this call, and restores the previous value on return (via destructor) - a genuine no-op for the depth-0 case (`eng == t_state_.uc` already), and makes every one of those ~15 existing call sites across 5 files observe the correct engine automatically, with zero changes needed to any of them. + +**Verification, on the Pixel 6a (real device, same as every other synthetic test this session)**: temporarily re-added the `RunReentrantCallRegisterTest(engine)` call site to `main.cpp` (the exact same test that originally caught this bug), rebuilt, installed, and watched logcat. + +``` +GuestEngine::EnsureThreadEngine: new engine for this thread, stack=[0x4b44000,0x5344000) +GuestEngine::GetOrCreateNestedEngine: new engine for depth=1 +REG_TEST: RunReentrantCallRegisterTest: stub_hit=yes result=PASS - outer call's callee-saved registers survived a nested reentrant call intact | r4=0x14(OK) r5=0x15(OK) r6=0x16(OK) r7=0x17(OK) +``` + +No hang - the test that used to block forever now completes in well under a second. The app then continued running normally past this point (previously impossible), with REAL (not synthetic) reentrant calls happening naturally on other threads during actual gameplay bootstrap - `GetOrCreateNestedEngine: new engine for depth=1` and even `depth=2` observed on a different thread a few seconds later, both without incident. Removed the temporary test call site again afterward (same "remove spent diagnostics once confirmed" discipline as every other synthetic test this session), rebuilt clean. + +**One new, separate signal surfaced by this fix unblocking further execution** (not something this fix caused or is responsible for fixing): a few seconds after the reentrancy test passed, the SAME run hit a `MEM FAULT WRITE_PROT (guard page or protected region) guest_addr=0xac77e8 ... r1=0xae1a0c` - a DIFFERENT fault class (a protected-region write, not a hang) at a different address, which then correctly tripped `crashed_`'s fail-fast ("refusing to run - engine already crashed"). This is a genuinely separate, pre-existing issue this reentrancy fix simply allowed the app to run far enough to reach - not investigated further as part of this task, noted here only so a future session doesn't mistake it for a regression from this change. + +**Running tally, updated**: of the properties tracked in the previous entry's tally, callee-saved register preservation across a nested/reentrant stub call - the one property that came back FAIL - is now fixed and reconfirmed PASS. Every synthetically-tracked `CallGuestFunction`/engine-correctness property this session tested is now clean. + +**2026-09-16, same day, follow-up: root-caused the WRITE_PROT fault @ 0xac77e8 (sub_3D58BC) - CONFIRMED real stack corruption inside sub_75E40, NOT a GuestEngine/translation-layer bug.** + +Per the user's explicit instruction ("Разберись с тем WRITE_PROT фолтом на 0xac77e8" - figure out that WRITE_PROT fault at 0xac77e8), the fault surfaced right after the uc_emu_start() reentrancy fix (see the entry above) unblocked further execution. Investigated via a sequence of live, targeted register/stack probes (`guest_engine.cpp`'s `CreateConfiguredEngine`, `Sub3D58BC*ProbeHookCb`/`Sub3D46C0ReturnProbeHookCb`/`Sub75E40ReturnProbeHookCb` - kept in the tree as active, unresolved-investigation diagnostics, not yet removed): + +**The crash**: `sub_3D58BC` (a periodic per-frame "run lazy one-time subsystem init" dispatcher, gated by process-global `byte_ADAEF4`/`byte_ADAEF5` flags - fires at most once ever) calls `a1`'s own vtable+8 slot via `BLX R1` @ 0x3d593c, then writes `byte_ADAEF4=1` via `STRB R0,[R6]` @ 0x3d5944 where `R6=&byte_ADAEF4` (0xadaef4, a fixed PC-relative literal, zero runtime variability) was computed BEFORE the call. R6 is AAPCS32 callee-saved - the vtable+8 callee must preserve it. Confirmed live: the vtable+8 target is `sub_75E40` (the app's own real bootstrap routine, already known from the directory-scan-stall investigation), and by the time it returns, R6 has been clobbered to `0xac77e8` - a real `.got` address (RELRO-protected, read-only under this engine) - so the subsequent `STRB` faults. + +**Two false starts, both explicitly corrected in the code comments to save a future investigation from repeating them**: +1. First assumed the literal-pool computation itself was wrong (R6 read as `0x7055cc` right at the `ADD R6,PC,R6` instruction's own hook address) - this was a `UC_HOOK_CODE` timing misunderstanding on my part: the hook fires BEFORE the instruction at that address executes, so it was reading R6 as left by the PRECEDING `LDR` (the raw un-added literal, confirmed by hand: `0xadaef4 - 0x3d5928 == 0x7055cc` exactly). A follow-up probe right before the `BLX` confirmed R6 genuinely is `0xadaef4` by the time the call actually happens - no literal-pool or PC-relative-addressing bug. +2. Second built a whole-image `UC_HOOK_BLOCK` watcher expecting R6 to equal `0xadaef4` at EVERY block entry throughout the callee's execution - this is not how AAPCS32 callee-saved actually works (a function may freely reuse r4-r11 as scratch internally; it only has to RESTORE them before ITS OWN return). The watcher's "first mismatch" landed inside `sub_3D46C0` (RunLoop::OnCreate) reassigning R6 to a log-string pointer - completely normal compiler output, not a bug. Replaced with the correct test: check each function's OWN saved-register stack slot right before its OWN epilogue. + +**The real, decisive test**: `sub_75E40`'s prologue (`PUSH {R4-R11,LR}; ADD R11,SP,#0x1C`) saves R6 at a fixed stack offset, `[R11-0x14]`, recoverable via the same frame-pointer-chain convention every function in this binary uses. Reading that exact memory location right before `sub_75E40`'s own single normal epilogue (`POP {R4-R11,PC}` @ 0x76d0c, found via `search_text` - a separate `abort()`-terminated error tail elsewhere in the function is not part of this run's path) showed: + +``` +Sub75E40ReturnProbe @0x76d0c: R6=0x0 (expected 0xadaef4) <-- ALREADY WRONG + saved-R6-on-stack[R11-0x14]=0xac77e8(ok) <-- STACK SLOT ITSELF IS CORRUPTED (real memory corruption) +``` + +The value sitting in memory at that slot is `0xac77e8` - EXACTLY the value that later reaches `sub_3D58BC` and faults. This is unambiguous: something WITHIN `sub_75E40`'s own ~970-instruction, ~113-basic-block body wrote `0xac77e8` directly into its own saved-R6 stack slot, overwriting the value its prologue correctly pushed there. The SAME check run against `sub_3D46C0` (called as `sub_75E40`'s very first real action, immediately after its prologue) showed that function's own saved-R6 slot is INTACT (`0xadaef4`, correct) - so `sub_3D46C0` itself is innocent; the corruption happens LATER, somewhere in `sub_75E40`'s remaining ~40+ calls (`sub_40879C` x2, `sub_3F7C88`, `sub_54762C`, and dozens more, many following the same "reserve via `sub_3DE128`, append via `sub_3DE198`" small-string-builder pattern also seen throughout `sub_3D46C0`). + +**Conclusion**: this is REAL memory corruption in the ORIGINAL game's own compiled code - almost certainly a stack-buffer overflow (the corrupted slot sits only 8 bytes past the end of `sub_75E40`'s own last named local variable, `var_8` @ SP+0x5c) from one of its many small string-building helper calls, not a GuestEngine translation bug. This is consistent with the "wrong approach to fit emulation to one binary" lesson from earlier the same day, but this finding is the OPPOSITE case: it's not an emulation-fitting exercise, it's confirmation the bug is genuinely in libapp.so's own code, most likely non-fatal on real ARM32 hardware only because that hardware's exact stack layout differs enough that the same overflow lands somewhere inconsequential (or a real dynamic linker's own RELRO enforcement differs subtly), while under this engine's specific arena/stack layout it happens to land exactly on a saved callee-register slot and then on a RELRO-protected `.got` address - a real, if unlucky, divergence. + +**Not yet pinpointed**: the EXACT single instruction (or `sub_3DE128`/`sub_3DE198`-style helper call) inside `sub_75E40` that performs the out-of-bounds write. `insn_query` found no direct symbolically-named store targeting the corrupted offset (`SP+0x6c`) anywhere in the function, meaning the write is either register-indexed (a loop/array write with a computed address, not a fixed literal offset) or comes from inside one of the many called helper functions given a stack pointer to write through. Further bisection (binary-search via more `Sub75E40Mid*ProbeHookCb`-style stack-slot checks at intermediate points in the function) would narrow this down further but wasn't completed this session - set aside to report findings and get direction on whether pinpointing the exact instruction vs. a pragmatic workaround is the better next step, matching this project's established practice of checking in before an open-ended bisection goes many more rounds. + +**2026-09-16, same day, final: found and FIXED the real root cause of the WRITE_PROT @0xac77e8 fault - not sub_75E40's own code, not a crypto-instruction bug, but a genuine architectural gap in CallGuestFunction's reentrant stack handling, only exposed once the earlier uc_emu_start() reentrancy fix let reentrant calls actually complete instead of hanging.** + +Per the user's follow-up instruction ("Давай бисекцией найдём точную инструкцию" - let's bisect to find the exact instruction), continued past the previous entry's "root-caused but not pinpointed" state. + +**Bisection method**: rather than registering an individual hook at each of `sub_75E40`'s 147 call sites (which would reintroduce the exact per-stub hook-list performance problem the 2026-09-06 `MiscStubDispatch` consolidation fixed), used ONE `UC_HOOK_BLOCK` tracking `sub_75E40`'s saved-R6 stack slot by its ABSOLUTE address (captured once, right after its own prologue sets R11) - checked on every block entry anywhere in the image, not just within `sub_75E40`'s own range, since the leading theory was a called helper corrupting the slot via a pointer, not `sub_75E40`'s own code. + +**First pinpoint**: the corruption is first observed at a block inside `sub_7433C`, called from `sub_88CCD0` - a CONFIRMED `pthread_once` target from this same session's earlier reentrancy investigation. `sub_7433C` turned out to be OpenSSL's own ARMv8-Crypto-Extension capability probe (`getenv("OPENSSL_armcap")`, `getauxval`/HWCAP checks, a `sigsetjmp`-then-try-the-instruction-and-see-if-it-SIGILLs fallback that probes `AESE.8`, `SHA1C.32`, `SHA256H.32`, `VMULL.P64`, and a `MRRC` CNTVCT read). Given this project's `Shim_setjmp` is a documented no-op, `sigsetjmp()` always reports "no signal caught," so `sub_7433C` always attempts every probe instruction unconditionally - a strong candidate for silent corruption if Unicorn's ARM32 TCG backend has any Crypto Extension decode/effect bug (historically a common QEMU gap). + +**This turned out to be a red herring, caught with a clean, decisive test**: bracketed all 5 candidate instructions directly. On re-run, `sub_7433C` took its EARLY-RETURN fast path (its own one-time-init flag `dword_B1574C` was already set from an earlier call) - none of the 5 crypto probes executed at all, yet the corruption still appeared at exactly the same point. So the crypto instructions are innocent; the corrupted memory was already wrong by the time `sub_7433C` even started. + +**The real finding, via correlating the corruption timing against `CallGuestFunction`'s own reentrancy log**: right before the corruption, a dense burst of REENTRANT calls fires - a misc-stub/JNI callback repeated many times back-to-back, then `sub_87b968` and `sub_88ccd0` (both CONFIRMED `pthread_once` targets), all at depth=2, i.e. all triggered from within an ALREADY-reentrant (depth=1) hook context. Re-reading `CallGuestFunction`'s stack-pointer logic (`guest_engine.cpp`) exposed the actual bug: + +```cpp +uint32_t sp = saved[13]; // saved[13] is `eng`'s OWN saved SP - `eng` here is the depth-specific engine +if (sp < t_state_.stackBase || sp > t_state_.stackTop) { + sp = t_state_.stackTop - 0x100; +} else { + sp -= 0x100; +} +``` + +This comment ("reuse the CURRENT live SP minus a safety gap, so nested frames thread through the real stack instead of colliding - the same way real recursive calls would") was written for the OLD, single-engine design, where "current live SP" genuinely meant "wherever the one shared CPU state currently is." After the earlier reentrancy fix gave EACH depth its OWN separate `uc_engine*` (a deliberate, correct fix for the register-file collision - see the entry above), this SP logic silently stopped meaning what it used to: `saved[13]` now reads a NESTED engine's OWN, INDEPENDENT saved SP - which, since that engine starts fresh at `stackTop - 0x100` and gets restored back there after every call, is COMPLETELY UNRELATED to how deep the OUTER (suspended) frame actually is in its own stack. Every reentrant call at every depth ends up anchored to the SAME small ~0x100-byte window near `stackTop`, regardless of nesting depth or which outer frame triggered it. + +`sub_3D58BC`/`sub_75E40` happen to run very shallow in their own thread's stack (confirmed live: `sub_75E40`'s own entry SP was `stackTop - 0x238`-ish, well within that same window) - so when the observed burst of depth-2 reentrant calls (JNI callback storm + two `pthread_once` targets, each with their own real local variables and further nested calls) ran in that SAME narrow window, one of them wrote through it and directly overwrote `sub_75E40`'s own saved-R6 stack slot with `0xac77e8` - a real `.got` address that happened to be some INNER call's own legitimate local value, just landing in completely the wrong place. + +**The fix** (`guest_engine.h`/`.cpp`): give each reentrancy depth its own DEDICATED stack, carved the exact same way a real host thread gets one (`CarveThreadStack()`), instead of reusing a slice of the primary thread's stack: + +1. `ThreadState::nestedStackTop[kMaxNestedEngines]` (new field, paired 1:1 with the existing `nestedEngines[]`) - each depth's own stack-top address, base implicitly `top - kStackSize`. +2. `GetOrCreateNestedEngine(depth)` now calls `CarveThreadStack()` (same arena, same mechanism, same failure handling as a brand-new host thread) the first time a depth is used, storing the result and using it for that engine's initial SP - instead of the old `t_state_.stackTop - 0x100`, which had nothing to do with this depth's own isolation. +3. `CallGuestFunction`'s SP-validity/reset logic now resolves the correct `[stackBase, stackTop)` bounds for whichever stack `eng` actually uses - `t_state_.stackBase/stackTop` at depth 0 (completely unchanged, zero behavior difference for the overwhelmingly common case), or `t_state_.nestedStackTop[callDepthAtEntry-1]`/`-kStackSize` at depth>0 - before doing the exact same "reuse current live SP minus a safety gap if valid, else fresh top-of-stack" logic as always, just correctly scoped per depth now. This makes a stack collision between two different reentrancy depths (or between a depth and the outer frame) physically impossible, the same isolation principle `nestedEngines` already gave the CPU register file, now extended to the stack memory itself. + +**Verification, on the Pixel 6a**: rebuilt, reinstalled, re-ran with the same bisection probes still active (now confirming the fix rather than diagnosing the bug): + +``` +Sub75E40ReturnProbe @0x76d0c: R6=0x0 (expected 0xadaef4) [live register, irrelevant - see the earlier entry's note on legitimate scratch reuse] + saved-R6-on-stack[R11-0x14]=0xadaef4(ok) <- stack slot correct, no longer corrupted +Sub3D58BCPostCallProbe @0x3d5944: R6=0xadaef4 (expected 0xadaef4) - OK, matches +``` + +No more corruption, no more WRITE_PROT fault. The app then continued running noticeably further than before, into completely new territory - and hit a DIFFERENT, already-known, unrelated fault (`MEM FAULT READ_UNMAPPED` at `sub_569478`, inside the already-documented `0x569xxx` render/shader-crash investigation area from much earlier this session, `RenderCrashProbeHookCb`/`sub_56962C`) - confirming the fix genuinely unblocked forward progress rather than just moving the symptom. + +**Housekeeping**: removed all temporary diagnostic hooks built for this investigation (`Sub3D58BC*ProbeHookCb`, `Sub3D46C0ReturnProbeHookCb`, `Sub75E40*ProbeHookCb`/`Sub75E40WatchBlockHookCb`, `Sub7433CCryptoProbeHookCb` and their registrations in `CreateConfiguredEngine`) per this project's "remove spent diagnostics once the question is answered" discipline - confirmed the build stays clean without them. The actual fix (`ThreadState::nestedStackTop`, `GetOrCreateNestedEngine`'s stack-carving, `CallGuestFunction`'s per-depth SP-bounds resolution) is permanent. + +**2026-09-17: the MEM FAULT @sub_569478 that surfaced right after the reentrancy-stack fix is a downstream symptom of the ALREADY-DOCUMENTED empty-shader-source root cause (2026-09-07/09-16 entries), not a new independent bug - confirmed live, not chased further per the user's own earlier stated priority.** + +Per the user's follow-up instruction ("Разберись с фолтом на sub_569478" - figure out the fault at sub_569478), investigated the new `MEM FAULT READ_UNMAPPED guest_addr=0x7461748b at guest PC=0x5695d8` that appeared once the WRITE_PROT/stack-collision fix (previous entry) let the app run substantially further. + +**What it is**: `sub_569478` is a shader-uniform-apply dispatcher (9 real call sites; real disasm confirms `case 2`=float/floatN, `3-5`=vecN, `7`=mat4, `8`=packed-color, `9`=Texture2D, `10`=TextureCube). `case 10` (real disasm @0x5695c0-0x569608): `v10 = *a2; glActiveTexture(...); if (v10) v8 = *(DWORD*)(v10+56);` - `a2` is the uniform's VALUE SLOT; for a texture-type uniform it should hold either 0 (unset) or a pointer to a real Texture wrapper object. A probe right after `v10 = *a2` loads confirmed `v10=0x74617453` - a wild, garbage pointer nowhere near any of this engine's arenas, meaning the uniform's value slot was ALREADY corrupt/uninitialized before this dispatcher ever ran; `sub_569478`'s own dereference logic is not at fault. + +**The real caller, recovered from the stack** (LR at fault time pointed inside `sub_569478` itself, from the preceding `glActiveTexture` stub-dispatch call - had to read the ACTUAL caller's return address from `[R11+4]`, per `sub_569478`'s own `PUSH {R4-R6,R10,R11,LR}; ADD R11,SP,#0x10` prologue): `sub_56962C` - **the real GLES draw-call function**, confirmed by its own decompiled body ending in `glDrawElements` and containing the exact same `"ZOMG"` printf check at `0x5698c0` that `ShaderVariantProbeHookCb` (registered much earlier this session) already investigates. This is unambiguously the same code region as the original empty-shader-source investigation (`sub_43FDE0`→`sub_4702D8`→`sub_27160C`, `dword_AE1A0C` sentinel, `sub_478A18`/`v43` cache-field analysis - all from the 2026-09-07 and 2026-09-16 entries above). + +**The connection**: that earlier investigation concluded the assembled fragment-shader source comes out empty, so the real GLSL compile-and-link (`sub_5673F8`) never succeeds, so `sub_43FDE0`'s own cache field (`v43`, read via `dword_ADBFB8`) stays permanently zero, so the shader "fast path" always fails. The three remaining, not-yet-investigated directions that entry listed (a layout/offset mismatch in the real game's builder object, `sub_43FDE0`'s cache corrupting something upstream, or leftover call-chain state) were deliberately left unexplored **per the user's own explicit priority at the time** ("ты сейчас пытаешься подогнать эмуляцию к одному единственному бинарнику" - spend further effort on general engine correctness, not this one binary's remaining specifics). `sub_56962C`'s uniform table holding garbage for this shader's texture-cube uniform is entirely consistent with drawing using a shader program that never properly linked/initialized in the first place - the SAME root cause, now finally reached at the actual draw call because the two real engine bugs fixed this session (the `uc_emu_start()` reentrancy hang and the reentrant-call stack collision) no longer block execution from getting this far. + +**Not investigated further this session** - this is a resumption point for the ALREADY-SCOPED, deliberately-paused shader-compilation investigation above, not a new open question. Diagnostic probe (`Sub569478TextureCubeProbeHookCb`) removed after confirming the connection, per this project's "remove spent diagnostics once the question is answered" discipline; rebuilt and reinstalled clean. + +**2026-09-17: FOUND AND FIXED the true root cause of the empty-shader-source investigation running since 2026-09-07 - `Shim_ios_base_init` was a complete no-op, leaving every freshly-constructed `std::ostringstream`'s error state as uninitialized stack garbage, silently no-op'ing every single write. Confirmed live: fixing it unblocks real writes, and immediately exposes a NEW, previously-unreachable downstream fault.** + +Per the user's follow-up instruction ("Возвращаемся к шейдерному расследованию, начни с направления 1" - let's return to the shader investigation, start with direction 1), resumed the 2026-09-16 entry's three open directions. Direction 1 was: "the specific stack/object layout `sub_4702D8` uses... might not match what the isolated `ostream_repro` test's simpler local produces." + +**Direction 1, tested directly - REFUTED, but led straight to the real bug.** Re-derived `sub_27160C`'s real mode-check logic from fresh disasm (`TST [a2+0x30],#0x10` then `#8`) and added a live probe reading that exact field at the real `sub_4702D8` → `sub_27160C` call site (`0x4706d0`). First result: `mode=0x18` (BOTH `ios_base::out`/`in` bits correctly set) - the mode field itself is fine, direction 1's specific "layout mismatch" hypothesis is wrong. + +**But this immediately surfaced a MUCH more important correction**: fresh disassembly of `sub_4702D8` showed its real `std::ostringstream`-equivalent object is NOT part of the `a1` "builder object" `ShaderBuilderObjectProbeHookCb` (2026-09-16) was reading at all - it's a completely separate, freshly-constructed LOCAL STACK object (`R8=SP+0xCC+var_A0`, established @`0x470488`), built via genuine C++ runtime calls: `_ZNSt6__ndk18ios_base4initEPv` (`ios_base::init`) @`0x47051c` and `_ZNSt6__ndk16localeC1Ev` (`locale::locale()`) @`0x470568` - the exact same construction path a real, compiler-generated `std::ostringstream` local goes through, matching the already-PASSING isolated `ostream_repro` test. `ShaderBuilderObjectProbeHookCb`'s `a1+12..+60` reads were reading unrelated fields of the WRONG object the entire time (explains its own odd repeating `(1.0f, 2.0f)` pattern - unrelated geometry/attribute data, not stream state at all). + +**Chased the real object instead**: since mode was correct, `sub_27160C` should extract from the GET area (`a2+0x14`=begin, `a2+0x18`=cur, `a2+0x2C`=end/high-mark). Extended the probe to dump these too: **all three read as exactly `0x0`** - the GET/high-mark area was never populated AT ALL, despite `sub_4702D8` having made multiple real `sub_79CD4` (`operator<<`) write calls before reaching extraction. This is why the extraction comes out empty (`begin==end`), independent of the mode field being correct. + +**Traced into `sub_79CD4` itself** (the real `basic_ostream::operator<<(const char*, size_t)`): its own real disasm computes the Itanium-ABI-adjusted `this` (`R6 = a1 + *(DWORD*)(*a1-12)`), then immediately checks `[R6+0x10]` (`CMP R0,#0; BNE loc_79DC8`) - a nonzero value branches PAST the entire write-and-grow-buffer logic (`sub_79E50`), matching the real C++ standard's `sentry`/`good()` fast-exit-on-bad-stream behavior (confirmed further via the SAME offset being written by two `_ZNSt6__ndk18ios_base5clearEj` (`ios_base::clear(uint)`) calls later in the same function, and `ios_base::clear` is documented ANYWHERE ELSE in this codebase as the real error-state setter). A scoped probe (armed only during `sub_4702D8`'s own execution, to avoid flooding on every unrelated `<<` in the whole app) confirmed: **`[R6+0x10] = 0x4404c0` on the very FIRST write call** - nonzero, meaning the write logic is skipped from the object's very first use. `0x4404c0` is not a plausible small iostate bitmask (goodbit/failbit/badbit/eofbit are 0-7) - it's a value inside `.text`'s own address range, strongly suggesting leftover, never-cleared stack garbage from an unrelated earlier frame. + +**Root cause, confirmed via source inspection**: `Shim_ios_base_init` (`rtti_shims.cpp`) was a hardcoded no-op: +```cpp +uint32_t Shim_ios_base_init(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { + // Sets up cin/cout/cerr/clog's shared state in real libc++ - nothing + // for this engine to do... no real formatted-stream output is implemented. + return 0; +} +``` +This comment's premise - that `ios_base::init()` is only ever called for the global `cin`/`cout`/`cerr`/`clog` singletons - is WRONG. The C++ standard requires it for EVERY `ios_base`-derived object's construction, including a completely ordinary local `std::ostringstream` - confirmed live, `sub_4702D8`'s own inlined local calls this exact import. Real `ios_base::init()` zeroes `__rdstate_` (the same `[this+0x10]` field `sub_79CD4` checks) among other per-object defaults. Leaving it as a no-op meant `[this+0x10]` was NEVER initialized for ANY `std::ostringstream`/`stringstream` construction anywhere in this engine - it silently inherited whatever stack garbage happened to already be there, which is essentially always nonzero, meaning `good()` was false from the very first use and every subsequent write silently no-op'd. **This one no-op shim was silently breaking every `ostringstream` construction in the entire engine** - not a shader-specific bug at all, a completely general one that just happened to first become externally visible via the shader-compile path. + +**Fix** (`rtti_shims.cpp`): +```cpp +uint32_t Shim_ios_base_init(GuestEngine& eng, uint32_t thisPtr, uint32_t, uint32_t, uint32_t, uint32_t) { + if (thisPtr) { uint32_t goodState = 0; memcpy(eng.G2H(thisPtr + 0x10), &goodState, 4); } + return 0; +} +``` +Also fixed the clearly-adjacent, equally-broken sibling while in the area: `Shim_ios_base_clear` (real `void ios_base::clear(iostate state = goodbit)`, a plain assignment into the same `[this+0x10]` field - callers pre-OR any new bits into their own local copy before calling, confirmed via `sub_79CD4`'s own `ORR R1,R1,#5; BL ios_base::clear` pattern) was ALSO a no-op; now does the real assignment. + +**Verified live, on the Pixel 6a**: rebuilt with the fix, re-ran with the same probes. `Sub79CD4StateCheck` now shows `state[R6+0x10]=0x0` (correctly zeroed, good state) on the very first write - the "NONZERO: stream already bad" condition is gone. The write logic that was NEVER ONCE exercised before (across this whole multi-week investigation) now actually runs - and immediately hits a NEW fault: `uc_emu_start returned 21` (`UC_ERR_EXCEPTION`) at `guest PC=0x4b3f09c`, just past the `misc_stub_end_` boundary (inside the control/`AllocPermanent` arena, a data-only region) - consistent with execution jumping through a bad/uninitialized function pointer once the real buffer-growth path (`sub_79E50` or whatever it calls into, likely another virtual dispatch this engine hasn't exercised before) actually runs for the first time. + +**This is genuine forward progress, not a regression**: the previous, much-later crash (`sub_569478`/`sub_56962C`, documented in the entry above) was a DOWNSTREAM consequence of shaders never compiling; this new fault is now happening EARLIER, DURING shader-source assembly itself, because the actual write/buffer-growth code path is executing for the first time ever. Both are naturally-occurring frontiers of the same underlying incompleteness (silent no-op shims), not new problems introduced by this fix. + +**Not yet investigated**: what specifically is at/around `0x4b3f09c`, and which virtual dispatch or shim gap sends execution there - a fresh, separate investigation, set aside here to check in with the user given the scale of what's already been found and fixed this turn (a genuine, multi-week-old, general-engine-correctness root cause, not a one-off). Diagnostic probes from this investigation (`Sub4702D8ModeFieldProbeHookCb`, `Sub4702D8Entry/EpilogueProbeHookCb`, `Sub79CD4StateCheckHookCb`) are left ACTIVE in the tree (not yet removed) since the investigation is ongoing, not concluded - a natural continuation point for a follow-up session. + +**2026-09-17 (later, same day): chased 0x4b3f09c's successor and found the `strchr`/PLT-return theory was a dead end - the real bug is a self-referential `basic_stringbuf::overflow()` recursion, still unresolved.** + +The fault address moved from `0x4b3f09c` to a new `UC_ERR_INSN_INVALID @ guest PC=0x1e0` once earlier fixes landed. Initial live probing (`LR=0x800634` at the crash site, which real disasm shows is the address of a `BL strchr` instruction in `sub_8003A4`) looked like a return-address-restoration bug in `strchr`'s import-stub dispatch. **This theory was refuted by direct evidence**: bracketing probes at `strchr`'s own PLT call site (`0x800634`) and its real post-call return address (`0x800638`) NEVER FIRED during a full reproduction - execution never passes through `strchr` at all on the path that reaches `0x1e0`. `LR=0x800634` was a stale/coincidental register value, not evidence of anything. + +**Ground truth via a temporary full block trace** (`EnableFullGuestTrace()`/`StartGuestTraceDumpThread()` in `main.cpp`, normally commented out per this project's "UC_HOOK_BLOCK must be opt-in" rule - re-enabled for exactly one capture, then reverted): the real sequence immediately before the fault is a call into `__aeabi_memcpy`'s PLT stub (`0x667cc`, confirmed via disasm as a genuine, correctly-registered import - not an unresolved-GOT bug), and PC becomes exactly `0` immediately after, then walks forward through raw ELF-header bytes (`0x0 → 0x4 → 0x68 → 0x6c → 0x80 → 0x84 → 0x120 → 0x124 → … → 0x1e0`) until hitting an undecodable byte sequence. This is a classic **NULL function-pointer call**, not a return-address bug. + +**Live register captures at the `__aeabi_memcpy` PLT call site across the final calls before the crash show a clean geometric-doubling pattern**, alternating between two call sites (`LR=0x270090` and `LR=0x270414`): +``` +LR=0x270090 dest=0x200000 src=0x200000 n=0x1fffff +LR=0x270414 dest=0x0 src=0x0 n=0x3fffff +LR=0x270090 dest=0x400000 src=0x400000 n=0x3fffff +LR=0x270414 dest=0x0 src=0x0 n=0x7fffff +LR=0x270090 dest=0x800000 src=0x800000 n=0x7fffff <- last call before the crash +``` +(`n` grows as `2n+1` each round; `dest`/`src` double in lockstep.) Identified via `lookup_funcs`: `0x270090` is inside `sub_27003C` (real `basic_streambuf::xsputn`) right after its own bulk `qmemcpy`; `0x270414` is inside `sub_27036C` (real `basic_string::push_back`, libc++ SSO-growth logic, `abort()`-guarded) right after ITS OWN internal `qmemcpy` during a capacity-doubling reallocation. + +**Ruled out two plausible sources of the huge/doubling length directly, with live probes that never fired**: `Shim_strlen` (flagging any result >4096 bytes - an unterminated guest string would scan host memory until hitting a stray zero, which would show up here) and `sub_79CD4` itself (real `basic_ostream::operator<<(const char*, size_t)`, flagging any entry `count`>4096 - would catch a single huge external write). Neither fired even once before the crash, so the huge value is not injected from outside the stream machinery - it originates from *within* the streambuf's own growth logic. + +**Real root-cause candidate, from `sub_2700E4`'s decompile** (the real `basic_stringbuf::overflow(int_type)`, reached via `sub_27003C`'s `vtable+52` call when the buffer is full): after growing the underlying string via `sub_27036C`/`sub_270464`, it re-checks whether the put-area pointers (`v6`/`v7`, cached at `[a1+24]`/`[a1+28]`) now differ; if they're **still equal** (i.e. growing did NOT create any new room, from `overflow`'s own point of view), it **tail-calls itself again through the same vtable slot** (`return (*(int(**)(int,int))(*(DWORD*)a1+52))(a1, v2);` @ `0x2702bc`). If the cached put-area pointers are never actually refreshed to point into the newly-grown buffer, this recurses forever, doubling the string's capacity every round - exactly matching the observed pattern. This is real, compiled libc++ code, not anything this engine shims directly; `sub_54e100` (the top-level `CallGuestFunction` target driving all of this) is simply `Java_com_ea_ironmonkey_RunLoop_nativeOnRunLoopTick` - the ordinary per-frame tick, not shader-specific machinery. (The shader-attribute-lookup probe lines seen firing shortly before earlier crash captures were coincidental proximity within the same per-frame tick, not evidence this bug is shader-related - that assumption from earlier in this investigation was wrong and should not be carried forward.) + +**Not yet resolved**: which specific field/invariant of the real streambuf/string object is wrong such that `overflow()`'s own "did growing help" re-check never succeeds - i.e. whether this is a genuine gap in this engine's emulation of some STL-internals detail (alignment, a miscomputed flag, a shim that doesn't fully replicate a real method's side effects), or corruption inherited from something earlier in the same per-frame tick. Diagnostic probes added this session and still ACTIVE in the tree, pending further investigation or cleanup: `CrashAt0x1e0ProbeHookCb` (0x1e0), `PreStrchrCallProbeHookCb`/`PostStrchrReturnProbeHookCb` (0x800634/0x800638 - confirmed innocent, safe to remove once this thread concludes), `PreMemcpyPltCallProbeHookCb` (0x667cc), `Sub79CD4EntryLargeCountProbeHookCb` (0x79cd4 - confirmed innocent, same), and `Shim_strlen`'s own inline >4096 check (confirmed innocent, same). Checking in with the user at this point given how far the original 0x4b3f09c/strchr chase has evolved. + +**2026-09-17 (later still): ROOT CAUSE CONFIRMED via real-hardware comparison - `GuestHeap`'s 64MB arena genuinely exhausts, because oversized allocations are never reclaimed on free. Quick fix applied (bumped to 256MB); a new, separate downstream fault surfaced past it.** + +Per the user's own suggestion ("а если взять Unicorn транслировать так же команды но arm32 -> arm32 но без всех шимов... то есть взять А9, на нём ранить unicorn но без прослойки шимов" - what if we ran Unicorn arm32->arm32 with no shim layer at all, on real ARM32 hardware, to isolate whether the bug lives in Unicorn/TCG itself or in this engine's own shims), rather than build a full shim-free ARM32-on-ARM32 harness (impractical - Unicorn has no dynamic linker of its own, so "no shims" would mean also emulating libc.so/libc++_shared.so/etc. as guest code, not just libapp.so), took the cheaper, still-decisive equivalent: the real doubling growth mechanism (`sub_2700E4`/`sub_27036C`/`sub_27003C`) is NOT shimmed at all already - it's genuine ARM32 code Unicorn executes directly, no different in kind from what real hardware runs. So built a standalone native ARM32 EXECUTABLE (`ostream_repro/ostream_stress.cpp`, new `ostream_stress` CMake target alongside the existing `ostream_repro` library, same `c++_static` toolchain settings) that pushes the exact same growth pattern - `std::string::push_back` in a tight loop, and separately `std::ostringstream operator<<` in a loop - well past the ~8MB point where GuestEngine crashes (20M chars / iterations), and ran it **directly on the Galaxy A9, no Unicorn, no GuestEngine, no shims whatsoever**. + +**Result: real hardware sails through cleanly in under half a second for both variants**, with capacity doubling proceeding exactly as expected (`2097151 -> 4194303 -> 8388607 -> 16777215 -> 33554431`), no stalling, no abort, no trouble at all - decisively refuting the "genuine infinite-recursion bug in the shipped libc++/game code" hypothesis, and by extension making a general Unicorn/TCG instruction-semantics bug much less likely too (this engine already runs enormous amounts of ordinary loop/malloc/memcpy code successfully elsewhere). The bug had to be specific to something in GuestEngine's own translation. + +**Found it directly**: added logging to `GuestHeap::Alloc`'s own "oversized" (>`kMaxSizeClassBytes`=64KiB) bump-allocate path - both for every allocation over 1MB, and loudly for outright exhaustion (previously a silent `return 0`). Re-ran on the Pixel 6a and captured the exact failure live: +``` +payload=70685072 (67.4MB) - headroom=8051040 bytes -> HEAP EXHAUSTED, returns 0 +payload=2097152 (2.0MB) - headroom=6018796 bytes -> OK +payload=4194304 (4.0MB) - headroom=3921632 bytes -> HEAP EXHAUSTED, returns 0 +payload=8388608 (8.0MB) - headroom=3921632 bytes -> HEAP EXHAUSTED, returns 0 +payload=16777216 (16.0MB) - headroom=3921632 bytes -> HEAP EXHAUSTED, returns 0 +CrashAt0x1e0Probe: R0=R1=0x800000 (exactly the failed 8MB allocation's size) +``` +Root cause, precisely: `GuestHeap::Free`'s own documented behavior only returns pooled (size-classed, ≤64KiB) blocks to a reusable free-list; oversized blocks are marked `free=1` and then **permanently abandoned** - `free_cursor_` never moves backward, so that memory is gone for the rest of the process's life. The real, legitimate `push_back`-driven doubling-growth pattern (allocate new buffer, copy old content in, discard/free the old one, repeat) is EXACTLY the pattern that pattern-matches worst against this design: every single growth round permanently wastes its predecessor's entire allocation, so a string that grows to N bytes via doubling burns roughly 2×N bytes of the arena forever. Across a real game session this adds up fast enough to exhaust even a 64MB arena. Once `malloc()` genuinely starts returning NULL (something real Android essentially never does for game-scale requests, so the shipped code has no defensive null-check), the resulting corrupted-but-not-immediately-crashing state - masked further by `Shim_aeabi_memcpy`'s own silent no-op on a null `dest`/`src` instead of a loud failure - cascades downstream until it manifests as the wild jump through address 0 up to `PC=0x1e0`. + +**Quick fix applied, per the user's explicit choice** ("Давай быстрый bump" - over the alternative of building a proper free-list/reclaim mechanism for oversized blocks right away): bumped `kHeapSize` from 64MB to 256MB (`guest_engine.cpp`). Verified live on the Pixel 6a: the `HEAP EXHAUSTED`/`0x1e0` crash is completely gone from a fresh run. **This is explicitly a relief valve, not the real fix** - the underlying leak (oversized blocks never reclaimed) is unchanged and a large enough negative case could still exhaust even 256MB the same way; the proper fix (a real free-list or reclaim path for oversized allocations in `GuestHeap`) remains open, tracked together with the pre-existing, related task #24 ("Fix GuestHeap::Alloc's O(n) linear-scan allocator with a proper free-list" - note: the allocator itself is already a proper size-classed free-list for ≤64KiB requests, not O(n) linear scan as that task's original description says; the description is stale and the REAL remaining gap is specifically the oversized-block reclaim path documented here, not what #24 originally described). + +**New, separate fault surfaced immediately past the fixed one** (expected forward progress, not a regression - same pattern as several earlier fixes this session): `MEM FAULT READ_UNMAPPED guest_addr=0x3d3d3d3d size=4 at guest PC=0x471300 LR=0x79e20`. `0x471300` is inside `sub_4711C8` (the real "//Attributes" shader-section builder - `aAttributes` string `"//Attributes\n//==========\n"`, sibling of `sub_4702D8`/`sub_46FD58`), at the line `v13 = *v7` while walking what looks like an intrusive map/tree of attribute nodes - `v7` (the node pointer being dereferenced) itself holds the bad address. `0x3d3d3d3d` is literally four `'='` bytes (`0x3d` = ASCII `=`) read as if they were a pointer - consistent with memory that should hold a tree/map node instead holding text data (a type-confusion or use-after-free-style corruption), though not yet confirmed as such. + +The user recalled (from memory, not yet re-verified against this specific binary) a convention from a different/earlier project where a string missing its localization gets wrapped as `====[STRING]====` with additional `X` characters worked in. Searched this binary's full string table for that literal pattern (`=+\[`, `\]=+`, `=+%s=+`) and for `notranslated`/`noloc`/`missing.*loc` variants - **found nothing matching**; every `====`-containing string in this binary is one of the shader-section comment headers already documented above (`//Uniforms`, `//Attributes`, `//Varying`, `//VERTEX SHADER`, `//FRAGMENT SHADER`), not a localization-fallback wrapper. + +**New standing methodology, adopted at the user's explicit direction from this point on** ([[feedback_verify_engine_theories_on_native_hw]]): check every theory about *why* emulated guest code misbehaves against real, unmodified ARM32 execution (`trace_agent` on the Galaxy A9, or a small standalone armeabi-v7a repro) before sinking more time into static/emulated-only analysis - the `ostream_stress` check two entries up settled the heap-exhaustion question in under a minute where static reasoning alone had not. + +**Applied immediately to the `0x3d3d3d3d` chase - decisive result.** Live-probed `sub_4711C8`'s own entry in the emulated engine first: `a1[27]` (bucket-array pointer, `0x43691a4`) and `a1[28]` (bucket count/index) read fine, but `bucketArray[0]` itself already holds `0x3d3d3d3d` - the corruption is in the bucket array's own storage, not a node deep in some chain. A raw byte dump around `bucketArrayPtr-32` removed all doubt: +``` +" //VERTEX SHADER\n//=============\n\n" +``` +literally sitting where the attribute map's bucket-array payload should be - and the would-be `GuestHeap::BlockHeader` immediately before it (`magic` field) decodes as `"ER\n/"`, i.e. also just more of the same string, not a real header at all. **This is real text data occupying memory that should hold live map bucket data** - a classic reuse-of-still-referenced-memory bug, not a null/garbage-pointer situation. + +Then, per the new rule, deployed the `native32` flavor + `trace_agent` to the Galaxy A9 to check what the REAL, unmodified game does at this exact call site (`sub_46FD58`→`sub_4711C8`, same fixed addresses, same binary). Deployment note: the `wrap.sh` bundled in `app/src/main/jniLibs/armeabi-v7a/` gets dropped by AGP's own native-lib packaging pipeline even with `useLegacyPackaging=true` (confirmed missing from the built APK) - worked around by hand-injecting it into the built APK via `zip` + `zipalign` + `apksigner` (debug keystore), same procedure this project used once before (2026-09-16 entry, "Deployment mechanism itself didn't work at all initially"). The user separately flagged a real gap during setup: **this A9 install has no OBB at all** (`/sdcard/Android/obb/com.ea.games.nfs13_arm/` empty, only the APK's own minimal bundled `assets/published/fonts/...`) - worth remembering for any future comparison that depends on real game *content* (tracks/cars), though it did not block this particular check, since `sub_4711C8`'s shader-assembly path also runs for ordinary UI/menu draw calls that don't need OBB data. + +**Real hardware result: completely clean, every time.** Captured multiple full `glShaderSource` calls containing genuine `"//VERTEX SHADER...//Attributes...` output, e.g.: +``` +//VERTEX SHADER +//============= + +//Attributes +//========== +attribute lowp vec4 a_Color0; +attribute highp vec4 a_Position0; + +//Uniforms +//======== +uniform highp mat4 sys_ModelViewProjection; +... +glCompileShader(15) status=OK +``` +This pins down exactly what `sub_4711C8` is for: walk the attribute map at `a1[27]`/`a1[28]` and emit one `//Attributes\n//==========\n` section header followed by an `attribute ;` line per map entry. On real hardware this ALWAYS completes and compiles successfully - multiple shaders captured, 2-3 attributes each, no corruption, no crash. This conclusively rules out a genuine game/libc++-side bug for this code path too (matching the same pattern as the `ostream_stress` result two entries up) - the memory-reuse corruption is specific to this engine's own allocator/shim behavior, not something inherent to the compiled code. + +**Use-after-free theory REFUTED, and the real mechanism found - a genuine heap buffer overflow, tracing back to the SAME suspect function as the earlier `0x1e0` chase.** Added caller-LR-tagged logging to `Shim_malloc`/`Shim_free` (`import_shims.cpp`, all allocations ≤256 bytes) and re-ran. The crash address (`0x4369124` this run) has exactly ONE `malloc(size=12)` in its entire history and **zero frees** before the crash - it was never given back, so it can't have been handed to a second, unrelated owner. Reuse-after-free is not what's happening here. + +**What actually happens, reconstructed from the malloc/free + `__aeabi_memcpy` PLT logs around the crash**, all in the same ~5ms burst: +1. A run of identically-sized `malloc(size=12)` calls (all from the same call site, `LR=0x3de064`) lay out a tight sequence of small blocks with zero gaps: `...0x43690f8(size 24→class 32)...0x4369124(size 12→class 16, THE CRASH ADDRESS)...0x4369140...0x436915c...` +2. Immediately before the corrupting write: `GuestHeap::Free: rejected addr=0x436962c - not a valid live block` - a genuine double-free-or-wrong-pointer attempt that our own safety check caught. A live symptom of the same object's bookkeeping already being inconsistent. +3. `PreMemcpyPltCallProbe: __aeabi_memcpy LR=0x270090 dest=0x436910b src=0x9e5d23("//VERTEX SHADER\n//=============\n\n") n=0x21(33)` - `dest` lands 19 bytes into the 32-byte-class block at `0x43690f8`, so a 33-byte write there needs 52 bytes total but only 13 remain before the next block's header - **a direct, 20-byte overflow into `0x4369124`'s own memory**, exactly matching the corrupted content dumped earlier (`" //VERTEX SHADER\n//=============\n\n"`). +4. `sub_4711C8` is entered right after, reads `a1[27]=0x4369124`, and finds it already stomped. +5. A SECOND write follows immediately, same call site (`LR=0x270090`, i.e. still `sub_27003C`/`xsputn`'s bulk-copy path): `dest=0x436912c src=0x9e5c63 n=0x1a(26)` - landing **inside** the crash block's own 16-byte payload (`[0x4369124,0x4369134)`, offset 8), overflowing it too. + +Both overflowing writes belong to the SAME stream object (`v34` in `sub_46FD58`) and go through the SAME call site (`sub_27003C`'s bulk copy, called after `sub_2700E4`/`overflow()` is supposed to have grown the buffer) - but successive writes keep landing at addresses consistent with the buffer NEVER actually growing between them, just advancing by roughly the previous write's own length within the SAME cramped region. This is the exact same failure mode already suspected (but left unresolved) in the earlier `0x1e0` chase two entries up: `sub_2700E4`'s cached put-area pointers (`[a1+24]`/`[a1+28]`) not being correctly refreshed to point into a freshly-grown buffer, so the outer `xsputn` loop's bulk copy keeps writing into the SAME stale, too-small location instead of relocating. Both the heap-exhaustion crash (`0x1e0`) and this one (`0x3d3d3d3d`) now trace back to the same suspect function, just manifesting differently (unbounded growth exhausting a 64MB arena there; a direct overflow into an adjacent live allocation here, now that 256MB gives enough room to not exhaust first). + +**Manual ABI/offset reasoning about `sub_2700E4` proved too error-prone to trust** (several tentative, contradictory field-offset readings in the process) - per the new standing rule, built an isolated repro of the EXACT call shape instead of continuing to guess from decompiled pseudocode. Extended `ostream_repro.cpp` with a new export, `TestOstreamAssemblyNested`: one long first write (`"//VERTEX SHADER\n//=============\n\n"`, 33 bytes - by itself already past libc++'s SSO capacity, forcing an immediate heap transition on the very first write) immediately followed by a call across a REAL, `noinline`-forced function boundary (`WriteAttributesSectionNested`) that writes more (`"//Attributes\n//==========\n"`, 26 bytes) into the SAME shared stream - matching `sub_46FD58`→`sub_4711C8`'s own shape exactly, decoupled from every other line of game code. Wired a matching `RunOstreamAssemblyNestedReproTest` into `main.cpp` (temporary, same one-shot-diagnostic pattern as the original `RunOstreamAssemblyReproTest`). + +**Result: PASS.** `len=59 retVal=59`, content matches exactly - this exact call shape, even running through the SAME `GuestHeap` (same zero-slack, tightly-packed allocator), does NOT reproduce any corruption in isolation. This is a decisive negative result: `sub_2700E4`/`sub_27003C`'s own logic, and this engine's general handling of "long write forcing SSO transition, then a nested call writing more," are NOT the bug - the real crash needs something ELSE from the real game's specific state at that moment. + +**New lead, from the same log window**: immediately before the corrupting `"//VERTEX SHADER"` write, `GuestHeap::Free: rejected addr=0x436962c - not a valid live block` fired - a genuine double-free-or-wrong-pointer attempt that this engine's own safety check caught rather than silently corrupting something. This is a live symptom that SOME object's lifecycle bookkeeping is already wrong by this point, immediately adjacent in time to the overflow. Worth checking whether this rejected free and the subsequent overflow share one root cause (e.g. a stream/string object being relocated - a "move" - incorrectly, leaving a stale pointer that later gets freed wrongly AND leaving the object's own put-area fields pointing somewhere they shouldn't). + +**Found it: `LR=0x79f78` traced straight back to `sub_79E50`, ROOT CAUSE CONFIRMED AND FIXED - `Shim_ios_base_init`'s own earlier fix was still incomplete.** `0x79f78` is the return address immediately after `sub_79E50`'s own `BL sub_3D0C04(v22)` (free) at `0x79f74` - and `sub_79E50` is called from EVERY plain `sub_79CD4`/`operator<<(const char*, size_t)` write (xref-confirmed, unconditionally, not gated on anything visible at the call site). Real disasm of `sub_79E50` shows it reads `*(DWORD*)(a5+12)` - the stream's own `width()` field, at offset `+0xC` from the (Itanium-ABI-adjusted) `ios_base` object - and if that value is *larger* than the string being written, it allocates a scratch padding buffer, passes it through a virtual `sputn()` call, then frees it. `width()` is supposed to default to (and auto-reset to) 0 for a plain, unformatted write - a real `setw()` call is the only way it should ever be nonzero. + +**`Shim_ios_base_init` (this session's own earlier fix, in the `0x4b3f09c`/`0x1e0` entries above) only ever zeroed `__rdstate_` (+0x10) and set `__rdbuf_` (+0x18) - it never touched `+0xC` (width).** Every freshly-constructed stream in this engine inherited whatever GARBAGE happened to already be at that stack/heap offset as its own "width" - on the (apparently not-rare) occasion that garbage exceeded the length of the very first string written to it, `sub_79E50`'s padding-buffer machinery ran when nothing in the real code ever asked it to, corrupting whatever heap memory the resulting scratch-buffer alloc/vtable-call/free sequence touched - directly explaining the `0x3d3d3d3d` corruption (and, very plausibly, the earlier `0x1e0`/heap-exhaustion chase too - both traced back to the same general area of `basic_ostream`/`basic_stringbuf` machinery, though that one was never re-tested against this specific fix). + +**Fix** (`rtti_shims.cpp`, extending the existing `Shim_ios_base_init`): also zero `thisPtr+0xC` (width) alongside the existing `+0x10`/`+0x18` writes. Real `ios_base::init()` also resets `precision_` (to 6, not 0) and clears `fmtflags_`/`exceptions_`, but their exact offsets in this binary's layout aren't yet confirmed by any live evidence the way width's was - left unguessed rather than risk a wrong write, since nothing observed so far depends on them. + +**Verified live on the Pixel 6a - the `0x3d3d3d3d` crash is completely gone.** `test_on_device.sh` now reports `MEM FAULT lines: 0` and `engine crashed (fail-fast tripped): no` - this engine's own fail-fast never trips at all anymore. Execution progresses dramatically further: the process now dies from a **real, native SIGSEGV** (not an emulated-guest fault) - `Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid ... (GLThread 707)`. This is expected forward progress, not a regression - shader assembly (this whole multi-entry investigation's subject) now completes successfully for the first time, and execution has moved on into real GLES rendering territory on the GL thread, a brand new frontier. Task #33 (`0x3d3d3d3d`) is now RESOLVED. + +**2026-09-17 (later still): the SIGSEGV, then a SIGABRT, then FULL RESOLUTION - real, sustained GLES rendering achieved for the first time in this project's history.** + +Symbolicated the SIGSEGV's real tombstone via `llvm-addr2line` against the unstripped build output (`mpcore/build/intermediates/cxx/Debug/.../libmpcore.so`, NOT the stripped APK copy): `_JNIEnv::GetMethodID` → `JniSlotDispatch` (`jni_shim.cpp:373`) → `helper_uc_tracecode` (Unicorn's own hook-dispatch helper) → JIT'd guest code, `fault addr 0x0`. Root cause: `JniShim::RealEnv()` is `thread_local` (correctly) but the real, JVM-managed thread named "GLThread" (later confirmed to be whichever thread calls `RunLoop.nativeOnRunLoopTick()`) never had it populated - `Impl_Vm_AttachCurrentThread` only ever built a FAKE guest JNIEnv handle and never actually attached the calling HOST thread to the real JVM, so any real JNI call on that thread dereferenced a null env. **Fix**: `Impl_Vm_AttachCurrentThread` now calls the real `JavaVM::AttachCurrentThread` (via a newly-cached process-wide `JniShim::real_vm_`, obtained once via `env->GetJavaVM()` the first time any thread supplies a real env) and stores the result via `SetRealEnv`; `RealEnv()` itself also got a defensive lazy-auto-attach fallback in case guest code never explicitly attaches. + +That fix exposed a real ART SIGABRT immediately after: `JNI DETECTED ERROR IN APPLICATION: JNI ERROR (app bug): jclass is an invalid local reference: 0x... (popped reference at index N in a table of size 0) in call to CallStaticObjectMethodA from void com.ea.ironmonkey.RunLoop.nativeOnRunLoopTick()`. First patched the ALREADY-KNOWN bug class (`Impl_GetMethodID`'s own 2026-09-05 comment - a cached local jclass reused across threads without `NewGlobalRef`, a real bug in the ~2013 game code) into every OTHER jclass/jobject-consuming `Impl_*` function missing the same `IsSafeToUseFromCurrentThread` guard (`IsInstanceOf`, `GetStaticMethodID`, `GetFieldID`, `GetStaticFieldID`, `NewObject`, `NewObjectV`, `GetObjectClass`, `NewObjectArray`, `ToReflectedMethod`, `ToReflectedField`, `GetSuperclass`, `IsAssignableFrom`, `AllocObject`, `NewObjectA`, `UnregisterNatives` - 15 call sites) - the SAME crash kept reproducing identically. Re-symbolicating the crash frame each time eventually pointed at `DoCall`/`DoCallV`/`DoCallA` - the SHARED implementation behind every `Call*Method`/`CallStatic*Method` variant, which resolves `r1` as a `jobject recv`, but for a STATIC call `r1` is actually the **jclass** (real JNI: `CallStaticXxxMethod(env, clazz, methodID, ...)`) - and this shared function had no guard at all. Fixed there too (plus `DoCallNonvirtualA`'s explicit `clazz` arg) - still reproduced, byte-identically. + +**Real root cause, one level deeper than "cross-thread"**: `IsSafeToUseFromCurrentThread` checked *only* the owning host thread, but real JNI local references are scoped to the single native-method invocation (JNI frame) that created them, not just the thread - confirmed directly: `real_native_call.h`'s `CallRealNative` (which implements `nativeOnRunLoopTick` and every other real-native-call entry point) calls `SetRealEnv(env)` on **every single entry** - once per frame/tick, each a genuinely separate JNI call with its own fresh local-ref frame per ART's own semantics. A jclass cached during one tick and reused during a *later* tick is stale the instant the earlier tick returns to Java, even on the exact same host thread throughout - the 2026-09-05 "different thread" framing was half the picture; "different top-level call" is the fuller, correct one (that entry's own class comment had already noted "and, strictly, the native-call frame" - the implementation just never caught up to its own comment). + +**Fix**: added a global, monotonic call-epoch counter to `JniHandleTable` (`g_callEpoch`, `BumpCallEpoch()`), bumped from `JniShim::SetRealEnv` (exactly the "a new top-level native call is beginning" boundary), stored per-entry at `Alloc()` time, and checked alongside thread ownership in `IsSafeToUseFromCurrentThread` (now requires both same thread AND same epoch, unless the entry is a durable global ref). + +**Verified live on the Pixel 6a, full 150s run**: zero engine fail-fast trips, zero native crashes, 31 gracefully-degraded "local reference from a different thread/call" detections (the guard doing its job instead of the process aborting), and - the actual milestone - **sustained, real GLES rendering**: `GLES: last 3s - glClear=155 glDrawArrays=0 glDrawElements=620 (31620 indices) glUseProgram=310`, steady across the entire run. This is the first time this engine has been confirmed to render real geometry end-to-end. Task #18 ("why do GLES draw calls stay at zero", open since early in this project) and task #34 are both resolved by this fix chain. + +**Not yet resolved** (lower-priority items surfaced by this milestone, not blocking it): `onCreate` still hasn't been observed completing within a 150s window, though the app is clearly doing substantial real work well past app-start now - worth a fresh, patient timing check now that rendering itself works. The pre-existing `GuestHeap` oversized-allocation-reclaim gap (task #24) remains open as a latent risk for longer runs. + +**2026-09-17 (later still): confirmed the latent risk is real - ran a full 480s (8-minute) session at the user's request to see whether `onCreate` eventually completes.** + +It doesn't - and the reason is exactly the one flagged as a risk two entries up. `GuestHeap::Alloc: HEAP EXHAUSTED - payload=179760 needed=179772 but only 112708 bytes remain (free_cursor_=0x10afb7bc arena_end_=0x10b17000)` fires ~8 minutes in - the 256MB arena (bumped from 64MB earlier this session) is now fully consumed, same root mechanism as before (oversized, >64KiB allocations are never reclaimed on `free()`; a real, sustained rendering session's own churn - now that rendering genuinely runs - burns through even 4x the original headroom eventually). The failed allocation (`payload=179760`, ~175KB) returns null; the caller doesn't check, and the resulting `this=0x0` gets used for a virtual call - reading its "vtable pointer" from guest address 0 returns `0x464c457f`, which is literally the loaded image's own ELF magic (`\x7fELF`) - guest address 0 maps directly to the start of the mapped `libapp.so`, so a null-pointer dereference at offset 0 reads the file header instead of faulting immediately, and the fault only manifests one dereference further in (`[[R0]+68]`, `0x464c457f` being far outside `region_size=0x1ab46000`). + +**Conclusion for the user's own question ("does onCreate ever finish"): not within 8 minutes, and the blocker is the same known gap, not a new one.** The 256MB bump was correctly described as a relief valve, not a fix, when it landed - this confirms that assessment directly rather than leaving it theoretical. The real fix (task #24 - a genuine free-list/reclaim path for `GuestHeap`'s oversized allocations, so a real, long-running session doesn't have to keep growing the arena to survive) is now empirically justified, not just a tidiness concern. + +**2026-09-18: a DIFFERENT, earlier crash found - `sub_4BA588` ("loadNodeUncached") dereferences an empty `std::vector` without a null check, ~9 minutes into real gameplay, well before the heap-exhaustion crash above ever gets a chance to fire.** `MEM FAULT READ_UNMAPPED guest_addr=0x464c457f size=4 at guest PC=0x4ba6b4 LR=0x4ba60c` - same "guest address 0 IS the mapped image's own ELF header, so a null dereference reads `\x7fELF` instead of faulting immediately, and the real fault only manifests one dereference further in" pattern as the heap-exhaustion crash two entries up, but a completely different root cause and call site. + +Full decompile of `sub_4BA588` plus its two real callees (`sub_494EF8`, the ".sba load by name" resolver; `sub_495698`, the real M3G binary-chunk parser) shows the exact mechanism: `sub_4BA588` calls `sub_1EDC48(&v20, &v16)` (a real `std::vector::operator=`) then immediately does `v12 = *v20` unconditionally - if the source vector is truly empty, `sub_1EDC48` legitimately sets `v20` itself to `nullptr` (confirmed via its own decompile), and `*v20` then reads guest address 0. Real ARM32 disasm confirms this exact unconditional dereference is present in the actual shipped machine code (`LDR R0,[SP,#0x14]; LDR R6,[R0]; CMP R6,#0; BEQ ...; LDR R0,[R6]` - the `CMP`/`BEQ` only guards against `*v20` itself being null, never against `v20` being null) - so if any real code path ever produces this exact "truly empty vector" case for a live gameplay node, it would crash identically on real hardware too. Since this is a shipped, working commercial game, real hardware must never hit this specific state for whichever resource we're hitting it for. + +Root-caused the exact empty-producing branch via 5 new targeted probes bracketing all 3 places in `sub_494EF8`/`sub_495698` that can legitimately null out the destination vector (`sub_494EF8`'s own "not found: \"...\"" branch, `sub_495698`'s "Unsupported M3G file format" branch, and a generic pre/post pair around `sub_494EF8`'s call into `sub_495698` catching a legitimate-but-empty parse): **`sub_495698` hits its own "Unsupported M3G file format; probably not an M3G file." branch** (a real game log line, tag `m3g::Loader`, confirmed printed by the actual EA code, not a diagnostic artifact) - `Sub494EF8NotFoundProbe` never fires (the resource IS found/resolvable), `Sub495698UnsupportedFormatProbe` fires every time right before the crash. + +Dumped the actual 12-byte "magic GUID" buffer `sub_495698` compares against the 3 known-good M3G chunk-type signatures (`unk_A395F9`/`A39605`/`A39611`, which decode as the classic PNG-style magic pattern `«JSR184»\r\n\x1a\n` / `«IM-M3G»\r\n\x1a\n` / `«IM2M3G»\r\n\x1a\n` - JSR184 being the actual standard name for the M3G format EA's own `im::isis` asset pipeline is built on): `00 00 00 00 8c 0a 4f 00 88 39 34 15 cc 39 34 15`. **This is not corrupted-but-real data - it's literal, never-written stack garbage**: the leading 4 bytes are zero, and the trailing bytes are themselves live stack ADDRESSES in the exact same `0x1534xxxx` region as the crash's own `SP` - i.e. leftover values from a previous stack frame, never overwritten by any actual read. Confirmed no fresh `.sba` disk load happens for this specific call (the existing `SbaLoadCallProbe`/`SbaLoadResultProbe`, which reliably fires for every real texture `.sba` load, does not fire between this call's entry and the crash) - `sub_494EF8` took its "cache slot already resolvable" fast path (`if (v8) {...} goto LABEL_22`, skipping the real vtable+32 file-existence/load call entirely) and handed `sub_495698` a reader that, per this evidence, produces zero real bytes. + +Checked whether either of this session's two existing real-ARM32 A9 captures (`a9_trace.log`, `a9_native_trace.log` - see [[reference_a9_trace_agent_full_trace]]) could settle whether real hardware ever takes this same path: both are far too short (~55s and ~68s respectively, both ending during early app startup) to reach the ~9-minute-deep gameplay state where this crash occurs - genuinely inconclusive, not a real comparison. What IS already certain without needing a new capture: this is a shipped, normally-functioning game, so whatever this code path does on real hardware for equivalent gameplay, it does not end in an unhandled null dereference - meaning our engine is doing something different (most likely: producing an empty/no-op reader in a case where real hardware's own reader actually returns real cached bytes). + +Added one more pre/post probe pair (`Sub495698ReadCallProbeHookCb`/`Sub495698ReadResultProbeHookCb`) bracketing `sub_495698`'s own first "read 2 bytes" virtual dispatch (`LDR R0,[R2]; LDR R1,[R0]; LDR R3,[R1,#0xC]; BLX R3` @0x4956cc-0x4956e4) to see which object and which real function this reader's vtable actually dispatches to - an unresolved-import-stub target would directly explain "silently returns nothing." + +**Result, live on the Pixel 6a: decisive.** The very same real function, `sub_3D79EC` at guest address `0x3d79ec`, is dispatched to for BOTH a working call (`readerObj=0x108d776c` → returns `R0=0x2`, the requested byte count, success) roughly a second before the crash, AND the crashing call (`readerObj=0x10ae3dd8` → returns `R0=0x0`, nothing read). Not an unresolved stub, not a wrong-function-pointer bug - a real, correctly-resolved function that legitimately returns "0 bytes available" for one specific reader object's underlying data. + +**Decompiled the full reader-construction chain to explain why**: `sub_494EF8`'s cache-slot fast path (`if (v8) {...} goto LABEL_22`, `v8` == the freshly re-canonicalized resource name, cached each call at `*(a2+20)`) wraps `v8` via `sub_3F4CB8` into a lightweight "byte-range source" object - this wrapper does no copying, it just takes `v8`'s own two fields at offset `+12`/`+16` as a `[begin, end)` pointer pair and stores them verbatim (`result[1] = *(a2+12); result[2] = *(a2+16);`). `sub_3D795C` then wraps THAT into a buffered reader: allocates and `memset(0)`s its own internal 4096-byte scratch buffer, and - critically - starts the buffer's "get" cursor at the buffer's OWN END, i.e. "empty, must refill from source before any read succeeds." `sub_3D79EC` (the read function itself, confirmed live above) refills by calling the wrapped source's own vtable+12 read, which for this lightweight range-wrapper almost certainly just returns `min(requested, end-begin)` and advances a cursor - if `begin == end` (the wrapped resource-name object's own byte range is empty), every single read attempt legitimately, correctly returns 0 forever, with no error of its own - it's doing exactly what an empty range should do. + +**This reframes the whole crash**: it is not a parser bug, not a stream-position bug, and not (per the still-refuted original theory) a wrong-file-served bug. It's that **the resource-name object `sub_494EF8` is reusing via its cache-slot fast path has an empty `[begin,end)` byte range for this specific "loadNodeUncached" resource, at this specific ~9-minutes-in moment**, while a DIFFERENT resource requested roughly a second earlier had a real, non-empty range. Added one more probe (`Sub494EF8CacheFastPathProbeHookCb`, real disasm confirms `R1 == v8` exactly at the `BL sub_3F4CB8` call site, `0x495030`) to read `v8+12`/`v8+16` directly and confirm this empty-range theory head-on, rather than only inferring it from the read()-returns-0 symptom. Built, running live now (round 4). + +**Working theory before the next round, now SUPERSEDED**: originally suspected an ordering/timing issue around `v8`'s own `[begin,end)` byte-range population (see struck-through reasoning above). Added `Sub494EF8CacheFastPathProbeHookCb` at the `BL sub_3F4CB8` call site (`0x495030`) to confirm this directly. **Ran round 4: this probe fired ZERO times across the entire 9-minute session** - the crashing call never takes the cache-slot fast path at all. Theory refuted before it could even be tested against the crash itself. + +**ROOT CAUSE FOUND, and it unifies this ENTIRE investigation with the earlier, separately-documented heap-exhaustion crash from four entries up.** Filtered the full round-4 log down to just the crashing call's own thread (tid), isolating the true causal sequence (the log's wall-clock interleaving across multiple real guest threads had been obscuring this). The actual sequence immediately preceding the crash, all on the same thread: + +``` +GuestHeap: free(0x10ad8b34) from guest LR=0x645b3c +GuestHeap: malloc(size=32) -> 0x10ad76e8 from guest LR=0x415cb0 +GuestHeap::Alloc: HEAP EXHAUSTED - payload=179760 needed=179772 but only 74524 bytes remain (free_cursor_=0x10b04ce4 arena_end_=0x10b17000) - returning 0 (null) +GuestHeap: free(0x10ad8da8) from guest LR=0x415cdc +... +GuestHeap: malloc(size=36) -> 0x10ad8d5c from guest LR=0x495098 <- j_malloc_0(0x24), sub_494EF8's own `v20 = j_malloc_0(0x24u)` (LABEL_22, right before constructing the buffered reader via sub_3D795C) +GuestHeap: malloc(size=30) -> 0x10ad7714 from guest LR=0x3de154 +Sub495698ReadCallProbe: about to BLX into 0x3d79ec - readerObj=0x10ad8d5c ... <- SAME ADDRESS as the malloc(size=36) two lines above +Sub495698ReadResultProbe: read call (target=0x3d79ec) returned R0=0x0 +Sub495698UnsupportedFormatProbe: ... +Sub4BA588V20Probe: v20=NULL ... THIS IS THE CRASH +MEM FAULT READ_UNMAPPED guest_addr=0x464c457f ... +``` + +**The mechanism, now fully traced end to end**: EA's own asset-loading code requests a large (~175KB, `payload=179760`) buffer to hold the M3G resource's real raw byte content. `GuestHeap::Alloc` fails this request - the same 256MB arena from the earlier heap-exhaustion crash is, once again, exhausted (oversized allocations still never reclaimed on `free()` - task #24, still open) - and returns null. **The real EA code does not check this allocation for null** (entirely reasonable on real hardware, where a request this size essentially never fails during normal gameplay against a real, much larger, real-`malloc`-backed heap) and proceeds to construct the buffered reader (`sub_3D795C`) around this null/empty source anyway. The reader's own `read()` (`sub_3D79EC`, confirmed live to be a real, correctly-resolved function, not a stub) then legitimately, correctly returns 0 bytes forever, since its wrapped source has nothing to give - `sub_495698`'s M3G format check sees all-zero/garbage stack memory instead of real file bytes, logs "Unsupported M3G file format" (a real, working, correct error path - just fed empty input by something upstream), and `sub_4BA588` crashes on the resulting empty vector exactly as already root-caused above. + +**This crash and the earlier-documented `onCreate`-never-completes heap-exhaustion crash (four entries up) are THE SAME underlying bug, not two separate ones** - just two different call sites among what are likely MANY unchecked-allocation call sites throughout the real ~2013 EA codebase, all equally exposed the moment `GuestHeap`'s arena runs out. The multi-hour detour through `sub_494EF8`/`sub_495698`/`sub_3D795C`/`sub_3F4CB8`'s M3G-parsing machinery was real, accurate, and necessary to trace the SYMPTOM correctly, but the M3G parser itself, the stream-cursor machinery, and the "cache-slot fast path" theory were all downstream red herrings - the actual defect is exactly where task #24 already said it was. + +**This also cleanly explains why real hardware never logs "Unsupported M3G file format" for this resource** (the specific detail that triggered this whole investigation, per the user's own observation) - on real hardware the backing allocation never fails, so the buffer is always populated with real bytes and the format check always passes. + +**Conclusion: task #24 (add reclaim/free-list support for `GuestHeap`'s oversized, >64KiB allocations) is the single real fix for BOTH this crash and the earlier heap-exhaustion crash** - not a `sub_4BA588`-specific null-check patch (which would only silence this one symptom while leaving every other unchecked-allocation call site in the real codebase equally exposed the next time the arena fills up). + +**2026-09-18 (implementation): task #24 done.** Discussed approach with the user first - three real options (widen the existing size-class free list to cover all sizes; an exact-size best-fit `std::multimap`; a simple unified first-fit list for the oversized tier) - user picked widening the existing scheme (`guest_heap.h`/`.cpp`'s `kNumSizeClasses`, 14 → 27, raising the ceiling from 64KiB to 512MiB) as the smallest, most consistent diff, reusing the already-proven O(1) size-classed free list from the 2026-09-06 fix instead of adding a second, differently-shaped reuse mechanism next to it. The old "oversized, bump-only, never reclaimed" branch in `GuestHeap::Alloc`/`Free` still exists but is now a genuinely rare fallback (only for requests bigger than even the widened 512MiB ceiling) rather than the routine path anything over 64KiB used to take. Also added the same loud "HEAP EXHAUSTED" logging (from the 2026-09-17 fix) to the pooled path's own exhaustion check, which had stayed silent since it used to only matter for small, rarely-exhausted classes - now that it's the primary exhaustion path for large allocations too, losing that diagnostic would have been a silent regression. + +Added two new tests to `guest_heap_test.cpp` (`mpcore/scripts/run_heap_tests.sh` - desktop-only, no Android/Unicorn/APK, runs in well under a second): an oversized alloc/free/reuse round-trip at the exact `175760`-byte size from the real crash log, and a 500-iteration repeated-alloc/free regression test in a 1MiB arena (sized so the OLD behavior would have exhausted it after ~5 iterations) confirming the new behavior sustains all 500. All 12 checks pass (10 pre-existing + 2 new). + +**Verified live on the Pixel 6a, full 10-minute run (covering both this crash's ~9min mark and the earlier heap-exhaustion crash's ~8min mark with margin)**: `MEM FAULT lines: 0`, `engine crashed (fail-fast tripped): no`, `Unsupported M3G file format: 0` occurrences (previously fired reliably every time this crash reproduced) - the crash is gone. GLES rendering stayed fully active through the entire window (`glDrawElements=176` at both the 3-minute mark and the 10-minute mark, no degradation), and the process was still alive and running normally when the capture window closed. Three `HEAP EXHAUSTED` events DID still occur (~3 minutes in, for two single, much larger one-off requests - 64MiB and 128MiB size classes, a completely different shape from the old small-object-churn unbounded-growth pattern) but degraded gracefully with no crash and no lasting effect - rendering continued unaffected for the remaining ~7 minutes. Not itself alarming, but worth keeping an eye on if a future session's own workload pushes a genuinely-simultaneous multi-hundred-MB working set again. + +**2026-09-18 (new investigation): a completely different symptom, found by actually looking at the screen instead of only log-level draw-call counts - the app renders real content internally but the DISPLAY stays frozen on the EA splash (or, after an app-switch/resume cycle, on a black frame) indefinitely.** Confirmed via `dumpsys` that `GameActivityMain` genuinely is the focused, resumed activity (not a lockscreen or system overlay) - this is a real in-app rendering/presentation bug. + +**Root mechanism, traced end to end via `GameRenderer.java`/`GameActivityMain.kt`/live logcat**: `GameRenderer.onDrawFrame` has exactly two branches - while `drawFrameListener != null`, it calls into Kotlin's own splash state machine (`STATE_SPLASH` -> ... -> `STATE_GAME_START`); once that machine's completion condition fires (`nativeRestoreContext()` returns `true`, confirmed via the real EA log line `Renderer::RestoreContext Finished`), it calls `nativeOnStart()`/`nativeOnResume()` **synchronously, from within this same `onDrawFrame` call**, then nulls the listener. From that point on, `onDrawFrame`'s "else" branch is `RunLoop.onRunLoopTick()` - but `RunLoop.start()` is **never called anywhere in `GameActivityMain.kt`** (confirmed via `grep`), so `RunLoop.state` stays `STATE_STOPPED` forever and `nativeOnRunLoopTick()` (confirmed via its real offset, `0x54e100`/`REAL_RUNLOOP_TICK_OFFSET`, never once appearing in any capture) is never invoked at all. + +Meanwhile, real ARM32 engine code - genuine asset-loading (`useAssetsFileSystem()` JNI up-calls, dozens of them, real bulk `memcpy`s) AND genuine GLES rendering (`glClear`/`glDrawElements`/`glUseProgram`, hundreds per 3s window, same magnitude as confirmed real gameplay) - is clearly executing, all on the real Android-owned "GLThread" (confirmed via `/proc//task//comm`). The only way that's possible is if `nativeOnResume()` (called synchronously from `onDrawFrame`, on GLThread) **never returns** - all of this real work is happening inside that one still-running call. Since `onDrawFrame` never returns, `GLSurfaceView`'s own automatic "swap buffers after `onDrawFrame` returns" never fires again either - and since `eglSwapBuffers` itself is never once called anywhere in any capture (checked directly, 0 occurrences, not even as an "unresolved import"), nothing else presents the real rendering to the screen. The visible framebuffer simply stays on whatever was last actually swapped (the splash, or a black frame) forever, while real rendering silently accumulates in a buffer nobody ever presents. + +**Initial hypothesis (before verifying) was that this was purely a performance/timing gap** - this project has documented history (below, 2026-09-06) of a confirmed ~284x real-vs-isolated slowdown from hundreds of individually-registered Unicorn `UC_HOOK_CODE` hooks, with an already-written, already-implemented fix (`AllocCodeStub`'s stubs consolidated into one dispatcher). Re-checked that fix directly: **it's already fully in place** (dated 2026-09-06 in the code's own comments, well before this session) - `misc_stub_dispatch_table_`/`MiscStubDispatch`/one `uc_hook_add` per engine, exactly as designed. But `EnsureThreadEngine` had since accumulated **58 individual `uc_hook_add` calls total (52 of them single-address `UC_HOOK_CODE`)** from many sessions' worth of ad-hoc diagnostic probes (shader-assembly chase, `0x1e0`/`0x3d3d3d3d` heap crashes, strchr theory, pthread_once stack corruption, Xiaomi 14 hang, OBB-index-build tracing) that were never removed once their own questions were answered - reintroducing the exact same shape of problem the 2026-09-06 fix eliminated, just via a different population of hooks. Removed the 28 confirmed-resolved ones (every investigation above has its own completed task and/or documented fix in this file), keeping only the still-active M3G/`sub_4BA588` chain probes, `PreMemcpyPltCallProbeHookCb` (still a general-purpose tool), and `FnvHashAccelHookCb` (a real perf fix, not a diagnostic) - down to 20 total / 13 single-address `UC_HOOK_CODE`. Clean build, verified live on the Pixel 6a: no regressions, no crashes, draw-call ramp-up timing unchanged from before the cleanup. + +**Confirmed this does NOT explain the frozen-display symptom** - even with the hook count cut by ~75%, the exact same pattern reproduced: real rendering starts around the same ~25-30s mark, `useAssetsFileSystem()` stops being called around the same point, and the screen stays frozen (black, this run) for the entire ~90s capture. This is decisive: the frozen display isn't a "just needs to run faster" problem, it's a structural one - `nativeOnResume()`'s real ARM32 implementation apparently runs its own persistent internal loop and never returns to Android's callback, and nothing (neither the real guest code nor this engine) ever calls `eglSwapBuffers` to present what it renders. + +**2026-09-18 (continued): decompiled `nativeOnResume` (`0x54c920`) directly - it's trivially small, a 2-instruction tail call (`B sub_3F8648`, confirmed via real disasm, not `BL` - so `nativeOnResume`'s own "return" is whatever `sub_3F8648` eventually returns to).** `sub_3F8648` is a generic, bounded (exactly 16 iterations) lifecycle-listener dispatcher - for each of up to 16 registered listener objects, calls that object's own vtable-slot-5 method (`*(*listener + 0x14)`, real disasm confirms `LDR R1,[R0]; LDR R1,[R1,R4]; BLX R1` where R4 is a "message type" - `&dword_14` for the resume broadcast specifically). + +Added a live pre/post probe pair bracketing that exact `BLX R1` call site (`0x3f8684`/`0x3f8688`) to catch which listener(s) take a long time, or never return at all. **Result, live on the Pixel 6a, 48-second window: 47,026 dispatch calls total, from 5 distinct real call sites (`sub_468320`, `sub_64054C`, `sub_3E3D7C`/vtable, `sub_3D58BC`/vtable, plus one showing sentinel `LR=0xfffffff0`), each firing ~2,350 times - i.e. each site fires roughly once per frame, continuously, for the entire 48-second capture (~49Hz).** This is decisive, and changes the picture significantly: `sub_3F8648` is NOT a rare, resume-specific call that hangs once - it's a routine, shared, per-frame broadcast utility (confirmed via `xrefs_to`: `sub_64054C` alone has 46 distinct call sites throughout the binary; `sub_3E3D7C`/`sub_3D58BC` are reached only via vtable/data xrefs, i.e. virtual dispatch from some generic "tick every subsystem" driver) - and it keeps firing at a steady per-frame rate for the ENTIRE session, meaning **a real, continuously-running internal frame loop IS active the whole time - this was never a hang in the traditional sense.** + +This confirms (now with much stronger evidence than the original theory) that `nativeOnResume()`'s real implementation contains or triggers the game's own persistent internal loop, running synchronously inside that one native call, on the real Android GLThread, for the entire session - it never returns to `onDrawFrame`, so Android's own automatic post-return `eglSwapBuffers` never gets a chance to fire again. + +**The remaining, now much narrower question: why does this actively-running, per-frame loop never itself call any EGL presentation function.** Checked directly: `eglSwapBuffers` - 0 occurrences in any capture (not even as an unresolved import, meaning the guest code never even attempts it). Also checked `eglGetProcAddress` (which real engines sometimes use to resolve a swap-with-damage extension variant instead of the core function) - also 0 occurrences, ruling out an unrecognized-extension-name theory. + +**2026-09-18 (continued): traced the actual call chain four levels deep, live, using the same entry-probe-plus-real-LR technique at each level - this is real `IsisApp` engine architecture, not a hang.** + +1. `sub_3EB650` ("IsisApp::Update"/"IsisApp::Render", per its own embedded profiling-zone strings) is called from exactly ONE site, every single time (1059/1059 calls in a 27s window, same `app=0xb6fc20`, same `SP=0x12343dc8`): `0x3d5980`, inside `sub_3D58BC` - specifically the `(*(vtable+140))(a1)` virtual call. I.e. `sub_3EB650` literally IS `IsisApp`'s vtable-slot-140 method. +2. `sub_3D58BC` (task #26's own "periodic per-frame subsystem tick" dispatcher - real decompile: lock-gated via `sub_54BC9C`/`sub_54BC94`, one-time lazy init via vtable+8/+20, then vtable+144 and vtable+140 every call) is ALSO only reachable via vtable (4 data xrefs) - traced its own real caller live too: 490/490 calls came from `LR=0x3f8688` - which is `sub_3F8648`'s OWN loop-internal return address. **`sub_3D58BC` is itself registered as one of `sub_3F8648`'s up-to-16 generic listeners** - i.e. the whole `sub_3EB650`/render chain is triggered as a REACTION to some OTHER broadcast, not from a `while` loop of its own. +3. Probed `sub_3F8648`'s own real entry point (`0x3f8648`) directly, logging the message-type param (R1/`a2`) and real caller LR for every invocation: two dominant, roughly-equal-rate message IDs appeared - **`msgId=0x44`** (157 times/35s) with `LR=0xfffffff0` - **this is `GuestEngine::kCallReturnSentinel` (guest_engine.h:341) verbatim** - the fixed LR value this engine sets for every TOP-LEVEL `CallGuestFunction()` invocation, confirming this specific broadcast is fired directly from a top-level native entry point (matching `pthread_shim.cpp`'s own `CallGuestFunction(startRoutine, arg)` pattern for a guest-created worker thread's start routine, not from anywhere inside the render chain) - and **`msgId=0x54`** (156 times/35s) with `LR=0x468338`, matching `sub_468320`'s own internal call site (the SAME lifecycle-listener wrapper `sub_3EB650` itself calls at the tail of its render branch, `sub_468320(v32)` @ 0x3ebab4) - i.e. `msgId=0x54` is the render chain broadcasting its OWN completion, not the trigger for the next iteration. The one-time `&dword_14`/`msgId=0x14` "OnResume" broadcast this whole chase started from appeared only twice (matching "resume fires once"), confirming `sub_3D58BC` is NOT registered against that one-shot message at all - it's driven by something else, firing continuously. + +**Checked one more theory directly rather than continuing to guess: is there simply a missing/unimplemented presentation-related shim (an unresolved import for `ANativeWindow_*`, `eglSwapBuffersWithDamageKHR`, or similar) that the guest code silently skips calling once it sees a null function pointer?** Grepped every capture this investigation produced for "unresolved import" - **zero occurrences, in any of them.** Every symbol the guest code has actually tried to call has a real, registered handler in this engine - ruling out "missing shim" as the explanation for the frozen display. + +**2026-09-18 (continued): the `msgId=0x44`/sentinel-LR lead turned out to be a red herring - checked `CallGuestFunction`'s own source directly.** `lr = kCallReturnSentinel` (guest_engine.cpp:3106) is set unconditionally on **every** `CallGuestFunction()` invocation, including reentrant/nested ones (the same mechanism behind this session's own "REENTRANT call #N" diagnostic elsewhere) - it does NOT distinguish a genuinely-fresh top-level entry from a nested up-call made from within an already-running guest call chain. Confirmed both `msgId=0x44` and `msgId=0x54` fire on the exact same tid throughout - one ongoing GLThread execution, not a separate worker thread. This doesn't overturn the core "nativeOnResume never returns" finding (still solid, independently established), it just rules out "a second thread is what's re-triggering everything" as the explanation. + +**Redirected effort toward the actually-promising lead: what do the three real vtable calls sub_3EB650 makes (`+168`/`+116`/`+144`) actually do?** Repurposed the `sub_3EB650` entry probe to read the object's REAL runtime vtable pointer (`*app`) live, rather than guessing from static analysis (which had found the same function's address in two DIFFERENT static tables with no way to tell which one this specific object actually uses). Result, confirmed live and self-consistent (slot+140 correctly read back as `sub_3EB650`'s own address): **vtable = `0xa9b248`, slot+168 = `0x78a1c`, slot+116 = `0x77794`, slot+144 = `0x3ebd28`.** + +**Decompiled all three - decisive, if unexpected: `+168` (`nullsub_26`) and `+116` (`nullsub_21`) are both literally empty function bodies (no instructions at all beyond the return), and `+144` (`sub_3EBD28`) is a trivial one-line predicate (`return dword_AC80D0 < 1`) - none of it touches rendering, EGL, or anything resembling presentation.** This is a genuine dead end for the "find the swap call" question specifically - the entire `sub_3EB650` render branch this multi-level chase carefully traced down to, while real and confirmed actively executing every frame, does essentially nothing on its own in this exact build/configuration. (Plausible explanation, not yet confirmed: `IsisApp` looks like a generic app-lifecycle base class with overridable render hooks: this specific derived class/configuration simply doesn't need them, or the real per-frame rendering happens through a completely different mechanism this chain never touches.) + +**Where this leaves the investigation**: the top-down trace from `nativeOnResume` (four levels deep, fully confirmed live at every step) firmly established that a real, continuous, correctly-executing engine loop runs forever inside that one native call and never returns to Android - but it also firmly ruled out this SPECIFIC branch (`sub_3D58BC`/`sub_3EB650`'s own vtable calls) as the source of the actual GLES rendering already confirmed happening every frame. + +**2026-09-18 (continued, ROOT CAUSE FOUND): checked ground truth on the Galaxy A9 (real, unmodified armeabi-v7a `libapp.so`, no emulation) via `trace_agent` - this whole session had never actually verified this specific investigation against real hardware until asked to.** Added `eglSwapBuffers`/`eglGetProcAddress` interposers to `trace_agent/libc_gles_trace.cpp` (previously only traced the 4 core GLES calls). Rebuilt, restored the A9's `com.ea.games.nfs13_arm` OBB (empty again - copied from the sibling `com.ea.games.nfs13_mod` package, same known gotcha as before), deployed via the documented `wrap.` procedure, captured a fresh ~46s run. + +**Result: decisive.** `eglSwapBuffers` is called **2,736 times** in 46 seconds - essentially 1:1 with `glClear` (2,735 times), the textbook "one swap per frame" pattern, every single call returning success (`-> 1`). The real game absolutely does call it, constantly, as part of normal per-frame operation - this thoroughly refutes any "maybe the design relies on Android's automatic swap and doesn't call it directly" theory from the entries above. + +**Root cause, found immediately after confirming the symptom was real**: `eglSwapBuffers` is **not a direct import** in `libapp.so` at all (checked via `imports_query` - only `eglGetProcAddress` itself is directly imported, at `0xb1677c`). The real game resolves it dynamically via `eglGetProcAddress("eglSwapBuffers")` - a completely normal pattern, not exotic. But this engine's own `Shim_eglGetProcAddress` (`libc_shims.cpp`) **unconditionally returns NULL for every single name it's ever asked for**, having been written under the assumption that `eglGetProcAddress` is only ever used for genuinely-optional driver extensions (matching its own comment: "extension entry points aren't guest-callable... no guest-callable stub for that SPECIFIC extension"). `eglSwapBuffers` is a core EGL 1.0 function, not an optional extension - resolving it this way is legitimate, and the guest code's own null-check (standard defensive coding after an `eglGetProcAddress` call) silently skips calling the resulting null pointer forever, exactly matching the observed symptom. + +**Compounding bug that hid this for the whole investigation**: the shim's own logging is gated by a single `static bool logged` flag - shared across ALL names, not per-name - so it only ever prints its diagnostic once per process lifetime. Confirmed live: every fresh-boot capture this session produced shows **exactly one** `eglGetProcAddress` log line, always for `"glDebugMessageControlKHR"` (a genuinely-optional debug extension, correctly returning NULL for that one) - consuming the one-shot flag and silencing every subsequent call, including whichever later call actually requests `"eglSwapBuffers"`. The bug was invisible in every capture precisely because of this logging gap, not because the call wasn't happening. + +**The fix (implemented, then found to be a dead end - see immediately below)**: `Shim_eglGetProcAddress` now recognizes `eglSwapBuffers` and returns a real, guest-callable stub for it via `ResolveOrCreateImportStub` (made public for this - same mechanism a real ELF PLT import uses), calling straight through to the real host `eglSwapBuffers(eglGetCurrentDisplay(), eglGetCurrentSurface(EGL_DRAW))`. Also fixed the one-shot logging flag to be per-name (`std::set` of already-logged names) - immediately paid off: the fixed logging revealed **160+ distinct extension names** the guest code queries via `eglGetProcAddress` (previously all hidden behind the single `logged` flag), none of which is `eglSwapBuffers`. + +**2026-09-18 (self-correction, same session): verified the fix on-device - still frozen, and checking WHY revealed the premise itself was wrong.** Built and deployed to the Pixel 6a; `Shim_eglSwapBuffers`'s own call counter: **0** - the guest never once asks for it, confirmed by the newly-fixed per-name logging (it would show up immediately if requested, the way all 160+ other extension names did). Re-checked the binary's FULL import list via `imports_query` with no filter (600+ entries) - `eglGetDisplay`, `eglMakeCurrent`, `eglCreateWindowSurface`, `eglInitialize`, and `eglSwapBuffers` are ALL absent; `eglGetProcAddress` is the only EGL-family symbol imported at all. **The guest ARM32 code has no mechanism whatsoever to call `eglSwapBuffers` directly** - not as a link-time import, not via `eglGetProcAddress`, not via `dlsym` (also zero calls, confirmed - `Shim_dlopen`/`Shim_dlsym` both log unconditionally). + +This means the Galaxy A9 ground-truth measurement (2,736 real `eglSwapBuffers` calls) was real data but attributed to the wrong caller: `trace_agent`'s `LD_PRELOAD` interposer operates process-wide, not just over the game's own ARM32 code - those calls are coming from **Android's own `GLSurfaceView`/framework-level native rendering machinery**, running in the same process, calling `eglSwapBuffers` automatically after each `onDrawFrame` return (real hardware's `onDrawFrame` genuinely keeps returning, once per real frame - this is exactly the "automatic post-return swap" mechanism theorized several entries up, now confirmed rather than ruled out). The game's own ARM32 code was never going to call it - it doesn't have the means to. + +**Corrected root cause, back to the very first finding in this whole chase**: the actual bug is that `onDrawFrame` never returns in this engine (`nativeOnResume`'s real implementation runs a persistent internal loop synchronously, confirmed five levels deep earlier in this document), which is what prevents Android's own automatic swap from ever firing again - not a missing EGL shim. The `eglGetProcAddress`/`eglSwapBuffers` fix that was just implemented is harmless (real, functionally correct, matches how SOME games legitimately would resolve it this way) but doesn't address this specific bug, since this particular game never takes that path. Kept in the codebase (correct behavior for any future case that does hit it, and the per-name logging fix has independent diagnostic value), but the real fix still needs to find why `onDrawFrame`/`nativeOnResume` never yields back to Android - worth revisiting the render-loop internals (`sub_3D58BC`'s own lock-gated logic, `sub_54BC9C`/`sub_54BC94`) for a loop-exit condition this engine might be evaluating differently than real hardware. + +**2026-09-18 (continued, same day): implemented the actual fix - this engine now synthesizes the swap Android would normally provide automatically, since moving `nativeOnResume` off the GLThread isn't safe (the real EGL context is only current on that one thread) and the guest code was never going to call `eglSwapBuffers` itself.** `Shim_glClear` (`gles_shim.cpp`) now calls the real host `eglSwapBuffers(eglGetCurrentDisplay(), eglGetCurrentSurface(EGL_DRAW))` immediately before every glClear against the DEFAULT framebuffer (binding 0, checked via `GL_FRAMEBUFFER_BINDING` - so clears of shadow maps/post-process FBOs don't trigger a spurious mid-frame swap) except the very first one ever. Timing matches the empirically-confirmed ~1:1 real-hardware ratio between `glClear` and `eglSwapBuffers` from the A9 ground-truth capture above. + +**Verified live on the Pixel 6a across several rounds of refinement, each adding a sharper diagnostic**: every synthetic swap succeeds (`eglSwapBuffers` returns `1`, `glGetError()` reports `0x0`, every single time across 700+ consecutive swaps spanning a real ~24s session) against a real, correctly-sized 2400x1080 surface whose `dpy`/`surf` handles never once change across the whole session (ruling out a mid-session context-recreation theory). Checked the live Android view hierarchy too (`dumpsys activity`) - `GameGLSurfaceView` is the only full-screen view, nothing else drawn on top. **This conclusively confirms the presentation pipeline itself now works correctly end-to-end** - a genuinely new capability for this engine. + +**But the screen still shows black - sampling the actual framebuffer content directly (via `glReadPixels`, right before each synthetic swap) revealed why, precisely.** The very first synthetic swap (presenting the Kotlin splash's own real content - the EA logo) samples as genuinely varied, non-black color across a 5x5 grid spanning the whole screen (`nonBlackOf25=25`) - direct proof the swap mechanism correctly captures and presents whatever's actually in the framebuffer. But starting from the very next swap onward - the first frame of the REAL game's own rendering - **every sampled point, across the whole screen, for the rest of the session (700+ consecutive frames checked), reads pure black, RGB=(0,0,0)**. Notably `alpha=255`, not the scene's own clear color's `alpha=0` (confirmed in an earlier check) - proving something DOES actively write every pixel, it just always writes black. + +**This is no longer a presentation/swap problem - it's a content problem, isolated to the real game's own rendering, starting the moment the splash hands off.** The swap/presentation fix (this whole entry) is confirmed correct and complete; whatever makes the real game's geometry/materials/lighting resolve to solid black needs its own separate investigation (candidates: textures failing to load, matching this session's earlier-confirmed real asset-loading issues around `sub_4BA588`/M3G parsing; a broken camera/projection matrix; a lighting/material uniform stuck at zero) - a materially different, and likely more tractable, class of bug than "nothing ever reaches the screen." + +**2026-09-18 (continued): ran a full 4-minute session (6,800+ frames sampled) to rule out "just needs more time" - definitively refuted, stayed uniformly black the entire time.** Checked the log for real game-side error/warning output during this longer run and found a strong-looking lead: 31 distinct `layouts/warning: TexturePack image not found for layout Sprite: ...` lines, naming real UI asset paths (`splash/most_wanted_logo.tif`, `splash/ea_logo.tif`, `buttons/btn_large_*.tif`, `bars_dividers/*.tif`, `backgrounds/generic_bg.tif`) - all belonging to the game's own loading-screen UI (found the real file paths in IDA: `/published/texturepacks_ui/splash.sba`, `loading.sba`, `loading_firstplay.sba` - the same `.sba`-based asset system this whole session's M3G investigation already covered). Looked exactly like the smoking gun: the WHOLE loading-screen texture pack failing to resolve would plausibly explain a fully black screen (missing background + missing UI elements). + +**Checked ground truth before spending further time on it - refuted immediately.** The earlier Galaxy A9 `trace_agent` capture (same session, same investigation, real unmodified `libapp.so`) shows the **exact same 43 `TexturePack image not found` warnings, for the exact same asset paths**, on real hardware that's confirmed actively rendering (2,736 real `eglSwapBuffers` calls, real content). This is not an engine bug at all - it's the real game's own known, tolerated behavior (these specific loading-screen sprites are legitimately absent/optional in this build's data, and the game handles their absence gracefully without it affecting the rest of rendering). A real, decisive example of [[feedback_verify_engine_theories_on_native_hw]] paying off immediately - caught what looked like a strong lead before sinking more time into it. Back to first principles for what actually makes the real game's own content render as solid black in this engine specifically, when the exact same warnings on real hardware don't prevent real content from appearing. + +**2026-09-18 (continued): closed the one remaining ambiguity in the black-content finding - is the black screen "nothing draws" (camera/projection/culling) or "something draws black" (material/shader/lighting)?** The earlier `glReadPixels` check only confirmed alpha=255 differed from the clear color observed once, during the splash phase - never re-checked what the clear color actually is during the real (black) content phase itself, so it was possible the game's own clear color was ALSO opaque black, which would have made "nothing reaches the screen" equally plausible. Added a `glGetFloatv(GL_COLOR_CLEAR_VALUE, ...)` read to the same diagnostic block in `Shim_glClear` (`gles_shim.cpp`), sampled on the same cadence as the existing grid read-back and swap. + +Deployment note (unrelated to the investigation itself, cost real time to diagnose): the first attempt at this test appeared completely stuck - `GLThread` burned real CPU but zero GuestEngine/gles_shim/JNI log lines fired for 3+ minutes. `simpleperf record -p -t -g` (required `adb root` first - GrapheneOS denies `perf_event_open` to non-root even on a debug build) showed the thread deep in pure ART/interpreter overhead (`java.lang.ref.Reference.get`, `Activity.hasWindowFocus`, zero native engine symbols anywhere in the call graph) - not inside our code at all. Cross-checked the Kotlin state machine (`GameActivityMain.kt`): the observed `state::game: onDrawFrame state=7` log is `STATE_RESTORE_CONTEXT` (`=7`, confirmed from the real constant table), not `STATE_GAME_START` (`=8`) as assumed - that state polls `hasWindowFocus()` every frame and only advances once it's true. `dumpsys window` confirmed the real cause: GrapheneOS's `DeprecatedAbiDialog` (the known blocker from [[reference_grapheneos_deprecated_abi_dialog]]) had stolen `mCurrentFocus`, silently reappearing on this fresh install/launch. Dismissed via `uiautomator dump` + `input tap` on its real "ОК" button coordinates (not a sleep-and-hope), confirmed focus returned to `GameActivityMain`, and the real test proceeded normally from a clean logcat. Worth remembering: a "stuck with zero native log activity" symptom on this device is now a fast first check for this specific dialog before assuming an engine regression. + +**Result, decisive: the clear color is `[0.000, 0.000, 0.000, 0.000]` (alpha=0) on every single sampled frame, throughout the entire black-content phase (frame #2 through #1001+, matching the earlier one-time splash-phase reading exactly) - yet every `glReadPixels` sample continues to read back `alpha=255`.** Since the clear color's own alpha (0) never matches what's actually on screen (255), the visible framebuffer cannot simply be the untouched clear color sitting there unmodified - something writes to (at minimum) the alpha channel across every one of the 25 sampled points spanning the full 2400x1080 viewport, every single frame. This rules out "nothing reaches the screen" (a pure camera/projection/culling bug would read back the clear color's own alpha=0, not 255). + +**Conclusion: real geometry IS being rasterized across the full screen every frame, with a material/shader that resolves to opaque black (RGB=0, A=1) rather than the intended content.** This narrows the remaining investigation to the material/shader/lighting/texture-binding path specifically, not "no draws happen at all" - candidates now: a texture unit sampling from an unbound/incorrectly-initialized texture (many GL implementations return black for an incomplete texture), a lighting/material uniform buffer stuck at its zero-initialized default (matching this session's earlier, unrelated finding of zero-initialized global state elsewhere), or a shader itself producing a black constant (worth checking `glUseProgram`/`glUniform*` call sequences and any shader compile/link failures around the same point in the log). + +**2026-09-18 (continued): sampled the actual render state at `glDrawElements` time and confirmed the blend math, then checked the bound texture's real content directly - both check out, narrowing this to a shader-uniform question specifically.** Added a low-rate (every 300th call) state dump to `Shim_glDrawElements` (`gles_shim.cpp`) covering bound framebuffer/program/texture, color write mask, blend enable/func, and depth test. (One bug caught and fixed before trusting any of it: the first version read `GL_COLOR_WRITEMASK` - a 4x `GLboolean` query - via `glGetIntegerv` into a 4-byte buffer, a real stack buffer overflow; fixed to the correct `glGetBooleanv` call before drawing any conclusions.) + +**Result: no pathological state found.** `colorMask=[1,1,1,1]` (nothing masked), `depthTest=0` (disabled, plausible for a UI/overlay pass), and critically **`blend=1(src=0x1/GL_ONE, dst=0x303/GL_ONE_MINUS_SRC_ALPHA)`** - a completely standard premultiplied-alpha blend, not a pathological `GL_ZERO` that would force black regardless of the fragment shader's own output. Working the blend equation by hand against the confirmed clear color (`(0,0,0,0)`) and the observed on-screen result (`(0,0,0,255)`): `result_rgb = src_rgb*1 + dst_rgb*(1-src_a)` and `result_a = src_a*1 + dst_a*(1-src_a)` - solving backward, this is *exactly* what a fragment shader outputting `(0,0,0,1.0)` (black RGB, fully opaque alpha) produces against this exact clear color, with completely ordinary blend state. This is decisive: the blend math itself is not the bug, it's faithfully reproducing whatever the shader outputs. + +The overwhelming majority of draws in the black-content phase (`#300` through at least `#4800` sampled, all with identical `count=12`, i.e. a single small repeated quad-like mesh) share one `program`/`tex0` pair, used continuously and exclusively for the entire capture window - a strong signal this is one specific, dominant draw call (likely a UI/text/sprite element), not "everything is broken." + +**Checked whether the bound texture itself might be the culprit - it isn't.** Added upload-time logging to `Shim_glTexImage2D`/`Shim_glCompressedTexImage2D` (texture id via `GL_TEXTURE_BINDING_2D`, dimensions, format, and - critically - five real texel samples read directly from the host-translated upload buffer, spread across the image rather than just offset 0, to rule out a false-black-corner reading). The texture bound during the dominant black-content draws (`tex=2`, uploaded once at `2044x1396`, `GL_RGBA`/`GL_UNSIGNED_BYTE`) samples as genuinely varied, real pixel data across its span (`[0,0,0,0][255,88,13,16][197,255,197,197][245,245,255,248][0,0,0,0]` - non-trivial colors and alphas, not silently zeroed). **This rules out "the texture failed to load/decode as black" as the cause** - the source data reaching the GPU is correct. + +**Where this leaves the investigation: the render state, blend math, and texture content are all confirmed correct - the remaining candidate is a shader uniform (most likely a tint/material color, or a lighting term) resolving to `(0,0,0,1)` instead of its intended value for this specific dominant draw.** Next step (not yet done): instrument `glUniform4f`/`glUniform4fv` while `program==26` (or whichever program id repeats in a fresh capture - ids aren't guaranteed stable across runs) is bound, to catch a color/tint uniform stuck at zero; alternately, dump `glGetActiveUniform`/`glGetUniformLocation` for that program to identify which uniform slot is the color/tint one before instrumenting its value specifically, since blind-logging every `glUniform*` call would be noisy without knowing which one to look for. Also worth checking whether this draw's vertex color attribute (rather than a uniform) is the zeroed source, if the shader multiplies texture-times-vertex-color instead of texture-times-uniform-color. + +**2026-09-18 (continued, ROOT CAUSE FOUND AND FIXED): enumerated program 26's real uniforms/attributes instead of guessing - it has no texture uniform at all, only `sys_ModelViewProjection` and the `a_Color0` vertex attribute.** Added one-time-per-program logging to `Shim_glUseProgram` (`gles_shim.cpp`, via `glGetActiveUniform`/`glGetActiveAttrib`) rather than blind-instrumenting every `glUniform*` call. Result: program 26 (the dominant black-content draw's program) has exactly one uniform (`sys_ModelViewProjection`, the transform matrix - not a color) and two attributes (`a_Position0`, `a_Color0`). **It never samples any texture** - whatever's bound to texture unit 0 is irrelevant to this program; the fragment color comes entirely from the per-vertex `a_Color0` attribute. This immediately explained why the earlier texture-content check (real, varied texel data) didn't matter for this specific draw. + +Checked `a_Color0`'s real state at draw time (extended the same `Shim_glDrawElements` state sample): the attribute array is **enabled** and bound to a real VBO (`vbo=1`/`3`, `stride=32`, `type=GL_UNSIGNED_BYTE`, `normalized=1` - a standard packed-color vertex format), not a disabled constant. But the reported client "pointer" for this VBO-backed attribute (`ptr=0xc5b9654ba00c`) looked like a full host memory address, not the small byte offset (0-31 range, matching the 32-byte vertex stride) that GLES spec requires when a buffer object is bound. + +**Checked `Shim_glVertexAttribPointer`'s and `Shim_glDrawElements`'s actual implementation - found the real bug.** Both unconditionally called `eng.G2H(pointerArg)` (translate as a guest memory address) on their pointer/offset argument, with no check for whether a buffer object was bound. Per the GLES 2.0 spec, this argument has dual meaning: a real client-memory pointer when `GL_ARRAY_BUFFER_BINDING`/`GL_ELEMENT_ARRAY_BUFFER_BINDING` is 0, but a **small byte offset into the bound buffer's own data store** when a buffer IS bound - and must be passed through to the host driver completely unchanged in that case, not translated as if it were a guest address. `eng.G2H()` was instead mapping these small offsets (0, 4, 12, 20...) into wherever the guest-to-host address translation happens to land for low guest addresses - producing a real but semantically-meaningless host pointer, which the real host GL driver (which has no idea a buffer is bound and expects a pure numeric offset here) used directly as a byte offset into whatever memory THAT translated value happened to correspond to, not the actual uploaded vertex-color data. + +Grepped every G2H call site in `gles_shim.cpp` for the same shape of bug - confirmed these are the ONLY two affected: `glGetActiveAttrib`/`glGetActiveUniform`/`glUniformMatrix*`/`glBufferSubData`/`glTexSubImage2D`/etc. all take real client pointers unconditionally per spec, with no VBO-offset duality, so their existing unconditional `G2H()` calls are correct as-is. + +**The fix**: both shims now check the relevant buffer binding (`GL_ARRAY_BUFFER_BINDING` for `glVertexAttribPointer`, `GL_ELEMENT_ARRAY_BUFFER_BINDING` for `glDrawElements`) and pass the raw guest-side value straight through as a numeric offset when a buffer is bound, only calling `G2H()` in the true client-memory-pointer case (no buffer bound). + +**Verified live on the Pixel 6a - the black screen is gone.** Immediately after the fix, the post-splash content phase (previously `nonBlackOf25=0` at literally every one of 6,800+ sampled frames across a full 4-minute run, completely uniform) now shows real, varied, stable UI content: `nonBlackOf25=25` sustained across 1,300+ consecutive frames, with genuinely distinct colors per grid point (`[246,250,246,255]` near-white background, `[65,64,65,255]`/`[139,133,139,255]` grayscale UI panels, `[213,0,0,255]` a red accent/highlight element) - a real, structured loading or menu screen, not noise. The scene later transitioned to a darker screen (`nonBlackOf25=5`, dark blue-gray tones `[32,40,41,255]`/`[32,44,49,255]` at a few grid points in a repeating pattern) with `glDrawElements` jumping to 106,920 indices/3s (vs. ~18,360 before) - consistent with the game having genuinely progressed to a more complex real scene, not a regression (confirmed: zero `FATAL`/`MEM FAULT`/crash lines throughout, process stayed alive and responsive). + +**This closes the black-content investigation.** The full chain, start to finish: `nativeOnResume` never returning to Android (found and understood five levels deep) meant Android's automatic post-frame `eglSwapBuffers` never fired again → fixed by synthesizing the swap directly in `Shim_glClear` → which correctly revealed that the actual rendered content itself was solid black → traced through render state (fine), blend math (fine, faithfully reproducing whatever the shader outputs), and texture content (fine) → down to the specific draw call and its vertex-color attribute → to a genuine, narrowly-scoped correctness bug in exactly two shims that mishandled the VBO-bound "pointer-is-actually-an-offset" case from the GLES spec. Each step was verified against real, live device state rather than assumed - the `simpleperf`-based misdirection early in this session (a GrapheneOS system dialog, unrelated to the engine) is itself a reminder that verifying assumptions against ground truth caught two separate false leads in a single session. + +**2026-09-18 (new investigation, same day): user directly observed the fixed build - real content renders, but as tiny scattered colored fragments on an otherwise-black screen, not full UI panels.** Measured directly rather than guessed: added a textured-vs-color-only draw-call counter (keyed off whether each program's enumerated uniforms include a sampler) to the periodic GLES ticker. **Refuted the first hypothesis** ("textured/panel draws only happen once at startup") immediately - textured draws happen continuously every frame, MORE often than color-only draws (`textured=270 colorOnly=90` per 3s in steady state). Checked the position data next: intercepted `glBufferData` uploads to the streaming quad VBOs and decoded the first two vertices as floats - real, sane full-screen pixel coordinates (`vtx0=(0,0,0)`, `vtx1=(0,1080,0)`, matching the confirmed 2400x1080 real surface), not degenerate/collapsed geometry. `GL_SCISSOR_TEST` was also checked and confirmed disabled with a full-screen box - not clipping anything. + +**Found the real anomaly: dumped the actual `sys_ModelViewProjection` matrix value for the textured programs (the ones with panel/background draws) at draw time.** Real, consistent value across every sample: `[0.001,0,0,0 | 0,-0.002,0,0 | 0,0,-1,0 | -1,1,0,1]` (column-major). Decoded: `scale_x = 0.001 = 2/2000`, `scale_y = -0.002 = 2/1000`, translate `(-1,+1)` - a textbook 2D pixel-to-NDC orthographic projection, but built for a **2000x1000 virtual/reference resolution**, not the real 2400x1080 device surface the position data (confirmed above) is actually expressed in. Plugging in a real screen-edge vertex (x=2400): `x' = 0.001*2400 - 1 = 1.4`, well outside the valid `[-1,1]` NDC clip range - most of any full-screen quad drawn with real pixel coordinates gets clipped away under this matrix, leaving only whatever small portion happens to fall within the smaller 2000x1000 "safe" window - a precise mechanical explanation for "tiny surviving fragments, mostly clipped away." + +**Checked ground truth before assuming this is an engine bug** (per [[feedback_verify_engine_theories_on_native_hw]], now proven valuable a third time in one session) - launched the REAL, unmodified `armeabi-v7a` build (`com.ea.games.nfs13_mod`) on the Galaxy A9 and screenshotted its current UI state. **Decisive: full-size, correctly-scaled, fully legible UI** - a complete in-game world-map hub screen (currency, road-completion percentages, a full-width bottom navigation bar with large clear icons, all properly filling the real screen). This conclusively proves the game itself is fully capable of correct full-screen UI scaling on real hardware - the 2000x1000-vs-2400x1080 mismatch is **a genuine bug specific to this translation layer**, not a game limitation or an unusual-aspect-ratio quirk (confirmed independent of the A9's own different real resolution, 1080x2220 portrait - the point was to check whether the GAME CAN scale UI correctly at all on real HW, and it clearly can). + +**Traced the width/height plumbing one level down**: `Java_..._nativeSurfaceChanged` (`game_lifecycle_stubs.cpp`) passes the real `w`/`h` values Android's `GLSurfaceView.onSurfaceChanged` callback provides straight through to the guest's real `SurfaceChanged` implementation, unmodified - so the *real* 2400x1080 values do reach the guest code at this specific call site. The mismatch must originate somewhere deeper in the guest's own reference-resolution/UI-scaling logic. + +**2026-09-18 (continued, DECISIVE ground-truth confirmation - byte-for-byte matching evidence): rewrote `trace_agent` to stop relying on `adb logcat` entirely for its high-volume output (user's own request, after logcat's small ring buffer had already lost the exact frames a live capture needed).** `trace_log.h`'s `TraceLog()` already had a working, per-line-flushed file-log path (`InitFileLog`) sitting unused - the only missing piece was ever calling it. Fixed by resolving the agent's own `.so` path via `dl_iterate_phdr` (same technique `LibappBase()` already used for the real `libapp.so`, including the same "wait for the first proven-safe hook" bootstrap-hazard gating this file's own extensive comments already document for that sibling case) and writing `trace_output.log` alongside it, in the app's own already-writable private files dir. Also removed the parallel `__android_log_print()` call from the hot path entirely (confirmed, not just suspected, actively harmful for a full trace: Android silently rate-limits a single process's logcat output past a threshold, on top of the ring-buffer-wrap problem) - only two one-shot lifecycle messages (agent loaded, file opened) still go to logcat, for a quick sanity check without needing to pull the file. + +**Result: complete, lossless capture - 86,000+ lines from process start, including exactly the moment this investigation needed.** Found the emulated engine's own confirmed texture id (`4`, `738x302`, the text-overlay upload) present in the REAL trace too, at the EXACT same dimensions - and, critically, **a real draw call DOES reference it**, immediately: `glTexImage2D tex=4 ... 738x302` → `glClear` → `glUseProgram(program=23)` (the g_Tex0-sampler program, confirmed earlier this investigation) → `glDrawElements(mode=0x4, count=6, type=0x1403) tex0=4` - a single 6-index quad (2 triangles), 3ms after the upload. This is the single most precise, reproducible fingerprint this whole investigation has produced: on real hardware, uploading this exact texture is immediately followed by exactly one small textured quad draw referencing it; on the emulated engine, the same upload happens (confirmed, real anti-aliased glyph pixels) but this specific `glUseProgram(23)` + `glDrawElements(...) tex0=4` sequence never fires anywhere in a full session capture. + +**This gives whoever continues task #39 a concrete, mechanical target: instrument (or breakpoint) the emulated engine's `Shim_glUseProgram` specifically for `program==23`'s (or whichever id matches on a fresh capture - not guaranteed stable across runs) invocations right after a texture upload, and trace forward from there to see exactly which guest instruction decides whether to proceed to the draw call or skip it** - almost certainly the same width/height-fed CPU-side visibility check theorized earlier (via `OrthoCameraController`), now anchored to an exact, byte-matching real-vs-emulated comparison point instead of a general theory. + +**2026-09-18 (continued, LIVE USER-VERIFIED CONFIRMATION of the black-screen fix): the user ran the fixed build directly and watched it boot, screenshotting alongside a live log/monitor session.** Confirmed, in order: (1) the studio mascot logo splash renders perfectly - correct size, correct colors, centered, with its loading bar; (2) the full "NEED FOR SPEED MOST WANTED" branded splash renders perfectly - large stylized logo text, the white Porsche, the row of background cars, city skyline, spinner and loading bar, all at full real screen size and correct proportions; (3) the screen then transitions to the same "tiny scattered fragments" symptom already under investigation (task #39). **This pins down exactly where the boundary is**: screens (1) and (2) are drawn by `GameActivityMain.kt`'s own Kotlin-side `SplashScreen` (pure Android `Canvas`/`GLES20` calls from Kotlin, established earlier this session as a completely separate code path from the guest ARM32 engine) - they render perfectly because they never touch the guest engine or its shims at all. The "dots" screen is the **first content the real ARM32 game engine itself renders** once `nativeOnResume()` takes over - exactly where the projection-matrix bug (this task) lives. The user also separately noted the transition animation into this state felt visibly janky/stuttery - a distinct, not-yet-investigated performance concern (likely Unicorn interpretation overhead), noted for a future session, not blocking this correctness investigation. + +**Went looking for the `DisplayMetrics.widthPixels`/`heightPixels` JNI query (found via IDA, see above) actually firing at runtime - it never did.** Added targeted logging to `Impl_GetIntField` (`jni_shim.cpp`) keyed off the field's remembered trace name (via the existing `LookupJniTraceName` cache) for exactly these two field names. Ran twice, live, capturing from the very start of the process (fresh `logcat -c` immediately before launch) - zero matches both times, for the entire session including through the point the "dots" screen appears. This rules out `sub_5463D8`'s device-info-gathering `DisplayMetrics` query (whatever it's actually for - likely a one-time device-profiling/telemetry pass) as the source of the 2000x1000 value; the game evidently doesn't reach that code path in this engine's execution, or its result isn't what feeds the UI camera. + +**Redirected the search from "who calls GetFieldID" to "who registers as owning uniform id=6" via a class name match.** `sub_455790` (found earlier) only registers `"ModelViewProjection"` -> id 6 as a name-to-ID mapping in a generic `SystemUniform` registry - not itself the computation. Searched more broadly for camera/projection-related class names and found `im::app::cameras::OrthoCameraController` (demangled C++ RTTI type name, `N2im3app7cameras21OrthoCameraControllerE` at `0xa21370`) - the engine's namespace is `im` (IronMonkey), and this looks like exactly the right class for a 2D UI camera. Its registration/constructor (`sub_9C1E8`) registers it into a reflection/factory type system (a generic pattern this engine's RTTI uses for many classes, not specific to this bug) with a real vtable at `off_A9B61C` (`0xa9b61c`) - **not yet decompiled/traced further**; the vtable's actual method slots (likely something like `SetViewport(w,h)`/`Update()`/`GetProjectionMatrix()`) are the next concrete lead for whoever picks this up next. + +**Where this leaves task #39**: root cause still not pinned to an exact instruction, but narrowed from "somewhere in guest code" to a specific, plausible class (`OrthoCameraController`) whose vtable is now a known, concrete next step. The `DisplayMetrics` JNI-query theory is now ruled out empirically (not just by static analysis) - whatever feeds this camera's reference resolution isn't that specific call. + +**2026-09-18 (continued, DECISIVE - user pushed back hard on an unverified claim and was right to): the user directly disputed this doc's earlier claim that the "NEED FOR SPEED MOST WANTED" splash is entirely Kotlin-drawn, and supplied concrete counter-evidence - all in-game text renders exclusively through a specific Java class, `BitmapGraphics`, via a `drawString(paint, text, x, y)` method, called from the guest engine over JNI.** This was directly verifiable and the user was completely right: `BitmapGraphics.kt` is a real class (`bitmap`/`canvas` backed by Android's own `Canvas.drawText`, with `createPaintFromFamilyName`/`createPaintFromFile` for custom fonts loaded from assets) - and it's called from the ENGINE, not from Kotlin's own splash sequence, confirmed by temporarily disabling the real `canvas.drawText` call (commenting it out, logging only) and rebuilding: the studio logo and the big stylized "NEED FOR SPEED"/"MOST WANTED" text stayed on screen unchanged (that specific logo IS a static baked-in texture, rendered by the real GLES engine, not text) - but the drawString calls themselves fired continuously, live, with real content: per-character JNI calls building up real UI strings, including a Russian legal disclaimer and `"© 2018 Electronic Arts..."` - a real, working, per-glyph text-rendering pipeline the earlier draft of this doc had wrongly written off as "not engine-related." + +**The user then supplied a real A9 reference screenshot of this exact same splash screen for direct comparison** - showing, below the car lineup, two lines of gray safety-disclaimer text ("В реальном мире соблюдайте правила дорожного движения...") and a copyright line ("© 2018 Electronic Arts Inc.") - both completely absent from the translated build's version of the same screen, matching the user's very first observation this session ("на этом экране должен быть текст"). + +**Traced the missing piece directly: the text DOES get correctly rendered into a bitmap and uploaded to a real GL texture - it's the subsequent draw call that never happens.** `AndroidBitmap_getInfo`/`AndroidBitmap_lockPixels`/`AndroidBitmap_unlockPixels` are all implemented as thin real passthroughs in `gles_shim.cpp` (no bug found there). Added upload-tracking already covered texture id `4` - an unusually narrow `738x302` upload, sampled as genuine anti-aliased grayscale glyph data (`[255,255,255,255][12,12,12,255][41,41,41,255]...` - white background, dark near-black glyph pixels, full alpha) - exactly matching what rasterized disclaimer text would look like. **Extended `Shim_glDrawElements`'s existing state-sample logic to log, unconditionally (not rate-limited), every single draw call that has `GL_TEXTURE_BINDING_2D == 4` bound.** Result, reproduced identically across two independent full clean-launch runs (fresh `logcat -c` immediately before launch, captured continuously to a file to survive logcat's small ring buffer, one run spanning 558,000+ log lines): the texture uploads correctly every time, but **zero draw calls ever reference it** - the geometry for this specific UI element is never issued at all, not drawn-wrong or drawn-invisible. + +**This directly ties back to the `OrthoCameraController`/2000x1000-vs-2400x1080 mismatch (same task) as the most likely unifying explanation**, via a mechanism distinct from clipping: many 2D UI engines do their own CPU-side visibility/culling check ("is this element within the virtual screen bounds?") *before* ever issuing a GL draw call, as a performance optimization - skipping the draw entirely for anything computed as off-screen, rather than relying on the GPU to clip it. The real A9 screenshot shows this disclaimer text anchored near the **bottom** of the screen (real y-coordinate near 1080); under a virtual canvas capped at height 1000, a CPU-side bounds check using that wrong reference height would very plausibly conclude "this element's y-position is off the bottom of the virtual screen" and skip the draw call altogether - independently explaining, with the same one root cause, both the earlier-observed "small fragments only" symptom (elements positioned within the safe 2000x1000 window still get their (correctly-transformed-but-mostly-clipped) geometry submitted) and this newly-found "draw call never issued at all" symptom for content anchored near a virtual-canvas edge. + +**Corrected understanding for the record**: the JNI/Java bridge itself (`FindClass`, `GetMethodID`, `BitmapGraphics.drawString`, `AndroidBitmap_*`) is confirmed working correctly end-to-end for text - this is NOT a "guest code holds a wrong class/method reference" bug (the user's own working hypothesis going in, reasonably so given this session's history of exactly that bug class elsewhere) - it's specifically that the code deciding *whether to submit a draw call at all* for this element is (very likely) fed the same wrong reference-resolution value as the `sys_ModelViewProjection` matrix. Whoever picks up task #39 next should treat "find where `OrthoCameraController`'s width/height inputs come from" as the single root fix that plausibly resolves both symptoms together, rather than chasing the missing-text symptom as a separate bug. + +**2026-09-18 (continued, in direct response to "Найди где именно решается рисовать или пропускать"): traced the exact call chain from GLES draw call back to the batch-append/clip site, purely via IDA decompilation of the real guest code (no new instrumentation needed for this step).** + +Chain, outer to inner: +1. `Shim_glUseProgram`/`Shim_glDrawElements`'s guest `LR` (from the text-overlay trace window, both calls) resolve to the exact same function, **`sub_56962C`** (`0x56962c`, size `0x81c`) - a single generic "execute one render-command item" function: binds program/attribs/uniforms/textures, then calls `glDrawElements`. **Its very first parameter is the index `count` to draw, and the whole function no-ops (no `glUseProgram`, no `glDrawElements`, nothing) if `count == 0`.** So the draw-or-skip decision is made entirely by whoever computes `count`, not inside this function. +2. `sub_56962C` has 9 call sites - 8 of them are structurally-identical small wrapper functions (`sub_2EA244`, `sub_400678`, `sub_433B80`, `sub_433F84`, `sub_4535F0`, `sub_457B18`, `sub_458164`, plus `sub_83A84`/`sub_83B08` which pass a hardcoded `count=3`) that just relay fields from a "render item" object (`a1`) straight into `sub_56962C`'s params - i.e. per-class virtual `Draw()` overrides holding no logic of their own, referenced only as vtable data (confirmed via `xrefs_to` showing `type: "data"` inside container functions, not `bl` call sites). +3. Followed the render-item object's `count` field (offset `+116`, read directly in all 8 wrappers) back to where it's written: **`sub_3FE160`** - a batch "flush" function. At its very top: `if (*(a1+12) > *(a1+16)) { ...build the render item, count = 6*((*(a1+12) - *(a1+16))/4)...; *(a1+16) = *(a1+12); } else return;` **If the batch's write-cursor (`a1+12`) hasn't advanced past the last-flushed cursor (`a1+16`), the function returns immediately - no render item is ever created, so `sub_56962C` never even gets a `count=0` call, it simply never gets called at all for this batch.** +4. Traced what advances `a1+12`: **`sub_4015D4`** - literally an `AppendQuad(batch, corner0, corner1, corner2)` function. Writes 4 vertices (32 bytes each) into the batch's vertex buffer and, at the very end, does `*(a1+12) = *(a1+12) + 4` - this is the ONE place that increments the cursor `sub_3FE160` checks. If this function is never called for a given quad, that quad's geometry simply doesn't exist in the batch. +5. Found `sub_4015D4`'s 3 callers. Two of them - **`sub_4010F0`** and **`sub_4014EC`** - are itself dispatchers with the same shape: `if () return sub_3DA3F0(, a2, a3, a4, a1); else return sub_4015D4(a1, a2, a3, a4);` - i.e. **there are two paths to append a quad: a plain unconditional append, and a clip-aware path that goes through `sub_3DA3F0` instead of `sub_4015D4` directly.** +6. Decompiled **`sub_3DA3F0`** - confirmed it's a textbook **Sutherland-Hodgman polygon clipper**: 4 sequential calls to `sub_3DA840(edgeA, edgeB, planeMin, planeMax, ...)` (matches the classic "clip against left/right/top/bottom" structure), writing surviving vertices into a growable scratch output list (`a5[106]`/`a5[107]`/`a5[108]` - base/cursor/end, grown via `sub_3DACB0` exactly like a `std::vector` reallocation), and returning `(outputCursor - outputBase) >> 5` - **the number of vertices that survived clipping against the rect passed in as its first argument.** Notably, `sub_3DA3F0` does NOT itself call `sub_4015D4` - it only produces a clipped vertex list in a side buffer; some other, not-yet-located piece of code must consume that list and fan-triangulate it into real `sub_4015D4` append calls. **If the clip rect rejects the quad entirely, that consumer has zero vertices to append, and step 4 above never happens for this element - draw silently skipped.** + +**This mechanically confirms, rather than just theorizes, the "CPU-side visibility/clip check" explanation already written into this doc** (the paragraph above, from the `OrthoCameraController` investigation) - it's not a guess about *some* culling check existing, it's the actual Sutherland-Hodgman clip routine (`sub_3DA3F0`) and its rect parameter (traced to `sub_4014EC`'s `*(a1+340)`-derived pointer, i.e. `v4-48` where `v4 = *(a1+340)`) sitting exactly where expected in the call chain between "compute quad corners" and "append quad to GPU batch." + +**Where this leaves task #39, now maximally narrowed**: the single remaining unknown is what populates the clip-rect structure `sub_3DA3F0` receives (traced to an object reached via `*(a1+340) - 48` from the calling context in `sub_4014EC`) - specifically whether its width/height come from the same 2000x1000 reference-resolution source as `sys_ModelViewProjection`, and if so, where THAT gets set. This is now a concrete, bounded next step (one more level of "who writes `*(a1+340)` / who constructs the object at `*(a1+340)-48`") rather than an open-ended search - the entire path from GL draw call down to the clip-rect consumer is now fully mapped and documented above. + +**2026-09-18 (continued): identified the real C++ class ecosystem behind this whole call chain via RTTI symbol search - the game has a full scene-graph UI framework, `im::scene2d_new`** (`Node`, `Text`, `Sprite`, `Group`, `ScrollViewport`, `layouts::Widget`, `layouts::Button`, and, notably, dedicated `layouts::ClipEventLayoutEvent`/`layouts::ClipSignalLayoutEvent` classes - confirming clipping in this engine is driven by an explicit event propagated down the widget tree, not just inline math). `im::scene2d_new::Text` (constructor `sub_4D887C`) is almost certainly the class that owns the disclaimer-text batch object this whole investigation has been tracing. + +**A promising but NOT YET CONFIRMED lead found in `Text`'s constructor, flagged here explicitly as unverified rather than asserted as fact** (per [[feedback_verify_engine_theories_on_native_hw]] - static-only tracing has already produced one wrong claim this session and shouldn't produce a second): `Text::Text` sets its own field at offset `+136` from a global Meyers-singleton (`sub_4DEAEC()`, lazily constructed via the standard `_cxa_guard_acquire` pattern) - specifically `singleton+140` by default, or `singleton+144` instead if an ancestor widget's field at `+132` equals exactly `3`. Decompiling `sub_4DEAEC` shows offsets `+140`/`+144` of that singleton hold raw floats `2.0`/`3.0` - **not pointers**, which directly contradicts how `sub_4010F0` (part of the already-mapped draw chain, step 5-6 above) treats the *same-looking* `a1+136` field: as a pointer it dereferences at negative offsets (`*(float*)(v3-132)` etc.) to read a transform. **This means either `a1` in `sub_4010F0`'s call chain is a different object than `Text` itself (e.g. a batch/renderer object `Text` owns via a different field), or there's a second write to `Text+136` elsewhere that actually stores a pointer - not yet determined.** Do not treat "ancestor's `+132 == 3`" as the confirmed switch between the 2000x1000 and 2400x1080 paths until this is checked dynamically. + +**Recommended next step, concrete and boundable**: rather than more static guessing, add targeted `gles_shim.cpp`/guest-side logging (or an IDA breakpoint-equivalent via the existing LR-tracing pattern already used throughout this investigation) at `sub_4010F0`/`sub_4014EC`'s entry to dump `a1+132`, `a1+136`, `a1+340` for the specific call that handles the disclaimer-text quad, and compare the emulated engine's values against the real A9's (via `trace_agent`, following the same file-logging pattern already built this session) - this resolves the `Text`-vs-`sub_4010F0`-object ambiguity and the `+132==3` branch question with real data in one pass, rather than further IDA-only speculation. + +**2026-09-18 (continued, DECISIVE - the "draw-or-skip" framing for this specific symptom was wrong; corrected the same session it was proposed).** Implemented the recommended next step above for real: added `TextClipDispatchEntryProbeHookCb` (`guest_engine.cpp`) hooking `sub_4010F0`/`sub_4014EC` entries, gated on a dedicated trace-window counter (`g_textClipDispatchTraceWindow`, `gles_shim.cpp` - needed real external linkage, not the anonymous-namespace pattern everything else in that file uses, specifically so this cross-TU `extern` could reach it) opened at the same trigger site as the existing text-upload trace window. First attempt logged unconditionally and flooded logcat badly enough the live Monitor auto-stopped itself - fixed by gating on the dedicated window (400-call budget, since this entry point fires far more often per frame than `glUseProgram`/`glDrawElements`). Also hit, and fixed, a second real problem this same attempt: `adb logcat`'s default 1 MiB ring buffer was silently dropping the exact `mpcore_log` lines needed (`Skipping entries from slow reader` in the raw buffer) during this build's ~15-20s boot sequence before ever reaching the splash screen - resized to 16 MiB (`adb logcat -G 16M`) and re-captured, this time cleanly. + +**The a1+132/a1+136/a1+340 fields turned out to be real, live-varying heap pointers** (e.g. `0x4373b7c`/`0x488e628`/`0x488e6b8`/`0x4372684`), not the raw-float-2.0/3.0 values `Text`'s own constructor appeared to write per the earlier static read - confirms the earlier flagged ambiguity was real: whatever object `sub_4010F0`/`sub_4014EC` operate on is not simply `Text` at the same field layout, or a later write overwrites it with a real pointer before these calls happen. Not chased further, because a much bigger, unexpected result came out of the SAME capture, below. + +**In this run, the exact real-hardware fingerprint (`glTexImage2D tex=4 738x302` → `glClear` → `glUseProgram(23)` → `glDrawElements(count=6) tex0=4`) DID occur on the emulated engine - byte-for-byte, timing-adjacent, in the very same capture.** This flatly contradicts this doc's own earlier "confirmed: never happens" claim (two independent full-session captures, one 558,000+ lines) from just a few entries above. Rather than trust either claim blindly, checked the one thing that actually matters - a live screenshot of the running app at this exact moment (`adb exec-out screencap`, then cropped/zoomed 3x on the region below the loading bar where the real A9 screenshot shows two lines of disclaimer text). **Result: a single small black horizontal sliver, a few pixels wide - not absent, not correctly-sized text, but the exact visual signature of a real quad that WAS drawn but got squashed nearly flat.** + +**This resolves the apparent contradiction and, more importantly, reframes the whole task**: the draw call is NOT being skipped (the earlier "never occurs" claim was itself wrong, likely from the same class of instrumentation blind spot already caught once this session with `program=26`'s coincidental `tex0=4` binding) - `sub_56962C` genuinely does receive a nonzero `count` and genuinely does call `glDrawElements` for this exact texture. **The entire `sub_56962C`/`sub_3FE160`/`sub_4015D4`/`sub_3DA3F0` call-chain tracing earlier in this doc is accurate reverse-engineering of a real code path, but it is NOT the mechanism behind the missing/broken disclaimer text** - it was a real path this investigation walked down before checking ground truth, not a wrong theory that was acted on. The actual, now-unified explanation is the SAME `sys_ModelViewProjection` 2000x1000-vs-2400x1080 scale bug already found and documented earlier in this same task: a 738x302 quad positioned near the real screen's bottom edge, transformed by a matrix built for a 2000x1000 virtual canvas instead of the real 2400x1080 surface, collapses toward the edge of (or past) the valid NDC range - visually indistinguishable from "not drawn" at a glance, but mechanically a squash, not an omission. The earlier "tiny scattered fragments" symptom (task #38/#39's original observation) and this "missing disclaimer text" symptom are conclusively the SAME bug now, not two bugs requiring two separate fixes. + +**Where this leaves task #39, now genuinely unified**: there is exactly one remaining root cause to find and fix - where the UI camera/projection system computes its reference resolution as 2000x1000 instead of the real 2400x1080 (the `OrthoCameraController` vtable at `off_A9B61C`, not yet decompiled, remains the most concrete lead - see this doc's earlier entry). The `sub_4010F0`/`sub_4014EC`/`sub_3DA3F0` clip-dispatch chain traced today, while real, is not on the critical path for this fix and doesn't need further pursuit unless a *different* symptom (an element that's fully absent, not just squashed) turns up later. + +**2026-09-18 (continued, TWO more concrete hypotheses tested live - BOTH DISPROVEN):** + +1. **Decompiled `OrthoCameraController`'s actual vtable for the first time - the `off_A9B61C`/`off_A9C1B0` references above were WRONG.** Found the real symbol via RTTI search: `_ZTVN2im3app7cameras21OrthoCameraControllerE` at `0xa9c1a8`, real slots starting `0xa9c1b0`. Those earlier addresses actually belong to `RainEffect`/`RearViewCameraController` - sibling classes laid out contiguously right after `OrthoCameraController` in `.data.rel.ro` (`im::app::cameras` namespace). The REAL 12-slot vtable (`sub_66FE28`, `sub_9C420`, `sub_9C384`, `nullsub_73`, `sub_3A7068`, four more no-ops, `sub_9C3B8`, `sub_9C418`) is ctor/dtor/factory plumbing plus one position-update method - **no width/height/resolution logic anywhere in it.** Given its real siblings, `OrthoCameraController` is almost certainly a 3D in-race camera class, not the 2D UI/text projection system - this whole lead, chased across two sessions on a plausible-sounding class name, was a dead end. + +2. **Added a live probe on every `glUniformMatrix4fv` call, shape-matched to the known-buggy coefficients** (`scale_x`/`scale_y` in 0.0002-0.003). **Fired thousands of times across a 600,000+-line capture spanning boot all the way past the "dots" screen, and every single hit showed the CORRECT matrix** (`scale_x=0.000833≈2/2400`, `scale_y=-0.001852≈2/1080`) - the buggy `0.001`/`-0.002` (2000x1000) coefficients never appeared once, despite a live screenshot at that exact moment still showing the squashed disclaimer-text sliver. **This directly contradicts this doc's own earlier "confirmed: 2000x1000" finding.** The projection matrix, measured live on the actual broken screen, is not the bug. + +3. **Per the user's own hypothesis (JNI feeds bad data, triggers a fallback) - tested `sub_4010F0`'s UV-atlas-remap transform** (the 8-float matrix + 4 offset/scale floats at `v3-96..v3-44`/`v3-124..v3-112`, gated by `*(v3-132)`, flagged earlier today as a plausible pre-projection squash point). **Result: the gate read 0 (remap SKIPPED) on all 210 live hits captured** - this path never activates for the disclaimer-text draw. Also ruled out. + +**Where this leaves task #39 now**: both of today's live-tested hypotheses are disproven, and even the original "2000x1000 projection matrix" finding no longer reproduces on this build. The mechanism behind the squash is still unconfirmed. Most promising untested direction: the raw quad CORNER POSITION data itself (`sub_4010F0`/`sub_4015D4`'s `a2`/`a3` arguments) - whoever converts "738x302 texture, place at screen position X,Y" into those 4 corner coordinates sits one level further upstream than anything probed today and hasn't been examined at all. Next step: probe `sub_4010F0`'s own callers (the `sub_3DA24C`/`sub_400678`-style wrapper `Draw()` methods) for the actual position values they pass in, rather than assuming they're correct. + +**2026-09-18 (continued, BREAKTHROUGH - the squash is measured directly, at its source).** Added `QuadGeometryProbeHookCb` (`guest_engine.cpp`) on three sites: `sub_4015D4` (AppendQuad - THE universal funnel, both the direct and clipped paths end there; logs the 4 corner (x,y) pairs from `a2`, which is 4 vertices of 4 dwords each), `sub_3DA24C` (the clipped-path consumer), and `sub_400678` (a Draw() wrapper's start-index/index-count). Shared trace-window budget raised 400 -> 1200 so the five hook sites now sharing it don't starve each other. + +**The captured geometry settles the question - the coordinate space is perfectly correct, and the text quads arrive already-degenerate:** + +| what | corners | span | +|---|---|---| +| full-screen background | `(0,0) (0,1080) (2400,1080) (2400,0)` | **2400x1080 - exactly right** | +| MOST WANTED logo | `(518,225) ... (1883.43,415)` | 1365x190 - sane | +| **disclaimer text line 1** | `(1200,988) (1200,993) (1210,993) (1210,988)` | **10x5** | +| **disclaimer text line 2** | `(1200,1023) ... (1210,1029)` | **10x6** | +| two other UI elements | `(2400,496)-(2400,584)`, `(2400,567)-(2400,770)` | **0.00 x N - literally zero width** | + +**This is conclusive on several points at once.** (1) The engine's quad geometry is expressed in REAL 2400x1080 device pixels and the full-screen quad proves that space is correct end-to-end - killing the last remnant of the "wrong reference resolution" theory. (2) The text quads are **not** mis-transformed later; they are *born* 10 pixels wide. (3) Temporal correlation is direct and tight: the 10x5 quad first appears 622 log lines after the `tex=4 738x302` text-bitmap upload, inside the same trace window - so a 738x302 text bitmap is being stretched onto a 10x5 box, which is *exactly* the tiny dark sliver the live screenshot shows. (4) **All 261 captured 10x5 quads sit at the identical position, and all 95 10x6 quads at another identical position** - these are two text elements (matching the real A9 screenshot's two lines of disclaimer text), each re-emitted every frame at a fixed, collapsed size, both anchored at x=1200 = exactly 2400/2 (screen centre). Widths are identical (10) while heights differ slightly (5 vs 6), suggesting height is derived from something real (font size) while width is stuck at a constant. + +**Honest correction on the JNI hypothesis** (user's own: "как будто JNI отдаёт неправильные данные в движок и там срабатывает фолбэк"): the same capture DOES contain real JNI failures returning 0 to the engine - `Call*MethodV`/`GetMethodID`/`GetObjectClass` refusing cross-thread local references ("class is a local reference from a different thread - returning 0"), 30 occurrences, at `CppBridge` helpers `sub_96AE60`/`sub_96ADD8`/`sub_96AF68` (generic "call Java method N on cached class, memoising the methodID" wrappers). These are genuine bugs in this layer's reference handling and worth fixing on their own. **But they are not the cause of this symptom**: they fire at 14:33:49 and concern `HashMap.put(Object,Object)`, while the degenerate text quads appear at 14:34:16 - 27 seconds later, with no text/font-metric call among the failures. Recorded as a separate issue rather than folded into this one. + +**Next step, now very tightly scoped**: `sub_4015D4` receives the already-degenerate corners, and the call reaching it comes through `sub_4010F0`'s virtual dispatch (`LR=0x4011dc`, the indirect call at `0x4011d8`) for the text quads, and via `sub_3DBAF0`->`sub_4014EC` (`LR=0x3dd610`) for the zero-width ones. So the 10x5 is computed by `sub_4010F0`'s own caller - `sub_401044`, `sub_401228`, or `sub_4042F0` - which builds the corner array it passes down as `a2`. Probe those three entries for their incoming position/size values and walk up until the constant 10 appears; that is the bug. + +**2026-09-18 (continued, ROOT CAUSE LOCATED - "откуда десятка" answered, and three follow-on hypotheses tested and refuted).** Probed `sub_3DA24C`/`sub_400678` as asked, plus `sub_4015D4` and `sub_4042F0` (the two that actually carry the data). + +**`sub_4042F0` is the glyph emitter, and its decompile gives the corner formula outright:** +```c +x0 = a1[1] + (float)g[2]; y0 = a1[2] + (float)g[3]; // pen + glyph offset +x1 = x0 + (float)g[4]; y1 = y0 + (float)g[5]; // + glyph width/height +// corners emitted as (x0,y0)(x0,y1)(x1,y1)(x1,y0) <-- matches the measured order exactly +a1[1] = *((float*)g + 10) + a1[1]; // pen += glyph ADVANCE +``` +So the "10" is `g[4]`, the glyph's own width field, and `g` is a per-glyph descriptor. Live dump of that descriptor (new `GlyphProbe`): + +- `size=10x5` / `10x6`, `off=(0,-1)`/`(0,-2)`, many DISTINCT descriptors (one per character), all sharing one texture. +- **UVs decode to floats that are exactly consistent with the size**: `du = 0.009766 = 10/1024`, `dv = 0.004883 = 5/1024`. So the descriptor is *internally coherent* - not random garbage, not uninitialised. It genuinely describes a 10x5 glyph in a 1024-wide atlas. +- **`advance = 0.000` on every single glyph, without exception.** +- **The text pen has only TWO distinct values in the whole capture** - `(1200,989)` and `(1200,1025)`, one per disclaimer line. It never moves within a line. + +**That is the bug, stated precisely: the per-glyph advance is 0, so `pen += advance` never advances, and every glyph of a line is emitted stacked on the same 10x5 box.** Rendered, that is a single ~10px dark smudge - exactly the sliver in the live screenshot. This fully explains the symptom with no reference to projection matrices, clipping, or atlas transforms; all of those were correct, as separately established above. + +**Three follow-on hypotheses about WHY advance is 0 - all tested live, all refuted (recorded so nobody re-walks them):** +1. *"The engine's font setup failed over JNI, so measureText was never resolved."* `sub_54208C` (font ctor, IDA) does `NewGlobalRef(paint)` -> `GetObjectClass` -> `GetMethodID("getTextSize"/"setTextSize"/"getFontMetricsInt"/"measureText")`, caching the last in `dword_ADFB08`. Read those globals live during glyph rendering: **`getTextSize=1479 setTextSize=1480 measureText=236` - all valid, none zero.** The font setup succeeded. (The one `GetObjectClass` rejection in the capture carries `LR=0x54ca38` once LR logging was added to it - a *different* function, not `sub_54208C`. Real issue, separate task.) +2. *"Float return values come back in the wrong ABI register."* `FloatBitsToR0`'s own comment in `jni_shim.cpp` flags exactly this ("a real AAPCS32 hardfloat caller expects this back in s0"), so it looked like a live match. Checked the binary instead of assuming: `readelf -A libapp.so` shows **no `Tag_ABI_VFP_args`**, i.e. base/softfp AAPCS - float returns *do* belong in r0. Our implementation is correct for this binary. Not the bug. +3. *"Float arguments are marshalled wrong on the way in"* (e.g. the text size passed to `createPaintFromFile(String,float)`). The marshallers reinterpret the raw 32 bits (`memcpy` into `jvalue.f`) in all three paths (`MarshalArgs`/V/A) - correct for softfp. Not the bug. + +**Next step, now a single question**: find what writes the glyph descriptor's `+40` (advance) field - and, secondarily, its `+16`/`+20` (10x5, suspiciously small for a disclaimer font). The descriptor is heap-allocated per glyph and internally consistent, so something *computed* these values from a font source; that computation is where the zero comes from. A guest memory-write watchpoint on a live descriptor's `+40` is the most direct way in, since static xref hunting for a generic struct offset is unlikely to converge. + +**2026-09-19: ROOT CAUSE FOUND AND FIXED - JNI varargs float->double promotion was never handled.** The user's original instinct ("как будто JNI отдаёт неправильные данные в движок") was right all along; it just took four refuted hypotheses to find the actual mechanism. + +**The chain, fully verified end to end (each link measured live, not inferred):** +1. `GlyphBuffer::prepareGlyph` (`sub_3E3248`, named by its own debug string) mallocs a 48-byte glyph descriptor and fills it via `sub_3E54C8`, passing the glyph advance it gets from the font's vtable slot +36. +2. Slot +36 (`sub_542DB4`) builds a one-character string and calls slot +40 (`sub_542E50` = `MeasureStringAdvance`, again self-named), whose body is literally `CallFloatMethod(paint, Paint.measureText, oneCharString)` scaled by two fields. +3. Live probe on that JNI call: **receiver valid, methodID valid (`measureText` resolved to 0xb3), the string argument perfect (`"A"`, `"0"`, `"©"`, `" "`, all `len=1`) - and Java answered `0.0` every single time.** A neighbouring float call (`getPerformanceScore -> 6.6`) worked fine through the same shim, so the float RETURN path was not at fault. +4. Java only returns 0 from `measureText` when the Paint's textSize is 0. The text size reaches Java as a float argument to `createPaintFromFile(String,float)` / `setTextSize(F)V`. +5. **Probing the raw va_list bytes settled it**: `raw=0x00000000 next=0x4037a000` -> read as a 4-byte float that is `0.0`, read as an 8-byte double it is **23.625**; likewise 27.0, 30.375, 33.75, 40.5, 54.0 - real font sizes. C's default argument promotions widen `float` to `double` in any varargs call, and every `Call*Method`/`Call*MethodV` JNI form IS varargs. This layer read a single 4-byte slot, i.e. the double's LOW half - which is exactly zero for every one of those values. + +**Fix** (`jni_shim.cpp`, both varargs marshallers - `MarshalArgs` for the register/stack forms and `MarshalArgsFromPointer` for the `va_list` forms; the `...A` jvalue-array path is deliberately untouched, since `jvalue[]` carries real un-promoted floats): read an 8-byte, 8-byte-aligned slot pair, reinterpret as `double`, narrow to `jfloat`. + +**Verified live on the Pixel 6a, in both directions** (per [[feedback_verify_fix_both_directions]] - "no crash" alone would prove nothing here): +- `measureText` now returns real per-character widths (10, 12, 13, ... 20) instead of 0. +- Glyph advances are now per-character and varied (10, 13, 14, ... 27); glyph sizes are real (`29x40`, `28x40`, `23x45`, `35x45`) instead of a uniform `10x5`. +- The text pen actually advances (`998 -> 1023 -> 1031 -> ...`) instead of standing still. +- The emitted quads match those glyph sizes exactly, and the atlas UVs are bit-exact against them (`u1 = 35/1024` for a 35px glyph, in a 1024x1024 atlas). +- On screen the disclaimer is now a full ~400px-wide laid-out text block instead of a 10px dash. No crashes, process healthy. + +**Scope note - this was never a text-only bug.** Every float argument passed to Java through a varargs `Call*Method` form across the whole engine was silently becoming 0. Text was simply where it showed up most legibly. + +**Still open (new, narrower)**: the laid-out text renders garbled - correct positions and sizes, wrong pixels. Atlas packing itself looks sound (`u0` varies per glyph, `u1-u0` matches the glyph width exactly), so the next suspects are the atlas V coordinates / row placement or the order of the atlas upload versus the draw. Separate, much smaller investigation than the one just closed. + +--- + +# 2026-09-19 — the day it became playable + +Summary of a single long session. The through-line: five plausible theories were refuted by live measurement before the real cause of each symptom was found, and every fix below is backed by a number, most of them compared against the real ARM32 build running on a Galaxy A9. + +## 1. ROOT CAUSE: JNI varargs float→double promotion (the big one) + +C promotes `float` to `double` in ANY varargs call, and every `Call*Method`/`Call*MethodV` JNI form is varargs. `MarshalArgs`/`MarshalArgsFromPointer` read a single 4-byte slot - i.e. the double's LOW half, which for typical values is exactly zero. Proof straight from the guest's own va_list: + +``` +raw=0x00000000 next=0x4037a000 -> as float4 = 0.0, as double = 23.625 + 0x403b0000 -> 27.0 + 0x4040e000 -> 33.75 +``` + +Those are real font sizes. Chain: `textSize` arrives as 0 → `Paint.measureText()` returns 0.0 → every glyph's advance is 0 → a whole text line is emitted stacked on one 10x5 box. + +**This was never a text bug.** Every float argument passed to Java through a varargs form across the entire engine was silently becoming 0. Text is just where it became legible. + +Fixed in both varargs marshallers; the `...A` (jvalue[]) path is deliberately untouched, since jvalue carries real un-promoted floats. Verified in both directions: `measureText` now returns 10..20, advances are per-character (10..27), glyph sizes real (29x40, 23x45), the pen actually advances (998 → 1023 → 1031), atlas UVs bit-exact (`u1 = 35/1024` for a 35px glyph). + +**Result: the game passes its EULA, loads the prologue, and renders real 3D gameplay** - Challenger on a wet highway, city, overpass, motion blur, HUD. + +## 2. GuestHeap: ~4x memory overhead removed, measured against hardware + +| | native A9 | this engine, before | after | +|---|---|---|---| +| menus | 46 MB | — | — | +| race loaded | **199 MB** | 768 MB exhausted mid-prologue | **peak live 207.5 MB** | + +Cause was structural, not a leak: power-of-two size classes where the bump allocator carved the FULL class (a 4.1 MB request took 8 MB; 70 KB took 128 KB) multiplied by segregated per-class free lists, where a freed 8 MB block could never serve a 64 KB request. ~1.5x waste × ~2.5x stranding ≈ the 3.9x observed. + +Reworked to exact-size allocation with one size-ordered free structure: `lower_bound` best-fit, **O(log n)** - deliberately not a linear scan, since this allocator's own history includes an O(n)-scan performance cliff. Oversized remainders are split back. Measured after: fragmentation `carved − live − free` ≈ **2.5 MB**, zero exhaustion, zero rejected frees. + +Three real bugs fell out on the way: +- `realloc` copied the NEW size out of the OLD block - reading past its end on every shrink. Now copies `min(old,new)` using the exact per-block size. +- `calloc` computed `nmemb*size` in 32 bits; on overflow it allocated small and then zeroed full size - a heap overflow. Now checked. +- Block header padded 12→16 bytes: payload alignment had been drifting between 8 and 4, so guest code could get a 4-aligned buffer for 8-byte accesses. + +`malloc` no longer zeroes (only `calloc` does, which is where the cost belongs). + +## 3. FMOD fake-handle leak + +`FakeHandle()` called `AllocPermanent(16)` - a never-freeing bump allocator - on EVERY call, from every FMOD factory/getter. During a level load that drained the control arena in seconds, after which the engine logged "control arena exhausted" ~120 times/second forever. Raising the arena did not and could not help; a leak is not a capacity problem. Replaced with a 1024-entry recycled pool. Handles are kept DISTINCT rather than returning one singleton, because guest code may compare two handles for inequality ("find a channel that isn't the current one") and a shared address could turn such a search into a spin. + +## 4. Frame presentation - fixed from A9 ground truth + +`trace_agent` now logs every `glClear` with its mask and bound framebuffer, against the real `eglSwapBuffers`. The real per-frame structure during a race, exactly and repeatably: + +``` +clear fb=1|2 (FBO) <- a frame STARTS with offscreen work +clear fb=0 (screen) 1st +clear fb=3, fb=3 (FBO) +clear fb=0 (screen) 2nd +clear fb=4 (FBO) +clear fb=0 (screen) 3rd +...HUD draws... +eglSwapBuffers <- ONE present, at the very end +``` + +**Three default-framebuffer colour clears per frame, one present.** This engine was presenting on every one of them - three times per frame, two with the frame part-drawn. That is the reported tearing and "missing frames". + +Nothing distinguishes the third screen clear from the first two, so the boundary used is the TRANSITION: present when the game starts clearing an offscreen target and the screen has been drawn into since the last present. A fallback keeps the old rule until an FBO clear has ever been seen, because the splash and menus measured zero non-default clears and would otherwise never present. + +Also counted on A9: 6250 default-fb colour clears vs 5354 swaps (1.17:1), and **362 depth/stencil-only clears on the default framebuffer** - mid-frame clears do exist on real hardware, which is why "only colour clears are frame boundaries" is correct even though the Pixel's splash phase showed zero of them. + +## 5. Load time - cause identified, fix not yet built + +zlib 1.2.5 is **statically linked** into `libapp.so` and NOT imported (no `libz.so` in NEEDED, no `inflate`/`crc32` among dynamic symbols) - so every byte the game decompresses runs through the emulator. Measured live: **66.2 MB of output from a single stream** before the prologue even finished, 27.8 MB of compressed input over 2000 calls. + +At the measured 12.8M guest instructions/sec and ~10-30 instructions per output byte, that is minutes of pure emulation. `inflate()` located: **`sub_667FFC`** (identified unambiguously - the only function referencing `"incorrect header check"`, which exists solely in inflate's zlib-header path; its neighbour `sub_6664EC` is `inflateBack()`). Tracked as task #43. + +Statically linked code is interceptable - the precedent is `FnvHashAccelHookCb`, which already replaces the guest's FNV-1a hash with a native loop. The difference: inflate is **stateful**, so the whole `inflateInit2_`/`inflate`/`inflateEnd`/`inflateReset` family must be intercepted together with a host `z_stream` kept per guest stream. + +## 6. Diagnostics cost + +One prologue-load capture held **1,381,445 log lines**: 554k malloc + 539k free + 198k from a `UC_HOOK_CODE` sitting on `__aeabi_memcpy`'s PLT stub - i.e. on the hottest routine in the program, forcing Unicorn to break its translation block and call out to C++ on every memcpy. All now opt-in (`kTraceHeapAllocations`, `kTraceCondVars`), registration removed for the memcpy and vtable probes. Log volume 1.38M → 5.8k lines. + +Two synchronous `glGetIntegerv` calls were also running on EVERY draw (framebuffer binding, current program + a mutex) - driver round-trips that can stall the CPU on the GPU. Both replaced with shadow state updated by our own shims. **Honest note: measured effect on frame rate was not visible.** Real overhead, not the dominant one. + +## Theories refuted by measurement (do not re-walk these) + +- **`OrthoCameraController` / 2000x1000 reference resolution** - the vtable examined in earlier sessions was the wrong one (`off_A9B61C` belongs to neighbouring `RainEffect`/`RearViewCameraController`); the real one at `0xa9c1a8` is ctor/dtor plumbing for a 3D in-race camera, with no resolution logic. And a live probe on every `glUniformMatrix4fv` showed the UI projection matrix is **correct** (`2/2400`, `2/1080`) on the very screen that renders wrong. The long-running 2000x1000 theory is dead. +- **UV-atlas remap in `sub_4010F0`** - gate read 0 on all 210 live hits; the path never runs. +- **Font setup failing over JNI** - `getTextSize`/`setTextSize`/`measureText` methodIDs are all valid (1479/1480/236). The one `GetObjectClass` rejection in the capture comes from a different function (`LR=0x54ca38`), a separate issue. +- **Float RETURN in the wrong ABI register** - `readelf -A` shows no `Tag_ABI_VFP_args`, i.e. base/softfp: returns belong in r0 and our implementation was already right. +- **Draw-or-skip culling** (`sub_56962C`/`sub_3FE160`/`sub_4015D4`/`sub_3DA3F0`) - real code, accurately traced, but the text quad IS drawn; it was born 10px wide, not skipped. + +## Still open + +- #41 garbled glyph pixels (positions and sizes now correct, pixels are not) +- #43 native zlib interception - the main load-time lever +- #42 frame-rate profiling - ~5 fps; the per-frame cost has NOT been attributed yet, and the heap/zlib work does not address it + +## 2026-09-19 (later): load time - measured, not guessed + +Three successive theories about why loads were slow each turned out to be REAL but not dominant, and each cost a build/test cycle to disprove: + +1. **Log volume** - 1,381,445 lines per load reduced to 5,806 (240x). User verdict: "чуть-чуть быстрее, но не намного". +2. **Per-draw synchronous `glGetIntegerv`** - two of them on every draw call (~1400 driver round-trips/sec), replaced with shadow state. User verdict: no visible change. +3. **Emulated zlib `inflate`** - intercepted and served by host zlib; **verified engaged**, 157 MB decompressed natively per load, zero failures. User verdict: "чуть быстрее, но не значительно". + +All three were real overhead worth removing. None was the answer. The lesson, paid for three times: back-of-envelope arithmetic answers "could this plausibly cost that much" and says nothing about "what does it actually cost". + +**So the block profiler was switched on for one capture** (`EnableProfiling()`, off again immediately afterwards - a UC_HOOK_BLOCK over the whole image has caused a user-visible regression twice before). 177,881 samples over 6,477 distinct block addresses: + +| guest routine | share | what it is | +|---|---|---| +| `sub_65F6C8` | **17.7%** | zlib `crc32` - `~crc` in/out, 8x256 slice-by-8 table at `dword_A40B84`, 32-byte unrolled loop | +| `sub_4F3704` | **18.8%** | resource lookup by name - **linear scan with `strcmp` per entry** | +| `sub_567BD4` | 3.8% | `glClear`'s caller (per-frame, not load) | + +**Two functions ≈ 36%; the rest is a long tail of ordinary guest code.** That shape is exactly why each earlier theory moved the needle so little - there was never a single dominant cost to find. + +`crc32` was intercepted immediately: pure and stateless, so unlike `inflate` there is no stream to own and every call can be served by host zlib. The game calls it directly on decompressed data (archive integrity), on top of whatever crc32 happens inside inflate. **User verdict after this one: "сильно стало быстрее"** - a clean comparison, since the profiler was off in both the before and after builds and `crc32` was the only difference. + +**Still on the table:** `sub_4F3704`'s linear name search (18.8%). Notably it ALREADY contains a hash-map fast path with memoisation (`sub_4F84CC` inserts the result), gated on a flag at `*(a1+8)` - yet the profiler's hot addresses are all inside the linear fallback. So this may not need native code at all, just an answer to why the cache isn't being used. Tracked as task #42. + +--- + +## 2026-09-19 — SOLVED: garbled glyphs were a NULL pixel pointer (task #41) + +Text had been laying out perfectly while rendering as coloured noise. Prior measurement had already +established that glyph positions, sizes, pen advance and UVs were all correct — UVs bit-exact +(`u1 = 35/1024` for a 35px glyph in a 1024×1024 atlas) — which localised the bug to pixel *content*, +not layout. + +`Shim_AndroidBitmap_lockPixels` returned **NULL** to the guest whenever the real pixel buffer lived +outside the guest region, under an assumption written into the code as a known, low-priority gap: + +> *"texture decode FROM a Bitmap is the main real use, which reads pixels via other means, not by +> writing through this specific pointer"* + +**The assumption was wrong, and the glyph atlas path is precisely the case it excluded.** A probe +added before changing any behaviour (deliberately — three theories were adopted and refuted on +arithmetic alone the day before) returned: + +``` +AndroidBitmap_lockPixels rc=0 hostAddr=0x... -> guest=0x0 <<< NULL >>> +bitmap 1024x1024 stride=4096 format=1 (RGBA_8888) +``` + +**1024×1024 is exactly the atlas size independently derived from the glyph UVs** — two unrelated +measurements landing on the same number. BitmapGraphics rasterised the glyphs correctly, the guest +asked for the pixels, got nothing, and uploaded whatever sits at guest address 0 as the font texture. + +**Fix**: a bounce buffer. On lock, `height * stride` bytes are allocated from the guest heap, the real +pixels copied in, and that guest address returned; on unlock the buffer is copied back into the real +bitmap and freed. The copy-back also closes the *write* direction the original comment named as +unsupported. Cost is one copy each way, a handful of times per session — not per frame. + +**Verified in both directions** (per the standing rule that "no visible defect" is not proof): the +fallback path now logs loudly when a bounce buffer could not be made, and it fired **zero** times +across a full run — so every lock genuinely got a buffer rather than silently taking the old NULL +path. User verdict: **"Шрифт починен!"** Committed as `f8744f0`. + +**Transferable lesson**: a shim that returns a plausible-but-empty value to the guest — NULL, 0, an +empty string — fails *silently and far from its cause*. This one cost a multi-session investigation +into draw calls, geometry and projection matrices, all of which were innocent. Where a shim cannot +honour its contract, the gap belongs in a log line, not only in a comment. + +### Why a NULL guest pointer produced *coloured noise* rather than a crash — and how to read that noise + +Worth recording as a reusable mechanic, because it cuts both ways. + +**Guest address 0 is a completely legal, mapped, readable address in this engine.** `G2H()` bounds-checks +only the upper end (`if (addr >= region_size_)`), and the loader places the ELF verbatim at its link-time +vaddr — `memcpy(host_region_ + ph->p_vaddr, ...)` — where libapp.so's first `PT_LOAD` has +**`VirtAddr 0x00000000`**, `0xa8d9bc` bytes, `R E`. So `G2H(0)` is the first byte of the guest region, +which is the ELF header followed by ~11 MB of the game's own machine code. + +Consequences: **a guest NULL dereference never faults.** It silently reads libapp.so's own image, offsets +and all — `0 + y*stride + x*4` just indexes into the code segment. Every such bug therefore surfaces as +plausible-looking garbage somewhere far from its cause, never as a crash. That is precisely how the glyph +bug survived: `glTexImage2D` was handed `G2H(0)` and dutifully uploaded 4 MB of `.text` as the font atlas. + +**The useful half: that garbage is identifiable.** Byte statistics over the region say exactly what was +read. Measured over the first 4 MB (= exactly one 1024×1024 RGBA atlas, saved as +`evidence/font_atlas_was_guest_addr_0.png`): + +| channel | mean | median | note | +|---|---|---|---| +| R | 47.4 | 12 | register / immediate fields — small numbers | +| G | 52.1 | 16 | same | +| B | 115.4 | 141 | opcode + `Rn` field | +| **A** | **179.2** | **227** | **76.5% of bytes ≥ `0xE0`** | + +One ARM instruction is 4 bytes = exactly one RGBA pixel, and A32's top nibble is the condition code — +`0b1110` (AL, "always") on the overwhelming majority of instructions. That lands in the **alpha** channel, +which is why the noise was opaque and visible at all. The image's three bands decode exactly: + +- row 0 — ELF header and program headers (coloured specks) +- rows ~7–102 — `.rel.dyn` (`0x6a80`, 387 KB): 50% of its words are `17000000` (`R_ARM_RELATIVE`) and the + other half are low addresses, so **every top byte is `0x00` → alpha 0 → fully transparent** +- rows 103+ — `.text` (`0x68000`) → opaque coloured noise + +So "alpha ≈ `0xEx` everywhere" reads as *code segment*, and a repeating `17000000` reads as *relocation +table*. Garbage on screen becomes a precise readout of which guest address was actually sampled. + +**Open architectural item (task #46)**: the multiplayer goal explicitly requires no memory errors, and this +design silently swallows an entire bug class that native hardware catches for free. Loading the image at a +non-zero bias would leave guest page 0 unmapped, making guest NULL dereferences fault immediately — the way +they already do on the A9. The engine applies `R_ARM_RELATIVE` relocations already, so a bias is plausible, +but it touches every hardcoded guest address in the shims and all the IDA-derived constants, so it is a +deliberate project, not a quick change. + +--- + +## 2026-09-19 — Measuring the real slowdown with the game's own race clock + +The in-game race timer (top right, `ВРЕМЯ M:SS,CC`) is a ground-truth clock the engine itself advances, +so comparing it against wall time measures the simulation deficit directly — no instrumentation, no +observer effect from our own counters. User's idea, and a much better instrument than anything we had. + +**Two independent methods agreed.** Timed `adb screencap` series (6 shots, ~13.3 s apart, wall time +stamped either side of each capture): game advanced 50.62 → 59.74 s over 66.61 s real = **7.30x slower**. +Then a 40 s on-device `screenrecord` (continuous, so it perturbs the app evenly instead of stalling it +~1.3 s per capture the way screencap does) sampled at *deliberately uneven* intervals, since uniform +spacing can hide burstiness: + +| real interval | game time advanced | ratio | +|---|---|---| +| 1.0 s | 0.13 s | 7.7x | +| 2.0 s | 0.27 s | 7.4x | +| 4.0 s | 0.53 s | 7.5x | +| 8.0 s | 1.12 s | 7.1x | +| 16.0 s | 2.20 s | 7.3x | +| 7.0 s | 1.05 s | 6.7x | + +**The ratio is rock-steady at ~7.2x across timescales from 1 s to 16 s**, even though *frame pacing* is +wildly uneven (intervals: min 13.7 ms, p10 16.0 ms, median 89 ms, p90 217 ms, max 316 ms — roughly 10% of +presents arrive at a full 60 Hz cadence, then long gaps). So the throughput deficit is a uniform ~7x tax, +not periodic stalls; the stutter and the slowness are separate phenomena. + +### The car jumping forward and back is a double-present, and it is our bug + +User reported the car visibly jumping forward, then back along its path. The frame-by-frame clock readout +shows exactly that, perfectly regularly — presents arrive in **pairs 10–20 ms apart, and the second member +of each pair displays the PREVIOUS simulation step**: + +| frame | real t | clock | +|---|---|---| +| #08 | 0.71 s | 1:10.06 | +| #09 | 0.72 s | 1:10.03 ← **back 0.03 s** | +| #10 | 0.92 s | 1:10.09 | +| #11 | 0.94 s | 1:10.06 ← **back 0.03 s** | +| #12 | 1.16 s | 1:10.12 | +| #13 | 1.17 s | 1:10.09 ← **back 0.03 s** | + +**Cause**: `GameGLSurfaceView extends GLSurfaceView` with `RENDERMODE_CONTINUOUSLY` (`RunLoop.kt:43`), and +GLSurfaceView's own GLThread unconditionally calls `eglSwapBuffers` after every `onDrawFrame` — framework +behaviour that cannot be switched off without taking over EGL management. Our synthetic swap +(`gles_shim.cpp`'s `frameBoundary`) fires *inside* the frame, so each rendered frame is presented twice: +once fresh by us, then again by GLSurfaceView, which hands over the back buffer still holding the previous +frame. Counts corroborate: synthetic swaps run **~4.8/s** (log markers `#7801`→`#8501`, 100 swaps per +~21 s) while the recording shows **9.03 presents/s** — almost exactly double. + +### What the numbers say together + +The clock steps by **~0.033 s (1/30 s) per simulation frame, fixed** — it is not wall-clock driven. We +produce **~4.5 real frames/s** instead of 30, so 4.5 × 0.033 = 0.149 s of game time per real second = +6.7x, which closes the loop with the 7.2x measured directly. + +**Consequence for multiplayer, and it is a hard one**: a fixed timestep means the game never catches up — +it simply runs in slow motion forever. A peer on native hardware would be 7x ahead within seconds. Frame +throughput is therefore not a polish item for the multiplayer goal, it is a prerequisite. + +### CORRECTION (same day): the double-present cause above is NOT established + +The section above blamed GLSurfaceView for the second present. That is not supported and is retracted; +the measurements in it stand, the cause does not. Prompted by the user asking the obvious question — +*why does nothing swap in the native environment?* — which the existing code already answers +(`gles_shim.cpp:389`): + +**Nothing in `libapp.so` can present a frame, natively or here.** Its only EGL import is +`eglGetProcAddress`, and the string `eglSwapBuffers` does not appear anywhere in the binary, so it cannot +even be looked up. On the A9 all 2,736 `eglSwapBuffers` calls in 46 s came from Android's own framework. +Native works because `GameRenderer.onDrawFrame` returns every frame and GLSurfaceView's GLThread swaps +automatically. Our synthetic swap exists precisely because `nativeOnResume` is recorded as running the +guest's persistent loop synchronously on the GLThread and never returning — **so GLSurfaceView cannot be +swapping a second time, and the retracted explanation contradicts its own premise.** + +The numbers leave a real hole: the `swapCount` counter (logged every 100th swap, markers `#7801`→`#8501` += 700 swaps over 152.57 s) gives **4.59 synthetic swaps/s** against **9.03 presents/s** recorded, with +pair spacing of 218 ms exactly matching the synthetic swap period. Two presents per swap, the second one +stale. Source unidentified. + +Worth flagging: the "onDrawFrame never returns" premise is itself carried over from 2026-09-18 and has +not been re-verified live — per this project's own standing rule about carried-over premises, it is now +the prime suspect rather than a fact to reason from. Task #47 specifies the experiment (per-swap +timestamps + an onDrawFrame entry/exit counter + correlated recording) that settles it without guessing. + +**Process note, because this is the fourth time**: a plausible mechanism was written up as a diagnosis +before its own numbers were checked. The swap-rate figure needed to refute it was already in the log. + +### RESOLVED: the retraction above was itself wrong — the premise was false + +Measured instead of argued, and the answer reverses the retraction. An entry/exit counter added to +`GameRenderer.onDrawFrame` shows it **returning every single frame**: + +``` +onDrawFrame entries=1 exits=0 +onDrawFrame entries=50 exits=49 +onDrawFrame entries=698 exits=697 <- the one-call gap is the call in flight +``` + +So the 2026-09-18 premise — `nativeOnResume` runs the guest's persistent loop on the GLThread and never +returns — **is false**, and had been reasoned from for a day. GLSurfaceView was presenting normally all +along, and the rates are 1:1: + +| | | +|---|---| +| synthetic swaps | **12.98/s** (SWAPMARK, one line per swap) | +| `onDrawFrame` | **12.69/s** → framework swaps at the same rate | + +One synthetic swap per `onDrawFrame`, plus the framework's own = every frame presented exactly twice, +the second time from a buffer holding the previous frame. The original diagnosis was right; the +retraction was wrong because it trusted a code comment over a measurement. + +**Fix**: `kSynthesizeSwap = false` in `gles_shim.cpp` — let the framework be the only presenter, which is +also exactly what the native game does. Measured on the Pixel 6a: + +| | before | after | +|---|---|---| +| frame interval median | 89 ms | **17 ms** | +| p90 | 217 ms | **19 ms** | +| max | 316 ms | **24 ms** | +| synthetic swaps | 4.59/s | **0** (verified present in the log, not merely unobserved) | +| `onDrawFrame` | 12.69/s | 12.39/s — **unchanged, as expected** | + +User verdict: *"Сейчас очень плавно"*. Committed as `7527089`. + +**Throughput is untouched** — this fixed presentation, not the ~7x simulation deficit. Note also that +`screenrecord` reporting 60 frames/s afterwards is *not* a render rate: `onDrawFrame` proves the game +still draws ~12.4/s, and the recorder simply emits one composited frame per vsync. Worth stating because +the 9.03/s → 60.04/s jump looks like a 6x speedup and is not one. + +**Process note**: this is the fourth time a plausible mechanism was written up before its own numbers were +checked, and the second time in one day. Both the diagnosis and its retraction were published on +reasoning; a single counter settled it in one run. The standing rule about carried-over premises +([[feedback-verify-ingame-before-re-theory]]) existed precisely for this and was not applied to the +`nativeOnResume` comment. + +--- + +## 2026-09-19 — First in-race profile: ~68% of CPU is Unicorn hook dispatch, not GPU or emulation + +The ~5 fps had never been attributed because every profile so far was taken during loading. Captured one +during an actual race using **`simpleperf` against the live process** — no rebuild, no restart, no lost +race. That technique is the reusable part of this entry: + +``` +adb shell simpleperf record -p --duration 20 -f 1000 -g -o /data/local/tmp/perf.data +adb shell simpleperf report -i /data/local/tmp/perf.data --sort dso # then --sort symbol +``` + +20,424 samples, 0 lost. Result: + +| shared object | share | +|---|---| +| `libmpcore.so` (our code + Unicorn) | **88.85%** | +| `libGLES_mali.so` (GPU driver) | 1.30% | +| kernel | 1.31% | + +| symbol | share | +|---|---| +| `helper_uc_tracecode` | **63.03%** | +| `helper_check_exit_request_arm` | **5.29%** | +| `libmpcore.so[+137c68]` | 2.19% | +| `helper_lookup_tb_ptr_arm` | 1.58% | + +**So the frame rate is not GPU-bound, and not dominated by raw instruction emulation — roughly two thirds +of all CPU time goes into Unicorn's hook machinery.** `check_exit_request` is emitted immediately after +every `gen_uc_tracecode` (`qemu/target/arm/translate.c:11594-11604`), which is why the two stack. + +Mechanism confirmed from the vendored source rather than assumed: `HOOK_EXISTS_BOUNDED` is evaluated +**per instruction at translate time** and walks the `UC_HOOK_CODE` linked list +(`include/uc_priv.h:253`); `helper_uc_tracecode` then walks that same list again on every hit. +`EnsureThreadEngine` currently registers **~24** `UC_HOOK_CODE` hooks. + +Two candidate explanations were checked and **ruled out**: no hook uses the global `begin > end` form that +`HOOK_BOUND_CHECK` treats as matching every address, and all three `UC_HOOK_BLOCK` hooks are correctly +gated off behind `ProfilingEnabled()`/`FullGuestTraceEnabled()` — the regression the 2026-09-05 comments +root-caused has not come back. + +Most of those 24 are **spent diagnostics from closed investigations** (`Sub4BA588Entry/V20`, +`Sub494EF8NotFound`, pre/postCall, readCall/readResult, cacheFastPath, sbaLoad, textClipDispatch, +appendQuad, clipConsumer, drawWrapper, glyphEmitter). The last four sit on the **text rendering path**, so +they fire per HUD glyph per frame — and the font investigation they belong to (#41) is closed. + +Tracked as task #48, with the experiment specified: delete the spent probes, re-run the identical capture, +compare. A plausible alternative survives and the comparison distinguishes it — `MiscStubDispatch` may +simply be hit enormously often (every import shim and JNI call), making the list walk a symptom and the +stub-call boundary the real cost. + +**Caveat on the call graph**: `simpleperf -g` callchains through TCG-generated code are unreliable (no +frame pointers in JIT output) — the recorded graph showed `helper_uc_tracecode` calling into Mali driver +functions, which is unwinding noise, not a real call path. Flat `--sort symbol` numbers are trustworthy; +the tree is not. + +**Also corrected here**: a `MEM FAULT READ_UNMAPPED` was briefly reported as evidence that the current run +had crashed. It had not — the fault belonged to a process that had already been killed. The log was read +without filtering by pid. Filter by pid before drawing conclusions about a live run. + +### Music never plays: the playlist falls through once a second, on the render thread + +User observation, and their proposed cause turned out to be exactly right: *"игра не находит трек, уходит +в фолбэк и ищет следующую по списку из playlists.sb"*. + +Four consecutive pause-menu screenshots ~1.5 s apart show four different tracks — DEAD SARA/*Weatherman*, +CROSSES/*Telepathy*, JOY FORMIDABLE/*Little Blimp*, CHEMICAL BROTHERS/*Galvanize*. The log explains it: + +``` +GameActivityMain: useAssetsFileSystem() called, mAssetLocationType=OBB, result=true, thread=GLThread +``` + +**24 calls in 25 s (0.96/s)**, with a strikingly regular interval — 1.11 1.10 1.10 1.09 1.05 1.03 1.03 +1.03 1.03 1.04 1.04 1.10 1.10 1.11 1.10 s. Real tracks run three to four minutes, so a ~1.05 s cycle means +playback never starts at all: each track fails and the playlist advances. One OBB asset-filesystem reopen +per cycle, **on the GLThread**, so it stalls frames directly. + +This is a candidate contributor to the ~5 fps independent of task #48's hook overhead, and the two must be +measured separately so neither is credited with the other's win. Tracked as task #49. The prime suspect, +by this project's own repeatedly-earned rule, is an FMOD shim that succeeds silently and reports +"finished" immediately — the same shape as the `AndroidBitmap_lockPixels` bug in #41. + +### Result: removing the probes cut the profile share but NOT the frame rate — hypothesis refuted + +Seventeen spent probes de-registered (registrations 20 → 9), then the identical `simpleperf` capture and +the same race-clock measurement: + +| | before | after | +|---|---|---| +| `helper_uc_tracecode` | 63.03% | **41.66%** | +| `helper_check_exit_request_arm` | 5.29% | 5.27% | +| `libmpcore.so` total | 88.85% | 84.65% | +| **game time vs wall clock** | **7.2x** | **7.69x** | + +27 s of wall time advanced the race timer 14.97 → 18.48 (3.51 s); the two sub-intervals gave 7.78x and +7.60x, so the reading is tight. **The frame rate did not improve.** The change did exactly what it was +mechanically supposed to and bought nothing — **hook-list length was not the binding constraint.** + +The freed time simply redistributed, visible in neighbouring shares rising as `tracecode`'s absolute cost +fell: `tb_invalidate_phys_page_fast_arm` 1.19 → 3.15%, `helper_lookup_tb_ptr_arm` 1.58 → 3.01%, JIT +`unknown` 7.17 → 9.98%, `libart.so` 0.15 → 1.17%. Committed as `1c6e2b7`; the cleanup is kept on its own +merits, and a profile no longer 63%-dominated by a single symbol is readable. + +**Caveat stated up front**: the before and after races are different track sections with different scene +load, so this is not a controlled comparison. It cannot hide a difference of this magnitude, but it is +worth repeating on the same section if the question ever becomes load-bearing. + +**Two traps avoided, both worth naming.** First, `onDrawFrame` measured 5.88/s after the change against a +recorded 12.39/s before — which looks like a catastrophic regression and is meaningless: the 12.39 figure +was captured during the *prologue load*, not a race. Scene-dependent rates are not comparable across +scenes. Second, a 21-point drop in a profile share is not a speedup; only the race clock settles that, and +it said no. + +**Where this points next**: something other than translation CPU is pacing the frame. Task #49 — the OBB +asset filesystem being reopened roughly once a second **on the GLThread** — is the standing candidate, +because if frames block on I/O then freeing CPU cannot make them arrive faster. + +--- + +## 2026-09-19 — SOLVED: music never played, and fixing it nearly doubled game speed (task #49) + +This one started as a user observation, not a profile: *"треки меняются очень быстро в меню паузы... Или +же игра не находит трек уходит в фолбэк и ищет следующую по списку из playlists.sb"*. The second guess was +essentially right, and the measurement followed: + +Four pause-menu frames ~1.5 s apart showed four different tracks — DEAD SARA/*Weatherman*, +CROSSES/*Telepathy*, JOY FORMIDABLE/*Little Blimp*, CHEMICAL BROTHERS/*Galvanize*, with GREEN DAY later +recurring non-consecutively. So not a sequential walk through `playlists.sb` but a **fresh selection every +cycle**. Meanwhile `GameActivityMain: useAssetsFileSystem() ... mAssetLocationType=OBB, thread=GLThread` +fired once a second. + +**Root cause, same shape as the other two found today:** + +```cpp +uint32_t Shim_Channel_isPlaying(...) { + OutBool(eng, out, false); // "not playing" + return kFmodOk; // "success" +} +``` + +`System::playSound` returned success plus a fake channel; `Channel::isPlaying` then answered **false** for +that very channel. The guest concluded each track had finished the instant it started and moved to the +next one, forever — reopening the OBB asset filesystem **on the render thread** every cycle. + +**Fix**: track the channels `playSound` hands out and report those as playing until `Channel::stop` drops +them, so genuine transitions (race end, menu change) still work. Plus a one-time log stating there is no +audio backend and playback is simulated — per the rule earned earlier today, an unhonoured contract +belongs in the log, not only in a comment. + +**Measured on the Pixel 6a, same method both times (the race's own clock):** + +| | before | after | +|---|---|---| +| **game time vs wall clock** | **7.69x** | **3.99x** | +| `useAssetsFileSystem` in-race | 1.00/s | **0.00/s** | +| faults | 0 | 0 | + +27 s of wall time advanced the race timer 18.82 → 25.58 (6.76 s); the sub-intervals gave 4.18x and 3.81x. +The OBB reopens stopped **completely**, which is the direct confirmation rather than the user's +"feels smoother". Committed as `e773ec4`. + +**The lesson worth carrying: this was worth roughly twice what the entire hook-removal effort was worth, +and no profile pointed at it.** Task #48 was chosen from a profile and bought nothing; this came from a +user noticing the music behaving oddly. A profiler shows where CPU goes, not where a thread is *blocked* — +and a once-per-second OBB reopen on the GLThread never appeared as a hot symbol. + +**Still ~4x from native**, so this is progress, not the finish line. + +--- + +## 2026-09-19 — ROOT CAUSE of the frame rate: `uc_emu_start`'s instruction-count limit (task #50) + +**`CallGuestFunction` passed `count=5000000` to `uc_emu_start`. Any non-zero count makes Unicorn install +a GLOBAL `UC_HOOK_CODE` of its own** (`uc.c:1202`): + +```c +uc_hook_add(uc, &uc->count_hook, UC_HOOK_CODE, hook_count_cb, NULL, 1, 0); +``` + +`begin=1 > end=0` — the form `HOOK_BOUND_CHECK` treats as matching **every address**. So +`helper_uc_tracecode`, a `gen_set_pc_im` PC sync, and `check_exit_request` were emitted and executed for +**every guest instruction**, not per shim call. + +### How it was found: an arithmetic contradiction, not a hunch + +The in-race profile said `helper_uc_tracecode` 34.54% and `MiscStubDispatch` **0.04%**. A counter said +~65,000 stub dispatches/sec. Dividing tracecode's 7.03 CPU-seconds by 1.3M dispatches gives **~5.4 µs per +dispatch** — two orders of magnitude too much for a nine-entry list walk. Rather than explain the number +away, the contradiction was taken at face value: *the divisor must be wrong*. It was. tracecode was being +called per instruction. + +Two hypotheses were killed by counters first, which is why the third one landed: +- `.text` write counter → **flat zero**, so `tb_invalidate_phys_page_fast_arm` is not self-modifying code. +- `FnvHashAccelHookCb` counter (it still does a `uc_emu_stop` round-trip) → **never fires during a race**. + +### Result + +| | before | after | +|---|---|---| +| `helper_uc_tracecode` | 34.54% | **absent from the top symbols** | +| stub dispatches | 65k/s | **76–93k/s** (more guest work done per second) | +| **game time vs wall clock** | **3.99x** | **2.73x** | + +27 s of wall time advanced the race timer 30.13 → 40.02 (9.89 s); sub-intervals 2.75x and 2.71x. +**Today's full progression: 7.2x → 3.99x → 2.73x.** Committed as `7b15d85`. + +Beyond the hook cost, the per-instruction `gen_set_pc_im` also prevented TCG from chaining blocks, so this +lifted a ceiling on translation quality as well as removing a tax. + +### This also explains task #48's null result, and an error of mine + +Removing 17 probe hooks shortened the hook *list* but left this global entry in it, so the +per-instruction call remained — exactly why the profile share moved and the frame rate did not. + +Worth recording plainly: during #48 I searched for a hook registered with the global `begin > end` form, +found none, and wrote that off. **I had searched only our own code, not Unicorn's.** The offending +registration was inside `uc_emu_start` itself. A correct search would have found this hours earlier. + +### Trade-off accepted + +A runaway guest loop inside one call now hangs rather than returning after 5M instructions to be caught by +`kMaxCallIterations`. That net was already documented in the function as *"not a security boundary"*, and +since the 2026-09-05 stub fix a normal call completes in a single `uc_emu_start` anyway. If a watchdog is +wanted, use **one long-lived thread** calling `uc_emu_stop` — **not** `uc_emu_start`'s `timeout` argument, +which spawns a fresh thread per call (`uc.c:1036`). + +### Clean-build final number for the day: 2.57x + +Re-measured with both temporary counters off (one of them hooked every write across `.text`, so the 2.73x +figure was conservative). 27 s of wall time advanced the race timer 55.33 → 65.84 (10.51 s); sub-intervals +2.41x and 2.76x. + +**Day's progression: 7.2x → 3.99x → 2.73x → 2.57x — the game is now running about 2.8x faster than it was +this morning.** + +Profile shape with no dominant symbol left, which is the healthy sign: + +| symbol | share | +|---|---| +| `tb_invalidate_phys_page_fast_arm` | 5.56% | +| `helper_check_exit_request_arm` | 5.40% | +| `flatview_translate_arm` | 5.00% | +| `helper_lookup_tb_ptr_arm` | 4.60% | +| `find_memory_mapping_arm` | 2.90% | + +Next candidates, in order of size: `flatview_translate_arm` + `find_memory_mapping_arm` are one path +(memory-region lookup, 7.9% together); `tb_invalidate_phys_page_fast_arm` at 5.56% remains genuinely +unexplained since the `.text` write counter ruled out self-modifying guest code. + +--- + +## 2026-09-19 (autonomous session) — mapping the guest region by permission: loading 8.3% faster + +**`UC_PROT_ALL` on the whole guest region was costing three of the top profile symbols at once.** QEMU's +`notdirty_write` (`qemu/accel/tcg/cputlb.c:1199`): + +```c +mr = cpu->uc->memory_mapping(cpu->uc, ...); // region lookup +if (mr && (mr->perms & UC_PROT_EXEC) != 0) { + page_collection_lock(...); + tb_invalidate_phys_page_fast(...); +} +``` + +Marking memory executable means **every write to it** pays a region lookup plus a translated-block +invalidation check. The engine mapped the entire region — heap, stacks, data, everything — as RWX, so +ordinary stores all took that path. + +### Found by elimination; two counters, two zeros + +Neither of the obvious explanations survived contact with a counter: + +| counter | result | +|---|---| +| guest writes into `.text` | **zero**, across two sessions | +| guest writes into trampoline + stub arenas | **zero** | + +With both dead, the only remaining possibility was that the writes were *ordinary data writes that merely +lived inside an executable mapping* — which pointed at the mapping, not at the writer. + +### The first split failed, and that failure was the useful part + +Splitting only at `heap_end_` (heap RW, everything above it RWX) moved the three symbols from 13.5% to +13.0% — nothing. **Because it left the thread stacks executable.** The heap is 768 MB and the stacks are +tiny, but stores go overwhelmingly to the *stack*, once per call frame. *Biggest region* and *most written +region* were not the same region; only measuring the failed attempt revealed that. + +Final layout: image RWX; heap RW; trampoline+stub arenas RWX; control + thread stacks RW; mmap arena RWX +(`LoadSecondaryImage` maps real code there). + +### Verified end-to-end, not just in the profile + +| | | +|---|---| +| `tb_invalidate_phys_page_fast_arm` | **4.52% → 0.04%** | + +Time from engine start to the first `OnCarLoaded`, three runs each, via a temporary A/B switch: + +| mapping | runs | mean | +|---|---|---| +| single RWX | 40.95 / 39.06 / 38.96 s | 39.66 s | +| split by permission | 36.31 / 35.87 / 36.88 s | **36.35 s** | + +**Ranges do not overlap — loading is 8.3% faster.** The A/B was run specifically because task #48 proved a +profile-share drop is not a speedup. Zero `FETCH_PROT`/`WRITE_PROT` faults, so nothing executes from the +now-non-executable spans. Committed as `edfa360`. + +`flatview_translate_arm` (5.25%) and `find_memory_mapping_arm` (2.99%) barely moved, so they have callers +beyond `notdirty_write` — task #53 stays open. + +**Reusable harness**: `/tmp/loadtime.sh` times engine-start → first `OnCarLoaded` over N runs. This is the +first load-speed metric in the project that does not need the user to drive the game, and it is what made +an honest A/B possible while nobody was at the device. + +### Task #52 closed: the "wrong" viewport is the game's own 0.8 render scale + +A Xiaomi 14 capture showed `viewport=[0,0,2136,960]` against a 2670x1200 surface, which looked like task +#39's failure shape (UI projection using 2000x1000 instead of the real 2400x1080 because a JNI float +arrived as zero). Logging every **distinct** viewport together with its **bound framebuffer** and the real +EGL surface size settles it — Pixel 6a: + +``` +VIEWPORT [0,0 2400x1080] fb=0 | EGL surface 2400x1080 <- screen, exact match +VIEWPORT [0,0 1920x864] fb=3 | EGL surface 2400x1080 <- an FBO +VIEWPORT [0,0 512x512] fb=1 | EGL surface 2400x1080 <- another FBO +``` + +The default framebuffer matches the surface exactly; the small viewport belongs to an offscreen target. +And the ratio decides it: + +| device | width | height | +|---|---|---| +| Pixel 6a | 1920/2400 = **0.80** | 864/1080 = **0.80** | +| Xiaomi 14 | 2136/2670 = **0.80** | 960/1200 = **0.80** | + +Exactly 0.8 on both devices and both axes — **the game's own render scale**, drawing the 3D scene into a +reduced FBO and upscaling while the HUD stays full-resolution. Standard mobile-engine practice, faithfully +reproduced. Nothing is broken. Committed as `1a74432`; the probe stays behind `kLogViewportChanges` +(default off). + +The original report quoted a draw-state line *without* its framebuffer binding, so an FBO viewport read as +a screen viewport — and the reporter did say they couldn't tell which it was. Worth noting for the +draw-call state logger: a viewport without its `fb=` is not interpretable. + +**Kept for later**: that 0.8 is a real performance lever. If the scene-FBO size ever traces back to a value +this engine supplies, lowering it would cut fragment work — but today it is the game's decision, not ours. + +### Task #51 attempted and reverted: the cached jclass does not come from FindClass + +The 28 "local reference from a different thread" rejections reported from the Xiaomi run were traced on +the Pixel 6a, and they cluster in **one guest function**: + +| guest LR | what | +|---|---| +| 13x `0x96ae23` | `Call*MethodV`, class from a different thread | +| 5x `0x96ae8d` | `GetMethodID("put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;")` — `Map.put` | +| 1x `0x96aea9` | `Call*MethodV`, **receiver** from a different thread | +| 1x `0x54ca38` | `GetObjectClass` | + +`sub_96ADD8`/`sub_96AE60` are adjacent helpers in the game's generic JNI bridge (IDA strings: +`"CppBridge"`, `"Can't find class %s"`). `sub_96AE60` decompiles to a descriptor-driven dispatcher — +`a1+0` cached jclass, `a1+4` class name, `a1+12` method names, `a1+16` signatures, `a1+20` cached +jmethodIDs — filling method IDs lazily and calling through the JNIEnv vtable. The jclass at `a1+0` is +cached for the process lifetime and reused from any thread: the missing-`NewGlobalRef` bug this shim +already documents. + +**The attempted fix failed and was reverted.** Making `Impl_FindClass` return a per-class-name cached +**global** reference did not reduce the rejections — 19 after versus 14 before, same call sites in the +same proportions. **So the class `CppBridge` caches never passes through `Impl_FindClass`.** Nothing was +committed; the tree is back to `1a74432`. + +Two things worth keeping from the attempt: + +- It **aborted the process within one run**: the guest calls `DeleteLocalRef` on the class it looked up, + and CheckJNI kills the process — *"expected reference of kind Local but found Global: 0x2fe6"*. Guarding + `Impl_DeleteLocalRef` with an `IsGlobal()` check fixed that cleanly. Any future attempt to introduce + global refs needs that guard, and should audit `NewLocalRef`/`PopLocalFrame` too. +- **Reverting was the right call, not a setback.** The change altered JNI reference lifetime, had already + caused one abort, and bought nothing measurable. Keeping it "because it's written" would have left a + semantic change in the engine with no result to justify it. + +**Before any further attempt**: find who actually writes the jclass into `descriptor+0`, and decide +whether these zeros matter at all — if EA's callers ignore `Map.put`'s return value, this is noise, and +answering that by decompiling the callers is cheaper than any fix. + +### Task #53 attempt 2 failed and was reverted: splitting the IMAGE span broke execution + +After the permission split landed, the image span `[0, image_end_)` was still one RWX block — and it +contains `.data`/`.bss`, which the guest writes constantly, so those writes were still paying +`notdirty_write`. The obvious refinement was to split the image at its own writable, non-executable +PT_LOAD (libapp.so: `[0, 0xa8d9bc)` R E, then `[0xa8f190, …)` RW), taken from the program headers rather +than hardcoded. + +**It broke the game immediately** — the prologue never loaded, and the log gave the reason: + +``` +MEM FAULT FETCH_PROT guest_addr=0x48da4c at guest PC=0x48da4c +``` + +`0x48da4c` is deep inside `.text`, which the change was supposed to leave executable. The bug was in how +the boundary was picked: the loop took the **minimum** writable-segment start across every PT_LOAD it +saw, and that loop also runs for **secondary images** (`LoadSecondaryImage`). One secondary image with a +low vaddr dragged the boundary down to around `0x48d000`, putting the primary image's code inside the +non-executable span. + +Reverted; the tree is back at `edfa360` behaviour. The idea itself is still sound — `.data`/`.bss` writes +really are the remaining `notdirty_write` traffic — but the boundary must come from the **primary image +only**, and be asserted against `image_end_` before use. + +**The loud failure is the reason this cost minutes rather than a session.** `FETCH_PROT` named the exact +address, and the address immediately said "this is code, in the wrong span". That is precisely the +property the span comment claims makes this area safe to iterate on, now demonstrated. + +**Harness caveat worth recording**: `/tmp/loadtime.sh` waits for the first `OnCarLoaded`, which only +happens while the prologue is loading. On a save where the prologue is already finished the game goes to +the world map instead and the script reports "НЕ ДОСТИГНУТО" forever — which reads like a regression and +is not one. Check `FETCH_PROT`/`MEM FAULT` counts before believing a timing harness that says a build is +broken. + +--- + +## 2026-09-19 — First direct native-vs-emulated comparison: **~1.7x slower than native** + +The project has measured itself against wall-clock and against its own past builds all day, but never +against the real thing. The A9 runs the native ARM32 build, so it can. + +**Method**: both devices reset to NO save file, so both load the prologue and do the same work. Timed +from the app's first log line to the first `OnCarLoaded`. + +| | run 1 | run 2 | | +|---|---|---|---| +| **A9, native** (SM-A920F) | 22.66 s | 22.42 s | | +| **Pixel 6a, this engine** | 38.01 s | 41.02 s | | +| **ratio** | 1.68x | 1.83x | **~1.7x** | + +### The comparison was validated before the number was believed + +An earlier attempt at this produced "2.25x" and was **thrown away**: only 21 of 60 compared log lines +matched, because the two devices were in different game states (A9 had progress and loaded the frontend +with `blacklist_clock`/`btn_blackmarket`; ours was fresh and loaded the prologue with +`busted_light_blueadd_loop`). Different UI, different work, meaningless ratio. + +With both saves cleared, the same check passes decisively: **all 150 `layouts/` lines our engine emits are +present in the A9's set.** The A9 emits 23 more, every one of them a control-scheme probe +(`controlscheme_controller`, `controlscheme_xperiaplay`, `rect_steering`, `btn_accelerate`) — so **native +does strictly more work and still finishes faster**, which strengthens the result rather than weakening it. + +### How to read ~1.7x + +It is a **lower bound on translation overhead**, for two reasons that both point the same way: + +- The interval includes Android/JVM process startup, where no guest code runs at all. +- The hardware is not equal, and not in our favour to correct for: the A9 is a 2018 Snapdragon 660; the + Pixel 6a is a 2022 Tensor. **We are 1.7x slower while running on the faster chip.** Normalised for CPU, + the real translation cost is meaningfully larger. + +Still, as a headline it is a good one: a full ARM32-on-ARM64 CPU translation layer, running a real +commercial game, is within a factor of two of native on the same workload — measured, not estimated. + +**Caveat on the A9 build**: its log carries this project's own `mpcore_log` tags, so it is the *modded* +native build rather than a pristine one. It executes ARM32 natively either way, which is what the +comparison is about, but it is not a stock APK. + +The A9's 67 KB progress save was backed up to +`save_backups/nfstr_save_2026-09-19_A9-native-67k.sb` (size verified byte-for-byte before deletion) and +can be restored. + +### Task #58 answered: `__dynamic_cast` is the GAME's behaviour, and we are the ones who cannot keep up + +The engine's per-shim counter found `__dynamic_cast` making up **49% of all shim crossings** during a +prologue load — 284,986/sec. That number could not distinguish "the game does this" from "our engine +induces this", so `trace_agent` was extended with a native interposer (libapp.so imports +`__dynamic_cast` as an undefined symbol, so LD_PRELOAD can count the real thing) and run on the A9. + +``` +DYNCAST native rate=696101/s +DYNCAST native rate=824200/s <- peak +DYNCAST native rate=395317/s +DYNCAST native rate=396870/s +total over the window: 2,773,690 calls +``` + +| | rate | +|---|---| +| native ARM32 on the A9 | **up to 824,200/s** | +| this engine on the Pixel 6a | 284,986/s | + +**Native calls it roughly three times more often than we do.** So there are no redundant calls to +remove — the game genuinely hammers RTTI, and native absorbs it because a real call costs tens of +nanoseconds. + +**The inversion that matters**: our 285k/s is not what the game *wants*, it is what we can *serve*. The +guest is asking for something closer to 800k/s and the shim boundary is throttling it. `__dynamic_cast` +is not a hot function we should optimise — it is the workload hitting our slowest mechanism. + +**Idea this opens, worth its own task**: `__dynamic_cast` only reads guest memory (RTTI type records, +vtables) and returns a pointer. It needs nothing from the host. So a **guest-side ARM32 implementation** +would run entirely inside Unicorn with **no boundary crossing at all** — and for a function called +hundreds of thousands of times per second, emulated-but-not-crossing may well beat +native-but-crossing. That inverts this project's usual instinct (move work to the host) and is exactly +why it is worth measuring rather than assuming. + +**Build note**: interposing `__dynamic_cast` collides with libc++abi.a, which the NDK links statically +into the agent. Resolved with `target_link_options(trace_agent PRIVATE "-Wl,--allow-multiple-definition")` +— our object file precedes the archive, so our definition wins and the archive copy goes unused. + +The A9's `wrap.` property was cleared and `adb unroot` run afterwards (leaving it rooted breaks `pm`/`am` +on this Knox device). The 55 MB `files/trace_output.log` was left in place for later analysis. + +### No reusable implementation in libapp.so — but 84% of calls need no hierarchy walk at all + +Checked whether the image already contains something to point the import at. It does not, and the reason +is instructive: **not only `__dynamic_cast` but the type_info vtables themselves are undefined** — +`__class_type_info`, `__si_class_type_info`, `__vmi_class_type_info`, `__pointer_type_info`, +`__function_type_info` are all `UND`. The game relies entirely on the external C++ ABI library, and this +engine supplies those vtables itself from `rtti_shims.cpp`. + +**But `Shim_dynamic_cast` is already a pure guest-memory walk** — it reads the object's vtable, +`vtable[-1]` (dynamic type) and `vtable[-2]` (offset-to-top), then compares type_info records. Nothing +comes from the host. That is exactly the shape that could run as emulated ARM32 with no crossing. + +Whether it is worth doing depends on how big it would have to be, so the shim was instrumented to count +what actually happens. Over one prologue load, **2,745,185 calls**: + +| case | share | +|---|---| +| **exact match, depth 0** (the object already IS the target type) | **84.2%** | +| one base, depth 1 | 0.4% | +| deeper | 0.0% | +| not found | 15.4% | +| max depth reached in the entire run | **3** | + +**84% of calls need no hierarchy walk whatsoever.** The guest fast path for them is about six +instructions: load the vtable, compare `vtable[-1]` against the target type, return `ptr + vtable[-2]`. +Everything else falls back to the existing host shim. + +Since `__dynamic_cast` is 49% of all shim crossings, eliminating 84% of them removes roughly **41% of the +entire crossing load**. Committed as `7af4886`; the survey stays behind `kSurveyDynamicCast`, default off. + +--- + +## 2026-09-19 — Guest-side `__dynamic_cast`: implemented, correct, and it changes nothing (task #59) + +The idea was sound on every prior measurement: `__dynamic_cast` is 49% of all shim crossings, native +calls it ~3x more often than this engine can serve, and the algorithm touches only guest memory — so +running it as emulated ARM32 would remove the crossing entirely. **Emulated-but-not-crossing should beat +native-but-crossing.** + +Built: 48 bytes of Thumb-2 (assembled with the NDK, not hand-encoded), clobbering only `r12` so `r1`/`r3` +reach the fallback intact, tail-calling the existing host shim for the ~16% of cases it does not handle. +`__dynamic_cast` is registered as a *data* symbol pointing at it, so the GOT resolves straight to guest +code with no hook involved. + +It works. It removes the crossings. It makes **no measurable difference**: + +| interleaved A/B, one run per build | runs | mean | +|---|---|---| +| fast path **ON** | 40.67 / 41.13 / 40.98 | **40.93 s** | +| fast path **OFF** | 41.35 / 40.52 / 41.46 | **41.11 s** | + +0.18 s apart, spreads fully overlapping. Noise. Committed as `633e99c`, default **off**. + +### The negative result is the valuable one + +**Shim-crossing count is not what paces loading.** Three findings now say this, and until today they read +as three unrelated disappointments: + +| | | +|---|---| +| removing 17 probe hooks (#48) | no speedup | +| cheapening the crossing itself (`9c4a455`) | ~3% | +| removing ~41% of all crossings (here) | no speedup | + +That closes an entire line of optimisation. Whatever paces the prologue load, it is not the boundary. + +### Two keepers found on the way + +**`AllocGuestCode()`** — `AllocPermanent` carves from the control arena, which task #54 mapped read-write +only. Code placed there faults instantly with `FETCH_PROT` *at its own entry address* (exactly the loud +failure that split was supposed to give, now demonstrated twice). Making the control arena executable to +accommodate it cost a measured **~4 s** — more than the fast path could ever save. `AllocGuestCode` uses +the trampoline arena, already executable and not write-hot. **Anything generating guest code must use it.** + +**Device drift invalidates cross-time comparisons.** The identical build that measured **35.20 s** earlier +in the day measured **41.11 s** a few hours later — a ~17% shift with nothing changed. Three near-miss +wrong conclusions today trace to comparing against a stale baseline, including one in this very +investigation where the fast path was briefly judged a 3.6 s regression against a morning number. **Only +interleaved A/B inside one sitting is trustworthy.** Earlier same-day figures in this document should be +read as valid *relative to their own immediate baseline*, not against each other. + +--- + +## 2026-09-19 — Toward native speed: the exit-request check ran on EVERY guest load and store + +With the shim boundary ruled out (task #59), the remaining cost had to be in the translation machinery +itself. A clean profile — taken only after discovering that **our own instrumentation was 3.67%** +(`__kernel_clock_gettime`, one `steady_clock` reading per draw and per shim crossing) — put +`helper_check_exit_request_arm` on top at **8.71%**. + +That number was a contradiction. `check_exit_request` is emitted next to `gen_uc_tracecode` at the ARM +translator's hook sites, so the two should be called equally often — yet `helper_uc_tracecode` was +**0.98%**, nine times smaller. Grepping every emission site across the whole tree rather than just +`target/arm` explained it: + +``` +qemu/tcg/tcg-op.c: tcg_gen_qemu_ld_i32 -> check_exit_request + tcg_gen_qemu_st_i32 -> check_exit_request + tcg_gen_qemu_ld_i64 -> check_exit_request + tcg_gen_qemu_st_i64 -> check_exit_request +``` + +**It is emitted on every guest memory access.** Not per block — per `ldr` and per `str`. Upstream QEMU +does not do this; it is Unicorn's own addition so that a `uc_emu_stop()` issued from a **memory** hook +takes effect immediately rather than at the next block boundary. + +This engine never does that: `mem_fault_hook_cb` only logs and returns false, and the one accelerator that +calls `uc_emu_stop` (`FnvHashAccelHookCb`) is a `UC_HOOK_CODE` hook, where the check is still emitted. On +the load/store path it is pure overhead. + +| | | +|---|---| +| `helper_check_exit_request_arm` | **8.71% → 4.07%** | + +Committed as `ea079b8`, behind `kEmitExitCheckOnMemoryAccess` — **anyone adding a memory hook that calls +`uc_emu_stop` must set it back to 1.** + +**Evidence level, stated plainly**: one clean interleaved A/B pair — **33.13 s** without the check vs +**35.89 s** with it (7.7%) — plus the profile share. That is below this project's usual bar. The follow-up +pairs were lost when the Pixel's screen dozed mid-session and the engine stopped rendering entirely +(`glClear=0`, no faults), and later attempts to reset the save between runs were blocked. **Re-A/B this on +a woken, freshly-booted device before treating it as settled.** + +### What the profile now says is left + +| symbol | share | +|---|---| +| `helper_lookup_tb_ptr_arm` | 5.74% | +| `flatview_translate_arm` | 4.46% | +| `helper_check_exit_request_arm` | 4.07% | +| `find_memory_mapping_arm` | 3.02% | +| `inflate_fast` (real work) | 3.24% | +| `float32_add_arm` + friends (**softfloat**) | ~2.6% | + +Three leads, in order of size: +1. **`flatview_translate` + `find_memory_mapping` ≈ 7.5%** — the softmmu lookup path, task #53. Still open, + and now the largest remaining item. +2. **`helper_lookup_tb_ptr` 5.74%** — indirect-branch dispatch. Block chaining *is* enabled (`tb_add_jump` + is called; only page-spanning blocks are excluded), so this is inherent to a guest full of virtual + calls and returns unless the jump cache can be improved. +3. **softfloat ~2.6% and probably more inside the JIT** — QEMU is emulating ARM VFP in software. An ARM64 + host can do these natively; whether Unicorn's ARM target can be made to use host FP is unexplored and + could be significant for a racing game. + +### Softfloat closed as a lever; the TLB is the last large one + +**Softfloat is not the problem it looked like.** `float32_add` is not pure software — it routes through +`float32_gen2(a, b, s, hard_f32_add, soft_f32_add)`, so QEMU already takes a host-FP fast path for normal +operands. The cost showing up as `float32_add_arm` is the **helper call per FP operation**: TCG has no +floating-point opcodes at all (`INDEX_op_add_f32` does not exist), so every guest VFP instruction becomes +a call out to C. Making guest FP map onto host FP instructions would mean adding FP ops to TCG itself — +upstream-QEMU-scale work, not a change this project can carry. **Direction closed.** + +**The TLB is the remaining large one.** `flatview_translate` 4.46% + `find_memory_mapping` 3.02% = 7.5% is +the softmmu lookup taken on every TLB miss. This QEMU uses the adaptive TLB with +`CPU_TLB_DYN_DEFAULT_BITS 8` — **256 entries × 4 KB = 1 MB of guest coverage** against a working set of +hundreds of megabytes. And from `tlb_mmu_resize_locked`'s own comment: + +> *"we only resize it on a TLB flush"* + +**So the TLB cannot grow unless something flushes it.** If this workload rarely flushes, it stays at 1 MB +forever. Coverage by size: 8 bits = 1 MB, 10 = 4 MB, 12 = 16 MB, 14 = 64 MB. + +The experiment is specified in task #53, and its first step is deliberately a *measurement*: log every +resize and count flushes. If the TLB is already growing on its own, the theory is dead and nothing should +be changed. + +### Device blocker + +This iteration could not measure anything. The Pixel 6a wedged with `NotificationShade` holding focus and +`mScreenState=OFF`. The screen could be woken (`input keyevent 224`) but the shade would not dismiss via +BACK, HOME, `service call statusbar 2`, or swipes; a reboot was declined as too invasive to do unattended. +**Without a foreground surface the GLSurfaceView never renders — `glClear=0`, zero draws, and no faults — +which reads exactly like an engine stall.** Worth remembering: check `mScreenState` and `mCurrentFocus` +before believing that a build has hung. Several measurements earlier in the session were almost certainly +lost to the same cause. + +--- + +## 2026-09-20 — Exit-check confirmed at 6.3%; the TLB theory refuted (task #53) + +### Confirmed: the per-load/store exit check was worth 6.3% + +`ea079b8` went in on a single A/B pair because the device wedged mid-measurement. Re-run on a freshly +rebooted Pixel 6a, three interleaved pairs, load to first `OnCarLoaded`: + +| | runs | mean | +|---|---|---| +| **without the check** | 33.42 / 33.10 / 33.32 s | **33.28 s** | +| with the check | 35.62 / 35.43 / 35.49 s | 35.51 s | + +Ranges do not overlap, each spread under a third of a second. **6.3%, solid.** + +### Refuted: TLB capacity is not what drives the softmmu lookup cost + +The premise checked out exactly as predicted. A probe in `tlb_mmu_resize_locked` showed it runs **about +twice per second**, and `current_entries` stayed at **256** for an entire run — so the adaptive TLB +genuinely never grows for this workload and sits at **1 MB of coverage** against a working set of hundreds +of megabytes. + +Raising `CPU_TLB_DYN_DEFAULT_BITS` 8 → 12 took effect (verified live, `current_entries=4096`) and bought +nothing: + +| | runs | +|---|---| +| 12 bits | 32.36 / 32.26 s | +| 8 bits | 32.86 / 32.32 s | + +0.28 s apart, ranges overlapping. And the profile moved the **wrong way** — `tlb_set_page_with_attrs` +0.86% → 2.92%, because a bigger TLB costs more to fill and flush. + +**So `flatview_translate` + `find_memory_mapping` (~7.5%) is not driven by TLB capacity.** Reverted to the +upstream default, with the refutation recorded in `cpu-defs.h` itself so nobody retries it without a new +theory. A correct premise does not make a correct fix — this is the second time today that a +well-supported diagnosis led to a change that changed nothing. + +### Device drift was device state, not code + +After the reboot the same build measures **~33 s** where it had measured **~41 s** before. That retro- +actively explains the "device drift" flagged earlier and reinforces the rule: **only interleaved A/B +inside one sitting counts**, and check `mScreenState`/`mCurrentFocus` before believing a build has hung. + +### Score after four closed directions + +Ruled out so far, each by measurement: shim-crossing count (three separate experiments), self-modifying +code, softfloat (TCG has no FP opcodes at all), TLB capacity. What remains hot — `helper_lookup_tb_ptr` +~5.4% (indirect-branch dispatch) and the softmmu lookup ~7.5% — has no cheap lever behind it. Getting +meaningfully closer to native from here likely needs an architectural change, not another constant. + +--- + +## 2026-09-20 — Why the softmmu lookup is hot: `memory_mapping` runs on every notdirty write + +With TLB capacity ruled out (#53), a probe on `notdirty_write`'s own first line found the cause — and it +is a **miss in this project's own earlier fix**: + +```c +ram_addr_t ram_addr = mem_vaddr + iotlbentry->addr; +MemoryRegion *mr = cpu->uc->memory_mapping(cpu->uc, ...); // <- unconditional +if (mr && (mr->perms & UC_PROT_EXEC) != 0) { ... } // <- result used for ONE bit +``` + +The lookup runs **before** the executable test, so it is paid even when the answer turns out not to +matter. Measured per 3-second window: + +| | | +|---|---| +| `hits=3478174 exec_region=1` | startup, all in the 0–1 MB bucket | +| `hits=1184522 exec_region=0` | | +| `hits=902501 exec_region=0` | buckets at 10 MB and 11 MB — **`.data`/`.bss`** | + +Over a million calls per three seconds where the full `find_memory_mapping → address_space_translate → +flatview_translate` chain ran and the answer was discarded. **Task #54 removed the `tb_invalidate` work +for non-executable spans but left this lookup in front of it**, so most of that cost survived a fix that +looked complete. + +The map is static once `CreateConfiguredEngine` has mapped its five spans, so a small per-thread +direct-mapped page → region cache removes all but cold misses. It is implemented in `5e246fc` but +**defaults OFF**, because it could not be measured — and shipping an unmeasured perf change is the exact +mistake this session has spent its time correcting. + +### Blocked on hardware + +- **Pixel 6a**: `mScreenState=OFF` within seconds despite a 30-minute timeout and `svc power stayon true`, + with `NotificationShade` holding focus. Needs physical attention. +- **Galaxy A9**: cannot run this build at all — `failed to attach / start timeout`. The engine's startup + work (11 MB ELF parse, relocations) exceeds Android's attach deadline on that hardware. Worth knowing + independently: **the A9 is not a viable fallback stand for the emulated build**, only for native + reference runs. + +To evaluate the cache: set `UC_MR_CACHE_ENABLED` to 1, then interleaved A/B on load time **plus** a check +that `flatview_translate`/`find_memory_mapping` actually fall. A profile-share drop alone is not a +speedup — established three times over in this project. + +### Verified: caching that lookup is worth 3.1% — after the first version made things worse + +| interleaved A/B, engine start → first `OnCarLoaded` | runs | mean | +|---|---|---| +| **cache on** | 31.27 / 31.34 / 31.22 s | **31.28 s** | +| cache off | 32.27 / 32.12 / 32.44 s | 32.28 s | + +**3.1%**, ranges fully separate, spreads under 0.35 s. And unlike several earlier attempts, the profile +confirms the *mechanism* and not just the outcome: `flatview_translate` 4.46% → 3.17%, +`find_memory_mapping` 3.02% → 2.36%. Committed as `8076865`. + +**The first version of this cache was slower, and only measurement caught it.** It used +`static __thread`, which on Android resolves through *emulated* TLS — `__emutls_get_address` showed up at +**8.11%** and `pthread_getspecific` at **3.07%**, together more than the 7.5% being removed. Moving the +storage into `uc_struct` (already per-thread here — one `uc_engine` per host thread) removed the TLS +entirely. **Do not reintroduce `__thread` on this path.** + +Worth naming as a pattern: a fix can be correct in mechanism and still lose, because the mechanism it +introduces has its own cost. Profiling the *fix* mattered as much as profiling the problem. + +--- + +## 2026-09-20 — Paper assessment: can we bypass softmmu? Measured answer: it is worth ~1.4x, not "native" + +### What softmmu costs us, measured + +Every guest memory access emits a TLB check before the access itself. Instrumenting the aarch64 backend +to count the bytes those sequences occupy against all generated host code, during a prologue load: + +``` +translation blocks: 4754 +total host code: 1,943,672 B +TLB sequences: 16,202 -> 580,132 B + = 29.8% of ALL generated host code +``` + +**35.8 bytes per sequence — exactly the nine instructions counted by reading `tcg_out_tlb_read`**: `LDP` +(mask+table), `AND_LSR` (index), `ADD` (entry address), `LDR` (comparator), `LDR` (addend), `AND` (page +mask), `CMP`, `B.NE`, and only then the access. + +The alternative already exists in the same file, behind `#ifndef CONFIG_SOFTMMU`: + +```c +#define USE_GUEST_BASE (guest_base != 0 || TARGET_LONG_BITS == 32) +#define TCG_REG_GUEST_BASE TCG_REG_X28 +... +tcg_out_qemu_ld_direct(s, memop, ext, data_reg, TCG_REG_GUEST_BASE, otype, addr_reg); +``` + +**One instruction** — `LDR Wd, [X28, Wn, UXTW]`. Our guest is 32-bit, so `UXTW` zero-extends the guest +address and adds the base register: **that is literally this engine's `G2H(addr) = host_base + addr`, +done by the addressing mode.** `X28` is already reserved for exactly this purpose in the backend. + +### Why the profile understated it + +`flatview_translate`, `find_memory_mapping`, `tlb_set_page_with_attrs`, `get_phys_addr` ≈ 11% is only the +**miss** path. The **hit** path is those nine inlined instructions, which carry no symbol and are +invisible in a sampling profile — they are spread through the JIT code and anonymous +`libmpcore.so[+…]` entries. That is why every optimisation aimed at the visible symbols returned +percentages: they were the tail, not the body. + +### Honest estimate of the prize + +| | | +|---|---| +| generated code that is TLB bookkeeping | **29.8%** | +| share of CPU in translated code (total minus named helpers) | ~60% | +| fast-path saving | ~18% of total CPU | +| plus the miss path disappearing entirely | ~11% | +| **total** | **~30%, i.e. about 1.4x** | + +The fast-path figure is likely conservative: that sequence is three *dependent* loads, so it costs more +per instruction than average code. + +**But 1.4x is not native.** It would take the game from ~2.6x slower than real time to ~1.8x. This is by +far the largest remaining lever and it does not, on its own, reach the goal. + +### What it would cost + +| lost | replacement | +|---|---| +| per-page permissions (guard pages, RW/RX) | real `mprotect()` on the same mmap — guest and host addresses differ by a constant, so **the hardware MMU enforces it for free** | +| `UC_HOOK_MEM_INVALID` | a `SIGSEGV` handler — prior art exists here, with a known trap: no `mmap` inside the handler on bionic ([[reference-arm-signal-handler-deadlock]]) | +| TB invalidation for self-modifying code | measured zero guest writes to `.text`, but must still be handled or it breaks silently | +| `uc_mem_read/write` and parts of the Unicorn API | go through `memory_mapping`; would need rework | + +Rebuilding Unicorn as `CONFIG_USER_ONLY` is not viable — its whole API assumes softmmu. The narrow path +is to patch **only** `tcg_out_qemu_ld/st` in the aarch64 backend to emit the direct form when the engine +is in flat-mapping mode, reserve `X28`, move permissions to `mprotect`, and add the signal handler. + +**This is the largest change the project has considered, for ~1.4x.** Worth doing if the goal is "as fast +as we can reasonably get"; not sufficient if the goal is literally native. + +--- + +## 2026-09-21 — Does the engine scale with CPU? Yes, 1.43x. Two bad measurements said otherwise first. + +The question mattered because task #61 (bypassing softmmu) reduces CPU work — if the engine were not +CPU-bound, that work would be wasted. + +### The answer, from the one measurement that is actually comparable + +`RunTcgBenchmark` runs a fixed synthetic ARM32 workload through the translation engine at every startup. +Identical code, identical work, every device: + +| device | SoC | throughput | +|---|---|---| +| **Xiaomi 14** | Snapdragon 8 Gen 3 | **26,783,896 B/s** | +| **Pixel 6a** | Tensor G1 | **18,737,899 B/s** | +| | | **1.43x** | + +Single-thread performance between those chips differs by roughly 1.6–1.9x, so 1.43x is a sensible scaling +factor — the engine tracks CPU speed, losing some of it to memory latency. **Task #61 is justified.** + +### Two measurements that said the opposite, and why they were wrong + +**"100k guest heap allocations" was not a unit of work.** Allocations happen in response to what the game +is doing, and the game does different things on different devices depending on save state and which screen +it is on. The metric produced Xiaomi 4.20s vs Pixel 6a 4.52s (a 7% gap, suspiciously small), and then a +steady-state variant produced Xiaomi *slower* than the Pixel — which is impossible and exposed the flaw. +**A fixed count of events is not a fixed amount of work unless the events are the same events.** + +**The Xiaomi was in a broken state throughout.** All textures were rendering black (see task #62) while +geometry and HUD drew correctly. With no texture sampling the GPU does far less work, so the device looked +faster than it is — and the user's impression that "the race start animation runs at native speed" is +most likely explained by that, not by the SoC. + +Both failures share a root: **comparing devices requires the same work on both, and neither the workload +nor the device state was verified before drawing conclusions.** The synthetic benchmark has neither +problem, and it was sitting in the tree already. + +### Device notes + +- **Galaxy A9 cannot run this build at all** — `failed to attach / start timeout`. Engine startup exceeds + Android's attach deadline on Snapdragon 660. It remains usable only as a *native* reference. +- Activities are not exported: `am start` from shell fails with `SecurityException` on some devices. + Use `monkey -p -c android.intent.category.LAUNCHER 1`. + +### Clean re-run: 1.63x scaling, and why the A9 cannot be measured + +`RunTcgBenchmark`, identical synthetic ARM32 workload, three runs per device: + +| device | runs (B/s) | mean | | +|---|---|---|---| +| **Xiaomi 14** | 28,866,461 / 29,266,837 / 28,626,660 | **28,920,000** | | +| **Pixel 6a** | 17,886,201 / 17,563,725 / 17,839,003 | **17,763,000** | | +| | | | **1.63x** | + +Spreads under 2%. **The engine scales with CPU**, which is what task #61 needs to be worth doing. + +**The Galaxy A9 cannot run this build**, and the failure is *before* our code: `Killing … start timeout` +with **zero `mpcore` log lines** — the process never reaches library load. So it is an app/asset-extraction +problem on that device's storage, not an engine limit. Note the Pixel has `files/libapp_armeabi_v7a.so` +(11 MB, extracted) while the A9 does not, so the A9 re-extracts on every launch and never finishes in time. + +**Cleanup note, my own mess**: the A9 was carrying a **1.6 GB** `files/trace_output.log` left over from an +earlier `trace_agent` session, plus the agent .so. Both removed. They were not the cause of the timeout +(it persists after cleanup) but should never have been left there. **Delete trace output after capturing +it.** + +## 2026-09-21 — CONFIRMED: bypassing softmmu is worth **1.24x** in the real game (task #61) + +The paper assessment of 2026-09-20 predicted ~1.4x for replacing the software-MMU +TLB check with a flat mapping. Implemented and measured: **1.24x**, real and +reproducible. This is the largest single engine win since the `uc_emu_start` +instruction-count fix. + +### What changed + +Our guest address space was *already* exactly what a flat mapping needs - one +contiguous host `mmap` where `G2H(a) == host_region_ + a`. The software MMU was +therefore translating an address that needed no translation, at a cost of nine +host instructions per guest access. The aarch64 backend can do the whole thing +in the addressing mode: + + LDR Wd, [X28, Wn, UXTW] // X28 = host base, Wn = guest address + +Four pieces, all in `third_party/unicorn`: + +- `uc_priv.h` - `flat_map_base` in `uc_struct`. Non-zero means "the guest + address space is one contiguous host block starting here". +- `tcg/aarch64/tcg-target.inc.c` - `UC_FLAT_MAP(s)` short-circuits + `tcg_out_qemu_ld`/`_st` before `tcg_out_tlb_read`, and the prologue + materialises the base into X28 and reserves it. `TCG_REG_GUEST_BASE` had to + move out of the `#ifndef CONFIG_SOFTMMU` guard: user-mode QEMU already does + precisely this, we just needed it under softmmu too. +- `uc.c` - `uc_set_flat_map_base()`, a new public entry point. +- `guest_engine.cpp` - calls it in `CreateConfiguredEngine`, behind + `kUseFlatGuestMapping`. + +### Two traps worth recording + +**Unicorn initialises lazily.** `uc_open()` only allocates the handle; the CPU, +the TCG context and the prologue are all built by the `UC_INIT` macro inside the +*first* API call that uses it. The first version set the base after `uc_open` +and found `tcg_ctx == NULL`, so every engine silently fell back to the software +MMU. It was caught only because the failure path logs - had it returned quietly, +the A/B would have measured noise and "refuted" a change that actually works. +The fix sets the base *before* `UC_INIT`, so the prologue is generated correctly +the first time; a rebuild path (`uc_reinit_prologue`) covers an +already-initialised engine and refuses if any block has been translated. + +**A 32-bit guest address zero-extends to 4 GiB.** Generated code adds it to X28 +with no bounds check at all, so a wild guest pointer - and we have hit real ones, +e.g. `0x3d3d3d3d` in task #33 - would land on an unrelated host mapping and be +read or *written* silently. `MapSegments` now reserves the full 4 GiB as +`PROT_NONE | MAP_NORESERVE` and maps the real region at its start, so any such +access is an immediate SIGSEGV instead. Costs address space, not memory. + +### Measurement + +Interleaved A/B on the Pixel 6a, same APK pair, alternating runs. Frames per +second over a fixed window, from the engine's own `onDrawFrame` counter: + +| | run 1 | run 2 | +|---|---|---| +| flat mapping | **51.2** | **50.7** | +| software MMU | 41.8 | 40.4 | + +Spread under 2% within each variant, and the two groups do not overlap. 1.24x. + +`RunTcgBenchmark` is **not** a valid metric for this change and was not used: it +builds its own bare `uc_engine`, which never gets the flat mapping, so it is +blind to it. (It stays valid for cross-device comparison, where both sides are +configured identically.) + +Checked that the win is not "faster because something silently stopped +rendering": over 90 s the flat build drew 351,492 elements across 3,768 frames +(93.3 per frame) against the software MMU's 128,344 across 2,720 (47.2), with +the same texture count (1,068 vs 1,087) and zero faults on either. The flat +build is not drawing less - it gets further into the scene. + +### The price, stated plainly + +Data accesses no longer consult Unicorn's memory map at all. That disables, by +construction: + +- `UC_PROT_*` enforcement - guard pages and the RELRO/`.text` split are advisory + for guest loads and stores (host `mprotect` would restore them; not done yet), +- `UC_HOOK_MEM_READ/_WRITE/_INVALID` - **the `MEM FAULT` diagnostics are silent**, +- self-modifying-code detection - a guest store to a page holding translated + code will not invalidate that translation. + +The escape hatch is one line: set `kUseFlatGuestMapping = false` in +`guest_engine.cpp` to get all of it back while chasing a memory bug. Any future +"impossible" corruption should try that first. + +### Where the time goes now + +Profile of the flat build (simpleperf, 20 s, flat `--sort symbol`). Every +softmmu symbol - `tlb_fill`, `victim_tlb_hit`, `notdirty_write`, +`memory_mapping` - has vanished from the profile, which independently confirms +the path is really bypassed. The new top: + +| share | symbol | what it is | +|---|---|---| +| 12.18% | `helper_lookup_tb_ptr` | indirect-branch target lookup | +| ~9% | `float32_add/mul/sub/compare` | **softfloat** - VFP ops run as helpers | +| ~8% | `uc_reg_read/write`, `reg_read/write_arm` | register transfer at the shim boundary | +| 4.28% | `helper_check_exit_request` | still runs at block boundaries | +| 2.51% | `cpu_get_tb_cpu_state` | + `arm_rebuild_hflags` 1.01%, `cpsr_write` 1.04% | + +Note the softfloat entry corrects an earlier conclusion. "Softfloat is +irrelevant because TCG has no FP opcodes" was right about the *IR* and wrong +about the cost: ARM VFP instructions are emitted as calls to helpers, and those +helpers emulate floating point in software on a host that has FP hardware. ~9% +sitting in `float32_*` is a cheap, self-contained target. + +`helper_lookup_tb_ptr` at 12.18% is the same mechanism +`STATIC_RECOMPILATION_FALLBACK.md` flags as the main risk for the lifting +approach (36,232 `blx ` sites): resolving an indirect branch to a +translated target. Both roads run through it. + +### Same build on the Xiaomi 14 (2026-09-21) + +Installed the identical APK (md5 `d123dfb3…`) on the Xiaomi 14 and measured with +the same `onDrawFrame` counter: + +| phase | Pixel 6a | Xiaomi 14 | +|---|---|---| +| menu / attract | 51 (engine-limited) | **61 — vsync cap**, engine has headroom | +| in race | not yet measured on this build | 40–56, rising 44→55 over 12 s | + +Two things worth separating, because conflating them is easy: + +- In the **menu** the Xiaomi sits exactly on 61 frames/sec, i.e. it is waiting + for the display, not for us. We cannot tell from that number how much faster + the engine actually is there. +- **In race** it drops to 40–56, so the engine is the limit again - the race is + genuinely heavier work, consistent with task #56. + +The rising trend inside the race (44 → 55 over twelve seconds) looks like +translation warm-up: new blocks are still being JIT-compiled early in the race +and the cost falls away as the cache fills. + +A screenshot taken mid-race shows the scene rendering in full - wet-road +reflections, motion blur, opponent car, HUD - with **no black textures** in this +run, so the frame rate is not being flattered by missing work. + +## 2026-09-21 — SOLVED: the post-prologue crash was a missing `num_get` facet (task #55) + +First session in which the game was played end to end: prologue, city map, car +selection, a full race, won. No crash. + +### The chain + +The last thing the engine logged before the SIGSEGV was its own shim admitting +it could not honour a request: + + std::locale::use_facet() for an id other than ctype/num_put + +The game runs `stream >> unsigned_int` and asks the locale for +`std::num_get`. We never built that facet - `rtti_shims.cpp` registered a +bare `id` with the comment *"no evidence anything requests these yet"*. The +shim returned NULL, the guest loaded a vtable through it and branched to +`0x464c459b` - bytes `9b 45 4c 46`, i.e. `\x9bELF`, the same +"\x7fELF"-as-a-pointer shape `FacetSlotCtx`'s comment already warned about. + +Two things made this findable in one reproduction rather than several: + +- **The shim logged that it was failing.** This is the whole value of the + "shims must log unhonoured contracts" rule - the diagnosis was the line + immediately above the fault. +- **The 4 GiB `PROT_NONE` reservation added the same day for task #61** turned + the wild pointer into a clean `SEGV_ACCERR` whose fault address decoded + exactly: `x28` (the new flat-map base) `= 0x6f00000000`, `x22 = 0x464c459b`, + fault addr `0x6f464c459b` - their sum. The guest address fell straight out of + the register dump. + +### Two diagnostic defects fixed on the way + +The message printed a bare arena address (`0x30b3f2f8`), which identifies +nothing - resolving it cost a rebuild and a second reproduction. And its +`static bool logged` was **global across all facets**, so a second missing +facet would have hidden behind the first. Now `GuestEngine::NameForDataSymbol` +resolves the address back to its mangled symbol, the message names the facet +and the calling guest address, and it reports once **per distinct id**. + +### Deriving the vtable offset instead of guessing it + +The crashing call is `(*(vtable + 28))(...)` at guest `0x3e5248`, decompiled as +`basic_istream::operator>>`. Offset 28 was pinned down by two independent +routes that agree: + +- This exact NDK's own `` gives num_get's virtual declaration order. +- The **already-working** `num_put` facet pins the vtable prefix: its confirmed + slots (16=long, 20=long long, 24=unsigned long, 32=double, 40=const void*) + line up with the declaration order only if three slots precede the first + virtual - the complete destructor, the deleting destructor, and + `__shared_count::__on_zero_shared`. + +Applying that prefix to num_get's order puts offset 28 on +`do_get(..., unsigned int&)` - exactly what `operator>>(unsigned int&)` calls. + +`ios_base::iostate` bit values were read from this NDK's `` rather than +from memory, because they differ between standard libraries: here +`badbit=0x1, eofbit=0x2, failbit=0x4` (libstdc++ orders them the other way). + +### What was implemented + +A real num_get vtable covering bool, every integer width, float and +double. Characters come straight out of the streambuf's get area - offsets +`eback@8, gptr@12, egptr@16`, pinned by the same anchor as +`WriteCharToStreambuf`'s already-working `pptr@24/epptr@28`. Parsing is handed +to the host's `strtoll`/`strtoull`/`strtod`, the same "offload it to real libc" +choice num_put makes for formatting. Slots outside the table still route to the +logging stub, so the next gap names itself instead of crashing. + +An exhausted get area would need a virtual `underflow()` call, which this shim +does not make - it reports end-of-input instead. Fine for the istringstream +case that occurs here, and logged rather than silent if it ever matters. + +### Measured afterwards, with its limits stated + +Across the winning session: **105,965 log lines, zero signals, zero +`MEM FAULT`, zero tombstones**, 19,955 frames and 1,052,420 draw calls. The +`use_facet` complaint is gone and no unimplemented-slot stub fired, which is +the positive evidence that num_get is now served. + +The frame-rate figure needs a caveat. The mpcore log buffer had wrapped, so the +counter only covers the **last 97 seconds** - and a screenshot shows that +window is the **post-race results screen** (27.5 draw calls per frame, a light +scene), not the race. 60.0-60.3 fps there is a vsync lock on an easy screen and +must NOT be read as "the race runs at 60". Measuring the race properly still +needs a run with the log captured from the start. + +## 2026-09-21 — Black screen entering a race: the thread-stack arena was one-way + +Found while trying to measure CPU load for the heat question, which is worth +noting on its own: the measurement came back as "2% of one core", which looked +like the engine idling and was actually the engine **hung**. The number was +nonsense for the question asked, and checking why saved the wrong conclusion. + +### What was wrong + +The game was foreground and focused, the screen awake, and the frame counter +frozen. The log said exactly why: + + EnsureThreadEngine: thread-stack arena exhausted (kMaxGuestThreads reached) + CallGuestFunction(0x54bb20): no engine available on this thread (depth=0) + pthread_shim: guest thread (handle 11) start_routine returned 0x0 + +`CarveThreadStack` was a pure bump allocator: `thread_stacks_cursor_` only ever +moved forward. Every guest thread that finished kept its 8 MiB stack forever, +and so did every nested-call engine - and those come out of the *same* arena, +so one guest thread could hold a primary stack plus up to `kMaxNestedEngines` +more. With `kMaxGuestThreads = 16` a session simply ran out. The game creates +threads as it goes (one per race among others), so this presents as: play for a +while, enter a race, get a black screen. + +Note the failure shape - the thread's start routine silently "returned 0x0" +rather than never running. Without the two log lines above it, that reads like +the guest's own code deciding to do nothing. + +### Fix + +`GuestEngine::ReleaseThreadEngine()` closes the thread's `uc_engine`, closes +every nested-call engine, and returns all their stacks to a free list that +`CarveThreadStack` now drains before growing the arena. `pthread_shim`'s thread +body calls it as its last act. + +`kMaxGuestThreads` also went 16 -> 64 as headroom for the genuinely-concurrent +peak. That is the secondary change, not the fix - the arena is lazily-mapped +address space, so raising it alone would only have delayed the same hang. + +Stacks are deliberately **not** scrubbed on reuse: the guest writes a stack +frame before reading it, and leaving the previous occupant's bytes in place has +caught real uninitialized-read bugs in this engine before. + +## 2026-09-21 — CPU and thermal budget of the engine, measured (task #65) + +The phones run noticeably warm. Measured on the Xiaomi 14 during real play, +after the thread-stack fix above (the first attempt was invalid: it reported +"2% of one core", which was the hang, not the engine idling). + +| | | +|---|---| +| whole process | **0.83–0.97 of one core**, sustained | +| `GLThread` alone | **78.8%** of one core | +| everything else | ~4% across 47 other threads | +| cores in the machine | 8 | +| peak CPU temperature under load | **49–55 °C**, flat over 2 min | +| temperature when cooling down | 44–48 °C | +| frame rate during the measurement | 66–70 | + +**It is not spinning.** Sampling `GLThread`'s scheduler state 300 times: 75% +running, 25% sleeping. So the core is busy with real translation work and the +sleep is the frame wait - there is no busy-wait to delete. (First attempt at +this sample read the wrong `/proc` field: the thread's `comm` is +`"GLThread 2052"`, which contains a space, so the parenthesised name shifts +every positional field after it.) + +**Reading of it.** The heat is one core held near capacity continuously, on a +machine with eight. Temperature plateaus rather than climbing, comfortably below +any throttling threshold, so this is not a thermal emergency - it is simply the +cost of the work. Headroom at 60 fps is thin though: only ~25% of the frame +budget is idle, which is consistent with the frame rate sagging on heavier +scenes. + +That makes the remaining profile items directly a heat story as well as a speed +one: softfloat (~9%, task #63) and indirect-branch lookup (12.18%, task #64) are +watts as much as milliseconds. + +A same-device native comparison is not possible here - the Xiaomi cannot run the +32-bit build at all, which is the entire reason this engine exists. Any +native-vs-emulated power figure would have to come from the A9, on a different +SoC, and should be labelled as such. + +## 2026-09-21 — SOLVED: black textures after resume (task #62) + +The shipped game explicitly asks the platform **not** to preserve its EGL +context across a pause - `GameGLSurfaceView`'s constructor passed `false` to +`setPreserveEGLContextOnPause` - and takes responsibility for rebuilding its GL +objects itself afterwards. Under this engine that rebuild does not happen. + +### Established live, not assumed + +Nothing logged the context lifecycle, so the central question - does the context +actually die? - was unanswerable from the existing logs. Two log lines in the +`EGLContextFactory` settled it: + + EGL context CREATED (#1) 13:10:01 launch + EGL context DESTROYED (#1) 13:11:14 on pause + EGL context CREATED (#2) 13:11:23 on resume + +So every texture, buffer and shader was genuinely destroyed. And across that +boundary the guest's own restore ran but did nothing useful: + + Renderer::RestoreContext 13:07:58.010 + Renderer::RestoreContext Finished 13:07:58.011 + +**One millisecond**, with **1** texture upload after the boundary against **86** +before it. That is the whole bug: geometry survived because vertex data is +re-sent every frame, textures are uploaded once, so they came back black. + +Worth recording why the restore never fires properly: `GameActivityMain.kt`'s +`onResume` does move the state machine into `STATE_RESTORE_CONTEXT`, but that +state only waits for focus and then falls back to the previous state. The real +`nativeRestoreContext()` call lives in `STATE_GAME_START` behind a one-shot +`restoreContextThreadStarted` flag, so it runs exactly once per process. + +### Fix + +`setPreserveEGLContextOnPause(true)`. Verified: after pause and resume the log +shows only `CREATED (#1)`, with no destroy, and a screenshot of the city map +after resuming shows the satellite imagery, city lights, roads and the whole UI +ribbon intact. + +This fixes the symptom without depending on the guest restoring anything, which +is the right trade here - the alternative is re-running the guest's asset +restore mid-session, which would also re-run `nativeOnStart`/`nativeOnResume` +and the splash sequence while the player may be mid-race. + +**It is a hint, not a guarantee.** The system may still drop the context under +memory pressure. That is exactly why the create/destroy logging stays in +permanently rather than being removed as a spent probe: a second `CREATED` line +in a beta report means this fell back, and the guest-side restore path becomes +the next thing to fix. Until then it would have been an invisible failure. + +## 2026-09-21 — Audio, step 1 of 4: the runtime-support gap is closed (task #67) + +No sound yet, and none was expected at this step. Recording the groundwork and +the measured size of the remaining job. + +### Why running the game's own FMOD beats reimplementing it + +`libfmodex.so` (841 KB) and `libfmodevent.so` are shipped as ordinary ARM32 +shared libraries; `libapp.so` names both in `DT_NEEDED`. Today all 63 FMOD entry +points in `fmod_shims.cpp` are no-ops that pretend success, and there is no +audio backend of any kind in the tree. + +`libfmodex` depends on no audio library at all - only libc/libstdc++/libm/libdl/ +liblog. It finds the device at runtime: it imports `dlopen`/`dlsym`/`dlclose`, +carries the literal string `libOpenSLES.so`, and references `slCreateEngine`, +`SL_IID_ENGINE`, `SL_IID_PLAY`, `SL_IID_ANDROIDSIMPLEBUFFERQUEUE`, +`SL_IID_ANDROIDCONFIGURATION`, `SL_IID_RECORD`. + +So real FMOD will do all mixing, event and bank work itself and the only thing +we owe it is the classic OpenSL ES buffer-queue output - roughly 15-20 entry +points instead of 63 sets of semantics. + +### Step 1, done: the 37 missing runtime functions + +Comparing libfmodex's 117 undefined symbols against everything this engine +already registers left **37 gaps, all compiler-runtime or libm**: + +- 14 `__aeabi_*` helpers - integer divide, double add/multiply/compare, the + int-and-float-to-double conversions, `atexit`, the unwind personality +- ~20 libm - `acos`, `atan2`, `cos`, `sin`, `tan`, `exp`, `log`, `log10`, + `log10f`, `sqrt`, `rint`, `lrintf`, `frexp`, `ldexp` +- a handful of libc - `usleep`, `memmem`, `inet_addr`, `chown`, `select`, + `pthread_attr_setdetachstate`, `operator delete` + +All implemented by forwarding to the host. The softfp r0:r1 pair convention the +`__aeabi_*` double helpers use is exactly what `ReturnDouble`/`ReadDoubleArg` +already handled, so nothing new was needed there. `__aeabi_uldivmod` returns its +remainder in r2:r3, which the shared dispatch contract does not cover, so it +writes those two registers directly - the same local trick `ReturnU64` uses for +r1 rather than changing the contract for every shim. + +**Checked for regression rather than assumed**: none of the 37 is imported by +`libapp.so` (it statically links its own math and `operator delete`), so the +working build's behaviour is provably unchanged. A smoke run on the Xiaomi +confirmed it - 4,052 frames, zero faults. + +Two are deliberately not real and say so in the log if reached: `select` +(FMOD's network-streaming path only) and `__aeabi_unwind_cpp_pr0` (a C++ +exception unwinding through guest frames, which this engine still cannot do). + +### Steps 2-4, not started + +2. Ship both FMOD libraries as assets and extract them, the way + `libapp_armeabi_v7a.so` already is - the APK declares only arm64-v8a, so + `jniLibs/armeabi-v7a` is not packaged. +3. Load them via `LoadSecondaryImage` and make `libapp.so`'s FMOD imports + resolve to the real guest symbols. **This needs a resolution-order change**: + `ResolveOrCreateImportStub` today checks cached entries, then registered data + symbols, then registered shims - it has no notion of a secondary image's + exports, and relocations are processed before any secondary image is loaded. +4. The OpenSL bridge, plus the buffer-queue callback which arrives on a host + audio thread and must re-enter guest FMOD. + +The risk still to be measured, not assumed: whether an emulated FMOD mixer meets +the ~10-20 ms audio deadline. + +### Steps 2-3 done: the game's real FMOD now loads and runs + +Both libraries ship as raw assets (the APK declares only arm64-v8a, so +`jniLibs/armeabi-v7a` is never packaged) and are extracted next to +`libapp_armeabi_v7a.so` on first launch. + +`GuestEngine::LoadSecondaryImage` now records **every** symbol an image defines, +not just the one entry point the test harness asked for, and +`ResolveOrCreateImportStub` consults that table **ahead of** the shim table. +`LoadSiblingLibraries` loads them from `LoadImage`, at the one point that works: +after `EnsureThreadEngine` (they need an engine to build their own import +stubs) but **before** `ProcessRelocations`. That ordering is not negotiable - a +GOT slot already resolved to a shim cannot be un-resolved later. + +Live result: + + libfmodex.so base=0x50b81000 462 .rel.plt entries, 94 imports, 42 init_array + libfmodevent.so base=0x50c8d000 269 .rel.plt entries, 168 imports, 1 init_array + 70 symbols now resolve to real guest code instead of a shim + +The displaced 70 include `System::playSound`, `Channel::isPlaying`, +`Channel::setVolume`, `Channel::stop`, `System::createDSPByType` - the entry +points that mattered. Both images' initialisers ran, the game reached 9,290 +frames, and there were **zero faults**: real FMOD executes under this engine +without crashing, which was the main thing steps 2-3 had to prove. + +Symbol collisions between the two libraries (compiler-runtime helpers like +`__aeabi_fadd`, and section markers like `_end`/`__data_start`) are resolved +first-definition-wins and each one is logged. Checked that this cannot disturb +the game: `libapp.so` imports none of the colliding names. + +### Still no sound, and the log says precisely why + +`Shim_dlopen` logs every call and there is **not one**. FMOD has not yet tried +to open `libOpenSLES.so`, so it has not reached output initialisation at all - +this is not the OpenSL bridge failing, it is the bridge never being asked for. + +Note what changed in the evidence: the old `fmod_shims.cpp` line "no audio +backend - will report channels as PLAYING" is also gone, because `playSound` is +no longer ours. So neither side is reporting, and the next step is a probe that +answers a single question - does the game call `EventSystem::init` at all, and +if so where does it stop? Guessing between "never called", "failed early" and +"defers until first playback" would be exactly the kind of unverified premise +this project keeps paying for. + +### The `isPlaying` regression risk, raised and settled + +Swapping 70 shims for real code put `Channel::isPlaying` back on FMOD's own +implementation - and task #49 is precisely the story of what happens when that +call answers "not playing": the game waits on music that never ends and its +logic crawls (7.69x -> 3.99x when it was fixed). With FMOD loaded but its output +never initialised, `playSound` almost certainly fails and hands back no usable +channel, which is the same state #49 was cured from. + +Settled by play rather than by argument: a full race ran normally, with no music +and **no stutter**, and the map sits at a steady 60.2 fps having progressed to +92,500 SP. So the stall does not reproduce and no safety net is needed. Worth +recording that the frame rate alone would NOT have shown this - #49's symptom +was in-game time dilation, not dropped frames. + +### Next: compare against the original game on real hardware + +Ours never calls `dlopen`, and the game's audio is known to start right after +the EA logo, so the chain runs early and breaks before output setup. Rather than +guess between "never called", "failed early" and "deferred", `trace_agent` now +interposes four points and logs each `FMOD_RESULT`: + + dlopen(path) - catches the libOpenSLES.so load + FMOD_Memory_Initialize(...) - imported by libapp + FMOD_EventSystem_Create(...) - imported by libapp + _ZN4FMOD11EventSystem4initEijPvj - EventSystem::init, imported by libapp + +Built and verified: all four are exported from the 32-bit agent. Running this on +the Galaxy A9, where audio works natively, gives the reference sequence to diff +our run against - and whichever call returns non-zero (or never appears) is the +divergence point, named rather than inferred. + +### Native reference captured - and two instrument failures worth keeping + +**The measurement.** On the Galaxy A9, running the real ARM32 build, the +process maps contain: + + lib/arm/libfmodex.so + lib/arm/libfmodevent.so + /system/lib/libOpenSLES.so <- FMOD reached output initialisation + +Read from `/proc//maps` via `run-as`, with nothing injected. Our emulated +run loads both FMOD libraries but never calls `dlopen` at all, so the +divergence is now observed rather than inferred: native gets as far as opening +the audio device, ours does not. + +**Two failures on the way there, both mine, both instructive.** + +First, interposing `dlopen` killed the app at startup. The Android runtime +`dlopen`s `libart.so` during process setup, our wrapper could not resolve the +real symbol that early (RealSym goes through `dlsym`, unusable from inside a +`dlopen` interposer at that point), returned NULL, and the runtime died on the +null handle - `wrap.sh terminated by signal 11`. The same hazard this file +already documents for `pthread_once`, walked into again. + +Second, and worse: `libfmodex`/`libfmodevent` live in the **app's linker +namespace**, which an `LD_PRELOAD`ed agent cannot reach. Neither +`dlsym(RTLD_NEXT)` nor `dlopen(..., RTLD_NOLOAD)` found a single FMOD symbol, +so the interposers always took their fallback path - and that fallback +**replaced FMOD's initialisation with a stub that claimed success**. The agent +silenced audio on the one device that was supposed to demonstrate working +audio. The user reported "no sound here either" on the A9, and that was the +instrument, not the game. + +The general lesson, which is a measurement one rather than an Android one: an +instrument that cannot do the real work must not stand in for it. The fallback +should have been "do not interpose", and since that is not expressible once the +symbol is exported, the interposer should not have existed. Reading +`/proc//maps` answers the same question from outside the process, with no +way to break what is being measured. + +A third near-miss: the first `maps` read came back with zero libraries, which +looks exactly like "no audio libraries are loaded" and would have been a +spectacular false finding. The command had lost its quoting and silently read +nothing. + +**Next**, and now on our own side where instrumenting is safe: find where the +guest's FMOD init chain stops. `libapp.so` imports `FMOD_Memory_Initialize`, +`FMOD_EventSystem_Create` and `_ZN4FMOD11EventSystem4initEijPvj`, all three now +resolving to real guest code, so a logging pass-through on those three - +implemented in the engine, which owns the dispatch - names the stopping point. + +### The init chain traced on our side - and the real audio architecture found + +A logging pass-through on the three FMOD entry points `libapp.so` imports, +implemented in the engine's own stub dispatcher (where the real target address +is known, so forwarding cannot silently degrade into replacing - the mistake +the A9 interposer made): + + FMOD_Memory_Initialize(0, 0, 0x3de6e0, 0x3de714, 0x3de718, 0) -> 0 OK + FMOD_EventSystem_Create(0xe30ab8) -> 0 OK + EventSystem::init(0xe33b20, 100 channels, 0x82, ..., 0) -> 0x30 FAILS + +**The probe lied first, and fixing it proved the fix.** Its first version +forwarded only r0-r3, but AAPCS32 puts arguments five and six on the stack - +and `FMOD_Memory_Initialize` takes six, `EventSystem::init` five. With the +arguments truncated, `Memory_Initialize` returned 37; with the full list +forwarded it returns 0. The first number was the instrument's own doing. Same +class of error as the A9 interposer, caught this time before it was believed. + +**Then the actual discovery.** `libfmodex.so` exports exactly two `Java_*` +symbols: + + Java_org_fmod_FMODAudioDevice_fmodGetInfo + Java_org_fmod_FMODAudioDevice_fmodProcess + +So audio output on this build does **not** go through OpenSL from native code. +It goes through a **Java** class, `org.fmod.FMODAudioDevice`, which owns an +AudioTrack and calls back down into FMOD to fill each buffer. That class is +already in the app (`GameActivityMain` holds `mFMODAudioDevice` and +starts/stops it in `onResume`/`onPause`). + +And both of its native methods are **silent stubs returning 0** in +`game_lifecycle_stubs_extra2.cpp`. `fmodProcess` returning 0 without filling +the buffer is silence by construction, no matter what FMOD does upstream. + +That file's own header comment explains why, and is now out of date: + +> FMOD (libfmodex.so, not loaded into the emulator - no arm64-v8a build exists +> at all) stays stubbed + +There is no arm64 build, true - but as of today the real **ARM32** one runs +inside the engine, and both symbols are in `secondary_image_exports_`. + +So the remaining work is not an OpenSL bridge at all. It is to forward these +two JNI methods into the guest FMOD, the same way `game_lifecycle_stubs.cpp` +already forwards the game's own lifecycle natives - with the guest JNIEnv and a +guest handle for the ByteBuffer. `EventSystem::init`'s failure is very likely +downstream of the same gap: FMOD's Android output expects the Java device to be +there. + +Not yet identified: what `0x30` means as an `FMOD_RESULT`. The shipped library +is stripped of its error strings and no FMOD header is present in the tree, so +the number is recorded as a number rather than guessed at from memory. + +### The audio bridge is built and proven; the blocker is now one function + +`Java_org_fmod_FMODAudioDevice_fmodGetInfo` and `_fmodProcess` no longer return +0. They resolve their targets out of `secondary_image_exports_` and forward +into the real guest FMOD through `CallRealNative`, the same path the game's own +lifecycle natives already use. + +`fmodProcess` bounces through a guest-memory buffer. It has to: the ByteBuffer +comes from Java's `allocateDirect`, so it lives at a host address, and the +guest's `GetDirectBufferAddress` can only answer for buffers this shim created +(its own comment says so). `JniShim::NewGuestBackedDirectBuffer` wraps a guest +allocation in a real Java direct ByteBuffer and registers the mapping, so guest +FMOD gets an address it can actually write to; the PCM is copied out +afterwards. The buffer is allocated once and reused - this runs on the +AudioTrack thread at buffer rate, where per-call allocation would be jitter. + +**Proven working end to end**: `fmod_bridge: fmodGetInfo(0) -> -1`. That is a +real value from real guest code - the stub could only ever return 0. -1 means +FMOD has no sample rate to report, so `FMODAudioDevice`'s thread sits in its +retry loop (~every 100 ms) waiting for FMOD to become ready, exactly as its +Java source says it will. + +**And the blocker is now a single function.** Adding `System::init` to the +watch list - libfmodevent calls it into libfmodex, so it crosses an image +boundary and goes through import resolution like anything else - separates the +layers cleanly: + + EventSystem::init(0xe336b0, 100 channels, flags=0x82, ..., 0) + -> System::init(0xe3b880, 100, 0x82, ...) -> 0x30 + -> EventSystem::init -> 0x30 (propagated) + +So the event layer is only passing the error up. `FMOD::System::init` itself +refuses, in about 2 ms - early, well before anything tries to open an audio +device, which is consistent with `dlopen` never being called. + +Next: find why. Either decode `0x30` as an `FMOD_RESULT` from a source better +than memory (the shipped library is stripped of error strings and no FMOD +header is in the tree), or disassemble `System::init`'s early exits at +`libfmodex+0x9e650` and see which precondition it is checking. + +### Root cause of `0x30`, found by disassembly and fixed + +`System::init` is a thin wrapper; the error comes from far below it. Walking +down through libfmodex: + + System::init -> sub_404DC -> sub_3E198 -> ... -> sub_A9AF8 + +and `sub_A9AF8` is blunt about it: + + v2 = sub_BFE0C(); // capability mask + if (!(v2 & 4)) { // NEON? + if (!(sub_BFE0C() & 8)) // VFP? + return 48; + } + +`sub_BFE0C` is a `pthread_once` wrapper over `sub_BFFD8`, which **opens +`/proc/cpuinfo`** and string-matches the `Features` line: `"vfp"` sets bit 8, +`"vfpv3"` bits 0xA, `"neon"` bits 0xE. + +Rather than reason about which bits an ARMv8 core "should" report, the two +files were compared directly: + + Pixel 6a: Features : fp asimd evtstrm aes pmull sha1 sha2 crc32 ... + FMOD wants: vfp / vfpv3 / neon + +Not one match. `fp` **is** VFP and `asimd` **is** NEON - the hardware has both; +the AArch64 kernel simply spells them differently, and a 32-bit library from +2012 has no way to know that. The mask stayed zero and FMOD concluded the CPU +could not do floating point. + +Finding the constant took one query rather than a hunt: searching libfmodex for +instructions loading 48 into a return register produced exactly one candidate +on this path. + +**Fix**: `Shim_open` serves `/proc/cpuinfo` from an in-memory ARMv7-format copy +(via `memfd_create`, so it stays an ordinary fd and `read`/`lseek`/`close` need +no special cases). This is not a lie told to the guest - it is the same CPU +described in the vocabulary the guest was built to read, and every feature +listed is genuinely present on any ARMv8 core this engine runs on. + +**Confirmed by the error changing**, which is the evidence that matters: + + System::init -> 0x30 before + System::init -> 0x21 after + +Still no audio, and still no `dlopen` - but the CPU check is passed and +initialisation now fails somewhere further in. `0x21` is recorded as a number, +not guessed at; the same method that resolved `0x30` (find what returns the +constant, then read backwards) applies directly to it. + +### `0x21` traced to our own guard - FMOD now initialises + +Unlike 48, the constant 33 appears at 39 sites in libfmodex, far too many to +reason about. So instead of arguing, all 39 were watched at once: +`GuestEngine::WatchGuestAddress` registers a pure observation hook (no +instruction displacement, unlike `InstallTrampolineHookRaw`) that logs the +first time execution reaches an address. Two fired - `sub_A9120` and +`sub_951C4`. + +**A near-miss worth recording.** The decompiler showed +`result = pthread_attr_destroy(...); if (result) return 33;` at the watched +address, which reads like a clean answer: attr_destroy failed. The +disassembly says otherwise - `loc_A9228` is a **shared** `MOV R0,#0x21` that +**six** separate `BNE`s jump to. Reaching it identifies nothing. Re-watching +the fall-through after each individual check gave the real answer: + + pthread_attr_init OK + pthread_attr_setdetachstate OK + pthread_attr_setstacksize OK + pthread_attr_setschedpolicy OK + pthread_attr_setschedparam OK + -> FAILED (pthread_create OK never reached) + +**The failing call was `pthread_create`, and the reason was ours.** +`Shim_pthread_create` rejected any `startRoutine >= image_end()` - correct +while exactly one image existed, since everything above it was an engine +arena. libfmodex now loads at 0x50b81000, far above `image_end()` (0xb16000), +so FMOD's own mixer thread was refused as "not real image code". That EINVAL +propagated the whole way up as silence: + + pthread_create -> EINVAL -> sub_A9120 -> 33 -> System::init -> 33 + -> EventSystem::init -> 33 -> fmodGetInfo -> -1 + -> FMODAudioDevice never builds its AudioTrack + +Fixed with `GuestEngine::IsGuestImageCode`, which knows every loaded image's +range, so the guard keeps its purpose without the single-image assumption. + +**Measured result - FMOD initialises:** + + System::init -> 0x0 + EventSystem::init -> 0x0 + REFUSING pthread_create: 0 occurrences + +### Still silent, and the reason is known + +`kEnableFmodAudioBridge` is **off**. The bridge works, but it crashed the +process: `CallRealNative` calls `JniShim::SetRealEnv()`, which stores ONE +global JNIEnv - as its own comment states it assumes. Running the bridge on +FMODAudioDevice's AudioTrack thread overwrote the env the GLThread was using +every 100 ms, and the process aborted inside GLThread with +`JNI DETECTED ERROR IN APPLICATION: jfieldID was NULL`. Racy, so it survived +several runs first. + +The fix is a per-thread JNIEnv in `JniShim`, not a save/restore around the +bridge: two threads genuinely need different envs at the same time, which one +slot cannot express. That is the next step, and it is the last known thing +between here and audio. + +### The audio path is complete - and the earlier diagnosis was wrong + +**Correction first.** The crash was blamed on `SetRealEnv` clobbering a shared +`JNIEnv`. That was wrong: `real_env_` was **already** `thread_local`, with a +lazy `AttachCurrentThread` fallback. Reading the code instead of acting on the +hypothesis found the real culprit one line below it. + +`SetRealEnv` also calls `JniHandleTable::BumpCallEpoch()`, and that epoch was a +single **process-wide** atomic. Its own comment even said it invalidates the +previous call's local refs "whether or not it's the same thread". Harmless +while exactly one host thread ever crossed into JNI - and fatal the moment the +audio bridge started crossing from FMODAudioDevice's AudioTrack thread every +100 ms, invalidating the refs GLThread held mid-call. + +Fixed by making the epoch `thread_local`. That is not a workaround: a local +ref's lifetime is scoped to a native call **on its own thread**, so a bump from +another thread never had any business invalidating it. Both halves of +`IsSafeToUseFromCurrentThread` are per-thread now, and the atomic is gone +(a thread's own epoch is only read and written by that thread). + +**Measured, with the bridge re-enabled:** + + System::init -> 0x0 + EventSystem::init -> 0x0 + fmodGetInfo(0) -> 24000 sample rate + fmodGetInfo(1) -> 1024 DSP buffer length + AudioTrack created, and registered with AudioFlinger + audio bounce buffer ready - 4096 bytes + fatal signals / JNI aborts: 0 + +So the whole chain now runs: the game's real ARM32 FMOD initialises inside the +engine, reports a real sample rate, Java's FMODAudioDevice builds its +AudioTrack from it, and `fmodProcess` pulls PCM through the guest-backed bounce +buffer. Whether it is audible is the user's ear to judge - everything measurable +from this side is connected. + +### Confirmed audible, and the probes removed + +The user confirms sound is playing. Spent diagnostics taken out the same day: + +- the per-call `fmodGetInfo` log - it ran at audio rate (1,210 lines in 40 s); + bridge log output is now 1 line per session instead of ~1,200 +- the step-by-step `WatchGuestAddress` hooks in `sub_A9120` - they answered + their question + +`WatchGuestAddress` itself stays: it is a general facility, and it is what +turned "which of 39 sites returns this code" into an observation. + +The `GUESTCALL` pass-through on FMOD's three init entry points is **kept +deliberately**, against the usual "remove spent probes" rule. It costs four +host crossings per process (init only, never per frame) and prints the exact +`FMOD_RESULT` of each stage - which is precisely the evidence that took a full +day to obtain this time. If audio regresses, the log names the stage +immediately. + +Clean-build smoke test: alive, 0 faults, AudioTrack created, 2,340 frames. + +## 2026-09-22 — Two in-game UX fixes: system bars, and volume keys + +Both confirmed by the user on the Pixel 6a. + +**The navigation bar sat on top of the game** because nothing in the app ever +touched the system bars - there was no immersive-mode code at all. Now hidden +via `WindowInsetsControllerCompat` with `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, +so an edge swipe still brings them back. + +Re-applied on every focus gain, not once in `onCreate`: Android restores the +bars after a dialog, the shade or a task switch, and a one-shot call silently +stops working the first time any of those happens. + +**Volume keys did nothing**, forcing the player into the notification shade. +The cause was one line: + + fun IsSystemKey(i: Int) = false + +with both key handlers ending in `return !IsSystemKey(keyCode)` - so the +activity claimed *every* key it ever saw. Volume keys are now reported as +system keys, the check runs **before** the `state != 8` gate (which otherwise +swallows everything during loading), and `volumeControlStream` is set to +`STREAM_MUSIC`. + +### A testing note worth more than either fix + +Three separate times today a sleeping or locked Pixel 6a was misread as a +broken build - once far enough to start diagnosing a rendering fault that did +not exist (`glClear=0 glDrawArrays=0` was simply a paused activity behind the +lock screen). The tell is `dumpsys power | grep mWakefulness` and +`dumpsys window | grep mCurrentFocus`; if focus is `NotificationShade`, the +screen is showing the keyguard and nothing about the app can be concluded. + +`adb shell wm dismiss-keyguard` only helps when `dumpsys trust` reports +`deviceLocked=0`. With `deviceLocked=1` the device needs a real unlock and no +adb command substitutes for it. `adb shell svc power stayon usb` prevents the +doze half of the problem while charging. + +## 2026-09-22 - x86_64 host: the game runs in WayDroid, and houdini never gets a turn + +Adding `x86_64` to `abiFilters` in `app/build.gradle.kts` and +`mpcore/build.gradle.kts` was the entire code change. WayDroid +(LineageOS 20, Android 13 / SDK 33, `ro.hardware.egl=mesa`, +`ro.hardware.vulkan=radeon`) then installed the APK with +`primaryCpuAbi=x86_64`, and the game ran on the first launch. + +Worth being precise about *why* this is interesting. The WayDroid image does +ship the ARM bridge - `ro.dalvik.vm.native.bridge=libhoudini.so`, and both +`/system/lib/libhoudini.so` and `/system/lib64/libhoudini.so` are present, so +`ro.product.cpu.abilist` advertises `armeabi-v7a` alongside the x86 ABIs. It +is never engaged: because our APK offers a `lib/x86_64/`, Android selects the +primary ABI, loads `libmpcore.so` natively, and the ARM32 guest is translated +by **our** engine through `tcg/i386`. The bridge that used to be the fragile +part of running this game on x86 is simply not in the path any more. + +Everything downstream came up unmodified: 623,470,192 bytes of game data +unpacked, `FMOD::System::init` and `FMOD::EventSystem::init` both returned 0, +`AudioTrack` went live, GLES drew at 1920x1048, and not one `MEM FAULT`. + +### Two numbers, and what they do *not* mean + +`TCG_BENCH` on this host reports **25,319,498 bytes/sec** against +**15,690,433** on the Pixel 6a. That is a desktop AMD core versus a phone +core - it says nothing about `tcg/i386` versus `tcg/aarch64`, and must not be +quoted as a backend comparison. + +The menu measures **144.0 fps**, which is exactly the host's vsync cap, so the +figure is display-limited and the engine's real headroom here is unknown. No +in-race frame rate has been measured on this host; the menu is a light +orthographic UI pass (`fb=0`, `depthTest=0`) and is not representative. + +One real caveat: the flat guest mapping (task #61, the 1.24x win) exists only +in `tcg/aarch64`. An x86_64 host silently falls back to the software MMU. That +is recorded in a comment next to the `abiFilters` line so the next person does +not read x86 numbers as if the same optimisation were in play. diff --git a/docs/BETA_TELEMETRY_PLAN.md b/docs/BETA_TELEMETRY_PLAN.md new file mode 100644 index 0000000..47d547b --- /dev/null +++ b/docs/BETA_TELEMETRY_PLAN.md @@ -0,0 +1,299 @@ +# Closed beta: diagnostics, crash reports and tester workflow + +Plan for shipping a preview build to a small group of testers and getting back +reports we can actually act on. Written 2026-09-21, the day the first playable +build appeared. + +Decisions already taken (owner's call): + +- **Delivery: a "send report" button using the system share sheet.** No backend, + no background upload. The tester sees the file and sends it. This keeps us out + of collecting data from other people's devices, which would otherwise need + consent handling, storage and a retention policy. +- **Scope: crashes and bug reports, performance counters, unhonoured shim + contracts, device model and OS version.** + +## Why this needs building at all + +Everything the engine currently reports goes to `__android_log_write` and +nowhere else (`util.cpp`'s `Log()`). That fails for beta in three separate ways, +each of which already bit us during development: + +1. **logcat wraps.** Twice on 2026-09-21 a measurement was lost to it - once the + whole startup sequence, once the frame-counter history, which made a + frame-rate figure look far better than it was. +2. **Testers cannot retrieve logcat.** It needs a cable and a developer setup. +3. **A hang produces no log at all.** The black-screen bug that day left the + process alive and silent; the only evidence was three lines that had already + scrolled past. + +## Part 1 - what the build must produce + +### 1.1 In-memory log ring (the foundation) + +`Log()` keeps writing to logcat, and additionally appends into a fixed-size +in-memory ring buffer (suggest 2 MB, tunable). **Nothing is written to disk +during normal play** - the log rate is high (per-frame GLES sampling alone +produces thousands of lines) and per-line file I/O would show up in the frame +time we just spent the day reducing. + +The ring is allocated once at startup. It must be usable from a signal handler, +so: a plain pre-allocated byte array, no `malloc`, no locks that a crashed +thread might hold. A per-record sequence number plus a short spinlock-free +write is enough; a torn last record in a crash dump is acceptable. + +### 1.2 Native crash handler + +A handler for `SIGSEGV`, `SIGBUS`, `SIGABRT`, `SIGILL`, `SIGFPE` that writes a +report file and then chains to the previous handler so the normal tombstone +still happens. + +**Async-signal-safety is not optional here and we have a recorded incident:** +calling `mmap()` inside a `SIGSEGV` handler has deadlocked on bionic in this +project before. The handler must use only a pre-allocated buffer, a +pre-opened-or-`open()`ed fd, and `write()`. No `malloc`, no `stdio`, no `Log()`, +no C++ allocation, no locks. + +What to capture - this is the part specific to a translation layer, and it is +what made today's crash solvable in one reproduction: + +- **The decoded guest address.** With the flat mapping, a host fault address is + `flat_map_base + guest_address`. On 2026-09-21 the fault at `0x6f464c459b` + decoded to guest `0x464c459b` by subtracting `x28`. The handler should do that + subtraction itself and print the guest address, since nobody reading a report + will do it by hand. +- **Which arena the guest address belongs to.** `guest_engine.cpp` already has + the classifier (`"thread-stacks arena"` and friends) - reuse it. "Fault in the + heap arena" and "fault 1.2 GB past the end of everything" are completely + different bugs and the report should say which. +- Full guest register set, and the host registers from `ucontext`. +- The last N KB of the log ring. +- Build stamp and device identity (below). + +### 1.3 Hang detector + +The black-screen bug was a **hang, not a crash** - no signal, no tombstone, +process alive at 2% CPU. A crash handler would have caught nothing. + +A watchdog thread checks the `onDrawFrame` counter. If it has not advanced for +~10 seconds while the activity is resumed, it writes the same report the crash +handler would, tagged `HANG`, including a snapshot of every thread's state and +stack pointer. It should fire **once** per hang, not repeatedly. + +### 1.4 Unhonoured-contract registry + +Today's crash was found because a shim logged that it could not honour a +request, one line above the fault. That should be a first-class, structured +record rather than a log line we grep for. + +A small registry: `ReportUnhonouredContract(area, detail)`, deduplicated by +string, counting occurrences. `rtti_shims.cpp`'s `use_facet`, +`jni_shim.cpp`'s silent zero-returns (task #51) and every other +"returning NULL because we do not implement this" path calls it. The report +carries the full deduplicated list. + +This turns beta into a **gap-discovery mechanism**: the union of these lists +across testers is a prioritised work queue for what the game actually needs, +discovered from real play instead of guessed. + +### 1.5 Session counters + +Sampled once a second into a compact rolling summary, not one line per sample: + +- frames per second - min, median, 10th percentile, and where the low ones + happened (which is what the median alone hides, as it did today) +- time from launch to first frame, and each level-load duration +- CPU time consumed by the process, and by the GLThread specifically +- peak CPU temperature +- guest heap: live, peak, arena exhaustion events +- thread-stack arena: peak in use, exhaustion events (the black-screen cause - + this must never again be discovered by reading a log tail) + +### 1.6 Build and device identity + +Every report starts with: build stamp (version plus a short git hash or build +timestamp baked in at compile time), device model, SoC, Android version, ABI, +available RAM, and screen size. + +The build stamp matters more than it sounds. Twice on 2026-09-21 the wrong APK +was nearly measured - once a three-week-old release build installed by mistake. +With testers there is no chance to check by hand; the report must say which +build produced it. + +### 1.7 The share button + +A screen reachable from the pause menu: **"Report a problem"**. It bundles the +newest reports plus the current log ring into a single zip in the app's own +files directory and hands it to `ACTION_SEND`. + +Before sharing it shows the tester a short plain-language summary of what is in +the file - log lines, device model, no personal data, no game account details. +They are sending it themselves; they should know what it is. + +If the previous session ended in a crash or hang, offer to send that report on +the next launch, since the tester will not go looking for it. + +## Part 2 - the tester-facing report form + +Free-text bug reports from testers are usually unusable not because testers are +careless but because nobody told them which three facts matter. Keep it short - +a long form gets skipped. + +**In-app, attached automatically:** build stamp, device, the log bundle. The +tester never types any of this. + +**What we ask the tester for, in this order:** + +1. **What were you doing?** One line. "Entered a race from the city map." +2. **What happened?** One line. "Black screen, music kept playing." +3. **What did you expect?** Only when it is not obvious. +4. **Can you make it happen again?** Every time / sometimes / happened once. + This single question decides whether we can chase it at all. +5. **Did you play for a while before it happened?** Yes/no. Specifically + included because the whole class of resource-exhaustion bugs - the + thread-stack arena, the guest heap - only shows up after a long session, and + testers do not think to mention it. + +**Severity, defined by consequence rather than by feeling**, so it is not +argued about: + +- **Blocker** - cannot continue playing; progress lost. +- **Major** - a feature does not work, but the session survives. +- **Minor** - visual or audio defect, gameplay unaffected. + +**Ask them explicitly to send the report even when the game recovers.** A hang +that resolved itself still wrote a `HANG` report, and that is often the easier +one to diagnose. + +## Part 3 - what we do with reports + +Triage order, informed by what has actually been expensive to find: + +1. **Unhonoured-contract list first, before reading the crash.** Today the + answer was in that list. It is cheap to check and frequently decisive. +2. **Decoded guest address and its arena.** Distinguishes a wild pointer from + arena exhaustion from a real logic bug, without any further work. +3. **Exhaustion counters.** If a thread-stack or heap arena hit its ceiling, the + crash is a symptom and the ceiling is the bug. +4. **Only then the register dump and the log tail.** + +Group reports by build stamp before comparing anything. Mixing builds is how a +fixed bug looks like it is still present. + +## Suggested order of work + +Each step is independently useful, so the beta does not wait on the whole set. + +1. Log ring + report file + share button, with device and build identity. + **Minimum shippable** - a tester can send something useful. +2. Crash handler with guest-address decoding. +3. Unhonoured-contract registry, with `use_facet` and the JNI zero-returns as + the first callers. +4. Hang detector. +5. Session counters. + +## Deliberately out of scope + +- **No backend, no automatic upload.** Chosen above. Revisit only if the manual + path proves too lossy in practice. +- **No unique device or user identifier.** Grouping by build stamp and device + model is enough at this scale and avoids tracking individuals. +- **No gameplay telemetry** - what cars, which races, how long played. It is not + needed to fix defects, and collecting it would change what this file is. + +--- + +## Status 2026-09-21: the crash handler is built (plan section 1.2 + 1.7 partial) + +Implemented and verified end to end on the Pixel 6a. + +**Native** (`mpcore/src/main/cpp/crash_handler.cpp`). Hooks SIGSEGV, SIGBUS, +SIGABRT, SIGILL, SIGFPE with `SA_SIGINFO | SA_ONSTACK`, on a pre-allocated +alternate stack so a stack-overflow crash is still reportable. Everything the +handler needs - the output path, the build stamp - is built at install time; +inside the handler only `open`/`write`/`close` and hand-written integer +formatters run. No malloc, no snprintf, no JNI. It chains to the previous +handler afterwards, so Android still writes its own tombstone. + +The report decodes the fault address: a host address inside the guest window is +also printed as the **guest** address, and flagged when it is past the end of +the mapped region ("a wild pointer, not a real guest object"). That is the +number worth reading, and nobody will subtract the base by hand from a tester's +report. + +**Java**. `CrashReportActivity` renames the pending report (the handler writes a +fixed name, since it cannot safely format a timestamp), zips it with device and +build details, shows it, and offers ACTION_SEND through a FileProvider scoped to +the crash directory only. Reports live in +`Android/data//files/crashes` - reachable over USB with no permission. + +**Verified**: handler installs; `kill -11` produces a report with the right +signal, registers and a correct "outside the guest window" verdict; the next +launch detects it, renames it, builds the zip, and `CrashReportActivity` becomes +the resumed activity. Files land where intended. + +**Not verified**: what the screen actually looks like. The test device locked +itself, so every screenshot was of a sleeping or locked display - which is also +why an early "black screen" reading was wrong and led to one unnecessary fix +(explicit colours, harmless and kept). The layout needs a human to unlock the +phone and look. + +### Three failures on the way, all worth keeping + +1. **Installed too early.** The call sat at the top of `onCreate`, but + `libmpcore.so` is only loaded later by `loadCore()` - + `UnsatisfiedLinkError`, caught and logged. Moved to immediately after + `loadCore()`. +2. **Missing `extern "C"`.** The JNI function was C++-mangled + (`_Z65Java_...`), so the JVM could not find it. The symptom was identical + to the load-order bug above, which cost a wrong fix before the symbol table + was actually read. +3. **Blocked activity start.** Checking for a pending report in + `GameActivityMain.onCreate` - which starts the report screen and finishes + itself - was refused by the platform (`BAL_ALLOW_GRACE_PERIOD`) and dumped + the tester on the home screen. The check belongs in `PermissionsActivity`, + the visible launcher entry. + +--- + +## Status 2026-09-22: game data ships inside the APK + +A tester now installs one file and plays. No separate .obb download, no file +manager, no instructions about where to put anything. + +**How.** The ~595 MB archive ships as `assets/game_data.obb`, and +`androidResources { noCompress += "obb" }` keeps it **stored** rather than +deflated - it is already compressed, so re-compressing would cost build and +install time for nothing. On first launch `GameDataUnpackActivity` copies it to +`getObbDir()/main...obb`, which is exactly the path +`GameActivityMain.obbFullPath` already builds, so no other code knows this +happened. + +The copy writes to a `.part` file and renames only on success. A half-written +archive that merely *exists* would pass a naive check and send the game off to +read truncated data - failing far from the cause, which is the failure mode this +project keeps paying for. Free space is checked before starting rather than +500 MB in. + +**Measured on the Pixel 6a**, with the real OBB renamed aside to simulate a +clean device: + +| | | +|---|---| +| APK size | 615 MB (was 22 MB) | +| build time | 16 s - aapt2 handles the stored asset without trouble | +| `adb install` | 31 s | +| unpack | under 8 s - it finished before the first progress poll | +| result | **md5 identical** to the original OBB | +| game afterwards | 2,893 frames, 0 faults, `mAssetLocationType=OBB` | + +**Costs worth stating.** The device needs the APK plus the unpacked copy at +once: about 1.2 GB free at install time, ~600 MB after. And the data exists +twice on disk permanently, since Android keeps the APK. + +**The alternative not taken.** Because the asset is stored uncompressed, its +bytes sit contiguously in the APK, so the engine's own `Shim_open`/`Shim_read` +could serve the OBB path straight out of the APK at an offset - no copy, no +duplication. That is a real option if the 600 MB ever matters, but it adds a new +failure surface in file I/O right before a beta, and the ask here was explicitly +for self-extraction. diff --git a/docs/DEBUG_MENU.md b/docs/DEBUG_MENU.md new file mode 100644 index 0000000..307e33e --- /dev/null +++ b/docs/DEBUG_MENU.md @@ -0,0 +1,31 @@ +# DEBUG_MENU.md — On-map debug menu (Compose) + +Living document for the in-app debug menu — a developer-only overlay for poking at mod state while testing, starting with a money editor. Split out as its own file (matching the `LOBBY_UI_DESIGN.md` precedent) so `ARCHITECTURE.md` doesn't accumulate UI-only debug-tooling detail. Update this file whenever the debug menu gains a new panel or its wiring changes. + +--- + +## 1. Purpose and scope + +Requested as a debugging aid, not a player-facing feature: a way to poke at mod/game state live on-device without rebuilding. First panel is a money editor. More debug panels are expected to be added here over time (same menu, more entries) rather than each getting its own ad-hoc overlay. + +**Explicit enable/disable mechanism**: `DebugFeatures.debugMenuEnabled` (`mpcore/src/main/java/nfs/mod/mpcore/DebugFeatures.kt`) — a single `var` flag, default `true` during active development. `GameActivityMain.onCreate` only adds the debug overlay's `ComposeView` to `mFrameLayout` when this flag is `true`, so flipping it to `false` removes the debug menu from the view hierarchy entirely (not just hidden) ahead of any release build. Chosen to live in `mpcore` (not `app`) per direct instruction, so the one gate for all debug tooling stays in the shared module rather than scattered across Activities. + +## 2. Activation — button on the map + +Reuses the exact mechanism `CarSelectionBadge` already established (`ARCHITECTURE.md` §4 / `LOBBY_UI_DESIGN.md` §1): a `ComposeView` overlay added to `mFrameLayout` above the game's `GLSurfaceView`, visibility driven by `GameEvents.onMapLoaded()` (fires via the existing, already-proven `MapTrack::HandleEvent` native hook — see `PROGRESS.md`'s `dispatchMapLoaded` history). No new native hook needed: the map-loaded signal already exists and already fires reliably. + +- `DebugMenuVisibility` (new, app module) — a `GameEventListener` object exposing `mapLoaded` as Compose state, same shape as `CarSelectionState` in `CarSelectionOverlay.kt`. +- A small floating button, bottom-end corner (deliberately opposite `CarSelectionBadge`'s top-start corner so the two never overlap), appears once `mapLoaded` is true. +- Tapping it opens the debug panel as a dialog over whatever screen is currently showing. + +## 3. First panel — money editor (stub, not wired to real game state) + +**Explicit scope decision (2026-08-19, direct user answer)**: for now this is a **stub** — a plain Compose text field + "Apply" button that only updates local Compose state (`DebugMoneyState.amount`), logged but **not** written into the game's actual memory/save state. Reason: no RE work has located the player's real cash balance (getter/setter/address) yet — `ANALYSIS.md`/`PROGRESS.md` only cover `CashReward` (a *race event's* bronze/silver/gold reward definition, §6aa/6z of `ANALYSIS.md`), not the player's own wallet/balance. Wiring this panel to the real balance is tracked as a **separate, later RE task** (find where `Profile`/`CurrentState` — both named in the `ISaveable` family, `PROGRESS.md` cont. "2026-08-06" save/profile entry — actually stores the spendable cash total, then add a native setter hook), not part of this UI work. + +## 4. Screen inventory + +| Panel | State | What it does | +|---|---|---| +| Money editor | **Stub** | Text field + Apply button; writes to local Compose state only, no game effect yet | + +More rows added here as panels are added. diff --git a/docs/LOBBY_PROTOTYPE.html b/docs/LOBBY_PROTOTYPE.html new file mode 100644 index 0000000..e542203 --- /dev/null +++ b/docs/LOBBY_PROTOTYPE.html @@ -0,0 +1,1029 @@ +Прототип: мультиплеерное лобби + + +
+
+ NFSMW Online — прототип UI +

Мультиплеерное лобби

+

Кликабельный прототип полного флоу: от карты до старта заезда. Альбомная ориентация, как в игре. Профиль (имя+аватар, справа сверху) — глобальный, доступен с любого оверлейного экрана, не отдельный шаг. Полное описание решений — LOBBY_UI_DESIGN.md, эта страница — только кликабельная витрина.

+
+ Хук уже работает на устройстве + JNI-мост есть, экрана ещё нет + Не начато +
+
+ +
+ +
+
+ + 1 / 9 + +
+
+
+ +
+
+ + diff --git a/docs/LOBBY_UI_DESIGN.md b/docs/LOBBY_UI_DESIGN.md new file mode 100644 index 0000000..79a26b3 --- /dev/null +++ b/docs/LOBBY_UI_DESIGN.md @@ -0,0 +1,200 @@ +# LOBBY_UI_DESIGN.md — Multiplayer Lobby UI & Flow + +Living document for the lobby overlay's UI/UX — screens, flow, data shape, and the product decisions behind them. Split out of `ARCHITECTURE.md` on 2026-08-18 (that file now only links here) so the native/engine-integration doc doesn't keep growing with UI-only decisions. Update this file whenever a screen or flow decision changes; don't let it drift from the prototype. + +**Clickable prototype:** [`LOBBY_PROTOTYPE.html`](LOBBY_PROTOTYPE.html) — self-contained, open directly in any browser, no server needed. Landscape orientation (matches the game). Every screen is tagged OVERLAY (our Compose UI) / NATIVE (real, unmodified game screen) / TRANSITION, with inline notes on what's already proven on-device vs. still open. + +--- + +## 1. Lobby UI mechanism — overlay `View`, markers via `BitmapGraphics` + +**Recommendation: hybrid.** Full interactive lobby screens (player list, ready-up, countdown) go through an Android overlay `View`/Compose layer on top of the game's `GLSurfaceView` — the native UI pipeline has no concept of touch-driven widgets, so it can't host these itself. Simple in-world/in-menu indicators (e.g. a future green/red map marker for an active lobby) should instead go through the **`BitmapGraphics` native text/atlas bridge** (`launcher/app/src/main/java/com/ea/ironmonkey/BitmapGraphics.kt`, ANALYSIS.md §3.3) — verified, already trusted by the game's own renderer, no new Flow/SB authoring needed. + +| Concern | Map markers → `BitmapGraphics` extension | Full lobby screens → Android overlay `View` | +|---|---|---| +| Mechanism | Add a method (e.g. `drawMarker(x, y, color)` via `canvas.drawCircle`) to `BitmapGraphics.kt`; call it from a hook alongside the existing `drawString` JNI call site (`0x5625a0`) | Standard Android `View`/Compose overlay added to the activity's view hierarchy above/around the `GLSurfaceView` | +| Implementation cost | Low — one new Kotlin method, one new hook call from `mpcore` at the point we know a lobby is active | Low — standard Android UI, fast iteration | +| Visual integration | Genuinely native — rendered through the exact same atlas-blit path as the game's own UI text, in the game's own coordinate space | Good but technically a separate layer composited over/under the GL surface | +| Touch/interaction support | None — this pipeline only produces pixels in a texture; no input handling | Full Android input handling, exactly what's needed for player list + ready buttons | +| Risk to singleplayer | Low — purely additive draw call, no-ops when no lobby is active | None — overlay is purely additive, toggled only when a multiplayer session exists | +| Car/upgrade display integration | N/A | We don't render the car ourselves either way — invoke the game's existing native car-select screen (same one singleplayer uses) as a sub-flow, our overlay only wraps around it | + +Both are additive and don't touch Flow/SB authoring, so neither destabilizes the shared menu system. **Preliminary** — revisit once a first marker hook is actually implemented. + +Still open: whether `EAMText`/`GlyphMeshGLES`/FreeType (also present in the binary, ANALYSIS.md §3.3) plays any role that would interfere with a `BitmapGraphics`-based marker — low priority until that hook is attempted. + +--- + +## 2. Flow decision & data schema + +**Flow:** Compose/View overlay for lobby creation and management, native car-select (filtered by the lobby's class setting) as a sub-flow, ready-checkmark to confirm. "Lobbies as cards on real street pins" is shelved — depends on hooking visible-street `MapTrack` event population, which [[visible-streets-investigation]] found no viable path for. The overlay approach needs no further RE — arbitrary track loading (ARCHITECTURE §3a) and the native car-select screen already work. + +``` +Lobby { + id: string + hostPlayerId: string + address: { ip: string, port: int } // see §4 — shown for both LAN and WAN join + trackId: string // arbitrary track, see ARCHITECTURE §3a BuildTrackScenePath hook + classRestriction: int | null // car class filter applied to native car-select; null = any + maxPlayers: int // real cap not decided yet (see §6a) — UI works for whatever it ends up being + rewards: { // reuses the CashReward shape found on RaceEvent, ANALYSIS.md §6aa + bronze: int + silver: int + gold: int + } // see §5 — explicit fields, validated at creation, not free-floating pills + players: [LobbyPlayer] +} + +LobbyPlayer { + playerId: string + displayName: string // from the local PlayerProfile, see §3 — sent to peers on join + avatarId: string // preset id ("color+icon"), see §3 — not a photo/upload + isLeader: bool // explicit flag, not inferred from hostPlayerId match — + // keeps leader-ness a first-class, UI-visible fact instead of + // something every screen has to re-derive by comparison + ready: ReadyState + carId: string + colourIndex: int + mods: [int | null, int | null] // exactly 2 upgrade slots; each is a CarMod ModType id + // (or null = "НЕТ"/empty). Purely for display — other players + // resolve name/icon/description client-side from the car's own + // CarDescription.Mods catalog, already loaded for the garage + // screen. No numeric value/balance computation server-side. +} + +enum ReadyState { + SetsUpLobby // leader only — track/class/rewards configuration + ChoosingCar + ChoosingRace + Loading // race-start fired, client is loading its own RaceLoaderTask + NotReady + Ready +} +``` + +Confirmed against the live in-game upgrade screen (2 slots, `ВЫБОР УЛУЧШЕНИЯ` list) and the `CarMod` struct in `alfaromeo_4c_2012_desc.prefabs.sb` — 7/7 checked entries matched by price (`ModType` 1/4/5/6/7/8 confirmed, `TYRES_REINFLATING`/`CHASSIS_REINFORCED`/`BODY_IMPACT`/`POWERTRAIN_POWERPACK`/`NITROUS_BURN`/`NITROUS_EARN`). `ModType` is a unique per-option id, not a shared category grouping. The separate `im::app::car::CarPart`/`UpgradeParts` catalog (`skipCost`/`orderTimeMinutes` timer-gating fields) has no live instance data anywhere checked — not part of this schema. + +**Resolved decisions:** +- **Host-leaves-lobby**: auto-promote the longest-connected remaining `LobbyPlayer` to leader; dissolve only when the lobby becomes empty. +- **`classRestriction`**: **enforced explicitly by our own overlay** (revised 2026-08-19, was "soft/advisory only") — car_select has no known native class-filter hook (`RACEEVENT_CATEGORYTAG_OFFSET` is a confirmed false lead, PROGRESS.md cont.58; 5 independent dead ends, cont.58-60), so enforcement can't happen inside the native carousel — but that's not a reason to skip enforcing it at all. The overlay checks the chosen `carId`'s class against `classRestriction` the moment `onCarSelected` fires; a mismatch **blocks** readying up (a visible warning replaces the ready button, "Сменить" is the only way forward) rather than silently letting a mismatched car through. Still a client-side check, not a true hard filter on the carousel itself — revisit if the real class-filter mechanism is ever found. +- **Straggler/timeout policy**: hard timeout, auto-start, default 45s from when the first client finishes loading — see §6. + +--- + +## 3. Player identity — profile chip, not a form + +Every player row needs *something* to display before anyone's picked a car, and it can't come from an EA account — ARCHITECTURE §8 already rules out the Synergy backend for multiplayer, and this project has no login system. + +**Where the button lives**: a small persistent chip (avatar + name) pinned in a fixed corner, present on **every OVERLAY screen** (map's entry point, lobby browser, create, waiting room) — not buried inside one specific screen's header. It's global identity, so it shouldn't only be reachable from one place. It deliberately does **not** appear on the two screens where our footprint is intentionally minimal (the native car_select hand-off, the start countdown) — same principle as the minimized lobby badge there. + +Tapping it opens a **popover, not a full-screen navigation** — name field + an 8-preset avatar grid, right there over whatever screen you were on. Editing your name mid-lobby-browsing shouldn't cost you your place in the flow. + +**First run — not a forced form.** Auto-generate a usable default (`ROOKIE_####`, random 4-digit suffix, random avatar preset) the moment the profile is first read, so multiplayer works immediately with zero typing. Edit anytime via the chip. This mirrors how most lobby-based games (Fortnite, Fall Guys, etc.) handle it — a name you can live with immediately, not a gate. + +``` +PlayerProfile { + displayName: string // local default "ROOKIE_####" (random), freely editable + avatarId: string // one of a small fixed set of {color, glyph} presets +} +``` + +Stored **locally on-device only** (Android `SharedPreferences`/DataStore — no server round-trip, no RE work, plain new Kotlin). **Avatar is a preset, not a photo** — a small fixed palette of colour+glyph combinations (🏎️/⚡/🔥/🏁/★/🌙/⚙/💀 over a themed accent colour), picked from a grid. Deliberately not a camera/gallery upload: no permissions prompt, no image transport concerns once RakNet carries lobby state, and it matches the HUD-badge visual language already established by `CarSelectionBadge`. Revisit only if the game ever needs a real player photo — nothing today requires it. + +`PlayerProfile` is copied into `LobbyPlayer.displayName`/`avatarId` at join/create time and travels with the rest of the lobby state once RakNet exists. + +--- + +## 4. Addressing — IP and port, LAN and WAN alike + +Every lobby reference carries an explicit `address: { ip, port }` (schema above), surfaced consistently rather than hidden behind "LAN auto-discovers, WAN doesn't need it": + +- **LAN browser cards** show the host's LAN address (`192.168.1.42:7777`) alongside name/track/players — not just a bare "● LAN" ping indicator. +- **Manual "по IP" join tab** gets real address + port input fields (not a stub), validated before the connect action enables: IPv4 dotted-quad or hostname pattern, port an integer in `1–65535`. +- **Waiting room** shows the host's own address in the side panel ("Адрес: `192.168.1.42:7777`", with a copy affordance — a plain icon button, not a text label, to stay out of the way of the address itself) — for sharing with WAN friends who need to type it in manually, since there's no master-server/matchmaking list yet (ARCHITECTURE §5 — direct-connect only for the first working version). + +Default placeholder port used throughout mocks/design: `7777` — not a final decision, just a consistent stand-in until RakNet setup actually picks one. + +--- + +## 5. Rewards — explicit fields, validated + +Bronze/Silver/Gold are real numeric inputs in Create Lobby, not fixed display pills. Validation, checked live as the leader types: +- Each value is a positive integer. +- Non-decreasing: `bronze ≤ silver ≤ gold`. +- "Создать" stays disabled with an inline error message until all three pass. + +No other reward shape changes — still the same `CashReward`-derived structure (ANALYSIS.md §6aa) on the `Lobby` schema above. + +**The waiting room shows all three tiers too**, not just gold — three small cells (🥇/🥈/🥉 + amount), not one pill. This mirrors the real game's own EventDetails screen (see §9 — its "1-Й/2-Й/3-Й НА ФИНИШЕ" reward list is exactly this shape), so the convention isn't invented, it's matched to what the real UI already does one screen over. + +--- + +## 6. Waiting room — track hero, ready as a button + +Revised 2026-08-18 after direct feedback that the first landscape pass was really just a portrait layout squeezed sideways, not a real redesign: + +- **Player rows** show the car name and paint colour next to the player name — no car-shaped icon. An earlier pass added a small CSS-drawn car silhouette per row; cut it after direct feedback that it read as visual noise ("некрасиво") rather than useful information. The colour swatch (below) already carries the "this player picked a car" signal on its own. +- **Track preview**: the leader's chosen track shows as a placeholder hero block (a wide gradient/road-motif rectangle standing in for a real screenshot, which doesn't exist yet) with the track's friendly name and internal id caption — more prominent than a small text pill, since "what track is this" is the single most important piece of context in the room. +- **Readiness is a button, not a toggle switch** — deliberately different from the small iOS-style switch most lobby UIs default to. A full-width button reading "Я ГОТОВ" that becomes a solid, distinctly-coloured "✓ ГОТОВ" on press (press again to un-ready) is a clearer tap target and a clearer at-a-glance state, especially at the smaller touch scale of a landscape phone overlay. +- **Car choice stays changeable after readying up.** A persistent "Машина: `` · Сменить" row is always tappable — even when `ready == Ready` — and always reopens native car_select. Changing car always resets `ready → NotReady` automatically, so no peer ever sees a stale "ready" attached to a car that just changed; the player has to explicitly re-press ready afterward. +- **Paint colour is its own swatch**, a small bordered round dot next to the car name (matching the visual weight of the game's own PAINT1–PAINT6 respray swatches) — never baked into an icon shape. An intermediate version tinted the (since-removed) car icon with the colour directly; that read poorly at small size, especially light/white paints against the dark panel. Same underlying `colorHex` data (from `onCarSelected`, proven), just its own unambiguous element. +- **Upgrades are shown too**, using the exact `mods: [int|null, int|null]` shape already in the `Lobby`/`LobbyPlayer` schema (§2) and already flowing live off `onUpgradesAccepted` (proven, cont.39-41): the compact player list shows a 2-dot filled/empty indicator per row (room space is tight — a name is more useful there than two upgrade names), while your own car card in the side panel shows the full resolved names for both slots (or "НЕТ" for an empty slot, matching the schema comment's own convention). Slot→name resolution (`ModType` id → display string) is a placeholder mapping in the prototype — the real display strings live in the game's own localized upgrade-screen text, not extracted here. +- **Fixed 2026-08-19**: the demo car assigned when confirming through the normal flow (`Chevrolet Corvette ZR1`) now matches the lobby's default class (`СПОРТКАР`), so the ready button is visible on the main click-through path instead of being silently replaced by the class-mismatch warning every time. The mismatch state (§2, still a real, useful thing to demo) is now reached deliberately, via a small "[демо] показать несовпадение класса" toggle link next to the ready button — not something the primary flow trips over by accident. + +--- + +## 6a. Max players per lobby — real number TBD, UI works either way + +Added 2026-08-19: the actual cap isn't decided yet, so the UI is built to not assume one. + +- **Create Lobby** gets a `−`/`+` stepper next to the track selector (range 2–12, arbitrary bounds — just enough to make the control meaningfully bounded, not a real design decision). +- **Waiting room** shows a live `👤 N/max` pill next to the class tag, turning the same warning colour used elsewhere (`--danger`) once the room is full. +- **Server browser** cards show `current/max` instead of a static player-count string, and a full server (`current == max`) is visually locked — dimmed, tagged "ЗАПОЛНЕНО", not tappable — rather than silently allowing a join that wouldn't actually work. + +None of this enforces anything server-side (there's no server yet) — it's UI that reflects whatever `Lobby.maxPlayers` ends up being once that number is actually decided. + +--- + +## 7. Loading & waiting for stragglers — engine-level, not a custom screen + +**Revised 2026-08-18 per direct correction** — the earlier draft had this as our own Compose "Загрузка заезда" screen with a list of progress bars. That's the wrong layer for it to live on. The actual requirement: one phone loads fast, another loads slow, and whoever's ready first needs to visibly still be *waiting on people*, not staring at our UI — the intro/starting-line scene should keep playing, on loop, until everyone's in. + +- Once a client's own race load reaches the point where the game would normally show its native pre-race starting-grid scene (cars lined up, camera settled, about to cut to the 3-2-1-GO countdown), **that's what stays on screen** — not a custom loading UI panel. Consistent with this project's standing principle (ARCHITECTURE §2): hook the game's own flow, don't replace it with ours. +- Our overlay's only contribution here is a **thin status strip**, not a full panel — something like "Ожидаем игроков (2/4) · SLOWPOKE_88 загружается", pinned to one edge, with a subtle looping pulse so it visibly reads as "still working," not "frozen." +- The native 3-2-1-GO countdown itself is **held** until the host signals everyone's in, then plays exactly as it would in a normal single-player race start — no substitute countdown of our own. +- **Engineering reality check**: the hold/release mechanism doesn't exist yet. Candidate hook anchor is `InRaceState` construction (RTTI-confirmed, ANALYSIS.md §3.1) or the final event in `RaceLoaderTask_DispatchInitialFSMEvents` (ANALYSIS.md §6b stage 8) — either is a real, already-located point, just not yet wired for this purpose. `RaceLoaderTask_ExecuteLoadSequence`'s own `SetLoadProgress(this, N)` calls (ANALYSIS.md §6a slot 4, fractions 0.1–0.8) are the natural source for the real per-client progress number to report to peers, instead of inventing a synthetic one. +- **Straggler timeout still applies on top of this**: hard 45s timeout (resolved 2026-08-18) from when the first client is ready — if it expires before everyone's in, the held countdown releases anyway and whoever isn't in yet is left behind. Left-behind representation (spectator vs. rubber-band-in-when-ready) is still an open, lower-priority call. +- Also still open, and *upstream* of all of this (see ARCHITECTURE §4b step 5.1): which race event actually carries the load. The synthetic "LAN" event currently has its loadout-confirm deliberately redirected back to the map because it has no real track/scene reference (crashes otherwise, PROGRESS.md cont.36) — the recommended fix is having the lobby's "start" re-target a real, existing race event as the vessel and let *its* loadout-confirm proceed normally (real events already sail through with zero crashes, cont.41), rather than trying to give the synthetic event a scene reference of its own. + +--- + +## 8. Screen inventory (prototype) + +Matches `LOBBY_PROTOTYPE.html`'s screen rail. Profile is **not** in this numbered flow — it's the global chip/popover from §3, demonstrable from any screen. + +| # | Screen | Tag | What it is | +|---|---|---|---| +| 0 | Карта — точка входа | OVERLAY | Entry button on the map, same `FrameLayout` pattern as `CarSelectionBadge` | +| 1 | Обзор лобби | OVERLAY | LAN list (with address) + "по IP" tab (validated) + create | +| 2 | Создание лобби | OVERLAY | Name, track (with placeholder preview), class, validated rewards | +| 3 | Комната ожидания | OVERLAY | Player rows (ready-button, reward trio, mod dots), track hero, address | +| 4 | event_details (перед car_select) | NATIVE | Real on-device screenshot — any ordinary event, not ours; see §9 | +| 5 | car_select (после event_details) | NATIVE | Real on-device screenshot — our overlay shrinks to a corner badge | +| 6 | Снова в лобби: авто выбрано | OVERLAY | Car/colour captured; "Сменить" stays available; ready button | +| 7 | Все готовы | OVERLAY | Host's "Начать гонку" enabled once everyone's `Ready` | +| 8 | Ожидание в гонке | NATIVE + thin overlay | Native starting-grid scene loops; thin status strip shows stragglers | +| 9 | Старт заезда | TRANSITION | Native 3-2-1-GO, released once everyone's in (or timeout) | + +--- + +## 9. event_details before car_select — real screenshots, real reason + +Added 2026-08-19, direct request: show the actual native hand-off as two steps, not one, using real captures instead of a drawn placeholder. + +**Why event_details comes first, not just car_select alone**: this isn't a UX preference, it's the same technical constraint already on record in ARCHITECTURE §4b step 5.2 / PROGRESS.md cont.55/57 — `TriggerTrueDirectCarSelectJump()` needs at least one *real* `event_detail → car_select` transition to have happened this session (`g_realEventDetailsVisitHappened`) before a cold direct jump is reliable. Showing event_details first in the prototype makes that real engine requirement visible in the flow itself, instead of leaving it as an invisible precondition documented only in prose. + +**The screenshots are real**, captured live via `adb screencap` on the project's own Galaxy A9 test device (`com.ea.games.nfs13_mod`), not drawn: an ordinary street (МАККЛЕЙН) → an ordinary event (ПОБУДКА, everyday street race) → its EventDetails screen → continue → car_select showing whatever car happened to be equipped (Subaru Cosworth Impreza STI CS400). Deliberately **not** the project's own synthetic "LAN" event — per the request, any real street/event/car works, and using a genuinely ordinary one keeps the mockup honest about what the native screens actually look like today, unmodified. + +Both screenshots are embedded directly in `LOBBY_PROTOTYPE.html` (inline `data:` JPEGs, cropped to drop the on-screen nav bar) — the prototype has zero external image dependencies. + +**On-device viewing**: `LOBBY_PROTOTYPE.html?kiosk=1` hides the review-tool chrome (masthead, screen rail, notes panel) and lets the phone-frame content fill the real viewport — this is the mode to use when checking the mockup on an actual phone via `adb`, not the full review page. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md new file mode 100644 index 0000000..d10c9c2 --- /dev/null +++ b/docs/PROGRESS.md @@ -0,0 +1,1318 @@ +# PROGRESS.md — Changelog of binary/code modifications + +Every entry: what changed, why, which snapshot/backup it corresponds to. No binary patches have been applied yet — this file will gain entries once we move past planning into subtask 1 (arbitrary track loading). + +**Mandatory rule (see ARCHITECTURE.md §6 for full reasoning):** `native_lib/libapp.so` (and siblings) is the pristine reference copy and is **not under version control** — it must never be overwritten in place. Any patched binary is written as a new file under `native_lib/patched/`, logged here, then copied into `launcher/app/src/main/jniLibs/armeabi-v7a/` (which *is* git-tracked inside the `launcher` repo) for the actual APK build. Runtime hooks installed by `mpcore` (the common case — no `.so` file on disk is touched, only memory at process start) must be logged here too, just as rigorously as on-disk patches, since a bad hook is just as capable of breaking singleplayer. + +Format for future entries: + +``` +## YYYY-MM-DD — +- **What**: +- **Why**: +- **Snapshot**: +- **Verification**: +``` + +--- + +## 2026-07-30 — Session start: context reconstruction, no binary changes + +- **What**: No binary or source modifications performed. This session was documentation/analysis-only: + - Reconstructed structured context from `chat-export-1785094024785.json` into `ANALYSIS.md`. + - Opened `native_lib/libapp.so.i64` via IDA MCP (idalib), surveyed segments/imports/entrypoints, confirmed RTTI presence of key Iron Monkey engine classes (`RaceLoaderTask`, `OpponentCollection`, `TrackNavigator`, etc.) at addresses different from the prior chat's (unrelated binary copy). + - Reviewed existing `launcher/mpcore` draft (`main.cpp`, `armhook.cpp`, `NativeLib.kt`) and cross-verified one of its hardcoded addresses (`0xC8C9D8` → `"APPLICATION_OK"`) against our actual `.i64`, confirming the draft was built against this exact binary. + - Wrote `ARCHITECTURE.md` with a preliminary lobby-UI recommendation (option (a), overlay Android View) based on the finding that in-game text/UI is rendered natively via GLES/EAMText, not via a JNI Canvas bridge. +- **Why**: Per task instructions, no reverse engineering of specific subsystems or binary patching should start before the subtask plan is reviewed and agreed with the user. +- **Snapshot**: N/A (no changes made). +- **Verification**: N/A. + +--- + +## 2026-07-31 — IDA annotation only: named the BitmapGraphics JNI bridge functions + +- **What**: Renamed 7 functions in `native_lib/libapp.so.i64` (analysis database only — **not** a runtime binary patch, `libapp.so` itself is untouched): + - `sub_5640EC` → `BitmapGraphics_ctor_jni` (`0x5640ec`) + - `sub_5625A0` → `BitmapGraphics_drawString_jni` (`0x5625a0`) + - `sub_56274C` → `BitmapGraphics_drawString_thunk` (`0x56274c`) + - `sub_562A58` → `BitmapGraphics_createPaintFromFamilyName_jni` (`0x562a58`) + - `sub_562B94` → `BitmapGraphics_createPaintFromFile_jni` (`0x562b94`) + - `sub_563D30` → `BitmapGraphics_blitBitmapToAtlasTexture` (`0x563d30`) + - `sub_51145C` → `ResolveFontPaint_ttfOtfOrFamily` (`0x51145c`) + Also added two explanatory comments (at `0x563d30` and `0x5625a0`) describing the native↔Java text/atlas pipeline. Saved via `idb_save`. +- **Why**: User corrected an earlier (wrong) claim in `ANALYSIS.md` §3.3 that text rendering had no JNI Canvas bridge. Traced and confirmed the real mechanism (`com.ea.ironmonkey.BitmapGraphics` — see `launcher/app/src/main/java/com/ea/ironmonkey/BitmapGraphics.kt`) against the actual `.i64`, and named the functions so future sessions don't have to re-derive this. Directly informs the lobby-UI marker-drawing approach in `ARCHITECTURE.md` §4. +- **Snapshot**: Not taken — this is a reversible, non-destructive IDA metadata change (renames/comments only) to an analysis database, not a patch to the shipped binary or source tree. No backup policy needed for this class of change; backups are for actual binary patches (subtask 1+ onward). +- **Verification**: Re-decompiled each renamed address after the rename via `decompile`/read-back is implicit in the `rename` tool's success response (7/7 `ok`); `idb_save` confirmed write to `native_lib/libapp.so.i64`. + +--- + +## 2026-07-31 — IDA annotation only: located and named RaceLoaderTask's real vtable (subtask 1 start) + +- **What**: Located `RaceLoaderTask`'s actual vtable at `0xd86210` (19 slots) by walking its RTTI chain (name string → `type_info` struct → `find_bytes` for who embeds that `type_info` address). Renamed 8 functions in `native_lib/libapp.so.i64`: + - `sub_2D8E04` → `RaceLoaderTask_dtor_complete` (`0x2d8e04`) + - `sub_2D8F18` → `RaceLoaderTask_dtor_deleting` (`0x2d8f18`) + - `sub_2DBBA4` → `RaceLoaderTask_ExecuteLoadSequence` (`0x2dbba4`) — main load orchestrator + - `sub_2DB160` → `RaceLoaderTask_ResolveDriverPositionComponent` (`0x2db160`) + - `sub_2D9D10` → `RaceLoaderTask_SetupPlayerCar` (`0x2d9d10`) + - `sub_2DAE0C` → `RaceLoaderTask_SetupOpponentCar` (`0x2dae0c`) + - `sub_2DA2A0` → `RaceLoaderTask_ResetStartingLine` (`0x2da2a0`) + - `sub_2DA880` → `RaceLoaderTask_HandleSpikeStrip` (`0x2da880`) + Added a full vtable-layout comment at `0xd86210` and a detailed comment at `0x2dbba4`. Saved via `idb_save`. Full table with evidence for every slot (including unconfirmed ones, left un-renamed) is in `ANALYSIS.md` §6a. +- **Why**: First concrete step of subtask 1 (arbitrary track loading) — needed real addresses in *our* binary before any hook can be written; the previous chat's addresses (see §1 provenance note) don't apply here at all. +- **Snapshot**: Not taken — same reasoning as the previous entry (IDA metadata only, no binary/source touched). +- **Verification**: `rename` tool reported 8/8 `ok`; role assignments backed by decompiled evidence (destructor pattern, literal progress-fraction floats, string refs `"playerCar"`/`"opponentCar"`/`"ResetLine"`/`"Spike Strip"`, and `dynamic_cast` RTTI arguments) rather than guesswork — see ANALYSIS.md §6a for the evidence behind each one. Slots without solid evidence were deliberately left unrenamed rather than guessed. + +--- + +## 2026-07-31 — IDA annotation only: decoded all 8 ExecuteLoadSequence stage functions, found subtask-1 hook point + +- **What**: Decompiled all 8 stage functions called from `RaceLoaderTask_ExecuteLoadSequence`. Renamed 7 in `native_lib/libapp.so.i64` (2 left un-renamed, evidence too thin): + - `sub_2DB384` → `RaceLoaderTask_BuildTrackScenePath` (`0x2db384`) — **the subtask-1 hook point**, builds the track prefab path from a single string field + - `sub_2DBF58` → `GenericLoadScene_trackEnvWrapper` (`0x2dbf58`) + - `sub_2DA710` → `RaceLoaderTask_SetupAIDifficultyProfiles` (`0x2da710`) + - `sub_2DAA7C` → `RaceLoaderTask_TagPlayerEntity` (`0x2daa7c`) + - `sub_2D994C` → `RaceLoaderTask_RegisterTrafficFlow` (`0x2d994c`) + - `sub_2DB534` → `RaceLoaderTask_LoadRaceFSM` (`0x2db534`) — loads the race rules/state-machine prefab, resolves the `im::app::race::Race` component, confirms `im::app::car::Health` exists in this binary + - `sub_2D969C` → `RaceLoaderTask_DispatchInitialFSMEvents` (`0x2d969c`) + Added detailed comments on all 4 most important ones. Saved via `idb_save`. Full breakdown with per-stage evidence in `ANALYSIS.md` §6b. +- **Why**: Direct continuation of subtask 1 — needed to find exactly which sub-step of the load sequence resolves the track path, rather than hooking the whole orchestrator. +- **Snapshot**: Not taken — IDA metadata only. +- **Verification**: Track-path construction is unambiguous (literal path-prefix strings `"published/prefabs/tracks/"` / `"published/prefabs/environments/"` plus a `.scene.sb`/`.prefabs.sb` suffix, directly matching the `game_cache/published/prefabs/tracks/` and `.../environments/` directories already on disk). Race-FSM loading similarly confirmed via the literal `"/published/prefabs/racefsms/{0}.prefabs.sb"` string matching `game_cache/published/prefabs/racefsms/`. + +--- + +## 2026-07-31 — IDA annotation only: identified `TrackTestLayer`, EA's own QA track-testing tool + +- **What**: Investigated the "SceneLoader" question and the `0x24a1f4` hot lead from the previous session. Confirmed a real, literally-named class `TrackTestLayer` exists and is fully functional in this binary. Renamed 5 functions in `native_lib/libapp.so.i64`: + - `sub_24A1F4` → `TrackTestLayer_ctor` (`0x24a1f4`) — constructor; 2nd arg is the scene/track path to load; loads it via the generic scene loader (`sub_33C284`), finds start/finish nodes, spawns 8 hardcoded reference cars bound to `TrackNavigator`, logs telemetry to CSV + - `sub_24AEC4` → `TrackTestLayer_factory_wrapper` (`0x24aec4`) + - `sub_243160` → `TrackTestLayer_QABatchRunner_tick` (`0x243160`) — auto-cycles through up to 15 tracks from a runtime table + - `sub_2217EC` → `DebugTestHarness_DispatchByName` (`0x2217ec`) — dispatches a mode-name string (`"Track/..."`, `"Performance/..."`, `"CarPreview/..."`, etc., default `"MainMenu"`) to construct the matching test layer + - `sub_CF904` → `ResourceDirListeners_Init_maybeCallsDebugHarness` (`0xcf904`) — the one caller of the dispatcher found so far, itself called once at startup + Added detailed comments on the 3 most important ones. Saved via `idb_save`. Full writeup in `ANALYSIS.md` §6d. +- **Why**: User recalled a "SceneLoader" and asked for a theory; while confirming no such literal class exists (§6c), traced into `TrackTestLayer` (previously flagged as a "hot lead") per explicit follow-up request. This is now the leading candidate approach for subtask 1 (and partially 2/4) — a pre-built, EA-tested path-to-scene loader with working start/finish detection and car-to-TrackNavigator binding, bypassing `RaceLoaderTask` entirely. +- **Snapshot**: Not taken — IDA metadata only. +- **Verification**: Class identity confirmed via the literal tag string `"TrackTestLayer"` written into the object right before its vtable pointer is set (standard constructor pattern, same technique used to confirm `BitmapGraphics`/`RaceLoaderTask` earlier). Car IDs and category-prefix strings are literal, unambiguous string references in the decompiled code. Not yet traced: where the `modeName` string passed to `DebugTestHarness_DispatchByName` actually originates at runtime — flagged as the next concrete step in `ANALYSIS.md` §6d. + +--- + +## 2026-07-31 — IDA annotation only: traced the debug mode-string source to a dead end; bonus discovery of an editable debug-menu SB file + +- **What**: Traced `ResourceDirListeners_Init_maybeCallsDebugHarness` (`0xcf904`) fully — it reads the mode string from `*(a1+292)` of a separately-resolved runtime object (not identified) and passes it to `DebugTestHarness_DispatchByName`. Found its one caller, the app bootstrap function `sub_DE180`, renamed to `AppBootstrap_flowInitOrStartupRaceCheck` (`0xde180`), which also independently checks a config key `"flow"` against literal `"STARTUP_RACE"`. Unpacked and searched **all 8 files** in `game_cache/published/tweaks/` (`debug_options.sb`, `tweaks.sb`, `race_tweaks.sb`, `track_performance_tweaks.sb`, `tweaks_ipad.sb`, `traffic_tweaks.sb`, `car_preview_tweaks.sb`, `lod_tweaks.sb`) via `NFSMW12MobileTools`, plus the reverse-engineered launcher Java sources — **neither the `+292` field's value nor the `"flow"`/`"STARTUP_RACE"` key/value pair appear anywhere in shipped assets.** Added comments explaining the trace and conclusion. Saved via `idb_save`. Full writeup in `ANALYSIS.md` §6e. + - **Bonus**: `debug_options.sb` turned out to be a full, directly-editable in-game debug-menu tree (unpack/edit-JSON/repack via `NFSMW12MobileTools`, zero binary patching) with entries directly useful to the mod (`AI/Race/Max Num AI Opponents`, `Everything is Available`, `Infinite Player Nitro`, etc.) — noted for later use, not yet tested on-device. +- **Why**: Direct follow-up to the previous session's `TrackTestLayer` finding — user asked specifically where the debug-mode string comes from, to determine if it's an available trigger mechanism. +- **Snapshot**: Not taken — IDA metadata only; the `sb_unpack` scratch files (unpacked JSON) live under the session scratchpad, not the project tree, and were not modified/repacked. +- **Verification**: Absence of the search terms was checked with plain-text `grep` across all 8 unpacked JSON files and the relevant Java source tree — a conclusive negative result, not a guess. Conclusion (EA-internal build-only switch) is an inference from that absence, clearly labeled as such in `ANALYSIS.md` §6e, alongside the recommended workaround (call `TrackTestLayer_ctor` directly instead of trying to trigger the dispatcher). + +--- + +## 2026-07-31 — First live test of `TrackTestLayer_ctor` from `mpcore` (two crashes, both understood; code left in a known-crashing state) + +- **What**: Modified `launcher/mpcore/src/main/cpp/main.cpp`: added `try_load_track_test_layer()`, spawned from `JNI_OnLoad` via a detached `std::thread` (15s delay), calling `TrackTestLayer_ctor` (`0x24a1f4`) directly with a manually-constructed path-string object for `published/prefabs/tracks/region1_foothills_track3.scene.sb`. Commented out the pre-existing `raise(SIGSTOP)` in `JNI_OnLoad` (it suspends the whole process, including the new thread — noted inline why, easily reverted). Also fixed an unrelated, pre-existing build break in `launcher/devmenu/domain/build.gradle.kts` (Kotlin/Java JVM-target mismatch, JDK 21 vs explicit `sourceCompatibility 17`) by pinning `compileKotlin`'s `jvmTarget` to 17 — required to get any variant of the app to build at all in this environment; unrelated to the mod logic itself. Built via `:app:assembleNo_devmenuDebug` (JDK 17 from `~/.jdks/corretto-17.0.14`, explicit `-Dorg.gradle.java.home`, since only JDK 21/8 are on `PATH` and AGP 8.1.2 requires 17). Installed and ran on the already-set-up WayDroid instance (`com.ea.games.nfs13_na`, a separate app from the already-installed `com.ea.games.nfs13_mod` — applicationId not changed, per the still-open question from the previous session). + - **Crash 1**: `sub_CF5F8` called with an assumed 2-arg "C-string assign" signature; its real signature is a 3-arg `[begin,end)` range constructor. Fixed by passing `trackPath + strlen(trackPath)` as the third argument. See `ANALYSIS.md` §6f for full detail, confirmed via decompiling `sub_CF5F8` after the crash pinpointed it (tombstone frame `#00 pc sub_CF5F8+0x68`). + - **Crash 2** (after the fix): construction proceeds correctly through scene load and car-catalog setup, then crashes in the "start"/"finish" named-node lookup (`sub_695A10` → `sub_FDA64`, no null-check for a missing node). Root cause: the test track doesn't have a top-level node named exactly `"start"` (has it as a nested path segment and as capitalized `"Name": "Start"` instead). See `ANALYSIS.md` §6f. +- **Why**: Direct, explicit user request to try this ("Попробуй вызвать TrackTestLayer через хук в mpcore") — validating whether the reverse-engineered `TrackTestLayer` constructor (§6d) can actually be driven from `mpcore` as designed for subtask 1. +- **Snapshot**: Not applicable to `native_lib/libapp.so` (untouched, no binary patch — this is a runtime call from `mpcore`, not an on-disk modification). `mpcore`'s own source changes are tracked in the `launcher` git repo as usual. +- **Verification**: Verified via live on-device testing, not static analysis alone — `adb logcat` confirmed each stage's log line firing, and `tombstone`/`DEBUG` crash logs plus IDA `lookup_funcs` on the crash PCs confirmed the exact crashing function both times. **Current state: the code in `main.cpp` as committed will crash on launch** (still targets a track without confirmed start/finish markers) — this is intentional/expected for now, not a regression to fix blindly; next step is picking a track actually used by a real race event (cross-reference `game_cache/published/data/races/*.sb`) before re-testing, per `ANALYSIS.md` §6f. + +--- + +## 2026-07-31 — Third live test: race-linked track, identical crash — ruled out "wrong track", theory revised + +- **What**: Cross-referenced `game_cache/published/data/races/event_01_race.prefabs.sb` for a real `TrackName` (`region4_chicago_track4`). Unpacked that track's `.scene.sb` via `NFSMW12MobileTools` and confirmed `"start"`/`"finish"` actors exist and are listed directly in the scene's top-level `"actors"` `DataIdsMap` (not nested). Updated `launcher/mpcore/src/main/cpp/main.cpp`'s `trackPath` to this track, rebuilt, reinstalled, re-ran on WayDroid. + - **Result: identical crash** (`sub_FDA64+0x18`, same as the previous attempt on an arbitrary track) despite the data being confirmed present and correctly structured. This rules out "picked a track without start/finish markers" as the cause. + - Revised theory (not confirmed): either (a) scene loading via `sub_33C284` is asynchronous and `TrackTestLayer`'s normal usage polls across frames before the lookup, which a single direct constructor call can't replicate, or (b) `TrackTestLayer` depends on ambient global engine state (e.g. a "current world" singleton) set up by whatever legitimate code path constructs it, which we never found (§6e) and can't replicate. See `ANALYSIS.md` §6f for full detail and the resulting recommendation. +- **Why**: Direct follow-up to the user's request to check whether a race-linked track resolves the earlier crash. +- **Snapshot**: N/A — no binary patch, `mpcore` source changes tracked in the `launcher` git repo. +- **Verification**: Live on-device (WayDroid), same method as the previous two attempts (`adb logcat` + tombstone + IDA `lookup_funcs` on the crash PC) — crash address matched exactly (`0xfda7c`/`0xfda74`) between this and the prior attempt, a clean, reproducible negative result. +- **Recommendation going forward** (see `ANALYSIS.md` §6f): stop iterating blindly on `TrackTestLayer` — either invest in tracing scene-load-completion state properly, or pivot subtask 1 to hooking `RaceLoaderTask_BuildTrackScenePath` (§6b) instead, the engine's own always-correctly-initialized code path. Left as an open decision for the user rather than unilaterally choosing. + +--- + +## 2026-07-31 — Pivoted to hooking `RaceLoaderTask_BuildTrackScenePath` (user chose option (b)) + +- **What**: Modified `launcher/mpcore/src/main/cpp/main.cpp`: disabled the `TrackTestLayer` thread-spawn experiment (commented out, not deleted). Added a new **ARM-mode inline hook** (`install_arm_inline_hook`, `hook_BuildTrackScenePath`) targeting `RaceLoaderTask_BuildTrackScenePath` (`0x2db384`), installed unconditionally in `JNI_OnLoad`. The hook overwrites the `RaceDefinition`'s track-name string field (`this[8] + 72`, a 3-word `{begin,end,capacity}` object, same layout as `sub_CF5F8`'s strings) with a hardcoded override (`region3_colorado_track2`) before calling through to the original function via a trampoline. **This does not reuse `armhook.cpp`'s existing `InstallHook`** — disassembly showed the target function is compiled in **ARM mode** (`E92D41F0`/`E24DD040`, classic 32-bit ARM encodings), while `armhook.cpp`'s hook helpers are hardcoded for Thumb targets and would have corrupted this function. Wrote a separate, ARM-mode-correct 8-byte `LDR PC,[PC,#-4]` detour + trampoline instead (full technical detail in `ANALYSIS.md` §6g). Built via `:app:assembleNo_devmenuDebug` (same JDK 17 pin as before), installed on WayDroid (`com.ea.games.nfs13_na`). + - **Result so far**: hook installs cleanly, confirmed via `mpcore_log`. App continued running and actively working (growing RSS) for 2+ minutes afterward with no crash — good evidence the ARM patch itself is well-formed. **However, the hook was never actually exercised**: the game never reached its main menu (screen stayed black indefinitely) because the WayDroid container has no default network route (`ip route` shows only the local `/24` subnet, confirmed by 100% packet loss pinging `8.8.8.8`), and the boot log shows `NIM_ERROR: No network connection` during EA's online-services init — very likely blocking progress to the menu. Fixing this needs root inside the WayDroid container, which requires the user's `sudo` password (not available in this session, and not something to prompt for). +- **Why**: Direct continuation of subtask 1 per the user's explicit choice of approach (b) over (a) (fix `TrackTestLayer`'s async/state dependency) after three straight `TrackTestLayer` crashes. +- **Snapshot**: N/A — no `native_lib/libapp.so` patch (all changes are runtime, via `mpcore`); `mpcore` source changes tracked in the `launcher` git repo as usual. +- **Verification**: Verified the ARM-vs-Thumb mode distinction directly via IDA disassembly (not assumed) before writing any hook code — this is the single most important correctness check for this kind of inline hook and was confirmed before, not after, implementation. Hook-install success and post-install stability verified live via `adb logcat` + repeated `ps`/screenshot polling over 2+ minutes. **Not yet verified**: whether the hook actually fires and correctly substitutes the track (blocked on the WayDroid networking issue above) — this is the concrete next step once network access is restored, not a known-working conclusion yet. + +--- + +## 2026-07-31 — Switched to `com.ea.games.nfs13_mod`; corrected an unverified claim about OBB versionCode lookup + +- **What**: The "no network" black-screen theory above was wrong — user identified the real cause: WayDroid's game cache exists only under `com.ea.games.nfs13_mod` (a separate, already-installed app, `versionName 1.3.128`, with `/sdcard/Android/obb/com.ea.games.nfs13_mod/main.1003128.com.ea.games.nfs13_mod.obb` present), not under our build's `com.ea.games.nfs13_na`. Changed `launcher/app/build.gradle.kts`: `applicationId` → `com.ea.games.nfs13_mod` (kept `namespace` as `com.ea.games.nfs13_na` since source still imports `com.ea.games.nfs13_na.BuildConfig`). First install attempt failed with `INSTALL_FAILED_VERSION_DOWNGRADE` (our `versionCode=1` vs. the real game's `1003128`, a confirmed OS-level fact about Android's installer). Fixed by setting `versionCode = 1003128`, `versionName = "1.3.128"` to match — `adb install -r` then succeeded without `-d`. + - **Correction**: the comment originally added alongside the versionCode change claimed the game's own code looks up its OBB file via `main...obb`. User asked for evidence. Checked properly: `grep -i obb` across the entire `launcher` source tree (Java/Kotlin/XML) → zero matches; `find_regex` for `addAssetPath|mountObb|StorageManager|\.obb` in `libapp.so` → zero matches (already run earlier this session, just not connected to this claim until asked). **No evidence this game's code does OBB-based lookup at all** — the comment was corrected in `build.gradle.kts` to state only the confirmed fact (avoids the installer downgrade block) and flag the OBB theory as unverified. Full detail in `ANALYSIS.md` §6h. + - Rebuilt/reinstalled/relaunched under the corrected package+versionCode: hook still installs cleanly (`mpcore_log` confirmed), but the screen is still black after one `onDrawFrame` call (same as before the package switch) — i.e. the versionCode fix, as expected given it's now understood to be unrelated to asset loading, did not change the outcome. +- **Why**: Correcting a wrong diagnosis (network) with the user's correct one (package/cache mismatch), then being properly rigorous when the user challenged an unverified claim in my own code comment rather than accepting it at face value. +- **Snapshot**: N/A — no `native_lib/libapp.so` patch; `build.gradle.kts`/`main.cpp` changes tracked in the `launcher` git repo. +- **Verification**: `INSTALL_FAILED_VERSION_DOWNGRADE` → success after versionCode match is directly observed `adb install` behavior, not inferred. The OBB-lookup claim's correction is a genuine `grep`/`find_regex` negative result across the full source tree and the native binary, not a guess. **Still open**: how `game_cache/published/...` is actually located at runtime (candidates: `AAssetManager` reading the APK's own bundled `assets/`, or loose external-storage files via `fopen` from an untraced root path) — not resolved this session. **Resolved empirically later the same day**: see the 2026-08-01 entry below — the user's corrected `launcher`/`native_lib` combination mounts the OBB successfully (`Mounting SKU: ... to /published` in logcat) and the actual EA splash screen renders on WayDroid, confirming cache loading now works end-to-end. + +--- + +## 2026-08-01 — Discovered the IDA database was stale (silently out of sync with the replaced binary); rebuilt fresh, re-verified + +- **What**: The user swapped in corrected versions of `native_lib/` and the `launcher/` project (now at `/home/megboyzz/AndroidStudioProjects/NFSMostWanted128`) partway through the previous session, but `native_lib/libapp.so.i64` was never rebuilt. It kept opening without error and even reported `survey_binary` hash metadata matching the new on-disk file, which looked like proof it was current. **Direct verification proved otherwise**: read raw bytes straight from `native_lib/libapp.so` at offset `0x2db384` via plain Python file I/O (bypassing IDA) and compared against the still-open `.i64`'s disassembly at that address — they were completely different instructions (`ADD R0,SP,#0x74`/`STR`/`MOV`/`BL` in the real file vs. the `PUSH {R4-R8,LR}`/`SUB SP,#0x40` `RaceLoaderTask_BuildTrackScenePath` prologue the stale `.i64` showed). This conclusively proved the open database was analyzing cached/stale content despite reporting a matching hash. + - Moved the stale database aside: `native_lib/libapp.so.i64` → `native_lib/libapp.so.i64.stale_2026-07-31` (kept, not deleted, in case it's ever useful for diffing against the pre-fix build). + - Opened `native_lib/libapp.so` fresh via `idb_open` (pointing at the `.so` directly, not an existing `.i64`), forcing full auto-analysis into a brand-new database. + - Re-verified the fix the same way the problem was found: disassembly at `0x2db384` in the fresh session now matches the raw file bytes exactly. + - Ran `survey_binary` on the fresh database: `image_size 0xb167d0` (~11.6MB), 34,726 functions (2,425 named), 21,031 strings — all different from the old build's 50,772/2,789/20,895. JNI entrypoints now read `Java_com_ea_ironmonkey_GameActivityMain_*`, matching the renamed activity class; new engine-assertion strings and a `libc++` `__ndk1` namespace suggest a genuinely different compiled build, not just a rename. + - Re-ran the RTTI class search (`RaceLoaderTask|TrackTestLayer|NFSScene|TrackNavigator|OpponentCollection|BitmapGraphics`) against the fresh binary: all present, confirming the underlying engine/architecture is unchanged even though every specific address from `ANALYSIS.md` §6a–§6h is now invalid. One refinement: `TrackTestLayer`'s full namespace is `im::app::layers::debug::TrackTestLayer`, not just a bare class name as previously known. +- **Why**: User asked to "check the binary again" after a prior (incorrect) all-clear where the reported hash match was mistakenly treated as sufficient proof of consistency. Full detail and the general lesson (don't trust `survey_binary`'s hash fields as proof of currency — cross-check actual bytes) are in `ANALYSIS.md` §6i. +- **Snapshot**: The stale `.i64` was preserved (renamed, not deleted) rather than discarded, consistent with the project's backup discipline even for analysis-only artifacts. +- **Verification**: The staleness was proven by direct raw-byte comparison (Python `open().seek().read()`) against IDA's own disassembly output at the same address — not inferred from indirect signals (hash fields, rename persistence) that turned out to be misleading. The fix was verified the identical way, closing the loop. +- **Next**: every address-specific finding in `ANALYSIS.md` §6a–§6h (RaceLoaderTask vtable, `BuildTrackScenePath`, `TrackTestLayer_ctor`, `BitmapGraphics_*`, etc.) needs to be rediscovered from scratch in the fresh database using the same methodology (RTTI walk, decompile, confirm via strings/constants) — not yet done, this session only re-established the baseline and confirmed the architecture still applies. The `mpcore` ARM hook from §6g/§6h now targets a meaningless offset in this binary; the user has already moved `main.cpp` on from it to a fresh `dl_iterate_phdr`-based base lookup with hooks not yet reinstalled, consistent with restarting that work properly once new addresses are found. + +--- + +## 2026-08-01 — Rediscovered `RaceLoaderTask` vtable + `BuildTrackScenePath` in the fresh binary (IDA annotation only) + +- **What**: Redid the §6a RTTI-walk against the freshly-rebuilt `native_lib/libapp.so.i64` (no runtime binary patch — analysis database only): + - Located `RaceLoaderTask` vtable at `0xaa78e8` (18 slots, was `0xd86210`/19 slots in the old build), via RTTI name `N2im3app4race14RaceLoaderTaskE` at `0xa2f570` → `type_info` at `0xaa7938`. + - Decompiled all 18 slots. Renamed 6 that had unambiguous evidence (a distinctive string or an exact structural match to the old binary's confirmed roles): `RaceLoaderTask_dtor_complete` (`0x2a7e58`), `RaceLoaderTask_dtor_deleting` (`0x2a8130`), `RaceLoaderTask_ExecuteLoadSequence` (`0x2a8144`), `RaceLoaderTask_SetupPlayerCar` (`0x2aafac`, string `"playerCar"`), `RaceLoaderTask_ResetStartingLine` (`0x2abe08`, string `"m_StartLine"`), `RaceLoaderTask_HandleSpikeStrip` (`0x2ad4a8`, `dynamic_cast`). Left 8 other slots unnamed rather than guess without evidence (e.g. the old binary's `"opponentCar"`-string slot has no equivalent string in the new slot at the same relative position — recorded as unconfirmed, not asserted). + - Decompiled all 8 stage sub-calls inside `RaceLoaderTask_ExecuteLoadSequence` and found stage 1 (`0x2a8424`) references the exact strings `"published/prefabs/tracks/"` + `".scene.sb"` — the `BuildTrackScenePath` equivalent (was `0x2db384`). Renamed to `RaceLoaderTask_BuildTrackScenePath`. + - Confirmed ARM-mode compilation at the new address too (`0xE92D4FF0` = `PUSH {R4-R11,LR}`, cond `E`) — same as the old binary, so the ARM-mode inline-hook design from §6g is directly reusable, just retargeted. + - Added `append_comments` at the vtable and key slots documenting evidence, and `idb_save`d. + - Full detail, table of all 18 slots, and the specific strings/constants used as evidence: `ANALYSIS.md` §6j. +- **Why**: Direct continuation of rebuilding the RE baseline after the stale-`.i64` incident (previous entry) — `RaceLoaderTask`/`BuildTrackScenePath` is the concrete hook point this project needs for subtask 1 (arbitrary track loading), so it was prioritized first among the invalidated §6a–§6h findings. +- **Snapshot**: N/A — `.i64` annotation only, `libapp.so` itself untouched. +- **Verification**: Each rename is backed by either a literal string reference decompiled from the function body, or an exact structural match (same call pattern/argument shape) to the corresponding already-verified old-binary slot from §6a — not positional guessing alone, consistent with the lesson from §6h's "find evidence" correction. +- **Next**: re-implement the ARM-mode inline hook in `launcher/mpcore/src/main/cpp/main.cpp` targeting `0x2a8424` (the design is proven from §6g, just needs the new address and a re-check of the track-name field's offset within the `RaceDefinition`-like struct, which may differ from the old binary's `this[8]+72`). Then re-test live on WayDroid. + +--- + +## 2026-08-03 — Implemented and live-tested the `RaceLoaderTask_BuildTrackScenePath` ARM-mode hook on Pixel 6a; mechanism confirmed working, exposed a real data-consistency limitation + +- **What**: Runtime hook installed by `mpcore` at process start (no `libapp.so` file on disk touched, memory-only, per this file's header rule): + - Added `Hook_BuildTrackScenePath` + `InstallBuildTrackScenePathHook` to `launcher/mpcore/src/main/cpp/main.cpp`, targeting `RaceLoaderTask_BuildTrackScenePath` at `libapp_base + 0x2a8424` (offset confirmed fresh this session in `ANALYSIS.md` §6j). 8-byte ARM-mode patch (`LDR PC,[PC,#-4]` + hook address) at function entry, `mmap`'d RWX trampoline relocates the 2 displaced `PUSH`/`ADD` instructions and jumps back to `target+8` — same design proven in the (now-stale) old-binary test from §6g, just retargeted. + - Hook overrides the track-name field (`raceDefPtr+72/+76`, where `raceDefPtr = a1[8]`) with a literal `"region3_colorado_track2"`, and (in a follow-up build) also the environment-name field (`raceDefPtr+100/+104`) with `"colorado"`. Deliberately leaks the original field buffers rather than freeing/reallocating them (avoids guessing an unconfirmed capacity-field offset — `BuildTrackScenePath` only reads these fields, never frees them). + - Built via `./gradlew :app:assembleDebug` (JDK 17), installed on the Pixel 6a (GrapheneOS, real ARM hardware — see `reference-pixel6a-grapheneos-testing` memory) via `adb install -r`, launched via the package's default-launcher intent (dismissing GrapheneOS's deprecated-32-bit-ABI dialog via `dumpsys window`/screenshot/tap, not by waiting), navigated the real menu UI (map → event → car select → confirm) to trigger an actual race load. +- **Why**: Direct continuation of subtask 1 (arbitrary track loading) — this is the concrete hook point identified in `ANALYSIS.md` §6j, now implemented and tested end-to-end on real hardware for the first time this project (WayDroid never got far enough to reach a race load). +- **Snapshot**: N/A — runtime hook only, no on-disk `libapp.so` patch. `main.cpp` changes tracked in the `launcher` git repo. +- **Verification / outcome**: Hook installs cleanly every time (`mpcore_log`: `Installed RaceLoaderTask_BuildTrackScenePath hook at , trampoline=` — address arithmetic checked against the logged `libapp_base` and matches exactly). Fires correctly on a real race load (`BuildTrackScenePath hook fired: track -> region3_colorado_track2, env -> colorado`), and the engine genuinely starts loading Colorado-region assets instead of the real event's track (`Add asset: .../texture_collidables_colorado.sba`) — **the field-pointer override reaches the engine's real path-construction logic, proving the hook mechanism itself is correct**. However, ~150ms later the process crashes: `Fatal signal 11 (SIGSEGV) at fault addr 0x38`, `Cause: null pointer dereference`, in a background thread (`Thread-9`). Decompiling the exact crash site (tombstone `pc - libapp_base = 0x53a604`) identified it as a generic transform/bounds getter (`a1[14]`, i.e. byte offset `0x38`, dereferenced on a NULL `a1`) called from 21 different sites across physics/rendering component code — not track/environment-specific. Adding the environment-field override (second test) did **not** change the crash (identical fault address and code offset both times), ruling out a simple track/environment name mismatch as the cause. Working theory (evidence-backed, not yet independently traced further): other `RaceDefinition` fields (checkpoints, opponents, starting grid — populated separately, per `RaceLoaderTask_LoadRaceFSM`'s stage in §6j) still reference the **original** event's objects, which don't exist in the substituted scene; a background thread walking one of these dangling references hits the crash. Confirmed this is a regression from the hook specifically (not a pre-existing device/build issue): the same event ("Петерсон стрит") was played to a clean, crash-free finish on this same device earlier the same session with the unmodified build (see the "Так, это все прекрасно" verification below/earlier this session). + - Also verified basic device-testing hygiene requested by the user: launched, force-killed (`kill -9`), and cleanly exited (in-game exit button → confirmation dialog → `System.exit status: 0`) the app multiple times this session with no leftover tombstones from the kill/clean-exit paths (only from the hook-induced SIGSEGV above) — confirms the GrapheneOS deprecated-ABI dialog dismissal and process-lifecycle understanding are solid. +- **Next**: extend the hook to also intercept/rewrite whatever supplies checkpoint and opponent-placement data (`RaceLoaderTask_ResetStartingLine`, `RaceLoaderTask_LoadRaceFSM`) so those resolve against the substituted track's own data instead of the original event's — full detail and the decompiled crash-site evidence in `ANALYSIS.md` §6k. + +--- + +## 2026-08-04 — Tried debugger attach (blocked by device sleep/Doze, not RE), added `Log()` diagnostics instead — confirmed the race-FSM is track-agnostic + +- **What**: Two parts, same goal (understand the §6k crash without more blind guessing): + 1. **Debugger attempt**: pushed a version-matched `lldb-server` (18.0.1, exact revision-hash match to the NDK 27 host client) to the Pixel 6a, attempted `platform`- and `gdbserver`-mode attach to the live game process via `adb forward` + `lldb`. Handshake never completed despite confirmed-listening server, matched versions, and working raw TCP connectivity. Root cause: the device's screen locked mid-session (fingerprint lock), and `adb shell`/`su` round-trips became wildly inconsistent while locked (34ms to 300s+, no clear pattern) — most likely Android Doze/screen-off throttling. Confirmed via direct `time adb shell echo` timing tests and cross-checking against a second connected device (which had simply disconnected, ruling out a general adb-server problem). After the user unlocked the device, responsiveness returned to normal, but the debugger session needed re-establishing — deprioritized in favor of the lighter-weight approach below per user's direction. Not a dead end, just not finished this round; see `ANALYSIS.md` §6l for exact resumption notes (keep the device unlocked/awake next time). + 2. **`Log()` diagnostics**: added temporary logging to `Hook_BuildTrackScenePath` (`launcher/mpcore/src/main/cpp/main.cpp`) dumping the original (pre-override) track name, the `raceDef+40/44` field, and — critically — `*(raceDef+12)+48/52`, the nested field that supplies the race-FSM prefab name (`sub_2A9338`/`RaceLoaderTask_LoadRaceFSM` builds `"/published/prefabs/racefsms/{0}.prefabs.sb"` from it). Rebuilt, reinstalled, relaunched (redoing the GrapheneOS deprecated-ABI dialog dismissal), played the same "Петерсон стрит" event to trigger the hook. +- **Why**: Direct continuation of diagnosing the §6k SIGSEGV — needed to know whether the checkpoint/rules data is tied to the original track (supporting the §6k theory) or something else entirely, without committing to the slower debugger path if a couple of log lines could answer it. +- **Snapshot**: N/A — runtime hook + temporary diagnostic logging only, no on-disk `libapp.so` patch. +- **Verification / outcome**: Live log on the real event (values captured *before* our override overwrites them): + ``` + diag: original track name = 'region1_foothills_track4' (len=24) + diag: raceDef+40/44 field = '' (len=0) + diag: racefsm name = 'point_to_point_fsm_newintro' (len=27) + ``` + Confirms `point_to_point_fsm_newintro` is a **generic, track-agnostic race-type template** (not per-track), refining the §6k theory: the crash isn't from loading the wrong FSM, it's from that generic FSM's checkpoint/actor lookups failing against the substituted `region3_colorado_track2` scene. A quick `strings` diff of both tracks' `.scene.sb` files for checkpoint-related tokens found identical type/prefab names in both (`Checkpoint`, `start`, `finish`) but couldn't distinguish instance counts/IDs (needs a real SB unpack, not just `strings`). +- **Next**: unpack both `.scene.sb` files via `NFSMW12MobileTools` and diff their checkpoint actor lists/counts (fast, no binary RE) to confirm the mismatch directly; or resume the debugger attempt (mechanics are proven correct, just needs the device to stay unlocked) for a live look at the exact null lookup. Full detail in `ANALYSIS.md` §6l. + +--- + +## 2026-08-04 — Root cause of the §6k SIGSEGV confirmed: checkpoint-container name AND count both mismatch (no debugger needed) + +- **What**: Unpacked both `.scene.sb` files via `NFSMW12MobileTools.jar unpack` (jar + `lib/` + `HCStructFileArray.json` copied into a scratch working directory) and walked their actor `DataIdsMap` structures directly: + - `region1_foothills_track4.scene.sb` (the real track behind "Петерсон стрит", per §6l's live-logged `original track name`): full unpack succeeded. Checkpoint container is named **`checkpoints_timetrial_event_2`** (tied to a specific numbered event instance) holding **6** checkpoints (`timetrialcheckpoint` … `_6`). + - `region3_colorado_track2.scene.sb` (our override target): the unpacker's full DATA-object parser crashes on this specific file (`NegativeArraySizeException`, a pre-existing bug in the community tool, unrelated to our work) — worked around with `-disableDATAObjectsUnpack`, which still parses the CDAT string table. Checkpoint container is named **`timetrial_checkpoints`** (generic, no event-number suffix — structurally different name) holding **8** checkpoints (`timetrialcheckpoint` … `_8`). + - Cross-checked against a sibling track, `region3_colorado_track1.scene.sb` (unpacks fully with no workaround needed): same generic container-naming convention, but yet another checkpoint count (7) — confirms colorado tracks generally use a different, generic naming scheme than event-specific foothills containers, and that counts vary freely per track. +- **Why**: Direct continuation of diagnosing the §6k crash after the `Log()`-based diagnostics (previous entry) narrowed it down to the checkpoint/actor lookup inside the generic `point_to_point_fsm_newintro` race-FSM. This was the fast, no-binary-RE way to get a definitive answer, avoiding the debugger environment issues from earlier the same day. +- **Snapshot**: N/A — read-only analysis of `game_cache/published/prefabs/tracks/*.scene.sb` via a third-party unpacker; no source or binary changes. +- **Verification / outcome**: **Root cause confirmed directly, not just theorized.** The generic FSM looks for a checkpoint container by the *original* event's name/count (`checkpoints_timetrial_event_2`, 6 checkpoints) inside whatever scene is loaded. Our hook substitutes `region3_colorado_track2`, which has no actor named `checkpoints_timetrial_event_2` at all (its container is `timetrial_checkpoints`) and has 8 checkpoints, not 6. The lookup returns NULL, and a background thread walking a nonexistent checkpoint/container dereferences it unchecked — matches the tombstone (`r0=NULL`, `fault addr 0x38`) exactly. +- **Next**: extend the hook to also override the checkpoint-container-name field (not yet located precisely — likely a sibling field on the same nested struct as the racefsm-name field found in §6l, at `*(raceDef+12)+48/52`) with the substituted track's real container name. This is the same low-risk string-pointer-repoint pattern already proven for track/environment names. Full detail, including the two candidate fix directions, in `ANALYSIS.md` §6m. + +--- + +## 2026-08-05 — `region3`/colorado confirmed cut content; switched to `region4_chicago_track4`; found the real bug and got a live successful race + +- **What**: Continued the §6m whack-a-mole one step further (patched `sub_53A5FC`, `sub_52A9B8`, `sub_52A620` with defensive ARM-mode null-guards as each new crash site appeared), then stopped to check the underlying assumption rather than patch indefinitely: + - Confirmed `game_cache/published/models/environments/` has no `colorado/` folder (only `chicago`/`desert`/`foothills`/`garage`/`newyork`), and `prefabs/environments/` has only one un-numbered `colorado.prefabs.sb` where every other region ships numbered `1..6.prefabs.sb`. Unpacked `region3_colorado_track2.scene.sb` and confirmed it references a loose `.m3g` model path under `models/environments/colorado/` that was never shipped. **region3/colorado is unfinished/cut content** — no downstream patch can fix a track whose geometry cannot load. + - Reverted all 4 defensive null-guard patches (no longer needed) and switched `Hook_BuildTrackScenePath`'s override target in `launcher/mpcore/src/main/cpp/main.cpp` to `region4_chicago_track4` (a confirmed-shipped, confirmed-playable track per §6). First attempt used `kOverrideEnvName = "chicago"` — wrong, hit `Could not open database at .../chicago.prefabs.sb` (environment prefabs are numbered per-track like every other complete region); fixed to `"chicago4"`. + - With real geometry now loading, hit a new crash: `GetComponent()` (`sub_870E8`) called on a NULL entity from inside the already-named `RaceLoaderTask_ResetStartingLine` — a genuine missing-null-check engine bug (every other access in that function null-checks; this one doesn't). Added temporary diagnostics dumping `raceDef+164/180/196/212` (`m_StartLine`/`m_FinishLine`/`m_EndOfTrack`/checkpoint-container name fields) and found `m_FinishLine`/checkpoints hold **per-event custom actor names** (`event_02_finish`, `checkpoints_timetrial_event_2`) for the time-trial "Петерсон стрит" event, while a regular point-to-point event ("Побудка", Macklein) has only generic names (`start`/`finish`/`end_of_track`/empty). + - Bought the "Каждый день"-class car required for "Побудка" (12 000$ in-game currency, user-confirmed) and played it with `region4_chicago_track4`/`chicago4` substituted in. +- **Why**: Direct continuation of subtask 1 validation — needed to know whether the §6k/§6m crash chain was a fixable data bug or something structural, before sinking more effort into defensive patches. +- **Snapshot**: `main.cpp` now has only `Hook_BuildTrackScenePath` installed (the 4 defensive null-guards were added and then removed in the same session — net diff from §6m's committed state is just the hook's target track/env strings and the diagnostic dump, no new permanent hooks). +- **Verification / outcome**: **Live, sustained, successful race** — "Побудка" loaded and ran on `region4_chicago_track4`'s substituted geometry: correct HUD (position 6/6, timer counting up), AI opponents visible and racing, no crash over multiple seconds of observation. Confirmed via `adb shell pidof` staying alive plus a live gameplay screenshot. This is the first time this project has gotten a substituted-track race to actually run, not just load-then-crash. +- **Next**: decide subtask-1 scope for time-trial/checkpoint events specifically (either also override `raceDef+180`/`+212` to generic names when substituting, or restrict arbitrary-track substitution to regular-race event types and leave time-trial events pinned to their original track) — not yet implemented, a design decision rather than a bug. Full trace, exact field values, and both candidate fixes in `ANALYSIS.md` §6n. + +--- + +## 2026-08-05 (cont.) — Cleaned up diagnostics, visually confirmed track substitution with a baseline A/B comparison + +- **What**: Removed the temporary `raceDef+40/44`/`+12`/`+164..212` diagnostic `Log()` dump added earlier this session (its job was done — findings are recorded in `ANALYSIS.md` §6n). `main.cpp` now has only the clean `Hook_BuildTrackScenePath` override (track/env name repoint) and no defensive null-guards, no diagnostic logging. Then, per user request, temporarily commented out the `InstallBuildTrackScenePathHook()` call, rebuilt/reinstalled, and replayed the same "Побудка" event (regular race, Macklein) to capture a baseline screenshot of the *real*, unmodified track - then re-enabled the hook, rebuilt/reinstalled again. +- **Why**: User wanted direct visual proof (not just logcat text) that the hook genuinely substitutes the track geometry, not just the on-screen labels. +- **Snapshot**: `main.cpp` end state = clean hook only, actively installed (same as the successful §6n test, minus the diagnostic logging). +- **Verification / outcome**: **Unambiguous visual confirmation.** With the hook active, "Побудка" loads a daytime highway/overpass scene with road signs reading "South 92", "McClane", "Ripley's Point" (chicago4-specific signage). With the hook disabled (baseline), the same event loads a completely different nighttime downtown street scene - a "HOTEL" building, road signs "Franklin Plaza / Rochelle Hall" and "Emerson Greenway", different guardrail/road geometry (the original `region5_newyork_track2`). Different time-of-day, different geometry, different signage, different lighting - conclusively the same event now runs on genuinely different track geometry depending on the hook, not a coincidence or cached state. +- **Next**: none outstanding for subtask 1's regular-race case - the hook is validated end-to-end, visually and via logs. Time-trial/opponent-race scope decision remains open per `[[track-substitution-scope]]` memory (out of scope per user direction, not pursued further). + +--- + +## 2026-08-05 (cont. 2) — Added a toggle flag for on/off comparisons; documented the hook as a reference in `ARCHITECTURE.md` + +- **What**: Per user request, replaced the manual "comment out `InstallBuildTrackScenePathHook()`, rebuild, reinstall" workflow with a single `constexpr bool kEnableTrackSubstitutionHook` in `main.cpp`, checked in `JNI_OnLoad` before installing the hook — flip the bool, rebuild, reinstall, no other edits needed. Done on a new branch (`launcher` repo, branch `track-hook-toggle-flag`, off `master`), committed as `d8a1f34` — kept scoped to only `mpcore/src/main/cpp/main.cpp` (the `launcher` working tree had several pre-existing uncommitted/staged files from earlier unrelated work - `.idea/*`, `AndroidManifest.xml`, `settings.gradle.kts`, a new `savepath.xml` - left untouched, not part of this change). Also added `ARCHITECTURE.md` §3a, a consolidated reference doc for this hook (struct offsets table, trampoline mechanics, the environment-naming gotcha, the toggle flag, and the regular-races-only limitation) - distinct from `ANALYSIS.md`'s chronological investigation log, meant to be the go-to doc for extending or reusing this hook pattern. +- **Why**: User wanted to iterate on before/after comparisons themselves (flip flag, rebuild, look) without spending a full agent turn per comparison; also wanted the by-now-substantial tribal knowledge about this hook consolidated somewhere reference-shaped rather than only spread across `ANALYSIS.md`'s per-session log entries. +- **Snapshot**: `launcher` branch `track-hook-toggle-flag`, commit `d8a1f34`. Not merged to `master` yet - user hasn't asked for that. +- **Verification**: Rebuilt after the flag was added (`kEnableTrackSubstitutionHook = true`) — compiles clean, user confirmed live on-device it "works great" (Fairphone 5). +- **Next**: none outstanding. If/when the user wants this merged to `master` or wants a similar toggle for future hooks (opponent list, coordinate sync), follow the same pattern. + +--- + +## 2026-08-05 (cont. 3) — Investigated street-event data model and the file-loading theory (read-only, no binary/code changes) + +- **What**: Unpacked all 62 `game_cache/published/data/races/event_*.prefabs.sb` files and `flow/menus/map_overworld.sb` via `NFSMW12MobileTools` to check whether a street's event list (e.g. "МАККЛЕЙН" → "ПОБУДКА") is simple, editable SB data. Then traced the engine's actual file-open call chain in IDA, starting from the generic SB property-accessor functions down through `sub_8604A0` → `sub_8598A4(path, mode) -> FILE*` → `j_fopen` → libc `fopen`. +- **Why**: User asked (a) whether a virtual "LAN: " entry could be added to an existing street's event list, and (b) whether SB file contents could be overridden entirely from `mpcore`/`JNI_OnLoad` in memory, without ever touching `game_cache` or patching the binary on disk. +- **Snapshot**: N/A — read-only IDA analysis and third-party-tool unpacking only, no source or binary changes. +- **Verification / outcome**: + - (a) **Not a quick data-only win.** `RaceEvent`'s `Location` field only holds 4 coarse region values (Chicago/Foothills/NewYork/Desert), not the street-level label shown on the map. `map_overworld.sb`'s Flow script contains no event-ID lists or street data. The street/POI clustering system wasn't found in any SB data checked - likely computed natively at runtime, needs further RE. + - (b) **Theory confirmed correct.** `sub_8598A4` (`0x8598a4`) is a clean `(path, mode) -> FILE*` function with only 4 callers in the whole binary, itself calling a `j_fopen` thunk with only 4 callers - a tight, single chokepoint for engine file opens. Hooking it and returning an `fmemopen()`-backed `FILE*` for specific virtual paths (falling through to the real `fopen` otherwise) would let any SB file's *content* be overridden purely in `mpcore` memory, with zero footprint on `native_lib`/`game_cache`/the OBB. +- **Next**: not implemented yet - this is a substantial, general-purpose capability (any asset override, not just track substitution) rather than a small tweak, flagged for a deliberate decision before building it. If pursued: install a trampoline hook on `sub_8598A4` (first-instruction relocatability not yet checked), inspect its 3 other callers (`sub_8BBD94`/`sub_8D6D9C`/`sub_8FC64C`), and prove it out on one already-understood file (e.g. an `event_*.prefabs.sb`) before generalizing. Full detail in `ANALYSIS.md` §6p. + +--- + +## 2026-08-05 (cont. 4) — Implemented and tested the `sub_8598A4` file-open hook: mechanism works, wrong chokepoint for game data + +- **What**: New branch `file-open-hook-poc` (based on `track-hook-toggle-flag` - `master` is a stale, unrelated baseline, confirmed this session). Added `Hook_sub_8598A4`/`InstallSub8598A4Hook` to `main.cpp` (trampoline hook, same PUSH/SUB pattern as `BuildTrackScenePath`), logging every `(path, mode)` and substituting an `fmemopen()` buffer for any path containing `"event_02_timetrial"`. Prepared the substitute payload via `NFSMW12MobileTools`: unpacked `event_02_timetrial.prefabs.sb`, edited `CashReward.Gold/Silver/Bronze` `10500/8500/7000` → `99999/88888/77777`, repacked, embedded as a byte array (`mpcore/src/main/cpp/test_event_02_data.h`). Gated behind a new `kEnableFileOpenHook` toggle flag (same pattern as the existing `kEnableTrackSubstitutionHook`). Built, installed, launched on the Fairphone 5, navigated map → "Петерсон стрит" → "На время" event card (the screen that displays those exact CashReward values). +- **Why**: Direct implementation of the plan from `ANALYSIS.md` §6p, at the user's explicit request to implement and test the file-open hook on `event_02_timetrial` specifically. +- **Snapshot**: `launcher` branch `file-open-hook-poc`, built but not yet committed (working tree only) - implementation is a scaffold to be corrected before committing, per the finding below. +- **Verification / outcome**: **Hook mechanism confirmed working** - logcat shows it genuinely intercepting a real engine `fopen()` call (`/home/ogami/output-arm/openssl.cnf` at startup). **Wrong target function** - `sub_8598A4` was never called again after that one OpenSSL open; the "На время" event card still showed the original `10 500$/8 500$/7 000$`, unchanged. Checked the other 2 named callers of the underlying `j_fopen` thunk: neither is a general asset opener either (`sub_859528` = stream-command dispatcher, `sub_8AC1F4` = a file-hashing/checksum utility). Revised theory: game assets are likely served from a single startup-time mmap'd bundle via an in-memory name→pointer lookup, not per-file `fopen` calls at all - not yet located. +- **Next**: trace the `sub_3FB6E4` ("Mounting SKU") mount-table path or the 11 other `open()` (not `fopen`) call sites in the binary to find the real per-asset data-resolution function, then repoint the existing hook scaffold (trampoline + path-match + `fmemopen` substitution logic all reusable as-is) at it. Full detail in `ANALYSIS.md` §6q. + +--- + +## 2026-08-05 (cont. 5) — Found the real VFS path resolver; redirected toward runtime object injection per user clarification (read-only, no code changes) + +- **What**: Traced `sub_208C88` (RaceEvent loader) → `sub_6753FC` → `sub_4F0138` → a virtual call on a VFS singleton (`sub_40E8E8()`, vtable `off_AB2084`, confirmed via `"VFS::AddVariant("` string) → resolved vtable slot+8 by reading raw bytes → `sub_410808`, confirmed as `VFS::OpenInputStream` via its own strings and by reproducing the exact `"Could not open database at "` error seen live in §6n. This is the real, universal per-asset file resolver (not `fopen`-based - §6q's hook target was a red herring). User then clarified the actual goal is runtime injection into already-parsed game objects (e.g. add a synthetic map event after the real list loads), not file-content substitution before load - redirected the investigation accordingly. Searched RTTI and found `im::app::flow::nfs::MapScreen` (map screen controller) and `im::app::ui::MapTrack` (likely the per-event marker object, appears as a `boost::shared_ptr` argument in one of `MapScreen`'s methods). Also checked `career.prefabs.sb` as a possible data-only source for street groupings - ruled out, it only holds progression tiers and car unlocks. +- **Why**: Direct continuation of "find the real function," redirected mid-investigation by explicit user clarification of the actual end goal (runtime injection, not load-time file swap). +- **Snapshot**: N/A - read-only IDA analysis and SB unpacking only. +- **Verification**: `sub_410808`'s identity as `VFS::OpenInputStream` is strongly evidenced (own literal strings + exact error-string match to a previously-observed live crash), not a guess. `MapScreen`/`MapTrack` are RTTI-confirmed class names, not yet confirmed as the actual injection point. +- **Next**: find where `MapScreen` builds/holds its `MapTrack` collection and `MapTrack`'s field layout, then apply the same "hook after real population, inject a synthetic entry" technique already proven for `RaceDefinition` fields. Full detail in `ANALYSIS.md` §6r. + +--- + +## 2026-08-06 — Investigated runtime (in-memory) injection into a street's event list; found MapScreen/MapTrack/MapTrackEventList, not yet the exact add-card call + +- **What**: Continued the file-open-hook investigation (§6p), then pivoted per explicit user feedback away from file/VFS-content substitution toward a purely in-memory, post-load UI injection approach (add a synthetic "LAN: " event card to an existing street's event list at runtime, no disk/OBB/game_cache changes at all). Read-only IDA RE only, no code/binary changes this entry. + - Confirmed via RTTI: `im::app::flow::nfs::MapScreen` (world-map screen), `im::app::ui::MapTrack` (one pin per street), `im::app::ui::MapTrackEventList` (the event-card list widget). + - Found `MapScreen`'s giant setup function `sub_1781BC` (~8.7KB, largest function found this project) - confirmed it looks up a **single, fixed-name `"event_list"` widget** (via a generic `FindOrCreateLayoutEntity(scene, name)` helper, `sub_17A4CC`) rather than one list per street, meaning the widget is repopulated per-pin-click, not pre-populated N times. + - Found 2 `Node -> MapTrack` `dynamic_cast` loops and a `"TrackName"` property-lookup inside the same function, but the exact call that matches `RaceEvent.TrackName` against a clicked pin's `TrackId` and adds a card to `"event_list"` was not isolated - the function is too large to decompile through the MCP tool in one call (truncates before reaching it), and windowed disassembly reading doesn't scale well at this size. +- **Why**: Direct continuation of the user's street-event-injection question, redirected from the file-content-substitution approach (§6p) after the user clarified they specifically want runtime/in-memory injection, not disk-based substitution. +- **Snapshot**: N/A - read-only IDA analysis, no source or binary changes. +- **Verification**: N/A - investigation only, nothing implemented or tested yet. +- **Next**: recommended pivoting to live on-device debugging (lldb-server setup already proven working on the Fairphone 5 earlier this session, per the 2026-08-04 entries) to directly observe the add-card call via a breakpoint/watchpoint while manually selecting a street pin in the running game, rather than continuing to guess through raw disassembly of the remaining ~7KB of `sub_1781BC`. Full detail in `ANALYSIS.md` §6q. + +--- + +## 2026-08-06 (cont.) — Live debugger attempt on Fairphone 5: reproducibly blocked, same failure as the 2026-08-04 attempt + +- **What**: Per user's explicit choice, attempted live debugging to find the exact "add event card" call in `MapScreen::sub_1781BC` (§6q). Pushed NDK 27's ARM32 `lldb-server` (18.0.1) to `/data/local/tmp/`, matched with the same-NDK host `lldb` client (`LD_LIBRARY_PATH` pointing at the NDK's bundled `python3/lib`). Used direct `gdbserver --attach *:5039` mode (not `platform`, avoiding the known zombie-child issue from 2026-08-04) via `adb forward tcp:5039 tcp:5039`, connected from the host with `gdb-remote 127.0.0.1:5039`. +- **Why**: Direct continuation of §6q - static disassembly of the 8.7KB `sub_1781BC` stalled before finding the exact per-street event-card-add call; the user chose live debugging over continued blind static reading. +- **Snapshot**: N/A - debugging session only, no source or binary changes. Game process needed relaunching afterward (killed when the debug connection tore down). +- **Verification / outcome**: **Reproducibly blocked by the same issue as the 2026-08-04 attempt**, this time isolated to a specific root cause: the on-device `lldb-server` hits an internal assertion (`GetMaxU64 invalid byte_size!`, `DataExtractor.cpp:527`) while enumerating thread/register state on every connection - non-fatal (the process doesn't crash and 37 threads list correctly with real names), but it leaves `PC` unresolvable for every thread (`frame #0: 0xffffffffffffffff`), matching the exact symptom documented on 2026-08-04. With a breakpoint set before `continue`, the client hangs indefinitely (breakpoint verification needs a working PC/register context that's already corrupted). Confirms this is a persistent bug in this specific `lldb-server` build (NDK 27, ARM32 target) against this device combination, not a one-off fluke - not something fixable by retrying the same tool. +- **Next**: live debugging is not viable with currently available tooling on this device/toolchain combination. Reverting to continued static IDA disassembly of `sub_1781BC`'s remaining unexplored ~7KB (the other option from the user's decision menu) to find the event-card-add call. A different NDK/lldb-server version, or a different device, would be needed to make live debugging viable - not pursued further this session. + +--- + +## 2026-08-06 (cont. 2) — Tried Frida as an alternative to lldb; also blocked (injection crashes the target) + +- **What**: After the `lldb`/`lldb-server` route hit a reproducible internal assertion bug (previous entry), tried Frida as a fundamentally different instrumentation approach (JS-bridge based, not raw GDB-remote protocol - different failure surface). Installed `frida`/`frida-tools` 17.17.0 into a fresh Python venv on the host, downloaded and pushed the matching `frida-server` (android-arm64, since the Fairphone 5's native ABI is `arm64-v8a` even though the target game process itself is `armeabi-v7a`) to `/data/local/tmp/`, ran it as root. Confirmed basic connectivity (`frida-ps -U` listed all device processes correctly). Attempted to attach to the running game process (both via `-f` spawn, which hit an unrelated Android `ActivityManager` start-timeout kill, and via `-p ` attach to an already-running instance, launched normally via `monkey`). +- **Why**: Direct continuation of the live-debugging attempt (previous entry) - Frida was tried specifically because its injection/instrumentation mechanism is architecturally different from `lldb-server`'s raw GDB-remote-protocol register enumeration, so it doesn't necessarily hit the same class of bug. +- **Snapshot**: N/A - debugging attempt only, no source or binary changes. +- **Verification / outcome**: **Also blocked, for a different reason.** `frida -U -p -l script.js` reliably reports `Failed to attach: agent connection closed unexpectedly`, and `logcat` confirms why: the target process is killed by the kernel with `signal 11 (Segmentation fault)` (`Zygote: Process exited due to signal 11`) at the exact moment of the attach attempt, every time. This is Frida's own agent injection crashing the process, not a transient issue - reproduced twice with fresh process launches. Root cause not diagnosed further (candidates: 32-bit ARM target + 64-bit `frida-server` cross-arch injection incompatibility specific to this NDK/toolchain combination, or some interaction with this build's own native init code) - not worth deeper investigation given the pattern (two independent, architecturally-different dynamic instrumentation tools both fail against this exact target) suggests an environment-level obstacle rather than a tool-specific bug worth working around by trying a third tool. +- **Next**: dynamic/live analysis is not viable in this environment for now (two different tools tried, two different failure modes, both blocking). Continuing with static IDA analysis of `sub_1781BC` as the only remaining viable path for finding the exact event-card-add call (§6q) - or, if the user wants to keep investigating dynamic instrumentation, it would need a different host/device/toolchain combination than what's available in this session. + +--- + +## 2026-08-06 (cont. 3) — Found the real cause of the debugger failures (ARM32 lldb-server bug, not anti-debug) and a working cross-arch fix + +- **What**: Per user's instinct ("может это защита?"), ran a control experiment instead of guessing: attached the same NDK's ARM32 `lldb-server` to a harmless system process (`systemui`, pid unrelated to the game) - hit the *identical* `GetMaxU64 invalid byte_size!` assertion and unresolvable-PC symptom seen against the game. This proved the bug is generic to the ARM32 `lldb-server` build itself, not anti-debug in the game. Then tested the NDK's **AArch64** `lldb-server` against that same system process - worked perfectly (correct PC, disassembly, no assertion). Finally tested whether the AArch64 `lldb-server` can also correctly trace the 32-bit game process (cross-arch ptrace) - **it can**, cleanly, with real PC values and correct ARM32 disassembly. Used this fixed setup to breakpoint `sub_1781BC`'s `"event_list"` widget-lookup return point (§6s) and observed a street-pin tap live - the breakpoint never fired across two separate taps, even though the event-card UI rendered correctly both times. +- **Why**: Direct continuation of the §6q/§6s MapScreen investigation - needed working live debugging to find the exact per-click "populate event list" call after static disassembly of the 8.7KB `sub_1781BC` stalled, and the user's suggestion to investigate *why* the debugger was failing (rather than giving up on debugging entirely) led directly to the fix. +- **Snapshot**: N/A - debugging only, no source or binary changes. +- **Verification**: Reproduced the AArch64-vs-ARM32 `lldb-server` distinction twice (control process, then the actual game process), and reproduced the breakpoint non-hit twice (initial tap, then back-out-and-retap) with screenshots confirming the game rendered the event list correctly both times (not stalled/crashed - genuinely never executed that code path). +- **Next**: **reusable finding for all future debugging on this device**: always use the AArch64 `lldb-server` binary (not ARM32), pointed at the target process regardless of the target's own bitness - full recipe in `ANALYSIS.md` §6t. Next concrete step: breakpoint `sub_17A4CC` itself (the generic named-widget-lookup helper) and inspect `r2`/`lr` on each hit while tapping a street pin, to find the actual click-handler function via whichever widget lookup (if any) it performs - not yet done due to session time, high-confidence this will work now that the tooling itself is fixed. Full detail in `ANALYSIS.md` §6t. + +--- + +## 2026-08-06 (cont. 4) — Two more negative debugging results + a likely-important UI structure observation + +- **What**: Continued live debugging (§6t's fixed setup). Breakpointed `sub_17A4CC` (generic widget-lookup-by-name) at its own entry, repeated the tap-a-pin sequence - never fired. Statically traced `sub_7D2E8`'s only 2 callers in the whole binary (`sub_208C88` and a generic prefab-loader `sub_7CE58`) - both are load-time-only, not click-time. Also noticed, from screenshots taken across this whole session, that every event-list screen shown always displays exactly 3 card slots (1 real + 2 locked placeholders), regardless of which street. +- **Why**: Direct continuation of the search for the per-click "populate event list" call (§6s/§6t) - each negative result narrows down what the click handler does *not* do (no widget re-lookup, no fresh RaceEvent load), converging on a much simpler theory than originally assumed. +- **Snapshot**: N/A - debugging/analysis only, no source or binary changes. +- **Verification**: Both breakpoints confirmed correctly set (via `lldb` output) and confirmed not hit despite the UI visibly updating correctly each time (screenshots) - a real negative result, not a broken breakpoint. +- **Next**: test whether the "always 3 slots" pattern holds for a street with more than 1 unlocked real event (would confirm a fixed-size pre-authored slot pool rather than dynamic child insertion) - if confirmed, the injection strategy simplifies to "populate one of the existing locked slots with synthetic data" rather than "find an AddChild call". Full detail in `ANALYSIS.md` §6u. + +--- + +## 2026-08-06 (cont. 5) — Correction: breakpoints don't actually work in the cross-arch lldb setup; earlier negative results retracted + +- **What**: The user directly questioned the two prior entries' evidence ("клик НЕ вызывает загрузку... доказательств мало", "уверен что в правильном месте поставил точку останова?"). Ran the right test instead of re-asserting: breakpointed `j_malloc_0` (called continuously, guaranteed to fire within milliseconds under normal operation) through the exact same cross-arch AArch64-`lldb-server`-on-32-bit-target setup used for the prior "negative results" - it never fired in 15 seconds of active gameplay. Tried a hardware breakpoint as a fallback - it failed outright with an explicit `lldb` error (`hardware breakpoint resources might be exhausted or unavailable`). +- **Why**: Direct response to the user's correct skepticism - the prior entries (this same day) claimed several functions were "never called" based on breakpoints not firing, without ever validating that breakpoints could fire *at all* in this specific tooling configuration. That validation was missing and needed to happen before trusting any negative result from it. +- **Snapshot**: N/A - debugging/documentation correction only, no source or binary changes. +- **Verification**: The malloc sanity check and the hardware-breakpoint fallback were both run live against the actual running game process, not simulated - both failed to insert a working breakpoint. +- **Outcome**: **This cross-arch debugging setup can attach and passively inspect (registers, memory, disassembly at whatever point the process is already stopped) but cannot actually insert working breakpoints, software or hardware.** All "function X is never called on click" claims from the two prior entries this day are retracted as unverified, not confirmed-false - the underlying static-analysis-based theories (`RaceEvent` loaded once at startup; the map's event list is a fixed 3-slot pool) are unaffected since they never depended on the broken breakpoint evidence, just no longer have live-debugging support behind them. +- **Next**: either find a debugging path where breakpoints genuinely work (untried: `PTRACE_SINGLESTEP`-based stepping instead of breakpoints; the user's own previously-working IDA GUI + `android_server32` remote debugger, which requires manual GUI interaction not available through the current MCP tooling) or continue with static IDA analysis only, treating live debugging as inspection-only for now. Full detail in `ANALYSIS.md` §6t. + +--- + +## 2026-08-06 (cont. 6) — Tried real GDB + genuine ARM32 gdbserver; also can't sustain a breakpoint (different failure mode, same practical outcome) + +- **What**: Following the user's question ("а gdb работает?"), installed `gdb-multiarch` without root (`apt-get download` + `dpkg-deb -x`, no `sudo` needed). It failed to talk to the existing AArch64 `lldb-server` at the wire-protocol level (`Invalid hex digit 59`) - a genuine LLDB/GDB remote-protocol dialect mismatch, separate from the earlier cross-arch issue. Downloaded the full legacy `android-ndk-r16b` (852MB - partial/range-based extraction against `dl.google.com` failed first, forcing the full download) to get a **real, native ARM32 `gdbserver`** (Google dropped `gdbserver` from the NDK after r17). Attached it natively (no cross-arch tricks needed) to the game process and connected with `gdb-multiarch`. +- **Why**: Direct continuation of the user's question and the broader goal (§6t) of finding a debugging setup where breakpoints actually work, having ruled out the AArch64-cross-arch `lldb-server` combination. +- **Snapshot**: N/A - debugging/tooling investigation only, no source or binary changes. +- **Verification**: `interrupt` correctly stopped the live process and printed a genuine, correct backtrace (`syscall() <- libart.so`) - strictly better evidence of real control than either `lldb` path achieved. A breakpoint was accepted on `j_malloc_0`, but `continue` crashed `gdbserver` itself (game process unaffected, survived and kept running normally) - `gdb` reported `Remote connection closed`, no breakpoint hit was ever observed. +- **Outcome**: Real GDB + real native ARM32 gdbserver can attach and interrupt correctly, but still cannot sustain a `continue` past an installed breakpoint - a different failure (`gdbserver` process death, not a silent no-op) but the same practical result as the `lldb` attempts. Likely cause: ~7-year version skew between the 2017 `gdbserver` (last NDK release to include it) and the 2024 `gdb-multiarch` 15.1 client - protocol-handshake warnings observed in earlier connection attempts support this. +- **Next**: not yet tried - a period-matched legacy GDB client (own toolchain, not just swapping the server), or abandoning breakpoints entirely in favor of `PTRACE_SINGLESTEP`-based stepping. Until then, debugging in this project is inspection-only (attach/interrupt/read state), and anything requiring "does function X run when Y happens" needs static IDA analysis instead. Full detail in `ANALYSIS.md` §6t. + +--- + +## 2026-08-06 (cont. 7) — Root cause of all debugging failures identified: Fairphone 5 has no native 32-bit hardware + +- **What**: The user identified, via their own research, that the Fairphone 5's SoC has no native AArch32 hardware support - 32-bit code runs through a software translation/compat layer (like Intel's Houdini on x86 Chromebooks), which modern Qualcomm chips increasingly lack real hardware for. Documented this as the retroactive explanation for every debugging symptom hit this session (ARM32 `lldb-server`'s register-parsing assertion, hardware breakpoints being explicitly unavailable, software breakpoints installing but never firing, `gdbserver` crashing on `continue`) - no further tooling investigation needed, this isn't a bug to work around on this device. +- **Why**: Explains and closes out the entire multi-session debugging investigation (§6t) with a single, coherent root cause rather than a pile of tool-specific workarounds. +- **Snapshot**: N/A - documentation/conclusion only. +- **Verification**: User-provided research; consistent with every observed symptom (passive ptrace operations via kernel compat translation work fine; anything requiring the patched/native code stream to actually execute does not). +- **Next**: retry live breakpoint-based debugging on a device with genuine AArch32 hardware - the already-available Pixel 6a (GrapheneOS) is the nearest candidate; the user also plans to bring a Pixel 5a or 2018 Galaxy A9. Until then, per the user's explicit direction, continuing with static IDA analysis only. Full detail in `ANALYSIS.md` §6t. + +## 2026-08-06 (cont. 8) — Static analysis resumed: decoded MapScreen's click hit-test and per-slot lock refresh (no debugger needed) +- **What**: Per the user's "continue with statics" direction, went back into `sub_1781BC` (MapScreen setup/update function) using `func_profile`+`disasm` windows (the plain `callees` tool returned an empty list for this function for unclear reasons — `func_profile` with `include_lists: true` worked and returned all 46 real callees). Found two distinct property-reading blocks: Region A (`0x178afc-0x178e40`) is a straight-line "populate selected-event summary panel" read of TrackName/Completion/EventName/class_restriction/event_type from one object at `[MapScreen+0x120]`. Region B (`0x179a2c-0x179cfc`) is the real touch-click hit-test: iterates `map_scroll`'s children, `dynamic_cast`s each, finds the nearest one to the touch point, and for the winner **copies two fields straight off the MapTrack object (`+0xB8`/`+0xBC`) into `MapScreen+0x1C0/0x1C4`** — no VFS/prefab-load call anywhere in this path. Region C (`0x179d30-0x179dfc`) walks a linked list of pre-existing card-slot entities and pushes each one's "locked" property to a vtable setter, rather than adding/removing children. +- **Why**: This is the concrete evidence needed to unblock the user's "inject a synthetic LAN lobby event into an already-loaded street's list, at runtime, no file/OBB changes" goal — it identifies the exact mechanism (a `MapTrack` pin carries a pre-set data pointer at `+0xB8`; card slots are a fixed pre-existing pool toggled by a `locked` flag) rather than a dynamic add-child call. +- **Snapshot**: N/A — read-only static analysis, `libapp.so` untouched. +- **Verification**: Static disassembly, cross-checked against `xrefs_to` on the `MapTrack` RTTI typeinfo (`0xac6434`) confirming only 3 functions in the whole binary ever `dynamic_cast` to `MapTrack` (`sub_1781BC` itself, `sub_17C120` — unlock/tap-routing, strings "unlocking"/"NEXT_EVENT"/"RACE"/"BLACKMARKET" — and `sub_17FC6C` — fully decompiled, confirmed to be a "scroll map to named track" helper, not event attachment). +- **Outcome/Next**: The function that *writes* `MapTrack+0xB8/+0xBC` (i.e., attaches a RaceEvent to a pin at load time) is still not found — that's the real injection target. Next: check the third, unexamined `dynamic_cast` site in `sub_1781BC` (~`0x17a02c`), find `MapTrack`'s own constructor, and/or fully decompile `sub_17C120`. Full detail in `ANALYSIS.md` §6u. + +## 2026-08-06 (cont. 9) — Ruled out both other MapTrack-touching functions; RTTI-xref search for the "attach event to pin" write site is a dead end +- **What**: Fully decompiled the other two functions in the binary that `dynamic_cast` to `MapTrack` (found via RTTI xrefs in cont. 8). `sub_17FC6C` = "scroll map camera to a named track" helper (auto-scroll-to-next-event). `sub_17C120` = `MapScreen`'s per-frame update/tick handler: touch-down dispatch (UIButton vs MapTrack), an "unlock reveal" animation state machine (reads/sets an `"unlocking"` property when a pending-unlock list drains), camera-scroll clamping, and a periodic QA-only "Soak Test" auto-race feature. Neither writes `MapTrack+0xB8/+0xBC`. +- **Why**: Needed to confirm/rule out whether either of these (the only other MapTrack-touching code in the whole binary) was the "attach RaceEvent to pin at load time" function — the actual target for the user's runtime-injection goal. +- **Snapshot**: N/A — read-only static analysis. +- **Verification**: Full Hex-Rays decompilation of both functions (both small enough to not hit the truncation limit that affects `sub_1781BC`). +- **Outcome/Next**: RTTI-xref searching for `MapTrack`-related code is exhausted as an approach — the real population site must get a statically-typed `MapTrack*` without needing `dynamic_cast`. Next: locate `MapTrack`'s own constructor via its vtable symbol near `_ZTIN2im3app2ui8MapTrackE` (`0xac6434`) in `.data.rel.ro`, same technique already proven for `RaceLoaderTask` (§6a), then follow xrefs to that. Full detail in `ANALYSIS.md` §6u. + +## 2026-08-06 (cont. 10) — FOUND the exact "attach RaceEvent to pin" function: MapTrack::AddEvent +- **What**: Pivoted away from the RTTI-xref dead end (cont. 9) to the proven §6a technique — found `MapTrack`'s real vtable (`_ZTVN2im3app2ui8MapTrackE`, `0xaabfdc`) via `entity_query` on names near its RTTI typeinfo, then its real constructor (`sub_368860`, confirms `+0xB8` starts zeroed) and factory (`sub_395B38`, called only from a 27KB class-registration bootstrap — a dead end on its own). The real answer came from the `"Couldn't find TrackId property on MapTrack widget "` error string, unique to one function: `sub_369040` = `MapTrack::RefreshEvents()`. It reads its own `TrackId`, looks up matching `RaceEvent`s in a registry, and calls `sub_369AB0` = **`MapTrack::AddEvent(RaceEventHandle*)`** for each match — which `push_back`s the event into a growable vector living directly on the pin (`MapTrack+0x240..0x248`), updates completion-percentage counters (3 medals/event), and sets lock/availability flags. `RefreshEvents` itself is triggered from `MapTrack::HandleEvent` (`sub_368DFC`) on the engine's `FlowSetLayoutScreenEvent` message — i.e. **each pin self-registers its own events when its screen finishes laying out**, a bottom-up per-pin pattern, not a top-down "MapScreen assigns events to pins" one. +- **Why**: This is the exact, concrete answer to the question this whole multi-session thread has been chasing — the real target for injecting a synthetic "LAN: " entry into an existing street's card list at runtime, with no file/OBB changes. +- **Snapshot**: N/A — read-only static analysis. +- **Verification**: Full Hex-Rays decompilation of the whole chain (`sub_368860`, `sub_3688D4`, `sub_395B38`, `sub_369040`, `sub_369AB0`, `sub_368DFC`, `sub_368FDC`), cross-checked via `xrefs_to` at each step (`AddEvent` has exactly one caller, confirming it's dedicated and safe to call directly). +- **Outcome/Next**: Implementation-ready target identified: hook or directly call `MapTrack::AddEvent` (`0x369AB0`) on an already-loaded pin with a synthetic event handle to make the engine's own bookkeeping (vector growth, completion %, lock flags) handle a fake "LAN lobby" entry exactly like a real one. Remaining unknown: the exact shape of the `RaceEventHandle` argument (looks like an ECS-style handle/ID, not a raw pointer — resolved through the same "component" indirection seen elsewhere in the binary, not a direct `RaceEvent*`). Full detail in `ANALYSIS.md` §6v. + +## 2026-08-06 (cont. 11) — Decoded AddEvent's handle format: a 32-bit FNV-1a-keyed resource cache; AddEvent doesn't touch the TrackId registry +- **What**: Decompiled `sub_173350` (called from `AddEvent`) and `sub_242778` (called from `RefreshEvents`, upstream). Found two independent, chained hash maps: `TrackId string --[FNV-1a keyed]--> vector` (sub_242778), then each `hash --[int keyed]--> refcounted RaceEvent-prefab-instance ptr` (sub_173350, same generic prefab-instance-cache system as `sub_7CE58` from §6s). Confirmed `AddEvent` only calls the second (hash→instance) map directly — it never touches the TrackId registry itself. +- **Why**: Needed the exact argument format `MapTrack::AddEvent` expects before it can be called with synthetic data — this determines what minimal fabrication is actually required for the injection. +- **Snapshot**: N/A — read-only static analysis. +- **Verification**: Full Hex-Rays decompilation of both functions; FNV-1a identification via its unmistakable literal constants (offset basis -2128831035, prime 16777619). +- **Outcome/Next**: Simplifies the injection plan — no need to touch the TrackId registry at all. Only requires: (1) one fabricated node in the prefab-instance cache (`sub_173350`'s hash map) under an unused ID, wrapping a fake `RaceEvent`-shaped object using the known SB field layout from §6p; (2) a direct call to `MapTrack::AddEvent(existingPin, &chosenId)`. Remaining unknowns: exact in-memory shape of a loaded `RaceEvent` prefab instance, and safe construction details for splicing a new hash-map node at runtime. Full detail in `ANALYSIS.md` §6w. + +## 2026-08-06 (cont. 12) — Confirmed cache-node value = real RaceEvent component pointer; found a much cheaper injection strategy (clone-and-relabel vs fabricate-from-scratch) +- **What**: Decompiled `sub_7CE58` (the insert side of the same prefab-instance cache `AddEvent`'s handle resolves against, per §6w) and confirmed it stores `sub_7D2E8`'s (the long-known "extract RaceEvent component from a loaded prefab" function) two outputs plus the owning refcounted Actor pointer into the cache node's `+12/+16/+20` fields. This means a resolved `AddEvent` handle always points at a real, fully-formed `RaceEvent` component object — never anything synthetic. +- **Why**: Determines whether injecting a synthetic event requires hand-fabricating a whole `RaceEvent`-shaped C++ object (vtable, RTTI, full field layout — risky, layout only partially known) or whether a cheaper route exists. +- **Snapshot**: N/A — read-only static analysis. +- **Verification**: Full Hex-Rays decompilation of `sub_7CE58`, cross-referenced against `sub_173350`'s read-side node layout (§6w) and `sub_7D2E8`'s established role from earlier sessions. +- **Outcome/Next**: Two viable strategies now documented (§6x): (1) clone-and-relabel — reuse a real, already-loaded `RaceEvent*` as the fake cache entry's payload and overwrite only its display properties via already-known property setters (recommended, avoids needing to fabricate a vtable/RTTI from scratch); (2) fabricate-from-scratch — build a real `RaceEvent`-shaped object by hand (needs further reversing of its live in-memory class layout, not just the on-disk SB field layout from §6p). No further static analysis strictly required to choose between these — this is now an implementation-planning decision. Full detail in `ANALYSIS.md` §6x. + +## 2026-08-06 (cont. 13) — Byte-precise RaceEvent field map found; corrected an earlier wrong guess about the 3 floats +- **What**: Per the user's choice of "Вариант Б" (fabricate a synthetic RaceEvent from scratch), found RaceEvent's real vtable (`_ZTVN2im3app4race9RaceEventE`, `0xaa78a8`) and constructor (`sub_2A4B58`: `malloc(0xE4)` — object is exactly 228 bytes; base ctor sets owner-ptr/type-tag header, then vtable overwritten with `off_AA78B0`). Fully decompiled `sub_2A4D70` (the field deserializer) and built a byte-precise offset→property-name table: `TrackName`@+72, `RaceType`@+24, `CarRestriction`@+56, `EventName`@+88 (interned ptr, not a full string), `Location`@+92, computed `Zone`@+96, `EnvironmentPrefab`@+100, `TrafficCarCount`@+116, `OpenWorldTrack`@+120, `AutologID`@+124 (default -1), `BlacklistEvent`@+128, `ClassRestriction`@+132, `PursuitType`@+136, `StartLineNoSpawnZone`/`FinishLineNoSpawnZone`/`SpawnDistance`@+152/156/160 (floats, default 1000.0 each), `StartLine`/`FinishLine`/`EndOfTrack`/`CheckpointCollection`@+164/180/196/212 (12-byte string triples). +- **Why**: Fabricating a convincing synthetic RaceEvent object requires knowing its exact live memory layout, not just its on-disk SB field names. +- **Snapshot**: N/A — read-only static analysis. +- **Verification**: Full Hex-Rays decompilation; `StartLine`/`FinishLine`/`EndOfTrack`/`CheckpointCollection` offsets (164/180/196/212) cross-checked exactly against the `raceDef+164/180/196/212` offsets already established in much earlier sessions — independent agreement. +- **Correction**: An earlier guess this session (§6x) that the 3 floats at +152/156/160 might be `CashReward` Gold/Silver/Bronze is now disproven — they're spawn-zone tuning floats, all defaulting to 1000.0. `CashReward` and the UI-facing card properties (`class_restriction`, `event_type`, `Completion`) are confirmed **absent** from this 228-byte struct entirely — they must live on a separate component or be computed at read time. +- **Outcome/Next**: Next concrete step — find the reflective property-descriptor table that `sub_15F2DC`/`sub_406644` consult when the UI reads `class_restriction`/`event_type`/`Completion`/`CashReward` by name, since those aren't raw fields of this struct. Full detail in `ANALYSIS.md` §6y. + +## 2026-08-06 (cont. 14) — Corrected sub_15F2DC's role: it's a named-widget lookup, not a property read — narrows the fabrication gap to just CashReward +- **What**: Fully decompiled `sub_15F2DC`, previously only inferred by call-pattern analogy as "a generic property getter." It's actually the same `FindOrCreateLayoutEntity` pattern as `sub_17A4CC` (same "Unable to locate layout entity: " error string), specialized for `im::scene2d_new::Text` widgets. This means §6u Region A of `sub_1781BC` looks up named Text-label *widgets* by fixed name ("TrackName", "class_restriction", "event_type", etc.) within a scene rooted at `[MapScreen+0x120]` (a Node, not a RaceEvent*) — it doesn't read data values at all. +- **Why**: Re-evaluating what §6y's RaceEvent field map still needs to cover for a convincing fabricated card, now that "class_restriction"/"event_type" are understood to be widget names (very likely fed from the already-known `ClassRestriction`@+132 and `RaceType`@+24 fields) rather than separate unknown properties. +- **Snapshot**: N/A — read-only static analysis. +- **Verification**: Full Hex-Rays decompilation; error string and list-traversal pattern match `sub_17A4CC` exactly. +- **Outcome/Next**: The fabrication gap narrows to just `CashReward` (Gold/Silver/Bronze) — the only field genuinely unaccounted for in the 228-byte RaceEvent struct (Completion is computed by AddEvent, not stored on RaceEvent, so not actually missing). TrackName/EventName/RaceType/ClassRestriction from §6y's table already cover what the visible card needs. Full detail in `ANALYSIS.md` §6z. + +## 2026-08-06 (cont. 15) — Found CashReward's full layout, closing the fabrication gap for Вариант Б +- **What**: Found `CashReward` (`im::app::metagame::CashReward`) is a separate 28-byte class (not a RaceEvent field), part of a `Reward`/`RewardsCollection` family. Constructor (`sub_23E2C4`) sets vtable `off_AA5B54`, then `+16`/`+20`/`+24` = 10000/30000/50000 (inferred Bronze/Silver/Gold by ascending default magnitude) and `+12` = an unrelated unknown field defaulting to 0. Same `Component` base (`sub_670454`) as RaceEvent, via an intermediate `Reward` base ctor (`sub_25D4F0`). +- **Why**: This was the last missing piece for fabricating a complete, convincing synthetic race event (Вариант Б) — RaceEvent's own struct (§6y) has no reward fields. +- **Snapshot**: N/A — read-only static analysis. +- **Verification**: Full Hex-Rays decompilation of both `CashReward`'s and `Reward`'s constructors. +- **Outcome/Next**: Both objects needed for a fabricated event are now fully specified (228B RaceEvent + 28B CashReward, both simple malloc-and-fill constructors). Remaining open question (not blocking): how a RaceEvent and its CashReward are associated on the same prefab Actor — not investigated yet, only matters if real card-rendering code fetches the reward via that link rather than a separate cache lookup we control ourselves. Full detail in `ANALYSIS.md` §6aa. + +## 2026-08-06 (cont. 16) — Resolved RaceEvent<->CashReward link: sibling components on the same Actor, each deserialized independently per-race +- **What**: Found `sub_164540` = `Actor::GetComponent()` (confirmed via "GetComponent" profiling string + generic component-list-iteration/dynamic_cast pattern) — its argument is an `Actor*`, the same object type `sub_7D2E8` extracts `RaceEvent` from. This confirms `RaceEvent` and `CashReward` are independent sibling components on the same `Actor`, linked only via that Actor's component list — not via any direct field on either object. Also found `CashReward`'s actual field deserializer (`sub_23E47C`, same code region as its constructor from §6aa), which explicitly reads `"Bronze"`→+16, `"Silver"`→+20, `"Gold"`→+24 from the same shared per-prefab property table `RaceEvent`'s own deserializer (`sub_2A4D70`) reads from — confirming (not just inferring, as in §6aa) the exact tier-to-offset mapping. +- **Why**: User specifically asked to resolve this link and flagged that different races have different rewards — needed to confirm the mechanism supports per-race variation (it does: each race's own `.prefabs.sb` produces its own freshly-deserialized `CashReward` instance on its own `Actor`). +- **Snapshot**: N/A — read-only static analysis. +- **Verification**: Full Hex-Rays decompilation of both `sub_164540` and `sub_23E47C`; property names read directly as literal strings in the decompile, not inferred. +- **Outcome/Next**: Fabrication plan is now fully closed for both components — a synthetic race's fake Actor needs a component list containing both a fake RaceEvent* (§6y) and a fake CashReward* (§6aa, with whichever Bronze/Silver/Gold values are desired) so that `sub_7D2E8` and `sub_164540` resolve correctly against it exactly as for a real race. Full detail in `ANALYSIS.md` §6bb. + +## 2026-08-06 (cont. 17) — Implementation plan finalized for the synthetic "LAN: " event injection +- **What**: Wrote and got user approval for a concrete implementation plan (saved at `/home/megboyzz/.claude/plans/dreamy-giggling-hearth.md`) covering: hooking `MapTrack::HandleEvent` (`sub_368DFC`, confirmed ARM-mode/position-independent prologue, same trampoline technique as the two existing hooks), an observation-only first step to log real on-device `TrackId` values before hardcoding a target street, and the full fabrication sequence (RaceEvent ctor `sub_2A4B58` + string-field append `sub_7B524` + interned EventName via `sub_406644`, CashReward ctor `sub_23E2C4`, a minimal fake Actor exposing only `actor[5]/[6]`'s component array, cache-key insertion via the real engine primitive `sub_7D638` rather than hand-rolled hash-map logic, then `MapTrack::AddEvent` `sub_369AB0`). +- **Why**: Closes out the multi-session static-analysis thread (§6s-§6bb) into an actionable next step, per the user's explicit request to move to injection implementation planning. +- **Snapshot**: N/A — planning only, no code written yet (user explicitly chose "plan only, no code" this pass via AskUserQuestion). +- **Verification**: N/A — plan not yet executed. +- **Outcome/Next**: Implementation (writing the actual hook code in `launcher/mpcore/src/main/cpp/`, on a new branch off `master`, e.g. `lan-event-injection-poc`) is the next session's task, on the user's go-ahead. Key flagged risk carried into that work: the fake Actor object only satisfies the two component accessors this plan calls — full `Actor` layout was never reverse-engineered, so other code touching the same fake object is a residual crash risk, mitigated by incremental on-device testing. + +## 2026-08-07 — BREAKTHROUGH: live breakpoint-based debugging confirmed working on Samsung Galaxy A9 (2018) +- **What**: User provided a rooted Samsung Galaxy A9 (2018), SM-A920F, Snapdragon 660/Kryo 260, Android 10 — a device whose SoC pre-dates Qualcomm's removal of native AArch32 hardware support, as a direct test of the root-cause theory from the Fairphone 5 debugging saga. Attached the same native ARM32 `gdbserver` (NDK r16b) used before, but this time paired with the *matching-generation* GDB client (`gdb-orig` 7.11, also from NDK r16b, run with a local `LD_LIBRARY_PATH` symlink working around its legacy `libncurses.so.5` dependency) instead of a modern `gdb-multiarch`. Set a software breakpoint on `malloc` — the exact same sanity check that never fired on the Fairphone 5 — and it **fired correctly on the first `continue`**, with a real PC and backtrace frame, then `gdbserver` detached cleanly with the game process undisturbed. +- **Why**: Confirms the Fairphone 5's lack of native 32-bit hardware (not a tooling bug) was indeed the root cause of every prior debugging failure this project hit, and unblocks genuine live debugging going forward. +- **Snapshot**: N/A — pure debugging session, no file/memory patches, `libapp.so` untouched. +- **Verification**: Direct, reproducible breakpoint hit with correct PC/backtrace; process survived cleanly after detach. +- **Notable side-findings**: (1) `gdbserver --attach` pauses the process immediately via ptrace — attaching mid-loading-screen freezes the app there, looked like a hang until understood. (2) This device (Android 10) mmaps native libs directly from inside the APK zip rather than extracting a standalone `libapp.so`, so `/proc/PID/maps` never shows a `libapp.so`-named entry — runtime load base has to be computed via the APK's zip data-offset for `libapp.so` matched against a `/proc/PID/maps` file-offset field, then verified byte-for-byte against the reference file's ELF header. Changes every process relaunch (ASLR), must be recomputed each time. +- **Outcome/Next**: The Galaxy A9 is now the reference device for live debugging on this project. This directly unblocks the previously-static-analysis-only questions from earlier this session (e.g. could now *verify* rather than infer the `MapTrack`/`RaceEvent`/`AddEvent` call chain from §6v-§6cc live, and will be the natural next step when validating the injection plan from `/home/megboyzz/.claude/plans/dreamy-giggling-hearth.md`). Full detail in `ANALYSIS.md` §6cc; device details also recorded in `[[reference-native-arm32-debugging-requirement]]`. + +## 2026-08-07 (cont. 2) — Live-verified the full MapTrack::HandleEvent -> RefreshEvents -> AddEvent chain; confirmed base-address resolution has no divergence on APK-embedded-libs devices +- **What**: Two live tests on the Galaxy A9. (1) Compared `get_libapp_base()`'s (existing `dl_iterate_phdr`-based code in `main.cpp`) actual runtime result against an independently, manually computed load address — read `libapp_base`'s live value straight out of the already-running `libmpcore.so` (bundled in the installed APK, not stripped) via its own computed load address. Both methods gave the identical `0xb8798000`. (2) Set a conditional breakpoint on `MapTrack::HandleEvent` (only stopping when the dispatched event type is really `1025`/`FlowSetLayoutScreenEvent`), plus plain breakpoints on `RefreshEvents` and `AddEvent`, attached early to a freshly-relaunched game process, and let it run through its natural startup. All three fired in the exact predicted order, back to back, for multiple street pins in sequence, with `AddEvent` firing 6/4/3 times per pin for the first three pins observed. +- **Why**: Directly answers the user's two questions — whether base-address resolution needs a fallback algorithm for this device class (no, one algorithm already works), and whether the reverse-engineered `MapTrack`/`AddEvent` chain from §6v-§6y actually holds up under real execution (yes, confirmed exactly). +- **Snapshot**: N/A — pure debugging/observation, no patches. +- **Verification**: Live breakpoint hits with correct `this` pointers matching across the whole call chain (`HandleEvent`'s `this` == `RefreshEvents`'s `this` == `AddEvent`'s `this`, repeated per pin); also learned Garage->Map navigation does *not* refire the layout event (MapScreen persists rather than being recreated) — a fresh app launch was needed to catch the real trigger. +- **Outcome/Next**: The injection plan's core assumption (`AddEvent` is the right, safe, real call target) is now empirically confirmed, not just inferred. New concrete data point: real streets carry 3-6 events per pin in this save, not just 1 — worth keeping in mind when picking a target pin for the synthetic "LAN" entry (a pin with existing events is a safer test target than an all-locked one). Full detail in `ANALYSIS.md` §6dd. + +## 2026-08-07 (cont. 3) — Implemented and live-tested the LAN event injection hook: works end-to-end, no crashes, after fixing 2 real bugs +- **What**: Implemented the approved plan on new branch `lan-event-injection-poc` (launcher repo), branched from `track-hook-toggle-flag` rather than bare `master` (master turned out to lack the working hook-trampoline infrastructure the plan assumed - it's a much earlier, scratch-code-laden state never merged forward from the feature branches). New file `launcher/mpcore/src/main/cpp/lan_event_injection.h` hooks `MapTrack::HandleEvent`, and on `FlowSetLayoutScreenEvent` fabricates a `RaceEvent`+`CashReward`+fake `Actor`, inserts a cache entry, and calls the real `MapTrack::AddEvent`. Built and deployed to the Galaxy A9 multiple times, iterating on real crashes: (1) `Actor+4` must be non-null ("actor has been deleted" check), (2) `Actor+8` (a refcount) must start at 1, not 0, since `AddEvent` does a borrow-then-release cycle that calls a virtual "release" method through the object's own vtable if it nets to exactly 0 - crashing on this fake object's null vtable at offset +12. Also fixed a separate, unrelated torn-read crash in the diagnostic logging code (reading a `MapTrack`'s event vector from a background GLThread raced a concurrent update once). +- **Why**: Validates the entire multi-session reverse-engineering chain (§6v-§6bb) against the live game, not just statically - this was the actual deliverable the user asked for. +- **Snapshot**: New file `launcher/mpcore/src/main/cpp/lan_event_injection.h`; `main.cpp` gained one include + one toggle flag + one `JNI_OnLoad` call. Not yet committed (only committed on explicit request per project convention). +- **Verification**: Repeated fresh-app-launch tests on the Galaxy A9 via `adb logcat`, confirming zero crashes across all ~12 `MapTrack` pins this hook currently reaches, each logging a successful "LAN injection: added synthetic event..." line. +- **Outcome/Next**: The injection mechanism itself is proven working. Not yet achieved: visual confirmation on a currently-visible street - the 3 streets shown on this save's map (one is a "Most Wanted #10" boss battle) aren't among the ~12 pins this hook's `evtype==1025` condition catches, meaning that special/story content likely gets populated through a different mechanism than the generic path this hook intercepts. Next investigative thread if visual confirmation is wanted: find why those 3 pins don't dispatch through the same broadcast HandleEvent gets. Full detail in `ANALYSIS.md` §6ee. + +## 2026-08-07 (cont. 4) — Narrowed the delayed injection crash to the "Blacklist" rival-ranking subsystem; exact faulting instruction still open +- **What**: Chased the delayed crash from cont.3 (fault addr = our injected key `0xc0ffee00`, ~5 min after injection) via static analysis and live debugging. Found it traces through `sub_233684` (checks event names against a hardcoded Blacklist-rival-rank table - "event_60_blacklist_1".."event_04_blacklist_10" - matching the "Most Wanted #10" boss card seen earlier), which walks a boost::function-keyed map via a generic invoker (`sub_234F38`), calling a previously-IDA-unrecognized callback function at `0x233b4c` (only reachable via a stored function pointer, never called by name), which calls into `0x40602C`. Along the way, fixed a recurring gdb/gdbserver "Cannot execute this command while the target is running" sync bug - reliably avoided by attaching immediately after process spawn rather than to an already-running instance. +- **Why**: User explicitly prioritized understanding/fixing this crash before continuing the separate "why don't 3 special pins dispatch through HandleEvent" investigation. +- **Snapshot**: N/A — investigation only, no code changes this pass. +- **Verification**: Live-caught the call chain firing 28+ times with stable, safe register values, confirming it's a frequent/routine scan unrelated to injection under normal conditions - the crash is a rare condition within this hot path, not yet isolated to one exact instruction. +- **Outcome/Next**: Root cause narrowed to subsystem level (Blacklist rival scan misusing a raw injected key as a pointer) but not to the exact instruction - would need either a much longer live-debug soak or a conditional breakpoint on suspicious register values to fully pin down. Proposed an untested, lower-effort mitigation direction instead: generate synthetic cache keys that are structurally closer to real hashes (via `sub_67223C`) rather than an easily-distinguishable sentinel range, and re-run the soak test empirically. Full detail in `ANALYSIS.md` §6ff. + +## 2026-08-07 (cont. 5) — EXACT root cause of the delayed crash found: QA-only "Soak Test" auto-race feature, not the Blacklist system +- **What**: Corrected cont.4's investigation - had traced the wrong backtrace frame (a caller/return-address site, not the actual fault). Re-computed the *real* faulting frame's static offset (`0x406cd0`) and found it's inside `sub_406CAC`, a generic "build a string from a C-string pointer" helper, faulting on its very first `strlen`-style scan when given a non-string pointer. Traced the caller: it's `sub_17C120`'s already-identified (§6v/§6bb) QA-only "Soak Test" auto-race feature - every ~4s of gameplay, it index-walks a *separate, parallel* array (obtained via `sub_242904`, apparently a list of resource-path strings, not the same hash-table `sub_7D638`/`sub_173350` operate on) and builds a debug log line from a "random" (index-based) entry. Our injection only touches the primary hash-table, never this parallel list, desyncing their sizes - eventually the Soak Test's index-based lookup reads past/misaligned memory landing on our raw injected key instead of a real string pointer. +- **Why**: Gives a concrete, well-supported, and importantly *cheap* mitigation path, without needing to reverse-engineer the parallel list itself. +- **Snapshot**: N/A — investigation only. +- **Verification**: Static re-analysis of the exact faulting instruction and its containing function, cross-referenced against the original crash's exact fault address (`0xc0ffee00`, our injected key) and against `sub_17C120`'s already-decompiled body from earlier sessions. +- **Outcome/Next**: Recommended mitigation: this whole code path is QA/debug-only (auto-launches random cached races for soak testing, not player-facing) - the clean fix is to prevent it from ever running (hook `sub_17C120` or force its `flt_AD417C > 4.0` gate to never trip) rather than trying to keep a second, unidentified parallel list in sync with every injected entry. Not yet implemented in code. Full detail in `ANALYSIS.md` §6gg (supersedes the less-precise §6ff). + +## 2026-08-07 (cont. 6) — Implemented and verified the Soak Test disable hook: 10-minute crash-free soak test, game confirmed healthy +- **What**: Added `Hook_MapScreenTick`/`InstallSoakTestDisableHook` to `lan_event_injection.h` - hooks `sub_17C120` (`MAPSCREEN_TICK_OFFSET`, same trampoline pattern as the other two hooks) and forces the Soak Test's trigger accumulator (`flt_AD417C`, static offset `0xAD417C`) to `0.0f` immediately before every call to the original function, so its `>4.0` threshold never trips and the feature never fires. Wired in via a new independent toggle flag `kEnableSoakTestDisableHook` in `main.cpp`, deliberately separate from `kEnableLanEventInjectionHook` so it can stay on even with injection itself toggled off. Rebuilt, reinstalled, relaunched fresh, then ran a 10-minute logcat monitor (past the ~5 min point the original crash always hit by) - zero crash events, `pidof` still showed the same PID throughout. Woke the screen and foregrounded the app afterward to confirm it wasn't just "alive" but genuinely responsive: screenshot showed a normal, interactive map screen with real pins/balance, not a hang. +- **Why**: Directly implements the fix for the exact root cause found in cont.5, per explicit user request ("Да, реализуй фикс"). +- **Snapshot**: `launcher/mpcore/src/main/cpp/lan_event_injection.h` gained the new hook; `main.cpp` gained one toggle flag + one `JNI_OnLoad` call. Still on branch `lan-event-injection-poc`, not yet committed. +- **Verification**: 10-minute crash-free `Monitor` run (`mpcore_log` + `*:F` fatal logcat), `pidof` continuity check, `logcat -d -v time *:F | grep -c "beginning of crash"` = 0, and a post-soak screenshot of a live, interactive map screen (pins, currency, nav bar all responsive) after waking/foregrounding the app. +- **Outcome/Next**: The delayed-crash bug from cont.3-5 is resolved and empirically confirmed fixed, not just theorized. Remaining open thread from cont.3 (paused, not abandoned, per explicit user prioritization): why the 3 special/story pins on this save (incl. the "Most Wanted #10" boss card) never dispatch through the generic `HandleEvent(evtype=1025)` path this hook intercepts - to resume on user's direction. + +## 2026-08-07 (cont. 7) — Solved the 3-pin mystery: they aren't MapTrack instances at all +- **What**: Resumed the paused 3-pin investigation. First tried live gdb (gdb-orig 7.11 + gdbserver_arm on the Galaxy A9, same recipe as cont.1-2) to set a breakpoint on `MapTrack`'s constructor (`sub_368860`) - this repeatedly failed today across ~25 attempts and two frameworks: gdb hit a persistent, 100%-reproducible remote-protocol sync error ("Cannot execute this command while the target is running") tied to the vendor graphics-driver library-loading burst every single attach went through, occasionally cascading into real app crashes (`crash_dump32`) when cleanup was too aggressive; a Frida fallback (after fixing a frida-server/frida-python version mismatch, 16.7.0 vs 17.17.0) hit a different, unrelated bug - Frida's own Java-bridge crashing with "Error: invalid address" on any attach to this device's ART build. A full device reboot (user-initiated) didn't resolve the gdb issue either. Abandoned live debugging and instead added a temporary compiled-in logging hook on the same constructor address, following this file's own established, proven hook pattern (`Hook_MapTrackCtor`/`InstallMapTrackCtorTraceHook` in `lan_event_injection.h`, gated by a new `kEnableMapTrackCtorTraceHook` flag) - rebuilt, installed, and read the result straight from logcat, which worked immediately and cleanly. Result: exactly **12** `MapTrack` constructor calls fire per app launch, all from the identical call site (`lr=0xba03cb5c` this run, static offset `0x395b5c`) - matching exactly the ~12 pins already known to reach the `HandleEvent` hook, with zero extra. Traced that call site via IDA: it's `sub_395B38`, a tiny `make_shared()`-style factory (malloc 0x118 + constructor call + shared_ptr control-block wrap), itself only referenced as a *data* function-pointer (never called directly) from `sub_38DA44` - a ~27KB function that reads as a generic widget-type factory registry (name-string -> factory-function-pointer table), not a per-instance construction loop. +- **Why**: Directly answers the question paused back in cont.3 - resumed per this session's explicit user request ("Погоди... живая отладка... ставь breakpoint на конструктор"). +- **Snapshot**: `launcher/mpcore/src/main/cpp/lan_event_injection.h` gained the temporary `Hook_MapTrackCtor`/`InstallMapTrackCtorTraceHook`; `main.cpp` gained one toggle flag + one `JNI_OnLoad` call. Still on branch `lan-event-injection-poc`, not yet committed - this hook is investigation-only and should be removed/disabled before that branch is considered final. +- **Verification**: Live logcat output from the running game (not static inference) - `MapTrack ctor #1..#12`, all with the same caller address, immediately following the three other hooks' "Installed..." lines at app startup. +- **Outcome/Next**: Since `sub_395B38` is registered generically by *type name* in a factory registry rather than being a fixed per-instance loop, the actual count and identity of constructed `MapTrack` widgets is driven entirely by the map screen's layout/prefab data - however many nodes that data types as `"MapTrack"` (12, matching every pin the `HandleEvent` hook already reaches, none missed). At the time this entry was written, this was read as meaning the 3 special/story pins (incl. the "Most Wanted #10" boss card) must be a *different* widget class (guessed: `im::app::ui::MostWantedLeaderboard`) - **this guess was wrong, corrected in cont.8 below.** + +## 2026-08-07 (cont. 8) — Correction: the "3 special pins" premise itself was false, not the widget-class theory +- **What**: User corrected the `MostWantedLeaderboard` guess from cont.7 - that class is the side-panel listing Blacklist rivals, not anything rendered as a map pin, so it was never a plausible fit. User asked to just open the game and visually check a supposedly-special pin (МАККЛЕЙН/McLane) directly instead of continuing to theorize. Did so: launched the game fresh, panned the map down/around from the default 3-pin view to reveal 5 pins total (Рэйнольдз Лэйн 50%, Кэмерон Драйв 0%, Крюгер Авеню 0%, Макклейн 33%, Петерсон Стрит 100%), and tapped both Макклейн and Петерсон Стрит. Both opened the completely ordinary per-street event-card screen (street races/checkpoint challenges, one with a trophy for a completed event) - no "Most Wanted #10" boss card or anything unusual anywhere. +- **Why**: Directly requested by the user, who was skeptical of the RTTI-based widget-class theory and wanted empirical confirmation before any more code was chased. +- **Snapshot**: N/A - pure UI inspection via adb screenshots, no code changes. +- **Verification**: Direct visual confirmation via `adb shell screencap` on the real device, tapping into two different pins' actual event-card screens. +- **Outcome/Next**: Combined with cont.7's live count (exactly 12 `MapTrack` instances constructed, matching all reachable-via-`HandleEvent` pins with none missing), there is now no evidence the "3 special pins never fire `HandleEvent`" observation (carried over from an earlier session) ever matched what this save actually shows. The whole investigation is closed as resting on a false or stale premise, not as an unsolved mystery - see `[[feedback-verify-ingame-before-re-theory]]` for the general lesson about re-verifying carried-over RE premises live before spending more effort explaining them. No code fix was needed since nothing was actually broken; both the `MapTrack`-constructor logging hook (cont.7) and everything else touched this pass were already reverted/left clean on `lan-event-injection-poc` before this correction was written. + +## 2026-08-08 (cont. 9) — Implemented per-street targeted injection (arbitrary street + arbitrary name); found the visible on-screen streets never go through AddEvent at all +- **What**: Per user request, revisited `sub_1781BC` and the surrounding `MapTrack`/`RefreshEvents` static analysis to finish the original plan's last step: instead of injecting into every pin unconditionally (temporary "visual confirmation" mode from cont.3), target exactly one chosen street and let the injected card's name be arbitrary. Found `sub_369040` (`RefreshEvents`) reads a `{char* begin, char* end}` vector at `MapTrack+0xE0/+0xE4` of plain null-terminated event-*group-name* C-strings, populated at layout/prefab-bind time and available the moment `HandleEvent(evtype=1025)` first fires - independent of whether any `RaceEvent` has actually resolved yet (unlike the existing `TrackName`-from-first-event logging, which needs a resolved event). Implemented `MAPTRACK_GROUPNAMEVEC_BEGIN/END_OFFSET`, changed `InjectSyntheticEvent` to take an explicit `eventName` parameter (previously a fixed constant), and gated injection on `strcmp(groupName, kTargetGroupName) == 0`. Live-tested: this correctly injects into exactly one of the 12 reachable `MapTrack` widgets, by name, with zero effect on the other 11 - the targeting mechanism itself is proven sound. However, the 12 observed group names are all `region{1,2,4,5}_{foothills,desert,chicago,newyork}_track{1,2,3}` - generic career-progression identifiers - and none of them textually or visually correspond to any of the 5 currently-visible/playable on-screen streets (МАККЛЕЙН, ПЕТЕРСОН СТРИТ, РЭЙНОЛЬДЗ ЛЭЙН, КЭМЕРОН ДРАЙВ, КРЮГЕР АВЕНЮ). To resolve the correlation empirically, temporarily broadened injection to fire into all 12 with a per-pin name built from its own `groupName` (`"LAN: "`) and checked every visible pin's card screen (МАККЛЕЙН, ПЕТЕРСОН СТРИТ) - no injected card appeared on either. +- **Why**: Directly requested by the user ("сделаешь предыдущую задачу с инжектом нового события в произвольную улицу с произвольными именем"), after explicitly preferring static analysis over the day's unreliable live-debugging tools. +- **Snapshot**: `launcher/mpcore/src/main/cpp/lan_event_injection.h`/`main.cpp` on branch `lan-event-injection-poc`, not yet committed - currently contains the targeted-injection changes plus several TEMPORARY diagnostic-only hooks added during this investigation (see below), not yet cleaned up. +- **Verification**: Live logcat confirmation of exact single-target injection (`kTargetGroupName` mode) and of the "inject into all 12, unique names" diagnostic mode; visual confirmation via `adb screencap` that neither МАККЛЕЙН nor ПЕТЕРСОН СТРИТ ever show an injected card regardless of which of the 12 targets is used. +- **Outcome/Next**: Pushed the investigation further to find how the visible streets *do* get populated, since it's clearly not via the same path. Found `sub_369040` (`RefreshEvents`) has a second caller besides `MapTrack::HandleEvent`: `sub_368FDC`, a thin direct `RefreshEvents(this)` wrapper with **zero static call-site xrefs anywhere in the binary** - only reachable through a `boost::_bi::bind_t const&>` callback object (confirmed via its RTTI typeinfo). Added a temporary compiled hook on `sub_368FDC` (`Hook_MapTrackDirectRefresh`/`InstallMapTrackDirectRefreshTraceHook`) to catch it live - it never fired at all during this session, including after tapping directly into МАККЛЕЙН's screen. Went one level more direct and hooked `AddEvent` itself (`sub_369AB0`, `Hook_AddEvent`/`InstallAddEventTraceHook`, filtering out our own synthetic `0xC0FFEE0*`-prefixed keys) - the single most authoritative signal, since every real "this street just got a race event" moment must pass through it. Result: **62 genuine engine-initiated `AddEvent` calls fired this session, all from the exact same caller address, all targeting the same 12 `region_*_track*` widgets (5-6 events each) - zero calls for any visible street.** This conclusively proves the visible/playable streets' events are not added via `AddEvent` at all during normal play, on this device, in this session - they're most likely already fully deserialized as part of the save-file load itself (baked-in from a previous playthrough) rather than resolved through the runtime prefab-cache system the region/career-track slots use. Was mid-way through testing one more hypothesis (that the earlier `kTargetGroupName`-matched injection *did* land correctly on some visible-street's underlying data, but the already-rendered card-list UI just doesn't live-refresh to show it) via a "navigate away and back" test when the app crashed/exited after an unrelated "no connection" network-check dialog; the game was relaunched and the diagnostic-mode injection re-confirmed firing cleanly (12/12, no crash), but the UI-refresh re-test itself was not completed before the user asked to stop here and record progress. **Explicitly paused, not resolved** - next session should either finish the UI-refresh hypothesis test (navigate to garage/profile and back, then recheck a visible street's card list) or pursue the boost::bind machinery further to find the actual populating mechanism for visible streets. Also outstanding: the temporary diagnostic hooks (group-name/direct-refresh/AddEvent tracing, and the "inject into all 12" diagnostic mode in place of the single-target `kTargetGroupName` match) are still active in the working tree and should be cleaned up/reverted to the single-target design once the visible-street mechanism is found or the approach changes. + +## 2026-08-08 (cont. 10) — Reverted the temporary diagnostic hooks from cont.9, kept the core targeted-injection feature +- **What**: Per user request, removed the investigation-only scaffolding added in cont.9 and left the working tree with only the genuinely useful result. Reverted `main.cpp` entirely via `git checkout` (its whole diff was the two diagnostic-hook toggle flags + `JNI_OnLoad` calls, nothing else). In `lan_event_injection.h`, removed `Hook_AddEvent`/`InstallAddEventTraceHook` (the `AddEvent`-level trace) and `Hook_MapTrackDirectRefresh`/`InstallMapTrackDirectRefreshTraceHook` (the `sub_368FDC` trace) in full, and reverted the "inject into all 12 with a per-pin diagnostic name" block back to the intended single-target design: `if (kInjectSyntheticEvent && groupName && strcmp(groupName, kTargetGroupName) == 0) InjectSyntheticEvent(mapTrackThis, kSyntheticEventName);`. Dropped the now-unused `#include ` (was only needed for the diagnostic `snprintf`). Kept everything that constitutes the actual deliverable from cont.9: `MAPTRACK_GROUPNAMEVEC_BEGIN/END_OFFSET`, `kTargetGroupName`, the `groupName` read/log inside `Hook_MapTrackHandleEvent`, and `InjectSyntheticEvent`'s explicit `eventName` parameter (the "arbitrary name" half of the original request). Added a code comment on the injection gate pointing at this file's PROGRESS.md entry for the still-open "visible streets never call AddEvent" limitation, so it isn't lost context next time this file is read cold. +- **Why**: Directly requested by the user ("Откати временные диагностические хуки"), after the UI-refresh hypothesis test from cont.9 was interrupted by an app crash and progress was recorded as paused. +- **Snapshot**: `launcher/mpcore/src/main/cpp/lan_event_injection.h` on branch `lan-event-injection-poc`, `main.cpp` back to its last-committed state (`ca55b2e`). Net diff is now 72 insertions/31 deletions in one file, confirmed to `./gradlew :app:assembleDebug` cleanly (`BUILD SUCCESSFUL`). Not yet committed. +- **Verification**: `git diff --stat` reviewed line-by-line to confirm only diagnostic-only additions were removed and the targeting/naming mechanism was preserved; full rebuild confirms no compile breakage from the removal. +- **Outcome/Next**: Working tree is back to a single coherent, uncommitted feature (per-street-by-groupName injection with an arbitrary card name), still limited to the 12 career-progression slots per cont.9's finding. The visible-on-screen-street population mechanism remains unfound - next session should pick up either the UI-refresh hypothesis test or the `boost::bind` lead from cont.9 if that thread is resumed. + +## 2026-08-09 (cont. 11) — New lead on the visible-street mystery: a separate `Career` component with its own PrefabDatabase, not the generic per-track cache +- **What**: Per user request, continued the "how do visible streets get populated" investigation from the save/profile-deserialization angle instead of the `boost::bind` callback-wiring angle. Along the way, re-verified (out of caution after noticing `AddEvent`'s own internal dedup-check loop also reads `MapTrack+0xE0/+0xE4`, comparing elements as raw pointers rather than strings) that the `MAPTRACK_GROUPNAMEVEC_BEGIN/END_OFFSET` reading in `RefreshEvents` is still correctly interpreted (confirmed via a fresh full decompile of `sub_369040`: the `while(*++v9);` loop unambiguously treats each element as a C-string) - the apparent contradiction is just two different, non-overlapping uses of the same field at different points in the object's lifecycle, not a bug in the already-committed feature. Then searched the binary for `SaveGame`/`ISaveable`/`Profile` class references and found a `boost::_bi::bind_t<..., im::app::metagame::SaveGame, eastl::basic_string<...> const&>` callback (a `SaveGame` method taking a slot-name string) and a large family of classes implementing `im::app::metagame::ISaveable` (`AchievementManager`, `Autolog`, `CachedSpeedwall`, `CurrentState`, `MenuState`, `Options`, `Profile`, ...). Enumerating the RTTI type-info table further (`.data.rel.ro` around `0xaa5700`-`0xaa6200`) turned up `im::app::metagame::Career`, `im::app::metagame::Progression`, and critically `im::components::PrefabDatabase` - a **separate, `Career`-specific prefab database template instantiation**, distinct from the generic per-track prefab cache (`sub_7566C`/`sub_242778`) that `RefreshEvents`/`AddEvent` use for the 12 `region_*_track*` slots. Checked `Career`'s own RTTI base class: it's a plain single-inheritance `im::components::Component` (the same base `RaceEvent`/`CashReward` derive from) - i.e. `Career` is a component, attachable to an Actor just like the objects our injection already fabricates. +- **Why**: Directly requested by the user, pursuing the save/profile angle in preference to further boost::bind reverse-engineering. +- **Snapshot**: N/A - static analysis only (IDA MCP), no code changes this pass. +- **Verification**: RTTI cross-references read directly from `.data.rel.ro` (type_info name strings, single/multi-inheritance base-class records) - not inferred, the class names and the `PrefabDatabase`/`Component` base relationship are read literally from the binary's own C++ RTTI metadata. +- **Outcome/Next**: Strong new lead, not yet fully closed: the currently-visible/playable streets are most plausibly populated through this separate `Career`/`Progression`/`PrefabDatabase` machinery rather than the generic `AddEvent` path this whole investigation (cont.3-cont.10) has been built around - which would fully explain why the `AddEvent`-level trace (cont.9, 62 genuine calls, zero for visible streets) never caught them. Not yet traced: `Career`'s own vtable/methods, how a loaded `Career` prefab's data actually reaches a `MapTrack` widget's event vector (whether via a still-undiscovered call to the same `AddEvent`/`MapTrack+0xF0` fields using a different key-resolution path, or via an entirely separate mechanism `Career` owns itself), and whether `PrefabDatabase::Load(name, path)` (the bound string-taking method glimpsed via the `boost::bind` signature) is the actual entry point invoked when the save loads. Next step: find `Career`'s full vtable (same `get_bytes` technique used earlier for `MapTrack`'s), and look for its constructor/factory xrefs the same way `MapTrack`'s `sub_395B38` factory was found via `sub_38DA44`'s type-name registry - `Career` is very likely registered there too, under some type name worth searching for directly (e.g. "Career" as a literal string in the prefab-type registry). + +## 2026-08-09/10 (cont. 12) — Traced Career's vtable and constructor; likely a car-garage component, not the street-population mechanism after all +- **What**: Continued digging per user request. Found `Career`'s constructor (`sub_239DC0`, installs vtable `off_AA5A58`, mallocs 76 bytes, base `im::components::Component` ctor via `sub_670454`, zero-initializes two separate 3-word fields at `+12/+16/+20` and `+28/+32/+36` - i.e. two vectors) and its factory (`sub_239C58`), which registers the literal string `"Career"` into what's very likely the same generic type-name-to-factory registry `MapTrack` was found registered in (cont.7's `sub_38DA44`/`sub_395B38` pattern - same `_cxa_guard`-protected static-init + string-build + registration-call shape). Read `Career`'s actual vtable content (10 entries, far smaller than `MapTrack`'s ~60-entry one, consistent with a lightweight `Component`) and decompiled the one entry outside the generic Component-boilerplate address range (`sub_23A5C4`): it reads a `"Cars"` property, iterates each entry's `"Name"` sub-property, resolves it through the same generic prefab-cache resolver (`sub_7566C`/`sub_11B144`) used throughout this project, and pushes the result into the `Career+12` vector found zeroed in the constructor - i.e. **this method populates a list of owned/resolved car prefabs, not tracks or streets.** Separately, chased the `"Location"` property found earlier (read inside `sub_2A4D70`, already-known from earlier sessions as `RaceEvent`'s own field deserializer) and found it's resolved through `sub_50D5B4` the same way `RaceType`/`FinishLineNoSpawnZone` are (a string-to-enum lookup with a coded default fallback) - i.e. `Location` is a **category tag stored directly on each `RaceEvent`** (like a region/city classification), not a live reference to a specific `MapTrack` widget. +- **Why**: Continuing the user-directed investigation into the save/profile-deserialization angle from cont.11. +- **Snapshot**: N/A - static analysis only, no code changes. +- **Verification**: Vtable slot addresses read directly via `get_bytes` on `Career`'s installed vtable pointer; the `"Cars"`/`"Name"` property-read pattern and push-into-`+12`-vector behavior read directly from `sub_23A5C4`'s Hex-Rays decompile, not inferred. +- **Outcome/Next**: This tempers cont.11's optimism - `Career` looks like it's most plausibly the player's **garage/owned-cars** component (matching its `PrefabDatabase` + `"Cars"` property + a `Career+12` resolved-car-list vector), not a per-street/track unlocking mechanism. The second zeroed vector at `Career+28/+32/+36` is still unexplained (populated by neither of the two non-destructor vtable-adjacent code paths inspected so far) and remains a candidate worth checking, but the direct "Career populates visible streets" hypothesis from cont.11 is now weaker than it looked. `RaceEvent`'s own `"Location"` field is a plain category tag, not a link to a widget - ruled out as the missing mechanism. **Still open**: how a `MapTrack` widget for a currently-visible/playable street gets its event vector populated outside the `AddEvent`/`RefreshEvents`/`sub_368FDC` paths already ruled out in cont.9. Given the breadth already covered this session (gdb/frida live debugging, `AddEvent`-level tracing, `boost::bind` callback wiring, `SaveGame`/`ISaveable`/`Career`/`Progression` RTTI enumeration) without closing the loop, the next productive step is likely either (a) checking the unexplained `Career+28/32/36` vector's populating code path, or (b) a live compiled-hook trace (proven reliable this session, see cont.7) on `Career`'s constructor/the `"Cars"` method to empirically observe when/how many `Career` objects exist and correlate their `this` pointers and any adjacent MapTrack construction timing. + +## 2026-08-10 (cont. 13) — Traced the full Career prefab-loading chain back to `Progression`'s "career" property; second vector still unexplained +- **What**: Continued chasing `Career`'s architecture. Found `Career`'s `GetComponent()` (`sub_25C9EC`, confirmed via the same `dynamic_cast(component_ptr, Component_typeinfo, Career_typeinfo, 0)` shape already seen for `RaceEvent`/`CashReward`), traced its one caller `sub_25C55C` (confirmed as the generic `PrefabDatabase::LoadOrGet(name)` - hash-lookup-or-insert, same shape as the RaceEvent prefab cache), traced *its* one caller `sub_25C404` (a thin `.prefabs.sb`-suffix-ensuring path-builder wrapper), and traced *its* callers to `sub_25A4D4` - a callback-registration function that binds `sub_25C404` under a property name into some type's schema via `sub_3E8DA0`. Found the actual registration call site, `sub_258D2C`: it interns the literal string `"career"` (lowercase) and reads/attaches it as a **named property called "career"** on a singleton object whose vtable (`off_AA5FA0`) sits immediately adjacent to the already-known RTTI block for `im::app::metagame::Progression`/`ManagedSingleton` - i.e. `Progression` (the same "career progression" singleton already flagged in cont.11) has a lazily-resolved `"career"` property that, when read, loads a named `Career` prefab file on demand. This is a real, concrete architectural finding: **the player's currently-active career is a single named `Career` prefab, loaded on demand via `Progression`'s "career" property**, not a hardcoded/baked object. +- Went hunting for what populates `Career`'s second, still-unexplained vector at `+28/+32/+36` (the one thing that would confirm or kill the "Career holds the visible streets" hypothesis): found a second caller of the same `sub_1738E8` push-into-vector helper used by the `"Cars"` loader, `sub_23B854` - but decompiling it showed **byte-for-byte identical code** to the `"Cars"` loader (`sub_23A5C4`), just at a different address - i.e. a compiler-duplicated instantiation for an unrelated class that happens to also have a `"Cars"`-shaped property list, not a `Career`-specific sibling method. Dead end for this specific search. +- **Why**: Continuing the user-directed deep dive into the save/profile angle. +- **Snapshot**: N/A - static analysis only, no code changes. +- **Verification**: Each link in the `GetComponent -> LoadOrGet -> path-builder -> callback-registration -> "career"-property-on-Progression` chain confirmed via direct decompile of every function in the xref chain, not inferred from naming alone; the `sub_23B854` dead-end confirmed by literal decompile-output comparison (identical to `sub_23A5C4`). +- **Outcome/Next**: `Career+28/32/36`'s populator is still unfound by pure xref-chasing from `Career`'s own vtable/nearby code. Given the architecture is now much clearer (`Progression` owns a lazily-loaded, single active `Career` prefab; `Career` itself is a small `Component` with a confirmed `"Cars"` garage-list property and one still-mysterious second list), the two remaining productive paths are the same two identified in cont.12: (a) find what other property name a *different*, not-yet-located `Career`-specific function reads into `+28/32/36` (the `"Cars"`-shaped-duplicate dead end suggests brute-force xref chasing from `sub_1738E8` won't find it directly - would need to search Career's surrounding code (`0x239000`-`0x23C000`ish) more broadly for *any* function referencing `a1+28`/`+0x1C` as a write target, not just ones sharing the exact same push-helper call), or (b) switch to empirical/live confirmation now that the static architecture is well understood - a compiled hook (this session's consistently reliable technique, see cont.7) on `Career`'s constructor and the `"Cars"`-loader, logging the resolved prefab name and the `+28/32/36` vector's contents once populated, would likely resolve this faster than further blind static xref-chasing. + +## 2026-08-10 (cont. 14) — CONCLUSIVE: `Career` is the player's car garage, not a street/track mechanism - cont.11's hypothesis is ruled out +- **What**: Per user's explicit choice to keep pursuing static analysis, did a broad string-literal scan of the *entire* `Career` code cluster (`0x239000`-`0x23C000`, every quoted-string reference in that range, not just targeted guesses) instead of continuing to chase the `+0x1C` offset specifically (which had led to a dead-end generic component-resolution utility, `sub_239F40`, unrelated to `Career`'s own field layout). Result: every single property name in this entire code region is car-related - `"Cars"`, `"Unlocked"`, `"Purchased"`, `"ColourIndex"`, `"InstalledParts"` (read by `sub_23A820`/`sub_23ADB4`, evidently per-car-entry deserializers/serializers for the garage list), plus `"CarOwnedRequirement"`, `"CarClassOwnedRequirement"`, `"ClassOwned"`, `"CarOwned"` (car-ownership *requirement/condition* checker classes, likely used to gate purchases or unlocks elsewhere). **Zero** references to tracks, streets, events, regions, or anything matching the `MapTrack`/`RaceEvent` domain anywhere in this cluster. +- **Why**: Directly settles whether `Career` (cont.11's lead) is actually relevant to the original "how do visible streets get populated" question, after cont.12/13's narrower probes left it ambiguous. +- **Snapshot**: N/A - static analysis only, no code changes. +- **Verification**: Exhaustive string-literal enumeration (regex `"[A-Z][a-zA-Z]+"`) across the full address range IDA attributes to this code cluster, not a handful of guessed keywords - this is as close to a conclusive negative as static analysis can give without full manual disassembly of every byte in range. +- **Outcome/Next**: **cont.11's "Career manages visible streets" hypothesis is ruled out.** `Career` = player's owned-car/garage record (which cars are unlocked, purchased, what colour/parts each has), entirely orthogonal to the `MapTrack`/street-unlock system this whole investigation (cont.3-cont.14) has been trying to find. The `Career+28/32/36` second vector is now most plausibly *also* car-related (a staging list during (de)serialization, or a class-requirement cache) rather than anything street-related - not worth further pursuit for this question. **The original question - how a `MapTrack` widget for a currently-visible/playable street gets its event vector populated outside the already-ruled-out `AddEvent`/`RefreshEvents`/`sub_368FDC`/`Career` paths - remains open after a very thorough multi-session investigation** (live debugging infra fights, `AddEvent`-level tracing, `boost::bind` callback-wiring, and now the full `SaveGame`/`ISaveable`/`Progression`/`Career` RTTI family). Given the breadth already covered, the most promising unexplored angles for a future session are: (a) `Progression` itself (the singleton that owns the `"career"` property) may have its *own* separate street/region-unlock state independent of `Career` - its RTTI/vtable was never directly inspected, only inferred adjacent to `Career`'s; or (b) pivot from static to the live compiled-hook technique (proven reliable all session, see cont.7) directly on `MapTrack`'s constructor or `AddEvent`, filtering specifically for calls whose caller address (`lr`) is *not* the one known `RefreshEvents` call site (`0x369210` internally) - since anything populating a visible street's event vector must call `AddEvent` from *somewhere*, and cont.9's trace only proved the 12 known calls share one `lr`, not that no other `lr` value is possible in a longer/different observation window. + +## 2026-08-10 (cont. 15) — Checked `Progression` directly per user request: also a dead end, and surfaced a methodology risk worth flagging +- **What**: Followed up on cont.14's remaining lead (a). Found `Progression`'s real vtable (`_ZTVN2im3app8metagame11ProgressionE`, object vptr `off_AA5FA0`) and its constructor (`sub_258E0C`, the same function already partially seen in cont.13 - confirmed it's a lazy-init `ManagedSingleton::Instance()`, mallocs 80 bytes, single base class `ManagedSingleton` only - i.e. `Progression` does **not** inherit `ISaveable` directly, unlike the `AchievementManager`/`Autolog`/`Profile`/etc. family from cont.11). The vtable itself is minimal - only 2 entries, and decompiling the first (`sub_258B30`) confirmed it's a plain destructor (releases a `boost::shared_ptr`-shaped field at instance offset 76 via refcount decrement, nothing else) - i.e. `Progression` has essentially no custom virtual behavior beyond the base singleton/destructor. A broad string-literal scan of `Progression`'s own tight code cluster (`0x258700`-`0x259400`) found almost nothing - just `"career"` (already known) and `"Initialise"` (a debug-assert message, not a property) - no second property name to chase, unlike `Career`'s rich `"Cars"/"Unlocked"/"Purchased"/...` set. +- Checking the destructor's field layout against the constructor's raw offsets exposed a real analysis gap: the earlier-assumed "`Progression+68/72/76` is a 3-word `{begin,end,cap}` vector, just like `Career`'s" was likely wrong - the destructor's refcount-release pattern on offset 76 alone looks much more like a 2-word `boost::shared_ptr` (ptr + control-block) at offset 72/76, with offset 68 a separate, single field - not a vector at all. Also flagged (not resolved): the `"career"` property-registration call (`sub_258D2C`, cont.13) operates on an object obtained via `sub_171610(instance)`, which may be a separate **property-schema/type-descriptor** object rather than `Progression`'s raw 80-byte instance directly - meaning some earlier offset reasoning in this thread may have conflated the instance layout with the schema-descriptor layout. Not disproven, just flagged as an open uncertainty rather than papered over. +- **Why**: Directly requested by the user, following up on cont.14's flagged next step. +- **Snapshot**: N/A - static analysis only, no code changes. +- **Verification**: Vtable/constructor addresses and the destructor's field-release pattern read directly from decompiles, not inferred; the string-literal scan used the same exhaustive regex technique as cont.14's conclusive `Career` scan. +- **Outcome/Next**: `Progression` is now also a dead end for this specific question via the techniques that worked for `Career` - it's a near-empty singleton wrapper whose only substantive job (visible in its own code) is lazily resolving the `"career"` property. **This investigation has now ruled out every architecturally-plausible mechanism found via static analysis** (`AddEvent`/`RefreshEvents`/`sub_368FDC` broadcast paths in cont.9, `Career` in cont.11-14, `Progression` in cont.15) **without finding the actual one.** Given the cumulative depth (multiple sessions, live-debugging infrastructure work, and now several RTTI-family deep-dives), further blind static xref-chasing has clearly diminishing returns. The two concrete options left on the table, unchanged from cont.14: (a) untangle the generic property/schema system itself (the `sub_171610`/`sub_3E8DA0`/`sub_67xxxx`-family functions this whole investigation kept bumping into without fully understanding) - a substantial standalone task, not a quick follow-up; or (b) go empirical with a live compiled-hook trace on `AddEvent` with a longer observation window and no `lr`-based filtering, to catch any call this session's shorter traces might have missed. Recommend checking in with the user before choosing either, given the effort-to-progress ratio has been poor for the last several `cont.` entries. + +## 2026-08-10 (cont. 16) — Option (b) executed: extended live `AddEvent` trace across heavy in-app interaction, conclusively found nothing new +- **What**: Per user's choice ("давай b"), re-added the `AddEvent`-level trace hook (`Hook_AddEvent`/`InstallAddEventTraceHook`, identical to cont.9's, no `lr`-based filtering - logs every genuine call regardless of caller) as a temporary addition, rebuilt, installed, and ran an extended, heavily-interactive session instead of the short launch-time-only window cont.9 used: tapped into МАККЛЕЙН and ПЕТЕРСОН СТРИТ's card screens (and each of their individual race-detail sub-screens, including opening "ТЬМА СГУЩАЕТСЯ" and "ПОБУДКА" fully), navigated through the Гараж (garage, showing 3/41 cars owned), the MOST WANTED/Blacklist panel (tapped into a locked rival entry), used the EASYDRIVE "next event" shortcut (which opened an existing McLane race), and found + opened a fifth, previously-unvisited pin - ЦЕНТР ГОРОДА ("Центр города"/City Center, 100% complete, home to the game's intro race "Перед вами Fairhaven"). Cleared logcat mid-session to isolate fresh activity from launch-time noise. +- **Why**: Directly executes the option the user chose after cont.15 left both `Career` and `Progression` as dead ends, to check whether a longer/more varied observation window catches a call path the short traces missed. +- **Snapshot**: `launcher/mpcore/src/main/cpp/lan_event_injection.h`/`main.cpp` temporarily gained the hook again during this pass; both fully reverted back to the clean, committed `lan-event-injection-poc` state (`git status` empty) once the trace was complete - no net changes left in the working tree. +- **Verification**: Live logcat capture across the whole interactive session - 186 genuine `AddEvent` calls captured post-clear (on top of an initial 62 from launch, confirmed to be the same call site replayed across two app launches by checking the static/module-relative offset behind the differing ASLR-shifted `lr` values), **all 186 sharing the exact same caller address and the exact same 12 `region_*_track*` group names already known since cont.9** - zero new call sites, zero new group names, despite deliberately visiting every visible pin (including a fifth, previously-unchecked one, ЦЕНТР ГОРОДА) and every major UI screen the game has. +- **Outcome/Next**: This is now about as thorough a negative result as this investigation can produce with the tools available: exhaustive static RTTI/xref analysis (cont.9-15) *and* an exhaustive live trace across real interactive play (this entry) both agree that `AddEvent` is never called for any visible/playable street, under any observed circumstance, this session. Whatever populates ЦЕНТР ГОРОДА/МАККЛЕЙН/ПЕТЕРСОН СТРИТ/etc.'s event vectors happens through a mechanism this investigation has not located - most likely a generic, data-driven property/schema deserialization path (see cont.15's flagged `sub_171610`-family uncertainty) that doesn't route through the named functions this whole investigation has been built around, and would require understanding that broader subsystem from scratch to find. **Recommend treating this specific question as closed for now** (not answered, but exhaustively searched via every technique available this session) unless/until the user wants to invest in reverse-engineering the generic property/schema system as its own standalone effort. + +## 2026-08-10 (cont. 17) — Resolved `sub_171610`: it's a plain singleton getter, not a hidden subsystem - closes cont.15's flagged uncertainty with no new lead +- **What**: Per user request, decompiled `sub_171610` in isolation instead of as a side-note. It turned out to be trivial: `int sub_171610() { return dword_AD40A8 ? dword_AD40A8 : (assert-log "Not initialised", dword_AD40A8); }` - i.e. exactly `ManagedSingleton::GetInstance()`, reading the same global (`dword_AD40A8`) that `Progression`'s own constructor (`sub_258E0C`, cont.13/15) sets. This **resolves cont.15's flagged uncertainty**: `sub_258D2C`'s `v3 = sub_171610(v2)` genuinely operates on `Progression`'s real 80-byte instance, not a separate schema-descriptor object as speculated - the earlier offset-based reasoning about `Progression+68/72/76` was on solid ground after all, it just didn't find anything street-related there (cont.15's negative result stands, now on firmer footing). +- Also decompiled the actual property-registration primitive, `sub_3E8DA0` (called from `sub_25A4D4`, cont.13, to bind the `"career"` string to its loader callback): it's a thin wrapper that packages the caller's `(name, callback)` into a generic boost::function-shaped object and calls a **virtual method at vtable+28** on a separate registry singleton (`sub_40E8E8()` - yet another lazy-init `_cxa_guard`-protected singleton, 376 bytes, no RTTI name found nearby). I.e. `sub_3E8DA0(registry, name, callback, ...)` = `PropertyRegistry::RegisterHandler(name, callback)` - a single, central, generic name-to-handler registry that `Career`'s `"career"` property (and by extension, presumably every other named property this whole investigation has bumped into - `"TrackId"`, `"Cars"`, `"Location"`, etc.) all funnel through. +- **Why**: Directly requested by the user, to settle whether this function hid anything relevant rather than leaving it as an open flag. +- **Snapshot**: N/A - static analysis only, no code changes. +- **Verification**: Both functions read directly via Hex-Rays decompile; the `dword_AD40A8` identity match against `Progression`'s own constructor confirmed by direct comparison against cont.13's decompile of `sub_258E0C`, not inferred from naming. +- **Outcome/Next**: **No new lead toward the visible-street mystery.** `sub_171610`/`sub_3E8DA0`/`sub_40E8E8` are exactly what they look like - a standard lazy-singleton + central property-registry pattern used pervasively across the `im::app::metagame` namespace for prefab/save deserialization plumbing, not a hidden mechanism specific to streets. This is genuinely generic infrastructure (every "metagame" class this investigation has touched - `Career`, `Progression`, and implicitly `RaceEvent`/`CashReward`/`MapTrack` themselves - registers its named properties through some instance of this same pattern), so "reverse-engineer the property system to find the street mechanism" was never going to shortcut past the real problem: finding *which* class registers a street/location-relevant property and *where* that property's callback ends up touching a `MapTrack`. That remains genuinely unlocated. **This closes out the `sub_171610` side-thread cleanly** - cont.16's conclusion (treat the core visible-street question as exhaustively searched and closed for this session) stands unchanged. + +## 2026-08-10 (cont. 18) — Answered directly: `TrackId`/`Location` aren't registered anywhere - they're plain prefab data fields, not `sub_3E8DA0` bindings +- **What**: Per user request, searched specifically for where `"TrackId"` or `"Location"` get registered for `MapTrack`. Found the `"TrackId"` string exists exactly once in `.rodata` (`0xa19218`) with **zero code cross-references** to it - consistent with what `RefreshEvents` (`sub_369040`, cont.9) already showed: the actual usage builds the string byte-by-byte on the stack rather than referencing a shared literal. Combined with `sub_3684CC`'s known implementation (an FNV-hash lookup into a per-*instance* dictionary stored on the calling object itself, not a query against `sub_40E8E8`'s central registry), this confirms `TrackId`/`Location` are **generic deserialized data fields** in a per-object property dictionary populated whenever *any* prefab loads - not named handlers bound via `sub_3E8DA0`'s `RegisterHandler` pattern the way `"career"` was (cont.13). There is no single "registration site" for them to find, because they aren't behaviorally registered at all. +- While confirming this, traced `sub_3E8DA0`'s caller `sub_7814C` back to its own caller, `sub_78004` - and this turned out to be a significant, useful find in its own right: it registers the actual prefab-cache *path roots* the whole project has been relying on - `"published/data/cars"` (offset `+416`), **`"published/data/races"` (offset `+320` - the exact same offset our own `lan_event_injection.h` uses as `GetCacheContext(mapTrackThis) + 320`, now confirmed by name, not just by observed behavior)**, and then calls `sub_1F19A0` (the large function from cont.15/16), which itself registers `"published/prefabs/tracks/"` (loader callback `sub_1F39E0`, shared with an identical `"published/prefabs/garage/"` registration right after it) - i.e. the generic *file-path*-based prefab loader for track/scene content, a different registration axis entirely from the per-property one. +- **Why**: Directly requested by the user, to settle the specific question left open at the end of cont.17. +- **Snapshot**: N/A - static analysis only, no code changes. +- **Verification**: `"TrackId"`'s zero-xref status confirmed via direct search; `sub_78004`'s decompile shows the literal `"published/data/cars"`/`"published/data/races"`/(inside `sub_1F19A0`) `"published/prefabs/tracks/"` strings and their exact byte offsets, not inferred. +- **Outcome/Next**: Directly answers the user's question: **`TrackId`/`Location` have no registration site because they're passive data, not registered behavior.** Confirms `+320` = the `"published/data/races"` prefab-cache root by name (previously only known by observed offset behavior). Does not open a new lead on the visible-street mystery - if anything it reinforces cont.16's conclusion, since the *only* two prefab-path roots found in this whole cache-initialisation function are for cars and races/tracks, both already-explored territory, with nothing resembling a third "local city streets" root. Recommend treating both the `sub_171610` side-thread and the core visible-street question as closed unless a genuinely new angle presents itself. + +--- + +## 2026-08-10 (cont. 19) — Design pivot: lobby UX schema fixed in ARCHITECTURE.md §4a; car-upgrade screen correlated to `CarMod`, not `CarPart` + +- **What**: Project direction shifted from RE-driven investigation to product/UX design for the multiplayer lobby (network layer explicitly deprioritized to last stage per user). Settled on Scenario 1 (Compose/View overlay → create lobby → pick track → native car-select sub-flow → ready checkmark) over Scenario 2 (lobby cards on real street pins), since the latter depends on the now-paused [[visible-streets-investigation]]. Wrote the full `Lobby`/`LobbyPlayer`/`ReadyState` data schema into **ARCHITECTURE.md §4a** (new section), including the overlay entry-button decision (Compose/View, not native - consistent with §4's BitmapGraphics=pixels-only finding). +- Separately, live-inspected the in-game car-upgrade screen (2 slots, "ВЫБОР УЛУЧШЕНИЯ" list) per user request and correlated it against binary/data findings: it's driven by **`CarMod`/`CarDescription.Mods`** (`Name`/`Description`/`Price`/`Value`/`ModType`), confirmed via 7/7 exact price matches against `alfaromeo_4c_2012_desc.prefabs.sb`'s real `CarMod` entries (`TYRES_REINFLATING`, `CHASSIS_REINFORCED`, `BODY_IMPACT`, `CHASSIS_LIGHTWEIGHT`, `POWERTRAIN_POWERPACK`, `NITROUS_BURN`, `NITROUS_EARN`) - **not** the separate `im::app::car::CarPart`/`UpgradeParts` catalog flagged in the prior session, which has no live instance data in any checked file and appears unused/vestigial in this build (no `skipCost`/`orderTimeMinutes`-style timer-gating fields exist on `CarMod` at all, consistent with the user's recollection that `skipCost` is always 0). This also supersedes an earlier guess that `ModType`/`Category` was a shared grouping enum - real data shows it's a unique ID per specific mod option, not a category. +- **Why**: User-directed pivot toward UX design ("нужно сформировать дизайн мультиплеера"), then explicit request to correlate the live upgrade screen with binary findings ("Ты можешь увидеть этот экран сейчас в игре... исследуй этот экран и спсоставь это с тем что есть в бинарнике"). +- **Snapshot**: N/A - design doc + static/data analysis only, no code changes. +- **Verification**: Screenshot-vs-data price matching (7/7 exact), not guessed; `CarMod`/`CarDescription` struct schemas read directly from `NFSMW12MobileTools`-unpacked JSON. +- **Outcome/Next**: `LobbyPlayer.mods: [int|null, int|null]` (two `CarMod.ModType` slots) added to the schema per user request, for cross-player visibility (not balance computation - client resolves name/icon from the car's own already-loaded `Mods` catalog). Design schema is now considered fixed pending implementation; next work returns to the pre-schema task list (see cont.20). + +## 2026-08-10 (cont. 20) — Found the real native car-select screen: `CarSelectionWidget` + a full data-driven Flow graph (`garage_pre_event` → `garage_pre_event_part2` → `/race/race`) + +- **What**: Investigating design prerequisite #2 for Scenario 1 ("show the native car-select screen on demand"). Found `CarSelectionWidget` - a real RTTI-confirmed class (constructor `sub_35E5D4`, vtable `off_AAAD54`) registered by name in the same generic type-registry (`sub_38DA44`) previously used to find `MapTrack`'s factory, via factory wrapper `sub_395718`. Traced its origin to `game_cache/published/layouts/layouts.sb` (a single monolithic UI-layout database, loaded wholesale at startup by `sub_75E40`/`AppBootstrap`), where it's the `widget_class` of a layout node named `car_select_loadout` (folder `_tier_selection_support_layouts`). +- Found the actual Flow-graph screens that use this layout, in `game_cache/published/flow/menus/garage/` (part of a 164-file `/published/flow/` directory, also loaded at startup, alongside a real `FlowManager` class): **`garage_pre_event.sb`** (`screenId="RestrictedGarage"`, layout `car_select_new`, entered directly from `BACK→/menus/map_overworld`, i.e. this is what a map race-pin tap opens) transitions on `CONTINUE` to **`garage_pre_event_part2.sb`** (`screenId="RestrictedGarageLoadout"`, layout `car_select_loadout` - **confirmed to be the exact 2-slot upgrade screen investigated in cont.19**), which itself transitions on `CONTINUE` to `/race/race`. Screens, transitions, and popups (`NEED_CASH`, `FUEL_ALERT`, etc.) are all polymorphic, type-registered objects instantiated by name, the same generic pattern as everything else in this binary. +- **Why**: Directly requested by the user as the concrete next step after fixing the lobby schema ("начни с пункта 2" - the native car-select prerequisite from the user's own Scenario 1 breakdown). +- **Snapshot**: N/A - static/data analysis only, no code changes. +- **Verification**: `CarSelectionWidget` RTTI strings and registry entry read directly via decompile/xref, not inferred; layout/flow node names and transition graph read directly from `NFSMW12MobileTools`-unpacked JSON of the actual shipped `.sb` files, not guessed. +- **Outcome/Next**: This conclusively answers *what* needs to be invoked (the existing `garage_pre_event`/`garage_pre_event_part2` Flow screens - no need to build a new UI or call `CarSelectionWidget`'s constructor directly) and *why it already looks right* (it's the same screen normal races use, already class-filterable via `car_select_new`'s layout). **Not yet found**: `FlowManager`'s runtime "transition to node/screen by name" trigger function - the actual call our hook would need to invoke to jump into this graph on demand from the lobby overlay, bypassing the map-pin tap. `sub_15663C` (found via the `"FlowManager "` log-tag string) turned out to be a relative-path-resolution utility, not the transition trigger itself - a dead end for this specific sub-question, not the class. Next concrete step: find `FlowManager`'s public transition/goto API (likely dispatched via named `Outputs` events like `CONTINUE`, given the flow data's own shape). + +## 2026-08-10 (cont. 21) — Live hook on `CarSelectionWidget`'s factory installed and tested: never fires, even navigating to the exact target screen fresh - identification was likely wrong, hook reverted + +- **What**: Per user's explicit choice ("живой compiled-hook") to empirically find `FlowManager`'s transition-trigger call, added a temporary trace hook (`Hook_CarSelectionWidgetFactory`/`InstallCarSelectionWidgetFactoryTraceHook`, offset `0x395718`, same trampoline pattern as the other hooks in `main.cpp`) meant to capture `lr` via `__builtin_return_address(0)` when `CarSelectionWidget`'s factory runs, revealing the generic widget-dispatcher's address. Built, installed on the Pixel 6a, and confirmed via a continuous background `adb logcat` capture (started before app launch, to survive the log-eviction problem that hid earlier hook-install confirmations) that **all 4 hooks, including this one, installed successfully** (`"Installed CarSelectionWidget factory trace hook at 0xba119718..."`, matching `libapp_base(0xB9D84000) + 0x395718` exactly). +- Live-navigated a **fresh app launch** through the real in-game flow (map → МАККЛЕЙН → ПОБУДКА → confirm race → confirm car) all the way to the `car_select_loadout` screen (2 empty upgrade slots, visually confirmed via screenshot) - the exact screen this hook should fire for if `CarSelectionWidget` is really what backs it. **The hook never fired** - zero `"CarSelectionWidget factory hit"` log lines in the full captured session, despite the hook being confirmed installed and despite reaching the target screen from a clean process start (ruling out "already constructed earlier this session" as an explanation). +- **Why**: Directly executes the user's chosen next step from cont.20's two remaining options (live hook vs. further static tracing). +- **Snapshot**: Hook code was added to `launcher/mpcore/src/main/cpp/main.cpp`, built, and tested live, then **fully reverted via `git checkout --`** once the negative result was clear (`git status --short` empty on the `lan-event-injection-poc` branch) - no net changes left in the working tree, consistent with this project's standing practice for temporary RE-discovery hooks. +- **Verification**: Hook-install success confirmed by direct log line (not assumed); the negative result (never fires) was checked against a fresh, from-scratch app launch and a live, deliberate navigation to the precise target screen, not a stale/cached session - a real empirical negative, not an artifact of missed logs (solved the earlier log-eviction risk specifically by capturing continuously from before launch). +- **Outcome/Next**: The cont.20 identification of `sub_395718`/`CarSelectionWidget` as the widget backing the `car_select_loadout` screen is **not confirmed live and is probably wrong** - either the `"CarSelectionWidget"`/`"car_select_loadout"` property pairing found in `layouts.sb` belongs to a different, unrelated layout node than the one actually shown (the file has 39k+ `DATA_Elements`, so a coincidental name collision is plausible), or this specific screen's root widget is constructed through an entirely different class/path not yet identified. **Recommend not sinking further effort into identifying `FlowManager`'s exact transition-trigger call via this specific angle** - the cumulative effort-to-progress ratio on this narrow sub-question has been poor across cont.20-21. The higher-value, already-solid finding from cont.20 stands on its own regardless: the `garage_pre_event`/`garage_pre_event_part2`/`/race/race` Flow graph is real, data-driven, and is the right *target* to reach somehow - only the precise low-level *mechanism* for triggering it on demand (as opposed to via the existing map-pin tap path) remains unsolved. If this is revisited later, a more promising angle than chasing more factory functions would be to trace `FlowManager::Update` (`sub_777B8`, confirmed by name) and its neighboring calls (`sub_156530`/`sub_1571E0`/`sub_157058`/`sub_1577F4`) directly, or to hook at the `Outputs`/`CONTINUE` dispatch level referenced by `sub_184CC4`'s registrations instead of a specific widget's constructor. +- **Bonus, unrelated to RE**: while debugging on-device, found and fixed a GrapheneOS/Samsung device-testing obstacle worth remembering for future sessions - the Pixel 6a's `screen_off_pocket=1` system setting was causing a persistent "Защита от случайного касания" (accidental-touch-protection) guard overlay to intercept nearly every `adb input tap`, making UI automation extremely unreliable. Fixed via `adb shell settings put system screen_off_pocket 0`. Also useful: `adb shell wm dismiss-keyguard` reliably bypasses the lock screen without needing swipe-gesture coordinates when `deviceLocked=0`. + +## 2026-08-10 (cont. 22) — Solved point 2 via a different route: synthetic-touch replay through a new native<->Kotlin GameEvents bridge, fully automated map-load-to-car-select-loadout chain confirmed live + +- **What**: Per user's suggestion, abandoned the `FlowManager`-internals angle entirely in favor of replaying the *already-proven-working* tap sequence (map pin -> event card -> confirm race -> confirm car) programmatically instead of reverse-engineering the transition call. Built the general native<->Kotlin event bridge the user explicitly asked for, plus a first consumer: + - **`launcher/mpcore/src/main/cpp/game_events.h`** (new, header-only): caches `JavaVM*`/a global ref to `nfs.mod.mpcore.GameEvents` during `JNI_OnLoad` (classloader-safe - `FindClass` only ever called from the properly-scoped `JNI_OnLoad` thread, never from an arbitrary later-attached native thread), then exposes `FireMapLoaded()`/`FireRaceStarted()`/`FireRaceEnded()` which `CallStaticVoidMethod` into Kotlin, handling attach/detach for whichever native thread calls them. + - **`launcher/mpcore/src/main/java/nfs/mod/mpcore/GameEvents.kt`** (new): `GameEventListener` interface (`onMapLoaded`/`onRaceStarted`/`onRaceEnded`, all default no-op) + `GameEvents` object as the register/dispatch hub, called from native via the `@JvmStatic dispatch*()` methods. + - **`launcher/mpcore/src/main/java/nfs/mod/mpcore/GameInput.kt`** (new): a `SyntheticInputDispatcher` interface + `GameInput.dispatcher` holder - needed because `mpcore` is a separate Gradle module `app` depends on (not vice versa), so it can't reference `GameActivityMain` by type without a circular module dependency; `GameActivityMain` implements the interface and hands itself in instead. + - **`GameActivityMain.kt`**: added `dispatchSyntheticTap(x, y)` - synthesizes `ACTION_DOWN`/`ACTION_UP` `MotionEvent`s directly into `gameGLSurfaceView.dispatchTouchEvent()`. + - **`launcher/mpcore/src/main/cpp/lan_event_injection.h`**: `Hook_MapTrackHandleEvent` now calls `FireMapLoaded()` once (static bool guard) on the first `EVENT_TYPE_FLOW_SET_LAYOUT_SCREEN` callback - a coarse but working "map screen is up" proxy, since a real map-screen-lifecycle hook hasn't been found. + - **`launcher/mpcore/src/main/java/nfs/mod/mpcore/CarSelectLoadoutTestTrigger.kt`** (new, explicitly throwaway/test-only per the user - "не должна быть UX-friendly"): registers a `GameEventListener` whose `onMapLoaded()` schedules 4 hardcoded taps (map pin, event card, confirm race, confirm car) 2s apart starting 3s after the map loads, via `Handler.postDelayed`. +- **Bug found and fixed along the way**: the first end-to-end run produced a fully clean log trail (JNI bridge init -> listener registered -> `dispatchMapLoaded` fired from the game's own GLThread -> all 4 taps dispatched -> `dispatchTouchEvent` returned `consumed=true` for every one) **but nothing happened on screen**. Root cause, found by reading `GameGLSurfaceView.java`: `onTouchEvent()` branches on `event.getSource()` (`SOURCE_TOUCHSCREEN`/etc.) before forwarding to the native touch handler (`nativeTouchScreenEvent`/`nativeTouchPadEvent`) - a plain `MotionEvent.obtain(...)` defaults to `SOURCE_UNKNOWN` (0), matching neither branch, so the View layer "consumes" the event (returns `true`, satisfying `dispatchTouchEvent`'s contract) while silently dropping it before it ever reaches the game engine. Fixed with one line per event: `down.source = InputDevice.SOURCE_TOUCHSCREEN` (and same for the `ACTION_UP` event). +- **Why**: Direct implementation of the user's explicit request/idea - trigger the car-select screen via a delay-after-map-load instead of chasing `FlowManager`'s internal API, plus build it as a reusable general event-bridge class rather than a one-off. +- **Snapshot**: All changes are additive new files + small hook-site edits, left in place (not reverted) since this is a working building block, not a dead-end trace hook. `git status` on `launcher` shows the new/modified files, not yet committed. +- **Verification**: Fully live-verified, twice - first run reproduced the `SOURCE_UNKNOWN` bug consistently (clean logs, zero visible effect); after the one-line fix, a from-cold-boot run (fresh install, fresh process) produced the exact same clean log trail **and** landed correctly on the `car_select_loadout` screen (2 empty upgrade slots, screenshot-confirmed) with **zero manual taps** - the whole map-load -> pin -> event -> race-confirm -> car-confirm chain played automatically. +- **Outcome/Next**: **Point 2 (show the native car-select screen on demand) is now solved end-to-end**, via touch-replay rather than a FlowManager API call - arguably a more robust approach anyway, since it only depends on the already-proven, stable UI touch path rather than an unversioned internal engine function. Known limitations of the current *test* wiring, to fix before this becomes a real feature: (1) tap coordinates are hardcoded to one device/resolution/save state - a real implementation needs to resolve target coordinates at runtime (e.g. from the injected event's actual `MapTrack` widget screen-space bounds) rather than hardcoding; (2) it currently always targets the one fixed LAN-injection career slot (`kTargetGroupName`) and the specific "ПОБУДКА" event on it - needs generalizing to an arbitrary lobby-selected track once dynamic `trackId` (subtask/point 1) is wired up; (3) `onMapLoaded`'s trigger (first `MapTrack::HandleEvent` call) is a coarse proxy, not a real screen-shown hook. `onRaceStarted`/`onRaceEnded` are scaffolded in `GameEvents` but have no real native trigger wired up yet - next candidates if pursued: the race-FSM load functions from `RaceLoaderTask_LoadRaceFSM`/`RaceLoaderTask_DispatchInitialFSMEvents` (already named, cont. from much earlier sessions) for `onRaceStarted`, and whatever shows the post-race results screen for `onRaceEnded`. + +## 2026-08-10 (cont. 23) — De-hardcoding tap coordinates: found a real bounds rect and a real "scroll" camera entity via static RE, but live-crashed trying to read it - paused, hook disabled + +- **What**: Per user's explicit request ("убери хардкод координат, читай реальные bounds MapTrack-виджета"), dumped `MapTrack`'s full memory live and found a consistent 4-float block at instance offsets `+0x44/+0x48/+0x4C/+0x50` = `{left, top, right, bottom}` - same ~721x244 size across all 12 career-placeholder `MapTrack`s, only position differs (table of all 12 in the session transcript). Empirically confirmed this rect is **not** simple screen-pixel space (values up to ~3880, camera-independent, don't match known on-screen tap positions under any simple linear hypothesis) - it's world/layout-space, consistent with the map being a tilted/perspective 3D scene rather than a flat pannable 2D canvas. +- User confirmed the game has **only one map** (no separate region-select screen), ruling out an earlier hypothesis that the `region1_foothills`/etc.-named rects belonged to a different, unvisited screen. +- Static RE (per user's explicit direction, "искать camera/projection карты") found a strong lead: `im::app::flow::nfs::MapScreen` (RTTI-confirmed, ctor `sub_1781BC`, vtable `_ZTVN2im3app4flow3nfs9MapScreenE`/`off_A9F420`) registers a named layout entity **`"scroll"`** via `sub_1332B8` (a generic `FindOrCreateLayoutEntity(registry, name)` - same shared Transform-based object family as `MapTrack`/`CarSelectionWidget`, base ctor `sub_4D39D0`), storing the raw entity pointer at `MapScreen+0x1D0`. Since this entity shares the same Transform layout (position floats at instance+36/+40, scale at +44/+48) already reverse-engineered from `MapTrack`'s own base class, it's a strong candidate for the map's live pan/zoom state - no separate `"zoom"`-named entity was found nearby, so scale (+44/+48) may double as zoom. +- **Bug/incident**: added a temporary hook on `sub_1781BC` (`InstallMapScreenCtorTraceHook` in `main.cpp`) to capture the live `MapScreen*` into a new global (`g_mapScreenInstance`), plus a read of the "scroll" entity's position/scale in `Hook_MapTrackHandleEvent` (`lan_event_injection.h`). This **reproducibly crashed the game** (`SIGSEGV`, fault addr `0x0`, in the GLThread) on every launch, before ever logging a single successful hook fire - confirmed via isolation (disabling just this one hook, `kEnableMapScreenCtorTraceHook = false`, removed the crash entirely on a clean relaunch; everything else - including the already-working `onMapLoaded` automated tap chain from cont.22 - stayed stable). Root cause not yet found: `sub_1781BC` decompiles to a genuinely massive (~62,000-char) function - likely a broader screen-setup/orchestration routine, not a small self-contained constructor - and the crash could stem from something more subtle than the trampoline pattern itself (which is identical to 5+ other hooks that work fine elsewhere in this codebase). The sibling function sharing the same vtable symbol, `sub_17B8FC`, was checked and is MapScreen's **destructor** (resets vtable to base, releases ~15 chained shared_ptr members) - not a safer alternative hook point. +- **Why**: Direct continuation of the user's own explicit two-part request this session (de-hardcode taps; if that needs deeper work, go after the camera/projection system rather than stopping at "screen coordinates don't match"). +- **Snapshot**: `InstallMapScreenCtorTraceHook`/`Hook_MapScreenCtor`/`g_mapScreenInstance` and the scroll-entity read in `Hook_MapTrackHandleEvent` are left in the source, **but disabled** (`kEnableMapScreenCtorTraceHook = false`) - the currently-built/installed APK on the test device is the stable (hook-off) variant. The still-enabled `MapTrack` rect dump (`+0x44..+0x50`, unconditional per HandleEvent call) remains active and harmless (never crashed on its own). +- **Verification**: Crash reproduced twice in a row with the hook on (100% repro on this device/build), then confirmed absent on a clean rebuild+relaunch with only that one hook flipped off - a real isolated cause, not a guess. +- **Outcome/Next**: The `"scroll"` entity lead is genuine and well-supported by static evidence, but actually reading it live has an unresolved native crash blocking it. This has now cost a full rebuild/install/crash-debug cycle without reaching a working coordinate transform, on top of the earlier failed 1:1-scale and world-canvas hypotheses from earlier in cont.23. Recommend a checkpoint with the user before continuing further: either (a) debug the `sub_1781BC` hook crash itself (unclear how deep - could be a one-line fix or a genuinely awkward hook target), or (b) find a different, safer way to reach a live `MapScreen*` (e.g. hook a smaller *method* that takes `MapScreen* this` as an existing parameter, rather than its huge constructor - `sub_1817A8`/`sub_18184C`/`sub_1818F0`/`sub_181A38` from the `boost::bind` setup code earlier in this investigation all reference `MapScreen`-typed callbacks and are much smaller, untried), or (c) pause the fully-dynamic-coordinate goal here and fall back to the already-working hardcoded-coordinate test chain (cont.22) as a documented interim limitation. + +## 2026-08-10 (cont. 24) — Found the real coordinate transform safely (no crash), got within a real pin's hit-area of the target but not exact yet + +- **What**: Per user's request, checked `sub_1817A8`/`sub_18184C` first - both decompile to pure `boost::_bi::bind_t` type-erasure "manager" functions (opcode-dispatched clone/destroy/typeid-compare/get-typeinfo on the *wrapper's own small-buffer storage*), never touching a live `MapScreen` instance. Confirmed dead end, not pursued further. +- Instead, recognized that `sub_17C120` - the function `sub_1781BC`'s crash was trying to reach data from - is **already hooked and proven stable all session**: it's `MapScreen`'s per-frame `Tick`, the exact function `InstallSoakTestDisableHook` (much earlier session) already wraps safely. Decompiling it in full for this task revealed camera-clamping code operating directly on **`MapScreen+316`/`+320`** (float scroll x/y, no separate "scroll" entity needed after all) and two rect-bearing pointers at **`MapScreen+456`** ("content", the full scrollable map bounds) and **`MapScreen+464`** ("viewport", the visible window) - both using the *same* 4-float rect shape (indices 17-20) already found on `MapTrack`. +- Extended the existing, already-safe `Hook_MapScreenTick` (in `lan_event_injection.h`) to log these three pieces of data once, ~1s after a `MapScreen` starts ticking (letting the camera settle) - **confirmed zero crashes** across a fresh install+launch. Live values captured: `scroll=(1557.0, 1622.5)`, `content=[-3, 0, 4280, 3159]`, `viewport=[0, 0, 1993, 928]`. +- Tested the simplest hypothesis - plain translation, `screen = world - scroll`, no extra scale factor (justified by `content`'s ~4280x3159 span and `viewport`'s ~1993x928 span both being roughly screen-pixel-scale, not wildly different orders of magnitude) - against `kTargetGroupName`'s (`"region1_foothills_track1"`) already-known rect (`left=2990 top=1995 right=3713 bottom=2238`, center `(3351.5, 2116.5)`): predicted tap point `(1794.5, 494.0)`. Live-tapped that exact point on the actual device. +- **Result: landed inside a real pin's hit-area (ПЕТЕРСОН СТРИТ's event-list screen opened) - not empty space, but not the intended target either.** This is a meaningfully positive result (the transform is in the right neighborhood, not off by an order of magnitude or a wrong axis), but not yet precise/unambiguous enough to reliably hit the intended (likely much smaller, currently-invisible/locked) target pin specifically, especially if hit-areas from different pins overlap in screen space. +- **Why**: Direct continuation of the user's chosen next step (try the smaller `sub_1817A8`/`sub_18184C` functions first), which led to recognizing the safer, already-proven `Tick` hook as the better vehicle once those two were ruled out. +- **Snapshot**: `Hook_MapScreenTick` in `lan_event_injection.h` now includes the one-shot scroll/content/viewport logging (harmless, gated behind a `tickCount > 60` + one-shot bool, same file already committed to logging patterns elsewhere in this hook). `kEnableMapScreenCtorTraceHook` remains `false` (the crashing hook from cont.23 is still disabled, unused code left in place but inert). +- **Verification**: Crash-free across a fresh install+launch (checked directly, not assumed); the transform hypothesis was tested by an actual live tap at the computed coordinate, not just eyeballed - a real (if not fully conclusive) empirical result. +- **Outcome/Next**: Very close, not yet finished. Open questions before this is a solid, reusable "get real screen coordinates for any MapTrack" primitive: (1) is the remaining offset a genuine small transform error (missing a scale factor, a viewport-origin offset, or the ~227px/~152px right/bottom UI-chrome margins noted between `viewport` (1993x928) and the full device screen (2220x1080) not being accounted for), or (2) is it actually correct and the mismatch is because `kTargetGroupName`'s hidden/locked pin and the real "ПЕТЕРСОН СТРИТ" pin's hit-areas simply overlap at that point, and Android/the engine resolved the tap to whichever pin was checked/on top first? Recommend a follow-up empirical pass: dump ПЕТЕРСОН СТРИТ's *own* screen-space hit-rect too (would need a hook that fires for real streets, still not found - see cont.20/21's dead end on that specific point) to check for overlap directly, or simply try a small grid of nearby taps around `(1794.5, 494.0)` to see if a different nearby point lands on the actual injected "LAN: Test Lobby" card instead. + +## 2026-08-10 (cont. 25) — Course correction: synthetic touch-replay ruled out for production; found the real `FlowNode::FireOutput`-equivalent primitive instead + +- **What**: User explicitly corrected the direction this investigation had drifted into: synthetic taps (cont.22-24) are only valid as an RE/verification tool ("ходить по меню и смотреть изменения") - never as the actual production trigger mechanism, since a real multiplayer feature can't depend on hardcoded/calibrated screen coordinates, camera state, or device resolution ("с точки зрения мультиплеера это ад"). This retires the whole coordinate-calibration sub-effort (cont.23/24) as a *test-only* artifact, not a step toward the real implementation - correctly refocuses back to cont.20/21's original goal: find `FlowManager`'s actual internal transition-trigger API. +- Resuming that search, re-examined `sub_1BB59C` (seen earlier inside `MapScreen::Tick`/`sub_17C120`'s handling of a `"NEXT_EVENT"` string) - it's a 2-instruction adjustor thunk (`LDR R0,[R0,#8]; B sub_1581A0`), forwarding to **`sub_1581A0(flowNode, outputNamePtr, ctx)`** with `MapScreen+8` (a Flow-node object, not `MapScreen` itself) as the real first argument. +- Decompiled `sub_1581A0` in full: guards against a transition already being queued (`flowNode+256`/`+260`/`+52`/`+44`), then calls `sub_159684(*(flowNode+28), outputNamePtr)` to check whether the node's schema actually defines an output with that name; if yes, **queues** the transition request (stores the name + a refcounted context arg into `flowNode+56.. +64`, sets `flowNode+52=1`) for `FlowManager::Update` to process on a later frame; if no, logs `"Node has no output for . Ignoring"` - a literal, unambiguous confirmation this is the engine's own `FlowNode::FireOutput(name)` (or equivalent) primitive, the exact same mechanism that executes `Outputs["CONTINUE"] -> "garage_pre_event_part2"`-style transitions already seen in the `.sb` flow-graph data (cont.20). +- **Why**: Direct continuation of the (re-corrected) goal - a real, coordinate-independent way to trigger Flow-screen transitions programmatically. +- **Snapshot**: Static analysis only, no code changes this entry. +- **Verification**: Read directly from Hex-Rays decompile of `sub_1581A0`/`sub_1BB59C`, including the literal `"Node "`/`" has no output for "`/`". Ignoring"` log-string reconstruction, which is about as close to ground-truth confirmation as static analysis gets without a live test. +- **Outcome/Next**: This is likely the real trigger primitive Point 2 has been looking for since cont.20. Not yet done: (1) decompile `sub_159684` (the has-output-named-X check) to fully understand the node-schema layout at `flowNode+28`; (2) confirm what a valid `ctx`/`a3` argument needs to look like (currently unclear if it can be null/zero or needs a real object); (3) identify which currently-reachable Flow node (e.g. `MapScreen`'s own node via `+8`, confirmed reachable through the already-safe `g_mapScreenInstance`) actually has a usable output name defined that leads toward `garage_pre_event`/the target screen, since `MapScreen`'s own node likely only has outputs for things reachable directly from the map (not necessarily a direct path to a specific race's garage screen) - may need to fire a chain of outputs across multiple nodes rather than one single call. No live testing done yet on this specific call - next step before attempting a hook is finishing the static read of `sub_159684` and the node-schema layout, given this session's repeated crash incidents from hooking under-understood functions. + +## 2026-08-10 (cont. 26) — Found `MapOverworld`'s real Outputs, including a demo-leftover shortcut straight into car-select - full chain confirmed from real shipped data + +- **What**: Decompiled `sub_159684` (the has-output-named-X check inside `sub_1581A0`) - a plain balanced-BST (`std::map`-shaped) key lookup comparing a name-hash against tree node keys, confirming `Outputs` is exactly the `map` structure already seen in every `.sb` flow file - nothing unexpected, closes out the low-level mechanics of `sub_1581A0`/`FireOutput`. +- Found and unpacked `game_cache/published/flow/menus/map_overworld.sb` (the `MapOverworld` screen - `im::app::flow::nfs::MapScreen`'s own Flow definition) and extracted its full `Outputs` map (26 entries). Most are unsurprising (`GARAGE`->garage grid, `STORE`, `OPTIONS`, popups, etc.), but three stand out: **`EVENT`/`NEXT_EVENT` -> `event_detail`** (the normal path, tapping a pin), and **`GAMESCOM_EVENT` -> `garage/garage_select_car`** - a leftover trade-show-demo shortcut that jumps *directly* into car selection, skipping event/track selection entirely. +- Unpacked `garage/garage_select_car.sb`: confirmed `screenId="RestrictedGarage"`, `layout="car_select_new"` - **identical shape to `garage_pre_event.sb`** (cont.20), i.e. this genuinely is a real, playable car-select screen, not a stub. Its own `CONTINUE` output leads to `garage_select_rollout`. +- Unpacked `garage/garage_select_rollout.sb`: confirmed `screenId="RestrictedGarageLoadout"`, `layout="car_select_loadout"` - **this is conclusively the exact target screen** (2 upgrade slots, matched against the live screenshot back in cont.19/20). Its own `CONTINUE` leads straight to `/race/race`. +- **Full confirmed chain, from real data, zero guessing**: `MapOverworld --[GAMESCOM_EVENT]--> garage_select_car --[CONTINUE]--> garage_select_rollout --[CONTINUE]--> /race/race`. If `GAMESCOM_EVENT` still fires correctly in this build, this reaches the target screen in **one single `FireOutput` call from the map screen**, without ever needing to pick a specific event/street first - a much better fit for a lobby feature than the normal per-event path anyway. +- **Why**: Direct continuation of the user's instruction to keep investigating statically after `sub_1581A0` was found. +- **Snapshot**: Static/data analysis only, no code changes. +- **Verification**: All screenIds/layouts/outputs read directly from `NFSMW12MobileTools`-unpacked JSON of the real shipped `.sb` files (`map_overworld.sb`, `garage_select_car.sb`, `garage_select_rollout.sb`) - not inferred or guessed. `garage_select_car`/`garage_select_rollout`'s screenId/layout pairing is an exact match to the already-live-verified screens from cont.19/20. +- **Outcome/Next**: Static case for the trigger mechanism is now about as strong as it can get without a live test. Remaining unknowns before attempting a hook: (1) whether `GAMESCOM_EVENT` is still wired to anything live in this build (demo-only code paths sometimes get stripped or gated behind a flag - genuine risk, unverified) - if dead, the normal `EVENT`/`NEXT_EVENT` -> `event_detail` path is the fallback (would need to also learn `event_detail.sb`'s own outputs to keep chaining toward `garage_select_car`, not yet unpacked); (2) what `sub_1581A0`'s third argument (`ctx`/`a3`, a `{namePtr, refcountedObj}`-shaped pair) needs to contain - nothing found yet indicating it can safely be null; (3) confirming `MapScreen+8` is really the right node to call `FireOutput` on for these top-level map outputs specifically (traced via the `sub_1BB59C` thunk used for `"NEXT_EVENT"` in `MapScreen::Tick`, so at least that one case is solid precedent). Recommend a careful, isolated live test next (mirroring the `Tick`-hook safety approach from cont.24, not a risky new hook) once `ctx` semantics are better understood. + +## 2026-08-10 (cont. 27) — `FireOutput`'s exact calling convention confirmed byte-for-byte; `ctx` can be null for the common case; reuses an already-known function + +- **What**: User confirmed live that `GAMESCOM_EVENT` is dead in the current build (not reachable in normal play) - noted as a future cleanup candidate (strip from the `.sb` files), and deprioritized as the trigger path; focus shifted to fully understanding `sub_1581A0`'s third argument (`ctx`) instead, since that's needed regardless of which output ends up being fired. +- Found 53 call sites to the `sub_1BB59C` "fire output" thunk (confirming it's a pervasive, generic mechanism, not MapScreen-specific) and picked the smallest, cleanest one - `sub_1A44BC` (0xdc bytes) - which fires `"CONTINUE"`. Its **raw disassembly** (not just Hex-Rays pseudocode, which mis-inferred the argument count for this register-passing thunk) gives the exact, confirmed calling convention: + ``` + R0 = screen/node "this" + R1 = pointer to an *interned* name slot (NOT a raw C-string pointer - + must call sub_406644(&slot, "NAME") first, exactly matching this + project's own already-wired InternString/INTERN_STRING_OFFSET=0x406644 + from lan_event_injection.h) + R2 = pointer to a 2-word ctx buffer + BL sub_1BB59C ; = FireOutput(node, internedNamePtr, ctx) + ``` + For `"CONTINUE"` specifically, the real shipped code **explicitly zeroes both ctx words** (`MOV R0,#0; STR R0,[ctx]; STR R0,[ctx+4]`) before the call - i.e. **`ctx = {0, 0}` is a real, live-used, safe value for at least this common no-payload case**, not a guess. +- **Why**: Direct continuation of the user's request to fully understand the `ctx` argument before any live test is attempted. +- **Snapshot**: Static analysis only, no code changes. +- **Verification**: Read directly from raw ARM disassembly (register-level), not Hex-Rays pseudocode (which under-reported the argument count here) - the most concrete confirmation level available short of a live test. +- **Outcome/Next**: The `FireOutput` primitive is now fully specified and ready to call from a hook: `FireOutput(node, &internedName, ctx)`, where `InternString` is a function this project already has typed and wired (`InternStringFn`, `INTERN_STRING_OFFSET`). Remaining before a live attempt: outputs that carry real payload data (like `"EVENT"`, which needs to say *which* event/street was tapped) almost certainly need a non-null `ctx` - `{0,0}` is confirmed safe only for payload-free outputs like `"CONTINUE"`/`"BACK"`. Since `GAMESCOM_EVENT` is confirmed dead, the practical next question is what `"EVENT"`/`"NEXT_EVENT"` on `MapOverworld` actually need in `ctx` to identify a target - worth checking a call site that fires one of *those* specifically (not yet located) before the first live test, to avoid guessing a payload shape. + +## 2026-08-10 (cont. 28) — Found the exact "fire EVENT with a real payload" call - reuses our own already-working LAN-injection machinery end-to-end + +- **What**: User confirmed `GAMESCOM_EVENT` is dead in the live build (future cleanup candidate: strip from `.sb` files) and asked to find a real payload-carrying call site for `"EVENT"` specifically. Found 4 standalone `"EVENT"` string literals plus a distinct `"EVENT ID "` log-prefix string; the log string's only xrefs all point to one small function, **`sub_17A99C`** (0x140 bytes) - decompiled in full: + ``` + sub_17A99C(a1 /*screen*/, a2 /*event handle/key*/): + Log("EVENT ID " + a2) // debug log only + ResolveHandle(&v16, GetCacheContext(a1)+320, &a2) // = sub_173350 - THE SAME function + // and +320 cache offset already + // used by our own InjectSyntheticEvent + wrapper = sub_161A40(malloc(0x10), v16) // builds a small event-context object + // from the {RaceEvent*, tag, Actor*} triple + sub_240294(wrapper, v16) // extra setup on the wrapper + ctxBox = { vtable=off_A9F5F0, ref=1, ref=1, ptr=wrapper } // standard refcounted-wrapper pattern + InternString(&nameSlot, "EVENT") // = sub_406644, already wired in this project + FireOutput(a1, &nameSlot, &ctxBox) // = sub_1BB59C + ``` + I.e. **`sub_17A99C(mapScreenNode, eventHandle)` is a ready-made, complete "fire the EVENT output for this specific event handle" function** - no need to hand-assemble the ctx wrapper ourselves at all, just call this one function with the same kind of `handle`/`key` our own `InjectSyntheticEvent` already produces (e.g. the `key=0xc0ffee00` used for the current synthetic "LAN: Test Lobby" event). + - Also decompiled `sub_161A40` (the wrapper constructor) far enough to flag a risk: it dereferences the resolved `Actor*` (`ResolveResult.actorPtr`) and walks its component chain, with defensive (non-fatal-looking, log-and-continue) null checks logging `"Dereferencing a NULL component pointer."`/`"...actor has been deleted."` if that's missing - meaning **our synthetic event's `actor` parameter (already a real argument `InjectSyntheticEvent` accepts, see `lan_event_injection.h`) needs to be a real, valid Actor pointer**, not null, for this path to behave cleanly - not yet verified what value we currently pass there. +- **Why**: Direct continuation of the user's request to find a real (non-`{0,0}`) `ctx` example, specifically for `"EVENT"`. +- **Snapshot**: Static analysis only, no code changes. +- **Verification**: Read directly from Hex-Rays decompile of `sub_17A99C`/`sub_161A40`; the reuse of `sub_173350`/`+320`/`sub_406644` isn't inferred - these are the exact same offsets/functions already declared and used successfully in `lan_event_injection.h` (`ResolveHandleFn`, `GET_CACHE_CONTEXT_OFFSET`, `RESOLVE_HANDLE_OFFSET`, `INTERN_STRING_OFFSET`). +- **Outcome/Next**: This closes essentially the entire remaining gap for Point 2's real trigger mechanism. The live-test plan is now concrete: call `sub_17A99C(g_mapScreenInstance, )` (e.g. `0xc0ffee00`) from a hook, which should fire `"EVENT"` with a correctly-built context pointing at our own already-injected LAN event, landing on `event_detail` for it - no touch simulation needed anywhere in the chain. Before attempting this live: (1) check what `actor` value `InjectSyntheticEvent`/`Hook_MapTrackHandleEvent` currently passes for the synthetic event, since `sub_161A40` wants a real one; (2) `sub_240294` (the "extra setup on the wrapper") wasn't decompiled yet - worth a quick check it's not doing something load-bearing we'd be skipping by not replicating it exactly (though calling `sub_17A99C` directly rather than hand-assembling avoids this concern entirely, since it's included automatically). + +**Note for this session only (not a durable project fact - do not carry into memory/docs):** a second Android device (a Pixel 6, unrelated project) may show up in `adb devices` output alongside the Galaxy A9 used for this project - always target the correct device explicitly (e.g. `adb -s `) rather than assuming a single-device default, to avoid accidentally operating on the wrong phone. + +## 2026-08-10 (cont. 29) — Live test #1: `RaceEvent+12` null-deref found and fixed via exact backtrace/disasm + +- **What**: User gave the explicit go-ahead ("живой тест, и да прибудет с тобой сила") to wire up and run `sub_17A99C`/`FireEventOutput` for real. Wired `FIRE_EVENT_OUTPUT_OFFSET 0x17A99C` into `lan_event_injection.h`, extended the existing Soak-Test `Hook_MapScreenTick` (already ticking every frame, already resolving the live `MapScreen*` into `g_mapScreenInstance`) with a one-shot timer: after 120 ticks, call `FireEventOutput(mapScreen, 0xC0FFEE00)` for our own already-injected synthetic "LAN: Test Lobby" event. + - First run crashed, SIGSEGV fault addr `0xc`. User asked to test a refcount hypothesis first (`sub_161A40` bumps `*(actorPtr+8)`) - raised `FakeActor::refcount` 1→2, crash persisted at the identical address, hypothesis disproved, reverted to 1. + - Real root cause found via exact backtrace + raw ARM disasm at the faulting PC (not just decompile, which had mis-attributed the read): `sub_27D1EC` unconditionally reads `*(a1+12)` with no null check. `RaceEventCtor` (`sub_2A4B58`) deliberately zeroes `RaceEvent+12` at construction - it's normally filled later by the prefab-deserialization pipeline, which a synthetic/injected `RaceEvent` never goes through. + - **Fix**: in `InjectSyntheticEvent`, `calloc(1, 16)` a dummy buffer and point `RaceEvent+12` (`RACEEVENT_CATEGORYTAG_OFFSET`) at it. Verified by the fault address moving from `0xc` to a new value on retest, confirming the first crash site was actually cleared rather than coincidentally still failing nearby. +- **Why**: Direct continuation of cont.28's plan - first real end-to-end exercise of the production trigger path. +- **Snapshot**: `lan_event_injection.h` - added `FIRE_EVENT_OUTPUT_OFFSET`/`FireEventOutputFn`, `RACEEVENT_CATEGORYTAG_OFFSET` + the calloc'd dummy in `InjectSyntheticEvent`, and the one-shot test call in `Hook_MapScreenTick`. +- **Verification**: Live on-device (Galaxy A9, `adb -s 2a40cdac1f0d7ece`), logcat backtraces + IDA disasm at the exact crash PC. +- **Outcome/Next**: One crash down, more expected - continue fixing them one at a time rather than trying to fully pre-populate every field a real prefab-loaded `RaceEvent` would have. + +## 2026-08-10/11 (cont. 30) — MILESTONE: `FireEventOutput` completes successfully; two more crashes found and fixed in unrelated subsystems; a further crash in Flow-transition processing found but not yet fixed - work paused here on user's call + +- **What**: Continued the "fix crashes one at a time" loop from cont.29, per explicit user direction each round ("Проверь actor в InjectSyntheticEvent" → "Сначала гляни sub_240294" → "Try again" → "Продолжай чинить это конкретное аудио-поле"): + - **Crash #2** (fault addr `0x8`, inside `im::app::sounds::CopSounds::Tick` = `sub_304AA0`, confirmed via vtable range `0xAA9260`-`0xAA92A8` containing the return address `0xAA9290`): an ambient global audio subsystem, unrelated to our RaceEvent, reading component data our synthetic actor doesn't have. Rather than trying to satisfy its data requirements, applied the same mitigation already used for the pre-existing Soak Test hook: hooked `sub_304AA0` (`COPSOUNDS_TICK_OFFSET`) to a **complete no-op** (`Hook_CopSoundsTick`, does not call the original). Installed unconditionally from `JNI_OnLoad` via `InstallCopSoundsTickSkipHook()`. + - One retest still crashed at the same address with the hook's install log line mysteriously missing (while 3 sibling hook-install logs were present that run). Added a diagnostic log at function entry, rebuilt, confirmed on the next run the hook installs correctly every time - treated as a one-off transient/timing artifact, not a real bug. + - **Crash #3** (fault addr `0x8`, inside `sub_240548`, reached from `sub_240294`): a component-name-cache lookup (instance-pointer → cached-name-string). Has a safe early return (`if (!*a2) return off_AC80E0;`), but on a cache miss (expected - our synthetic RaceEvent was never registered) falls through to a "build an error string from RTTI class name" fallback that crashes inside a generic low-level container/RTTI utility (`sub_66DB94`/`sub_66FFB0`). **Fix**: hooked `sub_240548` (`GET_COMPONENT_NAME_OFFSET`) to unconditionally return the function's own existing safe sentinel value (`EMPTY_STRING_SENTINEL_OFFSET` = `off_AC80E0`, the same value the function itself would return on its `!*a2` fast path) - bypasses the crashing fallback without calling the original. Installed unconditionally via `InstallGetComponentNameSkipHook()`. + - After both fixes: **`FireEventOutput` completed and returned successfully for the first time** ("LIVE TEST: FireEventOutput returned without crashing" logged) - this is the actual goal of the whole cont.25-29 arc: a real engine call, not touch simulation, successfully firing a named Flow output. + - **Crash #4** then appears ~0.4s *after* that successful return, in a new function `sub_2C62A0` (fault addr `0x0`, offset `0x2C6374`). Timing strongly suggests this is `FlowManager::Update` processing our now-queued transition on a later frame, not `FireEventOutput` itself. Decompiled `sub_2C62A0`: familiar `GetComponent` (`sub_8FF8C`, already known-safe) pattern repeated several times against our synthetic `Actor`/`RaceEvent+4`, ending in an unguarded triple-dereference (`*(_DWORD*)(*(_DWORD*)(*(_DWORD*)(a1+48)+44)+152)`) as the leading suspect - **not yet confirmed via disasm-at-exact-PC, not yet fixed**. + - User then called a stop ("Останавливаемся") to bank progress here rather than continue chasing crash #4/#5+. +- **Why**: Systematic root-cause-then-fix loop, continuing until the user chose to stop; each crash found via the same successful methodology (real backtrace + disasm at the exact fault PC, not guessing from decompile alone). +- **Snapshot**: `lan_event_injection.h` - `Hook_CopSoundsTick`/`InstallCopSoundsTickSkipHook`, `Hook_GetComponentName`/`InstallGetComponentNameSkipHook` added; `main.cpp` - both installed unconditionally from `JNI_OnLoad`. `Hook_MapScreenTick`'s one-shot test call from cont.29 unchanged. +- **Verification**: Live on-device (Galaxy A9), logcat backtraces at each new fault address, screenshots per user's explicit "Скринами проверяй" instruction. +- **Device notes (session-only, not carried to memory)**: hit an adb device-disconnect (`kill-server`/`start-server` fixed it), a screen stuck in `Dozing`/display-off that no adb command could wake (required a physical power-button press by the user), and a stale crash dialog blocking a subsequent clean launch (cleared via `KEYCODE_BACK` + `am force-stop`). +- **Outcome/Next (paused here on user's explicit "Останавливаемся")**: + 1. Resume by pinning down crash #4's exact faulting instruction via `disasm` at `0x2C6374` (same technique as crashes #1/#3) - the leading suspect is one of the three chained dereferences off `a1+48`, most likely because `a1` (looks like the Screen/FlowManager-side object, not our synthetic object) expects a fully-populated scene/UI hierarchy that a real, prefab-loaded transition would have and ours doesn't. + 2. This crash sits *after* the actual "fire the output" call already succeeded - so the core Point-3-prerequisite goal (a real, non-touch production trigger for Flow transitions) is functionally proven; what remains is making our specific synthetic/incomplete `RaceEvent`+`Actor` survive the rest of the transition pipeline, which may mean more of the same "find the exact null deref, fix or skip-hook it" loop, or could mean the synthetic object needs to satisfy more of the pipeline's expectations up front instead of patching each consumer reactively - worth deciding which approach once crash #4 is understood. + 3. Once a transition fully completes, verify the game visibly reaches `garage_select_car`/`car_select_loadout` via screenshot (per standing "Скринами проверяй" instruction), matching the already-confirmed real Flow chain from cont.26. + 4. Standing constraint remains in force: touch/tap simulation stays RE-verification-only; `FireEventOutput`/`FireOutput` is the correct, now largely-proven production path. + +## 2026-08-11 (cont. 31) — MILESTONE: reached the real Event Details screen for our synthetic event via `FireEventOutput` alone, no crash, screenshot-verified; found and corrected a PC-correlation bug from cont.30 + +- **What**: User asked to add a diagnostic hook for crash #4 and rebuild. Installed `Hook_Sub2C62A0` (entry hook on `sub_2C62A0`, logging `a1`/`a2` and each step of the `a1+0x30 -> +0x2C -> +0x98` chain before calling the original) - live test showed the crash still occurred (same fault addr `0x0`, same 4 "Dereferencing a NULL component pointer"/"...actor has been deleted" fatal log lines beforehand) but **the diagnostic hook's own log line never fired** - proving `sub_2C62A0` was never actually entered before the crash, i.e. cont.30's identification of the crash site was wrong. + - Root cause of the misidentification: cont.30 took the tombstone's self-reported `backtrace: #00 pc 002c6374` as if it were directly usable as an IDA file offset. It isn't - that value is relative to whatever specific VMA/segment mapping debuggerd's unwinder resolved, not to `libapp_base`. The correct file offset is `(raw PC register) - (our own resolved libapp_base)`; using this run's own logged values (`libapp_base=0xb9d9d000`, tombstone `pc ba3cc374`) gives **`0x62F374`** - a completely different, unrelated address, off by a page-aligned `0x369000` from the wrong value. + - Decompiling `0x62F374` (function `sub_62F340`) showed a hand-optimized SWAR `strlen()` implementation; the crashing line is `*(_DWORD*)v1` reading the first word of the input pointer with no null check - a plain **`strlen(NULL)`**, matching fault addr `0x0` exactly. This is a generic leaf routine used everywhere in the binary, almost certainly reached while the engine tries to build an RTTI/class-name debug string for our under-registered synthetic actor - the same family of issue as cont.29/30's `sub_240548` fix, but a different call site not covered by that guard. + - **Fix**: replaced the (now-disproven) `sub_2C62A0` hook with `Hook_Strlen`/`InstallStrlenNullGuardHook` at `STRLEN_OFFSET 0x62F340` - a minimal `if (!s) return 0;` guard before falling through to the real implementation for every other (real) string. Leaf function, no `PUSH`, so the trampoline just relocates `MOV R1,R0; TST R0,#3`. + - **Live result**: rebuilt, installed, ran. Logcat showed the same 4 fatal-but-non-crashing log lines as before, then **`Strlen null-guard: called with NULL, returning 0 instead of crashing`** (our hook catching the real fault), then the game continued: loaded a large batch of new layout entities (`event_details`, `event_frame`, `event_details_speedwall*`, `medal_targets_event_details`, `speedwall_header`/`footer`, etc.), logged `A.N. produce offset for race selection screen`, played the `ui/ui/event_menu_in` transition sound - **and did not crash**. `dumpsys activity activities` confirmed `GameActivityMain` still resumed/foregrounded (not bounced back to launcher). + - **Screenshot verification** (per standing "Скринами проверяй" instruction): confirms the game is showing the real **Event Details screen**, titled `XXXXX[LAN: TEST LOBBY]XXXXX` - our own synthetic event's name, rendered by the game's own UI (the `XXXXX[...]XXXXX` wrapping is the engine's own missing-localization-key marker, not something we added - further proof this is genuine engine-driven rendering, not a mock). Shows medal reward tiers (+$250,000 gold/silver/bronze), an empty speedwall/leaderboard table, and a confirm-race checkmark button. Track name shows a leftover placeholder (`XXXXX[REVERSED_REGION2_DESERT_TRACK3]XXXXX`, cosmetic - we never set a track name field on the synthetic event) rather than a crash. +- **Why**: Direct continuation of "поставь диагностический хук и пересобирай"; the diagnostic hook did its job precisely by producing a clean negative result that overturned the previous session's (wrong) hypothesis, rather than by confirming it. +- **Snapshot**: `lan_event_injection.h` - removed `Hook_Sub2C62A0`/`InstallSub2C62A0DiagHook`, added `Hook_Strlen`/`InstallStrlenNullGuardHook` (`STRLEN_OFFSET 0x62F340`); `main.cpp` - `InstallStrlenNullGuardHook()` replacing the removed diag-hook call in `JNI_OnLoad`. +- **Verification**: Live on-device (Galaxy A9, `adb -s 2a40cdac1f0d7ece`), logcat, and a pulled screenshot (`screencap` -> `adb pull`) showing the real Event Details screen for the synthetic event. +- **Outcome/Next**: **This is the actual achievement of the Point-3 prerequisite goal**: a fully native, non-touch production trigger (`FireEventOutput`) drives the game from the map screen all the way into a real Flow-rendered gameplay screen for a synthetic/injected event. Remaining work is incremental, not architectural: + 1. From here, the already-confirmed real chain (cont.26) continues `event_detail` -> (confirm race) -> `garage_select_car` -> (confirm car) -> `garage_select_rollout`/car-select-loadout -> `/race/race`. Advancing past `event_detail` needs the same kind of output-firing call (not touch) - likely another `sub_17A99C`-style or direct `FireOutput` call for whatever output name the confirm-race button fires, still to be identified. + 2. The cosmetic track-name placeholder is a simple data-completeness gap (set `RaceEvent`'s track-name field on the synthetic event), not a correctness bug - low priority relative to advancing the Flow chain further. + 3. Standing constraint remains in force: touch/tap simulation stays RE-verification-only; `FireEventOutput`/`FireOutput` is the correct, now proven-in-production path. + 4. Methodological note for future crash diagnosis: **always compute crash-site file offsets as `(raw tombstone PC register) - (our own logged libapp_base)`, never take the tombstone's self-reported `backtrace: #00 pc ...` line at face value** - it can be relative to a different memory mapping and silently point at the wrong function. + +## 2026-08-11 (cont. 32) — Fixed the "XXXXX[...]XXXXX" missing-localization marker: our synthetic event's name and track now render as clean text + +- **What**: User asked why the event card showed `XXXXX[LAN: TEST LOBBY]XXXXX` and requested substituting real strings so it displays correctly. Static search for the marker (literal "XXXXX" strings, UTF-16 byte patterns, nearby localization-sounding functions/strings like `Locale::`, `StringId`, "Place Holder Multilang") found nothing conclusive - several dead ends (ToS-URL-by-locale function, Flow-output-name popups that only *look* like loc keys, an unrelated `OptionsSettingsScreen`-specific placeholder check). + - Switched to a **live, timing-correlated diagnostic** instead of more static guessing: hooked `InternString` (`sub_406644`, offset already known/used) to log every string interned during a budgeted window right after `FireEventOutput` fires, and separately hooked `sub_87738` (the shared "fatal-style log" leaf function used everywhere for `"Dereferencing a NULL component pointer."` etc.) to log its **caller's return address** via `__builtin_return_address(0)` - since `sub_87738` itself is generic/shared, only its caller identifies which higher-level function is doing what. + - The `InternString` trace showed our raw `"LAN: Test Lobby"` gets interned, immediately followed by a `sub_87738` call from a caller whose address (after correcting for `libapp_base`, per cont.31's methodology note) resolved to **`sub_162F14`**, specifically the call `sub_40A29C(&v105, ctx, RaceEvent+RACEEVENT_EVENTNAME_OFFSET)` - i.e. exactly the site reading our own event-name field. `sub_40A29C` is a 1-line wrapper around **`sub_40A2B0`**, decompiling which revealed the real mechanism: it interns the given key, looks it up in a string table (`sub_40A580`); on a **hit** it copies the found text; on a **miss** it either returns empty text (if a debug flag at `context+32` is false) or builds `L"XXXXX[" + rawText + L"]"` - **the `L"XXXXX["` literal is real** (an earlier `find_bytes` search for `"58 00 58 00 58 00"` (UTF-16 "XXX") had actually already turned it up at `0xa1f1b4`, but was wrongly dismissed as coincidental without checking). + - Since our synthetic `RaceEvent`'s name/track fields were never registered as real localization-table keys (they never go through the normal prefab/loc pipeline), every lookup misses and takes the wrapped-raw-text path. +- **Fix**: rather than reverse-engineer `sub_40A580`'s table-registration format (unknown hash/prefab structure) or reimplement `sub_40A2B0`'s EASTL wide-string-building logic, hooked `sub_40A2B0` to call the **original function unmodified**, then post-process its already-built output buffer: if it starts with `"XXXXX["`, `memmove` the inner text over the prefix (shrink-only, no realloc) and shorten the container's end pointer - the begin pointer (the real allocation base) is never touched, so a later `free()` on it stays safe. Skips (leaves as-is) if the bracket content is empty, so a genuinely-unset field still visibly reads as "not filled in" rather than silently becoming a blank string. This is a small, generic fix (not specific to our event) - any raw-text fallback anywhere in the game benefits. +- **Snapshot**: `lan_event_injection.h` - added `Hook_InternStringDiag`/`InstallInternStringDiagHook` and `Hook_FatalLogCallerTrace`/`InstallFatalLogCallerTraceHook` (temporary diagnostics, left in place but harmless/budget-gated), and `Hook_ResolveDisplayText`/`InstallResolveDisplayTextHook` (`0x40A2B0`, the actual fix); `main.cpp` - all three installed unconditionally from `JNI_OnLoad`. +- **Verification**: Live on-device (Galaxy A9). Screenshot after the fix shows the event card title as clean `LAN: TEST LOBBY` and the track line as clean `REVERSED_REGION2_DESERT_TRACK3` (both previously wrapped). One remaining `XXXXX[]XXXXX` on a bottom action bar is a field we never populate at all (empty inner content, correctly left alone per the fix's own skip condition) - not a regression, just an unset field made visible instead of silently blanked. +- **Outcome/Next**: Cosmetic goal achieved. No further action expected unless the user wants the still-empty bottom-bar field populated too (would need identifying which RaceEvent/context field that specific widget binds to, same investigative pattern as this entry). + +## 2026-08-11 (cont. 33) — Filled the remaining empty bottom-bar field: found it was an out-of-bounds read past our undersized categoryTag buffer, not a missing binding + +- **What**: User asked to fill the still-empty `XXXXX[]XXXXX` bottom action bar from cont.32. Extended the same live-diagnostic technique: added a budget-gated log to `Hook_ResolveDisplayText` for the *empty-content* case (logging `key`/caller), and a second hook directly on `sub_40A29C` (the tiny wrapper around `sub_40A2B0`) since `sub_40A2B0`'s own caller-trace always resolves to the wrapper's epilogue, not the real higher-level call site - hooking one level up exposes both the real caller and the field POINTER itself (not just its already-dereferenced null value). + - First pass: `key=0x0` (a genuinely null pointer, not just an unregistered raw string) from caller `sub_40A29C+0x10`-ish, i.e. reached via the wrapper as expected. + - Second pass (hooking `sub_40A29C` directly, diffing the field pointer against a newly-added `g_lastSyntheticRaceEvent` global): the field pointer was **nowhere near our RaceEvent** (nonsensical `RaceEvent+4813788` offset) - meaning it isn't a RaceEvent field at all, so the earlier framing ("which RaceEvent field is this bound to") was the wrong question. + - The caller resolved (same `libapp_base`-correction methodology as cont.31/32) to `sub_162F14` again, specifically the block: `v56 = *(_DWORD*)(v54+12); ...; sub_40A29C(&v105, v52, v56+28);` where `v54 = v2[76]` is our RaceEvent and `+12` is `RACEEVENT_CATEGORYTAG_OFFSET` - **our own cont.29 fix**, a `calloc(1, 16)` dummy buffer pointed at from `RaceEvent+12` to stop an earlier crash. This code reads `*(categoryTagPtr + 28)` - **12 bytes past our 16-byte buffer's end** - undefined behavior that happened to read adjacent zeroed heap memory (hence null, not a crash) rather than the real category-tag struct's actual "label" field, which apparently lives at that offset in genuine, prefab-loaded objects. +- **Fix**: enlarged the `categoryTag` buffer from 16 to `CATEGORYTAG_BUFFER_SIZE` (64) bytes - eliminates the out-of-bounds read entirely - and populated `CATEGORYTAG_LABEL_OFFSET` (28) with a real interned string (`"LAN RACE"`). Since this is raw (unregistered) text like the event name, it goes through the exact same lookup-miss path from cont.32 and gets the `XXXXX[...]` wrapper automatically stripped by the already-installed `Hook_ResolveDisplayText` fix - no new display logic needed. +- **Snapshot**: `lan_event_injection.h` - `RACEEVENT_CATEGORYTAG_OFFSET` comment extended, added `CATEGORYTAG_BUFFER_SIZE`/`CATEGORYTAG_LABEL_OFFSET`; `InjectSyntheticEvent` now callocs 64 bytes instead of 16 and writes the interned `"LAN RACE"` pointer at `+28`; added (as temporary diagnostics, budget-gated, left in place) `Hook_ResolveDisplayText`'s empty-case log, `g_lastSyntheticRaceEvent` global, and `Hook_ResolveDisplayTextWrapperDiag`/`InstallResolveDisplayTextWrapperDiagHook` on `sub_40A29C` (`0x40A29C`). +- **Verification**: Live on-device (Galaxy A9). Screenshot confirms the bottom bar now reads clean `LAN RACE` text, no crash, no more `DIAG sub_40A29C NULL field` log line. +- **Side observation (not a regression, not acted on)**: the same screenshot's track-name line reverted to `XXXXX[]XXXXX` (previously showed a coincidental `REVERSED_REGION2_DESERT_TRACK3` in cont.32's screenshot). `RaceEvent`'s track-name field (`RACEEVENT_TRACKNAME_OFFSET`) has never been intentionally set by `InjectSyntheticEvent` - the earlier text was uninitialized/adjacent heap memory that happened to contain that string, and enlarging the `categoryTag` allocation shifted the heap layout enough that the coincidence no longer holds. Confirms that field's prior content was never reliable in the first place; left alone since it wasn't part of this request. +- **Outcome/Next**: Both text fields the user has pointed out so far (event name, bottom action bar) now show intentional, correct text. If the track-name gap matters later, it needs the same treatment: pick a real offset on `RaceEvent` for the track name and populate it intentionally (currently unset, `ANALYSIS.md §6y` marks `RACEEVENT_TRACKNAME_OFFSET 72` as a plain eastl-style string field), rather than relying on incidental memory contents. + +## 2026-08-11 (cont. 34) — Track-name subtitle: populating RACEEVENT_TRACKNAME_OFFSET did NOT fix it; extensive follow-up investigation found no working lead - left unresolved, user OK'd stopping + +- **What**: User asked to fill the track-name subtitle row too (cont.33's noted gap). Populated it the "obvious" way first: `RaceEvent+72/+76/+80` ({begin,end,capacity} raw-text field, format already confirmed correct by existing debug-dump code in `Hook_MapTrackHandleEvent`) with `kTargetGroupName` ("region1_foothills_track1", the real MapTrack this event is attached to). **Live-tested: no visible change** - the row still showed `XXXXX[]XXXXX`, proving the on-screen widget does not read this field at all (the cont.32/33 screenshots' `REVERSED_REGION2_DESERT_TRACK3` text was never coming from `RaceEvent+72` - it was incidental/adjacent heap memory content the whole time, as cont.33 already suspected). +- **Follow-up investigation (four independent techniques, all dead ends)**: + 1. **Widened live diagnostics**: extended `Hook_ResolveDisplayText` to also log the *other* lookup-miss branch (plain-empty result, no `"XXXXX["` marker - the `!*(context+32)` path inside `sub_40A2B0`), and broadened `Hook_ResolveDisplayTextWrapperDiag` (on `sub_40A29C`) to log every call unconditionally instead of only null-field ones. Raised budgets to 3000. Result: the **entire burst only contains ~26 real `sub_40A29C` calls**, and 100% of them are already-known (event name at `RaceEvent+88`, `categoryTag+24` null, `categoryTag+28` "LAN RACE") or unrelated ambient HUD/achievement strings (`AUTOLOG_*`, `UI_ONLINE`, `POST_RACE_NEW_PERSONAL_BEST`, etc. from one repeated, unrelated caller). **The track-name row never calls `sub_40A29C`/`sub_40A2B0` at all** - confirmed twice, at two different budget sizes, ruling out "budget too small" as the explanation. + 2. **Static layout-file archaeology**: found `layouts.sb.json` already unpacked in `NFSMW12MobileTools/` (1.6M lines, 4431 named widget entities). Located `event_details`'s root layout entry and confirmed `_name_header`/`_progress_bar` (seen erroring as "Unable to locate layout entity" in live logs) **don't exist anywhere in the file** - i.e. they're normal, harmless misses that happen for every event (real or synthetic), not a lead. Manually walking the full nested entity tree to find the specific subtitle widget wasn't tractable given the scale (thousands of entities, deep nesting via `DataIdsMap`/hex-id cross-references). + 3. **Traced `sub_162F14`'s real scope**: re-examining its full decompile (already in hand) showed it's entirely about the **speedwall/leaderboard section** (medal placement + cash reward text, car-class-restriction string building, player-name/score row template) - event name is read there too, but only incidentally (for a "Speedwall for [event]"-style label), not because this function owns the screen's top header/subtitle. + 4. **vtable sibling exploration**: `sub_162F14` is reached only via a vtable slot (`0xa9ed30`, xref-confirmed), not a direct call - found and batch-decompiled ~10 neighboring vtable slots (`0x162adc`, `0x162da0`, `0x162e5c`, `0x162e60`, `0x162ebc`, `0x1a70bc`, `0x1a70d4`, `0x1a7754`, `0x1a77ec`, `0x1a7890`) hoping to find a sibling "populate header section" method. All turned out to be **generic widget-lifecycle boilerplate** (destructor/refcount teardown, `OnEvent` with an `"EVENT"` string-match check, `HandleAnimationEvent`-style dispatch, a sound-trigger callback) - i.e. this is a normal component base-class vtable, not a family of section-populator methods as hypothesized. Dead end. + - Also caught and corrected a real **Hex-Rays misdecompilation** along the way: `sub_162F14`'s pseudocode shows `sub_4D913C(v9, &v105)` as a 2-argument call, but raw disassembly of `sub_4D913C` itself proves it only reads `R0` (`PUSH{R4,R10,R11,LR}; MOV R4,R0; ...; STR R0,[R4,#0x70]` - never touches `R1`) - it resets some unrelated field to a fixed empty-string sentinel (`off_AC80E0`) and has nothing to do with rendering the resolved text. Confirms (again, see cont.31) that Hex-Rays' argument-count inference for these register-passing ARM calls cannot be trusted without cross-checking raw disasm. +- **Conclusion**: the subtitle text is not sourced from `RaceEvent+72`, is never requested through the `sub_40A29C`/`sub_40A2B0` resolve-and-display pipeline that successfully drives the other two (now-fixed) fields, and its actual widget/binding could not be identified via static layout data, function-scope tracing, or vtable-neighbor inspection. Most likely explanation: it's a **conditionally-shown** element (e.g. gated on a "reversed track" or similar flag/field our minimal synthetic `RaceEvent` never sets) whose populating code path simply never runs for this event - not a matter of a lookup returning empty, but of the lookup never being attempted. Confirming that would require a fundamentally different technique (live debugger attach - already documented elsewhere in this project as flaky on ARM32 - or brute-force auditing of many more candidate functions with no strong hypothesis left to narrow the search). +- **Decision**: presented this status to the user directly; they confirmed it's low priority and to stop here rather than continue open-ended searching. +- **Snapshot**: `lan_event_injection.h` - `InjectSyntheticEvent` now populates `RACEEVENT_TRACKNAME_OFFSET`/`RACEEVENT_TRACKNAME_CAPACITY_OFFSET` with `kTargetGroupName` (harmless, correctly-formatted, just not what the screen reads - left in place since it's still a reasonable value for whatever *does* eventually consume that field, e.g. the debug-dump logging that already reads it). All cont.32-34 diagnostic hooks (`InstallInternStringDiagHook`, `InstallFatalLogCallerTraceHook`, `InstallResolveDisplayTextWrapperDiagHook`) remain installed but budget-gated/inert unless a budget global is manually raised above 0 again for a future investigation. +- **Outcome/Next**: **Stopping point, by user agreement.** Final state: event name and bottom action bar both show correct, intentional text (cont.32/33); the track-name subtitle row remains an unresolved `XXXXX[]XXXXX` placeholder - a known, low-priority cosmetic gap, not a functional blocker. If revisited later, the most promising unexplored angle is a live debugger breakpoint on `sub_40A2B0`/`sub_4D8ABC`-family functions during the transition (despite documented ARM32 debugging flakiness on this project), since static/live-hook tracing has now been exhausted without success. + +## 2026-08-11 (cont. 35) — MILESTONE: advanced past event_details to garage_select_car ("confirm race") entirely natively; root-caused why the first synchronous-fire attempt silently failed + +- **What**: User asked to advance the Flow chain one more step - "confirm race" (event_details' own checkmark) into `garage_select_car` - warning in advance that confirming the car afterward is known to crash (from their own prior touch-based testing). + - Confirmed the real Output data first: `event_detail.sb.json`'s own `Outputs` map (`0300`) shows `output="EVENT"` → `node="garage/garage_select_car"` - the exact same output *name* (`"EVENT"`) as `MapOverworld`'s, just scoped to a different screen's Flow node. Hypothesis: firing `FireOutput` again, this time on the *event_details* screen object instead of the map screen, with a payload-free `ctx={0,0}` (matching the `CONTINUE`-style pattern), should reach `garage_select_car`. + - **Needed a pointer to the live `event_details` screen** - unlike `MapScreen`, it has no dedicated Tick hook. Added `Hook_LayoutScreenCtor` on `sub_1A571C` (`LayoutScreen::LayoutScreen`, already fully decompiled in cont.32 - confirmed via live diagnostic to construct *every* named-layout screen: `SplashScreen`, `UpdateCheck`, `TutorialCheck`, `MapOverworld`, `EventDetails`, `RestrictedGarage`, etc., each identifiable by `*(const char**)(a1+4)`) to capture whichever screen is constructed right after the first `FireEventOutput` succeeds. + - **First attempt failed silently**: firing `FireOutput` synchronously from inside `Hook_LayoutScreenCtor`, right after `event_details` finished constructing, returned without crashing but produced **no visible transition** (confirmed via two screenshots, one taken 1s and one 6s after the fire - screen never changed). Diagnostic confirmed the captured screen WAS correctly `"EventDetails"` (not a wrong/nested object), ruling out that explanation. + - **Root cause**: firing too early/reentrant - still inside the tail end of the *first* transition's own construction callback. Also newly confirmed: `MapScreen::Tick` (the hook previously used for timing the first fire) **stops firing once `event_details` is on top of the screen stack** - a tick-count-based delay (as used for the first fire) isn't available for the second one. + - **Fix**: deferred the fire using **wall-clock time** instead of a tick counter or immediate synchronous call - `Hook_LayoutScreenCtor` now just records the target screen pointer + a `CLOCK_MONOTONIC` timestamp; the actual `FireOutput` call happens from inside `Hook_InternStringDiag` (confirmed live to keep firing every ~0.25s on `event_details`' own active update loop, unlike `MapScreen::Tick`) once ≥1.5s of real time has passed. +- **Live result**: **Success.** `LIVE TEST 2: FireOutput returned without crashing`, immediately followed by `DIAG LayoutScreenCtor: screenName="RestrictedGarage"` - the exact `screenId` confirmed back in cont.20 for `garage_select_car.sb`. Screenshot confirms the real **car select screen**: a Dodge Challenger SRT8 392, class filter ("КЛАСС - ВСЕ"), price ($17,000), stat bars (speed/acceleration/handling), prev/next car arrows, and a confirm checkmark - reached entirely through native `FireOutput` calls, zero touch simulation, continuing the standing production-path constraint. +- **Snapshot**: `lan_event_injection.h` - added `FireOutputFn`/`FireOutput` (raw `sub_1BB59C` primitive, `FIRE_OUTPUT_OFFSET 0x1BB59C`), moved `InternStringFn`/`InternString`'s declaration earlier in the file (needed by `Hook_InternStringDiag` now), added `Hook_LayoutScreenCtor`/`InstallLayoutScreenCtorHook` (`LAYOUTSCREEN_CTOR_OFFSET 0x1A571C`) with its screen-name diagnostic log, added `g_firedEventTest`/`g_firedSecondEventTest`/`g_secondEventTarget`/`g_secondEventTargetSetAt`/`MonotonicMillisSince()` globals, and the deferred-fire block inside `Hook_InternStringDiag`. `main.cpp` - `InstallLayoutScreenCtorHook()` added to `JNI_OnLoad`. +- **Verification**: Live on-device (Galaxy A9, `adb -s 2a40cdac1f0d7ece`), logcat + pulled screenshot (`screencap`/`adb pull`) confirming the real garage/car-select screen. +- **Outcome/Next**: Confirmed real Flow chain (cont.26) now live-verified two hops deep: `map_overworld` → (EVENT) → `event_detail` → (EVENT) → `garage_select_car`. Per the user's advance warning, the *next* hop - confirming the car selection, into `garage_select_rollout`/car-select-loadout - is expected to crash; that's the next thing to chase, using the same "find real Output name, fire on the right screen with the right timing" methodology now proven twice over. The wall-clock-deferred-fire-via-an-already-firing-hook pattern (rather than tick-counting or firing synchronously) is now the established technique for advancing through screens that lack their own dedicated Tick hook - worth reusing directly for the next hop instead of rediscovering it. + +## 2026-08-11 (cont. 36) — Advanced to garage_select_rollout (the car-upgrade-loadout screen, the original goal); root-caused the next crash as real-race-loading needing actual scene data + +- **What**: Continued the chain one more hop: `garage_select_car.sb.json`'s own Outputs confirmed `output="CONTINUE"` → `node="garage_select_rollout"`. Extended the same deferred-fire pattern (capture screen in `Hook_LayoutScreenCtor`, fire from `Hook_InternStringDiag` after ≥1.5s) for a third hop. +- **Live result**: **Success** - `DIAG LayoutScreenCtor: screenName="RestrictedGarageLoadout"` (the exact `screenId` for `garage_select_rollout.sb`, confirmed back in cont.20), and a screenshot showing the actual **car upgrade/loadout screen**: two upgrade slots ("ПУСТО (УЛУЧШ.)"), a start-race flag button ("ВСЕГО 0$"), the confirmed car rendered with plates. This is literally the original screen this entire investigation arc was chasing (the "2 upgrade slots" UI from the very first request in this session), reached three hops deep entirely via native `FireOutput`, zero touch. +- **Next hop attempted and crashed as warned**: `garage_select_rollout.sb.json`'s own `output="CONTINUE"` → `node="/race/race"` - the actual race-loading transition. Fired it (fourth hop); it crashed on a background thread (`Thread-10`, not the GLThread) ~4s later. + - Crash-site methodology (per cont.31's rule: always use `raw tombstone PC - our own logged libapp_base`, never the tombstone's self-reported offset): `pc=0xba04e744`, this run's `libapp_base=0xb9da2000` → real offset `0x2AC744`, inside a function named **`RaceLoaderTask_ResetStartingLine`**. + - Log immediately before the crash showed three real engine assertions firing: `Assertion failed (m_StartLine)`, `Assertion failed (m_FinishLine)`, `Assertion failed (m_EndOfTrack)` - all inside `OnSceneLoaded*` callbacks. Decompiling the crash site showed *why* these aren't immediately fatal but crash later anyway: each missing locator is logged as a warning (matching the project's now-familiar "Dereferencing a NULL component pointer" pattern) but the code **doesn't stop using the resolved pointer afterward** - `sub_690A94(v77, *(_DWORD*)(*(_DWORD*)(a1[23] + 4) + 64))` dereferences `a1[23]` unconditionally a bit later, and `a1[23]` was left null exactly because the `m_StartLine` locator was never found (fault addr `0x4` matches `NULL+4` precisely). + - **Root cause, one level up**: these locators (`m_StartLine`/`m_FinishLine`/`m_EndOfTrack`) are resolved from the **actual 3D track scene's own named locator objects** (`sub_672D64`/`sub_870E8`) - real race loading needs a genuinely loaded track scene, which our synthetic `RaceEvent` has never referenced (no real `TrackId`/scene reference was ever set - a long-standing open question in this project, noted since cont.18: "`TrackId`/`Location` aren't registered anywhere - they're plain prefab data fields"). This is architecturally different from every crash fixed so far (all of which were `RaceEvent`/`Actor` component-completeness gaps patchable with a calloc'd buffer or a skip-hook) - it needs an actual track scene loaded, not a bigger dummy buffer. +- **Decision point**: reported the crash and root cause to the user and asked whether to continue chasing it or stop, having reached the original target screen. + +## 2026-08-11 (cont. 37) — Pivoted to controlled scene exit instead of chasing race-loading; found and fixed a real use-after-free bug in our own code along the way + +- **What**: User redirected the goal: crashing on this scene is unacceptable regardless of cause. Production behavior should be: on confirming the car, **controllably exit back to the map** (not proceed into race loading), eventually recording accepted upgrades and opening a lobby overlay (both explicitly deferred - not needed yet, just noted as follow-up work). Also asked two forward-looking questions: (1) can `car_select` be opened without going through the event/stakes menu, and (2) can a race be launched directly from the map/track. Answered both inline: (1) untested but plausible in principle since Flow nodes are addressable by name regardless of path, though `garage_select_car`'s `Restricted=true`/`GarageType=prerace` properties suggest it expects context normally set up by `event_detail`'s own confirm handler - real risk of new missing-context crashes, needs live testing to confirm; (2) irrelevant to the crash just found, since `RaceLoaderTask_ResetStartingLine`'s missing-scene-locator crash is about race loading itself lacking a real track reference, independent of which UI screens were visited to get there - the real `TrackId` fix (cont.36) is needed regardless of navigation shortcuts. +- **Implementation**: found the real, already-shipped **"BACK"** output on each screen (from their own `.sb` Outputs data) forms exactly the exit chain needed - the same path a player pressing back 3 times would take: `garage_select_rollout` --BACK--> `garage_select_car` --BACK--> `event_detail` --BACK--> `map_overworld`. Reusing the game's own real, already-battle-tested back-navigation code is safer than inventing a new exit path. + - **Refactored** the by-then-three-times-hand-duplicated deferred-fire stages (cont.35/36's `g_secondEventTarget`/`g_thirdEventTarget`/`g_fourthEventTarget` etc.) into one generic mechanism: a `kOutputChain[]` array of output names (`"EVENT"`, `"CONTINUE"`, `"BACK"`, `"BACK"`, `"BACK"`) plus a single `g_chainIndex`/`g_chainTarget`/`g_chainTargetPending` state, walked one entry at a time by `Hook_LayoutScreenCtor` (capture) and `Hook_InternStringDiag` (deferred fire) - same proven timing pattern, no more duplicated blocks, and trivial to extend/reorder for future hops. +- **New crash found and fixed - in our own code, not the game's**: firing the full 5-step chain hit a SIGSEGV whose backtrace named `Hook_MapTrackHandleEvent+336` directly (inside our own `libmpcore.so`, not `libapp.so` - no offset-correction ambiguity this time, the symbol resolved cleanly). Root cause: navigating back to `map_overworld` destroys and reconstructs the real `MapScreen`/`MapTrack` objects, re-firing `MapTrack::HandleEvent` (the same hook that originally drove `InjectSyntheticEvent`) - and a **leftover TEMPORARY debug block** (from the long-abandoned tap-coordinate-calibration investigation, cont.23/24, explicitly ruled out as a production approach back in cont.25's "тапы не нужны" correction) dereferenced `g_mapScreenInstance` - a global captured *once* by `Hook_MapScreenTick` on the *first* map visit and never invalidated - now pointing at the **freed** original `MapScreen` object. Classic use-after-free. **Fix**: deleted both leftover TEMPORARY dump blocks (the MapTrack bounds-rect dump and the `g_mapScreenInstance` scroll-entity dump) from `Hook_MapTrackHandleEvent` entirely - their whole purpose (calibrating tap coordinates) was already obsolete, and removing them eliminates the crash at the source rather than patching around it. Also removed the now-fully-unused `MAPSCREEN_SCROLLENTITY_OFFSET` constant. +- **Live result**: **Full success.** All 5 chain steps fired cleanly (`LIVE TEST CHAIN[1]` through `[5]`), ending with `DIAG LayoutScreenCtor: screenName="MapOverworld"` and **no crash**. Screenshot confirms a fully healthy, interactive map screen (pins, nav bar, our injected event marker still present) - the complete cycle `map → event → car select → loadout → controlled exit → map` now works end-to-end, natively, crash-free. +- **Snapshot**: `lan_event_injection.h` - removed the per-hop `g_secondEventTarget`/`g_thirdEventTarget`/`g_fourthEventTarget`/`g_firedSecondEventTest`/etc. globals and their three hand-duplicated `Hook_LayoutScreenCtor`/`Hook_InternStringDiag` blocks, replaced with `kOutputChain[]`/`g_chainIndex`/`g_chainTarget`/`g_chainTargetPending`; deleted the two TEMPORARY debug-dump blocks (and `MAPSCREEN_SCROLLENTITY_OFFSET`) from `Hook_MapTrackHandleEvent`. +- **Verification**: Live on-device (Galaxy A9), full logcat capture across all 5 hops, pulled screenshot confirming the final map-screen state. +- **Outcome/Next**: The car-confirm → controlled-map-return cycle is now solid and crash-free - the practical foundation for the real multiplayer flow. Remaining work, in priority order: (1) persist the player's accepted car upgrades before/during the exit (not yet investigated - need to find where confirmed mods are read/written), (2) open the lobby overlay after returning to the map (explicitly deferred by the user), (3) if actual race-loading is wanted later, the `RaceLoaderTask_ResetStartingLine` crash (cont.36) needs a real `TrackId`/scene reference on the synthetic `RaceEvent` - a materially bigger investigation than anything solved so far, since it requires understanding how a real track scene gets loaded and referenced, not just patching a data-completeness gap. (4) The two open design questions (car_select without event_detail; direct race launch from the map) remain untested hypotheses, not yet worth pursuing until (1)-(2) are done. + +## 2026-08-11 (cont. 38) — Found the real mod-selection event and handler: `UIRolloutSelectedEvent` → `sub_37BE74`/`sub_37BF34`; confirmed selection commits instantly, no separate "confirm" step exists + +- **What**: User asked to find where the engine reads the selected upgrades on confirm (item (1) from cont.37's remaining-work list). Static C++ archaeology first (chasing `im::app::car::CarDescription`/`im::app::metagame::Profile`+`ProfileChangeSet` boost::bind RTTI strings through `sub_256188`→`sub_256028`→vtable `0xaa5f48`→`sub_252A9C`→`sub_251DBC`) dead-ended on a generic "subscribe to component change-event" utility with nothing mod-specific - same unproductive pattern as cont.34's subtitle search. Pivoted to a live/empirical approach instead, per this project's standing RE-verification-via-touch allowance (see `PROGRESS.md` cont.25/[[project_visible_streets_investigation]] methodology precedent): real `adb shell input tap` (not the project's own disabled synthetic-tap infra) on the live loadout screen, observed via the existing `Hook_InternStringDiag`/`Hook_FatalLogCallerTrace` diagnostics (budgets raised 3000/5000 → 8000/8000). +- **Live discovery**: opening a mod slot showed the real `CarMod` catalog on-screen - `НЕТ`(free)/`ВОССТ. ШИНЫ`(TYRES_REINFLATING, 1000$)/`ЗАЩ. ОТ СТОЛКН.`(1000$)/`УСИЛЕННОЕ ШАССИ`(1500$) - matching the previously-confirmed `CarMod` schema. Selecting an option updates the slot display **and** the total cost immediately - confirms selection commits instantly, there is no separate "confirm" action to hook. +- **Methodology hazard caught mid-investigation**: an early offset computation used a `libapp_base` value logged from a **different app launch** than the one that produced the caller address (ASLR re-randomizes per launch; the `libapp_base=0x...` log line kept scrolling out of the ring buffer before it could be captured alongside later diagnostic lines). Recognized as unsafe before finalizing anything - redid the test with a clean launch, capturing `libapp_base` via `logcat -d | grep libapp_base` within ~3s of `am start`, before more log volume could push it out. +- **Correctly-paired result**: with `libapp_base=0xb9c9c000` confirmed for the run, tapping a slot then selecting `ВОССТ. ШИНЫ` produced one single-fire `DIAG sub_40A29C` call (`as_str="MOD_NAME_TYRES_REINFLATING"`, `caller=0xba017fec`) distinct from the ~20x-repeating list-population caller (`0xba174fe0`, fires once per catalog entry every time the picker menu opens). Real file offset: `0xba017fec - 0xb9c9c000 = 0x37BFEC`, inside function **`sub_37BF34`** (`0x37BF34`). +- **Decompiled `sub_37BF34(a1, a2)`**: `a1` = the loadout slot's UI/component object, `a2` = an event pointer. Reads `v4 = *(a2+8)` (0 = "no mod"/empty, else a `CarMod` reference); if non-zero, resolves the mod's component data (`sub_890EC`/`sub_B25CC`), looks up its localized name/description via `sub_40A29C` (name) and `sub_388D84` (description) and writes them into the slot's label widgets (`a1+264`/`a1+272`), sets the slot's icon (via a vtable call at `*(a1+280)+92`), and recomputes something using `a1+292`/`a2+8` at the end (`sub_25DEF4`) - almost certainly the running total cost. If `v4==0` (empty/"НЕТ" selected), takes the else-branch: sets the label to `"UI_EMPTY_MOD"` and hides the description widget instead. Clears a dirty flag at `a1+289` on exit. +- **Traced one level up - `sub_37BE74`** (`0x37BE74`, `sub_37BF34`'s only caller): a standard `im::Event`-dispatch handler on the slot component. `lpsrc[1]` is the event's type-tag; **type 1057** `dynamic_cast`s the incoming event to `im::app::events::UIRolloutSelectedEvent` and, if it matches, calls `sub_37BF34(a1, thatEvent)` directly - i.e. **`a2` in `sub_37BF34` IS the `UIRolloutSelectedEvent*` itself**, and the selected `CarMod` is read straight from **`UIRolloutSelectedEvent+8`** (the event's own payload field), not from some separately-stored slot-state field. Type **1056** (`UIRolloutSlotButtonClickEvent`, the click that *opens* the picker) is handled separately - compares `v7[2]` (clicked slot id) against `a1+292` (this slot's own id) purely to invalidate a stale dirty-flag when a *different* slot was clicked, unrelated to reading the selection itself. +- **Answer to the user's question**: there is no "confirm" step to hook - **`im::app::events::UIRolloutSelectedEvent` is the commit**. It's posted the instant the player taps an option in the rollout/dropdown list, delivered to the slot's event handler (`sub_37BE74`), which reads the chosen `CarMod` directly from the event's own `+8` field and immediately writes it into the slot's display (`sub_37BF34`). To persist accepted upgrades (cont.37's deferred item (1)), the natural hook point is **`sub_37BF34`** itself: at entry, `*(int*)(a2+8)` is exactly "the `CarMod` just selected for this slot" (0 = cleared/empty) and `a1` identifies which slot (`a1+292` looks like a slot-id field, matching the id compared against in `sub_37BE74`'s 1056-handler) - both pieces of information needed to build a `{slot_index → CarMod}` record without touching any UI code. +- **Snapshot**: no code changes this session - purely investigative (live diagnostics + IDA decompilation). `g_internStringLogBudget`/`g_fatalLogCallerBudget` remain raised to 8000/8000 in `lan_event_injection.h` from this investigation's needs; harmless (budget-gated, inert once exhausted) but worth reverting toward the earlier 3000/5000 defaults if diagnostic log volume becomes a problem in future sessions. +- **Outstanding from cont.37, still not restored**: the 3 `"BACK"` entries in `kOutputChain[]` are still commented out (chain currently halts on `garage_select_rollout` instead of exiting to the map) - this was intentionally left disabled to give room for this session's manual tap-based investigation and **must be uncommented before any further production-path testing**. +- **Outcome/Next**: The mod-selection commit path is now fully understood and pinpointed to two exact functions/offsets. Next step (not started): add a hook on `sub_37BF34` (0x37BF34) that reads `a1` (slot id via `a1+292`) and `*(int*)(a2+8)` (selected `CarMod`) and stores them into a small persistent `{slot → CarMod}` array, then restore the `"BACK"` chain and use that array right before/during the exit to `map_overworld` to actually persist the accepted upgrades - fulfilling cont.37's deferred item (1). Item (2) (lobby overlay) and the two open design questions remain deferred as before. + +## 2026-08-11 (cont. 39) — Implemented the `sub_37BF34` hook; found and fixed a broadcast-event bug live: a naive version recorded the same pick into BOTH slots + +- **What**: Implemented cont.38's proposed next step - a hook on `sub_37BF34` (`MODSLOT_SELECTED_OFFSET 0x37BF34`) that records each accepted mod pick into a small in-memory `{slotId -> CarMod}` table (`ModSlotSelection g_modSlotSelections[MAX_TRACKED_MOD_SLOTS]`, update-in-place by `slotId`, matching the field read at `a1+292`). Confirmed the real ARM prologue live via IDA disasm before writing the trampoline (`PUSH {R4-R11,LR}; ADD R11,SP,#0x1C` - same pattern already used by several other hooks in this file, e.g. `InstallResolveDisplayTextHook`/`InstallLayoutScreenCtorHook`), rather than assuming it held. Registered via `InstallModSlotSelectedHook()` in `main.cpp`'s `JNI_OnLoad`. Built clean (`:mpcore:externalNativeBuildDebug`, `:app:assembleDebug`) and installed on-device (Galaxy A9) for live testing, since this project verifies UI-facing behavior in the actual running app rather than trusting a clean compile alone. +- **First live test found a real bug**: tapping the FIRST slot and selecting `ВОССТ. ШИНЫ` (TYRES_REINFLATING) logged **two** `ModSlot: slot N recorded` lines back-to-back - `slot 0 -> CarMod=0x1` AND `slot 1 -> CarMod=0x1` - from a single tap, even though the screenshot taken immediately after clearly showed only the TOP slot visually updated (bottom stayed `ПУСТО (УЛУЧШ.)`, total cost `1 000$` matching only one mod applied). Root cause: `im::app::events::UIRolloutSelectedEvent` is broadcast to *every* slot's `sub_37BE74` listener, not just the one whose picker was open - both slots' `sub_37BF34` calls receive the *same* event object as `a2`, so both read the identical `*(a2+8)` value. Unlike event type 1056 (`UIRolloutSlotButtonClickEvent`, the click that opens a picker), which `sub_37BE74` gates by comparing the clicked slot id against `a1+292` before acting, type 1057 (`UIRolloutSelectedEvent`) has **no such gate** in `sub_37BE74` - it calls `sub_37BF34` unconditionally for every listener. The real gate lives one level deeper: `sub_37BF34`'s entire body is wrapped in `if (*(_BYTE*)(a1+289))` (the field already visible in cont.38's decompile, previously read only as "a dirty flag cleared at the end") - the non-target slot's call takes the early "do nothing, return 0" path. Our original hook read `a1`/`a2` unconditionally *before* the original function ran, so it recorded both slots regardless of which one the flag would actually let through. +- **Fix**: added `MODSLOT_DIRTY_FLAG_OFFSET 289` and changed `Hook_ModSlotSelected` to check `*(uint8_t*)(a1+289) != 0` before recording - mirrors the exact same gate the engine itself checks, so the hook only records for the slot the pick actually applies to. +- **Second live test confirms the fix**: relaunched, opened the SECOND (bottom) slot this time, selected `ЗАЩ. ОТ СТОЛКН.` (collision protection) - logcat showed exactly **one** line, `ModSlot: slot 1 recorded -> CarMod=0x5`, and the screenshot confirmed only the bottom slot visually updated (top stayed empty). No crash either time. +- **Side observation**: `CarMod` values read at `*(a2+8)` are small integers (`0x1`, `0x5`), not raw pointers - confirms this field is a catalog index/id (resolved into a real component pointer later inside `sub_37BF34` via `sub_890EC`), not the `CarMod*` itself as cont.38's writeup loosely phrased it. Doesn't change the hook's correctness (the raw id is exactly what's needed to persist "which mod was picked"), just a precision correction to last session's terminology. +- **Snapshot**: `lan_event_injection.h` - added the "Mod-selection tracking (cont.38/39)" section (`MODSLOT_SELECTED_OFFSET`, `MODSLOT_DIRTY_FLAG_OFFSET`, `ModSlotSelection`/`g_modSlotSelections`/`g_modSlotSelectionCount`, `RecordModSlotSelection`, `Hook_ModSlotSelected`, `InstallModSlotSelectedHook`) right before the Soak Test disable section. `main.cpp` - added `InstallModSlotSelectedHook();` call in `JNI_OnLoad`, right after `InstallLayoutScreenCtorHook()`. +- **Verification**: Live on-device (Galaxy A9, `adb -s 2a40cdac1f0d7ece`), two full pick cycles (slot 0 then, after a clean relaunch, slot 1), each cross-checked against a screenshot of the actual UI state, not just the log line. +- **Outcome/Next**: `g_modSlotSelections[]` now correctly reflects exactly what's on-screen, per-slot, immediately after each pick - the data half of "persist accepted upgrades" (cont.37's deferred item (1)) is done. Still needed to close that item out: (a) restore the 3 commented-out `"BACK"` entries in `kOutputChain[]` (still disabled from cont.38's diagnostic pause), (b) actually consume `g_modSlotSelections[]` at the point of exiting to `map_overworld` (write it wherever the multiplayer lobby schema expects accepted upgrades - not yet designed). Item (2) (lobby overlay) and the two open design questions from cont.37 remain deferred as before. + +## 2026-08-11 (cont. 40) — Restored the BACK×3 exit chain and wired upgrade persistence to a new GameEvents bridge; a risky verification attempt found (and fixed by reverting) a real crash in the pre-existing chain mechanism, not in this session's new code + +- **What**: User asked to (a) uncomment the `"BACK"` ×3 chain entries left disabled since cont.38's diagnostic pause, and (b) finish "persist accepted upgrades on exit to map" (cont.37/39's deferred item). Restored `kOutputChain[]` to its full 5-entry production shape (`EVENT`, `CONTINUE`, `BACK`, `BACK`, `BACK`). +- **Persistence implementation**: extended the existing native→Kotlin `GameEvents` bridge (`game_events.h`/`GameEvents.kt`, already used for `onMapLoaded`/`onRaceStarted`/`onRaceEnded`) with a new `onUpgradesAccepted(slotIds: IntArray, carMods: IntArray)` event and `FireUpgradesAccepted()` (JNI `NewIntArray`/`SetIntArrayRegion` marshaling, since this is the bridge's first event carrying data rather than a plain no-arg lifecycle ping). Added `PersistAcceptedUpgrades()` in `lan_event_injection.h`, which copies `g_modSlotSelections[]` into parallel arrays and calls `FireUpgradesAccepted()`. Wired the call into `Hook_LayoutScreenCtor`, gated on `g_firedEventTest && !g_upgradesPersisted && g_chainIndex >= kOutputChainLength && screenName == "MapOverworld"` - i.e. fires exactly once, exactly when the BACK-chain's final hop lands back on the map (the "controlled exit" moment cont.37 asked for), not any earlier hop, since a player could still change a slot up until actually leaving the loadout screen. `g_upgradesPersisted` (a one-shot guard, forward-declared near the top of the file since `Hook_LayoutScreenCtor` is defined before the mod-tracking section that owns the data) prevents a double-fire if `MapOverworld` is ever reconstructed again later for unrelated reasons. +- **First-pass verification (production 1500ms timing, no manual mod selection)**: full 5-hop chain fired clean, ending with `PersistAcceptedUpgrades: exiting to map, dispatching 0 accepted slot(s) to Kotlin` / `GameEvents: onUpgradesAccepted dispatched to Kotlin successfully (0 slot(s))` - confirms the whole persistence plumbing (gating, array copy, JNI array marshaling, Kotlin dispatch) works end-to-end and survives the full production BACK×3 exit with zero crashes, for the empty-selection case. +- **Attempted to also verify the non-empty case, discovered the deferred-fire chain is tighter than adb tap latency allows**: selecting a mod needs two taps (open picker, pick option), but each `adb shell input tap` call has ~700-900ms of its own overhead - close to the full 1.5s window between a chain target being captured and the next hop auto-firing. A tight single-script poll+tap attempt (no round-trip through the agent) still landed the second tap ~100ms *after* the chain had already auto-fired BACK, i.e. selection didn't complete in time. +- **Escalated by temporarily widening the deferred-fire delay to 6000ms** (commented `TEMP cont.39: ... revert before shipping`) purely to get verification headroom - and this **did surface a real crash**, SIGSEGV fault addr 0x74 in `GLThread`, right as `LIVE TEST CHAIN[3]` fired `BACK` on the captured `RestrictedGarageLoadout` pointer. Root cause: the user's live observation (mid-test) caught it directly - "промах по кнопке открытия меню улучшений и сразу после этого игра вылетела" (a missed tap on the upgrade-menu-open button, then an immediate crash). The mistapped input landed on something that opened an unexpected `"GarageView"` screen (never seen in any prior run of this chain) partway through the widened 6s wait. Since `Hook_LayoutScreenCtor`'s target-capture is guarded by `!g_chainTargetPending` (only captures once per hop), `GarageView`'s construction didn't update `g_chainTarget` - it stayed pointed at the original `RestrictedGarageLoadout` instance, which by the time the deferred timer fired 6s later may have already been torn down/replaced by the `GarageView` transition. Firing `FireOutput("BACK")` against that stale pointer crashed. +- **This is a real fragility in the pre-existing deferred-chain mechanism** (present since cont.35/37, not introduced this session): the whole design assumes the captured target screen stays valid until its hop fires, which holds at the original 1.5s delay (tight enough that no *other*, unrelated screen transition has time to happen first) but breaks down if that window is widened, or in general if any out-of-band navigation (misplaced tap, background event, etc.) occurs while a hop is pending. **Not fixed** - this session's actual deliverable (persistence + restored chain) uses the original, already-proven 1.5s timing; the widened-delay build was a throwaway test-only artifact, immediately reverted (`MonotonicMillisSince(...) >= 1500`, TEMP comment removed) once the crash was understood, never left in a shippable state. +- **Final re-verification (production 1500ms timing, post-revert)**: rebuilt, reinstalled, relaunched clean - full 5-hop chain fired without any manual interaction, ending in the same clean `PersistAcceptedUpgrades .../onUpgradesAccepted dispatched ... (0 slot(s))` pair, zero crashes. Confirms the reverted build is exactly as solid as the pre-widening baseline. +- **Snapshot**: `lan_event_injection.h` - `kOutputChain[]` restored to its full 5-entry shape (comment updated to reflect cont.38's pause being over); added `g_upgradesPersisted`/forward-declared `PersistAcceptedUpgrades()` near the top; added the persistence-dispatch block in `Hook_LayoutScreenCtor`; added `PersistAcceptedUpgrades()` definition next to `RecordModSlotSelection`. `game_events.h` - added `g_onUpgradesAcceptedMethod`, resolved it in `InitGameEvents`, added `FireUpgradesAccepted(slotIds, carMods, count)` with its own JNI int-array marshaling. `GameEvents.kt` - added `onUpgradesAccepted(slotIds, carMods)` to `GameEventListener` (default no-op) and `dispatchUpgradesAccepted()` to `GameEvents`. +- **Verification**: Live on-device (Galaxy A9), three full end-to-end runs of the 5-hop chain (two at production 1500ms timing, one at a temporary 6000ms that surfaced the stale-target crash above and was reverted) - all cross-checked against full logcat captures, not just spot lines. +- **Known gap, not closed this session**: the non-empty-selection case (`g_modSlotSelections` actually containing a pick at the moment `PersistAcceptedUpgrades` runs) was NOT proven end-to-end through the live production chain - only proven independently in two pieces: (1) per-slot recording correctness (cont.39, screenshot-verified against real UI state, twice), and (2) the persist-and-dispatch mechanism surviving the full chain cleanly (this session, with a 0-slot result). The gap between them is a straightforward array copy with no pointer dereferencing or engine calls, so risk is low, but it's not the same as a closed-loop proof. If this matters later, the practical way to close it is a *slower* one-shot test build (e.g. the chain's very first hop only, `kOutputChain = {"EVENT"}`, giving unlimited manual time on the loadout screen before separately, manually, firing the remaining BACK×3 sequence) rather than widening the deferred-fire delay itself (now known to be unsafe, per the crash above). +- **Outcome/Next**: cont.37's deferred item (1) ("persist accepted upgrades before/during exit to map") is now implemented and wired end-to-end, modulo the non-empty-case gap noted above. `GameEvents.onUpgradesAccepted` has no real consumer yet - it's the hook point for whatever eventually needs this data (item (2), the lobby overlay, still deferred; no lobby/network schema exists yet). The stale-chain-target fragility discovered above is a pre-existing risk worth keeping in mind for any *future* work that touches the deferred-chain timing, but is out of scope to fix right now since production timing (1.5s) has never been observed to trigger it. + +## 2026-08-11 (cont. 41) — Found the REAL click handlers behind every confirm-checkmark tap, via live manual-tap discovery on a genuine (non-synthetic) event; bonus: proved a real event races end-to-end with zero crashes, confirming cont.36's root-cause theory + +- **What**: User laid out a longer-term roadmap - learn to open car_select reliably, apply upgrades+color correctly, and on the *real* last checkmark tap return to the map, with overlay wiring coming last - then chose "hook the real confirm checkmark" as the starting piece, adding "как бы минуя окно события" (as if bypassing the event window) as a refinement. Since everything so far (cont.35-40) drives navigation via a **timer-based test harness** (`kOutputChain`, fired ~1.5s after each screen appears, regardless of any real tap), the actual goal here is finding the game's own click-handler code so a REAL checkmark press can be intercepted/redirected directly, instead of relying on the timer. +- **`sub_1BB59C` (FireOutput's real target) turned out to be a 2-instruction PC-relative-branch thunk** (`LDR R0,[R0,#8]; B sub_1581A0`) - unsafe to hook with this file's usual 2-word-copy trampoline (a relocated `B`'s encoded offset resolves to the wrong target once moved). Hooked `sub_1581A0` instead (the real function the thunk tail-calls into, confirmed via disasm to have a safe `PUSH {R4-R6,R10,R11,LR}; ADD R11,SP,#0x10` prologue) - functionally identical for every caller, since the thunk always redirects there regardless of who calls it (this project's own `FireOutput` pointer included, which still resolves to the thunk's address). Added `Hook_FireOutputDiag`/`InstallFireOutputDiagHook` (`FIRE_OUTPUT_REAL_OFFSET 0x1581A0`), logging `flowNode`, the resolved output-name string, and `__builtin_return_address(0)` (budget-gated, `g_fireOutputLogBudget`, set live at install time rather than tied to the old tick-based trigger). +- **Added `kEnableAutoChainTest`** (default `true`) to `lan_event_injection.h` - flips off the entire timer-driven `kOutputChain` auto-navigation (leaving `InjectSyntheticEvent`/the map pin itself untouched) so real taps drive navigation instead, for this kind of discovery work. Temporarily set `false` for this session's testing, reverted to `true` before finishing. +- **Live manual-tap walkthrough** (auto-chain disabled): tapped the real map pin ("МАККЛЕЙН") → an existing REAL event card ("ПОБУДКА", not our synthetic one - chosen deliberately to sidestep cont.36's known synthetic-RaceEvent race-loading crash while hunting for click handlers) → event_detail's real confirm checkmark → car_select's real confirm checkmark → the loadout screen's mod-slot picker (a REAL, non-empty mod selection this time) → the loadout screen's real flag/checkmark button. Every hop's `DIAG FireOutput` line was cross-referenced against `DIAG LayoutScreenCtor`'s next screen and `InputDispatcher`'s tap timestamps to confirm which caller belongs to which real tap (not to be confused with any residual test-harness firing, since the harness was off). +- **Results, with real file offsets** (this run's `libapp_base=0xb9d84000`): + - Event-card tap -> `EVENT`: caller `0x17aa58`, which decompiled to **inside `sub_17A99C` itself** (i.e. `FireEventOutput`, the same primitive this project already calls programmatically) - meaning the true UI click handler is one level further up (whatever calls `FireEventOutput`), not yet identified, since `__builtin_return_address(0)` only shows the immediate caller of `FireOutput`. + - event_detail's checkmark -> `EVENT`, AND car_select's checkmark -> `CONTINUE`: **same caller both times**, `0x1a7958`, decompiling to **`sub_1A7920`** (`0x1A7920`) - a tiny generic function (`sub_40685C(v4, a2); return sub_1BB59C(a1);`, i.e. "intern this output name, fire it") with **32 xrefs** including several vtable data slots - a generic "Flow-output button" widget class's `OnClick`, reused across most of the UI, with the specific output name configured per button instance (from layout/prefab data). This is why the *same* function fires different output names for different screens. + - The loadout screen's own flag/checkmark -> `CONTINUE`: caller `0x16c764`, decompiling to **inside `sub_16C660`** (`0x16C660`, size `0x7f0` - a large, screen-specific function, not the generic button handler). Its logic: checks "new cars unlocked" popups (`CARSELECT_NEW_CARS_UNLOCKED`/`CAR_UNLOCK_POPUP_BODY_*`), checks tutorial-gating popups (`TUTORIAL_DEALERSHIP_POPUP`/`TUTORIAL_RESTRICTION_POPUP`/`TUTORIAL_MODS_POPUP`, the last one gated on the real event ID literally being `"event_03_race"`), and only then fires the real output - `"CONTINUE"` normally, or `"GAMESCOM_CONTINUE"` if a demo-build flag is set (the same GAMESCOM demo-config system dead-ended on in cont.38's static search - confirmed here to be a real, still-present branch, just not the mod-selection lead it was chased for). +- **Bonus discovery, unplanned but highly informative**: since this whole walkthrough used a REAL event (not our synthetic `RaceEvent`), tapping the real loadout checkmark proceeded past `PreRaceLoadingScreen` into an **actual, fully playable race** (screenshot confirms live gameplay: position 6/6, real speed/HUD, wall-scrape sparks) - **zero crashes**, `pidof` confirmed the process stayed alive throughout. This directly confirms cont.36's root-cause theory: the `RaceLoaderTask_ResetStartingLine` crash was specifically about our synthetic `RaceEvent` never referencing a real track scene - a real event with real track data sails through the exact same code path that crashes on synthetic data. +- **Mod-selection persistence also reconfirmed with real (non-synthetic) data**: selected `ВОССТ. ШИНЫ` on the real loadout screen's first slot - `ModSlot: slot 0 recorded -> CarMod=0x1` fired correctly, and the screen visibly showed the mod applied (1000$ total) - the same dirty-flag-gated hook from cont.39 works identically on real and synthetic RaceEvents, as expected (it never touched RaceEvent internals in the first place, only the slot-widget/event-payload fields). +- **Snapshot**: `lan_event_injection.h` - added `kEnableAutoChainTest` toggle (guarding the `tickCount`-based auto-fire block in `Hook_MapScreenTick`), added the "REAL FireOutput click-handler discovery" section (`FIRE_OUTPUT_REAL_OFFSET`, `g_fireOutputLogBudget`, `Hook_FireOutputDiag`, `InstallFireOutputDiagHook`). `main.cpp` - added `InstallFireOutputDiagHook();` to `JNI_OnLoad`. Left in place (budget-gated, harmless) for future re-use rather than removed, matching this project's established pattern for diagnostic hooks (cont.32's `InstallInternStringDiagHook` etc.). +- **Verification**: Live on-device (Galaxy A9), one complete manual walkthrough with `kEnableAutoChainTest=false`, cross-checking `DIAG FireOutput`/`DIAG LayoutScreenCtor`/`InputDispatcher`/`ModSlot` log lines against screenshots and real tap timestamps at every hop. Reverted `kEnableAutoChainTest` to `true` and confirmed the app was cleanly force-stopped afterward (it was mid-race at investigation end). +- **Outcome/Next**: the real click-handler landscape is now mapped: `sub_1A7920` (generic Flow-output button, used by event_detail/car_select's confirm and 30+ other spots) and `sub_16C660` (loadout-screen-specific confirm handler, which itself calls through to `FireOutput` same as everything else). Since **every** real handler ultimately funnels through the same `FireOutput`/`sub_1581A0` primitive already hooked this session (`Hook_FireOutputDiag`), the cleanest path to "redirect the real checkmark press away from race-loading" (the original cont.37/40 goal, now unblocked) is likely to **extend `Hook_FireOutputDiag` itself**: check whether the resolved output name is `"CONTINUE"` and whether `flowNode` matches the currently-tracked loadout screen's own flow node (`*(screenOwner+8)`, computable from `g_lastLayoutScreenInstance`), and if so substitute the BACK-chain instead of letting the real `"CONTINUE"`/race-load proceed - without needing to touch `sub_16C660`'s substantial unlock/tutorial-popup logic at all. **Not yet implemented** - this is the concrete next step. The "открыть car_select минуя event_detail" (open car_select bypassing the event window) refinement from the user's message is also still open - `sub_17A99C`'s decompile (the event-card-tap path) shows it resolves the event ID through the same handle-cache/`ResolveHandle` machinery already used elsewhere in this project, suggesting a direct call to `FireEventOutput`-equivalent logic bypassing the event_detail screen might be feasible, but this hasn't been tested. + +## 2026-08-12 (cont. 42) — Implemented the real-checkmark interception; found it's insufficient for this test event due to a car_select auto-cascade the substitution can't stop; hardened the chain against the resulting crash but the redirect itself remains unsolved + +- **What**: User asked to continue: after the real last checkmark, we should get ALL the information for our lobby - i.e. finish wiring cont.41's discovered click handlers into an actual redirect (away from race-loading, into the proven controlled BACK exit) plus persistence, instead of just observing them. +- **Implementation, attempt 1**: extended `Hook_FireOutputDiag` (hooked on `sub_1581A0`, cont.41) to detect the specific call: `outputName == "CONTINUE"` AND the currently-tracked screen (`g_lastLayoutScreenName`/`g_lastLayoutScreenInstance`, now updated in `Hook_LayoutScreenCtor` on every screen construction) is `"RestrictedGarageLoadout"`, with a redundant `flowNode == *(screenOwner+8)` check. On match: **suppress** the call, mark a new independent state machine (`g_realExitChainActive`, separate from the old test-harness's `g_chainIndex`/`kOutputChain`) active, and kick off the same proven BACK x3 exit via the existing deferred-fire machinery, reusing `Hook_LayoutScreenCtor`'s screen-capture and `Hook_InternStringDiag`'s 1.5s-deferred-fire pattern. +- **Caught before testing**: since `Hook_FireOutputDiag` was declared `void` but the real `sub_1581A0` returns `int` - and **this specific caller, `sub_16C660`, actually uses that return value** afterward (`v13 = sub_16CE9C(v10)`, followed by more tutorial/unlock-popup logic) - simply suppressing the call and returning nothing would hand `sub_16C660` an undefined R0 value to keep computing with, a real correctness risk (not just a style issue - a `void`-typed function gives the compiler no reason to preserve a callee's return register across the call). **Fixed before ever running it**: changed `Hook_FireOutputDiag`'s signature to `int`, changed `FireOutputRealFn`'s typedef to match, and - rather than suppressing the call outright - **substituted the output name from `"CONTINUE"` to `"BACK"`** and let the real `sub_1581A0` implementation run for real (same technique already used throughout this file for firing named outputs - `InternString` a new name, pass it through). This gives `sub_16C660` a well-defined return value from a *real* transition (functionally identical to what a real tap on the screen's own `<<` back-arrow would produce), while still redirecting navigation away from race-loading. +- **First live test (production 1.5s timing, real event "ПОБУДКА", real mod selection)**: the substitution fired correctly (`REAL CONFIRM: intercepted CONTINUE from RestrictedGarageLoadout ... substituting BACK`), and the screen went `RestrictedGarageLoadout -> RestrictedGarage` (car_select) as expected - **but then, entirely on its own, in under 1 second and with zero further taps from us**, cascaded straight back through `RestrictedGarage -> RestrictedGarageLoadout -> PreRaceLoadingScreen`. The still-fixed-3-hop deferred chain (a leftover assumption from the original design) then fired its 2nd scripted "BACK" against a target captured earlier that was by now stale/destroyed by this unexpected cascade, crashing (SIGSEGV fault addr `0x74`, `GLThread`) - the exact same class of bug as cont.40's stale-pointer crash, but this time triggered by real, un-forced game behavior instead of a deliberately-widened test delay. +- **Root-cause hypothesis (not yet confirmed via further RE, but well-supported)**: the test event "ПОБУДКА" only has one available/unlocked car (Ford Focus RS500 - no alternate cars appeared despite the car_select screen's `<< >>` switch-car arrows being visible) and is class-restricted (`КЛАСС - КАЖДЫЙ ДЕНЬ`, shown locked). `car_select`'s own construction/tick logic most likely detects "nothing for the player to actually choose here" and auto-advances forward on its own **regardless of which direction the screen was reached from** (a real "BACK" tap into car_select would probably trigger the exact same auto-cascade, not something specific to our hook) - a legitimate friction-reduction UX pattern colliding with this project's assumption that BACK-navigating a screen leaves it sitting there waiting for the player. +- **Hardened the chain against the crash** (not yet a fix for the redirect goal, but a real robustness improvement): replaced the fixed-3-hop assumption with **live tracking** - `g_realExitChainAttempts`/`REAL_EXIT_CHAIN_MAX_ATTEMPTS` (capped at 10) instead of a hardcoded count, firing "BACK" against `g_lastLayoutScreenInstance`/`g_lastLayoutScreenName` (always the CURRENTLY displayed screen, read fresh at fire-time) instead of a pointer snapshotted when the hop was queued, checking after each ~1.5s delay whether `"MapOverworld"` has been reached, and - critically - added `IsKnownBackableCarFlowScreen()`, a whitelist (`RestrictedGarageLoadout`/`RestrictedGarage`/`EventDetails`) that makes the chain **stop and log instead of firing "BACK" blindly** if it ever finds itself on an unrecognized screen (e.g. `PreRaceLoadingScreen`, which may not even have a `BACK` output configured). The persistence trigger (`PersistAcceptedUpgrades`) was decoupled from the old hop-count in the same pass - now gated on a simple one-shot `g_realExitChainTriggered` flag (set once, at interception time) instead of "did we count exactly 3 hops", since hop count is no longer a meaningful invariant. +- **Second live test (same conditions, hardened chain)**: **no crash** - the chain correctly detected it had landed on `"PreRaceLoadingScreen"` (`REAL EXIT CHAIN: landed on unexpected screenName="PreRaceLoadingScreen" ... stopping rather than firing BACK blindly`) and stopped safely. However, this also means **the redirect itself did not succeed** - a screenshot confirmed the app auto-cascaded all the way into an actual, playable race (real damage bar, real HUD, real gameplay) despite our substitution, since the underlying auto-continue mechanism turned out to be independent of what string we pass to `FireOutput` at this one call site. +- **Snapshot**: `lan_event_injection.h` - moved `g_lastLayoutScreenInstance`/`g_lastLayoutScreenName` declarations up near the top of the file (now needed by `Hook_InternStringDiag`, defined earlier than their original spot next to `Hook_LayoutScreenCtor`); added the full "Real controlled-exit chain" state block (`g_realExitChainActive`/`g_realExitChainTriggered`/`g_realExitChainAttempts`/`g_realExitChainTargetSetAt`/`g_realExitChainTargetPending`/`g_realUpgradesPersisted`, `REAL_EXIT_CHAIN_MAX_ATTEMPTS`, `IsKnownBackableCarFlowScreen`); changed `Hook_FireOutputDiag`/`FireOutputRealFn` to return `int` instead of `void`; added the interception block (checks `isRealLoadoutContinue`, substitutes "BACK", arms the live-tracking deferred loop); added the live-tracking deferred-fire block in `Hook_InternStringDiag`; added the `g_realExitChainTriggered`-gated persistence trigger in `Hook_LayoutScreenCtor`, replacing the old hop-count-gated one. +- **Verification**: Live on-device (Galaxy A9), two full attempts with `kEnableAutoChainTest=false` (manual real taps only) - first attempt crashed (analyzed above, informed the fix), second attempt (post-fix) survived without crashing but did not achieve the redirect. `kEnableAutoChainTest` reverted to `true` and the app force-stopped (was mid-race) before finishing, leaving the device in a clean, safe state. +- **Outcome/Next**: The interception mechanism itself (detecting the real "CONTINUE" from the loadout screen, with a properly-typed return value and a crash-hardened live-tracking exit chain) is solid infrastructure and didn't crash on its second try - but it does **not yet achieve the actual goal** for events like "ПОБУДКА" where car_select auto-advances on its own. Two possible directions, not yet explored: (a) find and neutralize whatever state/condition makes car_select auto-advance (deeper RE into `sub_16C660`'s siblings or car_select's own ctor/tick, looking for the "single available car" gate) so BACK-navigating it actually stops there like it does for events with multiple car choices; (b) intercept even earlier - hook `sub_16C660` itself (or gate our substitution on some flag that suppresses car_select's auto-advance specifically when reached via our redirect) rather than relying on FireOutput's argument alone, since that's proven insufficient on its own. The upgrade-persistence half of "get all info for the lobby" remains implemented and would fire correctly once the redirect itself works; the "car info" half (make/model/color) hasn't been started - the earlier session's `CAR_MAKE_FORD`/`CAR_MODEL_FOCUS_RS500` sightings (cont.38) are localization *keys*, not the underlying car-id field, and locating that field is still open, unstarted work. + +## 2026-08-12 (cont. 43) — SOLVED: real-checkmark redirect now works end-to-end; root-caused the auto-continue, abandoned a risky direct hook after it broke touch input, fixed it by widening the already-safe FireOutput interception instead + +- **What**: User asked to dig deeper into `sub_16C660` and find the actual source of the auto-continuation discovered in cont.42. +- **Root cause found**: checked `sub_16C660`'s own xrefs (only 2: a vtable click-dispatch slot, and one direct call site) and decompiled the direct caller, **`sub_188024`** (`0x188024`). It's the car-select-family screen's own per-frame **Tick** function, and its very first action, unconditionally, every single frame, is `sub_16C660((_DWORD*)a1)` - no gating, no player-input check. Combined with cont.41's finding that `sub_16C660`'s own body fires the real `"CONTINUE"` once its internal state (`a1[111]`/`sub_1EF580()`) judges the session "fully configured" (a real car chosen, nothing left to decide) - this fully explains cont.42's cascade: it's not that BACK-navigating into car_select somehow re-triggers a stale "confirmed" flag; car_select's own Tick re-evaluates and re-fires `"CONTINUE"` **every frame**, completely independent of how the screen was reached, for as long as that condition holds (which our BACK substitution never clears). +- **First attempted fix: hook `sub_16C660` directly** (confirmed safe-to-relocate prologue via disasm: `PUSH {R4-R11,LR}; ADD R11,SP,#0x1C`) and skip its entire body whenever our redirect is active (`g_realExitChainActive`), stopping the tick-driven re-fire at the source rather than fighting it downstream. **Live-tested and found to break touch input**: with the hook installed, navigation reliably got stuck on `RestrictedGarage` (car_select) - taps that worked perfectly without the hook (confirmed via an isolated control test, hook removed, otherwise identical build) produced no response at all, reproduced 5/5 tries with the hook vs 1/1 clean without it. Budget-gated diagnostic logging inside the hook showed `sub_16C660` itself running completely normally through the hook (~60 calls/sec, each `"returned 0"`, no hang, no crash) right up until the point touch stopped registering - the mechanism of the regression was never identified. Rather than keep guessing at a function called this frequently (far higher call rate than anything else hooked in this project - previously the highest-frequency hook was `Hook_InternStringDiag`, itself only firing ~4/sec), **abandoned this approach entirely** - too risky to ship a hook whose failure mode is silent, unexplained input breakage. `InstallConfirmCarSelectionHook()` is left defined in the source (with a clear "do not install, breaks input" comment) but is **not called** from `JNI_OnLoad`. +- **Working fix: widened the already-proven-safe `FireOutput`-level interception instead.** Since the tick's auto-continue still has to go through the same `FireOutput`/`sub_1581A0` call that's already safely hooked, extended `Hook_FireOutputDiag`: once the redirect has started (`g_realExitChainActive`), **also** catch any *repeated* `"CONTINUE"` fired from any known car-select-flow screen (`IsKnownBackableCarFlowScreen`, cont.42's whitelist) and substitute `"BACK"` again, every time, for as long as it keeps happening - not just the first interception. Live-tested: the tick re-fires far faster than actual screen navigation can process (all firing against the *same still-stale* `flowNode` until the real transition catches up) - the original `REAL_EXIT_CHAIN_MAX_ATTEMPTS` (10, sized for "3 real hops") burned out in ~150ms without the real navigation ever getting a chance to catch up, so raised it to **300** (cheap safety margin - each attempt is a fast, harmless `FireOutput` substitution, not a real per-hop cost). +- **Live result: full success.** One clean end-to-end run (real map-pin tap → real event-card tap → real event_detail checkmark → real car_select checkmark → real mod selection on loadout → real loadout checkmark): the widened interception caught and redirected the auto-cascade through **57 substituted `"BACK"` fires** (the vast majority against the same stale `flowNode` during the initial burst, a handful of genuinely-progressing ones as the screen stack actually unwound: `RestrictedGarageLoadout → RestrictedGarage → EventDetails → MapOverworld`), landed cleanly on `MapOverworld`, and correctly fired `PersistAcceptedUpgrades: exiting to map, dispatching 1 accepted slot(s) to Kotlin` / `GameEvents: onUpgradesAccepted dispatched to Kotlin successfully (1 slot(s))` - the REAL, non-empty upgrade pick (`ВОССТ. ШИНЫ`/`TYRES_REINFLATING`) selected on the REAL loadout screen, delivered end-to-end. **Zero crashes, app confirmed still alive (`pidof`) and crash-log-clean afterward.** +- **Snapshot**: `lan_event_injection.h` - `REAL_EXIT_CHAIN_MAX_ATTEMPTS` raised from 10 to 300; `Hook_FireOutputDiag`'s interception widened with `isRepeatedAutoCascadeContinue` (catches repeated CONTINUE from any `IsKnownBackableCarFlowScreen` once `g_realExitChainActive`, substituting BACK again each time, resetting the deferred-fallback timer); added (but did not wire up) `CONFIRM_CAR_SELECTION_OFFSET`/`ConfirmCarSelectionFn`/`orig_ConfirmCarSelection`/`Hook_ConfirmCarSelection`/`InstallConfirmCarSelectionHook` and `g_confirmCarSelectionLogBudget`, clearly marked as investigated-and-abandoned (breaks input, root cause of *that* regression unknown). `main.cpp` - `InstallConfirmCarSelectionHook()` call commented out with a detailed explanation, `InstallFireOutputDiagHook()`/others unchanged. +- **Verification**: Live on-device (Galaxy A9), multiple rounds this session - an isolated hook-vs-no-hook comparison (confirming `sub_16C660`'s direct-hook regression was real and reproducible, not device flakiness - one clean control run succeeded, five consecutive hook-installed runs all failed identically), then a full real-tap walkthrough with the widened fix confirming complete success including real non-empty upgrade persistence. Device left in production config (`kEnableAutoChainTest=true`, `Hook_ConfirmCarSelection` not installed) and app force-stopped afterward. +- **Note on tooling, not code**: `adb shell screencap`/`exec-out screencap` intermittently returned a stale/constant 14233-byte black PNG for a stretch of this session's testing, unrelated to any app or hook state (confirmed via `dumpsys power`/`dumpsys window` showing the app genuinely focused and rendering, and via `logcat`-based verification proceeding normally throughout) - abandoned screenshot-based verification for the remainder of this session's testing in favor of `logcat`-only verification, which remained reliable throughout. +- **Outcome/Next**: The user's original request ("после нажатия на последнюю галочку мы должны получить всю информацию для нашего лобби") is now fulfilled for the upgrades half: pressing the real confirm checkmark on the loadout screen reliably returns to the map (crash-free, verified against a real, non-trivially-auto-cascading event) and dispatches the real accepted-upgrades data to Kotlin via `GameEvents.onUpgradesAccepted`. Remaining, explicitly not started: (1) car identification (make/model) - `CAR_MAKE_FORD`/`CAR_MODEL_FOCUS_RS500` (cont.38) are localization keys, not the underlying car-id field; that field's real location is still unknown. (2) Car color - completely unexplored, no RE done yet. (3) The lobby overlay itself (item (2) from cont.37, still deferred - no consumer for `onUpgradesAccepted` exists yet). (4) The two open design questions from cont.37 (car_select without event_detail; direct race launch from map) remain untested. (5) Minor cleanup: `g_confirmCarSelectionLogBudget`'s diagnostic Log calls could be trimmed now that the hook is confirmed unused, though leaving them costs nothing since the hook never installs. + +## 2026-08-12 (cont. 44) — Confirmed car_select can be opened at an arbitrary moment (not just at boot), reusing the same proven FireEventOutput primitive, triggered externally via a debug broadcast standing in for a future lobby-overlay button + +- **What**: User asked a design question after seeing cont.43's demo: does any of this help open car_select whenever the player wants (e.g. a "select car" button in a future lobby overlay), not just automatically at map load? Ruled out the "ГАРАЖ" (garage) nav-bar button per the user's own knowledge - it's just the owned-cars list, tapping a car from there also drops into `car_select`, nothing new to investigate there. User asked to try "variant 1" first (call the already-proven event-opening primitive on demand instead of at boot) and fall back to variant 2 (further garage-button investigation) only if stuck, documenting throughout. +- **Implementation**: added `TriggerOpenCarSelectOnDemand()` in `lan_event_injection.h` - reuses the *exact* primitive already live-tested this session (`FireEventOutput(g_mapScreenInstance, 0xC0FFEE00u)`, the same call `Hook_MapScreenTick`'s own boot-time auto-fire makes, and the same one a real map-pin tap ultimately resolves to via `sub_17A99C`) - just callable at any time instead of only once, automatically, ~2s after boot behind the `kEnableAutoChainTest`/`tickCount>120` gate. No new native risk: this is a call site this project has exercised repeatedly and safely all session. +- **Exposed to Kotlin**: `Java_nfs_mod_mpcore_MultiplayerCore_triggerCarSelectTest` (`main.cpp`) → `MultiplayerCore.triggerCarSelectTest()` (`MultiplayerCore.kt`). Since no real lobby-overlay button exists yet to call this from, wired a **debug-only trigger** instead: `GameActivityMain.kt` registers a `BroadcastReceiver` for a custom action (`nfs.mod.mpcore.TEST_OPEN_CARSELECT`, `RECEIVER_EXPORTED` via `ContextCompat.registerReceiver` for Android 13+'s required-flag rule, unregistered in `onDestroy`), letting the whole path be exercised from `adb shell am broadcast -a nfs.mod.mpcore.TEST_OPEN_CARSELECT -p com.ea.games.nfs13_mod` - i.e. from *outside* the app, mirroring how a real UI button's click handler would eventually call into this same Kotlin function. +- **Live result: success.** Let the existing auto-chain test harness (`kEnableAutoChainTest=true`) run its own full cycle first and land back on `MapOverworld` (~26s after boot), then waited an *additional* ~43 seconds of idle time on the map before sending the broadcast - specifically to demonstrate "arbitrary moment," not just "immediately after the last thing that happened to construct a screen." The broadcast reached the app, `TriggerOpenCarSelectOnDemand: firing FireEventOutput(MapScreen=0xb5912c00, key=0xC0FFEE00) on demand` fired, and `EventDetails` constructed cleanly a moment later - **zero crashes**, app confirmed alive (`pidof`) and crash-log-clean afterward. +- **Snapshot**: `lan_event_injection.h` - added `TriggerOpenCarSelectOnDemand()`. `main.cpp` - added the JNI export. `MultiplayerCore.kt` - added `external fun triggerCarSelectTest()`. `GameActivityMain.kt` - added `carSelectTestReceiver` (a `BroadcastReceiver` field), registered in `onCreate` right after `loadCore()` via `ContextCompat.registerReceiver(..., RECEIVER_EXPORTED)`, unregistered in `onDestroy`; added the `IntentFilter` import. +- **Verification**: Live on-device (Galaxy A9), one full run - auto-chain completing normally, then a genuinely-idle 43-second gap, then the external broadcast trigger, then confirmed `EventDetails` opened and the app stayed alive. +- **Known gaps, not addressed here** (same one-shot-cycle limitation already flagged in cont.39/40/44's own code comments): (1) firing this a *second* time in the same app session would re-run the whole event_detail→car_select→loadout flow, but the downstream one-shot guards (`g_upgradesPersisted`/`g_realUpgradesPersisted`) would silently no-op the persistence dispatch on that second pass - fine for "can it open on demand at all" (this test), not yet fine for "can the player reopen car_select repeatedly from the lobby," which is the more realistic real-world use case and hasn't been addressed. (2) `EventDetails` still visibly appears for a moment before car_select - no attempt was made to skip through it programmatically (e.g. auto-firing its own "EVENT" checkmark-equivalent immediately after it constructs, the same technique cont.35 already proved works) since the user's question was specifically about *whether on-demand opening works at all*, not about hiding the intermediate screen; skipping it is a separate, likely-easy follow-up using tools already in hand. +- **Outcome/Next**: Variant 1 fully answers the user's question - car_select CAN be opened at any moment via the lobby (once a real button exists to call `MultiplayerCore.triggerCarSelectTest()`), with no new native risk since it's the same primitive already proven safe. Variant 2 (further garage-button investigation) is now moot per the user's own confirmation. Next steps, in rough priority order: (a) make the flow genuinely repeatable per-session (reset the one-shot guards, or redesign them as per-open-cycle state) so the player can open/close car_select from the lobby more than once; (b) decide whether to auto-skip `EventDetails`'s brief visible flash; (c) the still-open car-identification/color/lobby-overlay-consumer items from cont.42/43 remain unstarted. + +## 2026-08-12 (cont. 45/46) — Live demo requested: auto-open car_select ~2s after boot; user caught that it only reached EventDetails, not car_select - fixed by auto-skipping EventDetails with a new minimal one-hop deferred-fire + +- **What**: User wanted to literally watch this work on-device: open car_select ~2 seconds after the map first loads on a fresh launch (not via the debug broadcast from cont.44 - fully automatic, so they could just watch the phone screen). +- **cont.45 - boot-time trigger**: added `kAutoOpenCarSelectAtBoot`/`g_firedAutoOpenCarSelect` to `Hook_MapScreenTick`, calling `TriggerOpenCarSelectOnDemand()` (cont.44's primitive) once at `tickCount > 120` (~2s) - deliberately **not** reusing `kEnableAutoChainTest`'s block (temporarily set to `false` for this demo) since that also auto-continues through the entire scripted `kOutputChain` (CONTINUE, BACK×3) afterward, which would auto-navigate past whatever screen the user wanted to actually look at. +- **First live run**: worked exactly as coded - `MapOverworld` → (2s) → `EventDetails`. **User caught, watching the live device screen, that this landed on `EventDetails` (the event's own name/stakes screen), not car_select** - correct catch: `TriggerOpenCarSelectOnDemand` only ever fires the same `FireEventOutput` a real map-pin tap fires (cont.35), which stops at `EventDetails` by design; reaching car_select needs a second hop (`EventDetails`'s own "EVENT" output, cont.41). +- **cont.46 - auto-skip fix**: added a new, minimal, **independent** single-hop deferred-fire mechanism (`g_autoSkipEventDetailPending`/`g_autoSkipEventDetailTarget`/`g_autoSkipEventDetailTargetPending`, plus the matching capture block in `Hook_LayoutScreenCtor` and fire block in `Hook_InternStringDiag`) - same proven `>=1500ms` wall-clock-deferred pattern used everywhere else in this file (firing synchronously inside a ctor callback silently no-ops, per cont.35's original discovery), but deliberately **not** reusing `kOutputChain`/`g_firedEventTest`, so it stops exactly at car_select instead of continuing on into loadout and back out like the scripted test harness would. `TriggerOpenCarSelectOnDemand` now arms `g_autoSkipEventDetailPending = true` right before firing its own `FireEventOutput` call. +- **Second live run: success.** `MapOverworld` → (2s) → `TriggerOpenCarSelectOnDemand` fires → `EventDetails` constructs → (1.5s) → `AUTO-SKIP EventDetails: about to FireOutput(EVENT) ... (deferred, opening car_select)` → **`RestrictedGarage`** (car_select) constructs. Zero crashes, app confirmed alive (`pidof`) afterward. User watched the full sequence live on-device. +- **Snapshot**: `lan_event_injection.h` - `kEnableAutoChainTest` temporarily `false` (was `true`, needs reverting once this demo phase is over); added `kAutoOpenCarSelectAtBoot`/`g_firedAutoOpenCarSelect` and the new `g_autoSkipEventDetail*` state block near the top of the file (both need to be visible to `Hook_InternStringDiag`, defined early); added the boot-trigger call in `Hook_MapScreenTick`; added the capture block in `Hook_LayoutScreenCtor`; added the deferred-fire block in `Hook_InternStringDiag`; `TriggerOpenCarSelectOnDemand` now arms the auto-skip before firing. +- **Verification**: Live on-device (Galaxy A9), two full boot-to-car_select runs (first one deliberately showing the EventDetails-only result the user correctly flagged, second one showing the fixed full skip-through), both crash-free, cross-checked against `logcat` (screenshot capture remained unreliable per cont.43's note, not revisited). +- **Outcome/Next**: `kAutoOpenCarSelectAtBoot`+the `EventDetails` auto-skip are demo/debug scaffolding (analogous to cont.44's debug broadcast) - **not yet cleaned up for production**: `kEnableAutoChainTest` needs reverting to `true` (or a real decision made about whether it's still needed at all now that cont.44/46 provide a cleaner on-demand path), and `kAutoOpenCarSelectAtBoot` should probably become `false` by default once this demo phase is done, with the underlying mechanisms (`TriggerOpenCarSelectOnDemand` + the `EventDetails` auto-skip) kept available for the eventual real lobby-overlay "select car" button to call. The auto-skip mechanism itself is now reusable infrastructure - the same pattern could later be reused/extended for other "open screen X, skip past intermediate screen Y" needs if they come up. + +## 2026-08-12 (cont. 47/48) — MAJOR: found the real FlowNode-transition machinery via RTTI, and used it to achieve a genuine one-hop jump straight to car_select, bypassing EventDetails entirely - live-verified crash-free and fully functional afterward + +- **What**: User asked "Можно ли в Car select прыгать сразу, без EventDetails?" (can we jump straight to car_select, without EventDetails?) - a materially harder version of cont.44/46's demo-timing work, revisiting the open question from cont.37/42 about whether car_select can be reached without going through the event/stakes screen. User explicitly chose to keep digging (twice, at two decision points) rather than settle for the safe "tune the auto-skip delay down" fallback, accepting the higher risk this entailed. +- **cont.47 - finding the real mechanism**: static archaeology of `sub_1581A0` (`FireOutput`'s real target, cont.41) showed it only marks a transition "pending" if the CURRENT FlowNode's own Outputs tree (a per-node red-black tree at `node+28`, keyed by interned-string-pointer, membership-checked via `sub_159684`) actually contains the requested output name - explaining precisely why map can't just fire a custom output directly to car_select (its own tree, from real `.sb` data, only has `"EVENT"`→event_detail). Chasing the actual "process the pending transition" consumer via xrefs and vtable/RTTI archaeology hit real walls (`im::app::flow::FlowNode`'s vtable isn't a plain absolute-address data reference in this PIE library - `find`/`data_ref` couldn't locate it), and a live empirical field-watch (polling `+44/+52/+56/+60/+64/+256/+260` every frame after firing) answered the wrong question (turned out `FireEventOutput`'s raw-key-based first hop bypasses this whole named-output mechanism entirely - it's a separate resolution path, only hops *after* the first one go through it). **Found it properly by decompiling `GetOutputNode`'s (`sub_159700`, RTTI-confirmed via its own `"outputIt != m_Outputs.end()"` assertion string and `"GetOutputNode"` function-name string) single caller**: `sub_1573EC` - reads the pending output name (`+56`), resolves it via `GetOutputNode`, swaps the FlowNode's own `+28` ("current node") to the resolved target, performs the real transition (`sub_1C4318`/`sub_1C4420`), then clears the pending state. This is `FlowNode`'s own per-frame transition processor. +- **The single biggest unlock**: hooking `sub_1573EC` observationally (deliberately *not* substituting anything yet, given how tightly its refcounting is coupled to the resolved values) revealed that `*(screenOwner+8)` (the "FlowNode" pointer, cont.41) is **the exact same single shared object for every screen** - confirmed live: `MapScreen flowNode=0xd70a5bc0` and the `EventDetails`-owning tick's own `a1=0xd70a5bc0` were identical in the same run (and reproduced with a different value, `0xd70a6ac0`, in a later run - consistent per-session, varies only across app launches). Only `+28` (which static graph-node data is "loaded") actually differs per screen - there's one persistent global FlowNode-executor, not one object per screen. +- **cont.48 - the real jump**: since map's own tree genuinely lacks a car_select edge but event_detail's own tree (parsed from real `.sb` data, cont.26) genuinely has one, and since it's the *same shared object* either way, the safe move was to temporarily redirect **which already-real, already-valid tree** the shared FlowNode consults - not fabricate a target. `Hook_FlowNodeTick` now captures event_detail's own `+28` value (`g_capturedEventDetailsTreeRef`) the first time it's about to process a real `"EVENT"` pending transition (i.e. exactly the value representing event_detail's genuine Outputs tree). `TriggerTrueDirectCarSelectJump()` then: gets the shared FlowNode via `*(mapScreenOwner+8)`, temporarily overwrites its `+28` with the captured event_detail tree reference, and fires `FireOutput(mapScreenOwner, "EVENT", ctx)` - `sub_1581A0`'s own membership check now succeeds naturally (against the substituted-but-genuine tree), and `sub_1573EC`'s own unmodified resolution/refcounting/construction logic runs end-to-end on entirely real data. Never touches or fabricates the resolved *target* itself - only redirects which real source tree gets consulted. +- **First live attempt crashed - but cleanly, in our own unstripped code**: `TriggerTrueDirectCarSelectJump+104`, fault addr `0x1c` (=28 decimal) - `flowNode+28` dereferenced with `flowNode` itself NULL. Root cause: `g_mapScreenInstance` (only ever written by `Hook_MapScreenTick`, never invalidated) had gone stale - fired ~90s after the map had last been the active screen, well after the auto-cascade had moved on through several more screens. **Fixed** with a straightforward null guard (log + bail instead of dereferencing). A closer look at the *timing* of a second failed attempt (fired only ~200ms after capturing the tree ref, still logged `flowNode is null`) revealed the real nature of the staleness: it's not simply "how much time has passed" - `*(screenOwner+8)` reads back NULL specifically while that screen is *mid-transition-out* (the field gets cleared as part of hand-off, seemingly before the object itself is destroyed) - i.e. this only works reliably while MapScreen is the *genuinely, currently active* screen, which is exactly the real-world condition a lobby-overlay button would satisfy anyway (the player has to be looking at the map to tap it). +- **Second live attempt: full, clean success.** Returned to `MapOverworld` for real (system back button navigation: `RestrictedGarage → EventDetails → MapOverworld`, confirming the game's own real "BACK" outputs are unaffected by any of this), then fired the broadcast while map was genuinely active: `TriggerTrueDirectCarSelectJump: flowNode=0xd70a6ac0, temporarily swapping +28 from 0xbb4c7be0 (map's own) to 0xbb8ac760 (captured EventDetails tree)` → `FireOutput returned without crashing` → **`DIAG LayoutScreenCtor: screenName="RestrictedGarage"` - EventDetails never constructed at all.** True one-hop jump, `MapOverworld` straight to car_select. Zero crashes, app confirmed alive (`pidof`) afterward. +- **Verified car_select is fully, genuinely functional afterward, not just superficially reached**: tapped its real confirm checkmark - fired the REAL `"CONTINUE"` (caller `0xb9e28958`, the same generic `sub_1A7920` handler identified in cont.41) and landed cleanly on `RestrictedGarageLoadout` (the loadout screen), exactly as it does when reached the normal way. This directly answers cont.37's original open risk concern ("car_select's `Restricted=true`/`GarageType=prerace` properties suggest it expects context normally set up by event_detail's own confirm handler") - **empirically, no such missing context caused any problem**, at least through this much of the flow (loadout screen construction and normal checkmark interaction). +- **Snapshot**: `lan_event_injection.h` - added `g_capturedEventDetailsTreeRef` (state block near the top); `Hook_FlowNodeTick`/`InstallFlowNodeTickHook` (`FLOWNODE_TICK_OFFSET 0x1573EC`, observational + the one-time capture); `TriggerTrueDirectCarSelectJump()` (with the null-guard fix). `main.cpp` - added `InstallFlowNodeTickHook()` and the new JNI export. `MultiplayerCore.kt` - added `external fun triggerTrueDirectCarSelectJump()`. `GameActivityMain.kt` - added `trueDirectCarSelectTestReceiver` (debug broadcast `nfs.mod.mpcore.TEST_TRUE_DIRECT_CARSELECT`), registered/unregistered alongside the existing cont.44 receiver. +- **Verification**: Live on-device (Galaxy A9), multiple rounds - RTTI/xref archaeology (read-only, IDA-side), an observational live hook confirming the shared-FlowNode discovery (crash-free), one crashing live attempt at the real jump (cleanly diagnosed, own-symbol crash), then a corrected, fully successful live attempt confirmed via logs both for the jump itself and for car_select's continued normal functionality afterward. +- **Outcome/Next**: This is a real, working "true skip" - materially different from cont.45/46's fast-timing-based flash-minimization, and directly answers the user's question with a live yes. Not yet production-ready: (1) `g_mapScreenInstance` staleness is a real, general risk for *any* of these Trigger* functions (not new to this one - `TriggerOpenCarSelectOnDemand` has the identical unchecked dereference and should get the same null-guard treatment); (2) `g_capturedEventDetailsTreeRef` is captured once per session from a real event_detail visit - a lobby button wired directly to `TriggerTrueDirectCarSelectJump` with **no prior event_detail visit at all this session** would currently just log-and-ignore (needs either a one-time hidden warm-up pass, or a different capture strategy); (3) only tested reaching car_select and one checkmark-tap-deep into loadout - the fuller downstream flow (mod selection, the cont.42/43 confirm-interception/persistence machinery, actual race-avoidance) hasn't been re-verified specifically against a true-jump-reached car_select, though there's no structural reason to expect it to behave differently now that car_select's own construction has been shown to be identical either way. +## 2026-08-12 (cont. 49) — Post-demo cleanup: reverted both boot-time demo flags to their production defaults + +- **What**: User asked to put `kEnableAutoChainTest` and `kAutoOpenCarSelectAtBoot` back into production state, now that the cont.45-48 live demos (boot-time car_select opening, the EventDetails auto-skip, and the true direct-jump experiment) were done. +- **What "production state" means for each flag**, since they serve different purposes: + - `kEnableAutoChainTest` → **`true`**. This is the original cont.35-41 timer-driven test harness (fires the synthetic "LAN: Test Lobby" event's own `EVENT`→`CONTINUE`→`BACK`×3 cycle automatically, ~2s after boot, entirely independent of the cont.44+ on-demand mechanisms). `true` is its long-standing default throughout the whole session - it was only ever flipped to `false` temporarily during cont.42/43/45/46's live testing so it wouldn't race against whatever was being manually driven at the time. + - `kAutoOpenCarSelectAtBoot` → **`false`**. This flag did not exist before cont.45 - it was added specifically as demo scaffolding for the user's "давай сейчас откроем car_select через 2 секунды" request, to auto-fire `TriggerOpenCarSelectOnDemand()` at boot so the user could watch it live without needing to send an adb broadcast. `false` is its correct **resting** state now that the demo has been watched and confirmed working - the real production trigger going forward is a lobby-overlay button calling `TriggerOpenCarSelectOnDemand()` (cont.44) or `TriggerTrueDirectCarSelectJump()` (cont.48) via JNI, not a boot-timer. +- **Verification**: rebuilt (`:app:assembleDebug`), reinstalled on-device (Galaxy A9), confirmed the app launches and reaches `pidof`-visible normal running state with no changes to its startup behavior beyond what these two flags already controlled. +- **Snapshot**: `lan_event_injection.h` only - `kEnableAutoChainTest = true;` (comment noting it's back to its long-standing default, TEMP marker removed), `kAutoOpenCarSelectAtBoot = false;` (comment reframed from "flip to true for the live demo" to "flip to true only for another live boot-time demo" - the resting/shipped value is `false`). No other files touched - the underlying mechanisms themselves (`TriggerOpenCarSelectOnDemand`, `TriggerTrueDirectCarSelectJump`, the `EventDetails` auto-skip, both debug broadcast receivers `TEST_OPEN_CARSELECT`/`TEST_TRUE_DIRECT_CARSELECT`) are untouched and remain fully available - only the *automatic-at-boot* triggering of the demo path was disabled. +- **Outcome/Next**: Native module is back to a clean, coherent baseline: the original test harness runs automatically as it always has (`kEnableAutoChainTest`), the cont.44-48 on-demand car_select machinery is present but dormant until called (either via JNI from a real UI trigger, or via the two debug broadcasts for ad-hoc testing), and no boot-time behavior changed from before this whole cont.45-49 side-quest started except by explicit, already-reverted opt-in flags. Still open, unchanged from cont.48: `TriggerOpenCarSelectOnDemand`'s own unguarded `g_mapScreenInstance` dereference should get the same null-guard treatment `TriggerTrueDirectCarSelectJump` already has; `g_capturedEventDetailsTreeRef`'s cold-start (no prior event_detail visit this session) case is still unhandled; the fuller downstream flow through a true-jump-reached car_select (mod selection, confirm-interception/persistence) hasn't been re-verified end-to-end. + +## 2026-08-12 (cont. 50) — Closed cont.49's own follow-up: added the same null guard to TriggerOpenCarSelectOnDemand; verification was blocked for a while by a red-herring "device hang" that turned out to just be the screen asleep + +- **What**: User asked to add the same null guard to `TriggerOpenCarSelectOnDemand` that `TriggerTrueDirectCarSelectJump` already got after cont.48's live crash - closing the exact gap cont.48/49's own "Outcome/Next" notes had flagged as still open. +- **Implementation**: extracted `void* flowNode = *(void**)((uint8_t*)g_mapScreenInstance + 8);` as an explicit local (previously computed inline and stored straight into `g_flowNodeWatchPtr` without a null check), added the same `if (!flowNode) { Log(...); return; }` early-return pattern as `TriggerTrueDirectCarSelectJump`, and reused the now-guaranteed-non-null `flowNode` local everywhere the function previously re-read `*(g_mapScreenInstance+8)`. Mechanical, no behavior change on the success path. +- **Verification saga**: repeated attempts to trigger the *negative* path (fire the broadcast before the map is genuinely active) worked immediately and consistently - `TriggerOpenCarSelectOnDemand: flowNode is null (g_mapScreenInstance likely stale/destroyed) - ignoring`, no crash, confirmed multiple times across multiple launches. Getting the *positive* path (map genuinely active, guard passes, real transition happens) took much longer than expected: several consecutive app launches appeared to hang indefinitely with zero `DIAG LayoutScreenCtor` output even after 60-90+ seconds and even surviving a full user-initiated device reboot - `free -h` showed the device genuinely low on memory (217MB-527MB free of 5.5G) partway through, so a batch of cached-empty background processes (Samsung/Google bloat services already in Android's own lowest-priority `cch-empty` state, i.e. already marked safe-to-kill) were cleared via `am kill` to rule out memory pressure as the cause. **Root cause of the apparent hang turned out to be unrelated to any of that**: the user clarified the phone's screen had simply gone to sleep - Android suspends an app's rendering/update loop while the display is off, which looks indistinguishable from a genuine native-code hang purely from `adb logcat` (no error, no ANR, process stays resident, just zero forward progress) unless you know to check display/wake state specifically. The moment the device woke up, the ENTIRE previously-"stuck" boot sequence (SplashScreen through a full `kEnableAutoChainTest` cycle back to `MapOverworld`) completed in about 12 seconds, exactly matching this session's normal timing throughout. +- **Positive-path result, once actually tested**: fired the broadcast with the map genuinely on-screen and awake - `TriggerOpenCarSelectOnDemand: firing FireEventOutput(MapScreen=..., key=0xC0FFEE00) on demand` → `DIAG MapScreen flowNode=0xdb2cc080` (non-null, guard passed transparently) → `FireEventOutput returned without crashing` → `EventDetails` constructed → (auto-skip's proven 1.5s deferred fire) → **`RestrictedGarage`** (car_select) constructed. Zero crashes, app confirmed alive (`pidof`) throughout, identical behavior to every pre-guard run of this same path earlier in the session - confirming the guard adds zero regression risk on the success path, exactly as expected from a pure early-return addition. +- **Snapshot**: `lan_event_injection.h` only - `TriggerOpenCarSelectOnDemand` gained the `flowNode` local + null guard, mirroring `TriggerTrueDirectCarSelectJump`'s existing cont.48 fix. No other files touched. +- **Verification**: Live on-device (Galaxy A9) - negative path (guard fires, no crash) confirmed 2+ times across separate launches; positive path (guard passes, full transition succeeds) confirmed once, cleanly, immediately after the device-sleep red herring was identified and the device was genuinely awake and idle on the map. +- **Process note for future sessions**: when a launched app appears to make zero logcat progress for an extended period with no error/ANR/crash and the process stays resident, check the device's actual screen/wake state (`adb shell dumpsys power | grep mWakefulness` would have caught this immediately) before assuming a code-level hang or resource exhaustion - this cost significant back-and-forth (memory cleanup, a full reboot) that a wake-state check up front would have skipped entirely. +- **Outcome/Next**: Both `Trigger*` on-demand functions (`TriggerOpenCarSelectOnDemand`, `TriggerTrueDirectCarSelectJump`) now consistently guard against a stale/inactive `g_mapScreenInstance` before dereferencing its FlowNode, closing that specific gap for good. Remaining open items are unchanged from cont.48/49: `g_capturedEventDetailsTreeRef`'s cold-start case (no prior event_detail visit this session) is still unhandled by `TriggerTrueDirectCarSelectJump`, and the fuller downstream flow through a true-jump-reached car_select hasn't been re-verified end-to-end. + +## 2026-08-12 (cont. 51) — MAJOR BUG FOUND AND FIXED: `kEnableAutoChainTest`'s test harness was hijacking real player navigation, not just our own synthetic event - defaulted it to `false` + +- **What**: User, playing normally with production config (`kEnableAutoChainTest=true`, restored in cont.49), reported a bizarre flow they couldn't explain: open a real street (МАККЛЕЙН) → open a real event (ПОБУДКА) → `event_detail` → tap the real confirm checkmark → `car_select` opens → **under a second later, the confirm happens by itself** → the mod/loadout screen opens → **then, abruptly, BACK into `car_select`** → **then abruptly BACK again into `event_detail`** - all without the user touching anything after their own checkmark tap. +- **First hypothesis (car ownership) was wrong and said so directly**: initially (mis)diagnosed this as the cont.42/43 "single-car auto-continue" engine behavior and spent significant effort trying to find a real event with 2+ *owned* cars to compare against (checked the garage - player owns exactly 3 of 41 cars; checked several real events - none currently accessible have 2+ owned-car choices, all show either 0 or 1 real option). This entire investigation turned out to be a **red herring** - car ownership/choice was never the actual cause. +- **Real root cause, found by re-reading the user's exact step-by-step sequence**: it precisely matches `kOutputChain`'s own scripted `"EVENT"→"CONTINUE"→"BACK"→"BACK"→"BACK"` cycle (cont.35-41's original test harness, meant to drive only our own synthetic "LAN: Test Lobby" event). Two facts combine into the bug: (1) `Hook_LayoutScreenCtor`'s deferred-chain capture logic (`if (g_firedEventTest && !g_chainTargetPending && g_chainIndex < kOutputChainLength) { g_chainTarget = a1; ... }`) grabs **whichever `LayoutScreen` constructs next**, with no check that it actually belongs to our own synthetic event's flow - it was written when only our own scripted `FireOutput` calls were the source of new screens, and never updated once real, independent player navigation entered the picture (cont.44 onward). (2) cont.47's own discovery that `*(screenOwner+8)` is a **single shared FlowNode-executor object for every screen**, not one per screen - so firing a queued output against "the FlowNode" affects whatever screen is genuinely active, regardless of which screen's flow originally armed the fire. Combined: the harness's own boot-timer fires its "EVENT" hop in the background (~2s after boot) while the user is still reading event_detail; by the time the user's own checkmark tap constructs `car_select`, the harness's capture logic - still watching for "whatever constructs next" - grabs the **user's own, real** `car_select` as its target; ~1.5s later its deferred "CONTINUE" fires against it (the "confirm happens by itself" the user saw), advancing into the real loadout screen; the harness's own two remaining `"BACK"` hops then fire against whatever's next, walking the user backward through their own real `car_select` and `event_detail` - exactly the sequence reported. +- **Fix**: changed `kEnableAutoChainTest`'s default from `true` back to `false`. Confirmed this doesn't affect the two things that actually matter: real in-game navigation (game's own native Flow code, entirely independent of this flag) and the on-demand triggers `TriggerOpenCarSelectOnDemand`/`TriggerTrueDirectCarSelectJump` (cont.44/48, gated by neither this flag nor anything the harness touches). The synthetic event/map pin still gets injected either way (`kInjectSyntheticEvent`, a separate always-on flag) - only the *automatic, timer-driven* navigation of it is now off by default. +- **Live re-verification, real event, same exact flow the user reported**: map → МАККЛЕЙН → ПОБУДКА → `event_detail` (real tap) → `car_select` (real tap) → `RestrictedGarageLoadout` (real tap) → **waited 13+ seconds with zero further input**: no auto-confirm, no auto-back, screen sat completely stable exactly as it should, app confirmed alive throughout. Full, clean fix. +- **Side finding, worth flagging even though it wasn't the bug**: this same real-event test also **did not** reproduce cont.42/43's "auto-continue" behavior at all (the loadout screen just sat there normally, no unprompted `CONTINUE`) - suggesting that earlier finding may have been specific to our own synthetic `RaceEvent`'s minimal/incomplete data confusing whatever "is this session fully configured" check `sub_16C660`/`sub_1EF580()` performs (a real, correctly-populated `RaceEvent` might simply never satisfy that condition prematurely the way our stripped-down synthetic one did), rather than a general "only one available car" engine behavior as previously assumed. Not confirmed with further RE - noted here as a correction to earlier framing, not a new investigation. +- **Snapshot**: `lan_event_injection.h` only - `kEnableAutoChainTest` flipped from `true` to `false`, with a substantially rewritten comment explaining the hijack mechanism and why the new default is correct. +- **Verification**: Live on-device (Galaxy A9) - the exact bug reproduction path (real taps, matching the user's own reported sequence) re-run after the fix, confirmed stable with no auto-navigation for 13+ seconds on the loadout screen. +- **Outcome/Next**: This was a real, previously-undetected interaction bug between this project's own test scaffolding and genuine gameplay - worth remembering for any *future* diagnostic/test-harness code added to this project: anything that captures "whichever screen appears next" or fires outputs against a shared/global engine object needs an explicit ownership/identity check, not just a timing assumption, once real player input can also be driving the same systems. `kEnableAutoChainTest=true` remains available for a *deliberate, isolated* re-run of the original cont.35-41 test/demo cycle, but must not be left on during any session where real gameplay might also be happening. + +## 2026-08-12 (cont. 52) — Made the loadout-confirm interception conditional on event provenance (synthetic vs. real) - real events now proceed into actual race loading, unblocked + +- **What**: After playing through cont.51's fix normally, user asked to actually enter a real race and predicted it would fail. It did - `Hook_FireOutputDiag`'s cont.42/43 loadout-confirm interception (redirecting `RestrictedGarageLoadout`'s "CONTINUE" to "BACK", originally written to stop our own synthetic event from crashing on a missing track scene, cont.36) was unconditional, so it also silently blocked every *real* event's confirm from ever reaching actual race loading. User's fix instruction, plus an explicit standing principle for all future work: **"очень плохо перехватывать все / Нужно иметь ввиду что событяи могут быть как синтетические так и нормальноигровые"** - never intercept indiscriminately; any hook must account for both synthetic (our own LAN lobby) and real/normal-game events. +- **Mechanism added - event-provenance tracking, using only existing hooks (no new ARM hooks needed)**: two new flags, `g_nextEventDetailsIsSynthetic` (armed) and `g_currentEventIsSynthetic` (latched/consumed). + - `TriggerOpenCarSelectOnDemand()` and the legacy `kOutputChain` harness's own `FireEventOutput` call (inside `Hook_MapScreenTick`) each set `g_nextEventDetailsIsSynthetic = true` immediately before firing - both are always firing our own synthetic event (key `0xC0FFEE00`). + - `Hook_LayoutScreenCtor` latches `g_currentEventIsSynthetic = g_nextEventDetailsIsSynthetic` (then resets the arm-flag) whenever `EventDetails` constructs - the one common point every event's flow (real or synthetic) passes through. A real player's own map-pin tap never touches the arm-flag, so this correctly defaults to `false` (real) for genuine navigation. + - `TriggerTrueDirectCarSelectJump()` (cont.48) bypasses `EventDetails`' construction entirely by design, so it latches `g_currentEventIsSynthetic = true` directly instead of going through the arm/consume path. + - `Hook_LayoutScreenCtor` also resets `g_currentEventIsSynthetic = false` whenever `MapOverworld` constructs - a natural session boundary, so the flag can never leak into a later, unrelated real event visit. +- **The actual gate**: added `&& g_currentEventIsSynthetic` to both of `Hook_FireOutputDiag`'s existing interception conditions (`isRealLoadoutContinue`, `isRepeatedAutoCascadeContinue`) - the redirect-to-BACK logic itself is unchanged, it now simply only fires when the currently-active event is confirmed to be our own synthetic one. +- **Verification, both scenarios, live on-device (Galaxy A9)**: + 1. **Real event** (МАККЛЕЙН → ПОБУДКА, all real taps) → `event_detail` → `car_select` → `RestrictedGarageLoadout` → tapped the real confirm checkmark: logcat showed a clean, uninterrupted `DIAG FireOutput: ... output="CONTINUE"` → `DIAG LayoutScreenCtor: ... screenName="PreRaceLoadingScreen"` → `BuildTrackScenePath hook fired: track -> region4_chicago_track4` - **no** "REAL CONFIRM: intercepted..." log line at all. Screenshot confirmed an actual race in progress (Focus RS500 racing against AI opponents on a real track). Exited cleanly via the in-race pause menu back to the map. + 2. **Synthetic event** (`nfs.mod.mpcore.TEST_OPEN_CARSELECT` debug broadcast, same primitive as cont.44) → auto-skip through `EventDetails` → `car_select` → confirmed car → `RestrictedGarageLoadout` → tapped confirm: logcat showed the expected `REAL CONFIRM: intercepted CONTINUE from RestrictedGarageLoadout ... substituting BACK` followed by the full cascade (`REAL EXIT CHAIN: caught a repeated auto-continue ...` ×44) and `REAL EXIT CHAIN: reached MapOverworld after 44 BACK attempt(s)` - i.e. still correctly redirected back to the map, exactly as before this change, with zero regression. + - App process (`pidof`) confirmed alive throughout both runs; no crash in either path. +- **Snapshot**: `lan_event_injection.h` only. New state block (`g_nextEventDetailsIsSynthetic`, `g_currentEventIsSynthetic`) near the top; arm calls in `TriggerOpenCarSelectOnDemand()` and the legacy harness's `Hook_MapScreenTick` fire site; direct latch in `TriggerTrueDirectCarSelectJump()`; latch/reset logic added to `Hook_LayoutScreenCtor`; `g_currentEventIsSynthetic` gate added to both conditions in `Hook_FireOutputDiag`. No other files touched. Rebuilt (`:app:assembleDebug`), installed, live-tested - no code changes pending. +- **Standing principle for future work in this project** (the user's explicit general instruction, not just a one-off fix): any interception/hooking logic added going forward must explicitly distinguish synthetic (our own test/LAN-lobby) state from real/normal-game state before acting - never intercept indiscriminately just because a screen name or output name matches. This mirrors cont.51's lesson (identity/ownership checks, not just timing assumptions) but generalized as a standing rule, not just a retrospective fix for one bug. +- **Outcome/Next**: The loadout-confirm interception is now correctly scoped - real players can finally complete real races through this flow, while our own synthetic LAN lobby event remains crash-free (still redirected to map, since it still has no real track scene to load, cont.36). No known open gaps in this specific mechanism. Broader remaining items are unchanged from cont.48/49: `g_capturedEventDetailsTreeRef`'s cold-start case for `TriggerTrueDirectCarSelectJump`, and full re-verification of mod selection/persistence specifically through a true-jump-reached car_select. + +## 2026-08-12 (cont. 53) — Broader stability pass: confirmed the mod-menu and upgrade-purchase screens (not just the loadout confirm itself) are unaffected by cont.52's change, on both real and synthetic events + +- **What**: User asked to verify that the other screens reachable from loadout - the mod-selection menu and the upgrade-purchase flow - are also stable after cont.52's conditional-interception fix, not just the loadout confirm button itself. No code changes in this entry, pure live-verification. +- **Real event, second one this session** (МАККЛЕЙН → "ТЬМА СГУЩАЕТСЯ", a MASLKAR-class event, since ПОБУДКА was already 100% complete from cont.52's own test): event_detail → car_select (single-car class, confirmed straight through) → loadout → confirmed → genuinely entered and drove the race (opponent traffic, police cars visible, in-race pause menu worked, exited cleanly back to map via "ВЫЙТИ В МЕНЮ"). No interception fired, as expected. +- **Garage → car detail → paint screen** (`ГАРАЖ` bottom-nav tab → owned Ford Focus RS500 → paint/diamond icon): opened `GaragePaint` cleanly (confirmed via `DIAG LayoutScreenCtor: ... screenName="GaragePaint"`), back button returned to the car detail screen without issue. Establishes this garage-browsing path (outside any race/event flow, never gated by any of this project's hooks) was never at risk, but good to confirm explicitly. +- **Synthetic event's actual upgrade-purchase screen** (the specific thing not yet exercised by cont.52's test, which had only tapped straight through to the loadout confirm without opening anything): via `TriggerOpenCarSelectOnDemand` → car_select → confirmed → `RestrictedGarageLoadout` → this time tapped an empty upgrade slot (`ПУСТО (УЛУЧШ.)`) instead of the confirm button - opened `ВЫБОР УЛУЧШЕНИЯ` (upgrade-selection screen) cleanly, listing real purchasable options (`ВОССТ. ШИНЫ` 1000$, `ЗАЩ. ОТ СТОЛКН.` 1000$, `УСИЛЕННОЕ ШАССИ` 1500$, etc.). Selected `ВОССТ. ШИНЫ` - purchase applied correctly (loadout total updated 0$→1000$, `ModSlot: slot 0 recorded -> CarMod=0x1` logged), and the car's dollar balance (a separate currency from the SP shown on the map) was correctly debited 26000$→25000$ and stayed debited across screen transitions. +- **Loadout confirm after a real purchase, both trigger paths**: (1) manually tapping confirm with the tire-repair upgrade active still correctly hit `REAL CONFIRM: intercepted CONTINUE ... substituting BACK` and cascaded cleanly back to `MapOverworld`; (2) on a second synthetic-event cycle, the loadout's own auto-continue (cont.42/43's `sub_16C660`, which considers a session with an upgrade selected "fully configured") fired `CONTINUE` on its own without any tap - this too was caught correctly by the same interception and redirected back to the map (`REAL EXIT CHAIN: reached MapOverworld after 111 BACK attempt(s)`). Confirms the cont.52 gate holds regardless of whether the CONTINUE originates from a manual tap or the engine's own auto-fire. +- **Crash check across the whole session's logcat**: grepped for `FATAL|AndroidRuntime|SIGSEGV|crash` - found only the pre-existing, already-handled `F fatal: Couldn't find MedalPosition component` / `Dereferencing a NULL component pointer` lines (the game engine's own internal log level for conditions it recovers from, immediately followed by this project's own `... FireOutput/Strlen ... returned without crashing` confirmations) - these are the same benign messages documented back in cont.36/42/43 for the synthetic event's intentionally-minimal data, not new crashes. `pidof` confirmed the app process stayed alive through every step of this entry. +- **Snapshot**: No files changed - this entry is verification-only, following up on cont.52's fix. +- **Outcome/Next**: Every screen reachable from the loadout flow that was actually exercised this session (real race entry/pause-menu/exit, garage paint browsing, synthetic-event upgrade purchase + persistence, both manual and auto-fired confirm interception) is confirmed stable with no regressions from cont.52. Not covered in this pass: the mod-selection (performance parts, as opposed to the one-off consumable upgrades tested here) screen specifically, and a real event's own upgrade-purchase flow (only the synthetic event's was exercised) - worth a quick follow-up check if either becomes relevant, but nothing currently suggests either would behave differently given the interception gate is keyed on event provenance, not on which sub-screen is open. + +## 2026-08-13 (cont. 54) — Chased an unexplained self-navigating "BACK" cascade on a REAL event down to real engine behavior (not our code), via a new observational hook - then set the investigation aside per the user's own call + +- **What**: User asked why buttons were "pressing themselves" again after a manual device reboot - this time on a genuinely real event (КРЮГЕР АВЕНЮ → B2 "ЖАРКИЙ ЗАЕЗД"), not our synthetic one. Clarified this was the SAME bug shape as cont.51 (auto-navigation walking backward through EventDetails/car_select/loadout) but this time reproducibly tied to ~34s of idle time sitting on the loadout screen. +- **Ruled out our own code first, from logs alone**: the exact real-event sequence (event_detail → car_select → loadout → 34s idle → three consecutive "BACK" fires walking back to MapOverworld) showed **no** `REAL CONFIRM: intercepted CONTINUE...` log line anywhere, and **no** "CONTINUE" preceded the BACKs at all - ruling out `g_realExitChainActive`/`Hook_FireOutputDiag`'s interception (cont.42/43/52), which only ever substitutes BACK for an actual CONTINUE it catches. `kEnableAutoChainTest` confirmed still `false` in source. This is real engine behavior, not our mod. +- **New diagnostic hook added** (`Hook_GenericFireOutputWrapperDiag`, `GENERIC_FIRE_OUTPUT_WRAPPER_OFFSET 0x1A7920`): found via IDA that `Hook_FireOutputDiag`'s existing `__builtin_return_address(0)` (hooked at `FIRE_OUTPUT_REAL_OFFSET 0x1581A0`) always reports the SAME address regardless of true caller, because `sub_1A7920` - a single generic "fire this named output" wrapper used by real taps AND internal engine logic alike - tail-calls down into `FIRE_OUTPUT_OFFSET` (0x1BB59C) without pushing its own LR, so the real caller's distinguishing return address gets lost by the time it reaches 0x1581A0. Hooking `sub_1A7920`'s own entry instead - one level higher - recovers the genuine caller. Installed as a pure observational hook (budget-limited, logs only when the fired output name is exactly "BACK"). +- **Reproduction attempt**: rebuilt, installed, and set up to watch live while the user navigated to the exact real repro path and waited on the loadout screen - but the timeout did **not** reproduce this time (user checked ~10 times over several minutes). Given the new hook was correctly installed (confirmed in boot logs) but never fired, and the earlier repro was tied to a specific pre-reboot session, this looks like real, pre-existing NFSMW engine behavior (plausibly some kind of session/event-specific inactivity handling, not necessarily present on every event or every session) rather than anything introduced by this project's own hooks. +- **Outcome/Next**: User made the call to drop this specific thread rather than keep chasing a non-reproducing timeout, and pivoted to the (unrelated, pre-existing) open question of jumping straight from the map to car_select. `Hook_GenericFireOutputWrapperDiag` remains installed (harmless, budget-limited observational hook, may be useful again if this resurfaces) - not wired into any interception logic. + +## 2026-08-13 (cont. 55) — Warmed up TriggerTrueDirectCarSelectJump's tree capture with zero live visit needed - but found via a real, reproducible crash that this alone is NOT sufficient for a truly cold session; root-caused the crash precisely via IDA + +- **What**: Follow-up UX design discussion (unrelated to cont.54's dropped thread): user asked how a lobby "Select Car" button should work, given `TriggerOpenCarSelectOnDemand` always shows event_details first. Agreed `TriggerTrueDirectCarSelectJump` (cont.48) is the right primitive for this (no stakes-screen makes sense in a LAN lobby context), but flagged its own known gap: `g_capturedEventDetailsTreeRef` only gets populated by observing a REAL live "EVENT" transition (`Hook_FlowNodeTick`), so a lobby button pressed "cold" (before the player has ever opened any real event this session) would silently no-op. User rejected a fallback-to-event_details as bad UX (still shows the screen once) and asked for a true zero-visit solution. +- **Found the mechanism via IDA**: `GetOutputNode` (`sub_159700`, cont.47/48) internally resolves an unresolved-by-name output edge via `sub_156F08` - a lazily-loads-and-caches-by-name resolver with its own hash table. This meant the exact resolution `sub_1573EC` performs on a real map→event_detail transition could be invoked **directly**, without any live screen construction, by calling `GetOutputNode` ourselves with map's own already-loaded node data (`*(mapFlowNodeExecutor+28)`, available immediately once `MapOverworld` constructs) and the interned "EVENT" key. +- **Implemented** (`lan_event_injection.h`): `GetOutputNode` (`GET_OUTPUT_NODE_OFFSET 0x159700`) added as a plain function pointer (no hook needed, just a direct call - resolved in `InstallMapTrackHandleEventHook` alongside `InternString`/`FireOutput`). `Hook_LayoutScreenCtor`'s `MapOverworld` branch now does a one-shot (`g_eventDetailsTreeWarmedUp`) direct `GetOutputNode` call the moment the map constructs, populating `g_capturedEventDetailsTreeRef` with zero visible flash and no player action - confirmed working immediately on the very first map load, both on a normal boot and after a fresh relaunch (`WARM-UP: captured EventDetails tree ref=... via direct GetOutputNode call`). +- **The warm-up itself works, but the direct jump still crashes cold**: with a genuinely fresh session (app just booted, warm-up fired, but **no real event_detail construction has ever happened**), firing `TriggerTrueDirectCarSelectJump` once **crashed with a real SIGSEGV** (`fault addr 0x38`, `Cause: null pointer dereference`, abort message `Dereferencing a NULL component pointer.`, on the GLThread) - fully reproducible twice in a row on a clean boot. (A messier first test, where the trigger was fired twice without an app restart in between, additionally left the shared FlowNode's own `+28` permanently stuck on the substituted tree after the first failed/no-op attempt - `TriggerTrueDirectCarSelectJump` doesn't restore `+28` after firing, relying on the normal real-transition machinery to move it on, which doesn't happen when the transition doesn't actually complete. Not itself the root cause, but a real secondary hazard worth remembering: a failed direct-jump attempt can corrupt map's own navigation state for the rest of the session.) +- **Root-caused the crash precisely via IDA**, using a `libapp_base` value read directly from that exact crashed session's own boot log (`InstallCopSoundsTickSkipHook: called, libapp_base=0x...`) for byte-exact address translation (the crash report's own `pc 0006563c` backtrace-frame offset turned out to be relative to something other than a plain libapp_base delta - not directly usable; deriving the base independently from the crash's raw `lr`/`pc` registers against this project's own already-validated `__builtin_return_address(0)` reference point resolved cleanly to real, sensible function code, confirming the derivation): + - Crash PC resolved to `sub_2A65E4(a1, a2)`, a constructor-shaped function - the very first real dereference on its own second argument (`a2[14]`, i.e. `*(a2+56)` = `*(a2+0x38)`) is exactly what faults when `a2` is null, matching `fault addr 0x38` (56 decimal) exactly. + - Its only caller, `sub_23F990`, passes `*(_DWORD**)(v5+12)` as that argument (`v5` being `sub_23F990`'s own second parameter) - gated behind a "Dereferencing a NULL component pointer." **soft warning that logs and continues anyway** rather than aborting (the same benign-looking message pattern seen everywhere else in this codebase, cont.36/42/43 - except this time the code that follows the warning isn't actually null-safe, and it's a real crash). + - `sub_23F990` itself is called from `sub_170EC8` (part of car_select/`RestrictedGarage`'s own construction, per the live backtrace/register evidence - `r0`/`r4`/`r8` all held the exact `RestrictedGarage` screen instance address logged moments earlier by `Hook_LayoutScreenCtor`), which first resolves `sub_171278(a1)` and reads `*(a1+320)` (looks like a screen-mode/state enum) to decide which path to take; `sub_171278` in turn (for at least one mode value, `2`) builds something via `sub_77B70(0)` → `sub_2550C8(...)`. +- **Interpretation**: this is very unlikely to be anything specific to `event_details` itself setting up `car_select`-specific context (the original cont.37/48 worry) - it looks more like a **global cache/registry** (reached via `sub_77B70`/`sub_2550C8`, several hops removed from anything event-detail-specific) that just happens to normally get populated as a side effect of the game's ordinary navigation flow before car_select is ever reached, and which the direct jump's map→car_select shortcut simply never touches. +- **Snapshot**: `lan_event_injection.h` - added `g_eventDetailsTreeWarmedUp`, `GetOutputNode`/`GetOutputNodeFn`/`GET_OUTPUT_NODE_OFFSET`, the `GetOutputNode` initialization line in `InstallMapTrackHandleEventHook`, and the warm-up block in `Hook_LayoutScreenCtor`'s `MapOverworld` branch. No other files touched. Built, installed, and live-tested (warm-up confirmed working twice; the downstream crash confirmed reproducible twice on a clean boot). +- **Outcome (superseded by the rest of this entry below)**: the `sub_171278`/`sub_77B70`/`sub_2550C8` trace continued and led to a precise, empirically-confirmed root cause - see below. Short version: it is NOT a missing singleton construction; it's one specific, still-zero *field* on an already-valid singleton. + +### Continued same day: refuted the first fix attempt, found the precise field, still open + +- **First fix attempt (singleton construction) - tried, then refuted by direct measurement**: traced `sub_890EC` (called throughout this whole chain, always with a dummy/ignored argument per its own decompile) and found it is a plain no-arg `GetInstance()` - RTTI-confirmed via its own `"s_Instance"`/`"GetInstance"` assertion strings - returning a single global singleton (`dword_AD2A08`), soft-warning "Not initialised" if still null rather than crashing itself. Found its matching `Initialise()` (`sub_244CA8`, RTTI-confirmed via `"!s_Instance"`/`"Initialise"`) - constructs a self-contained `malloc(0xD0)` object (internal strings `"CurrentState - Car"`/`"CurrentState - Race"` suggest a car/race state tracker) with no external dependencies, and assigns it to `dword_AD2A08`. Added a direct call to this from `TriggerTrueDirectCarSelectJump`, gated on `dword_AD2A08` still being null. +- **Live-tested and refuted**: added an unconditional diagnostic log of `dword_AD2A08`'s current value right before the jump. Across every test - cold boot, 5s delay (matching the original crash's own timing), 30s+ delay - **the singleton was already non-null every single time**, even before any of this session's code ran. The fix's own `Initialise()` call was therefore never actually invoked (always found already-initialized) - it was a no-op, not a fix. Removed it (see the "SingletonInitialise() experiment removed" comment now in the code) rather than leave dead, misleading logic in place. +- **Real environmental confound discovered mid-investigation, worth remembering for future testing on this device**: after this session's very long runtime (many repeated app launches, IDA sessions, background monitors), the device hit **severe memory pressure** (`free -h` showed 163MB free of 5.5GB) and separately the **screen locked** during a long gap between commands - both independently produced a "the trigger fires, logs look normal, but nothing ever happens - no crash, no navigation" symptom that looked like a code regression but wasn't. Diagnosed and fixed each time: `am kill`'d third-party background packages to free memory, `input keyevent KEYCODE_WAKEUP` + swipe to clear the lock screen, and raised `screen_off_timeout` to 30 minutes for the rest of the session. Once memory was healthy and the screen confirmed unlocked, the original crash reproduced cleanly and consistently again - this was pure environmental noise, not a behavior change from any code edit. Worth checking `free -h` and lock state early next time something "just stops happening" mid-session for no apparent reason. +- **Found the actual field, confirmed by direct measurement**: re-examined `sub_23F990`'s crash-adjacent argument more carefully - `v5` (its own second parameter) **is** `dword_AD2A08` itself (the singleton, resolved via the no-arg `GetInstance()` one level up in `sub_171278`), not some other object as first assumed. The crash is `*(_DWORD**)(v5 + 12)` - i.e. **the singleton's own `+12` field**, which `sub_23E6AC` (the constructor) explicitly zeroes at construction time (`*(_DWORD *)(a1 + 12) = 0;`) and which something else, later, is expected to populate before car_select's construction path reads it. Added a direct diagnostic read of `*(int32_t*)(dword_AD2A08 + 12)` right before firing the jump: **confirmed `0` at the exact moment of a clean, reproduced crash** (`singleton=0xbf1e3080 *(singleton+12)=0(0x0)`, immediately followed by `RestrictedGarage` constructing and then the same `fault addr 0x38` SIGSEGV). This is now a precisely confirmed root cause, not a static-analysis guess - the field genuinely is unpopulated at the exact instant of the crash. +- **Not yet found**: what real code path normally populates `dword_AD2A08+12`, and whether it can be safely replicated or invoked directly (the same trick used for `GetOutputNode`). A first attempt to find a setter via `dword_AD2A08`'s own vtable (`off_AA5B94`) turned up a large, not-yet-fully-parsed vtable (RTTI/multiple-inheritance-shaped layout, several `0/2/ptr` triplets mixed with real function pointers) - not conclusive without more work; a global write-to-`+12` search wasn't completed either (IDA's static xrefs don't track `global+offset` field writes once code holds the resolved instance pointer rather than referencing `dword_AD2A08` by symbol again). +- **Snapshot**: `lan_event_injection.h` - `SINGLETON_INSTANCE_OFFSET`/`SINGLETON_INITIALISE_OFFSET`/`SingletonInitialiseFn`/`SingletonInitialise` declared (kept - `SingletonInitialise` itself is legitimate, RTTI-confirmed, harmless to keep resolved even though the call site was removed); the `TriggerTrueDirectCarSelectJump` call site now does a read-only diagnostic log of `dword_AD2A08` and its `+12` field instead of the removed `Initialise()` call. Two temporary diagnostic hooks added and then **removed** after serving their purpose (`Hook_GenericFireOutputWrapperDiag`/`GENERIC_FIRE_OUTPUT_WRAPPER_OFFSET 0x1A7920` sub_1A7920-entry hook from cont.54's dropped idle-timeout thread, and `Hook_Sub170EC8Diag`/`SUB_170EC8_OFFSET` added and removed this entry) - their `Install*` calls are gone from `main.cpp`, but the hook function definitions themselves are still present in the header (harmless, unused) in case either is useful again later; not cleaned up further to keep this diff reviewable. +- **Outcome/Next - STILL IN PROGRESS, not yet resolved**: `TriggerTrueDirectCarSelectJump` remains crash-prone on a genuinely cold session (no real `event_details` visit this session) - must still only be called after at least one real (or cont.45/46-style brief) `event_details` visit, exactly as before cont.55 started. The warm-up mechanism (tree-pointer capture, confirmed solid) is real, working, harmless infrastructure that narrows the gap but does not close it. Next step, if resumed: find `dword_AD2A08+12`'s real setter (finish the vtable parse, or search disassembly for `LDR/MOVW`+`#0xAD2A08`-adjacent code combined with a `#0xC` field store) and either call it directly (matching the `GetOutputNode` pattern) or determine what data it needs and whether that data is itself available without a live visit. + +## 2026-08-15 (cont. 56) — Closed cont.55: exhaustive live watchpoint search for `+12`'s setter (hit a real deadlock, fixed it, still didn't find it), then restored a safe, permanent gate instead of chasing further + +- **What**: Resumed cont.55 across a session restart, at the user's request, by finding the writer of `dword_AD2A08+12` dynamically instead of by further static tracing - checked the vtable at `off_AA5B94` fully (only 5 real entries: `Serialize()`, an empty `nullsub`, `Reset(bool)`, the destructor, and `delete` - the other four apparent "slots" turned out to be unrelated neighbouring PLT/IAT thunks, not part of this vtable at all) and confirmed none of the five touches `+12`. `sub_890EC`/`dword_AD2A08` itself turned out to be referenced from 200+ call sites across totally unrelated engine subsystems (physics, audio, etc.) - not car-select-specific at all - so exhaustively checking every caller by hand wasn't practical. +- **Built a live software watchpoint** instead: redirect `dword_AD2A08` to a dedicated shadow copy of the singleton, `mprotect` it read-only, and catch the very next write via a `SIGSEGV` handler that reads the exact field offset plus the writing instruction's own `pc`/`lr` straight from the signal's `ucontext` - no hardware watchpoint/ptrace needed (this device's gdb/Frida watchpoints are already known-flaky per project memory). Extended it to auto-rearm on every catch so a whole *chain* of early writers could be logged, not just the first. +- **First version deadlocked for real on-device** (required a `force-stop` to recover) once the chain ran deep - root-caused to calling `mmap()` from inside the signal handler on every rearm: the interrupted code was itself deep in a `malloc`-using insert-loop and almost certainly already held an internal allocator lock that the handler's own `mmap()` then needed too, classic signal-handler self-deadlock. **Fixed** by pre-allocating a whole pool of pages with a single `mmap()` call safely outside any signal context during install, so the handler itself only ever does `memcpy` (fixed 208 bytes) + `mprotect` (a single syscall, no allocator interaction) + a pointer write on each rearm - confirmed this version no longer hangs, even chaining thousands of catches. +- **Still didn't find `+12`'s writer**: the chain reliably caught two small, early, one-off writes (`+112`, `+164`) within microseconds of the singleton existing, then entered a very long repeated-write loop at `+156` (matching what looks like a bulk vector/hash-table insert - the same PC/LR fired thousands of times in a row) that **never finished** even after a 5000-page pool was fully exhausted. Suppressing repeat-offset log spam and raising the pool to 20000 pages didn't change the outcome either - `+12` simply wasn't reached within any tested budget. Given this loop's true length is unknown and each catch has real per-iteration syscall overhead, chasing it further would mean an open-ended, increasingly slow diagnostic run for uncertain payoff. +- **Decision, given the depth already reached**: stop hunting for the specific writer. Disabled the watchpoint's automatic arming (left commented-out and fully documented in the header, in case this is ever picked up again) rather than deleting the working, deadlock-fixed infrastructure outright. +- **Final fix - restored and generalized the original safety net**: added `g_realEventDetailsVisitHappened`, set `true` the moment `Hook_LayoutScreenCtor` sees a real `EventDetails` construction (covers both genuine player navigation and the cont.45/46 auto-skip flash - both construct a real `EventDetails` object, unlike the tree-only warm-up which never does). `TriggerTrueDirectCarSelectJump` now requires this flag in addition to `g_capturedEventDetailsTreeRef` before proceeding - i.e. the warm-up mechanism from earlier in cont.55 remains in place and still eliminates the *visible flash* once a real visit has happened, but no longer lets the jump fire when nothing on the car-select-family construction path has ever actually run yet. +- **Verified live, both branches, same session**: (1) **cold** - broadcast fired immediately after boot, no real `EventDetails` visit yet: `TriggerTrueDirectCarSelectJump: no real EventDetails construction yet this session (tree warm-up alone isn't safe - see cont.55) - ignoring` - clean refusal, zero crash, `pidof` confirmed alive. (2) **warm** - fired `TriggerOpenCarSelectOnDemand` once (real `EventDetails` construction via the auto-skip path), navigated back to the map, then fired `TriggerTrueDirectCarSelectJump`: `singleton=0xbf45da60 *(singleton+12)=-1084370432(0xbf5dd200)` - **non-null**, confirming the real visit is what populates it - `RestrictedGarage` constructed cleanly, no crash, screenshot confirmed a fully rendered, interactive car_select screen. +- **Snapshot**: `lan_event_injection.h` only - added `g_realEventDetailsVisitHappened` (declared next to `g_eventDetailsTreeWarmedUp`, with a comment correcting the earlier over-optimistic framing), set in `Hook_LayoutScreenCtor`'s `EventDetails` branch, checked in `TriggerTrueDirectCarSelectJump`. The watchpoint infrastructure (`ArmSingletonWatchpointFromCurrent`/`SingletonField12SigsegvHandler`/`InstallSingletonField12Watchpoint`/`WATCHPOINT_POOL_PAGES`) stays in the file, fully functional and deadlock-fixed, but its one call site in `Hook_LayoutScreenCtor` is commented out - not wired into any build path by default. No other files touched. +- **Outcome/Next**: `TriggerTrueDirectCarSelectJump` is now safe to call from a lobby "select car" button in every tested condition - it silently no-ops (logs and returns, no crash) if called before any real `EventDetails` construction this session, and works correctly (confirmed via full visual + crash-log verification) once one has happened, matching its original cont.48 safety contract exactly, just with the added zero-flash benefit once warmed. The underlying question - what specifically populates `dword_AD2A08+12`, and whether it could someday be replicated to lift this requirement entirely - remains genuinely open, but is no longer blocking anything: this is now a solid, safe stopping point for this whole cont.55/56 arc. + +## 2026-08-15 (cont. 57) — FOUND `dword_AD2A08+12`'s real writer (across a session restart, at the user's explicit request to keep digging) - fixed the crash for real, and found (but did not resolve) a second, separate cold-start gap + +- **What**: User asked to resume the "why does one real `EventDetails` visit fix it" question specifically, willing to stop if it turned out uninformative or risky. Re-armed the (already deadlock-fixed, cont.56) software watchpoint, but **armed it right before a real `FireEventOutput` call instead of at `MapOverworld` construction** - cont.55/56's attempt armed too early and drowned in an unrelated, very long early-boot insert-loop before ever reaching `+12`. Arming late, after all that boot noise settles, let the very **first** catch land exactly on `+12` on the first try. +- **Root cause, precisely identified**: the writing instruction is inside `sub_240294(a1, a2)` - `*(_DWORD*)(a1+12) = *a2;` (plus `+16`/`+20`, with refcounting on the third field) - called from directly inside `FireEventOutput` (`sub_17A99C`, already a known, resolved function pointer in this project since cont.26-28). `FireEventOutput` internally: resolves the raw event key via `ResolveHandle` (already known, `RESOLVE_HANDLE_OFFSET`) against a global "EVENT ID " cache context, builds a small refcounted wrapper around the resolved event data, calls `sub_240294(singleton, resolvedEventData)` to stash it as the state singleton's "current event context" - **then, only after that**, calls the same low-level `FireOutput`/`sub_1BB59C` this project already knew about. `TriggerTrueDirectCarSelectJump` had always called `FireOutput` directly (skipping `FireEventOutput` entirely, cont.48's original design), so it always skipped this resolve-and-populate step - explaining exactly why a prior real `FireEventOutput`-based visit (via `TriggerOpenCarSelectOnDemand`, cont.44) was needed first: that's the only thing in this whole file that ever actually ran `sub_240294`. +- **Real fix, not a workaround**: `TriggerTrueDirectCarSelectJump` now calls `FireEventOutput(g_mapScreenInstance, 0xC0FFEE00u)` (our own synthetic event's key, already a resolved pointer, the exact primitive `TriggerOpenCarSelectOnDemand` already uses) instead of building an "EVENT" name slot and calling `FireOutput` directly. The `+28` tree substitution beforehand is unchanged and still does its job - it governs which tree `FireEventOutput`'s own internal `FireOutput` call resolves against (car_select's captured tree, not map's own), so the true one-hop skip is preserved, now with the missing singleton side effect included for free. Using our own event's key here is correct, not just convenient - the "current race context" now correctly reflects our own synthetic LAN event's data, not stale data from someone else's event. +- **Verified live, repeatedly**: `*(singleton+12)` reads `0` immediately before the fix's own `FireEventOutput` call and non-null (a real heap pointer) on every subsequent read - confirmed across many fire attempts, cold and warm, on this device and after a full device reboot. Zero crashes in any of them (`pidof` alive, crash buffer empty every time) - a real, unambiguous improvement over the pre-cont.57 state. +- **Second, separate, NOT resolved issue found during this same testing**: even with the crash fixed, firing `TriggerTrueDirectCarSelectJump` as the very *first* call on a genuinely cold session (right after boot, before any real `EventDetails` visit) reproducibly does not visibly navigate at all - no crash, no screen change, indefinitely. Isolated this to being specific to this function, not a general device/environment flakiness: `TriggerOpenCarSelectOnDemand` (same `FireEventOutput` call, no `+28` substitution) was fired as the literal first cold call on the exact same fresh boot, in the same test session, and worked perfectly every time (`EventDetails` → auto-skip → `RestrictedGarage`, all constructing cleanly). So the one remaining structural difference - pre-substituting `flowNode+28` to `g_capturedEventDetailsTreeRef` (the cont.55 warm-up's `GetOutputNode`-resolved tree) before firing - is the current suspect: that tree reference is a valid, non-null pointer (the lookup itself succeeds), but is suspected to be an incompletely-initialized node compared to one obtained via a genuine `EventDetails` construction - i.e. potentially the same *class* of hidden-side-effect problem as the singleton, just not yet traced this deep. Not investigated further given the scope of effort already spent on this arc; the existing `g_realEventDetailsVisitHappened` gate (cont.56) already fully covers this case operationally, so it isn't blocking anything, just not yet *understood*. +- **Real environmental noise hit again during this session, worth reiterating**: multiple test cycles produced the exact same "fires, logs look clean, nothing happens" symptom this entry's own root-cause work was trying to distinguish from - traced each time to low free memory (`free -h` down to ~120MB free) or, after an explicit device reboot, to the **lock screen** (the reboot leaves the device locked; an app launched while locked never actually renders/progresses, producing hook logs that stop dead with zero further activity and no error). Killing background packages and/or swiping to unlock resolved each instance. Anyone continuing this investigation should check `free -h` and the lock state before trusting a "nothing happens" result as code behavior. +- **Snapshot**: `lan_event_injection.h` only. `TriggerTrueDirectCarSelectJump` now calls `FireEventOutput` instead of `FireOutput`+manual `InternString`/ctx setup (the actual fix). `TriggerOpenCarSelectOnDemand`'s temporary watchpoint-arming call (used only for this investigation) removed again. `WATCHPOINT_POOL_PAGES` left at the smaller `300` (cont.56 had raised it to 5000/20000 chasing the unrelated early-boot loop; not needed at that size once armed late). `g_realEventDetailsVisitHappened`'s gate in `TriggerTrueDirectCarSelectJump` kept (comment updated to explain it now covers two distinct, only-one-of-which-is-understood problems, not one). No other files touched. +- **Outcome/Next**: The actual crash this whole cont.55/56/57 arc chased is now genuinely fixed at its root, not just gated around - confirmed via the precise mechanism, not inference. The `g_realEventDetailsVisitHappened` requirement remains in place, now correctly understood to guard against (at least) two distinct hidden-initialization gaps rather than being a blunt "just in case" measure. If ever resumed: the second issue (direct-jump doesn't navigate cold, even without crashing) could likely be chased with the same late-arm-the-watchpoint technique, this time watching whatever `g_capturedEventDetailsTreeRef` itself points to rather than the singleton. + +## 2026-08-16 (cont. 58) — Explored two forward-looking asks (inject a car class into car_select; capture the player's car/upgrades/color for a future native client) - both hit genuine, deep walls; nothing shippable landed, but the walls themselves are now well-mapped + +- **What**: User asked to design, then implement, two things: (1) make the injected synthetic event open car_select pre-filtered to a specific car class, and (2) capture the player's final car + upgrades + paint color at confirm time so a UI layer or future native multiplayer client can know what was picked. Explicitly said to abandon either thread if it turned out uninformative or risky, rather than force a shaky landing. +- **Design phase (no code yet)**: surveyed existing infrastructure first. Upgrades tracking already exists end-to-end and needed no new work: `g_modSlotSelections[]`/`Hook_ModSlotSelected` (cont.39-41) captures accepted mod-slot picks, `PersistAcceptedUpgrades()` dispatches them to Kotlin via the established `game_events.h`/`GameEvents.kt` JNI bridge (`GameEventListener.onUpgradesAccepted`, with `register()`/listener fan-out already wired). User picked "read the car's current equipped color, don't add a new in-flow color-picker step" when asked to disambiguate - the simpler of two options. +- **Car-class injection (part 1) - looked solved, then found to be a false lead on closer inspection**: `RACEEVENT_CATEGORYTAG_OFFSET` (an already-known RaceEvent field from cont.29/33) looked like the answer - its own existing comment described `sub_27D1EC` as reading `*(thisPtr+12)` as "a small 0-7 category-tag index," which car_select's real class-filter dropdown (ВСЕ/ВНЕДОРОЖНИК/КАЖДЫЙ ДЕНЬ/МАСЛКАР/СПОРТКАР/GT/ЭКЗОТИКА) looked like a natural match for. Implemented writing a configurable index into the categoryTag buffer, live-tested index 0 (showed a locked Marussia/СПОРТКАР by default) vs 1 and 3 (both showed the owned Ford Focus RS500/КАЖДЫЙ ДЕНЬ) - looked promising at first glance. + - **Re-checked `sub_27D1EC` directly in IDA before trusting the A/B result, and found the original cont.29 comment itself was imprecise**: the function is `v1 = *(a1+12); return v1<=7 ? table[v1] : 0;` - it reads `*(a1+12)` **as the categoryTag pointer's own raw value**, not anything stored *inside* the buffer it points to, and uses that raw heap address directly as a table index. A real `calloc()` pointer is always far larger than 7, so this function *always* takes the `else 0` branch regardless of what's written inside the buffer - the index write had **zero effect** on this function. The observed 0-vs-1/3 car difference was almost certainly unrelated noise (heap-address/session-state dependent), not a real effect of this field. + - Reverted the write (kept `categoryTag` calloc'd as before, cont.29/33's original null-guard behavior unchanged) rather than leave a misleading no-op in place. `RACEEVENT_CATEGORYTAG_OFFSET`'s own file comment now flags this as a documented false lead, explicitly warning not to revive the same assumption without re-deriving it. + - Real mechanism for "which class car_select filters to" is still unidentified - worth noting the same field is *also* read completely differently by `sub_162F14` (as an actual pointer, `*(RaceEvent+12)+28`, for the category display label) - two functions reading the same field two incompatible ways strongly suggests they're not even looking at the same `a1`/object, i.e. `sub_27D1EC`'s own real argument may not be RaceEvent at all. Whoever picks this up next should trace `sub_27D1EC`'s actual callers/argument first, not assume continuity with the already-understood label-display path. +- **Car+color capture (part 2) - two live dead-ends, one documented promising-but-large path found**: + 1. Tried reading `RestrictedGarageLoadout`'s own construction parameter (`a2` in `Hook_LayoutScreenCtor`) live, on the theory car_select might hand off "which car" directly when constructing loadout. Found (no crash, useful negative result) that `*(a2+4)` matches the FlowNode pointer and `*(a2+8)` dereferences to the literal ASCII string `"car_select_loadout"` - i.e. `a2` is flow/layout-resource construction metadata (which screen/resource to build), not car selection data at all. + 2. Tried a live software watchpoint (cont.57's deadlock-fixed technique) armed at the exact moment car_select's own "CONTINUE" fires (car choice confirmed) - **but arming from inside `Hook_FireOutputDiag`'s own already-executing processing of that same call turned out to be reentrant and hung the app for real** (confirmed twice, required `force-stop`), almost certainly because something in that call chain holds an already-fetched reference to the real singleton pointer that would diverge once `dword_AD2A08` got redirected to a shadow copy mid-call. Built a second, simpler **in-place** variant (`InstallInPlaceSingletonWatchpoint`/`InPlaceSingletonSigsegvHandler`) that protects the singleton's own real page instead of swapping the pointer - confirmed this doesn't hang - but the singleton's own heap page turned out to be shared with a very frequent, unrelated write source (same PC `0xce2a176c`, both attempts, offsets **far** outside the singleton's own 208-byte size, with implausible/garbage `LR` values suggesting a GPU/driver-level write rather than normal heap code) that always won the race to fault first. Both attempts landed on this exact same noisy neighbor. + 3. Found the *real* mechanism via string/xref archaeology: `"/Cars/Current Car"` (with `"CurrentState - Car"` from the state singleton's own constructor, cont.55, being the same concept under a different key) is resolved through a full **declarative property-binding system** (`sub_152250`, which itself calls out to ~6 further sub-functions per property bound) rather than a plain struct field - the same general class of subsystem already flagged as a dead-end for an unrelated goal in this project's own memory (`project_visible_streets_investigation` - "property-registry all ruled out"). Confirmed real (`im::app::ui::CarSelectionWidget`/`CarSelectButton`/`CarPaintSelectionWidget` are genuine RTTI-named C++ classes in this binary, not guesses), but properly understanding and safely reading from it is a substantial standalone investigation, not an extension of anything already in hand. + - **User's call, given the size of what's left**: stop here rather than keep spending session time on it. The recommended next attempt, if ever picked up again, is **not** the property-registry path - it's mirroring `Hook_ModSlotSelected`'s already-proven pattern (cont.39-41: hook the specific, low-level "user made a choice" callback directly, rather than trying to read back engine state) applied to `im::app::ui::CarSelectButton`'s own click handler. That class's vtable/constructor haven't been examined yet. +- **Snapshot**: `lan_event_injection.h` only. Net change versus before this entry is close to zero shipped functionality: the `RestrictedGarageLoadout` `a2`-dump diagnostic and the `InstallInPlaceSingletonWatchpoint()` call site (added, then both removed after serving their purpose) are gone from the hot path; the categoryTag index write is reverted; `kSyntheticEventClassIndex` and its call site are removed entirely. The `InstallInPlaceSingletonWatchpoint`/`InPlaceSingletonSigsegvHandler` infrastructure itself (deadlock-free, confirmed working, just not productive on this specific page) stays in the file, unused, alongside the earlier shadow-copy watchpoint from cont.55-57 - both are available if a future investigation needs a live memory watchpoint again. No other files touched; no user-facing behavior changed by this entry. +- **Outcome/Next**: Neither of this entry's two asks landed. Upgrades-capture was already done before this entry started. Car-class injection needs a fresh, correctly-scoped trace of `sub_27D1EC`'s real caller/argument (not `RaceEvent`, per the two-incompatible-readers finding above). Car+color capture needs `im::app::ui::CarSelectButton`'s own click-handler vtable slot found and hooked, following the `Hook_ModSlotSelected` pattern instead of trying to read engine state. Both are real, bounded, but *separate* pieces of RE work - worth treating as their own dedicated sessions rather than folded into "let's think about X" scope again. + +## 2026-08-16 (cont. 59) — `CarSelectButton` (cont.58's recommended next step) tried live, confirmed to be a dead end for "which car" - it's the post-race "car unlocked" popup's button, not car_select's + +- **What**: User asked to follow up on cont.58's own recommendation - hook `im::app::ui::CarSelectButton`'s click handler, mirroring `Hook_ModSlotSelected`'s proven pattern, to capture which car the player picked. +- **Vtable turned out to be a dead end for finding a click handler directly**: found the class's vtable (`_ZTVN2im3app2ui15CarSelectButtonE`) via RTTI archaeology (mangled name string → `__class_type_info` → vtable via `data_ref`), but nearly every slot (95-102 xrefs each) turned out to be generic, shared `boost::function`/signal-dispatch machinery ("call to empty boost::function") used across dozens of unrelated widget classes - not a distinct override findable by inspection. +- **Found the constructor instead** (`sub_35E134`, found via `data_ref` to the vtable's own address) and, live-tested with the user's go-ahead, a per-instance "car reference" field at `+264` set either via a named-property lookup keyed on the constructor's second argument, or (fallback path, when a `"SetUnlockedCar"` property resolves to `"Yes"`) from the state singleton's own resolved instance. +- **Live test: hooked the constructor, logged every call across a full car_select session (default view, carousel nav both directions, opening the class-filter dropdown, selecting "КАЖДЫЙ ДЕНЬ") - zero constructions, ever.** Confirmed this wasn't a hook-install problem (install log fired normally, offset/prologue matched a fresh IDA re-check) - the class genuinely never gets instantiated by this screen. +- **Root-caused why, via IDA, rather than guessing further**: `sub_35E134` is only reachable through a factory wrapper (`sub_394F78`) that gets registered *by name* into a generic widget-factory table - `sub_38DA44` (a large factory-registration function) builds a name string byte-by-byte on the stack (`0x38df48`-`0x38df84`: `0x656C6553`,`0x61437463`,`'r'` → little-endian decodes to `"SelectCar"`) and passes it to a registration call (`sub_397BD8`) alongside the `sub_394F78` factory pointer. +- **Cross-checked "SelectCar" against `NFSMW12MobileTools/layouts.sb.json`'s string pool (already-unpacked game layout resources)**: exactly one `"ButtonType"`/`"SelectCar"` pair exists in the *entire* game's UI layouts, sitting immediately next to `"SetUnlockedCar"`/`"Yes"`, `"CAR UNLOCKED!!!"`, `"car_unlocked_info"`, `"GARAGE"` - i.e. `CarSelectButton` is specifically the single GARAGE button on the post-race "car unlocked" congratulations popup, not any part of the normal car_select browsing/carousel screen. This exactly matches the constructor's own `"SetUnlockedCar"=="Yes"` fallback path found in cont.58's decompile - fully consistent, not a coincidence. +- **Snapshot**: `lan_event_injection.h` and `main.cpp` only. The `InstallCarSelectButtonCtorHook()`/`Hook_CarSelectButtonCtor` diagnostic (implemented and live-tested this entry) was reverted after confirming the negative result, replaced with an explanatory comment in the same spot - consistent with this project's convention of not leaving confirmed-nonfunctional diagnostics installed. `main.cpp`'s install-call site reverted to match. No user-facing behavior changed. +- **Outcome/Next**: This is now the fifth independent dead end on "which car did the player pick" (a2 red herring, watchpoint noise, property-registry system, categoryTag misread, and now CarSelectButton) - all traced to clean, understood root causes rather than left ambiguous. `im::app::ui::CarSelectButton` and its whole factory chain (`sub_394F78`/`sub_394F20`/`sub_38DA44`/`sub_397BD8`) can be crossed off entirely for this goal. Two directions remain genuinely open, neither started: (1) find the *actual* widget class the car_select carousel uses (the `sub_38DA44` factory-registration function registers many other named widgets besides `SelectCar` - a neighboring registration, found by walking nearby string-building sequences the same way `"SelectCar"` was decoded here, likely names the real carousel/list widget); (2) revisit the declarative property-binding system (`sub_152250`, `"/Cars/Current Car"`) properly as its own dedicated investigation, now that the button-click-handler shortcut is confirmed not to exist for this screen. User has not yet chosen between these - ask before starting either. + +## 2026-08-16 (cont. 60) — Both cont.59 follow-ups (factory-registry neighbors; property-binding system) run to ground - the widget-factory registry is a flat list of single-purpose buttons (no per-car widget), and every real consumer of "/Cars/Current Car" is QA/debug tooling, not production UI + +- **What**: Two follow-up threads picked up in direct continuation of cont.59, both purely investigative (IDA-only, no device testing, no code changes). +- **Factory-registry neighbors of "SelectCar"**: decoded the two registrations immediately adjacent to `CarSelectButton`'s ("SelectCar") in `sub_38DA44`'s registration sequence, the same byte-by-byte-stack-string-decode technique used to find "SelectCar" itself: the preceding registration is **"DefaultSettings"** → `im::app::ui::DefaultSettingsButton` (`sub_3632E0`), the following one is **"Nav"** → `im::app::ui::NavButton` (`sub_36E9E0`) - both confirmed via RTTI vtable comments in the same pattern as `CarSelectButton`. This confirms `sub_38DA44`'s full 94-call-site registration table is a flat list of ~94 distinct, narrowly-scoped `...Button` subclasses (one class per specific action/purpose), **not** a per-list-item widget system - there is no "car carousel entry" widget class to find here at all. Car_select's single-car-at-a-time display is confirmed to be pure data/property rebinding of one persistent view, not N button instances, which is *why* zero `CarSelectButton`s (or anything else from this registry) ever construct while browsing. +- **Property-binding system (`sub_152250`, `"/Cars/Current Car"`), run down properly this time**: `sub_152250` itself is a generic "bind a string property path to a component slot, trying each supported value type in turn" utility (calls out to `sub_14A5F8`/`sub_14C240`/`sub_14E180`/`sub_14D0DC`/`sub_14DE7C`/`sub_14E664`/`sub_14DE84` - one per property type) - confirmed not car-specific. Found all 5 real call sites (`xrefs_to` on `sub_152250` itself, not just the string) and traced each to its owning class/context: + 1. `sub_20E088` (called from `sub_20DD38` ← `sub_20B5C4` ← `sub_20AF30`, whose constructor's own RTTI vtable comment reads `im::app::layers::debug::CarPerformanceLayer`) - binds `"/Cars/Current Car"` into a slot on this **debug overlay**, refreshed on every property-change notification. + 2. `sub_210BD0` (called from `sub_210880` ← `sub_20F5CC` ← `sub_20E914`, RTTI-confirmed `im::app::layers::debug::CarPreviewLayer`) - same pattern, second **debug overlay**. + 3./4. `sub_2ACFAC` (two call sites) - turned out to be a **generic, unrelated** helper that binds a *list* of components to sequential property paths, with the base path passed in as a parameter by its own caller rather than hardcoded - not specific to `"/Cars/Current Car"` at all, just a second, coincidental consumer of the shared `sub_152250` utility for some other property list. + 5. `sub_F85B8` (a ~4.5KB function in a completely different, low-address code region full of float/VFP register pressure and large `+0xFAC`-style struct offsets, with no RTTI/vtable setup of its own) - almost certainly gameplay/physics-side Car-object update code, i.e. the **writer**, not a reader, of `"/Cars/Current Car"` (the live Car object publishing itself into the property tree when it becomes "current"), not something with any obvious hookable UI moment. + - Cross-checked against `NFSMW12MobileTools/layouts.sb.json`'s string pool for corroboration, same technique as cont.59's "SelectCar" check - no separate finding needed here since the RTTI trail was already conclusive. +- **Snapshot**: IDA/analysis only. No files touched, no device testing this entry - purely static re-analysis to close out cont.59's two open threads. +- **Outcome/Next**: Both of cont.59's follow-ups are now fully closed, cleanly negative. The production car_select UI does **not** use a per-car button widget (ruled out via the factory registry), and does **not** read the currently-selected car through the named property-binding system either (every real consumer of `"/Cars/Current Car"` is QA/debug tooling or the writer side, not production UI). This converges hard on the conclusion already on record from cont.55-58: the actual, real mechanism the production UI uses is almost certainly the already-known state singleton (`dword_AD2A08`, `"CurrentState - Car"`, `+12/+16/+20` fields) - property-binding and per-widget approaches are now both exhausted as alternatives to it. cont.58's live watchpoint on that singleton failed due to unrelated page-sharing noise (not a wrong-target problem) - if this is picked up again, the recommended next step is a **different way to read the singleton** (e.g. a hook at a fixed, low-frequency call site that's known to read it - `TriggerTrueDirectCarSelectJump`'s own diagnostic already reads `*(singleton+12)` successfully - rather than a live memory watchpoint, which is what actually failed last time, not the singleton-as-target idea itself). + +## 2026-08-16 (cont. 61) — SOLVED: "which car did the player pick" - a plain synchronous read of the state singleton at car_select's own CONTINUE, no watchpoint needed + +- **What**: User's direct follow-up to cont.60's own recommendation - hook the singleton's read at the moment car_select's CONTINUE fires, using a plain synchronous snapshot instead of cont.58's failed live watchpoint (the watchpoint's problem was the technique - an unrelated, high-frequency neighbor write always won the race on that heap page - not the singleton-as-target idea). +- **Implementation**: added `DumpSingletonFields(tag)` (`lan_event_injection.h`) - reads all 208 bytes (`malloc(0xD0)`, cont.55) of the singleton at `SINGLETON_INSTANCE_OFFSET` as 52 raw DWORDs and logs them. Called from two points: once as a baseline right when car_select itself constructs (`screenName=="RestrictedGarage"`, in `Hook_LayoutScreenCtor`), and again - purely observationally, no interception - right when its own confirm checkmark fires `"CONTINUE"` (new budget-limited branch in `Hook_FireOutputDiag`, gated on `g_lastLayoutScreenName=="RestrictedGarage"`, distinct from the already-handled `"RestrictedGarageLoadout"` interception below it). +- **Live test, methodical A/B/A**: (1) baseline+CONTINUE on the default car (Ford Focus RS500) - all 52 fields identical between the two dumps, zero change. (2) Switched to a different owned car (Dodge Challenger SRT8 392, via the МАСЛКАР class filter) and confirmed - **6 fields changed**: `+24, +28, +56, +60, +64, +68`. (3) Switched back to Ford Focus RS500 and confirmed again - `+24/+56/+60/+64` **exactly reverted** to their original Ford values (`+28` stayed constant across all three - not car-specific; `+68` drifted slightly, likely an unrelated frame/session counter) - a real, reproducible, revert-verifiable per-car signal, not noise. +- **`+24` decoded conclusively**: it's a pointer to a car-resource object whose first bytes are directly the car's own ASCII resource-ID string, no further indirection needed. Dumped and manually little-endian-decoded the first 64 bytes at that pointer for both cars: Ford Focus RS500 → `"ford_focus_rs500_2010_desc"`, Dodge Challenger SRT8 392 → `"dodge_challenger_srt8_392_2011_desc"` - both exact, unambiguous, human-readable identifiers matching the on-screen car precisely (make_model_variant_year_desc pattern - almost certainly the same resource-id family already seen elsewhere in this project, e.g. `NFSMW12MobileTools`'s unpacked resources). +- **Snapshot**: `lan_event_injection.h` only - `DumpSingletonFields` (now also dereferencing `+24`/word-index-6 and dumping+logging its first 16 words, added after the first successful test confirmed `+24` as the signal), one baseline call site in `Hook_LayoutScreenCtor`, one CONTINUE-time call site in `Hook_FireOutputDiag` (`g_singletonContinueDumpBudget`, budget 20). Still a diagnostic (logs everything, doesn't extract/expose the string yet) - no JNI/game_events.h/GameEvents.kt wiring done this entry. +- **Outcome/Next**: This is the answer to the "which car" half of cont.58's original two-part design ask (car+upgrades+color capture for a UI layer / future native client) - genuinely solved, live-verified twice over (two different cars, confirmed revert). Upgrades-capture was already done (cont.39-41, `g_modSlotSelections[]`/`FireUpgradesAccepted`). Paint color (the third piece, "read the car's already-equipped color" per the user's own earlier choice) is still unexplored - likely lives on the same car-resource object `+24` points to, or a sibling field on the singleton, worth checking the same object's other bytes/offsets for a color index/enum now that the object itself is located. Next concrete step, if continued: (1) clean up the diagnostic into a minimal "read car ID string" helper (no more need for the full 52-word dump now that `+24` is confirmed - keep only `+24` and maybe `+28`/`+68` until their meaning is understood too), (2) extend `game_events.h`/`GameEvents.kt` with a new event (e.g. `onCarSelected(carId: String)`) mirroring `FireUpgradesAccepted`'s JNI pattern, fired from the same `Hook_FireOutputDiag` CONTINUE branch, (3) look for the paint color nearby. + +- **Wired into the JNI bridge same-session, per the user's follow-up choice**: replaced the exploratory 52-word dump with a minimal `GetCurrentCarId()` (`lan_event_injection.h`) that just returns `*(const char**)(singleton+24)`. Added `FireCarSelected(carId)` to `game_events.h` (`NewStringUTF`/`CallStaticVoidMethod`, same shape as `FireUpgradesAccepted`, new cached `g_onCarSelectedMethod`) and `onCarSelected(carId: String)`/`dispatchCarSelected` to `GameEvents.kt`, mirroring the existing `GameEventListener` pattern exactly. `Hook_FireOutputDiag`'s car_select-CONTINUE branch now calls `GetCurrentCarId()` + `FireCarSelected(carId)` directly (no more budget-limited raw-dump logging). The unused ctor-time baseline call and its dump helper were removed from `Hook_LayoutScreenCtor` too, now that the answer is known. +- **Live end-to-end verified**: rebuilt (native + Kotlin both recompiled cleanly), reinstalled, opened car_select, confirmed the default Ford Focus RS500 - log chain confirmed complete: `REAL CONFIRM: car_select CONTINUE - carId="ford_focus_rs500_2010_desc"` → `GameEvents.dispatchCarSelected: 0 listener(s), carId=ford_focus_rs500_2010_desc` → `GameEvents: onCarSelected dispatched to Kotlin successfully`. Zero listeners is expected (nothing subscribes yet) - the JNI plumbing itself is confirmed working end-to-end, native hook to Kotlin dispatch. +- **Final outcome**: "which car did the player pick" is now fully solved AND exposed on the same `GameEventListener`/`GameEvents` surface as `onMapLoaded`/`onUpgradesAccepted` - ready for a real Kotlin-side consumer (UI layer or future native-client bridge) to `register()` and receive it. Only paint color remains open from the original three-part design ask. + +## 2026-08-17/18 (cont. 63) — Found the real persistent per-car storage (color, confirmed) and definitively closed the "upgrades" question (no persistent storage exists - confirmed why, not just that) + +- **What**: User asked to keep digging for paint color specifically (not just the transient per-session write already found at `singleton+176` from the RESPRAY popup), then, after color was nailed down, asked to also verify whether *upgrades* land anywhere similarly persistent - "надо по максимуму узнать информацию о машине, апгрейдах и цвете, чтобы потом не возвращаться к этому", plus an explicit architectural note: the UI layer is just a controller, the **native** layer (the planned native RatNet multiplayer client) also needs to know this state directly, not only via one-shot events. +- **Color: `+56`/`+60`/`+64` probed and ruled out, `+64`'s own `+16` field caused a real, understood, self-diagnosed crash**: all three are pointer-shaped fields on the same singleton that change per-car (found in cont.61's A/B/A). `+56`'s begin pointer → another car-id string variant (no `_desc` suffix). `+60`'s begin pointer → empty string (likely an unused "custom name" field). `+64`'s own `+16` field is a custom SSO ("small string optimization") slot: for Ford Focus it held the literal inline ASCII bytes `"default"` (short string, fits inline); for Chevrolet Corvette it held a real heap pointer, dereferenced live to `"CAR_MODEL_CORVETTE_ZR1_NFS_EDITION"` - a model/SKU analytics tag, not a color. Blindly dereferencing `+64/+16` as a pointer for the Focus case crashed for real (`SIGSEGV fault addr 0x61666564` - that address *is* the literal ASCII bytes "defa", i.e. the crash **was** the inline-string bytes being misread as a pointer) - confirmed via the crash log/backtrace, then removed the risky blind deref from `ProbeCarColorFields` rather than add SSO-mode detection just to keep probing an already-identified non-color field. +- **Found the real, persistent per-car storage - `GetCarRegistry()`/`LookupCarRecord()`**: traced via `sub_246950` (called by the RESPRAY popup's `PAINT1`..`PAINT6` swatch handlers, `sub_188F7C`, specifically to compare a tapped swatch against the car's *already-saved* color) = `*(sub_25102C(sub_77B70(), &(singleton+24)) + 4)`. Decompiled the whole chain: + - `sub_77B70` (`GET_CAR_REGISTRY_OFFSET = 0x77B70`) is a **second, independent** `GetInstance()`-style singleton getter (own `"s_Instance"`/`"Not initialised"` assert pair, global `dword_AD299C`) - almost certainly the owned-car/garage collection manager (matches the garage screen's own "ПОЛУЧЕНО 55/55"). + - `sub_25102C` (`LOOKUP_CAR_RECORD_OFFSET = 0x25102C`) is a generic hashmap lookup: buckets at `registryPtr+72`/`+76`, each node is 8 words `{key, value[0..5], next}`, keyed by the car-id string's own **pointer value** (relies on car-id strings being interned/deduplicated, so the same car always resolves to the same pointer) - returns `node+1` (the 6-word/24-byte value region) on a hit. + - Wired both as real function pointers (`GetCarRegistry`/`LookupCarRecord`, resolved in `InstallMapTrackHandleEventHook` alongside the other primitives) instead of reimplementing the hash walk - reusing real engine code, this project's established pattern. +- **Color, live A/B/A/A verified across two cars, three colors, and a full app restart**: `DumpCarRecord(tag)` reads and logs the 6-word car record at car_select's own `CONTINUE`. + 1. Ford F-150 SVT Raptor, repainted blue (2nd swatch) → `record+4 = 1`. Verified this **survived a full app process restart** (the transient `singleton+176` field correctly reset to `0` on the same read, `record+4` did not - direct proof this field is the real persisted color, not session state). + 2. Ford Focus RS500, repainted green (2nd swatch in *its own* palette) → `record+4 = 1` (consistent - same swatch position). + 3. Same Focus, repainted orange (3rd swatch) → `record+4 = 2` (index tracked the swatch position change exactly, ruling out coincidence). + - **Color is fully solved and confirmed reliable**: `record+4` (a plain 0-based index into the car's own swatch palette) is the real, persistent, restart-surviving color state, readable at any time via `GetCarRegistry()`/`LookupCarRecord(carId)` - not tied to any specific UI screen or event. +- **Upgrades: found *why* there's no persistent storage, not just that there isn't one** - three-round live investigation: + 1. Live-tested `record+8`/`record+12` (which `sub_23AC88(record, upgradeTypeId)` reads as a begin/end vector of installed-upgrade-type IDs - looked like exactly the right field) immediately after actually purchasing and equipping "ВОССТ. ШИНЫ" (tire repair) on the Focus - **zero, three separate times**, including once with no app restart at all between the purchase and the check (~20 samples captured via the CONTINUE hook's own budget, all identical, all zero). + 2. Traced the purchase path itself (`sub_247640`, uses the interned string `"upgrade"` for the cash deduction) to `sub_24D5B4`, which builds a **composite key** `{carId, upgradeTypeId, ServerTime}` (own assert name: `"PendingUpgrade"`) and passes it to `sub_257894`. + 3. Decompiled `sub_257894` and found its real purpose from its own assert strings: `"!IsApplied()"` / `"OrderCarUpgrade"` / `"Cannot make changes to change set once applied"` - it appends the composite key to a **local/transient `std::vector`-shaped "change-set" object** (`a1+184/188/192`, begin/end/capacity pattern), **not** into the persistent per-car hashmap record at all. + - **Conclusion, matching the user's own clarification that upgrades apply per-race and are never saved**: there is no persistent per-car upgrade storage to find, because none should exist by design - "OrderCarUpgrade" is a transient, presumably server-bound transaction object (the `ServerTime` field fits this), and in this offline/LAN test environment it never resolves into any lasting state. The already-existing `Hook_ModSlotSelected`/`g_modSlotSelections[]`/`FireUpgradesAccepted` (cont.39-41) - which captures the UI's own transient per-race selection directly, event-driven - is confirmed to be the correct and complete mechanism; nothing deeper to find. Re-confirmed live this session too: `ModSlot: slot 0 recorded -> CarMod=0x1` for "ВОССТ. ШИНЫ", matching that hook's own pre-existing code comment. +- **Snapshot**: `lan_event_injection.h` only. Added `GET_CAR_REGISTRY_OFFSET`/`LOOKUP_CAR_RECORD_OFFSET` + `GetCarRegistry`/`LookupCarRecord` function pointers (resolved alongside the file's other primitives), `DumpCarRecord()` + `ProbeCarColorFields()` + `TryLogAsString()` (all still TEMPORARY/diagnostic, budget-limited, not yet wired to `game_events.h`/`GameEvents.kt` - color is understood but not yet exposed over the JNI bridge the way `carId` already is). No other files touched this entry. +- **Outcome/Next**: All three pieces of the original cont.58 design ask (car, upgrades, color) are now genuinely resolved - car and upgrades are already wired to Kotlin (cont.61, cont.39-41 respectively); color's storage/read mechanism is fully understood and live-verified but **not yet wired** to the JNI bridge. The user's own next request (this same message): don't expose color as a bare index to the UI/native layer - resolve it to at least a name string, ideally an RGB value, before wiring it up. That mapping (index → name/RGB) hasn't been located yet - likely lives in a per-car color/swatch resource table (matching the swatch icons seen in the paint UI), possibly in `NFSMW12MobileTools`'s unpacked resources or a similar in-binary table - not yet searched. + +## 2026-08-18 (cont. 63b) — Color name/RGB resolved DYNAMICALLY from the live engine (not a static table) - full carId+color pipeline now wired end-to-end to Kotlin + +- **What**: Direct follow-up to cont.63's open item. First explored a *static* solution: unpacked `alfaromeo_4c_2012_desc.prefabs.sb`/`ford_focus_rs500_2010_desc.prefabs.sb` (and all 71 `*_desc.prefabs.sb` car files) via `NFSMW12MobileTools.jar`, found each car's own resource file has a `PaintJob` struct list (`Name` + `SwatchColor`/`SwatchColor2` DATA_ID_REFs resolving to `{Red,Green,Blue,Alpha}` byte structs) whose **order exactly matches the in-game swatch index** (verified: Ford Focus index 1/2 = "Green"/"Orange", exactly matching cont.63's own live-painted colors) - built a complete 67-car `{carId: [{name, rgba}]}` table from this (28KB JSON). **User's own call, mid-implementation**: a static table goes stale if cars are ever added/modded into the game later - asked to find the *dynamic*, live in-engine equivalent instead, so color resolution keeps working for any future car without needing to re-extract/re-ship anything. +- **Found `CarDescription::GetPaintJobDescription(int paintJobIndex)`** (`sub_B3564`, own assert: `"paintJobIndex >= 0 && paintJobIndex < (int)m_PaintJobDescriptions.size()"`) - a plain vector-index accessor: `*(carDescPtr+104) + 112*paintJobIndex` (a `std::vector`'s begin pointer, 112 bytes/element). Found the real call site/argument via `sub_188024` (car_select's own screen `Tick`, which calls this same function every frame to render the live-previewed swatch): `sub_B3564(*(singleton+56), paintIndex)`. `sub_890EC()` appearing in *that* function's own decompile is a red herring - decompiled it directly and confirmed it takes no real argument, returning the same familiar `dword_AD2A08` singleton this file already reads everywhere. +- **First guess wrong, caught by a real live crash, second guess right**: assumed `singleton+24` (the same object the car's ID string lives on) was itself usable as `CarDescription*` - live-tested, got back an obviously-garbage pointer (`0x66647342`, which is literally the ASCII bytes of "Bsdf" misread as an address) and a real `SIGSEGV` when dereferencing it (fault addr matches exactly). Corrected to `singleton+56` per the `sub_188024` trace - live-tested again, got a real, sane 112-byte record, and its `word[0]` dereferenced to the string `"Orange"` with `+96` (packed RGBA) decoding to `R=240,G=120,B=0,A=255` - an **exact, byte-for-byte match** with cont.63's own live-painted "Orange" Focus and this entry's own static extraction of the same car/color, independently confirming both the offset and the field layout. +- **`PaintJobDescription`'s 112-byte layout, now fully mapped** (matches the unpacked resource schema's field order exactly): six 16-byte string fields (begin/current/capacityEnd triple + 4 bytes padding each) - `+0` Name, `+16` DiffuseTextureFilePath, `+32` DiffuseMaskFilePath, `+48` BRDFFilePath, `+64` BRDFSpecularResponseFilePath, `+80` NumberPlateTextureFilePath - then `+96` SwatchColor (packed RGBA, one byte per channel), `+100` SwatchColor2, `+104` Type (int32), `+108` UseVinylMap/padding. +- **Live-verified three times, three different cars, zero static data involved**: Ford Focus RS500 → `carId="ford_focus_rs500_2010_desc" color="Orange" RGBA=240,120,0,255`. Jeep Grand Cherokee SRT8 (its untouched factory color, never manually repainted this session) → `carId="jeep_grand_cherokee_srt8_2011_desc" color="Deep Cherry" RGBA=132,19,19,255`. Both dispatched cleanly end-to-end to Kotlin with no crash. +- **Wired into the JNI bridge, replacing the diagnostic scaffolding entirely**: `GetCurrentCarColor()` (`lan_event_injection.h`) is the clean, final helper - `struct CarColor {name, r,g,b,a}`, built from `GetCarRegistry()`/`LookupCarRecord()` (cont.63, for the saved color index) + the new `GetPaintJobDescription` chain. `FireCarSelected` (`game_events.h`) and `onCarSelected`/`dispatchCarSelected` (`GameEvents.kt`) all extended to `(carId, colorName, colorR, colorG, colorB, colorA)`. All of cont.63's exploratory diagnostics (`DumpCarRecord`, `ProbeCarColorFields`, `TryLogAsString`, `TryDumpPaintJobDescription`, their budget globals) removed now that the answer is known and shipped - matches this project's established convention of not leaving dead diagnostic code installed once a question is answered. +- **Snapshot**: `lan_event_injection.h`, `game_events.h`, `GameEvents.kt`. The unpacked 67-car static color table (`car_colors.json`, scratchpad only) was **not** added to the app - superseded entirely by the dynamic resolver per the user's own explicit direction, kept only as this entry's own verification cross-check. +- **Outcome/Next**: The full original cont.58 design ask (car + upgrades + color, for a UI layer and a future native RatNet client) is now completely resolved and shipped: `onCarSelected(carId, colorName, colorR/G/B/A)` fires once per real car_select confirm, `onUpgradesAccepted` fires once per real loadout exit (cont.39-41) - both live-verified end-to-end, both resolving all data dynamically from the game's own current engine state rather than any static/baked snapshot. Nothing outstanding from this arc; remaining open items are the older, unrelated ones already on record (car-class injection into car_select's filter, cont.58; the cold-session direct-jump navigation gap, cont.55/57). + +## 2026-08-18 (cont. 64) — Full lobby-flow design + clickable UI prototype (LOBBY_UI_DESIGN.md, LOBBY_PROTOTYPE.html) — no native/engine work this entry + +- **What**: User asked for a prototype of the lobby overlay UI and to fully think through the multiplayer lobby flow end-to-end — creation, joining, player list, hand-off into native car_select and its upgrade screen, and specifically how to handle players who load into the race at different speeds. Pure design/UI session — no IDA, no device testing, no native code touched. +- **Existing infrastructure surveyed first, not re-derived**: confirmed `MultiplayerCore.kt` already exposes `triggerCarSelectTest()`/`triggerTrueDirectCarSelectJump()` (native jump straight into car_select, PROGRESS.md cont.44/48/57) and `GameEvents.kt` already fires real `onCarSelected`/`onUpgradesAccepted` (cont.61/63b) — these became the concrete "proven" anchors the whole flow design is built around, rather than inventing new native hooks speculatively. +- **Key design correction found while mapping the flow**: the synthetic "LAN: " event used to reach car_select on demand has its loadout-confirm deliberately redirected back to the map (cont.36/52) because it has no real track/scene reference — letting it through crashes `RaceLoaderTask_ResetStartingLine`. Documented the recommended fix (ride a real, existing race event as the "vessel" and let track substitution, ARCHITECTURE §3a, swap its geometry) instead of the much bigger "give the synthetic event a real scene reference" investigation. +- **Three design/prototype iterations, each driven by direct user feedback**: + 1. First pass: portrait phone mockup, 9 screens, Compose-overlay pattern reused from `CarSelectionBadge`. + 2. User corrected orientation ("ориентация должна быть как у игры — альбомная") — rebuilt as a 720x340 landscape device frame with real two-column layouts (list + side-rail), not a squeezed portrait; added a dedicated player-profile screen (name + local avatar preset) after the user flagged nowhere existed to store player identity. + 3. User gave a large batch of follow-up feedback: profile should be a global chip/popover (not a forced first screen), first-run name should auto-generate (not force typing), IP:port should be shown everywhere for both LAN and WAN, rewards need to be explicit validated fields (not static pills), the landscape layout still read as an adapted-portrait list (needed real redesign: car icons per player, a track hero placeholder, readiness as a button not a toggle switch, and the ability to change car even after readying up), and — the most architecturally significant point — loading/waiting for stragglers should happen **at the engine level** (the real native pre-race starting-grid scene, looping, with only a thin overlay status strip on top) instead of a custom Compose loading screen. Rebuilt accordingly; caught and fixed a real bug during live DOM verification (`isValidAddr` accepted out-of-range IPv4 octets like `999.1.1.1` — tightened to a proper per-octet 0–255 check) and an initial-screen index bug (`state.screen` started on the lobby browser instead of the map). +- **Docs split per explicit instruction** ("выкладки по дизайну оверлея веди в отдельном файле, сделай из него ссылку из основного"): moved ARCHITECTURE.md §4/§4a/§4b/§4c (lobby UI mechanism, flow, data schema, player identity) into a new dedicated `LOBBY_UI_DESIGN.md`, replaced with a short pointer + the one native-relevant takeaway (engine-level waiting) in ARCHITECTURE.md; the original content is preserved in a collapsed `
` block for history rather than deleted. +- **Verification**: screenshot tooling in the Browser pane was intermittently flaky this session (timeouts on `computer{screenshot}` specifically, one scratchpad-directory reset mid-session); relied primarily on live DOM/JS assertions (`querySelector`/state checks after simulated clicks) to confirm each screen renders and each interaction (avatar picking, live name preview, ready-button toggle, car-change-after-ready, rewards validation, IP validation) actually works, with screenshots as a secondary check where the tool cooperated — both agree the prototype works as designed. +- **Snapshot**: `LOBBY_PROTOTYPE.html` and `LOBBY_UI_DESIGN.md` added at repo root (persisted, not just an ephemeral Artifact); ARCHITECTURE.md trimmed. No files under `launcher/` or `native_lib/` touched. +- **Outcome/Next**: Design phase for the lobby UI is in good shape and reviewable end-to-end via the prototype. Nothing here is implemented as real Kotlin/Compose yet — next step, whenever picked up, is turning `LOBBY_UI_DESIGN.md`'s screens into actual `mpcore`/`app` Compose code, starting with the parts already marked [proven]/[wired] (car-select hand-off, car/color/upgrade capture) since those need no new native work. The single biggest remaining native gap is still Phase 5 (engine-level wait-for-stragglers hold/release hook) — see LOBBY_UI_DESIGN.md §7. + +## 2026-08-18/19 (cont. 64) — car_select class-filter injection: the label field found and shipped; the actual list-filtering field found too, but its "rebuild" call crashes at the timing tried — reverted, documented, not yet solved end-to-end + +- **What**: User asked to revisit car_select's class-filter injection (cont.58's original, never-resolved "inject a car class" half of the design ask) — explicitly important for choosing the car class shown in the synthetic lobby. Also gave standing guidance mid-investigation: unlike car color (cont.63b, resolved dynamically from live engine data because per-car resource files could change), the car-class enum itself is a small, fixed, game-design-level set that will never change — hardcoding it (in both the native layer and any future UI) is the right call, not worth a dynamic-resolution effort. +- **Found the real label field**: traced `sub_162F14` (event_details' own category-label builder, already known from cont.29/58) fully — its switch statement reads `*(*(singleton+12) + 132)` (note: `+132` on the *resolved event object* `*(singleton+12)` itself, not `+12`'s own categoryTag buffer — cont.58's original false lead conflated the two) and maps it to one of 7 `"CAR_CLASS_X"` string keys. Full enum decoded and confirmed to line up exactly with car_select's live "КЛАСС" dropdown order (ВСЕ/МАСЛКАР/СПОРТКАР/GT/КАЖДЫЙ ДЕНЬ/ВНЕДОРОЖНИК/ЭКЗОТИКА): `0=ANY, 1=MUSCLE, 2=SPORTS, 3=GT, 4=SEDAN, 5=SUV, 6=SUPER`. Cross-checked against `RaceEventCtor` (`sub_2A4B58`)'s own decompile: it explicitly zeroes this same `+132` field, confirming `0`/ANY as the real engine default and that the object (`malloc(0xE4)` = 228 bytes) is comfortably large enough for this offset. +- **Wired `RACEEVENT_CARCLASS_OFFSET` (=132) into `InjectSyntheticEvent`** (`kSyntheticEventCarClass` constant) — live-tested with `1` (MUSCLE): car_select's own class dropdown correctly showed **"КЛАСС - МАСЛКАР", locked** (padlock icon, confirming the field is genuinely read as an active filter mode, not just cosmetic) — the label half is fully solved and confirmed working. +- **Label alone doesn't filter the browsable list**: live-tested repeatedly (arrow-key browsing after the label changed) — the carousel stayed stuck on the same non-muscle car (a Land Rover Range Rover Evoque, tagged ВНЕДОРОЖНИК/SUV) regardless. Traced why via `sub_16677C` (the real dispatcher a live player's own tap on a class-filter dropdown *row* resolves to, routed by matching `"CLASS_FILTER_MUSCLE"`/etc. strings) → `sub_165630`: its own first line is `*(sub_890EC()+68) = ` — the actual list-filtering state lives on a **completely different field**, `singleton+68` (a persistent, global "current UI class filter" slot), not anywhere on the RaceEvent object at all. Documented as `SINGLETON_CARCLASS_FILTER_OFFSET`, with its own (third!) index scheme decoded from `sub_165630`'s own switch (`0=MUSCLE, 1=SPORTS, 2=GT, 3=SEDAN, 4=SUV, 5=SUPER, 6=ALL`) — added a small conversion table (`RaceClassToUiFilterIndex`) so `kSyntheticEventCarClass` stays the single source of truth for both fields. +- **Setting `singleton+68` early (in `InjectSyntheticEvent`, well before car_select ever constructs) was ALSO live-tested and found insufficient on its own** — the list still wasn't filtered. Root cause: `sub_165630` doesn't just set the field, it also calls `sub_16692C(a1)` (`a1` = the car_select screen instance itself) to actually rebuild the list from it — car_select's own construction path evidently builds its list once, synchronously, and doesn't re-read the field lazily. Found and decompiled `sub_16692C` fully: clears the old list, calls `sub_23FF50(&result, sub_890EC(), 1)` (an "enumerate owned cars filtered by the current class field" style call) then populates UI rows one by one via `sub_166C8C(a1, ...)` — `a1`'s own field layout (`+304/+312/+328/+332/+364/+368/+380/+384/+388/+396/+400/+404`, all sub-widget/list-container-shaped) matches a large screen-controller object, consistent with it being the same kind of object as `Hook_LayoutScreenCtor`'s own `a1` for `screenName=="RestrictedGarage"`. +- **Tried calling `RebuildCarClassList(a1)` (resolved `sub_16692C` function pointer) right after `RestrictedGarage` constructs, using the already-captured screen instance — live-tested, crashed for real**: `SIGSEGV`, `"Dereferencing a NULL component pointer"`, fault addr `0x44`, inside `sub_16692C` itself, with `a1` correctly matching the just-built screen instance (confirmed via the log line printed immediately before the crash). Some dependency `sub_16692C` needs isn't ready yet this early in construction — calling it at this exact hook point isn't safe as-is. Reverted the call (kept the two field-writes, which are harmless and still make the label work) rather than ship a crashing path, per this project's established convention. +- **Snapshot**: `lan_event_injection.h` only. Added `RACEEVENT_CARCLASS_OFFSET`/`SINGLETON_CARCLASS_FILTER_OFFSET`/`REBUILD_CAR_CLASS_LIST_OFFSET` + `kSyntheticEventCarClass`/`RaceClassToUiFilterIndex`, both field-writes wired into `InjectSyntheticEvent` (live, active, safe), `RebuildCarClassList` function pointer resolved but its one call site removed after the crash. `kSyntheticEventCarClass` left at `0` (CAR_CLASS_ANY, matching the engine's own default) — the mechanism is proven and ready to flip on for a specific class once the rebuild-timing problem is solved. +- **Outcome/Next**: Genuinely two-thirds solved, not fully shippable yet. The class *label* (and, per the padlock, the "this session is class-locked" mode signal) works end-to-end and is safe to enable today. The actual car *list* doesn't filter yet because the one known way to force a rebuild (`sub_16692C`) crashes when called immediately post-construction. Next concrete step if resumed: find a *later*, safer point to call `RebuildCarClassList(a1)` — candidates matching this file's own established patterns for "needs to happen slightly after construction settles" are a short deferred timer (like `g_autoSkipEventDetailTargetPending`'s own delay mechanism) or piggybacking on the screen's first real per-frame `Tick` (`sub_188024`, already known from cont.42/43) instead of its constructor. Decompiling what specifically sits at offset `0x44` relative to whatever's null at crash time (likely one of `sub_16692C`'s own referenced sub-fields on `a1`, e.g. `+304`/`+312`) would also narrow down exactly what isn't ready yet, rather than guessing at timing alone. + +## 2026-08-20 (cont. 65) — Debug menu (Compose): on-map button, explicit mpcore flag, money-editor stub — see DEBUG_MENU.md + +- **What**: User asked for a Compose-based debug menu, first feature a money-amount editor, activated by a button on the map, with an explicit enable mechanism living in `mpcore` as a simple flag. Pure Kotlin/Compose UI session — no IDA, no native code touched, no device testing yet (build-verified only). +- **Design decision, confirmed with the user before implementing**: the money editor is a **stub** for now — edits local Compose state only, doesn't write into the game's real balance. No RE work has located the player's actual spendable-cash storage (getter/setter/address) — `ANALYSIS.md`/`PROGRESS.md`'s only cash-adjacent findings are `CashReward` (a *race event's* bronze/silver/gold reward definition, §6aa/6z) and the `ISaveable` family (`Profile`, `CurrentState`, etc., 2026-08-06 save/profile entry) that plausibly *hosts* the real balance but was never traced that far. Wiring the editor to the real balance is out of scope for this entry, tracked as a separate future RE task in `DEBUG_MENU.md` §3. +- **Reused, not reinvented, the existing overlay mechanism**: same `ComposeView`-added-to-`mFrameLayout` pattern as `CarSelectionBadge` (`CarSelectionOverlay.kt`, cont.63b), same `GameEventListener`-as-Compose-state adapter shape, and the same already-proven `GameEvents.onMapLoaded()` signal (fires off the existing `MapTrack::HandleEvent` native hook — no new native hook needed) to decide when the button should appear. +- **New files**: `mpcore/src/main/java/nfs/mod/mpcore/DebugFeatures.kt` — `object DebugFeatures { var debugMenuEnabled: Boolean = true }`, the one explicit gate for all debug tooling, deliberately placed in `mpcore` (not `app`) per direct instruction. `app/src/main/java/com/ea/ironmonkey/DebugMenuOverlay.kt` — `DebugMoneyState` (local Compose state, `apply()` setter), `DebugMenuVisibility` (`GameEventListener`, tracks `mapLoaded`), `registerDebugMenuListener()`, and the `DebugMenuButton`/`DebugMoneyDialog` Composables (FAB → `AlertDialog` with a digit-filtered `OutlinedTextField` + Apply/Cancel). +- **Wired into `GameActivityMain.kt`**: new `buildDebugMenuOverlay()` (bottom-end corner, deliberately opposite `CarSelectionBadge`'s top-start corner so the two never overlap), added to `mFrameLayout` in `onCreate` only `if (DebugFeatures.debugMenuEnabled)` — flipping the flag off means the view is never built or added, not just hidden. +- **Verification**: `./gradlew :app:compileDebugKotlin` — `BUILD SUCCESSFUL`, no new errors/warnings beyond pre-existing unrelated deprecation warnings elsewhere in `GameActivityMain.kt`. Not yet exercised on-device (no `adb`/device session this entry) — the on-map button's actual appearance/tap flow is unverified live, only compiled. +- **Docs**: per direct instruction, this work gets its own living doc — new `DEBUG_MENU.md` (design, activation mechanism, stub-scope decision, panel inventory table) — with a short pointer + one load-bearing decision (flag lives in `mpcore`) added to `ARCHITECTURE.md` §4d, same split pattern as `LOBBY_UI_DESIGN.md`/§4. +- **Snapshot**: on branch `lan-event-injection-poc` (already-current feature branch, per this project's master-stays-release-ready policy) — note this branch also carries pre-existing uncommitted changes from the class-filter work (cont.64, `lan_event_injection.h`/`main.cpp`), untouched by this entry. New: `DebugFeatures.kt`, `DebugMenuOverlay.kt`, `DEBUG_MENU.md`. Modified: `GameActivityMain.kt`, `ARCHITECTURE.md`, `PROGRESS.md`. Nothing committed yet. +- **Outcome/Next**: Debug menu framework is in place and compiles; next steps are (1) on-device verification of the actual button/dialog on a real map load, and (2) the separate RE task of locating the real player-cash storage so the money editor can eventually write real values, per `DEBUG_MENU.md` §3. Future debug panels should be added to the same menu/dialog rather than as new ad-hoc overlays. + +## 2026-08-20 (cont. 64 continued) — Two more timing strategies for the class-list rebuild tried and ruled out (Tick hook never fires; deferred timer crashes too) — the rebuild call itself, not its timing, is the real blocker + +- **What**: Direct follow-up to cont.64's own "Next" section - user asked to try calling `RebuildCarClassList` from car_select's own first Tick instead of its constructor. +- **Tick hook (`sub_188024`) installed and live-tested - genuinely never fires, contradicting cont.42/43's own documented finding**: hooked it with the lightest possible wrapper (call the original unconditionally first, identical timing to no hook at all, only check a one-shot flag after) specifically to avoid reproducing cont.43's unexplained touch-responsiveness regression from hooking `sub_16C660` (a function this same Tick calls). Confirmed the hook installs correctly (offset/prologue verified via IDA, install log fires). Live-tested on **three** separate occasions - car_select's own browsing screen, its loadout screen, and a REAL (non-synthetic) event's full flow through both screens - **zero Tick calls logged, ever**, despite cont.42/43's own record describing this exact function firing "~200x/sec from app boot onward". First test round's "success" (car_select screen showing correctly) turned out to be Android's `onRestart` lifecycle resuming a backgrounded activity, not a fresh construction - re-tested properly with a full `am force-stop` + fresh launch + real map-pin tap, and the negative result held. +- **Reverted the Tick hook entirely** (not just disabled) - not worth keeping installed-but-nonfunctional code around, and it's unclear whether it's genuinely dead in this build or something about the hook itself is still subtly wrong; either way, not a productive foundation to build on further right now. +- **Deferred-timer variant (polled from `Hook_InternStringDiag`, 500ms after `RestrictedGarage` constructs - the same wall-clock pattern every other deferred fire in this file already uses) - crashed for real, again**: `SIGSEGV`, `"Dereferencing a NULL component pointer"`, fault addr `0x7` this time (vs `0x44` for the original immediate-post-construct attempt) - a **different** null field inside the same function (`sub_16692C`), at a **very** different timing (500ms vs 0ms). Two independent crashes at two very different delays rules out simple "not ready yet" timing as the root cause. +- **Conclusion**: `sub_16692C` most likely depends on something our **synthetic** event flow never populates at all, not on anything timing-related - consistent with this whole project's recurring pattern of synthetic-vs-real state gaps (e.g. `g_realEventDetailsVisitHappened`, cont.55-57). Retrying with yet another delay or hook point isn't likely to help without first identifying what that missing dependency actually is - e.g. by decompiling what specifically sits at offsets `0x44`/`0x7` on whichever object faulted, or by testing this exact rebuild call on a REAL event's car_select (not yet tried) to see if it succeeds there, which would confirm the "synthetic-only gap" theory directly. +- **Snapshot**: `lan_event_injection.h`/`main.cpp`. All class-list-rebuild attempts (constructor-time, Tick-based, deferred-timer) fully reverted - only `RACEEVENT_CARCLASS_OFFSET`'s field-write (the working label) and `RebuildCarClassList`'s own function-pointer resolution (a proven-real primitive, kept resolved for whenever this is picked up again) remain. `kSyntheticEventCarClass` back at `0` (CAR_CLASS_ANY, safe default). Build and live device stability reconfirmed after every revert. +- **Outcome/Next**: Still two-thirds solved - class *label* works and ships, class *list filtering* remains unsolved after three independent timing strategies. This is no longer a timing problem to iterate on; it's a "what does `sub_16692C` actually need, and does our synthetic event flow have it at all" question. Recommended next step if resumed: test the exact same `RebuildCarClassList(a1)` call on a **real** event's car_select (confirmed reachable this session via a real map street's event list) to check whether it crashes there too - if it doesn't, the gap is synthetic-event-specific and the fix is providing whatever real events have; if it does, the function needs a proper argument/precondition trace via IDA before it's safe to call at all. + +## 2026-08-22 (cont. 66) — CRITICAL: found and fixed two real crashes on a genuinely fresh save/first race, both pre-existing gaps this project's testing had never actually exercised + +- **What**: User reported the game had become unplayable end-to-end - "impossible to complete any race", race results not saving on a fresh save, and a guaranteed crash right after the very first race on genuinely fresh game data. This session's entire prior testing (cont.61-64) had exclusively used an already-100%-complete save (loaded specifically to make car-variety testing easier) or mid-progression saves, which never exercised the prologue/tutorial race or an empty car-record registry - both gaps this entry found. +- **Bug 1 - `GetCurrentCarColor()` (cont.63b) can compute a wild pointer on a car never seen before**: re-decompiled `GetPaintJobDescription`/`sub_B3564` and found its own out-of-range handling is broken - it logs an assertion but still `return`s `vectorBegin + 112*index` using the *original*, still out-of-range `index`, not a safe fallback. Separately, `LookupCarRecord`'s own "not found in the hashmap" fallback record (`sub_25102C`'s lazily-initialized default object) is allocated via plain `malloc` (not `calloc`) and never explicitly zeroed - so its own `+4` field (our color index) can be genuine uninitialized heap garbage. On a fresh save, before the car-record hashmap has any entries, this combination means the player's very first car_select confirm could compute and dereference a wild pointer. **Fix**: `GetCurrentCarColor()` now independently reads the same vector bounds `sub_B3564` itself reads (`carDescPtr+104`/`+108`), validates them, and clamps `colorIndex` to `[0, count)` itself before ever calling `GetPaintJobDescription` - never trusts the native function's own broken fallback. +- **Bug 2 (the real, confirmed root cause of the reported crash) - `kEnableTrackSubstitutionHook` (a much older PoC toggle, subtask 1 of this whole project) was left `true` and has zero scoping**: `Hook_BuildTrackScenePath` unconditionally overrides the track/environment for **every** race load in the game, not just our own synthetic LAN test event - confirmed via `main.cpp`, no gate at all. Live-reproduced on a genuinely fresh save: `SIGSEGV`, null pointer deref, fault addr `0x14`, on a background loading thread, immediately after `"BuildTrackScenePath hook fired: track -> region4_chicago_track4, env -> chicago4"` fired during the game's own scripted **prologue/tutorial race**'s `PreRaceLoadingScreen`. The prologue's other scripted data (checkpoints, cutscene triggers, start/finish) still expects its own original track, so the substitution mismatches and null-derefs downstream - the exact same crash *class* this hook's own comment already documents for the abandoned region3/colorado override, just newly hitting a *different* race (the prologue) that no prior test session ever reached, since every earlier test used a save that had already passed it. This project's own `[[project_track_substitution_scope]]` note ("only regular races need to work") was an *intent*, never actually enforced in code. **Fix**: default `kEnableTrackSubstitutionHook` to `false` - this is a standalone PoC/test tool, not something that should run as part of normal play; flip it on deliberately only when specifically testing track substitution itself. +- **Verification**: live-tested on this session's own test device after a full `pm clear` (genuinely fresh save, matching the user's own repro exactly) - reached and completed the prologue race (gold/1st place, `+10000$`/`+20000 SP`), multiple screen transitions afterward, zero crashes, before the user's own parallel manual test (with real steering, which this session's own automated adb testing can't provide) confirmed the actual reported crash live and its exact log signature, which is what led to finding Bug 2 above. +- **Snapshot**: `lan_event_injection.h` (`GetCurrentCarColor`'s new bounds-check), `main.cpp` (`kEnableTrackSubstitutionHook` default flipped to `false`). Both fixes are defensive/config-level, not new features - no other behavior changed. +- **Outcome/Next**: Both real regressions found and fixed this entry. Standing lesson for this whole project, not just this bug: **testing exclusively on an already-progressed/100%-complete save systematically misses anything gated on "first ever" state** (empty registries, the prologue, first-run flags) - worth deliberately re-testing major changes against a `pm clear`-fresh save periodically, not just the convenient late-game save. `kEnableTrackSubstitutionHook`'s own track-substitution PoC itself (region4_chicago_track4 override) remains available, just off by default now - re-enable deliberately and re-scope it (e.g. gate on `g_currentEventIsSynthetic`) if that line of work is ever resumed. + +## 2026-08-25 (cont. 67) — Third critical regression found: real race medals silently never recorded, root-caused to a stale "narrow test fix" left permanently installed + +- **What**: After cont.66's crash fixes, user confirmed the prologue race completes without crashing - but reported a new symptom: finishing a real race (career/tutorial, e.g. "Перед вами FAIRHAVEN") on gold correctly grants cash/SP, but the medal itself is never recorded and the street's completion-% badge stays at 0%. User was confident this was a regression from this project's own patches specifically, not a pre-existing/server-side limitation (correctly - see below). +- **Read-side investigation**: decompiled `MapTrack::AddEvent` (`sub_369AB0`, called from `MapTrack::RefreshEvents`/`sub_369040` whenever a street's pin refreshes). It increments a per-pin "total medal slots" counter (`this+272 += 3`) unconditionally, but only increments "medals earned" (`this+276`) after resolving a per-event persistent progress record through a nested lookup chain (`sub_77B70`→`sub_251188`→`sub_77B70`→`sub_2515D0`/`sub_250F34`) and reading a reflective `"medal"` property off it (`sub_4F99F0`/`sub_4F9A80`, the same generic property-getter pattern already reverse-engineered for `CashReward`'s Bronze/Silver/Gold in earlier sessions) - this directly drives `RefreshEvents`'s `(this+276)/(this+272)*100` completion badge. Also found a same-shaped per-driver "medal" write path (`sub_235798`/`sub_236ED0`, building/reading a results-table row with `name`/`vehicle`/`localPlayer`/`time`/`medal` columns) used for the PostRaceResults display itself - a different, in-memory-only subsystem, not the persistent progress record. +- **Ruled out**: live logcat showed a recurring `[AUTOLOG QUERY] Server: Blaze Call: /nfs-2013-android/util/ping` firing every ~10s throughout the whole session with no response ever logged - EA's original online backend for this game is dead, so this looked like a plausible "medal commit needs a server ack that never arrives" explanation. **User correctly pushed back**: this exact symptom never happened before this project's patches, and the dead Blaze backend predates and is unrelated to any of this session's work - redirected the investigation back to this project's own hooks. +- **Method**: rather than continue tracing the write-side nested-registry chain blind (`sub_366160`/`sub_36AEC0`, both large, both call the same `sub_77B70`/`sub_251188`/`sub_2515D0` family), re-audited every hook `main.cpp` installs unconditionally (i.e. not behind any of the existing `kEnable*` toggle flags): `CopSoundsTickSkip`, `GetComponentNameSkip`, `StrlenNullGuard`, `InternStringDiag`, `FatalLogCallerTrace`, `ResolveDisplayText`, `ResolveDisplayTextWrapperDiag`, `LayoutScreenCtor`, `ModSlotSelected`, `FireOutputDiag`, `FlowNodeTick`. Added a temporary `kEnableDiagnosticAndTestHooks` toggle to install none of them (keeping only `MapTrackHandleEvent`/LAN injection and the Soak Test disable hook) - live-tested, **medal recorded correctly** with all of them off. Bisected by re-enabling all of them except `GetComponentNameSkipHook` - live-tested again, **medal still recorded correctly** - isolating that one hook as the sole cause. +- **Root cause**: `GetComponentNameSkipHook` (`Hook_GetComponentName`, hooking `sub_240548`) was added in cont.29/30 as a workaround for one narrow SIGSEGV: our own synthetic test actor (from `InjectSyntheticEvent`, never going through normal prefab registration) wasn't found in a hash-table lookup inside `GetComponentName`, falling into an RTTI/error-string fallback path that crashed. The fix as written always returns a fixed empty-string sentinel (`off_AC80E0`) for **every** call, unconditionally - not just the specific null-input case the comment described as "a real, already-safe code path in the shipped binary." Decompiling `sub_240548`'s actual callers found this was never "purely cosmetic" for real gameplay: `sub_240294` doesn't just log the returned name, it **writes it into a named-cache-context object's own `+8` field** (`*(a1+8) = sub_240548(...)`, the cache context itself resolved via `sub_7566C` - the *same* cache-context accessor the medal-lookup chain in `AddEvent` also uses), and `sub_240294` is called from `sub_17A99C` - the same real map-event-processing function this project's own `FireEventOutput` targets. With every real gameplay entity's component name always resolving to the same empty string instead of its real name, whatever downstream lookup/keying depends on that name plausibly collides or misses - breaking the medal-progress record specifically, while leaving the independent cash/SP grant path (which doesn't go through this same naming step) unaffected. This exactly matches the observed symptom. +- **Fix**: `InstallGetComponentNameSkipHook()` is no longer called from `JNI_OnLoad` - left declared/available (commented out) for if the original narrow crash needs revisiting later, but must not ship installed, since it silently corrupts real save progression for every player on every real race, not just the one synthetic test scenario it was originally written to unblock. +- **Verification**: live-tested three times total this session (all-diagnostic-hooks-off, all-but-this-one-on, and the final clean build with just this one hook removed) - medal correctly recorded in all three, confirming both the root cause and that no other currently-installed hook is implicated. +- **Snapshot**: `main.cpp` (`InstallGetComponentNameSkipHook()` call site removed from `JNI_OnLoad`, function itself left in place - now in `crash_workarounds.h` after cont.68's file split - unused). +- **Outcome/Next**: Standing lesson for this whole project: a hook written to fix one specific crash in one narrow test scenario (here, triggering the synthetic LAN event's own car-select flow) can silently corrupt unrelated, real gameplay systems if its "safe" substitute path is taken unconditionally instead of only for the exact input/condition that used to crash - always prefer gating narrow crash workarounds on the specific condition (a null input, a known synthetic-only object identity) over blanket-replacing a function's behavior. If the original cont.29/30 crash needs revisiting (e.g. to resume car-select-list-filtering work or the synthetic-event demo flow), a properly scoped fix should check the actual crash condition (or gate on `g_currentEventIsSynthetic`) rather than reinstalling this hook as-is. + +## 2026-08-25 (cont. 68) — Refactor: split lan_event_injection.h (3149 lines) into topical files, dedupe hook-install boilerplate, delete confirmed-dead code + +- **What**: User flagged that `lan_event_injection.h` had grown to ~3000 lines mixing unrelated concerns, with comments in places exceeding the code they documented - explicit direction to bring the file's architecture in line with what it's actually doing, and cut comment volume down. Pure refactor, no behavior change intended (or found) - verified live afterward. +- **Deleted ~490 lines of confirmed-dead code** (grep-verified zero call sites anywhere in the project, not just `main.cpp`): two experimental SIGSEGV-based software watchpoints on the state singleton (`InstallSingletonField12Watchpoint`/`InstallInPlaceSingletonWatchpoint`, cont.55/58 - both had their only call sites already removed in earlier sessions) and three one-shot diagnostic hooks (`InstallGenericFireOutputWrapperDiagHook`/`InstallSub170EC8DiagHook`/`InstallConfirmCarSelectionHook`, cont.43/54/55 - never called from `JNI_OnLoad`). Left a short pointer comment to the relevant PROGRESS.md sections in case either investigation resumes, instead of keeping the dead code itself around "just in case." +- **New `util/hook_install.h`**: one shared `InstallArmTrampolineHook(base, offset, hookFn, debugName)` helper implementing this project's one hooking pattern (2-word-copy trampoline + `LDR PC,[PC,#-4]` redirect) - replaces ~15 hand-copied ~28-line blocks (mmap/mprotect/patch/clear-cache, each previously retyped by hand per hook) with a single ~3-line call per installer. Every `Install*Hook()` in the project now goes through this one function; lower duplication, lower chance of a hand-copy typo in a security-sensitive code-patching path. +- **New `car_selection.h`**: `GetCurrentCarId`/`CarColor`/`GetCurrentCarColor` and their offsets (`GET_CAR_REGISTRY_OFFSET`/`LOOKUP_CAR_RECORD_OFFSET`/`GET_PAINT_JOB_DESC_OFFSET`/`REBUILD_CAR_CLASS_LIST_OFFSET`/`SINGLETON_INSTANCE_OFFSET`) - reading the player's live-selected car and its persisted paint color, entirely separate from the synthetic-event-injection machinery. (Car-CLASS handling - `RACEEVENT_CARCLASS_OFFSET`/`SINGLETON_CARCLASS_FILTER_OFFSET`/`RaceClassToUiFilterIndex` - stays in the core file: unlike color, it's about labeling/filtering the *synthetic* event's own class, deeply embedded in `InjectSyntheticEvent`'s `FakeActor` construction, not a standalone car-read concern.) +- **New `crash_workarounds.h`**: the narrow, single-purpose crash mitigations from cont.29-34 (`CopSoundsTick` skip, `GetComponentName` skip [kept disabled per cont.67's finding, with that lesson documented directly on the hook], `Strlen` null-guard, `ResolveDisplayText`'s "XXXXX[...]" marker strip, `ResolveDisplayTextWrapperDiag`). +- **New `mod_slot_tracking.h`**: loadout mod-slot-pick tracking (`ModSlotSelection`, `RecordModSlotSelection`, `PersistAcceptedUpgrades`, `Hook_ModSlotSelected`) - self-contained, only touches `game_events.h`'s `FireUpgradesAccepted`. +- **Comments trimmed**, not just relocated: the densest remaining block (the synthetic-event/real-exit-chain state-machine documentation at the top of the core file, ~300 lines) was condensed from multi-paragraph per-session "cont.NN" narration into single-paragraph why-notes - kept every load-bearing fact (offsets, invariants, what breaks if a gate is removed) but cut the blow-by-blow investigation diary, which already lives in this file's own history. +- **What stayed in the core `lan_event_injection.h`, deliberately not split further**: the synthetic-LAN-event-injection state machine itself (`InjectSyntheticEvent`, `Hook_MapTrackHandleEvent`, `Hook_LayoutScreenCtor`, `Hook_FireOutputDiag`, `Hook_InternStringDiag`, `Hook_FlowNodeTick`, `Hook_MapScreenTick`/Soak-Test-disable, the on-demand car-select triggers) - these all read and write the same dozen-odd global state flags (`g_currentEventIsSynthetic`, `g_realExitChainActive`, `g_chainIndex`, etc.) in a single cohesive state machine; splitting them across files by "which hook function this is" rather than "what subsystem this belongs to" would have fragmented one coherent concern instead of clarifying it. +- **Verification**: rebuilt after each extraction step (5 separate incremental builds, all `BUILD SUCCESSFUL`), then installed the final build on the test device and confirmed via logcat that all 12 expected hooks still install cleanly (`GetComponentNameSkipHook` correctly still absent, per cont.67) and that LAN synthetic-event injection + map navigation behave identically to before the refactor (same `MapTrack` enumeration, same synthetic-event injection log line, same `WARM-UP` capture) - no crash, no missing hook, no behavior change. +- **Snapshot**: `lan_event_injection.h` 3149 → 1691 lines. New: `car_selection.h` (125), `crash_workarounds.h` (200), `mod_slot_tracking.h` (86), `util/hook_install.h` (51) - 2153 total lines across all 5 files, vs. 3149 in one file before. Committed together with cont.64/66/67's fixes (class injection, the two fresh-save crash fixes, and the medal-tracking regression fix) as a single commit on `lan-event-injection-poc`, since all of it was still uncommitted from the same work session. +- **Outcome/Next**: File organization now roughly matches actual concerns (car reads / crash workarounds / mod tracking / shared hook infra / the core injection state machine), and the hook-install pattern has one canonical implementation instead of 15 hand-copies. If this file grows again, prefer adding a new topical file over growing the core one further - but resist over-splitting the exit-chain/synthetic-event state machine itself, which is genuinely one coupled subsystem, not several. + +## 2026-08-26 (cont. 69) — Subtask 2 fully scoped via RE: starting grid, opponent substitution, and cop-spawn all mapped to real addresses; no code yet + +- **What**: Autonomous overnight session (explicit instruction: work through the token budget, report at the end) answering all four open questions from `ARCHITECTURE.md` §3b (scoped the prior session): is the starting grid per-track, is "player always last" an index or insertion-order effect, where does an opponent's car/color get written, and where do cops/traffic spawn. Pure research — no hooks installed, nothing built or tested on-device. +- **Method**: started from already-unpacked `event_*_race.prefabs.sb.json` files (62 events, from an earlier session) plus two freshly-unpacked full track scenes (`region4_chicago_track1`, `region1_foothills_track1`, ~75k `DATA_Elements` each), then moved into IDA once the data side gave concrete leads (RTTI class names, property-name strings) to chase. +- **Grid layout — not per-track**: `StreetRaceStartingGrid`'s 5 spacing/speed parameters (`MinDistanceBetweenRacers`=10.0, `MaxDistanceBetweenRacers`=15.0, `MaxTrackWidthFraction`=0.8, `PlayerStartingSpeed`=100km/h, `OpponentsStartingSpeed`=130km/h) are hardcoded C++ constructor defaults, confirmed via decompile and confirmed via a full struct-name enumeration across all 62 events that no event ever overrides them via data. Each track supplies only a single `"start"` locator actor (confirmed via two full scene unpacks — no numbered/per-slot spawn actors exist anywhere). The `0xcfeb5c`/`0xcfeb8c`/`0xcfebb8` vtable addresses recorded in `ANALYSIS.md` §3.1 are stale (from an old `.i64`) — real addresses are `0xaa7d28`/`0xaa7da8` (`RaceStartingGrid`/`StreetRaceStartingGrid`). +- **"Player always last" — insertion-order, not an index**: the placement algorithm (`sub_2B88BC`) loops over the opponents vector first (accumulating randomized inter-car spacing), then places the player once at the tail in a separate call. This means multiplayer doesn't need to fight this rule — real players inserted into the opponents vector get normal grid slots via the same code path as AI, and only each device's own local player keeps the dedicated last slot (which is fine/expected). +- **Opponent car+color write path found**: `Opponent`'s live layout (`+20/+24/+28`=`CarDescriptionName` string, `+36`=`ColourIndex` int) and its builder (`OpponentCollection::PopulateFromProperties`, `sub_2B649C`) are both fully decompiled. Plan: hook after this builds the normal AI list, overwrite selected entries' two fields with real players' own already-captured car/color data. +- **Bonus, relevant to subtask 4**: `PlaceCar` (`sub_2914DC`) calls the *real* `TrackNavigator::Resolve` (`sub_3261B0`) — the spline-distance+lateral-offset → world-position transform `ARCHITECTURE.md` §5 predicted subtask 4 would need. Only the forward direction is confirmed; the inverse (world→spline, needed to read a moving car's current position) not yet found. +- **Cops**: found `SpawnCopCar` (`sub_F85B8`) and its scheduler gate (`sub_F7E9C` — cooldown timer + active-count check before spawning). Plan: skip the scheduler's body entirely for multiplayer sessions, same pattern as the existing `CopSoundsTick` hook. A second caller (`sub_F8078`) not yet examined. +- **Civilian traffic**: confirmed data-level (`RaceEvent.TrafficCarCount`, `TrafficFlow` component) but the runtime spawner wasn't traced — likely `TrafficCarCount=0` alone is sufficient (same pattern as every other per-race data override in this project), needs a live A/B test to confirm. +- **Snapshot**: `ANALYSIS.md` new §6hh (full technical writeup, all addresses/offsets/decompiled functions), `ARCHITECTURE.md` §3b rewritten from a scope-only placeholder to reflect these findings with per-item status tags. No `main.cpp`/`lan_event_injection.h` changes — this entry is planning/RE only. +- **Outcome/Next**: subtask 2 is now RE-complete enough to start implementing hooks. Recommended order: (1) `OpponentCollection` substitution hook (2.1) since it's the most self-contained and directly reuses this project's existing string-write/field-override patterns; (2) verify (1) also produces correct grid placement for free, confirming the Q2 insertion-order theory live; (3) cop-spawn scheduler skip (simple, same shape as an already-proven hook); (4) civilian traffic A/B test. Subtask 4 (coordinate sync) remains deliberately deferred per the user's own reasoning (avoid discovering facts that contradict subtask 2's now-large accumulated fact base) but already has a head start via the `TrackNavigator::Resolve` finding. + +## 2026-08-26 (cont. 70) — Subtask 2.1 implemented and CONFIRMED LIVE (opponent car substitution reaches the rendered grid); cop-spawn scheduler hook implemented; civilian traffic found harder than scoped + +- **What**: Direct continuation of cont.69's RE pass - user explicitly asked to start implementing subtask 2 point by point, working autonomously through the night, moving to the next open item rather than waiting whenever something needed a decision only the user could make (e.g. spending in-game currency to unlock a car class - skipped that path and found an already-owned-class event instead). +- **Diagnostic hooks first** (`opponent_substitution.h`): hooked `OpponentCollection::PopulateFromProperties` (`sub_2B649C`) and `StreetRaceStartingGrid`'s placement method (`sub_2B88BC`) to log every pointer both touch, specifically to answer cont.69's one open verification question - are these the same vector? Live-tested via a real (non-synthetic) event replay ("Перед вами FAIRHAVEN", driven through the whole menu→car-select→grid flow via `adb` UI taps): **confirmed they are NOT the same objects** - `StreetRaceGrid::Place`'s vector held addresses that never appeared in `OpponentCollection`'s own vector across two separate captures. There's a real, unidentified intermediate "spawn the actual racer" step between them. +- **Despite that, the substitution mechanism itself works**: modified the hook to unconditionally force opponent-slot-0's `CarDescriptionName`/`ColourIndex` to a fixed test car (`"ford_focus_rs500_2010_desc"`) at `Populate` time (map-load), rebuilt, and replayed the same real event end-to-end. **Screenshot proof**: before the hook, the lead opponent car was a white sedan-shape car; after, it's unmistakably a silver/gray Ford Focus RS500. Logcat confirms the in-memory write held across every collection populated that pass. **This settles subtask 2.1's core open question**: whatever the intermediate spawn step is, it reads car/color off these same `Opponent` objects (or a copy taken strictly after `Populate` runs) - the hook point identified in cont.69 is sufficient, no need to trace the intermediate step further. +- **Cop-spawn scheduler hook implemented** (`cop_traffic_disable.h`): hooks `sub_F7E9C` (the cooldown+count-check gate before `SpawnCopCar`) to skip its body entirely when enabled. Installs cleanly, confirmed stable through map/menu navigation, no crash - but the scheduler never actually fired while idle on the map (0 log lines), meaning it's very likely gated on active driving or a pursuit context. Visual "cops don't spawn" confirmation needs a longer, active-gameplay or pursuit-triggering test than this session's tap-only navigation could practically set up - left as a follow-up. +- **Civilian traffic correction**: cont.69's own writeup undersold this - `RaceEvent.TrafficCarCount` is not a plain fixed-offset field, it resolves through the same reflective per-name property lookup already seen for `"medal"` (cont.67) and `ColourIndex`. A post-deserialization field overwrite (the plan from cont.69) doesn't apply the way it did for `TrackName`/`CarDescriptionName`. Needs either the real runtime traffic-spawner function (not found) or a narrower hook on the reflective lookup helper itself, gated on the `"TrafficCarCount"` key. Deferred, not attempted this session. +- **Both new hooks left gated off by default** (`g_enableBlanketOpponentSubstitutionTest`/`g_enableCopSpawnSkipTest`, both `false`) before ending the session - the opponent substitution is a blanket test (no lobby data model exists yet to target a specific race/player), and leaving either on unconditionally would affect the user's own normal singleplayer testing. Final build installed on the test device has both off - safe for normal play. +- **Snapshot**: new `opponent_substitution.h`, new `cop_traffic_disable.h`, both wired into `main.cpp`'s `JNI_OnLoad` (unconditionally installed, behavior internally gated by the runtime flags above - diagnostic logging stays on, low-risk/budget-capped). `ANALYSIS.md` new §6ii/§6jj, `ARCHITECTURE.md` §3b updated with current per-item status. +- **Outcome/Next**: Subtask 2.1's core mechanism is proven end-to-end - the remaining work is entirely a lobby/session data-source problem (which race, which real players' cars/colors), not further RE. Subtask 2.4's cop half is code-complete pending a proper visual test; its traffic half needs another RE pass on the reflective property system. Recommended next steps, roughly in order: (1) a proper active-gameplay test of the cop-scheduler skip (drive around / trigger a pursuit, not just idle on the map), (2) trace the reflective `"TrafficCarCount"` consumer or scope a keyed hook on the property-lookup helper, (3) once the lobby UI/data model exists, replace the opponent-substitution blanket test with the real, targeted version. + +## 2026-08-26 (cont. 71) — Civilian traffic hook implemented and CONFIRMED LIVE, correcting cont.70's own mistake about TrafficCarCount + +- **What**: Direct continuation of cont.70, same autonomous session. Re-examined `RaceEvent`'s deserializer (`sub_2A4D70`) decompile more carefully after cont.70 wrongly concluded `TrafficCarCount` needed a harder, keyed reflective-lookup hook. +- **Correction**: the reflective read (`sub_4F99F0`/`sub_4F9A80`) happens exactly once, during deserialization - identical in shape to every other field this function reads. The resolved value is then stored as a plain int at a **fixed offset**, `RaceEvent+116`, on the live object - the original cont.69 plan (hook after deserialization, overwrite the field) was right all along. +- **Implemented and live-tested**: second hook in `cop_traffic_disable.h`, on `sub_2A4D70` itself, zeroing `+116` after the real deserializer runs. Confirmed via logcat: fired once per event at map-load time, correctly zeroed real non-trivial values (`1`, `2`, `10` for one event) to `0`, no crash, process stayed stable through the same real-event test flow as cont.70. +- **Not independently confirmed**: actual absence of ambient traffic while driving - only checked via the data write (logcat) and a stationary starting-grid screenshot, which doesn't show moving traffic either way regardless of the hook. A real visual check needs active driving, which this session's `adb`-tap-only navigation can't practically simulate. +- **Snapshot**: `cop_traffic_disable.h` now has two hooks (cop-scheduler skip, traffic-count zero), both gated off by default before ending the session. `ANALYSIS.md` §6jj corrected in place (not a new section - fixed the wrong conclusion directly, noted as a correction). `ARCHITECTURE.md` §3b's traffic status updated to match. +- **Outcome**: All four of subtask 2's original open items now have working, live-tested (to varying confidence levels) implementations: 2.1 opponent substitution (fully confirmed, screenshot proof), 2.2/2.3 grid/spawn-order (solved via RE, no code needed beyond 2.1's own hook), 2.4 cops (installs cleanly, spawn-time behavior not yet visually confirmed) and traffic (data write confirmed, visual road-emptiness not yet confirmed). Everything is gated off by default - the device was left in a safe, normal-play state. Next real step for subtask 2 is no longer RE - it's building the lobby/session data layer that can actually drive the now-proven opponent-substitution hook with real player data, plus two remaining visual confirmations (cops during a pursuit, traffic while driving) whenever manual/longer testing is convenient. + +## 2026-08-26 (cont. 72) — Full mock multiplayer grid emulated: 5 distinct cars/colors + randomized (non-last) player position, both confirmed live together + +- **What**: User asked to try emulating a fuller session - different cars/colors per opponent (not the single fixed test car from cont.70) and a randomized player grid position (not always last). +- **Varied roster**: extended the blanket opponent-substitution test from one car to a 5-entry roster of distinct real cars/colors, cycled by slot index. Live-confirmed: screenshot shows 3 visibly different cars on the same grid (BMW M3, Dodge Challenger, Ford Focus RS500), logcat confirms all 5 slots got distinct assignments correctly and consistently. +- **Random player position - the harder half**: "player always last" (cont.69/§6hh) is baked into the grid-placement function's own call ORDER (place every opponent first, then the player once at the tail), not a data field - so this needed a genuine reimplementation of that placement loop, not another post-hoc field tweak like every other hook this session. Wrote a new branch in the `StreetRaceGrid::Place` hook that calls the same two real engine primitives (`sub_291BA4` for opponents, `sub_2914DC`/PlaceCar directly for the player) in a randomly reordered sequence - `rand()` picks which of the `count+1` slots the player lands in, falling through to the completely untouched original function whenever the test flag is off. +- **Both confirmed live, together, in one test run**: logcat showed `RANDOM GRID TEST: 5 opponents, player placed at slot 0/5` on the exact same real-event replay that produced the 3-distinct-cars screenshot - no crash, stable through the full menu→car-select→grid flow. +- **Snapshot**: both extensions in `opponent_substitution.h`, gated off by default before ending - device left in a safe, normal-play state (both flags `false`). +- **Outcome/Next**: subtask 2 now has a fairly complete local demonstration of what a real multiplayer grid would look like - varied cars/colors, unpredictable player position, all working simultaneously and confirmed stable. The single remaining gap, unchanged from cont.70/71, is the lobby/session data layer to feed this real player data instead of a hardcoded test roster and `rand()`. + +## 2026-08-26 (cont. 73) — Full-save re-verification, a real spawn-collision bug found and fixed live, cop-scheduler test inconclusive + +- **What**: Continuation of cont.72, now against a user-provided "full" 100%-complete save (backed up the prior save to two locations first: scratchpad + device `/sdcard/`) to re-verify all four subtask 2 hooks on fresh, previously-untested tracks/cars instead of the same repeatedly-driven event. +- **Re-confirmed on a new track** ("Битва на шоссе" / Highway Battle, Reynolds Lane, player car Bugatti Veyron 16.4 Super Sport instead of the earlier test car): opponent roster substitution (all 5 distinct cars cycling correctly), `TrafficCarCount` zeroing (51 firings, real non-zero values correctly zeroed), and random grid position (different slot than previous tests, confirming the randomization varies per race) all still worked exactly as cont.70-72 found, on genuinely different data. +- **Real bug found by the user, live**: while watching a race start, the user caught the player's car spawning **inside** another car and being launched by the resulting physics interpenetration. This was cont.72's random-grid-position hook, not the substitution or traffic hooks. +- **Root cause, found by fully decompiling `sub_291BA4`/`sub_2914DC`/`sub_2B88BC`** (previously only partially read in cont.69): `sub_3261B0` (`TrackNavigator::Resolve`) returns `v13`/`v14`, the world-space lateral lane bounds at a given track distance, symmetric around the centerline. The player is always placed at **literal lateral offset 0** (dead center). An opponent's lane-cycle formula `((a6+1)%3)*0.5*a4 + (1-a4)*0.5` lands exactly on the same center (0) whenever its index `a6 ≡ 0 (mod 3)`. Vanilla is safe because the player is placed once, at the very end, past the *entire* accumulated distance of every opponent - lane overlap never matters because longitudinal separation is always huge. Cont.72's reimplementation broke that invariant by inserting the player mid-sequence, using only the normal `MinDistanceBetweenRacers..MaxDistanceBetweenRacers` gap - a spacing sized for two cars in *different* lanes, not the same one. When the player's random slot landed adjacent to an opponent with `a6 ≡ 0 (mod 3)` (observed: player at slot 1/5, right after opponent 0), both cars ended up in the same lane with insufficient longitudinal clearance → spawn overlap → launch. +- **Fix**: widened the distance gap specifically on both sides of the player's slot (`(minDist+maxDist)` to `(minDist+maxDist)*2` instead of the normal `minDist..maxDist`) in `Hook_StreetRaceGridPlace` (`opponent_substitution.h`) - guarantees longitudinal separation alone prevents overlap regardless of which lane either car ends up in, without touching the opponent lane-cycle math at all. +- **Fix verified live**: rebuilt, reinstalled, replayed the *same* event repeatedly via "Начать заново" until the RNG reproduced the same collision-prone case (player slot 1/5, immediately after opponent index 0) - car spawned cleanly, no interpenetration, raced normally (999 km/h, 1st place, car visibly undamaged). Confirmed across multiple restarts landing on different slots (5/5, 5/5, 1/5), all clean. +- **Cop-spawn scheduler test - still inconclusive**: navigated to a Blacklist daily challenge ("Нижняя ступенька", MOST WANTED #10, Reynolds Lane) expecting a pursuit context. Turned out to be a straight 1v1 no-rules race ("гонка без правил один на один") with no wanted/heat meter UI visible - not actually a police-pursuit event in this game mode. Scheduler hook still never logged a single firing across three separate sessions now (original save, full save idle/driving, full save Blacklist race). Cop spawning almost certainly requires a genuine wanted-level/pursuit state that hasn't been triggered yet by any tested event type. +- **Session wrap-up**: all four test flags (`g_enableBlanketOpponentSubstitutionTest`, `g_enableRandomPlayerGridPositionTest`, `g_enableCopSpawnSkipTest`, `g_enableTrafficCarCountZeroTest`) reverted to `false`, rebuilt, reinstalled - device left in a safe, normal-play state. +- **Snapshot**: `opponent_substitution.h`'s `Hook_StreetRaceGridPlace` gets the widened-gap fix (with an explanatory comment on the root cause, not just the fix). No other file changes this entry. +- **Outcome/Next**: subtask 2's random-grid-position hook is now collision-safe as well as functionally confirmed - this was a real, user-caught bug in previously-"confirmed live" code, a good reminder that "no crash" and "visually looks right in a screenshot" aren't the same as "physically correct," especially for anything touching placement/transforms. Cop-spawn scheduler confirmation remains the one loose end from subtask 2.4 - next attempt should specifically look for an event type with a visible wanted/heat meter (a genuine police pursuit, not just a "Most Wanted"-branded leaderboard race) rather than assuming any MW-themed event implies cops. + +## 2026-08-26 (cont. 74) — Cop-spawn scheduler's real two-path shape found and fixed (live roadblock A/B proof); ambient-traffic spawner found, partially chokeable + +- **What**: User reported live, mid-race, that they'd seen a police car during the very race that also produced cont.73's grid-collision bug - directly contradicting the standing belief that cops never spawn in a plain street race. Explicit instruction: keep digging, work as long as possible, open other investigation branches too. Pure autonomous continuation, no further user input during this entry. +- **Cop-spawn scheduler - real shape found**: fully decompiled `sub_F8078` (previously unexamined) and its own caller, and found the piece cont.70's original hook missed - `sub_F5EA4` is a **per-tick dispatcher**, itself only called from `sub_F5BB4` (CopManager's broader per-tick `Update`, which also runs unrelated bust-timer/etc. bookkeeping - too broad to skip wholesale). `sub_F5EA4` branches on a live byte flag (`*(a1+4036)`): true → `sub_F7E9C` (the only leaf cont.70 had hooked), false → `sub_F8078` (a *second*, previously-unhooked leaf - a distance-sorted-candidate-list scheduler that can check/spawn multiple cops per tick). **Both leaves call the same `SpawnCopCar`/`sub_F85B8`** (confirmed via `xrefs_to` - exactly 2 callers, both now covered) - hooking only `sub_F7E9C` left `sub_F8078`'s branch completely open, which is almost certainly why cops were showing up live with the old hook enabled and 0 log lines from it (all spawn activity was going through the un-hooked branch the whole time). +- **Fix**: moved the hook from `sub_F7E9C` to the shared dispatcher `sub_F5EA4` - one hook now covers both leaves, without touching `sub_F5BB4`'s other per-tick cop maintenance. +- **Live A/B proof, unambiguous**: replayed the exact same event ("Битва на шоссе", Reynolds Lane) three times. **Flag off**: drove through and hit a full police roadblock mid-race - multiple marked "POLICE" cars, light bars, cops standing in the road, a stop sign - screenshotted, completely real, on a plain street race with no "Most Wanted" branding. **Flag on** (new dispatcher hook): replayed the same event, dispatcher hook fired 7 times in a tight burst right at the same point in the route (logcat), and the road was completely clear at the equivalent location - no roadblock, no cops, clean pass-through. This is the strongest live confirmation of any hook this session - a real visual before/after on the exact same content. +- **Ambient traffic - real spawner found, only partially chokeable**: cont.71's `TrafficCarCount=0` field-write hook was independently re-tested live (drove the actual race, not just checked logcat) and civilian traffic (a red pickup, a blue sedan) was still clearly present - the field write was real but doesn't gate ambient traffic at all. Traced the actual spawner: `sub_33D734` is `TrafficCarSpawner`'s populate function (own assert string confirms the class name), called twice per race from `sub_33C020` (once per traffic direction/lane-group), with its max-count parameter read from a completely different object (`*(a1[3]+16)`/`*(a1[3]+20)`) than `RaceEvent.TrafficCarCount`. Hooked `sub_33D734` directly, forcing that max-count parameter to 0 - **but the underlying loop has a `do { ... } while` shape that unconditionally builds at least one traffic-car candidate per track-authored waypoint/road-node, regardless of the max-count parameter** - so this hook reduces traffic to a structural minimum (confirmed live: maxCount 1→0 and 2→0 logged, but the same red pickup was still present, i.e., real, meaningful reduction from cont.71's untouched baseline, not full elimination). `sub_33C020` (which builds this candidate list) is itself called exactly once at race setup (`sub_2A8CE8`, confirmed via `xrefs_to` - not a per-tick call), meaning the candidate list this builds is very likely the race's *entire* traffic roster, not a queue refilled over time - the remaining ~1-2 residual cars per race are the honest floor of this specific hook, not a sign it's not working. Full elimination needs either the track-waypoint-count source (harder, track-authored) or the actual world-instantiation consumer of this candidate list (not yet found) - left as an open, accurately-scoped item, not silently claimed as solved. +- **Session pattern**: this whole entry followed the same "trust but verify" shape twice in a row - a previous session's "confirmed live" claim (traffic - only checked via logcat before) and a previous session's "installs cleanly" claim (cops - never live-tested during actual driving/pursuit) both turned out to be incomplete once actually driven and watched, not just logged. Both fixes came from decompiling one level deeper than the first pass did (`sub_F8078`/`sub_F5EA4` for cops, `sub_33D734`/`sub_33C020` for traffic), not from new tooling or a different approach - the lesson is to keep pulling the caller thread until every path converges on the actual engine primitive (`SpawnCopCar`, the traffic candidate list), not stop at the first plausible-looking gate function. +- **Snapshot**: `cop_traffic_disable.h` - `Hook_CopSpawnScheduler`/`InstallCopSpawnSchedulerSkipHook` renamed to `Hook_CopSpawnDispatcher`/target `sub_F5EA4` (was `sub_F7E9C`); new `Hook_TrafficCarSpawnerPopulate`/`InstallTrafficCarSpawnerZeroHook` hooking `sub_33D734`, gated by new `g_enableTrafficSpawnerZeroTest`. `opponent_substitution.h`'s `Hook_StreetRaceGridPlace` unchanged this entry (already fixed in cont.73). All test flags reverted to `false` and a final clean build reinstalled before ending the session. +- **Outcome/Next**: subtask 2.4's cop half is now genuinely confirmed live with strong before/after proof - effectively done. Its traffic half is honestly partial: meaningfully improved, not solved - next session should look for the candidate list's world-instantiation consumer (search around `TrafficCarSpawner`'s owning object - the one built by `sub_2A8CE8`/`sub_33C020` - for a per-tick method reading the `a1[28]`/`a1[30]` handle slots `sub_33C020` writes) rather than re-deriving what's already been found here. With subtask 2 now covering grid, opponent substitution, cop-spawning (fully) and traffic (mostly), the accumulated fact base is about as solid as it's going to get without a real lobby data layer - subtask 4 (coordinate/position sync) is the next large deliberately-deferred item per the user's own cont.68-era reasoning, and remains the natural next branch once this session's remaining budget allows. + +## 2026-08-26 (cont. 75) — Civilian traffic fully eliminated, CONFIRMED LIVE over a 2+ minute drive + +- **What**: Direct continuation of cont.74, explicit user instruction: keep digging into traffic specifically, goal is literally zero cars on the road. `sub_33D734`'s do-while structural-minimum fix from cont.74 was the starting point - not good enough on its own, so this entry traces one level further to where a traffic candidate actually becomes a positioned, visible actor. +- **Found `sub_C26A0` (`CarReset`)**: own assert strings confirm "foundTrackInfo"/"Reset". Resolves a spline distance + lateral offset to a world position and writes it via the same `sub_10B09C`/`sub_D5138` transform-write pair already known from `PlaceCar` (subtask 2.2) and `SpawnCopCar` (subtask 2.4) - the actual moment any car's position becomes real, third confirmed user of that pair. Exactly two callers, found via `xrefs_to`: `sub_2A0470` and `sub_C201C`. +- **First attempt (disproven live)**: `sub_2A0470` is registered as the handler for a `"ResetLine"` event via `sub_31AAEC` (the same event-subscription idiom `SpawnCopCar` uses for its own `"EndOfTrack"` event) - looked like the natural "recycle/reposition a traffic car" hook point. Hooked it, live-tested by actually driving (not just checking logcat) - the hook's own unconditional diagnostic log never fired once across a full ~1 minute race replay, yet the same red pickup truck from cont.74 was clearly visible and moving in a screenshot taken mid-race. Conclusion: `sub_2A0470` isn't on the path that keeps an ordinary traffic car on the road during a normal-length race (possibly only relevant for much longer sessions - it did fire once, later, in the successful longer test below). Left in place as a secondary hook, not the fix. +- **Real fix: `sub_C201C`**, the traffic car's own per-tick controller (second parameter carries a delta-time value, `*a2 * -0.001` pattern). Calls `sub_C26A0` twice: once when an idle/wander timer at a fixed offset counts down to zero (computing a fresh position), once to retry a previously-deferred reset stored in fields `sub_C26A0` itself writes on its own "can't resolve position yet, queue it" fallback path. Distinct field layout from `PlaceCar`/`Opponent`/cops - genuinely a dedicated traffic-car component, not a generic car utility that opponents or the player also route through. Hooked to skip its body wholesale (`g_enableTrafficControllerSkipTest`). +- **Live-confirmed, unambiguous**: replayed the same "Битва на шоссе"/Reynolds Lane event that reliably showed a red pickup (tunnel, ~14s in) and a blue sedan (later straight) in every prior test this session. With the `sub_C201C` skip hook on: **completely empty road for the full drive** - checked at the tunnel (previously always had the pickup - gone), and again after driving over 2 minutes total (previously always had at least the blue sedan - still gone). No crash; the hook fired continuously as expected for a per-tick function (~50+ times, hit its own log budget cap almost immediately). The `sub_2A0470` hook also fired once, late in this longer drive, consistent with it being a genuine but rarely-needed secondary recycle path - both hooks installed together caused no conflict. +- **Snapshot**: `cop_traffic_disable.h` - new `Hook_TrafficCarControllerTick`/`InstallTrafficCarControllerSkipHook` on `sub_C201C` (`g_enableTrafficControllerSkipTest`), new `Hook_TrafficResetLineHandler`/`InstallTrafficResetLineSkipHook` on `sub_2A0470` (`g_enableTrafficResetLineSkipTest`, kept as a harmless secondary hook despite not being the primary fix). `main.cpp` installs both unconditionally (behavior gated by the flags). All traffic/cop test flags reverted to `false` and a final clean build reinstalled before ending the session. +- **Outcome**: subtask 2.4 is now **fully solved and live-confirmed** on both halves - cops (cont.74) and traffic (this entry). Both use the same underlying pattern this project keeps rediscovering: find the actual per-tick controller/dispatcher and skip it wholesale, rather than trying to starve a data-driven candidate list that has its own unrelated structural minimum. Subtask 2 as a whole is now about as complete as it can get without a real lobby/session data layer - the natural next major branch is subtask 4 (coordinate/position sync), deliberately deferred since the original cont.68-era planning discussion. + +## 2026-08-27 (cont. 76-78) — Live demo with real car IDs; a real grid-collision regression found and understood; deep dive into a pre-existing `TriggerOpenCarSelectOnDemand` crash (one real bug fixed, root cause still open) + +- **cont.76 — user-directed live demo**: ran subtask 2's full stack together with a user-specified roster (`marussia_b2_2011_desc`, `lamborghini_gallardo_lp570_4_superleggera_2011_desc`, `srt_viper_2013_desc`, `lamborghini_aventador_lp700_4_2011_desc`) and the player in `bmw_m3_e46_gtr_2001_desc`, sourced from unpacked game data files rather than binary strings (user's own correction - car IDs live in game data, not `.so` strings). Confirmed live on a fresh, previously-untested track: correct 4-car roster cycling into the fixed 5-opponent-slot structure, randomized grid position, player driving the requested car. **User caught a real regression live**: cars still spawned overlapping at the grid on this run, and traffic/cops were still visible - contradicting cont.73's "fixed" claim and cont.74/75's "confirmed zero" claims. Root cause for the opponent-count question (user flagged as "critical for multiplayer"): a regular race's AI slot count is genuinely **data-driven**, read from the `RaceEvent`'s own `"Opponents"` property (`sub_2B649C` calls `sub_4F99F0(..., "Opponents")` then sizes the loop from the resolved range) - not hardcoded engine-side as earlier sessions assumed from observing "always 5" across every sampled event. Every regular event happening to author exactly 5 was coincidence of content, not an engine constant - changing the *count* (not just car identity) would need to either truncate `OpponentCollection`'s own built vector or find where the grid's separately-built vector derives its count, neither done yet. +- **cont.77 — the collision/traffic regression, root-caused and fixed**: the `sub_C201C` traffic-controller skip hook (cont.75's "confirmed zero traffic" fix) turned out to be track-dependent, not universal - it blocks *repositioning* but not a traffic car's *first* position, which on the untested track apparently defaulted to somewhere at/near the grid instead of harmlessly off to the side as on Reynolds Lane. Real fix: hook `sub_C26A0` (`CarReset`) directly with a **once-per-object** gate (`Hook_CarReset`, `g_enableTrafficOnceOnlyResetTest`) - let each distinct traffic-car pointer through exactly one real, `TrackNavigator`-resolved placement (same system used for opponents/cops, never intentionally at the grid), then no-op every subsequent call for that same object. Net effect: each traffic car appears once, parked, never recycled - live-tested clean afterward. `opponent_substitution.h`'s player/opponent grid-gap widening (cont.73) was re-confirmed intact and not the cause of the user-observed collision. +- **cont.78 — user asked to switch to a fully synthetic test** (fire the project's own long-standing `"LAN: Test Lobby"` synthetic event via the existing debug broadcast receivers, `adb shell am broadcast -a nfs.mod.mpcore.TEST_OPEN_CARSELECT`, instead of navigating the real map) to get a track-agnostic, repeatable harness. This immediately hit a **pre-existing, 100%-reproducible native crash** (`SIGSEGV`, `fault addr 0x8`, null pointer dereference) every single time `TriggerOpenCarSelectOnDemand()`'s `FireEventOutput()` call fired - not something this session's work introduced. Investigated in depth: + - **Ruled out, with live A/B tests**: save-state size (crashed identically on both the 100%-save and the small original save, swapped via the established backup/swap workflow), and every one of this session's own test hooks (crashed identically with all of them - grid, traffic, cops, substitution - forced off). + - **One real bug found and fixed**: `carSelectTestReceiver`/`trueDirectCarSelectTestReceiver` in `GameActivityMain.kt` called straight into JNI from `BroadcastReceiver.onReceive()` (the Android main thread) - the only call sites in that file that skipped the `gameGLSurfaceView.queueEvent { }` wrapping every other native-touching call site there correctly uses to marshal onto the GL/render thread, where `MapScreen`/`FlowNode` state actually lives. Fixed by wrapping both in `queueEvent { }`. Confirmed via tombstone that the crash's thread changed from the main thread to the correct `GLThread` after the fix - a genuine, real thread-safety bug, worth keeping fixed regardless of the deeper issue below. + - **Deeper root cause, only partially resolved**: even on the correct thread, the crash persists, entirely inside `FireEventOutput` (`sub_17A99C`) itself, *before* it ever reaches the real event dispatch (`sub_1BB59C`/`sub_1581A0` - confirmed by the complete absence of the pre-existing `Hook_FireOutputDiag`'s `output="EVENT"` log line across every crash). `FireEventOutput` builds a debug label ("`Database of prefabs has no entry for component on actor `" / "`...has no entry with ID `" - two related but distinct prefab-lookup-and-error-message functions, `sub_240548` and `sub_173350`) keyed on the numeric event ID before ever touching the real FlowNode - and this project's synthetic key family (`0xC0FFEE00`, `0xC0FFEE01`, ...) is **negative as a signed int32** (top bit set), which a live-captured Android systrace line (`"EVENT ID "`) and a captured malformed eastl string range (`{begin=, end=0}`) both point at as mishandled somewhere in this number→string/lookup chain. A defensive repair in a new diagnostic hook (`Hook_PrefabLookupDiag` on `sub_240548`, collapsing a corrupt `end::push_back`, нужно опознать инстанцирование, восстановить точную раскладку `Foo` и +доказать совместимость с хостовой версией — а это ручной реверс, ровно то, ради отказа от чего и выбран +этот путь. + +То же касается boost (610 имён классов, 792 vtable, 1 247 виртуальных целей) и EASTL: они **размазаны по +всему `.text`** и не отделяются по адресам. Переводить как обычный код. + +### Что действительно облегчает задачу + +**Образ целиком в режиме ARM, без Thumb.** 2 249 305 инструкций в 8 989 480 байтах — ровно 4,0 байта на +инструкцию. В `.data.rel.ro` 27 107 указателей на ARM-код против 123 с Thumb-битом. Ноль `tbb`/`tbh`, +ноль `ldr pc,`, ноль `mov pc,`. Проверено независимо: все 34 экспортируемые функции имеют чётные адреса. +**Нет переключения режимов, нет IT-блоков, нет Thumb-таблиц переходов** — заметно более простая цель, чем +предполагалось. + +> Побочно: комментарий в `guest_engine.cpp:3901` называет Thumb «единственным реальным режимом этого +> движка». Это **неверно** и может ввести в заблуждение. Работе движка не мешает (режим берётся из CPSR), +> но как ориентир — ошибка. + +**`.ARM.exidx` покрывает `.text` на 100%** — 22 905 записей размечают 9 526 532 из 9 527 632 байт. + +**Дубликаты.** 4 918 функций побайтово идентичны и сводятся к 739 различным телам: 1 148 × `bx lr` +(пустой виртуальный метод), 678 × `mov r0,#0; bx lr`, 322 × переходник `boost::function`. Лифтер, +хеширующий тела, выдаёт 739 вместо 4 918 — **4 179 функций бесплатно**. + +**Длинный хвост мелочи.** 10 646 функций (31,6%) короче 32 байт и занимают всего 1,4% кода. При этом +1 613 функций (4,8%) длиннее килобайта и занимают 39,6%. + +## 4. Главная нерешённая трудность: косвенные переходы + +`blx ` — вызов по адресу из регистра. Статически неизвестно, куда он ведёт. + +**Замерено по всем 2 249 305 инструкциям образа:** + +| | количество | +|---|---| +| `blx ` — косвенные вызовы | **36 232** | +| `bl` — прямые вызовы | 136 075 | +| доля косвенных среди всех вызовов | **21%** | +| `bx ` (в основном `bx lr`, возвраты) | 9 364 + 1 833 условных | +| `pop`/`ldm` с `pc` (возвраты) | 27 011 | +| `ldr pc,` / `tbb` / `tbh` / `mov pc,` | **0** | + +### Сколько целей удаётся собрать статически + +| | количество | +|---|---| +| vtable, привязанных к typeinfo | **4 001** | +| слотов в них | 25 631 (из них 441 чисто виртуальных) | +| **различных виртуальных целей** | **10 624** | +| все указатели на код в данных (`.data.rel.ro`, `.got`, `.data`, `.init_array`) | 28 003 → **12 409 различных целей** | +| **покрытие функций `.text`** | **36,7%** | + +### И вот здесь главная оговорка, которой не было в первой редакции + +Первая редакция утверждала, что «большинство виртуальных целей можно собрать статически». Для **vtable** +это верно. Для **колбэков — нет.** + +В этой сборке с позиционно-независимым кодом взятие адреса функции в регистр выглядит как +`ldr rX,[pc,#N]; add rX,pc` — литерал хранит **смещение относительно PC** и **не требует релокации** +(проверено на дизассемблере по адресу `0x7c758`). Значит таблица релокаций такие цели **не видит**. + +Мера того, насколько она их не видит: **8 818 функций (1 405 236 байт) не имеют ни одного входящего +прямого вызова, ни одного указателя из данных.** Часть — мёртвый код, оставленный компоновщиком. +Остальное — колбэки, достижимые только анализом литеральных пулов. + +**Отсюда следует порядок работ:** первым делом нужен не транслятор, а **сканер литеральных пулов**. +Если покрытие косвенных целей не удастся поднять существенно выше 37%, то запасной путь через +хеш-таблицу «адрес → функция» съест ровно тот выигрыш в скорости, ради которого всё затевается. + +## 5. Что ещё придётся решить + +| задача | сложность | комментарий | +|---|---|---| +| **Исключения C++** | высокая | Есть `.ARM.exidx`/`.ARM.extab`. Раскрутка стека ARM32 не переносится на ARM64 напрямую — нужна либо своя реализация, либо отображение на нативные исключения | +| **Модель памяти** | средняя | ARM32 и ARM64 имеют разные гарантии упорядочивания. При многопоточности возможны тонкие гонки, которых не было на оригинале | +| **Точность флагов** | средняя | Где флаги реально читаются — надо сохранить. LLVM выбросит лишнее только если правильно разметить | +| **JNI-граница** | низкая | Уже решена в текущем движке, переносится почти как есть | +| **Системные вызовы и libc** | низкая | Уже есть полный набор шимов, линкуется напрямую | + +--- + +## 6. Первый шаг, если решим начать + +**Не писать транслятор.** Порядок такой: + +1. **Сканер литеральных пулов** (см. раздел 4). Это главный риск всего направления, и он проверяется + раньше всего. Цель — поднять покрытие косвенных целей существенно выше 37%. Если не выходит — + направление не окупается, и лучше узнать это на первом шаге. +2. **Покрытие кода.** Включить блочный профилировщик (`EnableProfiling()`, уже есть в движке) и записать + исполненные адреса за полный сеанс: загрузка, меню, гонка, финиш. Это покажет, сколько из 23 000 тел + реально работает, а сколько — мёртвый код. +3. **Прототип на одной функции.** Перевести одну чистую вычислительную функцию через LLVM IR, + подставить в работающий движок вместо эмулируемой и **замерить**. Это даст реальный коэффициент + ускорения — единственную цифру в этом документе, которая будет фактом, а не оценкой. + +--- + +## 7. Объём работ — итоговая таблица + +| категория | функций | доля | что делать | +|---|---|---|---| +| zlib, libjpeg, libpng, curl, OpenSSL | 6 502 | 19,3% | **заменить линковкой** — непрерывные диапазоны, ноль vtable | +| Bullet Physics | 1 828 | 5,4% | переводить (замена — отдельное исследование) | +| фреймворки EA, однозначные | 3 784 | 11,2% | переводить; обёртка GLES — единственный кандидат на замену | +| смешанные области EA и игры | 3 798 | 11,3% | переводить, по адресам не разделяются | +| **игра и движок** | **17 783** | **52,8%** | **переводить — неустранимое ядро** | + +Всего реальных функций в `.text`: **33 695** (прежние 34 726 включали 516 заглушек PLT и 515 +плейсхолдеров импорта, которые кодом не являются). + +**Требуют механического перевода: 27 193.** После дедупликации по содержимому — около **23 000 различных +тел, ~1,82 млн инструкций ARM**. + +--- + +## 8. Честный вывод + +Направление **выполнимо** в том смысле, что 23 000 тел — работа для машины, а не для человека. Условия +лучше, чем казалось: режим только ARM без переключений, `.ARM.exidx` покрывает код на 100%, **все 2 310 +имён классов RTTI сохранились целиком**, 4 001 vtable дают 10 624 разрешённых виртуальных цели, а треть +функций короче 32 байт. + +Но **главный риск не в объёме, а в 36 232 косвенных вызовах**, чьи цели собираются лишь частично. +Начинать надо с проверки именно этого, а не с транслятора. + +И цена из раздела 1 остаётся в силе: **этот путь обнуляет наработки по мультиплееру.** diff --git a/docs/evidence/font_atlas_was_guest_addr_0.png b/docs/evidence/font_atlas_was_guest_addr_0.png new file mode 100644 index 0000000..40812d1 Binary files /dev/null and b/docs/evidence/font_atlas_was_guest_addr_0.png differ diff --git a/docs/save_backups/nfstr_save_2026-09-19_A9-native-67k.sb b/docs/save_backups/nfstr_save_2026-09-19_A9-native-67k.sb new file mode 100644 index 0000000..4586952 Binary files /dev/null and b/docs/save_backups/nfstr_save_2026-09-19_A9-native-67k.sb differ diff --git a/docs/save_backups/nfstr_save_2026-09-19_prologue-done.sb b/docs/save_backups/nfstr_save_2026-09-19_prologue-done.sb new file mode 100644 index 0000000..8893d03 Binary files /dev/null and b/docs/save_backups/nfstr_save_2026-09-19_prologue-done.sb differ diff --git a/docs/save_backups/nfstr_save_2026-09-19_worldmap.sb b/docs/save_backups/nfstr_save_2026-09-19_worldmap.sb new file mode 100644 index 0000000..53b3435 Binary files /dev/null and b/docs/save_backups/nfstr_save_2026-09-19_worldmap.sb differ diff --git a/docs/save_backups/nfstr_save_2026-09-19_xiaomi.sb b/docs/save_backups/nfstr_save_2026-09-19_xiaomi.sb new file mode 100644 index 0000000..08d6ebf Binary files /dev/null and b/docs/save_backups/nfstr_save_2026-09-19_xiaomi.sb differ