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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user