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