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>
986 lines
37 KiB
Kotlin
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()
|
|
}
|
|
}
|