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>
570 KiB
ARM64_TRANSLATION_LAYER.md — Running libapp.so (ARM32) on AArch32-less ARM64 devices
Living document, split out from ARCHITECTURE.md §9 on 2026-08-19 per explicit instruction ("по этой эмуляции веди отдельный файл"). Pure theory so far — nothing implemented, nothing prototyped. Revisit and edit whenever a design decision here changes; don't let this drift out of sync with reality, same rule as every other doc in this repo.
1. Problem
libapp.so (and its native siblings — FMOD, Nimble, our own mpcore) is armeabi-v7a (AArch32). Newer Android 14+ devices increasingly ship SoCs whose performance cores dropped AArch32 execution entirely — ARM's own roadmap: Cortex-A710 was the last core generation with AArch32 EL0 support, everything from Cortex-A715/X3 onward (and every Cortex-X4/A720+ successor) is AArch64-only. Some 2023+ chips (e.g. Snapdragon 8 Gen 2) kept a few AArch32-capable little cores around for compatibility, but that's a vendor courtesy, not an ISA guarantee, and later designs remove it entirely. On a genuinely AArch32-less chip there is no hardware execution mode that can run ARM32 machine code, full stop — not even as a plain armeabi-v7a child process. This is a fundamentally different problem from "our library happens to be 32-bit," and confirmed (via research this session) to have no existing off-the-shelf solution for Android specifically — the only shipping consumer answer today is full-VM apps (VPhoneGaGa-style), i.e. exactly the VMOS-style approach the user wants to avoid.
2. Why the obvious alternatives are wrong for this project
Full-VM (VMOS-style): re-executes the entire guest OS (Android system services, Zygote, the whole 64-bit-capable Kotlin/Compose launcher we already wrote) inside a nested virtual device, discarding all the native-process integration this project depends on (real GLSurfaceView, direct JNI, mpcore living in the same address space as libapp.so). It also usually means running the host's 64-bit-capable code through 32-bit emulation too, for no reason — pure waste and a much bigger performance/compatibility hit than necessary. This is the thing the user explicitly wants to avoid, and the reasoning above is why avoiding it is also technically the right call, not just a preference.
Static/ahead-of-time recompilation (ARM32 machine code → real ARM64 machine code, once, at build time — a real technique used elsewhere, e.g. some legacy console-preservation projects): wrong for this specific codebase, even though it's legitimate in general. Every hook, struct offset, and vtable-slot number in ANALYSIS.md/ARCHITECTURE.md §3a (the RaceDefinition field table, RACEEVENT_CARCLASS_OFFSET, etc.) is derived from libapp.so's original 32-bit memory layout — 4-byte pointers, 32-bit vtable slots, AAPCS32 calling convention. A static recompiler that widens pointers to 8 bytes and re-lays-out every struct would invalidate essentially the entire body of RE work this project has built up over dozens of sessions, and require re-deriving it from scratch against a different binary. Static binary recompilation of a real-world 1990s–2010s-style C++ engine (indirect calls, vtables, a scripted Flow-machine VM, hand-tuned Thumb code) is also a genuinely unsolved problem in general — not a viable foundation to bet a solo project on.
3. Recommended direction: in-process, dynamic CPU-level translation with an API-boundary breakout
Not a full-OS/full-syscall emulation. The closest real, working precedent is the Hangover project (runs Windows x86/x86_64 binaries on ARM64 Linux by pairing Wine with an embedded CPU emulator): its core insight is "emulate the application's own instruction stream only; the moment execution reaches a Windows/Wine API call, break out of emulation and run that call natively" — i.e. don't emulate an OS, emulate a CPU, and reimplement/forward the API surface the guest code actually calls. Box64/Box86 (x86-on-ARM) use the same "wrapped libraries" idea for the same reason. The equivalent shape for this project:
- 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.
- ELF32 loader / mini dynamic linker: load
libapp.so(and any other ARM32-only dependency — need to check which of FMOD/Nimble/etc. actually ship anarm64-v8abuild already, since those could stay native ARM64 and onlylibapp.soitself would need translation) into guest-addressable memory, resolve its imports against our own shim table instead of a real 32-bit libc/libdl. - 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
TPIDRUROvs AArch64TPIDR_EL0). - JNI upcalls — bounded and mechanical: the guest's
JNIEnv*function-pointer table points at trampoline stubs that marshaljint/jfloat/jlong(already same-size both sides) and passjobject/jclass/jstringhandles 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 existingarmhook.cppThumb-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. Oncempcoreis the thing driving the emulator, hooking becomes "register a callback for guest PC == target address" (Unicorn'sUC_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.mpcoreitself should become plain native ARM64 code (we own its source, nothing stops us recompiling it forarm64-v8a) that owns and drives the embedded ARM32 core, rather than being ARM32 code living inside it.
- 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
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.sofunctions (a hot per-frame one likeRaceLoaderTask'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-v8abuilds; 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.soactually 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.sobytes via a desktop Python/unicorn/pyelftoolsspike (this session's own scratch script) — confirmed byte-identical mapping at two independently-documented function offsets, and aUC_HOOK_CODEhook firing exactly at a real target address. mpcoreitself moved fromarmeabi-v7atoarm64-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) mapslibapp.so'sPT_LOADsegments into one Unicorn-backed host buffer at guest address 0 (matching the ELF's own preferred base — confirmed viareadelf/pyelftoolsthis session,ET_DYNwith base 0), processes.rel.dyn/.rel.plt(skipsR_ARM_RELATIVEas a deliberate no-op since load bias is always 0 by construction; resolvesR_ARM_GLOB_DAT/R_ARM_JUMP_SLOT/R_ARM_ABS32against either the locally-defined symbol or a per-symbol import stub), and this design choice meanslibapp_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 oldInstallArmTrampolineHook/orig_X(...)calling convention almost verbatim, just backed by aUC_HOOK_CODEdispatch 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 theMapScreenctor 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-fitGuestHeap),strlen/strcmp/strcpy,__cxa_guard_*,abort— everything else gets a stub that logs "unresolved import" once and returns 0 rather than crashing. ./gradlew :mpcore:assembleDebugand:app:assembleDebugboth succeed; the resulting APK (aapt2 dump badging) reportsnative-code: 'arm64-v8a'only, shipslib/arm64-v8a/libmpcore.soandassets/native_probe/libapp.so(the original ARM32 binary as a raw asset, notjniLibs— 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-slotJNINativeInterfacetable (exact slot order extracted from this NDK's ownjni.h, not guessed) plus an 8-slotJNIInvokeInterface(JavaVM*), each slot a guest stub dispatched through the same mechanism as import stubs. AJniHandleTabletranslates 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, CallMethod/CallMethodV 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
.dynsympass (Java_com_ea_ironmonkey_GameActivityMain_*,Java_com_ea_EAIO_EAIO_*,Java_com_ea_EAMIO_StorageDirectory_*,Java_com_ea_nimble_bridge_NimbleCppApplicationLifeCycle_*,JNI_OnLoad, etc. - seereal_native_offsets.h) and wired throughCallRealNative()(real_native_call.h). Confirmed live:JNI_OnLoadexecutes and returns a real JNI version constant,nativeOnCreate/nativeOnStart/nativeOnResume/nativeSurfaceCreated/nativeSurfaceChanged/nativeOnPause/nativeOnStopall 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 tolibapp.so, reimplemented directly againstJniShimrather than emulating the missinglibNimble.so) needed dot-to-slash class-name conversion before calling the realFindClass. (2) The classic cross-thread JNI gotcha -FindClassonly sees app classes on the thread that loaded the native library; the real engine's ownGLThreadcalls 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 onGetMethodID/GetFieldID/Call*Method/NewObjectso a failed lookup degrades to a logged no-op instead of a hardCheckJNIabort. - Confirmed stable end state: process survives the full boot sequence (
onCreatethroughonStop) with zero crashes on real hardware (Xiaomi 14). Screen is black - expected and explained, not a bug: none of the ~150 unresolvedgl*/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 neededgl*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_engines 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 whetherstartRoutinefalls insideGuestEngine::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 astartRoutineoutside 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 inlibfmodex.so, which was never loaded (noarm64-v8abuild, 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 synchronousonApplicationLaunchchain inside it) genuinely completed, after ~115 seconds, not never.top -Hafterward 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 (onStart2.5s,onResume5.0s,onPause2.6s,onStop2.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 guestfree()again, structurally, not by convention.GuestHeapcorruption canary (guest_heap.h/.cpp):BlockHeadergained a magic-number field, checked on everyFree()- 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-classuc_errnow sets acrashed_flag; every subsequentCallGuestFunctioncall 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, mappedUC_PROT_NONEon 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 bymem_fault_hook_cbviaUC_HOOK_MEM_INVALID, not justUC_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) linkingguest_heap.cppdirectly. 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 reportsonCreatetiming,MEM FAULTcount, whether the fail-fast circuit breaker tripped, and (if the process died) the exact cause pulled fromActivityManagerlogs - 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 realdouble-returning functions -ceil/floor/fmod/pow/modfneeded a genuine ABI extension, see below),stdio.h(FILE*via a small handle table, same shape asJniHandleTable), 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),dlopenfamily (onlydlopen(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 beyondGuestHeapexists, logged), network/socket (directsockaddrpass-through - stable layout across 32/64-bit ARM, no marshaling needed), pthread extras (pthread_once, rwlocks - reusepthread_shim.cpp's own recursive-mutex-backed pattern),sem_*(a real counting semaphore viacondition_variable_any+ a guarded int, since this NDK level predates C++20'sstd::counting_semaphore), and the seven__aeabi_*ARM EABI float/long longconversion helpers the compiler emits calls to instead of inlining. - Category B - RTTI/exceptions (
emu/rtti_shims.cpp, new): a real__dynamic_cast- walks thetype_infohierarchy 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_infoshapes, documents virtual-inheritance diamonds as a known gap rather than getting them silently wrong. Real primitivetype_infoobjects (_ZTIa/_ZTIf/_ZTIi/_ZTIs/_ZTIt) and__cxxabiv1vtable "identity marker" blobs, built as genuine DATA inGuestEngine'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 aUC_HOOK_CODEstub, and reading struct fields through one of those is exactly the "wrong-shaped address" bug class this whole session keeps finding. Minimal but realstd::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 forshared_ptrcopy/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/localeinit/clear/destructor/getlocas safe no-ops/trivial objects, thectype<char>/num_get/num_putfacetidstatics as inert data (nothing ever successfully completes a facet lookup through them -use_facetlogs once and returns NULL rather than handing back a fake facet object a subsequent real virtual call would crash through),std::cerras a generously-oversized inert blob (real formatted output was never implemented), and libc++'s own internalstd::mutex(real - reuses the address-keyed real-mutex-table techniquepthread_shim.cppalready established for guestpthread_mutex_t). A handful of libc-adjacent data symbols from the same sweep (__stack_chk_guard,timezone/tznamemirrored from the real host globals on everytzset(),__sFmapped transparently onto the sameFILE*handle tablefopenuses) landed inlibc_shims.cppinstead, via the same newSetupLibcDataSymbolshook. - Category D - FMOD Ex "Event System" API (
emu/fmod_shims.cpp, new, 28 symbols): confirmed this session that noarm64-v8abuild 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). EveryFMOD_RESULT-returning function returnsFMOD_OKso the game's own logic proceeds instead of stalling on an audio-readiness gate; everyT**-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_created 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_created 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:
appmodule: newnative32Gradle product flavor (armeabi-v7a-only - confirmed live that shipping it alongsidearm64-v8amakes Android launch via 64-bitapp_process64, which can never load a 32-bit.soat all;mpcore's own always-arm64-v8anative build had to be stripped from the packaged APK post-build viazip -d+zipalign+apksignerre-sign, since AGP's dependency-native-lib merging doesn't respect a consuming app flavor's ownabiFilters), restoring the original (pre-emulation)System.loadLibrary(...)calls inGameActivityMain.ktbehindBuildConfig.NATIVE32.trace_agent/(new standalone directory, deliberately outside the Gradle build):libtrace_agent.so, built via a plain NDK/CMakebuild.sh, injected via Android's per-appwrap.<packageName>debuggable-appLD_PRELOADmechanism (no root needed to set onceadb root/setpropconfigures it). Two pieces:libc_gles_trace.cpp: classicdlsym(RTLD_NEXT, ...)interposition foropen/openat/fopen/stat/accessandglClear/glDrawArrays/glDrawElements/glUseProgram. Confirmed live that interposingmmap()deadlocks the whole process (the dynamic linker usesmmap()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 anIsInteresting()path filter (game-relevant keywords only) and droppingread()interposition entirely (highest-frequency, no path info anyway).jni_trace.cpp: patches the realJNINativeInterfacetable in place (viamprotect+ direct pointer overwrite, not swapping which structJNIEnv->functionspoints to) - since ART shares ONE table across every thread'sJNIEnv, this covers every thread automatically, not just whichever one calls the installer. Installed from a newTraceApplication/TraceAgentBridge(nfs.mod.traceagent, gated behindBuildConfig.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.sofrom/data/local/tmp(fixed:run-as-copy into the app's ownapp_data_file-labeled private storage instead); the OBB file needed migrating from the pre-existingcom.ea.games.nfs13_modpackage's real save data to the newcom.ea.games.nfs13_armpackage'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): classicdlsym(RTLD_NEXT, ...)interposition foropen/openat/fopen/read/stat/access/mmap(covers whatever the game does to read its.obbdata, since that ultimately goes through these same syscalls) andglClear/glDrawArrays/glDrawElements/glUseProgram(the exact same 4 entry points already counted in this session's owngles_shim.cpp, for direct comparison against real behavior) - logs path/args/result via__android_log_printunder aTRACE_AGENTtag, then calls straight through to the real implementation. - JNI (
jni_trace.cpp): JNI calls go through theJNIEnv->functionspointer table, not the dynamic symbol table, soLD_PRELOADinterposition alone can't see them. Patches the table's contents in place (mprotecttoPROT_READ|PROT_WRITE, overwrite selected slots, restorePROT_READ) rather than swapping which structenv->functionspoints to - real Android ART shares oneJNINativeInterfacetable across every thread'sJNIEnv, so this covers every thread automatically (including whichever one actually drives the game's realonDrawFrame/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 intoAndroidManifest.xml) -attachBaseContext, the earliest available hook point, checksBuildConfig.NATIVE32and whether/data/local/tmp/libtrace_agent.soactually 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:
- 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_54BAF0investigation above, since call is presumably via a vtable/generic retry-with-backoff helper, not a literal named loop). - 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:
Mount("/data/user/0/com.ea.games.nfs13_arm/files/var", "/var")- internal, normal.Mount("/storage/emulated/0/Android/data/com.ea.games.nfs13_arm/tmp", "/tmp")- external, but for a/tmpscratch mount, presumably by design.Mount("/data/user/0/com.ea.games.nfs13_arm/files/var1", "/var1")- internal, normal (matches thevar1/last_version.txt/var1/adcEventsfiles referenced all day).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=freaditself, 200104 hits - the single hottest address in the whole post-47s window.0x3de430(200104 hits, a 16-byte thin wrapper) and0x415694(200102 hits) - the direct call chain aroundfread, matching count-for-count.0x415810/0x3f5f1c(122905/163927/166974/122904 hits) - decompiled:sub_3F5F1Cis a plain/-delimited path-segment tokenizer (splits one path component per call);sub_415810iterates a value list, tokenizes a path viasub_3F5F1C, and does a linearmemcmp-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 theMount()/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:
- 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 viagrepthis 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. - Reducing per-call overhead further in
Shim_freaditself (e.g. a host-side read-ahead buffer so repeated 1-byte guest reads don't each pay full shim/marshaling cost) - worth checking whetherShim_freadalready delegates to a buffered hostFILE*(it should, real libcfreadalready 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)wherev84 = v26[2], andv26 = sub_567344(v83+76, a4[5], v91, &v89)- a shader-variant cache-entry lookup.- The struct
v26itself has offset+24 (v26[6]) checked right before use, with a real, in-gameif (!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:
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_modOBB 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 todword_B13F30- the classic shape of a pthread_once-guarded C++ function-local-static initializer (__cxa_guard_acquire/construct/__cxa_guard_releasepattern,sub_8BA55Calmost certainly being the constructor call).0x87b968is the exact same address this session's earlier, already-completedtask ("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 viapthread_once, just not previously confirmed as aCallGuestFunctionreentrancy case with this probe.sub_88CCD0(0x88ccd0): a similar but larger (0x58-byte) lazy-static-initializer pattern - constructs an object, registers anatexit-style cleanup (sub_7433C()) if construction succeeds, matches the samepthread_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 apthread_once_t-style control structure and invoked indirectly, matching exactly howShim_pthread_once(already flagged in this doc as a knownCallGuestFunction-reentrancy caller) would reach them: real game code callspthread_once(), which our shim intercepts and invokes the guest's real init routine via a reentrantCallGuestFunctioncall.
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_onceinvoking the real init routines -0x87b968and0x88ccd0- both loggedreg 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
WriteCharToStreambufoccurrences, same harmless pattern. - 05:24:50.056: the crash, ~3.3 seconds after the last reentrant call and ~13.3 seconds after the
pthread_oncecalls 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:
-
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 totouch, which never appeared across many relaunches, with and without root, with SELinux Enforcing and an attempted (Knox-blocked -setenforce 0silently 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: awrap.shbundled inside the APK's ownlib/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-*.sofile from that directory (confirmed: present aftermergeNative32DebugJniLibFolders, gone afterstripNative32DebugDebugSymbols) - worked around by hand-injectingwrap.shinto the built APK viazip/zipalign/apksignerdirectly. That producedINSTALL_FAILED_INVALID_APK: Failed to extract native libraries, res=-2under the defaultextractNativeLibs=falsepackaging (PackageManager validates everylib/<abi>/entry as a loadable library when it plans tommapstraight from the APK, and a plain shell script fails that check) - fixed properly, inbuild.gradle.kts, by switching thenative32flavor specifically topackaging.jniLibs.useLegacyPackaging = true(extracted-to-disk libraries, no longer strictly validated at install time). -
Once
wrap.shgenuinely activated (confirmed:open()/glShaderSource()/etc. all started firing, vs. zero hits under the earlierSystem.load()-only activation, which - now confirmed directly - never gavelibc_gles_trace.cpp's interposed libc/GLES symbols real process-wide visibility at all, onlyjni_trace.cpp's separateJNINativeInterface-table-patching mechanism worked before this), a NEW crash appeared: a tombstone resolved (llvm-addr2line) topthread_onceitself, called from__emutls_get_address. This toolchain/ABI implements C++'s thread-safe function-local-static initialization via emulated TLS, which internally callspthread_once()to lazily set up the TLS key - since we're now LD_PRELOAD'd fromexec()itself (not lazily viaSystem.load()well after zygote fork, as before), that internal call gets intercepted by our OWN wrapper, which then tries to lazily-initialize its OWNstatic auto real = RealSym<...>(...)the exact same way - infinite self-recursion, stack overflow, on literally the first call. Fixed by resolving the realpthread_oncepointer eagerly, via a genuine ELF constructor (__attribute__((constructor))) into a plain global, sidestepping the lazy-static-guard machinery entirely for that one symbol. -
One level deeper still: with (2) fixed, a further tombstone showed the SAME recursion shifted one frame down -
pthread_once's own diagnostic body callingLOGI(...)/TraceLog(), which touchestrace_log.h'sInTraceLog()reentrancy guard, a genuinelythread_localvariable 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-scopestd::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:
0x440424(CMP R4,#0): isv42(fromsub_478A18) itself zero?0x440470(CMP R0,R2): didsub_440508find an EXISTING entry in thedword_ADBFB8hash table for this exact signature, or is this the first time?0x440480(CMP R0,#0): isv43- 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 onlyreturn 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:
v42was non-zero on all 10/10 calls - confirmssub_478A18never 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=0x4359abcpair - 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 - notsub_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:
- Something upstream produces an empty/near-empty shader source string for this specific shader variant (root cause - still not found;
sub_46FD30, called bysub_5673F8right aftersub_43FDE0fails, is the function that actually assembles this string - not yet instrumented, and NOT the same as the "missing vertex attribute" path already ruled out viaShaderMissingAttrProbeHookCbnever firing). 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).- Because it fails,
*(a1+16)is never written. - Every later
sub_43FDE0cache 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). - Deep inside
sub_56962C, code that depends on a successfully-populated shader-variant object (v26[2], whose own+0x2Cfield 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:
-
ostream_repro/(new top-level directory, sibling totrace_agent/- same "standalone armeabi-v7a artifact, built via its ownbuild.shdriving the NDK's CMake toolchain directly, deliberately NOT wired into the main Gradle build" patterntrace_agent/already established).ostream_repro.cppexports oneextern "C"entry point,TestOstreamAssembly(char* outBuf, int outBufSize), that constructs astd::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 ownsub_4702D8pattern rather than a single combined write), calls.str(), and writes the resulting length + content into the caller-supplied buffer. -
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 readingandroid.toolchain.cmake- NOTc++_sharedas 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 byrtti_shims.cpp's ownRegisterRttiImportShimslist of hand-shimmed_ZNSt6__ndk1*symbols - locale, ios_base, ctype, num_put,__shared_weak_count,std::mutex). Rebuilt with-DANDROID_STL=c++_sharedexplicitly to match - but this pulledbasic_stringbuf<char>/basic_ostream<char>/basic_ios<char>/basic_ostringstream<char>'s own VTABLES AND CONSTRUCTORS in as undefined imports (this NDK's libc++ headersextern 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 onANDROID_STL=c++_staticas the better match: it compilesbasic_stringbuf<char>'s own write/extract logic directly into the test.so's own.text, the same character libapp.so's ownsub_79CD4/sub_27160Cevidently 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.shdocuments this reasoning inline. -
GuestEngine::LoadSecondaryImage(path, entrySymbol)(new public method,guest_engine.h/.cpp) - a second, independent ELF32 loader, NOT a second call toLoadImage/MapSegments(which is a genuinely single-image design:host_region_is onemmapsized around exactly one image's ownimage_end_, and depends on that image's preferredET_DYNbase being guest address 0 - calling it twice would re-mmapthe buffer out from under the already-loadedlibapp.so, losing its heap/hooks/relocations entirely). Instead, carves space for the WHOLE second image out of the existingAllocMmap()arena (already part of the samehost_region_every engine thread maps - 32 MiB, mostly unused since realShim_mmap()guest calls are rare) and repeatsMapSegments/ProcessRelocations' own logic with a REAL, non-zero bias added to every relocation - the primary loader'sR_ARM_RELATIVEno-op shortcut only works because its own bias is always exactly 0 (seeProcessRelocations' own comment); this one actually addsbaseto eachR_ARM_RELATIVEtarget and to every locally-defined symbol'sst_value. External symbol references resolve through the EXACT SAMEResolveOrCreateImportStub/RegisterImportShimtablelibapp.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 normalstrip, unlike.symtab, and give a real symbol COUNT thatDT_SYMTABalone 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. -
emu/ostream_repro_test.h/.cpp(new files, added toCMakeLists.txt) - the driver: callsLoadSecondaryImage, allocates a small result buffer viaAllocPermanent, calls the entry point viaCallGuestFunction, and compares the extracted content byte-for-byte against the known-correct literal, logging an unambiguous PASS/FAIL under tagOSTREAM_REPRO. Wired intoLoadEmulatedLibapp(main.cpp) for one test run, deployed viaadb pushto/data/local/tmp/thenrun-as com.ea.games.nfs13_arm cp ... files/ostream_repro.so(this app's own internal files dir - same directoryGameActivityMain.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)marshalsargCount>4by writingargs[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 readingr0-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-16LDR Rt,[SP,#imm8*4], encoding10011 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 SAMEAllocCodeStub-dispatched stub, on the same thread - e.g. a caching bug inCallGuestFunction's own save/restore bookkeeping orMiscStubDispatch'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 with0xDEADBEEFbetween 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):
GuestEngine::CreateConfiguredEngine()(new private method) - extracted out ofEnsureThreadEngine's own body verbatim:uc_open, mappinghost_region_onto the new engine, guard-page/RELRO protection, VFP/NEON enable (CPACR+FPEXC),ReplayHooksOnEngine, the oneMiscStubDispatchhook,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. Returnsnullptr(logging its own reason) on any hard failure; deliberately does NOT toucht_state_or carve a guest stack, since a nested engine doesn't get its own stack range (see below).EnsureThreadEngine()- now just: early-return ift_state_.ucis already set, else callCreateConfiguredEngine(), then carve a stack and populatet_state_(its own unchanged tail). Zero behavior change for the depth-0 path.ThreadState::nestedEngines[kMaxNestedEngines](new field,guest_engine.h;kMaxNestedEngines = 8, a generous safety cap - deepest depth actually observed live was 2, matchingkMaxCallIterations' own "safety net, not expected limit" philosophy) - one lazily-created, thread-localuc_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 - nouc_closeanywhere in this design), not recreated per call.GuestEngine::GetOrCreateNestedEngine(uint32_t depth)(new private method) -depth-1indexesnestedEngines[]; lazily creates viaCreateConfiguredEngine()and caches on first use for that depth; returns the cached engine on every subsequent call at that same depth. Returnsnullptr(logging) ifdepthexceedskMaxNestedEngines.CallGuestFunction's prologue -callDepthAtEntryis now read BEFORE resolving the engine (it used to be read later, purely for the existing reentrancy-probe log);engis nowt_state_.ucat depth 0 orGetOrCreateNestedEngine(callDepthAtEntry)at depth>0, instead of alwaysuc().EngineSwapGuard(new, scoped toCallGuestFunction's body via RAII, same pattern as the existingCallDepthGuard) - the ONE subtlety this fix needed. Several shim call sites across the codebase (DispatchCallinguest_fn.h, plus call sites inlibc_shims.cpp/jni_shim.cpp/pthread_shim.cpp/rtti_shims.cpp- confirmed via a fullgrepsweep for.uc()usage before writing this fix) read registers viaeng.uc()(i.e.t_state_.uc) instead of theuc_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.EngineSwapGuardsavest_state_.uc, sets it to the resolvedengfor 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_.ucalready), 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:
- First assumed the literal-pool computation itself was wrong (R6 read as
0x7055ccright at theADD R6,PC,R6instruction's own hook address) - this was aUC_HOOK_CODEtiming misunderstanding on my part: the hook fires BEFORE the instruction at that address executes, so it was reading R6 as left by the PRECEDINGLDR(the raw un-added literal, confirmed by hand:0xadaef4 - 0x3d5928 == 0x7055ccexactly). A follow-up probe right before theBLXconfirmed R6 genuinely is0xadaef4by the time the call actually happens - no literal-pool or PC-relative-addressing bug. - Second built a whole-image
UC_HOOK_BLOCKwatcher expecting R6 to equal0xadaef4at 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 insidesub_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:
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:
ThreadState::nestedStackTop[kMaxNestedEngines](new field, paired 1:1 with the existingnestedEngines[]) - each depth's own stack-top address, base implicitlytop - kStackSize.GetOrCreateNestedEngine(depth)now callsCarveThreadStack()(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 oldt_state_.stackTop - 0x100, which had nothing to do with this depth's own isolation.CallGuestFunction's SP-validity/reset logic now resolves the correct[stackBase, stackTop)bounds for whichever stackengactually uses -t_state_.stackBase/stackTopat depth 0 (completely unchanged, zero behavior difference for the overwhelmingly common case), ort_state_.nestedStackTop[callDepthAtEntry-1]/-kStackSizeat 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 principlenestedEnginesalready 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:
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):
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:
- 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... - 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. PreMemcpyPltCallProbe: __aeabi_memcpy LR=0x270090 dest=0x436910b src=0x9e5d23("//VERTEX SHADER\n//=============\n\n") n=0x21(33)-destlands 19 bytes into the 32-byte-class block at0x43690f8, 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 into0x4369124's own memory, exactly matching the corrupted content dumped earlier (" //VERTEX SHADER\n//=============\n\n").sub_4711C8is entered right after, readsa1[27]=0x4369124, and finds it already stomped.- A SECOND write follows immediately, same call site (
LR=0x270090, i.e. stillsub_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 memcpys) 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.
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, sameapp=0xb6fc20, sameSP=0x12343dc8):0x3d5980, insidesub_3D58BC- specifically the(*(vtable+140))(a1)virtual call. I.e.sub_3EB650literally ISIsisApp's vtable-slot-140 method.sub_3D58BC(task #26's own "periodic per-frame subsystem tick" dispatcher - real decompile: lock-gated viasub_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 fromLR=0x3f8688- which issub_3F8648's OWN loop-internal return address.sub_3D58BCis itself registered as one ofsub_3F8648's up-to-16 generic listeners - i.e. the wholesub_3EB650/render chain is triggered as a REACTION to some OTHER broadcast, not from awhileloop of its own.- 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) withLR=0xfffffff0- this isGuestEngine::kCallReturnSentinel(guest_engine.h:341) verbatim - the fixed LR value this engine sets for every TOP-LEVELCallGuestFunction()invocation, confirming this specific broadcast is fired directly from a top-level native entry point (matchingpthread_shim.cpp's ownCallGuestFunction(startRoutine, arg)pattern for a guest-created worker thread's start routine, not from anywhere inside the render chain) - andmsgId=0x54(156 times/35s) withLR=0x468338, matchingsub_468320's own internal call site (the SAME lifecycle-listener wrappersub_3EB650itself calls at the tail of its render branch,sub_468320(v32)@ 0x3ebab4) - i.e.msgId=0x54is 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"), confirmingsub_3D58BCis 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:
Shim_glUseProgram/Shim_glDrawElements's guestLR(from the text-overlay trace window, both calls) resolve to the exact same function,sub_56962C(0x56962c, size0x81c) - a single generic "execute one render-command item" function: binds program/attribs/uniforms/textures, then callsglDrawElements. Its very first parameter is the indexcountto draw, and the whole function no-ops (noglUseProgram, noglDrawElements, nothing) ifcount == 0. So the draw-or-skip decision is made entirely by whoever computescount, not inside this function.sub_56962Chas 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, plussub_83A84/sub_83B08which pass a hardcodedcount=3) that just relay fields from a "render item" object (a1) straight intosub_56962C's params - i.e. per-class virtualDraw()overrides holding no logic of their own, referenced only as vtable data (confirmed viaxrefs_toshowingtype: "data"inside container functions, notblcall sites).- Followed the render-item object's
countfield (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, sosub_56962Cnever even gets acount=0call, it simply never gets called at all for this batch. - Traced what advances
a1+12:sub_4015D4- literally anAppendQuad(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 cursorsub_3FE160checks. If this function is never called for a given quad, that quad's geometry simply doesn't exist in the batch. - Found
sub_4015D4's 3 callers. Two of them -sub_4010F0andsub_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 throughsub_3DA3F0instead ofsub_4015D4directly. - Decompiled
sub_3DA3F0- confirmed it's a textbook Sutherland-Hodgman polygon clipper: 4 sequential calls tosub_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 viasub_3DACB0exactly like astd::vectorreallocation), and returning(outputCursor - outputBase) >> 5- the number of vertices that survived clipping against the rect passed in as its first argument. Notably,sub_3DA3F0does NOT itself callsub_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 realsub_4015D4append 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):
-
Decompiled
OrthoCameraController's actual vtable for the first time - theoff_A9B61C/off_A9C1B0references above were WRONG. Found the real symbol via RTTI search:_ZTVN2im3app7cameras21OrthoCameraControllerEat0xa9c1a8, real slots starting0xa9c1b0. Those earlier addresses actually belong toRainEffect/RearViewCameraController- sibling classes laid out contiguously right afterOrthoCameraControllerin.data.rel.ro(im::app::camerasnamespace). 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,OrthoCameraControlleris 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. -
Added a live probe on every
glUniformMatrix4fvcall, shape-matched to the known-buggy coefficients (scale_x/scale_yin 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 buggy0.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. -
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 atv3-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:
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.000on 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):
- "The engine's font setup failed over JNI, so measureText was never resolved."
sub_54208C(font ctor, IDA) doesNewGlobalRef(paint)->GetObjectClass->GetMethodID("getTextSize"/"setTextSize"/"getFontMetricsInt"/"measureText"), caching the last indword_ADFB08. Read those globals live during glyph rendering:getTextSize=1479 setTextSize=1480 measureText=236- all valid, none zero. The font setup succeeded. (The oneGetObjectClassrejection in the capture carriesLR=0x54ca38once LR logging was added to it - a different function, notsub_54208C. Real issue, separate task.) - "Float return values come back in the wrong ABI register."
FloatBitsToR0's own comment injni_shim.cppflags 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.soshows noTag_ABI_VFP_args, i.e. base/softfp AAPCS - float returns do belong in r0. Our implementation is correct for this binary. Not the bug. - "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 (memcpyintojvalue.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):
GlyphBuffer::prepareGlyph(sub_3E3248, named by its own debug string) mallocs a 48-byte glyph descriptor and fills it viasub_3E54C8, passing the glyph advance it gets from the font's vtable slot +36.- Slot +36 (
sub_542DB4) builds a one-character string and calls slot +40 (sub_542E50=MeasureStringAdvance, again self-named), whose body is literallyCallFloatMethod(paint, Paint.measureText, oneCharString)scaled by two fields. - Live probe on that JNI call: receiver valid, methodID valid (
measureTextresolved to 0xb3), the string argument perfect ("A","0","©"," ", alllen=1) - and Java answered0.0every single time. A neighbouring float call (getPerformanceScore -> 6.6) worked fine through the same shim, so the float RETURN path was not at fault. - Java only returns 0 from
measureTextwhen the Paint's textSize is 0. The text size reaches Java as a float argument tocreatePaintFromFile(String,float)/setTextSize(F)V. - Probing the raw va_list bytes settled it:
raw=0x00000000 next=0x4037a000-> read as a 4-byte float that is0.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 widenfloattodoublein any varargs call, and everyCall*Method/Call*MethodVJNI 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):
measureTextnow 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 uniform10x5. - 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/1024for 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:
realloccopied the NEW size out of the OLD block - reading past its end on every shrink. Now copiesmin(old,new)using the exact per-block size.calloccomputednmemb*sizein 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_A9B61Cbelongs to neighbouringRainEffect/RearViewCameraController); the real one at0xa9c1a8is ctor/dtor plumbing for a 3D in-race camera, with no resolution logic. And a live probe on everyglUniformMatrix4fvshowed 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/measureTextmethodIDs are all valid (1479/1480/236). The oneGetObjectClassrejection in the capture comes from a different function (LR=0x54ca38), a separate issue. - Float RETURN in the wrong ABI register -
readelf -Ashows noTag_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:
- Log volume - 1,381,445 lines per load reduced to 5,806 (240x). User verdict: "чуть-чуть быстрее, но не намного".
- 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. - Emulated zlib
inflate- intercepted and served by host zlib; verified engaged, 157 MB decompressed natively per load, zero failures. User verdict: "чуть быстрее, но не значительно".
All three were real overhead worth removing. None was the answer. The lesson, paid for three times: back-of-envelope arithmetic answers "could this plausibly cost that much" and says nothing about "what does it actually cost".
So the block profiler was switched on for one capture (EnableProfiling(), off again immediately afterwards - a UC_HOOK_BLOCK over the whole image has caused a user-visible regression twice before). 177,881 samples over 6,477 distinct block addresses:
| guest routine | share | what it is |
|---|---|---|
sub_65F6C8 |
17.7% | zlib crc32 - ~crc in/out, 8x256 slice-by-8 table at dword_A40B84, 32-byte unrolled loop |
sub_4F3704 |
18.8% | resource lookup by name - linear scan with strcmp per entry |
sub_567BD4 |
3.8% | glClear's caller (per-frame, not load) |
Two functions ≈ 36%; the rest is a long tail of ordinary guest code. That shape is exactly why each earlier theory moved the needle so little - there was never a single dominant cost to find.
crc32 was intercepted immediately: pure and stateless, so unlike inflate there is no stream to own and every call can be served by host zlib. The game calls it directly on decompressed data (archive integrity), on top of whatever crc32 happens inside inflate. User verdict after this one: "сильно стало быстрее" - a clean comparison, since the profiler was off in both the before and after builds and crc32 was the only difference.
Still on the table: sub_4F3704's linear name search (18.8%). Notably it ALREADY contains a hash-map fast path with memoisation (sub_4F84CC inserts the result), gated on a flag at *(a1+8) - yet the profiler's hot addresses are all inside the linear fallback. So this may not need native code at all, just an answer to why the cache isn't being used. Tracked as task #42.
2026-09-19 — SOLVED: garbled glyphs were a NULL pixel pointer (task #41)
Text had been laying out perfectly while rendering as coloured noise. Prior measurement had already
established that glyph positions, sizes, pen advance and UVs were all correct — UVs bit-exact
(u1 = 35/1024 for a 35px glyph in a 1024×1024 atlas) — which localised the bug to pixel content,
not layout.
Shim_AndroidBitmap_lockPixels returned NULL to the guest whenever the real pixel buffer lived
outside the guest region, under an assumption written into the code as a known, low-priority gap:
"texture decode FROM a Bitmap is the main real use, which reads pixels via other means, not by writing through this specific pointer"
The assumption was wrong, and the glyph atlas path is precisely the case it excluded. A probe added before changing any behaviour (deliberately — three theories were adopted and refuted on arithmetic alone the day before) returned:
AndroidBitmap_lockPixels rc=0 hostAddr=0x... -> guest=0x0 <<< NULL >>>
bitmap 1024x1024 stride=4096 format=1 (RGBA_8888)
1024×1024 is exactly the atlas size independently derived from the glyph UVs — two unrelated measurements landing on the same number. BitmapGraphics rasterised the glyphs correctly, the guest asked for the pixels, got nothing, and uploaded whatever sits at guest address 0 as the font texture.
Fix: a bounce buffer. On lock, height * stride bytes are allocated from the guest heap, the real
pixels copied in, and that guest address returned; on unlock the buffer is copied back into the real
bitmap and freed. The copy-back also closes the write direction the original comment named as
unsupported. Cost is one copy each way, a handful of times per session — not per frame.
Verified in both directions (per the standing rule that "no visible defect" is not proof): the
fallback path now logs loudly when a bounce buffer could not be made, and it fired zero times
across a full run — so every lock genuinely got a buffer rather than silently taking the old NULL
path. User verdict: "Шрифт починен!" Committed as f8744f0.
Transferable lesson: a shim that returns a plausible-but-empty value to the guest — NULL, 0, an empty string — fails silently and far from its cause. This one cost a multi-session investigation into draw calls, geometry and projection matrices, all of which were innocent. Where a shim cannot honour its contract, the gap belongs in a log line, not only in a comment.
Why a NULL guest pointer produced coloured noise rather than a crash — and how to read that noise
Worth recording as a reusable mechanic, because it cuts both ways.
Guest address 0 is a completely legal, mapped, readable address in this engine. G2H() bounds-checks
only the upper end (if (addr >= region_size_)), and the loader places the ELF verbatim at its link-time
vaddr — memcpy(host_region_ + ph->p_vaddr, ...) — where libapp.so's first PT_LOAD has
VirtAddr 0x00000000, 0xa8d9bc bytes, R E. So G2H(0) is the first byte of the guest region,
which is the ELF header followed by ~11 MB of the game's own machine code.
Consequences: a guest NULL dereference never faults. It silently reads libapp.so's own image, offsets
and all — 0 + y*stride + x*4 just indexes into the code segment. Every such bug therefore surfaces as
plausible-looking garbage somewhere far from its cause, never as a crash. That is precisely how the glyph
bug survived: glTexImage2D was handed G2H(0) and dutifully uploaded 4 MB of .text as the font atlas.
The useful half: that garbage is identifiable. Byte statistics over the region say exactly what was
read. Measured over the first 4 MB (= exactly one 1024×1024 RGBA atlas, saved as
evidence/font_atlas_was_guest_addr_0.png):
| channel | mean | median | note |
|---|---|---|---|
| R | 47.4 | 12 | register / immediate fields — small numbers |
| G | 52.1 | 16 | same |
| B | 115.4 | 141 | opcode + Rn field |
| A | 179.2 | 227 | 76.5% of bytes ≥ 0xE0 |
One ARM instruction is 4 bytes = exactly one RGBA pixel, and A32's top nibble is the condition code —
0b1110 (AL, "always") on the overwhelming majority of instructions. That lands in the alpha channel,
which is why the noise was opaque and visible at all. The image's three bands decode exactly:
- row 0 — ELF header and program headers (coloured specks)
- rows ~7–102 —
.rel.dyn(0x6a80, 387 KB): 50% of its words are17000000(R_ARM_RELATIVE) and the other half are low addresses, so every top byte is0x00→ alpha 0 → fully transparent - rows 103+ —
.text(0x68000) → opaque coloured noise
So "alpha ≈ 0xEx everywhere" reads as code segment, and a repeating 17000000 reads as relocation
table. Garbage on screen becomes a precise readout of which guest address was actually sampled.
Open architectural item (task #46): the multiplayer goal explicitly requires no memory errors, and this
design silently swallows an entire bug class that native hardware catches for free. Loading the image at a
non-zero bias would leave guest page 0 unmapped, making guest NULL dereferences fault immediately — the way
they already do on the A9. The engine applies R_ARM_RELATIVE relocations already, so a bias is plausible,
but it touches every hardcoded guest address in the shims and all the IDA-derived constants, so it is a
deliberate project, not a quick change.
2026-09-19 — Measuring the real slowdown with the game's own race clock
The in-game race timer (top right, ВРЕМЯ M:SS,CC) is a ground-truth clock the engine itself advances,
so comparing it against wall time measures the simulation deficit directly — no instrumentation, no
observer effect from our own counters. User's idea, and a much better instrument than anything we had.
Two independent methods agreed. Timed adb screencap series (6 shots, ~13.3 s apart, wall time
stamped either side of each capture): game advanced 50.62 → 59.74 s over 66.61 s real = 7.30x slower.
Then a 40 s on-device screenrecord (continuous, so it perturbs the app evenly instead of stalling it
~1.3 s per capture the way screencap does) sampled at deliberately uneven intervals, since uniform
spacing can hide burstiness:
| real interval | game time advanced | ratio |
|---|---|---|
| 1.0 s | 0.13 s | 7.7x |
| 2.0 s | 0.27 s | 7.4x |
| 4.0 s | 0.53 s | 7.5x |
| 8.0 s | 1.12 s | 7.1x |
| 16.0 s | 2.20 s | 7.3x |
| 7.0 s | 1.05 s | 6.7x |
The ratio is rock-steady at ~7.2x across timescales from 1 s to 16 s, even though frame pacing is wildly uneven (intervals: min 13.7 ms, p10 16.0 ms, median 89 ms, p90 217 ms, max 316 ms — roughly 10% of presents arrive at a full 60 Hz cadence, then long gaps). So the throughput deficit is a uniform ~7x tax, not periodic stalls; the stutter and the slowness are separate phenomena.
The car jumping forward and back is a double-present, and it is our bug
User reported the car visibly jumping forward, then back along its path. The frame-by-frame clock readout shows exactly that, perfectly regularly — presents arrive in pairs 10–20 ms apart, and the second member of each pair displays the PREVIOUS simulation step:
| frame | real t | clock |
|---|---|---|
| #08 | 0.71 s | 1:10.06 |
| #09 | 0.72 s | 1:10.03 ← back 0.03 s |
| #10 | 0.92 s | 1:10.09 |
| #11 | 0.94 s | 1:10.06 ← back 0.03 s |
| #12 | 1.16 s | 1:10.12 |
| #13 | 1.17 s | 1:10.09 ← back 0.03 s |
Cause: GameGLSurfaceView extends GLSurfaceView with RENDERMODE_CONTINUOUSLY (RunLoop.kt:43), and
GLSurfaceView's own GLThread unconditionally calls eglSwapBuffers after every onDrawFrame — framework
behaviour that cannot be switched off without taking over EGL management. Our synthetic swap
(gles_shim.cpp's frameBoundary) fires inside the frame, so each rendered frame is presented twice:
once fresh by us, then again by GLSurfaceView, which hands over the back buffer still holding the previous
frame. Counts corroborate: synthetic swaps run ~4.8/s (log markers #7801→#8501, 100 swaps per
~21 s) while the recording shows 9.03 presents/s — almost exactly double.
What the numbers say together
The clock steps by ~0.033 s (1/30 s) per simulation frame, fixed — it is not wall-clock driven. We produce ~4.5 real frames/s instead of 30, so 4.5 × 0.033 = 0.149 s of game time per real second = 6.7x, which closes the loop with the 7.2x measured directly.
Consequence for multiplayer, and it is a hard one: a fixed timestep means the game never catches up — it simply runs in slow motion forever. A peer on native hardware would be 7x ahead within seconds. Frame throughput is therefore not a polish item for the multiplayer goal, it is a prerequisite.
CORRECTION (same day): the double-present cause above is NOT established
The section above blamed GLSurfaceView for the second present. That is not supported and is retracted;
the measurements in it stand, the cause does not. Prompted by the user asking the obvious question —
why does nothing swap in the native environment? — which the existing code already answers
(gles_shim.cpp:389):
Nothing in libapp.so can present a frame, natively or here. Its only EGL import is
eglGetProcAddress, and the string eglSwapBuffers does not appear anywhere in the binary, so it cannot
even be looked up. On the A9 all 2,736 eglSwapBuffers calls in 46 s came from Android's own framework.
Native works because GameRenderer.onDrawFrame returns every frame and GLSurfaceView's GLThread swaps
automatically. Our synthetic swap exists precisely because nativeOnResume is recorded as running the
guest's persistent loop synchronously on the GLThread and never returning — so GLSurfaceView cannot be
swapping a second time, and the retracted explanation contradicts its own premise.
The numbers leave a real hole: the swapCount counter (logged every 100th swap, markers #7801→#8501
= 700 swaps over 152.57 s) gives 4.59 synthetic swaps/s against 9.03 presents/s recorded, with
pair spacing of 218 ms exactly matching the synthetic swap period. Two presents per swap, the second one
stale. Source unidentified.
Worth flagging: the "onDrawFrame never returns" premise is itself carried over from 2026-09-18 and has not been re-verified live — per this project's own standing rule about carried-over premises, it is now the prime suspect rather than a fact to reason from. Task #47 specifies the experiment (per-swap timestamps + an onDrawFrame entry/exit counter + correlated recording) that settles it without guessing.
Process note, because this is the fourth time: a plausible mechanism was written up as a diagnosis before its own numbers were checked. The swap-rate figure needed to refute it was already in the log.
RESOLVED: the retraction above was itself wrong — the premise was false
Measured instead of argued, and the answer reverses the retraction. An entry/exit counter added to
GameRenderer.onDrawFrame shows it returning every single frame:
onDrawFrame entries=1 exits=0
onDrawFrame entries=50 exits=49
onDrawFrame entries=698 exits=697 <- the one-call gap is the call in flight
So the 2026-09-18 premise — nativeOnResume runs the guest's persistent loop on the GLThread and never
returns — is false, and had been reasoned from for a day. GLSurfaceView was presenting normally all
along, and the rates are 1:1:
| synthetic swaps | 12.98/s (SWAPMARK, one line per swap) |
onDrawFrame |
12.69/s → framework swaps at the same rate |
One synthetic swap per onDrawFrame, plus the framework's own = every frame presented exactly twice,
the second time from a buffer holding the previous frame. The original diagnosis was right; the
retraction was wrong because it trusted a code comment over a measurement.
Fix: kSynthesizeSwap = false in gles_shim.cpp — let the framework be the only presenter, which is
also exactly what the native game does. Measured on the Pixel 6a:
| before | after | |
|---|---|---|
| frame interval median | 89 ms | 17 ms |
| p90 | 217 ms | 19 ms |
| max | 316 ms | 24 ms |
| synthetic swaps | 4.59/s | 0 (verified present in the log, not merely unobserved) |
onDrawFrame |
12.69/s | 12.39/s — unchanged, as expected |
User verdict: "Сейчас очень плавно". Committed as 7527089.
Throughput is untouched — this fixed presentation, not the ~7x simulation deficit. Note also that
screenrecord reporting 60 frames/s afterwards is not a render rate: onDrawFrame proves the game
still draws ~12.4/s, and the recorder simply emits one composited frame per vsync. Worth stating because
the 9.03/s → 60.04/s jump looks like a 6x speedup and is not one.
Process note: this is the fourth time a plausible mechanism was written up before its own numbers were
checked, and the second time in one day. Both the diagnosis and its retraction were published on
reasoning; a single counter settled it in one run. The standing rule about carried-over premises
(feedback-verify-ingame-before-re-theory) existed precisely for this and was not applied to the
nativeOnResume comment.
2026-09-19 — First in-race profile: ~68% of CPU is Unicorn hook dispatch, not GPU or emulation
The ~5 fps had never been attributed because every profile so far was taken during loading. Captured one
during an actual race using simpleperf against the live process — no rebuild, no restart, no lost
race. That technique is the reusable part of this entry:
adb shell simpleperf record -p <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:
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):
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:
.textwrite counter → flat zero, sotb_invalidate_phys_page_fast_armis not self-modifying code.FnvHashAccelHookCbcounter (it still does auc_emu_stopround-trip) → never fires during a race.
Result
| before | after | |
|---|---|---|
helper_uc_tracecode |
34.54% | absent from the top symbols |
| stub dispatches | 65k/s | 76–93k/s (more guest work done per second) |
| game time vs wall clock | 3.99x | 2.73x |
27 s of wall time advanced the race timer 30.13 → 40.02 (9.89 s); sub-intervals 2.75x and 2.71x.
Today's full progression: 7.2x → 3.99x → 2.73x. Committed as 7b15d85.
Beyond the hook cost, the per-instruction gen_set_pc_im also prevented TCG from chaining blocks, so this
lifted a ceiling on translation quality as well as removing a tax.
This also explains task #48's null result, and an error of mine
Removing 17 probe hooks shortened the hook list but left this global entry in it, so the per-instruction call remained — exactly why the profile share moved and the frame rate did not.
Worth recording plainly: during #48 I searched for a hook registered with the global begin > end form,
found none, and wrote that off. I had searched only our own code, not Unicorn's. The offending
registration was inside uc_emu_start itself. A correct search would have found this hours earlier.
Trade-off accepted
A runaway guest loop inside one call now hangs rather than returning after 5M instructions to be caught by
kMaxCallIterations. That net was already documented in the function as "not a security boundary", and
since the 2026-09-05 stub fix a normal call completes in a single uc_emu_start anyway. If a watchdog is
wanted, use one long-lived thread calling uc_emu_stop — not uc_emu_start's timeout argument,
which spawns a fresh thread per call (uc.c:1036).
Clean-build final number for the day: 2.57x
Re-measured with both temporary counters off (one of them hooked every write across .text, so the 2.73x
figure was conservative). 27 s of wall time advanced the race timer 55.33 → 65.84 (10.51 s); sub-intervals
2.41x and 2.76x.
Day's progression: 7.2x → 3.99x → 2.73x → 2.57x — the game is now running about 2.8x faster than it was this morning.
Profile shape with no dominant symbol left, which is the healthy sign:
| symbol | share |
|---|---|
tb_invalidate_phys_page_fast_arm |
5.56% |
helper_check_exit_request_arm |
5.40% |
flatview_translate_arm |
5.00% |
helper_lookup_tb_ptr_arm |
4.60% |
find_memory_mapping_arm |
2.90% |
Next candidates, in order of size: flatview_translate_arm + find_memory_mapping_arm are one path
(memory-region lookup, 7.9% together); tb_invalidate_phys_page_fast_arm at 5.56% remains genuinely
unexplained since the .text write counter ruled out self-modifying guest code.
2026-09-19 (autonomous session) — mapping the guest region by permission: loading 8.3% faster
UC_PROT_ALL on the whole guest region was costing three of the top profile symbols at once. QEMU's
notdirty_write (qemu/accel/tcg/cputlb.c:1199):
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
DeleteLocalRefon the class it looked up, and CheckJNI kills the process — "expected reference of kind Local but found Global: 0x2fe6". GuardingImpl_DeleteLocalRefwith anIsGlobal()check fixed that cleanly. Any future attempt to introduce global refs needs that guard, and should auditNewLocalRef/PopLocalFrametoo. - 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:
flatview_translate+find_memory_mapping≈ 7.5% — the softmmu lookup path, task #53. Still open, and now the largest remaining item.helper_lookup_tb_ptr5.74% — indirect-branch dispatch. Block chaining is enabled (tb_add_jumpis 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.- 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:
ram_addr_t ram_addr = mem_vaddr + iotlbentry->addr;
MemoryRegion *mr = cpu->uc->memory_mapping(cpu->uc, ...); // <- unconditional
if (mr && (mr->perms & UC_PROT_EXEC) != 0) { ... } // <- result used for ONE bit
The lookup runs before the executable test, so it is paid even when the answer turns out not to matter. Measured per 3-second window:
hits=3478174 exec_region=1 |
startup, all in the 0–1 MB bucket |
hits=1184522 exec_region=0 |
|
hits=902501 exec_region=0 |
buckets at 10 MB and 11 MB — .data/.bss |
Over a million calls per three seconds where the full find_memory_mapping → address_space_translate → flatview_translate chain ran and the answer was discarded. Task #54 removed the tb_invalidate work
for non-executable spans but left this lookup in front of it, so most of that cost survived a fix that
looked complete.
The map is static once CreateConfiguredEngine has mapped its five spans, so a small per-thread
direct-mapped page → region cache removes all but cold misses. It is implemented in 5e246fc but
defaults OFF, because it could not be measured — and shipping an unmeasured perf change is the exact
mistake this session has spent its time correcting.
Blocked on hardware
- Pixel 6a:
mScreenState=OFFwithin seconds despite a 30-minute timeout andsvc power stayon true, withNotificationShadeholding 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:
#define USE_GUEST_BASE (guest_base != 0 || TARGET_LONG_BITS == 32)
#define TCG_REG_GUEST_BASE TCG_REG_X28
...
tcg_out_qemu_ld_direct(s, memop, ext, data_reg, TCG_REG_GUEST_BASE, otype, addr_reg);
One instruction — LDR Wd, [X28, Wn, UXTW]. Our guest is 32-bit, so UXTW zero-extends the guest
address and adds the base register: that is literally this engine's G2H(addr) = host_base + addr,
done by the addressing mode. X28 is already reserved for exactly this purpose in the backend.
Why the profile understated it
flatview_translate, find_memory_mapping, tlb_set_page_with_attrs, get_phys_addr ≈ 11% is only the
miss path. The hit path is those nine inlined instructions, which carry no symbol and are
invisible in a sampling profile — they are spread through the JIT code and anonymous
libmpcore.so[+…] entries. That is why every optimisation aimed at the visible symbols returned
percentages: they were the tail, not the body.
Honest estimate of the prize
| generated code that is TLB bookkeeping | 29.8% |
| share of CPU in translated code (total minus named helpers) | ~60% |
| fast-path saving | ~18% of total CPU |
| plus the miss path disappearing entirely | ~11% |
| total | ~30%, i.e. about 1.4x |
The fast-path figure is likely conservative: that sequence is three dependent loads, so it costs more per instruction than average code.
But 1.4x is not native. It would take the game from ~2.6x slower than real time to ~1.8x. This is by far the largest remaining lever and it does not, on its own, reach the goal.
What it would cost
| lost | replacement |
|---|---|
| per-page permissions (guard pages, RW/RX) | real mprotect() on the same mmap — guest and host addresses differ by a constant, so the hardware MMU enforces it for free |
UC_HOOK_MEM_INVALID |
a SIGSEGV handler — prior art exists here, with a known trap: no mmap inside the handler on bionic (reference-arm-signal-handler-deadlock) |
| TB invalidation for self-modifying code | measured zero guest writes to .text, but must still be handled or it breaks silently |
uc_mem_read/write and parts of the Unicorn API |
go through memory_mapping; would need rework |
Rebuilding Unicorn as CONFIG_USER_ONLY is not viable — its whole API assumes softmmu. The narrow path
is to patch only tcg_out_qemu_ld/st in the aarch64 backend to emit the direct form when the engine
is in flat-mapping mode, reserve X28, move permissions to mprotect, and add the signal handler.
This is the largest change the project has considered, for ~1.4x. Worth doing if the goal is "as fast as we can reasonably get"; not sufficient if the goal is literally native.
2026-09-21 — Does the engine scale with CPU? Yes, 1.43x. Two bad measurements said otherwise first.
The question mattered because task #61 (bypassing softmmu) reduces CPU work — if the engine were not CPU-bound, that work would be wasted.
The answer, from the one measurement that is actually comparable
RunTcgBenchmark runs a fixed synthetic ARM32 workload through the translation engine at every startup.
Identical code, identical work, every device:
| device | SoC | throughput |
|---|---|---|
| Xiaomi 14 | Snapdragon 8 Gen 3 | 26,783,896 B/s |
| Pixel 6a | Tensor G1 | 18,737,899 B/s |
| 1.43x |
Single-thread performance between those chips differs by roughly 1.6–1.9x, so 1.43x is a sensible scaling factor — the engine tracks CPU speed, losing some of it to memory latency. Task #61 is justified.
Two measurements that said the opposite, and why they were wrong
"100k guest heap allocations" was not a unit of work. Allocations happen in response to what the game is doing, and the game does different things on different devices depending on save state and which screen it is on. The metric produced Xiaomi 4.20s vs Pixel 6a 4.52s (a 7% gap, suspiciously small), and then a steady-state variant produced Xiaomi slower than the Pixel — which is impossible and exposed the flaw. A fixed count of events is not a fixed amount of work unless the events are the same events.
The Xiaomi was in a broken state throughout. All textures were rendering black (see task #62) while geometry and HUD drew correctly. With no texture sampling the GPU does far less work, so the device looked faster than it is — and the user's impression that "the race start animation runs at native speed" is most likely explained by that, not by the SoC.
Both failures share a root: comparing devices requires the same work on both, and neither the workload nor the device state was verified before drawing conclusions. The synthetic benchmark has neither problem, and it was sitting in the tree already.
Device notes
- Galaxy A9 cannot run this build at all —
failed to attach / start timeout. Engine startup exceeds Android's attach deadline on Snapdragon 660. It remains usable only as a native reference. - Activities are not exported:
am startfrom shell fails withSecurityExceptionon some devices. Usemonkey -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_baseinuc_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-circuitstcg_out_qemu_ld/_stbeforetcg_out_tlb_read, and the prologue materialises the base into X28 and reserves it.TCG_REG_GUEST_BASEhad to move out of the#ifndef CONFIG_SOFTMMUguard: 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 inCreateConfiguredEngine, behindkUseFlatGuestMapping.
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/.textsplit are advisory for guest loads and stores (hostmprotectwould restore them; not done yet),UC_HOOK_MEM_READ/_WRITE/_INVALID- theMEM FAULTdiagnostics 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 | 40–56, rising 44→55 over 12 s |
Two things worth separating, because conflating them is easy:
- In the menu the Xiaomi sits exactly on 61 frames/sec, i.e. it is waiting for the display, not for us. We cannot tell from that number how much faster the engine actually is there.
- In race it drops to 40–56, so the engine is the limit again - the race is genuinely heavier work, consistent with task #56.
The rising trend inside the race (44 → 55 over twelve seconds) looks like translation warm-up: new blocks are still being JIT-compiled early in the race and the cost falls away as the cache fills.
A screenshot taken mid-race shows the scene rendering in full - wet-road reflections, motion blur, opponent car, HUD - with no black textures in this run, so the frame rate is not being flattered by missing work.
2026-09-21 — SOLVED: the post-prologue crash was a missing num_get<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_NONEreservation added the same day for task #61 turned the wild pointer into a cleanSEGV_ACCERRwhose fault address decoded exactly:x28(the new flat-map base)= 0x6f00000000,x22 = 0x464c459b, fault addr0x6f464c459b- 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_putfacet 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 vtable covering bool, every integer width, float and
double. Characters come straight out of the streambuf's get area - offsets
eback@8, gptr@12, egptr@16, pinned by the same anchor as
WriteCharToStreambuf's already-working pptr@24/epptr@28. Parsing is handed
to the host's strtoll/strtoull/strtod, the same "offload it to real libc"
choice num_put makes for formatting. Slots outside the table still route to the
logging stub, so the next gap names itself instead of crashing.
An exhausted get area would need a virtual underflow() call, which this shim
does not make - it reports end-of-input instead. Fine for the istringstream
case that occurs here, and logged rather than silent if it ever matters.
Measured afterwards, with its limits stated
Across the winning session: 105,965 log lines, zero signals, zero
MEM FAULT, zero tombstones, 19,955 frames and 1,052,420 draw calls. The
use_facet complaint is gone and no unimplemented-slot stub fired, which is
the positive evidence that num_get is now served.
The frame-rate figure needs a caveat. The mpcore log buffer had wrapped, so the counter only covers the last 97 seconds - and a screenshot shows that window is the post-race results screen (27.5 draw calls per frame, a light scene), not the race. 60.0-60.3 fps there is a vsync lock on an easy screen and must NOT be read as "the race runs at 60". Measuring the race properly still needs a run with the log captured from the start.
2026-09-21 — Black screen entering a race: the thread-stack arena was one-way
Found while trying to measure CPU load for the heat question, which is worth noting on its own: the measurement came back as "2% of one core", which looked like the engine idling and was actually the engine hung. The number was nonsense for the question asked, and checking why saved the wrong conclusion.
What was wrong
The game was foreground and focused, the screen awake, and the frame counter frozen. The log said exactly why:
EnsureThreadEngine: thread-stack arena exhausted (kMaxGuestThreads reached)
CallGuestFunction(0x54bb20): no engine available on this thread (depth=0)
pthread_shim: guest thread (handle 11) start_routine returned 0x0
CarveThreadStack was a pure bump allocator: thread_stacks_cursor_ only ever
moved forward. Every guest thread that finished kept its 8 MiB stack forever,
and so did every nested-call engine - and those come out of the same arena,
so one guest thread could hold a primary stack plus up to kMaxNestedEngines
more. With kMaxGuestThreads = 16 a session simply ran out. The game creates
threads as it goes (one per race among others), so this presents as: play for a
while, enter a race, get a black screen.
Note the failure shape - the thread's start routine silently "returned 0x0" rather than never running. Without the two log lines above it, that reads like the guest's own code deciding to do nothing.
Fix
GuestEngine::ReleaseThreadEngine() closes the thread's uc_engine, closes
every nested-call engine, and returns all their stacks to a free list that
CarveThreadStack now drains before growing the arena. pthread_shim's thread
body calls it as its last act.
kMaxGuestThreads also went 16 -> 64 as headroom for the genuinely-concurrent
peak. That is the secondary change, not the fix - the arena is lazily-mapped
address space, so raising it alone would only have delayed the same hang.
Stacks are deliberately not scrubbed on reuse: the guest writes a stack frame before reading it, and leaving the previous occupant's bytes in place has caught real uninitialized-read bugs in this engine before.
2026-09-21 — CPU and thermal budget of the engine, measured (task #65)
The phones run noticeably warm. Measured on the Xiaomi 14 during real play, after the thread-stack fix above (the first attempt was invalid: it reported "2% of one core", which was the hang, not the engine idling).
| whole process | 0.83–0.97 of one core, sustained |
GLThread alone |
78.8% of one core |
| everything else | ~4% across 47 other threads |
| cores in the machine | 8 |
| peak CPU temperature under load | 49–55 °C, flat over 2 min |
| temperature when cooling down | 44–48 °C |
| frame rate during the measurement | 66–70 |
It is not spinning. Sampling GLThread's scheduler state 300 times: 75%
running, 25% sleeping. So the core is busy with real translation work and the
sleep is the frame wait - there is no busy-wait to delete. (First attempt at
this sample read the wrong /proc field: the thread's comm is
"GLThread 2052", which contains a space, so the parenthesised name shifts
every positional field after it.)
Reading of it. The heat is one core held near capacity continuously, on a machine with eight. Temperature plateaus rather than climbing, comfortably below any throttling threshold, so this is not a thermal emergency - it is simply the cost of the work. Headroom at 60 fps is thin though: only ~25% of the frame budget is idle, which is consistent with the frame rate sagging on heavier scenes.
That makes the remaining profile items directly a heat story as well as a speed one: softfloat (~9%, task #63) and indirect-branch lookup (12.18%, task #64) are watts as much as milliseconds.
A same-device native comparison is not possible here - the Xiaomi cannot run the 32-bit build at all, which is the entire reason this engine exists. Any native-vs-emulated power figure would have to come from the A9, on a different SoC, and should be labelled as such.
2026-09-21 — SOLVED: black textures after resume (task #62)
The shipped game explicitly asks the platform not to preserve its EGL
context across a pause - GameGLSurfaceView's constructor passed false to
setPreserveEGLContextOnPause - and takes responsibility for rebuilding its GL
objects itself afterwards. Under this engine that rebuild does not happen.
Established live, not assumed
Nothing logged the context lifecycle, so the central question - does the context
actually die? - was unanswerable from the existing logs. Two log lines in the
EGLContextFactory settled it:
EGL context CREATED (#1) 13:10:01 launch
EGL context DESTROYED (#1) 13:11:14 on pause
EGL context CREATED (#2) 13:11:23 on resume
So every texture, buffer and shader was genuinely destroyed. And across that boundary the guest's own restore ran but did nothing useful:
Renderer::RestoreContext 13:07:58.010
Renderer::RestoreContext Finished 13:07:58.011
One millisecond, with 1 texture upload after the boundary against 86 before it. That is the whole bug: geometry survived because vertex data is re-sent every frame, textures are uploaded once, so they came back black.
Worth recording why the restore never fires properly: GameActivityMain.kt's
onResume does move the state machine into STATE_RESTORE_CONTEXT, but that
state only waits for focus and then falls back to the previous state. The real
nativeRestoreContext() call lives in STATE_GAME_START behind a one-shot
restoreContextThreadStarted flag, so it runs exactly once per process.
Fix
setPreserveEGLContextOnPause(true). Verified: after pause and resume the log
shows only CREATED (#1), with no destroy, and a screenshot of the city map
after resuming shows the satellite imagery, city lights, roads and the whole UI
ribbon intact.
This fixes the symptom without depending on the guest restoring anything, which
is the right trade here - the alternative is re-running the guest's asset
restore mid-session, which would also re-run nativeOnStart/nativeOnResume
and the splash sequence while the player may be mid-race.
It is a hint, not a guarantee. The system may still drop the context under
memory pressure. That is exactly why the create/destroy logging stays in
permanently rather than being removed as a spent probe: a second CREATED line
in a beta report means this fell back, and the guest-side restore path becomes
the next thing to fix. Until then it would have been an invisible failure.
2026-09-21 — Audio, step 1 of 4: the runtime-support gap is closed (task #67)
No sound yet, and none was expected at this step. Recording the groundwork and the measured size of the remaining job.
Why running the game's own FMOD beats reimplementing it
libfmodex.so (841 KB) and libfmodevent.so are shipped as ordinary ARM32
shared libraries; libapp.so names both in DT_NEEDED. Today all 63 FMOD entry
points in fmod_shims.cpp are no-ops that pretend success, and there is no
audio backend of any kind in the tree.
libfmodex depends on no audio library at all - only libc/libstdc++/libm/libdl/
liblog. It finds the device at runtime: it imports dlopen/dlsym/dlclose,
carries the literal string libOpenSLES.so, and references slCreateEngine,
SL_IID_ENGINE, SL_IID_PLAY, SL_IID_ANDROIDSIMPLEBUFFERQUEUE,
SL_IID_ANDROIDCONFIGURATION, SL_IID_RECORD.
So real FMOD will do all mixing, event and bank work itself and the only thing we owe it is the classic OpenSL ES buffer-queue output - roughly 15-20 entry points instead of 63 sets of semantics.
Step 1, done: the 37 missing runtime functions
Comparing libfmodex's 117 undefined symbols against everything this engine already registers left 37 gaps, all compiler-runtime or libm:
- 14
__aeabi_*helpers - integer divide, double add/multiply/compare, the int-and-float-to-double conversions,atexit, the unwind personality - ~20 libm -
acos,atan2,cos,sin,tan,exp,log,log10,log10f,sqrt,rint,lrintf,frexp,ldexp - a handful of libc -
usleep,memmem,inet_addr,chown,select,pthread_attr_setdetachstate,operator delete
All implemented by forwarding to the host. The softfp r0:r1 pair convention the
__aeabi_* double helpers use is exactly what ReturnDouble/ReadDoubleArg
already handled, so nothing new was needed there. __aeabi_uldivmod returns its
remainder in r2:r3, which the shared dispatch contract does not cover, so it
writes those two registers directly - the same local trick ReturnU64 uses for
r1 rather than changing the contract for every shim.
Checked for regression rather than assumed: none of the 37 is imported by
libapp.so (it statically links its own math and operator delete), so the
working build's behaviour is provably unchanged. A smoke run on the Xiaomi
confirmed it - 4,052 frames, zero faults.
Two are deliberately not real and say so in the log if reached: select
(FMOD's network-streaming path only) and __aeabi_unwind_cpp_pr0 (a C++
exception unwinding through guest frames, which this engine still cannot do).
Steps 2-4, not started
- Ship both FMOD libraries as assets and extract them, the way
libapp_armeabi_v7a.soalready is - the APK declares only arm64-v8a, sojniLibs/armeabi-v7ais not packaged. - Load them via
LoadSecondaryImageand makelibapp.so's FMOD imports resolve to the real guest symbols. This needs a resolution-order change:ResolveOrCreateImportStubtoday 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. - 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
dlopens 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_PRELOADed 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 BNEs 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
fmodGetInfolog - 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
WatchGuestAddresshooks insub_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.