arm64: flat guest mapping, audio, crash reporting, self-extracting data

The run of work that took the translated build from "boots" to "playable".

Engine:
- Flat guest mapping replaces the software MMU on aarch64 hosts. A 4 GiB
  PROT_NONE reservation lets a 32-bit guest address zero-extend safely, so
  tcg_out_qemu_ld/st short-circuit before tcg_out_tlb_read and the prologue
  materialises the base into X28. Measured 1.24x (51 vs 41 fps, interleaved
  A/B). Note the base must be set BEFORE UC_INIT - Unicorn inits lazily, and
  setting it after uc_open silently falls back to softmmu.
- num_get<char> facet implemented, which was the real cause of the crash after
  the prologue race; a full race is now playable end to end.
- Thread-stack free list + ReleaseThreadEngine, fixing the arena leak that
  showed up as a black screen when entering a race. kMaxGuestThreads 16 -> 64.
- Real ARM32 FMOD now runs in-engine via the Java FMODAudioDevice bridge, with
  a per-thread JNIEnv. Two of the three blockers were our own single-image-era
  guards.

Host/app:
- Native crash handler: async-signal-safe, decodes the host fault back to a
  guest address, writes a report file and nothing else. CrashReportActivity
  picks it up on the NEXT launch, zips it, and offers to share. No backend, no
  automatic upload.
- Game data ships inside the APK and self-extracts on first launch, so a tester
  installs one file and plays. Copy-to-.part-then-rename, with a free-space
  check up front.
- EGL context preserved across pause, fixing black textures on resume.
- Navigation bar hidden and re-hidden on focus gain; volume keys reported as
  system keys, checked before the loading-state gate.
- x86_64 added to abiFilters: the ARM32 guest runs under tcg/i386 with no
  houdini in the path. The flat mapping is aarch64-only, so that host falls
  back to the software MMU - commented at the abiFilters line.

