// LD_PRELOAD interposition for libc file I/O (including whatever the game // does to read its .obb data) and GLESv2 draw calls, on the REAL, // unmodified armeabi-v7a libapp.so running natively on the Galaxy A9 (see // ARM64_TRANSLATION_LAYER.md's "Native ARM32 tracing harness" plan). This // is the ground-truth counterpart to this session's own gles_shim.cpp // counters and libc_shims.cpp - deployed via Android's per-app // `wrap.` debuggable-app mechanism (no root needed), so every // dynamically-resolved libc/GLESv2 symbol the real game calls resolves to // these wrappers first (standard dlsym(RTLD_NEXT, ...) interposition), each // logging then calling straight through to the real implementation - // observation only, never changes behavior. #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "trace_log.h" using trace_agent::TraceLog; #define LOGI TraceLog namespace { template Fn RealSym(const char* name) { void* p = dlsym(RTLD_NEXT, name); return reinterpret_cast(p); } // Declared here (used by open() below) rather than down near pthread_once's // own definition - see pthread_once's comment for why this flag exists. // Plain namespace-scope atomic (constant-initialized), NOT a function-local // static - see pthread_once's own comment for why lazy function-local // statics are unsafe to touch from inside this specific interposer. std::atomic g_pastBootstrap{false}; std::atomic& PastBootstrap() { return g_pastBootstrap; } // Ground-truth counterpart (2026-09-16, ARM64_TRANSLATION_LAYER.md - the // dword_AE00D8 reentrancy investigation) to the emulated engine's own // SnapshotAE00D8Window (guest_engine.h's G2H-based version). Since this // agent runs INSIDE the real process via LD_PRELOAD, it shares the exact // same address space as the real armeabi-v7a libapp.so - no guest/host // translation needed, just the real ASLR load bias for this one shared // object, resolved once via dl_iterate_phdr and cached. uintptr_t LibappBase() { // A POD `static` with a literal 0 initializer gets plain constant // (zero) initialization, not a dynamic/guarded one - safe from the // lazy-function-local-static hazard pthread_once's own comment // documents (no emutls/pthread_once guard is generated for this kind // of trivial static at all). Deliberately NOT cached on failure though: // the first few pthread_once() calls observed live fire before // libapp.so itself has even been dlopen'd (its own early static-init // routines, other libraries' locale setup, etc.), so caching a "not // found" result after the very first attempt would permanently blind // this to libapp.so once it DOES load moments later - confirmed live // (every pthread_once call this session logged "base unresolved" even // well after real gameplay/glClear activity had started). Only cache a // SUCCESSFUL resolution. static uintptr_t base = 0; if (base) return base; dl_iterate_phdr( [](struct dl_phdr_info* info, size_t, void* data) -> int { if (info->dlpi_name && strstr(info->dlpi_name, "libapp.so")) { *static_cast(data) = info->dlpi_addr; return 1; // stop iterating } return 0; }, &base); return base; } // Same window as the emulated engine's probe: [0xae00d8, 0xae0108) - // dword_AE00D8/dword_AE00DC/dword_AE00E0/dword_AE00FC, the small cluster of // globals sub_56962C's crash-site arithmetic reads directly by fixed // offset. libapp.so's own file vaddrs equal these offsets 1:1 (see // guest_engine.cpp's LoadImage - no rebasing in the emulated engine either), // so `LibappBase() + 0xae00d8` is the real, live runtime address. constexpr uintptr_t kAE00D8Offset = 0xae00d8; constexpr size_t kAE00D8WindowLen = 0x30; std::string SnapshotAE00D8Window() { uintptr_t base = LibappBase(); if (!base) return std::string(); return std::string(reinterpret_cast(base + kAE00D8Offset), kAE00D8WindowLen); } void LogAE00D8WindowDiff(const std::string& before, const std::string& after) { if (before.empty() || after.empty()) { LOGI("pthread_once: dword_AE00D8 window snapshot unavailable (libapp.so base not resolved)"); return; } std::string diffs; for (size_t i = 0; i + 4 <= kAE00D8WindowLen; i += 4) { uint32_t b = 0, a = 0; memcpy(&b, before.data() + i, 4); memcpy(&a, after.data() + i, 4); if (b != a) { char buf[64]; snprintf(buf, sizeof(buf), " [AE00D8+0x%zx]:0x%x->0x%x", i, b, a); diffs += buf; } } LOGI("pthread_once: dword_AE00D8 window [0x%lx,0x%lx) diffs:%s", (unsigned long)kAE00D8Offset, (unsigned long)(kAE00D8Offset + kAE00D8WindowLen), diffs.empty() ? " (none)" : diffs.c_str()); } // Confirmed live on-device: logging EVERY single libc file call (including // the many thousands ART's own dex/oat/vdex loading does during normal app // startup, well before any game code runs) adds enough per-call overhead // to intermittently blow past ActivityManager's ~10s process-attach // timeout, getting the whole process killed before it ever reaches game // code - not a deadlock, just volume. Filtering to paths actually relevant // to this investigation (game data, not framework/system housekeeping) // keeps the signal without the ART-startup noise. A null path (rare) is // always logged, since that's unusual enough to be worth seeing regardless. bool IsInteresting(const char* path) { if (!path) return true; static const char* kKeywords[] = { "nfs13", "libapp", ".obb", ".sb", "Nimble", "fmod", "var/", "var1/", }; for (const char* kw : kKeywords) { if (strstr(path, kw)) return true; } return false; } // 2026-09-18 (ARM64_TRANSLATION_LAYER.md task #39 - missing text overlay, // A9 ground-truth comparison): logcat alone lost the exact frames this // investigation needed - the game's own per-frame draw-call volume wraps // logcat's small ring buffer in well under a second, so by the time `adb // logcat -d` was dumped, the texture-upload/draw lines from the moment // that actually mattered were already gone (confirmed live: 95,000+ lines // captured, zero glTexImage2D lines survived). trace_log.h already writes // every line to an append-only file too (flushed per-line, exactly for // this reason) - the missing piece was simply that nothing ever called // InitFileLog(). Fixed here: resolve OUR OWN library's on-disk path via // dl_iterate_phdr (same technique LibappBase() uses for the real // libapp.so) and write the trace alongside it, in the app's own private // files dir (writable - that's literally where this .so was pushed to // run at all). Deliberately reuses the SAME "wait for the first // proven-safe hook" bootstrap gate as LibappBase()/pthread_once's own // diagnostics (see their extensive comments above) - dl_iterate_phdr this // early is the same documented re-entrant-linker-lock hazard regardless // of which library it's trying to locate. std::string SelfLibraryDir() { std::string dir; dl_iterate_phdr( [](struct dl_phdr_info* info, size_t, void* data) -> int { if (info->dlpi_name && strstr(info->dlpi_name, "libtrace_agent.so")) { std::string path(info->dlpi_name); size_t slash = path.find_last_of('/'); *static_cast(data) = slash != std::string::npos ? path.substr(0, slash) : std::string("."); return 1; // stop iterating } return 0; }, &dir); return dir; } std::atomic g_fileLogInitAttempted{false}; void InitFileLogOnceSafe() { bool expected = false; if (!g_fileLogInitAttempted.compare_exchange_strong(expected, true)) return; std::string dir = SelfLibraryDir(); if (dir.empty()) { __android_log_print(ANDROID_LOG_INFO, "TRACE_AGENT", "InitFileLogOnceSafe: could not resolve own library path, " "file logging stays off this run (logcat-only)"); return; } std::string path = dir + "/trace_output.log"; trace_agent::InitFileLog(path.c_str()); } } // namespace extern "C" { // ---- libc file I/O ---- int open(const char* path, int flags, ...) { static auto real = RealSym("open"); mode_t mode = 0; if (flags & O_CREAT) { va_list ap; va_start(ap, flags); mode = (mode_t)va_arg(ap, int); va_end(ap); } int fd = real(path, flags, mode); // First proven-safe hook to fire each run (confirmed across prior // sessions) - marks bootstrap as over for pthread_once's own guard, see // its comment above for why that matters. Also the earliest safe point // to turn on file-based logging (see InitFileLogOnceSafe's own comment) // - logcat's ring buffer wraps almost immediately once real gameplay // starts, so the file is the only reliable record for anything beyond // the first fraction of a second. PastBootstrap().store(true, std::memory_order_relaxed); InitFileLogOnceSafe(); if (IsInteresting(path)) { LOGI("open(\"%s\", flags=0x%x) -> fd=%d%s", path ? path : "(null)", flags, fd, fd < 0 ? " [FAILED]" : ""); } return fd; } int openat(int dirfd, const char* path, int flags, ...) { static auto real = RealSym("openat"); mode_t mode = 0; if (flags & O_CREAT) { va_list ap; va_start(ap, flags); mode = (mode_t)va_arg(ap, int); va_end(ap); } int fd = real(dirfd, path, flags, mode); if (IsInteresting(path)) { LOGI("openat(%d, \"%s\", flags=0x%x) -> fd=%d%s", dirfd, path ? path : "(null)", flags, fd, fd < 0 ? " [FAILED]" : ""); } return fd; } FILE* fopen(const char* path, const char* mode) { static auto real = RealSym("fopen"); FILE* f = real(path, mode); if (IsInteresting(path)) { LOGI("fopen(\"%s\", \"%s\") -> %p%s", path ? path : "(null)", mode ? mode : "(null)", (void*)f, f == nullptr ? " [FAILED]" : ""); } return f; } // Deliberately NOT interposing read() - by far the single highest-frequency // libc call during ART's own startup (every byte-buffer read of every dex/ // oat/vdex file goes through it), and it doesn't carry a path - open/openat/ // fopen already show WHICH file was touched, which is what this // investigation actually needs; logging read() too would reintroduce the // exact volume problem IsInteresting() above was added to avoid, for very // little extra information. int stat(const char* path, struct stat* buf) { static auto real = RealSym("stat"); int r = real(path, buf); if (IsInteresting(path)) { LOGI("stat(\"%s\") -> %d%s", path ? path : "(null)", r, r != 0 ? " [FAILED]" : ""); } return r; } int access(const char* path, int mode) { static auto real = RealSym("access"); int r = real(path, mode); if (IsInteresting(path)) { LOGI("access(\"%s\", 0x%x) -> %d%s", path ? path : "(null)", mode, r, r != 0 ? " [FAILED]" : ""); } return r; } // opendir - added specifically to check whether the real game also polls // the (on the emulated engine, confirmed empty) external-files directory // the same way the emulated engine's own guest thread was found looping // on (ARM64_TRANSLATION_LAYER.md's file-I/O-path investigation) - "nfs13" // is already a substring of the package's own external-files path // (.../Android/data/com.ea.games.nfs13_arm/files), so IsInteresting() // already covers it once this call is actually interposed. DIR* opendir(const char* path) { static auto real = RealSym("opendir"); DIR* d = real(path); if (IsInteresting(path)) { LOGI("opendir(\"%s\") -> %p%s", path ? path : "(null)", (void*)d, d == nullptr ? " [FAILED]" : ""); } return d; } // Added 2026-09-16 (ARM64_TRANSLATION_LAYER.md - dword_AE00D8 reentrancy // investigation): ground-truth counterpart to Shim_pthread_once's own // register/memory-diff probes in the emulated engine, which found that // EVERY reentrant CallGuestFunction call it could find (including into the // real sub_87B968/sub_88CCD0 init routines this exact libc call reaches on // the emulated side) leaves both the caller's registers and this same // dword_AE00D8 memory window completely unchanged - a clean result that // doesn't explain the crash. This answers a different but related // question: does REAL, unmodified pthread_once() on REAL hardware ever // touch this memory window at all, for comparison against the emulated // engine's own (also clean) result. // // CONFIRMED LIVE this session: an earlier version of this wrapper that // unconditionally called LibappBase() (dl_iterate_phdr) on every // pthread_once() call crashed the WHOLE process with SIGSEGV inside // libtrace_agent.so itself, before Application.attachBaseContext even ran // (tombstone showed the fault inside our own .so, called from deep within // app_process32's own bootstrap). pthread_once() is called extremely early // by the dynamic linker/bionic itself (library static-initializer guards, // TLS setup) - calling dl_iterate_phdr from inside one of those calls means // re-entering the dynamic linker's own internal module-list lock while it // may already be held on the SAME thread by an ancestor frame (the linker // loading a library and running ITS constructors) - the exact "chicken and // egg" bootstrap hazard this file's mmap()-avoidance comment (below) // already documents for a different symbol. Fix: skip ALL of this // diagnostic's work (dl_iterate_phdr, string snapshots) until the FIRST // already-proven-safe hook (open(), which prior sessions confirmed fires // without incident) has fired at least once - by then bootstrap is long // over and dl_iterate_phdr is safe. Before that point, pthread_once() is // passed straight through with no diagnostics at all - purely linker/libc // bookkeeping this investigation was never after in the first place. // // SECOND, DEEPER hazard found once wrap.sh (real process-wide LD_PRELOAD, // active from exec() itself) replaced the earlier System.load()-based // partial activation: a real tombstone (llvm-addr2line confirmed) showed // THIS function's own `static auto real = RealSym<...>(...)` line crashing // via __emutls_get_address - this toolchain/ABI implements function-local // static thread-safe initialization using emulated TLS, which internally // calls pthread_once() ITSELF to lazily set up the TLS key. Since we're // LD_PRELOAD'd, that internal call gets intercepted by THIS SAME wrapper - // which then tries to initialize ITS OWN `real` static again -> infinite // recursion -> stack overflow -> SIGSEGV, on literally the first call, no // PastBootstrap() check even reached. Fixed by resolving `real` eagerly, at // LIBRARY LOAD time via a real ELF constructor (single-threaded by // construction, no lazy-static guard machinery involved at all) instead of // a lazy function-local static - sidesteps the whole class of hazard. using PthreadOnceFn = int (*)(pthread_once_t*, void (*)(void)); PthreadOnceFn g_realPthreadOnce = nullptr; __attribute__((constructor)) void ResolveRealPthreadOnceEarly() { g_realPthreadOnce = RealSym("pthread_once"); } // THIRD hazard, one level deeper still: even with `real` resolved eagerly, // the diagnostic body's own LOGI(...) call touches TraceLog()'s // thread_local InTraceLog() reentrancy guard (trace_log.h) - a SEPARATE // thread_local variable that needs its OWN one-time emutls/pthread_once // setup, the first time ANY thread ever reaches it. That nested setup // calls back into THIS SAME pthread_once wrapper, which (bootstrap flag // already true by then) takes the logging branch again, touching // InTraceLog() again, recursing without end - a live tombstone confirmed // this exact chain (pthread_once -> TraceLog -> __emutls_get_address). // Fixed with a reentrancy guard that cannot itself need emutls: a plain // namespace-scope std::atomic, constant-initialized (no runtime lazy- // static guard at all, unlike a thread_local or a guarded function-local // static) - if pthread_once is already executing on ANY thread when // re-entered, skip every bit of the diagnostic (dl_iterate_phdr AND // logging alike) and just pass straight through to the real // implementation. Slightly imprecise across threads (a global, not // per-thread, counter) but that's an acceptable tradeoff for a // diagnostic-only tool, and avoids needing anything TLS-shaped at all. std::atomic g_pthreadOnceDepth{0}; int pthread_once(pthread_once_t* onceCtl, void (*initRoutine)(void)) { PthreadOnceFn real = g_realPthreadOnce; if (!real) { // Constructor hasn't run yet (shouldn't happen once loaded, but a // defensive fallback beats calling through a null pointer) - resolve // directly via RTLD_NEXT this one time. Still safer than before: // this is a plain function call, not a lazy-static-guarded one. real = RealSym("pthread_once"); } int depthAtEntry = g_pthreadOnceDepth.fetch_add(1, std::memory_order_relaxed); struct DepthGuard { ~DepthGuard() { g_pthreadOnceDepth.fetch_sub(1, std::memory_order_relaxed); } } depthGuard; if (depthAtEntry > 0 || !PastBootstrap().load(std::memory_order_relaxed)) { return real(onceCtl, initRoutine); } std::string before = SnapshotAE00D8Window(); uintptr_t base = LibappBase(); if (base && (uintptr_t)initRoutine >= base) { LOGI("pthread_once: initRoutine=%p (libapp.so+0x%lx)", (void*)initRoutine, (unsigned long)((uintptr_t)initRoutine - base)); } else { LOGI("pthread_once: initRoutine=%p (not in libapp.so, or base unresolved)", (void*)initRoutine); } int r = real(onceCtl, initRoutine); std::string after = SnapshotAE00D8Window(); LogAE00D8WindowDiff(before, after); return r; } // Deliberately NOT interposing mmap() - confirmed live on-device that it // hangs the whole app process indefinitely (ActivityManager kills it after // a start-timeout, zero TRACE_AGENT log output the entire time - the hang // happens before our first successful call). The dynamic linker uses // mmap() itself to map every shared library into memory, including ours, // mid-load; interposing it risks exactly this kind of bootstrap // chicken-and-egg deadlock (our wrapper's own dlsym(RTLD_NEXT, "mmap") // requires the linker to already be in a stable state that a call arriving // mid-bootstrap may not have reached yet). Not needed for this // investigation's goal anyway - open/fopen/stat/access already show which // files get touched. // ---- GLESv2 (same 4 entry points this session's own gles_shim.cpp counts, // for direct comparison against the emulated engine's counters) ---- // 2026-09-19: frame-structure ground truth (see glBindFramebuffer below). // The emulated engine has no eglSwapBuffers of its own to key off - // nativeOnResume never returns, so Android's automatic post-onDrawFrame swap // never fires (gles_shim.cpp's Shim_glClear) - and it synthesizes one per // default-framebuffer colour clear instead. That is a GUESS about how the // game structures a frame. Spelling out the mask bits and the bound // framebuffer here, against the already-traced eglSwapBuffers below, gives // the real ratio and ordering the emulated engine should be reproducing. GLuint g_tracedBoundFramebuffer = 0; void glClear(GLbitfield mask) { static auto real = RealSym("glClear"); LOGI("glClear(mask=0x%x%s%s%s) fb=%u", mask, (mask & GL_COLOR_BUFFER_BIT) ? " COLOR" : "", (mask & GL_DEPTH_BUFFER_BIT) ? " DEPTH" : "", (mask & GL_STENCIL_BUFFER_BIT) ? " STENCIL" : "", g_tracedBoundFramebuffer); real(mask); } void glDrawArrays(GLenum mode, GLint first, GLsizei count) { static auto real = RealSym("glDrawArrays"); LOGI("glDrawArrays(mode=0x%x, first=%d, count=%d)", mode, first, count); real(mode, first, count); } // 2026-09-18 (ARM64_TRANSLATION_LAYER.md task #39 - missing text overlay): // ground-truth counterpart to gles_shim.cpp's own Shim_glTexImage2D/ // Shim_glDrawElements instrumentation, which found a narrow 738x302 // texture (real anti-aliased glyph data, matching the missing safety- // disclaimer/copyright text) uploads correctly on the emulated engine but // is NEVER referenced by any subsequent draw call. Question this answers: // on REAL hardware, does the equivalent texture get uploaded with similar // dimensions, and - critically - does a real draw call actually reference // it (proving the real game DOES intend to draw it, ruling out "the text // is legitimately decorative-only and never drawn as geometry"). GLuint g_currentBoundTexture2D = 0; void glBindTexture(GLenum target, GLuint texture) { static auto real = RealSym("glBindTexture"); if (target == GL_TEXTURE_2D) g_currentBoundTexture2D = texture; real(target, texture); } void glTexImage2D(GLenum target, GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const void* pixels) { static auto real = RealSym("glTexImage2D"); LOGI("glTexImage2D tex=%u target=0x%x level=%d internalformat=0x%x %dx%d format=0x%x type=0x%x", g_currentBoundTexture2D, target, level, internalformat, width, height, format, type); real(target, level, internalformat, width, height, border, format, type, pixels); } void glDrawElements(GLenum mode, GLsizei count, GLenum type, const void* indices) { static auto real = RealSym("glDrawElements"); LOGI("glDrawElements(mode=0x%x, count=%d, type=0x%x) tex0=%u", mode, count, type, g_currentBoundTexture2D); real(mode, count, type, indices); } void glUseProgram(GLuint program) { static auto real = RealSym("glUseProgram"); LOGI("glUseProgram(program=%u)", program); real(program); } // 2026-09-19: frame-structure ground truth. The emulated engine has no // eglSwapBuffers of its own to key off (nativeOnResume never returns, so // Android's automatic post-onDrawFrame swap never fires - see gles_shim.cpp's // Shim_glClear), and synthesizes one per default-framebuffer colour clear // instead. That is a GUESS about how the game structures a frame. These three // interposers measure the real thing on hardware: which framebuffer is bound // at each clear, what the clear mask actually is, and exactly where the real // swaps fall relative to those clears. The ratio and ordering are what the // emulated engine needs to reproduce - the swap CALL itself can't be copied, // since on real hardware it is Android's framework making it, not the game. void glBindFramebuffer(GLenum target, GLuint framebuffer) { static auto real = RealSym("glBindFramebuffer"); g_tracedBoundFramebuffer = framebuffer; real(target, framebuffer); } // 2026-09-18 (ARM64_TRANSLATION_LAYER.md - the "frozen splash, real // rendering never reaches the screen" chase): the emulated engine's own // logs show 0 calls to eglSwapBuffers/eglGetProcAddress across every // capture, and IDA static analysis of the exact same real armeabi-v7a // libapp.so found the IsisApp render-pipeline's own vtable+168/+116 slots // to be empty no-ops. Ground-truth check: does the REAL app, running // natively on the Galaxy A9, actually call eglSwapBuffers at all, and if // so how often relative to the GLES draw calls above? EGLBoolean eglSwapBuffers(EGLDisplay dpy, EGLSurface surface) { static auto real = RealSym("eglSwapBuffers"); EGLBoolean result = real(dpy, surface); LOGI("eglSwapBuffers(dpy=%p, surface=%p) -> %d", dpy, surface, (int)result); return result; } __eglMustCastToProperFunctionPointerType eglGetProcAddress(const char* procname) { static auto real = RealSym<__eglMustCastToProperFunctionPointerType (*)(const char*)>("eglGetProcAddress"); __eglMustCastToProperFunctionPointerType result = real(procname); LOGI("eglGetProcAddress(\"%s\") -> %p", procname ? procname : "(null)", (void*)result); return result; } // Added 2026-09-16 - ground-truth counterpart to this session's own // gles_shim.cpp investigation (ARM64_TRANSLATION_LAYER.md): our emulated // engine's shader-variant cache (sub_43FDE0/dword_ADBFB8) consistently // fails, producing an EMPTY shader source that the real host GLSL compiler // rejects with "Missing main() function". Question this trace answers: on // REAL native ARM32 execution (no CPU translation at all), does the same // shader-variant lookup ever produce an empty source too (and get silently // recovered from), or does it never happen at all? Logs the real source // text/length and real GL_COMPILE_STATUS/GL_LINK_STATUS, same as // Shim_glShaderSource/Shim_glCompileShader/Shim_glLinkProgram do in our own // gles_shim.cpp, for direct comparison. void glShaderSource(GLuint shader, GLsizei count, const GLchar* const* string, const GLint* length) { static auto real = RealSym("glShaderSource"); for (GLsizei i = 0; i < count; i++) { GLint len = length ? length[i] : (string[i] ? (GLint)strlen(string[i]) : 0); LOGI("glShaderSource(%u) fragment[%d/%d] len=%d lengths_provided=%d: \"%.300s\"", shader, i, count, len, length != nullptr, string[i] ? string[i] : "(null)"); } real(shader, count, string, length); } void glCompileShader(GLuint shader) { static auto real = RealSym("glCompileShader"); real(shader); static auto realGetShaderiv = RealSym("glGetShaderiv"); static auto realGetShaderInfoLog = RealSym("glGetShaderInfoLog"); GLint status = GL_FALSE; realGetShaderiv(shader, GL_COMPILE_STATUS, &status); LOGI("glCompileShader(%u) status=%s", shader, status == GL_TRUE ? "OK" : "FAILED"); if (status != GL_TRUE) { char log[512] = {0}; realGetShaderInfoLog(shader, sizeof(log), nullptr, log); LOGI("glCompileShader(%u) FAILED: %s", shader, log); } } void glLinkProgram(GLuint program) { static auto real = RealSym("glLinkProgram"); real(program); static auto realGetProgramiv = RealSym("glGetProgramiv"); static auto realGetProgramInfoLog = RealSym("glGetProgramInfoLog"); GLint status = GL_FALSE; realGetProgramiv(program, GL_LINK_STATUS, &status); LOGI("glLinkProgram(%u) status=%s", program, status == GL_TRUE ? "OK" : "FAILED"); if (status != GL_TRUE) { char log[512] = {0}; realGetProgramInfoLog(program, sizeof(log), nullptr, log); LOGI("glLinkProgram(%u) FAILED: %s", program, log); } } // ---- FMOD bring-up trace (2026-09-21, task #67) ---- // // The emulated engine now loads the game's REAL libfmodex/libfmodevent and // runs them, but FMOD never reaches output initialisation - Shim_dlopen logs // every call and there is not one, so `libOpenSLES.so` is never opened. The // question that cannot be answered by staring at our side: what does this // sequence look like on real ARM32 hardware, where sound works? // // The user's observation that audio starts right after the EA logo says the // chain runs early, so these four points should all appear near the start of a // native trace. Each logs its FMOD_RESULT (0 == FMOD_OK), which names the // failure directly if one of them is where the two runs diverge. // // Per-call logging is fine here, unlike __dynamic_cast below: these are // one-shot initialisation calls, not a quarter million per second. // NOTE: do NOT interpose dlopen here. The first attempt did, and it killed the // process before the game even started: the Android runtime dlopen()s // libart.so during startup, our wrapper could not resolve the real symbol that // early (RealSym goes through dlsym, which is not usable from a dlopen // interposer at that point), so it returned NULL and the runtime died on the // null handle - "Failed to dlopen libart.so", then SIGSEGV at address 0, // "wrap.sh terminated by signal 11". Exactly the hazard this file already // documents for pthread_once. // // It is also unnecessary: our own side already logs every dlopen through // Shim_dlopen. What the native run has to answer is where FMOD's INIT chain // goes, and the three interposers below do that without touching the loader. // FMOD interposition was tried here on 2026-09-21 and REMOVED. Two failures, // both worth keeping as a warning: // // 1. Interposing dlopen killed the process at startup - the runtime's own // dlopen("libart.so") got our wrapper before it could resolve the real // symbol, returned NULL, and the app died on a null handle. // 2. libfmodex/libfmodevent live in the APP's linker namespace, which an // LD_PRELOAD'd agent cannot reach - neither dlsym(RTLD_NEXT) nor // dlopen(RTLD_NOLOAD) found a single FMOD symbol. The wrapper therefore // always took its fallback path, and that fallback REPLACED FMOD's // initialisation with a stub - silencing audio on the very device that // was supposed to serve as the working reference. // // The lesson is the measurement one: an instrument that cannot do the real // work must not stand in for it. Whether the native game reaches OpenSL is // answerable from OUTSIDE the process entirely, by looking for libOpenSLES.so // in /proc//maps - no injection, no interference, no way to break what is // being measured. // ---- __dynamic_cast rate (2026-09-19, task #58) ---- // The emulated engine's own per-shim counter found __dynamic_cast making up // 49% of ALL shim crossings during a prologue load - 284,986 calls/sec. The // obvious question that number does NOT answer: is that the game's own // behaviour, or something this engine induces? libapp.so imports // __dynamic_cast as an undefined symbol, so LD_PRELOAD can count the real // thing on native ARM32 hardware and settle it. // // Reports a rate once a second rather than logging each call: at a quarter // million per second, per-call logging would dominate the measurement (and // this agent writes to a file, see InitFileLog). Deliberately mirrors the // engine's TOPSHIMS accounting so the two numbers are directly comparable. void* __dynamic_cast(const void* sub, const void* src, const void* dst, ptrdiff_t src2dst) { static auto real = RealSym( "__dynamic_cast"); static std::atomic calls{0}; static std::atomic lastNs{0}; static uint64_t prevCalls = 0; uint64_t n = calls.fetch_add(1, std::memory_order_relaxed) + 1; struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); uint64_t now = (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; uint64_t last = lastNs.load(std::memory_order_relaxed); if (now - last >= 1000000000ull && lastNs.compare_exchange_strong(last, now, std::memory_order_relaxed) && last != 0) { double dt = (now - last) / 1e9; LOGI("DYNCAST native rate=%.0f/s (total %llu)", (double)(n - prevCalls) / dt, (unsigned long long)n); prevCalls = n; } return real(sub, src, dst, src2dst); } } // extern "C"