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