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,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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user