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
@@ -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}")
}
}
}