diff --git a/.gitignore b/.gitignore index 6c76c7a..a7eb71e 100644 --- a/.gitignore +++ b/.gitignore @@ -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...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 diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 4f0509f..26e186b 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -2,15 +2,24 @@ + + + + + + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d901043..970f0bd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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//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// 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") } } diff --git a/app/src/androidTest/java/com/ea/games/nfs13_arm/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/ea/games/nfs13_arm/ExampleInstrumentedTest.kt index d86e9a6..96600a2 100644 --- a/app/src/androidTest/java/com/ea/games/nfs13_arm/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/ea/games/nfs13_arm/ExampleInstrumentedTest.kt @@ -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) } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 814c3d1..5638675 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,7 @@ + + + + + + + + + + + /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("Отправить отчёт") } + } + } + } +} diff --git a/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt b/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt index 2338544..b6f186e 100644 --- a/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt +++ b/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt @@ -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 { return arrayOf(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 + } } } } diff --git a/app/src/main/java/com/ea/ironmonkey/GameDataInstaller.kt b/app/src/main/java/com/ea/ironmonkey/GameDataInstaller.kt new file mode 100644 index 0000000..c5f8fae --- /dev/null +++ b/app/src/main/java/com/ea/ironmonkey/GameDataInstaller.kt @@ -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...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}") + } + } +} diff --git a/app/src/main/java/com/ea/ironmonkey/GameDataUnpackActivity.kt b/app/src/main/java/com/ea/ironmonkey/GameDataUnpackActivity.kt new file mode 100644 index 0000000..6613b53 --- /dev/null +++ b/app/src/main/java/com/ea/ironmonkey/GameDataUnpackActivity.kt @@ -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(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), + ) + } + } + } +} diff --git a/app/src/main/java/com/ea/ironmonkey/GameGLSurfaceView.java b/app/src/main/java/com/ea/ironmonkey/GameGLSurfaceView.java index cb7bd41..4183035 100644 --- a/app/src/main/java/com/ea/ironmonkey/GameGLSurfaceView.java +++ b/app/src/main/java/com/ea/ironmonkey/GameGLSurfaceView.java @@ -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); } }); diff --git a/app/src/main/java/com/ea/ironmonkey/PermissionsActivity.kt b/app/src/main/java/com/ea/ironmonkey/PermissionsActivity.kt index 982441c..7d5bf15 100644 --- a/app/src/main/java/com/ea/ironmonkey/PermissionsActivity.kt +++ b/app/src/main/java/com/ea/ironmonkey/PermissionsActivity.kt @@ -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() diff --git a/app/src/main/java/nfs/mod/traceagent/TraceAgentBridge.kt b/app/src/main/java/nfs/mod/traceagent/TraceAgentBridge.kt new file mode 100644 index 0000000..7f4741e --- /dev/null +++ b/app/src/main/java/nfs/mod/traceagent/TraceAgentBridge.kt @@ -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) +} diff --git a/app/src/main/java/nfs/mod/traceagent/TraceApplication.kt b/app/src/main/java/nfs/mod/traceagent/TraceApplication.kt new file mode 100644 index 0000000..e84c731 --- /dev/null +++ b/app/src/main/java/nfs/mod/traceagent/TraceApplication.kt @@ -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.'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" + } +} diff --git a/app/src/main/jniLibs/armeabi-v7a/wrap.sh b/app/src/main/jniLibs/armeabi-v7a/wrap.sh new file mode 100755 index 0000000..251350c --- /dev/null +++ b/app/src/main/jniLibs/armeabi-v7a/wrap.sh @@ -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.) 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 "$@" diff --git a/app/src/main/res/xml/crash_report_paths.xml b/app/src/main/res/xml/crash_report_paths.xml new file mode 100644 index 0000000..e8a5148 --- /dev/null +++ b/app/src/main/res/xml/crash_report_paths.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/test/java/com/ea/games/nfs13_arm/ExampleUnitTest.kt b/app/src/test/java/com/ea/games/nfs13_arm/ExampleUnitTest.kt index c09445f..b60d837 100644 --- a/app/src/test/java/com/ea/games/nfs13_arm/ExampleUnitTest.kt +++ b/app/src/test/java/com/ea/games/nfs13_arm/ExampleUnitTest.kt @@ -1,4 +1,4 @@ -package com.ea.games.nfs13_mod +package com.ea.games.nfs13_arm import org.junit.Test diff --git a/mpcore/build.gradle.kts b/mpcore/build.gradle.kts index e9d4097..6acf54c 100644 --- a/mpcore/build.gradle.kts +++ b/mpcore/build.gradle.kts @@ -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" diff --git a/mpcore/scripts/test_on_device.sh b/mpcore/scripts/test_on_device.sh new file mode 100755 index 0000000..96642d7 --- /dev/null +++ b/mpcore/scripts/test_on_device.sh @@ -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" diff --git a/mpcore/src/main/cpp/CMakeLists.txt b/mpcore/src/main/cpp/CMakeLists.txt index 1cbeb3f..dcf83b7 100644 --- a/mpcore/src/main/cpp/CMakeLists.txt +++ b/mpcore/src/main/cpp/CMakeLists.txt @@ -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 diff --git a/mpcore/src/main/cpp/crash_handler.cpp b/mpcore/src/main/cpp/crash_handler.cpp new file mode 100644 index 0000000..6ee84f3 --- /dev/null +++ b/mpcore/src/main/cpp/crash_handler.cpp @@ -0,0 +1,283 @@ +#include "crash_handler.h" +#include "util/util.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// ---- 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(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); +} diff --git a/mpcore/src/main/cpp/crash_handler.h b/mpcore/src/main/cpp/crash_handler.h new file mode 100644 index 0000000..56b65e2 --- /dev/null +++ b/mpcore/src/main/cpp/crash_handler.h @@ -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//files/crashes), so +// a tester can reach it over USB or a file manager without any permission. + +#include + +// 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); diff --git a/mpcore/src/main/cpp/emu/guest_engine.cpp b/mpcore/src/main/cpp/emu/guest_engine.cpp index bc58a1c..0268c7e 100644 --- a/mpcore/src/main/cpp/emu/guest_engine.cpp +++ b/mpcore/src/main/cpp/emu/guest_engine.cpp @@ -4,11 +4,14 @@ #include "zlib_accel.h" #include "name_lookup_accel.h" #include "../util/util.h" +#include "../crash_handler.h" #include #include +#include #include #include #include +#include #include #include #include @@ -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(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 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 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 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 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(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 seen; + { + std::lock_guard 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 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_) { diff --git a/mpcore/src/main/cpp/emu/guest_engine.h b/mpcore/src/main/cpp/emu/guest_engine.h index ef75773..40f03d4 100644 --- a/mpcore/src/main/cpp/emu/guest_engine.h +++ b/mpcore/src/main/cpp/emu/guest_engine.h @@ -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 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 registered_shims_; std::unordered_map 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 secondary_image_exports_; + // [base, base+span) for each image LoadSecondaryImage has loaded. + struct LoadedImageRange { GuestAddr base; uint32_t span; }; + std::vector secondary_image_ranges_; std::vector data_symbol_setup_fns_; // see RegisterDataSymbolSetup std::unordered_map import_stub_by_symbol_; // dedupe: one stub per symbol name std::unordered_map shim_by_stub_addr_; diff --git a/mpcore/src/main/cpp/emu/jni_shim.cpp b/mpcore/src/main/cpp/emu/jni_shim.cpp index baae89d..2fc1a62 100644 --- a/mpcore/src/main/cpp/emu/jni_shim.cpp +++ b/mpcore/src/main/cpp/emu/jni_shim.cpp @@ -317,13 +317,12 @@ std::string GuestCStr(GuestEngine& eng, uint32_t guestPtr) { } // namespace -std::atomic 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 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_; diff --git a/mpcore/src/main/cpp/emu/jni_shim.h b/mpcore/src/main/cpp/emu/jni_shim.h index 0a054e6..12ed2e7 100644 --- a/mpcore/src/main/cpp/emu/jni_shim.h +++ b/mpcore/src/main/cpp/emu/jni_shim.h @@ -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 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 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 diff --git a/mpcore/src/main/cpp/emu/libc_shims.cpp b/mpcore/src/main/cpp/emu/libc_shims.cpp index 3ff04d9..1bf1573 100644 --- a/mpcore/src/main/cpp/emu/libc_shims.cpp +++ b/mpcore/src/main/cpp/emu/libc_shims.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -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); diff --git a/mpcore/src/main/cpp/emu/pthread_shim.cpp b/mpcore/src/main/cpp/emu/pthread_shim.cpp index 7435d48..fa0beef 100644 --- a/mpcore/src/main/cpp/emu/pthread_shim.cpp +++ b/mpcore/src/main/cpp/emu/pthread_shim.cpp @@ -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(); }); { diff --git a/mpcore/src/main/cpp/emu/rtti_shims.cpp b/mpcore/src/main/cpp/emu/rtti_shims.cpp index 58b8094..8235845 100644 --- a/mpcore/src/main/cpp/emu/rtti_shims.cpp +++ b/mpcore/src/main/cpp/emu/rtti_shims.cpp @@ -1,3 +1,4 @@ +#include #include #include #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 facet - same "real vtable, only confirmed slots // implemented" approach as ctype 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 facet - reading numbers back OUT of a stream ---- +// +// Confirmed live, not anticipated: the post-prologue dialog crashed because +// use_facet> returned NULL and the guest called through it +// (guest 0x3e5248, decompiled as basic_istream::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 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(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 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/num_put " - "(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 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 and num_put 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 : "", 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 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 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) ---- diff --git a/mpcore/src/main/cpp/game_lifecycle_stubs_extra2.cpp b/mpcore/src/main/cpp/game_lifecycle_stubs_extra2.cpp index 416392f..bebf632 100644 --- a/mpcore/src/main/cpp/game_lifecycle_stubs_extra2.cpp +++ b/mpcore/src/main/cpp/game_lifecycle_stubs_extra2.cpp @@ -9,17 +9,121 @@ #include #include #include +#include +#include +#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 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) \ diff --git a/mpcore/src/main/cpp/main.cpp b/mpcore/src/main/cpp/main.cpp index eb5c04f..e6f0ead 100644 --- a/mpcore/src/main/cpp/main.cpp +++ b/mpcore/src/main/cpp/main.cpp @@ -5,6 +5,7 @@ #include #include #include "main.h" +#include "crash_handler.h" #include "util/util.h" #include #include @@ -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) { diff --git a/mpcore/src/main/cpp/third_party/unicorn/include/uc_priv.h b/mpcore/src/main/cpp/third_party/unicorn/include/uc_priv.h index fef7ab8..bb10d5c 100644 --- a/mpcore/src/main/cpp/third_party/unicorn/include/uc_priv.h +++ b/mpcore/src/main/cpp/third_party/unicorn/include/uc_priv.h @@ -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 diff --git a/mpcore/src/main/cpp/third_party/unicorn/include/unicorn/unicorn.h b/mpcore/src/main/cpp/third_party/unicorn/include/unicorn/unicorn.h index b4ffc6b..0013aee 100644 --- a/mpcore/src/main/cpp/third_party/unicorn/include/unicorn/unicorn.h +++ b/mpcore/src/main/cpp/third_party/unicorn/include/unicorn/unicorn.h @@ -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 diff --git a/mpcore/src/main/cpp/third_party/unicorn/qemu/accel/tcg/translate-all.c b/mpcore/src/main/cpp/third_party/unicorn/qemu/accel/tcg/translate-all.c index d64af9e..9645adb 100644 --- a/mpcore/src/main/cpp/third_party/unicorn/qemu/accel/tcg/translate-all.c +++ b/mpcore/src/main/cpp/third_party/unicorn/qemu/accel/tcg/translate-all.c @@ -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 */ diff --git a/mpcore/src/main/cpp/third_party/unicorn/qemu/tcg/aarch64/tcg-target.inc.c b/mpcore/src/main/cpp/third_party/unicorn/qemu/tcg/aarch64/tcg-target.inc.c index 50c9e59..83404b3 100644 --- a/mpcore/src/main/cpp/third_party/unicorn/qemu/tcg/aarch64/tcg-target.inc.c +++ b/mpcore/src/main/cpp/third_party/unicorn/qemu/tcg/aarch64/tcg-target.inc.c @@ -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]); diff --git a/mpcore/src/main/cpp/third_party/unicorn/uc.c b/mpcore/src/main/cpp/third_party/unicorn/uc.c index 75b89a0..89d3fb8 100644 --- a/mpcore/src/main/cpp/third_party/unicorn/uc.c +++ b/mpcore/src/main/cpp/third_party/unicorn/uc.c @@ -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) { diff --git a/mpcore/src/main/java/nfs/mod/mpcore/MultiplayerCore.kt b/mpcore/src/main/java/nfs/mod/mpcore/MultiplayerCore.kt index 328e177..8e492c3 100644 --- a/mpcore/src/main/java/nfs/mod/mpcore/MultiplayerCore.kt +++ b/mpcore/src/main/java/nfs/mod/mpcore/MultiplayerCore.kt @@ -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() diff --git a/ostream_repro/CMakeLists.txt b/ostream_repro/CMakeLists.txt new file mode 100644 index 0000000..86ecd79 --- /dev/null +++ b/ostream_repro/CMakeLists.txt @@ -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) diff --git a/ostream_repro/build.sh b/ostream_repro/build.sh new file mode 100755 index 0000000..0ea9603 --- /dev/null +++ b/ostream_repro/build.sh @@ -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/basic_ostream/basic_ios/ +# basic_ostringstream (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::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" diff --git a/ostream_repro/ostream_repro.cpp b/ostream_repro/ostream_repro.cpp new file mode 100644 index 0000000..b2bbdea --- /dev/null +++ b/ostream_repro/ostream_repro.cpp @@ -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::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/ +// num_put/__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'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 +#include +#include + +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; +} diff --git a/ostream_repro/ostream_stress.cpp b/ostream_repro/ostream_stress.cpp new file mode 100644 index 0000000..a346341 --- /dev/null +++ b/ostream_repro/ostream_stress.cpp @@ -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/basic_stringbuf 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::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 +#include +#include +#include +#include + +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(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(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(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(Clock::now() - t1).count(); + printf("ostream_stress: ostringstream loop done in %ldms - final size=%zu\n", + (long)ossMs, result.size()); + fflush(stdout); + return 0; +} diff --git a/trace_agent/libc_gles_trace.cpp b/trace_agent/libc_gles_trace.cpp index 36f5b9c..2e06936 100644 --- a/trace_agent/libc_gles_trace.cpp +++ b/trace_agent/libc_gles_trace.cpp @@ -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//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