Ignore rules added for app/translated/ (611 MB of signed release APK, which
also carries the bundled OBB) and ostream_repro/build/.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-22 23:17:47 +03:00
co-authored by Claude
parent 80768652ea
commit 725ffbd8ed
44 changed files with 3096 additions and 86 deletions
+168 -5
View File
@@ -1,3 +1,8 @@
// Inside the android { } block the name `java` resolves to Gradle's own
// java extension and shadows the package, so these types have to be
// imported here rather than fully qualified at the use site.
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -5,22 +10,78 @@ plugins {
}
android {
namespace = "com.ea.games.nfs13_mod"
// ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md): "_arm"
// instead of "_mod" so this build can be installed side by side with
// the real armeabi-v7a mod, not overwrite it.
namespace = "com.ea.games.nfs13_arm"
compileSdk = 35
defaultConfig {
applicationId = "com.ea.games.nfs13_mod"
applicationId = "com.ea.games.nfs13_arm"
minSdk = 27
targetSdk = 35
versionCode = 1003128
versionName = "1.3.128"
ndk.abiFilters.add("armeabi-v7a")
//ndk.abiFilters.add("x86")
// mpcore is now plain native ARM64 code driving an embedded ARM32
// CPU-emulation core - no armeabi-v7a native code is built/shipped
// for this app at all.
ndk.abiFilters.add("arm64-v8a")
// x86_64 (2026-09-22): lets the translated build install in WayDroid,
// whose Android image is x86_64. Unicorn picks its tcg/i386 backend
// there automatically, so the ARM32 guest is translated by US rather
// than by houdini - which is what made earlier WayDroid attempts with
// the native ARM build unreliable.
//
// NOTE: the flat guest mapping (task #61, worth 1.24x) is implemented
// only in tcg/aarch64, so an x86_64 host silently falls back to the
// software MMU and will be slower. Fine for a test target; port the
// same change into tcg/i386 if it ever becomes a shipping target.
ndk.abiFilters.add("x86_64")
// Default: false. Overridden to true by the "native32" flavor - see
// its own comment below.
buildConfigField("boolean", "NATIVE32", "false")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
ndkVersion = "21.0.6113669"
ndkVersion = "27.0.12077973"
// Release signing. The keystore and its password live OUTSIDE this repo
// (~/keystores/), deliberately: this is a git working tree, and a signing
// identity that can be committed eventually is committed. Gradle reads
// them from a properties file if it is there, and falls back to the debug
// key if it is not - so a checkout without the key still builds, it just
// produces something that cannot be shipped.
//
// To create the key (once, and then back it up - losing it means never
// being able to update an installed build):
//
// keytool -genkeypair -v -keystore ~/keystores/nfsmw-release.jks \
// -alias nfsmw -keyalg RSA -keysize 4096 -validity 10950
//
// then write ~/keystores/nfsmw-release.properties with:
// storeFile=/home/<you>/keystores/nfsmw-release.jks
// storePassword=...
// keyAlias=nfsmw
// keyPassword=...
signingConfigs {
val releaseProps = File(System.getProperty("user.home"), "keystores/nfsmw-release.properties")
if (releaseProps.isFile) {
val props = Properties().apply { releaseProps.inputStream().use { load(it) } }
create("release") {
storeFile = file(props.getProperty("storeFile"))
storePassword = props.getProperty("storePassword")
keyAlias = props.getProperty("keyAlias")
keyPassword = props.getProperty("keyPassword")
// Both modern signature schemes: v2 covers the whole APK and
// is what Android 7+ verifies, v3 allows rotating the key
// later without invalidating existing installs.
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes {
release {
@@ -29,6 +90,28 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// Local-testing-only (2026-09-05, "why is this ~100x slower than
// native" investigation - see ARM64_TRANSLATION_LAYER.md): no
// real release signing config exists in this prototype, and
// there's no intent to ship this build anywhere - reusing the
// auto-generated debug keystore just makes `assembleTranslatedRelease`
// installable on a test device the same way translatedDebug
// already is. The actual point of testing this build type is the
// NATIVE side (mpcore's CMakeLists.txt now forces
// CMAKE_BUILD_TYPE=RelWithDebInfo unconditionally, so this
// isn't even required for that fix to take effect - it's here so
// this variant can be installed at all for a genuine debuggable=false
// comparison, e.g. CheckJNI's behavior).
// Real key when it exists, debug key otherwise. Announced at
// configuration time rather than discovered later from a
// mysteriously unshippable APK.
signingConfig = signingConfigs.findByName("release") ?: run {
logger.warn(
"RELEASE SIGNING: ~/keystores/nfsmw-release.properties not found - " +
"signing with the DEBUG key. This APK must NOT be distributed."
)
signingConfigs.getByName("debug")
}
}
}
compileOptions {
@@ -38,8 +121,88 @@ android {
kotlinOptions {
jvmTarget = "11"
}
androidResources {
// The bundled game data (assets/game_data.obb, ~595 MB) is an already
// compressed archive - deflating it again would cost a long build and
// a slower install for no size gain, and it must stay STORED so it can
// be streamed straight out of the APK.
noCompress += "obb"
}
buildFeatures {
compose = true
buildConfig = true
}
// "native32" flavor - diagnostic-only reference build for the native
// ARM32 tracing investigation (see ARM64_TRANSLATION_LAYER.md /
// sorted-popping-plum.md's "Native ARM32 tracing harness" plan). Real
// armeabi-v7a support and a BuildConfig.NATIVE32 flag GameActivityMain
// uses to load the real, unmodified native libraries directly
// (System.loadLibrary) instead of going through mpcore's emulation path
// - only meaningful on genuinely 32-bit-capable hardware (the Galaxy
// A9), never intended to ship. MUST be armeabi-v7a-ONLY (not just
// "armeabi-v7a added alongside arm64-v8a") - confirmed live on-device
// that shipping both ABIs makes Android launch the process via the
// 64-bit app_process64/Zygote (arm64-v8a is present, so it's preferred),
// which can never dlopen/LD_PRELOAD a 32-bit .so at all (a process's
// bitness is fixed for its whole lifetime, not per-library) - defeats
// the entire point of this flavor. ndk.abiFilters.clear() first,
// since AGP's per-flavor abiFilters otherwise ADD to defaultConfig's
// set rather than replace it.
flavorDimensions += "abi"
productFlavors {
create("translated") {
dimension = "abi"
// Default/existing behavior - the emulated arm64-v8a path,
// unchanged from before this flavor split existed.
}
create("native32") {
dimension = "abi"
ndk.abiFilters.clear()
ndk.abiFilters.add("armeabi-v7a")
buildConfigField("boolean", "NATIVE32", "true")
}
}
// 2026-09-16 (ARM64_TRANSLATION_LAYER.md - trace_agent wrap.sh deployment):
// manually injecting a bundled wrap.sh into the packaged native32 APK
// (AGP's own native-lib merge/strip pipeline only recognizes *.so files
// and silently drops anything else) failed to install at all with
// "Failed to extract native libraries, res=-2" under the default
// extractNativeLibs=false packaging - PackageManager validates every
// entry under lib/<abi>/ as a loadable library when it plans to mmap
// straight from the APK, and a plain shell script fails that check.
// Legacy (extracted-to-disk) packaging doesn't do that same strict
// validation. Only applied to native32 - the translated flavor doesn't
// need or want this (slightly larger install, marginally slower first
// native-lib load).
productFlavors.getByName("native32") {
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}
}
// 2026-09-06 (native32-on-real-hardware launch investigation - see
// ARM64_TRANSLATION_LAYER.md): ndk.abiFilters above only restricts THIS
// module's own native code - the "native32" flavor still needs `mpcore` on
// the compile classpath (GameActivityMain.kt's shared, non-flavor-specific
// source calls mpcore functions in its non-NATIVE32 branch), and mpcore's
// own CMakeLists always builds arm64-v8a regardless of which app flavor
// pulls it in. That arm64-v8a .so was silently merged into the native32
// APK too, so a real device with both an arm64-v8a AND an armeabi-v7a lib
// present chose to launch the process as 64-bit (app_process64) - which can
// never load this flavor's 32-bit fmodex/fmodevent/Nimble/app libraries at
// all, confirmed live via `UnsatisfiedLinkError: couldn't find "libfmodex.so"`
// (nativeLibraryDirectories only listed arm64/arm64-v8a paths). Explicitly
// drop every arm64-v8a .so from the native32 variant's packaging so the
// APK is genuinely armeabi-v7a-only, restoring 32-bit process selection.
androidComponents {
onVariants(selector().withFlavor("abi" to "native32")) { variant ->
variant.packaging.jniLibs.excludes.add("lib/arm64-v8a/*.so")
}
}
@@ -1,4 +1,4 @@
package com.ea.games.nfs13_mod
package com.ea.games.nfs13_arm
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.ea.games.nfs13_mod", appContext.packageName)
assertEquals("com.ea.games.nfs13_arm", appContext.packageName)
}
}
+29
View File
@@ -26,6 +26,7 @@
<uses-configuration android:reqTouchScreen="finger"/>
<application
android:name="nfs.mod.traceagent.TraceApplication"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:label="@string/app_name"
android:icon="@mipmap/adaptive_icon"
@@ -48,6 +49,34 @@
android:value="bar"/>
</activity>
<!-- One-time unpack of the game data bundled in the APK. -->
<activity
android:name="com.ea.ironmonkey.GameDataUnpackActivity"
android:exported="false"
android:excludeFromRecents="true"
android:label="Подготовка данных" />
<!-- Shown on the launch after a crash, never during one - a signal
handler cannot start an Activity (see crash_handler.cpp). -->
<activity
android:name="com.ea.ironmonkey.CrashReportActivity"
android:exported="false"
android:excludeFromRecents="true"
android:label="Crash report" />
<!-- Lets the tester share the zip. Required: targetSdk 35 rejects
file:// URIs in ACTION_SEND. Grants access to the crash directory
only, and only for the duration of the send. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.crashreports"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/crash_report_paths" />
</provider>
<activity
android:exported="true"
android:name="com.ea.ironmonkey.PermissionsActivity"
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,237 @@
package com.ea.ironmonkey
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Shown on the launch AFTER a crash, never during one.
*
* The native handler (crash_handler.cpp) can only write a file - a signal
* handler runs on an already-broken process where JNI and Activities are not
* legal. So it drops `crash_pending.txt` and dies; this screen is what the
* tester sees next time they open the game.
*
* Everything it produces stays on the device unless the tester presses send.
* There is no backend and no automatic upload - see BETA_TELEMETRY_PLAN.md.
*
* Built with Compose/Material3 and its own dark colour scheme rather than the
* game's `Theme.AppCompat.NoActionBar`: this screen must render correctly no
* matter what the rest of the app's theming does, and it is the one screen a
* tester sees when everything else has already gone wrong.
*/
class CrashReportActivity : ComponentActivity() {
companion object {
private const val TAG = "CrashReport"
private const val PENDING_NAME = "crash_pending.txt"
/** Reports live here: Android/data/<pkg>/files/crashes - reachable over USB, no permission needed. */
fun crashDir(activity: android.app.Activity): File =
File(activity.getExternalFilesDir(null), "crashes")
/** The file the native handler writes. Null if there is nothing waiting. */
fun pendingReport(activity: android.app.Activity): File? =
File(crashDir(activity), PENDING_NAME).takeIf { it.isFile && it.length() > 0 }
}
private var bundle: File? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Android 15 draws every app edge to edge whether it asks or not, so
// the choice is between handling insets or having text sit under the
// status bar. Declaring it explicitly and letting Scaffold apply the
// padding is the supported way; the first version did neither and the
// content ran under the system bars.
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val pending = pendingReport(this)
if (pending == null) {
// Nothing to report - never block the tester on an empty screen.
finish()
return
}
// Rename out of the way FIRST, before anything that could fail. The
// native handler always writes the same fixed name (it cannot safely
// format a timestamp inside a signal handler), so leaving it in place
// would let the next crash overwrite a report not yet sent.
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val kept = File(crashDir(this), "crash-$stamp.txt")
if (!pending.renameTo(kept)) {
Log.w(TAG, "could not rename $pending - reporting it in place")
}
val report = if (kept.isFile) kept else pending
val details = buildString {
append(deviceSummary())
append("\n\n")
append(runCatching { report.readText() }.getOrElse { "(could not read the report: $it)" })
}
bundle = runCatching { zipReport(report, details, stamp) }
.onFailure { Log.w(TAG, "could not build the zip", it) }
.getOrNull()
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
CrashReportScreen(
details = details,
bundlePath = bundle?.absolutePath,
canSend = bundle != null,
onSend = ::share,
onContinue = ::startGame,
)
}
}
}
private fun deviceSummary(): String = buildString {
append("device: ${Build.MANUFACTURER} ${Build.MODEL}\n")
append("android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})\n")
append("soc: ${Build.HARDWARE}\n")
append("abis: ${Build.SUPPORTED_ABIS.joinToString(", ")}\n")
append("app: ${appVersion()}")
}
private fun appVersion(): String = runCatching {
val p = packageManager.getPackageInfo(packageName, 0)
"${p.versionName} (${p.longVersionCode})"
}.getOrElse { "(unknown)" }
private fun zipReport(report: File, details: String, stamp: String): File {
val out = File(crashDir(this), "crash-$stamp.zip")
ZipOutputStream(out.outputStream().buffered()).use { zip ->
zip.putNextEntry(ZipEntry("crash.txt"))
zip.write(details.toByteArray())
zip.closeEntry()
if (report.isFile) {
zip.putNextEntry(ZipEntry(report.name))
report.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
}
}
return out
}
private fun share() {
val file = bundle ?: return
val uri = runCatching {
FileProvider.getUriForFile(this, "$packageName.crashreports", file)
}.getOrElse {
Log.w(TAG, "FileProvider failed for $file", it)
return
}
val send = Intent(Intent.ACTION_SEND).apply {
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "NFSMW arm64 - отчёт о сбое")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(send, "Отправить отчёт"))
}
private fun startGame() {
startActivity(Intent(this, GameActivityMain::class.java))
finish()
}
}
@Composable
private fun CrashReportScreen(
details: String,
bundlePath: String?,
canSend: Boolean,
onSend: () -> Unit,
onContinue: () -> Unit,
) {
// Scaffold's innerPadding carries the system-bar insets, so nothing ends up
// under the status bar or the gesture handle.
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp)
) {
Text(
text = "Игра аварийно завершилась",
style = MaterialTheme.typography.headlineSmall,
)
Text(
// Say plainly what is in the file before offering to send it.
// The tester is the one sending it; they should know what it
// contains.
text = "Отчёт сохранён на устройстве. В нём модель телефона, версия Android, " +
"версия сборки и технические данные о сбое. Личных данных и игрового " +
"аккаунта в нём нет.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
if (bundlePath != null) {
Text(
text = bundlePath,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
Card(modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.weight(1f)
) {
Text(
text = details,
fontSize = 11.sp,
lineHeight = 15.sp,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
OutlinedButton(onClick = onContinue) { Text("Продолжить") }
Button(onClick = onSend, enabled = canSend) { Text("Отправить отчёт") }
}
}
}
}
@@ -40,6 +40,7 @@ import com.ea.nimble.ApplicationLifecycle
import com.ea.nimble.Global
import nfs.mod.mpcore.GameInput
import nfs.mod.mpcore.MultiplayerCore.loadCore
import nfs.mod.mpcore.MultiplayerCore.loadEmulatedLibapp
import nfs.mod.mpcore.SyntheticInputDispatcher
import org.fmod.FMODAudioDevice
import java.io.File
@@ -50,6 +51,9 @@ import java.util.concurrent.TimeUnit
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
import androidx.core.net.toUri
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.ea.ironmonkey.domain.FSNode
import org.apache.http.BuildConfig
import kotlin.system.exitProcess
@@ -76,9 +80,24 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
private var isSleepModeEnabled = true
private var mRotation = 0
// nativeRestoreContext() is a real native call that can block for a
// long time (on the current emulated engine, indefinitely - see
// ARM64_TRANSLATION_LAYER.md's render-stall root cause: it transitively
// reaches NimbleWrapper::InitNimble(), which gets stuck polling an
// empty directory). Real hardware keeps this kind of init work off the
// GLThread entirely (confirmed via a native trace comparison - the
// real device's own version-check activity and its GL calls run on two
// separate OS threads). Dispatched to its own background thread here so
// onDrawFrame - which MUST keep running every frame to keep rendering -
// never blocks on it, instead of calling it synchronously every frame
// like the original code did.
@Volatile private var restoreContextThreadStarted = false
@Volatile private var restoreContextDone = false
@Volatile private var restoreContextResult = false
// cont.44 DEBUG: fires MultiplayerCore.triggerCarSelectTest() on
// demand via `adb shell am broadcast -a nfs.mod.mpcore.TEST_OPEN_CARSELECT
// -p com.ea.games.nfs13_mod` - stands in for a future real lobby-overlay
// -p com.ea.games.nfs13_arm` - stands in for a future real lobby-overlay
// "select car" button, letting the on-demand car_select-opening call be
// exercised at an arbitrary moment (not just automatically at boot) for
// live testing. See PROGRESS.md cont.44.
@@ -90,7 +109,7 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
// cont.48 DEBUG: fires the EXPERIMENTAL true-direct-jump variant via
// `adb shell am broadcast -a nfs.mod.mpcore.TEST_TRUE_DIRECT_CARSELECT
// -p com.ea.games.nfs13_mod`. See PROGRESS.md cont.48.
// -p com.ea.games.nfs13_arm`. See PROGRESS.md cont.48.
private val trueDirectCarSelectTestReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
nfs.mod.mpcore.MultiplayerCore.triggerTrueDirectCarSelectJump()
@@ -100,7 +119,19 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
private fun updateRequestedOrientation(i: Int) {
}
fun IsSystemKey(i: Int) = false
// Keys the SYSTEM must handle, not the game.
//
// This used to be a flat `= false`, which combined with the callers'
// `return !IsSystemKey(keyCode)` meant the activity claimed EVERY key it
// ever saw - including volume. That is why changing the volume needed the
// notification shade: the keys were being swallowed before Android could
// act on them.
fun IsSystemKey(i: Int) = when (i) {
KeyEvent.KEYCODE_VOLUME_UP,
KeyEvent.KEYCODE_VOLUME_DOWN,
KeyEvent.KEYCODE_VOLUME_MUTE -> true
else -> false
}
companion object {
private const val DOWNLOAD_PROPERTIES = "downloadcontent/config.properties"
@@ -156,11 +187,36 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
}
fun useAssetsFileSystem(): Boolean {
return mAssetLocationType != AssetLocationType.EXTERNAL
val result = mAssetLocationType != AssetLocationType.EXTERNAL
d(TAG, "useAssetsFileSystem() called, mAssetLocationType=$mAssetLocationType, result=$result, thread=${Thread.currentThread().name}")
return result
}
external fun nativeOnCreate()
/**
* Hooks the fatal signals so a native crash leaves a report behind. Must be
* called AFTER loadCore(); see its call site.
*/
private fun installNativeCrashHandler() {
try {
val dir = java.io.File(getExternalFilesDir(null), "crashes")
val info = packageManager.getPackageInfo(packageName, 0)
nativeInstallCrashHandler(
dir.absolutePath,
"${info.versionName} (${info.longVersionCode}) ${Build.MANUFACTURER} ${Build.MODEL} " +
"Android ${Build.VERSION.RELEASE}"
)
} catch (e: Throwable) {
// Diagnostics must never stop the game from starting - but say so,
// otherwise "no crash reports" looks like "no crashes".
i(TAG, "installNativeCrashHandler FAILED - native crashes will go unreported: $e")
}
}
/** See crash_handler.cpp. Writes to `dir`; `buildStamp` goes at the top of every report. */
external fun nativeInstallCrashHandler(dir: String, buildStamp: String)
external fun nativeOnDestroy()
external fun nativeOnMusicPlayerStateChanged()
@@ -199,6 +255,13 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
i(TAG, "onCreate")
super.onCreate(bundle)
// Volume keys should move the MUSIC stream - the one the game actually
// plays through - rather than whatever Android would pick by default.
volumeControlStream = AudioManager.STREAM_MUSIC
applyImmersiveMode()
handler = Handler()
@@ -244,14 +307,18 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
i(TAG, "onCreate() isMusicActive = $isAnyMusicPlaying")
mFMODAudioDevice = FMODAudioDevice()
d(TAG, "obb.size check: mAssetLocationType before = $mAssetLocationType")
if (mAssetLocationType != AssetLocationType.ASSETS) {
try {
val open2 = getResources().assets.open("obb.size")
mAssetLocationType = AssetLocationType.OBB
open2.close()
d(TAG, "obb.size check: opened OK, mAssetLocationType now OBB")
} catch (e2: IOException) {
Log.e(TAG, e2.message!!)
Log.e(TAG, "obb.size check FAILED: " + e2.message)
}
} else {
d(TAG, "obb.size check: skipped, already ASSETS")
}
val sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
val defaultSensor = sensorManager.getDefaultSensor(1)
@@ -281,12 +348,39 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
mFrameLayout.addView(gameGLSurfaceView)
mFrameLayout.addView(buildCarSelectionOverlay())
setContentView(mFrameLayout)
System.loadLibrary("fmodex")
System.loadLibrary("fmodevent")
System.loadLibrary("c++_shared")
System.loadLibrary(Global.NIMBLE_ID)
System.loadLibrary("app")
loadCore()
// ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md):
// fmodex/fmodevent/Nimble/app are all armeabi-v7a-only builds (no
// arm64-v8a .so shipped for any of them, confirmed this session -
// see jniLibs/) - System.loadLibrary() for them would throw
// UnsatisfiedLinkError immediately on an arm64-v8a-only APK. libapp.so
// is loaded through mpcore's embedded ARM32 emulation core instead
// (see loadCore() below); FMOD/Nimble audio and the real game
// render/boot loop are NOT bridged yet - this build proves the
// emulation-core load+hook mechanism, not a running game.
//
// BuildConfig.NATIVE32 (the "native32" Gradle flavor - see
// app/build.gradle.kts and the native-arm32-trace-harness plan in
// ARM64_TRANSLATION_LAYER.md) restores the ORIGINAL, real loading
// path instead: on genuinely 32-bit-capable hardware (the Galaxy
// A9), load the real, unmodified armeabi-v7a libraries directly, no
// emulation involved - the ground-truth reference build for that
// investigation. Never true for the normal (translated) flavor.
if (com.ea.games.nfs13_arm.BuildConfig.NATIVE32) {
System.loadLibrary("fmodex")
System.loadLibrary("fmodevent")
System.loadLibrary("c++_shared")
System.loadLibrary(Global.NIMBLE_ID)
System.loadLibrary("app")
} else {
loadCore()
// Immediately after loadCore(), and not a line earlier: this is
// the first moment libmpcore.so is loaded, so it is the first
// moment the native symbol exists. Installing it up in onCreate's
// opening lines threw UnsatisfiedLinkError, silently leaving every
// crash unreported.
installNativeCrashHandler()
loadEmulatedLibappFromAssets()
}
// RECEIVER_EXPORTED: needs to be reachable from an external `adb
// shell am broadcast` sender (there's no in-app sender for this
// debug-only trigger), and ContextCompat handles the API 33+
@@ -307,6 +401,42 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
nativeOnCreate()
}
// ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md): copies
// the bundled armeabi-v7a libapp.so (assets/native_probe/libapp.so - a
// raw asset, not jniLibs, since this app declares only arm64-v8a) to a
// real file the first time, then hands mpcore's embedded ARM32
// emulation core the path. Idempotent - skips the copy if the file
// already exists with a plausible size.
private fun loadEmulatedLibappFromAssets() {
val outFile = File(filesDir, "libapp_armeabi_v7a.so")
try {
if (!outFile.exists() || outFile.length() < 1_000_000L) {
assets.open("native_probe/libapp.so").use { input ->
outFile.outputStream().use { output -> input.copyTo(output) }
}
d(TAG, "loadEmulatedLibappFromAssets: extracted asset to " + outFile.absolutePath)
}
// The game's own ARM32 FMOD (task #67). Shipped as raw assets for the
// same reason libapp.so is: this APK declares only arm64-v8a, so
// jniLibs/armeabi-v7a is never packaged. The engine loads them as
// secondary guest images so libapp's FMOD imports reach real code
// instead of fmod_shims.cpp's no-ops.
for (lib in arrayOf("libfmodex.so", "libfmodevent.so")) {
val f = File(filesDir, lib)
if (!f.exists() || f.length() < 1_000L) {
assets.open("native_probe/" + lib).use { input ->
f.outputStream().use { output -> input.copyTo(output) }
}
d(TAG, "loadEmulatedLibappFromAssets: extracted " + f.absolutePath)
}
}
val ok = loadEmulatedLibapp(outFile.absolutePath)
d(TAG, "loadEmulatedLibappFromAssets: loadEmulatedLibapp -> " + ok)
} catch (e: IOException) {
d(TAG, "loadEmulatedLibappFromAssets: failed to extract/load asset: " + e)
}
}
fun forEach(input: String?): Array<String?> {
return arrayOf<String?>(input)
}
@@ -503,6 +633,10 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
super.onKeyDown(keyCode, keyEvent)
// Checked BEFORE the state gate on purpose: the gate returns true for
// every key whenever the game is not in STATE_GAME_START, which would
// otherwise keep swallowing volume through the whole load.
if (IsSystemKey(keyCode)) return false
if (state != 8) {
return true
}
@@ -518,6 +652,7 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
override fun onKeyUp(i: Int, keyEvent: KeyEvent): Boolean {
super.onKeyUp(i, keyEvent)
if (IsSystemKey(i)) return false
if (state != 8) {
return true
}
@@ -545,7 +680,28 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
}
}
/**
* Hides the status and navigation bars for the duration of the game.
*
* Nothing did this before, which is why the navigation bar sat on top of
* the game. TRANSIENT_BARS_BY_SWIPE keeps them reachable - a swipe from the
* edge brings them back temporarily - so this hides the bars without taking
* the system away from the player.
*
* Must be re-applied on every focus gain: Android restores the bars after a
* dialog, the shade, or a task switch, and a one-shot call in onCreate
* silently stops working the first time any of those happens.
*/
private fun applyImmersiveMode() {
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
override fun onWindowFocusChanged(z: Boolean) {
if (z) applyImmersiveMode()
super.onWindowFocusChanged(z)
i(TAG, "onWindowsFocusChanged($z) state=$state")
getGameGLSurfaceView().renderMode = if (z) 1 else 0
@@ -714,9 +870,9 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
override fun onDrawFrame(gl10: GL10?) {
var inputStream: InputStream?
var shouldCleanupSplash = false
Log.d("state::game", "onDrawFrame state=$state")
// Логирование изменения состояния
if (state != laststate) {
Log.d("state::game", "onDrawFrame state=$state")
Log.d(TAG, "onDrawFrame state=$state")
laststate = state
}
@@ -750,12 +906,24 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
}
STATE_GAME_START -> {
if (splashTimer < System.currentTimeMillis() && nativeRestoreContext()) {
isAssetsReady = true
nativeOnStart()
nativeOnResume()
gameRenderer.setDrawFrameListener(null)
shouldCleanupSplash = true
if (splashTimer < System.currentTimeMillis()) {
if (!restoreContextThreadStarted) {
restoreContextThreadStarted = true
d(TAG, "nativeRestoreContext: starting background thread from tid=" + Thread.currentThread().id)
Thread {
d(TAG, "nativeRestoreContext: background thread running, tid=" + Thread.currentThread().id)
restoreContextResult = nativeRestoreContext()
restoreContextDone = true
d(TAG, "nativeRestoreContext: background thread finished, result=$restoreContextResult")
}.apply { isDaemon = true }.start()
}
if (restoreContextDone && restoreContextResult) {
isAssetsReady = true
nativeOnStart()
nativeOnResume()
gameRenderer.setDrawFrameListener(null)
shouldCleanupSplash = true
}
}
}
}
@@ -0,0 +1,129 @@
package com.ea.ironmonkey
import android.content.Context
import android.util.Log
import java.io.File
/**
* Unpacks the game data bundled inside the APK to the place the game expects.
*
* The ~595 MB archive ships as `assets/game_data.obb`, stored uncompressed
* (see `noCompress` in build.gradle.kts - it is already a compressed archive,
* so deflating it again would only cost build and install time). On first
* launch it is copied to `getObbDir()/main.<versionCode>.<package>.obb`, which
* is exactly the path `GameActivityMain.obbFullPath` builds, so nothing else in
* the game needs to know this happened.
*
* The point is that a tester installs one APK and plays - no separate download,
* no file manager, no instructions about where to put an .obb.
*/
object GameDataInstaller {
private const val TAG = "GameDataInstaller"
private const val ASSET_NAME = "game_data.obb"
/** Progress callback: (bytes copied, total bytes). Called from a worker thread. */
fun interface Progress {
fun onProgress(copied: Long, total: Long)
}
sealed interface Result {
/** Already unpacked, or just unpacked successfully. */
object Ready : Result
/** Could not unpack - the message is safe to show a tester. */
data class Failed(val message: String) : Result
}
fun targetFile(context: Context): File {
val versionCode = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionCode
}.getOrDefault(0)
return File(context.obbDir, ObbHelper.getObbFileName(context, versionCode))
}
/** Size of the bundled asset, or -1 if it is not in this build. */
fun bundledSize(context: Context): Long = runCatching {
context.assets.openFd(ASSET_NAME).use { it.length }
}.getOrElse {
// openFd only works for an UNCOMPRESSED asset. If this ever starts
// failing, the noCompress rule has been lost and the asset is being
// deflated - worth knowing, because the copy below would still work
// but every install would be needlessly slower.
Log.w(TAG, "openFd($ASSET_NAME) failed - is noCompress still set? $it")
runCatching { context.assets.open(ASSET_NAME).use { s -> s.available().toLong() } }
.getOrDefault(-1L)
}
/** True when the data is already in place at its full expected size. */
fun isInstalled(context: Context): Boolean {
val expected = bundledSize(context)
if (expected <= 0) return false
val target = targetFile(context)
return target.isFile && target.length() == expected
}
/**
* Copies the bundled data into place. Blocking - call from a worker thread.
*
* Writes to a temporary file and renames only on success, so an interrupted
* copy can never leave a half-written archive that looks complete. A partial
* file that passed a mere "does it exist" check would send the game off to
* read truncated data, which fails far away from the real cause.
*/
fun install(context: Context, progress: Progress?): Result {
val expected = bundledSize(context)
if (expected <= 0) {
return Result.Failed("В этой сборке нет игровых данных (assets/$ASSET_NAME).")
}
val target = targetFile(context)
if (target.isFile && target.length() == expected) return Result.Ready
val dir = target.parentFile
if (dir != null && !dir.isDirectory && !dir.mkdirs()) {
return Result.Failed("Не удалось создать каталог ${dir.absolutePath}")
}
// Check free space before starting rather than failing 500 MB in.
val free = dir?.freeSpace ?: 0L
if (free in 1 until expected) {
return Result.Failed(
"Недостаточно места: нужно ${expected / 1_048_576} МБ, свободно ${free / 1_048_576} МБ."
)
}
val tmp = File(target.parentFile, target.name + ".part")
tmp.delete()
return try {
var copied = 0L
context.assets.open(ASSET_NAME).use { input ->
tmp.outputStream().buffered(1 shl 20).use { output ->
val buf = ByteArray(1 shl 20)
while (true) {
val n = input.read(buf)
if (n <= 0) break
output.write(buf, 0, n)
copied += n
progress?.onProgress(copied, expected)
}
output.flush()
}
}
if (copied != expected) {
tmp.delete()
return Result.Failed("Распаковка оборвалась: $copied из $expected байт.")
}
target.delete()
if (!tmp.renameTo(target)) {
tmp.delete()
return Result.Failed("Не удалось переименовать во ${target.absolutePath}")
}
Log.i(TAG, "unpacked $expected bytes to ${target.absolutePath}")
Result.Ready
} catch (e: Throwable) {
tmp.delete()
Log.w(TAG, "unpack failed", e)
Result.Failed("Ошибка распаковки: ${e.message}")
}
}
}
@@ -0,0 +1,148 @@
package com.ea.ironmonkey
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlin.concurrent.thread
/**
* First-launch unpacking of the game data bundled in the APK.
*
* Shown only when [GameDataInstaller.isInstalled] is false, so it appears once
* after install and never again. It exists because copying ~595 MB takes long
* enough that a tester staring at a frozen launcher would reasonably assume the
* game had hung.
*/
class GameDataUnpackActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
if (GameDataInstaller.isInstalled(this)) {
startGame()
return
}
var fraction by mutableFloatStateOf(0f)
var copiedMb by mutableStateOf(0L)
var totalMb by mutableStateOf(0L)
var error by mutableStateOf<String?>(null)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
UnpackScreen(
fraction = fraction,
copiedMb = copiedMb,
totalMb = totalMb,
error = error,
onRetry = { recreate() },
)
}
}
thread(name = "game-data-unpack", isDaemon = true) {
val result = GameDataInstaller.install(this) { copied, total ->
// Updating Compose state is safe from any thread; the recomposition
// is scheduled onto the main thread by the snapshot system.
fraction = if (total > 0) copied.toFloat() / total else 0f
copiedMb = copied / 1_048_576
totalMb = total / 1_048_576
}
runOnUiThread {
when (result) {
is GameDataInstaller.Result.Ready -> startGame()
is GameDataInstaller.Result.Failed -> error = result.message
}
}
}
}
private fun startGame() {
startActivity(Intent(this, GameActivityMain::class.java))
finish()
}
}
@Composable
private fun UnpackScreen(
fraction: Float,
copiedMb: Long,
totalMb: Long,
error: String?,
onRetry: () -> Unit,
) {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (error != null) {
Text(
text = "Не удалось подготовить игровые данные",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp),
)
Button(onClick = onRetry, modifier = Modifier.padding(top = 24.dp)) {
Text("Повторить")
}
} else {
Text(
text = "Подготовка игровых данных",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
text = "Выполняется один раз после установки.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
LinearProgressIndicator(
progress = { fraction },
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
)
Text(
text = if (totalMb > 0) "$copiedMb из $totalMb МБ" else "",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 12.dp),
)
}
}
}
}
@@ -11,6 +11,10 @@ import javax.microedition.khronos.egl.EGLDisplay;
public class GameGLSurfaceView extends GLSurfaceView {
private static final String TAG = "GameGLSurfaceView";
// See createContext/destroyContext below (task #62).
static int eglContextCreateCount = 0;
static int eglContextDestroyCount = 0;
private boolean enableHistoricalEvents;
private boolean kMotionEvent_GetSource;
private GameActivityMain mActivity;
@@ -35,15 +39,29 @@ public class GameGLSurfaceView extends GLSurfaceView {
setGLESVersion2();
setFocusable(true);
setFocusableInTouchMode(true);
if (Build.VERSION.SDK_INT >= 11) {
try {
Log.i(TAG, "setPreserveEGLContextOnPause");
getClass().getMethod("setPreserveEGLContextOnPause", Boolean.TYPE).invoke(this, false);
Log.e(TAG, "setPreserveEGLContextOnPause(false) success");
} catch (Exception unused2) {
Log.e(TAG, "setPreserveEGLContextOnPause failed");
}
}
// Task #62 - the black-textures-after-resume bug. The shipped game asked
// for the context NOT to be preserved (this call passed `false`) and took
// responsibility for rebuilding its GL objects afterwards. Under this
// engine that rebuild does not happen, which was confirmed live rather
// than assumed:
//
// EGL context CREATED (#1) launch
// EGL context DESTROYED (#1) on pause <- every texture gone here
// EGL context CREATED (#2) on resume
//
// and across that boundary the guest's own `Renderer::RestoreContext` ran
// to completion in ONE millisecond and re-uploaded a single texture, while
// 86 uploads had built the scene before it. Geometry survived only because
// vertex data is re-sent per frame; textures are uploaded once, so they
// came back black.
//
// Asking the platform to keep the context is the fix that does not depend
// on the guest restoring anything. It is a HINT, not a guarantee - the
// system may still drop the context under memory pressure - which is why
// the create/destroy logging added alongside it stays in permanently. A
// second CREATED line in a report means this fell back and the guest-side
// restore path is the next thing to fix.
setPreserveEGLContextOnPause(true);
}
public void setEnableHistoricalEvents(boolean z) {
@@ -56,11 +74,23 @@ public class GameGLSurfaceView extends GLSurfaceView {
@Override // android.opengl.GLSurfaceView.EGLContextFactory
public EGLContext createContext(EGL10 egl10, EGLDisplay eGLDisplay, EGLConfig eGLConfig) {
// Counted and logged (task #62). Whether the EGL context actually
// dies across a pause decides which bug we have: a SECOND create
// means every GL object the guest owns was destroyed and something
// must re-upload them, while a single create for the whole session
// means the black textures come from somewhere else entirely.
// Nothing logged this before, so the question was unanswerable.
eglContextCreateCount++;
android.util.Log.i("mpcore_log", "GameGLSurfaceView: EGL context CREATED (#"
+ eglContextCreateCount + ") - every GL object must be (re)uploaded after this");
return egl10.eglCreateContext(eGLDisplay, eGLConfig, EGL10.EGL_NO_CONTEXT, new int[]{EGL_CONTEXT_CLIENT_VERSION, 2, 12344});
}
@Override // android.opengl.GLSurfaceView.EGLContextFactory
public void destroyContext(EGL10 egl10, EGLDisplay eGLDisplay, EGLContext eGLContext) {
eglContextDestroyCount++;
android.util.Log.i("mpcore_log", "GameGLSurfaceView: EGL context DESTROYED (#"
+ eglContextDestroyCount + ") - all textures, buffers and shaders are now gone");
egl10.eglDestroyContext(eGLDisplay, eGLContext);
}
});
@@ -175,7 +175,26 @@ class PermissionsActivity : AppCompatActivity() {
private fun initActivity() {
try {
startActivity(Intent(this, GameActivityMain::class.java))
// A crash report left by the previous run takes precedence over
// starting the game. The check lives HERE, not in GameActivityMain:
// this activity is the visible, foreground launcher entry, so the
// start is allowed. Doing it from GameActivityMain.onCreate - which
// starts the report and immediately finishes itself - was refused
// by the platform ("Activity start ONLY allowed by
// BAL_ALLOW_GRACE_PERIOD"), and the tester just landed back on the
// home screen with no report shown.
val next = when {
// A crash report from the previous run comes first - it is the
// thing the tester needs to see, and the next crash would
// overwrite it.
CrashReportActivity.pendingReport(this) != null -> CrashReportActivity::class.java
// Then the one-time unpack of the game data bundled in the APK.
// Checked on every launch but only does work once, so there is
// no cost after the first run.
!GameDataInstaller.isInstalled(this) -> GameDataUnpackActivity::class.java
else -> GameActivityMain::class.java
}
startActivity(Intent(this, next))
finish()
} catch (e: Exception) {
e.printStackTrace()
@@ -0,0 +1,12 @@
package nfs.mod.traceagent
// Bridge to the standalone libtrace_agent.so (see trace_agent/ at the repo
// root and ARM64_TRANSLATION_LAYER.md's "Native ARM32 tracing harness"
// plan) - diagnostic-only, for the "native32" flavor running on the Galaxy
// A9. Not present in the APK's own jniLibs; loaded from wherever it was
// pushed on-device (see TraceApplication).
object TraceAgentBridge {
// outputPath: absolute path (app's own private files dir) the native
// side appends a full call trace to - see trace_agent/trace_log.h.
external fun install(outputPath: String)
}
@@ -0,0 +1,45 @@
package nfs.mod.traceagent
import android.app.Application
import android.content.Context
import android.util.Log
// Earliest available hook point for installing the JNI trace table patch
// (see TraceAgentBridge/trace_agent's own comments) - as early as possible,
// before any real game library gets a chance to grab its own JNIEnv
// reference. Entirely inert on the normal ("translated") flavor: gated
// behind BuildConfig.NATIVE32, and the .so path is checked for existence
// before attempting System.load, so a normal run that never pushed
// libtrace_agent.so to the device just logs and continues - no crash, no
// behavior change, matching the plan's "does not touch the emulated path"
// constraint.
class TraceApplication : Application() {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
if (!com.ea.games.nfs13_arm.BuildConfig.NATIVE32) return
// Must live under the app's own private data dir (app_data_file
// SELinux context), not /data/local/tmp (shell_data_file) - confirmed
// live on the Galaxy A9 that Enforcing SELinux denies mmap-exec of a
// shell_data_file-labeled .so from an app-domain process ("couldn't
// map ... segment 1: Permission denied"). The same path is what
// wrap.<packageName>'s LD_PRELOAD value must point at too (see the
// plan's Phase 4 deployment notes).
val soPath = base.filesDir.absolutePath + "/libtrace_agent.so"
if (!java.io.File(soPath).exists()) {
Log.d(TAG, "libtrace_agent.so not found at $soPath - JNI tracing not installed")
return
}
try {
System.load(soPath)
val outputPath = base.filesDir.absolutePath + "/trace_output.log"
TraceAgentBridge.install(outputPath)
Log.d(TAG, "trace agent JNI hooks installed, writing to $outputPath")
} catch (e: Throwable) {
Log.d(TAG, "failed to install trace agent JNI hooks: $e")
}
}
companion object {
private const val TAG = "TraceApplication"
}
}
+16
View File
@@ -0,0 +1,16 @@
#!/system/bin/sh
# APK-bundled wrap.sh (2026-09-16, ARM64_TRANSLATION_LAYER.md - trace_agent
# LD_PRELOAD deployment investigation). The loose-file mechanism
# (/data/local/tmp/wrap.<packageName>) was confirmed NOT invoked by zygote
# on this specific Galaxy A9/Samsung build - a canary marker file placed by
# that script never got created across multiple relaunches, root and
# non-root, SELinux Enforcing and an attempted Permissive switch. This is
# the OTHER officially documented NDK mechanism (developer.android.com/ndk/
# guides/wrap-script): a wrap.sh bundled alongside the app's own native
# libraries, picked up automatically for a debuggable app. Points LD_PRELOAD
# at the app's own private files dir (not this APK-bundled location) so the
# actual libtrace_agent.so can still be updated by just `run-as`-copying a
# freshly-built one in, without needing to reinstall the whole APK each
# iteration.
export LD_PRELOAD=/data/data/com.ea.games.nfs13_arm/files/libtrace_agent.so
exec "$@"
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shares ONLY the crash directory, nothing else. FileProvider is required
because targetSdk 35 rejects file:// URIs in an ACTION_SEND intent. -->
<paths>
<external-files-path name="crashes" path="crashes/" />
</paths>
@@ -1,4 +1,4 @@
package com.ea.games.nfs13_mod
package com.ea.games.nfs13_arm
import org.junit.Test