Files
nfsmw-online/app/src/main/java/com/ea/ironmonkey/GameActivityMain.kt
T
megboyzzandClaude 725ffbd8ed 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>
2026-09-22 23:17:47 +03:00

986 lines
37 KiB
Kotlin

package com.ea.ironmonkey
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.app.AlertDialog
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.AssetManager
import android.content.res.Configuration
import android.hardware.SensorManager
import android.media.AudioManager
import android.view.InputDevice
import android.opengl.GLES20
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.PowerManager.WakeLock
import android.os.SystemClock
import android.util.DisplayMetrics
import android.view.Gravity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.platform.ComposeView
import com.ea.EAIO.EAIO
import com.ea.EAMIO.StorageDirectory
import com.ea.ironmonkey.Log.d
import com.ea.ironmonkey.Log.i
import com.ea.ironmonkey.Log.setEnable
import com.ea.ironmonkey.ObbHelper.getObbFileName
import com.ea.ironmonkey.domain.AssetLocationType
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
import java.io.IOException
import java.io.InputStream
import java.util.Locale
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
class GameActivityMain : AppCompatActivity(), DrawFrameListener {
lateinit var accelerometer: Accelerometer
private lateinit var gameGLSurfaceView: GameGLSurfaceView
private lateinit var gameRenderer: GameRenderer
private lateinit var handler: Handler
private lateinit var mAd: AlertDialog.Builder
private lateinit var mFMODAudioDevice: FMODAudioDevice
private lateinit var mFrameLayout: FrameLayout
//private lateinit var mOrientationListener: OrientationEventListener
private val mResources: MutableMap<*, *>? = null
private lateinit var mWakeLock: WakeLock
lateinit var runLoop: RunLoop
private var splash = SplashScreen(this)
private var splashCounter = 0
private var splashDelay: Long = 0
private var splashTimer: Long = 0
private var laststate = 0
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_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.
private val carSelectTestReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
nfs.mod.mpcore.MultiplayerCore.triggerCarSelectTest()
}
}
// 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_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()
}
}
private fun updateRequestedOrientation(i: Int) {
}
// 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"
const val STATE_ADC_PROCESS: Int = 4
const val STATE_ADC_START: Int = 3
const val STATE_FULL_PROCESS: Int = 6
const val STATE_FULL_START: Int = 5
const val STATE_GAME_START: Int = 8
const val STATE_GOOGLE_DRM: Int = 2
const val STATE_RESTORE_CONTEXT: Int = 7
const val STATE_SPLASH: Int = 0
const val STATE_SPLASH_PROCESS: Int = 1
const val TAG: String = "GameActivityMain"
// Нужен в BitmapGraphics ((
lateinit var assetManager: AssetManager
var mAssetLocationType = AssetLocationType.EXTERNAL
var isAssetsReady: Boolean = false
private var mAudioManager: AudioManager? = null
private var oldState = 0
var isAnyMusicPlaying: Boolean = false
@JvmName("isAnyMusicPlaying") @JvmStatic get
private set
var state: Int = 0
fun isAtLeastAPI(i: Int): Boolean {
return Build.VERSION.SDK_INT >= i
}
@JvmStatic
fun GetDeviceName() = Build.MODEL
val osVersion: String
@JvmStatic get() = Build.VERSION.RELEASE
@JvmStatic
fun isAmazon() = false
@JvmStatic
fun GetDefaultLanguage() = Locale.getDefault().toString().substring(0, 2)
@JvmStatic
fun GetDeviceLocale(): String {
val upperCase =
Locale.getDefault().toString().replace('_', '-').uppercase(Locale.getDefault())
d(TAG, "GetDeviceLocale locale = $upperCase")
return upperCase
}
@JvmStatic
fun GetApplicationVersion() = BuildConfig.VERSION_NAME
}
fun useAssetsFileSystem(): Boolean {
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()
external fun nativeOnOrientationChange(i: Int)
external fun nativeOnPause()
external fun nativeOnPhysicalKeyDown(keyCode: Int, scanCode: Int)
external fun nativeOnPhysicalKeyUp(i: Int, i2: Int)
external fun nativeOnPhysicalKeyboardVisibilityChanged(z: Boolean)
external fun nativeOnPhysicalNavigationVisibilityChanged(z: Boolean)
external fun nativeOnRestart()
external fun nativeOnResume()
external fun nativeOnStart()
external fun nativeOnStop()
external fun nativeRestoreContext(): Boolean
external fun nativeSurfaceChanged(gl10: GL10?, i: Int, i2: Int)
external fun nativeSurfaceCreated(gl10: GL10?, eGLConfig: EGLConfig?)
public override fun onCreate(bundle: Bundle?) {
setEnable(true)
Companion.assetManager = assets
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()
if (!isAtLeastAPI(18) && getRequestedOrientation() != ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
}
if (isAtLeastAPI(28)) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
window.addFlags(67108864)
}
window.setFlags(1024, 1024)
requestWindowFeature(1)
/*mOrientationListener = object : OrientationEventListener(applicationContext) {
override fun onOrientationChanged(i: Int) {
if (this@GameActivityMain.mRotation == (this@GameActivityMain.window
.windowManager.defaultDisplay.rotation
.also { this@GameActivityMain.mRotation = it })
)
if (Settings.System.getInt(
this@GameActivityMain.contentResolver,
"accelerometer_rotation",
0
) == 1 || !isAtLeastAPI(18)
) {
this@GameActivityMain.accelerometer.updateOrientation(this@GameActivityMain.mRotation)
this@GameActivityMain.nativeOnOrientationChange(this@GameActivityMain.mRotation)
d(
TAG,
"OrientationEventListener::onOrientationChanged mRotation = " + this@GameActivityMain.mRotation.toString()
)
return
}
d(TAG, "OrientationEventListener::onOrientationChanged rotation disabled!")
}
}
if (mOrientationListener.canDetectOrientation()) {
mOrientationListener.enable()
}*/
mAudioManager = getSystemService(AUDIO_SERVICE) as AudioManager?
isAnyMusicPlaying = mAudioManager!!.isMusicActive()
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, "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)
val rotation = window.getWindowManager().getDefaultDisplay().getRotation()
when (rotation) {
0 -> d(TAG, "Orientation: ROTATION_0")
1 -> d(TAG, "Orientation: ROTATION_90")
2 -> d(TAG, "Orientation: ROTATION_180")
3 -> d(TAG, "Orientation: ROTATION_270")
}
mRotation = rotation
updateRequestedOrientation(rotation)
accelerometer = Accelerometer(sensorManager, defaultSensor, rotation)
gameGLSurfaceView = GameGLSurfaceView(this)
// mpcore lives in a separate Gradle module with no dependency on
// `app` (app depends on mpcore, not the other way - a reverse
// reference here would be circular), so it can't call back into this
// Activity by type. Hand it a plain interface implementation instead.
GameInput.dispatcher = object : SyntheticInputDispatcher {
override fun dispatchTap(x: Float, y: Float) = dispatchSyntheticTap(x, y)
}
gameRenderer = GameRenderer(this)
gameRenderer.setDrawFrameListener(this)
gameGLSurfaceView.setRenderer(gameRenderer)
runLoop = RunLoop(gameGLSurfaceView)
mFrameLayout = FrameLayout(this)
mFrameLayout.addView(gameGLSurfaceView)
mFrameLayout.addView(buildCarSelectionOverlay())
setContentView(mFrameLayout)
// 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+
// required-flag requirement transparently across minSdk 27.
androidx.core.content.ContextCompat.registerReceiver(
this, carSelectTestReceiver, IntentFilter("nfs.mod.mpcore.TEST_OPEN_CARSELECT"),
androidx.core.content.ContextCompat.RECEIVER_EXPORTED
)
androidx.core.content.ContextCompat.registerReceiver(
this, trueDirectCarSelectTestReceiver, IntentFilter("nfs.mod.mpcore.TEST_TRUE_DIRECT_CARSELECT"),
androidx.core.content.ContextCompat.RECEIVER_EXPORTED
)
d(TAG, "Init EAIO/EAMIO")
EAIO.Startup(this)
StorageDirectory.Startup(this)
ApplicationLifecycle.onActivityCreate(bundle, this)
d(TAG, "nativeOnCreate")
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)
}
fun getAssetSize(str: String): Int {
d(TAG, "getAssetSize filename = " + str)
val file = File(str)
val parent = file.getParent()
if (parent == null) {
if (mResources!!.containsKey(str)) {
d(TAG, "getAssetSize mResources contains $str")
return -1
}
d(TAG, "getAssetSize mResources not contains $str")
} else {
d(TAG, "getAssetSize path = $parent")
if (mResources!!.containsKey(parent)) {
d(TAG, "getAssetSize mResources contains $parent")
val map = mResources[parent] as MutableMap<*, *>?
val name = file.getName()
d(TAG, "getAssetSize name = $name")
if (map!!.containsKey(name)) {
d(TAG, "getAssetSize dir contains $name")
val fSNode = map[name] as FSNode
val i = if (fSNode.directory) -1 else fSNode.size
d(
TAG,
"getAssetSize filename = " + str + ", directory = " + fSNode.directory.toString() + ", size = " + fSNode.size.toString()
)
return i
}
d(TAG, "getAssetSize dir not contains $name")
} else {
d(TAG, "getAssetSize mResources not contains $parent")
}
}
return -2
}
val isObbAssets: Boolean
get() = mAssetLocationType == AssetLocationType.OBB
val isFullApkAssets: Boolean
get() = mAssetLocationType == AssetLocationType.ASSETS
private val versionCode: Int
get() {
try {
return getPackageManager().getPackageInfo(getPackageName(), 0).versionCode
} catch (e: PackageManager.NameNotFoundException) {
e.printStackTrace()
return 0
}
}
val obbFullPath: String
get() {
i(
javaClass.getName(),
obbDir.toString() + "/" + getObbFileName(this, versionCode)
)
return obbDir.toString() + "/" + getObbFileName(this, versionCode)
}
val assetManager: AssetManager?
get() = getResources().getAssets()
fun enableSleepMode() {
if (isSleepModeEnabled) {
return
}
isSleepModeEnabled = true
wakeLockRelease()
}
fun disableSleepMode() {
if (isSleepModeEnabled) {
isSleepModeEnabled = false
wakeLockAcquire()
}
}
private fun checkAnyMusicActive() {
if (mAudioManager != null) {
isAnyMusicPlaying = mAudioManager!!.isMusicActive()
nativeOnMusicPlayerStateChanged()
}
}
public override fun onStart() {
CallGC()
super.onStart()
wakeLockAcquire()
nativeOnStart()
ApplicationLifecycle.onActivityStart(this)
}
public override fun onRestart() {
i(TAG, "onRestart")
super.onRestart()
ApplicationLifecycle.onActivityRestart(this)
nativeOnRestart()
}
public override fun onPause() {
i(TAG, "onPause state=$state")
super.onPause()
mFMODAudioDevice.stop()
gameGLSurfaceView.onPause()
ApplicationLifecycle.onActivityPause(this)
nativeOnPause()
}
private fun ForceHideVirtualKeyboard() {
val currentFocus = getCurrentFocus()
if (currentFocus != null) {
(getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
currentFocus.getWindowToken(),
0
)
}
window.setSoftInputMode(3)
}
public override fun onResume() {
i(TAG, "onResume")
super.onResume()
if (state != 7) {
oldState = state
state = 7
gameRenderer.setDrawFrameListener(this)
}
gameGLSurfaceView.onResume()
ApplicationLifecycle.onActivityResume(this)
nativeOnResume()
checkAnyMusicActive()
mFMODAudioDevice.start()
i(TAG, "onResume() isMusicActive = $isAnyMusicPlaying")
}
public override fun onStop() {
i(TAG, "onStop")
super.onStop()
wakeLockRelease()
nativeOnStop()
}
public override fun onDestroy() {
i(TAG, "onDestroy")
unregisterReceiver(carSelectTestReceiver)
unregisterReceiver(trueDirectCarSelectTestReceiver)
super.onDestroy()
if (state == 8) {
ApplicationLifecycle.onActivityDestroy(this)
nativeOnDestroy()
}
StorageDirectory.Shutdown()
EAIO.Shutdown()
exitProcess(0)
}
@SuppressLint("InvalidWakeLockTag")
fun wakeLockAcquire() {
if (isSleepModeEnabled) return
if (mWakeLock.isHeld) return
mWakeLock.acquire(10*60*1000L /*10 minutes*/)
}
fun wakeLockRelease() {
if (isSleepModeEnabled) return
if (mWakeLock.isHeld) return
mWakeLock.release()
}
override fun onSaveInstanceState(bundle: Bundle) {
super.onSaveInstanceState(bundle)
ApplicationLifecycle.onActivitySaveInstanceState(bundle, this)
}
public override fun onActivityResult(i: Int, i2: Int, intent: Intent?) {
super.onActivityResult(i, i2, intent)
ApplicationLifecycle.onActivityResult(i, i2, intent, this)
}
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
}
if ((keyCode == 4 || keyCode == 108) && keyEvent.repeatCount > 0) {
return true
}
val scanCode = keyEvent.scanCode
gameGLSurfaceView.queueEvent {
nativeOnPhysicalKeyDown(keyCode, scanCode)
}
return !IsSystemKey(keyCode)
}
override fun onKeyUp(i: Int, keyEvent: KeyEvent): Boolean {
super.onKeyUp(i, keyEvent)
if (IsSystemKey(i)) return false
if (state != 8) {
return true
}
val scanCode = keyEvent.scanCode
gameGLSurfaceView.queueEvent { this@GameActivityMain.nativeOnPhysicalKeyUp(i, scanCode) }
return !IsSystemKey(i)
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
//super.onBackPressed()
ApplicationLifecycle.onBackPressed()
}
override fun onConfigurationChanged(configuration: Configuration) {
d(TAG, "onConfigurationChanged($configuration)")
super.onConfigurationChanged(configuration)
val rotation = window.windowManager.defaultDisplay.rotation
when (rotation) {
0 -> i(TAG, "Orientation: ROTATION_0")
1 -> i(TAG, "Orientation: ROTATION_90")
2 -> i(TAG, "Orientation: ROTATION_180")
3 -> i(TAG, "Orientation: ROTATION_270")
}
}
/**
* 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
if (!z) {
if (mFMODAudioDevice.isMixing) {
mFMODAudioDevice.stop()
checkAnyMusicActive()
}
i(TAG, "onWindowFocusChanged() isMusicActive = $isAnyMusicPlaying")
ForceHideVirtualKeyboard()
nativeOnPhysicalKeyDown(131, 0)
nativeOnPhysicalKeyUp(131, 0)
if (state == 8) {
oldState = state
state = 7
gameRenderer.setDrawFrameListener(this)
}
} else {
if (!mFMODAudioDevice.isMixing) {
checkAnyMusicActive()
mFMODAudioDevice.start()
}
i(TAG, "onWindowFocusChanged() isMusicActive = $isAnyMusicPlaying")
}
ApplicationLifecycle.onActivityWindowFocusChanged(z, this)
}
fun getGameGLSurfaceView(): GameGLSurfaceView {
return gameGLSurfaceView
}
// Synthesizes a tap (ACTION_DOWN + ACTION_UP) directly into the game's
// own GLSurfaceView, exactly as if the player had touched the screen at
// (x, y). Used by mpcore's GameEvents-driven automation (see
// ANALYSIS/PROGRESS.md - "trigger the native car-select Flow screen
// without needing FlowManager's internal API") to replay a known-working
// tap sequence instead of reverse-engineering the transition call.
// No special permission needed: this dispatches into our own view tree,
// it isn't a system-wide input injection.
fun dispatchSyntheticTap(x: Float, y: Float) {
i(TAG, "dispatchSyntheticTap: ($x, $y) on $gameGLSurfaceView")
val downTime = SystemClock.uptimeMillis()
val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0)
// GameGLSurfaceView.onTouchEvent() branches on event.getSource() (see
// its processTouchScreenEvent/processTouchPadEvent split) - a
// MotionEvent.obtain()'d event defaults to SOURCE_UNKNOWN (0), which
// matches neither branch, so the View layer happily "consumes" it
// (dispatchTouchEvent returns true) while the native touch handler
// never actually runs. Must set this explicitly.
down.source = InputDevice.SOURCE_TOUCHSCREEN
val downConsumed = gameGLSurfaceView.dispatchTouchEvent(down)
i(TAG, "dispatchSyntheticTap: ACTION_DOWN consumed=$downConsumed")
down.recycle()
handler.postDelayed({
val upTime = SystemClock.uptimeMillis()
val up = MotionEvent.obtain(downTime, upTime, MotionEvent.ACTION_UP, x, y, 0)
up.source = InputDevice.SOURCE_TOUCHSCREEN
gameGLSurfaceView.dispatchTouchEvent(up)
up.recycle()
}, 60)
}
fun GetViewRoot() = window.decorView.getRootView().parent
fun CallGC() {
d(TAG, "Call garbage collector")
System.gc()
}
fun ShowMessage(str: String?, strArr: Array<String?>, z: Boolean) {
d(TAG, "ShowMessage msg=$str finish=$z")
mAd = AlertDialog.Builder(this)
mAd.setMessage(str)
mAd.setCancelable(false)
mAd.setPositiveButton(strArr[0]) { dialogInterface, i ->
if (z) {
this@GameActivityMain.finish()
}
}
handler.postDelayed({ this@GameActivityMain.mAd.show() }, 20L)
}
override fun onLowMemory() {
val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
super.onLowMemory()
}
fun GetNaturalOrientation(): Int {
val width: Int
val height: Int
val defaultDisplay = window.windowManager.defaultDisplay
when (defaultDisplay.rotation) {
0, 2 -> {
width = defaultDisplay.getWidth()
height = defaultDisplay.getHeight()
}
1, 3 -> {
width = defaultDisplay.getHeight()
height = defaultDisplay.getWidth()
}
else -> {
height = 0
width = 0
}
}
if (width > height) {
d(TAG, "NaturalOrientation = LANDSCAPE")
return 0
}
d(TAG, "NaturalOrientation = PORTRAIT")
return 1
}
val displayMetrics: DisplayMetrics?
get() {
val displayMetrics = DisplayMetrics()
windowManager.getDefaultDisplay().getRealMetrics(displayMetrics)
return displayMetrics
}
// Example UI-layer consumer for GameEvents.onCarSelected (PROGRESS.md
// cont.63b) - a small ComposeView overlay, top-start corner, drawn above
// the game's own GLSurfaceView. Registers CarSelectionState as a
// GameEventListener so it starts updating as soon as the player
// confirms a car; the badge itself only appears once that first fires.
private fun buildCarSelectionOverlay(): ComposeView {
registerCarSelectionListener()
return ComposeView(this).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.TOP or Gravity.START,
).apply { setMargins(24, 24, 24, 24) }
setContent {
MaterialTheme {
CarSelectionBadge(selection = CarSelectionState.current)
}
}
}
}
override fun setContentView(view: View) {
d(TAG, "setContentView($view)")
if (view === mFrameLayout) {
super.setContentView(view)
return
}
val childCount = mFrameLayout.childCount
for (i in 0..<childCount) {
val childAt = mFrameLayout.getChildAt(i)
if (childAt !== gameGLSurfaceView) {
mFrameLayout.removeView(childAt)
}
}
if (view !== gameGLSurfaceView) {
mFrameLayout.addView(view)
}
mFrameLayout.bringChildToFront(view)
}
override fun onDrawFrame(gl10: GL10?) {
var inputStream: InputStream?
var shouldCleanupSplash = false
// Логирование изменения состояния
if (state != laststate) {
Log.d("state::game", "onDrawFrame state=$state")
Log.d(TAG, "onDrawFrame state=$state")
laststate = state
}
when (state) {
STATE_SPLASH -> {
//splash = SplashScreen(this)
splash.init(
gl10,
gameRenderer.width,
gameRenderer.height
)
state = STATE_SPLASH_PROCESS
splashCounter = 3
}
STATE_SPLASH_PROCESS -> {
splashTimer = System.currentTimeMillis() + 2000
state = STATE_GAME_START
}
STATE_RESTORE_CONTEXT -> {
if (splashDelay < System.currentTimeMillis() && hasWindowFocus()) {
state = if (oldState == STATE_GAME_START) {
splashTimer = System.currentTimeMillis() + 2000
STATE_GAME_START
} else {
oldState
}
}
}
STATE_GAME_START -> {
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
}
}
}
}
// Очистка экрана
GLES20.glClearColor(1.0f, 0.0f, 0.0f, 1.0f)
//GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
// Отрисовка сплеш-скрина если он существует
splash.draw(
gl10,
gameRenderer.width,
gameRenderer.height
)
// Очистка сплеш-скрина если нужно
/*if (shouldCleanupSplash) {
splash.destroy(gl10)
}*/
}
val totalMemory: Int
get() = 40000
fun openURL(str: String) {
val replace = str.replace("http://", "https://")
startActivity(Intent("android.intent.action.VIEW", replace.toUri()))
}
val performanceScore: Float
get() = 6.6f
fun needInstallWallpaper(): Boolean {
i(TAG, "needInstallWallpaper()")
return false
}
fun installWallpaper() {}
val utcTime: Long
get() = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis())
val notchesBoundingRects: Array<IntArray?>
get() {
if (Build.VERSION.SDK_INT < 28) {
return emptyArray()
}
val displayCutout = window.decorView.rootWindowInsets?.displayCutout
if (displayCutout == null) {
return emptyArray()
}
val boundingRects = displayCutout.boundingRects
return boundingRects.map { rect ->
intArrayOf(rect.left, rect.top, rect.width(), rect.height())
}.toTypedArray()
}
}