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>
130 lines
5.3 KiB
Kotlin
130 lines
5.3 KiB
Kotlin
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}")
|
|
}
|
|
}
|
|
}
|