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>
652 lines
32 KiB
C++
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"
|