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>
238 lines
9.3 KiB
Kotlin
238 lines
9.3 KiB
Kotlin
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("Отправить отчёт") }
|
||
}
|
||
}
|
||
}
|
||
}
|