Files
nfsmw-online/docs/ARM64_TRANSLATION_LAYER.md
T
megboyzzandClaude e7c76fc2dd docs: bring 1.8 MB of project documentation under version control
These files had never been tracked anywhere - they lived in a plain directory
with no git at all, which is also where the whole reverse-engineering record
sat. Code already committed refers to them by name (opponent_substitution.h
cites "ANALYSIS.md section 6hh", DebugMenuOverlay.kt cites "DEBUG_MENU.md
section 3"), so until now a fresh clone carried references to documents it did
not contain.

  ANALYSIS.md                       the RE record, and the reason the rest works
  ARCHITECTURE.md                   how the mod's pieces fit together
  ARM64_TRANSLATION_LAYER.md        the translation layer's running log
  PROGRESS.md                       chronological progress across both chats
  BETA_TELEMETRY_PLAN.md            how crash/telemetry reporting is meant to work
  LOBBY_UI_DESIGN.md + .html        lobby design and its clickable prototype
  DEBUG_MENU.md                     debug panel design
  STATIC_RECOMPILATION_FALLBACK.md  the plan if translation had not panned out
  evidence/                         font atlas capture from the glyph-corruption bug
  save_backups/                     saves at known milestones, for reproducing state

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-22 23:29:12 +03:00

3638 lines
570 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 1990s2010s-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<Ret,Args...>`/`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/<pkg>/` 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<char>`/`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=<host stack>, 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<char>` 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<char>::sentry`-style helper, calling `std::locale::use_facet(&ctype<char>::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<char>::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<char>::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<char>` 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<char>::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<char> (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<char>` 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<char>` (9 real xrefs) not `num_get<char>` (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<char>`. `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<char>`, now `num_put<char>`). 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<char>` 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<char>` 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 <int N> 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.<packageName>` 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.<packageName>` 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.<packageName>` 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, <suffix>); }` - 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/<package>/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 \"<name>"`, 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 <name>; size = <N>; checksum = <hash>"`**.
**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: <format>` / `Mounting SKU: <format> 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/<pid>/task/<tid>/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<char>`/`num_put<char>` 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<bool>`) with `reentrancy_log_count_` (an `atomic<int>`) 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.<packageName>` 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/<abi>/` 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<int>` 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<expr>;\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<char>, num_put<char>, `__shared_weak_count`, `std::mutex`). Rebuilt with `-DANDROID_STL=c++_shared` explicitly to match - but this pulled `basic_stringbuf<char>`/`basic_ostream<char>`/`basic_ios<char>`/`basic_ostringstream<char>`'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<char>`'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<char>`/`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<char>::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<char>::xsputn`) right after its own bulk `qmemcpy`; `0x270414` is inside `sub_27036C` (real `basic_string<char>::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<char>::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<char>::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 <precision> <type> <name>;` 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<RefCountedPointer>::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/<pid>/task/<tid>/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.<pkg>` 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<std::string>` 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 <pid> -t <tid> -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 (<a per-drawcall clip-enable flag>) return sub_3DA3F0(<clip-rect>, 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 ~7102 — `.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 1020 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 <pid> --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 | **7693k/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 01 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.61.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 <pkg> -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 <reg>` 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 | 4056, 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 4056, 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<char>` 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<char>/num_put<char>
The game runs `stream >> unsigned_int` and asks the locale for
`std::num_get<char>`. 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<char>::operator>>`. Offset 28 was pinned down by two independent
routes that agree:
- This exact NDK's own `<locale>` 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 `<ios>` 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<char> 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.830.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 | **4955 °C**, flat over 2 min |
| temperature when cooling down | 4448 °C |
| frame rate during the measurement | 6670 |
**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/<pid>/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/<pid>/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.