Files
megboyzzandClaude 725ffbd8ed arm64: flat guest mapping, audio, crash reporting, self-extracting data
The run of work that took the translated build from "boots" to "playable".

Engine:
- Flat guest mapping replaces the software MMU on aarch64 hosts. A 4 GiB
  PROT_NONE reservation lets a 32-bit guest address zero-extend safely, so
  tcg_out_qemu_ld/st short-circuit before tcg_out_tlb_read and the prologue
  materialises the base into X28. Measured 1.24x (51 vs 41 fps, interleaved
  A/B). Note the base must be set BEFORE UC_INIT - Unicorn inits lazily, and
  setting it after uc_open silently falls back to softmmu.
- num_get<char> facet implemented, which was the real cause of the crash after
  the prologue race; a full race is now playable end to end.
- Thread-stack free list + ReleaseThreadEngine, fixing the arena leak that
  showed up as a black screen when entering a race. kMaxGuestThreads 16 -> 64.
- Real ARM32 FMOD now runs in-engine via the Java FMODAudioDevice bridge, with
  a per-thread JNIEnv. Two of the three blockers were our own single-image-era
  guards.

Host/app:
- Native crash handler: async-signal-safe, decodes the host fault back to a
  guest address, writes a report file and nothing else. CrashReportActivity
  picks it up on the NEXT launch, zips it, and offers to share. No backend, no
  automatic upload.
- Game data ships inside the APK and self-extracts on first launch, so a tester
  installs one file and plays. Copy-to-.part-then-rename, with a free-space
  check up front.
- EGL context preserved across pause, fixing black textures on resume.
- Navigation bar hidden and re-hidden on focus gain; volume keys reported as
  system keys, checked before the loading-state gate.
- x86_64 added to abiFilters: the ARM32 guest runs under tcg/i386 with no
  houdini in the path. The flat mapping is aarch64-only, so that host falls
  back to the software MMU - commented at the abiFilters line.

Ignore rules added for app/translated/ (611 MB of signed release APK, which
also carries the bundled OBB) and ostream_repro/build/.

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

652 lines
32 KiB
C++

// 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.<packageName>` 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 <dlfcn.h>
#include <fcntl.h>
#include <atomic>
#include <cstdarg>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <sys/stat.h>
#include <sys/mman.h>
#include <unistd.h>
#include <dirent.h>
#include <link.h>
#include <pthread.h>
#include <GLES2/gl2.h>
#include <EGL/egl.h>
#include "trace_log.h"
using trace_agent::TraceLog;
#define LOGI TraceLog
namespace {
template <typename Fn>
Fn RealSym(const char* name) {
void* p = dlsym(RTLD_NEXT, name);
return reinterpret_cast<Fn>(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<bool> g_pastBootstrap{false};
std::atomic<bool>& 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<uintptr_t*>(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<char*>(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<std::string*>(data) = slash != std::string::npos
? path.substr(0, slash) : std::string(".");
return 1; // stop iterating
}
return 0;
},
&dir);
return dir;
}
std::atomic<bool> 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<int (*)(const char*, int, ...)>("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<int (*)(int, const char*, int, ...)>("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<FILE* (*)(const char*, const char*)>("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<int (*)(const char*, struct stat*)>("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<int (*)(const char*, int)>("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<DIR* (*)(const char*)>("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<PthreadOnceFn>("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<int>, 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<int> 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<PthreadOnceFn>("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<void (*)(GLbitfield)>("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<void (*)(GLenum, GLint, GLsizei)>("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<void (*)(GLenum, GLuint)>("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<void (*)(GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum,
GLenum, const void*)>("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<void (*)(GLenum, GLsizei, GLenum, const void*)>("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<void (*)(GLuint)>("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<void (*)(GLenum, GLuint)>("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<EGLBoolean (*)(EGLDisplay, EGLSurface)>("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<void (*)(GLuint, GLsizei, const GLchar* const*, const GLint*)>("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<void (*)(GLuint)>("glCompileShader");
real(shader);
static auto realGetShaderiv = RealSym<void (*)(GLuint, GLenum, GLint*)>("glGetShaderiv");
static auto realGetShaderInfoLog =
RealSym<void (*)(GLuint, GLsizei, GLsizei*, GLchar*)>("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<void (*)(GLuint)>("glLinkProgram");
real(program);
static auto realGetProgramiv = RealSym<void (*)(GLuint, GLenum, GLint*)>("glGetProgramiv");
static auto realGetProgramInfoLog =
RealSym<void (*)(GLuint, GLsizei, GLsizei*, GLchar*)>("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/<pid>/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<void* (*)(const void*, const void*, const void*, ptrdiff_t)>(
"__dynamic_cast");
static std::atomic<uint64_t> calls{0};
static std::atomic<uint64_t> 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"