merge: consolidate all mod-hook + ARM64 emulation work into master

master was stale (predates both feature branches). native-arm32-trace-harness
is a strict superset of lan-event-injection-poc (already merged in 1a771ae)
plus the Unicorn/GuestFn emulation layer, and file-open-hook-poc /
track-hook-toggle-flag / arm64-translation-poc contribute nothing unique
beyond it. This merge makes master the actual trunk; going forward both
mod-hooks and ARM64-emulation work happens on feature branches merged
back via PR.
This commit is contained in:
2026-09-23 00:48:03 +03:00
806 changed files with 545043 additions and 298 deletions
+36
View File
@@ -13,3 +13,39 @@
.externalNativeBuild
.cxx
local.properties
# IDA databases - 148MB for libapp.so.i64 alone, and regenerable from the .so
*.i64
*.id0
*.id1
*.nam
*.til
# trace_agent is built by its own build.sh, outside Gradle
trace_agent/build/
# ostream_repro likewise - CMake/ninja output, the sources next to it are kept
ostream_repro/build/
# ad-hoc device capture logs
device_run_*.log
# Where `gradlew assembleTranslatedRelease` output was collected by hand for
# signing. 611 MB of signed APK - a build product, and one that carries the
# bundled OBB with it, so it must never reach history.
app/translated/
# Release signing material never belongs in the repo - see app/build.gradle.kts
*.jks
*.keystore
keystore.properties
*-release.properties
# Bundled game data (~595 MB). A build INPUT, not source: it is the game's own
# OBB, copied into assets so the APK self-extracts it on first launch (see
# GameDataInstaller.kt). Keeping it out of history on purpose - a 595 MB blob
# is rejected outright by most hosts, and once committed it can only be removed
# by rewriting history.
#
# To build: copy the OBB here yourself, under exactly this name.
# cp main.<versionCode>.<package>.obb app/src/main/assets/game_data.obb
# Without it the app still builds and runs; it just expects the OBB to already
# be on the device at getObbDir(), the way it was before this was bundled.
app/src/main/assets/game_data.obb
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
<bytecodeTargetLevel target="17" />
</component>
</project>
+42
View File
@@ -2,7 +2,49 @@
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="savemanager">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="main">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="unitTest">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="androidTest">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="main">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-28T12:35:54.649730307Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=2B101JEGR07875" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="unitTest">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-28T12:35:54.649730307Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=2B101JEGR07875" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="androidTest">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-28T12:35:54.649730307Z">
<Target type="DEFAULT_BOOT">
+4 -4
View File
@@ -9,10 +9,10 @@
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/mpcore" />
<option value="$PROJECT_DIR$/savemanager" />
<option value="$USER_HOME$/AndroidStudioProjects/NFSMostWanted128" />
<option value="$USER_HOME$/AndroidStudioProjects/NFSMostWanted128/app" />
<option value="$USER_HOME$/AndroidStudioProjects/NFSMostWanted128/mpcore" />
<option value="$USER_HOME$/AndroidStudioProjects/NFSMostWanted128/savemanager" />
</set>
</option>
</GradleProjectSettings>
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="corretto-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
Generated
+1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$USER_HOME$/AndroidStudioProjects/NFSMostWanted128" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+179 -5
View File
@@ -1,25 +1,87 @@
// Inside the android { } block the name `java` resolves to Gradle's own
// java extension and shadows the package, so these types have to be
// imported here rather than fully qualified at the use site.
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.ea.games.nfs13_mod"
// ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md): "_arm"
// instead of "_mod" so this build can be installed side by side with
// the real armeabi-v7a mod, not overwrite it.
namespace = "com.ea.games.nfs13_arm"
compileSdk = 35
defaultConfig {
applicationId = "com.ea.games.nfs13_mod"
applicationId = "com.ea.games.nfs13_arm"
minSdk = 27
targetSdk = 35
versionCode = 1003128
versionName = "1.3.128"
ndk.abiFilters.add("armeabi-v7a")
//ndk.abiFilters.add("x86")
// mpcore is now plain native ARM64 code driving an embedded ARM32
// CPU-emulation core - no armeabi-v7a native code is built/shipped
// for this app at all.
ndk.abiFilters.add("arm64-v8a")
// x86_64 (2026-09-22): lets the translated build install in WayDroid,
// whose Android image is x86_64. Unicorn picks its tcg/i386 backend
// there automatically, so the ARM32 guest is translated by US rather
// than by houdini - which is what made earlier WayDroid attempts with
// the native ARM build unreliable.
//
// NOTE: the flat guest mapping (task #61, worth 1.24x) is implemented
// only in tcg/aarch64, so an x86_64 host silently falls back to the
// software MMU and will be slower. Fine for a test target; port the
// same change into tcg/i386 if it ever becomes a shipping target.
ndk.abiFilters.add("x86_64")
// Default: false. Overridden to true by the "native32" flavor - see
// its own comment below.
buildConfigField("boolean", "NATIVE32", "false")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
ndkVersion = "21.0.6113669"
ndkVersion = "27.0.12077973"
// Release signing. The keystore and its password live OUTSIDE this repo
// (~/keystores/), deliberately: this is a git working tree, and a signing
// identity that can be committed eventually is committed. Gradle reads
// them from a properties file if it is there, and falls back to the debug
// key if it is not - so a checkout without the key still builds, it just
// produces something that cannot be shipped.
//
// To create the key (once, and then back it up - losing it means never
// being able to update an installed build):
//
// keytool -genkeypair -v -keystore ~/keystores/nfsmw-release.jks \
// -alias nfsmw -keyalg RSA -keysize 4096 -validity 10950
//
// then write ~/keystores/nfsmw-release.properties with:
// storeFile=/home/<you>/keystores/nfsmw-release.jks
// storePassword=...
// keyAlias=nfsmw
// keyPassword=...
signingConfigs {
val releaseProps = File(System.getProperty("user.home"), "keystores/nfsmw-release.properties")
if (releaseProps.isFile) {
val props = Properties().apply { releaseProps.inputStream().use { load(it) } }
create("release") {
storeFile = file(props.getProperty("storeFile"))
storePassword = props.getProperty("storePassword")
keyAlias = props.getProperty("keyAlias")
keyPassword = props.getProperty("keyPassword")
// Both modern signature schemes: v2 covers the whole APK and
// is what Android 7+ verifies, v3 allows rotating the key
// later without invalidating existing installs.
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes {
release {
@@ -28,6 +90,28 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// Local-testing-only (2026-09-05, "why is this ~100x slower than
// native" investigation - see ARM64_TRANSLATION_LAYER.md): no
// real release signing config exists in this prototype, and
// there's no intent to ship this build anywhere - reusing the
// auto-generated debug keystore just makes `assembleTranslatedRelease`
// installable on a test device the same way translatedDebug
// already is. The actual point of testing this build type is the
// NATIVE side (mpcore's CMakeLists.txt now forces
// CMAKE_BUILD_TYPE=RelWithDebInfo unconditionally, so this
// isn't even required for that fix to take effect - it's here so
// this variant can be installed at all for a genuine debuggable=false
// comparison, e.g. CheckJNI's behavior).
// Real key when it exists, debug key otherwise. Announced at
// configuration time rather than discovered later from a
// mysteriously unshippable APK.
signingConfig = signingConfigs.findByName("release") ?: run {
logger.warn(
"RELEASE SIGNING: ~/keystores/nfsmw-release.properties not found - " +
"signing with the DEBUG key. This APK must NOT be distributed."
)
signingConfigs.getByName("debug")
}
}
}
compileOptions {
@@ -37,6 +121,89 @@ android {
kotlinOptions {
jvmTarget = "11"
}
androidResources {
// The bundled game data (assets/game_data.obb, ~595 MB) is an already
// compressed archive - deflating it again would cost a long build and
// a slower install for no size gain, and it must stay STORED so it can
// be streamed straight out of the APK.
noCompress += "obb"
}
buildFeatures {
compose = true
buildConfig = true
}
// "native32" flavor - diagnostic-only reference build for the native
// ARM32 tracing investigation (see ARM64_TRANSLATION_LAYER.md /
// sorted-popping-plum.md's "Native ARM32 tracing harness" plan). Real
// armeabi-v7a support and a BuildConfig.NATIVE32 flag GameActivityMain
// uses to load the real, unmodified native libraries directly
// (System.loadLibrary) instead of going through mpcore's emulation path
// - only meaningful on genuinely 32-bit-capable hardware (the Galaxy
// A9), never intended to ship. MUST be armeabi-v7a-ONLY (not just
// "armeabi-v7a added alongside arm64-v8a") - confirmed live on-device
// that shipping both ABIs makes Android launch the process via the
// 64-bit app_process64/Zygote (arm64-v8a is present, so it's preferred),
// which can never dlopen/LD_PRELOAD a 32-bit .so at all (a process's
// bitness is fixed for its whole lifetime, not per-library) - defeats
// the entire point of this flavor. ndk.abiFilters.clear() first,
// since AGP's per-flavor abiFilters otherwise ADD to defaultConfig's
// set rather than replace it.
flavorDimensions += "abi"
productFlavors {
create("translated") {
dimension = "abi"
// Default/existing behavior - the emulated arm64-v8a path,
// unchanged from before this flavor split existed.
}
create("native32") {
dimension = "abi"
ndk.abiFilters.clear()
ndk.abiFilters.add("armeabi-v7a")
buildConfigField("boolean", "NATIVE32", "true")
}
}
// 2026-09-16 (ARM64_TRANSLATION_LAYER.md - trace_agent wrap.sh deployment):
// manually injecting a bundled wrap.sh into the packaged native32 APK
// (AGP's own native-lib merge/strip pipeline only recognizes *.so files
// and silently drops anything else) failed to install at all with
// "Failed to extract native libraries, res=-2" under the default
// extractNativeLibs=false packaging - PackageManager validates every
// entry under lib/<abi>/ as a loadable library when it plans to mmap
// straight from the APK, and a plain shell script fails that check.
// Legacy (extracted-to-disk) packaging doesn't do that same strict
// validation. Only applied to native32 - the translated flavor doesn't
// need or want this (slightly larger install, marginally slower first
// native-lib load).
productFlavors.getByName("native32") {
packaging {
jniLibs {
useLegacyPackaging = true
}
}
}
}
// 2026-09-06 (native32-on-real-hardware launch investigation - see
// ARM64_TRANSLATION_LAYER.md): ndk.abiFilters above only restricts THIS
// module's own native code - the "native32" flavor still needs `mpcore` on
// the compile classpath (GameActivityMain.kt's shared, non-flavor-specific
// source calls mpcore functions in its non-NATIVE32 branch), and mpcore's
// own CMakeLists always builds arm64-v8a regardless of which app flavor
// pulls it in. That arm64-v8a .so was silently merged into the native32
// APK too, so a real device with both an arm64-v8a AND an armeabi-v7a lib
// present chose to launch the process as 64-bit (app_process64) - which can
// never load this flavor's 32-bit fmodex/fmodevent/Nimble/app libraries at
// all, confirmed live via `UnsatisfiedLinkError: couldn't find "libfmodex.so"`
// (nativeLibraryDirectories only listed arm64/arm64-v8a paths). Explicitly
// drop every arm64-v8a .so from the native32 variant's packaging so the
// APK is genuinely armeabi-v7a-only, restoring 32-bit process selection.
androidComponents {
onVariants(selector().withFlavor("abi" to "native32")) { variant ->
variant.packaging.jniLibs.excludes.add("lib/arm64-v8a/*.so")
}
}
dependencies {
@@ -48,6 +215,13 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.material3)
implementation(libs.androidx.activity.compose)
debugImplementation(libs.androidx.ui.tooling)
implementation("com.parse.bolts:bolts-tasks:1.4.0")
implementation("org.apache.httpcomponents:httpclient-android:4.3.5.1")
implementation("com.google.code.gson:gson:2.7")
@@ -1,4 +1,4 @@
package com.ea.games.nfs13_mod
package com.ea.games.nfs13_arm
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.ea.games.nfs13_mod", appContext.packageName)
assertEquals("com.ea.games.nfs13_arm", appContext.packageName)
}
}
+29
View File
@@ -26,6 +26,7 @@
<uses-configuration android:reqTouchScreen="finger"/>
<application
android:name="nfs.mod.traceagent.TraceApplication"
android:theme="@style/Theme.AppCompat.NoActionBar"
android:label="@string/app_name"
android:icon="@mipmap/adaptive_icon"
@@ -48,6 +49,34 @@
android:value="bar"/>
</activity>
<!-- One-time unpack of the game data bundled in the APK. -->
<activity
android:name="com.ea.ironmonkey.GameDataUnpackActivity"
android:exported="false"
android:excludeFromRecents="true"
android:label="Подготовка данных" />
<!-- Shown on the launch after a crash, never during one - a signal
handler cannot start an Activity (see crash_handler.cpp). -->
<activity
android:name="com.ea.ironmonkey.CrashReportActivity"
android:exported="false"
android:excludeFromRecents="true"
android:label="Crash report" />
<!-- Lets the tester share the zip. Required: targetSdk 35 rejects
file:// URIs in ACTION_SEND. Grants access to the crash directory
only, and only for the duration of the send. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.crashreports"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/crash_report_paths" />
</provider>
<activity
android:exported="true"
android:name="com.ea.ironmonkey.PermissionsActivity"
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,98 @@
package com.ea.ironmonkey
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import nfs.mod.mpcore.GameEventListener
import nfs.mod.mpcore.GameEvents
// Example UI-layer consumer of GameEvents.onCarSelected (see PROGRESS.md
// cont.63/63b for how carId/colorName/colorR-G-B-A get resolved natively).
// Bundles the four separate color components the JNI bridge hands over into
// one entity carrying a real Compose Color, since that's what any Composable
// actually wants to draw with - the raw ints only exist because they're what
// crosses the JNI boundary cleanly.
data class CarSelection(
val carId: String,
val colorName: String,
val color: Color,
)
// Tiny reactive holder + GameEventListener adapter: translates the raw
// (carId, colorName, r, g, b, a) callback into a CarSelection and exposes it
// as Compose state. Kept separate from CarSelection itself so a non-Compose
// consumer (or the future native RatNet client, per the user's own
// architecture note) could register its own GameEventListener directly
// against the same raw callback without depending on this class at all.
object CarSelectionState : GameEventListener {
var current by mutableStateOf<CarSelection?>(null)
private set
override fun onCarSelected(
carId: String,
colorName: String,
colorR: Int,
colorG: Int,
colorB: Int,
colorA: Int,
) {
current = CarSelection(
carId = carId,
colorName = colorName,
color = Color(red = colorR, green = colorG, blue = colorB, alpha = colorA),
)
}
}
// Small overlay badge: car id + resolved color name + a swatch painted with
// the actual Compose Color. Shows nothing until the player has confirmed a
// car at least once this session.
@Composable
fun CarSelectionBadge(selection: CarSelection?, modifier: Modifier = Modifier) {
AnimatedVisibility(visible = selection != null, modifier = modifier) {
val s = selection ?: return@AnimatedVisibility
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
tonalElevation = 4.dp,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(20.dp)
.background(color = s.color, shape = RoundedCornerShape(4.dp))
)
Text(
text = "${s.carId} ${s.colorName}",
modifier = Modifier.padding(start = 8.dp).width(220.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
// Call once, e.g. from GameActivityMain.onCreate, to start receiving
// onCarSelected events into CarSelectionState.
fun registerCarSelectionListener() {
GameEvents.register(CarSelectionState)
}
@@ -0,0 +1,237 @@
package com.ea.ironmonkey
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Shown on the launch AFTER a crash, never during one.
*
* The native handler (crash_handler.cpp) can only write a file - a signal
* handler runs on an already-broken process where JNI and Activities are not
* legal. So it drops `crash_pending.txt` and dies; this screen is what the
* tester sees next time they open the game.
*
* Everything it produces stays on the device unless the tester presses send.
* There is no backend and no automatic upload - see BETA_TELEMETRY_PLAN.md.
*
* Built with Compose/Material3 and its own dark colour scheme rather than the
* game's `Theme.AppCompat.NoActionBar`: this screen must render correctly no
* matter what the rest of the app's theming does, and it is the one screen a
* tester sees when everything else has already gone wrong.
*/
class CrashReportActivity : ComponentActivity() {
companion object {
private const val TAG = "CrashReport"
private const val PENDING_NAME = "crash_pending.txt"
/** Reports live here: Android/data/<pkg>/files/crashes - reachable over USB, no permission needed. */
fun crashDir(activity: android.app.Activity): File =
File(activity.getExternalFilesDir(null), "crashes")
/** The file the native handler writes. Null if there is nothing waiting. */
fun pendingReport(activity: android.app.Activity): File? =
File(crashDir(activity), PENDING_NAME).takeIf { it.isFile && it.length() > 0 }
}
private var bundle: File? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Android 15 draws every app edge to edge whether it asks or not, so
// the choice is between handling insets or having text sit under the
// status bar. Declaring it explicitly and letting Scaffold apply the
// padding is the supported way; the first version did neither and the
// content ran under the system bars.
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val pending = pendingReport(this)
if (pending == null) {
// Nothing to report - never block the tester on an empty screen.
finish()
return
}
// Rename out of the way FIRST, before anything that could fail. The
// native handler always writes the same fixed name (it cannot safely
// format a timestamp inside a signal handler), so leaving it in place
// would let the next crash overwrite a report not yet sent.
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
val kept = File(crashDir(this), "crash-$stamp.txt")
if (!pending.renameTo(kept)) {
Log.w(TAG, "could not rename $pending - reporting it in place")
}
val report = if (kept.isFile) kept else pending
val details = buildString {
append(deviceSummary())
append("\n\n")
append(runCatching { report.readText() }.getOrElse { "(could not read the report: $it)" })
}
bundle = runCatching { zipReport(report, details, stamp) }
.onFailure { Log.w(TAG, "could not build the zip", it) }
.getOrNull()
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
CrashReportScreen(
details = details,
bundlePath = bundle?.absolutePath,
canSend = bundle != null,
onSend = ::share,
onContinue = ::startGame,
)
}
}
}
private fun deviceSummary(): String = buildString {
append("device: ${Build.MANUFACTURER} ${Build.MODEL}\n")
append("android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})\n")
append("soc: ${Build.HARDWARE}\n")
append("abis: ${Build.SUPPORTED_ABIS.joinToString(", ")}\n")
append("app: ${appVersion()}")
}
private fun appVersion(): String = runCatching {
val p = packageManager.getPackageInfo(packageName, 0)
"${p.versionName} (${p.longVersionCode})"
}.getOrElse { "(unknown)" }
private fun zipReport(report: File, details: String, stamp: String): File {
val out = File(crashDir(this), "crash-$stamp.zip")
ZipOutputStream(out.outputStream().buffered()).use { zip ->
zip.putNextEntry(ZipEntry("crash.txt"))
zip.write(details.toByteArray())
zip.closeEntry()
if (report.isFile) {
zip.putNextEntry(ZipEntry(report.name))
report.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
}
}
return out
}
private fun share() {
val file = bundle ?: return
val uri = runCatching {
FileProvider.getUriForFile(this, "$packageName.crashreports", file)
}.getOrElse {
Log.w(TAG, "FileProvider failed for $file", it)
return
}
val send = Intent(Intent.ACTION_SEND).apply {
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "NFSMW arm64 - отчёт о сбое")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(send, "Отправить отчёт"))
}
private fun startGame() {
startActivity(Intent(this, GameActivityMain::class.java))
finish()
}
}
@Composable
private fun CrashReportScreen(
details: String,
bundlePath: String?,
canSend: Boolean,
onSend: () -> Unit,
onContinue: () -> Unit,
) {
// Scaffold's innerPadding carries the system-bar insets, so nothing ends up
// under the status bar or the gesture handle.
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp)
) {
Text(
text = "Игра аварийно завершилась",
style = MaterialTheme.typography.headlineSmall,
)
Text(
// Say plainly what is in the file before offering to send it.
// The tester is the one sending it; they should know what it
// contains.
text = "Отчёт сохранён на устройстве. В нём модель телефона, версия Android, " +
"версия сборки и технические данные о сбое. Личных данных и игрового " +
"аккаунта в нём нет.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
if (bundlePath != null) {
Text(
text = bundlePath,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
Card(modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.weight(1f)
) {
Text(
text = details,
fontSize = 11.sp,
lineHeight = 15.sp,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
OutlinedButton(onClick = onContinue) { Text("Продолжить") }
Button(onClick = onSend, enabled = canSend) { Text("Отправить отчёт") }
}
}
}
}
@@ -0,0 +1,86 @@
package com.ea.ironmonkey
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import nfs.mod.mpcore.GameEventListener
import nfs.mod.mpcore.GameEvents
// See DEBUG_MENU.md for the full design. Local-only Compose state for now -
// Apply doesn't touch any game memory, no real balance getter/setter has
// been located yet (see DEBUG_MENU.md §3). Exists purely so the debug panel
// has something to edit while that RE work is pending.
object DebugMoneyState {
var amount by mutableStateOf(0L)
private set
fun apply(newAmount: Long) {
amount = newAmount
}
}
// Same GameEventListener-as-Compose-state adapter shape as CarSelectionState
// (CarSelectionOverlay.kt) - tracks whether the map has loaded at least once
// this session, which is when the debug button should appear.
object DebugMenuVisibility : GameEventListener {
var mapLoaded by mutableStateOf(false)
private set
override fun onMapLoaded() {
mapLoaded = true
}
}
// Call once, e.g. from GameActivityMain.onCreate, to start receiving
// onMapLoaded into DebugMenuVisibility.
fun registerDebugMenuListener() {
GameEvents.register(DebugMenuVisibility)
}
@Composable
fun DebugMenuButton(visible: Boolean, modifier: Modifier = Modifier) {
var dialogOpen by remember { mutableStateOf(false) }
AnimatedVisibility(visible = visible, modifier = modifier) {
FloatingActionButton(onClick = { dialogOpen = true }) {
Text("DBG")
}
}
if (dialogOpen) {
DebugMoneyDialog(onDismiss = { dialogOpen = false })
}
}
@Composable
private fun DebugMoneyDialog(onDismiss: () -> Unit) {
var text by remember { mutableStateOf(DebugMoneyState.amount.toString()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Debug: Money") },
text = {
OutlinedTextField(
value = text,
onValueChange = { input -> text = input.filter(Char::isDigit) },
label = { Text("Amount") },
singleLine = true,
)
},
confirmButton = {
TextButton(onClick = {
DebugMoneyState.apply(text.toLongOrNull() ?: DebugMoneyState.amount)
onDismiss()
}) { Text("Apply") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@@ -4,24 +4,31 @@ 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
@@ -31,7 +38,11 @@ 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.DebugFeatures
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
@@ -41,6 +52,9 @@ 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
@@ -67,10 +81,70 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
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.
// cont.78 FIX: BroadcastReceiver.onReceive runs on the main thread, but
// every other call site in this file that touches native game state
// (nativeOnPhysicalKeyDown/Up, dispatchSyntheticTap) wraps it in
// gameGLSurfaceView.queueEvent{} to marshal onto the GL/render thread,
// where MapScreen/FlowNode/etc. actually live. These two receivers were
// the one place that called straight into JNI from the main thread -
// a real cross-thread race, reproduced live as a consistent
// "fault addr 0x8" SIGSEGV inside FireEventOutput on every single fire,
// independent of save state or any mpcore hook. Wrapping in queueEvent
// matches the pattern used everywhere else in this file.
private val carSelectTestReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
gameGLSurfaceView.queueEvent { 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` (nfs13_mod on the native32/launcher build -
// same trigger, different package id per build flavor). See PROGRESS.md
// cont.48, and cont.78 above for why this is wrapped in queueEvent.
private val trueDirectCarSelectTestReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
gameGLSurfaceView.queueEvent { nfs.mod.mpcore.MultiplayerCore.triggerTrueDirectCarSelectJump() }
}
}
private fun updateRequestedOrientation(i: Int) {
}
fun IsSystemKey(i: Int) = false
// 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"
@@ -126,11 +200,36 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
}
fun useAssetsFileSystem(): Boolean {
return mAssetLocationType != AssetLocationType.EXTERNAL
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()
@@ -169,6 +268,13 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
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()
@@ -214,14 +320,18 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
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, e2.message!!)
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)
@@ -236,19 +346,72 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
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())
// Explicit debug-tooling gate (see DEBUG_MENU.md) - flip
// DebugFeatures.debugMenuEnabled off and this view never gets built
// or added, not just hidden.
if (DebugFeatures.debugMenuEnabled) {
mFrameLayout.addView(buildDebugMenuOverlay())
}
setContentView(mFrameLayout)
System.loadLibrary("fmodex")
System.loadLibrary("fmodevent")
System.loadLibrary("c++_shared")
System.loadLibrary(Global.NIMBLE_ID)
System.loadLibrary("app")
loadCore()
// 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)
@@ -257,6 +420,42 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
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)
}
@@ -414,6 +613,8 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
public override fun onDestroy() {
i(TAG, "onDestroy")
unregisterReceiver(carSelectTestReceiver)
unregisterReceiver(trueDirectCarSelectTestReceiver)
super.onDestroy()
if (state == 8) {
ApplicationLifecycle.onActivityDestroy(this)
@@ -451,6 +652,10 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
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
}
@@ -466,6 +671,7 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
override fun onKeyUp(i: Int, keyEvent: KeyEvent): Boolean {
super.onKeyUp(i, keyEvent)
if (IsSystemKey(i)) return false
if (state != 8) {
return true
}
@@ -493,7 +699,28 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
}
}
/**
* 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
@@ -525,6 +752,37 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
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() {
@@ -587,7 +845,49 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
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)
}
}
}
}
// Debug-only menu (see DEBUG_MENU.md), same ComposeView-over-mFrameLayout
// pattern as buildCarSelectionOverlay above. Bottom-end corner,
// deliberately opposite the car-selection badge's top-start corner so
// the two never overlap. Only ever called when
// DebugFeatures.debugMenuEnabled is true (see onCreate).
private fun buildDebugMenuOverlay(): ComposeView {
registerDebugMenuListener()
return ComposeView(this).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM or Gravity.END,
).apply { setMargins(24, 24, 24, 24) }
setContent {
MaterialTheme {
DebugMenuButton(visible = DebugMenuVisibility.mapLoaded)
}
}
}
}
override fun setContentView(view: View) {
d(TAG, "setContentView($view)")
if (view === mFrameLayout) {
@@ -610,9 +910,9 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
override fun onDrawFrame(gl10: GL10?) {
var inputStream: InputStream?
var shouldCleanupSplash = false
Log.d("state::game", "onDrawFrame state=$state")
// Логирование изменения состояния
if (state != laststate) {
Log.d("state::game", "onDrawFrame state=$state")
Log.d(TAG, "onDrawFrame state=$state")
laststate = state
}
@@ -646,12 +946,24 @@ class GameActivityMain : AppCompatActivity(), DrawFrameListener {
}
STATE_GAME_START -> {
if (splashTimer < System.currentTimeMillis() && nativeRestoreContext()) {
isAssetsReady = true
nativeOnStart()
nativeOnResume()
gameRenderer.setDrawFrameListener(null)
shouldCleanupSplash = true
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
}
}
}
}
@@ -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}")
}
}
}
@@ -0,0 +1,148 @@
package com.ea.ironmonkey
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlin.concurrent.thread
/**
* First-launch unpacking of the game data bundled in the APK.
*
* Shown only when [GameDataInstaller.isInstalled] is false, so it appears once
* after install and never again. It exists because copying ~595 MB takes long
* enough that a tester staring at a frozen launcher would reasonably assume the
* game had hung.
*/
class GameDataUnpackActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
if (GameDataInstaller.isInstalled(this)) {
startGame()
return
}
var fraction by mutableFloatStateOf(0f)
var copiedMb by mutableStateOf(0L)
var totalMb by mutableStateOf(0L)
var error by mutableStateOf<String?>(null)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
UnpackScreen(
fraction = fraction,
copiedMb = copiedMb,
totalMb = totalMb,
error = error,
onRetry = { recreate() },
)
}
}
thread(name = "game-data-unpack", isDaemon = true) {
val result = GameDataInstaller.install(this) { copied, total ->
// Updating Compose state is safe from any thread; the recomposition
// is scheduled onto the main thread by the snapshot system.
fraction = if (total > 0) copied.toFloat() / total else 0f
copiedMb = copied / 1_048_576
totalMb = total / 1_048_576
}
runOnUiThread {
when (result) {
is GameDataInstaller.Result.Ready -> startGame()
is GameDataInstaller.Result.Failed -> error = result.message
}
}
}
}
private fun startGame() {
startActivity(Intent(this, GameActivityMain::class.java))
finish()
}
}
@Composable
private fun UnpackScreen(
fraction: Float,
copiedMb: Long,
totalMb: Long,
error: String?,
onRetry: () -> Unit,
) {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (error != null) {
Text(
text = "Не удалось подготовить игровые данные",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
text = error,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp),
)
Button(onClick = onRetry, modifier = Modifier.padding(top = 24.dp)) {
Text("Повторить")
}
} else {
Text(
text = "Подготовка игровых данных",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
text = "Выполняется один раз после установки.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
LinearProgressIndicator(
progress = { fraction },
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
)
Text(
text = if (totalMb > 0) "$copiedMb из $totalMb МБ" else "",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 12.dp),
)
}
}
}
}
@@ -11,6 +11,10 @@ import javax.microedition.khronos.egl.EGLDisplay;
public class GameGLSurfaceView extends GLSurfaceView {
private static final String TAG = "GameGLSurfaceView";
// See createContext/destroyContext below (task #62).
static int eglContextCreateCount = 0;
static int eglContextDestroyCount = 0;
private boolean enableHistoricalEvents;
private boolean kMotionEvent_GetSource;
private GameActivityMain mActivity;
@@ -35,15 +39,29 @@ public class GameGLSurfaceView extends GLSurfaceView {
setGLESVersion2();
setFocusable(true);
setFocusableInTouchMode(true);
if (Build.VERSION.SDK_INT >= 11) {
try {
Log.i(TAG, "setPreserveEGLContextOnPause");
getClass().getMethod("setPreserveEGLContextOnPause", Boolean.TYPE).invoke(this, false);
Log.e(TAG, "setPreserveEGLContextOnPause(false) success");
} catch (Exception unused2) {
Log.e(TAG, "setPreserveEGLContextOnPause failed");
}
}
// Task #62 - the black-textures-after-resume bug. The shipped game asked
// for the context NOT to be preserved (this call passed `false`) and took
// responsibility for rebuilding its GL objects afterwards. Under this
// engine that rebuild does not happen, which was confirmed live rather
// than assumed:
//
// EGL context CREATED (#1) launch
// EGL context DESTROYED (#1) on pause <- every texture gone here
// EGL context CREATED (#2) on resume
//
// and across that boundary the guest's own `Renderer::RestoreContext` ran
// to completion in ONE millisecond and re-uploaded a single texture, while
// 86 uploads had built the scene before it. Geometry survived only because
// vertex data is re-sent per frame; textures are uploaded once, so they
// came back black.
//
// Asking the platform to keep the context is the fix that does not depend
// on the guest restoring anything. It is a HINT, not a guarantee - the
// system may still drop the context under memory pressure - which is why
// the create/destroy logging added alongside it stays in permanently. A
// second CREATED line in a report means this fell back and the guest-side
// restore path is the next thing to fix.
setPreserveEGLContextOnPause(true);
}
public void setEnableHistoricalEvents(boolean z) {
@@ -56,11 +74,23 @@ public class GameGLSurfaceView extends GLSurfaceView {
@Override // android.opengl.GLSurfaceView.EGLContextFactory
public EGLContext createContext(EGL10 egl10, EGLDisplay eGLDisplay, EGLConfig eGLConfig) {
// Counted and logged (task #62). Whether the EGL context actually
// dies across a pause decides which bug we have: a SECOND create
// means every GL object the guest owns was destroyed and something
// must re-upload them, while a single create for the whole session
// means the black textures come from somewhere else entirely.
// Nothing logged this before, so the question was unanswerable.
eglContextCreateCount++;
android.util.Log.i("mpcore_log", "GameGLSurfaceView: EGL context CREATED (#"
+ eglContextCreateCount + ") - every GL object must be (re)uploaded after this");
return egl10.eglCreateContext(eGLDisplay, eGLConfig, EGL10.EGL_NO_CONTEXT, new int[]{EGL_CONTEXT_CLIENT_VERSION, 2, 12344});
}
@Override // android.opengl.GLSurfaceView.EGLContextFactory
public void destroyContext(EGL10 egl10, EGLDisplay eGLDisplay, EGLContext eGLContext) {
eglContextDestroyCount++;
android.util.Log.i("mpcore_log", "GameGLSurfaceView: EGL context DESTROYED (#"
+ eglContextDestroyCount + ") - all textures, buffers and shaders are now gone");
egl10.eglDestroyContext(eGLDisplay, eGLContext);
}
});
@@ -43,12 +43,35 @@ public class GameRenderer implements GLSurfaceView.Renderer {
this._height = i2;
}
// Task #47 instrumentation (2026-09-19, temporary). Settles a premise that
// has been reasoned from since 2026-09-18 without ever being re-verified
// live: that nativeOnResume runs the guest's persistent loop synchronously
// here and NEVER returns, so GLSurfaceView's own automatic
// post-onDrawFrame eglSwapBuffers stops firing. If that premise holds,
// this logs exactly one line reading entries=1 exits=0 and then stays
// silent forever. If the lines keep coming instead, onDrawFrame IS
// returning, the framework IS also swapping, and that is the second
// present of each observed pair.
// System.nanoTime() is CLOCK_MONOTONIC on Android - the same clock the
// native SWAPMARK lines use - so the two logs correlate directly.
private static int drawEntries = 0;
private static int drawExits = 0;
private static long lastDrawReport = 0;
@Override // android.opengl.GLSurfaceView.Renderer
public void onDrawFrame(GL10 gl10) {
drawEntries++;
long now = System.nanoTime();
if (now - lastDrawReport > 1000000000L) {
lastDrawReport = now;
android.util.Log.i("mpcore_log", "GameRenderer: onDrawFrame entries=" + drawEntries
+ " exits=" + drawExits + " t=" + now);
}
if (this.drawFrameListener != null) {
this.drawFrameListener.onDrawFrame(gl10);
} else {
this.activity.getRunLoop().onRunLoopTick();
}
drawExits++;
}
}
@@ -175,7 +175,26 @@ class PermissionsActivity : AppCompatActivity() {
private fun initActivity() {
try {
startActivity(Intent(this, GameActivityMain::class.java))
// A crash report left by the previous run takes precedence over
// starting the game. The check lives HERE, not in GameActivityMain:
// this activity is the visible, foreground launcher entry, so the
// start is allowed. Doing it from GameActivityMain.onCreate - which
// starts the report and immediately finishes itself - was refused
// by the platform ("Activity start ONLY allowed by
// BAL_ALLOW_GRACE_PERIOD"), and the tester just landed back on the
// home screen with no report shown.
val next = when {
// A crash report from the previous run comes first - it is the
// thing the tester needs to see, and the next crash would
// overwrite it.
CrashReportActivity.pendingReport(this) != null -> CrashReportActivity::class.java
// Then the one-time unpack of the game data bundled in the APK.
// Checked on every launch but only does work once, so there is
// no cost after the first run.
!GameDataInstaller.isInstalled(this) -> GameDataUnpackActivity::class.java
else -> GameActivityMain::class.java
}
startActivity(Intent(this, next))
finish()
} catch (e: Exception) {
e.printStackTrace()
@@ -0,0 +1,12 @@
package nfs.mod.traceagent
// Bridge to the standalone libtrace_agent.so (see trace_agent/ at the repo
// root and ARM64_TRANSLATION_LAYER.md's "Native ARM32 tracing harness"
// plan) - diagnostic-only, for the "native32" flavor running on the Galaxy
// A9. Not present in the APK's own jniLibs; loaded from wherever it was
// pushed on-device (see TraceApplication).
object TraceAgentBridge {
// outputPath: absolute path (app's own private files dir) the native
// side appends a full call trace to - see trace_agent/trace_log.h.
external fun install(outputPath: String)
}
@@ -0,0 +1,45 @@
package nfs.mod.traceagent
import android.app.Application
import android.content.Context
import android.util.Log
// Earliest available hook point for installing the JNI trace table patch
// (see TraceAgentBridge/trace_agent's own comments) - as early as possible,
// before any real game library gets a chance to grab its own JNIEnv
// reference. Entirely inert on the normal ("translated") flavor: gated
// behind BuildConfig.NATIVE32, and the .so path is checked for existence
// before attempting System.load, so a normal run that never pushed
// libtrace_agent.so to the device just logs and continues - no crash, no
// behavior change, matching the plan's "does not touch the emulated path"
// constraint.
class TraceApplication : Application() {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
if (!com.ea.games.nfs13_arm.BuildConfig.NATIVE32) return
// Must live under the app's own private data dir (app_data_file
// SELinux context), not /data/local/tmp (shell_data_file) - confirmed
// live on the Galaxy A9 that Enforcing SELinux denies mmap-exec of a
// shell_data_file-labeled .so from an app-domain process ("couldn't
// map ... segment 1: Permission denied"). The same path is what
// wrap.<packageName>'s LD_PRELOAD value must point at too (see the
// plan's Phase 4 deployment notes).
val soPath = base.filesDir.absolutePath + "/libtrace_agent.so"
if (!java.io.File(soPath).exists()) {
Log.d(TAG, "libtrace_agent.so not found at $soPath - JNI tracing not installed")
return
}
try {
System.load(soPath)
val outputPath = base.filesDir.absolutePath + "/trace_output.log"
TraceAgentBridge.install(outputPath)
Log.d(TAG, "trace agent JNI hooks installed, writing to $outputPath")
} catch (e: Throwable) {
Log.d(TAG, "failed to install trace agent JNI hooks: $e")
}
}
companion object {
private const val TAG = "TraceApplication"
}
}
+16
View File
@@ -0,0 +1,16 @@
#!/system/bin/sh
# APK-bundled wrap.sh (2026-09-16, ARM64_TRANSLATION_LAYER.md - trace_agent
# LD_PRELOAD deployment investigation). The loose-file mechanism
# (/data/local/tmp/wrap.<packageName>) was confirmed NOT invoked by zygote
# on this specific Galaxy A9/Samsung build - a canary marker file placed by
# that script never got created across multiple relaunches, root and
# non-root, SELinux Enforcing and an attempted Permissive switch. This is
# the OTHER officially documented NDK mechanism (developer.android.com/ndk/
# guides/wrap-script): a wrap.sh bundled alongside the app's own native
# libraries, picked up automatically for a debuggable app. Points LD_PRELOAD
# at the app's own private files dir (not this APK-bundled location) so the
# actual libtrace_agent.so can still be updated by just `run-as`-copying a
# freshly-built one in, without needing to reinstall the whole APK each
# iteration.
export LD_PRELOAD=/data/data/com.ea.games.nfs13_arm/files/libtrace_agent.so
exec "$@"
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shares ONLY the crash directory, nothing else. FileProvider is required
because targetSdk 35 rejects file:// URIs in an ACTION_SEND intent. -->
<paths>
<external-files-path name="crashes" path="crashes/" />
</paths>
@@ -1,4 +1,4 @@
package com.ea.games.nfs13_mod
package com.ea.games.nfs13_arm
import org.junit.Test
+971
View File
@@ -0,0 +1,971 @@
# ANALYSIS.md — libapp.so Reverse Engineering Notes
Target binary: `native_lib/libapp.so` (NFS Most Wanted 2012, Android v1.3.128, Firemonkeys/Iron Monkey engine, ARM EABI5 32-bit, Thumb/ARM mixed).
IDA database: `native_lib/libapp.so.i64` (opened via `ida-pro-mcp` idalib MCP server, session handle re-created per session — see "IDA session" below).
This document is a living record. Update it as findings are made; do not wait until the end of a session.
---
## 1. Provenance of prior findings (from `chat-export-1785094024785.json`)
A previous chat session (Open WebUI, model "deepseek-v4-flash uncensored", 2026-05 timeframe) analyzed a `libapp.so` **downloaded from a third-party file-sharing link** (`private-ai.tools/files/*.so`) inside a sandboxed Linux container, using `radare2` + hand-written Python byte-scanning — **not** IDA, and **not** this project's `native_lib/libapp.so`. The user re-uploaded the file at least 3 times under different hashes during that conversation, so it is unclear whether it was ever the same build as ours.
**Verification performed this session:** opening our actual `native_lib/libapp.so.i64` and searching for the same RTTI/mangled class names confirms this **is the same engine and the same general class taxonomy** (see §3), but at **completely different addresses** — the old chat's addresses top out around `0x00350000` while our `.text` segment alone spans `0xa1aa80xc28c44` and the image is `0xe52ebc` bytes (~14.9 MB). The old chat's specific offsets, vtable layouts, and struct field guesses **do not apply directly** and must be re-derived. They are recorded below only as **architectural hypotheses**, not verified facts.
The old chat's generated "C++ bindings header" was also never actually completed — the final message was a Python script emitting a header template whose format-string placeholders (`0x%08X`) were never substituted with real values (the export cuts off mid-generation). So no usable header exists from that session; it must be rebuilt from scratch against our binary.
### 1.1 What the prior session got right (reusable knowledge)
- **Game/engine identification**: EA "Iron Monkey" engine (`im::app::*`, `im::components::*` C++ namespaces), Need for Speed Most Wanted 2012 Mobile. JNI entry points under `com.ea.ironmonkey.*`.
- **SB/SBA resource format** (fully reconstructed from `NFSMW12MobileTools` Java source, not from the binary — this part is source-verified, not a guess):
- Header: `"SBIN"` (4 bytes) + version (1 byte, `0x03` for NFS MW12).
- Chunk stream, each chunk: `sig(4) + data_size(4, LE) + fnv1_32_hash(4, LE) + data[size] + pad-to-4`.
- Chunk types: `ENUM`, `STRU`, `FIEL`, `OHDR`, `DATA`, `CHDR`, `CDAT`, `BULK` (texture mip offsets), `BARG` (raw texture bytes).
- Field types (`SBinFieldType`): `INT8/16/32/64`, `FLOAT32`, `BOOLEAN`, `ENUM_ID_INT16/32`, `CDAT_STRING`, `SUB_STRUCT`, `GAME_UNIT`, plus opaque `HEX_*` types.
- Ordinary `.sb`: `SBIN→ENUM→STRU→FIEL→OHDR→DATA→CHDR→CDAT`. Save files swap `ENUM` after `STRU/FIEL`. Texture `.sba` appends `BULK→BARG`.
- **Save files are NOT parsed into an object model** by NFSMW12MobileTools — only HEX + string edits are supported for saves. Confirmed present in our `game_cache/` (see §5).
- This matches the directory layout we actually have: `game_cache/published/{prefabs,data,flow,stringdata,textures,models,sounds}/...` (see §5).
- **⚠️ Tool reliability caveat**: `NFSMW12MobileTools.jar unpack` does **not** reliably unpack every `.sb`/`.sba` file — it has real, reproducible parser bugs (confirmed example: `region3_colorado_track2.scene.sb` throws `NegativeArraySizeException` in `SBin.getCleanElementHex`/`parseDATABlock` on every attempt, while sibling files like `region3_colorado_track1.scene.sb` from the same directory unpack cleanly with identical invocation — see §6m). This is a pre-existing bug in the third-party tool, not something introduced by this project, and not worth "fixing" upstream for our purposes. **Don't treat a crash as "this file can't be analyzed" — try the workarounds first**: `-disableDATAObjectsUnpack` (skips per-object field parsing but still emits the top-level `CDAT_Strings` table, i.e. every unique string referenced anywhere in the file — often enough on its own to answer "does entity X exist / what's it named", exactly as used in §6m) or `-disableMipmapUnpack` (for `.sba` texture-pack issues). If a specific file still won't unpack under any flag, a same-directory sibling file is usually a decent structural proxy (same region/asset-authoring convention) when an exact answer isn't required. Command reference: `java -jar NFSMW12MobileTools/NFSMW12MobileTools.jar unpack <file.sb> [-disableMipmapUnpack] [-disableDATAObjectsUnpack]` — run from a directory containing `HCStructFileArray.json` (copy from the tool's own repo root) alongside the target file; output is `<file>.json` next to the input.
- **High-level race-loading architecture** (conceptually confirmed, see §3 for real anchors in our binary):
- Menu → Flow-machine (SB-scripted reactive state machine, `/published/flow/*.sb`) → `FlowAction "DoLoadRace"``RaceLoaderTask` (boost::shared_ptr-managed, polymorphic) → parses `RaceDefinition` from `/published/data/races/<id>.sb` → builds `OpponentCollection` (array of `Opponent` descriptors) → `RaceStartingGrid`/`StreetRaceStartingGrid` places cars using `TrackNavigator` (track-spline coordinate system: `distance_along_spline`, `lateral_offset`, `height_offset` ↔ world `Vector3`) → loads car/track prefabs → `InRaceState` (namespace `im::app::race::states`) runs the race.
- **Important safety conclusion (still believed valid conceptually)**: `RaceLoaderTask` cannot be safely constructed and invoked from an arbitrary moment — it's a `boost::shared_ptr`-owned polymorphic task queued through the Flow-machine, with refcount fields checked internally; calling it out of context risks a crash. The safe integration pattern is **hook, don't originate**: let the game's own Flow-machine create/drive `RaceLoaderTask` normally (player picks any existing event from the menu), and intercept it at well-defined points (opponent-list build, starting-grid placement, track-navigator position resolution) to substitute custom data — rather than trying to synthesize the whole load sequence from scratch.
- This maps directly onto our mod's needs: subtask 2 (arbitrary map + N players) and subtask 4 (coordinate interception) are naturally the same hook points.
- **Text rendering claim in the task brief needs correction** (see §4) — the previous chat never investigated this; it's a fresh finding this session.
### 1.2 What must be re-derived from scratch (do not trust old numbers)
- All function addresses (`RaceLoaderTask::*`, `OpponentCollection::Build`, `TrackNavigator::*`, `Health::*`, `NitroBehaviour::*`, etc.)
- All vtable layouts and indices
- All struct field offsets (the `Opponent` "0x50 bytes" struct, `TrackNavigator` fields, etc. — these were never disassembled against our binary, only guessed by pattern-matching a different file)
- The claimed GOT/PLT/vtable addresses in the old chat's "priority hook map" table
---
## 2. IDA database state (`libapp.so.i64`)
- Opened via idalib MCP (`idb_open`), auto-analysis + Hex-Rays already available (`hexrays_ready: true`).
- **50,772 total functions**, only **2,789 named** (~5.5%) — the rest are `sub_XXXXXX`. No demangled C++ names have been applied to *functions* yet (searching `list_funcs` for `*RaceLoaderTask*`, `*Opponent*`, `*TrackNavigator*`, `*Health*`, `*Nitro*` returns zero function matches), even though the **mangled RTTI name strings** for these classes exist in `.rodata`/data segments and are found via string/regex search. In other words: the `.i64`'s size (192 MB) comes from IDA's analysis caches (Hex-Rays microcode, xrefs, etc.), not from prior manual RE work — there is no pre-existing "someone already named all the classes" state to build on. We are starting from a clean, auto-analyzed base.
- Segments: `.text` (`0xa1aa8``0xc28c44`, ~11.7 MB), `.ARM.extab`/`.ARM.exidx` (unwind tables), `.rodata` (`0xc81010``0xd6fbc0`), `.data.rel.ro.local` (`0xd71558``0xd77ce4`), `.init_array`/`.fini_array`. Image base is `0x0` in the IDB (i.e., all addresses here are file-relative / load-relative offsets, matching what `mpcore`'s `APP_ADDR()` macro expects — see §6).
- 10 JNI entry points auto-detected as "entrypoints", all under `com.ea.ironmonkey.*` (`GameActivity`, `GameGLSurfaceView`, `MogaController` (game controller support), `RunLoop`, and `JNI_OnLoad` itself at `0x56a52c`).
- Imports include OpenGL ES 2.0 (`glCreateShader` etc.), FMOD (via `_ZN4FMOD12ChannelGroup...`), POSIX sockets (`socket/recv/send/connect` — used by the EA "Synergy" backend HTTP/HTTPS client, see §3.4, not by any game-level multiplayer protocol), pthreads, and EA Nimble bridge registration functions.
---
## 3. Confirmed class/subsystem inventory (RTTI-verified, this session)
Method: `find_regex` over decoded strings to locate Itanium-mangled RTTI type names (`N2im3app...E`) and `boost::shared_ptr` counted-impl wrappers (`N5boost6detail17sp_counted_impl_pI...E`), which reveal exactly which C++ classes exist even though functions aren't named. Addresses below are the **string** locations (evidence the class exists), not yet the vtables — vtable/function recovery is future work (see §7 plan).
### 3.1 Race / track / opponent subsystem (`im::app::race::*`, `im::app::track::*`)
| Class | Namespace | String address |
|---|---|---|
| `RaceLoaderTask` | `im::app::race` | `0xcfe924` (sp_counted_impl wrapper at `0xcf1420`) |
| `OpponentCollection` | `im::app::race::description` | `0xcfeaf8` |
| `Opponent` | `im::app::race::description` | referenced inside `boost::bind` signature at `0xcfe884` (`bind_t<..., mf1<..., RaceLoaderTask, shared_ptr<Opponent>>...>`) |
| `RaceStartingGrid` | `im::app::race::description` | `0xcfeb5c` |
| `StartingGrid` (base) | `im::app::race::description` | `0xcfeb8c` |
| `StreetRaceStartingGrid` | `im::app::race::description` | `0xcfebb8` |
| `InRaceState` | `im::app::race::states` | `0xcff010` (with `boost::bind` callbacks referencing `Checkpoint`, `Driver` component weak-ptrs) |
| `TrackNavigator` | `im::app::track` | `0xd00454` |
| `TrackNavigatorSubSystem` | `im::app::track` | `0xd004c0` (implements `ISceneComponentListener<TrackNavigator>`) |
### 3.2 Car subsystem (`im::app::car::*`)
| Class | String address | Notes |
|---|---|---|
| `NitroBehaviour` | `0xcf2f58` | |
| `AICarController` | `0xcf1c7c` | base AI controller |
| `CopAICarController` | `0xcf29f0` | police AI |
| `PlayerAICarController` | `0xcf2f98` | |
| `RaceAICarController` | `0xcf35d4` | |
| `PlayerCarController` | `0xcf31a0` | |
| `CarLoaderTask` | `0xcf2458` | |
Not yet re-located this session (present in old-chat hypothesis, still need string/RTTI confirmation in our binary): `Health`, `DamageDealtMultiplier`, `CarDamage`, `Nitro` (base), `SpikeStrip` (`im::app::bt::SpikeStrip`). These were found via `strings`/`r2` in the *other* binary; treat as "likely present, not yet confirmed here" until searched.
### 3.3 Text / UI rendering — corrected (this section was wrong in an earlier revision, see below)
**Earlier revision of this section incorrectly concluded text is rendered purely natively via GLES/EAMText with no Java bridge.** That was wrong — it only checked for `Java_com_ea_ironmonkey_*` *exported* JNI functions (native called *from* Java) and missed the reverse direction: native code calling *into* Java via cached `FindClass`/`GetMethodID`/`CallVoidMethod` upcalls, which don't show up as exported symbols at all. The user pointed to the actual mechanism, already reverse-engineered and sitting in the launcher project as `launcher/app/src/main/java/com/ea/ironmonkey/BitmapGraphics.kt` (comment in that file: *"Весь текст в игре отрисовывается от сюда"* — "all game text is drawn from here"). Verified this session against `libapp.so.i64` by decompiling the actual call sites (functions renamed in the IDB accordingly):
- **`BitmapGraphics_ctor_jni`** @ `0x5640ec``FindClass("com/ea/ironmonkey/BitmapGraphics")` + `GetMethodID("<init>", "(II)V")` + `NewObject(width, height)`. Matches `BitmapGraphics(width: Int, height: Int)`, which internally creates an `android.graphics.Bitmap` (`ARGB_8888`) and wraps it in a `Canvas`.
- **`BitmapGraphics_drawString_jni`** @ `0x5625a0` (thunk at `0x56274c`) — lazily resolves and calls `GetMethodID("drawString", "(Landroid/graphics/Paint;Ljava/lang/String;II)V")`. Matches `fun drawString(paint: Paint, text: String, x: Int, y: Int) = canvas.drawText(...)`.
- **`BitmapGraphics_createPaintFromFamilyName_jni`** @ `0x562a58` / **`BitmapGraphics_createPaintFromFile_jni`** @ `0x562b94` — call the companion-object `createPaintFromFamilyName(String, F): Paint` / `createPaintFromFile(path, F): Paint` statics.
- **`ResolveFontPaint_ttfOtfOrFamily`** @ `0x51145c` — checks for a `.ttf`/`.otf` file at the requested font path first, falls back to `createPaintFromFamilyName` (system font family) otherwise.
- **`BitmapGraphics_blitBitmapToAtlasTexture`** @ `0x563d30` — the missing link, found by tracing xrefs to the `"getBitmap"` string: calls `BitmapGraphics.getBitmap()`, then `AndroidBitmap_getInfo`/`AndroidBitmap_lockPixels`, then `memcpy`s the locked pixel buffer **row-by-row with a vertical flip** into what is, by the region/offset arithmetic (`v27,v28,v29` = atlas rect x/y/width, bounds-clipped against a requested sub-rect), a **native GL texture atlas** (consistent with the `EA::Graphics::OGLES20::Texture` class seen elsewhere in the binary). Flip direction makes sense: Android `Bitmap` rows are top-down, GL texture data is bottom-up.
**Corrected conclusion:** text rendering pipeline is **Kotlin `BitmapGraphics` (owns an offscreen `Bitmap`+`Canvas`) → `Canvas.drawText` via `Paint`/`Typeface` (handles proper font shaping for all 11 locales in `game_cache/published/stringdata/`, including CJK, which a bespoke native shaper would struggle with) → JNI readback via `AndroidBitmap_lockPixels` → row-flipped `memcpy` into a shared native GL font-atlas texture → drawn every frame by the game's normal GLES renderer as textured quads.** The `EAMText`/`GlyphMeshGLES`/FreeType strings noted in the previous revision are real and still present in the binary, but their exact role relative to this `BitmapGraphics` path (alternate/legacy renderer? metrics-only? a different UI layer?) is not yet determined — do not assume they're the primary path; the `BitmapGraphics` bridge above is the one with a live, traceable call chain from a known Kotlin source file.
**Why this matters for the mod**: since `BitmapGraphics.kt` is fully ours to extend (it's reverse-engineered launcher source, not opaque binary), we can add a new method — e.g. `drawMarker(x: Int, y: Int, color: Int)` using `canvas.drawCircle(...)` — and it will be blitted into the game's own font-atlas texture and rendered through the game's existing GLES pipeline exactly like any other UI text. This gives a low-risk, verified path for drawing simple lobby indicators (e.g. green/red event markers on the map) **without** needing to author new Flow/SB screens and without needing a separate Android overlay `View`. It does **not** by itself solve touch input or full interactive lobby screens (player list, ready buttons) — that's a separate concern, still likely served best by an overlay `View` for now (see `ARCHITECTURE.md`).
### 3.4 Networking / backend — no reusable multiplayer transport
Searched for `gamespy|eaonline|matchmak|lobby|leaderboard|raknet|socket|multiplayer|EA::Online|Synergy|CDMA|Freeway`. Findings:
- Extensive **EA "Synergy"** backend integration: `SERVER_SYNERGY_{DIRECTOR,MTU,PRODUCT,DRM,USER,TRACKING,CIPGL,S2S}`, hardcoded synergy endpoint URLs (`synergy-dev/int/stage.eamobile.com`), pinned TLS certs (`synergy-GeoTrustGlobalCA.crt` etc.), and world/friends leaderboard events (`SPEvent_GetWorldLeaderboardDone/Error`, `SPEvent_GetFriendsLeaderboardDone/Error`, `MostWantedLeaderboard`). This is EA's account/IAP/DRM/leaderboard HTTP(S) backend (likely long dead), **not** a peer race-sync protocol.
- Raw POSIX socket imports (`socket/recv/recvfrom/connect/send/sendto`) exist but are consistent with the above HTTP(S) client, not a custom UDP game protocol.
- **No RakNet, no GameSpy, no P2P/lobby-discovery code found.** Confirms the task brief's premise: there is nothing to reuse for multiplayer transport — RakNet must be integrated fresh, both client (embedded in the mod) and dedicated server (standalone), as already planned.
- `libnimble.so` (separately investigated, see §6) is EA's **Nimble SDK** — a generic native↔Java bridge/callback framework (`EA::Nimble::JavaClass`, `BridgeCallback`, `MTXNativeCallbackBridge` for in-app-purchase UI, `PushNotification`) — unrelated to multiplayer, but relevant as a *pattern* for how native↔Java callback bridging is done in this codebase if we need more JNI bridge surface later.
---
## 4. `libgnustl_shared.so`, `libfmodex.so`, `libfmodevent.so`, `libnimble.so` — quick disposition
- `libfmodex.so` / `libfmodevent.so`: FMOD audio engine runtime — not relevant to the multiplayer mod except that hooked code must not disturb their init order.
- `libgnustl_shared.so`: GNU libstdc++ runtime — no action needed.
- `libnimble.so`: EA Nimble SDK (see §3.4) — native↔Java bridge/callback plumbing for IAP (`MTX`), push notifications, identity. Not part of the race/multiplayer surface. Its `Java_com_ea_nimble_bridge_BaseNativeCallback_native{Callback,Finalize}` pattern is a useful reference for how this codebase wires native callbacks into Java if the lobby UI ends up needing a similar bridge.
---
## 5. `game_cache/` — confirms SB/SBA architecture in practice
Directory layout under `game_cache/published/`:
`prefabs/{cars,tracks,racefsms,racetypes,checkpoints,roadblocks,traffic,environments,garage,skydomes,props,lensflares,blacklisttech}`, `data/{races,cars,careers,achievements,sponsors,enginesounds,autolog,pursuit,tiers}`, `flow/{menus,race,postgame,frank,...}.sb`, `stringdata/<LANG>/`, `textures/...`, `models/...`, `sounds/...`, `fonts/`, `layouts/`, `tweaks/`, `particles/`, `replays/`. Plus `published.1x/2x/4x` texture-pack variants (resolution tiers).
This confirms: `prefabs/tracks` = track geometry, `data/races` = `RaceDefinition` SB files, `prefabs/racefsms` = likely the actual `InRaceState`/Flow FSM scripts driving a race (worth inspecting directly — plain SB, no binary RE needed), `flow/race/*.sb` = the Flow-machine scripts for the race-start UI sequence the user described (location → event list → info screen → car select → race). **These SB files are directly readable/editable with `NFSMW12MobileTools`** without touching the native binary at all for a large fraction of subtask 2/3 work (e.g., authoring a custom `RaceDefinition` SB with our own opponent list, if we go the "replace the file the game reads" route instead of the "hook the C++ struct after parse" route).
---
## 6. `launcher/mpcore/` — existing draft state, and a verified ground-truth anchor
Confirmed via direct file read: `mpcore` is exactly what the task brief said — an early draft, not production code. Current contents:
- `src/main/cpp/main.cpp`: `JNI_OnLoad` resolves `libapp.so` base via `FindLibrary()`, logs it, then `raise(SIGSTOP)` (presumably to attach a debugger before the process continues) and returns. A `pending_thread()` function exists but is unused (never spawned) — it sleeps 10s then reads/logs a value at `APP_ADDR(0x00E4B8EC)` and calls `unProtect(APP_ADDR(0x00E4A738))`. All hooking is currently commented out.
- `util/armhook.cpp`: a working **Thumb-mode (16-bit ISA) inline-hook toolkit**`unProtect` (mprotect with EACCES fallback), `makeNOP`/`makeJMP`/`makeBLX` (Thumb branch encoding), `WriteHookProc`/`InstallHook`/`InstallJMPHook`/`InstallBLXHook`/`InstallMethodHook` (vtable-slot overwrite)/`CodeInject` (register-move injection into a small stub), all built around a fixed trampoline region `memlib_start..memlib_end = APP_ADDR(0x000A1B44)..+0x1A36` plus a separate `mmap`'d PROT_RWX scratch page for original-bytes backup. **This confirms hooking must target Thumb encoding, not ARM32** for at least this region of the binary (consistent with the 16-bit `MOVS`/`STR`/`BX LR` opcodes the old chat's radare2 dump was implicitly assuming when scanning for "0x2050" MOVS patterns — that detail was accidentally right even though the addresses were wrong).
- **Verified ground truth**: a commented-out line in `main.cpp` reads `WriteMemory(APP_ADDR(0xC8C9D8), (uintptr_t)"APPLICATION_NO", 14)` after logging the existing string at that address. Checked against our `.i64` via `get_string`: **`0xC8C9D8` does contain the string `"APPLICATION_OK"`** — confirming the mod author was working against *this exact binary* (not a different build), and that `APP_ADDR()` is a direct file-offset-to-runtime-address identity mapping (base address 0 in the IDB matches). **This is our first fully-verified address in this session** and a template for how to cross-check future finds: locate a string/constant in IDA, then confirm the same offset in the live/dumped binary.
- `NativeLib.kt` (Kotlin/Java side): stub class, `System.loadLibrary("mpcore")` commented out — not yet wired into the app's load sequence.
- Build system: Gradle module already produces `libmpcore.so` for `armeabi-v7a` (matches our target ABI) via CMake (`src/main/cpp/CMakeLists.txt`), for both Debug and RelWithDebInfo.
**Implication for integration plan**: the `armhook.cpp` toolkit is a solid, already-working foundation for ARM32 Thumb inline hooking — we should build on it rather than writing a new hooking library from scratch, once we've confirmed it handles our specific hook sites (it was seemingly exercised only against the `0xC8C9D8` string-patch and a couple of NOP/log experiments so far, not against a real virtual-function hook).
---
## 6a. `RaceLoaderTask` real vtable — located and partially mapped (subtask 1 groundwork)
Method used (repeatable for the other classes in §3.1/3.2): from the class's RTTI name string, find who points to it (`xrefs_to`) — that location is `type_info->name`, so `type_info` itself sits 4 bytes earlier. Then `find_bytes` for a little-endian pointer to *that* `type_info` address across the binary; the real class vtable starts 4 bytes after the match (the found slot is the `typeinfo_ptr` field of the vtable group, per Itanium ABI: `[offset_to_top][typeinfo_ptr][vfunc0][vfunc1]...`). Cross-check: the resulting array should be a long unbroken run of `.text` addresses immediately followed by the class's own `type_info` fields — exactly what was found, confirming the technique. (Two other `find_bytes` hits for the same typeinfo address were false leads: one was the Iron Monkey engine's own custom reflection/pointer-tagging table — same `{ptr, 0x17}` pattern seen earlier for `BitmapGraphics`, unrelated to C++ vtables — the other was a different class's `base_type_info` field, i.e. some other class derives from `RaceLoaderTask`, not yet identified.)
**`RaceLoaderTask` vtable**: `0xd86210` (19 virtual function slots), immediately followed by its own `type_info` at `0xd8625c` (`{vtable=0xdc12a0, name=0xcfe924 "N2im3app4race14RaceLoaderTaskE", base_typeinfo=0xd79b78}`).
| Slot | Address | Renamed to | Role / evidence |
|---|---|---|---|
| 0 | `0xd5b04` | — | Shared thunk (xrefs from multiple unrelated typeinfo structs `0xd79b60`, `0xd79b90`, `0xd7a300`, plus ours) — generic `LoaderTask` base-class helper, not `RaceLoaderTask`-specific. Not decompiled in depth. |
| 1 | `0x2d8e04` | `RaceLoaderTask_dtor_complete` | Sets vptr back to `0xd86210` (own vtable), decrefs several shared-ptr-like members at `this+100/112/124/136/148/156`, destroys an internal vector of ref-counted elements (12-byte stride). |
| 2 | `0x2d8f18` | `RaceLoaderTask_dtor_deleting` | Calls slot 1, then `operator delete`. Standard Itanium destructor pair. |
| 3 | `0xd5818` | — | Same shared-thunk region as slot 0. Not decompiled. |
| 4 | `0x2dbba4` | `RaceLoaderTask_ExecuteLoadSequence` | **Main load orchestrator.** Calls `SetLoadProgress(this, N)` (via `sub_42EE9C`) with progress fractions `0.1, 0.2, 0.3, 0.4, 0.45, 0.5, 0.6, 0.7, 0.8` interleaved with stage calls (`sub_2DB384`, `sub_2DA710`, `sub_2DAA7C`, `sub_2D994C`, `sub_2DAB50`, `sub_2D9AD0`, `sub_2DB534`, `sub_2D969C` — candidate individual load stages, addresses known but not yet decompiled/named). Loads `/published/texturepacks_ui/in_game.sba` (loading-screen texture) partway through. **This is the prime hook candidate for subtasks 1/2** — top-level entry to intercept before track/opponent data reaches the stage functions. |
| 5 | `0xd5820` | — | Same shared-thunk region as slot 0/3. Not decompiled. |
| 6 | `0x2d83cc` | — | `nullsub` (unused/pure-virtual slot). |
| 7 | `0x2d9ba4` | — | Takes `(this, a2)` where `a2` looks like a 3-word ref-counted handle; assigns it into `this+64/68/72`, decrefs the old value, calls `sub_D7C00`/`sub_2DDED4`. Likely a "Set&lt;SharedResource&gt;" setter (track ref? scene ref?) — role unconfirmed, but the field it writes (`this+64..72`) is read by slot 16. |
| 8 | `0x2db160` | `RaceLoaderTask_ResolveDriverPositionComponent` | Calls `dynamic_cast<DriverPosition*>` against RTTI `im::components::Component``im::app::race::data::DriverPosition`. **Direct link to the coordinate/position system** — relevant to subtask 4 as well as 1/2. |
| 9 | `0x2d9d10` | `RaceLoaderTask_SetupPlayerCar` | References string `"playerCar"`. Large function (~1.1KB), gated by a state-check helper (`sub_360CC0`, shared with slot 11). |
| 10 | `0x2d9088` | — | Same `sub_360CC0` state-gate pattern as slots 9/11, but no distinctive string found — purpose unconfirmed (candidate: a third car-category setup, e.g. traffic). |
| 11 | `0x2dae0c` | `RaceLoaderTask_SetupOpponentCar` | References string `"opponentCar"`. Same state-gate pattern as slot 9 — clear sibling function (player vs. opponent car setup). **Prime hook candidate for subtask 2.** |
| 12 | `0x2d8884` | — | Trivial one-line wrapper calling `sub_2DC82C(a1, a3)` — thin delegation, not investigated further. |
| 13 | `0x2d8a74` | — | Allocates small objects and links them via another vtable (`off_D86110`, not yet investigated) — looks like constructing an auxiliary completion-callback object. Not fully understood. |
| 14 | `0x2da2a0` | `RaceLoaderTask_ResetStartingLine` | References string `"ResetLine"`. Large function (~1.1KB). **Prime hook candidate for subtask 2's starting-grid placement.** |
| 15 | `0x2d83d0` | — | `nullsub`. |
| 16 | `0x2d8f34` | — | Reads the field slot 7 writes (`this+64`), iterates a vector at `this+76/80` (same vector shape as the destructor's), calling `sub_18B670` per element — looks like a listener-notification loop. Role unconfirmed but clearly paired with slot 7. |
| 17 | `0x2da880` | `RaceLoaderTask_HandleSpikeStrip` | Calls `dynamic_cast` from `im::app::bt::BlacklistTech` to `im::app::bt::SpikeStrip`, references string `"Spike Strip"`. Confirms spike strips are handled as a kind of trackside `BlacklistTech` prop during race load. |
| 18 | `0x2d8c1c` | — | Not yet decompiled. |
## 6b. `ExecuteLoadSequence`'s 8 stage functions — decoded (subtask 1 hook point found)
Decompiled all 8 stages called from `RaceLoaderTask_ExecuteLoadSequence` (`0x2dbba4`), in call order:
| Order | Address | Renamed to | Role / evidence |
|---|---|---|---|
| 1 | `0x2db384` | **`RaceLoaderTask_BuildTrackScenePath`** | **★ Subtask-1 hook point.** Builds `"published/prefabs/tracks/<name>.scene.sb"` from a string field at `this+8`-struct `+72` (the track name, presumably parsed from the `RaceDefinition` SB), optionally builds `"published/prefabs/environments/<env>.prefabs.sb"` too (only if two fields differ — an environment-override check), then calls `GenericLoadScene_trackEnvWrapper` with both paths. **To load an arbitrary track: patch the track-name string this function reads, or hook the function and rewrite the constructed path before it reaches the loader.** |
| — | `0x2dbf58` | `GenericLoadScene_trackEnvWrapper` | Thin wrapper forwarding to `sub_33C284(out, trackPath, envPath, -1, 0)` — likely the engine's shared generic scene/asset loader (not race-specific), not yet investigated further. |
| 2 | `0x2da710` | `RaceLoaderTask_SetupAIDifficultyProfiles` | Builds three literal strings `"ai_easy"`, `"ai_hard"`, `"default"` and passes them to `sub_121288` along with a value read from `this+44`-struct`+152`. AI behavior-tuning profile lookup/registration. |
| 3 | `0x2daa7c` | `RaceLoaderTask_TagPlayerEntity` | Calls two virtual functions on `this` (vtable+36, vtable+28) and writes the literal string `"Player"` into an offset (`+52`) of the object returned by the first virtual call — tags/names the player's entity in the scene graph. |
| 4 | `0x2d994c` | `RaceLoaderTask_RegisterTrafficFlow` | Scans a component vector via `dynamic_cast` against RTTI `im::app::traffic::TrafficFlow`, then registers whichever component matches. Wires up traffic/AI-pathing infrastructure from the now-loaded track scene. |
| 5 | `0x2dab50` | *(not renamed — uncertain)* | Copies three fields (words 3840, i.e. `+152/156/160`) from the `this+8`-struct into a large object at `this+44`-struct`+304` (offsets `+3996..4004`), plus a byte at `+120→+4036`. Looks like copying `RaceDefinition` metadata (camera/weather/track-meta?) onto the loaded scene/race object — exact semantics unclear, left uncommented-name. |
| 6 | `0x2d9ad0` | *(not renamed — uncertain)* | Allocates a small object, passes `this+16` (the field group `RaceLoaderTask` slot 7 sets / slot 16 reads — see §6a) into `sub_2B6900`, wraps the result in a ref-counted adapter (different vtable, `off_D86150`, not `RaceLoaderTask`'s own), registers it into the scene at `this+44`-struct`+152`. Looks like constructing an anonymous listener/callback object tied to whatever slot 7/16 manage — not confidently named. |
| 7 | `0x2db534` | **`RaceLoaderTask_LoadRaceFSM`** | Loads `"/published/prefabs/racefsms/{0}.prefabs.sb"` (template-substituted, confirms the `game_cache/published/prefabs/racefsms/` directory noted in §5 is exactly the race rules/state-machine prefab). Searches the resulting scene for a component matching RTTI `im::app::race::Race` via `dynamic_cast`**this is the runtime race-FSM/rules controller object**, stored at `this+52`. Then iterates the opponent/car vector (same vector shape as the destructor) and `dynamic_cast`s each entry to `im::app::car::Health`**confirms `im::app::car::Health` exists in this binary** (RTTI-searchable, not yet done — see §7), tagging a flag byte at `Health+528` conditionally. |
| 8 | `0x2d969c` | **`RaceLoaderTask_DispatchInitialFSMEvents`** | Dispatches a sequence of numbered events (codes `17,18,22,2,20,19` at priorities `2..7`) via a generic `sub_2D9398(this, eventCode, priority, args)` — almost certainly `SendEvent(raceFSM, code, priority, args)` into the `Race` FSM object stage 7 just loaded — followed by a race-type-dependent final code (`switch` on a byte at `this+8`-struct`+112`: `0→11, 1→(12 or 0), 2→10, 5→13, default→46`), then event `9`. Event-code meanings not yet decoded, but this is clearly the "kick off the freshly-loaded race FSM" step. |
**Conclusion for subtask 1**: `RaceLoaderTask_BuildTrackScenePath` (`0x2db384`) is the concrete hook point — it's stage 1 of 8, runs before anything else touches track data, and constructs the track path from a single string field. Two viable approaches, to be decided once the field's origin (where `this+8`-struct`+72` gets populated from the `RaceDefinition` SB) is traced: (a) hook this function and substitute the track-name argument before path construction, or (b) find and patch the earlier SB-parsing step that fills that field in the first place. Either avoids touching `RaceLoaderTask_LoadRaceFSM`/`ExecuteLoadSequence`'s overall sequencing, keeping the change minimal.
## 6c. Is there a "SceneLoader"? — theory and findings
The user recalled a "SceneLoader" from memory. No C++ class with that literal name exists (searched RTTI strings exhaustively; also checked the reverse-engineered Java launcher sources — nothing named `SceneLoader` there either, unlike `BitmapGraphics` which did turn out to be real). However, the underlying *system* the user is likely remembering is real and now identified:
- **`sub_33C284``sub_33B038` is a generic, engine-wide scene-loading function, confirmed independent of `RaceLoaderTask`.** Traced via `xrefs_to`: it has **9 separate call sites** spread across four unrelated address ranges (`0x21e9f4`, `0x233f30`, `0x23bec8`, `0x23e8c4`, `0x242a28`, `0x245cd8`, `0x24a1f4`, `0x24d2f4`, plus our own `GenericLoadScene_trackEnvWrapper` at `0x2dbf58`). It builds an `im::app::NFSScene` object (confirmed via RTTI: `N2im3app8NFSSceneE`, and via string refs `NFSScene::PreUpdate`/`NFSScene::PostUpdate`) from a pair of paths. **This is functionally "the scene loader"** — shared infrastructure used by race loading, and evidently by several other subsystems (menu/garage/track-test — see below), even though it's not organized as a named class.
- **`im::app::NFSScene`** participates in a generic async task-pipeline abstraction: `im::general::pipeline::Stage<boost::shared_ptr<im::app::NFSScene>>` (RTTI confirmed at `0xd88004`). Located its vtable (`0xd88040`) but it turned out to be pure template scaffolding (destructor pair + a `pure_virtual_called` trap at slot 3) — the actual "load in progress"/"is complete"/"get result" logic must live in an undiscovered concrete subclass. Not pursued further this session.
- **`im::app::LoaderTask`** (RTTI confirmed `N2im3app10LoaderTaskE`, base of `RaceLoaderTask` per §6a) is the generic async "task" abstraction — sibling classes confirmed: `im::app::MenuLoaderTask`, `im::app::StartupLoaderTask`. So the engine's actual naming is `LoaderTask` (for the task/lifecycle side) + `NFSScene` (for the loaded-result side) + the shared `sub_33C284` free function (for the actual load work) — three different pieces, none literally called "SceneLoader", together doing what that name implies.
### Two new hot leads found while chasing this (not yet decompiled in full)
1. **`sub_233F30` (`0x233f30`)** — references **both** `/published/data/races/` (the `RaceDefinition` SB path) **and** `published/prefabs/tracks/` + `.scene.sb` (the track path) in the same function, then calls the generic scene loader directly. This may be the actual top-level "load race by ID" entry point that creates/drives `RaceLoaderTask` — which earlier investigation (see §1.1's provenance notes and the original "who calls DoLoadRace" question) never located. **Worth decompiling in full next.**
2. **`sub_24A1F4` (`0x24a1f4`)** — references strings `"TrackTestLayer"`, `"Car"`, `"start"`, `"finish"`. Strongly suggests an **existing track-testing/debug harness** that loads a track directly, likely bypassing the normal race-select Flow entirely. If confirmed, this could be the safest possible hook point for subtask 1 — reusing an already-exercised internal test path instead of a raw hook into `RaceLoaderTask`'s internals. **Worth decompiling in full next**, and worth checking whether it's reachable from the existing `devmenu` module in the launcher (the launcher already has a dev-menu concept — this could be a natural fit).
Both addresses are commented in the `.i64` as "HOT LEAD" for continuity.
## 6d. `TrackTestLayer` — a real, built-in EA QA track-testing tool (major subtask-1 lead)
Followed up on `0x24a1f4` per user request. **This is the constructor of a real class literally named `TrackTestLayer`** (confirmed: `sub_42BAFC(a1, "TrackTestLayer")` tags the object immediately before the vptr is set) — a genuine EA/Firemonkeys internal QA tool, fully present and functional in this shipped binary, not something we're inferring loosely.
**`TrackTestLayer_ctor` (`0x24a1f4`) signature and behavior:**
- Second constructor argument is a pointer to **the scene/track path string to load**.
- Immediately calls the generic scene loader (`sub_33C284`, see §6c) with that path — i.e. **this bypasses `RaceLoaderTask`/Flow entirely** and loads a scene directly.
- After loading, locates `"start"`/`"finish"` named nodes in the scene (`sub_695A10`) — the track's start/finish line markers.
- Spawns a **hardcoded list of 8 real test cars** (description IDs: `she_day_cou_65`, `ast_one_77_10`, `aud_r8_v10_10`, `bmw_m3_gts_10`, `lam_mur_sv_71`, `lot_evo_stk_10`, `mer_sls_amg_10`, `nis_240_zg_71` — Shelby Daytona Coupe, Aston Martin One-77, Audi R8 V10, BMW M3 GTS, Lamborghini Murciélago SV, Lotus Evora Stack?, Mercedes SLS AMG, Nissan 240SX), each with a `RaycastCar` (physics) component **and bound to the scene's `TrackNavigator` component** — i.e. cars actually drive on the track via the coordinate system we found in §6a/§6b.
- Writes telemetry to `/var/{0}.csv` per car (lap-time/QA logging, standard for this kind of tool).
**Callers / how it's triggered:**
- `TrackTestLayer_factory_wrapper` (`0x24aec4`) — a thin `TrackTestLayer(path)` factory, called from `0x21fd8c` inside `DebugTestHarness_DispatchByName`, and also referenced as a data pointer at `0x22092c` (a second, not-yet-identified call site).
- `TrackTestLayer_QABatchRunner_tick` (`0x243160`) — an **automatic QA batch-runner**: every N frames (default 30, at `this+40`), advances through up to 15 track-path entries in a runtime string table (`dword_DD0B54`, bounded by `dword_DD0C44`/`dword_DD0C48` — all-zero in the static image, so populated at runtime, not found statically; likely from a debug config file), constructing a fresh `TrackTestLayer` for each. I.e., there's a built-in "cycle through every track automatically" QA mode too.
- **`DebugTestHarness_DispatchByName` (`0x2217ec`)** — takes a single `const char* modeName` argument. Matches it against category prefixes: `"Track/"` (→ `TrackTestLayer`), `"Performance/"`, `"CarPreview/"` (with a confirmed concrete path `/published/prefabs/garage/car_preview.scene.sb`), `"CarThumbnailMaker/"`, `"CollisionTest/"`, `"RacingLine/"`, plus standalone modes `"MetaPerformanceLayer"`, `"MemoryLeakCheck/ClearMemory"`, `"MemoryLeakCheck/NoClear"`, and — critically — **`"MainMenu"`/`"MAIN_MENU"`, which is presumably the normal shipping-game default.**
- Only one caller found this session: `ResourceDirListeners_Init_maybeCallsDebugHarness` (`0xcf904`), itself called once at startup from `0xde1e4`. **Where the actual runtime `modeName` value comes from is not yet traced** — candidates: a debug config file, an environment variable, a hardcoded literal further up the call chain, or (less likely in a shipping build) a command-line/Intent-extra. This is the single most valuable next step: if `modeName` is externally overridable (e.g. read from a file we can write, or an env var `mpcore` can set before `libapp.so`'s init runs), **we could boot the game directly into `TrackTestLayer` with an arbitrary path — a fully legitimate, pre-built loading path that bypasses `RaceLoaderTask`, Flow, and the menu system entirely, and comes with working start/finish detection and reference cars already bound to `TrackNavigator`.**
**Practical implication for subtask 1 (and partially 2/4)**: this is now the strongest candidate approach, ahead of hooking `RaceLoaderTask_BuildTrackScenePath` (§6b) — construct `TrackTestLayer` directly. It gets us: scene loading, start/finish detection, and car-to-`TrackNavigator` binding all for free, using code EA already tested.
## 6e. Where the debug mode string comes from — traced to a dead end (use a direct call instead)
Traced `ResourceDirListeners_Init_maybeCallsDebugHarness` (`0xcf904`) in full: its last line is `return DebugTestHarness_DispatchByName(*(const char **)(a1 + 292));` — the mode-name string is read from offset `+292` of its argument object. That function is called exactly once, at boot, from the app bootstrap function `sub_DE180` (`0xde180`), as `ResourceDirListeners_Init_maybeCallsDebugHarness(v9)` where `v9` comes from a multi-step "resolve current instance" call chain (`sub_3C969C→sub_5821AC→sub_1911C8→sub_19073C→sub_D0AAC→...→sub_D2880`) — not `sub_DE180`'s own parameter, a separately-resolved object. Did not fully identify this object's class or trace who populates its `+292` field with a concrete value.
**A second, related mechanism was found a few lines later in the same `sub_DE180` bootstrap function**: it looks up a config value for the literal key `"flow"` (via `sub_3BF344`, backed by a global config/tweaks singleton at `dword_DD2E9C`) and compares it against the literal string `"STARTUP_RACE"` — if equal, a special code path runs instead of the normal Flow-init callback. This looked very promising (a named, deliberate "boot straight into a race" switch), so it was checked against every plausible shipped-asset source:
- **All 8 files in `game_cache/published/tweaks/`** (`debug_options.sb`, `tweaks.sb`, `race_tweaks.sb`, `track_performance_tweaks.sb`, `tweaks_ipad.sb`, `traffic_tweaks.sb`, `car_preview_tweaks.sb`, `lod_tweaks.sb`) were unpacked via `NFSMW12MobileTools` (no binary RE needed — see ANALYSIS.md §1.1 for the tool). **None contain a `"flow"` key or a `"STARTUP_RACE"` value.** (`debug_options.sb` did turn out to be a rich, directly-editable in-game debug menu tree — see the bonus finding below.)
- **The reverse-engineered launcher Java sources** (`launcher/app/src/main/java/...`) have no `STARTUP_RACE` string and no `getIntent()`/`getStringExtra()` handling that looks related (the only `getIntent`/`getStringExtra` usages found are in EA Nimble's push-notification/referrer-tracking code, unrelated).
**Conclusion: this is very likely an EA-internal, build-time-only debug switch** (probably set via their internal QA build tooling, e.g. a custom launcher argument or build flavor never shipped to us), **not reachable through any config file, Intent extra, or asset present in this project.** Chasing the "legitimate" source further is a dead end with the material available.
**This does not block subtask 1.** We already have everything needed to use `TrackTestLayer` without going through this dispatch mechanism at all: its constructor address (`0x24a1f4`) and calling convention (2nd arg = track/scene path pointer) are known (§6d). The practical plan is to **call `TrackTestLayer_ctor`/`TrackTestLayer_factory_wrapper` directly from a `mpcore` hook** once `libapp.so` is loaded and initialized, passing our own path — bypassing the mode-string plumbing entirely rather than trying to trigger it "the intended way."
## 6f. First live test: calling `TrackTestLayer_ctor` directly from `mpcore` (on-device, WayDroid)
Actually implemented and tested this, twice, on the running WayDroid setup (`com.ea.games.nfs13_na`, `no_devmenu` debug build). Both attempts crashed, but each crash pinpointed a concrete, understood cause — this is real progress, not a dead end.
**Design choice**: used a plain `std::thread` with a 15-second sleep in `mpcore`'s `JNI_OnLoad`, calling the constructor directly, rather than an inline hook via `armhook.cpp`'s `InstallHook`. Reasoning: that hook infrastructure is untested (see ANALYSIS.md §6 / PROGRESS.md), and mixing an untested hook mechanism with a first attempt at calling a new function would make any crash ambiguous (hook bug vs. constructor-call bug). A direct delayed call isolates the variable under test. `raise(SIGSTOP)` (existing in `main.cpp`, presumably a debugger-attach aid) was commented out for this experiment since it suspends the whole process, including the new thread.
**Attempt 1 — crash inside the string-builder helper itself.** Called `sub_CF5F8(destObj, cstr)` with what was assumed to be a 2-argument "assign from C-string" signature. Crashed instantly (SIGSEGV, `SEGV_MAPERR`) at `sub_CF5F8+0x68`, inside a `memmove` call. **Root cause, confirmed by decompiling `sub_CF5F8` properly**: its real signature is `sub_CF5F8(dest, rangeBegin, rangeEnd)` — a `[begin, end)` range constructor (length = `rangeEnd - rangeBegin`), not an implicit-strlen C-string assign. Every caller seen throughout the binary manually scans for the string's end pointer first (the odd-looking `do { ...*ptr++... } while(*ptr)` patterns noted in earlier decompiles were exactly this) before calling it with 3 arguments. Calling with only 2 left the 3rd ABI register (`end`) as garbage, producing a bogus `memmove` length. **Fixed** by computing `trackPath + strlen(trackPath)` as the third argument.
**Attempt 2 — crash inside the start/finish node lookup.** After the fix, the log confirmed the call reached `TrackTestLayer_ctor` (`0x24a1f4`) itself and got well past scene loading and car-catalog setup (no crash there — meaningful validation that scene loading via the generic loader works when called this way). Crashed later, inside `sub_FDA64` (called on the result of `sub_695A10("start"/"finish", ...)` — the named-node lookup from ANALYSIS.md §6d). **Root cause**: `sub_695A10` returns `{0,0}` when no top-level scene node with that exact name exists, and `TrackTestLayer_ctor` passes that result straight into `sub_FDA64` without a null check, which dereferences it (offset `+0x14`) and segfaults. Checked the actual test track used (`published/prefabs/tracks/region1_foothills_track3.scene.sb`, picked somewhat arbitrarily from the 48 available) by unpacking it via `NFSMW12MobileTools`: it does contain `"start"`/`"finish"` as **nested path segments** (e.g. `checkpoints/banner/en/mesh_start/mesh/vertices`) and several `"Name": "Start"` (capitalized) entries, but apparently not a **top-level node named exactly `"start"`** (lowercase) — either a case-sensitivity mismatch or this specific track genuinely isn't structured the way `TrackTestLayer` expects (it may not be the actual track used by any real race event — picked without checking that).
**What this establishes**: the calling convention, ABI, and approach are correct — we successfully call from a foreign thread into `libapp.so`'s C++ internals, construct its native string objects correctly (after the fix), and drive real engine subsystems (scene loading, car catalog) without crashing. The remaining blocker is track-specific (this constructor expects the target scene to already have "start"/"finish" markers at the top level) and not fundamental. `mpcore`'s current committed code **will crash on launch as-is** (still targets `region1_foothills_track3.scene.sb`) until either a track confirmed to have the right top-level markers is substituted, or the missing null-check is patched around.
**Follow-up (same session): tried a race-linked track — same crash, ruling out "wrong track" entirely.**
Cross-referenced `game_cache/published/data/races/event_01_race.prefabs.sb` (a real, shipped race event): its `TrackName` field points to `region4_chicago_track4`. Unpacked `region4_chicago_track4.scene.sb` via `NFSMW12MobileTools` and confirmed it contains `"actor"` structs named exactly `"start"` (id `1C610000`, prefab `/start.prefabs.xml/start`) and `"finish"` (id `AA090000`, prefab `/finish.prefabs.xml/finish`) — and, critically, **both IDs are listed in the scene's top-level `"actors"` `DataIdsMap` (id `04000000`)**, alongside 11 other sibling actor IDs — i.e. these are not deeply nested; they're direct entries in what is almost certainly the exact list `sub_695A10`'s lookup iterates.
Rebuilt `mpcore` targeting this track and re-ran the live test on WayDroid. **Identical crash, same PC (`sub_FDA64+0x18`, i.e. dereferencing a null lookup result).** This rules out "wrong/non-gameplay track" as the explanation — the data is present and structurally exactly where expected, yet the runtime lookup still fails.
**Revised theory**: the failure is not about the scene's authored content but about *runtime state at the moment of the lookup*. Two candidates, neither confirmed:
1. **Scene loading may be asynchronous**`sub_33C284`/`sub_33B038` (§6c) could parse/instantiate the scene over multiple engine ticks rather than fully synchronously within the call, and `TrackTestLayer_ctor` (in its normal, EA-authored call context) may rely on being re-entered or polled across frames before doing the start/finish lookup, something a single direct constructor call from a foreign thread can't replicate.
2. **`TrackTestLayer` may depend on ambient global/singleton engine state** (e.g. a "current active world/scene" pointer) that's normally established by whatever code path constructs it in EA's own tooling — a path we never found (§6e) and therefore can't replicate — rather than being self-contained from just its two constructor arguments.
**Recommendation: stop iterating on `TrackTestLayer` blindly.** Three attempts (bad-track-guess, then a confirmed race-linked track, both crashing identically) is enough signal that this isn't a simple parameter-tuning problem, and further guessing without new information is unproductive. Two real forward paths, to decide with the user:
- **(a)** Properly instrument/trace the scene-load completion state (e.g. poll whatever ref-count or "loaded" flag `sub_33C284`'s output object exposes before attempting the lookup) — a scoped, legitimate next RE step if `TrackTestLayer` is still wanted.
- **(b)** Pivot subtask 1 to hooking `RaceLoaderTask_BuildTrackScenePath` (§6b) instead — the engine's own always-used, always-correctly-initialized code path for every normal race load, sidestepping whatever implicit dependency `TrackTestLayer` has. Given `TrackTestLayer` itself has an unguarded null-deref bug (§6d) that fires on a supposedly-correct track, treating it as a reliable, maintained tool in this specific shipped build (1.3.128) is now in question — it may be stale/unexercised in this build even though the code is present.
**User chose (b).** See §6g for the implementation and where live testing currently stands.
## 6g. Pivoted to hooking `RaceLoaderTask_BuildTrackScenePath` — ARM-mode inline hook, installed and stable; end-to-end test blocked by WayDroid networking
**Key discovery before implementing anything**: disassembled `RaceLoaderTask_BuildTrackScenePath` (`0x2db384`)'s prologue and found it is compiled in **ARM mode**, not Thumb — `E92D41F0` (`PUSH {r4-r8,lr}`), `E24DD040` (`SUB SP, SP, #0x40`), both classic 32-bit ARM encodings (condition-code nibble `E` = "always", a dead giveaway vs. Thumb's 16-bit encodings). This matters a great deal: `armhook.cpp`'s existing `InstallHook`/`makeJMP`/`HOOK_PROC` machinery is hardcoded for **Thumb** targets (its `HOOK_PROC` byte sequence starts `01 B4 01 B4 01 48...`, all 16-bit Thumb opcodes). Using it on this ARM-mode function would misinterpret/corrupt the target — the existing hook infrastructure **cannot be used here as-is**. This confirms the earlier caution (ANALYSIS.md/PROGRESS.md) about that code being untested was well-founded, and now there's a concrete reason it would specifically fail on this target.
**Traced the exact field being overridden**: `sub_F5154`'s real signature is `sub_F5154(dest, literalCStr, srcRangeObjPtr)` — the third argument is the **address** of a 3-word `{begin,end,capacity}` string object (same layout as `sub_CF5F8`'s objects, read via `a3[0]`/`a3[1]`), not a raw string. So in `RaceLoaderTask_BuildTrackScenePath`'s call `sub_F5154(&v16, "published/prefabs/tracks/", a1[8] + 72)`, the track name lives as an **inline 3-word string object at byte offset 72 within the `RaceDefinition`-like struct pointed to by `RaceLoaderTask.this[8]`** (word index 8 = byte offset 32). To override the track: read `raceDefPtr = this[8]`, then call `sub_CF5F8(raceDefPtr + 72, newName, newName + strlen(newName))` to overwrite that field in place before the path gets built — reusing the exact same string-range helper already understood from the `TrackTestLayer` work (§6f), just applied to an *existing* object's field instead of a fresh stack buffer.
**Implemented (`launcher/mpcore/src/main/cpp/main.cpp`)**: an ARM-mode-correct inline hook, separate from `armhook.cpp` (documented inline why):
- Overwrite the target's first 8 bytes (exactly 2 whole ARM instructions — safe, ARM instructions are always 4 bytes wide, so there's no mid-instruction split risk the way there is in mixed 16/32-bit Thumb-2) with `LDR PC, [PC, #-4]` (`0xE51FF004`) + the hook function's address.
- A trampoline (RWX `mmap`'d page) holds the 2 displaced original instructions (both confirmed position-independent — plain `PUSH`/`SUB`, no PC-relative addressing, so relocating them is safe) followed by the same `LDR PC, [PC,#-4]` pattern jumping back to `target+8` to resume the original function in ARM mode.
- Mode-switch correctness: the hook function's address (a normal Thumb-2-compiled C++ function, since `mpcore` builds as Thumb-2 by default) has bit 0 set automatically by the compiler/linker for ARM/Thumb interworking — loading it into `PC` via `LDR` triggers the CPU to switch to Thumb state, per standard ARMv5T+ interworking semantics. The trampoline's jump back to `target+8` uses a plain (bit-0-clear) address, staying in ARM mode as required.
- The hook itself: reads `this[8]` (the `RaceDefinition` pointer), overwrites the string object at `+72` with a hardcoded override track name (`region3_colorado_track2` — deliberately different from any track a real race would use, so a successful override is visually unmistakable), logs, then calls through to the original via the trampoline.
- Installed unconditionally and immediately in `JNI_OnLoad` (no delay needed, unlike the `TrackTestLayer` experiment — patching code bytes is safe at any time since nothing is being called yet; the patched function only executes later, whenever the player naturally starts a race through the normal menu).
- The old `TrackTestLayer` thread-spawn experiment (§6f) is left in place but commented out (superseded, not deleted, consistent with this file's existing style of preserving prior experiments as commented-out code).
**Live test status (WayDroid)**: hook installed cleanly — `mpcore_log` confirmed `"Installed RaceLoaderTask_BuildTrackScenePath hook, orig trampoline=0x..."` and the app continued running (and slowly growing in RSS, i.e. actively doing work, not frozen) for 2+ minutes afterward with **no crash** — reasonably strong indirect evidence the 8-byte ARM patch itself is correctly formed and didn't corrupt anything nearby. However, the screen stayed black the entire time and never reached the main menu, so the hook itself was never actually exercised (it only fires when `RaceLoaderTask_BuildTrackScenePath` is called, i.e. once a race load begins). Diagnosed the black screen: **not a bug in this session's changes**`ip route` inside the WayDroid container shows only the local `192.168.240.0/24` subnet with **no default gateway**, confirmed by 100% ping packet loss to `8.8.8.8`; the boot log shows `NIM_ERROR: No network connection` during EA's Nimble/EASP init, and the app likely hangs on a network-dependent init step (or a long timeout) before ever reaching the menu. Fixing this needs root inside the WayDroid container (`waydroid shell` requires root; no passwordless `sudo` available in this session) — a genuine infrastructure gap, not something resolvable from here without the user's involvement.
**Bottom line**: the hook mechanism itself is implemented and shows no signs of corrupting the binary (survives 2+ minutes of live execution). What's *unverified* is whether it actually fires and successfully substitutes the track name when a real race is started, because the WayDroid container currently can't get far enough into the game's boot sequence to reach the menu. Next step once network is available: launch, reach the menu, start any race event, and confirm via `mpcore_log` that the hook fired and via screenshot that `region3_colorado_track2` (not the race's real track) loads.
**Correction (same day)**: the "no network" theory above was wrong. The user identified the real cause: the black screen is because this build (`applicationId com.ea.games.nfs13_na`) has no game_cache to find — WayDroid already has a cache, but only under the package name `com.ea.games.nfs13_mod` (confirmed: `com.ea.games.nfs13_mod` is installed there separately, `versionName 1.3.128`, and `/sdcard/Android/obb/com.ea.games.nfs13_mod/main.1003128.com.ea.games.nfs13_mod.obb`, a 623MB file dated 2020, exists on the device). The `NIM_ERROR: No network connection` log line is expected/harmless — a stub the user placed during reverse engineering, not a real blocker.
## 6h. Switching to `com.ea.games.nfs13_mod` — versionCode fix was necessary for install, but its OBB-lookup justification is UNVERIFIED
Changed `launcher/app/build.gradle.kts`: `applicationId``com.ea.games.nfs13_mod` (`namespace` left as `com.ea.games.nfs13_na` deliberately, since source files still `import com.ea.games.nfs13_na.BuildConfig` and changing `namespace` would require updating those). First install attempt failed with `INSTALL_FAILED_VERSION_DOWNGRADE` (our `versionCode=1` vs. the already-installed real game's `versionCode=1003128`) — this is a **confirmed, OS-level fact** (Android's package installer itself enforces version-code-based downgrade protection; `adb install -r -d` bypasses it, or bumping our own `versionCode` to match avoids it entirely). Set `versionCode = 1003128`, `versionName = "1.3.128"` to match, after which `adb install -r` succeeded without needing `-d`.
**The comment originally added alongside that change — that the game's own code looks up its OBB file via `main.<versionCode>.<applicationId>.obb`, so versionCode needed to match for asset loading — was an unverified assumption, not a checked fact, and the user correctly asked for evidence.** Checked properly:
- `grep` (case-insensitive) for `obb` across the **entire** `launcher` source tree (all `.java`/`.kt`/`.xml`) — **zero matches**.
- `find_regex` for `addAssetPath|mountObb|StorageManager|\.obb` in `libapp.so`**zero matches** (this check was actually already done earlier in the session, but its implication for the versionCode comment wasn't connected until asked).
**Conclusion: there is no evidence in this codebase (Java or native) that the game constructs or checks an OBB filename against its own versionCode, or uses Android's OBB/StorageManager APIs at all.** The `main.1003128.....obb` file on the test device is most likely a leftover from the original Play Store install of the real game (Google's own expansion-file delivery mechanism placed it there historically), not something this reverse-engineered launcher's code actively looks for. The `versionCode` fix was corrected in `build.gradle.kts` to state only what's actually confirmed (avoids the installer-level downgrade block) and flags the OBB-lookup theory as unverified. It should not be treated as "the fix" for the black-screen/cache-loading problem — that mechanism is still unidentified.
**Still open**: how does this build actually locate `game_cache/published/...` at runtime? Three candidate mechanisms were floated as hypotheses, none confirmed: (a) Android's native `AAssetManager` reading straight from the APK's own bundled `assets/` folder (plausible: `EAIO.Startup()` does pass `activity.assets`, an `AssetManager`, into native code) — but this would require `game_cache` to be bundled inside the APK's `assets/`, not on external storage at all; (b) plain loose files read via `fopen`/NDK file I/O from some external-storage path (would need to find where that path is set) — for AllocationList and the actual root/prefix, not yet traced; (c) some other, not-yet-considered mechanism. Not pursued further this session — genuinely needs either tracing the native file-open call chain from `RaceLoaderTask_BuildTrackScenePath`'s eventual `sub_33B038`/`sub_33C284` down to the actual `fopen`/`AAssetManager_open` call, or the user's own recollection from prior reverse-engineering of this launcher.
**Update — resolved live, same day**: switching the launcher project (now at `/home/megboyzz/AndroidStudioProjects/NFSMostWanted128`, symlinked as `launcher/`) to its current/correct state and rebuilding actually answered this empirically rather than requiring more static RE: the boot log shows `Mounting SKU: texture_dxt3 to /published`, `Mounting SKU: 1x/2x/4x to /published`, followed by real asset loads (`FlowManager splash.sba sba`, `ResourceManager: Add texture: /published/texturepacks_ui/splash_1775.sba_0`) — confirmed on-screen too, the actual EA logo splash rendered (not a black screen) on WayDroid. So the OBB *is* mounted somewhere such that `/published/...` resolves — mechanism (a)/(b)/(c) above still isn't pinned down architecturally, but it demonstrably works end-to-end now with the corrected launcher+native_libs+OBB file combination. Not worth further static tracing unless it breaks again.
## 6i. CRITICAL: the previous session's `.i64` was stale — silently out of sync with the on-disk binary; rebuilt fresh
**What happened**: the user replaced `native_lib/libapp.so` (and the whole `native_lib/` set, and the `launcher/` project itself — see §6h) with corrected versions partway through the previous session. `native_lib/libapp.so.i64` was **not** rebuilt at that point, but continued to open "successfully" and even reported `survey_binary` metadata (`sha256`/`md5`) that exactly matched the *new* on-disk file — which looked like reassuring confirmation that the database was current. **It was not.** Proof, obtained by direct comparison:
- Raw bytes read straight from the current `native_lib/libapp.so` file at offset `0x2db384` (via plain Python `open().seek().read()`, bypassing IDA entirely): `74 00 8D E2 0C 00 8D E5 04 00 A0 E1 15 B6 06 EB` → decodes to `ADD R0,SP,#0x74` / `STR R0,[SP,#0xC]` / `MOV R0,R4` / `BL ...`.
- What the (at-the-time still-open, "hash-matching") `.i64` showed for that same address: `PUSH {R4-R8,LR}` / `SUB SP,SP,#0x40` / `LDR R2,[R0,#0x20]` / ... — the `RaceLoaderTask_BuildTrackScenePath` prologue found and hooked in §6g/§6h.
**These are different instructions at the same address — conclusive proof the open `.i64` was analyzing stale, cached content, not the file on disk it claimed to correspond to.** The likely mechanism: `survey_binary`'s reported `sha256`/`md5` is computed live by re-reading whatever is *currently* at the recorded `input_path` on disk, independent of whatever was actually analyzed and cached inside the `.i64` when it was first built — so a hash match there proves the *external file* is unchanged since some point, but says nothing about whether the loaded database's internal snapshot still matches it. **Takeaway for future sessions: never trust `survey_binary`'s hash fields alone as proof an `.i64` is current after a binary might have changed underneath it — cross-check actual disassembly/raw bytes at a known address directly, exactly as done here.**
**Fix applied**: moved the stale database aside (`native_lib/libapp.so.i64.stale_2026-07-31`, not deleted — kept for reference/comparison against the old, pre-relocation build if ever needed) and opened `native_lib/libapp.so` fresh via `idb_open` (pointing at the `.so`, not an existing `.i64`), forcing a full auto-analysis. Verified the fix the same way it was diagnosed: disassembly at `0x2db384` in the fresh session now matches the raw file bytes exactly (`ADD R0,SP,#0x74` / `STR` / `MOV R0,R4` / `BL`).
**New binary's basic stats** (`survey_binary`, fresh session): `image_size 0xb167d0` (~11.6MB, vs. the old build's `0xe52ebc`/~14.9MB), **34,726 total functions** (vs. 50,772), 2,425 named, 21,031 strings, 19 segments. JNI entry points are now `Java_com_ea_ironmonkey_GameActivityMain_*` (matching the renamed `GameActivityMain` Kotlin class from §6h — the *native* side was rebuilt to match too, not just Java). New debug-assertion strings not seen in the old build (`"GetComponent: called with a component type that allows multiple instances..."`, `"Dereferencing a NULL component pointer."`) and `libc++`'s `__ndk1` namespace suggest a different/newer NDK toolchain and possibly a less aggressively stripped build than before. **This is a genuinely different compiled build, not merely a renamed or re-packaged file.**
**Good news: the architecture holds.** Re-ran the RTTI string search (`RaceLoaderTask|TrackTestLayer|NFSScene|TrackNavigator|OpponentCollection|BitmapGraphics`) against the fresh binary — every one of these classes is still present, confirming this is the same engine/codebase lineage, just recompiled with different addresses/layout. One correction/refinement: `TrackTestLayer`'s full namespace is confirmed as **`im::app::layers::debug::TrackTestLayer`** (a `layers::debug` namespace — previously only knew the bare class name), which also confirms the earlier characterization of it as a debug/QA tool directly from its own mangled namespace, not just inferred behavior.
**Practical implication**: every specific address in §6a–§6h (RaceLoaderTask's vtable at `0xd86210`, `RaceLoaderTask_BuildTrackScenePath` at `0x2db384`, `TrackTestLayer_ctor` at `0x24a1f4`, `BitmapGraphics_*` functions, etc.) is **invalid for this binary** and needs to be re-derived from scratch using the same techniques (RTTI name → `find_bytes` walk to vtable, decompile candidate functions, confirm via distinctive strings/constants). The `mpcore` hook installed in §6g/§6h currently targets a now-meaningless offset in this binary and must not be re-enabled as-is. The current `mpcore/main.cpp` (per the user's own recent edit, visible in the system reminder) has already moved on from that hook to a fresh `dl_iterate_phdr`-based base-address lookup with no hooks installed yet — consistent with starting over.
### Bonus finding: `game_cache/published/tweaks/debug_options.sb` is a real, editable in-game debug menu
While searching for `"flow"`, unpacking `debug_options.sb` revealed it's a full debug-menu configuration tree (`PropertyNameCHDR`/`DebugMenuPath` entries), directly useful for the mod and editable with zero binary patching (`NFSMW12MobileTools` unpack → edit JSON → repack). Top-level categories: `AI`, `Black Market`, `Camera`, `Collision`, `Garage`, `HUD`, `RacingLinePreviewLayer`, `Shader`. Notable entries directly relevant to this project:
- `AI/Race/Max Num AI Opponents`, `AI/Race/Disable AI Opponents` — direct control over opponent count, relevant to subtask 2.
- `AI/Race/Disable Rubber Banding`, `AI/Race/Disable Nitro`, `AI/Race/Enable Player AI Nitro`, `AI/Race/Weaken Opponents` (top-level `Weaken Opponents` also exists) — race-tuning toggles.
- `Everything is Available`, `Everything is Free` — content-unlock flags.
- `Infinite Player Nitro`, `Disable Traffic`, `Disable HUD`, `Enable Soak Test` — misc QoL/testing toggles.
- `AI/Race/Draw AI Track Navigators`, `AI/Dynamic Raceline/*` debug-rendering toggles — could help visualize the `TrackNavigator` coordinate system from §6a while developing subtask 4.
Not yet investigated: how `debug_options.sb`'s values actually get read into the running game (same `dword_DD2E9C`-style config singleton as `"flow"`, presumably) and whether editing this file alone is sufficient or whether the game reads a *device-local* copy that would need pushing separately. Worth a quick practical test via WayDroid before relying on it.
## 6j. `RaceLoaderTask` rediscovered in the fresh binary — vtable, 6 slots confirmed, `BuildTrackScenePath` equivalent found
Per user instruction ("Начни с RaceLoaderTask"), redid the §6a RTTI-walk against the current, verified-fresh `native_lib/libapp.so.i64` (rebuilt in §6i). Same technique: RTTI name string → `xrefs_to``type_info` struct → `find_bytes` for a pointer to that `type_info` → real vtable starts 4 bytes after the match.
- RTTI name `N2im3app4race14RaceLoaderTaskE` now at `0xa2f570` (was `0xcfe924` in the old binary).
- `type_info` struct at `0xaa7938` = `{vtable=0xb15fb4, name=0xa2f570, base_typeinfo=0xa9b5a0}`.
- `find_bytes` for `0xaa7938` (LE) returned 3 hits: `0x620d0` (false lead — the engine's own custom reflection/type-registry table, pattern `{ptr, 0x1802}` this time instead of the old binary's `{ptr, 0x17}` — same false-lead shape as §6a, just a different tag constant), `0xaa78e8` (the real vtable — confirmed by the same "long run of `.text` addresses immediately followed by the class's own `type_info`" pattern used in §6a), `0xac6514` (unexamined — likely a different class's `base_typeinfo` field, same as the unresolved false lead in §6a).
**`RaceLoaderTask` vtable**: `0xaa78e8`, **18 virtual function slots** (`0xaa78ec``0xaa7930`) — one fewer than the old binary's 19 (§6a slot 0's "shared thunk" role may have been folded/removed; not confirmed, low priority).
| Slot | Address | Renamed to | Role / evidence |
|---|---|---|---|
| 0 | `0x7e320` | — | Very low `.text` address, shared across many unrelated vtables (same "generic base-class thunk" shape as old binary's slot 0/3/5) — not decompiled. |
| 1 | `0x2a7e58` | `RaceLoaderTask_dtor_complete` | Sets vptr back to `0xaa78ec` (own vtable), decrefs a member at `a1[41]`. Same shape as old slot 1. |
| 2 | `0x2a8130` | `RaceLoaderTask_dtor_deleting` | Calls slot 1 then `sub_3D0C04` (`operator delete`). Same shape as old slot 2. |
| 3 | `0x7e450` | — | Shared-thunk region, same as slot 0. Not decompiled. |
| 4 | `0x2a8144` | `RaceLoaderTask_ExecuteLoadSequence` | **Main load orchestrator**, direct equivalent of old slot 4 (`0x2dbba4`). Calls a progress-setter (`sub_408AB4`, floats `0.1..0.8`) interleaved with 8 stage sub-calls (`0x2a8424, 0x2a89a4, 0x2a8b4c, 0x2a8ce8, 0x2a8e2c, 0x2a9274, 0x2a9338, 0x2a9bcc`), references `/published/texturepacks_ui/in_game.sba`. |
| 5 | `0x7e458` | — | Shared-thunk region. Not decompiled. |
| 6 | `0x2ade58` | — | `nullsub_258` — matches old slot 6 (also a nullsub) in relative position. |
| 7 | `0x2aa934` | — | Setter: `a1[16..18] = a2[0..2]` with refcount release on the old value — matches old slot 7's "Set<SharedResource>" shape. Role unconfirmed. |
| 8 | `0x2aab34` | — | Takes `(this, a2)`; builds a key via `sub_66FFB0`/`sub_2AAE68`, then appends a 3-word entry `{a2[0],a2[1],a2[2]}` into a growable vector at `this+76/80` (realloc via `sub_D746C`), plus registers into `this[11]+160` via `sub_27DFF0`. Vector-append shape suggests building a collection (candidate: opponent-car list), but **no distinctive string found** — do not treat as confirmed "SetupOpponentCar" (old slot 11 had the literal string `"opponentCar"`; this one doesn't). Left unnamed. |
| 9 | `0x2aafac` | `RaceLoaderTask_SetupPlayerCar` | References string `"playerCar"` — same as old slot 9. |
| 10 | `0x2ab4c4` | — | Single-arg `(this)`; refcounted lookup/creation against a component at `this+40`/`this+32`, dispatches through a callback vtable at `off_AA7978`. No distinctive string. Not confidently named. |
| 11 | `0x2abbf0` | — | Trivial 1-line thunk: `return sub_2AFED0(a1, a3);` — pure forwarding, not investigated further. |
| 12 | `0x2abc04` | — | Factory: allocates either a 0x14-byte or 0xAC-byte object (branching on a flag byte at `*a3+76`), tagging it with mini-vtable `off_AA7990` or `off_AA5048`. These mini-vtables are refcount-only closure wrappers (`{funcptr, funcptr}`, no RTTI/typeinfo — confirmed by reading their first word, which is a plain code address, not a typeinfo pointer), so class identity can't be recovered via RTTI here. Not confidently named. |
| 13 | `0x2abe08` | `RaceLoaderTask_ResetStartingLine` | References string `"m_StartLine"` + `"Assertion failed ("` — direct equivalent of old slot 14 (`"ResetLine"`). |
| 14 | `0x2acfa8` | — | `nullsub_257`. |
| 15 | `0x2acfac` | — | Iterates the same vector shape as slot 8 (`this[19]/this[20]`, 3-word stride), calling `sub_152250(raceCtx, ..., key, ...)` per element plus once before the loop — looks like a per-entry registration/lookup pass over whatever collection slot 8 builds. Not confidently named. |
| 16 | `0x2ad4a8` | `RaceLoaderTask_HandleSpikeStrip` | `dynamic_cast<im::app::bt::BlacklistTech> → im::app::bt::SpikeStrip` — direct equivalent of old slot 17. |
| 17 | `0x2ada80` | — | Allocates 3 closure objects (mini-vtables `off_AA7A58/AA7A80/AA7AA8`, same no-RTTI shape as slot 12) and registers each via `sub_2848A0(this[40], ...)`, then loops calling a virtual at `*this+68` per element of a collection at `*(this+16)+68`. Looks like registering 3 event listeners against whatever `this[40]` is (a dispatcher/FSM?), then notifying per-collection-element. Not confidently named. |
**Renamed and saved this round** (6 of 18 slots, all backed by a distinctive string or exact structural match to the old binary — the rest are left as `sub_`/generic to avoid repeating the "unverified claim" mistake from §6h): `RaceLoaderTask_dtor_complete`, `RaceLoaderTask_dtor_deleting`, `RaceLoaderTask_ExecuteLoadSequence`, `RaceLoaderTask_SetupPlayerCar`, `RaceLoaderTask_ResetStartingLine`, `RaceLoaderTask_HandleSpikeStrip`.
**`BuildTrackScenePath` equivalent found — stage 1/8 of `ExecuteLoadSequence`, address `0x2a8424`.** Decompiling all 8 stage sub-calls, the first one (`0x2a8424`, called first, matching old slot's call order) references the exact strings `"published/prefabs/tracks/"` (`0x9d7498`) and `".scene.sb"` (`0x9d74b2`) — the same path-construction signature as old `RaceLoaderTask_BuildTrackScenePath` (`0x2db384`, now stale). Renamed to `RaceLoaderTask_BuildTrackScenePath`.
**ARM-mode confirmed** at the new address too: raw bytes at `0x2a8424` are `F0 4F 2D E9` = `0xE92D4FF0` = `PUSH {R4-R11,LR}` (cond nibble `E`, classic 32-bit ARM encoding) — same situation as §6g's old-binary finding, so the same custom ARM-mode inline-hook design (overwrite first 8 bytes / 2 whole ARM instructions with `LDR PC,[PC,#-4]` + hook address; trampoline relocates the displaced instructions) is directly reusable here, just against this new address. Not yet re-implemented in `mpcore` this round (the old hook code was removed from `main.cpp` by the user per §6i — see PROGRESS.md).
**Next step**: trace where `RaceLoaderTask_BuildTrackScenePath`'s track-name field (equivalent of old `this[8]+72`) gets populated, confirm the field offset in this binary (register/stack layout may differ from the old build), then re-implement the ARM-mode hook in `mpcore/src/main/cpp/main.cpp` targeting `0x2a8424`, matching the design already proven working in §6g (just against new addresses).
## 6k. ARM-mode hook implemented and live-tested on real hardware (Pixel 6a) — mechanism works, exposes a real data-consistency limit
Per user instruction, implemented the ARM-mode inline hook designed in §6j/§6g against the fresh binary's `RaceLoaderTask_BuildTrackScenePath` (`0x2a8424`), in `launcher/mpcore/src/main/cpp/main.cpp`, and tested live via adb on the Pixel 6a (GrapheneOS) device — see `reference-pixel6a-grapheneos-testing` memory. This is the first live test of the mod's actual hook mechanism on any device (WayDroid never got far enough; the previous ARM hook in §6g was tested against the now-stale old binary).
**Implementation** (`Hook_BuildTrackScenePath`): reads `raceDefPtr = a1[8]` (word offset 8 = byte 32, confirmed identical to the old binary), then repoints the `{begin,end}` pointer pair for the track-name field (`raceDefPtr+72/+76`) at a static literal `"region3_colorado_track2"` — and, in a follow-up fix, also the environment-name field (`raceDefPtr+100/+104`) at `"colorado"` (matches the `.prefabs.sb`'s actual region folder). Deliberately does **not** free/reallocate the original buffers (leaks them — one tiny allocation per race load, negligible) since `BuildTrackScenePath` only ever reads these fields, never frees them; this avoids the capacity-field-offset guessing risk flagged as a concern before implementation. Hook installed via 8-byte ARM-mode patch (`LDR PC,[PC,#-4]` + hook address) at `JNI_OnLoad`, exactly as designed in §6g, just retargeted to the new address; trampoline relocates the 2 displaced `PUSH`/`ADD` instructions (both confirmed position-independent) and jumps back to `target+8`.
**Live test 1 (track-name override only)**: installed cleanly (`mpcore_log`: `Installed RaceLoaderTask_BuildTrackScenePath hook at 0xd7eff424, trampoline=0xe69e3000` — matches `libapp_base + 0x2a8424` exactly), no crash through menu navigation. Started the "Петерсон стрит" event (a real, working event — confirmed crash-free with the unmodified build in the same session, see PROGRESS.md). Hook fired (`BuildTrackScenePath hook fired: overriding track name -> region3_colorado_track2`) and the engine genuinely started loading Colorado-region assets (`Add asset: /published/textures/collidables/texture_collidables_colorado.sba`, colorado skydome references) instead of the real event's track — **conclusive proof the field override reaches the engine's actual path-construction logic**. However, ~150ms later: `Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x00000038` in a background thread (`Thread-9`), `Cause: null pointer dereference`, register `r0=00000000`.
**Live test 2 (track-name + environment-name override)**: hypothesized the crash was caused by the environment field still pointing at the original (non-colorado) event's environment while the track name pointed at colorado — a plausible mismatch given `BuildTrackScenePath` builds `"published/prefabs/environments/" + envName + ".prefabs.sb"` from that same struct. Added the environment override and retested. **Identical crash** — same fault address `0x38`, same thread name `Thread-9`, and (confirmed by computing `pc - libapp_base` from both tombstones) the **exact same code offset both times** (`0x53a604`), ruling out the environment-mismatch theory.
**Root cause, confirmed by decompiling the crash site** (`sub_53A5FC` at `0x53a5fc`, file offset `0x53a604` is its second instruction):
```c
int sub_53A5FC(int *a1, int *a2) {
if (a2) { *a2 = a1[14]; a2[1]=a1[15]; a2[2]=a1[16]; a2[3]=a1[17]; }
return a1[13];
}
```
`a1[14]` = byte offset `14*4 = 56 = 0x38`**exactly the crash fault address**, and `r0` (== `a1`, the first argument) was `00000000` at crash time, per the tombstone register dump. So some caller passed a **NULL object pointer** into this small getter (looks like a generic transform/bounds accessor — copies a 4-word block, e.g. a quaternion or bounding-sphere, plus a flags word). `xrefs_to` found **21 distinct call sites** across what looks like physics/rendering component code (`sub_496AE8`, `sub_4970D8`, `sub_53EDAC`, `sub_53F060`, `sub_5749A0`, `sub_57AB8C`, `sub_57B0C4`, etc.) — this is common shared utility code, not something track/environment-specific, so tracing the *exact* call chain back to a specific `RaceLoaderTask` stage was not pursued further this session (would need substantial additional tracing across 21 call sites).
**Working theory** (consistent with the evidence, not yet independently confirmed by tracing further): `RaceDefinition` carries more than just track/environment name — checkpoint list, opponent list, starting-grid data, etc. (see `RaceLoaderTask_ResetStartingLine`, `RaceLoaderTask_LoadRaceFSM`'s checkpoint/car iteration in §6j's stage table) — none of which our hook touches. Those still reference IDs/objects from the **original** "Петерсон стрит" event, which don't exist in the substituted Colorado scene. A background streaming/physics thread (`Thread-9`) walking one of these now-dangling references gets a NULL lookup result and calls the unguarded getter on it. In other words: **swapping only the track-name/environment-name fields performs a real, engine-level scene substitution, but is not sufficient for a fully arbitrary track swap** — the race-logic fields (checkpoints, opponents, starting grid) need to come from a source consistent with the new track, not the old event.
**Bottom line for subtask 1**: the hook *mechanism* itself (ARM-mode 8-byte patch, trampoline, field-pointer override, no-free-leak strategy) is proven correct and crash-free in isolation — the crash is a downstream data-consistency issue, not a hook bug. Confirmed no tombstones/crashes at all when the same event was played through normally (unmodified) earlier in this session (see PROGRESS.md), isolating the regression to the override itself. **Next step**: either (a) pick a substitute track from an event whose checkpoint/opponent data is actually compatible with the new scene (unlikely to generalize), or (b) extend the hook to also intercept/rewrite the checkpoint and opponent-placement stages (`RaceLoaderTask_ResetStartingLine`, `RaceLoaderTask_LoadRaceFSM`) so they resolve against the substituted track's own data instead of the original event's — the more general, correct fix for subtask 1's "arbitrary track" goal.
## 6l. Debugger attach attempted, blocked by environment; lightweight `Log()` diagnostics confirmed the §6k theory instead
Per user request, tried attaching `lldb` to the live game process on the Pixel 6a for a full memory-map view (the "harder path" alternative to more `Log()` calls), since the device is `userdebug` with `su` available.
**Debugger attempt — inconclusive, blocked by environment, not by anything RE-specific**: pushed a version-matched `lldb-server` (confirmed exact match: both client and server report `lldb version 18.0.1`, identical revision hash `d8003a456d14a3deb8054cdaa529ffbf02d9b262`, client sourced from `$ANDROID_SDK/ndk/27.0.12077973/toolchains/llvm/prebuilt/linux-x86_64/`). `su 0 lldb-server platform --listen '*:1234' --server` started and bound the port cleanly (`netstat` confirmed `LISTEN`), `adb forward` and raw TCP connect both worked. But the gdb-remote handshake never got a reply (`error: failed to get reply to handshake packet`) even with the client-side timeout raised to 90s, and a raw Python socket probe sending a well-formed `$QStartNoAckMode#b0` packet got zero bytes back within 10s on an otherwise-healthy connection. Root cause eventually found: **the device's screen had locked (fingerprint lock screen) partway through the session**, and while locked, `adb shell`/`su` round-trips degraded unpredictably (34ms one moment, 80300s+ hangs the next, tracked via `time adb shell echo`) — almost certainly Android's Doze/screen-off throttling affecting the `su`-elevated shell and/or `lldb-server`'s connection-handling thread. After the user physically unlocked the device, `adb shell` responsiveness returned to consistent sub-100ms — but by then the debugger session itself needed re-establishing from scratch, and rather than keep re-attempting, pivoted to the lighter-weight `Log()` approach (see below) at the user's direction, since it doesn't depend on a fragile interactive session. **Not marked as "doesn't work"** — the version-matching and forward/listen mechanics are confirmed correct; a retry after ensuring the device stays unlocked/awake (e.g. `adb shell svc power stayon true` or disabling screen timeout first) would very plausibly succeed. Worth revisiting if `Log()`-based diagnostics hit their limit.
**`Log()`-based diagnostic (the approach actually used this round)**: added temporary diagnostic logging to `Hook_BuildTrackScenePath` (`launcher/mpcore/src/main/cpp/main.cpp`) dumping, *before* any override is applied:
- The original track name (to confirm which real event/track was hit).
- `raceDef+40/44` — a second `{begin,end}` string field `sub_2A9338` (`RaceLoaderTask_LoadRaceFSM`, stage 7) reads and compares against itself (`!=` — turned out to just be an "is this non-empty" check, not an equality-against-another-field check as originally guessed in the stage-7 decompile).
- `*(raceDef+12)` — a **nested struct pointer** `sub_2A9338` dereferences, then reads *that* struct's `+48/+52` as the actual name substituted into `"/published/prefabs/racefsms/{0}.prefabs.sb"`.
Live result on the real "Петерсон стрит" event (before override, i.e. ground truth):
```
diag: original track name = 'region1_foothills_track4' (len=24)
diag: raceDef+40/44 field = '' (len=0, begin=0xd5670a0c)
diag: racefsm name = 'point_to_point_fsm_newintro' (len=27)
```
**This is a meaningful finding**: the race-FSM prefab (`point_to_point_fsm_newintro`) is a **generic, reusable race-type template** — "point to point" checkpoint-race rules, not a per-track asset — confirming the racefsms layer itself isn't what breaks when the *track* is swapped. This refines (without contradicting) the §6k working theory: the crash isn't from loading the *wrong FSM* (the FSM is track-agnostic by design), it's from the **generic FSM's checkpoint/actor lookups failing against the substituted scene**`point_to_point_fsm_newintro` presumably walks checkpoint-tagged actors by name/count in whatever scene is currently loaded, and `region3_colorado_track2`'s actual checkpoint layout doesn't satisfy whatever this FSM variant (note the `_newintro` suffix — possibly implies an expected intro-camera/cutscene actor too) expects.
A quick `strings`-based comparison of `region1_foothills_track4.scene.sb` vs `region3_colorado_track2.scene.sb` for checkpoint-related tokens (`Checkpoint`, `start`, `finish`) found identical **type/prefab names** in both — but `strings` can't distinguish instance counts or IDs within the packed SB `DataIdsMap` structure, so this doesn't confirm or rule out a checkpoint-count/tag mismatch. A real answer needs either the full `NFSMW12MobileTools`-based SB unpack-and-diff (fast, no binary RE) or the debugger. Done immediately after, below — **root cause now confirmed directly**.
## 6m. Root cause confirmed: checkpoint-container name AND count both mismatch between the original event and the substituted track
Per user request, unpacked both `.scene.sb` files via `NFSMW12MobileTools.jar unpack` (jar at `NFSMW12MobileTools/NFSMW12MobileTools.jar`, requires `HCStructFileArray.json` copied into the working directory) and diffed their checkpoint actor structure directly — no debugger needed, this closes the investigation with hard evidence.
**Tool note**: `region3_colorado_track2.scene.sb` (the exact file we hook to) crashes the unpacker's full DATA-object parser (`NegativeArraySizeException` in `SBin.getCleanElementHex`/`parseDATABlock` — an unrelated pre-existing bug in the community tool, not something we introduced). Worked around two ways: (a) `-disableDATAObjectsUnpack` still parses the CDAT string table (`CDAT_Strings`) even though it skips structured per-object field parsing, which was sufficient here; (b) as a cross-check, `region3_colorado_track1.scene.sb` (a sibling colorado track, same region) unpacks fully with no flags and confirms the same pattern independently.
**`region1_foothills_track4.scene.sb`** (the *real* track behind the "Петерсон стрит" event we've been testing against, confirmed via the live `diag: original track name` log in §6l) — full unpack, walked the top-level `04000000` actors `DataIdsMap` (11 entries: `checkpoints_timetrial_event_2`, `end_of_track`, `environment_sound`, `event_02_finish`, `finish`, `foothills_export_group`, `roadblock_level_02`, `root`, `skydome`, `start`, `track_info`), then the checkpoint container's own `children` map:
- **Container name: `checkpoints_timetrial_event_2`** — note the `_event_2` suffix, tying it to a *specific numbered event instance*, not a generic per-track asset.
- **6 checkpoints**: `timetrialcheckpoint`, `timetrialcheckpoint_2``timetrialcheckpoint_6`.
**`region3_colorado_track2.scene.sb`** (our override target) — via the `CDAT_Strings` table (all unique strings referenced anywhere in the file, present even with `-disableDATAObjectsUnpack`):
- **Container name: `timetrial_checkpoints`** — generic, no event-number suffix, structurally different name than the foothills track's container.
- **8 checkpoints**: `timetrialcheckpoint`, `timetrialcheckpoint_2``timetrialcheckpoint_8`.
Cross-checked against `region3_colorado_track1.scene.sb` (full parse succeeded): same generic container name (`timetrial_checkpoints`, no event suffix) and a *different* checkpoint count again (7) — confirming this naming convention (generic container, no event-number tie-in) is consistent across the colorado region, and that per-track checkpoint counts vary freely, not fixed at 6.
**This is the confirmed root cause of the §6k SIGSEGV**: `RaceDefinition` (or whatever populates the checkpoint-lookup path reached via `RaceLoaderTask_LoadRaceFSM`'s generic `point_to_point_fsm_newintro` FSM) looks for a checkpoint container tied to the **original** event — by name (`checkpoints_timetrial_event_2`) and/or by an expected count of **6** — inside whatever scene is currently loaded. Our hook substitutes the scene with `region3_colorado_track2`, which has **no actor named `checkpoints_timetrial_event_2`at all** (its container is `timetrial_checkpoints`) and has **8**, not 6, checkpoints. The lookup fails, returns NULL, and a background thread walking the (non-existent) 6th/7th checkpoint or the missing named container dereferences it unchecked — matching the tombstone's `r0=NULL` / `fault addr 0x38` exactly.
**Practical implication for subtask 1 (arbitrary track loading)**: a clean, general "load any track" hook needs to do more than swap the track/environment name strings (§6k's approach). Two viable directions, neither implemented yet:
- **(a) Track-compatible substitution**: only offer tracks whose checkpoint container is named to match what the current event expects (i.e. rename/alias at the hook level: also patch whatever field the FSM uses to look up the container name, pointing it at the substituted track's *actual* container name — here `timetrial_checkpoints` instead of `checkpoints_timetrial_event_2` — this is a bounded, mechanical fix: one more string-field override, same pattern as the track/environment overrides already working).
- **(b) Generic N-checkpoint handling**: confirm whether the FSM's checkpoint walk is truly hardcoded to a specific expected count (would need to also override wherever that count is read from) or dynamically discovers however many checkpoints exist under whatever container name it's given (more likely, given `timetrialcheckpoint_N` numbering is open-ended per track) — if the latter, fixing (a) alone might be sufficient for a full solution.
**Recommended next step**: extend `Hook_BuildTrackScenePath` (or add a second hook nearer `RaceLoaderTask_LoadRaceFSM`, `0x2a9338`) to also override the checkpoint-container-name field with the substituted track's real container name (`timetrial_checkpoints` for any colorado track, but this will differ by region/track — needs to be read from the target `.scene.sb` or hardcoded per supported track). This is the same low-risk "repoint a `{begin,end}` string pair" pattern already proven working for track/environment names in §6j/§6k — just needs the right field offset identified (not yet located; `RaceLoaderTask_LoadRaceFSM`'s decompile in §6k shows the *racefsm name* field at `*(raceDef+12)+48/52`, but not yet which field feeds the checkpoint-container lookup specifically — likely a sibling field on that same nested struct, worth checking first).
## 6n. `region3`/colorado is cut/incomplete content — switched hook target to `region4_chicago_track4`; exposed the *real* bug (missing null check in `RaceLoaderTask_ResetStartingLine`)
Continued the §6m whack-a-mole (patching each newly-discovered null-deref with a defensive ARM-mode entry-hook/trampoline guard — `sub_53A5FC`, `sub_52A9B8`, `sub_52A620`) until it led somewhere conclusive rather than indefinitely: `sub_58E5E8`, a recursive spatial-index/BVH builder, crashing on a NULL array pointer at `a1+152` — traced its allocation (`sub_58E2E8`) back to a per-item count populated by iterating actor bounding-boxes (`sub_58B65C`), which only reaches zero/absent if the substituted scene's geometry never actually loaded.
**Checked directly**: `game_cache/published/models/environments/` contains folders for `chicago`, `desert`, `foothills`, `garage`, `newyork`**no `colorado` folder at all**, even though `region3_colorado_track1/2/3.scene.sb` and a single un-numbered `colorado.prefabs.sb` exist under `prefabs/`. Every other region ships **numbered per-track** environment prefabs (`foothills1..6`, `desert1..6`, `chicago1..6`, `newyork1..6`); region3/colorado has only the one combined file, no `colorado1..6`. Unpacked `region3_colorado_track2.scene.sb` and confirmed it references `"published/models/environments/colorado/region3_colorado_track2.m3g"` as a loose external file — which was never shipped for the mobile release. **Conclusion: region3/colorado is unfinished/cut content** — its track-layout and prop data survived in the package, but its environment model was never exported as a loadable asset. No amount of downstream null-guarding can fix this; the geometry genuinely cannot load.
**Action taken**: reverted all 4 defensive null-guard patches (`sub_53A5FC`/`sub_52A9B8`/`sub_52A620`, the `sub_58E5E8` chain was never patched, investigation stopped there) and switched `Hook_BuildTrackScenePath`'s override target to `region4_chicago_track4` (confirmed shipped and playable — ties to `event_01_race.prefabs.sb`'s real `TrackName`, verified `start`/`finish` actors, per §6). First rebuild used `kOverrideEnvName = "chicago"` — wrong: hit `Could not open database at published/prefabs/environments/chicago.prefabs.sb` (immediate SIGSEGV), because — like all other regions — environment prefabs are the numbered per-track files, not a generic `<region>.prefabs.sb` (that pattern is unique to unfinished colorado). Fixed to `"chicago4"` (matching `region4_chicago_track4`) — geometry then loaded cleanly (no more "not found"/"could not open" warnings in logcat).
With real geometry loading, hit a **new, different, and much more informative** crash: `fault addr 0x14`, `r0=r1=r2=r3=0`, inside `sub_870E8` — decompiled cleanly as a textbook ECS `GetComponent<im::app::race::Checkpoint>(entity)` helper (iterate a `Component*` range on `entity`, `dynamic_cast` each to `Checkpoint`, return the first match). Crash is the very first field read (`entity[5]`, i.e. `entity+20 = 0x14`, matching the fault address exactly) — `entity` itself is NULL. Its only 3 callers are all inside the *already-named* `RaceLoaderTask_ResetStartingLine` (`0x2abe08`, named in an earlier session from its `"m_StartLine"` string reference) — which looks up an actor by name via `sub_672D64(&result, scene, raceDef+164/180/196/212)` for `m_StartLine`/`m_FinishLine`/`m_EndOfTrack`/a 4th field, then calls `GetComponent<Checkpoint>` on the result **with no null check**, unlike every other access in that same function (which all consistently assert "Dereferencing a NULL component pointer" first). This is a genuine, pre-existing engine bug that the original developers never had reason to hit, because every shipped event's named actors always existed in its own paired scene.
Added temporary diagnostics dumping `raceDef+164/180/196/212` as `{begin,end}` string pairs (same layout as the track/env name fields) and reproduced live on "Петерсон стрит" (still targeting `region4_chicago_track4`/`chicago4`):
```
raceDef+164 name = 'start'
raceDef+180 name = 'event_02_finish'
raceDef+196 name = 'end_of_track'
raceDef+212 name = 'checkpoints_timetrial_event_2'
```
**This is the true, final root cause, and it fully subsumes §6m's checkpoint-count theory**: `m_StartLine`/`m_EndOfTrack` are generic names, present in effectively any track's scene — those lookups succeed regardless of substitution. But `m_FinishLine` and the checkpoint-container field hold **per-event custom actor names** (`event_02_finish`, `checkpoints_timetrial_event_2` — the literal event-2/timetrial identifiers, tied to the *original* "Петерсон стрит" event's own data, not to any track/scene convention). No substituted scene will ever have an actor named `event_02_finish` unless it happens to be the exact original track. This is why §6k/§6m's whack-a-mole never converged: the underlying issue isn't a fixable data mismatch, it's that **time-trial/checkpoint-style events are inherently incompatible with simple track-name substitution** — their `RaceDefinition` hard-references scene-specific actor names that only the original track satisfies.
**Confirmed by direct test**: switched to a different event of the *regular point-to-point race* type — "Побудка" (Macklein street, class "Каждый день", original track `region5_newyork_track2`). Diagnostics on this event show only generic names:
```
raceDef+164 name = 'start'
raceDef+180 name = 'finish'
raceDef+196 name = 'end_of_track'
raceDef+212 name = '' (empty)
```
No custom suffixes anywhere. Rebuilt/reinstalled with the reverted (no defensive patches) build, played this event with `region4_chicago_track4`/`chicago4` substituted in — **the race loaded and ran successfully**: live gameplay on chicago4's geometry, correct HUD (position 6/6, timer), AI opponents present, no crash, sustained over multiple seconds. **The `BuildTrackScenePath` hook mechanism is fully validated end-to-end for regular races.**
**Practical implication for subtask 1**: the simple track/environment-name override (as implemented) is sufficient and correct for **regular point-to-point races**. Time-trial/checkpoint events need one of: (a) also overriding `raceDef+180`/`+212` to generic names when the target scene doesn't have the original's custom-named actors (risks silently changing the event's intended finish-line/checkpoint layout), or (b) restricting arbitrary-track substitution to regular-race event types only and leaving time-trial events pinned to their original track. Not decided/implemented yet — a scope decision, not a bug to patch.
## 6o. Scope decided (regular races only); cleaned up to the minimal working hook; visually confirmed with a baseline A/B comparison
Following §6n's finding that time-trial/checkpoint events are structurally incompatible with simple track-name substitution (their `RaceDefinition` hard-references per-event custom actor names), the user made the scope call directly: **the track-substitution hook only needs to support regular point-to-point races.** Time-trial and single-opponent/pursuit-style races are explicitly out of scope — their crashes are expected-unsupported, not bugs to chase. Recorded as a standing project-memory fact (`track-substitution-scope`) so future sessions don't re-litigate or re-chase this.
**Cleanup**: removed all temporary investigation code from `Hook_BuildTrackScenePath` in `launcher/mpcore/src/main/cpp/main.cpp` — the `raceDef+40/44`, `+12` nested-struct, and `+164/180/196/212` diagnostic `Log()` dumps (their job was done once §6n's findings were captured). The 4 defensive null-guard patches from the §6n whack-a-mole (`sub_53A5FC`/`sub_52A9B8`/`sub_52A620`, plus the abandoned `sub_58E5E8` investigation) were already reverted in §6n itself. **Current `main.cpp` state**: `JNI_OnLoad` installs exactly one hook, `Hook_BuildTrackScenePath`, which does nothing but repoint the track-name and environment-name `{begin,end}` string pairs on `raceDef` (`+72/76` and `+100/104`) to `kOverrideTrackName = "region4_chicago_track4"` / `kOverrideEnvName = "chicago4"`. No diagnostics, no defensive guards, no dead code.
**Visual A/B verification** (requested by the user — logcat text isn't proof the geometry itself changed, only that the string pointers were overwritten): built and ran two variants of the identical event ("Побудка", a regular race at Macklein):
- **Hook disabled** (temporarily commented out the `InstallBuildTrackScenePathHook()` call in `JNI_OnLoad`, rebuilt, reinstalled): loads the real, original `region5_newyork_track2` — a nighttime downtown street, a "HOTEL" building, road signs reading "Franklin Plaza / Rochelle Hall" and "Emerson Greenway", a distinct guardrail/road style.
- **Hook enabled** (re-reverted the comment-out, rebuilt, reinstalled): loads `region4_chicago_track4` — a daytime highway/overpass, road signs reading "South 92", "McClane", "Ripley's Point", visibly different geometry, lighting, and time-of-day.
Different time-of-day, different road geometry, different signage, different buildings — conclusive, non-coincidental visual proof the same event now genuinely renders different track geometry depending solely on whether the hook is installed. **Subtask 1 (arbitrary track loading) is now considered validated end-to-end for regular races** — both mechanically (hook installs, fires, no crash, sustained multi-minute gameplay) and visually (real geometry swap, not just a label change).
## 6p. Street/POI event lists are NOT simple SB data; found the engine's file-open chokepoint instead (validates a memory-only patching strategy)
Two separate investigations, prompted by the user's question about adding a virtual "LAN: <lobby>" entry to an existing street's event list, and the follow-up theory that SB files could be edited entirely in-memory from `mpcore` without touching `game_cache`/the OBB.
**1. Where street/POI groupings come from — not found in data yet.** Unpacked all 62 `game_cache/published/data/races/event_*.prefabs.sb` files (`NFSMW12MobileTools`, full struct parse, no workaround flags needed — small files, none hit the `NegativeArraySizeException` bug). Each contains one `RaceEvent` struct with a `Location` field — but across all 62 events, `Location` only takes **4 values**: `Chicago`, `Foothills`, `NewYork`, `Desert` (the coarse region, matching the environment). It is **not** the street-level label shown on the map (`МАККЛЕЙН`/`ПЕТЕРСОН СТРИТ`). `Name` is a localization key (`EVENT_NAME_2`, etc.), not a literal string either. Also unpacked `flow/menus/map_overworld.sb` (the map screen's Flow-machine script) — it contains only generic UI screen/transition wiring (`output`/`node`/`Transition` triples for buttons like `EVENT`, `GARAGE`, `STORE`) and a `layout: map_overworld` reference; no event-ID lists, no street names, no coordinates. **Conclusion: the street/POI clustering that groups nearby events under a named map pin is not stored as simple, easily-editable SB data anywhere checked so far** — it's most likely computed natively in C++ at runtime (e.g. proximity-clustering each `RaceEvent`'s track start-position against a small set of named zone boundaries), which would need further binary RE (not attempted yet) to locate precisely, not just an SB edit. This directly answers the original "street event" question: adding a synthetic entry isn't a quick data-only change; it needs a native hook once the clustering code is found.
**2. The memory-only-patching theory — confirmed correct, and the hook point is now identified.** Traced how `libapp.so` actually opens files on disk, starting from the `RaceEvent`-parsing code's generic string-keyed property accessors (`sub_4F9A80` etc., confirming SB files are parsed into an in-memory key-value property bag once, then read by name everywhere — not re-parsed per access) down to the real I/O layer:
```
(resource-loading code, not yet fully enumerated)
sub_8604A0(a1, path, a3) "load whole file into buffer" helper
│ calls sub_8598A4(path, "rb")
sub_8598A4(path, mode) -> FILE* only 4 callers total in the whole binary
│ calls j_fopen(path, mode)
j_fopen (0x89d350) thin thunk to libc fopen(), only 4 callers
libc fopen()
```
`sub_8598A4` (`0x8598a4`) is the practical hook point: a small, direct `(const char* path, const char* mode) -> FILE*` function with exactly 4 call sites in the entire binary (`sub_8604A0` "read whole file" helper, plus 3 others not yet inspected: `sub_8BBD94`, `sub_8D6D9C`, `sub_8FC64C`). This is far more general-purpose than the track/environment-name hook — it's the choke point for **every file the engine opens by path**, not just track scenes.
**What this enables**: hook `sub_8598A4`, check the incoming `path` against a list of virtual filenames we care about (e.g. `"published/data/races/event_02_timetrial.prefabs.sb"`, or an entirely new, game-never-shipped filename), and if it matches, return a `FILE*` from `fmemopen()`/`open_memstream()` backed by a buffer **we** control (a hand-edited copy of the original SB bytes, or a fully synthetic one) instead of calling through to the real `fopen`. For every other path, call through unmodified (pass-through, matching this project's "no-op when not relevant" hook principle). This achieves genuinely disk/cache-untouched patching — no `native_lib`/`game_cache` file is ever modified, the substitution happens purely in `mpcore`'s memory at load time, and it works for **any** SB file the engine reads (race definitions, flow scripts, checkpoints, etc.), not just the track path fields the current `BuildTrackScenePath` hook touches.
**Not yet done**: actually implementing/testing this hook (would need a `mmap`/trampoline install like the existing `BuildTrackScenePath` hook, since `sub_8598A4` is a plain ARM function — first-instruction check not yet done), confirming the mode-string comparison approach works for text vs. binary opens, and inspecting the 3 other callers (`sub_8BBD94`/`sub_8D6D9C`/`sub_8FC64C`) to rule out anything env-specific. This is a substantial, foundational new capability (general asset override, not just track substitution) — worth a deliberate go/no-go and its own test cycle before implementing, rather than folding into the existing hook.
## 6q. `sub_8598A4` hook implemented and tested — mechanism proven, but it's the wrong chokepoint for game assets (corrects §6p)
Implemented the hook proposed in §6p as a proof of concept: trampoline-hooked `sub_8598A4` (`libapp_base + 0x8598a4`, same PUSH/SUB-relocation pattern as `BuildTrackScenePath`), logging every `(path, mode)` pair and substituting an `fmemopen()`-backed buffer for any path containing `"event_02_timetrial"`. The substitute payload: `event_02_timetrial.prefabs.sb` unpacked via `NFSMW12MobileTools`, `CashReward.Gold/Silver/Bronze` edited `10500/8500/7000``99999/88888/77777`, repacked to a valid `.sb`, embedded as a C byte array (`mpcore/src/main/cpp/test_event_02_data.h`). Gated behind its own toggle flag (`kEnableFileOpenHook`, same pattern as §6o's `kEnableTrackSubstitutionHook`), on a new branch (`file-open-hook-poc`, based on `track-hook-toggle-flag``master` in this repo is a stale, unrelated baseline predating all of this work, confirmed this session, not touched).
**Mechanism confirmed working**: the hook installs cleanly and genuinely intercepts real engine `fopen()` calls — logcat shows `sub_8598A4 hook: fopen('/home/ogami/output-arm/openssl.cnf', 'rb')` firing during app startup (a build-machine-path leak from EA's OpenSSL config, harmless but proves real interception).
**Wrong function for game assets**: navigated through several screens that definitely read `.sb` data (map, event list, the `event_02_timetrial`-backed "Петерсон стрит" → "На время" event card, which still showed the original `10 500$/8 500$/7 000$` unchanged) — **`sub_8598A4` was never called again after the one OpenSSL open at startup.** Checked the other 2 named callers of the shared `j_fopen` thunk that §6p's trace was built on: `sub_859528` is a generic stream-command dispatcher (seek/tell/flush/close opcodes, not a general per-asset opener) and `sub_8AC1F4` is a **file-hashing utility** (reads in 1KB chunks through an accumulator function, `sub_8AC10C` — looks like a checksum/integrity-check pass over a whole file, not the resource loader). None of `j_fopen`'s 4 callers are the actual `published/data/races/*.sb`-reading code path.
**Revised theory**: individual game asset files are most likely **not opened via `fopen()`/`j_fopen()` at all**. The likelier design (common for mobile games, avoids per-asset syscall overhead): the whole OBB/asset bundle is opened/mapped **once** at startup (candidate: the `sub_3FB6E4`/"Mounting SKU" registration path from §6c's exploration, or a raw `open()`+`mmap()` pair — `open` does have 11 callers in this binary, not yet checked), and individual "files" like `event_02_timetrial.prefabs.sb` are served by looking up a name in an in-memory index/directory and returning a `pointer+length` slice **into that already-mapped memory** — no repeated per-file `fopen`. If true, this is actually a *better* hook target than a fake `FILE*`: intercepting after the name→pointer lookup would let a substitute just be a raw buffer swap, no `fmemopen` needed.
**Status**: hook mechanism (trampoline install, path matching, in-memory substitute payload, toggle flag) is proven and reusable — only the *target function* was wrong. Not reverted (harmless with `kEnableFileOpenHook` currently pointed at the wrong function, effectively a no-op for game data since `sub_8598A4` is never called with a matching path) — left in place on the `file-open-hook-poc` branch as a ready-to-repoint scaffold. **Not yet done**: trace the `sub_3FB6E4` mount-table path, or the 11 `open()` call sites, to find the real name→data resolution function.
## 6r. Found `VFS::OpenInputStream` (the real universal file-resolver) — then pivoted per user direction: runtime object injection, not file substitution
**Continuation of §6q's search for the real path→data resolver.** Traced up from `sub_208C88` (`RaceEvent`-loading function, confirmed via its `"/published/data/races/"` string reference and by producing the same `raceDef` struct the `BuildTrackScenePath`/`ResetStartingLine` hooks already use) through `sub_6753FC``sub_4F0138`, which calls a **virtual method at vtable offset+8** on a lazily-constructed singleton (`sub_40E8E8()`, whose class vtable is `off_AB2084`, explicitly confirmed as the engine's `VFS` class via the literal string `"VFS::AddVariant("` in its constructor `sub_40E9F4`). Read the vtable bytes directly (`get_bytes` at `0xAB2084`) and resolved slot+8 to **`sub_410808`**, which decompiles to exactly `VFS::OpenInputStream` (confirmed via its own literal strings: `"openInputStream \""`, `" .. returning variant \""`, `"\" mapping to fs path \""`) — and, decisively, `sub_4F0138` prints the exact literal `"Could not open database at "` when this returns null, **the identical error text observed live in §6n** when the `chicago` vs `chicago4` environment-name mistake was made. This conclusively identifies `sub_410808` as the true, universal, per-path file resolver used for every `published/...` asset in the game (not `fopen`-based at all - §6q's `sub_8598A4` hook was chasing a red herring; individual SB files are resolved via this VFS virtual-path→real-fs-path mapping, then handed to a per-backend "open real file" call, not raw libc `fopen`). First 2 instructions (`PUSH {R4-R11,LR}`; `ADD R11,SP,#0x1C`) are the same hookable, position-independent shape as every other hook this session.
**Not pursued further as a hook target**, per explicit user redirection: `sub_410808` returns a refcounted C++ stream *interface* object (`AddRef`/`Release`-style calls visible on it in `sub_4F0138`), not a raw buffer/`FILE*` - faking one correctly would need reverse-engineering its exact vtable contract, a nontrivial and crash-prone undertaking. More importantly, the user clarified the actual goal isn't "swap file content before the game reads it" at all - it's **runtime injection into already-loaded/parsed game objects** (e.g. the map loads normally, then a hook adds a synthetic "LAN: <lobby>" entry into an already-populated street's event list) - the same category of technique already proven working for `RaceDefinition` (`raceDef+72/76` field repointing in `BuildTrackScenePath`), just applied to a different, later point in the pipeline. `sub_410808`'s file-open-time hook doesn't serve that goal even if fully implemented.
**New lead for the actual goal**: searching RTTI for the map/street screen surfaced **`im::app::flow::nfs::MapScreen`** (the map screen's controller class - sibling of the already-known `im::app::flow::nfs::PostRaceMetagameScreen`/`GarageScreen`/`CarUnlockScreen`/`CongratsScreen` family) and, in one of its method signatures, **`boost::shared_ptr<im::app::ui::MapTrack>`** - strongly suggesting `MapTrack` is the per-event/per-marker runtime object the map screen holds one of per visible event. **Not yet done**: find where `MapScreen` builds/holds its `MapTrack` collection (constructor or an `UpdateXxx`/`Populate`-style method), and `MapTrack`'s field layout (display name, reward, target event-ID/track reference) - once both are known, the established "read/repoint fields on an already-loaded C++ object" technique (same as every hook this session) can inject a synthetic entry after the real collection is populated, exactly matching the user's actual request. Checked `career.prefabs.sb` (the one non-numbered file under `data/careers/`) as a possible data-driven source for street groupings first - it only holds progression tiers (`tier_1`..`tier_55`-style `TierItem`s) and unlockable car lists, **not** street/location data, ruling it out.
## 6s. Runtime injection into a street's event list — found the real classes and the setup function, not yet the exact "add card" call
Continuation of §6p, redirected by explicit user feedback: the user does **not** want a disk-touching approach (writing a substitute file to app-private storage, or faking a VFS stream) — they want the street's event list modified **in memory, at runtime, after the map has already loaded**, the same way a real multiplayer client would announce a discovered LAN lobby as an extra, synthetic event card under an existing street pin (e.g. `МАККЛЕЙН``LAN: <lobby name>`), without touching `game_cache`/the OBB/`native_lib` at all. This reframes the goal from "override what a file contains" to "hook the C++ code that turns loaded `RaceEvent`s into UI, and inject one extra fake entry into its output."
**Confirmed classes (RTTI-verified)**:
- `im::app::flow::nfs::MapScreen` — the world-map screen controller. Its main setup function is `sub_1781BC` (`0x1781bc`, **~8.7KB**, by far the largest function found in this project so far) — too large to fully decompile through the MCP tool in one call (response gets truncated); explored via targeted disassembly windows instead.
- `im::app::ui::MapTrack` — one visual pin/marker widget on the map (one instance per street shown, e.g. "МАККЛЕЙН", "ПЕТЕРСОН СТРИТ"). Has a `TrackId`/`TrackName`-keyed property read from the layout (confirmed via the literal error string `"Couldn't find TrackId property on MapTrack widget "` and a live `"TrackName"` property-name string used inside `sub_1781BC`).
- `im::app::ui::MapTrackEventList` — the list-of-event-cards widget. Looked up by a **fixed, singular widget name: `"event_list"`** (not one list per street — confirmed via `sub_17A4CC`, a generic `FindOrCreateLayoutEntity<T>(scene, name)` helper reused for several named widgets, called with the literal string `"event_list"` at `0x178308`-`0x178318`). A sibling `"map_scroll"` widget is looked up the same way immediately after. This means the event list is **one shared widget that gets repopulated each time the player selects a different street pin**, not N separate always-populated lists — matches the observed UI flow (map → tap pin → event cards appear).
- `sub_1781BC` contains exactly 2 `dynamic_cast<Node → MapTrack>` calls (`0x179034`, `0x179af4`), each inside a loop over the scene's child nodes filtering for `MapTrack` instances — i.e. two separate passes over "every pin on the map" (likely: one pass to wire up click handlers via the `boost::bind`-based `MapScreen::OnTrackClicked(shared_ptr<MapTrack> const&)` binding also found in this function's `.data.rel.ro` references, and a second pass for something else not yet identified - badge/lock-state refresh is a plausible guess, not confirmed).
**Not yet found**: the specific call, somewhere in the ~7KB of `sub_1781BC` not yet inspected, that iterates loaded `RaceEvent`s, matches each one's `TrackName` field against a `MapTrack` pin's `TrackId`, and adds a corresponding child card into the `"event_list"` widget. This is the actual hook point needed - either to call the *same* function ourselves with a synthetic/fake `RaceEvent`-shaped argument (reusing the engine's own card-construction logic, much lower-risk than hand-building a UI node), or to hook whatever lower-level "add child to `MapTrackEventList`" method it ultimately calls.
**Why static disassembly stalled here**: `sub_1781BC` is too large to decompile through the MCP tool in one shot (Hex-Rays output gets cut off around 62K characters before reaching the interesting part), and windowed raw-disassembly reading (as used successfully for smaller functions all session) doesn't scale well to a function this size - each 50-instruction window only covers a tiny fraction, and there's no shortcut like a distinctive string to `search_text` for near the exact call (tried `"TrackName"`/`"TrackId"`/`RaceEvent`/`dynamic_cast` - all found *something* relevant but not the precise add-card call yet).
**Recommended next step (not yet attempted at the time this section was first written)**: given the static-analysis approach is hitting diminishing returns on this specific function, live debugging on-device would likely be faster here. See §6t for the actual attempt.
## 6t. Live debugging: attach/registers/disassembly work via a cross-arch `lldb-server`, but breakpoints do not — every "negative result" below this point is unverified, not evidence
**What's genuinely confirmed**: the ARM32 `lldb-server` failures from earlier the same day (both `lldb` and Frida blocked, see `PROGRESS.md`) traced to a real bug in this NDK's **ARM32** `lldb-server` build - a control experiment (attaching it to a harmless system process, `systemui`) reproduced the identical `GetMaxU64 invalid byte_size!` assertion and unresolvable-PC symptom seen against the game, proving it's generic to that binary, not anti-debug in the game. The **AArch64** `lldb-server` (same NDK) attached cleanly to that same process, and - since Linux/Android's ptrace lets a 64-bit tracer debug a 32-bit compat-mode tracee - **also attaches cleanly to the 32-bit game process itself**: correct PC, correct ARM32 disassembly, correct thread names, no assertion. This part is solid and reusable: for passive inspection (attach, read registers/memory, disassemble at whatever point the process happens to be stopped), use the AArch64 `lldb-server` against this device regardless of the target's own bitness.
**What is NOT confirmed, and was wrongly reported as confirmed in an earlier version of this section**: that setup was used to breakpoint several functions (`sub_1781BC`'s post-widget-lookup point, `sub_17A4CC`, `sub_7D2E8`, `sub_208C88`, `sub_7CE58`) across multiple live sessions, tap a street pin each time, and observe zero hits - which was written up as a real negative result ("the click handler doesn't call X"). **This was premature.** Prompted by the user directly questioning the evidence, a sanity check was run: breakpointing `j_malloc_0` (a function guaranteed to be called continuously - dozens of times per second at minimum) through the exact same setup. **It also never fired, waiting 15 full seconds during active gameplay.** A hardware breakpoint was tried as a fallback and failed outright with an explicit error: `failed to set breakpoint site ... hardware breakpoint resources might be exhausted or unavailable`. Conclusion: **this cross-arch configuration cannot actually insert working breakpoints (software or hardware) at all** - `lldb` prints a success-looking `Breakpoint N: address = 0x...` message regardless, which is only confirming the *address resolves*, not that a trap was successfully placed and will fire. The passive capabilities (attach, register read, disassembly at an already-stopped point) are real; active tracing (breakpoints, and by extension anything depending on them) is not currently usable with this tool/device combination.
**Consequence**: every "X was never called" claim from the breakpoint experiments this session (previously written up as §6t/§6u findings about `sub_1781BC`, `sub_17A4CC`, and `sub_7D2E8`'s callers being load-time-only) is **retracted as unverified** - not necessarily wrong, just not actually tested. The `RaceEvent`-loaded-once-at-startup theory and the "always exactly 3 card slots" UI observation both still stand on their own (the former from static call-site counting in §6s, the latter from direct visual observation of screenshots), but the *live-debugging support* claimed for them does not hold.
**Follow-up: tried a genuine same-architecture `gdb`/`gdbserver` pair, ruled that out too.** Installed `gdb-multiarch` on the host (extracted from the `.deb` without root, via `apt-get download` + `dpkg-deb -x` - no `sudo` available in this environment). It could not talk to the AArch64 `lldb-server` at all (`Invalid hex digit 59` parsing register replies - a genuine wire-protocol incompatibility between LLDB's and GDB's remote-serial-protocol dialects, independent of the cross-arch issue above). Obtained a **real, native ARM32 `gdbserver`** (extracted only the one binary from the legacy `android-ndk-r16b` zip - Google's NDK dropped `gdbserver` after r17 - via a full 852MB download, since partial/range-request extraction against `dl.google.com` failed with SSL/range errors in this environment). Native-architecture attach worked meaningfully better than either `lldb` path: `interrupt` correctly stopped the process and produced a **real, correct backtrace** (`syscall() ← libart.so`, matching genuine ARM32 register/stack state) - clearly better than anything the cross-arch `lldb-server` could do. A software breakpoint was accepted (`Breakpoint 1 at 0xc389bbfc`, on `j_malloc_0`), but issuing `continue` **crashed `gdbserver` itself** (the game process survived unharmed; `gdbserver`'s process simply disappeared, and the GDB client reported `Remote connection closed`) - no breakpoint hit was ever observed. Most likely cause: a ~7-year version skew between this 2017-era `gdbserver` (bundled with a GDB 7.x/8.x-generation NDK) and the 2024 `gdb-multiarch` 15.1 client - `qSupported` handshake warnings (`unrecognized item "timeout"`, `Ignoring packet error`) were visible in earlier connection attempts, confirming real protocol drift between the two ends even where the connection nominally succeeded.
**Overall conclusion across all three attempts (cross-arch `lldb-server`, cross-arch `lldb-server` vs. `gdb-multiarch`, native `gdbserver` vs. `gdb-multiarch`)**: passive inspection (attach, interrupt, read registers/memory/backtrace at whatever point the process happens to be stopped) is achievable and was proven working more than once; **actively resuming execution with a breakpoint installed has not been achieved by any combination tried in this environment.**
**Root cause identified (user research, not further tooling changes needed to explain it)**: the Fairphone 5's SoC has **no native AArch32 (32-bit ARM) hardware support at all** — like an increasing number of recent Qualcomm chips, it runs 32-bit code (this project's `armeabi-v7a`-only `libapp.so` included) through a software translation/compat layer, conceptually similar to Intel's Houdini layer on x86 Chromebooks, rather than real 32-bit silicon. This retroactively explains *every* symptom hit this session in one stroke: there are no genuine AArch32 hardware debug registers underneath for a hardware breakpoint to program (matches the explicit "hardware breakpoint resources might be exhausted or unavailable" error); a software breakpoint's `PTRACE_POKETEXT` patches bytes in what may not be the actual code stream the translator is executing, so it can install without error yet never trigger; and register-context edge cases (the ARM32 `lldb-server`'s VFP/NEON assertion) are unsurprising against a translated/emulated register file. Passive operations (attach, `interrupt`, reading whatever the OS-level compat layer reports as current register/stack state) still worked because those go through the kernel's own ptrace compat translation, which is solid — only the "patch code and expect it to run" class of operation is affected.
**Practical implication**: this isn't a tooling bug to keep working around on this device - live breakpoint-based debugging needs a device whose SoC still has genuine AArch32 hardware. [[reference-pixel6a-grapheneos-testing]] (already available, already proven to run this project's `armeabi-v7a` libs with "no Houdini/binary-translation layer" per its own memory entry) is the most immediately-available candidate to retry this on. The user also plans to bring a Pixel 5a or a 2018 Galaxy A9 specifically for this. Until confirmed working on genuine 32-bit-capable hardware, treat this project's debugging capability as **read-only** (attach-and-inspect-current-state only) and rely on static IDA analysis for anything requiring "does function X get called when Y happens."
## 6u. Static analysis (post-debugging-pivot): decoded the actual click hit-test and per-slot lock refresh inside `sub_1781BC` — real evidence, no debugger needed
Per the user's direction after §6t ("continue with statics for now"), went back into `sub_1781BC` with `func_profile`/`disasm` windows instead of full decompile (which truncates around 62K characters for this function). Note: the plain `callees` tool returned an empty list for this function for unclear reasons (possibly a size-related edge case) — `func_profile` with `include_lists: true` worked correctly and returned all 46 real callees, so prefer that tool for large functions going forward.
**§6s's "exactly 2" call-site count stands, re-confirmed**: `xrefs_to` on the `MapTrack` RTTI typeinfo address (`0xac6434`) surfaced a third reference at `~0x17a02c` inside `sub_1781BC`, but checking it directly showed it's just a reused literal-pool constant (ARM32 `LDR =literal` pool word), not a distinct `dynamic_cast` call — that region (`0x179f54-0x17a118`) is unrelated first-run onboarding logic (checks "have I shown this before" flags and pushes `"TUTORIAL_MAP"`/`"CONGRATULATIONS_POPUP"` messages onto a queue at `MapScreen+0x230`). The two real call sites remain `~0x179034` and `~0x179af4` (analyzed below).
**Region A (`0x178afc`-`0x178e40`): a straight-line "populate selected-event summary" block, not a per-card loop.** Reads five named properties — `TrackName`, `Completion`, `EventName`, `class_restriction`, `event_type` — via the generic property-getter `sub_15F2DC` (four of them) and `sub_15E934` (for `event_type`), each call reading from a single object at `[MapScreen+0x120]` and caching the `{ptr,len}`-style result pair into fixed `MapScreen` fields (`0x1F8/0x1FC`, `0x200/0x204`, `0x208/0x20C`, `0x210/0x214`, `0x218/0x21C` respectively), with the old cached value released (refcounted `Release`-style vtable call) before each overwrite. This reads from one already-resolved object, once, straight-line — it's a details/header panel populated from "whatever is currently selected," not a loop building N cards.
**Region B (`0x179a2c`-`0x179cfc`): the actual touch/click hit-test.** Confirms (now via static evidence, not the retracted live-debugging claims from §6t) that clicking a street pin does **not** reload or re-extract any `RaceEvent` — it's a pure nearest-neighbor search over already-resident objects:
- Gets the current touch/click point from a global singleton (`sub_890EC()+0x9C/0xA0`), stored into `MapScreen+0x13C/0x140`.
- Iterates `MapScreen+0x1C8`'s child array (begin=`+0x5C`, end=`+0x60` — this is the `map_scroll` container's children), `dynamic_cast<MapTrack>` on each (typeinfo `_ZTIN2im3app2ui8MapTrackE` at `0xac6434`, confirmed to only be referenced from 3 places project-wide, see below).
- For each successfully-cast `MapTrack*`, gets its position via `sub_369EEC` and computes a blended squared-distance to the touch point (0.5 mix factor against the pin's extents, via VFP), tracking the minimum seen so far.
- **For the current closest match, copies two fields directly off the `MapTrack` object itself — offsets `+0xB8` and `+0xBC` — into `MapScreen+0x1C0`/`+0x1C4`.** There is no VFS call, no prefab load call, no `sub_7D2E8`/`sub_208C88` call anywhere in this path. This is decisive, statically-verified confirmation that each `MapTrack` pin already carries a pointer (almost certainly to its associated `RaceEvent`-derived data, or a thin wrapper around it) set once when the street/map loads, and a click is nothing more than "find nearest pin, copy its pre-existing pointer into the selection slot."
**Region C (`0x179d30`-`0x179dfc`): per-card-slot "locked" refresh — real evidence for the "fixed slot pool" theory.** Walks a linked list rooted at `MapScreen+0x18C` (sentinel = a stack local holding the list's `end()`); for each list node (one per card slot), if not already resolved (`node+0x10 == 0`), reads the `"locked"` property via `sub_406644` on the slot's associated object (`node+8`), then calls a vtable setter (`vtable+0x5C`) on that object with the locked value, followed by a refresh/invalidate call (`sub_4D364C`) on a sub-object at `node+8 → +0xC0`. This is a fixed-size (or at least pre-existing, non-dynamically-added) collection of slot entities whose lock state gets pushed per-refresh — consistent with, and now backed by real disassembly for, the "always exactly 3 card slots" UI observation from earlier sessions (still not confirmed as *exactly* 3 by count, but the mechanism — toggle pre-existing slots' locked flag rather than add/remove children — is now confirmed).
**Searched the whole binary for other `MapTrack`-related functions**: only 3 functions anywhere reference the `MapTrack` RTTI typeinfo (`0xac6434`) — `sub_1781BC` (this one), `sub_17C120`, and `sub_17FC6C`. Both of the other two are now fully decompiled and characterized:
- `sub_17FC6C` (~1.2KB) — a "scroll/pan the map camera to a named track" helper: resolves a target track name from a small candidate list, finds the matching `MapTrack` child, computes a tween/scroll command into `MapScreen+308..+376`. Almost certainly "auto-scroll to next unlocked event."
- `sub_17C120` (~4.4KB) — `MapScreen`'s **per-frame update/tick handler**, not a one-shot setup function: dispatches touch-down events by `dynamic_cast`-testing the tapped node against `UIButton` (e.g. the `"BLACKMARKET"` button) then `MapTrack`, caching the hit into `MapScreen+0x1A0/0x1A4`; drives an "unlock reveal" animation state machine (`MapScreen+0x134` state values incl. `3`/`1000`) that reads an `"unlocking"` property and calls a setter (`vtable+92`) on the newly-unlocked pin's icon object when a `"NEXT_EVENT"`-flagged pending-unlock list becomes empty; clamps camera scroll bounds; and periodically (every 4s of playtime) fires a QA-only "Soak Test" feature that auto-picks a random `"RACE"` track. None of this writes `MapTrack+0xB8`/`+0xBC` either — it only reads/reacts to already-attached pin state.
**Still not found**: the function that *writes* `MapTrack+0xB8`/`+0xBC` in the first place — i.e., where a `RaceEvent`'s data actually gets attached to a pin when a street/map scene loads. All 3 functions that `dynamic_cast` to `MapTrack` in the whole binary are now accounted for and none of them do this write, so the real population site must construct/populate `MapTrack` instances without ever needing to `dynamic_cast` to their own type (e.g. it already has a statically-typed `MapTrack*` from a factory/constructor call, no RTTI check needed) — the RTTI-xref search approach is exhausted. This is the real target for the user's "inject a synthetic LAN lobby event under an existing street" goal — either construct a fake pointer at that offset from a synthetic `RaceEvent`-shaped struct and write it directly into an already-loaded (but currently `locked`) `MapTrack`'s `+0xB8` field at runtime, or find and re-enter the same population function with synthetic data so the engine's own logic builds it correctly (much lower-risk, matches the general approach already favored in §6s). **Next static-analysis approach should pivot away from RTTI-xref searching** (dead end, confirmed) **toward finding `MapTrack`'s constructor directly** — e.g. via its vtable symbol (should sit near the RTTI typeinfo in `.data.rel.ro`, same pattern used successfully for `RaceLoaderTask` in §6a) and following xrefs to *that*.
## 6v. FOUND IT: `MapTrack::AddEvent` — the exact function that attaches a `RaceEvent` to a street pin, and the concrete injection point for the user's "LAN lobby" goal
Continuation of §6u, abandoning the RTTI-xref dead end in favor of the proven §6a technique: locate a class's real vtable via `entity_query` on `names` near its RTTI typeinfo address, then follow xrefs to *that* instead of to the typeinfo. This worked immediately.
**`MapTrack`'s real vtable**: `_ZTVN2im3app2ui8MapTrackE` at `0xaabfdc` (found in the same `.data.rel.ro` neighborhood as the typeinfo, via `entity_query`). Its usable function-pointer table starts at `off_AABFE4` (standard Itanium ABI: vtable symbol points at the offset-to-top slot; the RTTI pointer and actual vtable-proper follow).
**`MapTrack`'s real constructor**: `sub_368860` (found via `xrefs_to` on the vtable symbol). Sets the vtable pointer, then `memset(this+0xB8, 0, 0x23)` — confirms `+0xB8` (and everything through `+0xDA`) starts **zeroed**, i.e. every `MapTrack` pin is created with no event data attached; it must be populated by a separate step. (`sub_3688D4`, the other vtable-referencing function, is the destructor — releases held smart-pointer members then chains to the base class dtor.)
**`MapTrack`'s factory**: `sub_395B38` (the sole caller of the constructor) — a `make_shared<MapTrack>`-style allocator (raw object + a separate refcounted control block). Its sole caller is `sub_38DA44`, a ~27KB function that turned out to be a dead end: it's a generic class-factory **registration bootstrap** (hundreds of `{"ClassName" string, ctor function pointer}` pairs registered into a lookup table for the data-driven layout/prefab deserializer — the same string-keyed pattern seen for `"MostWantedLeaderboard"` and dozens of other unrelated classes). It doesn't populate anything itself; it just tells the generic layout loader how to construct a `MapTrack` when one is named in a `.sb`/layout resource.
**The real chain, found via the `"TrackId"` error string** (`"Couldn't find TrackId property on MapTrack widget "`, only ever emitted from one function):
1. **`sub_369040` = `MapTrack::RefreshEvents()`** (or equivalent). Reads its own `TrackId` layout property (via `sub_3684B4`); for a small internal collection of name/zone strings (`this+0x224..0x228`), looks each one up in a global registry (`sub_242778`) to get a collection of matching `RaceEvent` handles, and calls `sub_369AB0(this, &eventHandle)` for **every** match. It then also drives all of the pin's visual state — `"locked"`/`"available"`/`"new"`/`"blacklist"` property setters, a `"blacklist_icon"` visibility toggle, and (critically) a **completion-percentage badge**: `(this+0x276) / (this+0x272) * 100`, formatted and pushed via `sub_15F2DC(this+0x184, "completion")` — the same generic property-setter used for `EventName`/`TrackName` etc. in `sub_1781BC` (§6u Region A).
2. **`sub_369AB0` = `MapTrack::AddEvent(RaceEventHandle*)`** — has exactly one caller (`sub_369040`'s loop), confirming it's a dedicated, single-purpose method. For the given event handle it: `push_back`s it into a growable vector living directly on the `MapTrack` object (`this+0x240` begin / `+0x244` end / `+0x248` capacity — a real `std::vector`-style growth pattern, confirmed via the `sub_3DE038`-realloc-then-`memmove` sequence when full) — **so a street pin can and does hold *multiple* events, this is not a single-event field**; increments `this+0x272` by 3 (3 medals — Gold/Silver/Bronze — per event) and `this+0x276` by however many medals the player has actually earned for it (via `sub_4F0C1C`/`sub_4F9A80`, reading a `"medal"` field off some save/progress lookup) — feeding directly into `RefreshEvents`'s completion-percentage badge; and updates `this+0x216/0x217/0x218/0x220` lock/availability/"new" flags based on the same save-data lookup.
3. **`sub_368DFC` = `MapTrack::HandleEvent(eventTypeId, im::Event*)`** — `MapTrack`'s override of the engine's generic message-handler virtual method. On event type `1025`, `dynamic_cast`s the event to `im::app::events::FlowSetLayoutScreenEvent` ("the screen this pin lives in just finished its layout pass") and, if it matches, calls `RefreshEvents()` on itself. On event type `1048` (`UIButtonClickEvent`) matching its own embedded button component, it invokes a stored `boost::function` callback (`this+0x256`) — a **second, independent click-handling path** from the raw touch-coordinate hit-test found in `sub_1781BC` §6u Region B (this one is UI-focus/button-event-driven, not distance-based).
**This settles the multi-session architecture question**: population is **bottom-up and per-pin, not top-down**. There is no single "MapScreen iterates all RaceEvents and assigns them to pins" function to find, because that isn't how it works — each `MapTrack` pin, upon receiving its own `FlowSetLayoutScreenEvent`, independently looks up and self-registers whichever `RaceEvent`s match its own `TrackId` from a shared registry.
**Concrete implementation path for the user's actual goal** (inject a synthetic `"LAN: <lobby name>"` entry into an already-loaded street's card list, purely at runtime, no file/OBB/`game_cache` changes): hook `MapTrack::AddEvent` (`sub_369AB0`, `0x369AB0`) and, after the real `RefreshEvents` loop finishes populating a chosen pin (or by directly calling `AddEvent` again on an already-loaded `MapTrack*` at any later time, e.g. when a LAN lobby is discovered), call it a second time with a synthetic event handle. Because `AddEvent` already does all the real bookkeeping — vector growth, completion-percentage accounting, lock/availability flags — this reuses the exact same engine machinery real events go through, matching the general "call the engine's own function with fake data" strategy favored since §6s (far lower-risk than hand-building a UI node or a fake `MapTrackEventList` child directly).
**Not yet determined**: the exact shape/type of the `RaceEventHandle` argument `AddEvent` expects (`a2` in the decompile) — it's passed by pointer and resolved through what looks like this engine's entity-component-system indirection (`sub_7566C`/`sub_173350`-style "resolve a component from a handle" calls, matching the "Dereferencing a NULL component pointer" ECS error strings seen elsewhere in the binary, §6u), not a raw `RaceEvent*`. Determining the exact handle format (likely a small ID/generation-pair struct rather than a pointer) is the next concrete step before this can actually be called with synthetic data.
## 6w. `AddEvent`'s handle format decoded: a 32-bit FNV-1a hash keying a global resource cache — `AddEvent` doesn't need the TrackId registry at all
Continuation of §6v. Decompiled the two functions in the resolution chain to pin down exactly what `MapTrack::AddEvent`'s `RaceEventHandle` argument actually is.
**`sub_173350`** (called from `AddEvent`/`sub_369AB0` as `sub_173350(&out, context+320, &handle)`) is a **generic "resolve an ID into a cached, refcounted resource" lookup** against an intrusive hash map: buckets at `context+4`/count at `context+8` (relative to the `context` pointer it's given), bucket index computed via `sub_97D23C(hashKey, bucketCount)`, each node laid out as `[0]=key(int), ..., [3]=+12, [4]=+16, [5]=+20 refcounted-ptr, [6]=+24 next-in-chain`. On a hit it returns the `{+12, +16, +20}` triple (incrementing the refcount at `+20+8`) — this is **the same generic "prefab instance cache keyed by ID, refcounted" system already known from `sub_7CE58`** (§6s: "Failed to load prefab %s... already present in this database, ignoring"). On a miss it builds and logs `"Database of <name> prefabs has no entry with ID <N>"` — confirming the key really is just an **integer ID**, not a pointer or struct.
**`sub_242778`** (called from `RefreshEvents`/`sub_369040`, *not* from `AddEvent` itself) is a **different, string-keyed hash map**: FNV-1a-32 hashes (`offset basis -2128831035` / `prime 16777619` — the literal, unmistakable FNV-1a constants) the `TrackId`-derived string passed in, looks it up in a hash map rooted in some outer registry context (`a1+68`/`a1+72`), and on a hit returns a pointer to a `{begin,end}` pair at `foundNode+16` — exactly the `int*` array `RefreshEvents` iterates to get the list of matching hash IDs for that track. On a miss, returns a pointer to a static empty triple (`&dword_AD4710`) — i.e., an empty, safely-iterable result rather than null.
**So the full resolution is two independent hash maps chained together**: `TrackId string --[sub_242778, FNV-1a keyed]--> vector<uint32 hash>` (one entry per matching `RaceEvent`), then each `hash --[sub_173350, int keyed]--> refcounted RaceEvent-prefab-instance pointer`. Critically, **`AddEvent` (`sub_369AB0`) only ever touches the second map** (`sub_173350` directly) — it takes an already-resolved hash and looks it up in the prefab-instance cache; it never calls `sub_242778` or touches the `TrackId` registry itself. That lookup is entirely `RefreshEvents`'s job, upstream of `AddEvent`.
**This meaningfully simplifies the injection plan from §6v**: to add a synthetic `"LAN: <lobby>"` card to an already-loaded street pin, there is no need to touch the `TrackId`→events registry at all. It's enough to:
1. Fabricate one entry in the **prefab-instance cache** that `sub_173350` queries — pick an unused 32-bit ID, construct a fake `RaceEvent`-shaped object (using the already-known field layout from §6p: `TrackName`, `EventName` = `"LAN: <lobby name>"`, `Completion`, `class_restriction`, `event_type`, `CashReward` Gold/Silver/Bronze, etc.), wrap it in the expected `{+12, +16, +20-refcounted-ptr}` node shape, and insert it into that hash map's bucket chain under the chosen ID.
2. Call `MapTrack::AddEvent(existingPinPtr, &chosenId)` (`0x369AB0`) directly on an already-loaded, real `MapTrack*` (obtainable from the same hit-test/selection machinery already mapped in §6u).
`AddEvent` then does everything else itself using real engine code: grows the pin's own event vector, updates the completion-percentage badge, and sets lock/availability flags — exactly the "reuse the engine's own logic with fake data" strategy favored since §6s, and now with a concrete, minimal (single hash-map entry) fabrication requirement instead of needing to replicate the whole `TrackId` registry or a VFS/prefab load.
**Not yet determined**: the exact in-memory shape of a loaded `RaceEvent` prefab instance (i.e., what the refcounted pointer at cache-node `+20` actually points to — presumably the same object `sub_7D2E8` extracts a `RaceEvent` component from, per much earlier sessions) precisely enough to hand-construct a convincing fake one; and the exact hash-map node/bucket-array construction details (allocation sizes, `sub_97D23C`'s bucket-index formula) needed to splice a new node in safely at runtime without corrupting the real map.
## 6x. The cache's *insert* side (`sub_7CE58`) confirms the value triple's meaning — and reveals a much cheaper injection strategy: relabel a real `RaceEvent`, don't fabricate one
Continuation of §6w. Decompiled `sub_7CE58` — already known from §6s ("Failed to load prefab %s... already present in this database, ignoring") to be the generic prefab-loading/caching function — and confirmed it's the **write side of the exact same hash map** `sub_173350` reads (same bucket-array-at-`+4`/count-at-`+8` layout, same `sub_97D23C` bucket-index call, same "already present, ignoring" log path).
**What it does**: given a prefab resource path string, hashes it (via `sub_67223C` — a *different* hash function than the FNV-1a used for the `TrackId` registry in §6w, so the two maps use different hashing even though both are keyed by strings-derived-to-ints at the storage layer), checks the cache, and on a miss actually loads the prefab (`sub_671330`/`sub_6714E0`) and extracts its `RaceEvent` component via **`sub_7D2E8`** — the same function identified across many earlier sessions as "extracts the `RaceEvent` component from a loaded prefab via `dynamic_cast`." The two outputs of `sub_7D2E8` are then written into a newly-inserted cache node's `+12`/`+16` fields, and the loaded prefab's own refcounted instance pointer goes into `+20`.
**This confirms precisely what `AddEvent` receives once it resolves a handle**: the `+12` field is the actual `RaceEvent` component pointer (the same kind of pointer `sub_7D2E8` has always produced, that downstream code like `sub_208C88` reads `m_StartLine`/`m_FinishLine`/etc. from at fixed offsets, per much earlier sessions), `+16` is an accompanying tag/generation word, and `+20` is the refcounted owning `Actor`/prefab-instance pointer.
**This changes the practical injection recommendation**: rather than hand-fabricating a fake `RaceEvent`-shaped C++ object from scratch (risky — would need its exact vtable, RTTI, and full field layout, only partially known from §6p), a **much cheaper and lower-risk approach** is to reuse an already loaded, real, well-formed `RaceEvent` object: pick any currently-loaded event's component pointer (a guaranteed-valid, correctly-vtabled object), insert a *new* cache node under a fresh unused hash ID whose `+12`/`+16`/`+20` fields simply copy that real object's identity (bumping its refcount, mirroring the same refcount-increment pattern `sub_7CE58`/`sub_173350` already do), then use the existing property-setter machinery (`sub_15F2DC`/`sub_406644`, already reverse-engineered in §6u Region A and `sub_369040`) to overwrite just its display fields — `EventName``"LAN: <lobby name>"`, `TrackName`, `class_restriction`, `event_type`, etc. — in place on that shared object, or on a shallow copy of it if mutating the original is undesirable. This "clone an existing valid object and relabel it" strategy avoids ever needing to construct a `RaceEvent` instance's vtable/RTTI/full binary layout by hand, at the cost of needing a real donor event to already be loaded somewhere (which is true for every street with at least one authored event, per every street observed so far in this project).
**Two viable concrete strategies now on the table** (not yet chosen/implemented):
1. **Clone-and-relabel** (recommended, lower risk): reuse a real, already-loaded `RaceEvent*` as the cache entry's payload; overwrite only its display properties via the engine's own property setters before/after calling `AddEvent`.
2. **Fabricate-from-scratch** (higher risk, more complete control): hand-construct a new `RaceEvent`-shaped object with a real vtable pointer and correctly-laid-out fields, matching the full memory layout (not just the SB-file field layout already known from §6p) — would need further work to pin down `RaceEvent`'s actual C++ class layout (vtable location, exact field offsets in the live object, not just the on-disk SB representation).
## 6y. `RaceEvent`'s real vtable, constructor, and a byte-precise field map — the concrete basis for fabricating one from scratch (per user's choice of "Вариант Б")
Continuation of §6x. The user chose the fabricate-from-scratch strategy, so the next task was pinning down `RaceEvent`'s actual live C++ object layout (not just the on-disk SB field names already known from §6p).
**Vtable/RTTI**: found via the same `entity_query`-on-names technique as §6v — `_ZTVN2im3app4race9RaceEventE` at `0xaa78a8` (real vtable symbol), `_ZTIN2im3app4race9RaceEventE` typeinfo immediately after at `0xaa78d8`. `xrefs_to` on the vtable surfaced exactly 3 functions: `sub_2A4B58` (constructor), `sub_2A4C70` (presumably destructor, not yet decompiled), and `sub_2A7BF4` (not yet decompiled, 476 bytes — likely another virtual method or a clone/copy function).
**Constructor** (`sub_2A4B58`): `malloc(0xE4)`**the live object is exactly 228 bytes**. Calls a base-class constructor (`sub_670454` — sets a temporary base vtable `off_ABB274`, a 4-byte field at `+4` to `0`, and a 2-byte field at `+8` to `256`/`0x100`; this looks like a generic ECS `Component` base: owner/actor pointer + a type-tag default), then overwrites the vtable pointer with the real one (`off_AA78B0` — the usable, RTTI-header-skipped vtable, same Itanium-ABI convention as `MapTrack` in §6v), then zero/default-initializes every field through offset `+220`.
**Field map** (cross-referenced against `sub_2A4D70`, the `RaceEvent` field deserializer from §6w/§6x, now fully decompiled — every write in it targets `a1+<offset>` with an explicit property-name string literal right next to it, giving a byte-precise, high-confidence mapping):
| Offset | Field (property name) | Type | Default |
|---|---|---|---|
| `+0` | vtable ptr | ptr | `off_AA78B0` |
| `+4` | (base class: owner/actor?) | int | `0` |
| `+8` | (base class: type tag) | word | `256` |
| `+12` | *(unnamed — not written by `sub_2A4D70`; set elsewhere)* | string (12B: begin/end/cap) | empty |
| `+24` | `RaceType` | string (12B) | empty |
| `+40` | `RaceFSMPrefabOverride` | string (12B) | empty |
| `+56` | `CarRestriction` | string (12B) | empty |
| `+72` | `TrackName` | string (12B) | empty |
| `+88` | `EventName` (via an indirect "Name" lookup on a sub-list, then interned via `sub_406644`) | interned string ptr (4B) | empty |
| `+92` | `Location` (same indirect-lookup + intern pattern) | interned string ptr (4B) | empty |
| `+96` | `Zone`**computed**, not a raw property: `"ZONE_" + uppercase(Location)`, then interned | interned string ptr (4B) | empty |
| `+100` | `EnvironmentPrefab` | string (12B) | empty |
| `+116` | `TrafficCarCount` (indirect lookup) | int (4B) | `0`/unset |
| `+120` | `OpenWorldTrack` (indirect lookup) | bool (1B) | `0` |
| `+124` | `AutologID` (indirect lookup) | int (4B) | `-1` |
| `+128` | `BlacklistEvent` (indirect lookup) | bool (1B) | `0` |
| `+132` | `ClassRestriction` (indirect lookup) | int/enum (4B) | `0`/unset |
| `+136` | `PursuitType` | string (12B) | empty |
| `+152` | `StartLineNoSpawnZone` | float (4B) | `1000.0` (`0x447A0000`, confirmed via the exact literal `1148846080`) |
| `+156` | `FinishLineNoSpawnZone` | float (4B) | `1000.0` |
| `+160` | `SpawnDistance` | float (4B) | `1000.0` |
| `+164` | `StartLine` | string (12B) | empty |
| `+180` | `FinishLine` | string (12B) | empty |
| `+196` | `EndOfTrack` | string (12B) | empty |
| `+212` | `CheckpointCollection` | string (12B) | empty |
`StartLine`/`FinishLine`/`EndOfTrack`/`CheckpointCollection` at `+164/180/196/212` match, to the byte, the `raceDef+164/180/196/212` offsets already established across much earlier sessions (§6p and before) for `m_StartLine`/`m_FinishLine`/`m_EndOfTrack`/checkpoint-container — strong cross-session consistency check, both derivations agree exactly.
**Important correction**: earlier in this session (§6v/§6x speculation) the 3 consecutive floats at `+152/+156/+160` were guessed as a plausible match for `CashReward` Gold/Silver/Bronze. **That guess is now disproven** — they're confirmed to be `StartLineNoSpawnZone`/`FinishLineNoSpawnZone`/`SpawnDistance` (spawn-safety-radius tuning values), all defaulting to the same `1000.0`. `CashReward` and the UI-facing properties read reflectively elsewhere (`class_restriction`, `event_type`, `Completion` — read via `sub_15F2DC`/`sub_406644` in `sub_1781BC` §6u Region A and `sub_369040` §6v) are **not present anywhere in this 228-byte struct**. They must live on a separate component attached to the same `Actor`/prefab instance (this engine's ECS allows multiple components per entity, already established via the `component_weak_ptr<RaceEvent>` string constants in §6y's own vtable search) or be computed at read-time rather than stored — not yet confirmed which.
**Practical implication for fabrication**: a synthetic `RaceEvent` needs, at minimum: the real vtable pointer (`off_AA78B0`), a plausible base-class header (owner pointer + type tag), and populated `TrackName` (to match the target `MapTrack`'s own `TrackId` so `RefreshEvents`'s registry lookup — if that path is used — or manual placement resolves correctly) plus `EventName` (interned, e.g. `"LAN: <lobby name>"`) at minimum for a card to display meaningfully. `CashReward`/`class_restriction`/`event_type`/`Completion` — everything the actual UI card visibly shows beyond the name — are **not** part of this struct and remain the next concrete unknown: need to find the reflective property-descriptor table `sub_15F2DC` consults (likely a separate registered table mapping name strings to getter functions/offsets, not necessarily this struct at all) to know what a synthetic object must expose for those properties to resolve.
## 6z. Correction: `sub_15F2DC` is a named-widget lookup (`FindOrCreateLayoutEntity<Text>`), not a property read — re-interprets §6u Region A and narrows what's genuinely still missing from `RaceEvent`
Decompiled `sub_15F2DC` in full (previously only inferred as "a generic property getter" by analogy with its call pattern). It is **not** that — it's the exact same pattern as `sub_17A4CC` (§6s: `FindOrCreateLayoutEntity<T>`, searching a linked list of named layout entities at `scene[23]`/`scene[24]`, `strcmp` against the requested name, falling back to `"Unable to locate layout entity: "` — the identical error string), just instantiated for `im::scene2d_new::Text` instead of a generic `Node`. It looks up a **named child widget** in a scene graph and `dynamic_cast`s it to `Text` (a text-label widget), returning that widget pointer — it does not read or return a *value* at all.
**This re-interprets §6u Region A**: `sub_1781BC`'s block at `0x178afc-0x178e40` does not read `TrackName`/`Completion`/`EventName`/`class_restriction`/`event_type` as *properties off a RaceEvent-like object*. It looks up **named `Text` widgets** — child nodes of some scene rooted at `[MapScreen+0x120]` — by exactly those fixed names, and caches the resulting widget pointers into `MapScreen+0x1F8..+0x21C` for later use (almost certainly so a separate, not-yet-located binding/formatting step can set each widget's displayed text from the real data). `[MapScreen+0x120]` is therefore a **scene/`Node` pointer** (the event-detail-card template instance), not a `RaceEvent*` — consistent with `sub_15F2DC`'s second argument being treated as a scene object with an `[23]`/`[24]` named-entity list, exactly like `sub_17A4CC`'s.
**This narrows, rather than widens, the remaining gap from §6y**: `"class_restriction"` and `"event_type"` are very likely just the *names of the Text/icon widgets* that get filled in from `RaceEvent.ClassRestriction` (`+132`) and `RaceEvent.RaceType` (`+24`) respectively — both of which **are** already in the byte-precise field map from §6y. What's still genuinely unaccounted-for in the 228-byte `RaceEvent` struct is narrower than previously stated: only **`CashReward`** (Gold/Silver/Bronze) and **`Completion`** (already known, per §6v, to be *computed* by `MapTrack::AddEvent` from player-progress data, not read off `RaceEvent` at all — so not actually missing, just not stored on this object by design). `CashReward` remains the one open question — plausibly read via the same indirect "sub-list" lookup pattern (`sub_4F99F0`/`sub_4F9A80`) used for `EventName`/`Location`/`TrafficCarCount`/`AutologID`/`BlacklistEvent`/`ClassRestriction` in `sub_2A4D70`, just for a nested "Rewards" key not yet located, or extracted by an entirely separate deserializer function for a second component on the same prefab `Actor` (this engine's ECS allows several components per entity, as already established).
**Practical implication for the fabrication plan (§6y)**: the synthetic `RaceEvent` object's field requirements are essentially unchanged from §6y's table — `TrackName`, `EventName`, `RaceType`, `ClassRestriction` cover what the visible card UI needs (name, class icon, restriction), and `Completion` is handled automatically by `AddEvent`'s own bookkeeping (§6v) rather than needing to be set on the object at all. `CashReward` is the only remaining unknown, and is optional in the sense that a card lacking it would very likely just show a blank/default reward rather than fail outright (not yet confirmed, but low-risk either way, given every other field the deserializer sets defaults for missing prefab data rather than erroring).
## 6aa. `CashReward`'s real layout found — closes the last gap for fabricating a complete synthetic `RaceEvent`
Continuation of §6z. `CashReward` (`im::app::metagame::CashReward`) is confirmed to be a genuinely separate C++ class, not a `RaceEvent` field, part of a small class family also including `im::app::metagame::Reward` (base) and `im::app::metagame::RewardsCollection` (container) — found via the same `entity_query`-on-names / `xrefs_to`-on-vtable technique used for `MapTrack` (§6v) and `RaceEvent` (§6y).
**`CashReward`'s constructor** (`sub_23E2C4`): `malloc(0x1C)`**28 bytes total**, the smallest object found this session. Calls `Reward`'s base constructor (`sub_25D4F0`), which itself calls the *same* generic `Component` base constructor already seen for `RaceEvent` (`sub_670454` — sets a temporary vtable, `+4`=owner ptr default `0`, `+8`=type-tag word default `256`) before setting its own vtable (`off_AA60AC`). `CashReward`'s own constructor then overwrites the vtable a third time with its own (`off_AA5B54`, the real usable vtable) and sets:
| Offset | Field | Default |
|---|---|---|
| `+0` | vtable ptr | `off_AA5B54` |
| `+4` | (Component base: owner ptr) | `0` |
| `+8` | (Component base: type tag) | `256` |
| `+12` | unknown (not a reward amount — separate field, possibly a type/flags int) | `0` |
| `+16` | Bronze cash amount | `10000` |
| `+20` | Silver cash amount | `30000` |
| `+24` | Gold cash amount | `50000` |
(Tier-to-offset mapping is inferred from ascending magnitude matching the conventional Bronze < Silver < Gold ordering — not confirmed via an explicit property-name deserializer the way `RaceEvent`'s fields were in §6y, since no `CashReward`-specific field-deserializer function was located this pass; still high-confidence given the exact 3-tier shape and clean ascending defaults.)
**This closes the fabrication gap identified in §6y/§6z.** A synthetic event needs two objects, both now fully specified: a 228-byte `RaceEvent` (§6y's table: `TrackName`, `EventName`, `RaceType`, `ClassRestriction` at minimum for a meaningful card) and a 28-byte `CashReward` (this section) for the reward amounts shown on the card. Both constructors are simple, malloc-and-fill patterns with no complex dependencies — straightforward to replicate for a fabricated pair of objects at runtime.
**Still open, not yet located**: exactly how a `RaceEvent` and its `CashReward` are associated on the same prefab `Actor` (i.e., which field/mechanism lets code go from "I have this `RaceEvent`" to "here is its `CashReward`") — this wasn't needed to build the two objects individually, but will matter if the actual card-rendering code fetches the reward via that association rather than via a hardcoded second cache lookup. **Resolved — see §6bb.**
## 6bb. `RaceEvent`↔`CashReward` link resolved: sibling components on the same `Actor`, each deserialized independently per-race from a shared prefab property table — explains why every race has its own distinct reward
The user specifically asked to pin down this relationship, and flagged the important constraint that different races have different rewards (i.e. the link cannot be to one shared/global `CashReward`).
**Found the accessor**: `sub_164540(out, actorPtr)` = **`Actor::GetComponent<CashReward>()`** — confirmed via the profiling string `"GetComponent"` plus the exact same generic pattern used everywhere else in this engine (iterate an `Actor`'s component array at `actor[5]`..`actor[6]`, `dynamic_cast` each `Component*` to the target type, return the first match as a weak/shared handle). Crucially, its second argument (`a2`) is an **`Actor*`** — the *same* kind of object `sub_7D2E8` (the long-established "extract `RaceEvent` from a loaded prefab" function, referenced since much earlier sessions) also operates on. **This settles the relationship**: `RaceEvent` and `CashReward` are not linked to each other directly at all — they are independent **sibling components attached to the same `Actor`** (the loaded `.prefabs.sb` instance). Getting from "this `RaceEvent`" to "its `CashReward`" means going through the owning `Actor`'s component list, not through any field on `RaceEvent` itself. (A second function, `sub_25D998` = `Actor::GetComponents<Reward>()` — plural, base-class `Reward` rather than `CashReward` specifically — confirms the same component-list mechanism generically collects *all* reward-type components on an `Actor`, in case a race ever has more than one.)
**Found `CashReward`'s actual field deserializer**: `sub_23E47C`, in the same code region as `CashReward`'s constructor (§6aa). It reads exactly three named properties from the shared per-prefab property table (parameter `a3` — the same kind of indexed property-list object `sub_2A4D70`/`RaceEvent`'s deserializer read its own indirect properties from, e.g. `Location`/`TrafficCarCount`/`ClassRestriction`) via the identical `sub_4F99F0`/`sub_4F9A80`/`sub_50CE64` helper chain: **`"Bronze"``+16`** (default `10000`), **`"Silver"``+20`** (default `30000`), **`"Gold"``+24`** (default `50000`) — confirming, with explicit property-name strings this time (not just inferred from default ordering as in §6aa), the exact tier-to-offset mapping.
**This closes the loop architecturally**: a single `.prefabs.sb` file's property table is evidently shared across *all* of that prefab's component deserializers at `Actor`-construction time — `RaceEvent`'s deserializer (`sub_2A4D70`) and `CashReward`'s deserializer (`sub_23E47C`) each independently pull their own named subset of properties from the same source. Since every race has its own separate `.prefabs.sb` file (confirmed since the earliest sessions of this project, e.g. `event_02_timetrial.prefabs.sb`), every race's `Actor` gets its own freshly-constructed `CashReward` instance with that specific race's own `Bronze`/`Silver`/`Gold` values baked in at load time — exactly matching the user's point that rewards differ per race, and explaining precisely *how* that variation is represented (per-`Actor` component instances, not a shared/global reward table).
**Practical implication for fabrication (final piece)**: a synthetic race's fake `Actor` needs a component list containing both the fake `RaceEvent*` and the fake `CashReward*` (with whatever `Bronze`/`Silver`/`Gold` values are desired for the synthetic "LAN: <lobby>" entry). `sub_7D2E8` and `sub_164540`/`GetComponent<CashReward>()` will then resolve correctly against it exactly as they do for any real, loaded race — no need to intercept or special-case either accessor.
## 6cc. Live breakpoint-based debugging CONFIRMED WORKING — Samsung Galaxy A9 (2018), genuine AArch32 hardware
Per the user's own research (§6t "Root cause identified"), the Fairphone 5's failure to sustain breakpoints was attributed to its SoC lacking native AArch32 hardware. The user then obtained a Samsung Galaxy A9 (2018), SM-A920F, Snapdragon 660 (Kryo 260 = Cortex-A73/A53-based — genuinely pre-dates Qualcomm's native-32-bit-hardware removal), Android 10, rooted via Magisk, and asked for a debugger to be attached as a direct test of that hypothesis.
**Setup**: reused the native ARM32 `gdbserver` already extracted from NDK r16b in an earlier session, but this time paired it with the **matching-generation GDB client** rather than a modern `gdb-multiarch` — extracted `gdb-orig` (GDB 7.11) from the same NDK r16b archive's `prebuilt/linux-x86_64/bin/`, working around its legacy `libncurses.so.5` dependency via a local `LD_LIBRARY_PATH` symlink to the system's `libncurses.so.6` (no matching system package exists for that old ABI, and this avoided any system-wide install). This eliminates the ~7-year GDB/gdbserver protocol-version skew that was the leading suspect for the earlier `continue`-crashes-`gdbserver` failure on Fairphone 5's own gdbserver test.
**Result — decisive and clean**: the exact same "sanity check" that disproved breakpoints on the Fairphone 5 (a software breakpoint on `malloc`, expected to fire within seconds of any activity) **fired correctly on the very first `continue`**, on a background `GLThread`, with a correct `pc` and a real caller return-address backtrace frame. `gdbserver` then detached cleanly ("Detaching from process ...", not a crash), and the game process remained alive and undisturbed afterward. This is a genuine, reproducible, working breakpoint — the first of this entire project.
**Two practical gotchas hit and solved along the way** (now also recorded in `[[reference-native-arm32-debugging-requirement]]`):
- `gdbserver --attach` pauses the process immediately via ptrace; attaching mid-loading-screen freezes the app there (looked like a hang until understood — detaching let it resume immediately).
- This device (Android 10) mmaps native libraries **directly from inside the APK's zip** (uncompressed, page-aligned storage) rather than extracting a standalone `libapp.so` — so `/proc/PID/maps` never shows a `libapp.so`-named mapping to grep for, and the runtime load base has to be computed manually: get `libapp.so`'s data offset within `base.apk` via Python's `zipfile` module, match that offset against a `/proc/PID/maps` line, and that line's start address is the load base. Verified byte-for-byte against the reference `libapp.so`'s own ELF header before trusting it. This load base changes on every process (re)launch (ASLR) and must be recomputed each time.
**Practical implication**: this project's live-debugging capability is no longer read-only. The Galaxy A9 is now the reference device for any future "does function X actually get called" investigation — a question that blocked several static-analysis threads earlier this session (e.g. confirming exactly where a `MapTrack`'s pin gets its `RaceEvent` handles attached, §6v-§6y, was done entirely via static analysis precisely because live debugging wasn't available at the time).
## 6dd. Live-verified, end-to-end: `MapTrack::HandleEvent → RefreshEvents → AddEvent` — the full §6v-§6w chain confirmed exactly as reverse-engineered, plus base-address-resolution sanity check
Continuation of §6cc, now that live breakpoint debugging works on the Galaxy A9. Two things were checked, both live, on-device.
**Base-address resolution — no divergence found, single algorithm is sufficient.** The user's concern: since this device (Android 10) mmaps native libraries directly from inside the APK zip rather than extracting a standalone `libapp.so` (§6cc), does the existing `get_libapp_base()` (`dl_iterate_phdr`-based, in `launcher/mpcore/src/main/cpp/main.cpp`) compute the same address as manual inspection would? Since the installed APK on this device already bundles `libmpcore.so` (not stripped, full debug info), this was tested directly rather than synthetically: located `libmpcore.so`'s own runtime load address (same zip-offset technique as for `libapp.so`, verified byte-for-byte against its ELF header), then read the live value of its `libapp_base` global variable (found via `readelf -sW`, offset `0x3b6c`) straight out of process memory. **Result: `0xb8798000`, exactly matching** the value independently computed by hand for the same process. `dl_iterate_phdr` already correctly abstracts over the "loaded from an extracted file" vs. "loaded directly from within an APK zip" distinction — this is precisely what it's designed to do, and Android's own linker keeps `dlpi_addr` correct either way. **No fallback/dual-algorithm logic is needed**; the existing single implementation in `main.cpp` is correct as-is on this device.
**Live chain verification.** Computed runtime addresses for `MapTrack::HandleEvent` (`sub_368DFC`), `RefreshEvents` (`sub_369040`), and `AddEvent` (`sub_369AB0`) using the confirmed load bias, attached `gdbserver` to a **freshly relaunched** game process early enough to catch the map screen's first-ever layout pass (revisiting the map screen after a Garage trip, tried first, turned out *not* to refire the event — `MapScreen` evidently persists underneath Garage rather than being torn down and recreated, so a fresh app launch was needed instead), and set:
- `break *HandleEvent if *(int*)($r1+4) == 1025` (only stop on the real `FlowSetLayoutScreenEvent` dispatch, filtering out the very frequent, unrelated event type `31` noise already seen and characterized in this pass)
- plain breakpoints on `RefreshEvents` and `AddEvent`
**Result — the entire chain fired exactly as predicted, for real, back to back, for multiple pins in sequence**:
1. `HandleEvent` hit with `evtype==1025` confirmed live (first time this specific condition was ever directly observed, not just inferred from static analysis).
2. Immediately followed by `RefreshEvents` hit with the **identical `this`** pointer — confirming `HandleEvent`'s `sub_369040(a1)` call live.
3. Immediately followed by **multiple consecutive `AddEvent` hits, all with that same `this`** — 6 calls for the first pin observed, 4 for the second, 3 for the third (a fourth pin's `HandleEvent` was still starting when the test's stop budget ran out) — confirming `RefreshEvents`'s per-match loop calling `AddEvent` once per resolved `RaceEvent` hash, live, and giving the first-ever **real observed count** of how many events a single street pin can carry (previously only "each street pin has its own event vector, supports more than one" was established structurally, §6v; now concretely: real streets in this save have pins with 3-6 events attached, not just 1).
This closes the loop on the multi-session `MapTrack`/`AddEvent` investigation with actual runtime evidence, not just static inference — every claim in §6v-§6y about this call chain is now independently confirmed.
## 6ee. Implementation: the injection hook works end-to-end (no crash) — two real Actor-layout bugs found and fixed live on-device
Implemented the plan from `/home/megboyzz/.claude/plans/dreamy-giggling-hearth.md` on a new branch `lan-event-injection-poc` (based on `track-hook-toggle-flag`, not bare `master` — that branch is missing the working hook-trampoline infrastructure `master` was assumed to have; `master`'s `main.cpp` turned out to be a much earlier, scratch-code-laden state that was never actually merged forward). New file: `launcher/mpcore/src/main/cpp/lan_event_injection.h`, wired into `main.cpp` behind `kEnableLanEventInjectionHook`.
**Iterative on-device debugging found two real bugs in the "fabricate a fake Actor" approach from §6y**, both only surfaced by actually running it (exactly the residual risk the plan called out in advance):
1. **`Actor+4` must be non-null.** `AddEvent` (`sub_369AB0`) checks `*(actorPtr+4)` and logs "Dereferencing a component pointer whose actor has been deleted." (this string is Android's tombstone "Abort message" field even though the actual signal is SIGSEGV, not SIGABRT — a genuine tombstone quirk, not indicative of an abort() call) when it's zero. Fixed by giving the fake `Actor` a self-pointer at that offset.
2. **`Actor+8` must start at exactly `1`, not `0`.** This is a refcount: `sub_173350` (the handle resolver) increments it by 1 on every successful resolve (a temporary borrowed reference), and `AddEvent` releases that same reference before returning — if the release brings it to exactly 0, it invokes a virtual "release" method through the object's own vtable (`(*(int*)(*(int*)actorPtr + 12))(actorPtr)`). With refcount starting at 0, this net-negative pattern hit 0 and crashed on the null vtable (fault addr `0xc` = `NULL+12`, confirmed via live `objdump` disassembly of the exact crash offset). Fixed by starting the refcount at 1, so the borrow-then-release cycle nets back to 1 and never triggers that call — safe for any future number of resolves, since each is always a matched borrow+release pair.
**A third, unrelated bug was found and fixed in the diagnostic/observation logging code** (not the core injection path): reading a `MapTrack`'s event vector (`this+0x240/0x244`) from within the hook occasionally caught a garbage `begin` pointer (`0x100`) paired with a small, plausible-looking element count — most likely a torn read racing a concurrent update from a different thread (the crash always occurred on a `GLThread`, and `AddEvent`'s own `push_back` isn't atomic: realloc + memmove + three separate pointer writes). Fixed with a defensive plausibility check (the pointer must look like a real heap address, and the count must be small) before trusting it enough to dereference.
**Result after both fixes**: injection completes cleanly for all ~12 `MapTrack` pins this hook currently reaches, with zero crashes across repeated fresh-launch tests — a real, meaningful validation that the whole `RaceEvent`/`CashReward`/fake-`Actor`/hash-insert/`AddEvent` chain from §6v-§6bb works as designed against the live game.
**Not yet achieved**: visual confirmation of the synthetic card actually rendering on a real, currently-visible street. The three streets visible on this save's map ("РЭЙНОЛЬДЗ ЛЭЙН" — a "Most Wanted #10" boss battle + a real event; "КЭМЕРОН ДРАЙВ"; "КРЮГЕР АВЕНЮ" — a real event) were each checked directly (tapped, event-list panel inspected) and show no injected card — meaning **none of them are among the ~12 `MapTrack` instances this hook's `evtype==1025` condition catches**. Since `HandleEvent`'s dispatch mechanism itself is confirmed correct (§6dd) and injection is confirmed to work once a pin is reached, the remaining gap is purely "which pin is which" / "why do these 3 special-content streets not go through this same broadcast path" — plausibly because Most-Wanted-boss and other hand-authored story content gets attached through a different, not-yet-identified mechanism than the generic `TrackId`-registry-driven path this hook intercepts. This is the natural next investigative thread if visible confirmation on a known street is wanted, but is a separate question from "does the injection mechanism itself work," which is now answered: yes.
## 6ff. Delayed-crash root cause narrowed to the "Blacklist" rival system — exact faulting instruction not yet pinned down
Continuation of §6ee's flagged delayed crash (fault addr `0xc0ffee00`, our first injected cache key, dereferenced directly as a pointer roughly 5 minutes after injection). Investigated via a mix of static analysis and, eventually, successful live breakpoint-catching once a gdb/gdbserver state-sync quirk was worked around.
**Static trail**: the crash backtrace's frame #01 static offset (`0x233b70`, from `libapp.so (offset 0x4d8000)`) traces into `sub_233684` — a function that checks a fixed table of hardcoded event names (`"event_60_blacklist_1"` down to `"event_04_blacklist_10"`, all in a literal pool at `0x233b10`) to determine an event's **Blacklist rival rank** (NFS Most Wanted's rival-racer ranking system — matches the "Рэйнольдз Лэйн" street's observed "Most Wanted #10" boss card from §6ee). This function then walks a `boost::function`-keyed map/tree structure, invoking a stored callback per entry via a generic invoker thunk (`sub_234F38`, confirmed via decompile to be boost::function's standard dispatch pattern, not itself buggy).
**Live-caught**: after working around a recurring gdb/gdbserver synchronization bug (`continue` intermittently failing with "Cannot execute this command while the target is running" on already-running, multi-threaded processes — reliably avoided by attaching **immediately** after process spawn, before the game's worker threads fully start, rather than to an already-running instance), set a breakpoint at `0x233b70` and caught it firing **28+ times in a row with completely stable, valid register values** (`r0=0xb8c4f0f4`, a genuine stack address). Disassembling the actual bytes there (not a defined IDA function — literal pool followed by unrecognized code) revealed `0x233b70` is itself a `BL` instruction into a **fourth, previously unknown function at `0x233b4c`** (only reachable via an address taken and stored elsewhere, e.g. as a registered callback — never called directly by name anywhere), which itself calls into `0x40602C`.
**Conclusion so far**: this whole call chain (`sub_233684` → per-entry callback → `sub_233b4c``sub_40602C`) runs **frequently and safely** under normal conditions — it's a routine, periodic Blacklist-rival scan over some always-present collection, not something our injection specifically triggers. The crash is a **rare condition within this hot path**: at least once, an entry in whatever collection this scan walks contained our raw injected cache key (`0xc0ffee00`) instead of a properly-resolved value, and something downstream dereferenced it directly as a pointer. The **exact single faulting instruction is not yet pinned down** — live-catching it requires either a much longer soak (the crash took ~5 minutes to occur naturally in the two observed cases) or a smarter conditional breakpoint (e.g., break only when a register looks like one of our `0xC0FFEE0X`-range keys), neither attempted yet due to time already invested in this thread.
**Practical implication / mitigation direction (untested)**: real cache keys are hash values (computed via `sub_67223C`, not human-chosen constants), and this Blacklist-scan code appears to expect either a resolvable-through-`sub_173350` handle or a real hash-shaped value at some point in its processing — using an easily-recognizable sentinel range (`0xC0FFEE00`+) for synthetic keys, while convenient for debugging, may itself be more likely to look "plausible enough to use directly" to whatever misbehaving check exists here than a value that looks like a real hash would. Worth testing empirically: switch the injection's key-generation scheme to something structurally closer to a real `sub_67223C`-style hash (e.g., actually hash a fake resource-path string through that same function) and re-run the ~5-minute soak test to see if the crash still reproduces — this doesn't require finding the exact faulting instruction to potentially resolve the crash, though it's a mitigation-by-avoidance rather than a confirmed fix.
## 6gg. Delayed crash: EXACT root cause found — it's the QA-only "Soak Test" auto-race feature, not the Blacklist system; clean, cheap mitigation identified
Correction/completion of §6ff: that section traced the wrong backtrace frame (frame #01, a caller/return-address site inside the Blacklist rank-lookup code, which turned out to be an unrelated red herring that just happens to execute very frequently and safely). Re-examining the *original* crash report's **frame #00** (the actual faulting instruction, previously not converted to a static offset) gives the real answer.
**The faulting instruction**: static offset `0x406cd0`, inside `sub_406CAC(int **a1, const void **a2)` — a generic "construct an `eastl`-style string object from a C-string" helper (matches its call sites elsewhere in the binary, e.g. `sub_17C120`'s `sub_406CAC(&v150, v101)`). Its very first operation is `v2 = *a2;` (treat the input as a pointer to a C-string pointer) followed by a classic `strlen`-style scan: `v4 = v2 - 1; while (*++v4);` — this is the exact line at `0x406cd0`. If `*a2` isn't a real string pointer, this immediately faults trying to read the "string"'s first byte — and the original crash's fault address was **exactly `0xc0ffee00`**, our first injected cache key, confirming `*a2` held our raw integer key value directly, not a string pointer.
**Where this gets called with bad data — the QA "Soak Test" auto-race feature** (already identified and named in §6v/§6bb, `sub_17C120`): once every ~4 seconds of active gameplay (`flt_AD417C > 4.0`), it picks an entry by **numeric index** (a persistent, decrementing global counter `dword_AD4180`, wrapping around based on an array's element count) from an array `v156` obtained via `sub_242904(&v156, someContext + 320, 0)` — the same `+320`-offset context pattern established (§6w) as this project's prefab-instance cache — and calls `sub_406CAC(&v150, v101)` on that entry to build a debug log line ("Soak Test Run No: N - `<name>`"). This is **QA/debug-only instrumentation**, not player-facing functionality: it auto-picks and logs (likely also auto-launches) random cached races purely for automated soak testing.
**Root cause of the crash, precisely**: `sub_242904` appears to return a *separate, parallel* list (of resource-path-string pointers, one per real cached prefab) rather than reading the same hash-table `sub_7D638`/`sub_173350` operate on directly. Our injection only inserts into the **primary hash-table cache** (via `sub_7D638`) — it never adds a corresponding entry to this *other* parallel list. This desyncs the two structures' sizes/contents, so `dword_AD4180`'s index-based lookup into `v156` eventually reads memory that doesn't correspond to a real entry — landing, in the observed crash, on memory holding our raw injected key rather than a valid string pointer.
**Practical mitigation — cheap and clean, doesn't require touching the parallel list at all**: this whole code path is a **QA-only feature with no player-facing purpose** (auto-launches random races for soak testing — not something the mod needs to preserve, and almost certainly disabled in normal retail play already via whatever build/debug flag gates `sub_15811C(*(a1+8))`, one of the two conditions guarding it). The simplest fix is to **prevent this branch from ever running** rather than trying to keep a second, not-yet-reverse-engineered list in sync with every injected entry — e.g., hook `sub_17C120` (or more surgically, force `flt_AD417C` to never exceed `4.0`, or short-circuit right before the `sub_406CAC` call) so the Soak Test logic never fires. This fully eliminates the crash's trigger condition without needing to understand or replicate whatever `sub_242904`'s parallel list actually is.
## 7. Open questions / next steps (see plan presented to user for full detail)
1. Locate the real vtables (not just RTTI name strings) for §3.1/§3.2 classes in **this** binary, starting from the confirmed string addresses (walk backwards from `name_ptr` to the `type_info`/vtable structure, same technique demonstrated working this session for `RaceLoaderTask`'s `sp_counted_impl_p` wrapper at `0xd7a2c8`).
2. Decompile (Hex-Rays) the actual `RaceLoaderTask`/`OpponentCollection`/`TrackNavigator`/`RaceStartingGrid` virtual functions once addresses are known, to get real field offsets and signatures (replacing the old chat's guesses).
3. Confirm whether `Health`, `Nitro`, `DamageDealtMultiplier`, `CarDamage`, `SpikeStrip` exist under the same names in this binary (not yet searched this session — lower priority, not core to the multiplayer subtasks).
4. Read `game_cache/published/prefabs/racefsms/*.sb` and `game_cache/published/flow/race/*.sb` directly (via `NFSMW12MobileTools`) to get the *authoritative*, binary-RE-free picture of the race-start Flow sequence and FSM structure — likely faster and more reliable than reversing `InRaceState` from disassembly alone.
5. Confirm car-selection screen invocation path (how the "native car-selection menu" is invoked/returns a result) — needed for the "Choose Car" lobby button requirement.
**Not yet confirmed**: whether the "3 slots" cap is a real per-street constant (worth checking a street with 2 or 3 *unlocked* real events, if one exists in this save, to see whether it ever shows 4+ cards) or coincidental to the two streets tested so far (both had only 1 real event authored). If a street with 3 unlocked events still shows only 3 slots and a street with fewer shows fewer non-locked ones, that would strongly confirm the fixed-pool theory.
**Resolved — see §6v.** `MapTrack::AddEvent` (`sub_369AB0`, `0x369AB0`) is the exact function that attaches a `RaceEvent` to a pin (into a growable per-pin vector at `MapTrack+0x240..0x248`, not the `+0xB8` field originally suspected in §6u — that turned out to be a red herring from a different, secondary "closest track under touch" cache read by `sub_1781BC`, unrelated to the pin's actual owned event list). Next step: determine the exact `RaceEventHandle` argument shape `AddEvent` expects, so it can be called with synthetic data.
## 6hh. Subtask 2 groundwork — `RaceStartingGrid`/`StreetRaceStartingGrid` fully mapped (grid is procedural, not per-track data), `Opponent`/`OpponentCollection` live layout found, `TrackNavigator`'s spline→world resolver identified (reusable for subtask 4), cop-spawn scheduler located
Session 2026-08-26 (overnight, autonomous per explicit instruction — "работай до исхода лимита токенов, в конце расскажешь"). Scoped in `ARCHITECTURE.md` §3b the prior session; this entry answers all four of that section's open questions, all via static SB-data inspection + IDA decompilation, no live device needed.
### Q1 (is the street-race grid layout per-track or shared) — ANSWERED: shared, procedural, not per-track data
First checked the DATA side: unpacked and diffed 15+ different `event_*_race.prefabs.sb` files (`NFSMW12MobileTools`) spanning every region — **every single one has exactly 5 `Opponent` entries** (confirmed field-by-field, see Q3 below), regardless of track. Then unpacked two full track scene files (`region4_chicago_track1.scene.sb`, `region1_foothills_track1.scene.sb`, ~75k `DATA_Elements` each) and searched every `actor`'s `name` field for grid/spawn-related strings: **found exactly one actor literally named `"start"` per track** (plus many `"mesh_start"` visual props, not gameplay locators) — no numbered spawn-point actors (`start_1`, `grid_pos_2`, etc.) anywhere in either track.
This ruled out "per-track authored grid" and pointed at "single locator + code-computed offsets," confirmed by decompiling `RaceStartingGrid`/`StreetRaceStartingGrid` (RTTI: `im::app::race::description::{RaceStartingGrid,StreetRaceStartingGrid}`, vtables `_ZTVN2im3app4race11description{16RaceStartingGrid,22StreetRaceStartingGrid}E` at `0xaa7d28`/`0xaa7da8` — the old `0xcfeb5c`/`0xcfeb8c`/`0xcfebb8` addresses recorded in §3.1 are from a stale `.i64`, do not use them). `StreetRaceStartingGrid`'s constructor (`sub_2B884C`) hardcodes 5 float defaults directly in the C++ constructor:
| Offset | Field name (confirmed via deserializer `sub_2B8A60`) | Default | Notes |
|---|---|---|---|
| `+12` | `MinDistanceBetweenRacers` | `10.0` | |
| `+16` | `MaxDistanceBetweenRacers` | `15.0` | |
| `+20` | `MaxTrackWidthFraction` | `0.8` | **fraction**, not an absolute value — proportional to whatever track's actual width |
| `+24` | `PlayerStartingSpeed` | `27.778` (= 100 km/h, `100/3.6`) | read as km/h from data, converted `*0.27778` at load time; ctor default is pre-converted |
| `+28` | `OpponentsStartingSpeed` | `36.111` (= 130 km/h) | opponents start faster than the player by default |
The deserializer (`sub_2B8A60`) reads these same 5 property names reflectively (`sub_4F99F0`/`sub_4F9A80`, the same generic property-getter used throughout this codebase) from a property table, falling back to the ctor defaults if absent. **None of the 62 unpacked event files' schemas contain any struct/field with these names** — confirmed via a full struct-name enumeration across every `event_*.sb.json` in the repo. Conclusion: **every regular street race uses the identical 5 hardcoded grid parameters; the only per-track variation is the natural, unavoidable difference in where each track's own single `"start"` locator sits in world space** — which the existing code already resolves correctly for any track via `TrackNavigator` (see Q4). No per-track special-casing is needed for subtask 2's grid work.
`StreetRaceStartingGrid` is itself instantiated through a generic by-name component factory (`sub_2B8654`, registers the string `"StreetRaceStartingGrid"` → constructor `sub_2B87C4` via `sub_670758`, the same reflective-registration idiom already seen for other `Component`-derived classes) — i.e. it's a component on some Actor's shared race-FSM prefab (matching every checked event's `RaceFSMPrefabOverride` field, e.g. `"point_to_point_fsm_newintro"`), not something authored per-track or per-event.
### Q2 (is "player always last" an index or an insertion-order effect) — ANSWERED: insertion-order effect, in the racer-placement algorithm itself
`StreetRaceStartingGrid` overrides 4 of `RaceStartingGrid`'s 13 vtable slots (compared both vtables word-for-word: `off_AA7D28`/`off_AA7DA8`, slots 1/2/5/10 differ, slots 0/3/4/6/7/8/9/11 are inherited unchanged). Slot 5 is the deserializer above (Q1); **slot 10 (`sub_2B88BC`) is the actual placement algorithm**:
```
sub_2B88BC(this, raceContext, playerHandle, opponentsVector):
distance = 0.0
for (index, opponentEntry) in enumerate(opponentsVector): // 12-byte elements
PlaceCar(raceContext, opponentEntry, distance, LateralOffset(trackWidthFraction, index), OpponentsStartingSpeed)
distance += MinDistanceBetweenRacers + random(0,1) * (MaxDistanceBetweenRacers - MinDistanceBetweenRacers)
PlaceCar(raceContext, playerHandle, distance, 0 /* lateral: dead-center */, PlayerStartingSpeed)
```
The player is placed **once, after the loop, using whatever `distance` the loop accumulated** — not a fixed index and not an authored "last" flag. This is a structural consequence of the function's own two-phase design (place every opponent first, accumulating randomized spacing; place the player last, at the tail). **Practical implication for subtask 2**: real multiplayer players do not need to fight or bypass this rule at all — inserting them as entries in `opponentsVector` (the same vector `Opponent`/`OpponentCollection` builds, see Q3) gives them completely normal, correctly-spaced, correctly-jittered grid slots via the exact same code path a real AI opponent would get. Only the *local device's own* player stays in the dedicated last slot via the separate `playerHandle` call — which is fine, since each device's own player is already a distinct, privileged local entity (input/camera) regardless.
Lateral placement for opponents (the `LateralOffset(...)` call, inlined in `sub_2B88BC`) is a 3-lane zigzag: `((index+1) % 3) * 0.5 * trackWidthFraction + (1 - trackWidthFraction) * 0.5`, scaled by the track's actual width (resolved by `PlaceCar`/`TrackNavigator`, not baked into this formula) — so lane assignment naturally cycles through 3 lateral positions as the opponent index increases, using whatever `MaxTrackWidthFraction` (0.8 default) allows.
### Q3 (opponent car model + color write path) — ANSWERED: byte-precise live `Opponent` layout found, plus its owning `OpponentCollection`
Data-side confirmed first: every `event_*_race.prefabs.sb`'s `Opponent` struct schema is `DriverName` (string), `CarDescriptionName` (string, e.g. `"ford_mustang_boss_302_2012_desc"` — same format as the already-known `GetCurrentCarId()` result), `ColourIndex` (int32 — same field this project already reads for the *player's own* car via `LookupCarRecord`, cont.63), plus AI-tuning floats (`RacingLineScale`/`SpeedFactor`/`CorneringFactor`/`RubberBandingDifficulty`/`RubberBandingTweaksToUse`/`RubberBandingTargetDifficulty`/`PathfindingSkill`), `Stationary` (bool), `MaxHealth` (float). Every regular race event has exactly 5 `Opponent` entries (see Q1).
Found `Opponent`'s deserializer (`sub_2B5DD0`, located via the `"CarDescriptionName"` string xref) and its only caller, `OpponentCollection::PopulateFromProperties` (`sub_2B649C`, located via `"Opponents"` string xref) — both fully decompiled, giving the real live C++ layout (80-byte `malloc`, matches `Opponent`'s highest field offset `+76`+4):
| Offset | Field | Type |
|---|---|---|
| `+0` | vtable ptr | `off_AA7C78` |
| `+4` / `+8` / `+12` | `DriverName` | `{begin,end,capacity}` eastl string (same 12-byte string idiom as `RaceEvent`'s `TrackName`) |
| `+20` / `+24` / `+28` | `CarDescriptionName` | same string idiom — **the field to overwrite for opponent substitution** |
| `+36` | `ColourIndex` | plain `int`**the field to overwrite for opponent color** |
| `+40..+75` | AI-tuning sub-object | built by `sub_2B4B08`/`sub_2B4B44`, not yet mapped field-by-field (not needed for subtask 2 — a substituted real-player slot doesn't need AI tuning) |
| `+76` | `Stationary` | `bool` |
`OpponentCollection::PopulateFromProperties` (`sub_2B649C`) reads the `"Opponents"` property (a map), and for each entry: `malloc(0x50)` a new `Opponent`, deserializes it via `sub_2B5DD0`, wraps it in a 16-byte refcounted handle (`vtable=off_AA7CCC, refcount=1, +12=Opponent*`), and `push_back`s that wrapper into a vector living on the `OpponentCollection` object itself at `this+12`/`+16`/`+20` (begin/end/capacity — standard vector, 8-byte/2-word elements).
**This is the exact vector `sub_2B88BC` (Q2) iterates to place opponents on the grid** — not yet cross-confirmed by directly tracing the argument at the call site (a remaining, low-risk verification step), but the shapes match exactly (`OpponentCollection`'s built vector of wrapped `Opponent*` vs. the grid placement loop's 12-byte-strided vector — the 12-byte stride matches the wrapper's own `{vtable-tag, Opponent*}`-plus-something shape closely enough to be the same data, needs one direct trace to fully nail down before implementing).
**Practical implication for subtask 2.1**: hook `OpponentCollection::PopulateFromProperties` (`sub_2B649C`), let it build the normal AI opponent list unmodified (matches this whole project's established "hook after, tweak fields" pattern — never originate, always let the real engine construct first), then for up to N real lobby players, overwrite the corresponding `Opponent` entries' `CarDescriptionName` (via `sub_7B524`, the same string-append/set helper already used for `RaceEvent.TrackName` in the track-substitution hook) and `ColourIndex` (plain int write) with that player's own already-captured `GetCurrentCarId()`/color-index values (`car_selection.h`). No grid-specific code needed — Q2 already established substituted entries get correct placement for free.
### Q4 (bonus — found while chasing Q2/Q3, directly relevant to subtask 4) — `TrackNavigator`'s real spline→world resolver
`PlaceCar` (`sub_2914DC`, own assert strings confirm the name: `"m_Navigator"`/`"PlaceCar"`/`"foundHeight"`, `"Navigator must be created before placing cars"`) is the single, universal placement primitive both the opponent loop and the player call use — signature `PlaceCar(gridThis, racerHandle, distanceAlongSpline, lateralOffset, startingSpeed)`. Internally:
1. `sub_3261B0(navigator, outBuffer, distanceAlongSpline, lateralOffset)`**this is the real `TrackNavigator::Resolve` implementation**: reads a vector of 12-byte spline-segment records at `navigator+308`/`+312`, binary-searches for the segment containing `distanceAlongSpline` (`sub_327824`), then resolves world position + tangent direction (`sub_32273C`) into `outBuffer`. Exactly the "`distance_along_spline`, `lateral_offset` ↔ world `Vector3`" transform `ARCHITECTURE.md` §5 predicted subtask 4 would need — found here, as a side effect of subtask 2 work, not yet independently verified live but the decompile is unambiguous.
2. Resolves ground height via `sub_85660` (a raycast/height-query, own assert `"foundHeight"`).
3. Builds a heading quaternion from the resolved tangent direction (`atan2f`/`sinf`/`cosf`).
4. Writes the car's **initial velocity** as `direction * startingSpeed` directly into a physics component at a fixed sub-offset (`+320/+324/+328` off a resolved pointer chain) — confirms starting speed isn't just cosmetic, it's a real initial-velocity write.
5. Writes the resolved transform (position + heading quaternion) to **two** separate components (`sub_10B09C` and `sub_D5138` — likely physics/simulation vs. render/visual transform, not yet distinguished which is which).
**Not yet found**: the inverse resolver (world position → spline distance/lateral offset), which subtask 4 will need for reading a moving car's *current* position to broadcast over the network (this entry only found the forward direction, used for placement at race start). Worth checking `TrackNavigator`'s other vtable slots when subtask 4 starts.
### Task 4 (traffic/cop removal for multiplayer) — cops: exact scheduler found; civilian traffic: data-level path clear, runtime spawner not yet traced
**Cops**: found `SpawnCopCar` itself (`sub_F85B8`, own assert string confirms the name) — large (0x11a0 bytes), spawns+places+configures a cop actor, not fully mapped field-by-field (not needed). Found both its callers: `sub_F7E9C` is a **spawn scheduler/gate** — checks a cooldown timer (`a1+4020/+4028/+4032`) and an active-cop-count-vs-max check (`a1+4188+16`), and only calls `SpawnCopCar` if both pass. This is the natural hook point: skip `sub_F7E9C`'s body entirely (return early, no call to `SpawnCopCar` ever happens) when a multiplayer session is active — structurally identical to the already-proven `CopSoundsTick` skip hook (`crash_workarounds.h`), same low-risk pattern, just a different target function. (`sub_F8078`, the second caller, not yet decompiled — likely a second trigger context, e.g. scripted/pursuit-specific vs. ambient.)
Confirmed via RTTI that cops are a substantial, separate subsystem (`im::app::car::CopAICarController`, `CopAttackBehaviour`, `CopBustBehaviour`, `im::app::race::description::CopDescription` — its own `PrefabDatabase<CopDescription>`, mirroring `CarDescription`'s own pattern) — full understanding of cop AI behavior is out of scope for "just stop them from spawning," which the scheduler-skip hook achieves without touching any of that.
**Civilian traffic**: `RaceEvent.TrafficCarCount` (int, confirmed in every event's SB data, e.g. `1` for `event_05_race`) and a sibling `TrafficFlow` component (`MaxIncomingCarsOverride`/`MaxOutgoingCarsOverride`/congestion-distance tuning/`TrafficCarSpawnDescs`) fully describe ambient traffic density per-event, at the data level — same shape as every other per-race tunable already reverse-engineered in this project (`CashReward`, `MedalPosition`, etc.). The runtime spawner that actually consumes these fields was not traced this session (found `TrafficFlow`'s own deserializer, `sub_33A108`, but not its downstream spawn-trigger function) — lower priority than cops since the data-level override (set `TrafficCarCount` to `0` post-deserialization, same "hook after, tweak field" pattern as everywhere else) is very likely sufficient on its own and doesn't require finding the runtime spawner at all. Worth a quick live A/B test (does `TrafficCarCount=0` actually produce an empty road?) before investing further RE time here.
### Snapshot / Outcome
Pure research this session — **no code written, no hooks installed, nothing live-tested on device** (all findings are static SB-data inspection + IDA decompilation). All four of `ARCHITECTURE.md` §3b's open questions now have concrete, address-level answers; `ARCHITECTURE.md` §3b itself needs a rewrite to reflect this (queued as immediate next step). Remaining gaps before subtask 2 can be implemented: (1) directly confirm `sub_2B88BC`'s opponent vector argument really is `OpponentCollection`'s own vector (high confidence, not yet byte-traced), (2) decompile `sub_F8078` (cop spawn scheduler's second caller), (3) live-test whether `TrafficCarCount=0` actually suppresses civilian traffic, (4) map `Opponent`'s AI-tuning sub-object if a substituted real-player slot ever needs to suppress AI behavior explicitly (may not be necessary — a real player's own input should simply override AI control, but this hasn't been confirmed).
## 6ii. Subtask 2.1 — opponent car substitution CONFIRMED LIVE end-to-end, despite the two vectors being genuinely different objects
Direct follow-up to §6hh, same session (2026-08-26, continued autonomously per explicit instruction to keep working through open items without waiting). Implemented `opponent_substitution.h`: two diagnostic hooks on `OpponentCollection::PopulateFromProperties` (`sub_2B649C`) and `StreetRaceStartingGrid`'s placement method (`sub_2B88BC`), logging every pointer both touch.
**Confirmed live: the two vectors are genuinely different objects**, not the same data viewed two ways. `StreetRaceGrid::Place`'s own 12-byte-element vector held addresses (`0xb03xxxxx`/`0x7fcxxxxx` range) that never appeared anywhere in `OpponentCollection`'s own 8-byte-element vector (`Opponent*` values all in a `0xbc2xxxxx`/`0xd7dxxxxx`-range heap arena) across two separate live captures. There is a real intermediate step - likely a "spawn the actual racer Actor" stage - between `OpponentCollection` building its data-only `Opponent` list (confirmed to happen once, early, at **map load** for every nearby event, not per actual race start - all ~20-45 `OpponentCollection`s for surrounding events populate within about 1 second of the map screen appearing) and the grid actually placing physical, rendered cars at race start (confirmed to happen once, ~minutes later, exactly when the player actually starts a race).
**Despite that gap, overwriting `Opponent.CarDescriptionName`/`ColourIndex` at `Populate` time was confirmed, live, to reach the actual rendered car at the starting grid.** Test: hooked `Populate` to unconditionally force opponent-slot-0 of every collection to `"ford_focus_rs500_2010_desc"`/`ColourIndex=0`, rebuilt, replayed a real (non-synthetic) event ("Перед вами FAIRHAVEN", `event_05_race`-shaped, 5 opponents) end-to-end via `adb` UI taps through to the starting-grid cinematic. **Screenshot comparison**: same race, same car, before the hook showed a white sedan + red car at the front of the pack; after the hook, a silver/gray Ford Focus RS500 is unmistakably the lead car. Logcat confirms the in-memory write held (`CarDescriptionName="ford_focus_rs500_2010_desc" ColourIndex=0`) across every collection populated that map-load pass.
**Practical conclusion for subtask 2.1**: whatever the intermediate spawn step is, it reads car/color off the same `Opponent` objects `OpponentCollection` builds (or a copy taken strictly after `Populate` has already run) - it does not use some earlier-fixed snapshot from before `Populate`. This means the hook point identified in §6hh (write immediately after `OpponentCollection::PopulateFromProperties` returns) is sufficient on its own; no need to trace or hook the intermediate spawn step at all. The only remaining gap before this becomes a real (not blanket-test) feature is a data source: a lobby/session layer that can tell the hook *which* `OpponentCollection` belongs to the race the player is actually about to start, and *which* real player's car/color to write into which slot - neither exists yet (no lobby UI/data model built). The intermediate spawn step's own identity/location remains unknown and is not needed for this subtask.
**Snapshot**: `opponent_substitution.h`'s substitution write is gated behind `g_enableBlanketOpponentSubstitutionTest` (default `false`) - the live-tested build had it hardcoded on for this one test, reverted to gated-off before ending the session so normal play isn't affected. Diagnostic logging (both hooks, generous budget) stays on by default - low-risk, budget-capped, matches this project's established diagnostic-hook pattern.
## 6jj. Subtask 2.4 — cop-spawn scheduler hook implemented and installs cleanly; civilian traffic hook implemented and CONFIRMED LIVE (corrects this section's own earlier mistake)
- **Cops**: implemented `cop_traffic_disable.h`, hooking `sub_F7E9C` (the cop-spawn scheduler identified in §6hh) to skip its body entirely when a test flag is on. Confirmed live: installs cleanly (`Installed Cop spawn scheduler skip hook`), process stays stable through map/menu navigation, no crash. **Not yet confirmed**: the scheduler never actually fired during map/menu browsing in this test session (0 log lines from the hook while idle on the map) - it's very likely gated on active gameplay/a pursuit context, not a continuous background process, so a real visual "no cops spawned" confirmation needs an active-driving or pursuit-triggering test longer than this session's `adb`-tap-only navigation could practically set up. Structurally sound and ready; visual confirmation is the remaining step.
- **Civilian traffic — this section's own first pass was wrong, corrected by re-reading the same decompile more carefully**: this entry originally claimed `TrafficCarCount` needed a keyed reflective-lookup hook because `sub_2A4D70` (`RaceEvent`'s deserializer) reads it via `sub_4F99F0`/`sub_4F9A80`. On closer reading, that reflective read happens exactly **once**, during deserialization - identical in shape to every other field `sub_2A4D70` reads (`RaceType`, `Location`, `ClassRestriction`, etc.) - and the resolved value is then stored as a **plain int at a fixed offset**, `RaceEvent+116`, on the live object (`*(a1+116) = resolvedValue`, confirmed directly in the decompile, right next to the already-known `ClassRestriction`-at-`+132` pattern). The original §6hh plan (hook after deserialization, overwrite the fixed offset) was correct all along.
- **Implemented and confirmed live**: `cop_traffic_disable.h`'s second hook, on `sub_2A4D70` itself, overwrites `*(a1+116)` to `0` after the real deserializer runs. Live-tested: fired once per event at map-load time (same timing as `OpponentCollection::Populate`/§6ii), correctly zeroed real, non-trivial values (`1`, `2`, even `10` for one event) to `0`, no crash, process stayed stable. **Not independently visually confirmed** (no ambient traffic was visible in either the before- or after-hook starting-grid screenshot, but that's expected either way - traffic wouldn't render at a stationary pre-race moment regardless of the hook - a real visual check needs actual driving, not just reaching the grid).
- **Snapshot**: both `cop_traffic_disable.h` hooks gated off by default (`g_enableCopSpawnSkipTest`/`g_enableTrafficCarCountZeroTest`, both `false`), same pattern as `opponent_substitution.h`.
## 6kk. Subtask 2 — full multi-car/color roster AND random player grid position, both CONFIRMED LIVE simultaneously
Direct follow-up to §6ii/§6jj, same overnight session, per explicit request to emulate a fuller mock session (varied opponents + a non-last player slot).
- **Varied roster**: extended `opponent_substitution.h`'s blanket test from one fixed car to a 5-entry roster (`ford_focus_rs500_2010_desc`, `bmw_m3_e92_2009_desc`, `dodge_challenger_srt8_392_2011_desc`, `ford_mustang_boss_302_2012_desc`, `lancia_delta_hf_integrale_evoluzione_1993_desc`, distinct colour indices), cycling by opponent slot index in `Hook_OpponentCollectionPopulate`. Live-tested on the same real event replay: screenshot shows three visibly distinct cars (white BMW M3, white/blue Dodge Challenger, dark Ford Focus RS500) on the grid simultaneously, logcat confirms all 5 slots got their distinct roster entry, in order, every time.
- **Random player grid position — required reimplementing the placement algorithm, not just data tweaks**: §6hh Q2 already established "player always last" is a call-order artifact of `sub_2B88BC` (loop places every opponent first, accumulating randomized spacing, THEN places the player once at the tail in a separate call) - there's no field to flip for this, since the ordering itself is the behavior. Implemented a full reimplementation in `Hook_StreetRaceGridPlace` (gated behind `g_enableRandomPlayerGridPositionTest`, falls through to the untouched original otherwise): calls the same two real primitives orig uses - `sub_291BA4` (`PlaceOpponent`, keeps its own lateral-zigzag math) for opponents, `sub_2914DC` (`PlaceCar`) directly for the player (lateral=0, matching orig's own player call) - in a loop of `count+1` slots, with one slot chosen by `rand() % (count+1)` for the player and the rest going to opponents in original order. Distance accumulation mirrors orig's own shape (place, then advance by a random offset in `[MinDistanceBetweenRacers, MaxDistanceBetweenRacers]`) but uses plain `rand()` instead of replicating `sub_75680`/`sub_61C9F8`'s own RNG chain (seeded from the just-placed car's return value in a way not fully understood - not worth the risk for a test hook).
- **Live-tested successfully, simultaneously with the varied roster**: logcat showed `RANDOM GRID TEST: 5 opponents, player placed at slot 0/5` (i.e. the player was placed FIRST this run, not last) - no crash, process stayed stable through the same real-event replay used throughout this session. Both features work together: the same test run that produced the 3-distinct-cars screenshot also had the player at a randomized (non-last) slot.
- **Snapshot**: both extensions live in `opponent_substitution.h`, gated off by default (`g_enableBlanketOpponentSubstitutionTest`/`g_enableRandomPlayerGridPositionTest`, both `false`) before ending the test - device left in a safe, normal-play state.
- **Outcome**: this is now a fairly complete emulation of what a real multi-player race grid would look like (N distinct cars/colors, player at an unpredictable position) - purely as a local, single-device demonstration (all "opponents" are still AI, not real network peers). The only remaining gap to a real feature is, as before, the lobby/session data layer to source real per-player car/color/slot-order choices instead of this hardcoded test roster and `rand()`.
## 6ll. Subtask 2.4 — cop-spawn scheduler's real two-path shape found and CONFIRMED LIVE via a before/after roadblock; ambient-traffic spawner found, reducible but not fully chokeable
User caught a police car live, mid-race, in the exact same race that produced the random-grid-position spawn-collision bug documented in `PROGRESS.md` cont.73 - directly contradicting §6jj's "never fires" observation. That contradiction was the trigger to dig one level deeper on both the cop scheduler and (independently, on a hunch that the same "shallow hook" mistake might be repeated) the traffic hook.
**Cops — the missing second path.** §6hh/§6jj only decompiled `sub_F7E9C`. Decompiling its neighbor `sub_F8078` (previously unexamined) and both functions' own callers found the real shape:
```
sub_F5BB4 (CopManager::Update, per-tick, also runs unrelated bust-timer/etc. bookkeeping)
-> sub_F5EA4 (dispatcher, gated on `sub_33FF1C(a1)+88` byte + `a1+3736` byte + `sub_31A8A4(a1+3952)`)
branches on `*(byte*)(a1+4036)`:
true -> sub_F7E9C (cooldown-timer, single-candidate scheduler)
false -> sub_F8078 (distance-sorted candidate LIST, multiple checks/spawns per tick)
both leaves call sub_F85B8 (SpawnCopCar, own assert string confirms name)
```
`xrefs_to(0xF85B8)` confirms exactly 2 callers - `sub_F7E9C` (`0xf8018`) and `sub_F8078` (`0xf822c`) - so these two are the *complete* set of spawn paths, nothing else calls `SpawnCopCar`. `xrefs_to(0xF7E9C)` and `xrefs_to(0xF8078)` both resolve to the same single caller, `sub_F5EA4` (`0xf5f4c`/`0xf5f58` respectively) - confirming the dispatcher is the correct, minimal hook point that covers both leaves without touching `sub_F5BB4`'s other per-tick logic (bust timers etc., via `sub_F9D34`/`FA4F8`/`F6640`/`F67A8`/`F6BCC` - explicitly NOT wanted to be disturbed).
Fix: moved the hook from `sub_F7E9C` to `sub_F5EA4` (renamed `Hook_CopSpawnScheduler``Hook_CopSpawnDispatcher` in `cop_traffic_disable.h`), skipping the dispatcher's body entirely (return 0, discarded by the caller anyway) when `g_enableCopSpawnSkipTest` is on.
**Live A/B proof** (same event, "Битва на шоссе"/Reynolds Lane, replayed via "Начать заново" between runs):
- **Flag off** (old, single-leaf-hooked build, or the new build with the flag toggled off): drove through and hit a full, unambiguous police roadblock mid-race - multiple marked "POLICE"-liveried cars with light bars, cops standing in the road, a stop sign, on a completely plain street race with no Most-Wanted branding whatsoever. Screenshotted.
- **Flag on** (new dispatcher hook): replayed the identical route. Logcat showed `DIAG CopSpawnDispatcher: skipped` fire 7 times in a tight ~230ms burst right around the same point in the route (matching `sub_F8078`'s "check several distance-sorted candidates per tick" shape). The road was completely clear at the equivalent location - no roadblock, no cops, clean pass-through, screenshotted.
This is the strongest live confirmation in the project so far - an actual, reproducible visual difference on the identical content, not just an absence-of-crash or a logcat line.
**Ambient traffic — the same "shallow hook" mistake, found and only partially fixed.** §6jj's `TrafficCarCount=0` hook was re-tested by actually driving the race (not just checking logcat, which is what "confirmed" it before) - civilian traffic (a red pickup truck, a blue sedan) was still clearly visible with the hook active. Traced the real spawner:
- `sub_33D734``TrafficCarSpawner`'s populate function (own assert string: `"TrafficCarSpawner"`). Signature `(int a1/*this*/, int a2/*road context*/, char a3/*direction: 0 or 1*/, int a4/*ptr to a 4-float struct incl. spacing*/, int a5/*max candidate count*/)`. Builds a per-direction candidate list: for each road "node" in a waypoint/spline list (count `v66`, read from a road-context sub-object's own `+108/+112` list bounds - track-authored, nothing to do with `a5`), a `do { ...push candidate...; } while (++n < ceil(a5/(v66+something)))` loop runs. Because it's a `do-while`, **the body executes at least once per node regardless of `a5`** - forcing `a5=0` lowers the *additional* iterations to zero but can't prevent the guaranteed-first one.
- `sub_33C020` calls `sub_33D734` exactly twice - `(v7, a1, 0, v8, *(a1[3]+16))` and `(v17, a1, 1, v19, *(a1[3]+20))` - once per traffic direction, with each call's max-count read from offsets `+16`/`+20` on a *different* object (`a1[3]`) than `RaceEvent` (where `TrafficCarCount` lives at `+116`, per §6jj) - confirming `TrafficCarCount` was never the right field to begin with.
- `sub_2A8CE8` calls `sub_33C020` exactly once, at race setup (`xrefs_to(0x33c020)` → single caller) - meaning the two candidate lists `sub_33D734` builds are very likely the *entire* traffic roster for the race, built once, not a queue that's topped up per-tick the way cops are. This reframes the residual cars as this hook's structural floor, not a sign of an unrelated live spawner still running.
Fix implemented: `Hook_TrafficCarSpawnerPopulate` on `sub_33D734`, forcing `a5` to `0` when `g_enableTrafficSpawnerZeroTest` is on. **Live-tested**: logcat confirmed `maxCount=1 -> 0` and `maxCount=2 -> 0` for the two direction calls on the same event - and a single red pickup was still visible on-road, consistent with the `do-while` floor (1 node in one direction group, 2 in the other, before any hook involvement - already a small roster) rather than the hook silently failing.
**Left open, honestly scoped**: full elimination needs either (a) the track-waypoint-count source itself (harder - track-authored scene data, not a simple parameter), or (b) whatever consumes `sub_33C020`'s two candidate-list handles (stored at `a1[28]`/`a1[30]` on its own `this`) to actually instantiate world actors - not yet located. Worth checking that owning object's other methods for a read of those two slots before assuming a new decompile pass is needed from scratch.
**Snapshot**: `cop_traffic_disable.h` - dispatcher hook renamed and retargeted (`Hook_CopSpawnDispatcher`/`sub_F5EA4`); new `Hook_TrafficCarSpawnerPopulate`/`InstallTrafficCarSpawnerZeroHook` on `sub_33D734`, gated by new `g_enableTrafficSpawnerZeroTest`. All test flags reverted to `false` and a clean final build reinstalled before ending the session.
## 6mm. Subtask 2.4 — civilian traffic fully eliminated, CONFIRMED LIVE over a 2+ minute drive (resolves §6ll's open item)
Direct continuation of §6ll on explicit user instruction: keep digging into traffic, the goal is literally zero cars on the road, not just fewer. §6ll's `sub_33D734` do-while fix reduces the candidate list to a structural minimum but can never reach zero (the loop is `do { ...push candidate... } while`, so it always runs at least once per track-authored waypoint regardless of the max-count parameter) - this section traces one level further, past candidate-list *population* to actual world *placement*.
**Found `sub_C26A0` — the real placement primitive, a third confirmed user of the `PlaceCar`/`SpawnCopCar` write pair.** Own assert strings confirm the function's identity: `"foundTrackInfo"` and `"Reset"` (i.e. `CarReset`). Signature `(int a1, uint32_t* a2, char a3, int a4)`. Resolves a spline distance + lateral offset to a world position (via `sub_2B2D18`/`sub_85660`, the same `TrackNavigator`-family shape used throughout this project) and writes the result via `sub_10B09C`/`sub_D5138` - the exact same transform-write pair `PlaceCar` (`sub_2914DC`, §6hh) uses for the grid and `SpawnCopCar` (`sub_F85B8`, §6ll) uses for cops. `xrefs_to(0xC26A0)` returns exactly two callers:
- `sub_2A0470` — registered via `sub_31AAEC` (the same event-subscription idiom `SpawnCopCar` uses for its own `"EndOfTrack"` event, per §6hh) as the handler for a `"ResetLine"` event, one registration per traffic car (built in `sub_2BF4C4`, itself invoked - not traced further - once per traffic car object).
- `sub_C201C` — the traffic car's own **per-tick controller**. Second parameter is a delta-time-carrying struct (`*a2 * -0.001` appears twice, converting an integer millisecond tick into seconds). Calls `sub_C26A0` in two places: once when an idle/wander timer (a float field, counted down by delta-time each tick) reaches `<= 0.0` (computing a fresh target position via `sub_690A94`), and once, gated by a separate flag byte, to retry a *previously deferred* reset whose parameters were stashed in fields at `a1+112..128` - the exact same fields `sub_C26A0` itself writes on its own internal "couldn't resolve a position yet, save the request" fallback branch. This field layout (offsets 40/44/52/58/60/68/72/80/84/88/92/93/96/112-128) doesn't match `Opponent`, `PlaceCar`'s context object, or anything else touched by this project so far - a dedicated, traffic-specific per-car component.
**First hook attempt, live-tested and disproven**: hooked `sub_2A0470` on the reasonable-looking assumption that a `"ResetLine"` handler would cover both a car's initial placement and its ongoing recycling (analogous to how cops' single dispatcher covered both of *their* two paths in §6ll). Built with an **unconditional** diagnostic log (not gated by the test flag, specifically so its absence would be meaningful) and drove an actual race rather than just checking whether it installed. Result: the log never fired once during a ~1 minute race replay, yet a screenshot taken mid-race clearly showed the same red pickup truck from §6ll's testing, moving normally. This directly disproves the hypothesis for normal-length races - `sub_2A0470` isn't reached on whatever path keeps that traffic car active. (It *did* fire once, later, during the longer successful test below - so it's a real, occasionally-used path, just not the primary one.) Left installed as a harmless secondary hook, explicitly not relied upon.
**Real fix**: hooked `sub_C201C` itself, skipping its entire body (`g_enableTrafficControllerSkipTest`, returns early without calling orig or touching any of its fields). Since this is the traffic car's dedicated per-tick controller and the *only* other path into the actual position-write primitive, skipping it should leave that car's actor permanently un-positioned - never rendered moving on the road.
**Live-confirmed, unambiguous, over an extended drive**: replayed "Битва на шоссе"/Reynolds Lane - the same event that, across every prior test this session (§6ll and cont.73-74), reliably produced a red pickup truck in the tunnel section (~14s in) and a blue sedan later on a straight. With both traffic hooks active: screenshotted the tunnel section - completely clear, no pickup. Continued driving past 2 minutes total (well past where the blue sedan always appeared before) - still completely clear, no crash. The `sub_C201C` hook's own diagnostic log confirmed it firing continuously and rapidly (hit its 50-entry budget cap almost immediately after the race started, consistent with a genuine per-tick function), and the `sub_2A0470` hook fired exactly once late in the drive - both hooks coexisting without conflict.
**Snapshot**: `cop_traffic_disable.h` gains `Hook_TrafficCarControllerTick`/`InstallTrafficCarControllerSkipHook` on `sub_C201C` (the real fix, `g_enableTrafficControllerSkipTest`) and `Hook_TrafficResetLineHandler`/`InstallTrafficResetLineSkipHook` on `sub_2A0470` (secondary/harmless, `g_enableTrafficResetLineSkipTest`). `main.cpp` installs both unconditionally in `JNI_OnLoad` (runtime-gated by the flags, same pattern as every other test hook in this project). All flags reverted to `false`, final build reinstalled, before ending the session.
**Outcome**: subtask 2.4 is now fully solved on both halves - cops (§6ll) and traffic (this section) - each confirmed live via an actual before/after drive, not just a logcat line or an absence of crashes. The pattern that solved both: find the genuine per-tick controller/dispatcher for the subsystem and skip it wholesale, rather than trying to starve a data-driven candidate list or count field that turns out to have its own independent structural floor. Subtask 2 as a whole is now essentially complete pending only the lobby/session data layer; subtask 4 (coordinate/position sync) is the natural next major branch.
+328
View File
@@ -0,0 +1,328 @@
# ARCHITECTURE.md — Multiplayer Mod for NFS Most Wanted (2012) Android
Living document. Revisit and edit whenever a design decision changes — do not let this drift out of sync with reality.
---
## 1. Goal recap
Layer local + dedicated-server multiplayer onto the existing singleplayer game, without breaking singleplayer. Lobby-based flow (map marker → enter lobby → player list → native car-select → ready-up → countdown → race), LAN discovery (SAMP-style broadcast) + internet servers, RakNet-based network layer with a separate headless C++ server (not a repurposed game runtime).
---
## 2. Integration layers (top to bottom)
```
┌───────────────────────────────────────────────────────────────────┐
│ launcher (Android APK wrapper) │
│ - ships the original game_cache unmodified │
│ - ships modified native libraries (or hooks them at runtime) │
│ - hosts the lobby UI (approach TBD-per-screen, see §4) │
└───────────────────────────────────────────────────────────────────┘
│ loads
┌───────────────────────────────────────────────────────────────────┐
│ mpcore (native module, JNI_OnLoad entry point) │
│ - loaded AFTER libapp.so and friends (per task brief) │
│ - resolves libapp.so base via FindLibrary() │
│ - installs inline hooks (Thumb ARM32, armhook.cpp toolkit) │
│ - owns the RakNet CLIENT instance (peer, connects to a server) │
│ - bridges native <-> Java for lobby UI + car-select result │
└───────────────────────────────────────────────────────────────────┘
│ hooks into
┌───────────────────────────────────────────────────────────────────┐
│ libapp.so (game engine, Iron Monkey, C++) │
│ - RaceLoaderTask / OpponentCollection / RaceStartingGrid / │
│ TrackNavigator / InRaceState (race lifecycle — see ANALYSIS.md) │
│ - Flow-machine (SB-scripted UI/state flow) │
│ - EAMText/GLES native text+UI renderer │
│ - PlayerCarController / AICarController family (car physics/AI) │
└───────────────────────────────────────────────────────────────────┘
│ (future, separate process/machine)
┌───────────────────────────────────────────────────────────────────┐
│ Dedicated server (standalone, NOT libapp.so) │
│ - headless C++, links RakNet │
│ - authoritative-ish relay: lobby state, ready-check, position sync│
│ - local discovery: UDP broadcast responder (SAMP-style) │
│ - internet: direct connect / master-list registration (TBD) │
└───────────────────────────────────────────────────────────────────┘
```
Key architectural principle carried over from `ANALYSIS.md` §1.1: **we never originate `RaceLoaderTask`/race-start ourselves.** The player always starts a race through the game's own menu → Flow-machine → RaceLoaderTask path (any existing event works — see subtask plan). Our hooks intercept that *already-in-context* flow at specific points (opponent list build, starting grid, track navigator) to substitute multiplayer data. This keeps singleplayer's own code paths completely intact when no lobby session is active — hooks should be written to no-op/pass-through cleanly when there's no active multiplayer session.
---
## 3. Where hooks live and how they're installed
- **Injection point**: `mpcore`'s `JNI_OnLoad`, invoked after `libapp.so`/`libfmodex.so`/`libfmodevent.so`/`libnimble.so` are already loaded (confirmed as the intended design in the task brief and matches the existing `mpcore` draft's `FindLibrary("libapp.so")` call).
- **Hook mechanism**: reuse and extend `launcher/mpcore/src/main/cpp/util/armhook.cpp`. It already implements Thumb-mode (16-bit) inline hooking (`InstallHook`/`InstallJMPHook`/`InstallBLXHook`), vtable-slot overwrite (`InstallMethodHook`), and a register-copy code-injection stub (`CodeInject`), backed by a fixed trampoline region inside `libapp.so`'s own address space (`APP_ADDR(0x000A1B44)` sized `0x1A36`) plus an `mmap`'d PROT_RWX scratch page for original-bytes preservation. This is a working foundation, not a rewrite target — see ANALYSIS.md §6 for what's already verified.
- **Addressing**: `APP_ADDR(offset)` = `libapp_base + offset`, and the IDA database's addresses are already base-`0x0`-relative (i.e., IDA addresses == file offsets == `APP_ADDR` argument, no translation needed). Verified this session via the `0xC8C9D8` string anchor (see ANALYSIS.md §6).
- **Two hook strategies available depending on the subsystem** (decide per hook site once its real vtable/function is located):
1. **Inline/trampoline hook** on a free-function or a specific vtable-slot's target — for functions we want to wrap (call original, then modify results), e.g. `OpponentCollection::Build`.
2. **Direct vtable-slot replacement** (`InstallMethodHook`) — simpler when we want full control and don't need the original implementation, e.g. potentially `TrackNavigator`'s position resolver if we decide to fully own opponent positioning instead of post-processing it.
---
## 3a. Working hook reference: `RaceLoaderTask_BuildTrackScenePath` (arbitrary track substitution)
The first fully working, validated hook (mechanically and visually confirmed — see `ANALYSIS.md` §6g–§6o and `PROGRESS.md`'s 2026-08-05 entries). Lives entirely in `launcher/mpcore/src/main/cpp/main.cpp`, ~95 lines, no changes anywhere else. Use this as the template for future hooks (opponent list, starting grid, coordinate sync) — same trampoline pattern, same "read the struct, repoint a couple of pointers, call through to the original" shape.
**What it does**: whichever race event the player starts through the normal menu flow, this hook makes it load a *different*, hardcoded track's geometry instead of the event's real one — while everything else about the event (rewards, opponent count, car-class restriction, HUD) stays as designed. Confirmed with a live A/B test: same event, same car, same opponents — different city, different road layout, different time of day, purely by flipping the hook on/off.
**Target function**: `RaceLoaderTask_BuildTrackScenePath`, `libapp_base + 0x2a8424` (`BUILDTRACKSCENEPATH_OFFSET`). ARM-mode (`PUSH {R4-R11,LR}; ADD R11,SP,#0x1C`), both displaced instructions are position-independent, so it uses a **trampoline hook**: an `mmap`'d RWX page holds the two displaced instructions followed by a jump back to `target+8`, and the function's first 8 bytes are overwritten with `LDR PC,[PC,#-4]` + the hook's address. `Hook_BuildTrackScenePath` runs first, then always calls through to `orig_BuildTrackScenePath` — this is a wrap, not a replace.
**`RaceDefinition` struct offsets used** (confirmed live, not guessed — see `ANALYSIS.md` §6j/§6n for how each was found):
| Offset from `raceDef` | Field | Notes |
|---|---|---|
| (a1+32 → deref) | `raceDef` pointer itself | `a1` is the `RaceLoaderTask`; `raceDef = *(void**)(a1+32)` |
| `+72` / `+76` | track name `{begin,end}` char pointers | e.g. `"region1_foothills_track4"` — this hook overwrites both |
| `+100` / `+104` | environment name `{begin,end}` char pointers | e.g. `"foothills"`-style — **not** the same string as the track name; see naming convention below |
| `+164` / `+168` | `m_StartLine` actor name `{begin,end}` | generic across tracks (`"start"`) — safe |
| `+180` / `+184` | `m_FinishLine` actor name `{begin,end}` | **generic for regular races** (`"finish"`), but **per-event custom** for time-trial events (e.g. `"event_02_finish"`) — this is why time-trial events are out of scope, see below |
| `+196` / `+200` | `m_EndOfTrack` actor name `{begin,end}` | generic (`"end_of_track"`) |
| `+212` / `+216` | checkpoint-container actor name `{begin,end}` | empty for regular races; per-event custom for time-trial (e.g. `"checkpoints_timetrial_event_2"`) |
Only `+72/+76` and `+100/+104` are overridden by this hook. The original string buffers are deliberately leaked (never freed) — `BuildTrackScenePath` only reads them, and avoiding a free sidesteps needing to know an unconfirmed capacity/allocator-owner field.
**Environment-name gotcha**: environment prefabs are **per-track-numbered** files (`chicago1.prefabs.sb``chicago6.prefabs.sb`, matching `region4_chicago_track1``track6`), not one generic `<region>.prefabs.sb` per city. Setting the env name to just `"chicago"` fails to open the prefab database and crashes immediately. The env-name string must match the specific track number, not just the region (`"chicago4"` for `region4_chicago_track4`). The one region that doesn't follow this convention (`region3`/colorado, shipped as a single un-numbered `colorado.prefabs.sb`) turned out to be cut/incomplete content with no loadable geometry at all — see `ANALYSIS.md` §6n before picking a new target track.
**Picking a different target track**: edit `kOverrideTrackName`/`kOverrideEnvName` near the top of `main.cpp` to any `region{N}_{city}_track{M}` / `{city}{M}` pair that's confirmed to exist under `game_cache/published/prefabs/tracks/` and `game_cache/published/prefabs/environments/` respectively (and has a `game_cache/published/models/environments/{city}/` folder — the colorado lesson).
**Toggle flag**: `kEnableTrackSubstitutionHook` (`constexpr bool`, just above `JNI_OnLoad`) — `true` installs the hook, `false` runs the game completely unmodified. Flip, rebuild (`./gradlew :app:assembleDebug`), reinstall — no other code changes needed. Exists specifically so before/after comparisons don't require repeated edits to `JNI_OnLoad` itself.
**Known limitation — regular races only** (explicit project-scope decision, not a bug): time-trial and single-opponent/pursuit-style events reference their finish-line and checkpoint-group actors by the per-event custom names shown in the table above, which only exist in that event's *original* scene. Substituting the track for those event types either does nothing useful or crashes on a missing-null-check in `RaceLoaderTask_ResetStartingLine` (`GetComponent<Checkpoint>()` on a NULL actor lookup — a genuine pre-existing engine bug, not ours). This hook is only intended to run for regular point-to-point races. See the `track-substitution-scope` memory and `ANALYSIS.md` §6n for the full trace.
**Standing gap, not yet closed**: this hook currently fires for *every* race load, gated only by the build-time `kEnableTrackSubstitutionHook` flag (default `false` since `PROGRESS.md` cont.66's fresh-save prologue crash) — it has no runtime check for "is a multiplayer session actually active." Before this can run as part of normal multiplayer play, it needs to be re-scoped to only substitute the track when a real lobby session is active, not left as an always-on-or-always-off toggle.
---
## 3b. Subtask 2 — opponent substitution, starting grid, and traffic removal
Scoped 2026-08-25; all four open questions answered via RE 2026-08-26 (`ANALYSIS.md` §6hh — full addresses/offsets/decompiles there, this section is the implementation-facing summary). **[RE complete, no code written/tested yet]** — this is the actual "make N real players race together" mechanism; everything proven so far (§3a's track substitution, the car/color/class/upgrade capture in `car_selection.h`/`mod_slot_tracking.h`) is supporting infrastructure around a single local player's own car-select flow, not this.
**Mode scope**: street race only, consistent with §3a's "regular races only" decision.
### 2.1 — Opponent substitution **[mechanism CONFIRMED LIVE — cont.70]**
`OpponentCollection::PopulateFromProperties` (`sub_2B649C`) builds a vector of `Opponent` objects (80 bytes each) from `RaceDefinition` SB data, one per AI opponent (always 5 for a regular race — confirmed across 15+ events, every track). Live `Opponent` layout: `+20/+24/+28` = `CarDescriptionName` (eastl string, same idiom as `RaceEvent.TrackName`), `+36` = `ColourIndex` (plain int). Implemented and live-tested (`opponent_substitution.h`): hooking `sub_2B649C`, letting it build the normal AI list unmodified, then overwriting an entry's `CarDescriptionName`/`ColourIndex` **is confirmed to reach the actual spawned, rendered car at the starting grid** — screenshotted a forced Ford Focus RS500 replacing a real opponent's original car on a real (non-synthetic) event replay. This holds despite `StreetRaceStartingGrid`'s own placement vector (2.2) being confirmed, live, to hold **different heap objects** than `OpponentCollection`'s vector — there's an unidentified intermediate spawn step between them, but it reads car/color off these same `Opponent` objects (or a copy taken after `Populate` already ran), so hooking here is sufficient; the intermediate step itself doesn't need to be found. See `ANALYSIS.md` §6ii for the full live-test writeup. **What's left**: this is currently a blanket test (forces opponent-slot-0 of every race to the same fixed test car, gated off by default via `g_enableBlanketOpponentSubstitutionTest`) — turning it into the real feature needs a lobby/session data source (which race is this, which real player's car/color goes in which slot), which doesn't exist yet since no lobby UI/data model is built.
### 2.2 — Starting grid **[RE complete — turned out simpler than expected]**
Both open questions resolved, and favorably: the grid is **not per-track data**`StreetRaceStartingGrid`'s 5 spacing/speed parameters (`MinDistanceBetweenRacers`/`MaxDistanceBetweenRacers`/`MaxTrackWidthFraction`/`PlayerStartingSpeed`/`OpponentsStartingSpeed`) are hardcoded C++ constants, identical for every regular street race regardless of track (confirmed: no event's SB data ever overrides them). Each track only supplies a single `"start"` locator actor (confirmed via two full track scenes, no per-slot/numbered spawn actors exist) — `TrackNavigator` resolves that plus a computed `(distance, lateralOffset)` into a world position (see 2.5 below), so the same code naturally adapts to any track's geometry with zero special-casing needed.
"Player always last" is **not a fixed index** — it's a structural side effect of the placement algorithm (`sub_2B88BC`): it loops over the opponents vector first (accumulating randomized spacing between each), *then* places the player once, at the tail, in a separate call. **Practical upshot: multiplayer doesn't need to fight this rule at all.** Real players substituted into the opponents vector (2.1) get completely normal, correctly-spaced slots via the same code path as any AI opponent — only each device's own local player keeps the dedicated "always last" slot, which is fine since the local player is already a distinct, privileged entity (input/camera) regardless of multiplayer.
**Cont.73 pitfall, worth remembering**: a test reimplementation that inserted the player mid-sequence (instead of at the tail) reproduced a real spawn-inside-another-car collision live. Root cause: `TrackNavigator::Resolve`'s lane bounds are symmetric around 0, so the player's hardcoded lateral offset (0, dead center) exactly coincides with an opponent's own lane-cycle position whenever that opponent's index is `≡ 0 (mod 3)`. Vanilla is safe only because the player is always maximally far (past the whole accumulated distance) from every opponent — never adjacent. Any future code that changes *where* the player is inserted into this sequence must widen the longitudinal gap around that insertion point, not just reuse the normal opponent-to-opponent spacing (sized for adjacent-different-lane cars, not same-lane).
### 2.3 — Spawn order policy **[unchanged — still a design decision, not an RE question]**
Since 2.1/2.2 both resolve to "just insert real players as opponent-vector entries," the policy question becomes simpler: which N of the lobby's players get inserted, and their relative order within the vector (which determines lane-zigzag assignment and grid-row spacing, not "who's on the grid at all" — everyone inserted gets a normal slot). Default for the first version: alphabetical by display name, implemented as an isolated `ResolveGridOrder(lobbyPlayers) -> orderedList` function, swappable later without touching the hook itself.
### 2.4 — Remove police and civilian traffic **[CONFIRMED LIVE, both cops and traffic]**
**Cops — CONFIRMED LIVE** (cont.74): the original `sub_F7E9C` hook missed a second spawn path. Real shape: `sub_F5EA4` is a per-tick dispatcher (called only from `sub_F5BB4`, CopManager's broader Update - too broad to skip wholesale since it also runs unrelated bust-timer bookkeeping) that branches on a live flag to either `sub_F7E9C` (the originally-hooked leaf) or `sub_F8078` (a second, previously-unhooked leaf - a distance-sorted-candidate-list scheduler). Both leaves call the same `SpawnCopCar`/`sub_F85B8` (confirmed exactly 2 callers via `xrefs_to`). Hooking the dispatcher (`sub_F5EA4`) itself covers both leaves with one hook, without touching `sub_F5BB4`'s other per-tick maintenance. **Live A/B proof**: same event replayed 3x - flag off hit a full marked police roadblock mid-race (screenshotted: light bars, "POLICE" livery, cops standing in the road) on a plain street race with no Most-Wanted branding; flag on (dispatcher hook), same route, dispatcher fired 7x in a tight burst at the same point and the road was completely clear. Gated off by default (`g_enableCopSpawnSkipTest`).
**Civilian traffic — CONFIRMED LIVE, zero traffic achieved** (cont.75, resolving cont.74's open item): the `sub_33D734` candidate-list hook (below) can only reduce traffic to a structural minimum, never zero, so the real fix needed the actor's own placement path, not its candidate-list population. Traced `sub_C26A0` (`CarReset` - own assert strings confirm "foundTrackInfo"/"Reset") - resolves a spline distance + lateral offset to a world position and writes it via the same `sub_10B09C`/`sub_D5138` transform-write pair used by `PlaceCar` (2.2) and `SpawnCopCar` (2.4/cops) - i.e. the actual moment a traffic car's position becomes real, analogous to those two. It has exactly two callers: `sub_2A0470` (a `"ResetLine"` event handler, registered per traffic car via `sub_31AAEC` - same idiom `SpawnCopCar` uses for its own `"EndOfTrack"` event) and `sub_C201C`, the traffic car's own **per-tick controller** (second parameter carries a delta-time value; calls `sub_C26A0` once when an idle/wander timer expires, once to retry a previously-deferred reset stored in fields `sub_C26A0` itself writes on its "can't resolve yet" fallback path - i.e. genuinely traffic-car-specific, not a generic car utility shared with the player/opponents). First hooked `sub_2A0470` on the assumption it also handled initial placement - **live-tested and disproven**: its own unconditional diagnostic log never fired once across a full race replay, yet traffic was clearly visible and moving the whole time, so it isn't on the path that matters for a normal-length race (left in, harmless, but not the fix). Hooking `sub_C201C` instead (skipping its body wholesale, `g_enableTrafficControllerSkipTest`) **is** the fix: **live-confirmed zero traffic across a full 2+ minute drive** on the same route that previously showed a persistent red pickup truck and a blue sedan - clean road throughout, no crash, hook fired continuously (~50+ times, log budget exhausted) as expected for a per-tick controller.
**`sub_33D734` do-while structural-minimum hook** (superseded as the primary fix by the above, but harmless to keep): `TrafficCarSpawner`'s populate function (own assert string confirms the class name), called twice per race from `sub_33C020` (once per traffic direction), max-count read from a different object (`*(a1[3]+16)`/`*(a1[3]+20)`) than `RaceEvent.TrafficCarCount`. Forcing that max-count to 0 (`g_enableTrafficSpawnerZeroTest`) can't reach zero alone because the populate loop is a `do { ... } while`, unconditionally queuing at least one candidate per track waypoint regardless of the max-count parameter - this is what motivated tracing one level further to `sub_C26A0`/`sub_C201C` above.
### 2.5 — Bonus finding: `TrackNavigator`'s real spline→world resolver (relevant to subtask 4)
Found while tracing grid placement: `PlaceCar` (`sub_2914DC`) calls `sub_3261B0(navigator, outBuffer, distanceAlongSpline, lateralOffset)` — the real, working `TrackNavigator::Resolve` implementation §5 already predicted subtask 4 would need. Only the forward direction (spline→world) is confirmed; the inverse (world position → spline distance, needed to read a moving car's *current* position for network sync) hasn't been located — worth checking `TrackNavigator`'s other vtable slots when subtask 4 starts. See `ANALYSIS.md` §6hh Q4.
### Remaining before implementation
1. Directly confirm `sub_2B88BC`'s opponent-vector argument is really `OpponentCollection`'s own built vector (high confidence from matching shapes, not yet byte-traced).
2. Only if a substituted real-player slot needs it: map `Opponent`'s AI-tuning sub-object (`+40..+75`) to explicitly suppress AI behavior — may not be necessary if a real player's own input naturally overrides it.
---
## 4. Lobby UI & flow — see LOBBY_UI_DESIGN.md
Everything about the lobby overlay's screens, flow, data schema, player identity/profile, IP:port addressing, rewards validation, and ready-state UX now lives in a dedicated file — **[LOBBY_UI_DESIGN.md](LOBBY_UI_DESIGN.md)** — kept separate so this document stays focused on native/engine integration. Clickable prototype: **[LOBBY_PROTOTYPE.html](LOBBY_PROTOTYPE.html)**.
One decision from that doc is load-bearing here too, since it's really a native-engine question: the wait for slow-loading peers happens **at the engine level**, not on a custom Compose screen — a client that's ready sits in the game's own native pre-race starting-grid scene (looping, not our UI) with only a thin overlay status strip on top, and the real 3-2-1-GO countdown is held until everyone's in (or the 45s hard timeout). See LOBBY_UI_DESIGN.md §7 for the full reasoning and the still-open hook work this needs.
<details>
<summary>Archived: original §4-4c content (superseded by LOBBY_UI_DESIGN.md, kept here for history)</summary>
### 4. Lobby UI decision (original)
**Revised recommendation (updated after correcting ANALYSIS.md §3.3): use a hybrid.** Full interactive lobby screens (player list, ready-up, countdown) still go through option (a) — an overlay Android `View` on top of the game's `GLSurfaceView` — because that pipeline has no native concept of touch-driven widgets. But simple in-world/in-menu indicators (the green/red map markers for active lobbies) should go through the **`BitmapGraphics` native text/atlas bridge** instead of a separate overlay, since it's verified, already trusted by the game's own renderer, and requires no new Flow/SB authoring.
Corrected reasoning (this session confirmed, via decompiling the actual JNI call sites, that `com.ea.ironmonkey.BitmapGraphics` — already reverse-engineered as `launcher/app/src/main/java/com/ea/ironmonkey/BitmapGraphics.kt` — is a real, live native→Java upcall bridge: `Canvas.drawText` into an offscreen `Bitmap`, read back via `AndroidBitmap_lockPixels`, and blitted into a native GLES font-atlas texture every time UI text is drawn. See ANALYSIS.md §3.3 for the full call chain and renamed IDA functions):
| Concern | Map markers → `BitmapGraphics` extension | Full lobby screens → Android overlay `View` |
|---|---|---|
| Mechanism | Add a method (e.g. `drawMarker(x, y, color)` via `canvas.drawCircle`) to `BitmapGraphics.kt`; call it from a hook alongside the existing `drawString` JNI call site (`0x5625a0`) | Standard Android `View`/Compose overlay added to the activity's view hierarchy above/around the `GLSurfaceView` |
| Implementation cost | Low — one new Kotlin method, one new hook call from `mpcore` at the point we know a lobby is active | Low — standard Android UI, fast iteration |
| Visual integration | Genuinely native — rendered through the exact same atlas-blit path as the game's own UI text, in the game's own coordinate space | Good but technically a separate layer composited over/under the GL surface |
| Touch/interaction support | None — this pipeline only produces pixels in a texture; no input handling | Full Android input handling, exactly what's needed for player list + ready buttons |
| Risk to singleplayer | Low — purely additive draw call, no-ops when no lobby is active | None — overlay is purely additive, toggled only when a multiplayer session exists |
| Car/upgrade display integration | N/A | We don't render the car ourselves either way — invoke the game's existing native car-select screen (same one singleplayer uses) as a sub-flow, our overlay only wraps around it |
Both are additive and don't touch Flow/SB authoring, so neither destabilizes the shared menu system. This split (markers via the verified `BitmapGraphics` path, interactive screens via overlay `View`) is still a **preliminary** call — revisit once the "Choose Car" native hand-off and a first marker hook are actually implemented. Record any reversal here with reasoning.
Still open: whether `EAMText`/`GlyphMeshGLES`/FreeType (also present in the binary, see ANALYSIS.md §3.3) plays any role that would interfere with or duplicate a `BitmapGraphics`-based marker — not yet determined, low priority until the marker hook is actually attempted.
---
## 4a. Lobby flow decision and data schema
**Flow decision:** Scenario 1 — Compose/View overlay for lobby creation and management, native car-select (filtered by the lobby's class setting) as a sub-flow, ready-checkmark to confirm. The "lobbies as cards on real street pins" idea (Scenario 2) is shelved: it depends on hooking into visible-street `MapTrack` event population, which the [[visible-streets-investigation]] found no viable path for after exhausting `AddEvent`/`Career`/`Progression`/property-registry. Scenario 1 needs no further RE — arbitrary track loading (§3a) and the native car-select screen already work or are known-reachable.
**Data schema** (pure data shape, no UI/transport concerns):
```
Lobby {
id: string
hostPlayerId: string
trackId: string // arbitrary track, see §3a BuildTrackScenePath hook
classRestriction: int | null // car class filter applied to native car-select; null = any
rewards: { // reuses the CashReward shape found on RaceEvent, ANALYSIS.md §6aa
bronze: int
silver: int
gold: int
}
players: [LobbyPlayer]
}
LobbyPlayer {
playerId: string
displayName: string // from the local PlayerProfile, see §4c — sent to peers on join
avatarId: string // preset id ("color+icon"), see §4c — not a photo/upload
isLeader: bool // explicit flag, not inferred from hostPlayerId match —
// keeps leader-ness a first-class, UI-visible fact instead of
// something every screen has to re-derive by comparison
ready: ReadyState
carId: string
colourIndex: int
mods: [int | null, int | null] // exactly 2 upgrade slots; each is a CarMod ModType id
// (or null = "НЕТ"/empty). Purely for display — other players
// resolve name/icon/description client-side from the car's own
// CarDescription.Mods catalog, already loaded for the garage
// screen. No numeric value/balance computation server-side.
}
enum ReadyState {
SetsUpLobby // leader only — track/class/rewards configuration
ChoosingCar
ChoosingRace
Loading // race-start fired, client is loading its own RaceLoaderTask
NotReady
Ready
}
```
Confirmed against the live in-game upgrade screen (2 slots, `ВЫБОР УЛУЧШЕНИЯ` list) and the `CarMod` struct in `alfaromeo_4c_2012_desc.prefabs.sb` — 7/7 checked entries matched by price (`ModType` 1/4/5/6/7/8 confirmed, `TYRES_REINFLATING`/`CHASSIS_REINFORCED`/`BODY_IMPACT`/`POWERTRAIN_POWERPACK`/`NITROUS_BURN`/`NITROUS_EARN`). `ModType` is a unique per-option id, not a shared category grouping — an earlier hypothesis to that effect is superseded. The separate `im::app::car::CarPart`/`UpgradeParts` catalog (with `skipCost`/`orderTimeMinutes` timer-gating fields) has no live instance data in any checked file and appears unused in this build — not part of this schema.
**Resolved (2026-08-18, see §4b for reasoning):**
- Host-leaves-lobby: auto-promote the longest-connected remaining `LobbyPlayer` to leader; dissolve only when the lobby becomes empty. Keeps a session alive across a flaky host connection instead of punishing everyone else.
- `classRestriction`: **enforced explicitly, at the overlay layer, not the native carousel** (revised 2026-08-19) — car_select has no known native class-filter hook (`RACEEVENT_CATEGORYTAG_OFFSET` was tried and is a confirmed false lead, PROGRESS.md cont.58), and rather than leave that as an excuse to only show the restriction as a label, the client-side flow explicitly checks the chosen `carId`'s class against `classRestriction` right after `onCarSelected` fires: a mismatch **blocks** `ready` (visible error state, forces the player back into car_select) instead of just being advisory. Revisit as a true hard filter — blocking the native carousel itself before a mismatched pick is even possible — if the real class-filter mechanism is ever found (cont.59/60's open leads).
**Still open, needs a product decision:** the straggler/timeout policy during the loading phase — see §4b step 5.
---
## 4b. Full lobby flow (designed 2026-08-18)
Every step below is tagged **[proven]** (already implemented and live-tested on device per PROGRESS.md), **[wired, needs new UI]** (the native hook/JNI plumbing exists, only the lobby overlay code calling it is missing), or **[open]** (real engineering work not started). This distinction matters more than usual here because two of this project's biggest proven results — the track-substitution hook (§3a) and the car/color/upgrade capture (§3a of PROGRESS.md cont.58-63b) — were each built against a *different* entry point (a real, normal race event) than the one the synthetic "LAN: `<lobby>`" event currently reaches, and those two paths are not yet joined. See step 5 for why that matters.
### Phase 0 — Entry point **[open, trivial]**
A small persistent button (e.g. bottom-corner Compose overlay, always present once `onMapLoaded` has fired at least once) opens the lobby overlay full-screen on top of `GameGLSurfaceView`. Same `FrameLayout`-stacking pattern already used for `buildCarSelectionOverlay()` (`GameActivityMain.kt`) — the GL surface keeps rendering underneath (map stays visible/animated), our Compose content just takes over touch input while open.
### Phase 1 — Create or join **[open — no networking yet]**
- **Create**: leader picks lobby name, track (any `region{N}_{city}_track{M}` confirmed to exist per §3a's own caveats — reuse that same validated list, do not hand-author a new one), optional `classRestriction`, reward tier (`CashReward`, ANALYSIS.md §6aa — real engine struct, not invented). Leader becomes `LobbyPlayer{isLeader=true, ready=SetsUpLobby}`.
- **Join**: LAN broadcast discovery (ARCHITECTURE §5, SAMP-style UDP) or direct IP:port. Neither transport exists yet — RakNet integration hasn't started (§5). The prototype (see chat) stands this phase up with **local mock state only** (no real second device) until RakNet lands.
### Phase 2 — Player list / waiting room **[open UI, wired data underneath]**
Real-time roster: name, ready-state icon, chosen car icon + colour swatch (same visual as the existing `CarSelectionBadge`, just one per row instead of a single corner badge), leader crown. Leader additionally sees "Начать гонку", disabled until every `LobbyPlayer.ready == Ready`.
### Phase 3 — Native car-select hand-off **[wired, needs new UI]**
This is the one phase where the hard native work is already done:
- Tapping "Выбрать машину" calls `MultiplayerCore.triggerTrueDirectCarSelectJump()`**[proven]**, jumps straight into the real native car_select screen for our synthetic event, no map navigation needed (PROGRESS.md cont.48/57). Fallback to `MultiplayerCore.triggerCarSelectTest()` (opens EventDetails first, then auto-skips — also **[proven]**) if the true-jump's own warm-up precondition (`g_realEventDetailsVisitHappened`, i.e. at least one real event_detail→car_select transition already happened this session) hasn't been satisfied yet — the "cold session" gap noted in cont.55/57, still open. The lobby overlay should shrink to a small corner badge (not fully dismiss) while this runs, matching how `CarSelectionBadge` already coexists with the game's own UI.
- Player interacts with the fully native, unmodified car_select + loadout screens (real rendering, real touch — by design, ARCHITECTURE §4 already decided this stays native).
- `onCarSelected(carId, colorName, r,g,b,a)`**[proven, wired to Kotlin]** — fires the moment car_select's own CONTINUE is tapped (cont.61/63b).
- `onUpgradesAccepted(slotIds, carMods)`**[proven, wired to Kotlin]** — fires when the loadout screen's controlled BACK×3 exit lands back on the map (cont.39-41).
- The lobby overlay listens for `onUpgradesAccepted` (or, if the player has no mods to set and the loadout screen is skipped, the underlying "back on map" signal that same hook chain already detects) as the "player is done, back to lobby" trigger, restores itself to full view, and fills that player's roster row from the already-captured `CarSelectionState`. Player then taps a "Готов" toggle → `ready = Ready`.
### Phase 4 — Ready-check & start **[open — needs RakNet]**
Leader's "Начать гонку" broadcasts the final race definition (track id, per-player car/colour/mods) once every player is `Ready`. No transport exists yet to carry this — it's a data-shape problem only right now (the `Lobby`/`LobbyPlayer` schema above already covers what needs to go over the wire).
### Phase 5 — Loading, and the "someone's still loading" problem **[mostly open — the real gap in this whole flow]**
Two unresolved issues sit here, and they're different in kind:
1. **Which race event actually carries the load.** The synthetic `RaceEvent` used to reach car_select on demand (step 3) has its loadout-confirm **deliberately redirected back to the map today**, specifically because it has no real track/scene reference — letting it proceed into `RaceLoaderTask` crashes on a NULL start-line lookup (PROGRESS.md cont.36, confirmed root-caused via cont.41's A/B: a *real* event's loadout-confirm sails through to an actual playable race with zero crashes, using the exact same code path). Giving the synthetic event a real track/scene reference is flagged in cont.37/41 as "a materially bigger investigation than anything solved so far." **Recommended path, not yet implemented**: don't fight that — have the lobby's "start" instead re-target a *real*, existing race event as the vessel (any regular point-to-point event works, since §3a's track-substitution hook already proves the geometry can be swapped after the fact), and let that real event's own loadout-confirm proceed normally instead of being redirected. This sidesteps the missing-track-scene crash entirely instead of solving it. Needs its own short investigation (mostly wiring, since both halves — real-event loadout-confirm passthrough, and track substitution — already independently work) before it can be marked proven.
2. **Holding a fast-loading client at the start line.** Nothing hooks the moment between "this client's `RaceLoaderTask` finished" (candidate signal: `InRaceState` construction, RTTI-confirmed §3.1, or `RaceLoaderTask_DispatchInitialFSMEvents`'s final event, §6b stage 8 — either is a real, already-located anchor, just not yet hooked for this purpose) and "gameplay actually starts ticking." **This needs new hook work**: freeze input/sim on that client once loaded, show a small "Ожидание игроков (2/4)" overlay (same Compose layer, minimized), and only release once the host broadcasts "everyone's in." `RaceLoaderTask_ExecuteLoadSequence`'s own `SetLoadProgress(this, N)` calls (§6a slot 4, fractions 0.10.8) are a good source for a real, granular per-client progress percentage to report to peers, rather than inventing a synthetic one.
3. **Straggler/timeout policy — resolved 2026-08-18**: hard timeout, auto-start. Default 45s (configurable) counted from when the first client reports fully loaded; whoever hasn't reported loaded by then is left behind and the race starts without them. Simplest to implement/reason about, no host-facing decision UI needed — accepted tradeoff is occasionally starting a race short a player on a slow device. Still needs the actual per-client "loaded" signal (point 2 above) and a decision on how a left-behind player is represented (spectator vs. rubber-band-in-when-ready) — lower priority than the timeout mechanism itself.
---
## 4c. Player identity (name + avatar) — added 2026-08-18
Raised as a gap while prototyping the lobby screens: every player row needs *something* to display before any of them have picked a car, and it can't come from an EA account — ARCHITECTURE §8 already rules out the Synergy backend for anything multiplayer-related, and this project has no login system of its own.
**`PlayerProfile`**, stored **locally on-device only** (Android `SharedPreferences`/DataStore — no server round-trip, no RE work, plain new Kotlin), edited once on first run and editable any time after from a chip in the lobby browser header:
```
PlayerProfile {
displayName: string // free text, local default e.g. "ROOKIE_01"
avatarId: string // one of a small fixed set of {color, glyph} presets
}
```
**Avatar is a preset, not a photo** — a small fixed palette of colour+glyph combinations (e.g. 🏎️/⚡/🔥/🏁/★/🌙/⚙/💀 over a themed accent colour), picked from a grid. Deliberately not a camera/gallery photo upload: no permissions prompt, no image storage/transport concerns once RakNet is carrying lobby state, and it matches the HUD-badge visual language already established by `CarSelectionBadge`/the roster rows. Revisit only if the game ever needs to show a real player photo somewhere — nothing in the current design requires it.
`PlayerProfile` is copied into `LobbyPlayer.displayName`/`avatarId` (schema above) at join/create time and travels with the rest of the lobby state once RakNet exists — no separate sync mechanism needed.
</details>
---
## 4d. Debug menu (Compose) — see DEBUG_MENU.md
An on-map, developer-only debug overlay, added 2026-08-19 — full detail (activation mechanism, enable flag, panel inventory) now lives in a dedicated file, **[DEBUG_MENU.md](DEBUG_MENU.md)**, kept separate for the same reason as the lobby UI split (§4). One decision worth surfacing here: the enable/disable switch (`DebugFeatures.debugMenuEnabled`) deliberately lives in `mpcore`, not `app` — a direct instruction, so debug-tooling gating stays in the shared module rather than duplicated per-Activity.
---
## 5. Network layer (RakNet) — high-level plan (later stages, laid out now)
- **Client**: embedded inside `mpcore` (same process as the game). Owns a RakNet `RakPeerInterface`, connects to either a LAN-discovered server or a manually-entered/internet server address.
- **Server**: standalone headless C++ binary/service, links RakNet directly — explicitly **not** a copy of `libapp.so`'s game runtime. Responsibilities: lobby membership, ready-check aggregation, countdown trigger, relaying position/state updates between clients (topology TBD: pure relay vs. light authority — default to relay first, revisit if cheating/desync becomes a concern).
- **LAN discovery**: SAMP-style UDP broadcast query/response on a fixed port, server replies with lobby metadata (name, player count, track/event). Reference implementation pattern: `GTASA_examples/` (any version — wrappers differ, core discovery/RakNet integration pattern is what matters).
- **Internet servers**: direct-connect by IP:port initially; a master-server/list mechanism is a later refinement (not required for first working version).
- **Position sync**: builds directly on subtask 4 (coordinate read/write hook on the car/track-navigator layer) — the RakNet layer's job is just to move `(distance_along_spline, lateral_offset, height, or raw Vector3+rotation)` tuples between peers at some tick rate; the actual "make the opponent car appear at X" mechanism is the native hook, not the network code.
No RakNet integration work has started yet — this section exists so later native-layer decisions (e.g., what a hooked position-update struct looks like) are made with the eventual network payload shape in mind.
---
## 6. Build & deploy pipeline for native patches
Confirmed this session:
- `native_lib/` (top-level) is **not under any version control** (`git rev-parse` fails there — the repo root itself has no `.git`). It is the sole safety net for "what the game originally shipped" and the file the IDA `.i64` was built from. **Rule: `native_lib/libapp.so` (and siblings) must never be overwritten in place.** Any patched binary is a *new* file.
- `launcher/` is its own separate git repository, and — checked via `git ls-files``launcher/app/src/main/jniLibs/armeabi-v7a/*.so` **is tracked in git** (not gitignored), including `libapp.so`. Currently byte-identical to `native_lib/libapp.so` (same SHA-256). This means the copy that actually feeds the APK build has its own independent safety net (`git diff`/`git log`/`git checkout` inside `launcher/`), on top of our manual changelog.
- **Workflow for every native patch, going forward**:
1. Produce the patched bytes into a new file under `native_lib/patched/` (created this session, empty for now — no patches exist yet), never touching `native_lib/libapp.so` itself.
2. Log the change in `PROGRESS.md` (offset, old/new bytes or hook description, why, which patched-file snapshot it corresponds to).
3. Copy the patched file to `launcher/app/src/main/jniLibs/armeabi-v7a/libapp.so` (overwriting the build input — safe, since that copy is git-tracked and diffable).
4. Build the APK (Gradle, already configured per the task brief) — user installs/tests on their own device or WayDroid (see §7).
- `mpcore` itself is the other half of "the magic": it does no static patching of `libapp.so` — instead, at `JNI_OnLoad` time, it resolves `libapp.so`'s **runtime base address** and installs hooks against `base + offset` (the `APP_ADDR()` macro, already in `armhook.cpp`). So there are two independent things to track in `PROGRESS.md`, and they should be logged distinctly: (a) actual byte patches to the shipped `.so` file itself (rare — e.g. removing an anti-tamper check), and (b) runtime hooks installed by `mpcore` (the common case for all the race/opponent/coordinate hooks in the subtask plan) — these don't modify the `.so` on disk at all, only memory at runtime, but should still be documented (what address, what it does, why) since they're just as capable of breaking singleplayer if wrong.
## 7. On-device testing capability (WayDroid)
Checked this session: `adb` and `waydroid` CLI are both installed on this machine and reachable from this session's shell. Attempted `waydroid session start`**it failed**: `Wayland socket '/run/user/1000/wayland-0' doesn't exist; are you running a Wayland compositor?`. Confirmed cause: the active desktop session here is GNOME-on-**Xorg** (`DISPLAY=:1`, real `Xorg`/`gnome-shell` processes, `WAYLAND_DISPLAY` unset) — WayDroid requires a genuine Wayland compositor and cannot render under plain X11.
**Fix (user action needed)**: log out and pick a Wayland session at the GDM login screen (e.g. "GNOME" without the "(X11)" suffix), then `waydroid session start` should succeed.
**Once a Wayland session is active, this session can plausibly**: `waydroid app install <apk>`, `adb connect`/`adb shell` for logcat and file access, `adb shell screencap` piped to a file and viewed via the image-reading tool for visual verification, and even blind UI interaction via `adb shell input tap/swipe` driven off those screenshots (same pattern as the browser-automation tool, just for Android). This would close most of the test loop without needing the user present for every check — still recommend the user do final verification on real hardware, since WayDroid's GPU/driver stack can behave differently than a real device for this kind of engine.
## 8. Non-goals / explicit constraints carried from the task brief
- Singleplayer must keep working with the mod installed and multiplayer inactive — every hook must be conditional/pass-through when no session is active.
- The dedicated server is a separate C++ service, not a headless instance of the Android game runtime.
- No use of any legacy EA "Synergy" backend endpoints (leaderboards/IAP/DRM) for multiplayer — confirmed in ANALYSIS.md §3.4 this is unrelated infrastructure and likely defunct server-side anyway.
---
## 9. ARM64-only device support (Android 14+ AArch32-less hardware) — see ARM64_TRANSLATION_LAYER.md
Everything about running `libapp.so` (ARM32) on newer devices whose CPU cores dropped AArch32 execution entirely now lives in a dedicated file — **[ARM64_TRANSLATION_LAYER.md](ARM64_TRANSLATION_LAYER.md)** — kept separate so this document stays focused on the mod's own integration architecture. Pure theory as of 2026-08-19, nothing implemented: the short version is an in-process, dynamic CPU-level translation layer (Unicorn/QEMU-TCG-class embeddable ARM32 core, Hangover/Box64-style API-boundary breakout for libc/JNI/GLES/audio) rather than a full-VM (VMOS-style) or a static ARM32→ARM64 recompile — the latter would invalidate every offset in `ANALYSIS.md`/§3a's RE work, the former discards all of this project's native-process integration for no reason.
File diff suppressed because it is too large Load Diff
+299
View File
@@ -0,0 +1,299 @@
# Closed beta: diagnostics, crash reports and tester workflow
Plan for shipping a preview build to a small group of testers and getting back
reports we can actually act on. Written 2026-09-21, the day the first playable
build appeared.
Decisions already taken (owner's call):
- **Delivery: a "send report" button using the system share sheet.** No backend,
no background upload. The tester sees the file and sends it. This keeps us out
of collecting data from other people's devices, which would otherwise need
consent handling, storage and a retention policy.
- **Scope: crashes and bug reports, performance counters, unhonoured shim
contracts, device model and OS version.**
## Why this needs building at all
Everything the engine currently reports goes to `__android_log_write` and
nowhere else (`util.cpp`'s `Log()`). That fails for beta in three separate ways,
each of which already bit us during development:
1. **logcat wraps.** Twice on 2026-09-21 a measurement was lost to it - once the
whole startup sequence, once the frame-counter history, which made a
frame-rate figure look far better than it was.
2. **Testers cannot retrieve logcat.** It needs a cable and a developer setup.
3. **A hang produces no log at all.** The black-screen bug that day left the
process alive and silent; the only evidence was three lines that had already
scrolled past.
## Part 1 - what the build must produce
### 1.1 In-memory log ring (the foundation)
`Log()` keeps writing to logcat, and additionally appends into a fixed-size
in-memory ring buffer (suggest 2 MB, tunable). **Nothing is written to disk
during normal play** - the log rate is high (per-frame GLES sampling alone
produces thousands of lines) and per-line file I/O would show up in the frame
time we just spent the day reducing.
The ring is allocated once at startup. It must be usable from a signal handler,
so: a plain pre-allocated byte array, no `malloc`, no locks that a crashed
thread might hold. A per-record sequence number plus a short spinlock-free
write is enough; a torn last record in a crash dump is acceptable.
### 1.2 Native crash handler
A handler for `SIGSEGV`, `SIGBUS`, `SIGABRT`, `SIGILL`, `SIGFPE` that writes a
report file and then chains to the previous handler so the normal tombstone
still happens.
**Async-signal-safety is not optional here and we have a recorded incident:**
calling `mmap()` inside a `SIGSEGV` handler has deadlocked on bionic in this
project before. The handler must use only a pre-allocated buffer, a
pre-opened-or-`open()`ed fd, and `write()`. No `malloc`, no `stdio`, no `Log()`,
no C++ allocation, no locks.
What to capture - this is the part specific to a translation layer, and it is
what made today's crash solvable in one reproduction:
- **The decoded guest address.** With the flat mapping, a host fault address is
`flat_map_base + guest_address`. On 2026-09-21 the fault at `0x6f464c459b`
decoded to guest `0x464c459b` by subtracting `x28`. The handler should do that
subtraction itself and print the guest address, since nobody reading a report
will do it by hand.
- **Which arena the guest address belongs to.** `guest_engine.cpp` already has
the classifier (`"thread-stacks arena"` and friends) - reuse it. "Fault in the
heap arena" and "fault 1.2 GB past the end of everything" are completely
different bugs and the report should say which.
- Full guest register set, and the host registers from `ucontext`.
- The last N KB of the log ring.
- Build stamp and device identity (below).
### 1.3 Hang detector
The black-screen bug was a **hang, not a crash** - no signal, no tombstone,
process alive at 2% CPU. A crash handler would have caught nothing.
A watchdog thread checks the `onDrawFrame` counter. If it has not advanced for
~10 seconds while the activity is resumed, it writes the same report the crash
handler would, tagged `HANG`, including a snapshot of every thread's state and
stack pointer. It should fire **once** per hang, not repeatedly.
### 1.4 Unhonoured-contract registry
Today's crash was found because a shim logged that it could not honour a
request, one line above the fault. That should be a first-class, structured
record rather than a log line we grep for.
A small registry: `ReportUnhonouredContract(area, detail)`, deduplicated by
string, counting occurrences. `rtti_shims.cpp`'s `use_facet`,
`jni_shim.cpp`'s silent zero-returns (task #51) and every other
"returning NULL because we do not implement this" path calls it. The report
carries the full deduplicated list.
This turns beta into a **gap-discovery mechanism**: the union of these lists
across testers is a prioritised work queue for what the game actually needs,
discovered from real play instead of guessed.
### 1.5 Session counters
Sampled once a second into a compact rolling summary, not one line per sample:
- frames per second - min, median, 10th percentile, and where the low ones
happened (which is what the median alone hides, as it did today)
- time from launch to first frame, and each level-load duration
- CPU time consumed by the process, and by the GLThread specifically
- peak CPU temperature
- guest heap: live, peak, arena exhaustion events
- thread-stack arena: peak in use, exhaustion events (the black-screen cause -
this must never again be discovered by reading a log tail)
### 1.6 Build and device identity
Every report starts with: build stamp (version plus a short git hash or build
timestamp baked in at compile time), device model, SoC, Android version, ABI,
available RAM, and screen size.
The build stamp matters more than it sounds. Twice on 2026-09-21 the wrong APK
was nearly measured - once a three-week-old release build installed by mistake.
With testers there is no chance to check by hand; the report must say which
build produced it.
### 1.7 The share button
A screen reachable from the pause menu: **"Report a problem"**. It bundles the
newest reports plus the current log ring into a single zip in the app's own
files directory and hands it to `ACTION_SEND`.
Before sharing it shows the tester a short plain-language summary of what is in
the file - log lines, device model, no personal data, no game account details.
They are sending it themselves; they should know what it is.
If the previous session ended in a crash or hang, offer to send that report on
the next launch, since the tester will not go looking for it.
## Part 2 - the tester-facing report form
Free-text bug reports from testers are usually unusable not because testers are
careless but because nobody told them which three facts matter. Keep it short -
a long form gets skipped.
**In-app, attached automatically:** build stamp, device, the log bundle. The
tester never types any of this.
**What we ask the tester for, in this order:**
1. **What were you doing?** One line. "Entered a race from the city map."
2. **What happened?** One line. "Black screen, music kept playing."
3. **What did you expect?** Only when it is not obvious.
4. **Can you make it happen again?** Every time / sometimes / happened once.
This single question decides whether we can chase it at all.
5. **Did you play for a while before it happened?** Yes/no. Specifically
included because the whole class of resource-exhaustion bugs - the
thread-stack arena, the guest heap - only shows up after a long session, and
testers do not think to mention it.
**Severity, defined by consequence rather than by feeling**, so it is not
argued about:
- **Blocker** - cannot continue playing; progress lost.
- **Major** - a feature does not work, but the session survives.
- **Minor** - visual or audio defect, gameplay unaffected.
**Ask them explicitly to send the report even when the game recovers.** A hang
that resolved itself still wrote a `HANG` report, and that is often the easier
one to diagnose.
## Part 3 - what we do with reports
Triage order, informed by what has actually been expensive to find:
1. **Unhonoured-contract list first, before reading the crash.** Today the
answer was in that list. It is cheap to check and frequently decisive.
2. **Decoded guest address and its arena.** Distinguishes a wild pointer from
arena exhaustion from a real logic bug, without any further work.
3. **Exhaustion counters.** If a thread-stack or heap arena hit its ceiling, the
crash is a symptom and the ceiling is the bug.
4. **Only then the register dump and the log tail.**
Group reports by build stamp before comparing anything. Mixing builds is how a
fixed bug looks like it is still present.
## Suggested order of work
Each step is independently useful, so the beta does not wait on the whole set.
1. Log ring + report file + share button, with device and build identity.
**Minimum shippable** - a tester can send something useful.
2. Crash handler with guest-address decoding.
3. Unhonoured-contract registry, with `use_facet` and the JNI zero-returns as
the first callers.
4. Hang detector.
5. Session counters.
## Deliberately out of scope
- **No backend, no automatic upload.** Chosen above. Revisit only if the manual
path proves too lossy in practice.
- **No unique device or user identifier.** Grouping by build stamp and device
model is enough at this scale and avoids tracking individuals.
- **No gameplay telemetry** - what cars, which races, how long played. It is not
needed to fix defects, and collecting it would change what this file is.
---
## Status 2026-09-21: the crash handler is built (plan section 1.2 + 1.7 partial)
Implemented and verified end to end on the Pixel 6a.
**Native** (`mpcore/src/main/cpp/crash_handler.cpp`). Hooks SIGSEGV, SIGBUS,
SIGABRT, SIGILL, SIGFPE with `SA_SIGINFO | SA_ONSTACK`, on a pre-allocated
alternate stack so a stack-overflow crash is still reportable. Everything the
handler needs - the output path, the build stamp - is built at install time;
inside the handler only `open`/`write`/`close` and hand-written integer
formatters run. No malloc, no snprintf, no JNI. It chains to the previous
handler afterwards, so Android still writes its own tombstone.
The report decodes the fault address: a host address inside the guest window is
also printed as the **guest** address, and flagged when it is past the end of
the mapped region ("a wild pointer, not a real guest object"). That is the
number worth reading, and nobody will subtract the base by hand from a tester's
report.
**Java**. `CrashReportActivity` renames the pending report (the handler writes a
fixed name, since it cannot safely format a timestamp), zips it with device and
build details, shows it, and offers ACTION_SEND through a FileProvider scoped to
the crash directory only. Reports live in
`Android/data/<pkg>/files/crashes` - reachable over USB with no permission.
**Verified**: handler installs; `kill -11` produces a report with the right
signal, registers and a correct "outside the guest window" verdict; the next
launch detects it, renames it, builds the zip, and `CrashReportActivity` becomes
the resumed activity. Files land where intended.
**Not verified**: what the screen actually looks like. The test device locked
itself, so every screenshot was of a sleeping or locked display - which is also
why an early "black screen" reading was wrong and led to one unnecessary fix
(explicit colours, harmless and kept). The layout needs a human to unlock the
phone and look.
### Three failures on the way, all worth keeping
1. **Installed too early.** The call sat at the top of `onCreate`, but
`libmpcore.so` is only loaded later by `loadCore()` -
`UnsatisfiedLinkError`, caught and logged. Moved to immediately after
`loadCore()`.
2. **Missing `extern "C"`.** The JNI function was C++-mangled
(`_Z65Java_...`), so the JVM could not find it. The symptom was identical
to the load-order bug above, which cost a wrong fix before the symbol table
was actually read.
3. **Blocked activity start.** Checking for a pending report in
`GameActivityMain.onCreate` - which starts the report screen and finishes
itself - was refused by the platform (`BAL_ALLOW_GRACE_PERIOD`) and dumped
the tester on the home screen. The check belongs in `PermissionsActivity`,
the visible launcher entry.
---
## Status 2026-09-22: game data ships inside the APK
A tester now installs one file and plays. No separate .obb download, no file
manager, no instructions about where to put anything.
**How.** The ~595 MB archive ships as `assets/game_data.obb`, and
`androidResources { noCompress += "obb" }` keeps it **stored** rather than
deflated - it is already compressed, so re-compressing would cost build and
install time for nothing. On first launch `GameDataUnpackActivity` copies it to
`getObbDir()/main.<versionCode>.<package>.obb`, which is exactly the path
`GameActivityMain.obbFullPath` already builds, so no other code knows this
happened.
The copy writes to a `.part` file and renames only on success. A half-written
archive that merely *exists* would pass a naive check and send the game off to
read truncated data - failing far from the cause, which is the failure mode this
project keeps paying for. Free space is checked before starting rather than
500 MB in.
**Measured on the Pixel 6a**, with the real OBB renamed aside to simulate a
clean device:
| | |
|---|---|
| APK size | 615 MB (was 22 MB) |
| build time | 16 s - aapt2 handles the stored asset without trouble |
| `adb install` | 31 s |
| unpack | under 8 s - it finished before the first progress poll |
| result | **md5 identical** to the original OBB |
| game afterwards | 2,893 frames, 0 faults, `mAssetLocationType=OBB` |
**Costs worth stating.** The device needs the APK plus the unpacked copy at
once: about 1.2 GB free at install time, ~600 MB after. And the data exists
twice on disk permanently, since Android keeps the APK.
**The alternative not taken.** Because the asset is stored uncompressed, its
bytes sit contiguously in the APK, so the engine's own `Shim_open`/`Shim_read`
could serve the OBB path straight out of the APK at an offset - no copy, no
duplication. That is a real option if the 600 MB ever matters, but it adds a new
failure surface in file I/O right before a beta, and the ask here was explicitly
for self-extraction.
+31
View File
@@ -0,0 +1,31 @@
# DEBUG_MENU.md — On-map debug menu (Compose)
Living document for the in-app debug menu — a developer-only overlay for poking at mod state while testing, starting with a money editor. Split out as its own file (matching the `LOBBY_UI_DESIGN.md` precedent) so `ARCHITECTURE.md` doesn't accumulate UI-only debug-tooling detail. Update this file whenever the debug menu gains a new panel or its wiring changes.
---
## 1. Purpose and scope
Requested as a debugging aid, not a player-facing feature: a way to poke at mod/game state live on-device without rebuilding. First panel is a money editor. More debug panels are expected to be added here over time (same menu, more entries) rather than each getting its own ad-hoc overlay.
**Explicit enable/disable mechanism**: `DebugFeatures.debugMenuEnabled` (`mpcore/src/main/java/nfs/mod/mpcore/DebugFeatures.kt`) — a single `var` flag, default `true` during active development. `GameActivityMain.onCreate` only adds the debug overlay's `ComposeView` to `mFrameLayout` when this flag is `true`, so flipping it to `false` removes the debug menu from the view hierarchy entirely (not just hidden) ahead of any release build. Chosen to live in `mpcore` (not `app`) per direct instruction, so the one gate for all debug tooling stays in the shared module rather than scattered across Activities.
## 2. Activation — button on the map
Reuses the exact mechanism `CarSelectionBadge` already established (`ARCHITECTURE.md` §4 / `LOBBY_UI_DESIGN.md` §1): a `ComposeView` overlay added to `mFrameLayout` above the game's `GLSurfaceView`, visibility driven by `GameEvents.onMapLoaded()` (fires via the existing, already-proven `MapTrack::HandleEvent` native hook — see `PROGRESS.md`'s `dispatchMapLoaded` history). No new native hook needed: the map-loaded signal already exists and already fires reliably.
- `DebugMenuVisibility` (new, app module) — a `GameEventListener` object exposing `mapLoaded` as Compose state, same shape as `CarSelectionState` in `CarSelectionOverlay.kt`.
- A small floating button, bottom-end corner (deliberately opposite `CarSelectionBadge`'s top-start corner so the two never overlap), appears once `mapLoaded` is true.
- Tapping it opens the debug panel as a dialog over whatever screen is currently showing.
## 3. First panel — money editor (stub, not wired to real game state)
**Explicit scope decision (2026-08-19, direct user answer)**: for now this is a **stub** — a plain Compose text field + "Apply" button that only updates local Compose state (`DebugMoneyState.amount`), logged but **not** written into the game's actual memory/save state. Reason: no RE work has located the player's real cash balance (getter/setter/address) yet — `ANALYSIS.md`/`PROGRESS.md` only cover `CashReward` (a *race event's* bronze/silver/gold reward definition, §6aa/6z of `ANALYSIS.md`), not the player's own wallet/balance. Wiring this panel to the real balance is tracked as a **separate, later RE task** (find where `Profile`/`CurrentState` — both named in the `ISaveable` family, `PROGRESS.md` cont. "2026-08-06" save/profile entry — actually stores the spendable cash total, then add a native setter hook), not part of this UI work.
## 4. Screen inventory
| Panel | State | What it does |
|---|---|---|
| Money editor | **Stub** | Text field + Apply button; writes to local Compose state only, no game effect yet |
More rows added here as panels are added.
File diff suppressed because one or more lines are too long
+200
View File
@@ -0,0 +1,200 @@
# LOBBY_UI_DESIGN.md — Multiplayer Lobby UI & Flow
Living document for the lobby overlay's UI/UX — screens, flow, data shape, and the product decisions behind them. Split out of `ARCHITECTURE.md` on 2026-08-18 (that file now only links here) so the native/engine-integration doc doesn't keep growing with UI-only decisions. Update this file whenever a screen or flow decision changes; don't let it drift from the prototype.
**Clickable prototype:** [`LOBBY_PROTOTYPE.html`](LOBBY_PROTOTYPE.html) — self-contained, open directly in any browser, no server needed. Landscape orientation (matches the game). Every screen is tagged OVERLAY (our Compose UI) / NATIVE (real, unmodified game screen) / TRANSITION, with inline notes on what's already proven on-device vs. still open.
---
## 1. Lobby UI mechanism — overlay `View`, markers via `BitmapGraphics`
**Recommendation: hybrid.** Full interactive lobby screens (player list, ready-up, countdown) go through an Android overlay `View`/Compose layer on top of the game's `GLSurfaceView` — the native UI pipeline has no concept of touch-driven widgets, so it can't host these itself. Simple in-world/in-menu indicators (e.g. a future green/red map marker for an active lobby) should instead go through the **`BitmapGraphics` native text/atlas bridge** (`launcher/app/src/main/java/com/ea/ironmonkey/BitmapGraphics.kt`, ANALYSIS.md §3.3) — verified, already trusted by the game's own renderer, no new Flow/SB authoring needed.
| Concern | Map markers → `BitmapGraphics` extension | Full lobby screens → Android overlay `View` |
|---|---|---|
| Mechanism | Add a method (e.g. `drawMarker(x, y, color)` via `canvas.drawCircle`) to `BitmapGraphics.kt`; call it from a hook alongside the existing `drawString` JNI call site (`0x5625a0`) | Standard Android `View`/Compose overlay added to the activity's view hierarchy above/around the `GLSurfaceView` |
| Implementation cost | Low — one new Kotlin method, one new hook call from `mpcore` at the point we know a lobby is active | Low — standard Android UI, fast iteration |
| Visual integration | Genuinely native — rendered through the exact same atlas-blit path as the game's own UI text, in the game's own coordinate space | Good but technically a separate layer composited over/under the GL surface |
| Touch/interaction support | None — this pipeline only produces pixels in a texture; no input handling | Full Android input handling, exactly what's needed for player list + ready buttons |
| Risk to singleplayer | Low — purely additive draw call, no-ops when no lobby is active | None — overlay is purely additive, toggled only when a multiplayer session exists |
| Car/upgrade display integration | N/A | We don't render the car ourselves either way — invoke the game's existing native car-select screen (same one singleplayer uses) as a sub-flow, our overlay only wraps around it |
Both are additive and don't touch Flow/SB authoring, so neither destabilizes the shared menu system. **Preliminary** — revisit once a first marker hook is actually implemented.
Still open: whether `EAMText`/`GlyphMeshGLES`/FreeType (also present in the binary, ANALYSIS.md §3.3) plays any role that would interfere with a `BitmapGraphics`-based marker — low priority until that hook is attempted.
---
## 2. Flow decision & data schema
**Flow:** Compose/View overlay for lobby creation and management, native car-select (filtered by the lobby's class setting) as a sub-flow, ready-checkmark to confirm. "Lobbies as cards on real street pins" is shelved — depends on hooking visible-street `MapTrack` event population, which [[visible-streets-investigation]] found no viable path for. The overlay approach needs no further RE — arbitrary track loading (ARCHITECTURE §3a) and the native car-select screen already work.
```
Lobby {
id: string
hostPlayerId: string
address: { ip: string, port: int } // see §4 — shown for both LAN and WAN join
trackId: string // arbitrary track, see ARCHITECTURE §3a BuildTrackScenePath hook
classRestriction: int | null // car class filter applied to native car-select; null = any
maxPlayers: int // real cap not decided yet (see §6a) — UI works for whatever it ends up being
rewards: { // reuses the CashReward shape found on RaceEvent, ANALYSIS.md §6aa
bronze: int
silver: int
gold: int
} // see §5 — explicit fields, validated at creation, not free-floating pills
players: [LobbyPlayer]
}
LobbyPlayer {
playerId: string
displayName: string // from the local PlayerProfile, see §3 — sent to peers on join
avatarId: string // preset id ("color+icon"), see §3 — not a photo/upload
isLeader: bool // explicit flag, not inferred from hostPlayerId match —
// keeps leader-ness a first-class, UI-visible fact instead of
// something every screen has to re-derive by comparison
ready: ReadyState
carId: string
colourIndex: int
mods: [int | null, int | null] // exactly 2 upgrade slots; each is a CarMod ModType id
// (or null = "НЕТ"/empty). Purely for display — other players
// resolve name/icon/description client-side from the car's own
// CarDescription.Mods catalog, already loaded for the garage
// screen. No numeric value/balance computation server-side.
}
enum ReadyState {
SetsUpLobby // leader only — track/class/rewards configuration
ChoosingCar
ChoosingRace
Loading // race-start fired, client is loading its own RaceLoaderTask
NotReady
Ready
}
```
Confirmed against the live in-game upgrade screen (2 slots, `ВЫБОР УЛУЧШЕНИЯ` list) and the `CarMod` struct in `alfaromeo_4c_2012_desc.prefabs.sb` — 7/7 checked entries matched by price (`ModType` 1/4/5/6/7/8 confirmed, `TYRES_REINFLATING`/`CHASSIS_REINFORCED`/`BODY_IMPACT`/`POWERTRAIN_POWERPACK`/`NITROUS_BURN`/`NITROUS_EARN`). `ModType` is a unique per-option id, not a shared category grouping. The separate `im::app::car::CarPart`/`UpgradeParts` catalog (`skipCost`/`orderTimeMinutes` timer-gating fields) has no live instance data anywhere checked — not part of this schema.
**Resolved decisions:**
- **Host-leaves-lobby**: auto-promote the longest-connected remaining `LobbyPlayer` to leader; dissolve only when the lobby becomes empty.
- **`classRestriction`**: **enforced explicitly by our own overlay** (revised 2026-08-19, was "soft/advisory only") — car_select has no known native class-filter hook (`RACEEVENT_CATEGORYTAG_OFFSET` is a confirmed false lead, PROGRESS.md cont.58; 5 independent dead ends, cont.58-60), so enforcement can't happen inside the native carousel — but that's not a reason to skip enforcing it at all. The overlay checks the chosen `carId`'s class against `classRestriction` the moment `onCarSelected` fires; a mismatch **blocks** readying up (a visible warning replaces the ready button, "Сменить" is the only way forward) rather than silently letting a mismatched car through. Still a client-side check, not a true hard filter on the carousel itself — revisit if the real class-filter mechanism is ever found.
- **Straggler/timeout policy**: hard timeout, auto-start, default 45s from when the first client finishes loading — see §6.
---
## 3. Player identity — profile chip, not a form
Every player row needs *something* to display before anyone's picked a car, and it can't come from an EA account — ARCHITECTURE §8 already rules out the Synergy backend for multiplayer, and this project has no login system.
**Where the button lives**: a small persistent chip (avatar + name) pinned in a fixed corner, present on **every OVERLAY screen** (map's entry point, lobby browser, create, waiting room) — not buried inside one specific screen's header. It's global identity, so it shouldn't only be reachable from one place. It deliberately does **not** appear on the two screens where our footprint is intentionally minimal (the native car_select hand-off, the start countdown) — same principle as the minimized lobby badge there.
Tapping it opens a **popover, not a full-screen navigation** — name field + an 8-preset avatar grid, right there over whatever screen you were on. Editing your name mid-lobby-browsing shouldn't cost you your place in the flow.
**First run — not a forced form.** Auto-generate a usable default (`ROOKIE_####`, random 4-digit suffix, random avatar preset) the moment the profile is first read, so multiplayer works immediately with zero typing. Edit anytime via the chip. This mirrors how most lobby-based games (Fortnite, Fall Guys, etc.) handle it — a name you can live with immediately, not a gate.
```
PlayerProfile {
displayName: string // local default "ROOKIE_####" (random), freely editable
avatarId: string // one of a small fixed set of {color, glyph} presets
}
```
Stored **locally on-device only** (Android `SharedPreferences`/DataStore — no server round-trip, no RE work, plain new Kotlin). **Avatar is a preset, not a photo** — a small fixed palette of colour+glyph combinations (🏎️/⚡/🔥/🏁/★/🌙/⚙/💀 over a themed accent colour), picked from a grid. Deliberately not a camera/gallery upload: no permissions prompt, no image transport concerns once RakNet carries lobby state, and it matches the HUD-badge visual language already established by `CarSelectionBadge`. Revisit only if the game ever needs a real player photo — nothing today requires it.
`PlayerProfile` is copied into `LobbyPlayer.displayName`/`avatarId` at join/create time and travels with the rest of the lobby state once RakNet exists.
---
## 4. Addressing — IP and port, LAN and WAN alike
Every lobby reference carries an explicit `address: { ip, port }` (schema above), surfaced consistently rather than hidden behind "LAN auto-discovers, WAN doesn't need it":
- **LAN browser cards** show the host's LAN address (`192.168.1.42:7777`) alongside name/track/players — not just a bare "● LAN" ping indicator.
- **Manual "по IP" join tab** gets real address + port input fields (not a stub), validated before the connect action enables: IPv4 dotted-quad or hostname pattern, port an integer in `165535`.
- **Waiting room** shows the host's own address in the side panel ("Адрес: `192.168.1.42:7777`", with a copy affordance — a plain icon button, not a text label, to stay out of the way of the address itself) — for sharing with WAN friends who need to type it in manually, since there's no master-server/matchmaking list yet (ARCHITECTURE §5 — direct-connect only for the first working version).
Default placeholder port used throughout mocks/design: `7777` — not a final decision, just a consistent stand-in until RakNet setup actually picks one.
---
## 5. Rewards — explicit fields, validated
Bronze/Silver/Gold are real numeric inputs in Create Lobby, not fixed display pills. Validation, checked live as the leader types:
- Each value is a positive integer.
- Non-decreasing: `bronze ≤ silver ≤ gold`.
- "Создать" stays disabled with an inline error message until all three pass.
No other reward shape changes — still the same `CashReward`-derived structure (ANALYSIS.md §6aa) on the `Lobby` schema above.
**The waiting room shows all three tiers too**, not just gold — three small cells (🥇/🥈/🥉 + amount), not one pill. This mirrors the real game's own EventDetails screen (see §9 — its "1-Й/2-Й/3-Й НА ФИНИШЕ" reward list is exactly this shape), so the convention isn't invented, it's matched to what the real UI already does one screen over.
---
## 6. Waiting room — track hero, ready as a button
Revised 2026-08-18 after direct feedback that the first landscape pass was really just a portrait layout squeezed sideways, not a real redesign:
- **Player rows** show the car name and paint colour next to the player name — no car-shaped icon. An earlier pass added a small CSS-drawn car silhouette per row; cut it after direct feedback that it read as visual noise ("некрасиво") rather than useful information. The colour swatch (below) already carries the "this player picked a car" signal on its own.
- **Track preview**: the leader's chosen track shows as a placeholder hero block (a wide gradient/road-motif rectangle standing in for a real screenshot, which doesn't exist yet) with the track's friendly name and internal id caption — more prominent than a small text pill, since "what track is this" is the single most important piece of context in the room.
- **Readiness is a button, not a toggle switch** — deliberately different from the small iOS-style switch most lobby UIs default to. A full-width button reading "Я ГОТОВ" that becomes a solid, distinctly-coloured "✓ ГОТОВ" on press (press again to un-ready) is a clearer tap target and a clearer at-a-glance state, especially at the smaller touch scale of a landscape phone overlay.
- **Car choice stays changeable after readying up.** A persistent "Машина: `<car>` · Сменить" row is always tappable — even when `ready == Ready` — and always reopens native car_select. Changing car always resets `ready → NotReady` automatically, so no peer ever sees a stale "ready" attached to a car that just changed; the player has to explicitly re-press ready afterward.
- **Paint colour is its own swatch**, a small bordered round dot next to the car name (matching the visual weight of the game's own PAINT1PAINT6 respray swatches) — never baked into an icon shape. An intermediate version tinted the (since-removed) car icon with the colour directly; that read poorly at small size, especially light/white paints against the dark panel. Same underlying `colorHex` data (from `onCarSelected`, proven), just its own unambiguous element.
- **Upgrades are shown too**, using the exact `mods: [int|null, int|null]` shape already in the `Lobby`/`LobbyPlayer` schema (§2) and already flowing live off `onUpgradesAccepted` (proven, cont.39-41): the compact player list shows a 2-dot filled/empty indicator per row (room space is tight — a name is more useful there than two upgrade names), while your own car card in the side panel shows the full resolved names for both slots (or "НЕТ" for an empty slot, matching the schema comment's own convention). Slot→name resolution (`ModType` id → display string) is a placeholder mapping in the prototype — the real display strings live in the game's own localized upgrade-screen text, not extracted here.
- **Fixed 2026-08-19**: the demo car assigned when confirming through the normal flow (`Chevrolet Corvette ZR1`) now matches the lobby's default class (`СПОРТКАР`), so the ready button is visible on the main click-through path instead of being silently replaced by the class-mismatch warning every time. The mismatch state (§2, still a real, useful thing to demo) is now reached deliberately, via a small "[демо] показать несовпадение класса" toggle link next to the ready button — not something the primary flow trips over by accident.
---
## 6a. Max players per lobby — real number TBD, UI works either way
Added 2026-08-19: the actual cap isn't decided yet, so the UI is built to not assume one.
- **Create Lobby** gets a ``/`+` stepper next to the track selector (range 212, arbitrary bounds — just enough to make the control meaningfully bounded, not a real design decision).
- **Waiting room** shows a live `👤 N/max` pill next to the class tag, turning the same warning colour used elsewhere (`--danger`) once the room is full.
- **Server browser** cards show `current/max` instead of a static player-count string, and a full server (`current == max`) is visually locked — dimmed, tagged "ЗАПОЛНЕНО", not tappable — rather than silently allowing a join that wouldn't actually work.
None of this enforces anything server-side (there's no server yet) — it's UI that reflects whatever `Lobby.maxPlayers` ends up being once that number is actually decided.
---
## 7. Loading & waiting for stragglers — engine-level, not a custom screen
**Revised 2026-08-18 per direct correction** — the earlier draft had this as our own Compose "Загрузка заезда" screen with a list of progress bars. That's the wrong layer for it to live on. The actual requirement: one phone loads fast, another loads slow, and whoever's ready first needs to visibly still be *waiting on people*, not staring at our UI — the intro/starting-line scene should keep playing, on loop, until everyone's in.
- Once a client's own race load reaches the point where the game would normally show its native pre-race starting-grid scene (cars lined up, camera settled, about to cut to the 3-2-1-GO countdown), **that's what stays on screen** — not a custom loading UI panel. Consistent with this project's standing principle (ARCHITECTURE §2): hook the game's own flow, don't replace it with ours.
- Our overlay's only contribution here is a **thin status strip**, not a full panel — something like "Ожидаем игроков (2/4) · SLOWPOKE_88 загружается", pinned to one edge, with a subtle looping pulse so it visibly reads as "still working," not "frozen."
- The native 3-2-1-GO countdown itself is **held** until the host signals everyone's in, then plays exactly as it would in a normal single-player race start — no substitute countdown of our own.
- **Engineering reality check**: the hold/release mechanism doesn't exist yet. Candidate hook anchor is `InRaceState` construction (RTTI-confirmed, ANALYSIS.md §3.1) or the final event in `RaceLoaderTask_DispatchInitialFSMEvents` (ANALYSIS.md §6b stage 8) — either is a real, already-located point, just not yet wired for this purpose. `RaceLoaderTask_ExecuteLoadSequence`'s own `SetLoadProgress(this, N)` calls (ANALYSIS.md §6a slot 4, fractions 0.10.8) are the natural source for the real per-client progress number to report to peers, instead of inventing a synthetic one.
- **Straggler timeout still applies on top of this**: hard 45s timeout (resolved 2026-08-18) from when the first client is ready — if it expires before everyone's in, the held countdown releases anyway and whoever isn't in yet is left behind. Left-behind representation (spectator vs. rubber-band-in-when-ready) is still an open, lower-priority call.
- Also still open, and *upstream* of all of this (see ARCHITECTURE §4b step 5.1): which race event actually carries the load. The synthetic "LAN" event currently has its loadout-confirm deliberately redirected back to the map because it has no real track/scene reference (crashes otherwise, PROGRESS.md cont.36) — the recommended fix is having the lobby's "start" re-target a real, existing race event as the vessel and let *its* loadout-confirm proceed normally (real events already sail through with zero crashes, cont.41), rather than trying to give the synthetic event a scene reference of its own.
---
## 8. Screen inventory (prototype)
Matches `LOBBY_PROTOTYPE.html`'s screen rail. Profile is **not** in this numbered flow — it's the global chip/popover from §3, demonstrable from any screen.
| # | Screen | Tag | What it is |
|---|---|---|---|
| 0 | Карта — точка входа | OVERLAY | Entry button on the map, same `FrameLayout` pattern as `CarSelectionBadge` |
| 1 | Обзор лобби | OVERLAY | LAN list (with address) + "по IP" tab (validated) + create |
| 2 | Создание лобби | OVERLAY | Name, track (with placeholder preview), class, validated rewards |
| 3 | Комната ожидания | OVERLAY | Player rows (ready-button, reward trio, mod dots), track hero, address |
| 4 | event_details (перед car_select) | NATIVE | Real on-device screenshot — any ordinary event, not ours; see §9 |
| 5 | car_select (после event_details) | NATIVE | Real on-device screenshot — our overlay shrinks to a corner badge |
| 6 | Снова в лобби: авто выбрано | OVERLAY | Car/colour captured; "Сменить" stays available; ready button |
| 7 | Все готовы | OVERLAY | Host's "Начать гонку" enabled once everyone's `Ready` |
| 8 | Ожидание в гонке | NATIVE + thin overlay | Native starting-grid scene loops; thin status strip shows stragglers |
| 9 | Старт заезда | TRANSITION | Native 3-2-1-GO, released once everyone's in (or timeout) |
---
## 9. event_details before car_select — real screenshots, real reason
Added 2026-08-19, direct request: show the actual native hand-off as two steps, not one, using real captures instead of a drawn placeholder.
**Why event_details comes first, not just car_select alone**: this isn't a UX preference, it's the same technical constraint already on record in ARCHITECTURE §4b step 5.2 / PROGRESS.md cont.55/57 — `TriggerTrueDirectCarSelectJump()` needs at least one *real* `event_detail → car_select` transition to have happened this session (`g_realEventDetailsVisitHappened`) before a cold direct jump is reliable. Showing event_details first in the prototype makes that real engine requirement visible in the flow itself, instead of leaving it as an invisible precondition documented only in prose.
**The screenshots are real**, captured live via `adb screencap` on the project's own Galaxy A9 test device (`com.ea.games.nfs13_mod`), not drawn: an ordinary street (МАККЛЕЙН) → an ordinary event (ПОБУДКА, everyday street race) → its EventDetails screen → continue → car_select showing whatever car happened to be equipped (Subaru Cosworth Impreza STI CS400). Deliberately **not** the project's own synthetic "LAN" event — per the request, any real street/event/car works, and using a genuinely ordinary one keeps the mockup honest about what the native screens actually look like today, unmodified.
Both screenshots are embedded directly in `LOBBY_PROTOTYPE.html` (inline `data:` JPEGs, cropped to drop the on-screen nav bar) — the prototype has zero external image dependencies.
**On-device viewing**: `LOBBY_PROTOTYPE.html?kiosk=1` hides the review-tool chrome (masthead, screen rail, notes panel) and lets the phone-frame content fill the real viewport — this is the mode to use when checking the mockup on an actual phone via `adb`, not the full review page.
+1318
View File
File diff suppressed because it is too large Load Diff
+257
View File
@@ -0,0 +1,257 @@
# Статическая перекомпиляция ARM32 → LLVM IR → ARM64
**Статус: запасной путь.** Не начинать, пока не исчерпан текущий подход (JIT-трансляция через Unicorn).
Документ создан 2026-09-21 по запросу как запись направления, к которому осмысленно вернуться, если
скорость движка упрётся в потолок.
---
## 1. Когда переключаться на этот путь
Критерий один и он числовой. Текущий подход даёт **отставание примерно в 2,6 раза** от реального времени.
Измеренный потолок его оптимизации:
| шаг | ожидаемый результат |
|---|---|
| обход softmmu (задача #61) | ~1,4x → остаётся ~1,8x отставания |
| дальнейшие микрооптимизации | в лучшем случае 1,3–1,5x, оптимистично |
| **натив** | **недостижим в JIT-подходе** |
**Переключаться, если:** после обхода softmmu игра всё ещё не держит 30 кадров в гонке, и дальнейшие
замеры не показывают крупных резервов.
**Не переключаться, если:** 30 кадров достигнуты. Цель — играбельность, а не бенчмарк.
### Цена переключения, которую надо знать заранее
**Этот путь обнуляет наработки по мультиплееру.** Вся работа по внедрению сетевого кода делалась в
расчёте на перехват функций живого ARM32-бинарника через хуки Unicorn. После перекомпиляции бинарника
не будет — будет свой нативный код, и точки внедрения придётся искать заново, уже в другом виде
(зато, вероятно, удобнее: в статически слинкованном коде можно просто подменить символ).
Это самая серьёзная цена, и она не техническая, а проектная. Учитывать при решении.
---
## 2. Что это за подход и чем он отличается от «отреверсить игру»
**Это НЕ декомпиляция.** Никто не читает код, не восстанавливает классы, не пишет C++ заново.
Машинные инструкции ARM32 **механически** переводятся в промежуточное представление LLVM, а затем
компилируются в нативный ARM64. Инструмент не понимает, что делает код — он сохраняет его поведение
команда за командой.
Известные работающие примеры этого класса: **N64Recomp** (использован для портов игр с Nintendo 64 на PC),
аналогичные проекты для PS2 и GameCube.
### Почему это даёт скорость, которой не даст JIT
Ключ не в том, что трансляция происходит заранее. Ключ в том, что после лифтинга код попадает в
**настоящий оптимизирующий компилятор**:
| | JIT (сейчас) | статическая перекомпиляция |
|---|---|---|
| Флаги процессора ARM32 | пересчитываются после каждой операции, даже если не нужны | LLVM выбросит мёртвые вычисления |
| Регистры | 16 гостевых мапятся на 31 хостовый, лишние простаивают | распределение регистров с нуля, все 31 |
| Область оптимизации | внутри одного блока трансляции | межпроцедурная, всё приложение |
| Инлайнинг, векторизация | нет | стандартные проходы LLVM |
Именно поэтому это **единственный путь** к «неотличимо от натива». Для ориентира: Rosetta 2 от Apple —
заранее скомпилированная трансляция плюс аппаратная поддержка в процессоре — даёт 70–80% нативной
скорости.
---
## 3. Почему именно наш случай необычно удобен
Обычные блокеры статической трансляции у нас частично или полностью сняты, и это **измеренные факты**,
а не предположения.
### Границы функций известны
В бинарнике есть секция `.ARM.exidx` — таблица раскрутки стека для исключений C++:
```
.ARM.exidx 0x97e150 0x9aad18 (0x2cbc8 байт)
```
По 8 байт на запись это **≈22 900 записей**, каждая указывает на начало функции. Главная проблема
статической трансляции — «где начинается код» — решена самим бинарником. IDA независимо нашла
**34 726 функций**, что согласуется.
### Самомодифицирующегося кода нет
Проверено счётчиком на уровне Unicorn: за полный прогон загрузки пролога — **ноль записей гостя в
`.text`**. Это значит, что переведённый код не нужно инвалидировать и перетранслировать.
### Код и данные разделены
`.text` (9,5 МБ) отделён от `.rodata`, `.data`, `.bss`. Не надо угадывать, где инструкции, а где таблицы.
### Релокации дают карту указателей
`.rel.dyn` (387 КБ, ~48 000 записей `R_ARM_RELATIVE`) перечисляет все места, где лежат адреса.
Это карта того, что является указателем, а что числом.
### Чужой код, который действительно не надо переводить — 19,3%, а не треть
**Исходная оценка «треть бинарника чужая» оказалась завышенной примерно вдвое.** Подсчёт по адресным
диапазонам, подтверждённый тремя независимыми методами (кластеризация ссылок на строки, минимальный
разрез графа вызовов, тип записей `.ARM.exidx`):
| библиотека | диапазон | функций | байт | vtable внутри |
|---|---|---|---|---|
| zlib 1.2.11 | `0x6604000x66a000` | 61 | 39 612 | 0 |
| libjpeg | `0x7740000x790900` | 285 | 114 836 | 0 |
| libpng 1.5.10 | `0x7909000x7ad844` | 394 | 113 808 | 0 |
| curl 7.56.0 | `0x7c72c40x80e9b0` | 748 | 281 300 | 0 |
| OpenSSL 1.1.0f | `0x80e9b00x963120` | 5 014 | 1 156 034 | 0 |
| **итого** | | **6 502** | **1 705 590** | **0** |
Границы подтверждены independently: одна запись `.ARM.exidx` с признаком CANTUNWIND покрывает
1 687 132 байта одним куском — curl и OpenSSL собраны с `-fno-unwind-tables`, больше ничто в образе так
не собрано. Её концы совпадают с локальными минимумами разреза графа вызовов. И **ни одной C++ vtable**
внутри этих диапазонов — игровой код туда не затёк.
### Три ошибки первой редакции этого документа
| было записано | на самом деле | как проверено |
|---|---|---|
| FMOD влинкован статически | **Нет.** `DT_NEEDED: libfmodex.so, libfmodevent.so` — отдельные библиотеки | `readelf -d` |
| libc++ влинкован статически | **Нет.** `DT_NEEDED: libc++_shared.so`; в образе только заголовочные шаблоны | `readelf -d` |
| 2 432 «именованные» функции | Из них 1 170 — автогенерация IDA (`nullsub_*`), почти всё остальное — PLT-заглушки. **Восстановленных внутренних символов практически ноль** | гистограмма префиксов |
Пропущены были **libjpeg** (опознан по таблице сообщений `jerror.c`) и **Bullet Physics** (210 имён
классов `bt*`).
### Почему boost, EASTL и libc++ заменить НЕЛЬЗЯ
Первая редакция утверждала, что шаблоны стандартной библиотеки «пересобираются из заголовков, а не
переводятся». **В механическом лифтере это не работает.** У лифтера нет исходников. Чтобы не переводить
тело `std::vector<Foo>::push_back`, нужно опознать инстанцирование, восстановить точную раскладку `Foo` и
доказать совместимость с хостовой версией — а это ручной реверс, ровно то, ради отказа от чего и выбран
этот путь.
То же касается boost (610 имён классов, 792 vtable, 1 247 виртуальных целей) и EASTL: они **размазаны по
всему `.text`** и не отделяются по адресам. Переводить как обычный код.
### Что действительно облегчает задачу
**Образ целиком в режиме ARM, без Thumb.** 2 249 305 инструкций в 8 989 480 байтах — ровно 4,0 байта на
инструкцию. В `.data.rel.ro` 27 107 указателей на ARM-код против 123 с Thumb-битом. Ноль `tbb`/`tbh`,
ноль `ldr pc,`, ноль `mov pc,`. Проверено независимо: все 34 экспортируемые функции имеют чётные адреса.
**Нет переключения режимов, нет IT-блоков, нет Thumb-таблиц переходов** — заметно более простая цель, чем
предполагалось.
> Побочно: комментарий в `guest_engine.cpp:3901` называет Thumb «единственным реальным режимом этого
> движка». Это **неверно** и может ввести в заблуждение. Работе движка не мешает (режим берётся из CPSR),
> но как ориентир — ошибка.
**`.ARM.exidx` покрывает `.text` на 100%** — 22 905 записей размечают 9 526 532 из 9 527 632 байт.
**Дубликаты.** 4 918 функций побайтово идентичны и сводятся к 739 различным телам: 1 148 × `bx lr`
(пустой виртуальный метод), 678 × `mov r0,#0; bx lr`, 322 × переходник `boost::function`. Лифтер,
хеширующий тела, выдаёт 739 вместо 4 918 — **4 179 функций бесплатно**.
**Длинный хвост мелочи.** 10 646 функций (31,6%) короче 32 байт и занимают всего 1,4% кода. При этом
1 613 функций (4,8%) длиннее килобайта и занимают 39,6%.
## 4. Главная нерешённая трудность: косвенные переходы
`blx <reg>` — вызов по адресу из регистра. Статически неизвестно, куда он ведёт.
**Замерено по всем 2 249 305 инструкциям образа:**
| | количество |
|---|---|
| `blx <reg>` — косвенные вызовы | **36 232** |
| `bl` — прямые вызовы | 136 075 |
| доля косвенных среди всех вызовов | **21%** |
| `bx <reg>` (в основном `bx lr`, возвраты) | 9 364 + 1 833 условных |
| `pop`/`ldm` с `pc` (возвраты) | 27 011 |
| `ldr pc,` / `tbb` / `tbh` / `mov pc,` | **0** |
### Сколько целей удаётся собрать статически
| | количество |
|---|---|
| vtable, привязанных к typeinfo | **4 001** |
| слотов в них | 25 631 (из них 441 чисто виртуальных) |
| **различных виртуальных целей** | **10 624** |
| все указатели на код в данных (`.data.rel.ro`, `.got`, `.data`, `.init_array`) | 28 003 → **12 409 различных целей** |
| **покрытие функций `.text`** | **36,7%** |
### И вот здесь главная оговорка, которой не было в первой редакции
Первая редакция утверждала, что «большинство виртуальных целей можно собрать статически». Для **vtable**
это верно. Для **колбэков — нет.**
В этой сборке с позиционно-независимым кодом взятие адреса функции в регистр выглядит как
`ldr rX,[pc,#N]; add rX,pc` — литерал хранит **смещение относительно PC** и **не требует релокации**
(проверено на дизассемблере по адресу `0x7c758`). Значит таблица релокаций такие цели **не видит**.
Мера того, насколько она их не видит: **8 818 функций (1 405 236 байт) не имеют ни одного входящего
прямого вызова, ни одного указателя из данных.** Часть — мёртвый код, оставленный компоновщиком.
Остальное — колбэки, достижимые только анализом литеральных пулов.
**Отсюда следует порядок работ:** первым делом нужен не транслятор, а **сканер литеральных пулов**.
Если покрытие косвенных целей не удастся поднять существенно выше 37%, то запасной путь через
хеш-таблицу «адрес → функция» съест ровно тот выигрыш в скорости, ради которого всё затевается.
## 5. Что ещё придётся решить
| задача | сложность | комментарий |
|---|---|---|
| **Исключения C++** | высокая | Есть `.ARM.exidx`/`.ARM.extab`. Раскрутка стека ARM32 не переносится на ARM64 напрямую — нужна либо своя реализация, либо отображение на нативные исключения |
| **Модель памяти** | средняя | ARM32 и ARM64 имеют разные гарантии упорядочивания. При многопоточности возможны тонкие гонки, которых не было на оригинале |
| **Точность флагов** | средняя | Где флаги реально читаются — надо сохранить. LLVM выбросит лишнее только если правильно разметить |
| **JNI-граница** | низкая | Уже решена в текущем движке, переносится почти как есть |
| **Системные вызовы и libc** | низкая | Уже есть полный набор шимов, линкуется напрямую |
---
## 6. Первый шаг, если решим начать
**Не писать транслятор.** Порядок такой:
1. **Сканер литеральных пулов** (см. раздел 4). Это главный риск всего направления, и он проверяется
раньше всего. Цель — поднять покрытие косвенных целей существенно выше 37%. Если не выходит —
направление не окупается, и лучше узнать это на первом шаге.
2. **Покрытие кода.** Включить блочный профилировщик (`EnableProfiling()`, уже есть в движке) и записать
исполненные адреса за полный сеанс: загрузка, меню, гонка, финиш. Это покажет, сколько из 23 000 тел
реально работает, а сколько — мёртвый код.
3. **Прототип на одной функции.** Перевести одну чистую вычислительную функцию через LLVM IR,
подставить в работающий движок вместо эмулируемой и **замерить**. Это даст реальный коэффициент
ускорения — единственную цифру в этом документе, которая будет фактом, а не оценкой.
---
## 7. Объём работ — итоговая таблица
| категория | функций | доля | что делать |
|---|---|---|---|
| zlib, libjpeg, libpng, curl, OpenSSL | 6 502 | 19,3% | **заменить линковкой** — непрерывные диапазоны, ноль vtable |
| Bullet Physics | 1 828 | 5,4% | переводить (замена — отдельное исследование) |
| фреймворки EA, однозначные | 3 784 | 11,2% | переводить; обёртка GLES — единственный кандидат на замену |
| смешанные области EA и игры | 3 798 | 11,3% | переводить, по адресам не разделяются |
| **игра и движок** | **17 783** | **52,8%** | **переводить — неустранимое ядро** |
Всего реальных функций в `.text`: **33 695** (прежние 34 726 включали 516 заглушек PLT и 515
плейсхолдеров импорта, которые кодом не являются).
**Требуют механического перевода: 27 193.** После дедупликации по содержимому — около **23 000 различных
тел, ~1,82 млн инструкций ARM**.
---
## 8. Честный вывод
Направление **выполнимо** в том смысле, что 23 000 тел — работа для машины, а не для человека. Условия
лучше, чем казалось: режим только ARM без переключений, `.ARM.exidx` покрывает код на 100%, **все 2 310
имён классов RTTI сохранились целиком**, 4 001 vtable дают 10 624 разрешённых виртуальных цели, а треть
функций короче 32 байт.
Но **главный риск не в объёме, а в 36 232 косвенных вызовах**, чьи цели собираются лишь частично.
Начинать надо с проверки именно этого, а не с транслятора.
И цена из раздела 1 остаётся в силе: **этот путь обнуляет наработки по мультиплееру.**
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.
Binary file not shown.
+5 -1
View File
@@ -11,7 +11,11 @@ android {
minSdk = 27
ndk {
abiFilters += listOf("armeabi-v7a")
// ARM64-only-device prototype (see /ARM64_TRANSLATION_LAYER.md):
// mpcore itself is now plain native ARM64 code that drives an
// embedded ARM32 CPU-emulation core (Unicorn) instead of being
// ARM32 code injected into a natively-loaded libapp.so.
abiFilters += listOf("arm64-v8a", "x86_64")
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Desktop-only GuestHeap test harness (see guest_heap_test.cpp's own top
# comment) - no Android, no Unicorn, no APK, no device. Compiles
# guest_heap.cpp directly against the host's own C++ compiler and runs the
# resulting binary. Fast feedback loop for changes to the guest heap
# allocator before ever touching a device.
set -euo pipefail
cd "$(dirname "$0")/.."
SRC_DIR="src/main/cpp/emu"
OUT="${TMPDIR:-/tmp}/guest_heap_test"
c++ -std=c++17 -O0 -g -Wall \
"$SRC_DIR/guest_heap.cpp" \
"$SRC_DIR/tests/guest_heap_test.cpp" \
-I"$SRC_DIR" \
-o "$OUT"
"$OUT"
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Repeatable on-device smoke test for the ARM32-in-ARM64 prototype (see
# /ARM64_TRANSLATION_LAYER.md). Replaces the manual adb-shell archaeology
# this project's live debugging sessions have needed up to now: install,
# launch, watch logcat for a bounded window, then report the same signals
# those sessions kept hand-checking - onCreate timing, MEM FAULT count (see
# guest_engine.cpp's mem_fault_hook_cb), and process-death cause (ANR / OOM /
# MIUI SwipeUpClean / other) - as one command, so every future interface-
# layer change gets checked against the same baseline instead of a fresh
# investigation each time.
#
# Usage: mpcore/scripts/test_on_device.sh [device-serial] [watch-seconds]
# device-serial defaults to the first device `adb devices` lists.
# watch-seconds defaults to 180 (most observed onCreate runs finish in
# 100-120s; this leaves headroom to also see the post-onCreate lifecycle
# calls that previously kept faulting).
set -uo pipefail
# Script lives at mpcore/scripts/ - project root (where app/ and mpcore/ are
# siblings) is two levels up.
cd "$(dirname "$0")/../.."
PKG="com.ea.games.nfs13_arm"
ACTIVITY="com.ea.ironmonkey.GameActivityMain"
# "translated" (the emulated arm64-v8a path) - see app/build.gradle.kts's
# product flavors, added for the native ARM32 tracing investigation
# (ARM64_TRANSLATION_LAYER.md). The pre-flavor-split path
# (app/build/outputs/apk/debug/app-debug.apk) is now a STALE, never-updated
# file left over from before that split - silently reinstalling it here
# instead of the real current build wasted real debugging time confirming
# this fix, so this path is now the single source of truth.
APK="app/build/outputs/apk/translated/debug/app-translated-debug.apk"
SERIAL="${1:-$(adb devices | awk 'NR==2{print $1}')}"
WATCH_SECONDS="${2:-180}"
if [[ -z "$SERIAL" ]]; then
echo "No adb device found. Connect the device and retry." >&2
exit 1
fi
ADB="adb -s $SERIAL"
if [[ ! -f "$APK" ]]; then
echo "APK not found at $APK - build it first (./gradlew :app:assembleDebug)." >&2
exit 1
fi
echo "== Installing $APK on $SERIAL =="
$ADB install -r "$APK" || { echo "install failed" >&2; exit 1; }
echo "== Launching =="
$ADB shell am force-stop "$PKG"
$ADB logcat -c
$ADB shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null
sleep 2
PID=$($ADB shell pidof "$PKG" | tr -d '\r')
if [[ -z "$PID" ]]; then
echo "Process did not start." >&2
exit 1
fi
echo "pid=$PID"
echo "== Watching logcat for ${WATCH_SECONDS}s =="
LOG="$(mktemp)"
timeout "${WATCH_SECONDS}s" $ADB logcat --pid="$PID" > "$LOG" 2>/dev/null || true
echo ""
echo "===================== RESULTS ====================="
ONCREATE_LINE=$(grep "GameActivityMain onCreate took" "$LOG" | tail -1)
if [[ -n "$ONCREATE_LINE" ]]; then
echo "onCreate: $(echo "$ONCREATE_LINE" | grep -oE '[0-9]+ms')"
else
echo "onCreate: did not complete within ${WATCH_SECONDS}s"
fi
FAULT_COUNT=$(grep -c "MEM FAULT" "$LOG" || true)
echo "MEM FAULT lines: $FAULT_COUNT"
if [[ "$FAULT_COUNT" -gt 0 ]]; then
echo " first: $(grep "MEM FAULT" "$LOG" | head -1)"
echo " last: $(grep "MEM FAULT" "$LOG" | tail -1)"
fi
CRASHED_LINE=$(grep -c "refusing to run - engine already crashed" "$LOG" || true)
echo "engine crashed (fail-fast tripped): $([[ "$CRASHED_LINE" -gt 0 ]] && echo yes || echo no)"
STILL_ALIVE=$($ADB shell "pidof $PKG" | tr -d '\r')
if [[ -n "$STILL_ALIVE" ]]; then
echo "process status: alive (pid=$STILL_ALIVE) at end of watch window"
else
echo "process status: DEAD"
DEATH_LINE=$($ADB logcat -d 2>/dev/null | grep -E "Killing $PID:|$PID.*died->background" | tail -3)
if [[ -n "$DEATH_LINE" ]]; then
echo " cause:"
echo "$DEATH_LINE" | sed 's/^/ /'
else
echo " cause: unknown (no ActivityManager kill/death line found - check for a FATAL crash instead)"
$ADB logcat -d --pid="$PID" 2>/dev/null | grep -iE "FATAL|AndroidRuntime" | sed 's/^/ /'
fi
fi
echo "====================================================="
echo "Full log: $LOG"
+62 -2
View File
@@ -2,11 +2,71 @@ cmake_minimum_required(VERSION 3.22.1)
project("mpcore")
# Force an optimized build regardless of the Android Gradle Plugin's own
# build variant (2026-09-05, "why is this ~100x slower than native"
# investigation - see ARM64_TRANSLATION_LAYER.md). Confirmed live via the
# actual generated build.ninja: neither this project's own C++ (guest_engine.cpp
# et al.) nor vendored Unicorn/QEMU-TCG's own C sources ever received an -O
# flag - AGP's external CMake integration never sets CMAKE_BUILD_TYPE here,
# and CMake's own default (empty CMAKE_BUILD_TYPE) means no per-build-type
# flags get added at all, i.e. plain -O0. Two separate, targeted dispatch-
# overhead fixes made zero measurable difference to a tight guest hot loop's
# wall-clock rate - this is why: a CPU emulator's performance is dominated
# by how well the COMPILER optimizes its OWN interpreter/JIT dispatch, and
# -O0 there dwarfs any micro-optimization in the C++ source. RelWithDebInfo
# (not plain Release) keeps -g/debug info for native crash symbolication -
# this project's own crash/fault diagnostics throughout this session depend
# on it. FORCE + setting it before add_subdirectory so Unicorn's nested
# CMake build (which does NOT set its own CMAKE_BUILD_TYPE) inherits it too.
set(CMAKE_BUILD_TYPE RelWithDebInfo CACHE STRING "" FORCE)
# ---- Vendored Unicorn Engine (ARM32-on-ARM64 CPU-emulation core) ----
# Only the ARM (AArch32) backend is built - this project never needs any of
# Unicorn's other target architectures. See ARM64_TRANSLATION_LAYER.md for
# why Unicorn specifically (embeddable-by-design, MIT-licensed, QEMU-TCG-
# derived JIT) was picked over a full QEMU-user-mode process.
set(UNICORN_ARCH "arm" CACHE STRING "" FORCE)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(UNICORN_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(UNICORN_INSTALL OFF CACHE BOOL "" FORCE)
set(UNICORN_FUZZ OFF CACHE BOOL "" FORCE)
add_subdirectory(third_party/unicorn)
add_library(${CMAKE_PROJECT_NAME} SHARED
main.cpp
crash_handler.cpp
game_lifecycle_stubs.cpp
game_lifecycle_stubs_extra.cpp
game_lifecycle_stubs_extra2.cpp
util/util.cpp
util/armhook.cpp)
util/armhook.cpp
emu/guest_heap.cpp
emu/guest_engine.cpp
emu/import_shims.cpp
emu/pthread_shim.cpp
emu/jni_shim.cpp
emu/gles_shim.cpp
emu/dyncast_fastpath.cpp
emu/libc_shims.cpp
emu/rtti_shims.cpp
emu/fmod_shims.cpp
emu/zlib_accel.cpp
emu/name_lookup_accel.cpp
emu/profiler.cpp
emu/guest_trace.cpp
emu/tcg_bench.cpp
emu/ostream_repro_test.cpp)
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
third_party/unicorn/include)
target_compile_features(${CMAKE_PROJECT_NAME} PRIVATE cxx_std_17)
target_link_libraries(${CMAKE_PROJECT_NAME}
unicorn
android
log)
log
GLESv2
z
EGL
jnigraphics)
+125
View File
@@ -0,0 +1,125 @@
#pragma once
// Reads the player's currently-selected car and its persisted paint color
// straight from the game's own live engine state (not a baked/static table),
// so this keeps working for any car added or modded in later. See
// PROGRESS.md cont.61/63/63b/64 for the full derivation of every offset
// below.
#include <cstdint>
#include <cstring>
#include "util/util.h"
extern void* libapp_base;
// The engine's central per-session state singleton (own RTTI-confirmed
// "s_Instance"/"GetInstance" assert strings). +24 = current car's ID string
// pointer, +56 = current car's CarDescription* (used below).
// SingletonInitialise (sub_244CA8) lazily constructs it if some code path
// reads it before its normal owner does - idempotent, safe to call directly.
#define SINGLETON_INSTANCE_OFFSET 0xAD2A08
#define SINGLETON_INITIALISE_OFFSET 0x244CA8
typedef int (*SingletonInitialiseFn)();
static SingletonInitialiseFn SingletonInitialise = nullptr;
// The player's owned-car registry (own "s_Instance"/"Not initialised" assert
// pattern, separate singleton from the one above) and its hashmap lookup:
// LookupCarRecord(registryPtr, &carIdKeySlot) walks buckets keyed by the
// car-id string's own interned pointer value, returning a 24-byte persistent
// record (`+4` = saved paint index) on a hit, or a lazily-`malloc`'d
// (never-zeroed) default record on a miss.
#define GET_CAR_REGISTRY_OFFSET 0x77B70
#define LOOKUP_CAR_RECORD_OFFSET 0x25102C
typedef int (*GetCarRegistryFn)();
typedef int (*LookupCarRecordFn)(int registryPtr, int* carIdKeySlot);
static GetCarRegistryFn GetCarRegistry = nullptr;
static LookupCarRecordFn LookupCarRecord = nullptr;
// CarDescription::GetPaintJobDescription(int paintJobIndex) - a plain
// vector-index accessor: `*(carDescPtr+104) + 112*paintJobIndex`. Its own
// out-of-range assert does NOT actually stop it from computing and
// dereferencing a wild pointer for a bad index - see the cont.66 fix below.
#define GET_PAINT_JOB_DESC_OFFSET 0xB3564
typedef int (*GetPaintJobDescriptionFn)(int carDescPtr, int paintJobIndex);
static GetPaintJobDescriptionFn GetPaintJobDescription = nullptr;
// sub_16692C - rebuilds car_select's browsable car list from the current
// SINGLETON_CARCLASS_FILTER_OFFSET value (same rebuild a live class-filter
// dropdown tap triggers). Resolved but currently unused: three attempts to
// call it programmatically (immediate post-construct, on first Tick, and
// deferred 500ms) all crashed live with a NULL component pointer - see
// PROGRESS.md cont.64. Left declared for a future, better-scoped attempt.
#define REBUILD_CAR_CLASS_LIST_OFFSET 0x16692C
typedef int (*RebuildCarClassListFn)(int screenInstance);
static RebuildCarClassListFn RebuildCarClassList = nullptr;
// Confirmed live (A/B/A across two cars): a direct, header-free pointer to
// the car's ASCII resource-id string (e.g. "ford_focus_rs500_2010_desc").
static const char* GetCurrentCarId() {
void* singletonPtr = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET);
if (!singletonPtr) return nullptr;
return *(const char**)((uint8_t*)singletonPtr + 24);
}
// PaintJobDescription's 112-byte layout (matches the unpacked resource
// schema exactly): six 16-byte string fields (+0 Name, +16
// DiffuseTextureFilePath, +32 DiffuseMaskFilePath, +48 BRDFFilePath, +64
// BRDFSpecularResponseFilePath, +80 NumberPlateTextureFilePath), then +96
// SwatchColor (packed RGBA, one byte each), +100 SwatchColor2, +104 Type,
// +108 UseVinylMap/padding.
struct CarColor {
const char* name;
int r, g, b, a;
};
// cont.66 CRITICAL FIX: GetPaintJobDescription does not actually clamp an
// out-of-range index - it logs an assert but still computes and returns
// `vectorBegin + 112*index` using the original, still out-of-range index.
// LookupCarRecord's own "not found" fallback record is `malloc`'d, never
// zeroed, so its `+4` (color index) can be uninitialized garbage - on a
// fresh save, before the car-record hashmap has any entries, this crashed
// car_select's own CONTINUE (every real race, not just this project's own
// test flow), making the game unplayable end to end. Fix: independently
// read the same vector bounds GetPaintJobDescription itself reads
// (carDescPtr+104/+108) and clamp locally before ever calling it.
static CarColor GetCurrentCarColor() {
CarColor result = {"", 0, 0, 0, 0};
if (!GetCarRegistry || !LookupCarRecord || !GetPaintJobDescription) return result;
void* singletonPtr = *(void**)((uintptr_t)libapp_base + SINGLETON_INSTANCE_OFFSET);
if (!singletonPtr) return result;
int registryPtr = GetCarRegistry();
if (!registryPtr) return result;
int* carIdKeySlot = (int*)((uint8_t*)singletonPtr + 24);
int recordPtr = LookupCarRecord(registryPtr, carIdKeySlot);
if (!recordPtr) return result;
int colorIndex = *(int*)((uint8_t*)(uintptr_t)recordPtr + 4);
int carDescPtr = *(int*)((uint8_t*)singletonPtr + 56);
if (!carDescPtr) return result;
uint32_t vecBegin = *(uint32_t*)((uint8_t*)(uintptr_t)carDescPtr + 104);
uint32_t vecEnd = *(uint32_t*)((uint8_t*)(uintptr_t)carDescPtr + 108);
if (vecEnd < vecBegin || (vecEnd - vecBegin) % 112 != 0) {
Log("GetCurrentCarColor: implausible PaintJobDescriptions vector (begin=0x%x end=0x%x) - bailing",
vecBegin, vecEnd);
return result;
}
int paintJobCount = (int)((vecEnd - vecBegin) / 112);
if (paintJobCount <= 0) return result;
if (colorIndex < 0 || colorIndex >= paintJobCount) {
Log("GetCurrentCarColor: colorIndex=%d out of range (count=%d) - clamping to 0", colorIndex, paintJobCount);
colorIndex = 0;
}
int pjPtr = GetPaintJobDescription(carDescPtr, colorIndex);
if (!pjPtr) return result;
result.name = *(const char**)(uintptr_t)pjPtr; // word[0] = Name's own begin pointer
uint32_t swatch = *(const uint32_t*)((uint8_t*)(uintptr_t)pjPtr + 96);
result.r = swatch & 0xFF;
result.g = (swatch >> 8) & 0xFF;
result.b = (swatch >> 16) & 0xFF;
result.a = (swatch >> 24) & 0xFF;
return result;
}
+284
View File
@@ -0,0 +1,284 @@
#pragma once
// Subtask 2.4 — remove police from multiplayer races. See ANALYSIS.md §6hh
// and ARCHITECTURE.md §3b for the RE writeup this is built on.
//
// cont.73/74 CORRECTION: the original hook only covered sub_F7E9C. Tracing
// callers found the real shape: sub_F5EA4 is a per-tick dispatcher (itself
// only called from sub_F5BB4, the CopManager's broader per-tick Update,
// which also does unrelated bookkeeping - bust timers etc. via sub_F9D34/
// FA4F8/F6640/F67A8/F6BCC - so that outer function is too broad to skip
// wholesale). sub_F5EA4 branches on a live byte flag (`*(a1+4036)`):
// true -> sub_F7E9C (cooldown-timer-based single-candidate scheduler)
// false -> sub_F8078 (distance-sorted-list scheduler, multiple candidates
// checked per tick via the same sub_F82B4 candidate-check +
// sub_F85B8/SpawnCopCar call)
// Both leaves call the same SpawnCopCar (sub_F85B8, own assert string
// confirms the name) - hooking only sub_F7E9C left sub_F8078's branch
// completely unblocked, which is almost certainly why cops were still seen
// live in cont.73 with the old hook enabled and 0 log lines from it (all
// spawn activity was going through the un-hooked branch). Hooking the
// dispatcher itself (sub_F5EA4) covers both leaves with one hook and skips
// only the spawn-decision logic, not sub_F5BB4's other per-tick cop
// maintenance - same "skip a periodic check function" shape as the existing
// CopSoundsTick hook (crash_workarounds.h), just at the correct call depth.
//
// Gated off by default (g_enableCopSpawnSkipTest) for the same reason as
// opponent_substitution.h's blanket test: no session/lobby flag exists yet
// to distinguish "a multiplayer race is active" from singleplayer, so
// leaving this on unconditionally would remove cops from singleplayer too.
// Re-enable deliberately only for an isolated live test.
#include <cstdint>
#include "util/util.h"
#include "util/hook_install.h"
extern void* libapp_base;
#define COP_SPAWN_DISPATCHER_OFFSET 0xF5EA4
typedef int (*CopSpawnDispatcherFn)(int a1);
static CopSpawnDispatcherFn orig_CopSpawnDispatcher = nullptr;
static volatile bool g_enableCopSpawnSkipTest = true;
static volatile int g_copSpawnSkipLogBudget = 50;
extern "C" int Hook_CopSpawnDispatcher(int a1) {
if (g_enableCopSpawnSkipTest) {
if (g_copSpawnSkipLogBudget > 0) {
g_copSpawnSkipLogBudget--;
Log("DIAG CopSpawnDispatcher: skipped (g_enableCopSpawnSkipTest on) a1=%p", (void*)(uintptr_t)a1);
}
return 0;
}
return orig_CopSpawnDispatcher(a1);
}
static bool InstallCopSpawnSchedulerSkipHook() {
orig_CopSpawnDispatcher = (CopSpawnDispatcherFn)InstallArmTrampolineHook(
libapp_base, COP_SPAWN_DISPATCHER_OFFSET, (void*)&Hook_CopSpawnDispatcher,
"Cop spawn dispatcher skip hook");
return orig_CopSpawnDispatcher != nullptr;
}
// cont.70 CORRECTION: an earlier pass (ANALYSIS.md §6jj) wrongly concluded
// TrafficCarCount needed a keyed reflective-lookup hook because sub_2A4D70
// (RaceEvent's own deserializer) reads it via the same sub_4F99F0/sub_4F9A80
// reflective helper used for every other field in that function. Re-reading
// the full decompile: that reflective read happens ONCE, during
// deserialization, exactly like RaceType/Location/ClassRestriction/etc. -
// the resolved int is then stored at a plain, fixed offset on the live
// RaceEvent object (`*(a1+116) = resolvedValue`), same shape as
// ClassRestriction at +132. The original cont.69 plan (hook after
// deserialization, overwrite the fixed offset) was correct all along.
#define RACE_EVENT_DESERIALIZE_OFFSET 0x2A4D70
#define RACE_EVENT_TRAFFIC_CAR_COUNT_OFFSET 116
typedef uint8_t* (*RaceEventDeserializeFn)(int a1, int a2, uint32_t* a3);
static RaceEventDeserializeFn orig_RaceEventDeserialize = nullptr;
static volatile bool g_enableTrafficCarCountZeroTest = false;
static volatile int g_trafficCarCountLogBudget = 50;
extern "C" uint8_t* Hook_RaceEventDeserialize(int a1, int a2, uint32_t* a3) {
uint8_t* result = orig_RaceEventDeserialize(a1, a2, a3);
if (g_enableTrafficCarCountZeroTest) {
int before = *(int*)((uint8_t*)(uintptr_t)a1 + RACE_EVENT_TRAFFIC_CAR_COUNT_OFFSET);
*(int*)((uint8_t*)(uintptr_t)a1 + RACE_EVENT_TRAFFIC_CAR_COUNT_OFFSET) = 0;
if (g_trafficCarCountLogBudget > 0) {
g_trafficCarCountLogBudget--;
Log("DIAG RaceEvent::Deserialize: a1=%p TrafficCarCount %d -> 0", (void*)(uintptr_t)a1, before);
}
}
return result;
}
static bool InstallRaceEventTrafficCarCountHook() {
orig_RaceEventDeserialize = (RaceEventDeserializeFn)InstallArmTrampolineHook(
libapp_base, RACE_EVENT_DESERIALIZE_OFFSET, (void*)&Hook_RaceEventDeserialize,
"RaceEvent deserialize (TrafficCarCount) hook");
return orig_RaceEventDeserialize != nullptr;
}
// cont.74 CORRECTION: live A/B testing (drive an actual race, not just check
// logcat) showed civilian traffic (a red pickup, a blue sedan, etc.) still
// present with the TrafficCarCount=0 hook active - that field write was
// real (confirmed by logcat) but doesn't gate ambient traffic at all. Traced
// the real spawner: sub_33D734 is TrafficCarSpawner's populate function (own
// assert string confirms the class name "TrafficCarSpawner"), called twice
// per race from sub_33C020 - once per traffic direction/lane-group (a3=0/1)
// - with its max-count parameter (a5) read from `*(a1[3]+16)`/`*(a1[3]+20)`,
// NOT from RaceEvent.TrafficCarCount (a completely different object/offset).
// Whatever RaceEvent.TrafficCarCount actually feeds (not traced further -
// not needed once the real gate was found) isn't the base ambient-traffic
// density. Hooking sub_33D734 directly and forcing a5 to 0 chokes off both
// calls (both directions/lane-groups) at the source, regardless of where
// their real inputs come from.
#define TRAFFIC_CAR_SPAWNER_POPULATE_OFFSET 0x33D734
typedef int* (*TrafficCarSpawnerPopulateFn)(int a1, int a2, int a3, int a4, int a5);
static TrafficCarSpawnerPopulateFn orig_TrafficCarSpawnerPopulate = nullptr;
static volatile bool g_enableTrafficSpawnerZeroTest = false;
static volatile int g_trafficSpawnerLogBudget = 50;
extern "C" int* Hook_TrafficCarSpawnerPopulate(int a1, int a2, int a3, int a4, int a5) {
int effectiveMax = a5;
if (g_enableTrafficSpawnerZeroTest) {
effectiveMax = 0;
}
if (g_trafficSpawnerLogBudget > 0) {
g_trafficSpawnerLogBudget--;
Log("DIAG TrafficCarSpawner::Populate: mode=%d maxCount=%d -> %d", a3, a5, effectiveMax);
}
return orig_TrafficCarSpawnerPopulate(a1, a2, a3, a4, effectiveMax);
}
static bool InstallTrafficCarSpawnerZeroHook() {
orig_TrafficCarSpawnerPopulate = (TrafficCarSpawnerPopulateFn)InstallArmTrampolineHook(
libapp_base, TRAFFIC_CAR_SPAWNER_POPULATE_OFFSET, (void*)&Hook_TrafficCarSpawnerPopulate,
"TrafficCarSpawner::Populate zero hook");
return orig_TrafficCarSpawnerPopulate != nullptr;
}
// cont.75: the Populate do-while (above) can only reduce traffic to a
// structural minimum (>=1 candidate per track waypoint), never zero - traced
// one level further to find where a candidate actually becomes a visible,
// positioned car on the road. sub_C26A0 is a generic "CarReset" utility (own
// assert strings confirm "foundTrackInfo"/"Reset") - resolves a spline
// distance + lateral offset to a world position (sub_2B2D18/sub_85660, same
// TrackNavigator-family shape as PlaceCar/SpawnCopCar) and writes it via the
// same sub_10B09C/sub_D5138 transform-write pair used by PlaceCar (subtask
// 2.2) and SpawnCopCar (subtask 2.4) - i.e. this is the actual moment a
// traffic car's position becomes real. It has two callers: sub_2A0470 (the
// "ResetLine" event handler, via sub_31AAEC) and sub_C201C.
//
// First attempt hooked sub_2A0470 on the theory it handled both initial
// placement and periodic recycling. Live-tested and DISPROVEN: across a full
// race replay the hook's own diagnostic log (unconditional, not gated by the
// test flag) never fired once, yet a traffic pickup truck was clearly
// visible and moving the whole time - sub_2A0470 simply isn't on the path
// that keeps an ordinary traffic car on the road during a normal-length
// race. Left in place (harmless, possibly still relevant for very long
// races) but is NOT the real fix.
//
// sub_C201C is: own second parameter carries a delta-time value (`*a2 *
// -0.001` pattern), and it calls sub_C26A0 twice - once when an idle/wander
// timer at `a1+88` counts down to zero (computing a fresh position via
// sub_690A94), once to retry a previously-deferred reset stored at
// `a1+112..128` (the exact same field layout sub_C26A0 itself writes on its
// own "can't resolve position yet, queue it" fallback path) - i.e. this is
// the traffic car's own per-tick controller, not a generic car utility.
// Skipping it wholesale prevents that car from ever being (re)positioned on
// the road at all.
#define TRAFFIC_CAR_CONTROLLER_TICK_OFFSET 0xC201C
typedef int (*TrafficCarControllerTickFn)(int a1, int* a2);
static TrafficCarControllerTickFn orig_TrafficCarControllerTick = nullptr;
// cont.77 CORRECTION: live-testing on a second track ("ПРОМЫШЛ. СОБЫТИЕ")
// showed a traffic car sitting frozen right at the starting grid, causing a
// spawn collision - unconfirmed on the first track (Reynolds Lane), where a
// hook this thorough happened not to matter. Root cause: this wholesale skip
// prevents sub_C201C's idle-timer branch from EVER calling sub_C26A0
// (CarReset), which means the traffic car never receives ANY position -
// not "no position" (invisible), but whatever its pooled/reused object's
// stale transform already was, which can be anywhere, including right on
// the grid. Skipping unconditionally forever is wrong; the fix is a
// once-per-object CarReset hook below, which lets each traffic car receive
// exactly one real, TrackNavigator-resolved position (same placement logic
// used for opponents/cops - never intentionally at the grid) and then
// freezes it there, rather than leaving it at an arbitrary stale transform.
// This hook is now disabled by default in favor of Hook_CarReset.
static volatile bool g_enableTrafficControllerSkipTest = false;
static volatile int g_trafficControllerLogBudget = 50;
extern "C" int Hook_TrafficCarControllerTick(int a1, int* a2) {
if (g_trafficControllerLogBudget > 0) {
g_trafficControllerLogBudget--;
Log("DIAG TrafficCarControllerTick: a1=%p enabled=%d", (void*)(uintptr_t)a1, (int)g_enableTrafficControllerSkipTest);
}
if (g_enableTrafficControllerSkipTest) {
return 0;
}
return orig_TrafficCarControllerTick(a1, a2);
}
static bool InstallTrafficCarControllerSkipHook() {
orig_TrafficCarControllerTick = (TrafficCarControllerTickFn)InstallArmTrampolineHook(
libapp_base, TRAFFIC_CAR_CONTROLLER_TICK_OFFSET, (void*)&Hook_TrafficCarControllerTick,
"Traffic car controller tick skip hook");
return orig_TrafficCarControllerTick != nullptr;
}
// cont.77: the real fix. Hook CarReset (sub_C26A0) itself - the shared
// position-write primitive both sub_C201C and sub_2A0470 call into (see
// §6mm) - and let each distinct traffic-car "this" pointer through exactly
// once (a real, TrackNavigator-resolved position, same system used for
// opponents/cops, so it lands somewhere sane on the road, never at the
// grid by construction), then no-op every subsequent call for that same
// object. Net effect: traffic cars appear once, parked, never move again -
// no continuously-cycling ambient traffic, no risk of a frozen car sitting
// at an arbitrary stale (possibly grid-overlapping) transform.
#define CAR_RESET_OFFSET 0xC26A0
typedef int (*CarResetFn)(int a1, uint32_t* a2, int a3, int a4);
static CarResetFn orig_CarReset = nullptr;
static volatile bool g_enableTrafficOnceOnlyResetTest = true;
static const int kSeenCarResetCapacity = 128;
static int g_seenCarResetControllers[kSeenCarResetCapacity];
static volatile int g_seenCarResetCount = 0;
static volatile int g_carResetLogBudget = 50;
extern "C" int Hook_CarReset(int a1, uint32_t* a2, int a3, int a4) {
if (!g_enableTrafficOnceOnlyResetTest) {
return orig_CarReset(a1, a2, a3, a4);
}
for (int i = 0; i < g_seenCarResetCount; i++) {
if (g_seenCarResetControllers[i] == a1) {
return 0;
}
}
if (g_seenCarResetCount < kSeenCarResetCapacity) {
g_seenCarResetControllers[g_seenCarResetCount++] = a1;
}
if (g_carResetLogBudget > 0) {
g_carResetLogBudget--;
Log("DIAG CarReset: first-and-only real placement for a1=%p (seen=%d)", (void*)(uintptr_t)a1, g_seenCarResetCount);
}
return orig_CarReset(a1, a2, a3, a4);
}
static bool InstallCarResetOnceOnlyHook() {
orig_CarReset = (CarResetFn)InstallArmTrampolineHook(
libapp_base, CAR_RESET_OFFSET, (void*)&Hook_CarReset,
"CarReset once-only (traffic) hook");
return orig_CarReset != nullptr;
}
#define TRAFFIC_RESET_LINE_HANDLER_OFFSET 0x2A0470
typedef int (*TrafficResetLineHandlerFn)(uint32_t* a1);
static TrafficResetLineHandlerFn orig_TrafficResetLineHandler = nullptr;
static volatile bool g_enableTrafficResetLineSkipTest = false;
static volatile int g_trafficResetLineLogBudget = 50;
extern "C" int Hook_TrafficResetLineHandler(uint32_t* a1) {
if (g_trafficResetLineLogBudget > 0) {
g_trafficResetLineLogBudget--;
Log("DIAG TrafficResetLineHandler: a1=%p enabled=%d", (void*)a1, (int)g_enableTrafficResetLineSkipTest);
}
if (g_enableTrafficResetLineSkipTest) {
return 0;
}
return orig_TrafficResetLineHandler(a1);
}
static bool InstallTrafficResetLineSkipHook() {
orig_TrafficResetLineHandler = (TrafficResetLineHandlerFn)InstallArmTrampolineHook(
libapp_base, TRAFFIC_RESET_LINE_HANDLER_OFFSET, (void*)&Hook_TrafficResetLineHandler,
"Traffic ResetLine handler skip hook");
return orig_TrafficResetLineHandler != nullptr;
}
+283
View File
@@ -0,0 +1,283 @@
#include "crash_handler.h"
#include "util/util.h"
#include <cerrno>
#include <csignal>
#include <cstring>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/ucontext.h>
#include <unistd.h>
// ---- Everything below the handler boundary must be async-signal-safe ----
//
// The rule is narrow and unforgiving: inside the handler, only functions
// POSIX lists as async-signal-safe may be called. write(), open(), close(),
// _exit(), sigaction(), raise() are; snprintf(), malloc(), any C++ container,
// anything that takes a lock, and every JNI call are NOT.
//
// This project already paid for ignoring that once - see
// ARM64_TRANSLATION_LAYER.md's note that mmap() inside a SIGSEGV handler
// deadlocked on bionic. So: every string is built HERE, at install time, and
// the handler only appends bytes to a pre-opened path and formats integers
// with the hand-written helpers below.
namespace {
constexpr size_t kPathMax = 512;
constexpr size_t kStampMax = 256;
// Pre-built at install time. `g_installed` also guards against a second
// install and against the handler running before setup finished.
char g_reportPath[kPathMax];
char g_buildStamp[kStampMax];
bool g_installed = false;
// Guest memory window, for decoding a host fault address back to the guest
// address that produced it. Plain integers written once from the engine
// thread and only read afterwards.
uint64_t g_guestFlatBase = 0;
uint32_t g_guestRegionSize = 0;
// The alternate stack. A stack-overflow SIGSEGV cannot be handled on the
// stack that just overflowed, so the handler gets its own - allocated here,
// at install time, never inside the handler.
constexpr size_t kAltStackSize = 64 * 1024;
char g_altStack[kAltStackSize];
// Previous dispositions, so the default handler still runs afterwards and
// Android still produces its own tombstone. Our report is extra evidence, not
// a replacement for the system's.
struct sigaction g_prevActions[NSIG];
// ---- async-signal-safe output helpers ----
void SafeWrite(int fd, const char* s, size_t len) {
while (len > 0) {
ssize_t n = write(fd, s, len);
if (n <= 0) {
if (n < 0 && errno == EINTR) continue;
return; // nothing useful left to do inside a handler
}
s += n;
len -= (size_t)n;
}
}
void SafeStr(int fd, const char* s) {
if (!s) return;
size_t len = 0;
while (s[len]) len++;
SafeWrite(fd, s, len);
}
void SafeHex(int fd, uint64_t v) {
static const char kDigits[] = "0123456789abcdef";
char buf[19];
buf[0] = '0';
buf[1] = 'x';
int pos = 18;
buf[pos] = '\0';
if (v == 0) {
buf[--pos] = '0';
} else {
while (v && pos > 2) {
buf[--pos] = kDigits[v & 0xF];
v >>= 4;
}
}
SafeWrite(fd, "0x", 2);
SafeStr(fd, buf + pos);
}
void SafeDec(int fd, long v) {
char buf[24];
int pos = 23;
buf[pos] = '\0';
bool neg = v < 0;
unsigned long u = neg ? (unsigned long)(-v) : (unsigned long)v;
if (u == 0) buf[--pos] = '0';
while (u && pos > 0) {
buf[--pos] = (char)('0' + (u % 10));
u /= 10;
}
if (neg && pos > 0) buf[--pos] = '-';
SafeStr(fd, buf + pos);
}
const char* SignalName(int sig) {
switch (sig) {
case SIGSEGV: return "SIGSEGV (bad memory access)";
case SIGBUS: return "SIGBUS (misaligned or unmapped access)";
case SIGABRT: return "SIGABRT (abort - often a failed assertion or a JNI error)";
case SIGILL: return "SIGILL (illegal instruction)";
case SIGFPE: return "SIGFPE (arithmetic fault)";
default: return "unknown signal";
}
}
void WriteRegisters(int fd, void* ucontextRaw) {
if (!ucontextRaw) return;
auto* uc = static_cast<ucontext_t*>(ucontextRaw);
#if defined(__aarch64__)
const mcontext_t& mc = uc->uc_mcontext;
SafeStr(fd, "registers (host arm64):\n");
for (int i = 0; i < 31; i++) {
SafeStr(fd, " x");
SafeDec(fd, i);
SafeStr(fd, i < 10 ? " = " : " = ");
SafeHex(fd, mc.regs[i]);
SafeStr(fd, (i % 2) ? "\n" : " ");
}
SafeStr(fd, "\n sp = ");
SafeHex(fd, mc.sp);
SafeStr(fd, " pc = ");
SafeHex(fd, mc.pc);
SafeStr(fd, "\n");
// x28 is where the flat guest mapping keeps its base (task #61). Printing
// it makes the guest-address arithmetic below checkable by hand from the
// report alone.
SafeStr(fd, " x28 (guest base register) = ");
SafeHex(fd, mc.regs[28]);
SafeStr(fd, "\n");
#else
(void)uc;
SafeStr(fd, "registers: not captured on this architecture\n");
#endif
}
void CrashSignalHandler(int sig, siginfo_t* info, void* ucontextRaw) {
if (g_installed) {
// O_TRUNC, not O_APPEND: one pending report at a time. If a second
// crash happens before the first is collected, the newer one is the
// one worth having - it is the one the tester just saw.
int fd = open(g_reportPath, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) {
SafeStr(fd, "=== NFSMW arm64 engine - native crash report ===\n\n");
SafeStr(fd, "build: ");
SafeStr(fd, g_buildStamp);
SafeStr(fd, "\n\nsignal: ");
SafeStr(fd, SignalName(sig));
SafeStr(fd, " (");
SafeDec(fd, sig);
SafeStr(fd, ")\n");
const uint64_t faultAddr = info ? (uint64_t)(uintptr_t)info->si_addr : 0;
SafeStr(fd, "fault address (host): ");
SafeHex(fd, faultAddr);
SafeStr(fd, "\n");
// The number that actually helps. A host fault address inside the
// guest window is really a guest pointer; reported raw it is
// meaningless, and nobody reading a tester's report will subtract
// the base by hand.
if (g_guestFlatBase && faultAddr >= g_guestFlatBase &&
faultAddr < g_guestFlatBase + 0x100000000ull) {
uint64_t guestAddr = faultAddr - g_guestFlatBase;
SafeStr(fd, "fault address (GUEST): ");
SafeHex(fd, guestAddr);
if (g_guestRegionSize && guestAddr >= g_guestRegionSize) {
SafeStr(fd, " <- BEYOND the mapped guest region (");
SafeHex(fd, g_guestRegionSize);
SafeStr(fd, ") - a wild pointer, not a real guest object");
}
SafeStr(fd, "\n");
} else if (g_guestFlatBase) {
SafeStr(fd, "fault address is OUTSIDE the guest window - this is a fault in the "
"engine's own native code, not in emulated guest code\n");
}
SafeStr(fd, "thread id: ");
SafeDec(fd, (long)gettid());
SafeStr(fd, "\n\n");
WriteRegisters(fd, ucontextRaw);
SafeStr(fd,
"\nnote: Android's own tombstone for this crash has more detail "
"(/data/tombstones). This file exists because a tester cannot reach that.\n");
close(fd);
}
}
// Chain to whatever was installed before us so the platform still writes
// its tombstone and the process dies the way it would have. Restoring the
// previous action and re-raising is the portable way to do that.
if (sig >= 0 && sig < NSIG) {
sigaction(sig, &g_prevActions[sig], nullptr);
}
raise(sig);
}
} // namespace
void SetCrashHandlerGuestBase(uint64_t flatBase, uint32_t regionSize) {
g_guestFlatBase = flatBase;
g_guestRegionSize = regionSize;
}
void InstallCrashHandler(const char* crashDir, const char* buildStamp) {
if (g_installed) return;
if (!crashDir || !*crashDir) {
Log("crash_handler: no crash directory given - native crashes will NOT be reported");
return;
}
// mkdir here, at install time. Doing it lazily from the handler would mean
// a filesystem call on a broken process, which is exactly what this file
// exists to avoid.
if (mkdir(crashDir, 0755) != 0 && errno != EEXIST) {
Log("crash_handler: could not create %s (%s) - native crashes will NOT be reported",
crashDir, strerror(errno));
return;
}
// Fixed filename. The handler cannot safely format a timestamp, so Java
// renames this to something unique when it collects it on the next launch.
static const char kFileName[] = "/crash_pending.txt";
size_t dirLen = strlen(crashDir);
if (dirLen + sizeof(kFileName) >= kPathMax) {
Log("crash_handler: crash directory path is too long (%zu) - not installing", dirLen);
return;
}
memcpy(g_reportPath, crashDir, dirLen);
memcpy(g_reportPath + dirLen, kFileName, sizeof(kFileName));
if (buildStamp) {
size_t n = strlen(buildStamp);
if (n >= kStampMax) n = kStampMax - 1;
memcpy(g_buildStamp, buildStamp, n);
g_buildStamp[n] = '\0';
} else {
memcpy(g_buildStamp, "(unknown)", sizeof("(unknown)"));
}
stack_t ss{};
ss.ss_sp = g_altStack;
ss.ss_size = sizeof(g_altStack);
ss.ss_flags = 0;
if (sigaltstack(&ss, nullptr) != 0) {
// Not fatal: without it, a stack-overflow crash goes unreported, but
// every other kind still works. Worth saying out loud rather than
// discovering the gap from a missing report later.
Log("crash_handler: sigaltstack failed (%s) - stack-overflow crashes will not be "
"reported, other crashes still will", strerror(errno));
}
struct sigaction sa{};
sa.sa_sigaction = CrashSignalHandler;
sa.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESTART;
sigemptyset(&sa.sa_mask);
static const int kSignals[] = {SIGSEGV, SIGBUS, SIGABRT, SIGILL, SIGFPE};
for (int sig : kSignals) {
if (sigaction(sig, &sa, &g_prevActions[sig]) != 0) {
Log("crash_handler: could not hook signal %d (%s)", sig, strerror(errno));
}
}
g_installed = true;
Log("crash_handler: installed - native crashes will be written to %s", g_reportPath);
}
+33
View File
@@ -0,0 +1,33 @@
#pragma once
// Native crash reporting (task #66 / BETA_TELEMETRY_PLAN.md).
//
// Most crashes in this project are NATIVE - a SIGSEGV inside JIT-generated
// code - so Java's uncaught-exception handler never sees them and the tester
// sees only "the game closed". This writes a report the moment it happens.
//
// What it does NOT do, deliberately: show any UI. A signal handler runs on a
// process that has already gone wrong, where only async-signal-safe calls are
// legal - no malloc, no JNI, no Activity. It writes one file with write() and
// then lets the process die. The report screen is shown by Java on the NEXT
// launch, which is how every serious crash reporter handles native crashes.
//
// The report lands on external storage (Android/data/<pkg>/files/crashes), so
// a tester can reach it over USB or a file manager without any permission.
#include <cstdint>
// Call once, early, with the directory reports should be written to (the app's
// external files dir). Creates the directory if needed, pre-builds every string
// the handler will need, installs an alternate signal stack so a stack-overflow
// crash can still be reported, and hooks the fatal signals.
//
// Safe to call more than once; only the first call installs anything.
void InstallCrashHandler(const char* crashDir, const char* buildStamp);
// Tells the handler where guest memory starts, so a host fault address can be
// reported as the GUEST address that caused it. Without this a report says
// "fault at 0x6f464c459b", which means nothing to anyone; with it the report
// also says "guest 0x464c459b", which is the number worth reading. Called by
// GuestEngine once its region is mapped.
void SetCrashHandlerGuestBase(uint64_t flatBase, uint32_t regionSize);
+200
View File
@@ -0,0 +1,200 @@
#pragma once
// Narrow crash workarounds found while getting the synthetic LAN event flow
// working (PROGRESS.md cont.29-34). Each guards ONE specific null/missing-
// data condition our under-registered synthetic RaceEvent/Actor triggers -
// none of them are meant to change behavior for real, fully-registered game
// objects. See crash_workarounds.h's own per-hook comments for the exact
// condition each one guards; kEnableDiagnosticAndTestHooks-style blanket
// installs are intentionally NOT used here (see the cont.67 lesson on
// GetComponentNameSkipHook below).
#include <cstdint>
#include <cstring>
#include <cerrno>
#include <sys/mman.h>
#include <unistd.h>
#include "util/util.h"
#include "util/hook_install.h"
extern void* libapp_base;
extern void* g_lastSyntheticRaceEvent; // declared in lan_event_injection.h
// im::app::sounds::CopSounds::Tick reads component data our minimal
// synthetic Actor doesn't provide (live SIGSEGV). Ambient audio system,
// unrelated to the actual car-select flow - skip its body entirely rather
// than replicate its full component requirements. Cosmetic only (no
// chase-sound animation), but note this skips the tick for EVERY actor,
// not just our synthetic one - real chase audio is silently disabled too.
#define COPSOUNDS_TICK_OFFSET 0x304AA0
typedef void (*CopSoundsTickFn)(int a1, int* deltaMs);
static CopSoundsTickFn orig_CopSoundsTick = nullptr;
extern "C" void Hook_CopSoundsTick(int a1, int* deltaMs) {
(void)a1;
(void)deltaMs;
// Deliberately not calling orig_CopSoundsTick - see comment above.
}
static bool InstallCopSoundsTickSkipHook() {
orig_CopSoundsTick = (CopSoundsTickFn)InstallArmTrampolineHook(
libapp_base, COPSOUNDS_TICK_OFFSET, (void*)&Hook_CopSoundsTick, "CopSoundsTick skip hook");
return orig_CopSoundsTick != nullptr;
}
// GetComponentName (a component-name-cache lookup): our synthetic Actor
// isn't in its hash table, so it falls into a "build an RTTI class-name
// error string" fallback that crashed (SIGSEGV). cont.67 CRITICAL LESSON:
// an earlier version of this hook unconditionally returned an empty-string
// sentinel for EVERY call (not just the crashing case), assuming the result
// was "purely cosmetic debug text" - it is not. GetComponentName's result
// gets WRITTEN into a real cache-context object field (via sub_240294,
// called from sub_17A99C - the same function real map-event processing
// uses), and blanket-replacing it silently broke the persistent per-event
// "medal earned" progress record for every real race, not just our
// synthetic test event - see PROGRESS.md cont.67 for the full live-bisected
// root-cause writeup. Left DISABLED (not installed from main.cpp) for this
// reason - only re-enable with a fix scoped to the exact crashing input/
// object, never a blanket substitution.
#define GET_COMPONENT_NAME_OFFSET 0x240548
#define EMPTY_STRING_SENTINEL_OFFSET 0xAC80E0
typedef char* (*GetComponentNameFn)(int a1, int* a2);
static GetComponentNameFn orig_GetComponentName = nullptr;
extern "C" char* Hook_GetComponentName(int a1, int* a2) {
(void)a1;
(void)a2;
return (char*)((uintptr_t)libapp_base + EMPTY_STRING_SENTINEL_OFFSET);
}
static bool InstallGetComponentNameSkipHook() {
orig_GetComponentName = (GetComponentNameFn)InstallArmTrampolineHook(
libapp_base, GET_COMPONENT_NAME_OFFSET, (void*)&Hook_GetComponentName, "GetComponentName skip hook");
return orig_GetComponentName != nullptr;
}
// A hand-optimized SWAR strlen() (sub_62F340) dereferences a NULL string
// pointer (fault addr 0x0) - likely reached while building an RTTI/class-
// name debug string for our under-registered synthetic actor, same family
// as the GetComponentName crash above but a different call site. Guard the
// leaf itself: return 0 for NULL, fall through to the real implementation
// otherwise - this one only ever changes behavior for an input that would
// otherwise crash, so it's safe to leave on unconditionally.
#define STRLEN_OFFSET 0x62F340
typedef int (*StrlenFn)(const char* s);
static StrlenFn orig_Strlen = nullptr;
extern "C" int Hook_Strlen(const char* s) {
if (!s) {
Log("Strlen null-guard: called with NULL, returning 0 instead of crashing");
return 0;
}
return orig_Strlen(s);
}
static bool InstallStrlenNullGuardHook() {
orig_Strlen = (StrlenFn)InstallArmTrampolineHook(
libapp_base, STRLEN_OFFSET, (void*)&Hook_Strlen, "Strlen null-guard hook");
return orig_Strlen != nullptr;
}
// sub_40A2B0 is the engine's generic "resolve display text for this key"
// call. On a lookup miss it renders `"XXXXX[" + rawText + "]"` (a real,
// pre-existing missing-localization marker) instead of plain text - our
// synthetic event's name/track fields were never registered as real
// string-table keys, so they always take this path. Rather than replicate
// the string-table registration format, let the original run unmodified
// and post-process its already-allocated output buffer: strip the marker
// in place (shrink-only memmove, no realloc, allocation base untouched so
// a later free() stays safe). Generic fix, not specific to our event - any
// raw-text fallback anywhere in the game renders cleanly instead of with
// the debug marker.
typedef int* (*ResolveDisplayTextFn)(int* outStr, int context, int key);
static ResolveDisplayTextFn orig_ResolveDisplayText = nullptr;
// Diagnostic-only: identifies which key/context resolves to genuinely empty
// content, so a synthetic RaceEvent's missing fields can be found. Budget-
// gated so a runaway caller can't flood logcat.
static volatile int g_emptyResolveLogBudget = 0;
extern "C" int* Hook_ResolveDisplayText(int* outStr, int context, int key) {
int* result = orig_ResolveDisplayText(outStr, context, key);
if (!outStr[0] || !outStr[1] || outStr[0] == outStr[1]) {
// A second lookup-miss branch inside sub_40A2B0 returns a plain
// empty result with no "XXXXX[" marker at all - nothing to strip.
if (g_emptyResolveLogBudget > 0) {
g_emptyResolveLogBudget--;
Log("DIAG ResolveDisplayText BLANK: context=0x%x key=%p key_as_str=\"%s\" caller=%p",
context, (void*)(uintptr_t)key,
key ? (const char*)(uintptr_t)key : "(null)",
__builtin_return_address(0));
}
return result;
}
uint16_t* begin = (uint16_t*)outStr[0];
uint16_t* end = (uint16_t*)outStr[1];
static const uint16_t kPrefix[] = {'X', 'X', 'X', 'X', 'X', '['};
ptrdiff_t len = end - begin;
if (len < 6 || memcmp(begin, kPrefix, sizeof(kPrefix)) != 0) return result;
uint16_t* p = end;
while (p > begin + 6 && *(p - 1) != ']') p--;
if (p <= begin + 6) return result; // no closing bracket found, leave as-is
uint16_t* closeBracket = p - 1;
ptrdiff_t innerLen = closeBracket - (begin + 6);
if (innerLen <= 0) {
if (g_emptyResolveLogBudget > 0) {
g_emptyResolveLogBudget--;
Log("DIAG ResolveDisplayText EMPTY: context=0x%x key=%p key_as_str=\"%s\" caller=%p",
context, (void*)(uintptr_t)key,
key ? (const char*)(uintptr_t)key : "(null)",
__builtin_return_address(0));
}
return result;
}
memmove(begin, begin + 6, innerLen * sizeof(uint16_t));
begin[innerLen] = 0;
outStr[1] = (int)(uintptr_t)(begin + innerLen);
return result;
}
static bool InstallResolveDisplayTextHook() {
orig_ResolveDisplayText = (ResolveDisplayTextFn)InstallArmTrampolineHook(
libapp_base, 0x40A2B0, (void*)&Hook_ResolveDisplayText, "ResolveDisplayText hook");
return orig_ResolveDisplayText != nullptr;
}
// sub_40A29C is the tiny wrapper - `*fieldPtr` then call sub_40A2B0(a1, a2,
// *fieldPtr) - that all real callers actually invoke (sub_40A2B0 is always
// reached via this wrapper's tail-call, so a hook on sub_40A2B0 alone can't
// see the true caller via __builtin_return_address). Hooking here instead
// exposes both the real caller and the field pointer itself, so a null
// field can be reported as "RaceEvent+N" by diffing against
// g_lastSyntheticRaceEvent. Diagnostic-only, budget-gated.
static volatile int g_wrapperNullFieldLogBudget = 0;
typedef int (*ResolveDisplayTextWrapperFn)(int a1, int a2, int* fieldPtr);
static ResolveDisplayTextWrapperFn orig_ResolveDisplayTextWrapper = nullptr;
extern "C" int Hook_ResolveDisplayTextWrapperDiag(int a1, int a2, int* fieldPtr) {
if (g_wrapperNullFieldLogBudget > 0) {
g_wrapperNullFieldLogBudget--;
ptrdiff_t offset = g_lastSyntheticRaceEvent
? ((uint8_t*)fieldPtr - (uint8_t*)g_lastSyntheticRaceEvent)
: -1;
int fieldVal = (fieldPtr) ? *fieldPtr : 0;
Log("DIAG sub_40A29C: fieldPtr=%p (RaceEvent+%ld) *fieldPtr=%p as_str=\"%s\" caller=%p",
(void*)fieldPtr, (long)offset, (void*)(uintptr_t)fieldVal,
fieldVal ? (const char*)(uintptr_t)fieldVal : "(null)",
__builtin_return_address(0));
}
return orig_ResolveDisplayTextWrapper(a1, a2, fieldPtr);
}
static bool InstallResolveDisplayTextWrapperDiagHook() {
orig_ResolveDisplayTextWrapper = (ResolveDisplayTextWrapperFn)InstallArmTrampolineHook(
libapp_base, 0x40A29C, (void*)&Hook_ResolveDisplayTextWrapperDiag, "ResolveDisplayTextWrapper diag hook");
return orig_ResolveDisplayTextWrapper != nullptr;
}
@@ -0,0 +1,131 @@
// Guest-side __dynamic_cast fast path (task #59, 2026-09-19).
//
// WHY THIS EXISTS, and why it inverts this project's usual instinct.
//
// Everything accelerated so far - crc32, inflate, the FNV hash - was moved
// TO THE HOST, because host code is faster than emulated code. __dynamic_cast
// is the opposite case, and the measurements say so plainly:
//
// - It is 49% of ALL shim crossings during a prologue load (284,986/sec),
// found by the per-shim TOPSHIMS counter.
// - Native ARM32 on the A9 calls it up to 824,200/sec - roughly THREE TIMES
// more (trace_agent's DYNCAST interposer, task #58). So our rate is not
// what the guest wants, it is all we can supply: the boundary is
// throttling it.
// - Cheapening the boundary generally (lock-free dispatch, batched register
// reads - commit 9c4a455) bought only ~3%.
//
// So the win is not in making the call faster, it is in NOT CROSSING. The
// algorithm reads only guest memory (an object's vtable, vtable[-1] = dynamic
// type, vtable[-2] = offset-to-top) and needs nothing from the host, so it can
// run as ordinary emulated ARM32 with zero crossings. Emulated-but-not-
// crossing beats native-but-crossing here.
//
// HOW SMALL IT CAN BE, measured rather than assumed. Instrumenting
// Shim_dynamic_cast over one prologue load (2,745,185 calls):
//
// exact match at depth 0 ... 84.2% <- dynamic type already IS the target
// one base (depth 1) ....... 0.4%
// deeper ................... 0.0% (max depth all run: 3)
// not found ................ 15.4%
//
// 84% need no hierarchy walk at all. This handles exactly that case and tail-
// calls the existing host shim for everything else, so correctness is
// unchanged - the fallback is the same code that served every call before.
// Removing 84% of a 49% share removes ~41% of the total crossing load.
#include "dyncast_fastpath.h"
#include "guest_engine.h"
#include "../util/util.h"
#include <cstring>
namespace {
// Assembled with the NDK's clang for armv7a Thumb-2 (not hand-encoded - see
// this file's .S source kept below verbatim, and objdump output that was
// checked against it). Clobbers ONLY r12, deliberately: r1 (src type_info)
// and r3 (src2dst hint) must reach the fallback untouched, which is why the
// hit path re-loads the vtable instead of keeping it in a second register.
//
// dyncast_fast: @ r0=sub r1=src r2=dst r3=hint
// cmp r0, #0
// beq .Lret0
// ldr r12, [r0] @ vtable pointer
// cmp r12, #0
// beq .Lret0
// ldr r12, [r12, #-4] @ vtable[-1] = dynamic type_info
// cmp r12, r2
// bne .Lslow @ not an exact match -> host shim
// ldr r12, [r0]
// ldr r12, [r12, #-8] @ vtable[-2] = offset-to-top
// add r0, r0, r12
// bx lr
// .Lret0:
// movs r0, #0
// bx lr
// .Lslow:
// ldr r12, .Lslowaddr
// bx r12
// .align 2
// .Lslowaddr:
// .word 0xDEADBEEF @ patched with the slow-path stub
const uint8_t kFastPathCode[] = {
0x00, 0x28, 0x0e, 0xd0, 0xd0, 0xf8, 0x00, 0xc0,
0xbc, 0xf1, 0x00, 0x0f, 0x09, 0xd0, 0x5c, 0xf8,
0x04, 0xcc, 0x94, 0x45, 0x07, 0xd1, 0xd0, 0xf8,
0x00, 0xc0, 0x5c, 0xf8, 0x08, 0xcc, 0x60, 0x44,
0x70, 0x47, 0x00, 0x20, 0x70, 0x47, 0xdf, 0xf8,
0x04, 0xc0, 0x60, 0x47, 0xef, 0xbe, 0xad, 0xde,
};
constexpr uint32_t kSlowAddrPatchOffset = 44; // the 0xDEADBEEF word
} // namespace
// A/B switch (temporary): false leaves __dynamic_cast entirely on the host
// shim, so the control-arena permission change can be measured WITHOUT the
// fast path confounding it - the two landed together and must be separated
// before either is judged.
static constexpr bool kEnableGuestFastPath = false;
void RegisterDynamicCastFastPath(GuestEngine& engine) {
if (!kEnableGuestFastPath) {
Log("dyncast_fastpath: guest fast path DISABLED (kEnableGuestFastPath=false) - "
"__dynamic_cast stays on the host shim");
return;
}
engine.RegisterDataSymbolSetup([](GuestEngine& eng) {
// The fallback target. Registered under its own name so that
// ResolveOrCreateImportStub still builds a real callable stub for it -
// the data symbol installed below would otherwise win for the name
// "__dynamic_cast" and no stub would ever exist to fall back to.
GuestAddr slowStub = eng.ResolveOrCreateImportStub("__dynamic_cast_slowpath");
if (!slowStub) {
Log("dyncast_fastpath: could not create the slow-path stub - leaving __dynamic_cast "
"on the host shim entirely (no fast path installed)");
return;
}
// AllocGuestCode, NOT AllocPermanent: the latter carves from the
// control arena, which is mapped read-write only. Placing code there
// produced an immediate FETCH_PROT at this function's own entry, and
// making that arena executable to accommodate it cost a measured ~4s
// of load time (more than this fast path saves). The trampoline arena
// is already executable and is not a write-hot region.
GuestAddr code = eng.AllocGuestCode(sizeof(kFastPathCode));
if (!code) {
Log("dyncast_fastpath: AllocGuestCode(%zu) failed - leaving __dynamic_cast on the "
"host shim entirely (no fast path installed)", sizeof(kFastPathCode));
return;
}
memcpy(eng.G2H(code), kFastPathCode, sizeof(kFastPathCode));
memcpy(eng.G2H(code + kSlowAddrPatchOffset), &slowStub, 4);
// Thumb bit - this engine's only real mode (see MapSegments). Without
// it the guest would branch here in ARM mode and misdecode every byte.
GuestAddr entry = code | 1u;
eng.RegisterDataSymbol("__dynamic_cast", entry);
Log("dyncast_fastpath: __dynamic_cast now resolves to guest code at 0x%x (slow path stub "
"0x%x) - the 84%% exact-match case no longer crosses the shim boundary",
entry, slowStub);
});
}
@@ -0,0 +1,13 @@
#pragma once
// Installs a guest-side ARM32 fast path for __dynamic_cast so the common case
// never crosses the shim boundary. See dyncast_fastpath.cpp's own comment for
// the measurements that motivated it (it is 49% of all crossings, native calls
// it 3x more often than we can serve, and 84% of calls need no hierarchy walk).
//
// Must be called during shim registration, BEFORE the image is loaded: it
// registers a data-symbol setup function, which the loader runs after
// MapSegments (AllocPermanent is live by then) and before ProcessRelocations
// (so the resolved address reaches the GOT).
class GuestEngine;
void RegisterDynamicCastFastPath(GuestEngine& engine);
+403
View File
@@ -0,0 +1,403 @@
#include "fmod_shims.h"
#include <cstring>
#include <mutex>
#include <set>
namespace {
constexpr uint32_t kFmodOk = 0;
// 2026-09-06 (ARM64_TRANSLATION_LAYER.md "PC wanders into .data/.bss"
// investigation): FakeHandle used to hand out 16 raw AllocPermanent bytes
// with NO vtable pointer set (i.e. a real 0 at offset 0). That's fine for
// the handful of calls this file itself makes on a fake object (none - it
// never dereferences its own handles), but real guest code that later
// calls a genuinely virtual method through one of these handles (confirmed
// live: SoundManager::SetVolume calling EventCategory::setVolume via
// vtable+0x20 on a getCategory() fake handle) reads *(0+0x20) - the guest's
// own ELF header's e_shoff field, a real but unrelated large integer - and
// jumps into it as code, landing deep in .data with no diagnostic trail
// pointing back here. Give every fake handle a real, generous, callable
// vtable whose slots all just return 0/kFmodOk instead - same pattern this
// project already uses for RTTI/libc++ facet objects it doesn't fully
// reimplement (see rtti_shims.cpp's ctype<char>/num_put<char> vtables).
// Built once, shared by every FakeHandle() call (all real FMOD interfaces
// this project stubs - EventSystem, Event, EventCategory, ChannelGroup,
// DSP, Sound, Channel - are small enough that no real vtable comes close
// to 64 slots).
GuestAddr g_fmodNoOpVtable = 0;
GuestAddr FmodNoOpVtable(GuestEngine& eng) {
if (g_fmodNoOpVtable) return g_fmodNoOpVtable;
constexpr int kSlots = 64;
GuestAddr vtable = eng.AllocPermanent((uint32_t)kSlots * 4);
for (int i = 0; i < kSlots; i++) {
GuestAddr stub = eng.AllocCodeStub(
[](uc_engine* uc, uint64_t, uint32_t, void*) {
uint32_t zero = 0;
uc_reg_write(uc, UC_ARM_REG_R0, &zero);
},
nullptr);
if (vtable && stub) memcpy(eng.G2H(vtable + (uint32_t)i * 4), &stub, 4);
}
g_fmodNoOpVtable = vtable;
return vtable;
}
// FIX (2026-09-19): this used to call AllocPermanent(16) on EVERY invocation.
// AllocPermanent is a bump allocator that never frees (by design - see its
// own comment), while FakeHandle is called from every FMOD factory/getter
// shim below (EventSystem_Create, getEvent, getChannel, getSound, ...), which
// the game hits continuously while loading a level. That is an unbounded
// leak: confirmed live on the Pixel 6a, where a prologue load drained the
// control arena and then logged "control arena exhausted (requested 16 bytes,
// 0 remaining)" roughly 120 times a second, indefinitely. Raising the arena
// size did not help and could not have - a leak is not a capacity problem.
//
// These handles are interchangeable opaque dummies: their only content is the
// shared no-op vtable pointer, and every shim that receives one ignores it
// and returns kFmodOk. So they are recycled from a fixed pool allocated once.
// The pool keeps them DISTINCT rather than returning one singleton, because
// guest code may legitimately compare two handles for inequality (e.g. "find
// a channel that isn't the current one"), and a single shared address could
// turn such a search into a spin. 1024 far exceeds the number of FMOD objects
// the game holds live at once, so live handles never alias in practice.
constexpr int kFakeHandlePoolSize = 1024;
GuestAddr g_fakeHandlePool = 0;
int g_fakeHandleCursor = 0;
GuestAddr FakeHandle(GuestEngine& eng) {
GuestAddr vt = FmodNoOpVtable(eng);
if (!g_fakeHandlePool) {
g_fakeHandlePool = eng.AllocPermanent(kFakeHandlePoolSize * 16);
if (!g_fakeHandlePool) return 0;
// Every slot carries the same vtable pointer; the remaining 12 bytes
// stay zero, exactly as the per-call version left them.
for (int i = 0; i < kFakeHandlePoolSize; i++) {
GuestAddr slot = g_fakeHandlePool + (uint32_t)i * 16;
if (vt) memcpy(eng.G2H(slot), &vt, 4);
}
}
GuestAddr obj = g_fakeHandlePool + (uint32_t)g_fakeHandleCursor * 16;
g_fakeHandleCursor = (g_fakeHandleCursor + 1) % kFakeHandlePoolSize;
return obj;
}
void OutPtr(GuestEngine& eng, uint32_t slot, GuestAddr v) { if (slot) memcpy(eng.G2H(slot), &v, 4); }
void OutFloat(GuestEngine& eng, uint32_t slot, float v) { if (slot) memcpy(eng.G2H(slot), &v, 4); }
void OutBool(GuestEngine& eng, uint32_t slot, bool v) { if (slot) { uint32_t b = v ? 1u : 0u; memcpy(eng.G2H(slot), &b, 4); } }
void OutU32(GuestEngine& eng, uint32_t slot, uint32_t v) { if (slot) memcpy(eng.G2H(slot), &v, 4); }
// FMOD_VECTOR = 3 floats (12 bytes) - stable/unchanged across every FMOD
// version, safe to zero exactly (unlike the bigger, uncertain structs this
// file otherwise leaves untouched - see fmod_shims.h's own comment).
void OutVectorZero(GuestEngine& eng, uint32_t slot) { if (slot) memset(eng.G2H(slot), 0, 12); }
// ---- Factory functions ----
uint32_t Shim_FMOD_EventSystem_Create(GuestEngine& eng, uint32_t out, uint32_t, uint32_t, uint32_t, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_FMOD_Memory_Initialize(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
// ---- EventSystem ----
uint32_t Shim_EventSystem_init(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_EventSystem_update(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_EventSystem_release(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_EventSystem_getSystemObject(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_EventSystem_getMusicSystem(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_EventSystem_getEvent(GuestEngine& eng, uint32_t, uint32_t, uint32_t, uint32_t out, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_EventSystem_getGroup(GuestEngine& eng, uint32_t, uint32_t, uint32_t, uint32_t out, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_EventSystem_getCategory(GuestEngine& eng, uint32_t, uint32_t, uint32_t out, uint32_t, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_EventSystem_load(GuestEngine& eng, uint32_t, uint32_t, uint32_t, uint32_t out, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_EventSystem_getReverbPreset(GuestEngine& eng, uint32_t, uint32_t, uint32_t, uint32_t indexOut, uint32_t) {
OutU32(eng, indexOut, 0);
return kFmodOk; // FMOD_REVERB_PROPERTIES* left untouched - size not independently confirmed, see fmod_shims.h
}
uint32_t Shim_EventSystem_setReverbProperties(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_EventSystem_set3DListenerAttributes(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
// ---- ChannelGroup ----
uint32_t Shim_ChannelGroup_getSystemObject(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_ChannelGroup_addDSP(GuestEngine& eng, uint32_t, uint32_t, uint32_t out, uint32_t, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
// ---- EventParameter ----
uint32_t Shim_EventParameter_keyOff(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_EventParameter_getValue(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutFloat(eng, out, 0.0f);
return kFmodOk;
}
uint32_t Shim_EventParameter_setValue(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
// ---- DSP ----
uint32_t Shim_DSP_setParameter(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_DSP_release(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
// ---- Event ----
uint32_t Shim_Event_getCategory(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_Event_setCallback(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; } // callback never invoked - no real event ever fires
uint32_t Shim_Event_getParameter(GuestEngine& eng, uint32_t, uint32_t, uint32_t out, uint32_t, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_Event_get3DOcclusion(GuestEngine& eng, uint32_t, uint32_t out1, uint32_t out2, uint32_t, uint32_t) {
OutFloat(eng, out1, 0.0f);
OutFloat(eng, out2, 0.0f);
return kFmodOk;
}
uint32_t Shim_Event_set3DOcclusion(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Event_get3DAttributes(GuestEngine& eng, uint32_t, uint32_t out1, uint32_t out2, uint32_t, uint32_t) {
OutVectorZero(eng, out1);
OutVectorZero(eng, out2);
return kFmodOk;
}
uint32_t Shim_Event_getChannelGroup(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_Event_set3DAttributes(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Event_setPropertyByIndex(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Event_stop(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Event_start(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Event_getInfo(GuestEngine& eng, uint32_t, uint32_t indexOut, uint32_t, uint32_t, uint32_t) {
OutU32(eng, indexOut, 0);
return kFmodOk; // name (char**) and FMOD_EVENT_INFO* left untouched - see fmod_shims.h
}
uint32_t Shim_Event_getMute(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutBool(eng, out, false);
return kFmodOk;
}
uint32_t Shim_Event_release(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Event_setMute(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Event_getPitch(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutFloat(eng, out, 1.0f); // neutral pitch
return kFmodOk;
}
uint32_t Shim_Event_getState(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutU32(eng, out, 0); // no state flags active - not playing/loading/etc
return kFmodOk;
}
uint32_t Shim_Event_setPitch(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Event_getPaused(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutBool(eng, out, false);
return kFmodOk;
}
uint32_t Shim_Event_getVolume(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutFloat(eng, out, 1.0f);
return kFmodOk;
}
uint32_t Shim_Event_setPaused(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Event_setVolume(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
// ---- Sound ----
uint32_t Shim_Sound_release(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
// ---- System ----
uint32_t Shim_System_createSound(GuestEngine& eng, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t sp) {
uint32_t out = eng.ReadIncomingArg(4, 0, 0, 0, 0, sp); // 5th total arg (this,name,mode,exinfo,+sound**)
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_System_getCPUUsage(GuestEngine& eng, uint32_t, uint32_t out1, uint32_t out2, uint32_t out3, uint32_t sp) {
uint32_t out4 = eng.ReadIncomingArg(4, 0, out1, out2, out3, sp);
uint32_t out5 = eng.ReadIncomingArg(5, 0, out1, out2, out3, sp);
OutFloat(eng, out1, 0.0f);
OutFloat(eng, out2, 0.0f);
OutFloat(eng, out3, 0.0f);
OutFloat(eng, out4, 0.0f);
OutFloat(eng, out5, 0.0f);
return kFmodOk;
}
uint32_t Shim_System_setFileSystem(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
// Real signature takes ~10 args (mostly guest function-pointer
// callbacks for async file I/O) - none of them will ever be called
// (no real audio, no real file streaming), so there's nothing worth
// reading even the extra stack args for.
return kFmodOk;
}
uint32_t Shim_System_setSpeakerMode(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_System_createDSPByType(GuestEngine& eng, uint32_t, uint32_t, uint32_t out, uint32_t, uint32_t) {
OutPtr(eng, out, FakeHandle(eng));
return kFmodOk;
}
uint32_t Shim_System_setDSPBufferSize(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_System_getSoftwareFormat(GuestEngine& eng, uint32_t, uint32_t out1, uint32_t out2, uint32_t out3, uint32_t sp) {
uint32_t out4 = eng.ReadIncomingArg(4, 0, out1, out2, out3, sp);
uint32_t out5 = eng.ReadIncomingArg(5, 0, out1, out2, out3, sp);
uint32_t out6 = eng.ReadIncomingArg(6, 0, out1, out2, out3, sp);
OutU32(eng, out1, 0);
OutU32(eng, out2, 0);
OutU32(eng, out3, 0);
OutU32(eng, out4, 0);
OutU32(eng, out5, 0);
OutU32(eng, out6, 0);
return kFmodOk;
}
uint32_t Shim_System_setSoftwareFormat(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_System_set3DSpeakerPosition(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
// FIX (2026-09-19, task #49). Channels handed out by System::playSound and
// not yet stopped. Without this the music never played: playSound reported
// success and returned a fake channel, then Channel::isPlaying answered
// FALSE for it, so the game concluded the track had finished and immediately
// started the next one - forever. Measured in the pause menu: a different
// track roughly every 1.5s, picked non-sequentially (DEAD SARA, DEAD SARA,
// GREEN DAY, SKRILLEX, GREEN DAY, CROSSES, ICONA POP, JOY FORMIDABLE), so
// not a walk through playlists.sb but a fresh selection each time.
//
// Third instance today of the same failure shape - a shim reporting SUCCESS
// while handing back an empty/negative value (see AndroidBitmap_lockPixels
// in gles_shim.cpp, task #41). Hence the log below: this engine has no audio
// backend, so playback here is simulated, and that fact belongs in the log
// rather than only in a comment.
std::set<uint32_t> g_playingChannels;
std::mutex g_playingChannelsMutex;
uint32_t Shim_System_playSound(GuestEngine& eng, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t sp) {
uint32_t out = eng.ReadIncomingArg(4, 0, 0, 0, 0, sp); // 5th total arg (this,channelid,sound,paused,+channel**)
GuestAddr channel = FakeHandle(eng);
OutPtr(eng, out, channel);
{
std::lock_guard<std::mutex> lock(g_playingChannelsMutex);
g_playingChannels.insert(channel);
}
static std::once_flag once;
std::call_once(once, [] {
Log("fmod_shims: no audio backend - System::playSound will report channels as PLAYING until "
"the guest stops them. Sound is silent by design; without this the guest sees every track "
"finish instantly and thrashes through the playlist (task #49).");
});
return kFmodOk;
}
uint32_t Shim_System_setOutput(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
// ---- Channel ----
uint32_t Shim_Channel_setCallback(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Channel_setPriority(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Channel_stop(GuestEngine&, uint32_t r0, uint32_t, uint32_t, uint32_t, uint32_t) {
// r0 is `this`. Dropping it here is what lets the guest end a track on its
// own terms - the next isPlaying() then honestly answers false, so normal
// transitions (race ends, menu change) still work.
std::lock_guard<std::mutex> lock(g_playingChannelsMutex);
g_playingChannels.erase(r0);
return kFmodOk;
}
uint32_t Shim_Channel_setMute(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Channel_getPaused(GuestEngine& eng, uint32_t, uint32_t out, uint32_t, uint32_t, uint32_t) {
OutBool(eng, out, false);
return kFmodOk;
}
uint32_t Shim_Channel_isPlaying(GuestEngine& eng, uint32_t r0, uint32_t out, uint32_t, uint32_t, uint32_t) {
// See g_playingChannels' comment. Answering "playing" only for channels we
// actually handed out - rather than a blanket true - keeps a stale or
// never-started channel honest.
bool playing;
{
std::lock_guard<std::mutex> lock(g_playingChannelsMutex);
playing = g_playingChannels.count(r0) != 0;
}
OutBool(eng, out, playing);
return kFmodOk;
}
uint32_t Shim_Channel_setPaused(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
uint32_t Shim_Channel_setVolume(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return kFmodOk; }
} // namespace
void RegisterFmodImportShims(GuestEngine& engine) {
engine.RegisterImportShim("FMOD_EventSystem_Create", Shim_FMOD_EventSystem_Create);
engine.RegisterImportShim("FMOD_Memory_Initialize", Shim_FMOD_Memory_Initialize);
engine.RegisterImportShim("_ZN4FMOD11EventSystem4initEijPvj", Shim_EventSystem_init);
engine.RegisterImportShim("_ZN4FMOD11EventSystem6updateEv", Shim_EventSystem_update);
engine.RegisterImportShim("_ZN4FMOD11EventSystem7releaseEv", Shim_EventSystem_release);
engine.RegisterImportShim("_ZN4FMOD11EventSystem15getSystemObjectEPPNS_6SystemE", Shim_EventSystem_getSystemObject);
engine.RegisterImportShim("_ZN4FMOD11EventSystem14getMusicSystemEPPNS_11MusicSystemE", Shim_EventSystem_getMusicSystem);
engine.RegisterImportShim("_ZN4FMOD11EventSystem8getEventEPKcjPPNS_5EventE", Shim_EventSystem_getEvent);
engine.RegisterImportShim("_ZN4FMOD11EventSystem8getGroupEPKcbPPNS_10EventGroupE", Shim_EventSystem_getGroup);
engine.RegisterImportShim("_ZN4FMOD11EventSystem11getCategoryEPKcPPNS_13EventCategoryE", Shim_EventSystem_getCategory);
engine.RegisterImportShim("_ZN4FMOD11EventSystem4loadEPKcP19FMOD_EVENT_LOADINFOPPNS_12EventProjectE", Shim_EventSystem_load);
engine.RegisterImportShim("_ZN4FMOD11EventSystem15getReverbPresetEPKcP22FMOD_REVERB_PROPERTIESPi", Shim_EventSystem_getReverbPreset);
engine.RegisterImportShim("_ZN4FMOD11EventSystem19setReverbPropertiesEPK22FMOD_REVERB_PROPERTIES", Shim_EventSystem_setReverbProperties);
engine.RegisterImportShim("_ZN4FMOD11EventSystem23set3DListenerAttributesEiPK11FMOD_VECTORS3_S3_S3_", Shim_EventSystem_set3DListenerAttributes);
engine.RegisterImportShim("_ZN4FMOD12ChannelGroup15getSystemObjectEPPNS_6SystemE", Shim_ChannelGroup_getSystemObject);
engine.RegisterImportShim("_ZN4FMOD12ChannelGroup6addDSPEPNS_3DSPEPPNS_13DSPConnectionE", Shim_ChannelGroup_addDSP);
engine.RegisterImportShim("_ZN4FMOD14EventParameter6keyOffEv", Shim_EventParameter_keyOff);
engine.RegisterImportShim("_ZN4FMOD14EventParameter8getValueEPf", Shim_EventParameter_getValue);
engine.RegisterImportShim("_ZN4FMOD14EventParameter8setValueEf", Shim_EventParameter_setValue);
engine.RegisterImportShim("_ZN4FMOD3DSP12setParameterEif", Shim_DSP_setParameter);
engine.RegisterImportShim("_ZN4FMOD3DSP7releaseEv", Shim_DSP_release);
engine.RegisterImportShim("_ZN4FMOD5Event11getCategoryEPPNS_13EventCategoryE", Shim_Event_getCategory);
engine.RegisterImportShim("_ZN4FMOD5Event11setCallbackEPF11FMOD_RESULTP10FMOD_EVENT23FMOD_EVENT_CALLBACKTYPEPvS5_S5_ES5_", Shim_Event_setCallback);
engine.RegisterImportShim("_ZN4FMOD5Event12getParameterEPKcPPNS_14EventParameterE", Shim_Event_getParameter);
engine.RegisterImportShim("_ZN4FMOD5Event14get3DOcclusionEPfS1_", Shim_Event_get3DOcclusion);
engine.RegisterImportShim("_ZN4FMOD5Event14set3DOcclusionEff", Shim_Event_set3DOcclusion);
engine.RegisterImportShim("_ZN4FMOD5Event15get3DAttributesEP11FMOD_VECTORS2_S2_", Shim_Event_get3DAttributes);
engine.RegisterImportShim("_ZN4FMOD5Event15getChannelGroupEPPNS_12ChannelGroupE", Shim_Event_getChannelGroup);
engine.RegisterImportShim("_ZN4FMOD5Event15set3DAttributesEPK11FMOD_VECTORS3_S3_", Shim_Event_set3DAttributes);
engine.RegisterImportShim("_ZN4FMOD5Event18setPropertyByIndexEiPvb", Shim_Event_setPropertyByIndex);
engine.RegisterImportShim("_ZN4FMOD5Event4stopEb", Shim_Event_stop);
engine.RegisterImportShim("_ZN4FMOD5Event5startEv", Shim_Event_start);
engine.RegisterImportShim("_ZN4FMOD5Event7getInfoEPiPPcP15FMOD_EVENT_INFO", Shim_Event_getInfo);
engine.RegisterImportShim("_ZN4FMOD5Event7getMuteEPb", Shim_Event_getMute);
engine.RegisterImportShim("_ZN4FMOD5Event7releaseEbb", Shim_Event_release);
engine.RegisterImportShim("_ZN4FMOD5Event7setMuteEb", Shim_Event_setMute);
engine.RegisterImportShim("_ZN4FMOD5Event8getPitchEPf21FMOD_EVENT_PITCHUNITS", Shim_Event_getPitch);
engine.RegisterImportShim("_ZN4FMOD5Event8getStateEPj", Shim_Event_getState);
engine.RegisterImportShim("_ZN4FMOD5Event8setPitchEf21FMOD_EVENT_PITCHUNITS", Shim_Event_setPitch);
engine.RegisterImportShim("_ZN4FMOD5Event9getPausedEPb", Shim_Event_getPaused);
engine.RegisterImportShim("_ZN4FMOD5Event9getVolumeEPf", Shim_Event_getVolume);
engine.RegisterImportShim("_ZN4FMOD5Event9setPausedEb", Shim_Event_setPaused);
engine.RegisterImportShim("_ZN4FMOD5Event9setVolumeEf", Shim_Event_setVolume);
engine.RegisterImportShim("_ZN4FMOD5Sound7releaseEv", Shim_Sound_release);
engine.RegisterImportShim("_ZN4FMOD6System11createSoundEPKcjP22FMOD_CREATESOUNDEXINFOPPNS_5SoundE", Shim_System_createSound);
engine.RegisterImportShim("_ZN4FMOD6System11getCPUUsageEPfS1_S1_S1_S1_", Shim_System_getCPUUsage);
engine.RegisterImportShim(
"_ZN4FMOD6System13setFileSystemEPF11FMOD_RESULTPKciPjPPvS6_EPFS1_S5_S5_EPFS1_S5_S5_jS4_S5_EPFS1_S5_jS5_EPFS1_P18FMOD_ASYNCREADINFOS5_ESA_i",
Shim_System_setFileSystem);
engine.RegisterImportShim("_ZN4FMOD6System14setSpeakerModeE16FMOD_SPEAKERMODE", Shim_System_setSpeakerMode);
engine.RegisterImportShim("_ZN4FMOD6System15createDSPByTypeE13FMOD_DSP_TYPEPPNS_3DSPE", Shim_System_createDSPByType);
engine.RegisterImportShim("_ZN4FMOD6System16setDSPBufferSizeEji", Shim_System_setDSPBufferSize);
engine.RegisterImportShim("_ZN4FMOD6System17getSoftwareFormatEPiP17FMOD_SOUND_FORMATS1_S1_P18FMOD_DSP_RESAMPLERS1_", Shim_System_getSoftwareFormat);
engine.RegisterImportShim("_ZN4FMOD6System17setSoftwareFormatEi17FMOD_SOUND_FORMATii18FMOD_DSP_RESAMPLER", Shim_System_setSoftwareFormat);
engine.RegisterImportShim("_ZN4FMOD6System20set3DSpeakerPositionE12FMOD_SPEAKERffb", Shim_System_set3DSpeakerPosition);
engine.RegisterImportShim("_ZN4FMOD6System9playSoundE17FMOD_CHANNELINDEXPNS_5SoundEbPPNS_7ChannelE", Shim_System_playSound);
engine.RegisterImportShim("_ZN4FMOD6System9setOutputE15FMOD_OUTPUTTYPE", Shim_System_setOutput);
engine.RegisterImportShim("_ZN4FMOD7Channel11setCallbackEPF11FMOD_RESULTP12FMOD_CHANNEL25FMOD_CHANNEL_CALLBACKTYPEPvS5_E", Shim_Channel_setCallback);
engine.RegisterImportShim("_ZN4FMOD7Channel11setPriorityEi", Shim_Channel_setPriority);
engine.RegisterImportShim("_ZN4FMOD7Channel4stopEv", Shim_Channel_stop);
engine.RegisterImportShim("_ZN4FMOD7Channel7setMuteEb", Shim_Channel_setMute);
engine.RegisterImportShim("_ZN4FMOD7Channel9getPausedEPb", Shim_Channel_getPaused);
engine.RegisterImportShim("_ZN4FMOD7Channel9isPlayingEPb", Shim_Channel_isPlaying);
engine.RegisterImportShim("_ZN4FMOD7Channel9setPausedEb", Shim_Channel_setPaused);
engine.RegisterImportShim("_ZN4FMOD7Channel9setVolumeEf", Shim_Channel_setVolume);
}
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "guest_engine.h"
// FMOD Ex "Event System" API stubs (28 symbols: FMOD_EventSystem_Create,
// FMOD_Memory_Initialize, and the EventSystem/Event/EventParameter/
// EventCategory/EventGroup/ChannelGroup/DSP/System/Channel/Sound methods
// this game's own dynsym relocations reference). No real audio - confirmed
// this session that no arm64-v8a build of FMOD Ex (this specific, long-
// deprecated Event System API generation, distinct from modern FMOD
// Studio, which doesn't even have these classes) is available to link
// against for real. See ARM64_TRANSLATION_LAYER.md.
//
// Every FMOD_RESULT-returning function here returns FMOD_OK (0) - the
// GAME's own logic should proceed as if audio initialized successfully
// rather than getting stuck on an audio-readiness gate, exactly the
// "unresolved import silently returns 0" pattern this whole session has
// been replacing elsewhere, except here 0 (FMOD_OK) genuinely IS the
// semantically-correct "everything's fine" answer to give, not an
// accidental one. Every `T**`-shaped output parameter (getEvent,
// createSound, getSystemObject, ...) gets a small, valid, non-null FAKE
// HANDLE (a real guest address, safe to store/pass to a later call, never
// dereferenced beyond identity) instead of NULL, so calling code that
// reasonably checks "did I get a real object back" doesn't bail out of
// its own subsequent logic. Getters write plausible neutral defaults
// (volume=1.0, paused=false, pitch=1.0, no active state flags) rather than
// leaving output params untouched. Struct-shaped output parameters whose
// EXACT size this file can't independently confirm (FMOD_EVENT_INFO,
// FMOD_REVERB_PROPERTIES, FMOD_CREATESOUNDEXINFO) are deliberately left
// untouched rather than guessed-and-memset, to avoid a wrong-sized write
// overflowing into adjacent guest memory - the one well-known, stable FMOD
// struct (FMOD_VECTOR - 3 floats, unchanged across every FMOD version)
// does get zeroed. Callback registrations (Event::setCallback,
// Channel::setCallback) accept and discard the guest callback pointer -
// consistent with "no real audio ever plays," no event will ever fire to
// invoke it.
void RegisterFmodImportShims(GuestEngine& engine);
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
#pragma once
// Real GLES2 shim layer: forwards every gl* import the guest calls to the
// REAL host GLES2 functions - not a translated/emulated GPU driver, just
// argument marshaling (mechanically identical to the JNI shim: pointer args
// G2H-translated, GLfloat args are raw-bit-reinterpreted since armeabi-v7a
// uses the softfp calling convention - see import_shims.cpp's own top
// comment). This works because the guest code calling these is executed
// synchronously on whatever real host thread issued the CallGuestFunction
// (the real engine's own GLThread, via nativeSurfaceCreated/nativeOnDrawFrame
// - see real_native_offsets.h) - Android's own GLSurfaceView machinery has
// ALREADY made a real, current EGL context current on that exact thread
// before calling into any of this, so there is no separate EGL context to
// stand up here; GL calls just land in the real, already-current context.
//
// Covers the full 142-function GLES2 core API (GLES2/gl2.h) minus 3 handled
// by hand for pointer-indirection/return-ownership reasons (see
// gles_shim.cpp): glGetString (returns a driver-owned string, copied into
// guest heap memory rather than returning a raw host pointer),
// glShaderSource (its `string` parameter is an ARRAY of guest pointers,
// each element needs its own translation), glGetVertexAttribPointerv
// (writes a pointer *value* into guest memory - needs H2G on the result,
// not a direct G2H pass-through like every other pointer arg).
//
// Also covers AndroidBitmap_{getInfo,lockPixels,unlockPixels} (<android/
// bitmap.h> - texture loading from Android Bitmap objects).
//
// Known correctness gap (semantic, not a crash): `glVertexAttribPointer`'s
// and `glDrawElements`'s final pointer-shaped parameter is genuinely
// dual-purpose in real OpenGL ES - a real pointer when no buffer is bound
// (GL_ARRAY_BUFFER / GL_ELEMENT_ARRAY_BUFFER respectively), but a small
// integer BYTE OFFSET into the currently-bound buffer object when one IS
// bound (extremely common in real GLES2 rendering code - VBO-based
// pipelines pass small offsets like 0/12/24 here, not pointers). The
// generic "every pointer parameter is G2H-translated" rule these two share
// with the other 137 functions is WRONG for the buffer-bound case: it
// silently turns a small offset into a nonsense (but non-crashing) host
// address instead of passing the offset through unchanged. Fixing this
// properly needs this shim layer to track which buffer is currently bound
// (mirroring glBindBuffer calls) and skip G2H when one is - not done yet;
// documented rather than silently wrong. Low priority until real per-frame
// draw calls are being exercised (this session got only as far as
// GL-capability-detection calls, not actual drawing).
#include "guest_engine.h"
void RegisterGlesImportShims(GuestEngine& engine);
// Diagnostic for the "nothing renders past the splash" investigation (see
// ARM64_TRANSLATION_LAYER.md) - starts a detached background thread that
// periodically logs how many real glClear/glDrawArrays/glDrawElements/
// glUseProgram calls have happened, mirroring profiler.h's
// StartProfileDumpThread pattern. Answers whether the render pipeline is
// being exercised at all once the game reaches its post-splash state.
void StartGlesCounterDumpThread();
File diff suppressed because it is too large Load Diff
+674
View File
@@ -0,0 +1,674 @@
#pragma once
// Core ARM32-on-ARM64 in-process CPU-level translation engine.
// See /ARM64_TRANSLATION_LAYER.md for the design rationale, and this
// session's desktop spike (scratchpad/spike_load.py, run against the real
// native_lib/libapp.so) for what's been validated outside the NDK build
// before this C++ was written.
//
// Design in one paragraph: libapp.so is loaded as an ET_DYN ELF32 whose own
// preferred base is vaddr 0 (confirmed via readelf/pyelftools) - so instead
// of inventing a separate guest/host address translation scheme, this
// engine deliberately maps the image at guest address 0 too, backed by a
// SINGLE contiguous host mmap covering [0, region_size). That makes
// G2H(addr) = host_base + addr and H2G(ptr) = ptr - host_base plain pointer
// arithmetic, and - more importantly - makes it the EXACT SAME arithmetic
// every OFFSET macro already scattered through main.cpp/lan_event_injection.h
// etc. already performs (APP_ADDR(offset) == (uintptr_t)libapp + offset).
// None of that pre-existing reverse-engineering work needs to change.
//
// Hooking model: InstallTrampolineHook<Ret,Args...>(target, hookFn, name)
// registers a Unicorn UC_HOOK_CODE at `target` (no byte-patching, unlike
// this project's old armhook.cpp - Unicorn's hook fires BEFORE the real
// instruction there is fetched, so nothing needs to be overwritten). The
// dispatcher decodes r0-r3 into hookFn's real C++ argument types (pointer
// args are G2H-translated automatically), calls hookFn (real host C++,
// unchanged from what it always was), writes its return value into r0, and
// sets PC=LR - i.e. from the guest's point of view the target function ran
// and returned instantly. If hookFn wants to also run the REAL original
// code (this project's universal "call orig_XXX(...) and use/return its
// result" pattern), it calls the GuestFn this function returns, which
// invokes a small trampoline built IN GUEST MEMORY (a verbatim copy of the
// two displaced original instructions + a jump back to target+8, built the
// same way this project's old InstallArmTrampolineHook built one in host
// RWX memory) via CallGuestFunction - a real, separate, re-entrant Unicorn
// call, so the rest of the original function actually executes.
//
// Multithreading model (added 2026-09-01, after a real on-device hang -
// see ARM64_TRANSLATION_LAYER.md): a single Unicorn `uc_engine` holds ONE
// CPU register set, so it cannot run more than one guest instruction stream
// concurrently - real guest threads (spawned via pthread_create, see
// emu/pthread_shim.*) each get their OWN `uc_engine*`, mapped onto the SAME
// shared `host_region_` buffer via `uc_mem_map_ptr` (exactly mirroring how
// real OS threads share one process's memory but have separate register/
// stack state) and their own freshly-carved guest stack region (see
// CarveThreadStack). All UC_HOOK_CODE registrations (import stubs, JNI/GLES
// slots, trampoline hooks) are recorded in `hook_registrations_` at install
// time and REPLAYED onto every new engine (ReplayHooksOnEngine) - Unicorn
// hooks are per-engine, not shared. `uc_` itself is `thread_local`, and
// every public entry point that might run on a new host thread
// (CallGuestFunction) calls EnsureThreadEngine() first, so callers never
// need to think about which engine is "current" - existing code that reads
// `eng.uc()` automatically gets the right one for whichever thread it's
// running on.
#include <cstdint>
#include <cstddef>
#include <cstring>
#include <string>
#include <mutex>
#include <atomic>
#include <vector>
#include <unordered_map>
#include <dlfcn.h>
#include <unicorn/unicorn.h>
#include "guest_types.h"
#include "guest_heap.h"
#include "../util/util.h"
// A resolved-and-loaded import shim: called when guest code reaches a GOT
// slot's stub address instead of a real function (see import_shims.h/.cpp
// and emu/gles_shim.h/.cpp for the actual implementations). Args are raw
// r0-r3 plus `sp` (the entry stack pointer, needed to read AAPCS32 stack
// args for any import with more than 4 parameters - e.g. most GLES2
// functions - via engine.ReadIncomingArg()); return is written into r0.
// Marshaling to/from real host types is the shim's own job (same contract
// as a hook callback).
using ImportShimFn = uint32_t (*)(class GuestEngine& engine, uint32_t r0, uint32_t r1, uint32_t r2, uint32_t r3, uint32_t sp);
class GuestEngine {
public:
static GuestEngine& Instance();
// Loads libapp.so from `path` (a real file - see app module's asset
// extraction for how it gets there since it can no longer be a normal
// jniLibs/armeabi-v7a entry the loader dlopen()s). Returns false and
// logs on any failure (bad ELF, mmap failure, etc.) - this project's
// existing convention (see util/util.cpp's Log) of "log and return
// false, never abort/crash the host process on a load failure".
bool LoadImage(const char* path);
// Loads a SEPARATE ARM32 ELF32 .so image alongside whatever LoadImage
// already loaded, for cases that need a real, independently-compiled
// artifact running under this engine without disturbing libapp.so's own
// state (2026-09-16, ARM64_TRANSLATION_LAYER.md's "isolated
// std::ostringstream repro" test - see emu/ostream_repro_test.cpp for
// the actual caller). Deliberately NOT a second call to LoadImage:
// MapSegments is a single-image design end to end (host_region_ is one
// mmap sized around exactly one image's own image_end_, and depends on
// the loaded image's OWN preferred ET_DYN base being guest address 0 -
// see this class's own top comment) - calling it twice would re-mmap
// host_region_ out from under the already-loaded primary image, losing
// its heap/hooks/relocations entirely. Instead, this carves space for
// the WHOLE second image out of the existing AllocMmap() arena (already
// part of the SAME host_region_ every engine thread maps - see
// AllocMmap's own comment) at whatever guest address that arena's bump
// allocator hands out, then repeats MapSegments/ProcessRelocations'
// logic with a real, non-zero BIAS added to every relocation (the
// primary loader gets away with skipping R_ARM_RELATIVE entirely
// because its own bias is always exactly 0 - see ProcessRelocations'
// own comment - this one actually adds `base` to each one). External
// symbol references resolve through the EXACT SAME
// ResolveOrCreateImportStub/RegisterImportShim table the primary
// image's own imports already use, so this needs no new engine-side
// shim plumbing by itself - whatever real shims (or "unresolved import,
// log once, return 0" fallbacks) libapp.so's loading already registered
// apply here unchanged. This is deliberately NOT a general-purpose
// second dynamic linker (no DT_NEEDED handling, no symbol versioning,
// no PLT-lazy-binding tricks) - just enough to run one small,
// self-contained test artifact end to end.
//
// `entrySymbol` is looked up in the image's own .dynsym via its section
// headers (survives a normal `strip`, unlike .symtab - see this
// function's own .cpp comment for why section headers rather than
// DT_SYMTAB are used for this specific lookup). Returns the resolved
// guest address (Thumb bit already included, same convention as every
// other resolved address in this engine) ready to hand straight to
// CallGuestFunction, or 0 on any failure (bad ELF, arena exhaustion,
// symbol not found - all logged).
// Loads an additional guest ELF alongside the primary image and records
// everything it EXPORTS, so a later import of one of those names resolves
// to the real code instead of a shim (see ResolveOrCreateImportStub).
//
// Pass entrySymbol = nullptr when the caller only wants the library loaded
// and does not need one specific address back - which is the case for the
// game's own FMOD libraries, whose entry points are reached through
// libapp.so's ordinary imports rather than by name from the host.
// Returns the entry symbol's address, or 1 for "loaded, no entry requested",
// or 0 on failure.
GuestAddr LoadSecondaryImage(const char* path, const char* entrySymbol);
// Loads the guest libraries that sit alongside the primary image in the
// same directory - currently the game's own FMOD. Called from LoadImage at
// the one point where it is both possible and still useful; see the call
// site's own comment for why the ordering is not negotiable.
void LoadSiblingLibraries(const char* primaryImagePath);
bool loaded() const { return host_region_ != nullptr; }
// ---- Address translation ----
// Both are plain pointer arithmetic (see class comment) - cheap enough
// to call at every struct-field access site, matching how this
// project's existing APP_ADDR(offset) macro is already used everywhere.
void* G2H(GuestAddr addr) const {
// host_region_ is a single contiguous mmap of exactly region_size_
// bytes covering every real arena (image/heap/trampoline/import-
// stub/misc-stub/control/thread-stacks) - any addr past that is
// definitely not a real guest address. Left unchecked, this used to
// be plain pointer arithmetic handed straight to whatever the
// caller does next (memcpy, strlen, direct struct access...) - one
// of this engine's own Shim_*/Impl_* functions handed a garbage
// guest-supplied pointer (e.g. Shim_time()'s tPtr) would compute a
// wild HOST pointer and crash the entire process with a real
// SIGSEGV, not a graceful, recoverable guest-level fault the way a
// bad access from actual emulated ARM32 code would (that path goes
// through Unicorn's own protection and mem_fault_hook_cb instead -
// this one bypasses it entirely, since it's host C++ dereferencing
// directly). Confirmed live this session via a symbolicated
// tombstone: Shim_time() got a garbage tPtr, memcpy'd 4 bytes
// through G2H(tPtr), and took down the whole app. G2H() is the
// single choke point for all such guest-pointer translation
// (250+ call sites across emu/*.cpp) - bounds-checking it here
// closes the entire bug class at once instead of guarding each
// call site individually.
if (addr >= region_size_) {
LogOutOfRangeG2H(addr);
static thread_local uint8_t scratch[64];
return scratch;
}
return host_region_ + addr;
}
GuestAddr H2G(const void* hostPtr) const {
return static_cast<GuestAddr>(reinterpret_cast<const uint8_t*>(hostPtr) - host_region_);
}
// True if `hostPtr` actually falls inside this engine's guest-backed
// region - H2G() on a pointer that DOESN'T (e.g. a real driver-owned
// buffer, like an AndroidBitmap pixel buffer - see gles_shim.cpp) would
// silently produce a garbage/wraparound "guest address" rather than an
// error, so callers that got a pointer from somewhere OTHER than this
// engine's own G2H/heap should check this before calling H2G.
bool IsHostPointerInRegion(const void* hostPtr) const {
auto p = reinterpret_cast<const uint8_t*>(hostPtr);
return p >= host_region_ && p < host_region_ + region_size_;
}
// host_region_ itself, for code that wants the old-style
// "(uintptr_t)libapp + offset" spelling unchanged (see main.h's
// APP_ADDR macro, redefined in terms of this).
uint8_t* image_host_base() const { return host_region_; }
// One-past-the-end guest address of the loaded ELF image's own PT_LOAD
// segments (page-aligned) - any guest address >= this is one of THIS
// engine's own arenas (heap/trampoline/import-stub/misc-stub/thread-
// stacks), never real libapp.so code, useful for diagnosing a function
// pointer the guest passes around (e.g. pthread_shim.cpp's
// pthread_create logging) that turns out to point at one of our own
// stubs instead of real code.
GuestAddr image_end() const { return image_end_; }
// ---- Guest heap (for malloc/free/calloc shims and any hook body that
// still wants to allocate guest-visible memory directly, e.g.
// InjectSyntheticEvent's RaceEvent/CashReward/FakeActor objects) ----
GuestHeap& heap() { return heap_; }
// Bump-allocates from a small, SEPARATE, never-freed arena reserved for
// permanent, safety-critical control structures - currently just the
// guest JNIEnv/JavaVM (see jni_shim.cpp's BuildGuestJNIEnv/
// BuildGuestJavaVM). Deliberately NOT part of heap() (GuestHeap backs
// the guest program's own uncontrolled malloc/free churn - see
// guest_heap.h's class comment for the corruption this session traced a
// real crash to) - anything allocated here is meant to live for the
// rest of the process and must never be reachable by a stray guest
// free()/overflow in the general heap. No Free() counterpart on
// purpose: nothing here is ever supposed to go away.
GuestAddr AllocPermanent(uint32_t size);
// Bump-allocates from a dedicated arena backing real mmap() (see
// libc_shims.cpp's Shim_mmap) for the anonymous-mapping case. Same
// "never freed" simplicity as AllocPermanent (munmap() stays a no-op -
// no evidence yet that guest code depends on reclaiming mmap'd space),
// but page-granular and its own cursor/mutex since mmap allocations are
// arbitrarily larger than AllocPermanent's small control structures.
// Returns 0 (and logs) if the arena is exhausted.
GuestAddr AllocMmap(uint32_t length);
// ---- Calling into guest code from host C++ ----
// Up to 4 integer/pointer args go in r0-r3; any beyond that are pushed
// onto the guest stack per AAPCS32 (args[4] at [sp+0], args[5] at
// [sp+4], ...) - needed for real JNI entry points like
// nativeSurfaceChanged(env,thiz,gl10,w,h) (5 args) and arbitrary-arity
// JNI Call*Method calls (see emu/jni_shim.*).
uint32_t CallGuestFunction(GuestAddr target, const uint32_t* args, int argCount);
// Convenience overload for the common <=4-arg case (existing call sites).
uint32_t CallGuestFunction(GuestAddr target, uint32_t a0 = 0, uint32_t a1 = 0,
uint32_t a2 = 0, uint32_t a3 = 0) {
uint32_t args[4] = {a0, a1, a2, a3};
return CallGuestFunction(target, args, 4);
}
// The r1 half of the r0:r1 pair a guest function declared to return a
// 64-bit value (long/double, per AAPCS32) left behind at the end of the
// MOST RECENT CallGuestFunction() call on this thread - call this
// immediately after CallGuestFunction() returns, before making any other
// guest call on the same thread (a nested/reentrant CallGuestFunction
// would overwrite it). Added for jni_shim.cpp's RegisterNatives reverse
// bridge (TrampolineBodyWide) to support Java-calls-guest native methods
// declared to return long/double, matching the same r0:r1-pair handling
// already used for the forward (Call*Method) direction - see
// jni_shim.cpp's InvokeCall.
uint32_t LastCallHighWord() const { return t_state_.lastCallHiWord; }
// Reads incoming argument N (0-based) of the function whose UC_HOOK_CODE
// callback is currently executing - r0-r3 for N<4, else the guest stack
// at the point of entry (sp is the entry SP, as seen by the hook
// callback). Used by the JNI shim's dispatcher (jni_shim.cpp) to read
// arguments beyond the 4 general-purpose registers.
uint32_t ReadIncomingArg(int n, uint32_t r0, uint32_t r1, uint32_t r2, uint32_t r3, uint32_t sp) const {
switch (n) {
case 0: return r0;
case 1: return r1;
case 2: return r2;
case 3: return r3;
default: {
uint32_t v = 0;
memcpy(&v, G2H(sp + (uint32_t)(n - 4) * 4), 4);
return v;
}
}
}
// ---- Hooking ----
// Builds a guest-memory trampoline (verbatim copy of target's first two
// instruction words + a jump back to target+8 - REQUIRES those two
// words to be position-independent, exactly the same precondition this
// project's old InstallArmTrampolineHook already required and every
// existing hook site was already manually verified against via IDA
// disasm before hooking) and registers a UC_HOOK_CODE at `target`.
// Returns the trampoline's guest address (0 on failure) - wrap it in a
// GuestFn<Ret,Args...> to get an "orig_XXX"-shaped callable.
GuestAddr InstallTrampolineHookRaw(GuestAddr target, void (*dispatch)(GuestEngine&, void*),
void* userData, const char* debugName);
// ---- Imports ----
void RegisterImportShim(const char* symbolName, ImportShimFn fn);
// Same registration role as RegisterImportShim, but for symbols that
// are semantically DATA (typeinfo objects, vtables), not callable
// functions - e.g. `_ZTIi` (int's type_info) is referenced as `&_ZTIi`
// and read through directly (vtable-pointer field, name-pointer field),
// never called. Resolving a data symbol through the normal code-stub
// path (AllocCodeStub) would hand out an address in the misc-stub arena
// holding a 4-byte UC_HOOK_CODE placeholder - reading struct fields
// through that is garbage, the same "read/write through a wrong-shaped
// address" bug class this session already traced a real corruption to.
// `address` must be a real, pre-built guest address (see
// rtti_shims.cpp for the typeinfo objects this backs) - checked in
// ResolveOrCreateImportStub BEFORE falling back to a code stub.
void RegisterDataSymbol(const char* symbolName, GuestAddr address);
// Reverse of RegisterDataSymbol: which symbol, if any, was given this
// address. Exists so a shim that cannot honour a request can NAME what it
// was asked for instead of printing a bare arena address - the facet
// addresses handed out by AllocPermanent mean nothing on their own, and
// resolving one otherwise costs a rebuild and a reproduction (see
// Shim_use_facet). Linear scan: this is an error path only.
// Returns nullptr when the address is not a registered data symbol.
const char* NameForDataSymbol(GuestAddr address) const;
// Address of a symbol defined by a secondary guest image (see
// LoadSecondaryImage), or 0 if no loaded image exports that name. Lets
// host-side JNI entry points forward into real guest code - see
// game_lifecycle_stubs_extra2.cpp's FMOD audio bridge.
GuestAddr LookupSecondaryExport(const char* symbolName) const;
// Logs once when execution first reaches `addr`, without altering control
// flow - a pure observation point, unlike InstallTrampolineHookRaw which
// displaces instructions. Used to answer "which of these N places produced
// the error code" by watching every candidate at once instead of reasoning
// about which one is reachable.
//
// `label` is copied and used verbatim in the log line.
void WatchGuestAddress(GuestAddr addr, const char* label);
// True if `addr` lies inside ANY loaded guest image - the primary one or
// a sibling loaded by LoadSecondaryImage. Callers that need to tell "real
// guest code" from "one of this engine's own arenas" must ask this rather
// than comparing against image_end(), which only ever described the
// primary image and silently rejected every sibling.
bool IsGuestImageCode(GuestAddr addr) const;
// Registers a callback GuestEngine invokes exactly once per LoadImage
// call, right after MapSegments succeeds (so host_region_/
// AllocPermanent are usable) but strictly BEFORE ProcessRelocations
// resolves any GOT slot - lets a caller (rtti_shims.cpp's
// SetupRttiDataSymbols) build real guest-memory-backed data objects and
// RegisterDataSymbol() them before anything could reference them. Kept
// as a callback rather than a hard dependency so guest_engine.h/.cpp
// doesn't need to know rtti_shims.h exists - main.cpp wires the two
// together by calling this before engine.LoadImage(path).
using DataSymbolSetupFn = void (*)(GuestEngine&);
void RegisterDataSymbolSetup(DataSymbolSetupFn fn) { data_symbol_setup_fns_.push_back(fn); }
// Generic building block behind both the ELF import-stub resolver above
// and jni_shim.cpp's per-slot JNIEnv stubs: carves one fresh guest
// address out of a small reserved arena and registers a UC_HOOK_CODE
// there directly (no trampoline/displaced-instruction copying needed,
// unlike InstallTrampolineHookRaw - there's no real instruction bytes
// at a stub address, it only ever exists to be intercepted).
GuestAddr AllocCodeStub(uc_cb_hookcode_t callback, void* userData);
// Allocates `size` bytes of guest memory that may be EXECUTED, out of the
// trampoline arena. AllocPermanent cannot be used for code: it carves from
// the control arena, which CreateConfiguredEngine maps read-write only
// (task #54 - an executable mapping makes every write to it pay QEMU's
// notdirty_write path, and making the control arena executable again cost
// a measured ~4s of load time). Returns 0 if the arena is exhausted.
GuestAddr AllocGuestCode(uint32_t size);
// Resolves `symbolName` to a real, guest-callable address, creating a
// fresh import stub (via AllocCodeStub) the first time it's asked for
// and caching it thereafter - the same path a real ELF PLT import goes
// through, but callable directly by name for symbols that were never
// themselves a real import (2026-09-18: eglGetProcAddress's own shim
// needs this - some real games resolve even core, non-optional
// functions like eglSwapBuffers this way instead of a direct import -
// see libc_shims.cpp's own Shim_eglGetProcAddress). Was private; made
// public for that use case, no behavior change.
GuestAddr ResolveOrCreateImportStub(const std::string& symbolName);
// Diagnostic: classifies a guest address by which arena it falls into
// (real image code, heap, trampoline/import-stub/misc-stub/control,
// thread-stacks, or fully out of range) - and, for the import-stub
// arena specifically, which registered symbol's stub it is (reverse
// lookup over import_stub_by_symbol_, a small map - fine for a rare
// diagnostic call, not a hot path). Added to investigate a guest
// function pointer (pthread_create's start_routine) landing on one of
// this engine's own stub addresses instead of real ARM32 code - see
// pthread_shim.cpp's own use and ARM64_TRANSLATION_LAYER.md.
std::string DescribeAddress(GuestAddr addr) const;
// The CURRENT host thread's own guest CPU - see class comment's
// "Multithreading model". Never null when called from inside a
// dispatch callback (Unicorn always hands the callback the engine it
// fired on) or after EnsureThreadEngine() has run for this thread.
uc_engine* uc() const { return t_state_.uc; }
// Guarantees the CALLING host thread has its own `uc_engine*` (mapped
// onto the shared guest memory, with every hook replayed) and its own
// guest stack region, creating them on first use if needed. Called
// automatically by CallGuestFunction, so ordinary callers never need to
// call this themselves - exposed mainly for pthread_shim.cpp, which
// must call it as the very first thing on a freshly spawned host thread
// before that thread can run any guest code at all.
void EnsureThreadEngine();
// Tears down everything EnsureThreadEngine (and any nested-call engine)
// set up for the CALLING host thread, returning its guest stacks to the
// arena for reuse. Must be called by any thread that created an engine
// and is about to exit - pthread_shim's thread body does. Safe to call on
// a thread that never had one.
void ReleaseThreadEngine();
// Reserved, never-fetched guest PC used as the "return to caller"
// target for every CallGuestFunction invocation (see .cpp for why a
// fixed constant is safe to reuse across nested calls).
static constexpr GuestAddr kCallReturnSentinel = 0xFFFFFFF0u;
// True once any CallGuestFunction call has hit a fault-class uc_err
// (unmapped/protected memory access, invalid instruction, ...) - see
// CallGuestFunction's own comment for why every subsequent call then
// refuses to run instead of re-entering guest code whose SHARED memory
// (host_region_, one buffer for every thread - see class comment) may
// already be corrupted.
bool crashed() const { return crashed_.load(std::memory_order_relaxed); }
private:
GuestEngine() = default;
bool MapSegments(const uint8_t* fileData, size_t fileSize);
bool ProcessRelocations(const uint8_t* fileData, size_t fileSize);
void ReplayHooksOnEngine(uc_engine* newUc);
// Fix (2026-09-16, ARM64_TRANSLATION_LAYER.md - uc_emu_start()
// reentrancy hang). Everything EnsureThreadEngine() used to do inline to
// stand up a brand-new uc_engine* - uc_open, mapping host_region_ onto
// it, guard-page/RELRO protection, VFP/NEON enable, ReplayHooksOnEngine,
// the MiscStubDispatch/mem_fault_hook_cb/profiling hooks, and every
// diagnostic one-off probe hook - factored out so BOTH the thread's
// primary engine (EnsureThreadEngine) and any per-depth nested engine
// (GetOrCreateNestedEngine) get the exact same setup. Returns nullptr on
// any hard failure (logs its own reason). Deliberately does NOT touch
// t_state_ or carve a guest stack - callers own that part, since a
// nested engine shares the primary engine's existing stack range rather
// than getting its own (see CallGuestFunction's SP-reuse logic).
uc_engine* CreateConfiguredEngine();
// Returns (creating on first use) the uc_engine* this thread uses for
// CallGuestFunction calls at the given reentrancy depth (>=1; depth 0
// always uses t_state_.uc directly, see CallGuestFunction) - see
// ThreadState::nestedEngines' own comment. Returns nullptr and logs if
// depth exceeds kMaxNestedEngines.
uc_engine* GetOrCreateNestedEngine(uint32_t depth);
// The ONE UC_HOOK_CODE ever registered over the whole misc-stub arena
// (see EnsureThreadEngine) - looks up the real {callback, userData} for
// the faulting address in misc_stub_dispatch_table_ via O(1) array
// indexing and delegates to it. Static (matches uc_cb_hookcode_t's
// plain-function-pointer signature - no `this` to pass) - reaches state
// via GuestEngine::Instance(), same singleton-access pattern used
// throughout this file's own free-function callbacks. See
// misc_stub_arena_start_'s own comment (above) for why this exists.
static void MiscStubDispatch(uc_engine* uc, uint64_t address, uint32_t size, void* userData);
// G2H()'s out-of-range guard - see G2H()'s own comment. Rate-limited
// (first kMaxOutOfRangeG2HLogs occurrences, then a final "suppressed"
// notice and silence) so a call site that gets hit in a loop can't
// flood logcat; different bad addresses across those occurrences
// likely mean different underlying bugs, so this logs every one of
// them up to the cap rather than a single "logged once ever" flag.
void LogOutOfRangeG2H(GuestAddr addr) const;
// Carves one fresh, never-reused kThreadStackSize-byte region out of the
// shared thread-stacks arena and returns its TOP (highest usable
// address) - 0 if the arena is exhausted (see kMaxGuestThreads).
GuestAddr CarveThreadStack();
// Cap on ThreadState::nestedEngines below - see its own comment. Purely
// a safety net (mirrors kMaxCallIterations' "not an expected limit"
// philosophy), not a value callers should ever need to approach.
static constexpr uint32_t kMaxNestedEngines = 8;
// Per-host-thread state - see class comment's "Multithreading model".
// `static thread_local` because GuestEngine is a singleton (one logical
// instance) but each real host thread needs to see its OWN CPU/stack -
// this is the ONLY thing that needs to be per-thread; host_region_,
// heap_, and every *_cursor_/*_end_ arena boundary below are genuinely
// shared (real threads share one process's memory, which is exactly
// what host_region_ already models).
struct ThreadState {
uc_engine* uc = nullptr;
GuestAddr stackBase = 0, stackTop = 0;
// R1 as it stood the instant the most recent CallGuestFunction() on
// THIS thread finished (before the reentrancy save/restore at the
// end of that function overwrites it with the outer call's value) -
// the high word of a 64-bit (long/double) guest return, which the
// public CallGuestFunction() API itself only ever exposes r0 of. See
// LastCallHighWord()'s own comment.
uint32_t lastCallHiWord = 0;
// Diagnostic (2026-09-16, ARM64_TRANSLATION_LAYER.md - synthetic
// unit test confirmed a reentrant CallGuestFunction() call, invoked
// from within a UC_HOOK_CODE callback that's itself running during
// an already-active uc_emu_start() on this thread's engine, hangs
// (a nested uc_emu_start() on the same uc_engine* is not safely
// reentrant in this Unicorn build). >0 means CallGuestFunction is
// currently executing on this thread - lets CallGuestFunction log
// when it's entered reentrantly, to check whether the REAL game
// code path that leads to the sub_43FDE0 crash ever actually
// triggers this exact mechanism.
uint32_t callDepth = 0;
// Fix (2026-09-16, ARM64_TRANSLATION_LAYER.md - the confirmed
// uc_emu_start() reentrancy hang). One lazily-created uc_engine* per
// reentrancy depth beyond the outermost (index 0 here == depth 1,
// since depth 0 always uses `uc` above) - nestedEngines[callDepth-1]
// is the engine a CallGuestFunction() invoked from within a
// UC_HOOK_CODE callback (itself running during an already-active
// uc_emu_start() on `uc`) runs on, instead of re-entering `uc`
// itself. Created once per depth per thread and kept for the
// thread's lifetime (same "leaked deliberately, cheap to keep"
// pattern as `uc` itself - see EnsureThreadEngine), not
// recreated per call. kMaxNestedEngines is a generous safety cap
// (deepest depth actually observed live was 2), not an expected
// limit - see GetOrCreateNestedEngine.
uc_engine* nestedEngines[kMaxNestedEngines] = {};
// Fix (2026-09-16, ARM64_TRANSLATION_LAYER.md - the WRITE_PROT
// fault @0xac77e8/sub_3D58BC bisection). Each depth's own DEDICATED
// stack range, top address only (base = top - kStackSize, same
// convention as stackTop/stackBase above) - carved via
// CarveThreadStack() the same way a real host thread's own stack
// is, the first time that depth is used. Without this, a depth>0
// call's SP defaults to "this engine's own SP minus a small gap",
// which - since nestedEngines[i] is a SEPARATE engine with its OWN
// independent SP, unrelated to how deep the OUTER (suspended)
// frame actually is in ITS stack - always lands near the SAME
// small window close to stackTop, REGARDLESS of depth or which
// outer frame triggered it. Confirmed live: an outer frame that
// itself happens to run near the top of its own thread's stack
// (sub_75E40, entered only a few frames deep) collided directly
// with reentrant JNI/misc-stub and pthread_once (sub_87b968/
// sub_88ccd0) calls all anchored at that same stackTop-adjacent
// window, corrupting the outer frame's own saved-register stack
// slot. A dedicated per-depth stack (same isolation principle
// nestedEngines already gives the CPU register file) makes this
// physically impossible - see GetOrCreateNestedEngine.
GuestAddr nestedStackTop[kMaxNestedEngines] = {};
};
static thread_local ThreadState t_state_;
uint8_t* host_region_ = nullptr; // guest address 0 == this host pointer
uint32_t region_size_ = 0;
GuestAddr image_end_ = 0;
// PT_GNU_RELRO range (page-aligned, conservatively rounded inward - see
// MapSegments' own comment), mprotect'd UC_PROT_READ on every engine
// after relocations write into it. 0 size = not present in this ELF /
// rounding left nothing to protect - skip.
GuestAddr relro_start_ = 0;
uint32_t relro_size_ = 0;
GuestAddr heap_base_ = 0, heap_end_ = 0;
GuestAddr trampoline_cursor_ = 0, trampoline_end_ = 0;
GuestAddr import_stub_cursor_ = 0, import_stub_end_ = 0;
GuestAddr misc_stub_cursor_ = 0, misc_stub_end_ = 0; // AllocCodeStub arena (jni_shim.cpp's 233 slots, etc.)
// Fixed start of the misc-stub arena (misc_stub_cursor_ itself moves as
// a bump pointer, so a separate fixed value is needed to compute a
// stub's index later) - set once, at the same place misc_stub_cursor_
// gets its own one-time initial value. See MiscStubDispatch's own
// comment (guest_engine.cpp) for why this exists: 2026-09-06,
// ARM64_TRANSLATION_LAYER.md - consolidating ~600+ individually
// Unicorn-registered UC_HOOK_CODE hooks (one per AllocCodeStub call)
// into one hook + an O(1) array lookup, after confirming (both
// empirically, via a 284x isolated-vs-real-context benchmark gap, and
// mechanistically, via Unicorn's own hook storage being a linked list
// walked per translated block - third_party/unicorn/include/uc_priv.h)
// that registering hundreds of individual hooks was a real, engine-wide
// performance tax paid on every block anywhere in the address space.
GuestAddr misc_stub_arena_start_ = 0;
GuestAddr control_cursor_ = 0, control_end_ = 0; // AllocPermanent arena - see its own comment
GuestAddr thread_stacks_cursor_ = 0, thread_stacks_end_ = 0; // CarveThreadStack arena
GuestAddr mmap_cursor_ = 0, mmap_end_ = 0; // AllocMmap arena - see its own comment
std::mutex thread_stacks_mutex_; // guards thread_stacks_cursor_ and the free list below
// Stacks handed back by ReleaseThreadEngine, ready to be reused.
//
// Without this the arena was a one-way bump allocator: every guest thread
// that finished kept its stack forever, and so did every nested-call
// engine, so a long session simply ran out. Confirmed live - the game
// created its race thread, CarveThreadStack returned 0, the thread's
// start routine never ran, and the race rendered as a black screen.
std::vector<GuestAddr> thread_stack_free_list_;
std::mutex control_mutex_; // guards control_cursor_
std::mutex mmap_mutex_; // guards mmap_cursor_
GuestHeap heap_;
std::unordered_map<std::string, ImportShimFn> registered_shims_;
std::unordered_map<std::string, GuestAddr> registered_data_symbols_; // see RegisterDataSymbol
// Symbols DEFINED by secondary guest images (see LoadSecondaryImage).
// Consulted by ResolveOrCreateImportStub ahead of the shim table, so that
// once the game's real ARM32 FMOD is loaded its own entry points win over
// the no-op stand-ins fmod_shims.cpp registers.
std::unordered_map<std::string, GuestAddr> secondary_image_exports_;
// [base, base+span) for each image LoadSecondaryImage has loaded.
struct LoadedImageRange { GuestAddr base; uint32_t span; };
std::vector<LoadedImageRange> secondary_image_ranges_;
std::vector<DataSymbolSetupFn> data_symbol_setup_fns_; // see RegisterDataSymbolSetup
std::unordered_map<std::string, GuestAddr> import_stub_by_symbol_; // dedupe: one stub per symbol name
std::unordered_map<GuestAddr, ImportShimFn> shim_by_stub_addr_;
std::unordered_map<GuestAddr, std::string> unresolved_stub_names_; // for shims we don't implement - logged once, then return 0
struct TrampolineHookEntry {
void (*dispatch)(GuestEngine&, void*);
void* userData;
std::string debugName;
};
// Every UC_HOOK_CODE ever installed (import stubs, JNI/GLES slots,
// trampoline hooks) - replayed onto each new guest thread's own engine
// by ReplayHooksOnEngine, since Unicorn hooks are per-engine. Installed
// once, during single-threaded setup (before any guest thread exists),
// but mutex-guarded anyway since EnsureThreadEngine (reading this list)
// could in principle race a very-early AllocCodeStub call.
struct HookRegistration {
uc_cb_hookcode_t callback;
void* userData;
GuestAddr addr;
};
std::vector<HookRegistration> hook_registrations_;
std::mutex hook_registrations_mutex_;
// AllocCodeStub's own dispatch table (see MiscStubDispatch,
// guest_engine.cpp) - one entry per stub, in allocation order, which
// equals address order since misc_stub_cursor_ is a pure sequential
// 4-byte bump allocator with no frees (same guarantee hook_registrations_
// already relied on for its own replay). Index = (addr -
// misc_stub_arena_start_) / 4. Replaces per-stub uc_hook_add calls with
// one array lookup - see misc_stub_arena_start_'s own comment for why.
struct MiscStubEntry {
uc_cb_hookcode_t callback;
void* userData;
};
// Lock-free on the read side (2026-09-19, task #56). This table is hit on
// EVERY shim crossing - measured at ~170,000/sec during gameplay on the
// Xiaomi 14, from several threads - and taking a std::mutex there taxed
// the exact thing being measured. A fixed array plus an atomic published
// count removes it safely: the arena is only kMiscStubArenaSize/4 stubs
// wide by construction (AllocCodeStub bump-allocates 4 bytes each and
// refuses past the end), entries are appended and never removed or
// rewritten, and a writer fills its slot BEFORE publishing the new count
// with release ordering. A reader that acquires the count therefore sees
// a fully-written entry. The mutex stays for the append side, which runs
// a few hundred times at startup.
static constexpr size_t kMaxMiscStubs = 16 * 1024 / 4;
MiscStubEntry misc_stub_dispatch_table_[kMaxMiscStubs] = {};
std::atomic<size_t> misc_stub_dispatch_count_{0};
std::mutex misc_stub_dispatch_table_mutex_; // append side only
// See crashed()'s own comment. Global (not per-thread) on purpose - a
// fault on ANY thread means the ONE shared host_region_ every thread's
// engine maps may be corrupted for everyone, not just the thread that
// happened to hit it.
std::atomic<bool> crashed_{false};
std::atomic<bool> crashed_logged_{false}; // so the "refusing to run" log fires once, not on every retry
// Counter (was a one-shot bool) for CallGuestFunction's 2026-09-16
// reentrancy probe - the first log-once version only ever showed the
// FIRST reentrant call, which turned out (2026-09-16, same-day
// follow-up) to be a harmless trivial stub. Now logs every occurrence
// up to reentrancy_log_cap_ (see CallGuestFunction), so occurrences
// CLOSER to an actual crash are visible too - capped, not fully
// unconditional, because a runaway retry loop (the exact synthetic-test
// scenario this probe was built to detect) re-enters at ~6000 calls/sec
// against the SAME target and would otherwise flood logcat's ring
// buffer with nothing else visible within milliseconds.
std::atomic<int> reentrancy_log_count_{0};
static constexpr int kReentrancyLogCap = 200;
};
// Diagnostic for the render-stall investigation (see
// ARM64_TRANSLATION_LAYER.md's "Periodic live instruction-trace dump" plan)
// - starts a detached background thread that periodically logs the last N
// executed guest blocks in true execution order (not a sampled histogram
// like profiler.h's own dump), so a loop that never faults (and so never
// hits CallGuestFunction's own fault-branch trace dump) can still be traced.
void StartLiveTraceDumpThread();
+144
View File
@@ -0,0 +1,144 @@
#pragma once
// Typed calling-convention layer on top of GuestEngine's raw
// CallGuestFunction/InstallTrampolineHookRaw. This is what lets the rest of
// mpcore's existing hook code (main.cpp, lan_event_injection.h, ...) keep
// its original shape almost unchanged: a "resolved function pointer" this
// codebase already declares as e.g.
// typedef void* (*RaceEventCtorFn)();
// static RaceEventCtorFn RaceEventCtor = (RaceEventCtorFn)APP_ADDR(OFFSET);
// becomes
// static GuestFn<void*> RaceEventCtor(OFFSET);
// and is still called exactly the same way (`RaceEventCtor()`). Likewise
// InstallArmTrampolineHook's "returns a callable orig_X" pattern becomes
// InstallTrampolineHook<Ret,Args...>(target, &Hook_X, "name"), still
// returning something callable the same way.
//
// See ARM64_TRANSLATION_LAYER.md and guest_engine.h's own class comment for
// the full design; this file is deliberately "just" marshaling glue.
#include <cstdint>
#include <type_traits>
#include <utility>
#include "guest_engine.h"
// ---- Value marshaling ----
// Pointer-shaped C++ types translate through G2H/H2G automatically.
// Everything else (int, uint32_t, bool, enums, ...) passes through as a raw
// 32-bit register value unchanged. This deliberately also covers this
// codebase's existing "int, but actually holds an address" declarations
// (e.g. GetCacheContextFn(int anyComponentPtr)) INCORRECTLY if left as
// `int` - see the port notes in main.cpp/lan_event_injection.h for exactly
// which declarations were changed from `int` to a real pointer type for
// this reason, and which genuinely small integers (paintJobIndex, evType,
// deltaMs, ...) were deliberately left as plain integer types.
template<typename T, typename = void>
struct GuestMarshal {
static uint32_t ToReg(T v) { return static_cast<uint32_t>(v); }
static T FromReg(uint32_t v) { return static_cast<T>(v); }
};
template<typename T>
struct GuestMarshal<T, std::enable_if_t<std::is_pointer<T>::value>> {
static uint32_t ToReg(T v) {
return v ? GuestEngine::Instance().H2G(v) : 0;
}
static T FromReg(uint32_t v) {
return v ? reinterpret_cast<T>(GuestEngine::Instance().G2H(v)) : nullptr;
}
};
// Max args GuestFn/InstallTrampolineHook support - r0-r3 plus stack args,
// generous headroom over anything this codebase's hooks or the JNI shim's
// own trampolines actually need.
constexpr size_t kMaxGuestFnArgs = 10;
namespace guest_fn_detail {
template<typename Ret, typename... Args, size_t... I>
uint32_t InvokeAndPack(Ret (*fn)(Args...), const uint32_t regs[kMaxGuestFnArgs], std::index_sequence<I...>) {
if constexpr (std::is_void<Ret>::value) {
fn(GuestMarshal<Args>::FromReg(regs[I])...);
return 0;
} else {
Ret result = fn(GuestMarshal<Args>::FromReg(regs[I])...);
return GuestMarshal<Ret>::ToReg(result);
}
}
template<typename Ret, typename... Args>
void DispatchCall(GuestEngine& eng, Ret (*fn)(Args...)) {
static_assert(sizeof...(Args) <= kMaxGuestFnArgs, "too many args for GuestFn/InstallTrampolineHook");
uint32_t r0 = 0, r1 = 0, r2 = 0, r3 = 0, sp = 0;
uc_reg_read(eng.uc(), UC_ARM_REG_R0, &r0);
uc_reg_read(eng.uc(), UC_ARM_REG_R1, &r1);
uc_reg_read(eng.uc(), UC_ARM_REG_R2, &r2);
uc_reg_read(eng.uc(), UC_ARM_REG_R3, &r3);
uc_reg_read(eng.uc(), UC_ARM_REG_SP, &sp);
uint32_t regs[kMaxGuestFnArgs];
for (size_t i = 0; i < sizeof...(Args); i++) {
regs[i] = eng.ReadIncomingArg((int)i, r0, r1, r2, r3, sp);
}
uint32_t result = InvokeAndPack(fn, regs, std::index_sequence_for<Args...>{});
uint32_t lr = 0;
uc_reg_read(eng.uc(), UC_ARM_REG_LR, &lr);
uc_reg_write(eng.uc(), UC_ARM_REG_R0, &result);
// Simulate "the hooked function ran and returned" - jump straight to
// the caller's LR. Unicorn switches ARM/Thumb decode based on bit0 of
// the PC value written, matching real AArch32 interworking.
uc_reg_write(eng.uc(), UC_ARM_REG_PC, &lr);
}
template<typename... Args>
void PackArgs(uint32_t*, size_t) {}
template<typename Head, typename... Tail>
void PackArgs(uint32_t* regs, size_t i, Head h, Tail... tail) {
regs[i] = GuestMarshal<Head>::ToReg(h);
PackArgs(regs, i + 1, tail...);
}
} // namespace guest_fn_detail
// A resolved, callable guest function - the "GetOutputNode/RaceEventCtor/
// ..." replacement for a raw `(FnType)APP_ADDR(OFFSET)` cast.
template<typename Ret, typename... Args>
class GuestFn {
public:
static_assert(sizeof...(Args) <= kMaxGuestFnArgs, "too many args for GuestFn");
GuestFn() = default;
explicit GuestFn(GuestAddr addr) : addr_(addr) {}
explicit operator bool() const { return addr_ != 0; }
GuestAddr addr() const { return addr_; }
Ret operator()(Args... args) const {
uint32_t regs[kMaxGuestFnArgs] = {0};
guest_fn_detail::PackArgs(regs, 0, args...);
uint32_t r0 = GuestEngine::Instance().CallGuestFunction(addr_, regs, (int)sizeof...(Args));
if constexpr (std::is_void<Ret>::value) {
(void)r0;
} else {
return GuestMarshal<Ret>::FromReg(r0);
}
}
private:
GuestAddr addr_ = 0;
};
// Installs a trampoline hook exactly like this codebase's old
// InstallArmTrampolineHook, but backed by GuestEngine - see this file's own
// top comment and guest_engine.h's class comment for the mechanism. hookFn
// must be a plain (non-capturing) function pointer, matching every existing
// Hook_X function in this codebase already.
template<typename Ret, typename... Args>
GuestFn<Ret, Args...> InstallTrampolineHook(GuestAddr target, Ret (*hookFn)(Args...), const char* debugName) {
using FnPtr = Ret (*)(Args...);
auto* ctx = new FnPtr(hookFn); // leaked deliberately, see guest_engine.cpp's own note
auto dispatch = +[](GuestEngine& eng, void* userData) {
auto* fn = static_cast<FnPtr*>(userData);
guest_fn_detail::DispatchCall(eng, *fn);
};
GuestAddr tramp = GuestEngine::Instance().InstallTrampolineHookRaw(target, dispatch, ctx, debugName);
return GuestFn<Ret, Args...>(tramp);
}
+237
View File
@@ -0,0 +1,237 @@
#include "guest_heap.h"
#include "../util/util.h"
#include <atomic>
#include <cstring>
#include <time.h>
// Temporary sanity check (2026-09-06) - confirm the new size-class free
// list actually IS O(1) per call now, independent of whether it explains
// the overall fread-rate mystery (first measurement showed it did NOT move
// the needle - see ARM64_TRANSLATION_LAYER.md). Remove once confirmed.
namespace {
std::atomic<uint64_t> g_allocCalls2{0};
uint64_t NowNs2() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000000ull + ts.tv_nsec;
}
} // namespace
void GuestHeap::Init(uint8_t* hostBase, GuestAddr arenaStart, uint32_t arenaSize) {
host_base_ = hostBase;
arena_start_ = arenaStart;
arena_end_ = arenaStart + arenaSize;
// Align the bump cursor so every payload (header is a multiple of kAlign
// by construction, see BlockHeader's own comment) comes back kAlign-aligned.
free_cursor_ = AlignUp(arenaStart, kAlign);
free_by_size_.clear();
live_bytes_ = peak_live_bytes_ = free_bytes_ = 0;
free_blocks_ = 0;
}
void GuestHeap::PushFree(GuestAddr dataAddr, uint32_t size) {
free_bytes_ += size;
free_blocks_++;
auto it = free_by_size_.find(size);
uint32_t next = (it == free_by_size_.end()) ? 0u : it->second;
memcpy(host_base_ + dataAddr, &next, 4);
if (it == free_by_size_.end()) {
free_by_size_.emplace(size, dataAddr);
} else {
it->second = dataAddr;
}
}
uint32_t GuestHeap::BlockSize(GuestAddr addr) const {
if (!IsValidLiveBlock(addr)) return 0;
// The USER size, not the footprint - callers (Shim_realloc) want to know
// how many bytes they were actually given, not how much the guard adds.
return HeaderAt(addr)->user_size;
}
void GuestHeap::WriteRedZone(GuestAddr dataAddr, uint32_t userSize) {
if (!kHeapDebugChecks) return;
std::memset(host_base_ + dataAddr + userSize, kRedZoneFill, kRedZoneBytes);
}
bool GuestHeap::CheckRedZone(GuestAddr dataAddr, uint32_t userSize) const {
if (!kHeapDebugChecks) return true;
const uint8_t* guard = host_base_ + dataAddr + userSize;
for (uint32_t i = 0; i < kRedZoneBytes; i++) {
if (guard[i] == kRedZoneFill) continue;
// Loud and specific: this identifies the block that was overflowed
// AND how far past its end the write reached, which is the whole
// point - the corruption is reported at the offending block rather
// than wherever it happens to surface later.
Log("GuestHeap: RED ZONE VIOLATION - block at 0x%x (user_size=%u) was written past its "
"end: guard byte %u of %u is 0x%02x, expected 0x%02x. Something overflowed THIS "
"block; see guest_heap.h's kHeapDebugChecks comment.",
dataAddr, userSize, i, kRedZoneBytes, guard[i], kRedZoneFill);
return false;
}
return true;
}
void GuestHeap::PoisonPayload(GuestAddr dataAddr, uint32_t userSize) {
if (!kHeapDebugChecks) return;
// Makes a use-after-free read obviously-wrong data. Matters more since
// Alloc stopped zeroing (2026-09-19): without this, a freed block keeps
// its old, plausible contents and a stale pointer keeps "working".
std::memset(host_base_ + dataAddr, kFreePoisonFill, userSize);
}
GuestAddr GuestHeap::Alloc(uint32_t size) {
std::lock_guard<std::mutex> lock(mutex_);
if (!host_base_ || size == 0) return 0;
uint64_t callNo = g_allocCalls2.fetch_add(1, std::memory_order_relaxed) + 1;
uint64_t t0 = NowNs2();
const uint32_t userSize = AlignUp(size, kAlign);
// Everything below allocates in terms of the FOOTPRINT (user bytes plus
// the trailing guard). kRedZoneBytes is 0 unless checks are on, so this
// is identical to the previous arithmetic in normal builds.
const uint32_t payload = userSize + kRedZoneBytes;
auto logIfSampled = [&](const char* how) {
if (callNo <= 5 || callNo % 20000 == 0) {
Log("GuestHeap::Alloc: call #%llu (%s), took %lluns",
(unsigned long long)callNo, how, (unsigned long long)(NowNs2() - t0));
}
};
// Heap gauge (2026-09-19). Reads the member counters DIRECTLY rather
// than calling GetStats() - this function already holds mutex_, and
// GetStats takes it too, which on a non-recursive std::mutex would
// deadlock. Tied to allocation count rather than a timer so it reports
// during heavy loading and stays silent when the game is idle.
// "live" is what the guest actually holds right now; "carved" is how far
// the bump cursor has travelled, so carved-minus-live-minus-free is the
// fragmentation this allocator has not yet managed to give back.
if (callNo % 50000 == 0) {
constexpr double kMB = 1024.0 * 1024.0;
Log("GuestHeap: gauge @%lluk allocs - live=%.1fMB peak=%.1fMB free=%.1fMB "
"carved=%.1fMB of %.1fMB arena | freeBlocks=%u distinctSizes=%u",
(unsigned long long)(callNo / 1000), live_bytes_ / kMB, peak_live_bytes_ / kMB,
free_bytes_ / kMB,
((free_cursor_ >= arena_start_) ? (free_cursor_ - arena_start_) : 0) / kMB,
((arena_end_ >= arena_start_) ? (arena_end_ - arena_start_) : 0) / kMB,
free_blocks_, (unsigned)free_by_size_.size());
}
// Reuse: smallest free block that fits. O(log n) via the size-ordered
// map (an exact-size hit costs the same lookup) - deliberately not a
// linear scan over free blocks, see guest_heap.h's 2026-09-19 note and
// this allocator's own 2026-09-06 history with an O(n) scan.
auto it = free_by_size_.lower_bound(payload);
if (it != free_by_size_.end()) {
uint32_t blockSize = it->first;
GuestAddr blockAddr = it->second;
uint32_t next = 0;
memcpy(&next, host_base_ + blockAddr, 4);
if (next) {
it->second = next;
} else {
free_by_size_.erase(it);
}
free_bytes_ -= blockSize;
free_blocks_--;
BlockHeader* h = HeaderAt(blockAddr);
h->free = 0;
// Split the tail back into the free structure when what is left over
// can hold a header plus a minimally useful payload. Without this,
// exact sizing would just re-create stranding in a new shape (a 8MiB
// block permanently consumed by a 64KiB request).
uint32_t remainder = blockSize - payload;
if (remainder >= kMinSplitRemainder) {
h->size = payload;
GuestAddr tailHeader = blockAddr + payload;
BlockHeader* th = reinterpret_cast<BlockHeader*>(host_base_ + tailHeader);
th->magic = kMagic;
th->size = remainder - sizeof(BlockHeader);
th->free = 1;
th->user_size = th->size;
PushFree(tailHeader + sizeof(BlockHeader), th->size);
}
// The guard goes right after the USER bytes, so an overflow of even
// one byte past what the caller asked for is caught - even when the
// block reused was larger and did not get split.
h->user_size = userSize;
WriteRedZone(blockAddr, userSize);
live_bytes_ += h->size;
if (live_bytes_ > peak_live_bytes_) peak_live_bytes_ = live_bytes_;
logIfSampled("reuse");
return blockAddr;
}
// Nothing reusable - carve a fresh block at the EXACT requested size.
uint32_t needed = sizeof(BlockHeader) + payload;
if (free_cursor_ + needed > arena_end_) {
// Loud on purpose (2026-09-17): a silent `return 0` gets swallowed by
// downstream null-checks, and the real EA code does not check malloc.
Log("GuestHeap::Alloc: HEAP EXHAUSTED - payload=%u needed=%u but only %u bytes remain "
"(free_cursor_=0x%x arena_end_=0x%x) - returning 0 (null)",
payload, needed, (free_cursor_ <= arena_end_) ? (arena_end_ - free_cursor_) : 0,
free_cursor_, arena_end_);
return 0;
}
BlockHeader* h = reinterpret_cast<BlockHeader*>(host_base_ + free_cursor_);
h->magic = kMagic;
h->size = payload;
h->free = 0;
h->user_size = userSize;
GuestAddr dataAddr = free_cursor_ + sizeof(BlockHeader);
free_cursor_ += needed;
WriteRedZone(dataAddr, userSize);
live_bytes_ += payload;
if (live_bytes_ > peak_live_bytes_) peak_live_bytes_ = live_bytes_;
logIfSampled("bump-new");
return dataAddr;
}
bool GuestHeap::IsValidLiveBlock(GuestAddr addr) const {
if (!host_base_ || addr < arena_start_ + sizeof(BlockHeader) || addr >= free_cursor_) return false;
BlockHeader* h = HeaderAt(addr);
if (h->magic != kMagic) return false; // not a real header - wrong/stale address
if (h->free) return false; // already free - reject double-free
// Payload must fit inside the arena and not run past the bump cursor -
// catches a header whose magic happens to match by coincidence but
// whose size field is nonsense (e.g. a wild write landed exactly 8
// bytes before some unrelated valid-looking magic value).
if (addr + h->size > free_cursor_) return false;
return true;
}
void GuestHeap::Free(GuestAddr addr) {
if (addr == 0) return; // free(NULL) is legal and common - silent, not a corruption signal
std::lock_guard<std::mutex> lock(mutex_);
if (!IsValidLiveBlock(addr)) {
Log("GuestHeap::Free: rejected addr=0x%x - not a valid live block (wrong pointer, double-free, "
"or heap corruption - see guest_heap.h's own class comment)", addr);
return;
}
BlockHeader* h = HeaderAt(addr);
// Verify the guard BEFORE the block goes back on a free list: this is the
// last moment the overflow can still be attributed to this block rather
// than to whoever allocates the memory next. Deliberately not fatal - the
// point is a loud, precise report, and aborting here would make the
// engine less debuggable than the corruption it is reporting.
CheckRedZone(addr, h->user_size);
PoisonPayload(addr, h->user_size);
h->free = 1;
live_bytes_ -= h->size;
// Every block carries its exact payload size, so it goes straight onto
// the free list for that size - no class rounding, nothing excluded by
// being "too big" (the old scheme silently never reclaimed anything past
// its largest class). O(log n) for the map lookup.
PushFree(addr, h->size);
}
GuestHeap::Stats GuestHeap::GetStats() const {
std::lock_guard<std::mutex> lock(mutex_);
Stats s{};
s.liveBytes = live_bytes_;
s.peakLiveBytes = peak_live_bytes_;
s.freeBytes = free_bytes_;
s.carvedBytes = (free_cursor_ >= arena_start_) ? (free_cursor_ - arena_start_) : 0;
s.freeBlocks = free_blocks_;
s.distinctFreeSizes = (uint32_t)free_by_size_.size();
s.arenaBytes = (arena_end_ >= arena_start_) ? (arena_end_ - arena_start_) : 0;
return s;
}
+249
View File
@@ -0,0 +1,249 @@
#pragma once
#include "guest_types.h"
#include <map>
#include <mutex>
// Minimal first-fit heap allocator carved out of a fixed-size arena that
// lives INSIDE the same flat guest/host region as the loaded libapp.so
// image (see GuestEngine). Backs the malloc/free/calloc import shims, so
// that any object the guest code allocates at runtime (e.g. InjectSyntheticEvent's
// RaceEvent/CashReward/FakeActor - see lan_event_injection.h) gets a real
// guest address in the SAME identity-offset numbering as every static
// OFFSET macro in this codebase, not an arbitrary host heap pointer outside
// the mapped region.
//
// 2026-09-06: replaced the original first-fit-scan-the-whole-arena design
// with a size-class-segregated free list, after directly measuring the old
// one as the real (confirmed, quantified) root cause of the OBB-index-build
// performance cliff investigated in ARM64_TRANSLATION_LAYER.md - the old
// Alloc() scanned every block ever carved, free or in-use, on every call,
// so cost grew with total historical allocation count (measured: 0 blocks
// scanned at call #1, 9294 at call #40000, ~30us/call once the arena's
// free/used population reached its steady-state size). Requested sizes are
// now rounded up to one of a small set of power-of-two size classes
// (kAlign=8 .. kMaxSizeClassBytes, see kNumSizeClasses's own comment for
// its current ceiling); each class keeps its own singly-linked free list
// (the "next" pointer for a free block lives in the block's own now-unused
// payload - always at least kAlign=8 bytes, enough for one 32-bit guest
// address), making both Alloc() and Free() O(1) instead of O(n). Only a
// request bigger than the largest class - now a genuinely rare, near-
// arena-sized edge case rather than the routine "anything over 64KiB" path
// it used to be (see kNumSizeClasses's own 2026-09-18 comment) - still
// bump-allocates fresh and is simply never reused on free.
//
// This heap backs BOTH the guest program's own malloc/free/calloc/realloc
// AND short-lived JNI marshaling buffers (see import_shims.cpp/jni_shim.cpp)
// - i.e. arbitrary, uncontrolled usage from millions of lines of real ARM32
// game code sharing one arena. A single wrong free() anywhere in that code
// (or a buffer overflow into a neighboring block) can otherwise silently
// corrupt any other live allocation - confirmed live this session, tracked
// down to the permanently-cached guest JNIEnv structure getting overwritten
// this way. Two structural responses: (1) permanent, safety-critical
// structures (guest JNIEnv/JavaVM) no longer live in this heap at all - see
// GuestEngine::AllocPermanent's own comment; (2) this heap now validates a
// magic value on every Free() so a wrong/stale address is rejected and
// logged instead of silently corrupting whatever bytes precede it. Doesn't
// stop a wild WRITE (nothing but a full guard-page/MMU scheme would), but
// catches the specific failure mode actually observed: a bad free().
class GuestHeap {
public:
// hostBase: host pointer corresponding to guest address 0 (i.e. the
// engine's G2H(0)). arenaStart/arenaSize: guest-address range this heap
// is allowed to hand out (must not overlap the loaded image or the
// guest stack).
void Init(uint8_t* hostBase, GuestAddr arenaStart, uint32_t arenaSize);
// Returns 0 (a guest NULL) on failure, matching malloc's own contract.
// Thread-safe (mutex-guarded) - now that real guest threads exist (see
// emu/pthread_shim.h), any of them can call malloc/free concurrently.
//
// Does NOT zero the returned memory (2026-09-19). malloc makes no such
// promise; only calloc does, and Shim_calloc now zeroes explicitly. The
// previous behaviour zeroed the whole rounded-up class block on every
// single allocation - for an 8MiB class that was an 8MiB memset per call,
// and it forced physical commit of pages the guest might never touch.
GuestAddr Alloc(uint32_t size);
// No-ops (logged) if `addr` doesn't point at a real, currently-allocated
// block's payload - see class comment.
void Free(GuestAddr addr);
// Exact payload size of a live block, or 0 if `addr` isn't one. Lets
// Shim_realloc copy min(oldSize,newSize) instead of guessing (it used to
// copy the NEW size out of a possibly-smaller old block).
uint32_t BlockSize(GuestAddr addr) const;
struct Stats {
uint64_t liveBytes; // payload currently handed out to the guest
uint64_t peakLiveBytes; // high-water mark of the above
uint64_t freeBytes; // payload sitting in the free structure, reusable
uint64_t carvedBytes; // bump cursor travel - memory this arena has ever touched
uint32_t freeBlocks;
uint32_t distinctFreeSizes;
uint32_t arenaBytes;
};
// Cheap: every field is a counter maintained in O(1) by Alloc/Free, not
// computed by walking anything.
Stats GetStats() const;
private:
// 16 bytes, not 12: with a 12-byte header the bump cursor advanced by
// 12+payload, so payload alignment alternated between 8- and 4-byte and
// could hand a 4-aligned buffer to guest code doing 8-byte accesses.
// Padding to 16 keeps every payload 8-aligned (arena start is aligned and
// payloads are multiples of kAlign), and makes the split arithmetic below
// exact. Costs 4 bytes per block, trivial next to the ~4x this file's
// 2026-09-19 rework removes.
// ---- memory-error detection (2026-09-19, task #45) ----
// OFF by default. The existing magic check on Free catches a wrong or
// stale POINTER, but not the more insidious failure this layer is prone
// to: a shim writing a few bytes past the end of a block and silently
// corrupting whatever live object follows. That has already happened
// once in this project's history (the guest JNIEnv structure was
// overwritten that way - see this class's own comment) and it surfaces
// subsystems later, looking like an unrelated crash.
//
// With checks on, each block gets a trailing guard filled with a known
// pattern, verified when the block is freed, and freed payloads are
// poisoned so a use-after-free reads obviously-wrong data instead of
// plausible stale values. The latter matters more since 2026-09-19,
// when Alloc stopped zeroing.
//
// Both cost memory and time, so this follows the same opt-in discipline
// as every other diagnostic here (see kTraceHeapAllocations in
// import_shims.cpp): flip to true while hunting a corruption bug, never
// leave it on. With it false, kRedZoneBytes is 0 and every guard-related
// branch folds away at compile time.
static constexpr bool kHeapDebugChecks = false;
static constexpr uint32_t kRedZoneBytes = kHeapDebugChecks ? 16 : 0;
static constexpr uint8_t kRedZoneFill = 0xBE; // "guard"
static constexpr uint8_t kFreePoisonFill = 0xDF; // "dead free"
struct BlockHeader {
uint32_t magic; // kMagic if this is a real header GuestHeap itself wrote - see class comment
// Total payload footprint this block occupies, INCLUDING the trailing
// guard when checks are on. Free lists, splitting and IsValidLiveBlock
// all work in these terms.
uint32_t size;
uint32_t free; // 1 = free, 0 = in use
// What the caller actually asked for (aligned up) - i.e. size minus
// the guard. Was pure alignment padding before; it is maintained
// unconditionally so BlockSize() can report the user-visible size and
// realloc can copy the right amount, whether or not checks are on.
uint32_t user_size;
};
static constexpr uint32_t kMagic = 0x47484B21; // "GuestHeap blocK!" - arbitrary but distinctive
static constexpr uint32_t kAlign = 8;
static uint32_t AlignUp(uint32_t v, uint32_t a) { return (v + a - 1) & ~(a - 1); }
// 2026-09-19 REWORK - exact sizing, measured against real hardware.
// The size-class scheme described above (kept in the history below
// because its own reasoning was sound for the bug it fixed) rounded
// EVERY request up to the next power of two and bump-allocated the full
// class size. Measured cost on the Galaxy A9 running the real ARM32
// build of this same game: native heap ~46MB in menus, ~199MB with a
// race loaded - while this engine exhausted a 768MB arena before the
// prologue even finished. That ~4x gap decomposes exactly as the scheme
// predicts: ~1.5x average waste from rounding (a 4.1MB request took
// 8MB, a 70KB request took 128KB) multiplied by the stranding caused by
// segregated per-class free lists, where a freed 8MB block could never
// satisfy a 64KB request no matter how much of the arena it held.
//
// Replaced by exact-size allocation with a single size-ordered free
// structure. Deliberately NOT a linear best-fit scan: this allocator's
// own history (the 2026-09-06 note above) is that an O(n) scan per
// allocation was itself a measured performance cliff, so reuse is a
// std::map lookup - lower_bound for "smallest free block that fits",
// O(log n), with the same cost for an exact hit. Blocks of identical
// size share one intrusive singly-linked list (the "next" pointer lives
// in the free block's own payload, always >= kAlign bytes), so the map
// holds one node per DISTINCT live free size, not per free block.
//
// A block larger than the request is split and the remainder returned to
// the free structure, which is what keeps exact sizing from simply
// re-creating stranding in a different shape. Still no coalescing of
// adjacent free blocks - consistent with this allocator's long-standing
// choice, and the same reasoning still applies: reuse alone is what
// converts "grows forever" into "reaches a steady state".
static constexpr uint32_t kMinSplitRemainder = sizeof(BlockHeader) + kAlign;
// ---- history: the superseded size-class scheme ----
// Size classes: kAlign(8), 16, 32, ... up to kAlign << (kNumSizeClasses-1).
// 27 classes tops out at 8 << 26 = 512MiB.
//
// 2026-09-18 (ARM64_TRANSLATION_LAYER.md - the sub_4BA588/"loadNodeUncached"
// chase, ROOT CAUSE): widened from 14 classes (64KiB ceiling) to 27
// (512MiB ceiling) - the >64KiB "oversized" path used to bump-allocate
// fresh on every call and NEVER reuse freed memory (see the old
// GuestHeap::Alloc's own now-removed comment), which was root-caused
// live on the Pixel 6a as the single cause of BOTH the "onCreate never
// completes" heap-exhaustion crash AND a separate, later-discovered
// crash in sub_4BA588 ("loadNodeUncached"): a real ~175KB M3G resource
// buffer allocation failed because the 256MB arena was already full of
// permanently-stranded freed-but-unreclaimed oversized blocks from
// earlier in the same session, the real EA code doesn't check malloc's
// result for null (reasonably, since real hardware's own heap
// essentially never fails a request this size), and the null flows
// downstream into a reader that legitimately-but-incorrectly reports
// "0 bytes available" forever. Real hardware never hits this because
// its heap never actually runs out. Simplest complete fix: there is no
// structural difference between a "small" and a "large" allocation from
// this allocator's own point of view - the SAME O(1) size-classed
// free-list scheme that already correctly reuses freed <=64KiB blocks
// (see the 2026-09-06 comment two paragraphs up) works identically at
// any size, so widening it removes the separate never-reclaimed path
// entirely rather than adding a second, differently-shaped one next to
// it. Trades up to ~2x internal fragmentation per oversized object
// (worst case: a request just over a class boundary rounds up to
// double) for a hard bound on total arena growth - a real win given the
// failure mode being fixed is fully UNBOUNDED growth, not excess
// fragmentation. Does not coalesce adjacent free blocks (consistent
// with this allocator's existing choice not to do so even for small
// blocks - see the class comment) - not needed for this fix: reuse
// alone converts "grows forever" into "reaches a steady state," which
// is the actual guarantee being restored here.
// (kNumSizeClasses/SizeClassFor/SizeClassBytes removed 2026-09-19 with
// the scheme they implemented - see the rework note above.)
// Pushes a free block onto the intrusive list for its exact size.
void PushFree(GuestAddr dataAddr, uint32_t size);
// Guard handling - all no-ops when kHeapDebugChecks is false.
// `dataAddr` is a payload address, `userSize` what the caller asked for;
// the guard lives immediately after the user payload so that a one-byte
// overflow is caught, rather than at the very end of a possibly-larger
// block where slack would hide it.
void WriteRedZone(GuestAddr dataAddr, uint32_t userSize);
// Logs and returns false if the guard was damaged.
bool CheckRedZone(GuestAddr dataAddr, uint32_t userSize) const;
void PoisonPayload(GuestAddr dataAddr, uint32_t userSize);
BlockHeader* HeaderAt(GuestAddr addr) const {
return reinterpret_cast<BlockHeader*>(host_base_ + addr - sizeof(BlockHeader));
}
GuestAddr AddrOfHeader(BlockHeader* h) const {
return static_cast<GuestAddr>(reinterpret_cast<uint8_t*>(h) - host_base_);
}
// True if `addr` is in-range AND the bytes immediately before it look
// like a real header this allocator wrote (magic matches) AND that
// block is currently marked in-use (double-free protection too).
bool IsValidLiveBlock(GuestAddr addr) const;
uint8_t* host_base_ = nullptr;
GuestAddr arena_start_ = 0;
GuestAddr arena_end_ = 0; // one-past-the-end of the arena
GuestAddr free_cursor_ = 0; // next never-yet-used byte (bump pointer for the "no free block fits" case)
// exact payload size -> head of that size's intrusive free list (a
// block's data address). One map node per DISTINCT free size, not per
// free block - see the 2026-09-19 rework note above.
std::map<uint32_t, GuestAddr> free_by_size_;
// Accounting for GetStats (2026-09-19). Maintained incrementally so the
// gauge costs nothing to read - the point of it is to answer "how much
// does this game actually need" with a measured number instead of
// inferring it from the process's resident set, which mixes in thread
// stacks and never shrinks once a page has been touched.
uint64_t live_bytes_ = 0;
uint64_t peak_live_bytes_ = 0;
uint64_t free_bytes_ = 0;
uint32_t free_blocks_ = 0;
mutable std::mutex mutex_;
};
+131
View File
@@ -0,0 +1,131 @@
#include "guest_trace.h"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <thread>
#include <time.h>
#include <unistd.h>
namespace {
std::atomic<bool> g_enabled{false};
uint64_t NowMs() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000;
}
uint64_t g_epochMs = 0;
std::once_flag g_epochOnce;
// Block-trace ring: raw array + atomic write index, same accepted-torn-read
// tradeoff as guest_engine.cpp's own g_liveTraceRing (a diagnostic, not a
// correctness-critical path) - deliberately NOT a mutex-guarded structure,
// since this callback fires once per translated basic block and any lock
// there would dominate the very execution speed this trace exists to
// measure. 4M entries * 12 bytes = 48MB, comfortably bounded.
struct BlockEntry {
uint32_t relMs;
uint32_t tid;
uint32_t addr;
};
constexpr size_t kRingSize = 4 * 1024 * 1024;
BlockEntry g_ring[kRingSize];
std::atomic<uint64_t> g_pos{0};
constexpr char kGuestTracePath[] = "/data/data/com.ea.games.nfs13_arm/files/guest_trace.log";
constexpr char kJniTracePath[] = "/data/data/com.ea.games.nfs13_arm/files/jni_trace.log";
} // namespace
uint64_t GuestTraceEpochMs() {
std::call_once(g_epochOnce, [] { g_epochMs = NowMs(); });
return g_epochMs;
}
void EnableFullGuestTrace() {
GuestTraceEpochMs(); // establish the shared epoch at the moment tracing is armed
g_enabled.store(true, std::memory_order_relaxed);
}
bool FullGuestTraceEnabled() {
return g_enabled.load(std::memory_order_relaxed);
}
void FullGuestTraceHookCb(uc_engine*, uint64_t address, uint32_t, void*) {
uint64_t idx = g_pos.fetch_add(1, std::memory_order_relaxed);
BlockEntry& e = g_ring[idx % kRingSize];
e.relMs = (uint32_t)(NowMs() - GuestTraceEpochMs());
e.tid = (uint32_t)gettid();
e.addr = (uint32_t)address;
}
void StartGuestTraceDumpThread() {
static std::atomic<bool> started{false};
bool expected = false;
if (!started.compare_exchange_strong(expected, true)) return; // already running
std::thread([]() {
FILE* f = nullptr;
uint64_t lastDumped = 0;
while (true) {
std::this_thread::sleep_for(std::chrono::seconds(2));
if (!FullGuestTraceEnabled()) continue;
if (!f) {
f = fopen(kGuestTracePath, "a");
if (!f) continue;
fprintf(f, "---- guest_trace opened, epoch_ms(monotonic)=%llu ----\n",
(unsigned long long)GuestTraceEpochMs());
fflush(f);
}
uint64_t posNow = g_pos.load(std::memory_order_relaxed);
if (posNow <= lastDumped) continue;
uint64_t start = lastDumped;
if (posNow - lastDumped > kRingSize) {
// Consumer fell behind the writer by more than a full ring -
// say so explicitly rather than silently presenting a gap as
// a continuous sequence (matches caveman_retrieve's own
// "non-adjacent" marker convention for the same reason).
fprintf(f, "---- [guest_trace: %llu entries dropped, ring overflowed] ----\n",
(unsigned long long)(posNow - lastDumped - kRingSize));
start = posNow - kRingSize;
}
for (uint64_t i = start; i < posNow; i++) {
const BlockEntry& e = g_ring[i % kRingSize];
fprintf(f, "[%u] [tid=%u] 0x%x\n", e.relMs, e.tid, e.addr);
}
fflush(f);
lastDumped = posNow;
}
}).detach();
}
void LogJniCall(const char* fmt, ...) {
if (!FullGuestTraceEnabled()) return;
char buf[512];
va_list ap;
va_start(ap, fmt);
vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
static std::mutex m;
static FILE* f = nullptr;
std::lock_guard<std::mutex> lock(m);
if (!f) {
f = fopen(kJniTracePath, "a");
if (f) {
fprintf(f, "---- jni_trace opened, epoch_ms(monotonic)=%llu ----\n",
(unsigned long long)GuestTraceEpochMs());
}
}
if (f) {
fprintf(f, "[%llu] [tid=%d] %s\n",
(unsigned long long)(NowMs() - GuestTraceEpochMs()), gettid(), buf);
fflush(f);
}
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <unicorn/unicorn.h>
#include <cstdint>
#include <cstdarg>
// Full, file-backed execution trace for the emulated engine - correlates
// against the Galaxy A9's own `trace_agent` full-call trace (built the same
// day, see ARM64_TRANSLATION_LAYER.md's 2026-09-06 entries) using a shared
// monotonic-clock epoch, for the "where does the emulated engine's real
// execution first diverge from real hardware's" investigation - both
// devices confirmed to take the same useAssetsFileSystem()/OBB-gated path,
// yet only one renders, so the divergence must be found downstream of that,
// not assumed from a single-variable theory.
//
// Two files, deliberately not interleaved into one (still directly
// comparable by timestamp):
// - guest_trace.log: one line per executed basic block (UC_HOOK_BLOCK -
// the same cheap, already-proven mechanism as profiler.h's own sampling
// hook and guest_engine.cpp's existing TraceRing/LiveTraceRing). This is
// BLOCK-level, not strictly call-level: reconstructing an accurate
// guest call sequence from it means keeping only addresses that are
// real function entry points, cross-referenced against the IDA
// database offline - classifying that live, per block, would need a
// disassembler in the hot path, which this project has deliberately
// avoided everywhere else for cost reasons.
// - jni_trace.log (written by jni_shim.cpp via LogJniCall): one line per
// real Java-side JNI call the guest makes (Call*Method family via
// InvokeCall, Get/Set*Field via DoGetField/DoSetField, GetMethodID/
// GetFieldID lookups, RegisterNatives) - the same call granularity as
// the A9 tracer's own JNI table patch, so the two are diffable line by
// line once resolved to real names.
void EnableFullGuestTrace();
bool FullGuestTraceEnabled();
// UC_HOOK_BLOCK callback - pass directly to uc_hook_add, gated on
// FullGuestTraceEnabled() by the caller (see guest_engine.cpp).
void FullGuestTraceHookCb(uc_engine* uc, uint64_t address, uint32_t size, void* user_data);
// Starts (once) a background thread that appends newly-recorded block-trace
// entries to guest_trace.log every 2 seconds until the process exits. No-op
// (just sleeps) while FullGuestTraceEnabled() is false.
void StartGuestTraceDumpThread();
// Monotonic-clock epoch (ms since CLOCK_MONOTONIC's own reference point),
// established the moment EnableFullGuestTrace() is first called. Both
// guest_trace.log and jni_trace.log timestamp every line as an offset from
// this same epoch, so the two files line up directly - no wall-clock/RTC
// involved, matching this project's existing NowMs()-style conventions.
uint64_t GuestTraceEpochMs();
// Shared JNI-call logger (used by jni_shim.cpp) - appends a timestamped,
// tid-tagged line to jni_trace.log. No-op while FullGuestTraceEnabled() is
// false, so ordinary runs pay no cost beyond the one atomic flag check.
void LogJniCall(const char* fmt, ...);
+39
View File
@@ -0,0 +1,39 @@
#pragma once
// Shared basic types for the ARM32-on-ARM64 in-process translation core.
// See /ARM64_TRANSLATION_LAYER.md for the design this implements.
#include <cstdint>
// A guest address is "offset from the loaded libapp.so image's own base"
// (vaddr 0, since the ELF is ET_DYN with a preferred base of 0 - confirmed
// via readelf/pyelftools this session). This is DELIBERATELY the exact same
// number space every existing OFFSET macro in this codebase already uses
// (ARCHITECTURE.md's own "IDA addresses == file offsets == APP_ADDR
// argument" observation) - the whole point of choosing this layout is that
// none of the ~150 hand-derived offsets in lan_event_injection.h etc. need
// to change.
using GuestAddr = uint32_t;
// bit0 of a GuestAddr, when passed to CallGuestFunction/InstallCodeHook,
// selects Thumb vs ARM instruction decoding - same convention this
// project's own armhook.cpp (isThumbMode/makeThumbPtr) already used for a
// real (non-emulated) process. Every hook site in this codebase so far
// targets ARM-mode code (confirmed via IDA disasm before hooking each one),
// so bit0 is 0 everywhere in practice today, but the mechanism supports
// Thumb targets too.
constexpr GuestAddr kThumbBit = 1;
enum class HookMode {
// Callback runs, then guest execution CONTINUES from the original
// instruction (nothing skipped) - used to observe/modify state without
// altering control flow, or as the entry point for a "modify args, then
// fall through to real code" hook (matches this project's existing
// "wrap" hooks, e.g. Hook_BuildTrackScenePath).
kWrap,
// Callback fully replaces the target: after it returns, execution jumps
// straight to the guest LR (as if the target function returned) without
// ever running the target's own instructions (matches e.g.
// Hook_CopSoundsTick's "skip the body entirely" pattern).
kReplace,
};
+529
View File
@@ -0,0 +1,529 @@
#include "import_shims.h"
#include "jni_shim.h"
#include "../util/util.h"
#include <cstring>
#include <cctype>
#include <cstdlib>
#include <math.h>
#include <ctime>
#include <string>
#include <mutex>
#include <atomic>
#include <android/log.h>
// ---- Known, documented gaps in this shim layer (prototype scope) ----
// - Corrected (2026-08-29): `armeabi-v7a` uses the **softfp** calling
// convention (confirmed - this is the standard, documented Android NDK
// choice for this ABI, kept for compatibility with older soft-float
// armeabi code even though the CPU has real VFP hardware) - float/double
// args and return values pass through r0-r3 (and the stack) as raw bit
// patterns, NOT through S/D VFP registers. So float marshaling via
// `memcpy` on the raw uint32_t register value (see GetFloatField in
// jni_shim.cpp, and every GLfloat parameter in gles_shim.cpp) is already
// CORRECT, not a gap - the earlier version of this comment overstated the
// risk. Only real jlong/jdouble (8-byte, register-pair-aligned) values
// remain genuinely unhandled (see MarshalArgs*'s own comment in
// jni_shim.cpp), which is a JNI/varargs marshaling limitation, not a
// float-ABI one.
// - __cxa_guard_acquire/release below are still NOT thread-safe (no atomic
// CAS, no futex wait for a concurrent initializer) - a real race here
// (two guest threads racing the same function-local static's first-time
// init) would need actual fixing, not just documenting, but hasn't been
// observed yet. Real pthread_create/join/mutex/cond support now lives in
// emu/pthread_shim.h/.cpp (added once the prototype actually needed guest
// threads to stop the engine deadlocking on its own worker threads - see
// ARM64_TRANSLATION_LAYER.md). The pthread_key_*/TLS shims below now use
// a `thread_local` value array (fixed at this same session) - only key
// ALLOCATION (pthread_key_create's counter) is still process-wide/shared,
// matching real bionic's own TLS-key semantics.
// - __cxa_atexit is a no-op (guest static destructors never run) - harmless
// for a process that's never expected to cleanly "exit" its guest image.
// - dladdr/__dynamic_cast/getauxval/qsort/longjmp/the *printf family are
// NOT implemented (still trap to the generic "unresolved, return 0"
// handler) - each needs either real variadic-argument marshaling
// (printf/snprintf/__android_log_print), a guest-callback trampoline
// (qsort's comparator), or non-trivial semantics (longjmp) this pass
// deliberately didn't attempt. File I/O (fopen/fread/fclose/...) is in
// the same "deliberately not attempted" bucket - would need a guest
// FILE* handle table, same shape as JniHandleTable, not built yet.
// Every gap above was a deliberate scope cut for this session, not an
// oversight - see ARM64_TRANSLATION_LAYER.md's own "open risks" section for
// the class of work this belongs to.
namespace {
uint32_t Shim_aeabi_memcpy(GuestEngine& eng, uint32_t dest, uint32_t src, uint32_t n, uint32_t, uint32_t) {
if (dest && src && n) memcpy(eng.G2H(dest), eng.G2H(src), n);
return dest;
}
uint32_t Shim_aeabi_memmove(GuestEngine& eng, uint32_t dest, uint32_t src, uint32_t n, uint32_t, uint32_t) {
if (dest && src && n) memmove(eng.G2H(dest), eng.G2H(src), n);
return dest;
}
// AEABI memset/memclr use (dest, n, c) - deliberately reversed vs libc
// memset(dest, c, n). See ARM IHI 0043 (AEABI) sec 7.2.
uint32_t Shim_aeabi_memset(GuestEngine& eng, uint32_t dest, uint32_t n, uint32_t c, uint32_t, uint32_t) {
if (dest && n) memset(eng.G2H(dest), (int)c, n);
return dest;
}
// __memcpy_chk (2026-09-16, ARM64_TRANSLATION_LAYER.md - the "isolated
// std::ostringstream repro" test): bionic's _FORTIFY_SOURCE=2 wrapper around
// memcpy, emitted by clang whenever the compiler can prove a destination
// object's size at compile time - unlike __aeabi_memcpy* above, this is a
// LIBC symbol, not an AEABI one, so a freshly-built c++_static artifact
// linking straight against bionic (rather than going through libapp.so's
// own already-compiled, already-relocated calls) can reference it even
// though nothing in this codebase had needed it before. Confirmed live:
// missing this shim silently turned every one of ostream_repro.so's own
// `memcpy(outBuf, ...)` calls into a no-op (the generic "unresolved import,
// return 0" fallback doesn't copy anything), which looked EXACTLY like a
// real ostringstream-extraction bug (result buffer stayed all-zero) until
// this was traced back to the missing shim - a confound worth documenting
// so it doesn't get mistaken for engine-level memory corruption again.
// destlen is bionic's real 4th argument (the compiler-computed destination
// object size); this shim logs instead of aborting if n exceeds it (real
// bionic would abort() - a hard crash - which is almost certainly less
// useful for debugging a guest than a loud log line here).
uint32_t Shim_memcpy_chk(GuestEngine& eng, uint32_t dest, uint32_t src, uint32_t n, uint32_t destlen, uint32_t) {
if (destlen != 0xFFFFFFFFu && n > destlen) {
Log("GuestEngine: __memcpy_chk: n(%u) > destlen(%u) at dest=0x%x, src=0x%x - real bionic "
"would abort() here; copying anyway (see import_shims.cpp)", n, destlen, dest, src);
}
if (dest && src && n) memcpy(eng.G2H(dest), eng.G2H(src), n);
return dest;
}
// ---- Minimal bionic locale-family shims (2026-09-16, same investigation)
// ----
// A statically-linked libc++abi's classic/"C" locale singleton calls these
// real bionic entry points during its own one-time setup - none had ever
// been needed before this session (every previous locale-touching call
// site in this codebase went through rtti_shims.cpp's own hand-written
// _ZNSt6__ndk1*-prefixed libc++-internal shims instead, which never call
// down into bionic's OWN locale layer at all - see rtti_shims.cpp's top
// comment). newlocale returning 0 (this engine's default "unresolved,
// return 0" behavior) was tested live and did NOT block basic char-only
// ostringstream writes from working (this session's own repro test's
// reported length came back correct even before this shim was added), but
// leaving it unresolved is still a real, avoidable confound for any FUTURE
// artifact that touches actual locale-sensitive formatting - a minimal,
// always-succeeds "C locale" stand-in costs nothing and removes the
// ambiguity. Genuinely locale-SENSITIVE behavior (real multi-locale
// support) is out of scope, same as rtti_shims.cpp's own use_facet gap -
// this only needs to make single-locale ("C"/classic) code paths not
// silently fail.
GuestAddr g_fakeLocaleT = 0; // lazily allocated on first newlocale() call
uint32_t Shim_newlocale(GuestEngine& eng, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
if (!g_fakeLocaleT) g_fakeLocaleT = eng.AllocPermanent(4); // never freed - see AllocPermanent's own comment
return g_fakeLocaleT ? g_fakeLocaleT : 1u; // never return a real 0/NULL "failed" locale_t
}
uint32_t Shim_uselocale(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
return g_fakeLocaleT ? g_fakeLocaleT : 1u; // "previous locale" - same single fake handle either way
}
uint32_t Shim_freelocale(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
return 0; // never actually freed - g_fakeLocaleT is a permanent singleton, matches AllocPermanent's own contract
}
uint32_t Shim_aeabi_memclr(GuestEngine& eng, uint32_t dest, uint32_t n, uint32_t, uint32_t, uint32_t) {
if (dest && n) memset(eng.G2H(dest), 0, n);
return dest;
}
// Diagnostic (2026-09-17, ARM64_TRANSLATION_LAYER.md - the 0x3d3d3d3d
// use-after-free-shaped chase): per-allocation malloc/free tracing with the
// caller's guest LR, to reconstruct which call sites touch a given address
// across its lifetime.
//
// OFF by default since 2026-09-19. That investigation is closed, and this
// pair turned out to be by far the loudest thing in the engine: a single
// prologue-load capture held 554,383 malloc lines and 539,003 free lines -
// 93% of a 1.38-million-line log once __aeabi_memcpy's probe is counted in.
// That volume does not burn CPU so much as block the app against logd, which
// is exactly the "loading takes forever but the phone isn't even warm"
// symptom the user reported. Same opt-in discipline as the UC_HOOK_BLOCK
// diagnostics (see guest_engine.cpp) - flip to true only for a short,
// targeted capture, never leave it on.
constexpr bool kTraceHeapAllocations = false;
uint32_t Shim_malloc(GuestEngine& eng, uint32_t size, uint32_t, uint32_t, uint32_t, uint32_t) {
GuestAddr addr = eng.heap().Alloc(size);
// Small allocations only (<=256 bytes - covers both the tiny
// attribute-map bucket array and the ~33-byte shader-header string
// buffer implicated in that investigation).
if (kTraceHeapAllocations && size <= 256) {
uint32_t callerLr = 0;
if (uc_engine* uc = eng.uc()) uc_reg_read(uc, UC_ARM_REG_LR, &callerLr);
Log("GuestHeap: malloc(size=%u) -> 0x%x from guest LR=0x%x", size, addr, callerLr);
}
return addr;
}
uint32_t Shim_free(GuestEngine& eng, uint32_t ptr, uint32_t, uint32_t, uint32_t, uint32_t) {
// No size available here without reading GuestHeap's own private header,
// so this logs every free() - fine for a bounded capture, ruinous left on.
if (kTraceHeapAllocations) {
uint32_t callerLr = 0;
if (uc_engine* uc = eng.uc()) uc_reg_read(uc, UC_ARM_REG_LR, &callerLr);
Log("GuestHeap: free(0x%x) from guest LR=0x%x", ptr, callerLr);
}
eng.heap().Free(ptr);
return 0;
}
uint32_t Shim_calloc(GuestEngine& eng, uint32_t nmemb, uint32_t size, uint32_t, uint32_t, uint32_t) {
// 2026-09-19: GuestHeap::Alloc no longer zero-fills (it used to memset
// the whole rounded-up class block on every malloc - see its own
// comment), so calloc has to do it itself, which is where the cost
// belongs. Overflow-checked: nmemb*size in 32-bit guest arithmetic can
// wrap, and a wrapped-small allocation followed by a full-size memset
// would be a heap overflow.
uint64_t total = (uint64_t)nmemb * (uint64_t)size;
if (total > 0xFFFFFFFFull) {
Log("Shim_calloc: %u * %u overflows 32 bits - returning NULL", nmemb, size);
return 0;
}
GuestAddr addr = eng.heap().Alloc((uint32_t)total);
if (addr && total) memset(eng.G2H(addr), 0, (size_t)total);
return addr;
}
uint32_t Shim_realloc(GuestEngine& eng, uint32_t ptr, uint32_t size, uint32_t, uint32_t, uint32_t) {
// No real "grow in place" support in GuestHeap (see its own class
// comment - no coalescing) - always relocates. Correct but wasteful;
// fine for a prototype's expected allocation volume.
// 2026-09-19: the old block's exact size is now available (GuestHeap
// stores it per block and exposes BlockSize), so this copies the real
// min(oldSize,newSize). It used to copy `size` unconditionally, which on
// a SHRINKING realloc read past the end of the old block - harmless in
// practice only because the arena is one contiguous mapping.
uint32_t oldSize = ptr ? eng.heap().BlockSize(ptr) : 0;
GuestAddr newAddr = eng.heap().Alloc(size);
if (ptr && newAddr) {
uint32_t toCopy = (oldSize && oldSize < size) ? oldSize : size;
memcpy(eng.G2H(newAddr), eng.G2H(ptr), toCopy);
}
if (ptr) eng.heap().Free(ptr);
return newAddr;
}
uint32_t Shim_strlen(GuestEngine& eng, uint32_t s, uint32_t, uint32_t, uint32_t, uint32_t) {
if (!s) return 0;
uint32_t len = (uint32_t)strlen((const char*)eng.G2H(s));
// Diagnostic (2026-09-17, ARM64_TRANSLATION_LAYER.md - the 0x1e0 wild-jump
// chase). strlen() runs on the HOST over the translated pointer, so an
// unterminated guest string doesn't fault - it just keeps scanning
// forward through whatever else lives in the shared host_region_ arena
// until it happens to hit a stray zero byte, potentially megabytes later.
// That exact symptom (a huge, ever-doubling "length" feeding into a
// runaway std::string/streambuf reallocation) is what's driving the
// 0x1e0 crash - flagging any suspiciously large result (not the normal
// case, so cheap to check) to catch it at the source instead of several
// frames downstream.
if (len > 4096) {
uint32_t callerLr = 0;
if (uc_engine* uc = eng.uc()) uc_reg_read(uc, UC_ARM_REG_LR, &callerLr);
Log("GuestEngine: Shim_strlen: suspiciously large result - s=0x%x len=%u from guest LR=0x%x",
s, len, callerLr);
}
return len;
}
uint32_t Shim_strcmp(GuestEngine& eng, uint32_t a, uint32_t b, uint32_t, uint32_t, uint32_t) {
return (uint32_t)(int32_t)strcmp((const char*)eng.G2H(a), (const char*)eng.G2H(b));
}
uint32_t Shim_strcpy(GuestEngine& eng, uint32_t dst, uint32_t src, uint32_t, uint32_t, uint32_t) {
strcpy((char*)eng.G2H(dst), (const char*)eng.G2H(src));
return dst;
}
uint32_t Shim_strncpy(GuestEngine& eng, uint32_t dst, uint32_t src, uint32_t n, uint32_t, uint32_t) {
strncpy((char*)eng.G2H(dst), (const char*)eng.G2H(src), n);
return dst;
}
uint32_t Shim_tolower(GuestEngine&, uint32_t c, uint32_t, uint32_t, uint32_t, uint32_t) {
return (uint32_t)tolower((int)c);
}
// pthread_mutexattr_t is entirely ignored (see pthread_shim.h's gap list -
// every guest mutex is a real std::recursive_mutex regardless of the attr
// type requested, so there's nothing for init/settype/destroy to actually
// configure); these three stay plain always-succeed no-ops.
uint32_t Shim_pthread_noop_success(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return 0; }
uint32_t Shim_raise(GuestEngine&, uint32_t sig, uint32_t, uint32_t, uint32_t, uint32_t) {
Log("import_shims: guest code called raise(%u) - not delivering a real signal to the guest "
"(no guest signal handling exists), returning as if handled", sig);
return 0;
}
// EA's own thin JNIEnv/classloader-caching wrappers (EA::Nimble::getEnv/
// findClass) - genuinely external to libapp.so itself (normally provided by
// libNimble.so, which - like libapp.so - has no arm64-v8a build and isn't
// loaded into the emulator). Implemented directly against JniShim instead
// of emulating libNimble.so's own code, since these two are simple enough
// to reimplement natively (same "wrap the API, don't translate the
// implementation" principle as the rest of this shim layer).
uint32_t Shim_ea_nimble_getEnv(GuestEngine& eng, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
return JniShim::Instance().BuildGuestJNIEnv(eng);
}
uint32_t Shim_ea_nimble_findClass(GuestEngine& eng, uint32_t namePtr, uint32_t, uint32_t, uint32_t, uint32_t) {
if (!namePtr) return 0;
// Real JNI FindClass requires slash-separated names ("com/ea/..."), but
// callers of this EA convenience wrapper pass dot-separated ones
// ("com.ea.nimble.Log", confirmed live via a CheckJNI "illegal class
// name" abort this session) - the real libNimble.so implementation
// clearly did this same conversion internally before calling the real
// FindClass, so this reimplementation must too.
std::string name = (const char*)eng.G2H(namePtr);
for (char& c : name) if (c == '.') c = '/';
jclass c = JniShim::Instance().RealEnv()
? JniShim::Instance().FindClassWithFallback(JniShim::Instance().RealEnv(), name)
: nullptr;
return c ? JniShim::Instance().handles().Alloc(c) : 0;
}
uint32_t Shim_cxa_guard_acquire(GuestEngine& eng, uint32_t guard, uint32_t, uint32_t, uint32_t, uint32_t) {
uint8_t* p = (uint8_t*)eng.G2H(guard);
return (*p == 0) ? 1u : 0u; // 1 = "you run the initializer", 0 = "already done"
}
uint32_t Shim_cxa_guard_release(GuestEngine& eng, uint32_t guard, uint32_t, uint32_t, uint32_t, uint32_t) {
*(uint8_t*)eng.G2H(guard) = 1;
return 0;
}
uint32_t Shim_cxa_guard_abort(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return 0; }
uint32_t Shim_cxa_atexit(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return 0; }
uint32_t Shim_cxa_pure_virtual(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
Log("import_shims: __cxa_pure_virtual called (guest called a pure-virtual method) - "
"this is a real bug signal, not expected in normal operation");
return 0;
}
uint32_t Shim_abort(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
Log("import_shims: guest code called abort() - NOT actually aborting the host process "
"(would take down the whole app); this shim just logs and returns, so guest execution "
"after this point is running past what the real binary would have allowed. See this "
"file's own top-of-file gap notes.");
return 0;
}
uint32_t Shim_dladdr(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return 0; } // always "not found"
// ---- Single-precision math (softfp - see this file's top comment) ----
// All take/return one or two GLfloat-shaped 32-bit register values; no
// alignment concerns since none of these have a double-precision (8-byte)
// parameter to worry about.
#define MATH1F_SHIM(name) \
uint32_t Shim_##name(GuestEngine&, uint32_t r0, uint32_t, uint32_t, uint32_t, uint32_t) { \
float x, r; \
memcpy(&x, &r0, 4); \
r = name(x); \
uint32_t bits; \
memcpy(&bits, &r, 4); \
return bits; \
}
#define MATH2F_SHIM(name) \
uint32_t Shim_##name(GuestEngine&, uint32_t r0, uint32_t r1, uint32_t, uint32_t, uint32_t) { \
float x, y, r; \
memcpy(&x, &r0, 4); \
memcpy(&y, &r1, 4); \
r = name(x, y); \
uint32_t bits; \
memcpy(&bits, &r, 4); \
return bits; \
}
MATH1F_SHIM(acosf)
MATH1F_SHIM(asinf)
MATH1F_SHIM(ceilf)
MATH1F_SHIM(cosf)
MATH1F_SHIM(expf)
MATH1F_SHIM(floorf)
MATH1F_SHIM(roundf)
MATH1F_SHIM(sinf)
MATH1F_SHIM(sqrtf)
MATH1F_SHIM(tanf)
MATH2F_SHIM(atan2f)
MATH2F_SHIM(fmodf)
MATH2F_SHIM(powf)
#undef MATH1F_SHIM
#undef MATH2F_SHIM
uint32_t Shim_atoi(GuestEngine& eng, uint32_t s, uint32_t, uint32_t, uint32_t, uint32_t) {
return s ? (uint32_t)atoi((const char*)eng.G2H(s)) : 0;
}
uint32_t Shim_memcmp(GuestEngine& eng, uint32_t a, uint32_t b, uint32_t n, uint32_t, uint32_t) {
return (uint32_t)(int32_t)memcmp(eng.G2H(a), eng.G2H(b), n);
}
uint32_t Shim_memset(GuestEngine& eng, uint32_t dst, uint32_t c, uint32_t n, uint32_t, uint32_t) {
memset(eng.G2H(dst), (int)c, n);
return dst;
}
uint32_t Shim_strncmp(GuestEngine& eng, uint32_t a, uint32_t b, uint32_t n, uint32_t, uint32_t) {
return (uint32_t)(int32_t)strncmp((const char*)eng.G2H(a), (const char*)eng.G2H(b), n);
}
uint32_t Shim_strrchr(GuestEngine& eng, uint32_t s, uint32_t c, uint32_t, uint32_t, uint32_t) {
if (!s) return 0;
char* p = strrchr((char*)eng.G2H(s), (int)c);
return p ? eng.H2G(p) : 0;
}
uint32_t Shim_strstr(GuestEngine& eng, uint32_t hay, uint32_t needle, uint32_t, uint32_t, uint32_t) {
if (!hay || !needle) return 0;
char* p = strstr((char*)eng.G2H(hay), (const char*)eng.G2H(needle));
return p ? eng.H2G(p) : 0;
}
uint32_t Shim_toupper(GuestEngine&, uint32_t c, uint32_t, uint32_t, uint32_t, uint32_t) {
return (uint32_t)toupper((int)c);
}
uint32_t Shim_lrand48(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
return (uint32_t)lrand48();
}
// clock_gettime/nanosleep/time all write into a guest-addressed struct/
// pointer - direct G2H translation, same as everywhere else in this file.
// Real host struct layouts (`struct timespec`) are ABI-identical between
// 32-bit and 64-bit (two `long`-ish fields that both fit this project's
// existing 32-bit-guest assumption closely enough for timing purposes,
// though a fully correct implementation would need to know the guest's own
// expected struct timespec field widths - approximated here, documented).
uint32_t Shim_clock_gettime(GuestEngine& eng, uint32_t clockId, uint32_t tsPtr, uint32_t, uint32_t, uint32_t) {
struct timespec ts{};
int rc = clock_gettime((clockid_t)clockId, &ts);
if (tsPtr) {
uint32_t sec = (uint32_t)ts.tv_sec, nsec = (uint32_t)ts.tv_nsec;
memcpy(eng.G2H(tsPtr), &sec, 4);
memcpy((uint8_t*)eng.G2H(tsPtr) + 4, &nsec, 4);
}
return (uint32_t)rc;
}
uint32_t Shim_nanosleep(GuestEngine& eng, uint32_t reqPtr, uint32_t, uint32_t, uint32_t, uint32_t) {
if (!reqPtr) return -1;
uint32_t sec = 0, nsec = 0;
memcpy(&sec, eng.G2H(reqPtr), 4);
memcpy(&nsec, (uint8_t*)eng.G2H(reqPtr) + 4, 4);
struct timespec req{(time_t)sec, (long)nsec};
return (uint32_t)nanosleep(&req, nullptr);
}
uint32_t Shim_time(GuestEngine& eng, uint32_t tPtr, uint32_t, uint32_t, uint32_t, uint32_t) {
time_t t = time(nullptr);
if (tPtr) { uint32_t v = (uint32_t)t; memcpy(eng.G2H(tPtr), &v, 4); }
return (uint32_t)t;
}
uint32_t Shim_android_log_write(GuestEngine& eng, uint32_t prio, uint32_t tagPtr, uint32_t msgPtr, uint32_t, uint32_t) {
const char* tag = tagPtr ? (const char*)eng.G2H(tagPtr) : "libapp";
const char* msg = msgPtr ? (const char*)eng.G2H(msgPtr) : "";
return (uint32_t)__android_log_write((int)prio, tag, msg);
}
// `thread_local` (not a single shared array) - now that real guest threads
// exist (emu/pthread_shim.cpp), a flat shared array would let one guest
// thread's pthread_setspecific silently clobber every other thread's value
// for the same key, which is exactly backwards from real TLS semantics. Key
// ALLOCATION (g_tlsKeyCount) is still process-wide/shared, as real bionic's
// is - only the per-key VALUES are per-thread.
constexpr int kMaxTlsKeys = 64;
thread_local uint32_t g_tlsValues[kMaxTlsKeys] = {};
std::mutex g_tlsKeyCountMutex;
int g_tlsKeyCount = 0;
uint32_t Shim_pthread_key_create(GuestEngine& eng, uint32_t keyOutPtr, uint32_t, uint32_t, uint32_t, uint32_t) {
std::lock_guard<std::mutex> lock(g_tlsKeyCountMutex);
if (g_tlsKeyCount >= kMaxTlsKeys) return -1;
uint32_t key = (uint32_t)g_tlsKeyCount++;
if (keyOutPtr) memcpy(eng.G2H(keyOutPtr), &key, 4);
return 0;
}
uint32_t Shim_pthread_key_delete(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) { return 0; }
uint32_t Shim_pthread_getspecific(GuestEngine&, uint32_t key, uint32_t, uint32_t, uint32_t, uint32_t) {
return (key < (uint32_t)kMaxTlsKeys) ? g_tlsValues[key] : 0;
}
uint32_t Shim_pthread_setspecific(GuestEngine&, uint32_t key, uint32_t value, uint32_t, uint32_t, uint32_t) {
if (key < (uint32_t)kMaxTlsKeys) g_tlsValues[key] = value;
return 0;
}
// Per-real-host-thread fake id (was a single hardcoded `1` when the guest
// was single-threaded) - a thread_local counter assigned once per thread on
// first call, distinct across real guest threads, still opaque/never a real
// bionic pthread_t.
uint32_t Shim_pthread_self(GuestEngine&, uint32_t, uint32_t, uint32_t, uint32_t, uint32_t) {
static std::atomic<uint32_t> nextId{1};
static thread_local uint32_t id = nextId.fetch_add(1);
return id;
}
} // namespace
void RegisterCoreImportShims(GuestEngine& engine) {
engine.RegisterImportShim("__aeabi_memcpy", Shim_aeabi_memcpy);
engine.RegisterImportShim("__aeabi_memcpy4", Shim_aeabi_memcpy);
engine.RegisterImportShim("__aeabi_memcpy8", Shim_aeabi_memcpy);
engine.RegisterImportShim("__memcpy_chk", Shim_memcpy_chk);
engine.RegisterImportShim("newlocale", Shim_newlocale);
engine.RegisterImportShim("uselocale", Shim_uselocale);
engine.RegisterImportShim("freelocale", Shim_freelocale);
engine.RegisterImportShim("__aeabi_memmove", Shim_aeabi_memmove);
engine.RegisterImportShim("__aeabi_memmove4", Shim_aeabi_memmove);
engine.RegisterImportShim("__aeabi_memmove8", Shim_aeabi_memmove);
engine.RegisterImportShim("__aeabi_memset", Shim_aeabi_memset);
engine.RegisterImportShim("__aeabi_memset4", Shim_aeabi_memset);
engine.RegisterImportShim("__aeabi_memset8", Shim_aeabi_memset);
engine.RegisterImportShim("__aeabi_memclr", Shim_aeabi_memclr);
engine.RegisterImportShim("__aeabi_memclr4", Shim_aeabi_memclr);
engine.RegisterImportShim("__aeabi_memclr8", Shim_aeabi_memclr);
engine.RegisterImportShim("memcpy", Shim_aeabi_memcpy);
engine.RegisterImportShim("memmove", Shim_aeabi_memmove);
engine.RegisterImportShim("malloc", Shim_malloc);
engine.RegisterImportShim("free", Shim_free);
engine.RegisterImportShim("calloc", Shim_calloc);
engine.RegisterImportShim("realloc", Shim_realloc);
engine.RegisterImportShim("strlen", Shim_strlen);
engine.RegisterImportShim("strcmp", Shim_strcmp);
engine.RegisterImportShim("strcpy", Shim_strcpy);
engine.RegisterImportShim("strncpy", Shim_strncpy);
engine.RegisterImportShim("tolower", Shim_tolower);
engine.RegisterImportShim("pthread_mutexattr_init", Shim_pthread_noop_success);
engine.RegisterImportShim("pthread_mutexattr_settype", Shim_pthread_noop_success);
engine.RegisterImportShim("pthread_mutexattr_destroy", Shim_pthread_noop_success);
// Real pthread_create/join/mutex_*/cond_* shims are registered by
// RegisterPthreadImportShims (emu/pthread_shim.cpp) - the caller (see
// main.cpp) calls that after this function.
engine.RegisterImportShim("raise", Shim_raise);
engine.RegisterImportShim("_ZN2EA6Nimble6getEnvEv", Shim_ea_nimble_getEnv);
engine.RegisterImportShim("_ZN2EA6Nimble9findClassEPKc", Shim_ea_nimble_findClass);
engine.RegisterImportShim("__cxa_guard_acquire", Shim_cxa_guard_acquire);
engine.RegisterImportShim("__cxa_guard_release", Shim_cxa_guard_release);
engine.RegisterImportShim("__cxa_guard_abort", Shim_cxa_guard_abort);
engine.RegisterImportShim("__cxa_atexit", Shim_cxa_atexit);
engine.RegisterImportShim("__cxa_pure_virtual", Shim_cxa_pure_virtual);
engine.RegisterImportShim("abort", Shim_abort);
engine.RegisterImportShim("dladdr", Shim_dladdr);
engine.RegisterImportShim("acosf", Shim_acosf);
engine.RegisterImportShim("asinf", Shim_asinf);
engine.RegisterImportShim("ceilf", Shim_ceilf);
engine.RegisterImportShim("cosf", Shim_cosf);
engine.RegisterImportShim("expf", Shim_expf);
engine.RegisterImportShim("floorf", Shim_floorf);
engine.RegisterImportShim("roundf", Shim_roundf);
engine.RegisterImportShim("sinf", Shim_sinf);
engine.RegisterImportShim("sqrtf", Shim_sqrtf);
engine.RegisterImportShim("tanf", Shim_tanf);
engine.RegisterImportShim("atan2f", Shim_atan2f);
engine.RegisterImportShim("fmodf", Shim_fmodf);
engine.RegisterImportShim("powf", Shim_powf);
engine.RegisterImportShim("atoi", Shim_atoi);
engine.RegisterImportShim("memcmp", Shim_memcmp);
engine.RegisterImportShim("memset", Shim_memset);
engine.RegisterImportShim("strncmp", Shim_strncmp);
engine.RegisterImportShim("strrchr", Shim_strrchr);
engine.RegisterImportShim("strstr", Shim_strstr);
engine.RegisterImportShim("toupper", Shim_toupper);
engine.RegisterImportShim("lrand48", Shim_lrand48);
engine.RegisterImportShim("clock_gettime", Shim_clock_gettime);
engine.RegisterImportShim("nanosleep", Shim_nanosleep);
engine.RegisterImportShim("time", Shim_time);
engine.RegisterImportShim("__android_log_write", Shim_android_log_write);
engine.RegisterImportShim("pthread_key_create", Shim_pthread_key_create);
engine.RegisterImportShim("pthread_key_delete", Shim_pthread_key_delete);
engine.RegisterImportShim("pthread_getspecific", Shim_pthread_getspecific);
engine.RegisterImportShim("pthread_setspecific", Shim_pthread_setspecific);
engine.RegisterImportShim("pthread_self", Shim_pthread_self);
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include "guest_engine.h"
// Registers the small, deliberately-bounded set of real import shims this
// prototype implements (see import_shims.cpp for the list and the
// class-level notes in that file about what's NOT covered yet - notably
// libc++ locale/iostream/RTTI beyond trivial __cxa_guard/pure_virtual
// stubs). Real pthread_create/join/mutex/cond support is registered
// separately by RegisterPthreadImportShims (emu/pthread_shim.h) - call both
// (see main.cpp). Anything not registered by either still gets a guest stub
// address (so relocations always resolve to *something*), it just logs
// "unresolved import" once and returns 0 instead of crashing - see
// GuestEngine::ResolveOrCreateImportStub / import_stub_dispatch_cb.
void RegisterCoreImportShims(GuestEngine& engine);
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
#pragma once
// Guest-visible JNIEnv - lets guest code (running inside GuestEngine) call
// back into the REAL Android JVM, e.g. from libapp.so's own real
// nativeOnCreate/JNI_OnLoad now that they're invoked via CallGuestFunction
// instead of being no-op host stubs. See ARM64_TRANSLATION_LAYER.md's
// "JNI upcalls" section - this is the "least risky, most mechanical" piece
// that doc predicted, now actually built.
//
// Mechanism: builds a real `struct JNINativeInterface` layout (233 function-
// pointer slots, exact order extracted from this NDK's own jni.h) in guest
// memory, one stub guest-address per slot (same UC_HOOK_CODE "replace and
// return via LR" pattern as GuestEngine's import stubs / InstallTrampolineHook).
// When guest code calls env->FindClass(...), Unicorn's hook fires at that
// slot's stub address, the dispatcher reads the incoming args (r1.. plus
// stack, since r0 is always the guest JNIEnv* itself), forwards to the REAL
// host JNIEnv, and translates the result back into a guest-visible handle.
//
// Reference-shaped JNI values (jobject/jclass/jstring/jarray/jmethodID/
// jfieldID/jthrowable/jweak) are all real 64-bit-ish host values on this
// (64-bit ART) runtime and cannot fit in a 32-bit guest register - JniHandleTable
// hands out small sequential 32-bit guest handles and resolves them back,
// the same "handle indirection" every ARM-on-64-bit-host JNI bridge needs.
//
// Deliberately bounded scope (documented, not silent): only ~25 of the 233
// slots have real implementations (the ones covering class/method/field
// lookup, 0-argument method calls, strings, refs, exceptions - see
// jni_shim.cpp's own top comment for the exact list and the known gaps in
// Call*Method argument marshaling for methods that take parameters). Every
// other slot gets a guest stub that logs "unresolved JNI slot N (name)" once
// and returns 0, exactly matching GuestEngine's own import-stub philosophy.
#include <jni.h>
#include <vector>
#include <mutex>
#include <thread>
#include <atomic>
#include "guest_engine.h"
// Bidirectional 32-bit guest handle <-> real 64-bit-capable JNI reference.
// Never reuses/frees slots in this prototype (documented leak, acceptable
// for a short test run - see class comment above). Mutex-guarded - now that
// real guest threads exist (see emu/pthread_shim.h), more than one could in
// principle resolve/allocate a JNI handle concurrently (e.g. two threads
// each calling a Call*Method JNI slot at the same time).
//
// Each slot also records the real host thread that created it and whether
// it's a durable ("global") reference. Real JNI local references are only
// valid on the thread (and, strictly, the native-call frame) that created
// them - reused elsewhere they're either NULL (harmless) or a live-looking
// pointer to a DIFFERENT, unrelated real object, which real ART's CheckJNI
// hard-aborts the whole process on (confirmed live 2026-09-05 - see
// ARM64_TRANSLATION_LAYER.md's "invalid local jclass" investigation: a guest
// C++ helper cached a bare local jclass once on the main thread and reused
// it ~90s later from the GLThread inside RunLoop.nativeOnRunLoopTick - a
// real bug in that ~2013 EA code, not this shim, but one this shim can
// detect and degrade gracefully instead of crashing the whole process).
// IsSafeToUseFromCurrentThread lets a call site check this BEFORE handing
// the resolved pointer to any real JNI function - deliberately never asks
// ART itself (e.g. via GetObjectRefType), since CheckJNI validates every
// reference argument to every JNI function, including that one, so there is
// no real-JNI-call-based way to probe a reference's validity that doesn't
// risk aborting on exactly the kind of stale reference being checked for.
class JniHandleTable {
public:
uint32_t Alloc(void* real, bool isGlobal = false);
void* Resolve(uint32_t handle) const;
// Fix (2026-09-17, ARM64_TRANSLATION_LAYER.md - the SIGABRT-in-GLThread
// chase, ROOT CAUSE). This class's own comment above already called out
// "and, strictly, the native-call frame" as part of what makes a local
// ref valid - but the implementation only ever checked the owning
// THREAD, never the owning CALL. Confirmed live: `nativeOnRunLoopTick`
// is called repeatedly, ONCE PER FRAME, on the SAME real "GLThread" -
// each call is its own fresh JNI native-method invocation with its own
// local-ref frame (real_native_call.h's CallRealNative calls
// SetRealEnv(env) on every single entry, confirming this), so a jclass
// cached during one tick and reused during a LATER tick is stale THE
// MOMENT the tick that created it returns to Java - even though it's
// the exact same host thread throughout. BumpCallEpoch() is called
// from SetRealEnv (see its own comment) to mark each such boundary;
// IsSafeToUseFromCurrentThread now checks BOTH the owning thread AND
// the owning epoch.
bool IsSafeToUseFromCurrentThread(uint32_t handle) const;
//
// Corrected 2026-09-21 (task #67): the epoch used to be ONE process-wide
// counter. That was indistinguishable from correct while only one host
// thread ever crossed into JNI - but a local ref's lifetime is scoped to
// a native call ON ITS OWN THREAD, and a bump from a different thread has
// no business invalidating it.
//
// It stopped being academic the moment the FMOD audio bridge began
// calling in from FMODAudioDevice's AudioTrack thread every 100 ms: each
// of those bumps invalidated the refs GLThread was holding mid-call, and
// the process aborted with "JNI DETECTED ERROR IN APPLICATION: jfieldID
// was NULL" inside GLThread. Per-thread is both the fix and the more
// accurate model - no atomics needed either, since a thread's own epoch
// is only ever read and written by that thread.
static void BumpCallEpoch() { ++t_callEpoch; }
static uint32_t CurrentCallEpoch() { return t_callEpoch; }
private:
static thread_local uint32_t t_callEpoch;
struct Entry {
void* real = nullptr;
std::thread::id owner;
bool isGlobal = false;
uint32_t epoch = 0; // the OWNING THREAD's epoch at Alloc() time - see IsSafeToUseFromCurrentThread
};
mutable std::mutex mutex_;
std::vector<Entry> table_{Entry{}}; // index 0 reserved for guest NULL
};
class JniShim {
public:
static JniShim& Instance();
// Builds the guest JNIEnv structure (once) and returns its guest
// address - pass this as the first (r0) argument to any real guest
// function that expects a JNIEnv*.
GuestAddr BuildGuestJNIEnv(GuestEngine& engine);
// Wraps a range of GUEST memory in a real Java direct ByteBuffer and
// returns the guest handle for it, registered so that the guest's own
// GetDirectBufferAddress resolves it back to `guestAddr`.
//
// Needed because GetDirectBufferAddress can only answer for buffers this
// shim created (see its own comment): a ByteBuffer that Java allocated
// lives at a host address the guest cannot reach. Anything handing guest
// code a Java-allocated buffer therefore has to bounce through one of
// these - the same shape as the AndroidBitmap_lockPixels fix.
uint32_t NewGuestBackedDirectBuffer(GuestEngine& engine, GuestAddr guestAddr, uint32_t capacity);
// Builds a minimal guest JavaVM (8-slot JNIInvokeInterface) - only
// GetEnv and AttachCurrentThread are real (both just return the same
// guest JNIEnv from BuildGuestJNIEnv - this shim only ever has one
// "thread"/env, see SetRealEnv's own comment); DestroyJavaVM/
// DetachCurrentThread/AttachCurrentThreadAsDaemon are logged no-ops.
// Needed to call libapp.so's own real JNI_OnLoad(JavaVM*, void*).
GuestAddr BuildGuestJavaVM(GuestEngine& engine);
// Must be called before each top-level CallGuestFunction into guest
// code that might call back into Java - stores the REAL, currently
// valid JNIEnv* for the CALLING thread (JNIEnv* is only valid on the
// thread that obtained it - this is `thread_local` specifically so that
// holds, now that real guest pthreads exist - see emu/pthread_shim.h).
// Every current call site (main.cpp / game_lifecycle_stubs*.cpp) still
// only ever runs on the main/UI thread, so this doesn't change today's
// behavior - it just means a guest worker thread that starts calling
// into JNI won't silently corrupt/steal the main thread's slot; it will
// need to set its OWN via AttachCurrentThread first (see
// Impl_Vm_AttachCurrentThread in jni_shim.cpp), same as real Android JNI
// requires.
// Fix (2026-09-17, ARM64_TRANSLATION_LAYER.md - the SIGSEGV-in-GLThread
// chase, directly following on from this class's own comment above
// about a guest worker thread needing to AttachCurrentThread first).
// Confirmed live: a real, engine-spawned "GLThread" pthread eventually
// calls a JNI slot (GetMethodID) with its own real_env_ still null -
// this thread's slot was never populated, because
// Impl_Vm_AttachCurrentThread (jni_shim.cpp) only ever built a FAKE
// guest JNIEnv handle and never actually attached the calling HOST
// thread to the real JVM at all. Caches the process-wide `JavaVM*` the
// first time any thread supplies a real env (JavaVM* itself, unlike
// JNIEnv*, is valid across every thread) so Impl_Vm_AttachCurrentThread
// can call the REAL AttachCurrentThread for whichever host thread asks.
void SetRealEnv(JNIEnv* env) {
real_env_ = env;
if (env && !real_vm_) {
env->GetJavaVM(&real_vm_);
}
// Fix (2026-09-17) - see JniHandleTable::BumpCallEpoch's own
// comment. This call site is exactly "a new top-level native call
// is beginning" (real_native_call.h's CallRealNative calls this on
// every single entry) - every local ref handed out during the
// PREVIOUS call is now stale, whether or not it's the same thread.
if (env) handles_.BumpCallEpoch();
}
// Defensive fallback (same fix): if this HOST thread's own real_env_ is
// still null (guest code never called AttachCurrentThread on this
// thread, or it hasn't run yet), lazily attach for real here too,
// rather than only relying on Impl_Vm_AttachCurrentThread being the
// one and only path that populates it. Matches real Android's own
// forgiving behavior for JNI calls arriving on a not-yet-attached
// native thread. Safe to call from any thread; no-ops if there's no
// real JavaVM cached yet (nothing to attach to) or the thread is
// already attached (real AttachCurrentThread is itself idempotent).
JNIEnv* RealEnv() {
if (!real_env_ && real_vm_) {
JNIEnv* env = nullptr;
if (real_vm_->AttachCurrentThread(&env, nullptr) == JNI_OK && env) {
real_env_ = env;
}
}
return real_env_;
}
JavaVM* RealVm() const { return real_vm_; }
JniHandleTable& handles() { return handles_; }
// Classic Android JNI gotcha: FindClass only sees app classes correctly
// when called from the thread that originally loaded the native
// library (or one attached the same way) - called from any other
// thread (e.g. the real engine's GLThread, confirmed live this session
// via a "JNI DETECTED ERROR...GetMethodID received NULL jclass" abort
// traced back to a failed FindClass inside nativeSurfaceCreated) it
// silently only sees bootclasspath classes. Standard fix: cache the
// app's real ClassLoader once (from any already-reachable app object,
// on the main thread) and fall back to ClassLoader.loadClass() when a
// direct FindClass call fails. Call once, early (LoadEmulatedLibapp).
void CacheClassLoader(JNIEnv* env, jobject anyAppObject);
// Resolves `name` (slash-separated, real JNI FindClass convention) via
// a direct FindClass first, falling back to the cached ClassLoader if
// that fails/throws - this is what Impl_FindClass (jni_shim.cpp) and
// the EA::Nimble::findClass shim (import_shims.cpp) both call through.
jclass FindClassWithFallback(JNIEnv* env, const std::string& slashName);
private:
JniShim() = default;
static thread_local JNIEnv* real_env_;
JavaVM* real_vm_ = nullptr; // process-wide, NOT thread_local - see SetRealEnv's own comment
JniHandleTable handles_;
GuestAddr guest_env_ = 0;
GuestAddr guest_vm_ = 0;
jobject class_loader_ = nullptr; // global ref
jmethodID load_class_method_ = nullptr;
};
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include "guest_engine.h"
// Real implementations for the ~180 basic libc/POSIX imports libapp.so
// actually calls that import_shims.cpp didn't cover (string.h/ctype.h/
// math.h/stdio.h, POSIX file I/O, time.h, process/signal, dlopen family,
// network/socket, pthread extras, sem_*, __aeabi_* ARM EABI helpers, and a
// handful of Android/EGL/GLES-extension odds and ends).
//
// Root cause this file exists: EVERY one of these previously fell through
// to the generic "unresolved import, log once, return 0" handler. That's
// not a harmless gap - guest code that reasonably assumes fopen()/getenv()/
// strdup() succeeded and got a real pointer back, then dereferences a
// silently-substituted NULL/0, is undefined behavior from that point on -
// this session traced a real, hard-to-diagnose memory corruption bug back
// to exactly this pattern (a burst of unresolved fread/fseek/fwrite/RTTI
// symbols during libc++ runtime bootstrap, immediately followed by
// something writing garbage into unrelated guest memory). "Return 0 and
// hope" is not an acceptable default for anything actually called - see
// ARM64_TRANSLATION_LAYER.md's "go through all the imports" entry.
//
// Same marshaling conventions as import_shims.cpp throughout: pointer args
// are guest addresses, G2H-translated before use; functions returning a
// pointer INTO an already-G2H'd buffer (strchr, memchr, ...) H2G-translate
// the result back; functions returning a host-owned string (strerror,
// getenv, strdup, ...) copy it into a freshly heap-allocated guest buffer
// (same pattern as jni_shim.cpp's GetStringUTFChars); FILE*/DIR* (real host
// pointers, don't fit a 32-bit guest register) go through small handle
// tables, same shape as JniHandleTable.
void RegisterLibcImportShims(GuestEngine& engine);
// A GuestEngine::DataSymbolSetupFn (register via
// engine.RegisterDataSymbolSetup BEFORE engine.LoadImage, same as
// rtti_shims.h's SetupRttiDataSymbols) - builds real guest-visible data for
// the handful of libc DATA symbols (not callable functions) this sweep
// turned up: __stack_chk_guard (a stack-canary value - see its own
// comment for why any stable value works), timezone/tzname (mirrored from
// the real host globals, refreshed on every tzset() call), and __sF (real
// FILE* handles for stdin/stdout/stderr, via the same handle table
// fopen/fclose/etc already use).
void SetupLibcDataSymbols(GuestEngine& engine);
@@ -0,0 +1,202 @@
#include "name_lookup_accel.h"
#include "guest_engine.h"
#include "../util/util.h"
#include <atomic>
#include <cstring>
#include <map>
#include <mutex>
#include <string>
#include <unordered_map>
namespace name_lookup_accel {
namespace {
constexpr uint32_t kOffCacheFlag = 8;
constexpr uint32_t kOffEntriesA = 196;
constexpr uint32_t kOffCountA = 200;
constexpr uint32_t kOffEntriesB = 204;
constexpr uint32_t kOffCountB = 220;
constexpr uint32_t kOffPoolA = 224;
constexpr uint32_t kOffPoolThreshold = 228;
constexpr uint32_t kOffPoolB = 232;
// Refuses to index anything implausible. A garbage count field would
// otherwise turn the build loop into a multi-million-iteration walk over
// arbitrary memory - the first version had no such guard and paid for it.
constexpr uint32_t kMaxEntries = 200000;
constexpr uint32_t kMaxNameLen = 128;
struct TableCache {
uint32_t countA = 0;
uint32_t countB = 0;
uint32_t entriesA = 0;
uint32_t entriesB = 0;
uint32_t poolA = 0;
uint32_t poolB = 0;
uint32_t threshold = 0;
std::unordered_map<std::string, int32_t> byName;
};
std::mutex g_mutex;
std::map<uint32_t, TableCache> g_tables;
std::atomic<uint64_t> g_served{0};
std::atomic<uint64_t> g_fellThrough{0};
std::atomic<uint64_t> g_builds{0};
// Every guest read goes through uc_mem_read, which FAILS on an unmapped
// address instead of handing back a host pointer to walk off the end of the
// region. That is the whole difference from the first attempt, which used
// G2H plus an unbounded NUL scan and segfaulted the process.
bool TryReadU32(uc_engine* uc, uint32_t addr, uint32_t* out) {
return addr && uc_mem_read(uc, addr, out, 4) == UC_ERR_OK;
}
// Bounded, fault-tolerant guest string read. Returns false if the string is
// unterminated within kMaxNameLen or runs into unmapped memory.
bool TryReadString(uc_engine* uc, uint32_t addr, std::string* out) {
if (!addr) return false;
out->clear();
for (uint32_t i = 0; i < kMaxNameLen; i++) {
uint8_t c = 0;
if (uc_mem_read(uc, addr + i, &c, 1) != UC_ERR_OK) return false;
if (!c) return true;
out->push_back((char)c);
}
return false;
}
// Resolves entry index -> the guest address of that entry's name, mirroring
// the guest loop's own addressing (two entry arrays, and a threshold that
// selects which of two string pools an offset belongs to).
bool EntryNameAddr(uc_engine* uc, const TableCache& t, uint32_t index, uint32_t* out) {
uint32_t entry = (index >= t.countA) ? t.entriesB + (index - t.countA) * 8
: t.entriesA + index * 8;
uint32_t off = 0;
if (!TryReadU32(uc, entry, &off)) return false;
uint32_t base = t.poolA;
if (off >= t.threshold) {
off -= t.threshold;
base = t.poolB;
}
if (!base) return false;
*out = base + off;
return true;
}
// Reads the table's own descriptor fields. Returns false if anything looks
// unreadable or implausible, in which case this layer stays out of the way.
bool ReadTableDesc(uc_engine* uc, uint32_t self, TableCache* t) {
if (!TryReadU32(uc, self + kOffCountA, &t->countA)) return false;
if (!TryReadU32(uc, self + kOffCountB, &t->countB)) return false;
if (!TryReadU32(uc, self + kOffEntriesA, &t->entriesA)) return false;
if (!TryReadU32(uc, self + kOffEntriesB, &t->entriesB)) return false;
if (!TryReadU32(uc, self + kOffPoolA, &t->poolA)) return false;
if (!TryReadU32(uc, self + kOffPoolB, &t->poolB)) return false;
if (!TryReadU32(uc, self + kOffPoolThreshold, &t->threshold)) return false;
uint64_t total = (uint64_t)t->countA + t->countB;
return total != 0 && total <= kMaxEntries;
}
bool BuildIndex(uc_engine* uc, uint32_t self, TableCache* t) {
TableCache desc;
if (!ReadTableDesc(uc, self, &desc)) return false;
desc.byName.reserve((size_t)(desc.countA + desc.countB) * 2);
const uint32_t total = desc.countA + desc.countB;
for (uint32_t i = 0; i < total; i++) {
uint32_t nameAddr = 0;
if (!EntryNameAddr(uc, desc, i, &nameAddr)) return false;
std::string name;
if (!TryReadString(uc, nameAddr, &name)) return false;
if (name.empty()) continue;
// First occurrence wins - the guest loop returns on its first match.
desc.byName.emplace(std::move(name), (int32_t)i);
}
*t = std::move(desc);
g_builds.fetch_add(1, std::memory_order_relaxed);
return true;
}
} // namespace
void HookCb(uc_engine* uc, uint64_t, uint32_t, void*) {
uint32_t self = 0, namePtr = 0;
uc_reg_read(uc, UC_ARM_REG_R0, &self);
uc_reg_read(uc, UC_ARM_REG_R1, &namePtr);
if (!self || !namePtr) return;
// If the guest's own hash cache is ever enabled, step aside - that path
// also WRITES into guest structures (it memoises), and reproducing that
// here would be guesswork.
uint8_t cacheFlag = 0;
if (uc_mem_read(uc, self + kOffCacheFlag, &cacheFlag, 1) != UC_ERR_OK) return;
if (cacheFlag) return;
std::string query;
if (!TryReadString(uc, namePtr, &query) || query.empty()) return;
int32_t candidate = -1;
{
std::lock_guard<std::mutex> lock(g_mutex);
TableCache& cache = g_tables[self];
// The first attempt keyed the cache on the table ADDRESS and treated
// a changed entry count as "the table grew". The live log disproved
// that: one address alternated between 35 and 59 entries, i.e.
// DIFFERENT objects reusing the same address. So the descriptor is
// re-read every call (seven cheap word reads) and the index is
// rebuilt whenever any of it moved.
TableCache desc;
if (!ReadTableDesc(uc, self, &desc)) return;
const bool stale = cache.byName.empty() || cache.countA != desc.countA ||
cache.countB != desc.countB || cache.entriesA != desc.entriesA ||
cache.entriesB != desc.entriesB || cache.poolA != desc.poolA ||
cache.poolB != desc.poolB || cache.threshold != desc.threshold;
if (stale && !BuildIndex(uc, self, &cache)) {
g_tables.erase(self);
return; // could not index safely - let the guest do its own scan
}
auto it = cache.byName.find(query);
if (it == cache.byName.end()) {
// Not found is NOT answered from cache: a stale index would turn
// a real entry into a false "absent", and absence is exactly what
// the caller acts on. Let the guest's own scan decide.
g_fellThrough.fetch_add(1, std::memory_order_relaxed);
return;
}
candidate = it->second;
// Verify the hit against live guest memory before trusting it. This
// is what makes a wrong structural assumption cost performance
// instead of correctness.
uint32_t nameAddr = 0;
std::string actual;
if (!EntryNameAddr(uc, cache, (uint32_t)candidate, &nameAddr) ||
!TryReadString(uc, nameAddr, &actual) || actual != query) {
g_tables.erase(self);
g_fellThrough.fetch_add(1, std::memory_order_relaxed);
return;
}
}
uint64_t n = g_served.fetch_add(1, std::memory_order_relaxed) + 1;
if (n % 200000 == 0) {
Log("name_lookup_accel: %llu lookups served natively, %llu fell through to the guest, "
"%llu index builds",
(unsigned long long)n,
(unsigned long long)g_fellThrough.load(std::memory_order_relaxed),
(unsigned long long)g_builds.load(std::memory_order_relaxed));
}
uint32_t lr = 0, ret = (uint32_t)candidate;
uc_reg_read(uc, UC_ARM_REG_LR, &lr);
uc_reg_write(uc, UC_ARM_REG_R0, &ret);
uc_reg_write(uc, UC_ARM_REG_PC, &lr);
uc_emu_stop(uc);
}
} // namespace name_lookup_accel
@@ -0,0 +1,45 @@
#pragma once
#include <unicorn/unicorn.h>
// Native acceleration for the game's "find resource index by name" lookup
// (guest sub_4F3704), measured by the block profiler as 18.8% of all
// load-time samples - the second hottest thing after zlib's crc32.
//
// Why it is so expensive, from a live probe rather than from reading the
// code: the function HAS a hash-map fast path with memoisation, but both
// halves are gated on a byte at *(self+8), and that byte is **0** in this
// build - so the cache is simply never used and every call falls through to
// a LINEAR scan doing strcmp against a 6232-entry name table. Measured
// 320,000+ calls in ~40 seconds, i.e. on the order of a billion emulated
// strcmp comparisons. The names seen are shader/material parameters
// ("AmbientR", "LightingIndex", "LateralSlices", "Z"), which is why this
// hurts frame time as well as load time - they are resolved per object.
//
// Real hardware runs the same disabled-cache code and absorbs it; at ~12.8M
// emulated instructions/sec this layer cannot.
//
// Approach: keep the guest's data structures untouched and answer the lookup
// from a host-side hash map built once per table (rebuilt if the table's own
// entry counts change). Same interception mechanism as zlib_accel and
// FnvHashAccelHookCb - UC_HOOK_CODE at the entry, PC=LR, uc_emu_stop.
//
// Guest layout, read straight out of the decompile:
// *(self+8) byte - cache-enabled flag (0 here; if ever non-zero this
// layer steps aside and lets the guest run its own
// cached path)
// *(self+196) ptr - first entry array, 8 bytes per entry {offset, len}
// *(self+200) u32 - number of entries in the first array
// *(self+204) ptr - second entry array, same element layout
// *(self+220) u32 - number of entries in the second array
// *(self+224) ptr - string pool A
// *(self+228) u32 - offset threshold selecting pool A vs B
// *(self+232) ptr - string pool B
// Return value: the entry index, or -1 when the name is absent.
namespace name_lookup_accel {
constexpr uint64_t kLookupAddr = 0x4f3704;
void HookCb(uc_engine* uc, uint64_t address, uint32_t size, void* userData);
} // namespace name_lookup_accel
@@ -0,0 +1,170 @@
#include "ostream_repro_test.h"
#include "guest_engine.h"
#include <android/log.h>
#include <cstring>
#include <cstdint>
namespace {
// Real device path the artifact gets pushed to ahead of time (see
// ostream_repro/build.sh's own instructions and this session's
// ARM64_TRANSLATION_LAYER.md entry for the exact adb invocation used) -
// this app's own internal files dir, the same directory
// GameActivityMain.kt's loadEmulatedLibappFromAssets() extracts the real
// libapp.so asset into (`filesDir`), just for a file this project's build
// never bundles as an asset itself since it's a throwaway diagnostic, not a
// real dependency of the app.
constexpr const char* kOstreamReproPath = "/data/data/com.ea.games.nfs13_arm/files/ostream_repro.so";
constexpr const char* kEntrySymbol = "TestOstreamAssembly";
// MUST match ostream_repro.cpp's own four separate operator<< writes
// exactly - this is the host-side oracle this test checks the guest's
// extracted string against.
constexpr const char* kExpected =
"//FRAGMENT SHADER\n"
"//===========\n\n"
"void main()\n{\n"
"}\n";
constexpr uint32_t kResultBufSize = 260; // 4 (int32 length) + up to 256 bytes of content
} // namespace
void RunOstreamAssemblyReproTest(GuestEngine& engine) {
GuestAddr entry = engine.LoadSecondaryImage(kOstreamReproPath, kEntrySymbol);
if (!entry) {
__android_log_print(ANDROID_LOG_INFO, "OSTREAM_REPRO",
"RunOstreamAssemblyReproTest: LoadSecondaryImage(%s) failed or the entry symbol "
"wasn't found - see the preceding GuestEngine log line for which. Skipping (not "
"fatal - this artifact is a throwaway diagnostic, not a real app dependency); push "
"it via ostream_repro/build.sh + adb if you want this test to actually run.",
kOstreamReproPath);
return;
}
GuestAddr resultBuf = engine.AllocPermanent(kResultBufSize);
if (!resultBuf) {
__android_log_print(ANDROID_LOG_INFO, "OSTREAM_REPRO",
"RunOstreamAssemblyReproTest: AllocPermanent(%u) for the result buffer failed",
kResultBufSize);
return;
}
// TestOstreamAssembly(char* outBuf, int outBufSize) - AAPCS32 r0/r1,
// matches the convenience 2-arg CallGuestFunction overload exactly.
uint32_t retVal = engine.CallGuestFunction(entry, resultBuf, kResultBufSize);
int32_t reportedLen = 0;
memcpy(&reportedLen, engine.G2H(resultBuf), sizeof(reportedLen));
int copyLen = reportedLen;
if (copyLen < 0) copyLen = 0;
if (copyLen > (int)(kResultBufSize - 4)) copyLen = (int)(kResultBufSize - 4);
char content[kResultBufSize - 4 + 1] = {0};
memcpy(content, (const char*)engine.G2H(resultBuf) + 4, (size_t)copyLen);
content[copyLen] = 0;
size_t expectedLen = strlen(kExpected);
bool lengthMatches = (reportedLen == (int32_t)expectedLen);
bool contentMatches = lengthMatches && (memcmp(content, kExpected, expectedLen) == 0);
if (contentMatches) {
__android_log_print(ANDROID_LOG_INFO, "OSTREAM_REPRO",
"RunOstreamAssemblyReproTest: PASS - extracted std::ostringstream content matches "
"exactly what was written (len=%d, retVal=%u). The isolated write-then-extract "
"pattern works correctly under GuestEngine in total isolation from libapp.so - "
"whatever breaks the real game's sub_4702D8/sub_27160C path is NOT a general "
"ostringstream/basic_stringbuf-extraction bug in this engine.",
reportedLen, retVal);
} else if (reportedLen == 0) {
__android_log_print(ANDROID_LOG_INFO, "OSTREAM_REPRO",
"RunOstreamAssemblyReproTest: FAIL (REPRODUCES the real-game symptom) - extraction "
"came back EMPTY (len=0, retVal=%u) despite writing %zu bytes of real content via "
"4 separate operator<< calls, in total isolation from libapp.so. This is the SAME "
"'write succeeds, extraction returns empty' symptom already traced through the real "
"game's own sub_4702D8/sub_27160C - reproducing it here, in a minimal standalone "
"artifact with no game code involved at all, is real evidence this is a general "
"std::ostringstream/basic_stringbuf<char>::str()-extraction correctness bug in "
"GuestEngine itself, not something specific to libapp.so's own state.",
retVal, expectedLen);
} else {
__android_log_print(ANDROID_LOG_INFO, "OSTREAM_REPRO",
"RunOstreamAssemblyReproTest: FAIL (different from the real-game symptom) - "
"extracted len=%d (expected %zu), retVal=%u, content=\"%s\" - non-empty but WRONG "
"content is a different failure mode than the real game's clean empty-string "
"symptom; inspect `content` above before drawing a conclusion either way.",
reportedLen, expectedLen, retVal, content);
}
}
namespace {
constexpr const char* kNestedEntrySymbol = "TestOstreamAssemblyNested";
// MUST match ostream_repro.cpp's own TestOstreamAssemblyNested exactly.
constexpr const char* kNestedExpected =
"//VERTEX SHADER\n//=============\n\n"
"//Attributes\n//==========\n";
} // namespace
// 2026-09-17, ARM64_TRANSLATION_LAYER.md - the 0x3d3d3d3d heap-overflow
// chase. Reuses the same LoadSecondaryImage/CallGuestFunction plumbing as
// RunOstreamAssemblyReproTest above, against ostream_repro.cpp's NEW
// TestOstreamAssemblyNested export - the FIRST write alone (33 bytes)
// already forces libc++'s SSO->heap transition, immediately followed by a
// REAL function-call boundary (WriteAttributesSectionNested, noinline) that
// writes MORE content into the SAME shared stream - the exact shape of
// sub_46FD58 -> sub_4711C8's own real call, in total isolation from every
// other line of game code. If this ALSO corrupts/misbehaves under
// GuestEngine, that's decisive: the bug is in this engine's own handling of
// "grow past SSO on the first write, then write again across a function
// call boundary" - not something specific to the real game's broader state.
void RunOstreamAssemblyNestedReproTest(GuestEngine& engine) {
GuestAddr entry = engine.LoadSecondaryImage(kOstreamReproPath, kNestedEntrySymbol);
if (!entry) {
__android_log_print(ANDROID_LOG_INFO, "OSTREAM_REPRO",
"RunOstreamAssemblyNestedReproTest: LoadSecondaryImage(%s, %s) failed - see the "
"preceding GuestEngine log line for why. Push a freshly-rebuilt ostream_repro.so "
"if this is unexpected.",
kOstreamReproPath, kNestedEntrySymbol);
return;
}
GuestAddr resultBuf = engine.AllocPermanent(kResultBufSize);
if (!resultBuf) {
__android_log_print(ANDROID_LOG_INFO, "OSTREAM_REPRO",
"RunOstreamAssemblyNestedReproTest: AllocPermanent(%u) for the result buffer failed",
kResultBufSize);
return;
}
uint32_t retVal = engine.CallGuestFunction(entry, resultBuf, kResultBufSize);
int32_t reportedLen = 0;
memcpy(&reportedLen, engine.G2H(resultBuf), sizeof(reportedLen));
int copyLen = reportedLen;
if (copyLen < 0) copyLen = 0;
if (copyLen > (int)(kResultBufSize - 4)) copyLen = (int)(kResultBufSize - 4);
char content[kResultBufSize - 4 + 1] = {0};
memcpy(content, (const char*)engine.G2H(resultBuf) + 4, (size_t)copyLen);
content[copyLen] = 0;
size_t expectedLen = strlen(kNestedExpected);
bool lengthMatches = (reportedLen == (int32_t)expectedLen);
bool contentMatches = lengthMatches && (memcmp(content, kNestedExpected, expectedLen) == 0);
if (contentMatches) {
__android_log_print(ANDROID_LOG_INFO, "OSTREAM_REPRO",
"RunOstreamAssemblyNestedReproTest: PASS - len=%d retVal=%u matches exactly. The "
"'long first write forcing SSO->heap, then a nested function call writing more into "
"the same stream' pattern works correctly in total isolation - the real crash's bug "
"is NOT reproduced by this shape alone, so something ELSE about the real game's "
"broader state/heap layout at that moment is implicated instead.",
reportedLen, retVal);
} else {
__android_log_print(ANDROID_LOG_INFO, "OSTREAM_REPRO",
"RunOstreamAssemblyNestedReproTest: FAIL - len=%d (expected %zu) retVal=%u "
"content=\"%s\" - THIS ISOLATED SHAPE ALONE reproduces a problem, independent of "
"the real game's own state - strong evidence the bug is in GuestEngine's general "
"handling of this exact call pattern, not something libapp.so-specific.",
reportedLen, expectedLen, retVal, content);
}
}
@@ -0,0 +1,38 @@
#pragma once
class GuestEngine;
// 2026-09-16, ARM64_TRANSLATION_LAYER.md - "isolated std::ostringstream
// repro" test. Loads a SEPARATE, tiny, independently-compiled armeabi-v7a
// artifact (../../../ostream_repro/ostream_repro.cpp, built via
// ostream_repro/build.sh, pushed to the device ahead of time - NOT part of
// this Gradle build, see that file's own comments) via
// GuestEngine::LoadSecondaryImage, then calls its one exported entry point
// (TestOstreamAssembly) and logs whether the write-then-extract
// std::ostringstream pattern this artifact exercises comes back correct or
// empty - the same symptom this session spent most of 2026-09-16 tracing
// through the real game's own sub_4702D8/sub_27160C, but here in total
// isolation from every other line of game code. Per the user's own explicit
// direction ("ты сейчас пытаешься подогнать эмуляцию к одному единственному
// бинарнику, это не правильный подход"), this is what actually answers
// "is this a general bug in GuestEngine, or something specific to
// libapp.so's own state" - continuing to probe more hardcoded libapp.so
// addresses could not have answered that question no matter how far it
// went.
//
// Logs its own PASS/FAIL verdict unambiguously (tag "OSTREAM_REPRO") -
// see ostream_repro_test.cpp's own top comment for exactly what counts as
// each. No-ops (logs and returns) if the secondary image fails to load -
// e.g. the artifact was never pushed to this device - so this is safe to
// leave wired into LoadEmulatedLibapp without risking the real game's own
// boot sequence if the file is simply missing.
void RunOstreamAssemblyReproTest(GuestEngine& engine);
// 2026-09-17, ARM64_TRANSLATION_LAYER.md - the 0x3d3d3d3d heap-overflow
// chase. Same idea as above, against ostream_repro.cpp's
// TestOstreamAssemblyNested export instead - a long first write forcing an
// immediate SSO->heap transition, then a real (noinline) function-call
// boundary writing more into the same shared stream, matching
// sub_46FD58->sub_4711C8's own shape. See ostream_repro_test.cpp's own
// comment for the full rationale.
void RunOstreamAssemblyNestedReproTest(GuestEngine& engine);
+67
View File
@@ -0,0 +1,67 @@
#include "profiler.h"
#include "../util/util.h"
#include <atomic>
#include <mutex>
#include <unordered_map>
#include <vector>
#include <algorithm>
#include <chrono>
#include <thread>
namespace {
std::atomic<bool> g_profilingEnabled{false};
std::mutex g_histMutex;
std::unordered_map<uint64_t, uint64_t> g_hist;
std::atomic<uint64_t> g_totalSamples{0};
std::atomic<bool> g_dumpThreadStarted{false};
} // namespace
void EnableProfiling() { g_profilingEnabled.store(true, std::memory_order_relaxed); }
bool ProfilingEnabled() { return g_profilingEnabled.load(std::memory_order_relaxed); }
void ProfileBlockHookCb(uc_engine*, uint64_t address, uint32_t, void*) {
// thread_local, not a shared "last sample" - each real host thread has
// its own uc_engine (see guest_engine.h's "Multithreading model") and
// fires this independently; gating per-thread avoids one busy thread's
// sampling starving another's.
static thread_local std::chrono::steady_clock::time_point lastSample{};
auto now = std::chrono::steady_clock::now();
if (now - lastSample < std::chrono::milliseconds(2)) return;
lastSample = now;
std::lock_guard<std::mutex> lock(g_histMutex);
g_hist[address]++;
g_totalSamples.fetch_add(1, std::memory_order_relaxed);
}
void StartProfileDumpThread() {
bool expected = false;
if (!g_dumpThreadStarted.compare_exchange_strong(expected, true)) return; // already running
std::thread([]() {
while (true) {
std::this_thread::sleep_for(std::chrono::seconds(3));
std::vector<std::pair<uint64_t, uint64_t>> top;
{
std::lock_guard<std::mutex> lock(g_histMutex);
top.assign(g_hist.begin(), g_hist.end());
}
if (top.empty()) continue;
std::sort(top.begin(), top.end(),
[](const auto& a, const auto& b) { return a.second > b.second; });
uint64_t total = g_totalSamples.load(std::memory_order_relaxed);
Log("PROFILE: %llu total samples across %zu distinct block addresses - top hot PCs:",
(unsigned long long)total, top.size());
for (size_t i = 0; i < top.size() && i < 15; i++) {
Log("PROFILE: 0x%llx - %llu samples (%.1f%%)",
(unsigned long long)top[i].first, (unsigned long long)top[i].second,
total ? 100.0 * (double)top[i].second / (double)total : 0.0);
}
}
}).detach();
}
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <unicorn/unicorn.h>
// Throwaway sampling profiler for the "why did onCreate take 115 seconds"
// investigation (see ARM64_TRANSLATION_LAYER.md, 2026-09-01 entry) - NOT a
// permanent part of the architecture, just the cheapest way to answer "where
// does the guest CPU actually spend its time" without a real disassembler-
// aware profiler. No-op unless EnableProfiling() is called (see main.cpp).
//
// Mechanism: a UC_HOOK_BLOCK callback (fires once per translated basic
// block, not per instruction - much cheaper) installed on every guest
// engine (see GuestEngine::EnsureThreadEngine) across the loaded image's own
// code range. Time-gated per-thread sampling (skips most block hits, only
// records roughly every 2ms of wall-clock time) keeps overhead from
// dominating the very thing being measured. A background thread dumps the
// hottest sampled PCs to logcat every few seconds - cross-reference those
// addresses against the IDA database (native_lib/libapp.so.i64, guest
// addresses ARE real file vaddrs - see guest_engine.h's own class comment on
// why bias is always 0) to find which real functions are hot.
void EnableProfiling();
bool ProfilingEnabled();
// UC_HOOK_BLOCK callback - pass directly to uc_hook_add.
void ProfileBlockHookCb(uc_engine* uc, uint64_t address, uint32_t size, void* user_data);
// Starts (once) a detached background thread that logs the top hottest
// sampled PCs every 3 seconds until the process exits.
void StartProfileDumpThread();
+312
View File
@@ -0,0 +1,312 @@
#include "pthread_shim.h"
#include "../util/util.h"
#include <thread>
#include <mutex>
#include <condition_variable>
#include <chrono>
#include <unordered_map>
#include <cstring>
#include <ctime>
namespace {
// ---- Guest pthread_t handle table ----
// Real bionic pthread_t values are host-thread-implementation-specific and
// never exposed to guest code - guest code only ever receives an opaque
// uint32_t handle from Shim_pthread_create and only ever passes it back into
// Shim_pthread_join, so this encoding is a private contract of this file,
// not a real bionic ABI.
struct GuestThreadRecord {
std::thread thread;
};
std::mutex g_threadsMutex;
std::unordered_map<uint32_t, GuestThreadRecord*> g_threads;
uint32_t g_nextThreadHandle = 1;
uint32_t Shim_pthread_create(GuestEngine& eng, uint32_t threadOutPtr, uint32_t /*attr*/,
uint32_t startRoutine, uint32_t arg, uint32_t) {
if (!startRoutine) {
Log("pthread_shim: pthread_create with null start_routine");
return -1;
}
// Diagnostic: log the REAL caller address (LR at the point this stub
// was entered - register state is still exactly as the hook fired with,
// since import_stub_dispatch_cb hasn't written anything back yet at
// this point) so a suspiciously-fast-returning thread's actual call
// site can be found in IDA, and whether startRoutine itself is real
// libapp.so code vs one of our own stub addresses (anything >=
// GuestEngine's own image_end() is a stub, not real code - see
// guest_engine.h's arena layout).
{
uint32_t lr = 0;
uc_reg_read(eng.uc(), UC_ARM_REG_LR, &lr);
Log("pthread_shim: pthread_create called from guest LR=0x%x, startRoutine=0x%x (image_end=0x%x)",
lr, startRoutine, eng.image_end());
}
// startRoutine must point into the real loaded image - anything else is
// not ARM32 code at all, it's one of this engine's own arena addresses
// (heap/trampoline/import-stub/misc-stub/control/thread-stacks all live
// past image_end()). Spawning a host thread that calls
// CallGuestFunction() on such an address doesn't fail cleanly - if it
// happens to land on one of this engine's own AllocCodeStub dispatch
// points, it invokes some unrelated real shim with whatever garbage sits
// in r1-r3 for a freshly-created, never-primed uc_engine, which was
// previously observed to cascade into a burst of unrelated shim calls
// with corrupted arguments and an eventual MEM FAULT at image_end_
// itself (see ARM64_TRANSLATION_LAYER.md, "register/stack corruption"
// investigation). Reject loudly and immediately instead - one clear
// diagnostic beats six confusing downstream ones.
//
// Corrected 2026-09-21 (task #67): this used to compare against
// image_end(), which was right only while exactly ONE image existed. Once
// the game's own libfmodex/libfmodevent load as sibling images - far above
// image_end() - that test rejected FMOD's OWN mixer thread as "not real
// image code". The refusal propagated all the way up as silence:
//
// pthread_create -> EINVAL -> sub_A9120 -> 33 -> System::init -> 33
// -> EventSystem::init -> 33 -> fmodGetInfo -> -1
// -> FMODAudioDevice never builds its AudioTrack
//
// IsGuestImageCode knows about every loaded image, so the guard keeps its
// original purpose without the single-image assumption.
if (!eng.IsGuestImageCode(startRoutine)) {
Log("pthread_shim: REFUSING pthread_create - startRoutine=0x%x is not inside any loaded "
"guest image (%s)", startRoutine, eng.DescribeAddress(startRoutine).c_str());
return 22; // EINVAL - matches pthread_create's own errno-style failure contract
}
uint32_t handle;
{
std::lock_guard<std::mutex> lock(g_threadsMutex);
handle = g_nextThreadHandle++;
}
auto* record = new GuestThreadRecord();
// `eng` is always GuestEngine::Instance() (a static singleton, see
// ImportShimFn's contract) - safe to capture by reference into a thread
// that outlives this call.
record->thread = std::thread([&eng, startRoutine, arg, handle]() {
// Every new host thread needs its OWN uc_engine before it can touch
// any guest register - see guest_engine.h's "Multithreading model".
eng.EnsureThreadEngine();
uint32_t result = eng.CallGuestFunction(startRoutine, arg);
Log("pthread_shim: guest thread (handle %u) start_routine returned 0x%x", handle, result);
// Hand this thread's guest stack(s) and uc_engine back. Without this
// the thread-stack arena was one-way: the game creates threads across
// a session (one per race, among others) and after kMaxGuestThreads of
// them CarveThreadStack started returning 0, the next thread's start
// routine never ran, and the race came up as a black screen.
eng.ReleaseThreadEngine();
});
{
std::lock_guard<std::mutex> lock(g_threadsMutex);
g_threads[handle] = record;
}
if (threadOutPtr) memcpy(eng.G2H(threadOutPtr), &handle, sizeof(handle));
Log("pthread_shim: pthread_create -> guest handle %u, start_routine=0x%x, arg=0x%x",
handle, startRoutine, arg);
return 0;
}
uint32_t Shim_pthread_join(GuestEngine& eng, uint32_t handle, uint32_t retvalOutPtr, uint32_t, uint32_t, uint32_t) {
GuestThreadRecord* record = nullptr;
{
std::lock_guard<std::mutex> lock(g_threadsMutex);
auto it = g_threads.find(handle);
if (it != g_threads.end()) record = it->second;
}
if (!record) {
Log("pthread_shim: pthread_join(%u) - unknown handle", handle);
return 3; // ESRCH
}
if (record->thread.joinable()) record->thread.join();
// The guest start_routine's real void* return value isn't propagated
// here (see pthread_shim.h's own gap list) - writing 0 is the closest
// correct-shaped stand-in for callers that check *retval for NULL.
if (retvalOutPtr) {
uint32_t zero = 0;
memcpy(eng.G2H(retvalOutPtr), &zero, sizeof(zero));
}
{
std::lock_guard<std::mutex> lock(g_threadsMutex);
g_threads.erase(handle);
}
delete record;
return 0;
}
// ---- Mutexes ----
// Keyed by the GUEST ADDRESS of the pthread_mutex_t object (stable for its
// lifetime - always a field of some other guest struct or a guest global,
// never moved). std::recursive_mutex (not std::mutex) regardless of the
// real attr type requested - see pthread_shim.h's gap list for why.
std::mutex g_mutexTableMutex;
std::unordered_map<uint32_t, std::recursive_mutex*> g_mutexes;
std::recursive_mutex* GetOrCreateMutex(uint32_t guestAddr) {
std::lock_guard<std::mutex> lock(g_mutexTableMutex);
auto it = g_mutexes.find(guestAddr);
if (it != g_mutexes.end()) return it->second;
auto* m = new std::recursive_mutex();
g_mutexes[guestAddr] = m;
return m;
}
uint32_t Shim_pthread_mutex_init(GuestEngine&, uint32_t mutexPtr, uint32_t /*attr*/, uint32_t, uint32_t, uint32_t) {
if (mutexPtr) GetOrCreateMutex(mutexPtr);
return 0;
}
uint32_t Shim_pthread_mutex_lock(GuestEngine&, uint32_t mutexPtr, uint32_t, uint32_t, uint32_t, uint32_t) {
if (!mutexPtr) return 22; // EINVAL
GetOrCreateMutex(mutexPtr)->lock();
return 0;
}
uint32_t Shim_pthread_mutex_unlock(GuestEngine&, uint32_t mutexPtr, uint32_t, uint32_t, uint32_t, uint32_t) {
if (!mutexPtr) return 22; // EINVAL
GetOrCreateMutex(mutexPtr)->unlock();
return 0;
}
uint32_t Shim_pthread_mutex_trylock(GuestEngine&, uint32_t mutexPtr, uint32_t, uint32_t, uint32_t, uint32_t) {
if (!mutexPtr) return 22; // EINVAL
return GetOrCreateMutex(mutexPtr)->try_lock() ? 0 : 16; // EBUSY
}
uint32_t Shim_pthread_mutex_destroy(GuestEngine&, uint32_t mutexPtr, uint32_t, uint32_t, uint32_t, uint32_t) {
std::lock_guard<std::mutex> lock(g_mutexTableMutex);
auto it = g_mutexes.find(mutexPtr);
if (it != g_mutexes.end()) {
delete it->second;
g_mutexes.erase(it);
}
return 0;
}
// ---- Condition variables ----
// std::condition_variable_any (not std::condition_variable) specifically
// because it's the variant that works with std::recursive_mutex - a plain
// std::condition_variable only accepts std::unique_lock<std::mutex>.
std::mutex g_condTableMutex;
std::unordered_map<uint32_t, std::condition_variable_any*> g_conds;
std::condition_variable_any* GetOrCreateCond(uint32_t guestAddr) {
std::lock_guard<std::mutex> lock(g_condTableMutex);
auto it = g_conds.find(guestAddr);
if (it != g_conds.end()) return it->second;
auto* cv = new std::condition_variable_any();
g_conds[guestAddr] = cv;
return cv;
}
uint32_t Shim_pthread_cond_init(GuestEngine&, uint32_t condPtr, uint32_t, uint32_t, uint32_t, uint32_t) {
if (condPtr) GetOrCreateCond(condPtr);
return 0;
}
uint32_t Shim_pthread_cond_destroy(GuestEngine&, uint32_t condPtr, uint32_t, uint32_t, uint32_t, uint32_t) {
std::lock_guard<std::mutex> lock(g_condTableMutex);
auto it = g_conds.find(condPtr);
if (it != g_conds.end()) {
delete it->second;
g_conds.erase(it);
}
return 0;
}
// TEMP diagnostic for the render-stall investigation (see
// ARM64_TRANSLATION_LAYER.md's "pthread_cond_wait deadlock" plan) - logs the
// real guest caller (LR, same technique Shim_pthread_create already uses)
// and the condvar's guest address, so a hang can be traced to the exact
// calling function in IDA instead of guessed at.
uint32_t GuestCallerLR(GuestEngine& eng) {
uint32_t lr = 0;
uc_reg_read(eng.uc(), UC_ARM_REG_LR, &lr);
return lr;
}
// Diagnostic tracing for the condvar shims (task #7's deadlock hunt). OFF by
// default since 2026-09-19: signal alone fired 20,085 times in one
// prologue-load capture, and every one of these lines is a blocking write to
// logd on a path the engine takes constantly. Flip to true for a targeted
// deadlock capture, not for normal runs.
constexpr bool kTraceCondVars = false;
uint32_t Shim_pthread_cond_signal(GuestEngine& eng, uint32_t condPtr, uint32_t, uint32_t, uint32_t, uint32_t) {
if (kTraceCondVars)
Log("pthread_shim: pthread_cond_signal(cond=0x%x) from guest LR=0x%x", condPtr, GuestCallerLR(eng));
if (condPtr) GetOrCreateCond(condPtr)->notify_one();
return 0;
}
uint32_t Shim_pthread_cond_broadcast(GuestEngine& eng, uint32_t condPtr, uint32_t, uint32_t, uint32_t, uint32_t) {
Log("pthread_shim: pthread_cond_broadcast(cond=0x%x) from guest LR=0x%x", condPtr, GuestCallerLR(eng));
if (condPtr) GetOrCreateCond(condPtr)->notify_all();
return 0;
}
// Real pthread_cond_wait semantics: mutexPtr is ALREADY locked (by this same
// guest thread) on entry, must be atomically unlocked while waiting, and
// re-locked before returning. std::condition_variable_any::wait(lock) wants
// to manage a Lockable itself, so this adopts the ALREADY-held lock
// (adopt_lock - no double-lock attempt), waits (which correctly unlocks/
// relocks around the real OS wait), then release()s the unique_lock's
// ownership WITHOUT unlocking, since the real mutex must stay locked for the
// caller on return - exactly matching real pthread_cond_wait's contract.
uint32_t Shim_pthread_cond_wait(GuestEngine& eng, uint32_t condPtr, uint32_t mutexPtr, uint32_t, uint32_t, uint32_t) {
if (!condPtr || !mutexPtr) return 22; // EINVAL
Log("pthread_shim: pthread_cond_wait(cond=0x%x, mutex=0x%x) from guest LR=0x%x - blocking now",
condPtr, mutexPtr, GuestCallerLR(eng));
auto* cv = GetOrCreateCond(condPtr);
auto* mtx = GetOrCreateMutex(mutexPtr);
std::unique_lock<std::recursive_mutex> lock(*mtx, std::adopt_lock);
cv->wait(lock);
lock.release();
Log("pthread_shim: pthread_cond_wait(cond=0x%x) woke up", condPtr);
return 0;
}
uint32_t Shim_pthread_cond_timedwait(GuestEngine& eng, uint32_t condPtr, uint32_t mutexPtr,
uint32_t abstimePtr, uint32_t, uint32_t) {
if (!condPtr || !mutexPtr) return 22; // EINVAL
auto* cv = GetOrCreateCond(condPtr);
auto* mtx = GetOrCreateMutex(mutexPtr);
std::unique_lock<std::recursive_mutex> lock(*mtx, std::adopt_lock);
std::cv_status status = std::cv_status::no_timeout;
if (abstimePtr) {
// Guest struct timespec { long tv_sec; long tv_nsec; } - both
// 32-bit fields on this ABI, 8 bytes total, same layout host-side.
uint32_t sec = 0, nsec = 0;
memcpy(&sec, eng.G2H(abstimePtr), 4);
memcpy(&nsec, eng.G2H(abstimePtr + 4), 4);
// abstime is CLOCK_REALTIME-based unless the guest called
// pthread_condattr_setclock first (not observed at any call site) -
// std::chrono::system_clock is the closest host equivalent.
auto deadline = std::chrono::system_clock::from_time_t((time_t)sec) +
std::chrono::nanoseconds(nsec);
status = cv->wait_until(lock, deadline);
} else {
cv->wait(lock);
}
lock.release();
return status == std::cv_status::timeout ? 110 : 0; // ETIMEDOUT
}
} // namespace
void RegisterPthreadImportShims(GuestEngine& engine) {
engine.RegisterImportShim("pthread_create", Shim_pthread_create);
engine.RegisterImportShim("pthread_join", Shim_pthread_join);
engine.RegisterImportShim("pthread_mutex_init", Shim_pthread_mutex_init);
engine.RegisterImportShim("pthread_mutex_lock", Shim_pthread_mutex_lock);
engine.RegisterImportShim("pthread_mutex_unlock", Shim_pthread_mutex_unlock);
engine.RegisterImportShim("pthread_mutex_trylock", Shim_pthread_mutex_trylock);
engine.RegisterImportShim("pthread_mutex_destroy", Shim_pthread_mutex_destroy);
engine.RegisterImportShim("pthread_cond_init", Shim_pthread_cond_init);
engine.RegisterImportShim("pthread_cond_destroy", Shim_pthread_cond_destroy);
engine.RegisterImportShim("pthread_cond_signal", Shim_pthread_cond_signal);
engine.RegisterImportShim("pthread_cond_broadcast", Shim_pthread_cond_broadcast);
engine.RegisterImportShim("pthread_cond_wait", Shim_pthread_cond_wait);
engine.RegisterImportShim("pthread_cond_timedwait", Shim_pthread_cond_timedwait);
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "guest_engine.h"
// Real pthread threading support - replaces the old single-threaded no-op
// fakes (see import_shims.cpp's own top comment for what those used to be).
// pthread_create spawns a genuine host std::thread that calls
// GuestEngine::EnsureThreadEngine() (its own uc_engine sharing the same
// guest memory - see guest_engine.h's "Multithreading model") before running
// the guest start_routine; pthread_mutex_t/pthread_cond_t are backed by real
// std::recursive_mutex/std::condition_variable_any objects, keyed by the
// GUEST ADDRESS of the pthread_mutex_t/pthread_cond_t object itself (stable
// for the object's lifetime - these are always fields of some other guest
// struct or globals, never moved).
//
// Known gaps (documented, not silently wrong):
// - pthread_attr_t is entirely ignored (detached-vs-joinable, stack size,
// scheduling priority) - every guest thread is created host-joinable
// regardless of what the guest requested; pthread_join is the only exit
// path this shim supports (a guest thread created "detached" that's never
// joined will leak its host std::thread object, same as an ordinary
// pthread_join call the caller forgets to make - acceptable for a
// prototype scope).
// - pthread_exit() is NOT implemented (falls through to the generic
// "unresolved import, return 0" handler) - a guest thread that calls it
// explicitly (rather than just returning from its start_routine, the
// common case) will incorrectly resume as if it were an ordinary no-op
// call rather than unwinding. Fixing this properly needs
// import_stub_dispatch_cb itself (guest_engine.cpp) to let a shim
// override the unconditional "write PC=lr" return path, which no other
// shim currently needs - deferred.
// - std::recursive_mutex (not std::mutex) backs every guest mutex
// regardless of the real attr type requested, specifically so a
// PTHREAD_MUTEX_RECURSIVE guest mutex (common in game engines) can't
// self-deadlock against a plain std::mutex that doesn't support it - a
// deliberately safe default, not a precise recursive/non-recursive
// distinction.
void RegisterPthreadImportShims(GuestEngine& engine);
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include "guest_engine.h"
// Real RTTI (typeid/dynamic_cast) and minimal C++ exception-class support
// for the imports libc++abi/libc++ would normally provide (not statically
// linked into libapp.so - these came up UNDEFINED same as every other gap
// this session has been closing, see ARM64_TRANSLATION_LAYER.md's "go
// through all the imports" entry).
//
// Two distinct pieces, because these symbols are a mix of DATA and CODE:
//
// SetupRttiDataSymbols(engine) - a GuestEngine::DataSymbolSetupFn (register
// via engine.RegisterDataSymbolSetup BEFORE engine.LoadImage) - builds real,
// permanently-allocated (see GuestEngine::AllocPermanent) type_info objects
// for the 5 primitive types actually referenced (_ZTIa/_ZTIf/_ZTIi/_ZTIs/
// _ZTIt - char/float/int/short/unsigned short) plus small "vtable identity"
// marker blobs for the 5 __cxxabiv1 abstract RTTI base classes
// (__class_type_info/__si_class_type_info/__vmi_class_type_info/
// __pointer_type_info/__function_type_info). These markers are NOT real
// vtables with real function pointers - RegisterRttiImportShims's own
// __dynamic_cast implementation recognizes them by ADDRESS (matching how
// the Itanium ABI's __dynamic_cast already distinguishes a type_info's
// concrete kind by comparing its vtable pointer against known base-class
// vtable addresses, not by making a virtual call) and interprets the
// following fields directly, so no real virtual dispatch through these
// markers is ever needed. Getting these resolved as real DATA (not a
// callable code stub - see GuestEngine::RegisterDataSymbol's own comment)
// depended on this session's ELF-relocation-addend fix (guest_engine.cpp's
// ProcessRelocations) - a type_info object's own stored vtable-pointer
// field is laid out at compile time as "vtable_symbol + 2*sizeof(void*)"
// (the Itanium ABI's offset-to-top/rtti-slot skip), which is exactly the
// non-zero implicit addend that fix started honoring.
//
// RegisterRttiImportShims(engine) - the callable pieces: a real
// __dynamic_cast (walks the type_info hierarchy directly, not through
// virtual dispatch - handles the common single/no-inheritance and
// multiple-non-virtual-base cases; true virtual-inheritance diamonds are a
// documented gap, not silently wrong), __cxa_bad_typeid (can't really
// throw - see its own comment), minimal std::exception/logic_error/
// runtime_error/bad_alloc constructor/destructor/what() (a simplified but
// internally-consistent object layout - not byte-identical to real
// libc++'s __libcpp_refstring-backed one, which is fine since libc++
// itself was never statically linked here to compare against - only code
// THIS file also wrote ever reads these bytes), std::uncaught_exception
// (always false - no real exception is ever "in flight", see the
// dedicated comment on why real throw/catch unwinding isn't attempted),
// and a partial (refcount-only, no deleter-callback) __shared_weak_count
// (std::shared_ptr's internal control block).
//
// Also covers the small "misc libc++ runtime" surface that came up
// alongside RTTI in the same undefined-symbol sweep: std::cerr (a
// generously-oversized inert data blob - real formatted-output support
// would need libc++'s actual ostream/streambuf machinery, not attempted),
// ios_base/locale's init/clear/destructor/getloc (safe no-ops/trivial
// objects), the ctype<char>/num_get/num_put facet `id` statics (inert data
// - nothing ever successfully resolves a facet lookup through them, see
// use_facet's own comment for why), and libc++'s own internal std::mutex
// (real - backed by the same real-mutex-table pattern pthread_shim.cpp
// already uses for guest pthread_mutex_t, keyed by the object's own guest
// address).
void SetupRttiDataSymbols(GuestEngine& engine);
void RegisterRttiImportShims(GuestEngine& engine);
+511
View File
@@ -0,0 +1,511 @@
#include "tcg_bench.h"
#include "guest_engine.h"
#include <unicorn/unicorn.h>
#include <android/log.h>
#include <chrono>
#include <cstdint>
#include <cstring>
#include <vector>
namespace {
// ---- Minimal Thumb/Thumb-2 hand-assembler, just enough for the synthetic
// engine tests below (2026-09-16, ARM64_TRANSLATION_LAYER.md). Not a real
// assembler - just the handful of encodings these tests need, each with the
// bit-layout spelled out so it can be checked against the ARM ARM directly
// rather than trusted blindly.
void Emit16(std::vector<uint8_t>& out, uint16_t hw) {
out.push_back((uint8_t)(hw & 0xFF));
out.push_back((uint8_t)(hw >> 8));
}
// PUSH {r4-r7, lr} - Thumb-16 "B5F0": 1011 010 L rrrrrrrr, L=1 (include LR),
// register_list bits0-7 = r0..r7 presence (r4,r5,r6,r7 set = 0xF0).
void EmitPushR4toR7Lr(std::vector<uint8_t>& out) {
Emit16(out, 0xB5F0);
}
// POP {r4-r7, pc} - Thumb-16 "BDF0": 1011 110 P rrrrrrrr, P=1 (include PC).
void EmitPopR4toR7Pc(std::vector<uint8_t>& out) {
Emit16(out, 0xBDF0);
}
// MOVS Rd, #imm8 (Thumb-16, low registers r0-r7 only): 00100 ddd iiiiiiii.
void EmitMovsImm8(std::vector<uint8_t>& out, uint8_t rd, uint8_t imm8) {
uint16_t hw = (uint16_t)(0x2000 | ((rd & 0x7) << 8) | imm8);
Emit16(out, hw);
}
// STR Rt, [Rn, #imm5*4] (Thumb-16, word, low registers): 01100 iiiii nnn ttt.
void EmitStrImm5(std::vector<uint8_t>& out, uint8_t rt, uint8_t rn, uint8_t imm5) {
uint16_t hw = (uint16_t)(0x6000 | ((imm5 & 0x1F) << 6) | ((rn & 0x7) << 3) | (rt & 0x7));
Emit16(out, hw);
}
// BLX Rm (Thumb-16): 010001111 mmmm 000.
void EmitBlxReg(std::vector<uint8_t>& out, uint8_t rm) {
uint16_t hw = (uint16_t)(0x4780 | ((rm & 0xF) << 3));
Emit16(out, hw);
}
// MOVW/MOVT Rd, #imm16 (Thumb-2, 32-bit, T3 encoding). First halfword:
// 11110 i 10 op00 imm4 (op=0 MOVW/1 MOVT via bit5 of the "10 op00" group -
// concretely opcode field bits[9:4] = 100100 for MOVW, 101100 for MOVT).
// Second halfword: 0 imm3 Rd(4) imm8.
void EmitMovWT(std::vector<uint8_t>& out, bool isMovt, uint8_t rd, uint16_t imm16) {
uint16_t imm4 = (imm16 >> 12) & 0xF;
uint16_t i = (imm16 >> 11) & 0x1;
uint16_t imm3 = (imm16 >> 8) & 0x7;
uint16_t imm8 = imm16 & 0xFF;
uint16_t hw1 = (uint16_t)(0xF000 | (i << 10) | (isMovt ? 0x02C0 : 0x0240) | imm4);
uint16_t hw2 = (uint16_t)((imm3 << 12) | ((rd & 0xF) << 8) | imm8);
Emit16(out, hw1);
Emit16(out, hw2);
}
// Loads a full 32-bit guest address into Rd via MOVW (low 16) + MOVT (high 16).
void EmitLoadAddr32(std::vector<uint8_t>& out, uint8_t rd, uint32_t addr) {
EmitMovWT(out, /*isMovt=*/false, rd, (uint16_t)(addr & 0xFFFF));
EmitMovWT(out, /*isMovt=*/true, rd, (uint16_t)(addr >> 16));
}
// BX LR (Thumb-16): 010001110 mmmm 000, Rm=LR(1110).
void EmitBxLr(std::vector<uint8_t>& out) {
Emit16(out, 0x4770);
}
// LDR Rt, [SP, #imm8*4] (Thumb-16, SP-relative load): 10011 ttt iiiiiiii.
// Reads a stack slot directly without needing SP loaded into a general
// register first - exactly what a function reading its own stack-passed
// arguments (AAPCS32, args beyond the first 4) does.
void EmitLdrSpImm8(std::vector<uint8_t>& out, uint8_t rt, uint8_t imm8) {
uint16_t hw = (uint16_t)(0x9800 | ((rt & 0x7) << 8) | imm8);
Emit16(out, hw);
}
// No-op stub callback - deliberately does NOTHING to guest registers beyond
// what AllocCodeStub's own dispatch (MiscStubDispatch/whatever fires this)
// does on its own, matching a REAL import shim as closely as possible
// (RegisterImportShim's own real-shim path reads r0-r3/sp and writes r0 -
// see import_stub_dispatch_cb in guest_engine.cpp - this callback is
// exactly that same shape, just with trivial body).
uint32_t g_calleeSavedTestNoopHits = 0;
void CalleeSavedTestNoopCb(uc_engine*, uint64_t, uint32_t, void*) {
g_calleeSavedTestNoopHits++;
}
// Reentrant-test stub callback - userData carries the "inner" guest
// function's address (Thumb-tagged). Calling GuestEngine::CallGuestFunction
// from WITHIN a UC_HOOK_CODE callback exercises the exact same "shim needs
// to call back into guest code" shape as a real JNI upcall, deliberately
// nested one level inside the outer test call this file already builds.
uint32_t g_reentrantTestStubHits = 0;
void ReentrantTestStubCb(uc_engine*, uint64_t, uint32_t, void* userData) {
g_reentrantTestStubHits++;
GuestAddr innerAddr = (GuestAddr)(uintptr_t)userData;
GuestEngine::Instance().CallGuestFunction(innerAddr);
}
// Real bytes of sub_4EDAD4 (0x4edad4, 56 bytes), copied verbatim from
// native_lib/libapp.so via IDA - the exact FNV-1a hash-update loop this
// session root-caused and shimmed (guest_engine.cpp's FnvHashAccelHookCb).
// ARM-mode (not Thumb) machine code:
// push {r11, lr}
// mov r11, sp
// cmp r2, #1
// blt <exit>
// loop:
// ldrb r3, [r1], #1 ; r4 = *bytes++ (actual reg numbers per IDA)
// ...multiply by 16777619, xor, store...
// subs r2, r2, #1
// bne loop
// exit:
// pop {r11, pc}
const uint8_t kFnvCode[] = {
0x00, 0x48, 0x2d, 0xe9, 0x0d, 0xb0, 0xa0, 0xe1, 0x01, 0x00, 0x52, 0xe3, 0x00, 0x88, 0xbd, 0xb8,
0x00, 0x30, 0x90, 0xe5, 0x93, 0xc1, 0x00, 0xe3, 0x00, 0xc1, 0x40, 0xe3, 0x93, 0x0c, 0x0e, 0xe0,
0x01, 0x30, 0xd1, 0xe4, 0x01, 0x20, 0x52, 0xe2, 0x03, 0x30, 0x2e, 0xe0, 0x00, 0x30, 0x80, 0xe5,
0xf9, 0xff, 0xff, 0x1a, 0x00, 0x88, 0xbd, 0xe8,
};
constexpr uint64_t kCodeAddr = 0x1000;
constexpr uint64_t kCodeSize = 0x1000;
constexpr uint64_t kResultAddr = 0x2000;
constexpr uint64_t kResultSize = 0x1000;
constexpr uint64_t kStackAddr = 0x9000;
constexpr uint64_t kStackSize = 0x1000;
constexpr uint64_t kDataAddr = 0x10000000;
constexpr uint64_t kDataSize = 16u * 1024 * 1024; // 16 MiB - representative of a real .sb bundle section
constexpr uint64_t kSentinelReturn = 0xfffffff0u; // never mapped - uc_emu_start's `until` stops here cleanly
} // namespace
void RunTcgBenchmark() {
uc_engine* uc = nullptr;
uc_err err = uc_open(UC_ARCH_ARM, UC_MODE_ARM, &uc);
if (err != UC_ERR_OK) {
__android_log_print(ANDROID_LOG_INFO, "TCG_BENCH", "uc_open failed: %d", (int)err);
return;
}
uc_mem_map(uc, kCodeAddr, kCodeSize, UC_PROT_ALL);
uc_mem_map(uc, kResultAddr, kResultSize, UC_PROT_ALL);
uc_mem_map(uc, kStackAddr, kStackSize, UC_PROT_ALL);
uc_mem_map(uc, kDataAddr, kDataSize, UC_PROT_ALL);
uc_mem_write(uc, kCodeAddr, kFnvCode, sizeof(kFnvCode));
std::vector<uint8_t> dummy(kDataSize, 0x5a);
uc_mem_write(uc, kDataAddr, dummy.data(), dummy.size());
uint32_t hashState = 2166136261u; // FNV-1a offset basis
uc_mem_write(uc, kResultAddr, &hashState, sizeof(hashState));
uint32_t r0 = (uint32_t)kResultAddr;
uint32_t r1 = (uint32_t)kDataAddr;
uint32_t r2 = (uint32_t)kDataSize;
uint32_t sp = (uint32_t)(kStackAddr + kStackSize - 0x100);
uint32_t lr = kSentinelReturn;
uc_reg_write(uc, UC_ARM_REG_R0, &r0);
uc_reg_write(uc, UC_ARM_REG_R1, &r1);
uc_reg_write(uc, UC_ARM_REG_R2, &r2);
uc_reg_write(uc, UC_ARM_REG_SP, &sp);
uc_reg_write(uc, UC_ARM_REG_LR, &lr);
auto t0 = std::chrono::steady_clock::now();
err = uc_emu_start(uc, kCodeAddr, kSentinelReturn, 0, 0);
auto t1 = std::chrono::steady_clock::now();
double seconds = std::chrono::duration<double>(t1 - t0).count();
double bytesPerSec = seconds > 0 ? (double)kDataSize / seconds : 0.0;
double itersPerSec = seconds > 0 ? (double)kDataSize / seconds : 0.0;
__android_log_print(ANDROID_LOG_INFO, "TCG_BENCH",
"uc_emu_start rc=%d, %llu bytes in %.4fs = %.0f bytes/sec (%.0f iterations/sec) - "
"bare Unicorn/TCG, zero shims/hooks/game code involved",
(int)err, (unsigned long long)kDataSize, seconds, bytesPerSec, itersPerSec);
uc_close(uc);
}
void RunTcgBenchmarkInRealContext(GuestEngine& engine) {
// Keep this modest relative to the real 64 MiB guest heap (kHeapSize,
// guest_engine.cpp) - this runs after the real image and JNI_OnLoad
// have already claimed some of it, and this is a measurement, not a
// stress test.
constexpr uint32_t kBufSize = 4u * 1024 * 1024; // 4 MiB
GuestAddr codeAddr = engine.AllocPermanent((uint32_t)sizeof(kFnvCode));
GuestAddr resultAddr = engine.AllocPermanent(4);
GuestAddr dataAddr = engine.heap().Alloc(kBufSize);
if (!codeAddr || !resultAddr || !dataAddr) {
__android_log_print(ANDROID_LOG_INFO, "TCG_BENCH",
"RunTcgBenchmarkInRealContext: allocation failed (code=0x%x result=0x%x data=0x%x)",
codeAddr, resultAddr, dataAddr);
return;
}
memcpy(engine.G2H(codeAddr), kFnvCode, sizeof(kFnvCode));
memset(engine.G2H(dataAddr), 0x5a, kBufSize);
uint32_t hashState = 2166136261u;
memcpy(engine.G2H(resultAddr), &hashState, sizeof(hashState));
auto t0 = std::chrono::steady_clock::now();
engine.CallGuestFunction(codeAddr, resultAddr, dataAddr, kBufSize, 0);
auto t1 = std::chrono::steady_clock::now();
double seconds = std::chrono::duration<double>(t1 - t0).count();
double bytesPerSec = seconds > 0 ? (double)kBufSize / seconds : 0.0;
__android_log_print(ANDROID_LOG_INFO, "TCG_BENCH",
"RunTcgBenchmarkInRealContext: %u bytes in %.4fs = %.0f bytes/sec - "
"REAL loaded engine (whole game image + all hooks active), identical instruction bytes, "
"invoked via the same CallGuestFunction() every real guest call uses",
kBufSize, seconds, bytesPerSec);
}
void RunCalleeSavedRegisterTest(GuestEngine& engine) {
// Register a stub via the EXACT mechanism every real GLESv2/libc import
// shim uses (AllocCodeStub -> real Thumb `BX LR` + MiscStubDispatch's
// O(1) table lookup - see guest_engine.cpp). The callback itself does
// nothing (see CalleeSavedTestNoopCb above) - the point is to isolate
// whether the DISPATCH MECHANISM itself preserves callee-saved
// registers (AAPCS32: r4-r11 must survive a function call unchanged),
// not any particular shim's own logic.
GuestAddr stubAddr = engine.AllocCodeStub(CalleeSavedTestNoopCb, nullptr);
if (!stubAddr) {
__android_log_print(ANDROID_LOG_INFO, "REG_TEST",
"RunCalleeSavedRegisterTest: AllocCodeStub failed");
return;
}
GuestAddr resultsAddr = engine.AllocPermanent(16); // r4,r5,r6,r7 post-call snapshot
GuestAddr codeAddr = engine.AllocPermanent(64); // generous - real size is ~24 bytes
if (!resultsAddr || !codeAddr) {
__android_log_print(ANDROID_LOG_INFO, "REG_TEST",
"RunCalleeSavedRegisterTest: allocation failed (results=0x%x code=0x%x)",
resultsAddr, codeAddr);
return;
}
// stubAddr already carries the Thumb tag (bit0=1) from AllocCodeStub's
// own return convention - BLX needs that same tag to switch/stay in
// Thumb mode, so use it as-is (not the raw, untagged address).
std::vector<uint8_t> code;
EmitPushR4toR7Lr(code); // preserve OUR OWN caller's r4-r7/lr
EmitMovsImm8(code, /*rd=*/4, 0x44); // r4 = sentinel 0x44
EmitMovsImm8(code, /*rd=*/5, 0x55); // r5 = sentinel 0x55
EmitMovsImm8(code, /*rd=*/6, 0x66); // r6 = sentinel 0x66 (the exact register the real bug showed corrupted)
EmitMovsImm8(code, /*rd=*/7, 0x77); // r7 = sentinel 0x77
EmitLoadAddr32(code, /*rd=*/0, stubAddr); // r0 = stub address (Thumb-tagged)
EmitBlxReg(code, /*rm=*/0); // call it - real import-stub call path
EmitLoadAddr32(code, /*rd=*/1, resultsAddr); // r1 = results buffer
EmitStrImm5(code, /*rt=*/4, /*rn=*/1, /*imm5=*/0); // results[0] = r4 (post-call)
EmitStrImm5(code, /*rt=*/5, /*rn=*/1, /*imm5=*/1); // results[1] = r5
EmitStrImm5(code, /*rt=*/6, /*rn=*/1, /*imm5=*/2); // results[2] = r6
EmitStrImm5(code, /*rt=*/7, /*rn=*/1, /*imm5=*/3); // results[3] = r7
EmitPopR4toR7Pc(code); // restore OUR caller's r4-r7, return via pc
memcpy(engine.G2H(codeAddr), code.data(), code.size());
uint32_t before = g_calleeSavedTestNoopHits;
engine.CallGuestFunction(codeAddr | 1u); // Thumb-tagged entry, no args needed
bool stubWasHit = g_calleeSavedTestNoopHits > before;
uint32_t results[4] = {0, 0, 0, 0};
memcpy(results, engine.G2H(resultsAddr), sizeof(results));
const uint32_t expected[4] = {0x44, 0x55, 0x66, 0x77};
const char* names[4] = {"r4", "r5", "r6", "r7"};
bool anyClobbered = false;
char summary[256] = {0};
int off = 0;
for (int i = 0; i < 4; i++) {
bool ok = results[i] == expected[i];
if (!ok) anyClobbered = true;
off += snprintf(summary + off, sizeof(summary) - off, "%s=0x%x(%s) ",
names[i], results[i], ok ? "OK" : "CLOBBERED");
}
__android_log_print(ANDROID_LOG_INFO, "REG_TEST",
"RunCalleeSavedRegisterTest: stub_hit=%s result=%s | %s",
stubWasHit ? "yes" : "NO(!)",
anyClobbered ? "FAIL - callee-saved register(s) clobbered by the import-stub dispatch path"
: "PASS - all callee-saved registers survived the stub call intact",
summary);
}
void RunReentrantCallRegisterTest(GuestEngine& engine) {
// Inner guest function: MOVS r0, #0x99 ; BX LR - trivial, just needs to
// be a real, callable guest function for CallGuestFunction to run.
std::vector<uint8_t> innerCode;
EmitMovsImm8(innerCode, /*rd=*/0, 0x99);
EmitBxLr(innerCode);
GuestAddr innerAddr = engine.AllocPermanent(16);
if (!innerAddr) {
__android_log_print(ANDROID_LOG_INFO, "REG_TEST", "RunReentrantCallRegisterTest: inner alloc failed");
return;
}
memcpy(engine.G2H(innerAddr), innerCode.data(), innerCode.size());
// Stub whose C++ callback reenters the guest via CallGuestFunction -
// userData carries the Thumb-tagged inner function address.
GuestAddr stubAddr = engine.AllocCodeStub(ReentrantTestStubCb, (void*)(uintptr_t)(innerAddr | 1u));
GuestAddr resultsAddr = engine.AllocPermanent(16);
GuestAddr codeAddr = engine.AllocPermanent(64);
if (!stubAddr || !resultsAddr || !codeAddr) {
__android_log_print(ANDROID_LOG_INFO, "REG_TEST",
"RunReentrantCallRegisterTest: allocation failed (stub=0x%x results=0x%x code=0x%x)",
stubAddr, resultsAddr, codeAddr);
return;
}
// Same outer shape as RunCalleeSavedRegisterTest - sentinels in r4-r7,
// call the stub (which now reenters CallGuestFunction internally
// instead of just returning), snapshot r4-r7 afterward.
std::vector<uint8_t> code;
EmitPushR4toR7Lr(code);
EmitMovsImm8(code, 4, 0x14);
EmitMovsImm8(code, 5, 0x15);
EmitMovsImm8(code, 6, 0x16);
EmitMovsImm8(code, 7, 0x17);
EmitLoadAddr32(code, 0, stubAddr);
EmitBlxReg(code, 0);
EmitLoadAddr32(code, 1, resultsAddr);
EmitStrImm5(code, 4, 1, 0);
EmitStrImm5(code, 5, 1, 1);
EmitStrImm5(code, 6, 1, 2);
EmitStrImm5(code, 7, 1, 3);
EmitPopR4toR7Pc(code);
memcpy(engine.G2H(codeAddr), code.data(), code.size());
uint32_t before = g_reentrantTestStubHits;
engine.CallGuestFunction(codeAddr | 1u);
bool stubWasHit = g_reentrantTestStubHits > before;
uint32_t results[4] = {0, 0, 0, 0};
memcpy(results, engine.G2H(resultsAddr), sizeof(results));
const uint32_t expected[4] = {0x14, 0x15, 0x16, 0x17};
const char* names[4] = {"r4", "r5", "r6", "r7"};
bool anyClobbered = false;
char summary[256] = {0};
int off = 0;
for (int i = 0; i < 4; i++) {
bool ok = results[i] == expected[i];
if (!ok) anyClobbered = true;
off += snprintf(summary + off, sizeof(summary) - off, "%s=0x%x(%s) ",
names[i], results[i], ok ? "OK" : "CLOBBERED");
}
__android_log_print(ANDROID_LOG_INFO, "REG_TEST",
"RunReentrantCallRegisterTest: stub_hit=%s result=%s | %s",
stubWasHit ? "yes" : "NO(!)",
anyClobbered ? "FAIL - outer call's callee-saved register(s) clobbered by a nested reentrant CallGuestFunction"
: "PASS - outer call's callee-saved registers survived a nested reentrant call intact",
summary);
}
void RunStackArgMarshalingTest(GuestEngine& engine) {
// Leaf function - deliberately never pushes/pops or calls anything else,
// so there's no need to preserve r4-r7 for a caller beyond this test's
// own use of them as scratch. Reads r0-r3 (register-passed args 0-3)
// FIRST (before overwriting them), storing each straight to the results
// buffer via r4 (loaded once, untouched by anything else here), THEN
// reuses r0-r3 as scratch to read the stack-passed args 4-7 directly
// via SP-relative loads (exactly where CallGuestFunction's own
// marshaling is documented to place them: [sp+0]=args[4], [sp+4]=
// args[5], [sp+8]=args[6], [sp+12]=args[7]).
GuestAddr resultsAddr = engine.AllocPermanent(32); // 8 x uint32_t
GuestAddr codeAddr = engine.AllocPermanent(96);
if (!resultsAddr || !codeAddr) {
__android_log_print(ANDROID_LOG_INFO, "REG_TEST",
"RunStackArgMarshalingTest: allocation failed (results=0x%x code=0x%x)",
resultsAddr, codeAddr);
return;
}
std::vector<uint8_t> code;
EmitLoadAddr32(code, /*rd=*/4, resultsAddr);
EmitStrImm5(code, /*rt=*/0, /*rn=*/4, /*imm5=*/0); // results[0] = arg0 (r0)
EmitStrImm5(code, /*rt=*/1, /*rn=*/4, /*imm5=*/1); // results[1] = arg1 (r1)
EmitStrImm5(code, /*rt=*/2, /*rn=*/4, /*imm5=*/2); // results[2] = arg2 (r2)
EmitStrImm5(code, /*rt=*/3, /*rn=*/4, /*imm5=*/3); // results[3] = arg3 (r3)
EmitLdrSpImm8(code, /*rt=*/0, /*imm8=*/0); // r0 = [sp+0] = arg4
EmitStrImm5(code, /*rt=*/0, /*rn=*/4, /*imm5=*/4);
EmitLdrSpImm8(code, /*rt=*/0, /*imm8=*/1); // r0 = [sp+4] = arg5
EmitStrImm5(code, /*rt=*/0, /*rn=*/4, /*imm5=*/5);
EmitLdrSpImm8(code, /*rt=*/0, /*imm8=*/2); // r0 = [sp+8] = arg6
EmitStrImm5(code, /*rt=*/0, /*rn=*/4, /*imm5=*/6);
EmitLdrSpImm8(code, /*rt=*/0, /*imm8=*/3); // r0 = [sp+12] = arg7
EmitStrImm5(code, /*rt=*/0, /*rn=*/4, /*imm5=*/7);
EmitBxLr(code);
memcpy(engine.G2H(codeAddr), code.data(), code.size());
const uint32_t sentinels[8] = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17};
engine.CallGuestFunction(codeAddr | 1u, sentinels, 8);
uint32_t results[8] = {0};
memcpy(results, engine.G2H(resultsAddr), sizeof(results));
bool anyWrong = false;
char summary[384] = {0};
int off = 0;
for (int i = 0; i < 8; i++) {
bool ok = results[i] == sentinels[i];
if (!ok) anyWrong = true;
off += snprintf(summary + off, sizeof(summary) - off, "arg%d=0x%x(%s) ",
i, results[i], ok ? "OK" : "WRONG");
}
__android_log_print(ANDROID_LOG_INFO, "REG_TEST",
"RunStackArgMarshalingTest: result=%s | %s",
anyWrong ? "FAIL - stack-passed argument(s) (argCount>4) marshaled incorrectly"
: "PASS - all 4 register args and 4 stack-marshaled args (argCount=8) arrived correctly",
summary);
}
void RunSequentialCallStateLeakTest(GuestEngine& engine) {
// Reuses CalleeSavedTestNoopCb (the same trivial no-op stub
// RunCalleeSavedRegisterTest already validated preserves r4-r7 within
// ONE call) - this test's question is different: does calling the
// SAME stub, through the SAME dispatch table entry, on the SAME
// thread, MULTIPLE TIMES IN A ROW (not nested/reentrant - each call
// fully completes before the next starts) ever let one call's state
// leak into another's, e.g. via a caching bug in CallGuestFunction's
// save/restore bookkeeping or AllocCodeStub/MiscStubDispatch's table
// lookup that only manifests on a second or third call.
GuestAddr stubAddr = engine.AllocCodeStub(CalleeSavedTestNoopCb, nullptr);
GuestAddr resultsAddr = engine.AllocPermanent(16);
GuestAddr codeAddr = engine.AllocPermanent(64);
if (!stubAddr || !resultsAddr || !codeAddr) {
__android_log_print(ANDROID_LOG_INFO, "REG_TEST",
"RunSequentialCallStateLeakTest: allocation failed (stub=0x%x results=0x%x code=0x%x)",
stubAddr, resultsAddr, codeAddr);
return;
}
// Same shape as RunCalleeSavedRegisterTest, but the sentinel values are
// baked into three SEPARATE code buffers (one per call) so each call's
// expected values are unambiguous and distinct from the others -
// avoids any chance of a false PASS from comparing against a leftover
// value that just happens to equal what THIS call wrote anyway.
struct Round { uint32_t sentinels[4]; GuestAddr codeAddr; };
Round rounds[3] = {
{{0x21, 0x22, 0x23, 0x24}, 0},
{{0x31, 0x32, 0x33, 0x34}, 0},
{{0x41, 0x42, 0x43, 0x44}, 0},
};
bool allocOk = true;
for (auto& round : rounds) {
round.codeAddr = engine.AllocPermanent(64);
if (!round.codeAddr) { allocOk = false; break; }
std::vector<uint8_t> code;
EmitPushR4toR7Lr(code);
EmitMovsImm8(code, 4, (uint8_t)round.sentinels[0]);
EmitMovsImm8(code, 5, (uint8_t)round.sentinels[1]);
EmitMovsImm8(code, 6, (uint8_t)round.sentinels[2]);
EmitMovsImm8(code, 7, (uint8_t)round.sentinels[3]);
EmitLoadAddr32(code, 0, stubAddr);
EmitBlxReg(code, 0);
EmitLoadAddr32(code, 1, resultsAddr);
EmitStrImm5(code, 4, 1, 0);
EmitStrImm5(code, 5, 1, 1);
EmitStrImm5(code, 6, 1, 2);
EmitStrImm5(code, 7, 1, 3);
EmitPopR4toR7Pc(code);
memcpy(engine.G2H(round.codeAddr), code.data(), code.size());
}
if (!allocOk) {
__android_log_print(ANDROID_LOG_INFO, "REG_TEST",
"RunSequentialCallStateLeakTest: per-round code allocation failed");
return;
}
bool anyFailed = false;
char summary[512] = {0};
int off = 0;
for (int r = 0; r < 3; r++) {
// Poison the results buffer with a recognizable non-sentinel value
// between rounds - a leftover-value false PASS is impossible here,
// since 0xDEADBEEF never matches any round's real sentinels.
uint32_t poison[4] = {0xDEADBEEFu, 0xDEADBEEFu, 0xDEADBEEFu, 0xDEADBEEFu};
memcpy(engine.G2H(resultsAddr), poison, sizeof(poison));
engine.CallGuestFunction(rounds[r].codeAddr | 1u);
uint32_t results[4] = {0};
memcpy(results, engine.G2H(resultsAddr), sizeof(results));
bool roundOk = true;
for (int i = 0; i < 4; i++) {
if (results[i] != rounds[r].sentinels[i]) roundOk = false;
}
if (!roundOk) anyFailed = true;
off += snprintf(summary + off, sizeof(summary) - off,
"round%d=%s[0x%x,0x%x,0x%x,0x%x] ", r, roundOk ? "OK" : "FAIL",
results[0], results[1], results[2], results[3]);
}
__android_log_print(ANDROID_LOG_INFO, "REG_TEST",
"RunSequentialCallStateLeakTest: result=%s | %s",
anyFailed ? "FAIL - state leaked across sequential (non-reentrant) calls"
: "PASS - three sequential calls to the same stub each saw only their own sentinels",
summary);
}
+88
View File
@@ -0,0 +1,88 @@
#pragma once
class GuestEngine;
// One-shot, throwaway benchmark (2026-09-06, ARM64_TRANSLATION_LAYER.md -
// "is Unicorn/QEMU-TCG itself the bottleneck, or just this project's own
// overhead on top of it" question, raised directly by the user comparing
// against libhoudini's own ARM32->x86_64 translation achieving good
// real-world game performance). Runs the EXACT real machine code bytes of
// sub_4EDAD4 (the FNV-1a hash loop already root-caused and shimmed this
// session) on a brand-new, hook-free uc_engine with no relationship to
// GuestEngine/the loaded game image at all - the purest possible measure
// of "what can bare Unicorn/TCG achieve for this exact instruction
// sequence," isolated from every other project-specific cost this session
// already found and fixed (uc_emu_stop() round-trips, GuestHeap's O(n)
// scan, per-block hook overhead). Call once, log the result, then remove.
void RunTcgBenchmark();
// Same measurement, same exact instruction bytes, but run INSIDE the real,
// fully-loaded GuestEngine (whole ~9+ MB game image mapped, every other
// hook this project registers still active, invoked via the same
// CallGuestFunction() every other real guest call in this codebase uses) -
// via a scratch copy of the code at a different address so it doesn't hit
// FnvHashAccelHookCb's own address-pinned skip. Directly answers: is bare
// Unicorn/TCG itself slower once the real binary is loaded (translation-
// cache pressure being the leading candidate), or is the gap this
// session's earlier real-world measurements found actually coming from
// surrounding work (marshaling, allocations, other per-call bookkeeping)
// rather than the instruction-emulation cost of the loop itself?
void RunTcgBenchmarkInRealContext(GuestEngine& engine);
// Synthetic unit test (2026-09-16, ARM64_TRANSLATION_LAYER.md - the
// sub_43FDE0/dword_ADBFB8 investigation). Real game code shows a
// callee-saved register (R6, holding a value that should survive
// unchanged across several nested guest-to-guest BL calls including real
// GLESv2 import calls like glUseProgram/glVertexAttribPointer) ending up
// corrupted by the time it's read many instructions later. Rather than
// keep tracing further through real, complex game code, this builds a
// minimal synthetic guest function that: sets R4-R7 to known sentinel
// values, calls a stub allocated via the EXACT SAME AllocCodeStub/
// MiscStubDispatch/import_stub_dispatch_cb mechanism every real GLES
// import shim uses (not a simplified stand-in), then checks whether R4-R7
// still hold their sentinels. Directly tests whether the import-stub call
// path preserves callee-saved registers (AAPCS32) - isolates the
// mechanism in one controlled call instead of chasing it through real
// game logic. Logs a bitmask of which registers (if any) got clobbered.
void RunCalleeSavedRegisterTest(GuestEngine& engine);
// Same idea, but for the REENTRANT case: a stub callback that itself
// triggers a nested CallGuestFunction() from within its own C++ body
// (matching what a JNI upcall or any "shim needs to call back into guest
// code" path does - CallGuestFunction's own elaborate save/restore of
// r0-r12/sp/lr/pc/cpsr + all 32 D-registers exists specifically for this
// case, per its own comment in guest_engine.cpp). Verifies the OUTER,
// suspended call's callee-saved registers survive a nested nested call
// correctly - a much less-exercised path than a single flat stub call.
void RunReentrantCallRegisterTest(GuestEngine& engine);
// Synthetic unit test (2026-09-16, ARM64_TRANSLATION_LAYER.md - the
// broader "покрой синтетикой весь свой код" directive, and specifically
// the earlier-in-session "Копай CallGuestFunction и маршалинг
// stack-аргументов" instruction that was never actually followed up with a
// dedicated test). `CallGuestFunction(target, args, argCount)` marshals
// argCount>4 by writing args[4..] onto the guest stack per AAPCS32
// (args[4] at [sp+0], args[5] at [sp+4], ...) - real call sites depend on
// this (JNI entry points with >4 params, arbitrary-arity Call*Method) but
// it had no isolated correctness test of its own. Builds a minimal LEAF
// guest function that reads r0-r3 (register-passed args) plus [sp+0],
// [sp+4], [sp+8], [sp+12] (stack-passed args) directly, writes all 8 back
// to a results buffer, and calls it with 8 known sentinel values via the
// array-taking CallGuestFunction overload. Directly answers whether the
// stack portion of the marshaling is correct (right values, right
// alignment/offsets) rather than just the register portion every other
// existing test already covers.
void RunStackArgMarshalingTest(GuestEngine& engine);
// Synthetic unit test (2026-09-16, ARM64_TRANSLATION_LAYER.md - same
// broader coverage directive). Every existing register-preservation test
// checks ONE call in isolation. This checks whether STATE LEAKS ACROSS
// SEQUENTIAL (not nested/reentrant) calls on the same thread - e.g. a
// caching bug in CallGuestFunction's own save/restore bookkeeping, or in
// AllocCodeStub/MiscStubDispatch's table lookup, that only manifests on
// the second or third call and not the first. Calls the SAME
// AllocCodeStub-dispatched stub three times in a row, each time with
// DIFFERENT sentinel values in r4-r7, checking after EACH call that only
// THAT call's own sentinels come back (not a stale value from the
// previous call).
void RunSequentialCallStateLeakTest(GuestEngine& engine);
@@ -0,0 +1,168 @@
// Desktop-only test harness for GuestHeap (see /ARM64_TRANSLATION_LAYER.md
// and guest_heap.h's own class comment) - no Unicorn, no Android, no APK.
// Compiles and links ONLY guest_heap.cpp directly against a host-side stub
// of util.h's Log() below (guest_heap.cpp is otherwise fully portable - see
// guest_types.h). Run via mpcore/scripts/run_heap_tests.sh.
//
// Exercises exactly the failure mode a real on-device crash was traced to
// this session: GuestHeap::Free() with a wrong/stale address silently
// corrupting an unrelated live allocation (which, in the real crash, was
// the permanently-cached guest JNIEnv). Confirms the canary added in
// response catches it instead of letting it through.
#include "../guest_heap.h"
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <vector>
// util.h declares this; guest_heap.cpp calls it on rejected frees/
// exhaustion. Desktop stand-in - prints to stdout instead of
// __android_log_write (an NDK-only API, not available on the host).
void Log(const char* fmt, ...) {
va_list args;
va_start(args, fmt);
vprintf(fmt, args);
va_end(args);
printf("\n");
}
namespace {
int g_checks = 0;
int g_failures = 0;
void Check(bool cond, const char* what) {
g_checks++;
if (!cond) {
g_failures++;
printf(" FAIL: %s\n", what);
} else {
printf(" ok: %s\n", what);
}
}
} // namespace
int main() {
constexpr uint32_t kArenaSize = 64 * 1024;
constexpr GuestAddr kArenaStart = 0x1000; // nonzero, matches real usage (heap never starts at guest 0)
// Backing buffer is addressed the same way GuestEngine::G2H does
// (hostBase + guestAddr) - kArenaStart headroom below the arena itself
// so that convention holds without a separate translation helper here.
std::vector<uint8_t> backing(kArenaStart + kArenaSize, 0);
printf("test: basic alloc/free round trip\n");
{
GuestHeap heap;
heap.Init(backing.data(), kArenaStart, kArenaSize);
GuestAddr a = heap.Alloc(64);
Check(a != 0, "Alloc(64) succeeds");
heap.Free(a);
GuestAddr b = heap.Alloc(64);
Check(b == a, "Alloc after Free reuses the freed block (first-fit)");
}
printf("test: free(0) is a silent no-op, matching free(NULL)\n");
{
GuestHeap heap;
heap.Init(backing.data(), kArenaStart, kArenaSize);
heap.Free(0); // must not crash, must not touch anything
GuestAddr a = heap.Alloc(16);
Check(a != 0, "heap still works normally after Free(0)");
}
printf("test: double-free is rejected, not silently accepted\n");
{
GuestHeap heap;
heap.Init(backing.data(), kArenaStart, kArenaSize);
GuestAddr a = heap.Alloc(32);
heap.Free(a);
heap.Free(a); // second free of the same address - must be rejected, not corrupt bookkeeping
GuestAddr b = heap.Alloc(32);
Check(b == a, "block is still consistently reusable after an attempted double-free");
}
printf("test: a wrong/stale address passed to Free() does not corrupt a live neighbor\n");
{
GuestHeap heap;
heap.Init(backing.data(), kArenaStart, kArenaSize);
GuestAddr victim = heap.Alloc(16);
Check(victim != 0, "victim allocation succeeds");
memset(backing.data() + victim, 0xAB, 16); // sentinel payload
// NOT a real block's payload start - exactly the shape of bug this
// session traced a real crash to (a miscalculated/stale guest
// address handed to Free()).
GuestAddr wrongAddr = victim + 5;
heap.Free(wrongAddr); // must be rejected
bool intact = true;
for (int i = 0; i < 16; i++) {
if (backing.data()[victim + i] != 0xAB) intact = false;
}
Check(intact, "victim block's payload is untouched after a rejected wrong-address free");
// The victim should still be considered in-use - first-fit must
// skip it (not reuse it) for a fresh allocation.
GuestAddr other = heap.Alloc(16);
Check(other != victim, "victim block was NOT freed by the rejected wrong-address free");
}
printf("test: oversized (>64KiB) alloc/free round trip reuses the freed block\n");
{
// Separate, larger arena - the default 64KiB test arena above can't
// fit even one >64KiB allocation.
constexpr uint32_t kBigArenaSize = 4 * 1024 * 1024; // 4MiB
std::vector<uint8_t> bigBacking(kArenaStart + kBigArenaSize, 0);
GuestHeap heap;
heap.Init(bigBacking.data(), kArenaStart, kBigArenaSize);
GuestAddr a = heap.Alloc(175760); // real size from the sub_4BA588 crash log
Check(a != 0, "Alloc(175760) succeeds");
heap.Free(a);
GuestAddr b = heap.Alloc(175760);
Check(b == a, "oversized Alloc after Free reuses the freed block, same as small sizes");
}
printf("test: repeated oversized alloc/free does NOT grow the arena unboundedly "
"(regression test for the sub_4BA588/loadNodeUncached heap-exhaustion crash, "
"ARM64_TRANSLATION_LAYER.md 2026-09-18)\n");
{
// Small enough that the OLD never-reclaim behavior would exhaust
// this arena in well under 100 iterations (100 * (175760+12) ~=
// 17.6MB > this 1MB arena), but large enough that a handful of
// real, simultaneously-live oversized objects still fit alongside
// the reused ones - a tight, realistic regression bound instead of
// an arbitrarily generous one.
constexpr uint32_t kBigArenaSize = 1 * 1024 * 1024; // 1MiB
std::vector<uint8_t> bigBacking(kArenaStart + kBigArenaSize, 0);
GuestHeap heap;
heap.Init(bigBacking.data(), kArenaStart, kBigArenaSize);
bool allSucceeded = true;
for (int i = 0; i < 500; i++) {
GuestAddr a = heap.Alloc(175760);
if (a == 0) { allSucceeded = false; break; }
heap.Free(a);
}
Check(allSucceeded, "500 repeated alloc/free cycles of a real oversized size all "
"succeed in a 1MiB arena (would have exhausted after ~5 cycles "
"under the old never-reclaimed behavior)");
}
printf("test: exhaustion returns 0 (guest NULL), not garbage or a crash\n");
{
GuestHeap heap;
heap.Init(backing.data(), kArenaStart, kArenaSize);
int count = 0;
while (heap.Alloc(256) != 0) {
count++;
if (count > 100000) break; // safety valve
}
Check(count > 0, "at least one allocation succeeded before exhaustion");
GuestAddr overflow = heap.Alloc(256);
Check(overflow == 0, "allocation after exhaustion returns 0");
}
printf("\n%d checks, %d failures\n", g_checks, g_failures);
return g_failures == 0 ? 0 : 1;
}
+236
View File
@@ -0,0 +1,236 @@
#include "zlib_accel.h"
#include "guest_engine.h"
#include "../util/util.h"
#include <zlib.h>
#include <atomic>
#include <cstring>
#include <map>
#include <mutex>
namespace zlib_accel {
namespace {
// z_stream on this 32-bit ABI (sizeof == 56, which inflateInit2_ itself
// checks - see its decompile in ARM64_TRANSLATION_LAYER.md):
constexpr uint32_t kOffNextIn = 0;
constexpr uint32_t kOffAvailIn = 4;
constexpr uint32_t kOffTotalIn = 8;
constexpr uint32_t kOffNextOut = 12;
constexpr uint32_t kOffAvailOut = 16;
constexpr uint32_t kOffTotalOut = 20;
constexpr uint32_t kOffMsg = 24;
constexpr uint32_t kOffState = 28;
constexpr uint32_t kOffAdler = 48;
constexpr uint32_t kGuestZStreamSize = 56;
std::mutex g_mutex;
// guest z_stream address -> the host stream doing the real work.
std::map<uint32_t, z_stream*> g_streams;
std::atomic<uint64_t> g_calls{0};
std::atomic<uint64_t> g_bytesOut{0};
uint32_t ReadU32(uc_engine* uc, uint32_t addr) {
uint32_t v = 0;
uc_mem_read(uc, addr, &v, 4);
return v;
}
void WriteU32(uc_engine* uc, uint32_t addr, uint32_t v) { uc_mem_write(uc, addr, &v, 4); }
// Returns from the intercepted guest function with `ret` in r0, without ever
// executing its body - same mechanism as guest_engine.cpp's
// FnvHashAccelHookCb (write PC=LR, stop the emulation so it resumes there).
void ReturnToCaller(uc_engine* uc, uint32_t ret) {
uint32_t lr = 0;
uc_reg_read(uc, UC_ARM_REG_LR, &lr);
uc_reg_write(uc, UC_ARM_REG_R0, &ret);
uc_reg_write(uc, UC_ARM_REG_PC, &lr);
uc_emu_stop(uc);
}
z_stream* FindStream(uint32_t guestStrm) {
std::lock_guard<std::mutex> lock(g_mutex);
auto it = g_streams.find(guestStrm);
return it == g_streams.end() ? nullptr : it->second;
}
void HandleInit2(uc_engine* uc) {
uint32_t strm = 0, windowBits = 0, version = 0, streamSize = 0;
uc_reg_read(uc, UC_ARM_REG_R0, &strm);
uc_reg_read(uc, UC_ARM_REG_R1, &windowBits);
uc_reg_read(uc, UC_ARM_REG_R2, &version);
uc_reg_read(uc, UC_ARM_REG_R3, &streamSize);
// Mirror the real function's own argument validation exactly, so a
// caller that gets this wrong still sees the error code it expects
// rather than silently succeeding.
if (!version) { ReturnToCaller(uc, (uint32_t)Z_VERSION_ERROR); return; }
uint8_t versionFirst = 0;
uc_mem_read(uc, version, &versionFirst, 1);
if (streamSize != kGuestZStreamSize || versionFirst != '1') {
ReturnToCaller(uc, (uint32_t)Z_VERSION_ERROR);
return;
}
if (!strm) { ReturnToCaller(uc, (uint32_t)Z_STREAM_ERROR); return; }
auto* hs = new z_stream();
std::memset(hs, 0, sizeof(*hs));
// Host allocation on purpose: the guest's own zalloc/zfree hand out
// GuestHeap memory, and the window/state buffers are pure zlib internals
// the guest never looks at. Keeping them off the guest heap also keeps
// this acceleration from competing for the guest's own arena.
int ret = inflateInit2(hs, (int)windowBits);
if (ret != Z_OK) {
delete hs;
ReturnToCaller(uc, (uint32_t)ret);
return;
}
{
std::lock_guard<std::mutex> lock(g_mutex);
auto existing = g_streams.find(strm);
if (existing != g_streams.end()) {
// Re-init of an address we already own (the guest freed and
// reallocated a z_stream at the same address). Drop the old one
// rather than leaking it.
inflateEnd(existing->second);
delete existing->second;
existing->second = hs;
} else {
g_streams.emplace(strm, hs);
}
}
// Deliberately leave the guest's `state` NULL. Nothing in the guest
// dereferences it while we own the stream, and if some zlib entry point
// this layer does NOT intercept is ever called on this stream, zlib's
// own NULL-state check makes it return Z_STREAM_ERROR - a clean,
// debuggable failure instead of walking a fabricated pointer.
WriteU32(uc, strm + kOffState, 0);
WriteU32(uc, strm + kOffMsg, 0);
WriteU32(uc, strm + kOffTotalIn, 0);
WriteU32(uc, strm + kOffTotalOut, 0);
ReturnToCaller(uc, (uint32_t)Z_OK);
}
void HandleInflate(uc_engine* uc) {
uint32_t strm = 0, flush = 0;
uc_reg_read(uc, UC_ARM_REG_R0, &strm);
uc_reg_read(uc, UC_ARM_REG_R1, &flush);
z_stream* hs = strm ? FindStream(strm) : nullptr;
if (!hs) return; // not ours - let the original emulated code run
auto& eng = GuestEngine::Instance();
uint32_t nextIn = ReadU32(uc, strm + kOffNextIn);
uint32_t availIn = ReadU32(uc, strm + kOffAvailIn);
uint32_t nextOut = ReadU32(uc, strm + kOffNextOut);
uint32_t availOut = ReadU32(uc, strm + kOffAvailOut);
// The guest's buffers live in the same flat host region, so host zlib
// reads and writes them in place - no copying in or out.
hs->next_in = availIn ? (Bytef*)eng.G2H(nextIn) : nullptr;
hs->avail_in = availIn;
hs->next_out = availOut ? (Bytef*)eng.G2H(nextOut) : nullptr;
hs->avail_out = availOut;
int ret = inflate(hs, (int)flush);
uint32_t consumed = availIn - hs->avail_in;
uint32_t produced = availOut - hs->avail_out;
WriteU32(uc, strm + kOffNextIn, nextIn + consumed);
WriteU32(uc, strm + kOffAvailIn, hs->avail_in);
WriteU32(uc, strm + kOffNextOut, nextOut + produced);
WriteU32(uc, strm + kOffAvailOut, hs->avail_out);
WriteU32(uc, strm + kOffTotalIn, (uint32_t)hs->total_in);
WriteU32(uc, strm + kOffTotalOut, (uint32_t)hs->total_out);
WriteU32(uc, strm + kOffAdler, (uint32_t)hs->adler);
// msg points at a host string literal that the guest cannot read; leave
// it NULL rather than handing over an address outside the guest region.
WriteU32(uc, strm + kOffMsg, 0);
uint64_t n = g_calls.fetch_add(1, std::memory_order_relaxed) + 1;
uint64_t total = g_bytesOut.fetch_add(produced, std::memory_order_relaxed) + produced;
if (n % 2000 == 0) {
Log("zlib_accel: %llu native inflate calls, %.1fMB produced (host zlib, not emulated)",
(unsigned long long)n, total / (1024.0 * 1024.0));
}
ReturnToCaller(uc, (uint32_t)ret);
}
void HandleReset2(uc_engine* uc) {
uint32_t strm = 0, windowBits = 0;
uc_reg_read(uc, UC_ARM_REG_R0, &strm);
uc_reg_read(uc, UC_ARM_REG_R1, &windowBits);
z_stream* hs = strm ? FindStream(strm) : nullptr;
if (!hs) return; // not ours
int ret = inflateReset2(hs, (int)windowBits);
WriteU32(uc, strm + kOffTotalIn, 0);
WriteU32(uc, strm + kOffTotalOut, 0);
WriteU32(uc, strm + kOffMsg, 0);
ReturnToCaller(uc, (uint32_t)ret);
}
void HandleEnd(uc_engine* uc) {
uint32_t strm = 0;
uc_reg_read(uc, UC_ARM_REG_R0, &strm);
if (!strm) return; // not ours (and the real code returns Z_STREAM_ERROR)
z_stream* hs = nullptr;
{
std::lock_guard<std::mutex> lock(g_mutex);
auto it = g_streams.find(strm);
if (it == g_streams.end()) return; // not ours
hs = it->second;
g_streams.erase(it);
}
inflateEnd(hs);
delete hs;
WriteU32(uc, strm + kOffState, 0);
ReturnToCaller(uc, (uint32_t)Z_OK);
}
std::atomic<uint64_t> g_crcCalls{0};
std::atomic<uint64_t> g_crcBytes{0};
// crc32(uLong crc, const Bytef* buf, uInt len) - pure, so unlike inflate
// there is no stream to own and nothing to fall back to: every call can be
// served natively. Mirrors the real function's own `buf == NULL -> 0` case.
void HandleCrc32(uc_engine* uc) {
uint32_t crc = 0, buf = 0, len = 0;
uc_reg_read(uc, UC_ARM_REG_R0, &crc);
uc_reg_read(uc, UC_ARM_REG_R1, &buf);
uc_reg_read(uc, UC_ARM_REG_R2, &len);
if (!buf) { ReturnToCaller(uc, 0); return; }
auto& eng = GuestEngine::Instance();
uint32_t result =
(uint32_t)::crc32((uLong)crc, (const Bytef*)eng.G2H(buf), (uInt)len);
uint64_t n = g_crcCalls.fetch_add(1, std::memory_order_relaxed) + 1;
uint64_t total = g_crcBytes.fetch_add(len, std::memory_order_relaxed) + len;
if (n % 20000 == 0) {
Log("zlib_accel: %llu native crc32 calls, %.1fMB checksummed (host, not emulated)",
(unsigned long long)n, total / (1024.0 * 1024.0));
}
ReturnToCaller(uc, result);
}
} // namespace
void HookCb(uc_engine* uc, uint64_t address, uint32_t, void*) {
switch (address) {
case kInflateInit2Addr: HandleInit2(uc); break;
case kInflateAddr: HandleInflate(uc); break;
case kInflateReset2Addr: HandleReset2(uc); break;
case kInflateEndAddr: HandleEnd(uc); break;
case kCrc32Addr: HandleCrc32(uc); break;
default: break;
}
}
} // namespace zlib_accel
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <unicorn/unicorn.h>
// Native acceleration for the zlib inflate family that is STATICALLY LINKED
// into libapp.so (zlib 1.2.5 - confirmed by its embedded copyright string;
// the binary imports no libz.so and has no inflate/crc32 dynamic symbols).
// Because it is statically linked it runs as guest ARM32 code through
// Unicorn, i.e. at the measured ~12.8M instructions/sec against a native
// core's ~1G. Decompression costs roughly 10-30 instructions per output byte,
// and a single load was measured producing 66.2MB from ONE stream - minutes
// of pure emulation. See ARM64_TRANSLATION_LAYER.md (task #43).
//
// The interception follows the same mechanism as FnvHashAccelHookCb
// (guest_engine.cpp): a UC_HOOK_CODE at the function's entry address, the
// work done natively, then PC=LR and uc_emu_stop so the guest body never
// runs. The difference, and the reason this needs its own file: FNV-1a was a
// pure function, whereas inflate is STATEFUL - the state is created by
// inflateInit2_ and threaded through many inflate() calls - so the whole
// family has to be taken over together, with a real host z_stream kept per
// guest stream.
//
// SAFETY: a stream is only taken over if this layer saw its own
// inflateInit2_ go through. libapp.so has nine distinct callers of inflate
// (libpng among them, with its own streams and its own use of functions this
// layer does not intercept); any stream this layer does not recognise is left
// entirely alone and runs the original emulated code, slowly but correctly.
//
// Guest addresses below are from this exact libapp.so build:
// inflateInit2_ 0x667D64 (strm, windowBits, version, stream_size)
// inflateReset2 0x667C44 (strm, windowBits)
// inflate 0x667FFC (strm, flush)
// inflateEnd 0x669B64 (strm)
namespace zlib_accel {
constexpr uint64_t kInflateInit2Addr = 0x667d64;
constexpr uint64_t kInflateReset2Addr = 0x667c44;
constexpr uint64_t kInflateAddr = 0x667ffc;
constexpr uint64_t kInflateEndAddr = 0x669b64;
// zlib's crc32(crc, buf, len) - identified by its ~crc on entry and exit, the
// 8x256 slice-by-8 table at dword_A40B84, and the 32-byte unrolled loop.
// Measured by the block profiler as the single hottest thing during a load:
// 17.7% of all samples. Unlike inflate this is PURE - no state, no lifetime -
// so it is the same trivial, safe interception as FnvHashAccelHookCb.
// The game calls it directly on decompressed data (archive integrity
// checks), i.e. on top of whatever crc32 work happens inside inflate itself.
constexpr uint64_t kCrc32Addr = 0x65f6c8;
// One callback for all four - it dispatches on the hook address, the same way
// QuadGeometryProbeHookCb does, so registration stays a handful of lines.
void HookCb(uc_engine* uc, uint64_t address, uint32_t size, void* userData);
} // namespace zlib_accel
+187
View File
@@ -0,0 +1,187 @@
#pragma once
#include <jni.h>
#include "util/util.h"
// ---- Native -> Kotlin game-lifecycle event bridge ----
//
// Mirrors nfs.mod.mpcore.GameEvents on the Kotlin side: native hooks call
// FireMapLoaded()/FireRaceStarted()/FireRaceEnded() whenever they observe the
// corresponding moment, and this forwards to GameEvents.dispatch*() via JNI,
// which fans it out to whatever Kotlin GameEventListeners are registered.
//
// Only onMapLoaded and onUpgradesAccepted have real triggers wired up right
// now (from Hook_MapTrackHandleEvent and Hook_LayoutScreenCtor respectively,
// both in lan_event_injection.h) - FireRaceStarted/FireRaceEnded are
// scaffolded for when those hook points are found, per the user's request
// for a general onMapLoaded/onRaceStarted/onRaceEnded-shaped event class on
// both sides. See PROGRESS.md for status.
static JavaVM* g_gameEventsJvm = nullptr;
static jclass g_gameEventsClass = nullptr;
static jmethodID g_onMapLoadedMethod = nullptr;
static jmethodID g_onRaceStartedMethod = nullptr;
static jmethodID g_onRaceEndedMethod = nullptr;
static jmethodID g_onUpgradesAcceptedMethod = nullptr;
static jmethodID g_onCarSelectedMethod = nullptr;
// Must be called from JNI_OnLoad (a properly app-classloader-scoped thread -
// FindClass from an arbitrary AttachCurrentThread'd native thread later would
// resolve against the wrong classloader and fail to find app classes).
inline void InitGameEvents(JavaVM* vm, JNIEnv* env) {
g_gameEventsJvm = vm;
jclass localClass = env->FindClass("nfs/mod/mpcore/GameEvents");
if (!localClass) {
Log("GameEvents: FindClass(nfs/mod/mpcore/GameEvents) failed");
env->ExceptionClear();
return;
}
g_gameEventsClass = (jclass)env->NewGlobalRef(localClass);
env->DeleteLocalRef(localClass);
g_onMapLoadedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchMapLoaded", "()V");
g_onRaceStartedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchRaceStarted", "()V");
g_onRaceEndedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchRaceEnded", "()V");
g_onUpgradesAcceptedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchUpgradesAccepted", "([I[I)V");
g_onCarSelectedMethod = env->GetStaticMethodID(g_gameEventsClass, "dispatchCarSelected",
"(Ljava/lang/String;Ljava/lang/String;IIII)V");
if (!g_onMapLoadedMethod || !g_onRaceStartedMethod || !g_onRaceEndedMethod || !g_onUpgradesAcceptedMethod ||
!g_onCarSelectedMethod) {
Log("GameEvents: one or more dispatch* methods not found on GameEvents.kt");
env->ExceptionClear();
} else {
Log("GameEvents: JNI bridge initialised");
}
}
inline JNIEnv* GetJNIEnvForCurrentThread(bool* didAttach) {
*didAttach = false;
if (!g_gameEventsJvm) return nullptr;
JNIEnv* env = nullptr;
jint res = g_gameEventsJvm->GetEnv((void**)&env, JNI_VERSION_1_6);
if (res == JNI_EDETACHED) {
if (g_gameEventsJvm->AttachCurrentThread(&env, nullptr) != JNI_OK) {
Log("GameEvents: AttachCurrentThread failed");
return nullptr;
}
*didAttach = true;
} else if (res != JNI_OK) {
Log("GameEvents: GetEnv failed (res=%d)", res);
return nullptr;
}
return env;
}
inline void FireGameEvent(jmethodID method, const char* name) {
if (!g_gameEventsClass || !method) {
Log("GameEvents: %s fired but JNI bridge isn't ready, dropping", name);
return;
}
bool didAttach = false;
JNIEnv* env = GetJNIEnvForCurrentThread(&didAttach);
if (!env) return;
env->CallStaticVoidMethod(g_gameEventsClass, method);
if (env->ExceptionCheck()) {
Log("GameEvents: %s dispatch threw a Java exception", name);
env->ExceptionDescribe();
env->ExceptionClear();
} else {
Log("GameEvents: %s dispatched to Kotlin successfully", name);
}
if (didAttach) {
g_gameEventsJvm->DetachCurrentThread();
}
}
inline void FireMapLoaded() { FireGameEvent(g_onMapLoadedMethod, "onMapLoaded"); }
inline void FireRaceStarted() { FireGameEvent(g_onRaceStartedMethod, "onRaceStarted"); }
inline void FireRaceEnded() { FireGameEvent(g_onRaceEndedMethod, "onRaceEnded"); }
// Carries data (unlike the plain lifecycle events above), so it needs its
// own JNI array marshaling instead of reusing FireGameEvent. Called once,
// right as the controlled BACK-chain exit lands back on map_overworld (see
// Hook_LayoutScreenCtor in lan_event_injection.h) - slotIds/carMods mirror
// g_modSlotSelections[] at that moment: parallel arrays, one entry per slot
// the player touched this loadout session (carMod==0 means "cleared/NONE").
inline void FireUpgradesAccepted(const int* slotIds, const int* carMods, int count) {
if (!g_gameEventsClass || !g_onUpgradesAcceptedMethod) {
Log("GameEvents: onUpgradesAccepted fired but JNI bridge isn't ready, dropping (%d slot(s))", count);
return;
}
bool didAttach = false;
JNIEnv* env = GetJNIEnvForCurrentThread(&didAttach);
if (!env) return;
jintArray jSlotIds = env->NewIntArray(count);
jintArray jCarMods = env->NewIntArray(count);
if (!jSlotIds || !jCarMods) {
Log("GameEvents: onUpgradesAccepted failed to allocate JNI int arrays");
env->ExceptionClear();
} else {
env->SetIntArrayRegion(jSlotIds, 0, count, slotIds);
env->SetIntArrayRegion(jCarMods, 0, count, carMods);
env->CallStaticVoidMethod(g_gameEventsClass, g_onUpgradesAcceptedMethod, jSlotIds, jCarMods);
if (env->ExceptionCheck()) {
Log("GameEvents: onUpgradesAccepted dispatch threw a Java exception");
env->ExceptionDescribe();
env->ExceptionClear();
} else {
Log("GameEvents: onUpgradesAccepted dispatched to Kotlin successfully (%d slot(s))", count);
}
}
if (jSlotIds) env->DeleteLocalRef(jSlotIds);
if (jCarMods) env->DeleteLocalRef(jCarMods);
if (didAttach) {
g_gameEventsJvm->DetachCurrentThread();
}
}
// cont.61/63b: fired once, right as car_select's own confirm checkmark
// fires "CONTINUE" (see Hook_FireOutputDiag in lan_event_injection.h).
// carId is the resource-id string read directly off the car-select state
// singleton (GetCurrentCarId), e.g. "ford_focus_rs500_2010_desc". colorName
// + colorR/G/B/A are resolved live from the game's own engine data (see
// GetCurrentCarColor in lan_event_injection.h -
// CarDescription::GetPaintJobDescription via `*(singleton+56)`) rather than
// a static extracted table, so this keeps working for any car
// added/modded into the game later, per the user's own explicit request.
inline void FireCarSelected(const char* carId, const char* colorName,
int colorR, int colorG, int colorB, int colorA) {
if (!g_gameEventsClass || !g_onCarSelectedMethod) {
Log("GameEvents: onCarSelected fired but JNI bridge isn't ready, dropping");
return;
}
bool didAttach = false;
JNIEnv* env = GetJNIEnvForCurrentThread(&didAttach);
if (!env) return;
jstring jCarId = env->NewStringUTF(carId ? carId : "");
jstring jColorName = env->NewStringUTF(colorName ? colorName : "");
if (!jCarId || !jColorName) {
Log("GameEvents: onCarSelected failed to allocate JNI string(s)");
env->ExceptionClear();
} else {
env->CallStaticVoidMethod(g_gameEventsClass, g_onCarSelectedMethod, jCarId, jColorName,
colorR, colorG, colorB, colorA);
if (env->ExceptionCheck()) {
Log("GameEvents: onCarSelected dispatch threw a Java exception");
env->ExceptionDescribe();
env->ExceptionClear();
} else {
Log("GameEvents: onCarSelected dispatched to Kotlin successfully "
"(carId=%s, color=%s RGBA=%d,%d,%d,%d)",
carId ? carId : "(null)", colorName ? colorName : "(null)",
colorR, colorG, colorB, colorA);
}
}
if (jCarId) env->DeleteLocalRef(jCarId);
if (jColorName) env->DeleteLocalRef(jColorName);
if (didAttach) {
g_gameEventsJvm->DetachCurrentThread();
}
}
@@ -0,0 +1,82 @@
// ARM64-only-device prototype: `GameActivityMain`'s native lifecycle/GL
// callbacks, wired for real (2026-08-29) - see ARM64_TRANSLATION_LAYER.md's
// "boot the game" follow-up. Each of these forwards into libapp.so's own
// real implementation (real_native_offsets.h - found via a plain .dynsym
// dump, not IDA RE, since JNI export names survive stripping) via
// CallGuestFunction, through the guest JNIEnv bridge (emu/jni_shim.*).
//
// `nativeOnPhysicalKeyboardVisibilityChanged` has no real libapp.so
// implementation (absent from .dynsym - confirmed, not just unresolved by
// this port) and stays a no-op stub.
#include <jni.h>
#include "util/util.h"
#include "real_native_call.h"
#include "real_native_offsets.h"
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnCreate(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONCREATE_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnDestroy(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONDESTROY_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnMusicPlayerStateChanged(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONMUSICPLAYERSTATECHANGED_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnPause(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONPAUSE_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnRestart(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONRESTART_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnResume(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONRESUME_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnStart(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONSTART_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnStop(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONSTOP_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnOrientationChange(JNIEnv* env, jobject thiz, jint i) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONORIENTATIONCHANGE_OFFSET, {(uint32_t)i});
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnPhysicalKeyDown(JNIEnv* env, jobject thiz, jint keyCode, jint scanCode) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONPHYSICALKEYDOWN_OFFSET, {(uint32_t)keyCode, (uint32_t)scanCode});
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnPhysicalKeyUp(JNIEnv* env, jobject thiz, jint i, jint i2) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONPHYSICALKEYUP_OFFSET, {(uint32_t)i, (uint32_t)i2});
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnPhysicalKeyboardVisibilityChanged(JNIEnv*, jobject, jboolean) {
Log("game_lifecycle_stubs: nativeOnPhysicalKeyboardVisibilityChanged() - no real libapp.so "
"implementation exists (absent from .dynsym) - staying a no-op");
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeOnPhysicalNavigationVisibilityChanged(JNIEnv* env, jobject thiz, jboolean z) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_ONPHYSICALNAVIGATIONVISIBILITYCHANGED_OFFSET, {(uint32_t)z});
}
extern "C" JNIEXPORT jboolean JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeRestoreContext(JNIEnv* env, jobject thiz) {
return (jboolean)CallRealNative(env, thiz, REAL_GAMEACTIVITY_RESTORECONTEXT_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeSurfaceChanged(JNIEnv* env, jobject thiz, jobject gl10, jint w, jint h) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_SURFACECHANGED_OFFSET,
{GuestHandleFor(gl10), (uint32_t)w, (uint32_t)h});
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeSurfaceCreated(JNIEnv* env, jobject thiz, jobject gl10, jobject eglConfig) {
CallRealNative(env, thiz, REAL_GAMEACTIVITY_SURFACECREATED_OFFSET,
{GuestHandleFor(gl10), GuestHandleFor(eglConfig)});
}
@@ -0,0 +1,51 @@
// ARM64-only-device prototype: EAIO/StorageDirectory/RunLoop/MogaController
// natives, wired for real (2026-08-29) - see game_lifecycle_stubs.cpp's own
// top comment for the mechanism. All confirmed present in libapp.so's own
// .dynsym (real_native_offsets.h).
#include <jni.h>
#include "util/util.h"
#include "real_native_call.h"
#include "real_native_offsets.h"
extern "C" JNIEXPORT void JNICALL
Java_com_ea_EAIO_EAIO_StartupNativeImpl(JNIEnv* env, jobject thiz, jobject assetManager,
jstring dataPath, jstring filesDirPath, jstring externalPath) {
CallRealNative(env, thiz, REAL_EAIO_STARTUP_OFFSET,
{GuestHandleFor(assetManager), GuestHandleFor(dataPath),
GuestHandleFor(filesDirPath), GuestHandleFor(externalPath)});
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_EAIO_EAIO_Shutdown(JNIEnv* env, jclass clazz) {
// Shutdown() is @JvmStatic (jclass receiver, not jobject) - the real
// guest function itself doesn't care (it never dereferences `thiz` as a
// real object here, just needs *some* consistent handle to pass), so
// reuse CallRealNative's jobject-shaped helper with the class handle.
CallRealNative(env, (jobject)clazz, REAL_EAIO_SHUTDOWN_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_EAMIO_StorageDirectory_StartupNativeImpl(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_STORAGEDIR_STARTUP_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_EAMIO_StorageDirectory_ShutdownNativeImpl(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_STORAGEDIR_SHUTDOWN_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_RunLoop_nativeOnRunLoopTick(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_RUNLOOP_TICK_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_MogaController_nativeOnKeyEvent(JNIEnv* env, jobject thiz, jobject keyEvent) {
CallRealNative(env, thiz, REAL_MOGA_ONKEYEVENT_OFFSET, {GuestHandleFor(keyEvent)});
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_MogaController_nativeOnMotionEvent(JNIEnv* env, jobject thiz, jobject motionEvent) {
CallRealNative(env, thiz, REAL_MOGA_ONMOTIONEVENT_OFFSET, {GuestHandleFor(motionEvent)});
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_MogaController_nativeOnStateEvent(JNIEnv* env, jobject thiz, jobject stateEvent) {
CallRealNative(env, thiz, REAL_MOGA_ONSTATEEVENT_OFFSET, {GuestHandleFor(stateEvent)});
}
@@ -0,0 +1,184 @@
// ARM64-only-device prototype - FMOD (libfmodex.so, not loaded into the
// emulator - no arm64-v8a build exists at all, see ARM64_TRANSLATION_LAYER.md)
// stays stubbed. EA Nimble's lifecycle bridge and GameGLSurfaceView's touch
// forwarders ARE inside libapp.so's own .dynsym and are wired for real
// (2026-08-29) - see game_lifecycle_stubs.cpp's own top comment.
// NimbleCppComponentRegistrar$NimbleCppComponent's 6 methods and
// BaseNativeCallback's 2 are NOT in libapp.so's .dynsym (confirmed, not
// just unresolved by this port) and stay stubbed too.
#include <jni.h>
#include <cstdint>
#include <cstring>
#include <set>
#include <string>
#include "emu/jni_shim.h"
#include "util/util.h"
#include "real_native_call.h"
#include "real_native_offsets.h"
// ---- FMOD audio bridge (task #67, 2026-09-21) ----
//
// Both of these used to `return 0`, silently, on the stated grounds that FMOD
// was not loaded because no arm64 build exists. That is still true of arm64 -
// but the game's real ARM32 libfmodex/libfmodevent now run inside the engine
// as secondary guest images, and both of these symbols are among their
// exports, so the calls can be forwarded to the real implementations.
//
// Why this pair is the whole audio path: FMOD on this Android build does not
// drive the output device from native code. org/fmod/FMODAudioDevice.java owns
// an AudioTrack and pulls PCM down through these two methods. Its thread reads
//
// int rate = fmodGetInfo(FMOD_INFO_SAMPLERATE);
// if (rate > 0) { ...create the AudioTrack, then loop on fmodProcess... }
//
// so a `return 0` from fmodGetInfo alone made that thread give up before the
// AudioTrack was ever constructed. Nothing downstream could have produced
// sound regardless of what FMOD itself did.
// Re-enabled 2026-09-21 once the JNI call epoch became per-thread.
//
// These two run on FMODAudioDevice's own AudioTrack thread, and the first
// attempt aborted the process inside GLThread with "JNI DETECTED ERROR IN
// APPLICATION: jfieldID was NULL". The first diagnosis - that CallRealNative's
// SetRealEnv() clobbered a shared JNIEnv - was WRONG: real_env_ was already
// thread_local, with a lazy AttachCurrentThread fallback.
//
// The actual culprit was one line further on. SetRealEnv also calls
// JniHandleTable::BumpCallEpoch(), and that epoch was a single process-wide
// counter whose own comment said it invalidates the previous call's local refs
// "whether or not it's the same thread". So every 100 ms this bridge was
// invalidating the references GLThread held mid-call. The epoch is per-thread
// now, which is the more accurate model anyway - a local ref's lifetime is
// scoped to a native call on its own thread.
static constexpr bool kEnableFmodAudioBridge = true;
static GuestAddr FmodGuestSymbol(const char* name) {
GuestAddr addr = GuestEngine::Instance().LookupSecondaryExport(name);
if (!addr) {
static std::set<std::string> reported;
if (reported.insert(name).second) {
Log("fmod_bridge: '%s' is not exported by any loaded guest image - audio stays silent. "
"Did libfmodex.so/libfmodevent.so fail to load? (see LoadSiblingLibraries)", name);
}
}
return addr;
}
extern "C" JNIEXPORT jint JNICALL
Java_org_fmod_FMODAudioDevice_fmodGetInfo(JNIEnv* env, jobject thiz, jint info) {
if (!kEnableFmodAudioBridge) return 0;
static GuestAddr fn = FmodGuestSymbol("Java_org_fmod_FMODAudioDevice_fmodGetInfo");
if (!fn) return 0;
// Deliberately NOT logged per call: FMODAudioDevice polls this from its
// audio thread continuously (1,210 calls in a 40-second run), so a log
// line here is a steady drip into a buffer we already lose history to.
return (jint)CallRealNative(env, thiz, fn, {(uint32_t)info});
}
extern "C" JNIEXPORT jint JNICALL
Java_org_fmod_FMODAudioDevice_fmodProcess(JNIEnv* env, jobject thiz, jobject buffer) {
if (!kEnableFmodAudioBridge) return 0;
static GuestAddr fn = FmodGuestSymbol("Java_org_fmod_FMODAudioDevice_fmodProcess");
if (!fn || !buffer) return 0;
// The ByteBuffer came from Java's own allocateDirect, so it lives at a host
// address the guest cannot write to - GetDirectBufferAddress would hand
// guest FMOD a zero. Bounce through a guest-memory buffer of the same size
// instead, exactly as the font fix does for AndroidBitmap_lockPixels, and
// copy the rendered PCM back out afterwards.
jlong capacity = env->GetDirectBufferCapacity(buffer);
void* hostDest = env->GetDirectBufferAddress(buffer);
if (capacity <= 0 || !hostDest) {
static bool logged = false;
if (!logged) {
logged = true;
Log("fmod_bridge: fmodProcess got a ByteBuffer with no direct address (capacity=%lld) - "
"returning silence", (long long)capacity);
}
return 0;
}
// Allocated once and reused: this runs on the AudioTrack thread at the
// audio buffer rate, so per-call allocation would be both wasteful and a
// source of jitter. The capacity is fixed by FMOD's DSP buffer settings
// and does not change after the device starts, but it is re-checked rather
// than assumed.
static GuestAddr guestBuf = 0;
static uint32_t guestBufSize = 0;
static uint32_t guestBufHandle = 0;
if (guestBufSize != (uint32_t)capacity) {
guestBuf = GuestEngine::Instance().heap().Alloc((uint32_t)capacity);
if (!guestBuf) {
Log("fmod_bridge: could not allocate a %lld-byte guest audio buffer - silence",
(long long)capacity);
return 0;
}
guestBufSize = (uint32_t)capacity;
guestBufHandle = JniShim::Instance().NewGuestBackedDirectBuffer(
GuestEngine::Instance(), guestBuf, guestBufSize);
Log("fmod_bridge: audio bounce buffer ready - %u bytes at guest 0x%x (handle %u)",
guestBufSize, guestBuf, guestBufHandle);
}
if (!guestBufHandle) return 0;
memset(GuestEngine::Instance().G2H(guestBuf), 0, guestBufSize);
jint r = (jint)CallRealNative(env, thiz, fn, {guestBufHandle});
memcpy(hostDest, GuestEngine::Instance().G2H(guestBuf), guestBufSize);
return r;
}
#define NIMBLE_COMPONENT_STUB(name) \
extern "C" JNIEXPORT void JNICALL \
Java_com_ea_nimble_bridge_NimbleCppComponentRegistrar_00024NimbleCppComponent_##name(JNIEnv*, jobject) { \
Log("game_lifecycle_stubs_extra2: NimbleCppComponent." #name "() - no-op " \
"(no real libapp.so implementation - absent from .dynsym)"); \
}
NIMBLE_COMPONENT_STUB(cleanup)
NIMBLE_COMPONENT_STUB(restore)
NIMBLE_COMPONENT_STUB(resume)
NIMBLE_COMPONENT_STUB(setup)
NIMBLE_COMPONENT_STUB(suspend)
NIMBLE_COMPONENT_STUB(teardown)
#undef NIMBLE_COMPONENT_STUB
extern "C" JNIEXPORT void JNICALL
Java_com_ea_nimble_bridge_BaseNativeCallback_nativeCallback(JNIEnv*, jclass, jint, jobjectArray) {
Log("game_lifecycle_stubs_extra2: BaseNativeCallback.nativeCallback() - no-op");
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_nimble_bridge_BaseNativeCallback_nativeFinalize(JNIEnv*, jclass, jint) {}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_nimble_bridge_NimbleCppApplicationLifeCycle_onApplicationLaunch(JNIEnv* env, jobject thiz, jobject map) {
CallRealNative(env, thiz, REAL_NIMBLE_ON_APP_LAUNCH_OFFSET, {GuestHandleFor(map)});
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_nimble_bridge_NimbleCppApplicationLifeCycle_onApplicationQuit(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_NIMBLE_ON_APP_QUIT_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_nimble_bridge_NimbleCppApplicationLifeCycle_onApplicationResume(JNIEnv* env, jobject thiz, jobject map) {
CallRealNative(env, thiz, REAL_NIMBLE_ON_APP_RESUME_OFFSET, {GuestHandleFor(map)});
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_nimble_bridge_NimbleCppApplicationLifeCycle_onApplicationSuspend(JNIEnv* env, jobject thiz) {
CallRealNative(env, thiz, REAL_NIMBLE_ON_APP_SUSPEND_OFFSET);
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_nimble_bridge_NimbleCppApplicationLifeCycle_onUpdateLaunchMethod(JNIEnv* env, jobject thiz, jobject map) {
CallRealNative(env, thiz, REAL_NIMBLE_ON_UPDATE_LAUNCH_METHOD_OFFSET, {GuestHandleFor(map)});
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameGLSurfaceView_nativeTouchPadEvent(JNIEnv* env, jobject thiz, jint i, jint i2, jfloat f, jfloat f2) {
uint32_t fBits, f2Bits;
memcpy(&fBits, &f, 4);
memcpy(&f2Bits, &f2, 4);
CallRealNative(env, thiz, REAL_GLSURFACEVIEW_TOUCHPAD_OFFSET, {(uint32_t)i, (uint32_t)i2, fBits, f2Bits});
}
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameGLSurfaceView_nativeTouchScreenEvent(JNIEnv* env, jobject thiz, jint i, jint i2, jfloat f, jfloat f2) {
uint32_t fBits, f2Bits;
memcpy(&fBits, &f, 4);
memcpy(&f2Bits, &f2, 4);
CallRealNative(env, thiz, REAL_GLSURFACEVIEW_TOUCHSCREEN_OFFSET, {(uint32_t)i, (uint32_t)i2, fBits, f2Bits});
}
File diff suppressed because it is too large Load Diff
+535 -252
View File
@@ -1,8 +1,11 @@
#include <cstring>
#include <cstdio>
#include <cstdint>
#include <cerrno>
#include <android/log.h>
#include <jni.h>
#include "main.h"
#include "crash_handler.h"
#include "util/util.h"
#include <unistd.h>
#include <unwind.h>
@@ -14,24 +17,333 @@
#include <iomanip>
#include "util/armhook.h"
#include "util/armhooks.h"
#include "game_events.h"
#include "emu/guest_engine.h"
#include "emu/ostream_repro_test.h"
#include "emu/guest_trace.h"
#include "emu/tcg_bench.h"
#include "emu/guest_fn.h"
#include "emu/import_shims.h"
#include "emu/pthread_shim.h"
#include "emu/jni_shim.h"
#include "emu/gles_shim.h"
#include "emu/libc_shims.h"
#include "emu/rtti_shims.h"
#include "emu/dyncast_fastpath.h"
#include "emu/fmod_shims.h"
#include "emu/profiler.h"
#include "real_native_offsets.h"
// ---- ARM64-only-device prototype (2026-08-19): the LAN event-injection
// subsystem (lan_event_injection.h + its own nested car_selection.h/
// mod_slot_tracking.h/crash_workarounds.h includes) is DELIBERATELY NOT
// wired into this emulated build - see ARM64_TRANSLATION_LAYER.md and this
// session's own notes. Short version: every function pointer it resolves
// (GetOutputNode, ResolveHandle, HashInsert, ...) now needs to route
// through GuestFn/CallGuestFunction instead of a raw C call, which is a
// small, mechanical change - but several of its call sites pass a pointer
// to a LOCAL HOST STACK VARIABLE as an "out parameter" the guest function
// writes into (e.g. ResolveHandle(&res, ctx, &key), HashInsert(&insertResult,
// ...)) - Unicorn's guest code can only read/write memory inside the mapped
// guest region, not arbitrary host process memory, so each of those call
// sites needs its own guest-scratch-buffer marshaling (copy in, call,
// copy out), individually, by hand. That's real, bounded work, but doing
// it blind - with no device or even a desktop harness available for THIS
// subsystem's own live-tuned wall-clock timing assumptions - risked
// shipping quietly-wrong behavior across ~30 call sites with zero way to
// catch a mistake. Left as source (untouched) for a follow-up session with
// real testing available, rather than force a rushed port now. The two
// self-contained hooks below (BuildTrackScenePath, MapScreenCtor) have no
// out-parameter calls at all, so they ARE fully ported and are the real
// end-to-end proof this mechanism works.
// #include "lan_event_injection.h"
//
// Branch merge (2026-09-22): opponent_substitution.h and cop_traffic_disable.h
// landed from the native32/lan-event-injection-poc branch, where they were
// built and live-confirmed against a real dlopen'd libapp.so - proven
// correct, but every one of their hooks resolves and calls raw
// "libapp_base + OFFSET" function pointers directly (this file's own
// InstallArmTrampolineHook pattern), the exact same shape as
// lan_event_injection.h's un-ported call sites above. On THIS build there is
// no real ARM32 code at that address to jump to - only Unicorn-backed guest
// memory - so including these unmodified would not just misbehave, it would
// jump the host CPU into data and crash immediately. Left as source
// (untouched, not included) for the same follow-up porting session as
// lan_event_injection.h, not wired in here.
// #include "opponent_substitution.h"
// #include "cop_traffic_disable.h"
void* libapp_base = NULL;
static int find_lib_callback(struct dl_phdr_info* info, size_t size, void* data) {
if (strstr(info->dlpi_name, "libapp.so")) {
libapp_base = (void*)info->dlpi_addr;
LOGD("Found libapp.so at base: 0x%08X", (uintptr_t)libapp_base);
return 1; // Останавливаем перебор
}
return 0;
}
bool get_libapp_base() {
dl_iterate_phdr(find_lib_callback, NULL);
if (!libapp_base) {
Log("libapp.so not found in memory!");
// Loads libapp.so through the embedded ARM32 CPU-emulation core instead of
// finding it via dl_iterate_phdr - there is no real dlopen'd libapp.so to
// find in this build; see ARM64_TRANSLATION_LAYER.md. `path` is a real
// filesystem path Kotlin extracts the bundled asset to before calling this
// (see MultiplayerCore.loadEmulatedLibapp / GameActivityMain.kt) since
// JNI_OnLoad itself has no Context/AssetManager access.
bool LoadEmulatedLibapp(const char* path, JNIEnv* env, jobject thiz) {
// One-shot, throwaway benchmark - see tcg_bench.h's own comment. Remove
// once the "is Unicorn/TCG itself the bottleneck" question is answered.
RunTcgBenchmark();
GuestEngine& engine = GuestEngine::Instance();
// EnableProfiling() (see profiler.h) used to be called unconditionally
// here for the "why did onCreate take 115s" investigation
// (ARM64_TRANSLATION_LAYER.md, 2026-09-01). Confirmed live (2026-09-05,
// "100x native" investigation) that this - and the two other
// UC_HOOK_BLOCK diagnostics it gates in guest_engine.cpp
// (TraceRingHookCb, LiveTraceRingHookCb) - fire on literally every
// basic block executed anywhere in the guest binary, for the whole run:
// a real, avoidable per-block tax, independent of and on top of the
// separate uc_emu_stop()-per-shim-call fix. Left disabled by default;
// uncomment when actually debugging a fault, stall, or hot-path
// question that needs the block profiler/trace ring.
// TEMPORARILY ENABLED 2026-09-19 for ONE capture (task #42). Three
// separate load-time theories - log volume, per-draw glGetIntegerv, and
// emulated zlib - each turned out to be real but not dominant: the zlib
// interception demonstrably moved 156MB of decompression off the
// emulator and the load barely moved. Stop theorising, measure.
// MUST be commented out again after the capture - this is a
// UC_HOOK_BLOCK over the whole image and leaving it on has already
// caused a user-visible regression twice, as the comment above records.
// Capture done 2026-09-19, profiler switched back off (see the warning
// above - a UC_HOOK_BLOCK over the whole image must never be left on).
// What it found, over 177,881 samples across 6,477 distinct blocks:
// crc32 (sub_65F6C8) 17.7% -> now served by host zlib
// name lookup (sub_4F3704) 18.8% -> linear strcmp scan, task #42
// glClear's caller (sub_567BD4) 3.8%
// i.e. no single dominant hotspot beyond those two; the rest is a long
// tail of ordinary guest code, which is why the three earlier
// "obvious" load-time theories each moved the needle so little.
// EnableProfiling();
StartProfileDumpThread();
// 2026-09-06: full block+JNI-call trace for the "where does the
// emulated engine's execution first diverge from A9's" investigation
// (see guest_trace.h and ARM64_TRANSLATION_LAYER.md). Left ENABLED
// unconditionally here for one capture session and caused a real,
// user-noticed performance regression (clock_gettime()+gettid() on
// every single executed block, on top of the already-fixed
// uc_emu_stop() cost) - the exact same "always-on UC_HOOK_BLOCK
// diagnostic" mistake EnableProfiling()'s own comment above already
// documents and fixed once. Disabled by default now, like
// EnableProfiling() - uncomment only while actively capturing a new
// guest/JNI trace, never leave it on for an ordinary test run.
// EnableFullGuestTrace();
// StartGuestTraceDumpThread();
// See gles_shim.h's own comment - "nothing renders past the splash"
// investigation.
StartGlesCounterDumpThread();
// See guest_engine.h's own comment - periodic live instruction trace for
// the same investigation, once RegisterNatives was ruled out.
StartLiveTraceDumpThread();
// See jni_shim.h's own comment: FindClass only sees app classes when
// called from the thread that loaded the native library - cache the
// real ClassLoader now (main thread, from a real app object) so later
// FindClass calls from other threads (e.g. the real engine's own
// GLThread) have a working fallback.
JniShim::Instance().CacheClassLoader(env, thiz);
RegisterCoreImportShims(engine);
// Must run BEFORE RegisterPthreadImportShims - libc_shims.cpp registers
// a placeholder "pthread_join" purely so the symbol is never silently
// unresolved; pthread_shim.cpp's REAL join implementation needs to
// register after it and win (RegisterImportShim is last-registration-
// wins, see guest_engine.cpp).
RegisterLibcImportShims(engine);
RegisterPthreadImportShims(engine);
RegisterGlesImportShims(engine);
RegisterRttiImportShims(engine);
// Must follow RegisterRttiImportShims: the fast path's fallback resolves
// "__dynamic_cast_slowpath", which that call registers.
RegisterDynamicCastFastPath(engine);
RegisterFmodImportShims(engine);
// Must be registered BEFORE LoadImage() - SetupRttiDataSymbols needs to
// run after MapSegments (for AllocPermanent) but strictly before
// ProcessRelocations resolves any GOT slot referencing these typeinfo
// symbols; see GuestEngine::RegisterDataSymbolSetup's own comment for
// why this is a callback rather than a direct call here.
engine.RegisterDataSymbolSetup(&SetupRttiDataSymbols);
engine.RegisterDataSymbolSetup(&SetupLibcDataSymbols);
if (!engine.LoadImage(path)) {
Log("LoadEmulatedLibapp: GuestEngine::LoadImage(%s) failed", path);
return false;
}
// Every existing "(uintptr_t)libapp_base + OFFSET" expression across
// this codebase (car_selection.h, crash_workarounds.h, and the two
// hooks below) now resolves to a real host pointer into the emulator's
// own guest-backing memory, unchanged - see GuestEngine's class comment
// for why this identity mapping is possible.
libapp_base = engine.image_host_base();
// libapp.so's own real JNI_OnLoad (found via .dynsym, same as every
// other offset in real_native_offsets.h) - statically-linked engines
// commonly do extra runtime registration here beyond what the ELF's own
// .init_array (run by GuestEngine::ProcessRelocations, right after
// relocations complete - see its own comment) covers. Calling it needs
// a guest-visible JavaVM* (see jni_shim.h's own comment).
JniShim::Instance().SetRealEnv(env);
GuestAddr guestVm = JniShim::Instance().BuildGuestJavaVM(engine);
uint32_t jniOnLoadResult = engine.CallGuestFunction(REAL_JNI_ONLOAD_OFFSET, guestVm, 0);
Log("LoadEmulatedLibapp: real JNI_OnLoad returned 0x%x", jniOnLoadResult);
// Synthetic benchmark/unit-test calls (RunTcgBenchmarkInRealContext,
// RunCalleeSavedRegisterTest) removed 2026-09-16 - their questions are
// answered (see ARM64_TRANSLATION_LAYER.md): TCG itself isn't the
// bottleneck, and the import-stub dispatch path preserves callee-saved
// registers.
//
// RunReentrantCallRegisterTest call removed again 2026-09-16 (same day,
// second removal) after confirming PASS on-device: once
// GuestEngine::CreateConfiguredEngine/GetOrCreateNestedEngine
// (guest_engine.h/.cpp) gave depth>0 CallGuestFunction calls their own
// uc_engine* instead of re-entering the depth-0 one mid-uc_emu_start(),
// this test completed cleanly (stub_hit=yes, r4-r7 all OK) instead of
// hanging forever - and the app kept running afterward, with real
// reentrant calls (depth=1, even depth=2) happening naturally on other
// threads with no hang. Test function stays in tcg_bench.h/.cpp for
// reuse if this area is ever suspected again.
// 2026-09-05/06 (ARM64_TRANSLATION_LAYER.md "PERFORMANCE tier"
// investigation): sub_547B40 computes a device "performance tier" from
// RAM/a Java-side getPerformanceScore() heuristic/GPU-renderer-string
// and caches it in these two globals (byte_ADFD0C = "already computed",
// dword_ADFD10 = the value). Confirmed live via a native32/armeabi-v7a
// reference build on the Galaxy A9 (real hardware, no emulation): real
// devices settle on "High" (tier 23), reached via a SECOND call once the
// GL renderer string is known (the first, GPU-string-less call takes an
// early-return path that doesn't mark the cache as final, matching the
// observed "Tier = Higest" then "Tier = High" sequence in the real
// device's own log). Pre-seeding 23 here matches that real behavior -
// worth doing regardless, but NOT by itself sufficient to avoid the
// crash below (confirmed live: forcing 23 hits the exact same crash as
// the unforced/default path, just via a different switch case in
// sub_3A4E5C - the real bug is the dword_ADCAA0 issue documented at
// this function's next fix, not which tier gets chosen). Placed AFTER
// JNI_OnLoad/BuildGuestJavaVM (moved 2026-09-06 - see this function's
// own next comment for why ordering here matters).
{
constexpr GuestAddr kPerfTierCachedFlagAddr = 0xADFD0Cu; // byte_ADFD0C
constexpr GuestAddr kPerfTierCachedValueAddr = 0xADFD10u; // dword_ADFD10
constexpr uint32_t kHighTier = 23; // sub_547B40's own tier constant for "High" - confirmed on real hardware
uint32_t tierValue = kHighTier;
uint8_t alreadyCached = 1;
memcpy(engine.G2H(kPerfTierCachedValueAddr), &tierValue, sizeof(tierValue));
memcpy(engine.G2H(kPerfTierCachedFlagAddr), &alreadyCached, sizeof(alreadyCached));
Log("LoadEmulatedLibapp: pre-seeded sub_547B40's performance-tier cache to High (23) - "
"see this call site's own comment");
}
// 2026-09-06 (same investigation, real root cause): dword_ADCAA0 is
// libapp.so's own global "debug log stream" object (used from 200+
// call sites across the whole binary, e.g. every "Foo = bar" line seen
// in logcat under tag "info"/"trace"). Its C++ constructor DOES run
// (via .init_array - confirmed live: dword_ADCAA0's vtable pointer and
// "good" flag at +76 are set up correctly), but the embedded streambuf-
// shaped sub-object's OWN internal pointer (read by sub_3EA194 as "v17",
// then called through via vtable+48 while converting a wide string to
// UTF-8 for logging) is left null - and nothing in this engine ever
// populates it afterward, unlike on real hardware (confirmed via the
// same Galaxy A9 native32 reference build: the identical "PERFORMANCE -
// level = 3" log line - same function, same wide-string-conversion path
// - completes successfully there and the game continues straight into
// SoundManager init). Since real devices reach this through machinery
// this project doesn't emulate (whatever real ART/libc++ construction
// path finishes attaching this sub-object) rather than through anything
// under our control, the pragmatic fix - matching this file's existing
// "pre-seed a real, reasonable state instead of tracing the original
// construction path to its end" precedent for the tier cache above - is
// to give that field a genuine, safe, callable vtable whose slots all
// just return 0 (same generic pattern rtti_shims.cpp's ctype<char>/
// num_put<char> facets already use for library objects this project
// doesn't fully reimplement), instead of leaving it null. Placed AFTER
// BuildGuestJavaVM/BuildGuestJNIEnv's own AllocPermanent/AllocCodeStub
// calls (moved 2026-09-06): confirmed live that placing this block
// BEFORE them let its allocations shift the control/misc-stub arena
// layout under the VM/JNIEnv function tables built later, corrupting a
// JavaVM slot (AttachCurrentThread) that a background pthread_create'd
// thread then jumped through - moving this fix to run strictly after
// every other subsystem's own one-time setup calls avoids stepping on
// arena layout anything else still needs to allocate.
{
// sub_3EA194 (called with a1 = dword_ADCAA0+224, the embedded
// streambuf sub-object) computes its crashing pointer as:
// innerVtable = *(a1) // the streambuf's OWN vtable ptr
// off = *(innerVtable - 12) // real Itanium offset-to-top-style value baked into that vtable
// v17 = *(a1 + off + 24) // THIS is the null field - not a fixed "+248" offset
// Confirmed live this session that assuming off==0 (a naive
// "224+24=248" guess) was WRONG - that write didn't reach the real
// v17 storage location and the crash persisted identically. Read
// `off` from the real, already-constructed vtable instead of
// assuming it, so the fix lands on the actual field regardless of
// this class's real (compiler-chosen) layout.
constexpr GuestAddr kAdcaa0Addr = 0xADCAA0u;
constexpr GuestAddr kStreamBufAddr = kAdcaa0Addr + 224u;
uint32_t innerVtable = 0;
memcpy(&innerVtable, engine.G2H(kStreamBufAddr), 4);
uint32_t off = 0;
if (innerVtable >= 12) memcpy(&off, engine.G2H(innerVtable - 12), 4);
GuestAddr v17Addr = kStreamBufAddr + off + 24u;
Log("LoadEmulatedLibapp: dword_ADCAA0 streambuf vtable=0x%x off=0x%x -> v17 storage at 0x%x "
"(current value 0x%x)",
innerVtable, off, v17Addr, *(uint32_t*)engine.G2H(v17Addr));
constexpr int kNoOpVtableSlots = 16; // generous - the confirmed-needed slot is #12 (byte offset 48)
GuestAddr vtable = engine.AllocPermanent((uint32_t)kNoOpVtableSlots * 4);
for (int i = 0; i < kNoOpVtableSlots; i++) {
GuestAddr stub = engine.AllocCodeStub(
[](uc_engine* uc, uint64_t, uint32_t, void*) {
uint32_t zero = 0;
uc_reg_write(uc, UC_ARM_REG_R0, &zero);
},
nullptr);
if (vtable && stub) memcpy(engine.G2H(vtable + (uint32_t)i * 4), &stub, 4);
}
GuestAddr obj = engine.AllocPermanent(4);
if (obj && vtable) memcpy(engine.G2H(obj), &vtable, 4);
if (obj) {
memcpy(engine.G2H(v17Addr), &obj, 4);
Log("LoadEmulatedLibapp: pre-seeded dword_ADCAA0's real v17 field (0x%x) with a safe "
"no-op vtable object (0x%x) - see this call site's own comment", v17Addr, obj);
} else {
Log("LoadEmulatedLibapp: failed to allocate the dword_ADCAA0 v17 no-op object");
}
}
// Temporarily wired in (2026-09-17, ARM64_TRANSLATION_LAYER.md - the
// 0x3d3d3d3d heap-overflow chase). Tests whether "long first write
// forcing SSO->heap transition, then a real nested function call
// writing more into the same stream" alone reproduces the corruption in
// total isolation from the real game. Remove once this question is
// answered, same discipline as every other one-shot test call in this
// function.
RunOstreamAssemblyNestedReproTest(engine);
// RunOstreamAssemblyReproTest call removed 2026-09-16 - its question is
// answered (see ARM64_TRANSLATION_LAYER.md's "isolated
// std::ostringstream repro" entry): a real, standalone armeabi-v7a
// artifact (ostream_repro/, GuestEngine::LoadSecondaryImage) exercising
// the exact same write-then-extract std::ostringstream pattern as the
// real game's sub_4702D8/sub_27160C came back PASS, twice, with zero
// unresolved-import noise in either run - extracted content matched
// exactly what was written (len=49, retVal=49). This rules OUT a
// general ostringstream/basic_stringbuf<char>::str()-extraction bug in
// GuestEngine itself as the cause of the real game's empty-shader-
// source symptom; whatever's actually wrong is specific to libapp.so's
// own state/control flow reaching sub_4702D8, not this engine's
// translation of the C++ runtime mechanism in general. Same "remove
// spent diagnostics once their question is answered" discipline as the
// RunCalleeSavedRegisterTest/RunReentrantCallRegisterTest removal note
// above - the infrastructure (GuestEngine::LoadSecondaryImage,
// emu/ostream_repro_test.{h,cpp}, ostream_repro/) stays in the tree,
// only this one-shot call site is gone.
// RunStackArgMarshalingTest/RunSequentialCallStateLeakTest calls
// removed 2026-09-16 - both questions answered PASS (see
// ARM64_TRANSLATION_LAYER.md): argCount>4 stack-marshaled arguments
// arrive correctly (all 8 of 8 args, register- and stack-passed alike),
// and three sequential (non-reentrant) calls through the same
// AllocCodeStub-dispatched stub show zero cross-call state leakage.
// Same "remove spent diagnostics once their question is answered"
// discipline as every other synthetic-test removal note in this
// function - the test functions themselves stay in tcg_bench.h/.cpp
// for reuse if this area is ever suspected again.
return true;
}
int (*sub_4087CC)() = nullptr;
@@ -73,255 +385,226 @@ bool is_memory_writable(void* addr, size_t size) {
using namespace std;
// Базовый класс - Животное
class Animal {
protected:
string name;
int age;
// ---- RaceLoaderTask_BuildTrackScenePath hook (see ANALYSIS.md §6j) ----
// Target compiled in ARM mode (PUSH {R4-R11,LR}; ADD R11,SP,#0x1C - both
// position-independent, safe to relocate into the trampoline as-is).
#define BUILDTRACKSCENEPATH_OFFSET 0x2a8424
public:
// Конструктор
Animal(const string& name, int age) : name(name), age(age) {
cout << "Animal constructor: " << name << endl;
static GuestFn<int, void*> orig_BuildTrackScenePath;
// Deliberately different from any real event's track, so a successful
// override is visually unmistakable. region3/colorado was tried first and
// abandoned: it's cut/incomplete content in this build - models/environments/
// has no colorado/ folder at all (only chicago, desert, foothills, garage,
// newyork), even though region3_colorado_track2.scene.sb itself contains
// full embedded geometry. The scene's internal m3g loader still tries to
// open "/published/models/environments/colorado/region3_colorado_track2.m3g"
// as a loose file, which was never shipped -> geometry never loads -> every
// downstream consumer (spatial index, checkpoints, ...) sees empty/zero data
// and null-derefs, which is what the whole sub_53A5FC/sub_52A9B8/sub_52A620/
// sub_58E5E8 crash chain actually was (see ANALYSIS.md §6n/§6o). Switched to
// region4_chicago_track4/chicago instead: confirmed shipped and playable
// (event_01_race.prefabs.sb's real TrackName, verified start/finish actors
// in the scene, and a full models/environments/chicago/ folder present).
static const char* kOverrideTrackName = "region4_chicago_track4";
// Environment prefabs are per-track variants ("chicago1.prefabs.sb" ..
// "chicago6.prefabs.sb", matching "region4_chicago_track1".."track6"), not a
// single generic "chicago.prefabs.sb" - confirmed live: env="chicago" alone
// hit "Could not open database at published/prefabs/environments/chicago.
// prefabs.sb" followed by an immediate SIGSEGV. Only colorado shipped as one
// un-numbered colorado.prefabs.sb instead of colorado1..6 - further evidence
// region3/colorado is unfinished/cut content (see kOverrideTrackName above).
static const char* kOverrideEnvName = "chicago4";
int Hook_BuildTrackScenePath(void* a1) {
// a1[8] (word offset 8 = byte 32): pointer to the RaceDefinition-like
// struct. Track name is a {begin,end} pair at byte offsets +72/+76,
// environment name likewise at +100/+104. BuildTrackScenePath only
// READS these fields (never frees them), so we just repoint begin/end
// at our own static buffers instead of freeing/reallocating the
// originals - avoids guessing an unconfirmed capacity-field offset.
// The original buffers are deliberately leaked (two small allocations
// per race load - negligible).
void* raceDef = *(void**)((uint8_t*)a1 + 32);
if (raceDef) {
size_t trackLen = strlen(kOverrideTrackName);
*(const char**)((uint8_t*)raceDef + 72) = kOverrideTrackName;
*(const char**)((uint8_t*)raceDef + 76) = kOverrideTrackName + trackLen;
size_t envLen = strlen(kOverrideEnvName);
*(const char**)((uint8_t*)raceDef + 100) = kOverrideEnvName;
*(const char**)((uint8_t*)raceDef + 104) = kOverrideEnvName + envLen;
Log("BuildTrackScenePath hook fired: track -> %s, env -> %s", kOverrideTrackName, kOverrideEnvName);
} else {
Log("BuildTrackScenePath hook fired but raceDef (a1[8]) is NULL, skipping override");
}
// Виртуальный деструктор
virtual ~Animal() {
cout << "Animal destructor: " << name << endl;
}
// Виртуальный метод (будет переопределяться)
virtual void makeSound() const {
cout << name << " makes a generic animal sound" << endl;
}
// Не виртуальный метод (не будет переопределяться)
void sleep() const {
cout << name << " is sleeping" << endl;
}
string getName() const { return name; }
int getAge() const { return age; }
};
// Производный класс - Млекопитающее
class Mammal : public Animal {
protected:
bool hasFur;
public:
// Конструктор
Mammal(const string& name, int age, bool hasFur)
: Animal(name, age), hasFur(hasFur) {
cout << "Mammal constructor: " << name << endl;
}
// Деструктор
~Mammal() override {
cout << "Mammal destructor: " << name << endl;
}
// Переопределение метода
void makeSound() const override {
cout << name << " makes a mammal sound" << endl;
}
// Новый метод
void feedMilk() const {
cout << name << " is feeding milk" << endl;
}
};
// Производный класс - Птица
class Bird : public Animal {
protected:
double wingspan;
public:
// Конструктор
Bird(const string& name, int age, double wingspan)
: Animal(name, age), wingspan(wingspan) {
cout << "Bird constructor: " << name << endl;
}
// Деструктор
~Bird() override {
cout << "Bird destructor: " << name << endl;
}
// Переопределение метода
void makeSound() const override {
cout << name << " chirps" << endl;
}
// Новый метод
void fly() const {
cout << name << " is flying with wingspan " << wingspan << "m" << endl;
}
};
// Производный класс от Млекопитающего - Собака
class Dog : public Mammal {
private:
string breed;
public:
// Конструктор
Dog(const string& name, int age, bool hasFur, const string& breed)
: Mammal(name, age, hasFur), breed(breed) {
cout << "Dog constructor: " << name << endl;
}
// Деструктор
~Dog() override {
cout << "Dog destructor: " << name << endl;
}
// Переопределение метода
void makeSound() const override {
cout << name << " barks: Woof! Woof!" << endl;
}
// Новый метод
void fetch() const {
cout << name << " is fetching the ball" << endl;
}
};
// Производный класс от Млекопитающего - Кошка
class Cat : public Mammal {
private:
int lives;
public:
// Конструктор
Cat(const string& name, int age, bool hasFur, int lives = 9)
: Mammal(name, age, hasFur), lives(lives) {
cout << "Cat constructor: " << name << endl;
}
// Деструктор
~Cat() override {
cout << "Cat destructor: " << name << endl;
}
// Переопределение метода
void makeSound() const override {
cout << name << " meows: Meow! Meow!" << endl;
}
// Новый метод
void purr() const {
cout << name << " is purring" << endl;
}
};
// Производный класс от Птицы - Орел
class Eagle : public Bird {
private:
double visionRange;
public:
// Конструктор
Eagle(const string& name, int age, double wingspan, double visionRange)
: Bird(name, age, wingspan), visionRange(visionRange) {
cout << "Eagle constructor: " << name << endl;
}
// Деструктор
~Eagle() override {
cout << "Eagle destructor: " << name << endl;
}
// Метод НЕ переопределяется (используется версия из Bird)
// void makeSound() const override {...}
// Новый метод
void hunt() const {
cout << name << " is hunting with vision range " << visionRange << "km" << endl;
}
};
// Производный класс от Птицы - Попугай
class Parrot : public Bird {
private:
bool canTalk;
public:
// Конструктор
Parrot(const string& name, int age, double wingspan, bool canTalk)
: Bird(name, age, wingspan), canTalk(canTalk) {
cout << "Parrot constructor: " << name << endl;
}
// Деструктор
~Parrot() override {
cout << "Parrot destructor: " << name << endl;
}
// Переопределение метода
void makeSound() const override {
if (canTalk) {
cout << name << " says: Hello! Polly wants a cracker!" << endl;
} else {
cout << name << " squawks" << endl;
}
}
// Новый метод
void repeat(const string& phrase) const {
if (canTalk) {
cout << name << " repeats: " << phrase << endl;
}
}
};
// Пример использования
int main() {
cout << "=== Creating objects ===" << endl;
Dog dog("Rex", 3, true, "German Shepherd");
Cat cat("Whiskers", 2, true);
Eagle eagle("Thor", 5, 2.1, 3.5);
Parrot parrot("Polly", 1, 0.3, true);
cout << "\n=== Testing methods ===" << endl;
dog.makeSound(); // Переопределен в Dog
dog.fetch(); // Уникальный метод Dog
cat.makeSound(); // Переопределен в Cat
cat.purr(); // Уникальный метод Cat
eagle.makeSound(); // НЕ переопределен - использует версию Bird
eagle.hunt(); // Уникальный метод Eagle
parrot.makeSound(); // Переопределен в Parrot
parrot.repeat("I love C++"); // Уникальный метод Parrot
cout << "\n=== Testing polymorphism ===" << endl;
Animal* animals[] = {&dog, &cat, &eagle, &parrot};
for (Animal* animal : animals) {
animal->makeSound(); // Полиморфный вызов
animal->sleep(); // Не виртуальный метод - всегда Animal::sleep()
}
cout << "\n=== Objects going out of scope ===" << endl;
// Деструкторы будут вызваны автоматически
return 0;
return orig_BuildTrackScenePath(a1);
}
// Ported onto GuestEngine (2026-08-19): same target offset, same
// precondition (position-independent 2-instruction prologue, already
// verified live-byte-matched against the real libapp.so this session - see
// scratchpad/spike_load.py), same trampoline TECHNIQUE (verbatim copy of
// the displaced instructions + jump back to target+8) - it now just builds
// that trampoline in Unicorn-backed guest memory and is invoked via
// CallGuestFunction instead of live byte-patching a real dlopen'd library.
// See guest_engine.h/guest_fn.h for the mechanism.
static bool InstallBuildTrackScenePathHook() {
orig_BuildTrackScenePath = InstallTrampolineHook(
BUILDTRACKSCENEPATH_OFFSET, &Hook_BuildTrackScenePath, "RaceLoaderTask_BuildTrackScenePath hook");
return (bool)orig_BuildTrackScenePath;
}
// ---- MapScreen constructor trace hook (temporary, RE discovery only) ----
// Purpose: capture the live `im::app::flow::nfs::MapScreen` instance pointer
// so we can read its "scroll" layout-entity (found via sub_1332B8's
// FindOrCreateLayoutEntity call with the literal name "scroll" - see
// PROGRESS.md) - a Transform-shaped object whose position (offset +36/+40)
// and scale (offset +44/+48) are hypothesized to be the map's current
// pan/zoom state, needed to convert a MapTrack's world-space bounds rect
// (found earlier, offsets +0x44.."+0x50") into real screen pixels.
#define MAPSCREEN_CTOR_OFFSET 0x1781BC
static GuestFn<void*, void*> orig_MapScreenCtor;
void* g_mapScreenInstance = nullptr;
void* Hook_MapScreenCtor(void* a1) {
void* result = orig_MapScreenCtor(a1);
g_mapScreenInstance = a1;
Log("MapScreen constructed: %p", a1);
return result;
}
// Ported onto GuestEngine (2026-08-19) - see InstallBuildTrackScenePathHook's
// own comment just above for the mechanism; identical technique.
static bool InstallMapScreenCtorTraceHook() {
orig_MapScreenCtor = InstallTrampolineHook(
MAPSCREEN_CTOR_OFFSET, &Hook_MapScreenCtor, "MapScreen ctor trace hook");
return (bool)orig_MapScreenCtor;
}
// Flip to false to run the game completely unmodified (e.g. to capture a
// baseline/"before" comparison) - true installs the track-substitution hook.
// Just edit this and rebuild, no need to touch anything else.
//
// cont.66 CRITICAL FIX: Hook_BuildTrackScenePath (see its own comment)
// substitutes the track/env for EVERY race load with zero gating - not
// just our own synthetic LAN test event, but any real race too, including
// the game's own scripted prologue/tutorial race. Live-tested crash on a
// genuinely fresh save (SIGSEGV, null pointer deref, fault addr 0x14, on a
// background loading thread right after "BuildTrackScenePath hook fired"
// during the prologue's own PreRaceLoadingScreen) - the prologue's other
// scripted data (checkpoints/cutscene triggers/start-finish) still expects
// the ORIGINAL track, so substituting it mismatches and null-derefs
// downstream, same crash class already documented above (region3/colorado
// case). This was never hit before because every previous test session
// used a save that had already passed the prologue - this project's own
// "Track substitution scope" note ("only regular races need to work") was
// an *intent*, never actually enforced in code. Defaulting to `false` -
// this PoC toggle should only be flipped on deliberately, for a specific
// track-substitution test, not left on as the default running state.
static constexpr bool kEnableTrackSubstitutionHook = false;
static constexpr bool kEnableMapScreenCtorTraceHook = false; // TEMP: isolating a reproducible SIGSEGV, see PROGRESS.md
// JNI_OnLoad only sets up the (unaffected, host-side-only) GameEvents JNI
// bridge now - loading libapp.so and installing hooks against it needs a
// real filesystem path to the extracted asset (see LoadEmulatedLibapp's own
// comment), which JNI_OnLoad has no reliable way to obtain on its own
// (no Context/AssetManager access at this point). See
// Java_..._MultiplayerCore_loadEmulatedLibapp below for where that now happens.
// extern "C" is NOT optional here: without it the name is C++-mangled and the
// JVM cannot find it by its JNI name. The first version omitted it, and the
// only symptom was "No implementation found" at runtime - which looked exactly
// like a load-order problem and cost a wrong fix before the symbol table was
// actually read.
extern "C" JNIEXPORT void JNICALL
Java_com_ea_ironmonkey_GameActivityMain_nativeInstallCrashHandler(JNIEnv* env, jobject,
jstring dir, jstring buildStamp) {
const char* d = dir ? env->GetStringUTFChars(dir, nullptr) : nullptr;
const char* b = buildStamp ? env->GetStringUTFChars(buildStamp, nullptr) : nullptr;
InstallCrashHandler(d, b);
if (d) env->ReleaseStringUTFChars(dir, d);
if (b) env->ReleaseStringUTFChars(buildStamp, b);
}
// cont.69/70: subtask 2.1 diagnostic pass - logs OpponentCollection's built
// Opponent vector and StreetRaceStartingGrid's own placement-vector argument
// side by side, to confirm live whether they're the same set before writing
// the real substitution hook (see opponent_substitution.h's own comment and
// ANALYSIS.md §6hh). Diagnostic-only for now - does not change any opponent
// data yet, only logs. Gated independently so it can stay on for real-race
// testing without pulling in unrelated hooks.
static constexpr bool kEnableOpponentGridDiagnosticHooks = true;
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
get_libapp_base();
JNIEnv* env = nullptr;
if (vm->GetEnv((void**)&env, JNI_VERSION_1_6) == JNI_OK) {
InitGameEvents(vm, env);
} else {
Log("JNI_OnLoad: GetEnv failed, GameEvents bridge not initialised");
}
// Branch merge (2026-09-22): the native32/lan-event-injection-poc
// branch's own JNI_OnLoad installs ~20 hooks here (opponent
// substitution, cop/traffic removal, the LAN event-injection state
// machine, several crash workarounds) against a real dlopen'd
// libapp.so, available at JNI_OnLoad time. On this build libapp.so
// isn't loaded until Java calls loadEmulatedLibapp() well after
// JNI_OnLoad (see LoadEmulatedLibapp's own comment - no
// Context/AssetManager access this early) - `get_libapp_base()` would
// always be null here regardless, and every one of those Install*Hook
// calls resolves a raw "libapp_base + OFFSET" function pointer that
// needs GuestFn/CallGuestFunction plumbing to be callable at all (see
// this file's own comment above the commented-out includes). None of
// it is wired in here - deliberately, not an oversight - pending the
// same porting session as lan_event_injection.h/
// opponent_substitution.h/cop_traffic_disable.h themselves.
return JNI_VERSION_1_6;
}
// Called from Kotlin once it has extracted the bundled armeabi-v7a
// libapp.so asset to a real file (see GameActivityMain.kt) - loads it
// through the embedded ARM32 emulation core and installs whichever hooks
// are enabled. Returns true on success. See ARM64_TRANSLATION_LAYER.md and
// this file's own comment above the (currently excluded)
// "#include lan_event_injection.h" line for what is and isn't wired up yet.
extern "C"
JNIEXPORT jboolean JNICALL
Java_nfs_mod_mpcore_MultiplayerCore_loadEmulatedLibapp(JNIEnv* env, jobject thiz, jstring path) {
const char* pathUtf8 = env->GetStringUTFChars(path, nullptr);
bool ok = LoadEmulatedLibapp(pathUtf8, env, thiz);
env->ReleaseStringUTFChars(path, pathUtf8);
if (!ok) return JNI_FALSE;
if (kEnableTrackSubstitutionHook) {
InstallBuildTrackScenePathHook();
}
if (kEnableMapScreenCtorTraceHook) {
InstallMapScreenCtorTraceHook();
}
Log("loadEmulatedLibapp: libapp.so loaded into the emulation core, host base=%p", libapp_base);
return JNI_TRUE;
}
extern "C"
JNIEXPORT void JNICALL
Java_nfs_mod_mpcore_MultiplayerCore_bumpBackTraceToLogcat(JNIEnv *env, jobject thiz) {
//backtraceToLogcat();
}
// cont.44/48's on-demand car_select triggers depend on the LAN
// event-injection subsystem, which isn't wired into this emulated build yet
// (see the comment above this file's excluded lan_event_injection.h
// include). Kept as no-op JNI stubs, not removed outright, so
// MultiplayerCore.kt's existing `external fun` declarations still link.
extern "C"
JNIEXPORT void JNICALL
Java_nfs_mod_mpcore_MultiplayerCore_triggerCarSelectTest(JNIEnv *env, jobject thiz) {
Log("triggerCarSelectTest: not available yet in the ARM64 emulated-core build (see main.cpp)");
}
extern "C"
JNIEXPORT void JNICALL
Java_nfs_mod_mpcore_MultiplayerCore_triggerTrueDirectCarSelectJump(JNIEnv *env, jobject thiz) {
Log("triggerTrueDirectCarSelectJump: not available yet in the ARM64 emulated-core build (see main.cpp)");
}
+86
View File
@@ -0,0 +1,86 @@
#pragma once
// Tracks which upgrade the player picks in each loadout mod-slot picker, so
// the accepted set can be dispatched to Kotlin once the player exits back to
// the map. See PROGRESS.md cont.38/39 for the full derivation.
#include <cstdint>
#include "util/util.h"
#include "util/hook_install.h"
#include "game_events.h"
extern void* libapp_base;
// sub_37BF34(slotComponent, selectedEvent) commits a player's pick from the
// loadout screen's mod-picker into that slot's UI. Its only caller dispatches
// a UIRolloutSelectedEvent (type 1057) to EVERY slot's handler, not just the
// one whose picker was open - all slots see the identical event data, and
// only the slot whose own dirty flag (`a1+MODSLOT_DIRTY_FLAG_OFFSET`) is set
// actually applies it (the same flag sub_37BF34 itself checks, read here
// before the original call clears it). Recording without that check was
// live-tested and found to record the SAME pick for every slot from one tap.
#define MODSLOT_SELECTED_OFFSET 0x37BF34
#define MODSLOT_DIRTY_FLAG_OFFSET 289
#define MAX_TRACKED_MOD_SLOTS 8
struct ModSlotSelection {
int slotId;
int carMod; // 0 = no mod selected ("NONE"/empty slot)
};
static ModSlotSelection g_modSlotSelections[MAX_TRACKED_MOD_SLOTS] = {};
static int g_modSlotSelectionCount = 0;
static void RecordModSlotSelection(int slotId, int carMod) {
for (int i = 0; i < g_modSlotSelectionCount; i++) {
if (g_modSlotSelections[i].slotId == slotId) {
g_modSlotSelections[i].carMod = carMod;
Log("ModSlot: slot %d updated -> CarMod=0x%x", slotId, carMod);
return;
}
}
if (g_modSlotSelectionCount < MAX_TRACKED_MOD_SLOTS) {
g_modSlotSelections[g_modSlotSelectionCount].slotId = slotId;
g_modSlotSelections[g_modSlotSelectionCount].carMod = carMod;
g_modSlotSelectionCount++;
Log("ModSlot: slot %d recorded -> CarMod=0x%x", slotId, carMod);
} else {
Log("ModSlot: tracking array full (%d), dropping slot %d selection", MAX_TRACKED_MOD_SLOTS, slotId);
}
}
// Dispatches whatever's accumulated in g_modSlotSelections[] to Kotlin via
// GameEvents.onUpgradesAccepted - called once from Hook_LayoutScreenCtor,
// right as the controlled exit chain lands back on MapOverworld. Forward-
// declared in lan_event_injection.h since that hook is defined earlier in
// the file than this one. Deliberately does not reset
// g_modSlotSelectionCount afterward - see PROGRESS.md cont.39.
static void PersistAcceptedUpgrades() {
int slotIds[MAX_TRACKED_MOD_SLOTS];
int carMods[MAX_TRACKED_MOD_SLOTS];
for (int i = 0; i < g_modSlotSelectionCount; i++) {
slotIds[i] = g_modSlotSelections[i].slotId;
carMods[i] = g_modSlotSelections[i].carMod;
}
Log("PersistAcceptedUpgrades: exiting to map, dispatching %d accepted slot(s) to Kotlin",
g_modSlotSelectionCount);
FireUpgradesAccepted(slotIds, carMods, g_modSlotSelectionCount);
}
typedef int (*ModSlotSelectedFn)(int a1, int a2);
static ModSlotSelectedFn orig_ModSlotSelected = nullptr;
extern "C" int Hook_ModSlotSelected(int a1, int a2) {
bool applies = *(uint8_t*)((uint8_t*)(uintptr_t)a1 + MODSLOT_DIRTY_FLAG_OFFSET) != 0;
if (applies) {
int slotId = *(int*)((uint8_t*)(uintptr_t)a1 + 292);
int carMod = *(int*)((uint8_t*)(uintptr_t)a2 + 8);
RecordModSlotSelection(slotId, carMod);
}
return orig_ModSlotSelected(a1, a2);
}
static bool InstallModSlotSelectedHook() {
orig_ModSlotSelected = (ModSlotSelectedFn)InstallArmTrampolineHook(
libapp_base, MODSLOT_SELECTED_OFFSET, (void*)&Hook_ModSlotSelected, "ModSlotSelected hook");
return orig_ModSlotSelected != nullptr;
}
+227
View File
@@ -0,0 +1,227 @@
#pragma once
// Subtask 2.1 — substitute real lobby players' car+color into AI opponent
// slots. See ANALYSIS.md §6hh and ARCHITECTURE.md §3b for the full RE
// writeup this is built on.
//
// cont.70 CONFIRMED LIVE: OpponentCollection::PopulateFromProperties
// (sub_2B649C) builds a vector of Opponent* (8-byte elements) at
// this+12/+16/+20. StreetRaceStartingGrid's own placement method
// (sub_2B88BC) separately iterates a DIFFERENT vector (12-byte elements,
// different heap addresses entirely, confirmed by comparing live pointer
// dumps from both hooks) - so the two are NOT the same objects, there's an
// intermediate spawn/resolve step between them. Despite that, overwriting
// Opponent.CarDescriptionName/ColourIndex (+20/+24 string, +36 int) at
// Populate time (map-load) WAS confirmed live to reach the actual spawned,
// rendered car at the starting grid - screenshotted a Ford Focus RS500
// forced into an opponent slot that was a completely different car/color
// before the hook, on a real (non-synthetic) "Перед вами FAIRHAVEN" replay.
// Whatever the intermediate step is, it reads CarDescriptionName/ColourIndex
// off these same Opponent objects (or a value-copy taken after Populate
// already ran), not off some earlier, already-fixed snapshot. Mechanism is
// proven; the intermediate step itself was not traced (not needed for the
// question this was testing).
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include "util/util.h"
#include "util/hook_install.h"
extern void* libapp_base;
#define OPPONENT_COLLECTION_POPULATE_OFFSET 0x2B649C
#define STREET_RACE_GRID_PLACE_OFFSET 0x2B88BC
typedef int (*OpponentCollectionPopulateFn)(uint32_t* thisPtr, int a2, uint32_t* a3);
static OpponentCollectionPopulateFn orig_OpponentCollectionPopulate = nullptr;
static volatile int g_opponentPopulateLogBudget = 2000;
// Blanket test substitution (every opponent slot of EVERY collection, any
// race) that produced the cont.70 live confirmation above. Kept here, but
// gated OFF by default (g_enableBlanketOpponentSubstitutionTest) - it has no
// way to target a specific race or a specific real lobby player yet (no
// lobby system exists to source that data from), so leaving it on would
// silently affect every race in normal play. Re-enable deliberately only for
// another isolated live test; the real, production substitution hook
// (targeted, sourced from actual lobby player data) is a separate follow-up
// once the lobby UI/data model exists to drive it.
//
// cont.72: extended from a single fixed test car (cont.70) to a small roster
// of distinct cars/colors cycling by slot index, to emulate what a real
// multi-player lobby would look like (each opponent slot = "a different
// player's own car"), per direct request.
struct TestSubstituteCar {
const char* carId;
int colourIndex;
};
// cont.76: user-requested specific demo roster (car IDs sourced from
// unpacked game data files, not binary strings, per user's own correction).
static const TestSubstituteCar kTestSubstituteRoster[] = {
{"marussia_b2_2011_desc", 0},
{"lamborghini_gallardo_lp570_4_superleggera_2011_desc", 0},
{"srt_viper_2013_desc", 0},
{"lamborghini_aventador_lp700_4_2011_desc", 0},
};
static const int kTestSubstituteRosterLength =
(int)(sizeof(kTestSubstituteRoster) / sizeof(kTestSubstituteRoster[0]));
static volatile bool g_enableBlanketOpponentSubstitutionTest = true;
extern "C" int Hook_OpponentCollectionPopulate(uint32_t* thisPtr, int a2, uint32_t* a3) {
int result = orig_OpponentCollectionPopulate(thisPtr, a2, a3);
uint32_t* begin = (uint32_t*)thisPtr[3];
uint32_t* end = (uint32_t*)thisPtr[4];
int count = begin ? (int)(end - begin) / 2 : 0;
if (count > 0 && g_enableBlanketOpponentSubstitutionTest) {
for (int i = 0; i < count; i++) {
uint32_t* opp = (uint32_t*)begin[i * 2];
if (!opp) continue;
const TestSubstituteCar& car = kTestSubstituteRoster[i % kTestSubstituteRosterLength];
size_t len = strlen(car.carId);
*(const char**)((uint8_t*)opp + 20) = car.carId;
*(const char**)((uint8_t*)opp + 24) = car.carId + len;
*(int*)((uint8_t*)opp + 36) = car.colourIndex;
}
}
if (g_opponentPopulateLogBudget > 0) {
g_opponentPopulateLogBudget--;
Log("DIAG OpponentCollection::Populate: this=%p vec=[%p..%p) count=%d", thisPtr, begin, end, count);
for (int i = 0; i < count && i < 8; i++) {
uint32_t* opp = (uint32_t*)begin[i * 2];
if (!opp) {
Log(" [%d] Opponent*=NULL", i);
continue;
}
const char* nameBegin = *(const char**)((uint8_t*)opp + 20);
const char* nameEnd = *(const char**)((uint8_t*)opp + 24);
int nameLen = (nameEnd && nameBegin && nameEnd > nameBegin) ? (int)(nameEnd - nameBegin) : 0;
char buf[96];
if (nameLen > 0 && nameLen < (int)sizeof(buf)) {
memcpy(buf, nameBegin, nameLen);
buf[nameLen] = 0;
} else {
buf[0] = 0;
}
int colourIndex = *(int*)((uint8_t*)opp + 36);
Log(" [%d] Opponent*=%p CarDescriptionName=\"%s\" ColourIndex=%d (AFTER any substitution)", i, opp, buf, colourIndex);
}
}
return result;
}
static bool InstallOpponentCollectionPopulateHook() {
orig_OpponentCollectionPopulate = (OpponentCollectionPopulateFn)InstallArmTrampolineHook(
libapp_base, OPPONENT_COLLECTION_POPULATE_OFFSET, (void*)&Hook_OpponentCollectionPopulate,
"OpponentCollection::Populate diag hook");
return orig_OpponentCollectionPopulate != nullptr;
}
typedef int (*StreetRaceGridPlaceFn)(int a1, int* a2, int a3, uint32_t* a4);
static StreetRaceGridPlaceFn orig_StreetRaceGridPlace = nullptr;
static volatile int g_gridPlaceLogBudget = 20;
// cont.72: random player grid position. sub_2B88BC's own algorithm (see
// ANALYSIS.md §6hh Q2) is why the player is always last - it loops over
// every opponent first (accumulating a randomized inter-car distance each
// time via sub_291BA4/PlaceOpponent), THEN places the player once, at
// whatever distance the loop finished on. There's no field to flip for
// this - the "always last" behavior is baked into the call ORDER, so
// randomizing it means reimplementing that order, not just tweaking data
// beforehand/afterward like every other hook in this project. This calls
// the same two real placement primitives orig uses (sub_291BA4 for an
// opponent - keeps its own lateral zigzag math untouched; sub_2914DC/
// PlaceCar directly for the player, lateral=0, matching orig's own player
// call) in a randomly reordered sequence: count+1 total slots (N opponents
// + 1 player), one is picked at random for the player, the rest go to
// opponents in their original order. Distance accumulation matches orig's
// own shape (place, then advance by a random offset in
// [MinDistanceBetweenRacers, MaxDistanceBetweenRacers]) but uses plain
// rand() instead of replicating sub_75680/sub_61C9F8's own RNG chain
// (which is seeded from the just-placed car's own return value in a way
// not fully understood - not worth the risk of misusing it for a test
// hook). Falls back to the real, untouched orig_StreetRaceGridPlace
// whenever this test is disabled - zero behavior change for normal play.
typedef int (*PlaceOpponentFn)(int a1, int a2, int a3, float a4, float a5, int a6);
typedef int (*PlaceCarFn)(int a1, uint32_t* a2, int a3, int a4, float a5);
static PlaceOpponentFn PlaceOpponent = nullptr;
static PlaceCarFn PlaceCar = nullptr;
static volatile bool g_enableRandomPlayerGridPositionTest = true;
extern "C" int Hook_StreetRaceGridPlace(int a1, int* a2, int a3, uint32_t* a4) {
if (g_gridPlaceLogBudget > 0) {
g_gridPlaceLogBudget--;
uint32_t begin = a4[0];
uint32_t end = a4[1];
int count = (end > begin) ? (int)(end - begin) / 12 : 0;
Log("DIAG StreetRaceGrid::Place: a1(grid)=%p a2(ctx)=%p a3(playerHandle)=0x%x vec=[0x%x..0x%x) count=%d",
(void*)(uintptr_t)a1, (void*)a2, a3, begin, end, count);
for (int i = 0; i < count && i < 8; i++) {
uint32_t* elem = (uint32_t*)(uintptr_t)(begin + i * 12);
Log(" [%d] word0=0x%x word1=0x%x word2=0x%x", i, elem[0], elem[1], elem[2]);
}
}
if (!g_enableRandomPlayerGridPositionTest || !PlaceOpponent || !PlaceCar) {
return orig_StreetRaceGridPlace(a1, a2, a3, a4);
}
uint32_t begin = a4[0];
uint32_t end = a4[1];
int count = (end > begin) ? (int)(end - begin) / 12 : 0;
if (count <= 0) {
return orig_StreetRaceGridPlace(a1, a2, a3, a4);
}
float minDist = *(float*)((uint8_t*)(uintptr_t)a1 + 12);
float maxDist = *(float*)((uint8_t*)(uintptr_t)a1 + 16);
float trackWidthFraction = *(float*)((uint8_t*)(uintptr_t)a1 + 20);
float playerSpeed = *(float*)((uint8_t*)(uintptr_t)a1 + 24);
float opponentSpeed = *(float*)((uint8_t*)(uintptr_t)a1 + 28);
int playerSlot = rand() % (count + 1); // 0..count inclusive - count+1 total physical slots
int ctxDeref = *a2;
float distance = 0.0f;
int opponentIndex = 0;
Log("RANDOM GRID TEST: %d opponents, player placed at slot %d/%d", count, playerSlot, count);
// Opponent lane index a6 cycles ((a6+1)%3), and a6%3==0 lands exactly on
// world-lateral 0 (center) - the SAME literal offset PlaceCar always uses
// for the player. Vanilla is safe because the player is placed once, at
// the very end, past the entire accumulated distance - never adjacent to
// any specific opponent. Here the player can land next to an opponent
// sharing that center lane, and a plain minDist..maxDist gap (sized for
// adjacent DIFFERENT lanes) isn't enough separation for two cars in the
// SAME lane - reproduced live as a spawn-inside-another-car launch bug.
// Fix: widen the distance gap on both sides of the player's slot so
// longitudinal separation alone guarantees no overlap, regardless of lane.
for (int slot = 0; slot <= count; slot++) {
if (slot == playerSlot) {
PlaceCar(ctxDeref, (uint32_t*)(uintptr_t)a3, (int)distance, 0, playerSpeed);
} else {
uint32_t* elem = (uint32_t*)(uintptr_t)(begin + opponentIndex * 12);
PlaceOpponent(ctxDeref, (int)(uintptr_t)elem, (int)distance, trackWidthFraction, opponentSpeed, opponentIndex);
opponentIndex++;
}
float randomFraction = (float)rand() / (float)RAND_MAX;
bool adjacentToPlayer = (slot == playerSlot) || (slot + 1 == playerSlot);
float gapMin = adjacentToPlayer ? (minDist + maxDist) : minDist;
float gapMax = adjacentToPlayer ? (minDist + maxDist) * 2.0f : maxDist;
distance += gapMin + randomFraction * (gapMax - gapMin);
}
return 1;
}
static bool InstallStreetRaceGridPlaceHook() {
orig_StreetRaceGridPlace = (StreetRaceGridPlaceFn)InstallArmTrampolineHook(
libapp_base, STREET_RACE_GRID_PLACE_OFFSET, (void*)&Hook_StreetRaceGridPlace,
"StreetRaceGrid::Place diag hook");
PlaceOpponent = (PlaceOpponentFn)((uintptr_t)libapp_base + 0x291BA4);
PlaceCar = (PlaceCarFn)((uintptr_t)libapp_base + 0x2914DC);
return orig_StreetRaceGridPlace != nullptr;
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
// Shared helper for the JNIEXPORT wrappers in game_lifecycle_stubs*.cpp:
// calls one of libapp.so's own real native functions (real_native_offsets.h)
// through the emulation core, marshaling `env`/`thiz` into the guest JNIEnv/
// jobject handle scheme (emu/jni_shim.h) first.
#include <jni.h>
#include <vector>
#include "emu/guest_engine.h"
#include "emu/jni_shim.h"
inline uint32_t CallRealNative(JNIEnv* env, jobject thiz, GuestAddr offset,
std::initializer_list<uint32_t> extraArgs = {}) {
JniShim::Instance().SetRealEnv(env);
GuestAddr guestEnv = JniShim::Instance().BuildGuestJNIEnv(GuestEngine::Instance());
uint32_t guestThiz = JniShim::Instance().handles().Alloc(thiz);
std::vector<uint32_t> args = {guestEnv, guestThiz};
args.insert(args.end(), extraArgs);
return GuestEngine::Instance().CallGuestFunction(offset, args.data(), (int)args.size());
}
inline uint32_t GuestHandleFor(jobject obj) {
return JniShim::Instance().handles().Alloc(obj);
}
+51
View File
@@ -0,0 +1,51 @@
#pragma once
// Real guest addresses of libapp.so's own JNI-exported native functions -
// found this session via a plain .dynsym dump (readelf/pyelftools), NOT IDA
// RE: even though the file is stripped, JNI symbols stay in .dynsym so the
// dynamic linker/dlsym can find them, which meant every one of these was
// available for free. See ARM64_TRANSLATION_LAYER.md's "boot the game"
// follow-up notes for context. All are ARM-mode entry points (standard for
// this NDK/compiler combination, consistent with every other hooked offset
// in this codebase already being ARM-mode).
//
// Verify with: readelf -sW native_lib/libapp.so | grep 'Java_\|JNI_OnLoad'
#define REAL_JNI_ONLOAD_OFFSET 0x54e124
#define REAL_EAIO_STARTUP_OFFSET 0x6bc7d4
#define REAL_EAIO_SHUTDOWN_OFFSET 0x6bc804
#define REAL_STORAGEDIR_STARTUP_OFFSET 0x764264
#define REAL_STORAGEDIR_SHUTDOWN_OFFSET 0x764378
#define REAL_EATHREAD_INIT_OFFSET 0x643328
#define REAL_NIMBLE_ON_APP_LAUNCH_OFFSET 0x96c781
#define REAL_NIMBLE_ON_APP_QUIT_OFFSET 0x96c8c9
#define REAL_NIMBLE_ON_APP_RESUME_OFFSET 0x96c855
#define REAL_NIMBLE_ON_APP_SUSPEND_OFFSET 0x96c7f5
#define REAL_NIMBLE_ON_UPDATE_LAUNCH_METHOD_OFFSET 0x96c925
#define REAL_GAMEACTIVITY_ONCREATE_OFFSET 0x54c6e0
#define REAL_GAMEACTIVITY_ONDESTROY_OFFSET 0x54cb20
#define REAL_GAMEACTIVITY_ONMUSICPLAYERSTATECHANGED_OFFSET 0x54cfc8
#define REAL_GAMEACTIVITY_ONORIENTATIONCHANGE_OFFSET 0x54cfe4
#define REAL_GAMEACTIVITY_ONPAUSE_OFFSET 0x54c904
#define REAL_GAMEACTIVITY_ONPHYSICALKEYDOWN_OFFSET 0x54cb98
#define REAL_GAMEACTIVITY_ONPHYSICALKEYUP_OFFSET 0x54cc88
#define REAL_GAMEACTIVITY_ONPHYSICALNAVIGATIONVISIBILITYCHANGED_OFFSET 0x54cd84
#define REAL_GAMEACTIVITY_ONRESTART_OFFSET 0x54c900
#define REAL_GAMEACTIVITY_ONRESUME_OFFSET 0x54c920
#define REAL_GAMEACTIVITY_ONSTART_OFFSET 0x54c8e4
#define REAL_GAMEACTIVITY_ONSTOP_OFFSET 0x54c93c
#define REAL_GAMEACTIVITY_RESTORECONTEXT_OFFSET 0x54cb70
#define REAL_GAMEACTIVITY_SURFACECHANGED_OFFSET 0x54cb50
#define REAL_GAMEACTIVITY_SURFACECREATED_OFFSET 0x54cb48
#define REAL_RUNLOOP_TICK_OFFSET 0x54e100
#define REAL_MOGA_ONKEYEVENT_OFFSET 0x265ea0
#define REAL_MOGA_ONMOTIONEVENT_OFFSET 0x2667a0
#define REAL_MOGA_ONSTATEEVENT_OFFSET 0x266cdc
#define REAL_GLSURFACEVIEW_TOUCHPAD_OFFSET 0x54d9d4
#define REAL_GLSURFACEVIEW_TOUCHSCREEN_OFFSET 0x54d764
+16
View File
@@ -0,0 +1,16 @@
BasedOnStyle: LLVM
IndentWidth: 4
UseTab: Never
BreakBeforeBraces: Linux
AllowShortIfStatementsOnASingleLine: Never
AllowShortCaseLabelsOnASingleLine: false
AllowShortBlocksOnASingleLine: Empty
AllowShortFunctionsOnASingleLine: Empty
AllowShortLoopsOnASingleLine: false
IndentCaseLabels: false
ColumnLimit: 80
SortIncludes: false
AllowShortLambdasOnASingleLine: Inline
AlwaysBreakBeforeMultilineStrings: false
BreakStringLiterals: true
PointerAlignment: Right
@@ -0,0 +1,3 @@
[submodule "docs/Unicorn_Engine_Documentation"]
path = docs/Unicorn_Engine_Documentation
url = https://github.com/kabeor/Unicorn-Engine-Documentation
+2
View File
@@ -0,0 +1,2 @@
Nguyen Anh Quynh <aquynh -at- gmail.com>
Dang Hoang Vu <dang.hvu -at- gmail.com>
File diff suppressed because it is too large Load Diff
+339
View File
@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+481
View File
@@ -0,0 +1,481 @@
GNU LIBRARY GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1991 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the library GPL. It is
numbered 2 because it goes with version 2 of the ordinary GPL.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Library General Public License, applies to some
specially designated Free Software Foundation software, and to any
other libraries whose authors decide to use it. You can use it for
your libraries, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if
you distribute copies of the library, or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link a program with the library, you must provide
complete object files to the recipients so that they can relink them
with the library, after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
Our method of protecting your rights has two steps: (1) copyright
the library, and (2) offer you this license which gives you legal
permission to copy, distribute and/or modify the library.
Also, for each distributor's protection, we want to make certain
that everyone understands that there is no warranty for this free
library. If the library is modified by someone else and passed on, we
want its recipients to know that what they have is not the original
version, so that any problems introduced by others will not reflect on
the original authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that companies distributing free
software will individually obtain patent licenses, thus in effect
transforming the program into proprietary software. To prevent this,
we have made it clear that any patent must be licensed for everyone's
free use or not licensed at all.
Most GNU software, including some libraries, is covered by the ordinary
GNU General Public License, which was designed for utility programs. This
license, the GNU Library General Public License, applies to certain
designated libraries. This license is quite different from the ordinary
one; be sure to read it in full, and don't assume that anything in it is
the same as in the ordinary license.
The reason we have a separate public license for some libraries is that
they blur the distinction we usually make between modifying or adding to a
program and simply using it. Linking a program with a library, without
changing the library, is in some sense simply using the library, and is
analogous to running a utility program or application program. However, in
a textual and legal sense, the linked executable is a combined work, a
derivative of the original library, and the ordinary General Public License
treats it as such.
Because of this blurred distinction, using the ordinary General
Public License for libraries did not effectively promote software
sharing, because most developers did not use the libraries. We
concluded that weaker conditions might promote sharing better.
However, unrestricted linking of non-free programs would deprive the
users of those programs of all benefit from the free status of the
libraries themselves. This Library General Public License is intended to
permit developers of non-free programs to use free libraries, while
preserving your freedom as a user of such programs to change the free
libraries that are incorporated in them. (We have not seen how to achieve
this as regards changes in header files, but we have achieved it as regards
changes in the actual functions of the Library.) The hope is that this
will lead to faster development of free libraries.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, while the latter only
works together with the library.
Note that it is possible for a library to be covered by the ordinary
General Public License rather than by this special one.
GNU LIBRARY GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library which
contains a notice placed by the copyright holder or other authorized
party saying it may be distributed under the terms of this Library
General Public License (also called "this License"). Each licensee is
addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also compile or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
c) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
d) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the source code distributed need not include anything that is normally
distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Library General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Library General Public
License as published by the Free Software Foundation; either
version 2 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Library General Public License for more details.
You should have received a copy of the GNU Library General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Ty Coon>, 1 April 1990
Ty Coon, President of Vice
That's all there is to it!

Some files were not shown because too many files have changed in this diff Show More