Stable checkpoint: game reaches playable 3D gameplay on ARM64

Verified live on a Pixel 6a: the game passes its EULA, loads the prologue
and renders real 3D gameplay, with zero heap exhaustion, zero faults and
zero rejected frees over a full session.

Root causes fixed in this state, each backed by a measurement (details and
the list of refuted theories live in ARM64_TRANSLATION_LAYER.md):

* JNI varargs float->double promotion. C promotes float to double in any
  varargs call and every Call*Method form is varargs, so reading one 4-byte
  slot yielded the double's always-zero low half. EVERY float argument
  passed to Java was silently becoming 0; text was just where it showed.

* GuestHeap ~4x memory overhead. Power-of-two size classes carving the full
  class, plus segregated free lists that could never share memory between
  sizes. Reworked to exact sizing with O(log n) best-fit reuse and splitting
  (deliberately not a linear scan - this allocator already had an O(n) perf
  cliff in its history). Peak live now 207MB against the real A9's 199MB,
  fragmentation ~2.5MB. Also fixed: realloc reading past the old block on
  shrink, a 32-bit overflow in calloc, and drifting payload alignment.

* Unbounded FMOD fake-handle leak into the never-freeing permanent arena,
  which is why enlarging that arena had not helped.

* Frame presentation, corrected against A9 ground truth: the real frame has
  three default-framebuffer colour clears and ONE present at the end; this
  engine had been presenting on each of them.

