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>
This commit is contained in:
2026-09-22 23:17:47 +03:00
co-authored by Claude
parent 80768652ea
commit 725ffbd8ed
44 changed files with 3096 additions and 86 deletions
+25
View File
@@ -22,5 +22,30 @@ local.properties
*.til
# trace_agent is built by its own build.sh, outside Gradle
trace_agent/build/
# ostream_repro likewise - CMake/ninja output, the sources next to it are kept
ostream_repro/build/
# ad-hoc device capture logs
device_run_*.log
# Where `gradlew assembleTranslatedRelease` output was collected by hand for
# signing. 611 MB of signed APK - a build product, and one that carries the
# bundled OBB with it, so it must never reach history.
app/translated/
# Release signing material never belongs in the repo - see app/build.gradle.kts
*.jks
*.keystore
keystore.properties
*-release.properties
# Bundled game data (~595 MB). A build INPUT, not source: it is the game's own
# OBB, copied into assets so the APK self-extracts it on first launch (see
# GameDataInstaller.kt). Keeping it out of history on purpose - a 595 MB blob
# is rejected outright by most hosts, and once committed it can only be removed
# by rewriting history.
#
# To build: copy the OBB here yourself, under exactly this name.
# cp main.<versionCode>.<package>.obb app/src/main/assets/game_data.obb
# Without it the app still builds and runs; it just expects the OBB to already
# be on the device at getObbDir(), the way it was before this was bundled.
app/src/main/assets/game_data.obb
+16 -7
View File
@@ -2,15 +2,24 @@
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="savemanager">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-28T12:35:54.649730307Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=2B101JEGR07875" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="main">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="unitTest">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="androidTest">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
+168 -5
View File
@@ -1,3 +1,8 @@
// Inside the android { } block the name `java` resolves to Gradle's own
// java extension and shadows the package, so these types have to be
// imported here rather than fully qualified at the use site.
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -5,22 +10,78 @@ plugins {
}
android {
namespace = "com.ea.games.nfs13_mod"
// ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md): "_arm"
// instead of "_mod" so this build can be installed side by side with
// the real armeabi-v7a mod, not overwrite it.
namespace = "com.ea.games.nfs13_arm"
compileSdk = 35
defaultConfig {
applicationId = "com.ea.games.nfs13_mod"
applicationId = "com.ea.games.nfs13_arm"
minSdk = 27
targetSdk = 35
versionCode = 1003128
versionName = "1.3.128"
ndk.abiFilters.add("armeabi-v7a")
//ndk.abiFilters.add("x86")
// mpcore is now plain native ARM64 code driving an embedded ARM32
// CPU-emulation core - no armeabi-v7a native code is built/shipped
// for this app at all.
ndk.abiFilters.add("arm64-v8a")
// x86_64 (2026-09-22): lets the translated build install in WayDroid,
// whose Android image is x86_64. Unicorn picks its tcg/i386 backend
// there automatically, so the ARM32 guest is translated by US rather
// than by houdini - which is what made earlier WayDroid attempts with
// the native ARM build unreliable.
//
// NOTE: the flat guest mapping (task #61, worth 1.24x) is implemented
// only in tcg/aarch64, so an x86_64 host silently falls back to the
// software MMU and will be slower. Fine for a test target; port the
// same change into tcg/i386 if it ever becomes a shipping target.
ndk.abiFilters.add("x86_64")
// Default: false. Overridden to true by the "native32" flavor - see
// its own comment below.
buildConfigField("boolean", "NATIVE32", "false")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
ndkVersion = "21.0.6113669"
ndkVersion = "27.0.12077973"
// Release signing. The keystore and its password live OUTSIDE this repo
// (~/keystores/), deliberately: this is a git working tree, and a signing
// identity that can be committed eventually is committed. Gradle reads
// them from a properties file if it is there, and falls back to the debug
// key if it is not - so a checkout without the key still builds, it just
// produces something that cannot be shipped.
//
// To create the key (once, and then back it up - losing it means never
// being able to update an installed build):
//
// keytool -genkeypair -v -keystore ~/keystores/nfsmw-release.jks \
// -alias nfsmw -keyalg RSA -keysize 4096 -validity 10950
//
// then write ~/keystores/nfsmw-release.properties with:
// storeFile=/home/<you>/keystores/nfsmw-release.jks
// storePassword=...
// keyAlias=nfsmw
// keyPassword=...
signingConfigs {
val releaseProps = File(System.getProperty("user.home"), "keystores/nfsmw-release.properties")
if (releaseProps.isFile) {
val props = Properties().apply { releaseProps.inputStream().use { load(it) } }
create("release") {
storeFile = file(props.getProperty("storeFile"))
storePassword = props.getProperty("storePassword")
keyAlias = props.getProperty("keyAlias")
keyPassword = props.getProperty("keyPassword")
// Both modern signature schemes: v2 covers the whole APK and
// is what Android 7+ verifies, v3 allows rotating the key
// later without invalidating existing installs.
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes {
release {
@@ -29,6 +90,28 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// Local-testing-only (2026-09-05, "why is this ~100x slower than
// native" investigation - see ARM64_TRANSLATION_LAYER.md): no
// real release signing config exists in this prototype, and
// there's no intent to ship this build anywhere - reusing the
// auto-generated debug keystore just makes `assembleTranslatedRelease`
// installable on a test device the same way translatedDebug
// already is. The actual point of testing this build type is the
// NATIVE side (mpcore's CMakeLists.txt now forces
// CMAKE_BUILD_TYPE=RelWithDebInfo unconditionally, so this
// isn't even required for that fix to take effect - it's here so
// this variant can be installed at all for a genuine debuggable=false
// comparison, e.g. CheckJNI's behavior).
// Real key when it exists, debug key otherwise. Announced at
// configuration time rather than discovered later from a
// mysteriously unshippable APK.
signingConfig = signingConfigs.findByName("release") ?: run {
logger.warn(
"RELEASE SIGNING: ~/keystores/nfsmw-release.properties not found - " +
"signing with the DEBUG key. This APK must NOT be distributed."
)
signingConfigs.getByName("debug")
}
}
}
compileOptions {
@@ -38,8 +121,88 @@ android {
kotlinOptions {
jvmTarget = "11"
}
androidResources {
// The bundled game data (assets/game_data.obb, ~595 MB) is an already
// compressed archive - deflating it again would cost a long build and
// a slower install for no size gain, and it must stay STORED so it can
// be streamed straight out of the APK.
noCompress += "obb"
}
buildFeatures {
compose = true
buildConfig = true
}
// "native32" flavor - diagnostic-only reference build for the native
// ARM32 tracing investigation (see ARM64_TRANSLATION_LAYER.md /
// sorted-popping-plum.md's "Native ARM32 tracing harness" plan). Real
// armeabi-v7a support and a BuildConfig.NATIVE32 flag GameActivityMain
// uses to load the real, unmodified native libraries directly
// (System.loadLibrary) instead of going through mpcore's emulation path
// - only meaningful on genuinely 32-bit-capable hardware (the Galaxy
// A9), never intended to ship. MUST be armeabi-v7a-ONLY (not just
// "armeabi-v7a added alongside arm64-v8a") - confirmed live on-device
// that shipping both ABIs makes Android launch the process via the
// 64-bit app_process64/Zygote (arm64-v8a is present, so it's preferred),
// which can never dlopen/LD_PRELOAD a 32-bit .so at all (a process's
// bitness is fixed for its whole lifetime, not per-library) - defeats
// the entire point of this flavor. ndk.abiFilters.clear() first,
// since AGP's per-flavor abiFilters otherwise ADD to defaultConfig's
// set rather than replace it.
flavorDimensions += "abi"
productFlavors {
create("translated") {
dimension = "abi"
// Default/existing behavior - the emulated arm64-v8a path,
// unchanged from before this flavor split existed.
}
create("native32") {
dimension = "abi"
ndk.abiFilters.clear()
ndk.abiFilters.add("armeabi-v7a")
buildConfigField("boolean", "NATIVE32", "true")
}
}
// 2026-09-16 (ARM64_TRANSLATION_LAYER.md - trace_agent wrap.sh deployment):
// manually injecting a bundled wrap.sh into the packaged native32 APK
// (AGP's own native-lib merge/strip pipeline only recognizes *.so files
// and silently drops anything else) failed to install at all with
// "Failed to extract native libraries, res=-2" under the default
// extractNativeLibs=false packaging - PackageManager validates every
// entry under lib/<abi>/ as a loadable library when it plans to mmap
// straight from the APK, and a plain shell script fails that check.
// Legacy (extracted-to-disk) packaging doesn't do that same strict
// validation. Only applied to native32 - the translated flavor doesn't
// need or want this (slightly larger install, marginally slower first
// native-lib load).
productFlavors.getByName("native32") {
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}
}
// 2026-09-06 (native32-on-real-hardware launch investigation - see
// ARM64_TRANSLATION_LAYER.md): ndk.abiFilters above only restricts THIS
// module's own native code - the "native32" flavor still needs `mpcore` on
// the compile classpath (GameActivityMain.kt's shared, non-flavor-specific
// source calls mpcore functions in its non-NATIVE32 branch), and mpcore's
// own CMakeLists always builds arm64-v8a regardless of which app flavor
// pulls it in. That arm64-v8a .so was silently merged into the native32
// APK too, so a real device with both an arm64-v8a AND an armeabi-v7a lib
// present chose to launch the process as 64-bit (app_process64) - which can
// never load this flavor's 32-bit fmodex/fmodevent/Nimble/app libraries at
// all, confirmed live via `UnsatisfiedLinkError: couldn't find "libfmodex.so"`
// (nativeLibraryDirectories only listed arm64/arm64-v8a paths). Explicitly
// drop every arm64-v8a .so from the native32 variant's packaging so the
// APK is genuinely armeabi-v7a-only, restoring 32-bit process selection.
androidComponents {
onVariants(selector().withFlavor("abi" to "native32")) { variant ->
variant.packaging.jniLibs.excludes.add("lib/arm64-v8a/*.so")
}
}
@@ -1,4 +1,4 @@
package com.ea.games.nfs13_mod
package com.ea.games.nfs13_arm
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.ea.games.nfs13_mod", appContext.packageName)
assertEquals("com.ea.games.nfs13_arm", appContext.packageName)
}
}
+29
View File
@@ -26,6 +26,7 @@
<uses-configuration android:reqTouchScreen="finger"/>
<application
android:name="nfs.mod.traceagent.TraceApplication"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:label="@string/app_name"
android:icon="@mipmap/adaptive_icon"
@@ -48,6 +49,34 @@
android:value="bar"/>
</activity>
<!-- One-time unpack of the game data bundled in the APK. -->
<activity
android:name="com.ea.ironmonkey.GameDataUnpackActivity"
android:exported="false"
android:excludeFromRecents="true"
android:label="Подготовка данных" />
<!-- Shown on the launch after a crash, never during one - a signal
handler cannot start an Activity (see crash_handler.cpp). -->
<activity
android:name="com.ea.ironmonkey.CrashReportActivity"
android:exported="false"
android:excludeFromRecents="true"
android:label="Crash report" />
<!-- Lets the tester share the zip. Required: targetSdk 35 rejects
file:// URIs in ACTION_SEND. Grants access to the crash directory
only, and only for the duration of the send. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.crashreports"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/crash_report_paths" />
</provider>
<activity
android:exported="true"
android:name="com.ea.ironmonkey.PermissionsActivity"
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,237 @@
package com.ea.ironmonkey
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Shown on the launch AFTER a crash, never during one.
*
* The native handler (crash_handler.cpp) can only write a file - a signal
* handler runs on an already-broken process where JNI and Activities are not
* legal. So it drops `crash_pending.txt` and dies; this screen is what the
* tester sees next time they open the game.
*
* Everything it produces stays on the device unless the tester presses send.
* There is no backend and no automatic upload - see BETA_TELEMETRY_PLAN.md.
*
* Built with Compose/Material3 and its own dark colour scheme rather than the
* game's `Theme.AppCompat.NoActionBar`: this screen must render correctly no
* matter what the rest of the app's theming does, and it is the one screen a
* tester sees when everything else has already gone wrong.
*/
class CrashReportActivity : ComponentActivity() {
companion object {
private const val TAG = "CrashReport"
private const val PENDING_NAME = "crash_pending.txt"
/** Reports live here: Android/data/<pkg>/files/crashes - reachable over USB, no permission needed. */
fun crashDir(activity: android.app.Activity): File =
File(activity.getExternalFilesDir(null), "crashes")
/** The file the native handler writes. Null if there is nothing waiting. */
fun pendingReport(activity: android.app.Activity): File? =
File(crashDir(activity), PENDING_NAME).takeIf { it.isFile && it.length() > 0 }
}
private var bundle: File? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Android 15 draws every app edge to edge whether it asks or not, so
// the choice is between handling insets or having text sit under the
// status bar. Declaring it explicitly and letting Scaffold apply the
// padding is the supported way; the first version did neither and the
// content ran under the system bars.
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val pending = pendingReport(this)
if (pending == null) {
// Nothing to report - never block the tester on an empty screen.
finish()
return
}
// Rename out of the way FIRST, before anything that could fail. The
// native handler always writes the same fixed name (it cannot safely
// format a timestamp inside a signal handler), so leaving it in place
// would let the next crash overwrite a report not yet sent.
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val kept = File(crashDir(this), "crash-$stamp.txt")
if (!pending.renameTo(kept)) {
Log.w(TAG, "could not rename $pending - reporting it in place")
}
val report = if (kept.isFile) kept else pending
val details = buildString {
append(deviceSummary())
append("\n\n")
append(runCatching { report.readText() }.getOrElse { "(could not read the report: $it)" })
}
bundle = runCatching { zipReport(report, details, stamp) }
.onFailure { Log.w(TAG, "could not build the zip", it) }
.getOrNull()
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
CrashReportScreen(
details = details,
bundlePath = bundle?.absolutePath,
canSend = bundle != null,
onSend = ::share,
onContinue = ::startGame,
)
}
}
}
private fun deviceSummary(): String = buildString {
append("device: ${Build.MANUFACTURER} ${Build.MODEL}\n")
append("android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})\n")
append("soc: ${Build.HARDWARE}\n")
append("abis: ${Build.SUPPORTED_ABIS.joinToString(", ")}\n")
append("app: ${appVersion()}")
}
private fun appVersion(): String = runCatching {
val p = packageManager.getPackageInfo(packageName, 0)
"${p.versionName} (${p.longVersionCode})"
}.getOrElse { "(unknown)" }
private fun zipReport(report: File, details: String, stamp: String): File {
val out = File(crashDir(this), "crash-$stamp.zip")
ZipOutputStream(out.outputStream().buffered()).use { zip ->
zip.putNextEntry(ZipEntry("crash.txt"))
zip.write(details.toByteArray())
zip.closeEntry()
if (report.isFile) {
zip.putNextEntry(ZipEntry(report.name))
report.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
}
}
return out
}
private fun share() {
val file = bundle ?: return
val uri = runCatching {
FileProvider.getUriForFile(this, "$packageName.crashreports", file)
}.getOrElse {
Log.w(TAG, "FileProvider failed for $file", it)
return
}
val send = Intent(Intent.ACTION_SEND).apply {
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "NFSMW arm64 - отчёт о сбое")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(send, "Отправить отчёт"))
}
private fun startGame() {
startActivity(Intent(this, GameActivityMain::class.java))
finish()
}
}
@Composable
private fun CrashReportScreen(
details: String,
bundlePath: String?,
canSend: Boolean,
onSend: () -> Unit,
onContinue: () -> Unit,
) {
// Scaffold's innerPadding carries the system-bar insets, so nothing ends up
// under the status bar or the gesture handle.
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp)
) {
Text(
text = "Игра аварийно завершилась",
style = MaterialTheme.typography.headlineSmall,
)
Text(
// Say plainly what is in the file before offering to send it.
// The tester is the one sending it; they should know what it
// contains.
text = "Отчёт сохранён на устройстве. В нём модель телефона, версия Android, " +
"версия сборки и технические данные о сбое. Личных данных и игрового " +
"аккаунта в нём нет.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
if (bundlePath != null) {
Text(
text = bundlePath,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
Card(modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.weight(1f)
) {
Text(
text = details,
fontSize = 11.sp,
lineHeight = 15.sp,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
OutlinedButton(onClick = onContinue) { Text("Продолжить") }
Button(onClick = onSend, enabled = canSend) { Text("Отправить отчёт") }
}
}
}
}
@@ -40,6 +40,7 @@ import com.ea.nimble.ApplicationLifecycle
import com.ea.nimble.Global
import nfs.mod.mpcore.GameInput
import nfs.mod.mpcore.MultiplayerCore.loadCore
import nfs.mod.mpcore.MultiplayerCore.loadEmulatedLibapp
import nfs.mod.mpcore.SyntheticInputDispatcher
import org.fmod.FMODAudioDevice
import java.io.File
@@ -50,6 +51,9 @@ import java.util.concurrent.TimeUnit
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
import androidx.core.net.toUri
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.ea.ironmonkey.domain.FSNode
import org.apache.http.BuildConfig
import kotlin.system.exitProcess
@@ -76,9 +80,24 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
private var isSleepModeEnabled = true
private var mRotation = 0
// nativeRestoreContext() is a real native call that can block for a
// long time (on the current emulated engine, indefinitely - see
// ARM64_TRANSLATION_LAYER.md's render-stall root cause: it transitively
// reaches NimbleWrapper::InitNimble(), which gets stuck polling an
// empty directory). Real hardware keeps this kind of init work off the
// GLThread entirely (confirmed via a native trace comparison - the
// real device's own version-check activity and its GL calls run on two
// separate OS threads). Dispatched to its own background thread here so
// onDrawFrame - which MUST keep running every frame to keep rendering -
// never blocks on it, instead of calling it synchronously every frame
// like the original code did.
@Volatile private var restoreContextThreadStarted = false
@Volatile private var restoreContextDone = false
@Volatile private var restoreContextResult = false
// cont.44 DEBUG: fires MultiplayerCore.triggerCarSelectTest() on
// demand via `adb shell am broadcast -a nfs.mod.mpcore.TEST_OPEN_CARSELECT
// -p com.ea.games.nfs13_mod` - stands in for a future real lobby-overlay
// -p com.ea.games.nfs13_arm` - stands in for a future real lobby-overlay
// "select car" button, letting the on-demand car_select-opening call be
// exercised at an arbitrary moment (not just automatically at boot) for
// live testing. See PROGRESS.md cont.44.
@@ -90,7 +109,7 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
// cont.48 DEBUG: fires the EXPERIMENTAL true-direct-jump variant via
// `adb shell am broadcast -a nfs.mod.mpcore.TEST_TRUE_DIRECT_CARSELECT
// -p com.ea.games.nfs13_mod`. See PROGRESS.md cont.48.
// -p com.ea.games.nfs13_arm`. See PROGRESS.md cont.48.
private val trueDirectCarSelectTestReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
nfs.mod.mpcore.MultiplayerCore.triggerTrueDirectCarSelectJump()
@@ -100,7 +119,19 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
private fun updateRequestedOrientation(i: Int) {
}
fun IsSystemKey(i: Int) = false
// Keys the SYSTEM must handle, not the game.
//
// This used to be a flat `= false`, which combined with the callers'
// `return !IsSystemKey(keyCode)` meant the activity claimed EVERY key it
// ever saw - including volume. That is why changing the volume needed the
// notification shade: the keys were being swallowed before Android could
// act on them.
fun IsSystemKey(i: Int) = when (i) {
KeyEvent.KEYCODE_VOLUME_UP,
KeyEvent.KEYCODE_VOLUME_DOWN,
KeyEvent.KEYCODE_VOLUME_MUTE -> true
else -> false
}
companion object {
private const val DOWNLOAD_PROPERTIES = "downloadcontent/config.properties"
@@ -156,11 +187,36 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
}
fun useAssetsFileSystem(): Boolean {
return mAssetLocationType != AssetLocationType.EXTERNAL
val result = mAssetLocationType != AssetLocationType.EXTERNAL
d(TAG, "useAssetsFileSystem() called, mAssetLocationType=$mAssetLocationType, result=$result, thread=${Thread.currentThread().name}")
return result
}
external fun nativeOnCreate()
/**
* Hooks the fatal signals so a native crash leaves a report behind. Must be
* called AFTER loadCore(); see its call site.
*/
private fun installNativeCrashHandler() {
try {
val dir = java.io.File(getExternalFilesDir(null), "crashes")
val info = packageManager.getPackageInfo(packageName, 0)
nativeInstallCrashHandler(
dir.absolutePath,
"${info.versionName} (${info.longVersionCode}) ${Build.MANUFACTURER} ${Build.MODEL} " +
"Android ${Build.VERSION.RELEASE}"
)
} catch (e: Throwable) {
// Diagnostics must never stop the game from starting - but say so,
// otherwise "no crash reports" looks like "no crashes".
i(TAG, "installNativeCrashHandler FAILED - native crashes will go unreported: $e")
}
}
/** See crash_handler.cpp. Writes to `dir`; `buildStamp` goes at the top of every report. */
external fun nativeInstallCrashHandler(dir: String, buildStamp: String)
external fun nativeOnDestroy()
external fun nativeOnMusicPlayerStateChanged()
@@ -199,6 +255,13 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
i(TAG, "onCreate")
super.onCreate(bundle)
// Volume keys should move the MUSIC stream - the one the game actually
// plays through - rather than whatever Android would pick by default.
volumeControlStream = AudioManager.STREAM_MUSIC
applyImmersiveMode()
handler = Handler()
@@ -244,14 +307,18 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
i(TAG, "onCreate() isMusicActive = $isAnyMusicPlaying")
mFMODAudioDevice = FMODAudioDevice()
d(TAG, "obb.size check: mAssetLocationType before = $mAssetLocationType")
if (mAssetLocationType != AssetLocationType.ASSETS) {
try {
val open2 = getResources().assets.open("obb.size")
mAssetLocationType = AssetLocationType.OBB
open2.close()
d(TAG, "obb.size check: opened OK, mAssetLocationType now OBB")
} catch (e2: IOException) {
Log.e(TAG, e2.message!!)
Log.e(TAG, "obb.size check FAILED: " + e2.message)
}
} else {
d(TAG, "obb.size check: skipped, already ASSETS")
}
val sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
val defaultSensor = sensorManager.getDefaultSensor(1)
@@ -281,12 +348,39 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
mFrameLayout.addView(gameGLSurfaceView)
mFrameLayout.addView(buildCarSelectionOverlay())
setContentView(mFrameLayout)
System.loadLibrary("fmodex")
System.loadLibrary("fmodevent")
System.loadLibrary("c++_shared")
System.loadLibrary(Global.NIMBLE_ID)
System.loadLibrary("app")
loadCore()
// ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md):
// fmodex/fmodevent/Nimble/app are all armeabi-v7a-only builds (no
// arm64-v8a .so shipped for any of them, confirmed this session -
// see jniLibs/) - System.loadLibrary() for them would throw
// UnsatisfiedLinkError immediately on an arm64-v8a-only APK. libapp.so
// is loaded through mpcore's embedded ARM32 emulation core instead
// (see loadCore() below); FMOD/Nimble audio and the real game
// render/boot loop are NOT bridged yet - this build proves the
// emulation-core load+hook mechanism, not a running game.
//
// BuildConfig.NATIVE32 (the "native32" Gradle flavor - see
// app/build.gradle.kts and the native-arm32-trace-harness plan in
// ARM64_TRANSLATION_LAYER.md) restores the ORIGINAL, real loading
// path instead: on genuinely 32-bit-capable hardware (the Galaxy
// A9), load the real, unmodified armeabi-v7a libraries directly, no
// emulation involved - the ground-truth reference build for that
// investigation. Never true for the normal (translated) flavor.
if (com.ea.games.nfs13_arm.BuildConfig.NATIVE32) {
System.loadLibrary("fmodex")
System.loadLibrary("fmodevent")
System.loadLibrary("c++_shared")
System.loadLibrary(Global.NIMBLE_ID)
System.loadLibrary("app")
} else {
loadCore()
// Immediately after loadCore(), and not a line earlier: this is
// the first moment libmpcore.so is loaded, so it is the first
// moment the native symbol exists. Installing it up in onCreate's
// opening lines threw UnsatisfiedLinkError, silently leaving every
// crash unreported.
installNativeCrashHandler()
loadEmulatedLibappFromAssets()
}
// RECEIVER_EXPORTED: needs to be reachable from an external `adb
// shell am broadcast` sender (there's no in-app sender for this
// debug-only trigger), and ContextCompat handles the API 33+
@@ -307,6 +401,42 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
nativeOnCreate()
}
// ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md): copies
// the bundled armeabi-v7a libapp.so (assets/native_probe/libapp.so - a
// raw asset, not jniLibs, since this app declares only arm64-v8a) to a
// real file the first time, then hands mpcore's embedded ARM32
// emulation core the path. Idempotent - skips the copy if the file
// already exists with a plausible size.
private fun loadEmulatedLibappFromAssets() {
val outFile = File(filesDir, "libapp_armeabi_v7a.so")
try {
if (!outFile.exists() || outFile.length() < 1_000_000L) {
assets.open("native_probe/libapp.so").use { input ->
outFile.outputStream().use { output -> input.copyTo(output) }
}
d(TAG, "loadEmulatedLibappFromAssets: extracted asset to " + outFile.absolutePath)
}
// The game's own ARM32 FMOD (task #67). Shipped as raw assets for the
// same reason libapp.so is: this APK declares only arm64-v8a, so
// jniLibs/armeabi-v7a is never packaged. The engine loads them as
// secondary guest images so libapp's FMOD imports reach real code
// instead of fmod_shims.cpp's no-ops.
for (lib in arrayOf("libfmodex.so", "libfmodevent.so")) {
val f = File(filesDir, lib)
if (!f.exists() || f.length() < 1_000L) {
assets.open("native_probe/" + lib).use { input ->
f.outputStream().use { output -> input.copyTo(output) }
}
d(TAG, "loadEmulatedLibappFromAssets: extracted " + f.absolutePath)
}
}
val ok = loadEmulatedLibapp(outFile.absolutePath)
d(TAG, "loadEmulatedLibappFromAssets: loadEmulatedLibapp -> " + ok)
} catch (e: IOException) {
d(TAG, "loadEmulatedLibappFromAssets: failed to extract/load asset: " + e)
}
}
fun forEach(input: String?): Array<String?> {
return arrayOf<String?>(input)
}
@@ -503,6 +633,10 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
super.onKeyDown(keyCode, keyEvent)
// Checked BEFORE the state gate on purpose: the gate returns true for
// every key whenever the game is not in STATE_GAME_START, which would
// otherwise keep swallowing volume through the whole load.
if (IsSystemKey(keyCode)) return false
if (state != 8) {
return true
}
@@ -518,6 +652,7 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
override fun onKeyUp(i: Int, keyEvent: KeyEvent): Boolean {
super.onKeyUp(i, keyEvent)
if (IsSystemKey(i)) return false
if (state != 8) {
return true
}
@@ -545,7 +680,28 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
}
}
/**
* Hides the status and navigation bars for the duration of the game.
*
* Nothing did this before, which is why the navigation bar sat on top of
* the game. TRANSIENT_BARS_BY_SWIPE keeps them reachable - a swipe from the
* edge brings them back temporarily - so this hides the bars without taking
* the system away from the player.
*
* Must be re-applied on every focus gain: Android restores the bars after a
* dialog, the shade, or a task switch, and a one-shot call in onCreate
* silently stops working the first time any of those happens.
*/
private fun applyImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
override fun onWindowFocusChanged(z: Boolean) {
if (z) applyImmersiveMode()
super.onWindowFocusChanged(z)
i(TAG, "onWindowsFocusChanged($z) state=$state")
getGameGLSurfaceView().renderMode = if (z) 1 else 0
@@ -714,9 +870,9 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
override fun onDrawFrame(gl10: GL10?) {
var inputStream: InputStream?
var shouldCleanupSplash = false
Log.d("state::game", "onDrawFrame state=$state")
// Логирование изменения состояния
if (state != laststate) {
Log.d("state::game", "onDrawFrame state=$state")
Log.d(TAG, "onDrawFrame state=$state")
laststate = state
}
@@ -750,12 +906,24 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
}
STATE_GAME_START -> {
if (splashTimer < System.currentTimeMillis() && nativeRestoreContext()) {
isAssetsReady = true
nativeOnStart()
nativeOnResume()
gameRenderer.setDrawFrameListener(null)
shouldCleanupSplash = true
if (splashTimer < System.currentTimeMillis()) {
if (!restoreContextThreadStarted) {
restoreContextThreadStarted = true
d(TAG, "nativeRestoreContext: starting background thread from tid=" + Thread.currentThread().id)
Thread {
d(TAG, "nativeRestoreContext: background thread running, tid=" + Thread.currentThread().id)
restoreContextResult = nativeRestoreContext()
restoreContextDone = true
d(TAG, "nativeRestoreContext: background thread finished, result=$restoreContextResult")
}.apply { isDaemon = true }.start()
}
if (restoreContextDone && restoreContextResult) {
isAssetsReady = true
nativeOnStart()
nativeOnResume()
gameRenderer.setDrawFrameListener(null)
shouldCleanupSplash = true
}
}
}
}
@@ -0,0 +1,129 @@
package com.ea.ironmonkey
import android.content.Context
import android.util.Log
import java.io.File
/**
* Unpacks the game data bundled inside the APK to the place the game expects.
*
* The ~595 MB archive ships as `assets/game_data.obb`, stored uncompressed
* (see `noCompress` in build.gradle.kts - it is already a compressed archive,
* so deflating it again would only cost build and install time). On first
* launch it is copied to `getObbDir()/main.<versionCode>.<package>.obb`, which
* is exactly the path `GameActivityMain.obbFullPath` builds, so nothing else in
* the game needs to know this happened.
*
* The point is that a tester installs one APK and plays - no separate download,
* no file manager, no instructions about where to put an .obb.
*/
object GameDataInstaller {
private const val TAG = "GameDataInstaller"
private const val ASSET_NAME = "game_data.obb"
/** Progress callback: (bytes copied, total bytes). Called from a worker thread. */
fun interface Progress {
fun onProgress(copied: Long, total: Long)
}
sealed interface Result {
/** Already unpacked, or just unpacked successfully. */
object Ready : Result
/** Could not unpack - the message is safe to show a tester. */
data class Failed(val message: String) : Result
}
fun targetFile(context: Context): File {
val versionCode = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
}.getOrDefault(0)
return File(context.obbDir, ObbHelper.getObbFileName(context, versionCode))
}
/** Size of the bundled asset, or -1 if it is not in this build. */
fun bundledSize(context: Context): Long = runCatching {
context.assets.openFd(ASSET_NAME).use { it.length }
}.getOrElse {
// openFd only works for an UNCOMPRESSED asset. If this ever starts
// failing, the noCompress rule has been lost and the asset is being
// deflated - worth knowing, because the copy below would still work
// but every install would be needlessly slower.
Log.w(TAG, "openFd($ASSET_NAME) failed - is noCompress still set? $it")
runCatching { context.assets.open(ASSET_NAME).use { s -> s.available().toLong() } }
.getOrDefault(-1L)
}
/** True when the data is already in place at its full expected size. */
fun isInstalled(context: Context): Boolean {
val expected = bundledSize(context)
if (expected <= 0) return false
val target = targetFile(context)
return target.isFile && target.length() == expected
}
/**
* Copies the bundled data into place. Blocking - call from a worker thread.
*
* Writes to a temporary file and renames only on success, so an interrupted
* copy can never leave a half-written archive that looks complete. A partial
* file that passed a mere "does it exist" check would send the game off to
* read truncated data, which fails far away from the real cause.
*/
fun install(context: Context, progress: Progress?): Result {
val expected = bundledSize(context)
if (expected <= 0) {
return Result.Failed("В этой сборке нет игровых данных (assets/$ASSET_NAME).")
}
val target = targetFile(context)
if (target.isFile && target.length() == expected) return Result.Ready
val dir = target.parentFile
if (dir != null && !dir.isDirectory && !dir.mkdirs()) {
return Result.Failed("Не удалось создать каталог ${dir.absolutePath}")
}
// Check free space before starting rather than failing 500 MB in.
val free = dir?.freeSpace ?: 0L
if (free in 1 until expected) {
return Result.Failed(
"Недостаточно места: нужно ${expected / 1_048_576} МБ, свободно ${free / 1_048_576} МБ."
)
}
val tmp = File(target.parentFile, target.name + ".part")
tmp.delete()
return try {
var copied = 0L
context.assets.open(ASSET_NAME).use { input ->
tmp.outputStream().buffered(1 shl 20).use { output ->
val buf = ByteArray(1 shl 20)
while (true) {
val n = input.read(buf)
if (n <= 0) break
output.write(buf, 0, n)
copied += n
progress?.onProgress(copied, expected)
}
output.flush()
}
}
if (copied != expected) {
tmp.delete()
return Result.Failed("Распаковка оборвалась: $copied из $expected байт.")
}
target.delete()
if (!tmp.renameTo(target)) {
tmp.delete()
return Result.Failed("Не удалось переименовать во ${target.absolutePath}")
}
Log.i(TAG, "unpacked $expected bytes to ${target.absolutePath}")
Result.Ready
} catch (e: Throwable) {
tmp.delete()
Log.w(TAG, "unpack failed", e)
Result.Failed("Ошибка распаковки: ${e.message}")
}
}
}
@@ -0,0 +1,148 @@
package com.ea.ironmonkey
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlin.concurrent.thread
/**
* First-launch unpacking of the game data bundled in the APK.
*
* Shown only when [GameDataInstaller.isInstalled] is false, so it appears once
* after install and never again. It exists because copying ~595 MB takes long
* enough that a tester staring at a frozen launcher would reasonably assume the
* game had hung.
*/
class GameDataUnpackActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
if (GameDataInstaller.isInstalled(this)) {
startGame()
return
}
var fraction by mutableFloatStateOf(0f)
var copiedMb by mutableStateOf(0L)
var totalMb by mutableStateOf(0L)
var error by mutableStateOf<String?>(null)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
UnpackScreen(
fraction = fraction,
copiedMb = copiedMb,
totalMb = totalMb,
error = error,
onRetry = { recreate() },
)
}
}
thread(name = "game-data-unpack", isDaemon = true) {
val result = GameDataInstaller.install(this) { copied, total ->
// Updating Compose state is safe from any thread; the recomposition
// is scheduled onto the main thread by the snapshot system.
fraction = if (total > 0) copied.toFloat() / total else 0f
copiedMb = copied / 1_048_576
totalMb = total / 1_048_576
}
runOnUiThread {
when (result) {
is GameDataInstaller.Result.Ready -> startGame()
is GameDataInstaller.Result.Failed -> error = result.message
}
}
}
}
private fun startGame() {
startActivity(Intent(this, GameActivityMain::class.java))
finish()
}
}
@Composable
private fun UnpackScreen(
fraction: Float,
copiedMb: Long,
totalMb: Long,
error: String?,
onRetry: () -> Unit,
) {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (error != null) {
Text(
text = "Не удалось подготовить игровые данные",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp),
)
Button(onClick = onRetry, modifier = Modifier.padding(top = 24.dp)) {
Text("Повторить")
}
} else {
Text(
text = "Подготовка игровых данных",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
text = "Выполняется один раз после установки.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
LinearProgressIndicator(
progress = { fraction },
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
)
Text(
text = if (totalMb > 0) "$copiedMb из $totalMb МБ" else "",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 12.dp),
)
}
}
}
}
@@ -11,6 +11,10 @@ import javax.microedition.khronos.egl.EGLDisplay;
public class GameGLSurfaceView extends GLSurfaceView {
private static final String TAG = "GameGLSurfaceView";
// See createContext/destroyContext below (task #62).
static int eglContextCreateCount = 0;
static int eglContextDestroyCount = 0;
private boolean enableHistoricalEvents;
private boolean kMotionEvent_GetSource;
private GameActivityMain mActivity;
@@ -35,15 +39,29 @@ public class GameGLSurfaceView extends GLSurfaceView {
setGLESVersion2();
setFocusable(true);
setFocusableInTouchMode(true);
if (Build.VERSION.SDK_INT >= 11) {
try {
Log.i(TAG, "setPreserveEGLContextOnPause");
getClass().getMethod("setPreserveEGLContextOnPause", Boolean.TYPE).invoke(this, false);
Log.e(TAG, "setPreserveEGLContextOnPause(false) success");
} catch (Exception unused2) {
Log.e(TAG, "setPreserveEGLContextOnPause failed");
}
}
// Task #62 - the black-textures-after-resume bug. The shipped game asked
// for the context NOT to be preserved (this call passed `false`) and took
// responsibility for rebuilding its GL objects afterwards. Under this
// engine that rebuild does not happen, which was confirmed live rather
// than assumed:
//
// EGL context CREATED (#1) launch
// EGL context DESTROYED (#1) on pause <- every texture gone here
// EGL context CREATED (#2) on resume
//
// and across that boundary the guest's own `Renderer::RestoreContext` ran
// to completion in ONE millisecond and re-uploaded a single texture, while
// 86 uploads had built the scene before it. Geometry survived only because
// vertex data is re-sent per frame; textures are uploaded once, so they
// came back black.
//
// Asking the platform to keep the context is the fix that does not depend
// on the guest restoring anything. It is a HINT, not a guarantee - the
// system may still drop the context under memory pressure - which is why
// the create/destroy logging added alongside it stays in permanently. A
// second CREATED line in a report means this fell back and the guest-side
// restore path is the next thing to fix.
setPreserveEGLContextOnPause(true);
}
public void setEnableHistoricalEvents(boolean z) {
@@ -56,11 +74,23 @@ public class GameGLSurfaceView extends GLSurfaceView {
@Override // android.opengl.GLSurfaceView.EGLContextFactory
public EGLContext createContext(EGL10 egl10, EGLDisplay eGLDisplay, EGLConfig eGLConfig) {
// Counted and logged (task #62). Whether the EGL context actually
// dies across a pause decides which bug we have: a SECOND create
// means every GL object the guest owns was destroyed and something
// must re-upload them, while a single create for the whole session
// means the black textures come from somewhere else entirely.
// Nothing logged this before, so the question was unanswerable.
eglContextCreateCount++;
android.util.Log.i("mpcore_log", "GameGLSurfaceView: EGL context CREATED (#"
+ eglContextCreateCount + ") - every GL object must be (re)uploaded after this");
return egl10.eglCreateContext(eGLDisplay, eGLConfig, EGL10.EGL_NO_CONTEXT, new int[]{EGL_CONTEXT_CLIENT_VERSION, 2, 12344});
}
@Override // android.opengl.GLSurfaceView.EGLContextFactory
public void destroyContext(EGL10 egl10, EGLDisplay eGLDisplay, EGLContext eGLContext) {
eglContextDestroyCount++;
android.util.Log.i("mpcore_log", "GameGLSurfaceView: EGL context DESTROYED (#"
+ eglContextDestroyCount + ") - all textures, buffers and shaders are now gone");
egl10.eglDestroyContext(eGLDisplay, eGLContext);
}
});
@@ -175,7 +175,26 @@ class PermissionsActivity : AppCompatActivity() {
private fun initActivity() {
try {
startActivity(Intent(this, GameActivityMain::class.java))
// A crash report left by the previous run takes precedence over
// starting the game. The check lives HERE, not in GameActivityMain:
// this activity is the visible, foreground launcher entry, so the
// start is allowed. Doing it from GameActivityMain.onCreate - which
// starts the report and immediately finishes itself - was refused
// by the platform ("Activity start ONLY allowed by
// BAL_ALLOW_GRACE_PERIOD"), and the tester just landed back on the
// home screen with no report shown.
val next = when {
// A crash report from the previous run comes first - it is the
// thing the tester needs to see, and the next crash would
// overwrite it.
CrashReportActivity.pendingReport(this) != null -> CrashReportActivity::class.java
// Then the one-time unpack of the game data bundled in the APK.
// Checked on every launch but only does work once, so there is
// no cost after the first run.
!GameDataInstaller.isInstalled(this) -> GameDataUnpackActivity::class.java
else -> GameActivityMain::class.java
}
startActivity(Intent(this, next))
finish()
} catch (e: Exception) {
e.printStackTrace()
@@ -0,0 +1,12 @@
package nfs.mod.traceagent
// Bridge to the standalone libtrace_agent.so (see trace_agent/ at the repo
// root and ARM64_TRANSLATION_LAYER.md's "Native ARM32 tracing harness"
// plan) - diagnostic-only, for the "native32" flavor running on the Galaxy
// A9. Not present in the APK's own jniLibs; loaded from wherever it was
// pushed on-device (see TraceApplication).
object TraceAgentBridge {
// outputPath: absolute path (app's own private files dir) the native
// side appends a full call trace to - see trace_agent/trace_log.h.
external fun install(outputPath: String)
}
@@ -0,0 +1,45 @@
package nfs.mod.traceagent
import android.app.Application
import android.content.Context
import android.util.Log
// Earliest available hook point for installing the JNI trace table patch
// (see TraceAgentBridge/trace_agent's own comments) - as early as possible,
// before any real game library gets a chance to grab its own JNIEnv
// reference. Entirely inert on the normal ("translated") flavor: gated
// behind BuildConfig.NATIVE32, and the .so path is checked for existence
// before attempting System.load, so a normal run that never pushed
// libtrace_agent.so to the device just logs and continues - no crash, no
// behavior change, matching the plan's "does not touch the emulated path"
// constraint.
class TraceApplication : Application() {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
if (!com.ea.games.nfs13_arm.BuildConfig.NATIVE32) return
// Must live under the app's own private data dir (app_data_file
// SELinux context), not /data/local/tmp (shell_data_file) - confirmed
// live on the Galaxy A9 that Enforcing SELinux denies mmap-exec of a
// shell_data_file-labeled .so from an app-domain process ("couldn't
// map ... segment 1: Permission denied"). The same path is what
// wrap.<packageName>'s LD_PRELOAD value must point at too (see the
// plan's Phase 4 deployment notes).
val soPath = base.filesDir.absolutePath + "/libtrace_agent.so"
if (!java.io.File(soPath).exists()) {
Log.d(TAG, "libtrace_agent.so not found at $soPath - JNI tracing not installed")
return
}
try {
System.load(soPath)
val outputPath = base.filesDir.absolutePath + "/trace_output.log"
TraceAgentBridge.install(outputPath)
Log.d(TAG, "trace agent JNI hooks installed, writing to $outputPath")
} catch (e: Throwable) {
Log.d(TAG, "failed to install trace agent JNI hooks: $e")
}
}
companion object {
private const val TAG = "TraceApplication"
}
}
+16
View File
@@ -0,0 +1,16 @@
#!/system/bin/sh
# APK-bundled wrap.sh (2026-09-16, ARM64_TRANSLATION_LAYER.md - trace_agent
# LD_PRELOAD deployment investigation). The loose-file mechanism
# (/data/local/tmp/wrap.<packageName>) was confirmed NOT invoked by zygote
# on this specific Galaxy A9/Samsung build - a canary marker file placed by
# that script never got created across multiple relaunches, root and
# non-root, SELinux Enforcing and an attempted Permissive switch. This is
# the OTHER officially documented NDK mechanism (developer.android.com/ndk/
# guides/wrap-script): a wrap.sh bundled alongside the app's own native
# libraries, picked up automatically for a debuggable app. Points LD_PRELOAD
# at the app's own private files dir (not this APK-bundled location) so the
# actual libtrace_agent.so can still be updated by just `run-as`-copying a
# freshly-built one in, without needing to reinstall the whole APK each
# iteration.
export LD_PRELOAD=/data/data/com.ea.games.nfs13_arm/files/libtrace_agent.so
exec "$@"
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shares ONLY the crash directory, nothing else. FileProvider is required
because targetSdk 35 rejects file:// URIs in an ACTION_SEND intent. -->
<paths>
<external-files-path name="crashes" path="crashes/" />
</paths>
@@ -1,4 +1,4 @@
package com.ea.games.nfs13_mod
package com.ea.games.nfs13_arm
import org.junit.Test
+5 -1
View File
@@ -11,7 +11,11 @@ android {
minSdk = 27
ndk {
abiFilters += listOf("armeabi-v7a")
// ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md):
// mpcore itself is now plain native ARM64 code that drives an
// embedded ARM32 CPU-emulation core (Unicorn) instead of being
// ARM32 code injected into a natively-loaded libapp.so.
abiFilters += listOf("arm64-v8a", "x86_64")
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Repeatable on-device smoke test for the ARM32-in-ARM64 prototype (see
# /ARM64_TRANSLATION_LAYER.md). Replaces the manual adb-shell archaeology
# this project's live debugging sessions have needed up to now: install,
# launch, watch logcat for a bounded window, then report the same signals
# those sessions kept hand-checking - onCreate timing, MEM FAULT count (see
# guest_engine.cpp's mem_fault_hook_cb), and process-death cause (ANR / OOM /
# MIUI SwipeUpClean / other) - as one command, so every future interface-
# layer change gets checked against the same baseline instead of a fresh
# investigation each time.
#
# Usage: mpcore/scripts/test_on_device.sh [device-serial] [watch-seconds]
# device-serial defaults to the first device `adb devices` lists.
# watch-seconds defaults to 180 (most observed onCreate runs finish in
# 100-120s; this leaves headroom to also see the post-onCreate lifecycle
# calls that previously kept faulting).
set -uo pipefail
# Script lives at mpcore/scripts/ - project root (where app/ and mpcore/ are
# siblings) is two levels up.
cd "$(dirname "$0")/../.."
PKG="com.ea.games.nfs13_arm"
ACTIVITY="com.ea.ironmonkey.GameActivityMain"
# "translated" (the emulated arm64-v8a path) - see app/build.gradle.kts's
# product flavors, added for the native ARM32 tracing investigation
# (ARM64_TRANSLATION_LAYER.md). The pre-flavor-split path
# (app/build/outputs/apk/debug/app-debug.apk) is now a STALE, never-updated
# file left over from before that split - silently reinstalling it here
# instead of the real current build wasted real debugging time confirming
# this fix, so this path is now the single source of truth.
APK="app/build/outputs/apk/translated/debug/app-translated-debug.apk"
SERIAL="${1:-$(adb devices | awk 'NR==2{print $1}')}"
WATCH_SECONDS="${2:-180}"
if [[ -z "$SERIAL" ]]; then
echo "No adb device found. Connect the device and retry." >&2
exit 1
fi
ADB="adb -s $SERIAL"
if [[ ! -f "$APK" ]]; then
echo "APK not found at $APK - build it first (./gradlew :app:assembleDebug)." >&2
exit 1
fi
echo "== Installing $APK on $SERIAL =="
$ADB install -r "$APK" || { echo "install failed" >&2; exit 1; }
echo "== Launching =="
$ADB shell am force-stop "$PKG"
$ADB logcat -c
$ADB shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null
sleep 2
PID=$($ADB shell pidof "$PKG" | tr -d '\r')
if [[ -z "$PID" ]]; then
echo "Process did not start." >&2
exit 1
fi
echo "pid=$PID"
echo "== Watching logcat for ${WATCH_SECONDS}s =="
LOG="$(mktemp)"
timeout "${WATCH_SECONDS}s" $ADB logcat --pid="$PID" > "$LOG" 2>/dev/null || true
echo ""
echo "===================== RESULTS ====================="
ONCREATE_LINE=$(grep "GameActivityMain onCreate took" "$LOG" | tail -1)
if [[ -n "$ONCREATE_LINE" ]]; then
echo "onCreate: $(echo "$ONCREATE_LINE" | grep -oE '[0-9]+ms')"
else
echo "onCreate: did not complete within ${WATCH_SECONDS}s"
fi
FAULT_COUNT=$(grep -c "MEM FAULT" "$LOG" || true)
echo "MEM FAULT lines: $FAULT_COUNT"
if [[ "$FAULT_COUNT" -gt 0 ]]; then
echo " first: $(grep "MEM FAULT" "$LOG" | head -1)"
echo " last: $(grep "MEM FAULT" "$LOG" | tail -1)"
fi
CRASHED_LINE=$(grep -c "refusing to run - engine already crashed" "$LOG" || true)
echo "engine crashed (fail-fast tripped): $([[ "$CRASHED_LINE" -gt 0 ]] && echo yes || echo no)"
STILL_ALIVE=$($ADB shell "pidof $PKG" | tr -d '\r')
if [[ -n "$STILL_ALIVE" ]]; then
echo "process status: alive (pid=$STILL_ALIVE) at end of watch window"
else
echo "process status: DEAD"
DEATH_LINE=$($ADB logcat -d 2>/dev/null | grep -E "Killing $PID:|$PID.*died->background" | tail -3)
if [[ -n "$DEATH_LINE" ]]; then
echo " cause:"
echo "$DEATH_LINE" | sed 's/^/ /'
else
echo " cause: unknown (no ActivityManager kill/death line found - check for a FATAL crash instead)"
$ADB logcat -d --pid="$PID" 2>/dev/null | grep -iE "FATAL|AndroidRuntime" | sed 's/^/ /'
fi
fi
echo "====================================================="
echo "Full log: $LOG"
+1
View File
@@ -34,6 +34,7 @@ add_subdirectory(third_party/unicorn)
add_library(${CMAKE_PROJECT_NAME} SHARED
main.cpp
crash_handler.cpp
game_lifecycle_stubs.cpp
game_lifecycle_stubs_extra.cpp
game_lifecycle_stubs_extra2.cpp
+283
View File
@@ -0,0 +1,283 @@
#include "crash_handler.h"
#include "util/util.h"
#include <cerrno>
#include <csignal>
#include <cstring>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/ucontext.h>
#include <unistd.h>
// ---- Everything below the handler boundary must be async-signal-safe ----
//
// The rule is narrow and unforgiving: inside the handler, only functions
// POSIX lists as async-signal-safe may be called. write(), open(), close(),
// _exit(), sigaction(), raise() are; snprintf(), malloc(), any C++ container,
// anything that takes a lock, and every JNI call are NOT.
//
// This project already paid for ignoring that once - see
// ARM64_TRANSLATION_LAYER.md's note that mmap() inside a SIGSEGV handler
// deadlocked on bionic. So: every string is built HERE, at install time, and
// the handler only appends bytes to a pre-opened path and formats integers
// with the hand-written helpers below.
namespace {
constexpr size_t kPathMax = 512;
constexpr size_t kStampMax = 256;
// Pre-built at install time. `g_installed` also guards against a second
// install and against the handler running before setup finished.
char g_reportPath[kPathMax];
char g_buildStamp[kStampMax];
bool g_installed = false;
// Guest memory window, for decoding a host fault address back to the guest
// address that produced it. Plain integers written once from the engine
// thread and only read afterwards.
uint64_t g_guestFlatBase = 0;
uint32_t g_guestRegionSize = 0;
// The alternate stack. A stack-overflow SIGSEGV cannot be handled on the
// stack that just overflowed, so the handler gets its own - allocated here,
// at install time, never inside the handler.
constexpr size_t kAltStackSize = 64 * 1024;
char g_altStack[kAltStackSize];
// Previous dispositions, so the default handler still runs afterwards and
// Android still produces its own tombstone. Our report is extra evidence, not
// a replacement for the system's.
struct sigaction g_prevActions[NSIG];
// ---- async-signal-safe output helpers ----
void SafeWrite(int fd, const char* s, size_t len) {
while (len > 0) {
ssize_t n = write(fd, s, len);
if (n <= 0) {
if (n < 0 && errno == EINTR) continue;
return; // nothing useful left to do inside a handler
}
s += n;
len -= (size_t)n;
}
}
void SafeStr(int fd, const char* s) {
if (!s) return;
size_t len = 0;
while (s[len]) len++;
SafeWrite(fd, s, len);
}
void SafeHex(int fd, uint64_t v) {
static const char kDigits[] = "0123456789abcdef";
char buf[19];
buf[0] = '0';
buf[1] = 'x';
int pos = 18;
buf[pos] = '\0';
if (v == 0) {
buf[--pos] = '0';
} else {
while (v && pos > 2) {
buf[--pos] = kDigits[v & 0xF];
v >>= 4;
}
}
SafeWrite(fd, "0x", 2);
SafeStr(fd, buf + pos);
}
void SafeDec(int fd, long v) {
char buf[24];
int pos = 23;
buf[pos] = '\0';
bool neg = v < 0;
unsigned long u = neg ? (unsigned long)(-v) : (unsigned long)v;
if (u == 0) buf[--pos] = '0';
while (u && pos > 0) {
buf[--pos] = (char)('0' + (u % 10));
u /= 10;
}
if (neg && pos > 0) buf[--pos] = '-';
SafeStr(fd, buf + pos);
}
const char* SignalName(int sig) {
switch (sig) {
case SIGSEGV: return "SIGSEGV (bad memory access)";
case SIGBUS: return "SIGBUS (misaligned or unmapped access)";
case SIGABRT: return "SIGABRT (abort - often a failed assertion or a JNI error)";
case SIGILL: return "SIGILL (illegal instruction)";
case SIGFPE: return "SIGFPE (arithmetic fault)";
default: return "unknown signal";
}
}
void WriteRegisters(int fd, void* ucontextRaw) {
if (!ucontextRaw) return;
auto* uc = static_cast<ucontext_t*>(ucontextRaw);
#if defined(__aarch64__)
const mcontext_t& mc = uc->uc_mcontext;
SafeStr(fd, "registers (host arm64):\n");
for (int i = 0; i < 31; i++) {
SafeStr(fd, " x");
SafeDec(fd, i);
SafeStr(fd, i < 10 ? " = " : " = ");
SafeHex(fd, mc.regs[i]);
SafeStr(fd, (i % 2) ? "\n" : " ");
}
SafeStr(fd, "\n sp = ");
SafeHex(fd, mc.sp);
SafeStr(fd, " pc = ");
SafeHex(fd, mc.pc);
SafeStr(fd, "\n");
// x28 is where the flat guest mapping keeps its base (task #61). Printing
// it makes the guest-address arithmetic below checkable by hand from the
// report alone.
SafeStr(fd, " x28 (guest base register) = ");
SafeHex(fd, mc.regs[28]);
SafeStr(fd, "\n");
#else
(void)uc;
SafeStr(fd, "registers: not captured on this architecture\n");
#endif
}
void CrashSignalHandler(int sig, siginfo_t* info, void* ucontextRaw) {
if (g_installed) {
// O_TRUNC, not O_APPEND: one pending report at a time. If a second
// crash happens before the first is collected, the newer one is the
// one worth having - it is the one the tester just saw.
int fd = open(g_reportPath, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) {
SafeStr(fd, "=== NFSMW arm64 engine - native crash report ===\n\n");
SafeStr(fd, "build: ");
SafeStr(fd, g_buildStamp);
SafeStr(fd, "\n\nsignal: ");
SafeStr(fd, SignalName(sig));
SafeStr(fd, " (");
SafeDec(fd, sig);
SafeStr(fd, ")\n");
const uint64_t faultAddr = info ? (uint64_t)(uintptr_t)info->si_addr : 0;
SafeStr(fd, "fault address (host): ");
SafeHex(fd, faultAddr);
SafeStr(fd, "\n");
// The number that actually helps. A host fault address inside the
// guest window is really a guest pointer; reported raw it is
// meaningless, and nobody reading a tester's report will subtract
// the base by hand.
if (g_guestFlatBase && faultAddr >= g_guestFlatBase &&
faultAddr < g_guestFlatBase + 0x100000000ull) {
uint64_t guestAddr = faultAddr - g_guestFlatBase;
SafeStr(fd, "fault address (GUEST): ");
SafeHex(fd, guestAddr);
if (g_guestRegionSize && guestAddr >= g_guestRegionSize) {
SafeStr(fd, " <- BEYOND the mapped guest region (");
SafeHex(fd, g_guestRegionSize);
SafeStr(fd, ") - a wild pointer, not a real guest object");
}
SafeStr(fd, "\n");
} else if (g_guestFlatBase) {
SafeStr(fd, "fault address is OUTSIDE the guest window - this is a fault in the "
"engine's own native code, not in emulated guest code\n");
}
SafeStr(fd, "thread id: ");
SafeDec(fd, (long)gettid());
SafeStr(fd, "\n\n");
WriteRegisters(fd, ucontextRaw);
SafeStr(fd,
"\nnote: Android's own tombstone for this crash has more detail "
"(/data/tombstones). This file exists because a tester cannot reach that.\n");
close(fd);
}
}
// Chain to whatever was installed before us so the platform still writes
// its tombstone and the process dies the way it would have. Restoring the
// previous action and re-raising is the portable way to do that.
if (sig >= 0 && sig < NSIG) {
sigaction(sig, &g_prevActions[sig], nullptr);
}
raise(sig);
}
} // namespace
void SetCrashHandlerGuestBase(uint64_t flatBase, uint32_t regionSize) {
g_guestFlatBase = flatBase;
g_guestRegionSize = regionSize;
}
void InstallCrashHandler(const char* crashDir, const char* buildStamp) {
if (g_installed) return;
if (!crashDir || !*crashDir) {
Log("crash_handler: no crash directory given - native crashes will NOT be reported");
return;
}
// mkdir here, at install time. Doing it lazily from the handler would mean
// a filesystem call on a broken process, which is exactly what this file
// exists to avoid.
if (mkdir(crashDir, 0755) != 0 && errno != EEXIST) {
Log("crash_handler: could not create %s (%s) - native crashes will NOT be reported",
crashDir, strerror(errno));
return;
}
// Fixed filename. The handler cannot safely format a timestamp, so Java
// renames this to something unique when it collects it on the next launch.
static const char kFileName[] = "/crash_pending.txt";
size_t dirLen = strlen(crashDir);
if (dirLen + sizeof(kFileName) >= kPathMax) {
Log("crash_handler: crash directory path is too long (%zu) - not installing", dirLen);
return;
}
memcpy(g_reportPath, crashDir, dirLen);
memcpy(g_reportPath + dirLen, kFileName, sizeof(kFileName));
if (buildStamp) {
size_t n = strlen(buildStamp);
if (n >= kStampMax) n = kStampMax - 1;
memcpy(g_buildStamp, buildStamp, n);
g_buildStamp[n] = '\0';
} else {
memcpy(g_buildStamp, "(unknown)", sizeof("(unknown)"));
}
stack_t ss{};
ss.ss_sp = g_altStack;
ss.ss_size = sizeof(g_altStack);
ss.ss_flags = 0;
if (sigaltstack(&ss, nullptr) != 0) {
// Not fatal: without it, a stack-overflow crash goes unreported, but
// every other kind still works. Worth saying out loud rather than
// discovering the gap from a missing report later.
Log("crash_handler: sigaltstack failed (%s) - stack-overflow crashes will not be "
"reported, other crashes still will", strerror(errno));
}
struct sigaction sa{};
sa.sa_sigaction = CrashSignalHandler;
sa.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESTART;
sigemptyset(&sa.sa_mask);
static const int kSignals[] = {SIGSEGV, SIGBUS, SIGABRT, SIGILL, SIGFPE};
for (int sig : kSignals) {
if (sigaction(sig, &sa, &g_prevActions[sig]) != 0) {
Log("crash_handler: could not hook signal %d (%s)", sig, strerror(errno));
}
}
g_installed = true;
Log("crash_handler: installed - native crashes will be written to %s", g_reportPath);
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
// Native crash reporting (task #66 / BETA_TELEMETRY_PLAN.md).
//
// Most crashes in this project are NATIVE - a SIGSEGV inside JIT-generated
// code - so Java's uncaught-exception handler never sees them and the tester
// sees only "the game closed". This writes a report the moment it happens.
//
// What it does NOT do, deliberately: show any UI. A signal handler runs on a
// process that has already gone wrong, where only async-signal-safe calls are
// legal - no malloc, no JNI, no Activity. It writes one file with write() and
// then lets the process die. The report screen is shown by Java on the NEXT
// launch, which is how every serious crash reporter handles native crashes.
//
// The report lands on external storage (Android/data/<pkg>/files/crashes), so
// a tester can reach it over USB or a file manager without any permission.
#include <cstdint>
// Call once, early, with the directory reports should be written to (the app's
// external files dir). Creates the directory if needed, pre-builds every string
// the handler will need, installs an alternate signal stack so a stack-overflow
// crash can still be reported, and hooks the fatal signals.
//
// Safe to call more than once; only the first call installs anything.
void InstallCrashHandler(const char* crashDir, const char* buildStamp);
// Tells the handler where guest memory starts, so a host fault address can be
// reported as the GUEST address that caused it. Without this a report says
// "fault at 0x6f464c459b", which means nothing to anyone; with it the report
// also says "guest 0x464c459b", which is the number worth reading. Called by
// GuestEngine once its region is mapped.
void SetCrashHandlerGuestBase(uint64_t flatBase, uint32_t regionSize);
+339 -12
View File
@@ -4,11 +4,14 @@
#include "zlib_accel.h"
#include "name_lookup_accel.h"
#include "../util/util.h"
#include "../crash_handler.h"
#include <cstdio>
#include <cstring>
#include <cerrno>
#include <cstdlib>
#include <sys/mman.h>
#include <dlfcn.h>
#include <set>
#include <array>
#include <string>
#include <thread>
@@ -133,6 +136,13 @@ constexpr uint32_t kGuardPageSize = kPageSize;
// TCG to re-translate (tb_invalidate_phys_page_fast_arm, 3.15%). If this
// reports zero, the invalidation comes from somewhere else and that lead
// is dead.
// Task #61: bypass the software MMU and let generated code reach guest memory
// as `[X28, Wn, UXTW]`. Kept as an explicit switch because it trades away real
// safety nets (UC_PROT_* enforcement, UC_HOOK_MEM_* callbacks, self-modifying
// code detection) - when chasing a memory-corruption bug, turn it OFF to get
// MEM FAULT diagnostics back, then turn it on again.
constexpr bool kUseFlatGuestMapping = true;
constexpr bool kCountStubDispatches = false;
constexpr bool kCountTextWrites = false;
// kCountArenaWrites - task #54. tb_invalidate_phys_page_fast_arm is 5.56% of
@@ -169,7 +179,13 @@ constexpr uint32_t kControlArenaSize = 256 * 1024;
// engine is not expected to spawn anywhere near this many worker threads -
// generous headroom, not a measured requirement. CarveThreadStack() fails
// (logged, not fatal) if this is ever exceeded.
constexpr uint32_t kMaxGuestThreads = 16;
// Raised 16 -> 64 (2026-09-21) alongside ReleaseThreadEngine's free list.
// The free list is the actual fix for the exhaustion that showed up as a black
// screen entering a race; this is headroom for the genuinely-concurrent peak,
// since a single thread can hold one primary stack plus up to
// kMaxNestedEngines nested ones at the same time. The arena is lazily-mapped
// address space (see MapSegments), so unused entries cost no resident memory.
constexpr uint32_t kMaxGuestThreads = 64;
constexpr uint32_t kThreadStacksArenaSize = kMaxGuestThreads * kStackSize;
// AllocMmap's arena - backs real anonymous mmap() (see libc_shims.cpp's
@@ -186,6 +202,25 @@ constexpr uint32_t kArmLdrPcPcMinus4 = 0xE51FF004u;
struct ImportStubEntry {
std::string name;
ImportShimFn shim; // nullptr if not implemented - see guest_engine's own unresolved-import log-once behavior
// Task #67. Non-zero means "this symbol IS implemented, by real guest code
// in a secondary image, and we want to watch the call". The dispatcher
// logs the arguments, forwards to that address, and logs what comes back.
// Used to find where the game's FMOD initialisation chain stops: the three
// entry points libapp.so imports all return an FMOD_RESULT, so the first
// non-zero one names the failure.
//
// Deliberately on OUR side of the boundary. The same thing attempted from
// outside, via an LD_PRELOAD interposer on the A9, could not resolve the
// real symbols and ended up standing in for them - silencing audio on the
// reference device. Here the real address is known, so forwarding cannot
// silently degrade into replacing.
GuestAddr tracedGuestTarget = 0;
// How many arguments the traced function really takes. AAPCS32 puts the
// first four in r0-r3 and the REST ON THE STACK, so forwarding only the
// registers silently truncates the call. The first version of this probe
// did exactly that and produced error codes that were its own doing -
// FMOD_Memory_Initialize takes six arguments, EventSystem::init five.
int tracedArgCount = 4;
bool loggedUnresolved = false;
// Task #56 instrumentation (2026-09-19, temporary). Live measurement on
// the Xiaomi 14 showed shim crossings/sec pinned near a ceiling (~170k)
@@ -2289,7 +2324,29 @@ void import_stub_dispatch_cb(uc_engine* uc, uint64_t address, uint32_t /*size*/,
}
uint32_t result = 0;
if (entry->shim) {
if (entry->tracedGuestTarget) {
GuestEngine& eng = GuestEngine::Instance();
// Rebuild the WHOLE argument list, stack arguments included, before
// forwarding - see tracedArgCount's own comment for what truncating it
// cost the first time.
uint32_t args[8] = {r0, r1, r2, r3, 0, 0, 0, 0};
int n = entry->tracedArgCount;
if (n < 1) n = 1;
if (n > 8) n = 8;
for (int i = 4; i < n; i++) {
args[i] = eng.ReadIncomingArg(i, r0, r1, r2, r3, sp);
}
std::string argText;
for (int i = 0; i < n; i++) {
char buf[24];
snprintf(buf, sizeof(buf), "%s0x%x", i ? ", " : "", args[i]);
argText += buf;
}
Log("GUESTCALL %s(%s) - entering real guest code at 0x%x",
entry->name.c_str(), argText.c_str(), entry->tracedGuestTarget);
result = eng.CallGuestFunction(entry->tracedGuestTarget, args, n);
Log("GUESTCALL %s -> 0x%x", entry->name.c_str(), result);
} else if (entry->shim) {
result = entry->shim(GuestEngine::Instance(), r0, r1, r2, r3, sp);
} else {
if (!entry->loggedUnresolved) {
@@ -2424,6 +2481,19 @@ bool GuestEngine::LoadImage(const char* path) {
// addresses by the time any GOT slot references them.
for (auto fn : data_symbol_setup_fns_) fn(*this);
EnsureThreadEngine();
// Sibling guest libraries, loaded HERE - after the engine exists (they
// need one to build their own import stubs) but BEFORE this image's
// relocations are processed. The ordering is the whole point: a GOT
// slot resolved against a shim cannot be un-resolved later, so anything
// that should reach real code has to be exported before the first
// relocation is applied (task #67).
//
// Failure is not fatal. A missing library leaves fmod_shims.cpp's
// no-ops in charge, which is exactly the behaviour that shipped before
// this - the game runs, silently, rather than not at all.
if (t_state_.uc) LoadSiblingLibraries(path);
ok = t_state_.uc != nullptr && ProcessRelocations(fileData, fileSize);
}
free(fileData);
@@ -2509,13 +2579,42 @@ bool GuestEngine::MapSegments(const uint8_t* fileData, size_t fileSize) {
mmap_end_ = mmap_cursor_ + kMmapArenaSize;
region_size_ = AlignUp(mmap_end_ + kPageSize, kPageSize);
void* backing = mmap(nullptr, region_size_, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// Reserve the FULL 4 GiB a 32-bit guest address can name, then commit the
// part we actually use at its start (task #61).
//
// Why reserve the whole span rather than just region_size_: with the flat
// mapping enabled (uc_set_flat_map_base), generated code reaches guest
// memory as `[X28, Wn, UXTW]` - the guest address is zero-extended and
// added with no bounds check whatsoever. A wild guest pointer beyond
// region_size_ (we have hit real ones, e.g. 0x3d3d3d3d in task #33) would
// otherwise land on whatever unrelated host mapping happens to sit there
// and be read or WRITTEN silently. Holding the whole 4 GiB as PROT_NONE
// turns every such access into an immediate, honest SIGSEGV instead.
//
// This costs address space, not memory: PROT_NONE|MAP_NORESERVE commits no
// pages, and the host is 64-bit so 4 GiB of VA is unremarkable.
constexpr size_t kGuestAddressSpace = 4ull * 1024 * 1024 * 1024;
void* reservation = mmap(nullptr, kGuestAddressSpace, PROT_NONE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
if (reservation == MAP_FAILED) {
Log("GuestEngine: reserving the 4GiB guest address space failed (%s) - cannot continue",
strerror(errno));
return false;
}
void* backing = mmap(reservation, region_size_, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
if (backing == MAP_FAILED) {
Log("GuestEngine: mmap(0x%x) for guest region failed", region_size_);
Log("GuestEngine: mmap(0x%x) for guest region failed (%s)", region_size_, strerror(errno));
munmap(reservation, kGuestAddressSpace);
return false;
}
host_region_ = static_cast<uint8_t*>(backing);
// The crash handler needs this to turn a host fault address back into the
// guest address that caused it - see crash_handler.h. Set as soon as the
// region exists, so a crash during the rest of loading is already decodable.
SetCrashHandlerGuestBase((uint64_t)(uintptr_t)host_region_, region_size_);
// No uc_engine created here anymore - each host thread gets its own,
// mapped onto this same host_region_, the first time it needs one (see
// EnsureThreadEngine). This call (LoadImage's caller) will get its
@@ -2686,6 +2785,36 @@ bool GuestEngine::ProcessRelocations(const uint8_t* fileData, size_t fileSize) {
// of a clean crash - a small, self-contained, independently-readable
// function is safer here than a shared helper parameterized just enough to
// serve both call sites' different assumptions.
void GuestEngine::LoadSiblingLibraries(const char* primaryImagePath) {
// libfmodex FIRST: libfmodevent lists it in its own DT_NEEDED, so its
// relocations only resolve to real code if libfmodex's exports are already
// recorded. Load order here IS dependency order.
static const char* kSiblings[] = { "libfmodex.so", "libfmodevent.so" };
std::string dir(primaryImagePath ? primaryImagePath : "");
size_t slash = dir.find_last_of('/');
dir = (slash == std::string::npos) ? std::string(".") : dir.substr(0, slash);
// Every instruction in libfmodex that loads FMOD_RESULT 33 into a register
// (found by scanning the disassembly for the constant - 39 sites, far too
// many to reason about by hand). Watching them all at once turns "which
// one fires" into an observation instead of an argument. Offsets are
// file-relative and biased by the load address below.
//
// Temporary, for the audio bring-up (task #67) - delete the list to remove
// the hooks entirely.
for (const char* lib : kSiblings) {
std::string full = dir + "/" + lib;
if (LoadSecondaryImage(full.c_str(), nullptr)) continue;
// Say which one and what it costs. A silent miss here would present
// later as "the game runs but makes no sound", with nothing pointing
// back to the real cause.
Log("GuestEngine::LoadSiblingLibraries: could not load %s - its entry points stay on the "
"no-op shims in fmod_shims.cpp, so expect no audio from it", full.c_str());
}
}
GuestAddr GuestEngine::LoadSecondaryImage(const char* path, const char* entrySymbol) {
if (!host_region_) {
Log("GuestEngine::LoadSecondaryImage(%s): no primary image loaded yet (host_region_ null) - "
@@ -2890,24 +3019,67 @@ GuestAddr GuestEngine::LoadSecondaryImage(const char* path, const char* entrySym
for (size_t s = 0; s < symCount; s++) {
if (syms[s].st_shndx == SHN_UNDEF) continue;
const char* name = strs + syms[s].st_name;
if (strcmp(name, entrySymbol) == 0) {
entryAddr = base + syms[s].st_value; // st_value already carries the Thumb bit, same convention as everywhere else
break;
if (!name || !*name) continue;
// st_value already carries the Thumb bit, same convention as everywhere else.
GuestAddr addr = base + syms[s].st_value;
// Record the export so a later import of this name reaches the
// real code. First definition wins, matching how a real dynamic
// linker resolves against load order - and a collision is worth
// saying out loud rather than silently preferring one.
auto existing = secondary_image_exports_.find(name);
if (existing == secondary_image_exports_.end()) {
secondary_image_exports_[name] = addr;
} else if (existing->second != addr) {
Log("GuestEngine::LoadSecondaryImage(%s): symbol '%s' already exported at 0x%x by an "
"earlier image - keeping the first, ignoring 0x%x", path, name, existing->second, addr);
}
if (entrySymbol && !entryAddr && strcmp(name, entrySymbol) == 0) {
entryAddr = addr;
}
}
}
}
free(fileData);
if (!entryAddr) {
if (entrySymbol && !entryAddr) {
Log("GuestEngine::LoadSecondaryImage: entry symbol '%s' not found in %s's .dynsym - "
"was it declared extern \"C\" with default visibility?", entrySymbol, path);
return 0;
}
Log("GuestEngine::LoadSecondaryImage: loaded %s at base=0x%x span=0x%x - entry '%s'=0x%x",
path, base, span, entrySymbol, entryAddr);
return entryAddr;
secondary_image_ranges_.push_back({base, span});
Log("GuestEngine::LoadSecondaryImage: loaded %s at base=0x%x span=0x%x - entry '%s'=0x%x, "
"%zu exported symbol(s) now available to later imports",
path, base, span, entrySymbol ? entrySymbol : "(none requested)", entryAddr,
secondary_image_exports_.size());
return entrySymbol ? entryAddr : 1;
}
// Symbols whose calls are logged and forwarded rather than branched to
// directly (task #67, temporary). Empty this list to remove the overhead -
// each entry costs a host crossing per call, so only initialisation-time
// functions belong here, never anything on a per-frame path.
// Argument counts come from FMOD's public headers, not from guesswork:
// FMOD_Memory_Initialize(poolmem, poollen, useralloc, userrealloc,
// userfree, memtypeflags) - 6
// FMOD_EventSystem_Create(eventsystem) - 1
// EventSystem::init(this, maxchannels, flags, extradriverdata,
// eventflags) - 5
static int TracedGuestSymbolArgCount(const std::string& name) {
if (name == "FMOD_Memory_Initialize") return 6;
if (name == "FMOD_EventSystem_Create") return 1;
if (name == "_ZN4FMOD11EventSystem4initEijPvj") return 5;
// System::init(this, maxchannels, flags, extradriverdata) - libfmodevent
// calls this INTO libfmodex, so it crosses an image boundary and goes
// through import resolution like any other. Watched to separate "the event
// layer refused" from "the core system refused".
if (name == "_ZN4FMOD6System4initEijPv") return 4;
return 0; // not traced
}
static bool IsTracedGuestSymbol(const std::string& name) {
return TracedGuestSymbolArgCount(name) > 0;
}
GuestAddr GuestEngine::ResolveOrCreateImportStub(const std::string& symbolName) {
@@ -2925,6 +3097,44 @@ GuestAddr GuestEngine::ResolveOrCreateImportStub(const std::string& symbolName)
return dataIt->second;
}
// A symbol DEFINED by a secondary guest image is real code - branch
// straight to it, no stub and no host crossing. Checked before the shim
// table on purpose: once the game's own FMOD is loaded, its entry points
// must win over fmod_shims.cpp's no-op stand-ins.
auto exportIt = secondary_image_exports_.find(symbolName);
if (exportIt != secondary_image_exports_.end()) {
if (registered_shims_.count(symbolName)) {
Log("GuestEngine: '%s' resolves to real guest code at 0x%x from a secondary image - "
"the registered shim for it is now unused", symbolName.c_str(), exportIt->second);
}
// Watched symbols get a stub that forwards; everything else branches
// straight to the real code with no stub and no host crossing.
if (!IsTracedGuestSymbol(symbolName)) {
import_stub_by_symbol_[symbolName] = exportIt->second;
return exportIt->second;
}
auto* traced = new ImportStubEntry();
traced->name = symbolName;
traced->shim = nullptr;
traced->tracedGuestTarget = exportIt->second;
traced->tracedArgCount = TracedGuestSymbolArgCount(symbolName);
{
std::lock_guard<std::mutex> lock(g_importStubEntriesMutex);
g_importStubEntries.push_back(traced);
}
GuestAddr tracedStub = AllocCodeStub(import_stub_dispatch_cb, traced);
if (!tracedStub) {
Log("GuestEngine: could not allocate a tracing stub for '%s' - calling it directly, "
"untraced", symbolName.c_str());
import_stub_by_symbol_[symbolName] = exportIt->second;
return exportIt->second;
}
Log("GuestEngine: watching '%s' - calls will be logged and forwarded to 0x%x",
symbolName.c_str(), exportIt->second);
import_stub_by_symbol_[symbolName] = tracedStub;
return tracedStub;
}
auto* entry = new ImportStubEntry();
entry->name = symbolName;
{
@@ -2960,6 +3170,30 @@ uc_engine* GuestEngine::CreateConfiguredEngine() {
Log("GuestEngine::CreateConfiguredEngine: uc_open failed: %d", (int)err);
return nullptr;
}
// Task #61: tell the JIT it can reach guest memory by plain addition.
//
// Must happen here, before anything is translated on this engine. Our
// guest address space is already exactly what the flat path requires - one
// contiguous host block where G2H(a) == host_region_ + a - so the software
// TLB is pure overhead: a nine-instruction check per access, measured at
// 29.8% of all generated host code.
//
// The price, paid knowingly: UC_PROT_* is no longer enforced on data
// accesses, UC_HOOK_MEM_* stops firing (so MEM FAULT diagnostics go
// quiet), and guest stores no longer invalidate translated code. The
// 4 GiB PROT_NONE reservation in MapSegments is what keeps a wild guest
// pointer from silently touching unrelated host memory.
if (kUseFlatGuestMapping) {
uc_err flatErr = uc_set_flat_map_base(newUc, (uint64_t)(uintptr_t)host_region_);
if (flatErr != UC_ERR_OK) {
// Not fatal - the software MMU still works - but it means this
// engine silently runs several times slower than its siblings,
// which is exactly the kind of thing that must not pass in silence.
Log("GuestEngine::CreateConfiguredEngine: uc_set_flat_map_base failed: %d - this "
"engine falls back to the software MMU and will be much slower", (int)flatErr);
}
}
// uc_mem_map_ptr shares OUR host buffer directly as the guest's backing
// store, instead of Unicorn allocating its own - this is what makes
// G2H/H2G plain pointer arithmetic (see guest_engine.h's class comment).
@@ -3419,6 +3653,15 @@ void GuestEngine::ReplayHooksOnEngine(uc_engine* newUc) {
GuestAddr GuestEngine::CarveThreadStack() {
std::lock_guard<std::mutex> lock(thread_stacks_mutex_);
// Reuse a returned stack before growing the arena. The guest does not read
// a fresh stack before writing it, so no scrubbing is needed here - and
// leaving the previous thread's bytes in place has caught real
// uninitialized-read bugs before.
if (!thread_stack_free_list_.empty()) {
GuestAddr top = thread_stack_free_list_.back();
thread_stack_free_list_.pop_back();
return top;
}
if (thread_stacks_cursor_ + kStackSize > thread_stacks_end_) {
return 0;
}
@@ -3427,6 +3670,37 @@ GuestAddr GuestEngine::CarveThreadStack() {
return base + kStackSize; // top = highest usable address, same convention the old stack_top_ used
}
void GuestEngine::ReleaseThreadEngine() {
// Nested-call engines first: each one holds its OWN dedicated stack
// (GetOrCreateNestedEngine), and those leaked exactly like the primary
// one did - a single guest thread could consume several stacks out of the
// arena and give none of them back.
for (uint32_t i = 0; i < kMaxNestedEngines; i++) {
if (t_state_.nestedEngines[i]) {
uc_close(t_state_.nestedEngines[i]);
t_state_.nestedEngines[i] = nullptr;
}
if (t_state_.nestedStackTop[i]) {
std::lock_guard<std::mutex> lock(thread_stacks_mutex_);
thread_stack_free_list_.push_back(t_state_.nestedStackTop[i]);
t_state_.nestedStackTop[i] = 0;
}
}
if (!t_state_.uc) return;
uc_close(t_state_.uc);
t_state_.uc = nullptr;
if (t_state_.stackTop) {
std::lock_guard<std::mutex> lock(thread_stacks_mutex_);
thread_stack_free_list_.push_back(t_state_.stackTop);
}
t_state_.stackBase = 0;
t_state_.stackTop = 0;
t_state_.callDepth = 0;
t_state_.lastCallHiWord = 0;
}
// Task #56: every shim crossing (libc, JNI and GLES alike) passes through
// here, so one relaxed increment gives a total that gles_shim's PERF line can
// print alongside fps and draws in a SINGLE log line - which is the whole
@@ -4055,6 +4329,59 @@ void GuestEngine::RegisterDataSymbol(const char* symbolName, GuestAddr address)
registered_data_symbols_[symbolName] = address;
}
namespace {
void watch_address_cb(uc_engine*, uint64_t address, uint32_t, void* user_data) {
auto* label = static_cast<const char*>(user_data);
// Once per address: these sit on error paths, and an error path that runs
// in a loop would otherwise bury everything else in the log.
static std::mutex seenMutex;
static std::set<uint64_t> seen;
{
std::lock_guard<std::mutex> lock(seenMutex);
if (!seen.insert(address).second) return;
}
Log("WATCH reached guest 0x%llx - %s", (unsigned long long)address, label ? label : "?");
}
} // namespace
void GuestEngine::WatchGuestAddress(GuestAddr addr, const char* label) {
if (!addr) return;
char* owned = strdup(label ? label : "");
{
std::lock_guard<std::mutex> lock(hook_registrations_mutex_);
hook_registrations_.push_back({watch_address_cb, owned, addr});
}
if (t_state_.uc) {
uc_hook h;
uc_err err = uc_hook_add(t_state_.uc, &h, UC_HOOK_CODE, (void*)watch_address_cb, owned,
addr, addr);
if (err != UC_ERR_OK) {
Log("GuestEngine::WatchGuestAddress(0x%x): uc_hook_add failed: %d", addr, (int)err);
}
}
}
bool GuestEngine::IsGuestImageCode(GuestAddr addr) const {
if (addr && addr < image_end_) return true;
for (const auto& r : secondary_image_ranges_) {
if (addr >= r.base && addr < r.base + r.span) return true;
}
return false;
}
GuestAddr GuestEngine::LookupSecondaryExport(const char* symbolName) const {
if (!symbolName) return 0;
auto it = secondary_image_exports_.find(symbolName);
return it == secondary_image_exports_.end() ? 0 : it->second;
}
const char* GuestEngine::NameForDataSymbol(GuestAddr address) const {
for (const auto& entry : registered_data_symbols_) {
if (entry.second == address) return entry.first.c_str();
}
return nullptr;
}
std::string GuestEngine::DescribeAddress(GuestAddr addr) const {
char buf[128];
if (addr >= region_size_) {
+71 -1
View File
@@ -126,8 +126,24 @@ public:
// other resolved address in this engine) ready to hand straight to
// CallGuestFunction, or 0 on any failure (bad ELF, arena exhaustion,
// symbol not found - all logged).
// Loads an additional guest ELF alongside the primary image and records
// everything it EXPORTS, so a later import of one of those names resolves
// to the real code instead of a shim (see ResolveOrCreateImportStub).
//
// Pass entrySymbol = nullptr when the caller only wants the library loaded
// and does not need one specific address back - which is the case for the
// game's own FMOD libraries, whose entry points are reached through
// libapp.so's ordinary imports rather than by name from the host.
// Returns the entry symbol's address, or 1 for "loaded, no entry requested",
// or 0 on failure.
GuestAddr LoadSecondaryImage(const char* path, const char* entrySymbol);
// Loads the guest libraries that sit alongside the primary image in the
// same directory - currently the game's own FMOD. Called from LoadImage at
// the one point where it is both possible and still useful; see the call
// site's own comment for why the ordering is not negotiable.
void LoadSiblingLibraries(const char* primaryImagePath);
bool loaded() const { return host_region_ != nullptr; }
// ---- Address translation ----
@@ -289,6 +305,37 @@ public:
// ResolveOrCreateImportStub BEFORE falling back to a code stub.
void RegisterDataSymbol(const char* symbolName, GuestAddr address);
// Reverse of RegisterDataSymbol: which symbol, if any, was given this
// address. Exists so a shim that cannot honour a request can NAME what it
// was asked for instead of printing a bare arena address - the facet
// addresses handed out by AllocPermanent mean nothing on their own, and
// resolving one otherwise costs a rebuild and a reproduction (see
// Shim_use_facet). Linear scan: this is an error path only.
// Returns nullptr when the address is not a registered data symbol.
const char* NameForDataSymbol(GuestAddr address) const;
// Address of a symbol defined by a secondary guest image (see
// LoadSecondaryImage), or 0 if no loaded image exports that name. Lets
// host-side JNI entry points forward into real guest code - see
// game_lifecycle_stubs_extra2.cpp's FMOD audio bridge.
GuestAddr LookupSecondaryExport(const char* symbolName) const;
// Logs once when execution first reaches `addr`, without altering control
// flow - a pure observation point, unlike InstallTrampolineHookRaw which
// displaces instructions. Used to answer "which of these N places produced
// the error code" by watching every candidate at once instead of reasoning
// about which one is reachable.
//
// `label` is copied and used verbatim in the log line.
void WatchGuestAddress(GuestAddr addr, const char* label);
// True if `addr` lies inside ANY loaded guest image - the primary one or
// a sibling loaded by LoadSecondaryImage. Callers that need to tell "real
// guest code" from "one of this engine's own arenas" must ask this rather
// than comparing against image_end(), which only ever described the
// primary image and silently rejected every sibling.
bool IsGuestImageCode(GuestAddr addr) const;
// Registers a callback GuestEngine invokes exactly once per LoadImage
// call, right after MapSegments succeeds (so host_region_/
// AllocPermanent are usable) but strictly BEFORE ProcessRelocations
@@ -354,6 +401,13 @@ public:
// before that thread can run any guest code at all.
void EnsureThreadEngine();
// Tears down everything EnsureThreadEngine (and any nested-call engine)
// set up for the CALLING host thread, returning its guest stacks to the
// arena for reuse. Must be called by any thread that created an engine
// and is about to exit - pthread_shim's thread body does. Safe to call on
// a thread that never had one.
void ReleaseThreadEngine();
// Reserved, never-fetched guest PC used as the "return to caller"
// target for every CallGuestFunction invocation (see .cpp for why a
// fixed constant is safe to reuse across nested calls).
@@ -515,7 +569,15 @@ private:
GuestAddr thread_stacks_cursor_ = 0, thread_stacks_end_ = 0; // CarveThreadStack arena
GuestAddr mmap_cursor_ = 0, mmap_end_ = 0; // AllocMmap arena - see its own comment
std::mutex thread_stacks_mutex_; // guards thread_stacks_cursor_ (concurrent pthread_create calls)
std::mutex thread_stacks_mutex_; // guards thread_stacks_cursor_ and the free list below
// Stacks handed back by ReleaseThreadEngine, ready to be reused.
//
// Without this the arena was a one-way bump allocator: every guest thread
// that finished kept its stack forever, and so did every nested-call
// engine, so a long session simply ran out. Confirmed live - the game
// created its race thread, CarveThreadStack returned 0, the thread's
// start routine never ran, and the race rendered as a black screen.
std::vector<GuestAddr> thread_stack_free_list_;
std::mutex control_mutex_; // guards control_cursor_
std::mutex mmap_mutex_; // guards mmap_cursor_
@@ -523,6 +585,14 @@ private:
std::unordered_map<std::string, ImportShimFn> registered_shims_;
std::unordered_map<std::string, GuestAddr> registered_data_symbols_; // see RegisterDataSymbol
// Symbols DEFINED by secondary guest images (see LoadSecondaryImage).
// Consulted by ResolveOrCreateImportStub ahead of the shim table, so that
// once the game's real ARM32 FMOD is loaded its own entry points win over
// the no-op stand-ins fmod_shims.cpp registers.
std::unordered_map<std::string, GuestAddr> secondary_image_exports_;
// [base, base+span) for each image LoadSecondaryImage has loaded.
struct LoadedImageRange { GuestAddr base; uint32_t span; };
std::vector<LoadedImageRange> secondary_image_ranges_;
std::vector<DataSymbolSetupFn> data_symbol_setup_fns_; // see RegisterDataSymbolSetup
std::unordered_map<std::string, GuestAddr> import_stub_by_symbol_; // dedupe: one stub per symbol name
std::unordered_map<GuestAddr, ImportShimFn> shim_by_stub_addr_;
+25 -5
View File
@@ -317,13 +317,12 @@ std::string GuestCStr(GuestEngine& eng, uint32_t guestPtr) {
} // namespace
std::atomic<uint32_t> JniHandleTable::g_callEpoch{0};
thread_local uint32_t JniHandleTable::t_callEpoch = 0;
uint32_t JniHandleTable::Alloc(void* real, bool isGlobal) {
if (!real) return 0;
std::lock_guard<std::mutex> lock(mutex_);
table_.push_back(Entry{real, std::this_thread::get_id(), isGlobal,
g_callEpoch.load(std::memory_order_relaxed)});
table_.push_back(Entry{real, std::this_thread::get_id(), isGlobal, t_callEpoch});
return (uint32_t)(table_.size() - 1);
}
@@ -344,8 +343,10 @@ bool JniHandleTable::IsSafeToUseFromCurrentThread(uint32_t handle) const {
// nativeOnRunLoopTick calls SetRealEnv/BumpCallEpoch on every single
// entry, so a handle from an earlier tick on the SAME thread is just
// as stale as one from a different thread).
return e.owner == std::this_thread::get_id() &&
e.epoch == g_callEpoch.load(std::memory_order_relaxed);
// Both halves are per-thread now: the ref must have been created by THIS
// thread, during the native call this thread is currently inside. Another
// thread entering or leaving a native call cannot affect either test.
return e.owner == std::this_thread::get_id() && e.epoch == t_callEpoch;
}
thread_local JNIEnv* JniShim::real_env_ = nullptr;
@@ -1734,6 +1735,11 @@ uint32_t Impl_NewDirectByteBuffer(GuestEngine& eng, uint32_t, uint32_t r1, uint3
g_directBufferGuestAddrs[h] = r1;
return h;
}
void RegisterDirectBufferGuestAddr(uint32_t handle, GuestAddr guestAddr);
void RegisterDirectBufferGuestAddr(uint32_t handle, GuestAddr guestAddr) {
g_directBufferGuestAddrs[handle] = guestAddr;
}
uint32_t Impl_GetDirectBufferAddress(GuestEngine&, uint32_t, uint32_t r1, uint32_t, uint32_t, uint32_t) {
auto it = g_directBufferGuestAddrs.find(r1);
if (it == g_directBufferGuestAddrs.end()) {
@@ -2143,6 +2149,20 @@ uint32_t Impl_RegisterNatives(GuestEngine& eng, uint32_t, uint32_t r1, uint32_t
} // namespace
uint32_t JniShim::NewGuestBackedDirectBuffer(GuestEngine& engine, GuestAddr guestAddr,
uint32_t capacity) {
if (!guestAddr || !capacity || !RealEnv()) return 0;
jobject buf = RealEnv()->NewDirectByteBuffer(engine.G2H(guestAddr), (jlong)capacity);
if (!buf) {
Log("jni_shim: NewGuestBackedDirectBuffer(0x%x, %u) - NewDirectByteBuffer failed",
guestAddr, capacity);
return 0;
}
uint32_t handle = handles().Alloc(buf);
RegisterDirectBufferGuestAddr(handle, guestAddr);
return handle;
}
GuestAddr JniShim::BuildGuestJNIEnv(GuestEngine& engine) {
if (guest_env_) return guest_env_;
+29 -3
View File
@@ -81,15 +81,30 @@ public:
// IsSafeToUseFromCurrentThread now checks BOTH the owning thread AND
// the owning epoch.
bool IsSafeToUseFromCurrentThread(uint32_t handle) const;
static void BumpCallEpoch() { g_callEpoch.fetch_add(1, std::memory_order_relaxed); }
//
// Corrected 2026-09-21 (task #67): the epoch used to be ONE process-wide
// counter. That was indistinguishable from correct while only one host
// thread ever crossed into JNI - but a local ref's lifetime is scoped to
// a native call ON ITS OWN THREAD, and a bump from a different thread has
// no business invalidating it.
//
// It stopped being academic the moment the FMOD audio bridge began
// calling in from FMODAudioDevice's AudioTrack thread every 100 ms: each
// of those bumps invalidated the refs GLThread was holding mid-call, and
// the process aborted with "JNI DETECTED ERROR IN APPLICATION: jfieldID
// was NULL" inside GLThread. Per-thread is both the fix and the more
// accurate model - no atomics needed either, since a thread's own epoch
// is only ever read and written by that thread.
static void BumpCallEpoch() { ++t_callEpoch; }
static uint32_t CurrentCallEpoch() { return t_callEpoch; }
private:
static std::atomic<uint32_t> g_callEpoch;
static thread_local uint32_t t_callEpoch;
struct Entry {
void* real = nullptr;
std::thread::id owner;
bool isGlobal = false;
uint32_t epoch = 0; // g_callEpoch's value at Alloc() time - see IsSafeToUseFromCurrentThread
uint32_t epoch = 0; // the OWNING THREAD's epoch at Alloc() time - see IsSafeToUseFromCurrentThread
};
mutable std::mutex mutex_;
std::vector<Entry> table_{Entry{}}; // index 0 reserved for guest NULL
@@ -104,6 +119,17 @@ public:
// function that expects a JNIEnv*.
GuestAddr BuildGuestJNIEnv(GuestEngine& engine);
// Wraps a range of GUEST memory in a real Java direct ByteBuffer and
// returns the guest handle for it, registered so that the guest's own
// GetDirectBufferAddress resolves it back to `guestAddr`.
//
// Needed because GetDirectBufferAddress can only answer for buffers this
// shim created (see its own comment): a ByteBuffer that Java allocated
// lives at a host address the guest cannot reach. Anything handing guest
// code a Java-allocated buffer therefore has to bounce through one of
// these - the same shape as the AndroidBitmap_lockPixels fix.
uint32_t NewGuestBackedDirectBuffer(GuestEngine& engine, GuestAddr guestAddr, uint32_t capacity);
// Builds a minimal guest JavaVM (8-slot JNIInvokeInterface) - only
// GetEnv and AttachCurrentThread are real (both just return the same
// guest JNIEnv from BuildGuestJNIEnv - this shim only ever has one
+269
View File
@@ -17,6 +17,7 @@
#include <functional>
#include <algorithm>
#include <unistd.h>
#include <sys/syscall.h>
#include <fcntl.h>
#include <dirent.h>
#include <dlfcn.h>
@@ -667,8 +668,63 @@ uint32_t Shim_vsnprintf(GuestEngine& eng, uint32_t dst, uint32_t bufSize, uint32
}
// ==================== unistd.h / fcntl.h (POSIX file I/O) ====================
// /proc/cpuinfo, rewritten for a 32-bit reader (task #67).
//
// This is the root cause of "no audio", traced all the way down. FMOD detects
// CPU capabilities by reading /proc/cpuinfo and string-matching the Features
// line for "vfp", "vfpv3" and "neon" (libfmodex sub_BFFD8). If it finds none
// of them it leaves its capability mask at zero, and sub_A9AF8 then returns
// FMOD_RESULT 48, which propagates out through System::init and
// EventSystem::init - measured live as exactly that chain.
//
// On an arm64 kernel the same silicon is described with the AArch64 names:
//
// Pixel 6a: Features : fp asimd evtstrm aes pmull sha1 sha2 crc32 ...
//
// "fp" IS VFP and "asimd" IS NEON - the hardware has both, the 64-bit format
// simply spells them differently, and a 32-bit library from 2012 cannot know
// that. So this is not a lie told to the guest: it is the same CPU, described
// in the vocabulary the guest was built to read. Everything reported here is
// genuinely present on any ARMv8 core running this engine.
//
// Kept minimal on purpose - only the fields FMOD's parser actually looks at
// ("CPU architecture", "Processor", "Features"), plus enough shape to look
// like a real file.
static const char kGuestCpuInfo[] =
"Processor\t: ARMv7 Processor rev 1 (v7l)\n"
"processor\t: 0\n"
"BogoMIPS\t: 38.40\n"
"Features\t: swp half thumb fastmult vfp edsp neon vfpv3 tls vfpv4 idiva idivt\n"
"CPU implementer\t: 0x41\n"
"CPU architecture: 7\n"
"CPU variant\t: 0x0\n"
"CPU part\t: 0xd03\n"
"CPU revision\t: 1\n"
"\n"
"Hardware\t: ARMv7 Processor\n"
"Revision\t: 0000\n"
"Serial\t\t: 0000000000000000\n";
uint32_t Shim_open(GuestEngine& eng, uint32_t path, uint32_t flags, uint32_t mode, uint32_t, uint32_t) {
const char* p = (const char*)eng.G2H(path);
if (p && strcmp(p, "/proc/cpuinfo") == 0) {
// memfd_create keeps this a perfectly ordinary fd, so read/lseek/close
// need no special cases and the guest cannot tell the difference.
int memfd = syscall(__NR_memfd_create, "guest_cpuinfo", 0);
if (memfd >= 0) {
size_t len = sizeof(kGuestCpuInfo) - 1;
if (write(memfd, kGuestCpuInfo, len) == (ssize_t)len && lseek(memfd, 0, SEEK_SET) == 0) {
Log("libc_shims: open(\"/proc/cpuinfo\") -> %d, serving an ARMv7-format copy "
"(the host's arm64 kernel calls VFP \"fp\" and NEON \"asimd\", which no 32-bit "
"library recognises - see kGuestCpuInfo)", memfd);
return (uint32_t)memfd;
}
close(memfd);
}
Log("libc_shims: could not build the ARMv7 /proc/cpuinfo replacement (%s) - falling through "
"to the host's own, which will make FMOD report no VFP/NEON and fail to initialise",
strerror(errno));
}
int fd = open(p, (int)flags, (mode_t)mode);
Log("libc_shims: open(\"%s\", flags=0x%x, mode=0%o) -> %d%s", p, flags, mode, fd,
fd < 0 ? " [FAILED]" : "");
@@ -1692,7 +1748,220 @@ uint32_t Shim_glGetBufferPointervOES(GuestEngine& eng, uint32_t target, uint32_t
} // namespace
// ---- Runtime support the game's own ARM32 FMOD needs (task #67) ----
//
// libfmodex.so is a real, shipped ARM32 shared library, and the plan is to run
// it under this engine rather than reimplement 63 FMOD entry points. Comparing
// its 117 undefined symbols against everything already registered left exactly
// 37 gaps, and every one of them is compiler-runtime or libm - no reverse
// engineering, just forwarding. They live here rather than in fmod_shims.cpp
// because none of them is FMOD-specific; libapp.so may well want them too.
//
// The __aeabi_* helpers are the ARM EABI's own arithmetic routines. They use a
// softfp register convention (a double is an r0:r1 pair), which is exactly what
// ReadDoubleArg/ReturnDouble above already handle.
// Integer division. ARM has no integer divide instruction in this profile, so
// the compiler emits calls to these instead.
uint32_t Shim_aeabi_idiv(GuestEngine&, uint32_t a, uint32_t b, uint32_t, uint32_t, uint32_t) {
int32_t n = (int32_t)a, d = (int32_t)b;
return d ? (uint32_t)(n / d) : 0; // divide-by-zero: EABI leaves it undefined, 0 is as good as anything
}
uint32_t Shim_aeabi_uidiv(GuestEngine&, uint32_t a, uint32_t b, uint32_t, uint32_t, uint32_t) {
return b ? (a / b) : 0;
}
// ...mod variants return quotient in r0 AND remainder in r1 - the same r0:r1
// pair convention ReturnU64 writes.
uint32_t Shim_aeabi_idivmod(GuestEngine& eng, uint32_t a, uint32_t b, uint32_t, uint32_t, uint32_t) {
int32_t n = (int32_t)a, d = (int32_t)b;
int32_t q = d ? n / d : 0, r = d ? n % d : 0;
return ReturnU64(eng, ((uint64_t)(uint32_t)r << 32) | (uint32_t)q);
}
uint32_t Shim_aeabi_uidivmod(GuestEngine& eng, uint32_t a, uint32_t b, uint32_t, uint32_t, uint32_t) {
uint32_t q = b ? a / b : 0, r = b ? a % b : 0;
return ReturnU64(eng, ((uint64_t)r << 32) | q);
}
// 64-bit unsigned divide: quotient in r0:r1, remainder in r2:r3. The remainder
// half needs registers the shared dispatch contract does not cover, so it is
// written directly, the same way ReturnU64 writes r1.
uint32_t Shim_aeabi_uldivmod(GuestEngine& eng, uint32_t nlo, uint32_t nhi, uint32_t dlo, uint32_t dhi, uint32_t) {
uint64_t n = ((uint64_t)nhi << 32) | nlo;
uint64_t d = ((uint64_t)dhi << 32) | dlo;
uint64_t q = d ? n / d : 0, r = d ? n % d : 0;
if (uc_engine* uc = eng.uc()) {
uint32_t rlo = (uint32_t)r, rhi = (uint32_t)(r >> 32);
uc_reg_write(uc, UC_ARM_REG_R2, &rlo);
uc_reg_write(uc, UC_ARM_REG_R3, &rhi);
}
return ReturnU64(eng, q);
}
// Double arithmetic and conversions.
uint32_t Shim_aeabi_dadd(GuestEngine& eng, uint32_t r0, uint32_t r1, uint32_t r2, uint32_t r3, uint32_t sp) {
return ReturnDouble(eng, ReadDoubleArg(eng, 0, r0, r1, r2, r3, sp) + ReadDoubleArg(eng, 2, r0, r1, r2, r3, sp));
}
uint32_t Shim_aeabi_dmul(GuestEngine& eng, uint32_t r0, uint32_t r1, uint32_t r2, uint32_t r3, uint32_t sp) {
return ReturnDouble(eng, ReadDoubleArg(eng, 0, r0, r1, r2, r3, sp) * ReadDoubleArg(eng, 2, r0, r1, r2, r3, sp));
}
uint32_t Shim_aeabi_dcmplt(GuestEngine& eng, uint32_t r0, uint32_t r1, uint32_t r2, uint32_t r3, uint32_t sp) {
return ReadDoubleArg(eng, 0, r0, r1, r2, r3, sp) < ReadDoubleArg(eng, 2, r0, r1, r2, r3, sp) ? 1u : 0u;
}
uint32_t Shim_aeabi_f2d(GuestEngine& eng, uint32_t f, uint32_t, uint32_t, uint32_t, uint32_t) {
float v; memcpy(&v, &f, 4);
return ReturnDouble(eng, (double)v);
}
uint32_t Shim_aeabi_ui2d(GuestEngine& eng, uint32_t v, uint32_t, uint32_t, uint32_t, uint32_t) {
return ReturnDouble(eng, (double)v);
}
uint32_t Shim_aeabi_ul2d(GuestEngine& eng, uint32_t lo, uint32_t hi, uint32_t, uint32_t, uint32_t) {
return ReturnDouble(eng, (double)(((uint64_t)hi << 32) | lo));
}
// Static-destructor registration. Nothing in this engine ever tears the guest
// image down, so recording the destructor would be write-only state.
uint32_t Shim_aeabi_atexit(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return 0; }
// The C++ unwinding personality routine. Reaching it means a real exception is
// unwinding through guest frames, which this engine cannot do (see
// __cxa_bad_typeid's own comment) - so say so loudly rather than return a code
// the unwinder would act on.
uint32_t Shim_aeabi_unwind_cpp_pr0(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
static bool logged = false;
if (!logged) {
logged = true;
Log("libc_shims: __aeabi_unwind_cpp_pr0 called - a C++ exception is unwinding through guest "
"frames and this engine has no guest stack unwinder. Returning 'unwind failed'; expect "
"the guest to abort rather than to catch.");
}
return 9; // _URC_FAILURE
}
// libm. Plain forwarding - the host has the same IEEE doubles the guest wants.
#define FMOD_MATH_D1(name, expr) \
uint32_t Shim_##name(GuestEngine& eng, uint32_t r0, uint32_t r1, uint32_t r2, uint32_t r3, \
uint32_t sp) { \
double x = ReadDoubleArg(eng, 0, r0, r1, r2, r3, sp); \
return ReturnDouble(eng, (expr)); \
}
FMOD_MATH_D1(acos, acos(x))
FMOD_MATH_D1(cos, cos(x))
FMOD_MATH_D1(sin, sin(x))
FMOD_MATH_D1(tan, tan(x))
FMOD_MATH_D1(exp, exp(x))
FMOD_MATH_D1(log, log(x))
FMOD_MATH_D1(log10, log10(x))
FMOD_MATH_D1(sqrt, sqrt(x))
FMOD_MATH_D1(rint, rint(x))
#undef FMOD_MATH_D1
uint32_t Shim_atan2(GuestEngine& eng, uint32_t r0, uint32_t r1, uint32_t r2, uint32_t r3, uint32_t sp) {
return ReturnDouble(eng, atan2(ReadDoubleArg(eng, 0, r0, r1, r2, r3, sp),
ReadDoubleArg(eng, 2, r0, r1, r2, r3, sp)));
}
uint32_t Shim_log10f(GuestEngine&, uint32_t f, uint32_t, uint32_t, uint32_t, uint32_t) {
float v; memcpy(&v, &f, 4);
float r = log10f(v);
uint32_t out; memcpy(&out, &r, 4);
return out;
}
uint32_t Shim_lrintf(GuestEngine&, uint32_t f, uint32_t, uint32_t, uint32_t, uint32_t) {
float v; memcpy(&v, &f, 4);
return (uint32_t)(int32_t)lrintf(v);
}
// frexp/ldexp take or return a pointer/int alongside the double.
uint32_t Shim_frexp(GuestEngine& eng, uint32_t r0, uint32_t r1, uint32_t r2, uint32_t r3, uint32_t sp) {
double x = ReadDoubleArg(eng, 0, r0, r1, r2, r3, sp);
uint32_t expPtr = eng.ReadIncomingArg(2, r0, r1, r2, r3, sp);
int e = 0;
double m = frexp(x, &e);
if (expPtr) { int32_t v = e; memcpy(eng.G2H(expPtr), &v, 4); }
return ReturnDouble(eng, m);
}
uint32_t Shim_ldexp(GuestEngine& eng, uint32_t r0, uint32_t r1, uint32_t r2, uint32_t r3, uint32_t sp) {
double x = ReadDoubleArg(eng, 0, r0, r1, r2, r3, sp);
int32_t e = (int32_t)eng.ReadIncomingArg(2, r0, r1, r2, r3, sp);
return ReturnDouble(eng, ldexp(x, e));
}
// Remaining libc gaps.
uint32_t Shim_usleep(GuestEngine&, uint32_t us, uint32_t, uint32_t, uint32_t, uint32_t) {
return (uint32_t)usleep(us);
}
uint32_t Shim_memmem(GuestEngine& eng, uint32_t hay, uint32_t hayLen, uint32_t needle, uint32_t needleLen, uint32_t) {
if (!hay || !needle) return 0;
void* found = memmem(eng.G2H(hay), hayLen, eng.G2H(needle), needleLen);
if (!found) return 0;
return hay + (uint32_t)((uint8_t*)found - (uint8_t*)eng.G2H(hay));
}
uint32_t Shim_inet_addr(GuestEngine& eng, uint32_t s, uint32_t, uint32_t, uint32_t, uint32_t) {
return s ? (uint32_t)inet_addr((const char*)eng.G2H(s)) : 0xFFFFFFFFu;
}
uint32_t Shim_chown(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
// An app sandbox cannot chown anything anyway; real Android would fail this
// too, so reporting success costs nothing and failing could stall a caller.
return 0;
}
uint32_t Shim_pthread_attr_setdetachstate(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
// pthread_shim always creates detached-equivalent std::threads it owns, so
// the guest's preference here is already satisfied.
return 0;
}
// FMOD only reaches select() on its network-streaming path, which local
// playback never uses. Reporting "nothing ready" is the honest answer for a
// backend we do not provide - and it is logged, so if audio ever does depend on
// it that shows up as a named gap instead of silence.
uint32_t Shim_select(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
static bool logged = false;
if (!logged) {
logged = true;
Log("libc_shims: select() is not implemented - reporting 'no descriptors ready'. Only "
"FMOD's network-streaming path calls this; if audio depends on it, implement it here.");
}
return 0;
}
uint32_t Shim_operator_delete(GuestEngine& eng, uint32_t p, uint32_t, uint32_t, uint32_t, uint32_t) {
if (p) eng.heap().Free(p);
return 0;
}
void RegisterLibcImportShims(GuestEngine& engine) {
// Task #67 - runtime support the game's own ARM32 FMOD needs. See the
// block of implementations above for why these 37 live here.
engine.RegisterImportShim("__aeabi_idiv", Shim_aeabi_idiv);
engine.RegisterImportShim("__aeabi_uidiv", Shim_aeabi_uidiv);
engine.RegisterImportShim("__aeabi_idivmod", Shim_aeabi_idivmod);
engine.RegisterImportShim("__aeabi_uidivmod", Shim_aeabi_uidivmod);
engine.RegisterImportShim("__aeabi_uldivmod", Shim_aeabi_uldivmod);
engine.RegisterImportShim("__aeabi_dadd", Shim_aeabi_dadd);
engine.RegisterImportShim("__aeabi_dmul", Shim_aeabi_dmul);
engine.RegisterImportShim("__aeabi_dcmplt", Shim_aeabi_dcmplt);
engine.RegisterImportShim("__aeabi_f2d", Shim_aeabi_f2d);
engine.RegisterImportShim("__aeabi_ui2d", Shim_aeabi_ui2d);
engine.RegisterImportShim("__aeabi_ul2d", Shim_aeabi_ul2d);
engine.RegisterImportShim("__aeabi_atexit", Shim_aeabi_atexit);
engine.RegisterImportShim("__aeabi_unwind_cpp_pr0", Shim_aeabi_unwind_cpp_pr0);
engine.RegisterImportShim("acos", Shim_acos);
engine.RegisterImportShim("cos", Shim_cos);
engine.RegisterImportShim("sin", Shim_sin);
engine.RegisterImportShim("tan", Shim_tan);
engine.RegisterImportShim("exp", Shim_exp);
engine.RegisterImportShim("log", Shim_log);
engine.RegisterImportShim("log10", Shim_log10);
engine.RegisterImportShim("sqrt", Shim_sqrt);
engine.RegisterImportShim("rint", Shim_rint);
engine.RegisterImportShim("atan2", Shim_atan2);
engine.RegisterImportShim("log10f", Shim_log10f);
engine.RegisterImportShim("lrintf", Shim_lrintf);
engine.RegisterImportShim("frexp", Shim_frexp);
engine.RegisterImportShim("ldexp", Shim_ldexp);
engine.RegisterImportShim("usleep", Shim_usleep);
engine.RegisterImportShim("memmem", Shim_memmem);
engine.RegisterImportShim("inet_addr", Shim_inet_addr);
engine.RegisterImportShim("chown", Shim_chown);
engine.RegisterImportShim("pthread_attr_setdetachstate", Shim_pthread_attr_setdetachstate);
engine.RegisterImportShim("select", Shim_select);
engine.RegisterImportShim("_ZdlPv", Shim_operator_delete);
// ---- ctype.h ----
engine.RegisterImportShim("isalnum", Shim_isalnum);
engine.RegisterImportShim("isalpha", Shim_isalpha);
+22 -3
View File
@@ -57,9 +57,22 @@ uint32_t Shim_pthread_create(GuestEngine& eng, uint32_t threadOutPtr, uint32_t /
// itself (see ARM64_TRANSLATION_LAYER.md, "register/stack corruption"
// investigation). Reject loudly and immediately instead - one clear
// diagnostic beats six confusing downstream ones.
if (startRoutine >= eng.image_end()) {
Log("pthread_shim: REFUSING pthread_create - startRoutine=0x%x is not real image code (%s)",
startRoutine, eng.DescribeAddress(startRoutine).c_str());
//
// Corrected 2026-09-21 (task #67): this used to compare against
// image_end(), which was right only while exactly ONE image existed. Once
// the game's own libfmodex/libfmodevent load as sibling images - far above
// image_end() - that test rejected FMOD's OWN mixer thread as "not real
// image code". The refusal propagated all the way up as silence:
//
// pthread_create -> EINVAL -> sub_A9120 -> 33 -> System::init -> 33
// -> EventSystem::init -> 33 -> fmodGetInfo -> -1
// -> FMODAudioDevice never builds its AudioTrack
//
// IsGuestImageCode knows about every loaded image, so the guard keeps its
// original purpose without the single-image assumption.
if (!eng.IsGuestImageCode(startRoutine)) {
Log("pthread_shim: REFUSING pthread_create - startRoutine=0x%x is not inside any loaded "
"guest image (%s)", startRoutine, eng.DescribeAddress(startRoutine).c_str());
return 22; // EINVAL - matches pthread_create's own errno-style failure contract
}
@@ -79,6 +92,12 @@ uint32_t Shim_pthread_create(GuestEngine& eng, uint32_t threadOutPtr, uint32_t /
eng.EnsureThreadEngine();
uint32_t result = eng.CallGuestFunction(startRoutine, arg);
Log("pthread_shim: guest thread (handle %u) start_routine returned 0x%x", handle, result);
// Hand this thread's guest stack(s) and uc_engine back. Without this
// the thread-stack arena was one-way: the game creates threads across
// a session (one per race, among others) and after kMaxGuestThreads of
// them CarveThreadStack started returning 0, the next thread's start
// routine never ran, and the race came up as a black screen.
eng.ReleaseThreadEngine();
});
{
+212 -12
View File
@@ -1,3 +1,4 @@
#include <set>
#include <chrono>
#include <atomic>
#include "rtti_shims.h"
@@ -505,6 +506,7 @@ uint32_t Shim_ctype_char_do_widen(GuestEngine&, uint32_t /*this*/, uint32_t c, u
GuestAddr g_ctypeCharId = 0, g_ctypeCharFacet = 0;
GuestAddr g_numPutCharId = 0, g_numPutCharFacet = 0;
GuestAddr g_numGetCharId = 0, g_numGetCharFacet = 0;
// ---- num_put<char> facet - same "real vtable, only confirmed slots
// implemented" approach as ctype<char> above. Confirmed via IDA
@@ -679,6 +681,148 @@ uint32_t Shim_num_put_double(GuestEngine& eng, uint32_t, uint32_t streambuf, uin
return streambuf;
}
// ---- num_get<char> facet - reading numbers back OUT of a stream ----
//
// Confirmed live, not anticipated: the post-prologue dialog crashed because
// use_facet<num_get<char>> returned NULL and the guest called through it
// (guest 0x3e5248, decompiled as basic_istream<char>::operator>>). The NULL
// facet's "vtable" read produced the wild pointer 0x464c459b - the same
// "\x7fELF"-as-a-pointer crash shape FacetSlotCtx's comment warns about.
//
// libc++ basic_streambuf get-area pointers. The layout anchor is
// WriteCharToStreambuf's already-working put area (pptr@24, epptr@28): the six
// pointers run eback@8, gptr@12, egptr@16, pbase@20, pptr@24, epptr@28 after
// the vtable and the embedded locale, so the put offsets pin the get offsets.
constexpr uint32_t kSbGptr = 12, kSbEgptr = 16;
// ios_base::iostate bits, taken from THIS NDK's own <ios> rather than from
// memory (they differ between standard libraries - libstdc++ orders them the
// other way round).
constexpr uint32_t kIosBadbit = 0x1, kIosEofbit = 0x2, kIosFailbit = 0x4;
int PeekStreambuf(GuestEngine& eng, GuestAddr sb) {
if (!sb) return -1;
uint32_t g = 0, e = 0;
memcpy(&g, eng.G2H(sb + kSbGptr), 4);
memcpy(&e, eng.G2H(sb + kSbEgptr), 4);
// An exhausted get area would need a virtual underflow() call to refill.
// This shim does not make one - for the istringstream case that actually
// occurs here the whole string is already in the get area. Reported as
// end-of-input rather than silently treated as a parse failure.
if (!g || g >= e) return -1;
uint8_t c = 0;
memcpy(&c, eng.G2H(g), 1);
return c;
}
void BumpStreambuf(GuestEngine& eng, GuestAddr sb) {
uint32_t g = 0;
memcpy(&g, eng.G2H(sb + kSbGptr), 4);
g += 1;
memcpy(eng.G2H(sb + kSbGptr), &g, 4);
}
enum class NumGetKind { Bool, Signed, Unsigned, Float };
// Shared body for every do_get overload. Lifts the next token out of the get
// area and hands it to the host's own strtoll/strtoull/strtod - the same
// "offload the hard part to real libc instead of reimplementing it" choice
// num_put makes for formatting. Like num_put, it deliberately ignores
// ios_base's formatting flags (base/boolalpha), which is a documented scope
// cut, not an oversight.
uint32_t NumGetCommon(GuestEngine& eng, uint32_t sb, uint32_t errPtr, uint32_t valPtr,
NumGetKind kind, int width) {
uint32_t err = 0;
auto storeErr = [&]() { if (errPtr) memcpy(eng.G2H(errPtr), &err, 4); };
if (!sb || !valPtr) {
err = kIosFailbit | kIosBadbit;
storeErr();
return sb;
}
int c = PeekStreambuf(eng, sb);
while (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v') {
BumpStreambuf(eng, sb);
c = PeekStreambuf(eng, sb);
}
std::string tok;
if (c == '+' || c == '-') { tok.push_back((char)c); BumpStreambuf(eng, sb); c = PeekStreambuf(eng, sb); }
bool sawDigit = false;
while (c >= 0) {
char ch = (char)c;
bool part = (ch >= '0' && ch <= '9');
if (!part && kind == NumGetKind::Float &&
(ch == '.' || ch == 'e' || ch == 'E' || ch == '+' || ch == '-')) part = true;
if (!part) break;
if (ch >= '0' && ch <= '9') sawDigit = true;
tok.push_back(ch);
BumpStreambuf(eng, sb);
c = PeekStreambuf(eng, sb);
}
if (!sawDigit) {
// No number here at all. Real num_get sets failbit and leaves the
// destination untouched; matching that is what lets the caller's
// stream go into a failed state instead of reading a fabricated value.
err = kIosFailbit;
if (c < 0) err |= kIosEofbit;
storeErr();
Log("rtti_shims: num_get found no number to parse (streambuf=0x%x) - setting failbit, "
"leaving the destination untouched", sb);
return sb;
}
if (c < 0) err |= kIosEofbit; // consumed right up to the end of input
uint8_t b8; uint16_t b16; uint32_t b32; uint64_t b64;
switch (kind) {
case NumGetKind::Bool:
b8 = (uint8_t)(strtoll(tok.c_str(), nullptr, 10) != 0);
memcpy(eng.G2H(valPtr), &b8, 1);
break;
case NumGetKind::Signed: {
long long v = strtoll(tok.c_str(), nullptr, 10);
if (width == 8) { b64 = (uint64_t)v; memcpy(eng.G2H(valPtr), &b64, 8); }
else { b32 = (uint32_t)(int32_t)v; memcpy(eng.G2H(valPtr), &b32, 4); }
break;
}
case NumGetKind::Unsigned: {
unsigned long long v = strtoull(tok.c_str(), nullptr, 10);
if (width == 8) { b64 = (uint64_t)v; memcpy(eng.G2H(valPtr), &b64, 8); }
else if (width == 2) { b16 = (uint16_t)v; memcpy(eng.G2H(valPtr), &b16, 2); }
else { b32 = (uint32_t)v; memcpy(eng.G2H(valPtr), &b32, 4); }
break;
}
case NumGetKind::Float: {
double v = strtod(tok.c_str(), nullptr);
if (width == 4) { float f = (float)v; memcpy(eng.G2H(valPtr), &f, 4); }
else { memcpy(eng.G2H(valPtr), &v, 8); }
break;
}
}
storeErr();
return sb;
}
// r0=this(facet, unused), r1=begin iterator (an istreambuf_iterator is a bare
// streambuf*), r2=end iterator (unused - the get area's own end bounds us),
// r3=ios_base* (unused, same scope cut as num_put), stack slot 4 = iostate*,
// slot 5 = the destination. Returns the iterator, i.e. the streambuf - exactly
// how the working num_put overloads return theirs.
uint32_t NumGetEntry(GuestEngine& eng, uint32_t sb, uint32_t sp, NumGetKind kind, int width) {
uint32_t errPtr = eng.ReadIncomingArg(4, 0, 0, 0, 0, sp);
uint32_t valPtr = eng.ReadIncomingArg(5, 0, 0, 0, 0, sp);
return NumGetCommon(eng, sb, errPtr, valPtr, kind, width);
}
uint32_t Shim_num_get_bool(GuestEngine& e, uint32_t, uint32_t sb, uint32_t, uint32_t, uint32_t sp) { return NumGetEntry(e, sb, sp, NumGetKind::Bool, 1); }
uint32_t Shim_num_get_s32(GuestEngine& e, uint32_t, uint32_t sb, uint32_t, uint32_t, uint32_t sp) { return NumGetEntry(e, sb, sp, NumGetKind::Signed, 4); }
uint32_t Shim_num_get_s64(GuestEngine& e, uint32_t, uint32_t sb, uint32_t, uint32_t, uint32_t sp) { return NumGetEntry(e, sb, sp, NumGetKind::Signed, 8); }
uint32_t Shim_num_get_u16(GuestEngine& e, uint32_t, uint32_t sb, uint32_t, uint32_t, uint32_t sp) { return NumGetEntry(e, sb, sp, NumGetKind::Unsigned, 2); }
uint32_t Shim_num_get_u32(GuestEngine& e, uint32_t, uint32_t sb, uint32_t, uint32_t, uint32_t sp) { return NumGetEntry(e, sb, sp, NumGetKind::Unsigned, 4); }
uint32_t Shim_num_get_u64(GuestEngine& e, uint32_t, uint32_t sb, uint32_t, uint32_t, uint32_t sp) { return NumGetEntry(e, sb, sp, NumGetKind::Unsigned, 8); }
uint32_t Shim_num_get_float(GuestEngine& e, uint32_t, uint32_t sb, uint32_t, uint32_t, uint32_t sp) { return NumGetEntry(e, sb, sp, NumGetKind::Float, 4); }
uint32_t Shim_num_get_double(GuestEngine& e, uint32_t, uint32_t sb, uint32_t, uint32_t, uint32_t sp){ return NumGetEntry(e, sb, sp, NumGetKind::Float, 8); }
// use_facet<Facet>(locale) looks up a facet by its static `id` member and
// THROWS std::bad_cast if not found - facet types with no evidence of ever
// being requested by this binary (num_get, ...) keep the original
@@ -688,17 +832,30 @@ uint32_t Shim_num_put_double(GuestEngine& eng, uint32_t, uint32_t streambuf, uin
// and num_put<char> are different - confirmed real, actually-called use
// (see their own comments above) - return the real facet objects built
// above.
uint32_t Shim_use_facet(GuestEngine&, uint32_t, uint32_t idAddr, uint32_t, uint32_t, uint32_t) {
uint32_t Shim_use_facet(GuestEngine& eng, uint32_t, uint32_t idAddr, uint32_t, uint32_t, uint32_t) {
if (idAddr == g_ctypeCharId) return g_ctypeCharFacet;
if (idAddr == g_numPutCharId) return g_numPutCharFacet;
static bool logged = false;
if (!logged) {
logged = true;
Log("rtti_shims: std::locale::use_facet() for an id other than ctype<char>/num_put<char> "
"(0x%x) - this engine's locale objects have no real facet table for it (see "
"rtti_shims.cpp) - returning NULL rather than a facet object a subsequent virtual call "
"would crash through; real libc++ would throw std::bad_cast here, which needs a guest "
"stack unwinder this engine doesn't have (see __cxa_bad_typeid's own comment)", idAddr);
if (idAddr == g_numGetCharId) return g_numGetCharFacet;
// Report once PER DISTINCT ID, not once overall. The previous single
// `static bool` meant the first unimplemented facet hid every other one
// behind it, so a crash caused by the second facet looked like it had no
// diagnostic at all. Naming the facet and the call site matters just as
// much: the id is an address our own AllocPermanent handed out, which says
// nothing on its own - resolving one used to cost a rebuild and a repro.
static std::set<uint32_t> reported;
if (reported.insert(idAddr).second) {
const char* name = eng.NameForDataSymbol(idAddr);
uint32_t callerLr = 0;
if (uc_engine* uc = eng.uc()) uc_reg_read(uc, UC_ARM_REG_LR, &callerLr);
Log("rtti_shims: std::locale::use_facet(%s) is NOT implemented - id=0x%x, called from guest "
"LR=0x%x. Returning NULL; the caller will dereference it and take a wild pointer through "
"whatever the NULL facet's vtable slot reads (this is the known "
"\"\\x7fELF\"-as-a-pointer crash shape). ctype<char> and num_put<char> are the only "
"facets this engine builds - implement this one next if the game depends on it. Real "
"libc++ would throw std::bad_cast, which needs a guest stack unwinder this engine does "
"not have (see __cxa_bad_typeid's own comment).",
name ? name : "<unregistered id>", idAddr, callerLr);
}
return 0;
}
@@ -827,9 +984,52 @@ void SetupRttiDataSymbols(GuestEngine& engine) {
g_numPutCharId = engine.AllocPermanent(4);
engine.RegisterDataSymbol("_ZNSt6__ndk17num_putIcNS_19ostreambuf_iteratorIcNS_11char_traitsIcEEEEE2idE", g_numPutCharId);
}
// ---- Other facet `id` statics - inert (see Shim_use_facet's own
// comment - no evidence anything requests these yet) ----
engine.RegisterDataSymbol("_ZNSt6__ndk17num_getIcNS_19istreambuf_iteratorIcNS_11char_traitsIcEEEEE2idE", engine.AllocPermanent(4));
// ---- num_get<char> facet - a real, callable vtable. This used to be an
// inert `id` with no facet behind it, on the stated grounds that nothing
// requested it; that turned out to be wrong and it cost a crash (see
// NumGetCommon's own comment).
//
// The slot offsets are DERIVED, not guessed. num_put above is confirmed
// working, and its known-good slots (16=long, 20=long long,
// 24=unsigned long, 32=double, 40=const void*) only line up with this
// NDK's declaration order if three slots precede the first virtual:
// the complete and deleting destructors plus
// __shared_count::__on_zero_shared. Applying that same prefix to
// num_get's declaration order in this NDK's own <locale> produces the
// table below - and offset 28, the one the game actually calls, lands on
// `unsigned int&`, exactly what basic_istream::operator>>(unsigned int&)
// would invoke. Two independent routes, same answer.
//
// Slots outside this table still route to the logging stub rather than to
// a guess, so the next gap names itself instead of crashing.
{
constexpr int kNumGetCharVtableSlots = 24;
GuestAddr vtable = engine.AllocPermanent(kNumGetCharVtableSlots * 4);
for (int i = 0; i < kNumGetCharVtableSlots; i++) {
int byteOffset = i * 4;
ImportShimFn impl = nullptr;
switch (byteOffset) {
case 12: impl = Shim_num_get_bool; break; // bool&
case 16: impl = Shim_num_get_s32; break; // long&
case 20: impl = Shim_num_get_s64; break; // long long&
case 24: impl = Shim_num_get_u16; break; // unsigned short&
case 28: impl = Shim_num_get_u32; break; // unsigned int& <- the crashing call
case 32: impl = Shim_num_get_u32; break; // unsigned long& (32-bit here)
case 36: impl = Shim_num_get_u64; break; // unsigned long long&
case 40: impl = Shim_num_get_float; break; // float&
case 44: impl = Shim_num_get_double; break; // double&
case 48: impl = Shim_num_get_double; break; // long double& (== double on ARM32)
default: break;
}
auto* ctx = new FacetSlotCtx{byteOffset, impl};
GuestAddr stub = engine.AllocCodeStub(FacetSlotDispatch, ctx);
if (vtable && stub) memcpy(engine.G2H(vtable + (uint32_t)byteOffset), &stub, 4);
}
g_numGetCharFacet = engine.AllocPermanent(4);
if (g_numGetCharFacet && vtable) memcpy(engine.G2H(g_numGetCharFacet), &vtable, 4);
g_numGetCharId = engine.AllocPermanent(4);
engine.RegisterDataSymbol("_ZNSt6__ndk17num_getIcNS_19istreambuf_iteratorIcNS_11char_traitsIcEEEEE2idE", g_numGetCharId);
}
// ---- The shared "classic locale" every locale::locale() call returns
// (see Shim_locale_ctor's own comment) ----
@@ -9,17 +9,121 @@
#include <jni.h>
#include <cstdint>
#include <cstring>
#include <set>
#include <string>
#include "emu/jni_shim.h"
#include "util/util.h"
#include "real_native_call.h"
#include "real_native_offsets.h"
extern "C" JNIEXPORT jint JNICALL
Java_org_fmod_FMODAudioDevice_fmodGetInfo(JNIEnv*, jobject, jint) {
return 0;
// ---- FMOD audio bridge (task #67, 2026-09-21) ----
//
// Both of these used to `return 0`, silently, on the stated grounds that FMOD
// was not loaded because no arm64 build exists. That is still true of arm64 -
// but the game's real ARM32 libfmodex/libfmodevent now run inside the engine
// as secondary guest images, and both of these symbols are among their
// exports, so the calls can be forwarded to the real implementations.
//
// Why this pair is the whole audio path: FMOD on this Android build does not
// drive the output device from native code. org/fmod/FMODAudioDevice.java owns
// an AudioTrack and pulls PCM down through these two methods. Its thread reads
//
// int rate = fmodGetInfo(FMOD_INFO_SAMPLERATE);
// if (rate > 0) { ...create the AudioTrack, then loop on fmodProcess... }
//
// so a `return 0` from fmodGetInfo alone made that thread give up before the
// AudioTrack was ever constructed. Nothing downstream could have produced
// sound regardless of what FMOD itself did.
// Re-enabled 2026-09-21 once the JNI call epoch became per-thread.
//
// These two run on FMODAudioDevice's own AudioTrack thread, and the first
// attempt aborted the process inside GLThread with "JNI DETECTED ERROR IN
// APPLICATION: jfieldID was NULL". The first diagnosis - that CallRealNative's
// SetRealEnv() clobbered a shared JNIEnv - was WRONG: real_env_ was already
// thread_local, with a lazy AttachCurrentThread fallback.
//
// The actual culprit was one line further on. SetRealEnv also calls
// JniHandleTable::BumpCallEpoch(), and that epoch was a single process-wide
// counter whose own comment said it invalidates the previous call's local refs
// "whether or not it's the same thread". So every 100 ms this bridge was
// invalidating the references GLThread held mid-call. The epoch is per-thread
// now, which is the more accurate model anyway - a local ref's lifetime is
// scoped to a native call on its own thread.
static constexpr bool kEnableFmodAudioBridge = true;
static GuestAddr FmodGuestSymbol(const char* name) {
GuestAddr addr = GuestEngine::Instance().LookupSecondaryExport(name);
if (!addr) {
static std::set<std::string> reported;
if (reported.insert(name).second) {
Log("fmod_bridge: '%s' is not exported by any loaded guest image - audio stays silent. "
"Did libfmodex.so/libfmodevent.so fail to load? (see LoadSiblingLibraries)", name);
}
}
return addr;
}
extern "C" JNIEXPORT jint JNICALL
Java_org_fmod_FMODAudioDevice_fmodProcess(JNIEnv*, jobject, jobject) {
return 0;
Java_org_fmod_FMODAudioDevice_fmodGetInfo(JNIEnv* env, jobject thiz, jint info) {
if (!kEnableFmodAudioBridge) return 0;
static GuestAddr fn = FmodGuestSymbol("Java_org_fmod_FMODAudioDevice_fmodGetInfo");
if (!fn) return 0;
// Deliberately NOT logged per call: FMODAudioDevice polls this from its
// audio thread continuously (1,210 calls in a 40-second run), so a log
// line here is a steady drip into a buffer we already lose history to.
return (jint)CallRealNative(env, thiz, fn, {(uint32_t)info});
}
extern "C" JNIEXPORT jint JNICALL
Java_org_fmod_FMODAudioDevice_fmodProcess(JNIEnv* env, jobject thiz, jobject buffer) {
if (!kEnableFmodAudioBridge) return 0;
static GuestAddr fn = FmodGuestSymbol("Java_org_fmod_FMODAudioDevice_fmodProcess");
if (!fn || !buffer) return 0;
// The ByteBuffer came from Java's own allocateDirect, so it lives at a host
// address the guest cannot write to - GetDirectBufferAddress would hand
// guest FMOD a zero. Bounce through a guest-memory buffer of the same size
// instead, exactly as the font fix does for AndroidBitmap_lockPixels, and
// copy the rendered PCM back out afterwards.
jlong capacity = env->GetDirectBufferCapacity(buffer);
void* hostDest = env->GetDirectBufferAddress(buffer);
if (capacity <= 0 || !hostDest) {
static bool logged = false;
if (!logged) {
logged = true;
Log("fmod_bridge: fmodProcess got a ByteBuffer with no direct address (capacity=%lld) - "
"returning silence", (long long)capacity);
}
return 0;
}
// Allocated once and reused: this runs on the AudioTrack thread at the
// audio buffer rate, so per-call allocation would be both wasteful and a
// source of jitter. The capacity is fixed by FMOD's DSP buffer settings
// and does not change after the device starts, but it is re-checked rather
// than assumed.
static GuestAddr guestBuf = 0;
static uint32_t guestBufSize = 0;
static uint32_t guestBufHandle = 0;
if (guestBufSize != (uint32_t)capacity) {
guestBuf = GuestEngine::Instance().heap().Alloc((uint32_t)capacity);
if (!guestBuf) {
Log("fmod_bridge: could not allocate a %lld-byte guest audio buffer - silence",
(long long)capacity);
return 0;
}
guestBufSize = (uint32_t)capacity;
guestBufHandle = JniShim::Instance().NewGuestBackedDirectBuffer(
GuestEngine::Instance(), guestBuf, guestBufSize);
Log("fmod_bridge: audio bounce buffer ready - %u bytes at guest 0x%x (handle %u)",
guestBufSize, guestBuf, guestBufHandle);
}
if (!guestBufHandle) return 0;
memset(GuestEngine::Instance().G2H(guestBuf), 0, guestBufSize);
jint r = (jint)CallRealNative(env, thiz, fn, {guestBufHandle});
memcpy(hostDest, GuestEngine::Instance().G2H(guestBuf), guestBufSize);
return r;
}
#define NIMBLE_COMPONENT_STUB(name) \
+16
View File
@@ -5,6 +5,7 @@
#include <android/log.h>
#include <jni.h>
#include "main.h"
#include "crash_handler.h"
#include "util/util.h"
#include <unistd.h>
#include <unwind.h>
@@ -497,6 +498,21 @@ static constexpr bool kEnableMapScreenCtorTraceHook = false; // TEMP: isolating
// comment), which JNI_OnLoad has no reliable way to obtain on its own
// (no Context/AssetManager access at this point). See
// Java_..._MultiplayerCore_loadEmulatedLibapp below for where that now happens.
// extern "C" is NOT optional here: without it the name is C++-mangled and the
// JVM cannot find it by its JNI name. The first version omitted it, and the
// only symptom was "No implementation found" at runtime - which looked exactly
// like a load-order problem and cost a wrong fix before the symbol table was
// actually read.
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeInstallCrashHandler(JNIEnv* env, jobject,
jstring dir, jstring buildStamp) {
const char* d = dir ? env->GetStringUTFChars(dir, nullptr) : nullptr;
const char* b = buildStamp ? env->GetStringUTFChars(buildStamp, nullptr) : nullptr;
InstallCrashHandler(d, b);
if (d) env->ReleaseStringUTFChars(dir, d);
if (b) env->ReleaseStringUTFChars(buildStamp, b);
}
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
JNIEnv* env = nullptr;
if (vm->GetEnv((void**)&env, JNI_VERSION_1_6) == JNI_OK) {
@@ -361,6 +361,34 @@ struct uc_struct {
// linked lists containing hooks per type
struct list hook[UC_HOOK_MAX];
struct list hooks_to_del;
// NFSMW arm64-poc, task #61: flat guest mapping.
//
// When non-zero, the whole guest address space is ONE contiguous host
// block starting here, and a guest address translates to a host address
// by plain addition - which is exactly what GuestEngine's G2H() already
// does. The aarch64 TCG backend can then emit a single
// `LDR Wd, [X28, Wn, UXTW]` per guest memory access instead of the
// nine-instruction softmmu TLB check (see tcg_out_qemu_ld/st).
//
// Measured motivation: those TLB sequences are 29.8% of ALL generated
// host code (16,202 sequences x 35.8 bytes against 1,943,672 bytes total,
// counted during a prologue load).
//
// Set by the engine AFTER mapping its spans and BEFORE any translation
// happens, and never changed afterwards - the generated code bakes this
// value into a register, so changing it later would silently corrupt
// every existing translated block.
uint64_t flat_map_base;
// Regenerates the TCG prologue so a freshly-set flat_map_base actually
// reaches the generated code. Needed because the prologue is built inside
// uc_open(), before the embedder can say anything - and it is the prologue
// that loads the base into X28. Set by tcg_exec_init, which unlike uc.c is
// compiled per-architecture and can name tcg_prologue_init/tcg_nb_tbs.
// Returns 0 on success, -1 if blocks were already translated (in which
// case the base is left alone rather than corrupting those blocks).
int (*uc_reinit_prologue)(struct uc_struct *uc);
// NFSMW arm64-poc: page -> MemoryRegion cache for notdirty_write, which
// otherwise calls memory_mapping() on EVERY guest write just to test one
// permission bit (see cputlb.c). Lives here rather than in a __thread
@@ -1449,6 +1449,35 @@ size_t uc_context_size(uc_engine *uc);
UNICORN_EXPORT
uc_err uc_context_free(uc_context *context);
/*
NFSMW arm64-poc extension (task #61). Declare that the entire guest address
space is one contiguous host block starting at @base, so the JIT can reach
guest memory by plain addition instead of a software TLB lookup. On aarch64
hosts with a 32-bit guest this replaces a nine-instruction TLB check with a
single LDR/STR, which is why it exists.
Enabling this DISABLES, for every emulated memory access:
- UC_PROT_* enforcement (uc_mem_protect becomes advisory),
- UC_HOOK_MEM_READ / _WRITE / _INVALID callbacks,
- self-modifying-code detection, so guest stores to pages that already
hold translated code will NOT invalidate those translations.
Callers depending on any of the above must leave it off.
@uc: handle returned by uc_open()
@base: host address the guest's address 0 maps to. The caller must guarantee
that [base, base + 4GiB) is reserved host address space, since a 32-bit
guest address is zero-extended and used as an unchecked offset from it.
Pass 0 to keep the normal software-MMU path.
Must be called before any code is translated - generated code bakes @base
into a register, so a later change would silently corrupt existing blocks.
@return UC_ERR_OK on success, or other value on failure (refer to uc_err enum
for detailed error).
*/
UNICORN_EXPORT
uc_err uc_set_flat_map_base(uc_engine *uc, uint64_t base);
#ifdef __cplusplus
}
#endif
@@ -1217,6 +1217,23 @@ static uc_err uc_gen_tb(struct uc_struct *uc, uint64_t addr, uc_tb *out_tb)
/* Must be called before using the QEMU cpus. 'tb_size' is the size
(in bytes) allocated to the translation buffer. Zero means default
size. */
/*
* NFSMW arm64-poc, task #61. Rebuild the prologue in place after
* uc_set_flat_map_base(). Safe only with zero translated blocks - the caller
* enforces that - because tcg_prologue_init resets code_ptr to the start of
* the code buffer and re-emits from there.
*/
static int uc_reinit_prologue(struct uc_struct *uc)
{
if (tcg_nb_tbs(uc->tcg_ctx) != 0) {
return -1;
}
tb_exec_unlock(uc);
tcg_prologue_init(uc->tcg_ctx);
tb_exec_lock(uc);
return 0;
}
void tcg_exec_init(struct uc_struct *uc, uint32_t tb_size)
{
/* remove tcg object. init here. */
@@ -1233,6 +1250,7 @@ void tcg_exec_init(struct uc_struct *uc, uint32_t tb_size)
tb_exec_unlock(uc);
tcg_prologue_init(uc->tcg_ctx);
tb_exec_lock(uc);
uc->uc_reinit_prologue = uc_reinit_prologue;
/* cpu_interrupt_handler is not used in uc1 */
uc->l1_map = g_malloc0(sizeof(void *) * V_L1_MAX_SIZE);
/* Invalidate / Cache TBs */
@@ -69,13 +69,39 @@ static const int tcg_target_call_oarg_regs[1] = {
#define TCG_REG_TMP TCG_REG_X30
#define TCG_VEC_TMP TCG_REG_V31
/* The register holding the base of the guest's address space. Defined
unconditionally because task #61's flat mapping below uses it under
CONFIG_SOFTMMU too, for exactly the same purpose as user-mode does. */
#define TCG_REG_GUEST_BASE TCG_REG_X28
/*
* NFSMW arm64-poc, task #61: flat guest mapping under softmmu.
*
* When the embedder has told us the whole guest address space is one
* contiguous host block (uc->flat_map_base), a guest access needs no TLB
* lookup at all - the host address is base + zero-extended guest address,
* which aarch64 does in the addressing mode itself. That turns the nine
* instruction tcg_out_tlb_read sequence plus its slow-path label into a
* SINGLE instruction: LDR Wd, [X28, Wn, UXTW].
*
* Only valid because TARGET_LONG_BITS == 32 here: a 32-bit guest address
* zero-extends to at most 4 GiB - 1, so it cannot escape the host
* reservation. The guard is spelled out rather than assumed.
*
* What this deliberately gives up (see guest_engine.cpp's SetFlatMapBase):
* UC_PROT_* enforcement, UC_HOOK_MEM_* callbacks, and self-modifying-code
* detection via notdirty_write. The embedder must not enable it while it
* depends on any of those.
*/
#define UC_FLAT_MAP(s) (TARGET_LONG_BITS == 32 && (s)->uc->flat_map_base)
#ifndef CONFIG_SOFTMMU
/* Note that XZR cannot be encoded in the address base register slot,
as that actaully encodes SP. So if we need to zero-extend the guest
address, via the address index register slot, we need to load even
a zero guest base into a register. */
#define USE_GUEST_BASE (guest_base != 0 || TARGET_LONG_BITS == 32)
#define TCG_REG_GUEST_BASE TCG_REG_X28
#endif
static inline bool reloc_pc26(tcg_insn_unit *code_ptr, tcg_insn_unit *target)
@@ -1823,6 +1849,12 @@ static void tcg_out_qemu_ld(TCGContext *s, TCGReg data_reg, TCGReg addr_reg,
unsigned mem_index = get_mmuidx(oi);
tcg_insn_unit *label_ptr;
if (UC_FLAT_MAP(s)) {
tcg_out_qemu_ld_direct(s, memop, ext, data_reg,
TCG_REG_GUEST_BASE, otype, addr_reg);
return;
}
tcg_out_tlb_read(s, addr_reg, memop, &label_ptr, mem_index, 1);
tcg_out_qemu_ld_direct(s, memop, ext, data_reg,
TCG_REG_X1, otype, addr_reg);
@@ -1848,6 +1880,12 @@ static void tcg_out_qemu_st(TCGContext *s, TCGReg data_reg, TCGReg addr_reg,
unsigned mem_index = get_mmuidx(oi);
tcg_insn_unit *label_ptr;
if (UC_FLAT_MAP(s)) {
tcg_out_qemu_st_direct(s, memop, data_reg,
TCG_REG_GUEST_BASE, otype, addr_reg);
return;
}
tcg_out_tlb_read(s, addr_reg, memop, &label_ptr, mem_index, 0);
tcg_out_qemu_st_direct(s, memop, data_reg,
TCG_REG_X1, otype, addr_reg);
@@ -2850,6 +2888,17 @@ static void tcg_target_qemu_prologue(TCGContext *s)
tcg_out_movi(s, TCG_TYPE_PTR, TCG_REG_GUEST_BASE, guest_base);
tcg_regset_set_reg(s->reserved_regs, TCG_REG_GUEST_BASE);
}
#else
/*
* Task #61. Same idea as the user-mode path above, but the base comes
* from the embedder instead of QEMU's global guest_base. X28 was already
* pushed as x27's pair partner a few lines up, and the epilogue restores
* it, so clobbering it here is safe for our caller.
*/
if (UC_FLAT_MAP(s)) {
tcg_out_movi(s, TCG_TYPE_PTR, TCG_REG_GUEST_BASE, s->uc->flat_map_base);
tcg_regset_set_reg(s->reserved_regs, TCG_REG_GUEST_BASE);
}
#endif
tcg_out_mov(s, TCG_TYPE_PTR, TCG_AREG0, tcg_target_call_iarg_regs[0]);
+32
View File
@@ -2594,6 +2594,38 @@ uc_err uc_context_restore(uc_engine *uc, uc_context *context)
return UC_ERR_OK;
}
UNICORN_EXPORT
uc_err uc_set_flat_map_base(uc_engine *uc, uint64_t base)
{
uint64_t previous = uc->flat_map_base;
bool was_initialised = uc->init_done;
// Set the base BEFORE UC_INIT. Engine setup is lazy in Unicorn - uc_open()
// only allocates the handle, and the first API call using UC_INIT is what
// builds the CPU, the TCG context and the prologue. Assigning here means
// that, in the common case, the prologue is generated with the right base
// the first time and needs no rebuilding at all.
uc->flat_map_base = base;
UC_INIT(uc);
if (!was_initialised) {
return UC_ERR_OK;
}
// The engine was already up, so a prologue exists that loaded a different
// base into the register. Rebuild it - which also refuses, leaving the old
// base in place, if any block has already been translated against it.
if (!uc->uc_reinit_prologue) {
uc->flat_map_base = previous;
return UC_ERR_HANDLE;
}
if (uc->uc_reinit_prologue(uc) != 0) {
uc->flat_map_base = previous;
return UC_ERR_ARG;
}
return UC_ERR_OK;
}
UNICORN_EXPORT
uc_err uc_context_free(uc_context *context)
{
@@ -22,6 +22,20 @@ object MultiplayerCore {
*/
external fun triggerTrueDirectCarSelectJump()
/**
* ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md): loads
* the original armeabi-v7a libapp.so through mpcore's embedded ARM32
* CPU-emulation core (Unicorn) instead of a real System.loadLibrary
* dlopen(), and installs whichever hooks are enabled in main.cpp.
* `path` must be a real file (e.g. extracted from assets to
* filesDir/libapp.so at first run - libapp.so is shipped as a raw
* asset, not under jniLibs, since this app declares only arm64-v8a and
* the packaging system would refuse/ignore an armeabi-v7a .so there).
* Returns false on any load failure (bad ELF, mmap failure, ...) -
* check logcat's "GuestEngine"/"mpcore_log" tag for why.
*/
external fun loadEmulatedLibapp(path: String): Boolean
fun loadCore() {
System.loadLibrary("mpcore")
installCarSelectLoadoutTestTrigger()
+26
View File
@@ -0,0 +1,26 @@
# Standalone armeabi-v7a artifact for the 2026-09-16 "isolated
# std::ostringstream repro" test (see ARM64_TRANSLATION_LAYER.md and
# ostream_repro.cpp's own top comment). Same pattern as ../trace_agent's own
# CMakeLists.txt - deliberately NOT wired into the main Gradle build
# (settings.gradle.kts), built and pushed to the device independently via
# build.sh. Default STL linkage (c++_shared, the NDK CMake toolchain's own
# default - NOT overridden to c++_static here) is a deliberate choice, not
# an oversight - see ostream_repro.cpp's top comment for why matching
# libapp.so's own dynamic-libc++ linkage matters for this specific test.
cmake_minimum_required(VERSION 3.22.1)
project(ostream_repro)
add_library(ostream_repro SHARED
ostream_repro.cpp
)
target_compile_options(ostream_repro PRIVATE -Wall -Wno-unused-parameter)
# Standalone native executable (2026-09-17, ARM64_TRANSLATION_LAYER.md - "does
# real hardware ever see runaway basic_stringbuf::overflow() growth" check).
# See ostream_stress.cpp's own top comment. Independent target, does not
# affect the ostream_repro library above.
add_executable(ostream_stress
ostream_stress.cpp
)
target_compile_options(ostream_stress PRIVATE -Wall -Wno-unused-parameter)
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Builds libostream_repro.so for armeabi-v7a via the NDK's CMake toolchain
# file - standalone, deliberately outside the Gradle build, same pattern as
# ../trace_agent/build.sh. Output: build/libostream_repro.so, ready to push
# to a device for GuestEngine::LoadSecondaryImage to load (see
# mpcore/src/main/cpp/emu/ostream_repro_test.cpp).
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
# ANDROID_STL=c++_static explicitly - see ostream_repro.cpp's own top
# comment for the full reasoning (tried c++_shared FIRST, actually built
# and inspected both outcomes with llvm-readelf before deciding, not
# guessed): the modern NDK r27 toolchain's libc++ headers extern-template-
# declare basic_stringbuf<char>/basic_ostream<char>/basic_ios<char>/
# basic_ostringstream<char> (their vtables AND ctors/dtors become UNDEFINED
# imports resolved against libc++_shared.so, confirmed via
# `llvm-readelf --dyn-syms` on a real c++_shared build of this exact file),
# which GuestEngine has zero shims for - that combination would make this
# test fail for an uninteresting, unrelated reason (unimplemented vtable
# stub returning 0) instead of actually exercising real, compiled
# std::basic_stringbuf<char>::str()/write() logic. c++_static compiles that
# logic directly into THIS .so's own .text - both a working build AND a
# closer functional match to what libapp.so's own (much older) toolchain
# evidently did for sub_79CD4/sub_27160C (confirmed real, compiled,
# fixed-address code inside libapp.so itself, never external imports -
# see ARM64_TRANSLATION_LAYER.md's 2026-09-16 entries).
cmake -B build -G Ninja \
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \
-DANDROID_ABI=armeabi-v7a \
-DANDROID_PLATFORM=android-27 \
-DANDROID_STL=c++_static \
-DCMAKE_BUILD_TYPE=Debug \
.
cmake --build build
echo "Built: $(pwd)/build/libostream_repro.so"
+132
View File
@@ -0,0 +1,132 @@
// Standalone, minimal armeabi-v7a artifact for the 2026-09-16
// ARM64_TRANSLATION_LAYER.md "isolated std::ostringstream repro" test - see
// that doc's entry of the same date for the full methodology and the
// GuestEngine-side driver (mpcore/src/main/cpp/emu/ostream_repro_test.cpp)
// that loads and calls this.
//
// Why this exists: the real game's own libapp.so, deep inside its shader-
// variant builder (sub_4702D8, per ARM64_TRANSLATION_LAYER.md's 2026-09-16
// entries), writes several string literals to a real, compiled
// std::ostringstream-shaped object via operator<< and then extracts the
// accumulated text via what's effectively basic_stringbuf<char>::str() -
// and the extraction always comes back empty under GuestEngine, even though
// register/memory probes confirmed real content was genuinely written
// first. Rather than keep chasing that one binary's own hardcoded
// addresses (explicitly the wrong approach per the user's own direction -
// "ты сейчас пытаешься подогнать эмуляцию к одному единственному бинарнику,
// это не правильный подход" - that only tells us about THIS game, not
// whether GuestEngine itself has a real, general ostringstream-extraction
// bug), this reproduces the EXACT SAME write-then-extract shape in total
// isolation from every other line of game code, compiled fresh by the same
// NDK toolchain and loaded as its own tiny ELF image
// (GuestEngine::LoadSecondaryImage) alongside (not instead of) libapp.so.
//
// Deliberately built the SAME way libapp.so itself is (default NDK
// ANDROID_STL=c++_shared, not a static-libstdc++/c++_static build) rather
// than statically linking libc++ into this .so: rtti_shims.cpp's own
// RegisterRttiImportShims already shows GuestEngine hand-reimplements a
// SPECIFIC set of libc++-internal symbols (locale/ios_base/ctype<char>/
// num_put<char>/__shared_weak_count/std::mutex - all undefined imports in
// libapp.so's own .dynsym, meaning libapp.so links these dynamically
// against libc++_shared.so too, while template-heavy code like
// basic_stringbuf<char>'s own methods gets compiled directly into
// libapp.so's .text, same as here). A c++_static build of this artifact
// would sidestep ALL of those hand-shims entirely and test a completely
// different code path - less isolated from "does the real game's runtime
// dependency surface work," not more. Matching libapp.so's own linkage
// means this test exercises the EXACT SAME shim functions the real crash
// investigation already spent most of 2026-09-16 on (Shim_locale_ctor,
// Shim_use_facet, Shim_ctype_char_do_widen, Shim_ios_base_init) - if the
// bug lives in one of those, this test should reproduce it; if it doesn't,
// that's real evidence the bug is specific to something in libapp.so's own
// state/control flow instead.
//
// GuestEngine::LoadSecondaryImage resolves every undefined symbol through
// the SAME ResolveOrCreateImportStub/RegisterImportShim table libapp.so's
// own imports already use (see that function's own comment,
// guest_engine.cpp) - so this artifact needs no special-casing on the
// engine side beyond what already exists, with one confirmed exception:
// operator new/delete (_Znwj/_ZdlPv et al.) had NO shim registered anywhere
// in this codebase before this test - grepped the whole emu/ tree and came
// up empty. Not a pre-existing bug: libapp.so's own build apparently
// defines ITS OWN operator new/delete locally (a common AAA-engine pattern,
// pooled/custom allocators overriding the global operator) - a LOCALLY
// DEFINED symbol never touches ResolveOrCreateImportStub at all, so this
// engine never had to shim it before. A plain artifact like this one that
// does NOT override global operator new/delete needs the real ones, added
// to rtti_shims.cpp (Shim_operator_new/Shim_operator_delete) specifically
// to make this test possible - see that file's own comment.
#include <sstream>
#include <cstring>
#include <cstdint>
extern "C" __attribute__((visibility("default")))
int TestOstreamAssembly(char* outBuf, int outBufSize) {
// Matches the real game's own pattern (sub_4702D8): several SEPARATE
// writes via operator<< before one .str() extraction, not a single
// combined write - see this session's investigation notes on why that
// distinction might matter (a single write could mask a bug that only
// shows up across multiple overflow()/sputn() calls into the same
// streambuf). Content itself doesn't matter - it's deliberately
// boilerplate-shaped (comment lines + a function skeleton) to loosely
// mirror the real shader source text without claiming to BE a shader.
std::ostringstream oss;
oss << "//FRAGMENT SHADER\n";
oss << "//===========\n\n";
oss << "void main()\n{\n";
oss << "}\n";
std::string result = oss.str();
int32_t len = (int32_t)result.size();
if (outBuf && outBufSize >= 4) {
memcpy(outBuf, &len, sizeof(len));
int avail = outBufSize - 4;
int copyLen = (int)result.size();
if (copyLen > avail) copyLen = avail;
if (copyLen > 0) memcpy(outBuf + 4, result.data(), (size_t)copyLen);
if (copyLen < avail) outBuf[4 + copyLen] = 0; // NUL-terminate for easy logging, if room
}
return len;
}
// 2026-09-17, ARM64_TRANSLATION_LAYER.md - the 0x3d3d3d3d heap-overflow
// chase. TestOstreamAssembly's own pattern (above) uses 4 short, similarly-
// sized writes with no intervening function call - none of them alone force
// an IMMEDIATE SSO->heap transition, and there's no real CALL boundary
// between writes into the SAME stream. The real crash's own sequence
// (sub_46FD58) is different in both respects: its FIRST write is
// "//VERTEX SHADER\n//=============\n\n" (33 bytes - by itself already past
// libc++'s ~22-byte SSO capacity, forcing an immediate heap transition on
// the very first write), immediately followed by a call into a SEPARATE
// function (sub_4711C8) that writes MORE content ("//Attributes\n
// //==========\n", 26 bytes) into the SAME shared stream object, passed by
// pointer across that real call boundary. Reproducing that exact shape here
// - noinline to guarantee a genuine function-call boundary, not something
// the compiler could inline away - to test whether THIS specific pattern
// (not just "several small writes in one function") is what's needed to
// expose the bug under GuestEngine.
extern "C" __attribute__((noinline))
void WriteAttributesSectionNested(std::ostringstream& oss) {
oss << "//Attributes\n//==========\n";
}
extern "C" __attribute__((visibility("default")))
int TestOstreamAssemblyNested(char* outBuf, int outBufSize) {
std::ostringstream oss;
oss << "//VERTEX SHADER\n//=============\n\n";
WriteAttributesSectionNested(oss);
std::string result = oss.str();
int32_t len = (int32_t)result.size();
if (outBuf && outBufSize >= 4) {
memcpy(outBuf, &len, sizeof(len));
int avail = outBufSize - 4;
int copyLen = (int)result.size();
if (copyLen > avail) copyLen = avail;
if (copyLen > 0) memcpy(outBuf + 4, result.data(), (size_t)copyLen);
if (copyLen < avail) outBuf[4 + copyLen] = 0;
}
return len;
}
+79
View File
@@ -0,0 +1,79 @@
// Standalone native ARM32 EXECUTABLE (not a .so, unlike ostream_repro.cpp's
// own SHARED library target - see this directory's CMakeLists.txt) for the
// 2026-09-17 "does real hardware ever see runaway basic_stringbuf::overflow()
// growth" check (ARM64_TRANSLATION_LAYER.md). Built c++_static (same as
// ostream_repro.cpp's own build.sh switched to - real, compiled
// basic_string<char>/basic_stringbuf<char> logic baked directly into this
// binary's own .text, not external imports), meant to run DIRECTLY on the
// Galaxy A9 via `adb shell` - no Unicorn, no GuestEngine, no shims of any
// kind involved. Pure ground truth: does the SAME growth pattern observed
// under GuestEngine (push_back-driven capacity doubling via
// basic_stringbuf<char>::overflow(), real disasm at sub_2700E4/sub_27036C/
// sub_27003C) ever get stuck, or does it complete normally no matter how far
// it's pushed?
#include <cstdio>
#include <cstdint>
#include <sstream>
#include <string>
#include <chrono>
using Clock = std::chrono::steady_clock;
int main(int argc, char** argv) {
// Default: push well past the ~8MB (0x800000) point where GuestEngine's
// own trace showed the crash - if real hardware sails through this same
// magnitude with no trouble, that's strong evidence the growth mechanism
// itself is fine and the bug is specific to GuestEngine (its own malloc
// shim, or something else in its translation of these particular
// instructions), not a genuine bug in the shipped game/libc++ pairing.
long targetChars = (argc > 1) ? atol(argv[1]) : 20L * 1024 * 1024; // 20M
printf("ostream_stress: appending %ld chars one at a time via push_back\n", targetChars);
fflush(stdout);
auto t0 = Clock::now();
std::string s;
for (long i = 0; i < targetChars; i++) {
s.push_back((char)('a' + (i % 26)));
// Progress heartbeat every 1M chars, and explicitly flag whenever
// capacity crosses the same doubling milestones GuestEngine's own
// register trace captured (0xfffff, 0x1fffff, 0x3fffff, 0x7fffff) -
// lets a hang be diagnosed by "last milestone reached" even if the
// process needs to be killed rather than exiting cleanly.
if (i != 0 && (i % (1L * 1024 * 1024)) == 0) {
auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0).count();
printf(" [%ldms] i=%ld cap=%zu size=%zu\n", (long)elapsedMs, i, s.capacity(), s.size());
fflush(stdout);
}
}
auto totalMs = std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0).count();
printf("ostream_stress: push_back loop done in %ldms - final size=%zu cap=%zu\n",
(long)totalMs, s.size(), s.capacity());
fflush(stdout);
// Second phase: the ACTUAL crashing pattern is via basic_stringbuf's own
// xsputn/overflow chain (operator<<), not raw std::string::push_back -
// exercise that path too, via repeated small ostringstream writes,
// mirroring sub_4702D8's own "several separate writes before one
// extraction" shape but looped enough times to force the same magnitude
// of reallocation.
printf("ostream_stress: now stress-testing via ostringstream operator<<\n");
fflush(stdout);
auto t1 = Clock::now();
std::ostringstream oss;
long chunkCount = targetChars / 16; // ~16 chars per write, same order of magnitude
for (long i = 0; i < chunkCount; i++) {
oss << "0123456789ABCDEF";
if (i != 0 && (i % (65536)) == 0) {
auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t1).count();
std::string cur = oss.str();
printf(" [oss %ldms] i=%ld size-so-far=%zu\n", (long)elapsedMs, i, cur.size());
fflush(stdout);
}
}
std::string result = oss.str();
auto ossMs = std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t1).count();
printf("ostream_stress: ostringstream loop done in %ldms - final size=%zu\n",
(long)ossMs, result.size());
fflush(stdout);
return 0;
}
+47
View File
@@ -566,6 +566,53 @@ void glLinkProgram(GLuint program) {
}
// ---- 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