Load-time acceleration (zlib_accel.cpp): host zlib now serves inflate and
crc32, the latter measured by the block profiler as the single hottest guest
routine at 17.7%. Streams are only taken over when this layer saw their own
inflateInit2_, so unknown streams (libpng's, among others) still run the
original emulated path.

name_lookup_accel.cpp is present but its hook is NOT registered - it crashed
on bad assumptions about guest table lifetime and is kept as a starting
point, with both mistakes recorded in its comments.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-19 02:12:03 +03:00
co-authored by Claude
parent 9a5736bf3f
commit d5e6037fc7
744 changed files with 528750 additions and 272 deletions
+33
View File
@@ -0,0 +1,33 @@
# Standalone armeabi-v7a tracing agent for the native ARM32 investigation
# (see ARM64_TRANSLATION_LAYER.md's "Native ARM32 tracing harness" plan).
# Deliberately NOT wired into the main Gradle build (settings.gradle.kts) -
# this is a diagnostic-only artifact built and pushed to the device
# independently via build.sh, never bundled into any APK. Runs exclusively
# on the "native32" flavor's real, unmodified libapp.so on the Galaxy A9.
cmake_minimum_required(VERSION 3.22.1)
project(trace_agent)
add_library(trace_agent SHARED
libc_gles_trace.cpp
jni_trace.cpp
)
# Deliberately NOT linking against libGLESv2.so here, even though the GLES
# wrappers exist below - confirmed live on-device that an eager DT_NEEDED
# dependency on it crashes the WHOLE process immediately on launch (exit
# code 1, right after Zygote's early "-Xcheck:jni" specialization, well
# before any Activity/EGL/GL context exists to make libGLESv2.so resolvable
# that early). Each GLES wrapper already resolves its real implementation
# lazily via dlsym(RTLD_NEXT, ...) on first real call - by then the app's
# own GL usage has long since loaded libGLESv2.so itself, so no explicit
# link-time dependency is needed at all. <GLES2/gl2.h> is included only for
# its type definitions (GLenum/GLuint/...), not any function linkage.
find_library(log-lib log)
find_library(dl-lib dl)
target_link_libraries(trace_agent
${log-lib}
${dl-lib}
)
target_compile_options(trace_agent PRIVATE -Wall -Wno-unused-parameter)
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Builds libtrace_agent.so for armeabi-v7a via the NDK's CMake toolchain
# file - standalone, deliberately outside the Gradle build (see
# CMakeLists.txt's own comment). Output: build/libtrace_agent.so, ready to
# `adb push` to the Galaxy A9 (see the plan's Phase 4).
set -euo pipefail
cd "$(dirname "$0")"
NDK="${ANDROID_NDK_HOME:-/home/megboyzz/Android/Sdk/ndk/27.0.12077973}"
TOOLCHAIN="$NDK/build/cmake/android.toolchain.cmake"
if [ ! -f "$TOOLCHAIN" ]; then
echo "NDK toolchain file not found at $TOOLCHAIN - set ANDROID_NDK_HOME" >&2
exit 1
fi
cmake -B build -G Ninja \
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \
-DANDROID_ABI=armeabi-v7a \
-DANDROID_PLATFORM=android-27 \
-DCMAKE_BUILD_TYPE=Debug \
.
cmake --build build
echo "Built: $(pwd)/build/libtrace_agent.so"
+501
View File
@@ -0,0 +1,501 @@
// JNI tracing for the native ARM32 investigation (see
// ARM64_TRANSLATION_LAYER.md's "Native ARM32 tracing harness" plan).
//
// JNI calls go through the JNIEnv->functions function-pointer table, not
// the dynamic symbol table - LD_PRELOAD interposition (libc_gles_trace.cpp)
// can't see them. Real Android ART shares ONE JNINativeInterface table
// across every thread's JNIEnv (confirmed by design, not assumed - this is
// standard JVM/ART behavior), so this patches the table'S CONTENTS in
// place (not swapping which struct `functions` points to) - covers every
// thread automatically, including whichever one actually drives the game's
// real onDrawFrame/RunLoop-tick GL thread, not just whichever thread
// happens to call the installer below.
//
// 2026-09-06: widened from the original curated dozen to a much broader
// call-flow set (method/field access, object lifecycle, exceptions, local
// frames) - the point of a "full" trace is reconstructing execution order
// on real hardware, and the original subset was too narrow for that. Still
// deliberately skips a few genuinely high-frequency, low-information calls
// (DeleteLocalRef, array element accessors) - see their own comments below.
#include <jni.h>
#include <cstring>
#include <sys/mman.h>
#include <unistd.h>
#include "trace_log.h"
using trace_agent::LookupName;
using trace_agent::RememberName;
using trace_agent::TraceLog;
namespace {
JNINativeInterface g_orig{}; // real function pointers, saved before patching, so wrappers can call through
jclass Wrap_FindClass(JNIEnv* env, const char* name) {
jclass r = g_orig.FindClass(env, name);
TraceLog("JNI FindClass(\"%s\") -> %p%s", name ? name : "(null)", (void*)r, r == nullptr ? " [FAILED]" : "");
return r;
}
// Diagnostic for the ARM64_TRANSLATION_LAYER.md "external-storage overlay
// at VFS root" investigation - is GameActivityMain.useAssetsFileSystem()
// even called on real native ARM32, and what does it actually return?
// The Java-side implementation is this project's own (native32 shares
// GameActivityMain.kt with the translated/emulated flavor), so a
// same-vs-different result here isn't about different source code - it's
// about whether the same code produces a different real answer natively.
jmethodID g_useAssetsFileSystemId = nullptr;
jmethodID Wrap_GetMethodID(JNIEnv* env, jclass clazz, const char* name, const char* sig) {
jmethodID r = g_orig.GetMethodID(env, clazz, name, sig);
TraceLog("JNI GetMethodID(\"%s\", \"%s\") -> %p%s", name ? name : "(null)", sig ? sig : "(null)",
(void*)r, r == nullptr ? " [FAILED]" : "");
if (r) RememberName(r, name, sig);
if (name && r && strcmp(name, "useAssetsFileSystem") == 0) {
g_useAssetsFileSystemId = r;
TraceLog("JNI GetMethodID: cached useAssetsFileSystem jmethodID=%p for CallBooleanMethod tracing", (void*)r);
}
return r;
}
jboolean Wrap_CallBooleanMethod(JNIEnv* env, jobject obj, jmethodID id, ...) {
va_list ap;
va_start(ap, id);
jboolean r = g_orig.CallBooleanMethodV(env, obj, id, ap);
va_end(ap);
TraceLog("JNI CallBooleanMethod: %s(obj=%p) -> %s", LookupName(id).c_str(), (void*)obj, r ? "true" : "false");
return r;
}
jboolean Wrap_CallBooleanMethodV(JNIEnv* env, jobject obj, jmethodID id, va_list args) {
// va_list can only be consumed once - copy before forwarding so the
// real call still gets a fresh, valid list.
va_list copy;
va_copy(copy, args);
jboolean r = g_orig.CallBooleanMethodV(env, obj, id, copy);
va_end(copy);
TraceLog("JNI CallBooleanMethodV: %s(obj=%p) -> %s", LookupName(id).c_str(), (void*)obj, r ? "true" : "false");
return r;
}
jboolean Wrap_CallBooleanMethodA(JNIEnv* env, jobject obj, jmethodID id, const jvalue* args) {
jboolean r = g_orig.CallBooleanMethodA(env, obj, id, args);
TraceLog("JNI CallBooleanMethodA: %s(obj=%p) -> %s", LookupName(id).c_str(), (void*)obj, r ? "true" : "false");
return r;
}
jmethodID Wrap_GetStaticMethodID(JNIEnv* env, jclass clazz, const char* name, const char* sig) {
jmethodID r = g_orig.GetStaticMethodID(env, clazz, name, sig);
TraceLog("JNI GetStaticMethodID(\"%s\", \"%s\") -> %p%s", name ? name : "(null)", sig ? sig : "(null)",
(void*)r, r == nullptr ? " [FAILED]" : "");
if (r) RememberName(r, name, sig);
return r;
}
jfieldID Wrap_GetFieldID(JNIEnv* env, jclass clazz, const char* name, const char* sig) {
jfieldID r = g_orig.GetFieldID(env, clazz, name, sig);
TraceLog("JNI GetFieldID(\"%s\", \"%s\") -> %p%s", name ? name : "(null)", sig ? sig : "(null)",
(void*)r, r == nullptr ? " [FAILED]" : "");
if (r) RememberName(r, name, sig);
return r;
}
jfieldID Wrap_GetStaticFieldID(JNIEnv* env, jclass clazz, const char* name, const char* sig) {
jfieldID r = g_orig.GetStaticFieldID(env, clazz, name, sig);
TraceLog("JNI GetStaticFieldID(\"%s\", \"%s\") -> %p%s", name ? name : "(null)", sig ? sig : "(null)",
(void*)r, r == nullptr ? " [FAILED]" : "");
if (r) RememberName(r, name, sig);
return r;
}
jint Wrap_RegisterNatives(JNIEnv* env, jclass clazz, const JNINativeMethod* methods, jint n) {
jint r = g_orig.RegisterNatives(env, clazz, methods, n);
TraceLog("JNI RegisterNatives(count=%d) -> %d%s", n, r, r != 0 ? " [FAILED]" : "");
for (jint i = 0; i < n; i++) {
TraceLog("JNI RegisterNatives: %s %s", methods[i].name, methods[i].signature);
}
return r;
}
jstring Wrap_NewStringUTF(JNIEnv* env, const char* bytes) {
jstring r = g_orig.NewStringUTF(env, bytes);
TraceLog("JNI NewStringUTF(\"%s\") -> %p", bytes ? bytes : "(null)", (void*)r);
return r;
}
const char* Wrap_GetStringUTFChars(JNIEnv* env, jstring str, jboolean* isCopy) {
const char* r = g_orig.GetStringUTFChars(env, str, isCopy);
TraceLog("JNI GetStringUTFChars() -> \"%s\"", r ? r : "(null)");
return r;
}
jboolean Wrap_ExceptionCheck(JNIEnv* env) {
jboolean r = g_orig.ExceptionCheck(env);
if (r) TraceLog("JNI ExceptionCheck() -> TRUE");
return r;
}
void Wrap_ExceptionDescribe(JNIEnv* env) {
TraceLog("JNI ExceptionDescribe() called");
g_orig.ExceptionDescribe(env);
}
// ---- object lifecycle / class checks ----
jclass Wrap_GetObjectClass(JNIEnv* env, jobject obj) {
jclass r = g_orig.GetObjectClass(env, obj);
TraceLog("JNI GetObjectClass(obj=%p) -> %p", (void*)obj, (void*)r);
return r;
}
jboolean Wrap_IsInstanceOf(JNIEnv* env, jobject obj, jclass clazz) {
jboolean r = g_orig.IsInstanceOf(env, obj, clazz);
TraceLog("JNI IsInstanceOf(obj=%p, class=%p) -> %s", (void*)obj, (void*)clazz, r ? "true" : "false");
return r;
}
jboolean Wrap_IsSameObject(JNIEnv* env, jobject a, jobject b) {
jboolean r = g_orig.IsSameObject(env, a, b);
TraceLog("JNI IsSameObject(%p, %p) -> %s", (void*)a, (void*)b, r ? "true" : "false");
return r;
}
jobject Wrap_NewGlobalRef(JNIEnv* env, jobject obj) {
jobject r = g_orig.NewGlobalRef(env, obj);
TraceLog("JNI NewGlobalRef(%p) -> %p", (void*)obj, (void*)r);
return r;
}
void Wrap_DeleteGlobalRef(JNIEnv* env, jobject obj) {
TraceLog("JNI DeleteGlobalRef(%p)", (void*)obj);
g_orig.DeleteGlobalRef(env, obj);
}
// DeleteLocalRef is deliberately NOT wrapped - by far the highest-frequency
// JNI call in any real run (every local ref a native function creates gets
// cleaned up through it), and it carries no name/signature information of
// its own. Logging it would reintroduce the exact volume problem
// libc_gles_trace.cpp's IsInteresting() was added to avoid, for a line that
// never says which method/field it's tied to.
jint Wrap_PushLocalFrame(JNIEnv* env, jint cap) {
TraceLog("JNI PushLocalFrame(%d)", (int)cap);
return g_orig.PushLocalFrame(env, cap);
}
jobject Wrap_PopLocalFrame(JNIEnv* env, jobject result) {
TraceLog("JNI PopLocalFrame()");
return g_orig.PopLocalFrame(env, result);
}
// ---- exceptions ----
jint Wrap_Throw(JNIEnv* env, jthrowable obj) {
TraceLog("JNI Throw(%p)", (void*)obj);
return g_orig.Throw(env, obj);
}
jint Wrap_ThrowNew(JNIEnv* env, jclass clazz, const char* msg) {
TraceLog("JNI ThrowNew(class=%p, \"%s\")", (void*)clazz, msg ? msg : "(null)");
return g_orig.ThrowNew(env, clazz, msg);
}
jthrowable Wrap_ExceptionOccurred(JNIEnv* env) {
jthrowable r = g_orig.ExceptionOccurred(env);
if (r) TraceLog("JNI ExceptionOccurred() -> %p", (void*)r);
return r;
}
void Wrap_ExceptionClear(JNIEnv* env) {
TraceLog("JNI ExceptionClear()");
g_orig.ExceptionClear(env);
}
// ---- object construction / arrays ----
jobject Wrap_NewObject(JNIEnv* env, jclass clazz, jmethodID id, ...) {
va_list ap;
va_start(ap, id);
jobject r = g_orig.NewObjectV(env, clazz, id, ap);
va_end(ap);
TraceLog("JNI NewObject: %s -> %p", LookupName(id).c_str(), (void*)r);
return r;
}
jobject Wrap_NewObjectV(JNIEnv* env, jclass clazz, jmethodID id, va_list args) {
va_list copy;
va_copy(copy, args);
jobject r = g_orig.NewObjectV(env, clazz, id, copy);
va_end(copy);
TraceLog("JNI NewObjectV: %s -> %p", LookupName(id).c_str(), (void*)r);
return r;
}
jsize Wrap_GetArrayLength(JNIEnv* env, jarray arr) {
jsize r = g_orig.GetArrayLength(env, arr);
TraceLog("JNI GetArrayLength(%p) -> %d", (void*)arr, (int)r);
return r;
}
jobjectArray Wrap_NewObjectArray(JNIEnv* env, jsize len, jclass clazz, jobject init) {
jobjectArray r = g_orig.NewObjectArray(env, len, clazz, init);
TraceLog("JNI NewObjectArray(len=%d, class=%p) -> %p", (int)len, (void*)clazz, (void*)r);
return r;
}
// ---- instance Call*Method / Call*MethodV families (mechanical - same
// shape as jni_shim.cpp's own DoCall/DoCallV on the emulated side) ----
#define DEF_CALL_METHOD(RetType, Name) \
RetType Wrap_Call##Name##Method(JNIEnv* env, jobject obj, jmethodID id, ...) { \
va_list ap; \
va_start(ap, id); \
RetType r = g_orig.Call##Name##MethodV(env, obj, id, ap); \
va_end(ap); \
TraceLog("JNI Call" #Name "Method: %s(obj=%p)", LookupName(id).c_str(), (void*)obj); \
return r; \
} \
RetType Wrap_Call##Name##MethodV(JNIEnv* env, jobject obj, jmethodID id, va_list args) { \
va_list copy; \
va_copy(copy, args); \
RetType r = g_orig.Call##Name##MethodV(env, obj, id, copy); \
va_end(copy); \
TraceLog("JNI Call" #Name "MethodV: %s(obj=%p)", LookupName(id).c_str(), (void*)obj); \
return r; \
}
// void has no return value to capture, so it can't share DEF_CALL_METHOD's
// RetType-returning shape.
void Wrap_CallVoidMethod(JNIEnv* env, jobject obj, jmethodID id, ...) {
va_list ap;
va_start(ap, id);
g_orig.CallVoidMethodV(env, obj, id, ap);
va_end(ap);
TraceLog("JNI CallVoidMethod: %s(obj=%p)", LookupName(id).c_str(), (void*)obj);
}
void Wrap_CallVoidMethodV(JNIEnv* env, jobject obj, jmethodID id, va_list args) {
va_list copy;
va_copy(copy, args);
g_orig.CallVoidMethodV(env, obj, id, copy);
va_end(copy);
TraceLog("JNI CallVoidMethodV: %s(obj=%p)", LookupName(id).c_str(), (void*)obj);
}
DEF_CALL_METHOD(jobject, Object)
DEF_CALL_METHOD(jint, Int)
DEF_CALL_METHOD(jlong, Long)
DEF_CALL_METHOD(jfloat, Float)
DEF_CALL_METHOD(jdouble, Double)
#undef DEF_CALL_METHOD
// ---- static Call*Method / Call*MethodV families ----
#define DEF_CALL_STATIC_METHOD(RetType, Name) \
RetType Wrap_CallStatic##Name##Method(JNIEnv* env, jclass clazz, jmethodID id, ...) { \
va_list ap; \
va_start(ap, id); \
RetType r = g_orig.CallStatic##Name##MethodV(env, clazz, id, ap); \
va_end(ap); \
TraceLog("JNI CallStatic" #Name "Method: %s", LookupName(id).c_str()); \
return r; \
} \
RetType Wrap_CallStatic##Name##MethodV(JNIEnv* env, jclass clazz, jmethodID id, va_list args) { \
va_list copy; \
va_copy(copy, args); \
RetType r = g_orig.CallStatic##Name##MethodV(env, clazz, id, copy); \
va_end(copy); \
TraceLog("JNI CallStatic" #Name "MethodV: %s", LookupName(id).c_str()); \
return r; \
}
void Wrap_CallStaticVoidMethod(JNIEnv* env, jclass clazz, jmethodID id, ...) {
va_list ap;
va_start(ap, id);
g_orig.CallStaticVoidMethodV(env, clazz, id, ap);
va_end(ap);
TraceLog("JNI CallStaticVoidMethod: %s", LookupName(id).c_str());
}
void Wrap_CallStaticVoidMethodV(JNIEnv* env, jclass clazz, jmethodID id, va_list args) {
va_list copy;
va_copy(copy, args);
g_orig.CallStaticVoidMethodV(env, clazz, id, copy);
va_end(copy);
TraceLog("JNI CallStaticVoidMethodV: %s", LookupName(id).c_str());
}
DEF_CALL_STATIC_METHOD(jobject, Object)
DEF_CALL_STATIC_METHOD(jboolean, Boolean)
DEF_CALL_STATIC_METHOD(jint, Int)
DEF_CALL_STATIC_METHOD(jlong, Long)
DEF_CALL_STATIC_METHOD(jfloat, Float)
DEF_CALL_STATIC_METHOD(jdouble, Double)
#undef DEF_CALL_STATIC_METHOD
// ---- instance Get*Field / Set*Field families ----
#define DEF_GET_FIELD(RetType, Name) \
RetType Wrap_Get##Name##Field(JNIEnv* env, jobject obj, jfieldID id) { \
RetType r = g_orig.Get##Name##Field(env, obj, id); \
TraceLog("JNI Get" #Name "Field: %s(obj=%p)", LookupName(id).c_str(), (void*)obj); \
return r; \
}
#define DEF_SET_FIELD(ValType, Name) \
void Wrap_Set##Name##Field(JNIEnv* env, jobject obj, jfieldID id, ValType val) { \
TraceLog("JNI Set" #Name "Field: %s(obj=%p)", LookupName(id).c_str(), (void*)obj); \
g_orig.Set##Name##Field(env, obj, id, val); \
}
DEF_GET_FIELD(jobject, Object)
DEF_GET_FIELD(jboolean, Boolean)
DEF_GET_FIELD(jint, Int)
DEF_GET_FIELD(jlong, Long)
DEF_GET_FIELD(jfloat, Float)
DEF_GET_FIELD(jdouble, Double)
DEF_SET_FIELD(jobject, Object)
DEF_SET_FIELD(jboolean, Boolean)
DEF_SET_FIELD(jint, Int)
DEF_SET_FIELD(jlong, Long)
DEF_SET_FIELD(jfloat, Float)
DEF_SET_FIELD(jdouble, Double)
#undef DEF_GET_FIELD
#undef DEF_SET_FIELD
// ---- static Get*Field / Set*Field families ----
#define DEF_GET_STATIC_FIELD(RetType, Name) \
RetType Wrap_GetStatic##Name##Field(JNIEnv* env, jclass clazz, jfieldID id) { \
RetType r = g_orig.GetStatic##Name##Field(env, clazz, id); \
TraceLog("JNI GetStatic" #Name "Field: %s", LookupName(id).c_str()); \
return r; \
}
#define DEF_SET_STATIC_FIELD(ValType, Name) \
void Wrap_SetStatic##Name##Field(JNIEnv* env, jclass clazz, jfieldID id, ValType val) { \
TraceLog("JNI SetStatic" #Name "Field: %s", LookupName(id).c_str()); \
g_orig.SetStatic##Name##Field(env, clazz, id, val); \
}
DEF_GET_STATIC_FIELD(jobject, Object)
DEF_GET_STATIC_FIELD(jboolean, Boolean)
DEF_GET_STATIC_FIELD(jint, Int)
DEF_SET_STATIC_FIELD(jobject, Object)
DEF_SET_STATIC_FIELD(jboolean, Boolean)
DEF_SET_STATIC_FIELD(jint, Int)
#undef DEF_GET_STATIC_FIELD
#undef DEF_SET_STATIC_FIELD
} // namespace
// Kotlin side: nfs.mod.traceagent.TraceAgentBridge.install(outputPath) - a
// tiny new class added purely for this investigation (see the plan's Phase
// 3 file list), called from a new minimal Application.attachBaseContext, as
// early as possible, before any real game library gets a chance to grab its
// own JNIEnv reference. outputPath is the app's own private files dir (see
// TraceApplication.kt) - trace_log.h opens it for the whole process's life.
extern "C" JNIEXPORT void JNICALL
Java_nfs_mod_traceagent_TraceAgentBridge_install(JNIEnv* env, jclass, jstring outputPath) {
if (outputPath) {
const char* path = env->GetStringUTFChars(outputPath, nullptr);
trace_agent::InitFileLog(path);
env->ReleaseStringUTFChars(outputPath, path);
}
auto* table = const_cast<JNINativeInterface*>(env->functions);
g_orig = *table;
long pageSize = sysconf(_SC_PAGESIZE);
uintptr_t base = (uintptr_t)table & ~(uintptr_t)(pageSize - 1);
size_t span = ((uintptr_t)table + sizeof(*table)) - base;
if (mprotect((void*)base, span, PROT_READ | PROT_WRITE) != 0) {
TraceLog("InstallJniTraceHooks: mprotect failed - JNI tracing disabled, libc/GLES tracing (if LD_PRELOAD'd) still active");
return;
}
table->FindClass = Wrap_FindClass;
table->GetMethodID = Wrap_GetMethodID;
table->CallBooleanMethod = Wrap_CallBooleanMethod;
table->CallBooleanMethodV = Wrap_CallBooleanMethodV;
table->CallBooleanMethodA = Wrap_CallBooleanMethodA;
table->GetStaticMethodID = Wrap_GetStaticMethodID;
table->GetFieldID = Wrap_GetFieldID;
table->GetStaticFieldID = Wrap_GetStaticFieldID;
table->RegisterNatives = Wrap_RegisterNatives;
table->NewStringUTF = Wrap_NewStringUTF;
table->GetStringUTFChars = Wrap_GetStringUTFChars;
table->ExceptionCheck = Wrap_ExceptionCheck;
table->ExceptionDescribe = Wrap_ExceptionDescribe;
table->GetObjectClass = Wrap_GetObjectClass;
table->IsInstanceOf = Wrap_IsInstanceOf;
table->IsSameObject = Wrap_IsSameObject;
table->NewGlobalRef = Wrap_NewGlobalRef;
table->DeleteGlobalRef = Wrap_DeleteGlobalRef;
table->PushLocalFrame = Wrap_PushLocalFrame;
table->PopLocalFrame = Wrap_PopLocalFrame;
table->Throw = Wrap_Throw;
table->ThrowNew = Wrap_ThrowNew;
table->ExceptionOccurred = Wrap_ExceptionOccurred;
table->ExceptionClear = Wrap_ExceptionClear;
table->NewObject = Wrap_NewObject;
table->NewObjectV = Wrap_NewObjectV;
table->GetArrayLength = Wrap_GetArrayLength;
table->NewObjectArray = Wrap_NewObjectArray;
table->CallVoidMethod = Wrap_CallVoidMethod;
table->CallVoidMethodV = Wrap_CallVoidMethodV;
table->CallObjectMethod = Wrap_CallObjectMethod;
table->CallObjectMethodV = Wrap_CallObjectMethodV;
table->CallIntMethod = Wrap_CallIntMethod;
table->CallIntMethodV = Wrap_CallIntMethodV;
table->CallLongMethod = Wrap_CallLongMethod;
table->CallLongMethodV = Wrap_CallLongMethodV;
table->CallFloatMethod = Wrap_CallFloatMethod;
table->CallFloatMethodV = Wrap_CallFloatMethodV;
table->CallDoubleMethod = Wrap_CallDoubleMethod;
table->CallDoubleMethodV = Wrap_CallDoubleMethodV;
table->CallStaticVoidMethod = Wrap_CallStaticVoidMethod;
table->CallStaticVoidMethodV = Wrap_CallStaticVoidMethodV;
table->CallStaticObjectMethod = Wrap_CallStaticObjectMethod;
table->CallStaticObjectMethodV = Wrap_CallStaticObjectMethodV;
table->CallStaticBooleanMethod = Wrap_CallStaticBooleanMethod;
table->CallStaticBooleanMethodV = Wrap_CallStaticBooleanMethodV;
table->CallStaticIntMethod = Wrap_CallStaticIntMethod;
table->CallStaticIntMethodV = Wrap_CallStaticIntMethodV;
table->CallStaticLongMethod = Wrap_CallStaticLongMethod;
table->CallStaticLongMethodV = Wrap_CallStaticLongMethodV;
table->CallStaticFloatMethod = Wrap_CallStaticFloatMethod;
table->CallStaticFloatMethodV = Wrap_CallStaticFloatMethodV;
table->CallStaticDoubleMethod = Wrap_CallStaticDoubleMethod;
table->CallStaticDoubleMethodV = Wrap_CallStaticDoubleMethodV;
table->GetObjectField = Wrap_GetObjectField;
table->GetBooleanField = Wrap_GetBooleanField;
table->GetIntField = Wrap_GetIntField;
table->GetLongField = Wrap_GetLongField;
table->GetFloatField = Wrap_GetFloatField;
table->GetDoubleField = Wrap_GetDoubleField;
table->SetObjectField = Wrap_SetObjectField;
table->SetBooleanField = Wrap_SetBooleanField;
table->SetIntField = Wrap_SetIntField;
table->SetLongField = Wrap_SetLongField;
table->SetFloatField = Wrap_SetFloatField;
table->SetDoubleField = Wrap_SetDoubleField;
table->GetStaticObjectField = Wrap_GetStaticObjectField;
table->GetStaticBooleanField = Wrap_GetStaticBooleanField;
table->GetStaticIntField = Wrap_GetStaticIntField;
table->SetStaticObjectField = Wrap_SetStaticObjectField;
table->SetStaticBooleanField = Wrap_SetStaticBooleanField;
table->SetStaticIntField = Wrap_SetStaticIntField;
mprotect((void*)base, span, PROT_READ); // restore - real ART keeps this read-only
TraceLog("InstallJniTraceHooks: installed (full call/field/exception/lifecycle set, file+logcat)");
}
+567
View File
@@ -0,0 +1,567 @@
// 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 <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);
}
}
} // extern "C"
+148
View File
@@ -0,0 +1,148 @@
// Shared logging for trace_agent (see ARM64_TRANSLATION_LAYER.md's "Native
// ARM32 tracing harness" section). Writes every trace line to BOTH logcat
// (for live `adb logcat` monitoring) and an append-only file under the
// app's own private files dir - logcat alone drops lines on long runs
// (ring buffer wraps, and per-process rate limiting kicks in well before a
// "full" trace's real call volume), so the file is the actual source of
// truth for offline analysis.
//
// Also holds a jmethodID/jfieldID -> "Name sig" cache, populated by the
// Get*MethodID/Get*FieldID wrappers in jni_trace.cpp, so the high-volume
// Call*Method/Get*Field/Set*Field wrappers can log a real, readable name
// instead of a bare pointer - the whole point of a "full" trace is being
// able to read it after the fact without cross-referencing every ID by hand.
#pragma once
#include <android/log.h>
#include <cstdio>
#include <cstdarg>
#include <cstring>
#include <mutex>
#include <string>
#include <unordered_map>
#include <time.h>
#include <unistd.h>
namespace trace_agent {
inline FILE*& LogFile() {
static FILE* f = nullptr;
return f;
}
inline std::mutex& LogMutex() {
static std::mutex m;
return m;
}
inline void InitFileLog(const char* path) {
// fopen() called OUTSIDE the lock, deliberately: bionic's fopen()
// internally calls the exported open() symbol, which - since this
// library is LD_PRELOAD'd - gets re-intercepted by our own open()
// wrapper in libc_gles_trace.cpp, which itself calls TraceLog(). If
// LogMutex() were still held at that point, this second, same-thread
// lock attempt on a non-reentrant std::mutex would deadlock. The
// reentrancy guard in TraceLog() below is a second, independent layer
// against the same class of self-interposition recursion.
FILE* f = fopen(path, "a");
std::lock_guard<std::mutex> lock(LogMutex());
if (LogFile()) fclose(LogFile());
LogFile() = f;
if (LogFile()) {
fprintf(LogFile(), "---- trace_agent file log opened: %s ----\n", path);
fflush(LogFile());
}
__android_log_print(ANDROID_LOG_INFO, "TRACE_AGENT",
"InitFileLog: %s -> %s", path, LogFile() ? "ok" : "FAILED (fopen)");
}
inline long long NowMs() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
inline bool& InTraceLog() {
static thread_local bool v = false;
return v;
}
// 2026-09-18 (ARM64_TRANSLATION_LAYER.md task #39, A9 ground-truth
// comparison): this used to ALSO call __android_log_print() on every line,
// same as the file write. Confirmed live that's actively harmful for a
// "full" trace, not just redundant - the game's own per-frame draw-call
// volume wraps logcat's small ring buffer in well under a second (a
// 95,000+ line `adb logcat -d` dump had already lost every
// glTexImage2D/glDrawElements line from the exact moment under
// investigation) and Android additionally rate-limits/drops a single
// process's log output past a threshold, silently. Neither of those
// problems touch a plain flushed file write. File-only now - the file is
// the one and only source of truth for anything beyond the first instant;
// see InitFileLogOnceSafe (libc_gles_trace.cpp) for where the output path
// comes from. `adb logcat` is still useful for confirming the agent loaded
// at all (see InitFileLog's own one-time __android_log_print), just not
// for the high-volume trace itself anymore.
inline void TraceLog(const char* fmt, ...) {
// Reentrancy guard: a handful of libc calls this agent itself makes
// (fopen -> open, etc.) get re-intercepted by our own LD_PRELOAD
// wrappers (self-interposition), which would otherwise call back into
// TraceLog from inside TraceLog on the same thread. Drop the nested
// call rather than log it (or, worse, deadlock on LogMutex() below) -
// matches the project's general "never intercept indiscriminately"
// principle: this agent's own bookkeeping calls aren't part of the
// game's execution trace anyway.
if (InTraceLog()) return;
InTraceLog() = true;
char buf[1024];
va_list ap;
va_start(ap, fmt);
vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
{
std::lock_guard<std::mutex> lock(LogMutex());
if (LogFile()) {
// Flushed every line, not buffered - these investigations
// routinely end in a crash or an ActivityManager kill, and a
// buffered-but-unflushed tail is exactly the data most worth
// having at that point.
fprintf(LogFile(), "[%lld] [tid=%d] %s\n", NowMs(), gettid(), buf);
fflush(LogFile());
}
}
InTraceLog() = false;
}
inline std::unordered_map<const void*, std::string>& NameCache() {
static std::unordered_map<const void*, std::string> m;
return m;
}
inline std::mutex& NameCacheMutex() {
static std::mutex m;
return m;
}
inline void RememberName(const void* id, const char* name, const char* sig) {
if (!id) return;
std::string full = name ? name : "?";
if (sig) {
full += " ";
full += sig;
}
std::lock_guard<std::mutex> lock(NameCacheMutex());
NameCache()[id] = std::move(full);
}
inline std::string LookupName(const void* id) {
if (!id) return "(null)";
{
std::lock_guard<std::mutex> lock(NameCacheMutex());
auto it = NameCache().find(id);
if (it != NameCache().end()) return it->second;
}
char buf[32];
snprintf(buf, sizeof(buf), "%p", id);
return buf;
}
} // namespace trace_agent