diff --git a/.gitattributes b/.gitattributes
index 6fd1589a1..0b6224cf0 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -3,3 +3,5 @@ app/src/main/assets/imagefs.tzst filter=lfs diff=lfs merge=lfs -text
app/src/main/assets/imagefs.part*.tzst filter=lfs diff=lfs merge=lfs -text
reference/tooling/jadx.zip filter=lfs diff=lfs merge=lfs -text
reference/tooling/jadx/lib/jadx-1.5.3-all.jar filter=lfs diff=lfs merge=lfs -text
+
+app/src/main/jniLibs/arm64-v8a/*_libretro_android.so filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml
index c32e0daa6..e13440f96 100644
--- a/.github/workflows/pr-ci.yml
+++ b/.github/workflows/pr-ci.yml
@@ -91,10 +91,15 @@ jobs:
- name: Install Rust Android target
run: rustup target add aarch64-linux-android
+ - name: Validate Gradle wrappers
+ uses: gradle/actions/wrapper-validation@v6
+ with:
+ allow-checksums: e2b82129ab64751fd40437007bd2f7f2afb3c6e41a9198e628650b22d5824a14
+
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
with:
- validate-wrappers: true
+ validate-wrappers: false
cache-cleanup: 'on-success'
# PRs restore the Gradle cache read-only; only main pushes write it.
cache-read-only: ${{ github.event_name == 'pull_request' }}
diff --git a/.github/workflows/tag-apk-artifacts.yml b/.github/workflows/tag-apk-artifacts.yml
index ba0955edb..5b3aae746 100644
--- a/.github/workflows/tag-apk-artifacts.yml
+++ b/.github/workflows/tag-apk-artifacts.yml
@@ -84,10 +84,15 @@ jobs:
- name: Install Rust Android target
run: rustup target add aarch64-linux-android
+ - name: Validate Gradle wrappers
+ uses: gradle/actions/wrapper-validation@v6
+ with:
+ allow-checksums: e2b82129ab64751fd40437007bd2f7f2afb3c6e41a9198e628650b22d5824a14
+
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v6
with:
- validate-wrappers: true
+ validate-wrappers: false
cache-cleanup: 'on-success'
- name: Decode keystore and build debug APK
diff --git a/.gitignore b/.gitignore
index ecf01c18a..d3a4ae7a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,6 +53,12 @@ signing.properties
References/
*.hprof
android_sysvshm/build64/
-
-# local build artifacts (FEX/Proton wcp)
-dist/
+
+# local build artifacts (FEX/Proton wcp)
+dist/
+
+/cores/*/
+!/cores/patches/
+
+/armsx2/emucore-src/
+/armsx2/emucore-out/
diff --git a/.gitmodules b/.gitmodules
index b5cf369f6..9a403e6ed 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -5,3 +5,9 @@
path = app/src/main/cpp/vkbasalt
url = https://github.com/WinNative-Emu/vkBasalt.git
branch = Test
+[submodule "libretrodroid/src/main/cpp/oboe"]
+ path = libretrodroid/src/main/cpp/oboe
+ url = https://github.com/google/oboe
+[submodule "libretrodroid/src/main/cpp/libretro/libretro-common"]
+ path = libretrodroid/src/main/cpp/libretro/libretro-common
+ url = https://github.com/libretro/libretro-common
diff --git a/EMULATOR_CREDITS.md b/EMULATOR_CREDITS.md
new file mode 100644
index 000000000..588c0b91e
--- /dev/null
+++ b/EMULATOR_CREDITS.md
@@ -0,0 +1,67 @@
+# Emulator & Library Credits
+
+WinNative's retro-console features are built on open-source emulators and libraries.
+This project is distributed under the **GNU General Public License v3.0** (see [LICENSE](LICENSE)).
+In compliance with the GPL and the other licenses below, the corresponding source code
+for every GPL/copyleft component is available from the upstream projects linked here,
+and their copyright and license notices are preserved.
+
+## PlayStation 2
+
+PS2 games are recognized and imported into the library. PS2 emulation is built on
+**ARMSX2** (a GPL-3.0 fork of PCSX2) and is in active development.
+
+| Component | Role | License | Source |
+| --- | --- | --- | --- |
+| ARMSX2 | PS2 emulation + RetroAchievements | GPL-3.0 | https://github.com/ARMSX2/ARMSX2 |
+| PCSX2 | Upstream project ARMSX2 is derived from | GPL-3.0 | https://github.com/pcsx2/pcsx2 |
+
+## GameCube / Wii
+
+GameCube and Wii games are recognized and imported into the library. GC/Wii emulation is
+built on an embedded build of **Dolphin**, rendered on Vulkan and driven entirely by
+WinNative (Dolphin's own UI is stripped). Local and online multiplayer use Dolphin's own
+native NetPlay engine.
+
+| Component | Role | License | Source |
+| --- | --- | --- | --- |
+| Dolphin | GameCube/Wii emulation + NetPlay | GPL-2.0-or-later | https://github.com/dolphin-emu/dolphin |
+
+## Bundled libretro cores
+
+Each core is shipped as an unmodified `arm64-v8a` build and loaded through LibretroDroid.
+
+| System | Core | License | Source |
+| --- | --- | --- | --- |
+| Game Boy / Color | Gambatte | GPL-2.0 | https://github.com/libretro/gambatte-libretro |
+| Game Boy Advance | mGBA | MPL-2.0 | https://github.com/libretro/mgba |
+| Genesis / Master System / Game Gear | Genesis Plus GX | Genesis Plus GX License (non-commercial) | https://github.com/libretro/Genesis-Plus-GX |
+| NES | FCEUmm | GPL-2.0 | https://github.com/libretro/libretro-fceumm |
+| Nintendo 64 | ParaLLEl N64 | GPL-2.0 | https://github.com/libretro/parallel-n64 |
+| Nintendo 64 | Mupen64Plus-Next | GPL-2.0 | https://github.com/libretro/mupen64plus-libretro-nx |
+| PlayStation | Beetle PSX (mednafen_psx) | GPL-2.0 | https://github.com/libretro/beetle-psx-libretro |
+| SNES | Snes9x | Snes9x License (non-commercial) | https://github.com/libretro/snes9x |
+
+### Also evaluated for PlayStation
+
+| Component | License | Source |
+| --- | --- | --- |
+| SwanStation | GPL-3.0 | https://github.com/libretro/swanstation |
+
+## Frontend, achievements, and supporting libraries
+
+| Component | Role | License | Source |
+| --- | --- | --- | --- |
+| LibretroDroid | libretro frontend the retro backend is built on | GPL-3.0 | https://github.com/Swordfish90/LibretroDroid |
+| Oboe | Audio output | Apache-2.0 | https://github.com/google/oboe |
+| rcheevos | RetroAchievements client library | MIT | https://github.com/RetroAchievements/rcheevos |
+| Snapdragon Game Super Resolution (SGSR) | Upscaling shader | BSD-3-Clause | https://github.com/quic/snapdragon-gsr |
+| Winlator | Windows-on-Android base this project forks | GPL-3.0 | https://github.com/brunodev85/winlator |
+
+## Source availability
+
+WinNative is released under the GPL-3.0. As required by that license and by the GPL-2.0
+cores above, the complete corresponding source for every copyleft component is obtainable
+from the upstream repositories linked in this document, and the bundled license texts are
+retained in the source tree (for example `libretrodroid/src/main/cpp/rcheevos/LICENSE`,
+`libretrodroid/src/main/cpp/SGSR_LICENSE`, and the top-level `LICENSE`).
diff --git a/README.md b/README.md
index 5a008d88c..2f15049e8 100644
--- a/README.md
+++ b/README.md
@@ -1,60 +1,98 @@
-
-
-
-
-
-
-
-
-
-## WinNative: A Community Built Windows Emulation App for Android
-
- **WinNative** is an advanced, high-performance Windows (x86_64) emulation environment for Android. It bridges the gap between desktop gaming and mobile by unifying the best technologies from **Winlator Bionic** and **Pluvia**.
-
-Designed for enthusiasts and power users, WinNative delivers the full Winlator experience while making it easy to connect your Steam, Epic, and GOG game libraries.
-
----
-
-### Installation
-
-1. **Download:** Get the latest APK from the [Releases](https://github.com/WinNative-Emu/WinNative/releases) section.
-2. **Variants:**
- - `Ludashi`: Forces both Max GPU and CPU clocks on some devices. (Performance Mode trigger).
- - `Vanilla`: Standard package name for side-loading with other forks.
- - `Antutu`: Forces Max GPU clocks on most devices. (antutu benchmark spoof)
- - `Pubg`: Standard pubg package name which allows some Game Booster advanced Features.
-3. **Setup:** Launch the app, allow the ImageFS to install, and start adding your games manually or sync your library.
-
----
-
-### How to Build
-
-**Requirements:** Android Studio, JDK 17, NDK `27.3.13750724`, and CMake.
-
-1. **Clone the repository and update submodules** (Required):
- ```bash
- git clone https://github.com/MaxsTechReview/WinNative.git
- cd WinNative
- git submodule update --init --recursive
- ```
-2. **Build via Android Studio:** Open the `WinNative` directory, let Gradle sync, then select **Build > Build APK(s)**.
-3. **Build via CLI:** Run `.\gradlew.bat assembleDebug` (Windows).
-
----
-
-### Contributing
-
-We welcome community contributions! Feel free to open a pull request for bug fixes, driver updates, UI improvements, or anything else you'd like to add.
-
-Please match the existing code style and ensure any AI-assisted code is thoroughly reviewed and tested before submission.
-
----
-
-### Credits & Acknowledgments
-
-- **Original Winlator** by [brunodev85](https://github.com/brunodev85/winlator)
-- **Winlator Bionic** by [Pipetto-crypto](https://github.com/Pipetto-crypto/winlator)
-- **Pluvia** features by the [Pluvia](https://github.com/oxters168/Pluvia) / [GameNative](https://github.com/utkarshdalal/GameNative) community
-- **Mesa/Turnip** contributions by the [Mesa3D](https://www.mesa3d.org/) team
-- **Goldberg Steam Emulator** by [Mr. Goldberg](https://gitlab.com/Mr_Goldberg/goldberg_emulator), maintained by [Detanup01](https://github.com/Detanup01/gbe_fork)
+
+
+
+
+
+
+
+
+
+## WinNative: A Community Built Windows Emulation App for Android
+
+**WinNative** is an advanced, high-performance Windows (x86_64) emulation environment for Android. It bridges the gap between desktop gaming and mobile by unifying the best technologies from **Winlator Bionic** and **Pluvia**.
+
+Designed for enthusiasts and power users, WinNative delivers the full Winlator experience while making it easy to connect your Steam, Epic, and GOG game libraries.
+
+---
+
+### Installation
+
+1. **Download:** Get the latest APK from the [Releases](https://github.com/WinNative-Emu/WinNative/releases) section.
+2. **Variants:**
+ - `Ludashi`: Forces both Max GPU and CPU clocks on some devices. (Performance Mode trigger).
+ - `Vanilla`: Standard package name for side-loading with other forks.
+ - `Antutu`: Forces Max GPU clocks on most devices. (antutu benchmark spoof)
+ - `Pubg`: Standard pubg package name which allows some Game Booster advanced Features.
+3. **Setup:** Launch the app, allow the ImageFS to install, and start adding your games manually or sync your library.
+
+---
+
+### How to Build
+
+**Requirements:** Android Studio, JDK 17, and [Git LFS](https://git-lfs.com). The NDK
+(`27.3.13750724`) and CMake are only needed if you build native cores from source (see below).
+
+1. **Clone with submodules and pull LFS objects** (Required):
+ ```bash
+ git clone --recursive https://github.com/MaxsTechReview/WinNative.git
+ cd WinNative
+ git lfs pull # fetches imagefs
+ git submodule update --init --recursive
+ ```
+2. **Build via Android Studio:** Open the `WinNative` directory, let Gradle sync, then select **Build > Build APK(s)**.
+3. **Build via CLI:** Run `./gradlew assembleStandardDebug` (or `.\gradlew.bat` on Windows).
+
+The APK carries no retro console cores. Each core is built from its own fork under the
+[WinNative-Emu](https://github.com/WinNative-Emu) org, and
+[Retro-Consoles](https://github.com/WinNative-Emu/Retro-Consoles) packs every core plus the
+Dolphin and ARMSX2 runtime data into one `retro-consoles.tzst`. The app downloads and
+verifies it on demand from **Settings > Retro > Download console cores**, so a core update
+no longer needs an app release. To change a core, change its fork and re-run the
+Retro-Consoles bundle workflow.
+
+---
+
+### Retro Console Support
+
+WinNative can also run classic console games alongside your PC library. Retro games live in the same Library and launch just like PC games, but run on an embedded libretro backend instead of Wine.
+
+Supported systems (bundled cores):
+
+| System | Core | ROM extensions |
+| --- | --- | --- |
+| NES | FCEUmm | `.nes` `.unf` `.unif` |
+| SNES | Snes9x | `.smc` `.sfc` `.swc` `.fig` |
+| Game Boy / Color | Gambatte | `.gb` `.gbc` |
+| Game Boy Advance | mGBA | `.gba` |
+| Genesis / Mega Drive, Master System, Game Gear | Genesis Plus GX | `.gen` `.md` `.smd` `.sms` `.gg` |
+| Nintendo 64 | Mupen64Plus-Next | `.n64` `.z64` `.v64` |
+| PlayStation | Beetle PSX | `.cue` `.chd` `.pbp` `.m3u` `.iso` |
+| PlayStation 2 | ARMSX2 (PCSX2 fork) | `.iso` `.chd` `.cso` `.bin` |
+
+Cores ship **prebuilt** (committed via Git LFS) and are used by default; they are built from
+source with the opt-in flags above (see `cores/` for the libretro cores and
+`armsx2/build-emucore.sh` / `dolphin/build-emucore.sh` for the PS2 and GameCube/Wii cores).
+PlayStation 2 online play is supported through the emulated DEV9 network adapter (see the
+in-game **Online** tab).
+
+**How to use:** In the Library, tap **Add Custom Game** and select a ROM instead of an `.exe`. WinNative detects the console and adds the game to your Library. Tap **Play** to launch it with on-screen touch controls and physical gamepad support; the in-game menu (Back button or on-screen **MENU**) offers save/load state, reset, and fast-forward. PlayStation and PlayStation 2 BIOS files can be imported from **Settings → Retro**.
+
+### Contributing
+
+We welcome community contributions! Feel free to open a pull request for bug fixes, driver updates, UI improvements, or anything else you'd like to add.
+
+Please match the existing code style and ensure any AI-assisted code is thoroughly reviewed and tested before submission.
+
+---
+
+### Credits & Acknowledgments
+
+- **Original Winlator** by [brunodev85](https://github.com/brunodev85/winlator)
+- **Winlator Bionic** by [Pipetto-crypto](https://github.com/Pipetto-crypto/winlator)
+- **Pluvia** features by the [Pluvia](https://github.com/oxters168/Pluvia) / [GameNative](https://github.com/utkarshdalal/GameNative) community
+- **Mesa/Turnip** contributions by the [Mesa3D](https://www.mesa3d.org/) team
+- **Goldberg Steam Emulator** by [Mr. Goldberg](https://gitlab.com/Mr_Goldberg/goldberg_emulator), maintained by [Detanup01](https://github.com/Detanup01/gbe_fork)
+- **LibretroDroid** by [Filippo Scognamiglio](https://github.com/Swordfish90/LibretroDroid) (GPL-3.0) — the embedded libretro host for retro console support
+- **libretro / RetroArch** and the individual core authors, built from source: [FCEUmm](https://github.com/libretro/libretro-fceumm), [Snes9x](https://github.com/libretro/snes9x), [Gambatte](https://github.com/libretro/gambatte-libretro), [mGBA](https://github.com/libretro/mgba), [Genesis Plus GX](https://github.com/libretro/Genesis-Plus-GX), [Mupen64Plus-Next](https://github.com/libretro/mupen64plus-libretro-nx), [Beetle PSX](https://github.com/libretro/beetle-psx-libretro)
+- **ARMSX2** by the [ARMSX2](https://github.com/ARMSX2/ARMSX2) team (GPL-3.0) — the PlayStation 2 core, a fork of **[PCSX2](https://github.com/pcsx2/pcsx2)** (GPL-3.0), built from source into `libemucore`. PS2 online play uses PCSX2's DEV9 network adapter
diff --git a/app/build.gradle b/app/build.gradle
index 62637d031..5971e9d69 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -209,7 +209,13 @@ android {
'src/main/feature',
'src/main/sharedmemory',
'src/main/runtime',
- 'src/main/shared'
+ 'src/main/shared',
+ // SDL2 and love-android glue, vendored for the 3D engine.
+ // Their class names are a JNI contract with liblove.so and
+ // cannot be renamed or repackaged, so they are kept apart
+ // from WinNative's own sources with their upstream licence
+ // headers intact. See EMULATOR_CREDITS.md.
+ 'src/main/engine'
]
}
}
@@ -285,6 +291,10 @@ dependencies {
implementation libs.playServicesGamesV2
implementation libs.workRuntimeKtx
+
+ implementation project(':libretrodroid')
+ implementation project(':armsx2')
+ implementation project(':dolphin')
}
spotless {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index dcb6bc9e1..7d06e5609 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -127,6 +127,38 @@
android:screenOrientation="sensorLandscape"
android:exported="false" />
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/app/PluviaApp.kt b/app/src/main/app/PluviaApp.kt
index b52d883b9..7db378cbe 100644
--- a/app/src/main/app/PluviaApp.kt
+++ b/app/src/main/app/PluviaApp.kt
@@ -24,10 +24,32 @@ import java.io.File
class PluviaApp : Application() {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private fun isPs2Process(): Boolean {
+ val name =
+ if (android.os.Build.VERSION.SDK_INT >= 28) {
+ Application.getProcessName()
+ } else {
+ runCatching {
+ val pid = android.os.Process.myPid()
+ val am = getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager
+ am.runningAppProcesses?.firstOrNull { it.pid == pid }?.processName
+ }.getOrNull()
+ }
+ return name?.endsWith(":ps2") == true || name?.endsWith(":gc") == true
+ }
+
override fun onCreate() {
super.onCreate()
instance = this
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
+ Log.e("PluviaApp", "CRASH in thread ${thread.name}", throwable)
+ }
+
+ com.winlator.cmod.feature.retro.Ps2GameOverlay.install()
+ com.winlator.cmod.feature.retro.DolphinGameOverlay.install()
+ if (isPs2Process()) return
+
// Cached probe for devices whose native stack still needs system libjpeg preloaded.
preloadSystemLibraries()
@@ -39,10 +61,6 @@ class PluviaApp : Application() {
com.winlator.cmod.app.service.NetworkMonitor
.init(this)
scheduleColdStartWarmups()
-
- Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
- Log.e("PluviaApp", "CRASH in thread ${thread.name}", throwable)
- }
}
companion object {
diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt
index 66f62fb8f..1fd6cacb1 100644
--- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt
+++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt
@@ -43,7 +43,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.FactCheck
import androidx.compose.material.icons.outlined.ArrowDropDown
+import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.CloudSync
+import androidx.compose.material.icons.outlined.SaveAlt
import androidx.compose.material.icons.outlined.Construction
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.DesktopWindows
@@ -63,6 +65,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -105,6 +108,7 @@ import coil.request.ImageRequest
import com.winlator.cmod.R
import androidx.compose.runtime.CompositionLocalProvider
import com.winlator.cmod.shared.ui.focus.controllerFocusGlow
+import com.winlator.cmod.shared.ui.outlinedSwitchColors
import com.winlator.cmod.shared.ui.nav.DialogPaneNav
import com.winlator.cmod.shared.ui.nav.LocalPaneNav
import com.winlator.cmod.shared.ui.nav.PaneNavRegistry
@@ -134,6 +138,9 @@ internal fun LibraryGameLaunchScreen(
lastPlayedMillis: Long,
installSizeText: String?,
isCustom: Boolean,
+ isRetro: Boolean = false,
+ showBootToDesktop: Boolean = !isRetro,
+ showSaveTransfer: Boolean = false,
hasPinnedShortcut: Boolean,
steamMenuEnabled: Boolean = false,
areSteamActionsEnabled: Boolean = true,
@@ -142,13 +149,25 @@ internal fun LibraryGameLaunchScreen(
showWorkshop: Boolean = true,
playEnabled: Boolean = true,
playDisabledLabel: String? = null,
+ /**
+ * An alternative engine this particular game can be played with, offered
+ * right above Play because it changes what Play does. Absent (and the row
+ * not drawn at all) for every game that has no such choice, which is all
+ * but a handful.
+ */
+ altEngineLabel: String? = null,
+ altEngineEnabled: Boolean = false,
+ onAltEngineChange: ((Boolean) -> Unit)? = null,
onBack: () -> Unit,
onPlay: () -> Unit,
onSettings: () -> Unit,
onBootToDesktop: () -> Unit,
onAchievements: (() -> Unit)? = null,
+ onCheats: (() -> Unit)? = null,
+ cheatsEnabled: Boolean = true,
onShortcut: () -> Unit,
onCloudSaves: () -> Unit,
+ onSaveTransfer: (() -> Unit)? = null,
onUninstall: () -> Unit,
onVerifyFiles: () -> Unit = {},
onCheckForUpdate: () -> Unit = {},
@@ -156,6 +175,15 @@ internal fun LibraryGameLaunchScreen(
) {
val context = LocalContext.current
var uninstallMenuOpen by remember { mutableStateOf(false) }
+ val saveTransferVisible = showSaveTransfer && onSaveTransfer != null
+ val bootVisible = showBootToDesktop
+ val actionIconCount =
+ 1 +
+ (if (saveTransferVisible) 1 else 0) +
+ 1 +
+ (if (bootVisible) 1 else 0) +
+ 1 +
+ 1
LaunchScreenCutoutMode()
@@ -164,9 +192,7 @@ internal fun LibraryGameLaunchScreen(
val bottomPadding = 20.dp
val actionIconSize = 46.dp
val actionIconSpacing = 8.dp
- // Action icons: Settings, Boot, CloudSync, Shortcut, Delete.
- val actionIconCount = 5
- val actionWidth = actionIconSize * actionIconCount + actionIconSpacing * (actionIconCount - 1)
+ val actionWidth = actionIconSize * actionIconCount + actionIconSpacing * (actionIconCount - 1).coerceAtLeast(0)
val playHeight = 56.dp
val contentGap = 18.dp
val horizontalNavInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
@@ -278,11 +304,14 @@ internal fun LibraryGameLaunchScreen(
showCheckForUpdate = showCheckForUpdate,
showWorkshop = showWorkshop,
showAchievements = onAchievements != null,
+ showCheats = onCheats != null,
+ cheatsEnabled = cheatsEnabled,
areSteamActionsEnabled = areSteamActionsEnabled,
onVerifyFiles = onVerifyFiles,
onCheckForUpdate = onCheckForUpdate,
onWorkshop = onWorkshop,
onAchievements = { onAchievements?.invoke() },
+ onCheats = { onCheats?.invoke() },
)
}
@@ -374,6 +403,15 @@ internal fun LibraryGameLaunchScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
+ if (altEngineLabel != null && onAltEngineChange != null) {
+ LaunchAltEngineToggle(
+ label = altEngineLabel,
+ checked = altEngineEnabled,
+ width = actionWidth,
+ onCheckedChange = onAltEngineChange,
+ )
+ }
+
LaunchPlayButton(
height = playHeight,
enabled = playEnabled,
@@ -391,18 +429,28 @@ internal fun LibraryGameLaunchScreen(
size = actionIconSize,
onClick = onSettings,
)
- LaunchIconActionButton(
- icon = Icons.Outlined.DesktopWindows,
- contentDescription = stringResource(R.string.hero_boot_to_desktop_title),
- size = actionIconSize,
- onClick = onBootToDesktop,
- )
+ if (saveTransferVisible) {
+ LaunchIconActionButton(
+ icon = Icons.Outlined.SaveAlt,
+ contentDescription = stringResource(R.string.retro_save_transfer_title),
+ size = actionIconSize,
+ onClick = { onSaveTransfer?.invoke() },
+ )
+ }
LaunchIconActionButton(
icon = Icons.Outlined.CloudSync,
contentDescription = stringResource(R.string.cloud_saves_title),
size = actionIconSize,
onClick = onCloudSaves,
)
+ if (bootVisible) {
+ LaunchIconActionButton(
+ icon = Icons.Outlined.DesktopWindows,
+ contentDescription = stringResource(R.string.hero_boot_to_desktop_title),
+ size = actionIconSize,
+ onClick = onBootToDesktop,
+ )
+ }
LaunchIconActionButton(
icon = Icons.Outlined.Home,
contentDescription =
@@ -821,14 +869,18 @@ private fun SourceTag(
showCheckForUpdate: Boolean = true,
showWorkshop: Boolean = true,
showAchievements: Boolean = false,
+ showCheats: Boolean = false,
+ cheatsEnabled: Boolean = true,
areSteamActionsEnabled: Boolean = true,
onVerifyFiles: () -> Unit = {},
onCheckForUpdate: () -> Unit = {},
onWorkshop: () -> Unit = {},
onAchievements: () -> Unit = {},
+ onCheats: () -> Unit = {},
) {
var menuOpen by remember { mutableStateOf(false) }
var anchorHeightPx by remember { mutableStateOf(0) }
+ val menuInteractive = menuEnabled || showAchievements || showCheats
Box {
Surface(
color = Color.White.copy(alpha = 0.1f),
@@ -837,7 +889,7 @@ private fun SourceTag(
modifier =
Modifier
.onSizeChanged { anchorHeightPx = it.height }
- .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier),
+ .then(if (menuInteractive) Modifier.clickable { menuOpen = true } else Modifier),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
@@ -858,7 +910,7 @@ private fun SourceTag(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
- if (menuEnabled) {
+ if (menuInteractive) {
Icon(
Icons.Outlined.ArrowDropDown,
contentDescription = stringResource(R.string.store_game_steam_options),
@@ -868,28 +920,28 @@ private fun SourceTag(
}
}
}
- if (menuEnabled) {
+ if (menuInteractive) {
val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() }
LaunchSourceActionPopup(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
offset = IntOffset(0, anchorHeightPx + gapPx),
) {
- if (showVerifyFiles) {
+ if (menuEnabled && showVerifyFiles) {
LaunchSourceMenuItem(
icon = Icons.AutoMirrored.Outlined.FactCheck,
label = stringResource(R.string.store_game_verify_files),
enabled = areSteamActionsEnabled,
) { menuOpen = false; onVerifyFiles() }
}
- if (showCheckForUpdate) {
+ if (menuEnabled && showCheckForUpdate) {
LaunchSourceMenuItem(
icon = Icons.Outlined.Refresh,
label = stringResource(R.string.store_game_check_for_update),
enabled = areSteamActionsEnabled,
) { menuOpen = false; onCheckForUpdate() }
}
- if (showWorkshop) {
+ if (menuEnabled && showWorkshop) {
LaunchSourceMenuItem(
icon = Icons.Outlined.Construction,
label = stringResource(R.string.store_game_workshop),
@@ -902,6 +954,13 @@ private fun SourceTag(
label = stringResource(R.string.steam_achievements_title),
) { menuOpen = false; onAchievements() }
}
+ if (showCheats) {
+ LaunchSourceMenuItem(
+ icon = Icons.Outlined.Bolt,
+ label = stringResource(R.string.retro_cheats_title),
+ enabled = cheatsEnabled,
+ ) { menuOpen = false; onCheats() }
+ }
}
}
}
@@ -1034,6 +1093,48 @@ private fun GameStatChip(
}
}
+/**
+ * The alternative-engine switch above Play.
+ *
+ * Deliberately the same width as the Play button and immediately above it: it
+ * decides which engine Play will start, so it belongs in the reading path to
+ * that button rather than buried in a settings pane. The state is the game's
+ * own saved setting, so what it shows survives leaving the screen.
+ */
+@Composable
+private fun LaunchAltEngineToggle(
+ label: String,
+ checked: Boolean,
+ width: Dp,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .width(width)
+ .clip(RoundedCornerShape(14.dp))
+ .background(Color.White.copy(alpha = 0.06f))
+ .clickable { onCheckedChange(!checked) }
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = label,
+ color = Color.White.copy(alpha = 0.92f),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ colors = outlinedSwitchColors(
+ accentColor = LaunchAccent,
+ textSecondaryColor = Color.White.copy(alpha = 0.55f),
+ ),
+ )
+ }
+}
+
@Composable
private fun LaunchPlayButton(
height: Dp,
diff --git a/app/src/main/app/shell/RetroConsoleRibbon.kt b/app/src/main/app/shell/RetroConsoleRibbon.kt
new file mode 100644
index 000000000..7b2d6c04f
--- /dev/null
+++ b/app/src/main/app/shell/RetroConsoleRibbon.kt
@@ -0,0 +1,72 @@
+package com.winlator.cmod.app.shell
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+internal val retroLibrarySystemIds = mutableStateOf>(emptyMap())
+
+internal fun libraryBadgeLabel(
+ appId: Int,
+ isCustom: Boolean,
+): String? {
+ val systemId = retroLibrarySystemIds.value[appId]
+ if (systemId != null) {
+ return com.winlator.cmod.feature.retro.RetroSystems
+ .fromId(systemId)
+ ?.badgeLabel
+ ?: systemId
+ }
+ return if (isCustom) "PC" else null
+}
+
+@Composable
+internal fun RetroConsoleRibbon(
+ label: String,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier =
+ modifier
+ .fillMaxHeight()
+ .width(14.dp)
+ .background(Color(0xD9090C10)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = label,
+ color = Color(0xFFE6EDF3),
+ fontSize = 8.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.2.sp,
+ maxLines = 1,
+ softWrap = false,
+ modifier = Modifier.verticalRibbonText(),
+ )
+ }
+}
+
+private fun Modifier.verticalRibbonText(): Modifier =
+ this.layout { measurable, _ ->
+ val placeable = measurable.measure(androidx.compose.ui.unit.Constraints())
+
+ layout(placeable.height, placeable.width) {
+ placeable.placeWithLayer(
+ x = -(placeable.width - placeable.height) / 2,
+ y = -(placeable.height - placeable.width) / 2,
+ ) {
+ rotationZ = -90f
+ }
+ }
+ }
diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt
index ab661f60d..d0c4076af 100644
--- a/app/src/main/app/shell/UnifiedActivity.kt
+++ b/app/src/main/app/shell/UnifiedActivity.kt
@@ -430,6 +430,7 @@ class UnifiedActivity :
private var dpadHeld = false
private var joystickActive = false
+ @Volatile private var retroCloudUploadBusy = false
internal val settingsNavBridge = SettingsNavBridge()
internal val downloadsNavBridge = DownloadsNavBridge()
@@ -628,6 +629,7 @@ class UnifiedActivity :
}
UpdateChecker.startBackgroundLoop(this)
+ processPendingRetroCloudBackup()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
@@ -911,6 +913,74 @@ class UnifiedActivity :
handleSettingsIntent(intent)
}
+ internal fun retryPendingRetroCloudBackup() = processPendingRetroCloudBackup()
+
+ private fun processPendingRetroCloudBackup() {
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(this)
+ val hasLegacy = prefs.getString("retro_pending_backup_id", null) != null
+ val hasDolphin = com.winlator.cmod.feature.retro.DolphinCloudSync.peekPending(this) != null
+ if (!hasLegacy && !hasDolphin) return
+ if (!com.winlator.cmod.feature.sync.google.GameSaveBackupManager.isDriveConnected(this)) return
+ runCatching {
+ com.winlator.cmod.feature.sync.google.PlayGamesBootstrap.ensureInitialized(this)
+ com.google.android.gms.games.PlayGames
+ .getGamesSignInClient(this)
+ .signIn()
+ .addOnCompleteListener { runPendingRetroUploads() }
+ }.onFailure { runPendingRetroUploads() }
+ }
+
+ private fun runPendingRetroUploads() {
+ if (retroCloudUploadBusy) return
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(this)
+ val pendingId = prefs.getString("retro_pending_backup_id", null)
+ val pendingName = prefs.getString("retro_pending_backup_name", null)
+ val dolphinPending = com.winlator.cmod.feature.retro.DolphinCloudSync.peekPending(this)
+ if ((pendingId == null || pendingName == null) && dolphinPending == null) return
+ retroCloudUploadBusy = true
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ dolphinPending?.let { p ->
+ if (uploadRetroCloudBackup(p.cloudId, p.gameName)) {
+ com.winlator.cmod.feature.retro.DolphinCloudSync.clearPending(this@UnifiedActivity)
+ if (p.fingerprint.isNotEmpty()) {
+ prefs.edit().putString("retro_cloud_fp_${p.cloudId}", p.fingerprint).apply()
+ }
+ }
+ }
+ if (pendingId != null && pendingName != null &&
+ uploadRetroCloudBackup(pendingId, pendingName)
+ ) {
+ prefs.edit().remove("retro_pending_backup_id").remove("retro_pending_backup_name").apply()
+ }
+ } finally {
+ retroCloudUploadBusy = false
+ }
+ }
+ }
+
+ private suspend fun uploadRetroCloudBackup(cloudId: String, gameName: String): Boolean {
+ val result =
+ runCatching {
+ GameSaveBackupManager.backupSaveToGoogle(
+ this@UnifiedActivity,
+ GameSaveBackupManager.GameSource.CUSTOM,
+ cloudId,
+ gameName,
+ GameSaveBackupManager.BackupOrigin.AUTO,
+ com.winlator.cmod.feature.sync.google.GoogleAuthMode.RESUME,
+ customSaveDir = com.winlator.cmod.feature.retro.RetroSaveStates.gameDir(this, gameName),
+ )
+ }.getOrNull()
+ android.util.Log.i("WnDolphin", "upload id=$cloudId success=${result?.success} msg=${result?.message}")
+ if (result?.success == true) {
+ androidx.preference.PreferenceManager.getDefaultSharedPreferences(this)
+ .edit().putLong("retro_cloud_mark_$cloudId", System.currentTimeMillis()).apply()
+ return true
+ }
+ return false
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
instance = this
super.onCreate(savedInstanceState)
@@ -931,6 +1001,7 @@ class UnifiedActivity :
com.winlator.cmod.runtime.display.GlassesManager.init(this)
bootstrapStartupState()
maybeAutoSignInGoogleOnLaunch()
+ processPendingRetroCloudBackup()
// Surface store-session events as toasts.
lifecycleScope.launch {
@@ -1191,6 +1262,7 @@ class UnifiedActivity :
internal data class HomeShortcutUiState(
val shortcut: Shortcut? = null,
val isPinned: Boolean = false,
+ val loaded: Boolean = false,
)
internal data class ArtworkCacheId(
diff --git a/app/src/main/app/shell/UnifiedActivityDrawer.kt b/app/src/main/app/shell/UnifiedActivityDrawer.kt
index 3f749527a..0fd85a29f 100644
--- a/app/src/main/app/shell/UnifiedActivityDrawer.kt
+++ b/app/src/main/app/shell/UnifiedActivityDrawer.kt
@@ -776,6 +776,7 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) {
var selectedExePath by remember { mutableStateOf(null) }
var gameName by remember { mutableStateOf("") }
var gameFolder by remember { mutableStateOf(null) }
+ var retroSystem by remember { mutableStateOf(null) }
var isAdding by remember { mutableStateOf(false) }
var nameEditing by remember { mutableStateOf(false) }
val nameFocus = remember { FocusRequester() }
@@ -787,26 +788,46 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) {
}
}
val registry = remember { PaneNavRegistry() }
- val addEnabled = selectedExePath != null && gameName.isNotBlank() && gameFolder != null && !isAdding
+ val addEnabled =
+ selectedExePath != null && gameName.isNotBlank() && !isAdding &&
+ (retroSystem != null || gameFolder != null)
val doAdd: () -> Unit = {
isAdding = true
+ val chosenRetro = retroSystem
scope.launch(Dispatchers.IO) {
- addCustomGame(context, gameName.trim(), selectedExePath!!, gameFolder!!)
+ val added =
+ if (chosenRetro != null) {
+ com.winlator.cmod.feature.retro.RetroShortcuts
+ .create(context, gameName.trim(), selectedExePath!!, chosenRetro)
+ } else {
+ addCustomGame(context, gameName.trim(), selectedExePath!!, gameFolder!!)
+ true
+ }
withContext(Dispatchers.Main) {
isAdding = false
- com.winlator.cmod.shared.ui.toast.WinToast.show(
- context,
- "$gameName added!",
- android.widget.Toast.LENGTH_SHORT,
- )
- onDismiss()
+ if (added) {
+ com.winlator.cmod.shared.ui.toast.WinToast.show(
+ context,
+ "$gameName added!",
+ android.widget.Toast.LENGTH_SHORT,
+ )
+ onDismiss()
+ } else {
+ com.winlator.cmod.shared.ui.toast.WinToast.show(
+ context,
+ "Could not add game",
+ android.widget.Toast.LENGTH_SHORT,
+ )
+ }
}
}
}
fun selectExecutable(path: String) {
+ val detectedRetro = com.winlator.cmod.feature.retro.RetroSystems.detectForFile(path)
val file = java.io.File(path)
- if (!file.isFile || file.extension.lowercase() !in DirectoryPickerDialog.ExecutableExtensions) {
+ val launchable = file.extension.lowercase() in DirectoryPickerDialog.ExecutableExtensions
+ if (!file.isFile || (!launchable && detectedRetro == null)) {
com.winlator.cmod.shared.ui.toast.WinToast.show(
context,
R.string.common_ui_select_valid_exe_file,
@@ -816,7 +837,13 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) {
}
selectedExePath = path
- gameFolder = LibraryShortcutUtils.detectCustomGameFolder(path)
+ retroSystem = detectedRetro
+ gameFolder =
+ if (detectedRetro != null) {
+ java.io.File(path).parent
+ } else {
+ LibraryShortcutUtils.detectCustomGameFolder(path)
+ }
// Auto-generate a game name from the EXE name (without extension)
if (gameName.isBlank()) {
gameName =
@@ -886,7 +913,8 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) {
android.os.Environment.DIRECTORY_DOWNLOADS,
).absolutePath,
title = getString(R.string.common_ui_select_exe),
- allowedExtensions = DirectoryPickerDialog.ExecutableExtensions,
+ allowedExtensions = DirectoryPickerDialog.ExecutableExtensions +
+ com.winlator.cmod.feature.retro.RetroSystems.allExtensions,
dimAmount = 0.5f,
preserveBackdropBlur = true,
extraRoots = driveRoots(includeInternal = true),
@@ -899,7 +927,7 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) {
Icon(Icons.Outlined.FolderOpen, contentDescription = null, tint = Accent, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Text(
- selectedExePath ?: "Select Executable",
+ selectedExePath ?: "Select Executable or Console ROM",
color = if (selectedExePath == null) TextSecondary else TextPrimary,
maxLines = if (selectedExePath == null) 1 else Int.MAX_VALUE,
overflow = if (selectedExePath == null) TextOverflow.Ellipsis else TextOverflow.Visible,
@@ -947,6 +975,72 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) {
Spacer(Modifier.height(8.dp))
+ if (retroSystem != null) {
+ val activeRetroSystem = retroSystem
+ var consoleMenuOpen by remember { mutableStateOf(false) }
+ Box {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(10.dp))
+ .background(Color.White.copy(alpha = 0.05f))
+ .paneNavItem(
+ cornerRadius = 10.dp,
+ tapToSelect = true,
+ onActivate = { consoleMenuOpen = true },
+ ).clickable { consoleMenuOpen = true }
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ Icons.Outlined.SportsEsports,
+ contentDescription = null,
+ tint = StatusOnline.copy(alpha = 0.7f),
+ modifier = Modifier.size(14.dp),
+ )
+ Spacer(Modifier.width(6.dp))
+ Column(Modifier.weight(1f)) {
+ Text("Console", color = TextSecondary, fontSize = 9.sp)
+ Text(
+ activeRetroSystem?.displayName ?: "",
+ color = TextPrimary,
+ fontSize = 10.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Icon(
+ Icons.Outlined.Edit,
+ contentDescription = stringResource(R.string.common_ui_change),
+ tint = Accent,
+ modifier = Modifier.size(14.dp),
+ )
+ }
+ DropdownMenu(
+ expanded = consoleMenuOpen,
+ onDismissRequest = { consoleMenuOpen = false },
+ containerColor = Color(0xFF1C232E),
+ ) {
+ com.winlator.cmod.feature.retro.RetroSystems.ALL.forEach { candidate ->
+ DropdownMenuItem(
+ text = {
+ Text(
+ candidate.displayName,
+ color =
+ if (candidate.id == activeRetroSystem?.id) Accent else TextPrimary,
+ fontSize = 12.sp,
+ )
+ },
+ onClick = {
+ retroSystem = candidate
+ consoleMenuOpen = false
+ },
+ )
+ }
+ }
+ }
+ } else {
// Game folder — single compact row
Row(
modifier =
@@ -1006,6 +1100,7 @@ internal fun UnifiedActivity.AddCustomGameDialog(onDismiss: () -> Unit) {
)
}
}
+ }
}
}
diff --git a/app/src/main/app/shell/UnifiedActivityGameDialogs.kt b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt
index 8d19bc398..57bc7e5d4 100644
--- a/app/src/main/app/shell/UnifiedActivityGameDialogs.kt
+++ b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt
@@ -899,6 +899,7 @@ internal fun UnifiedActivity.GameSettingsDialog(
HomeShortcutUiState(
shortcut = shortcut,
isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true,
+ loaded = true,
)
}
}
@@ -1265,6 +1266,7 @@ internal fun UnifiedActivity.GameSettingsDialog(
gameId = gameIdStr,
gameName = app.name,
shortcut = shortcut,
+ retroSaveDir = com.winlator.cmod.feature.sync.google.GameSaveBackupManager.retroSaveDir(context, shortcut),
onCloudSyncToggle = { enabled ->
cloudSyncEnabled = enabled
setShortcutCloudSyncEnabled(shortcut, enabled)
@@ -1426,6 +1428,7 @@ internal fun UnifiedActivity.GOGGameSettingsDialog(
HomeShortcutUiState(
shortcut = shortcut,
isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true,
+ loaded = true,
)
}
}
@@ -1761,6 +1764,7 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
HomeShortcutUiState(
shortcut = shortcut,
isPinned = shortcut?.let { LibraryShortcutUtils.hasPinnedHomeShortcut(context, it) } == true,
+ loaded = true,
)
}
}
@@ -1781,6 +1785,32 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
}
}
val hasPinnedShortcut = pinnedShortcutOverride ?: homeShortcutState.isPinned
+ val librarySystemIdHint = if (isCustom) retroLibrarySystemIds.value[app.id] else null
+ val retroCaps =
+ remember(homeShortcutState.shortcut, homeShortcutState.loaded, isCustom, librarySystemIdHint) {
+ when {
+ !isCustom -> com.winlator.cmod.feature.retro.RetroShortcuts.LibraryCapabilities()
+ homeShortcutState.loaded ->
+ com.winlator.cmod.feature.retro.RetroShortcuts.libraryCapabilities(homeShortcutState.shortcut)
+ else ->
+ com.winlator.cmod.feature.retro.RetroShortcuts.libraryCapabilitiesForSystemId(librarySystemIdHint)
+ }
+ }
+ val isRetro = retroCaps.isRetro
+ val isExternalRetro = retroCaps.isExternal
+ val retroSystemId = retroCaps.systemId
+ val retroRomPath =
+ retroCaps.romPath
+ ?: homeShortcutState.shortcut
+ ?.getExtra(com.winlator.cmod.feature.retro.RetroShortcuts.KEY_ROM)
+ ?.takeIf { it.isNotEmpty() }
+ LaunchedEffect(retroSystemId, retroRomPath, isExternalRetro) {
+ val sid = retroSystemId
+ val rp = retroRomPath
+ if (sid != null && rp != null && !isExternalRetro) {
+ com.winlator.cmod.feature.retro.RetroAchievementsManager.prefetch(context, sid, rp)
+ }
+ }
BackHandler(enabled = activePopup != null) {
activePopup = null
@@ -1870,7 +1900,14 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
when {
isGog -> "GOG"
isEpic -> "Epic Games"
- isCustom -> "Custom"
+ isCustom ->
+ retroCaps.sourceLabel
+ ?: librarySystemIdHint?.let {
+ com.winlator.cmod.feature.retro.RetroSystems
+ .fromId(it)
+ ?.badgeLabel
+ }
+ ?: "Custom"
else -> "Steam"
}
@@ -2313,6 +2350,107 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
)
}
}
+ var showSaveTransfer by remember(app.id) { mutableStateOf(false) }
+ val retroSaveImportLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) {
+ val sourceName =
+ context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
+ if (it.moveToFirst()) it.getString(0) else null
+ } ?: "save"
+ val result =
+ runCatching {
+ val bytes =
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ ?: return@runCatching com.winlator.cmod.feature.retro.RetroSaveImport.Result.Invalid("Could not read the file.")
+ com.winlator.cmod.feature.retro.RetroSaveImport.import(context, app.name, sourceName, bytes)
+ }.getOrElse { com.winlator.cmod.feature.retro.RetroSaveImport.Result.Invalid("Could not read the file.") }
+ val message =
+ when (result) {
+ is com.winlator.cmod.feature.retro.RetroSaveImport.Result.Success ->
+ "Imported save (${result.name}, ${result.bytes / 1024} KB)"
+ is com.winlator.cmod.feature.retro.RetroSaveImport.Result.Invalid ->
+ "Import failed: ${result.reason}"
+ }
+ android.widget.Toast.makeText(context, message, android.widget.Toast.LENGTH_LONG).show()
+ }
+ }
+ val retroSaveExportLauncher =
+ rememberLauncherForActivityResult(
+ ActivityResultContracts.CreateDocument("application/octet-stream"),
+ ) { uri ->
+ if (uri != null) {
+ val ok =
+ runCatching {
+ val sram =
+ com.winlator.cmod.feature.retro.RetroSaveStates.sramFile(context, app.name)
+ if (!sram.isFile) return@runCatching false
+ context.contentResolver.openOutputStream(uri)?.use { it.write(sram.readBytes()) }
+ true
+ }.getOrDefault(false)
+ android.widget.Toast.makeText(
+ context,
+ context.getString(
+ if (ok) R.string.retro_save_transfer_export_ok else R.string.retro_save_transfer_export_failed,
+ ),
+ android.widget.Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ if (showSaveTransfer) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = { showSaveTransfer = false },
+ title = { androidx.compose.material3.Text(stringResource(R.string.retro_save_transfer_title)) },
+ text = { androidx.compose.material3.Text(stringResource(R.string.retro_save_transfer_message)) },
+ confirmButton = {
+ androidx.compose.material3.TextButton(onClick = {
+ showSaveTransfer = false
+ retroSaveImportLauncher.launch(arrayOf("*/*"))
+ }) { androidx.compose.material3.Text(stringResource(R.string.retro_save_transfer_import)) }
+ },
+ dismissButton = {
+ androidx.compose.material3.TextButton(onClick = {
+ val sram = com.winlator.cmod.feature.retro.RetroSaveStates.sramFile(context, app.name)
+ showSaveTransfer = false
+ if (!sram.isFile) {
+ android.widget.Toast.makeText(
+ context,
+ context.getString(R.string.retro_save_transfer_none),
+ android.widget.Toast.LENGTH_SHORT,
+ ).show()
+ } else {
+ retroSaveExportLauncher.launch("${app.name}.srm")
+ }
+ }) { androidx.compose.material3.Text(stringResource(R.string.retro_save_transfer_export)) }
+ },
+ )
+ }
+ // The 3D engine offers itself only for the few Gen 1
+ // titles it supports, and only when its files are
+ // actually in the retro bundle. Compatibility is the
+ // ROM's SHA-1, so deciding it means hashing a file:
+ // done once off the composition thread, keyed on the
+ // shortcut, rather than on every recomposition.
+ val engine3dShortcut = homeShortcutState.shortcut
+ val engine3dKey = engine3dShortcut?.file?.absolutePath
+ var engine3dSupported by remember(engine3dKey) { mutableStateOf(false) }
+ var engine3dOn by remember(engine3dKey) {
+ mutableStateOf(
+ engine3dShortcut?.let {
+ com.winlator.cmod.feature.retro.Gen1EmbedLaunch.isEnabled(it)
+ } ?: false,
+ )
+ }
+ LaunchedEffect(engine3dKey) {
+ engine3dSupported =
+ engine3dShortcut != null &&
+ withContext(Dispatchers.IO) {
+ runCatching {
+ com.winlator.cmod.feature.retro.Gen1EmbedLaunch
+ .isCompatible(context, engine3dShortcut)
+ }.getOrDefault(false)
+ }
+ }
LibraryGameLaunchScreen(
appName = launchAppName,
subtitle = subtitle,
@@ -2325,9 +2463,31 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
lastPlayedMillis = lastPlayed,
installSizeText = installSizeText,
isCustom = isCustom,
+ isRetro = isRetro,
+ showBootToDesktop = retroCaps.showBootToDesktop,
+ showSaveTransfer = retroCaps.showSaveTransfer,
hasPinnedShortcut = hasPinnedShortcut,
playEnabled = playEnabled,
playDisabledLabel = playDisabledLabel,
+ altEngineLabel =
+ if (engine3dSupported) stringResource(R.string.retro_gs_engine_3d) else null,
+ altEngineEnabled = engine3dOn,
+ onAltEngineChange =
+ if (engine3dSupported && engine3dShortcut != null) {
+ { on ->
+ engine3dOn = on
+ // Written straight to the shortcut, so
+ // Play uses it immediately and the
+ // Graphics pane agrees with it.
+ engine3dShortcut.putExtra(
+ com.winlator.cmod.feature.retro.Gen1EmbedLaunch.KEY_ENGINE_3D,
+ if (on) "1" else "0",
+ )
+ engine3dShortcut.saveData()
+ }
+ } else {
+ null
+ },
onBack = onDismissRequest,
onPlay = {
val containerManager = ContainerManager(context)
@@ -2345,7 +2505,6 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
onSettings = {
val shortcut = resolveOrCreateShortcut()
if (shortcut != null) {
- // Layer the settings dialog on top; keep the detail dialog open underneath.
ShortcutSettingsComposeDialog(this@LibraryGameDetailDialog, shortcut).show()
}
},
@@ -2362,9 +2521,46 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
)
}
},
- onAchievements = if (!isCustom && !isEpic && !isGog) {
- { showAchievements = true }
- } else null,
+ onAchievements =
+ when {
+ isRetro -> {
+ val sysId = retroSystemId
+ val rom = retroRomPath
+ if (sysId != null && rom != null && retroCaps.showAchievements) {
+ {
+ context.startActivity(
+ android.content.Intent(
+ context,
+ com.winlator.cmod.feature.retro.RetroAchievementsActivity::class.java,
+ ).apply {
+ putExtra(
+ com.winlator.cmod.feature.retro.RetroAchievementsActivity.EXTRA_SYSTEM_ID,
+ sysId,
+ )
+ putExtra(
+ com.winlator.cmod.feature.retro.RetroAchievementsActivity.EXTRA_ROM_PATH,
+ rom,
+ )
+ putExtra(
+ com.winlator.cmod.feature.retro.RetroAchievementsActivity.EXTRA_GAME_NAME,
+ app.name,
+ )
+ putExtra(
+ com.winlator.cmod.feature.retro.RetroAchievementsActivity.EXTRA_IN_SESSION,
+ false,
+ )
+ },
+ )
+ }
+ } else {
+ null
+ }
+ }
+ !isCustom && !isEpic && !isGog -> {
+ { showAchievements = true }
+ }
+ else -> null
+ },
onShortcut = {
if (hasPinnedShortcut) {
heroPopup = HeroLaunchPopup.RemoveShortcut
@@ -2399,9 +2595,41 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
}
},
onCloudSaves = { activePopup = LibraryDetailPopup.CloudSaves },
+ onSaveTransfer =
+ if (retroCaps.showSaveTransfer) {
+ { showSaveTransfer = true }
+ } else {
+ null
+ },
+ onCheats =
+ if (retroCaps.showCheats && retroSystemId != null) {
+ {
+ context.startActivity(
+ android.content.Intent(
+ context,
+ com.winlator.cmod.feature.retro.RetroCheatsActivity::class.java,
+ ).apply {
+ putExtra(
+ com.winlator.cmod.feature.retro.RetroCheatsActivity.EXTRA_SYSTEM_ID,
+ retroSystemId,
+ )
+ putExtra(
+ com.winlator.cmod.feature.retro.RetroCheatsActivity.EXTRA_GAME_NAME,
+ app.name,
+ )
+ },
+ )
+ }
+ } else {
+ null
+ },
+ cheatsEnabled =
+ !(
+ com.winlator.cmod.feature.retro.RetroAchievementsManager.isEnabled(context) &&
+ com.winlator.cmod.feature.retro.RetroAchievementsManager.isLoggedIn(context) &&
+ com.winlator.cmod.feature.retro.RetroAchievementsManager.isHardcorePreferred(context)
+ ),
onUninstall = uninstallGame,
- // Store source tag actions. Steam exposes verify/update/workshop;
- // Epic and GOG expose verify/update for installed games.
steamMenuEnabled = !isCustom &&
(!isEpic || epicGame?.isInstalled == true) &&
(!isGog || gogGame?.isInstalled == true),
@@ -2411,7 +2639,7 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
showCheckForUpdate = !isCustom &&
(!isEpic || epicGame?.isInstalled == true) &&
(!isGog || gogGame?.isInstalled == true),
- showWorkshop = !isEpic && !isGog,
+ showWorkshop = !isCustom && !isEpic && !isGog,
areSteamActionsEnabled =
when {
isEpic -> !hasBlockingEpicDownloadForLibrary
@@ -2430,8 +2658,6 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
}
}
if (started != null) {
- // Hand off to the activity-root host so the
- // pop-up + completion notice outlive this dialog.
showTaskProgressPopup(
started,
if (isGog) gogGame!!.title else app.name,
@@ -2457,7 +2683,7 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
else -> startUpdateCheck(app.id, app.name)
}
},
- onWorkshop = { if (!isEpic && !isGog) showWorkshopDialog = true },
+ onWorkshop = { if (!isEpic && !isGog && !isCustom) showWorkshopDialog = true },
)
when (heroPopup) {
@@ -2631,6 +2857,7 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
gameId = detailGameId,
gameName = app.name,
shortcut = detailShortcut,
+ retroSaveDir = com.winlator.cmod.feature.sync.google.GameSaveBackupManager.retroSaveDir(context, detailShortcut),
onCloudSyncToggle = { enabled ->
cloudSyncEnabled = enabled
setShortcutCloudSyncEnabled(detailShortcut, enabled)
@@ -2824,6 +3051,7 @@ internal fun UnifiedActivity.LibraryGameDetailDialog(
gameId = detailGameId,
gameName = app.name,
shortcut = detailShortcut,
+ retroSaveDir = com.winlator.cmod.feature.sync.google.GameSaveBackupManager.retroSaveDir(context, detailShortcut),
onCloudSyncToggle = { enabled ->
cloudSyncEnabled = enabled
setShortcutCloudSyncEnabled(detailShortcut, enabled)
diff --git a/app/src/main/app/shell/UnifiedActivityHub.kt b/app/src/main/app/shell/UnifiedActivityHub.kt
index 755c4987f..6f3744af4 100644
--- a/app/src/main/app/shell/UnifiedActivityHub.kt
+++ b/app/src/main/app/shell/UnifiedActivityHub.kt
@@ -1837,7 +1837,7 @@ internal fun UnifiedActivity.LibraryCarousel(
var localLibraryRefreshKey by remember { mutableIntStateOf(0) }
var shortcutsLoaded by remember { mutableStateOf(false) }
var pullRefreshing by remember { mutableStateOf(false) }
- LaunchedEffect(shortcutRefreshKey, localLibraryRefreshKey) {
+ LaunchedEffect(shortcutRefreshKey, localLibraryRefreshKey, com.winlator.cmod.feature.retro.RetroBoxart.artVersion.value) {
shortcutsLoaded = false
// Pull-to-refresh only: rescan disk so a manually moved game is picked up without faking a re-download.
@@ -1851,11 +1851,13 @@ internal fun UnifiedActivity.LibraryCarousel(
val shortcutScanResult =
runCatching {
withContext(Dispatchers.IO) {
+ runCatching { com.winlator.cmod.feature.retro.RetroRomScanner.scanConfiguredFolder(context) }
val cm = ContainerManager(context)
cm.upgradeShortcuts {
localLibraryRefreshKey++
}
val allShortcuts = cm.loadShortcuts()
+ val badges = HashMap()
val apps =
allShortcuts
.mapNotNull { shortcut ->
@@ -1875,6 +1877,13 @@ internal fun UnifiedActivity.LibraryCarousel(
-(displayName.hashCode().and(0x7FFFFFFF) + 1)
}
+ com.winlator.cmod.feature.retro.RetroSystems
+ .fromId(
+ shortcut.getExtra(
+ com.winlator.cmod.feature.retro.RetroShortcuts.KEY_SYSTEM,
+ ),
+ )?.let { badges[customId] = it.id }
+
SteamApp(
id = customId,
name = displayName,
@@ -1887,13 +1896,14 @@ internal fun UnifiedActivity.LibraryCarousel(
)
}
- allShortcuts to apps
+ Triple(allShortcuts, apps, badges)
}
}.getOrNull()
if (shortcutScanResult != null) {
cachedShortcuts = shortcutScanResult.first
customApps = shortcutScanResult.second
+ retroLibrarySystemIds.value = shortcutScanResult.third
}
shortcutsLoaded = true
diff --git a/app/src/main/app/shell/UnifiedActivityLaunch.kt b/app/src/main/app/shell/UnifiedActivityLaunch.kt
index d7691b39b..eb211455f 100644
--- a/app/src/main/app/shell/UnifiedActivityLaunch.kt
+++ b/app/src/main/app/shell/UnifiedActivityLaunch.kt
@@ -923,6 +923,22 @@ internal fun UnifiedActivity.launchCustomGame(
return@launch
}
+ if (com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut)) {
+ // Asks RetroShortcuts which launcher this shortcut needs rather
+ // than reasoning about it here. Listing the embedded paths in two
+ // places is what sent 3D-enabled games to the libretro core: this
+ // caller knew about PS2 and Dolphin but not about the 3D engine.
+ if (com.winlator.cmod.feature.retro.RetroShortcuts.usesEmbeddedLauncher(context, shortcut)) {
+ withContext(Dispatchers.Main) {
+ com.winlator.cmod.feature.retro.RetroShortcuts.launch(context, shortcut)
+ }
+ return@launch
+ }
+ val retroIntent = com.winlator.cmod.feature.retro.RetroShortcuts.launchIntent(context, shortcut)
+ withContext(Dispatchers.Main) { launchGame(context, retroIntent) }
+ return@launch
+ }
+
// Backfill custom_name if missing (legacy shortcuts)
if (shortcut.getExtra("custom_name").isEmpty()) {
shortcut.putExtra("custom_name", gameName)
diff --git a/app/src/main/app/shell/UnifiedActivityStartup.kt b/app/src/main/app/shell/UnifiedActivityStartup.kt
index 0c587030d..887ef156d 100644
--- a/app/src/main/app/shell/UnifiedActivityStartup.kt
+++ b/app/src/main/app/shell/UnifiedActivityStartup.kt
@@ -464,6 +464,7 @@ internal fun UnifiedActivity.maybeAutoSignInGoogleOnLaunch() {
if (authed) {
com.winlator.cmod.feature.sync.google.GameSaveBackupManager
.setDriveConnected(applicationContext, true)
+ retryPendingRetroCloudBackup()
}
}
}.onFailure {
diff --git a/app/src/main/app/shell/UnifiedActivityStores.kt b/app/src/main/app/shell/UnifiedActivityStores.kt
index dc1441e8c..f466d40da 100644
--- a/app/src/main/app/shell/UnifiedActivityStores.kt
+++ b/app/src/main/app/shell/UnifiedActivityStores.kt
@@ -538,6 +538,9 @@ internal fun UnifiedActivity.GameCapsule(
.clip(RoundedCornerShape(8.dp)),
) {
ArtContent(Modifier.fillMaxSize())
+ libraryBadgeLabel(app.id, isCustom)?.let { badge ->
+ RetroConsoleRibbon(badge, Modifier.align(Alignment.CenterStart))
+ }
}
Spacer(Modifier.width(14.dp))
@@ -585,6 +588,9 @@ internal fun UnifiedActivity.GameCapsule(
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)),
) {
ArtContent(Modifier.fillMaxSize())
+ libraryBadgeLabel(app.id, isCustom)?.let { badge ->
+ RetroConsoleRibbon(badge, Modifier.align(Alignment.CenterStart))
+ }
}
Text(
diff --git a/app/src/main/assets/dnas/dnas_bypass.json b/app/src/main/assets/dnas/dnas_bypass.json
new file mode 100644
index 000000000..fb7610484
--- /dev/null
+++ b/app/src/main/assets/dnas/dnas_bypass.json
@@ -0,0 +1,514 @@
+{
+ "SCES-51706": {
+ "title": "Amplitude",
+ "cheats": [
+ {
+ "name": "DNAS Patch",
+ "codes": [
+ "D04E7BFA 00000001",
+ "2025BE18 14400013"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SLUS-21165": {
+ "title": "Arc the Lad: End of Darkness",
+ "cheats": [
+ {
+ "name": "DNAS Bypass by Harry62",
+ "codes": [
+ "2011AF84 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SLES-53729": {
+ "title": "Battlefield 2: Modern Combat",
+ "cheats": [
+ {
+ "name": "DNAS Bypass Code",
+ "codes": [
+ "2021A268 00000000"
+ ],
+ "crc": "185D22A9",
+ "auto": true
+ },
+ {
+ "name": "DNAS Bypass Code",
+ "codes": [
+ "2021A078 00000000"
+ ],
+ "crc": "89CDE501",
+ "auto": true
+ }
+ ]
+ },
+ "SLES-53730": {
+ "title": "Battlefield 2: Modern Combat",
+ "cheats": [
+ {
+ "name": "DNAS Bypass Code",
+ "codes": [
+ "2021A268 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SLPM-66206": {
+ "title": "Battlefield 2: Modern Combat",
+ "cheats": [
+ {
+ "name": "DNAS Bypass Code",
+ "codes": [
+ "2021A078 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SLUS-21026": {
+ "title": "Battlefield 2: Modern Combat",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "2021A240 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SLES-52782": {
+ "title": "Call of Duty: Finest Hour",
+ "cheats": [
+ {
+ "name": "DNAS Bypass Code",
+ "codes": [
+ "D02942F0 0000F0B6",
+ "202942F0 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCES-50781": {
+ "title": "Destruction Derby Arenas",
+ "cheats": [
+ {
+ "name": "[MODE 3] DNAS Patch",
+ "codes": [
+ "D1D8139C 24020006",
+ "01D81390 00000000",
+ "D1D8139C 24020006",
+ "01D8139C 00000005"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCES-53033": {
+ "title": "Formula One 05",
+ "cheats": [
+ {
+ "name": "DNAS bypass",
+ "codes": [
+ "D039FBA4 24020006",
+ "0039FB98 00000000",
+ "D039FBA4 24020006",
+ "0039FBA4 00000005"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SLES-53667": {
+ "title": "Gauntlet: Seven Sorrows",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "203842E0 03E00008",
+ "203842E4 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "PAPX-90523": {
+ "title": "Gran Turismo 4 Online (Beta)",
+ "cheats": [
+ {
+ "name": "DNAS Bypass Code",
+ "codes": [
+ "D188D734 24020006",
+ "0188D728 00000000",
+ "D188D734 24020006",
+ "0188D734 00000005"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCES-51977": {
+ "title": "Hardware: Online Arena",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "D1D424A8 00000007",
+ "01D424A8 00000005",
+ "D1D424A8 00000006",
+ "01D424A8 00000005"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SLES-53585": {
+ "title": "Marvel Nemesis: Rise of the Imperfects",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "2020249C 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SLUS-21281": {
+ "title": "Marvel Nemesis: Rise of the Imperfects",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "2020249C 00000000",
+ "20509B9C 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCES-51677": {
+ "title": "MyStreet",
+ "cheats": [
+ {
+ "name": "DNAS Patch",
+ "codes": [
+ "D19A40B4 24020006",
+ "019A40A8 00000000",
+ "D19A40B4 24020006",
+ "019A40B4 00000005"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCES-52456": {
+ "title": "Ratchet & Clank 3",
+ "cheats": [
+ {
+ "name": "DNAS Patch",
+ "codes": [
+ "D04D55E4 24020006",
+ "004D55D8 00000000",
+ "D04D55E4 24020006",
+ "004D55E4 00000005"
+ ],
+ "auto": true
+ },
+ {
+ "name": "DNAS Patch",
+ "codes": [
+ "D178CAF4 24020006",
+ "0178CAE8 00000000",
+ "D178CAF4 24020006",
+ "0178CAF4 00000005"
+ ],
+ "auto": false
+ }
+ ]
+ },
+ "SCUS-97474": {
+ "title": "SOCOM 3: U.S. Navy SEALs",
+ "cheats": [
+ {
+ "name": "DNAS Bypass (Base version of the game)",
+ "codes": [
+ "202859A0 03E00008",
+ "202859A4 00000000"
+ ],
+ "auto": true
+ },
+ {
+ "name": "DNAS Bypass (Patch 2.3)",
+ "codes": [
+ "2028AE10 03E00008",
+ "2028AE14 00000000"
+ ],
+ "auto": false
+ }
+ ]
+ },
+ "SCUS-97489": {
+ "title": "SOCOM 3: U.S. Navy SEALs",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "202461E0 03E00008",
+ "202461E4 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCUS-97545": {
+ "title": "SOCOM: U.S. Navy SEALs Combined Assault",
+ "cheats": [
+ {
+ "name": "DNAS Bypass (Works for both 1.0 and 1.4 version of the game)",
+ "codes": [
+ "2029A2DC 24020001",
+ "2029BD90 03E00008",
+ "2029BD94 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCES-52306": {
+ "title": "SOCOM II: U.S. Navy SEALs",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "202D61B0 03E00008",
+ "202D61B4 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCKA-20020": {
+ "title": "SOCOM II: U.S. Navy SEALs",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "202D7818 24020001"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCPS-15065": {
+ "title": "SOCOM II: U.S. Navy SEALs",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "202DD648 24020001"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCUS-97275": {
+ "title": "SOCOM II: U.S. Navy SEALs",
+ "cheats": [
+ {
+ "name": "DNAS Bypass r0001",
+ "codes": [
+ "202CC670 03E00008",
+ "202CC674 00000000"
+ ],
+ "auto": true
+ },
+ {
+ "name": "DNAS Bypass r0004",
+ "codes": [
+ "203953C0 03E00008",
+ "203953C4 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCUS-97366": {
+ "title": "SOCOM II: U.S. Navy SEALs",
+ "cheats": [
+ {
+ "name": "DNAS Bypass(Harry62)",
+ "codes": [
+ "203953C0 03E00008",
+ "203953C4 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "TCES-51904": {
+ "title": "SOCOM II: U.S. Navy SEALs",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "2040F1C0 03E00008",
+ "2040F1C4 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCKA-24008": {
+ "title": "SOCOM: U.S. Navy SEALs",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "20379C48 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCKA-90010": {
+ "title": "SOCOM: U.S. Navy SEALs",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "20379548 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCPS-15044": {
+ "title": "SOCOM: U.S. Navy SEALs",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "203814C8 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCES-52033": {
+ "title": "Syphon Filter: The Omega Strain",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "20382390 03E00008",
+ "20382394 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCKA-20032": {
+ "title": "Syphon Filter: The Omega Strain",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "20384D30 03E00008",
+ "20384D34 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCUS-97264": {
+ "title": "Syphon Filter: The Omega Strain",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "2044B7A0 03E00008",
+ "2044B7A4 00000000"
+ ],
+ "auto": true
+ },
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "20343940 03E00008",
+ "20343944 00000000"
+ ],
+ "auto": false
+ }
+ ]
+ },
+ "SCUS-97397": {
+ "title": "Syphon Filter: The Omega Strain",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "203563C0 03E00008",
+ "203563C4 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "TCES-52033": {
+ "title": "Syphon Filter: The Omega Strain",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "2037B960 03E00008",
+ "2037B964 00000000"
+ ],
+ "auto": true
+ }
+ ]
+ },
+ "SCES-52389": {
+ "title": "WRC 4",
+ "cheats": [
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "D05B4C88 00000000",
+ "205B4C88 00000001"
+ ],
+ "crc": "CDE7C999",
+ "auto": true
+ },
+ {
+ "name": "DNAS Bypass",
+ "codes": [
+ "D05B4E88 00000000",
+ "205B4E88 00000001"
+ ],
+ "crc": "AFD06CBA",
+ "auto": true
+ }
+ ]
+ },
+ "TCES-53247": {
+ "title": "WRC: Rally Evolved",
+ "cheats": [
+ {
+ "name": "DNAS Patch",
+ "codes": [
+ "D19606AC 24020006",
+ "019606A0 00000000",
+ "D19606AC 24020006",
+ "019606AC 00000005"
+ ],
+ "auto": true
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/assets/retro/GLideN64.custom.ini b/app/src/main/assets/retro/GLideN64.custom.ini
new file mode 100644
index 000000000..842d2d54b
--- /dev/null
+++ b/app/src/main/assets/retro/GLideN64.custom.ini
@@ -0,0 +1,360 @@
+; Custom game settings
+[General]
+version=13
+
+[TWINE]
+Good_Name=007 - The World Is Not Enough (E)(U)
+frameBufferEmulation\N64DepthCompare=1
+
+[40%20WINKS]
+Good_Name=40 Winks (E) (M3) (Prototype)
+graphics2D\enableNativeResTexrects=1
+
+[BAKU-BOMBERMAN]
+Good_Name=Baku Bomberman (J) [!]
+generalEmulation\enableLegacyBlending=0
+
+[BIOFREAKS]
+Good_Name=Bio F.R.E.A.K.S. (E)(U)
+frameBufferEmulation\copyToRDRAM=1
+
+[BIOHAZARD%20II]
+Good_Name=Biohazard 2 (J)
+frameBufferEmulation\copyFromRDRAM=1
+frameBufferEmulation\copyToRDRAM=0
+frameBufferEmulation\copyDepthToRDRAM=0
+
+[BOMBERMAN64E]
+Good_Name=Bomberman 64 (E) [!]
+generalEmulation\enableLegacyBlending=0
+
+[BOMBERMAN64U]
+Good_Name=Bomberman 64 (U) [!]
+generalEmulation\enableLegacyBlending=0
+
+[362D06B6]
+Good_Name=Densha de Go! 64 (J)
+generalEmulation\enableLegacyBlending=0
+
+[68D128AE]
+Good_Name=Densha de Go! 64 (J) (Localization Patch v1.01)
+generalEmulation\enableLegacyBlending=0
+
+[52150A67]
+Good_Name=Bokujou Monogatari 2 (J)
+frameBufferEmulation\N64DepthCompare=1
+
+[67000C2B]
+Good_Name=Eikou no Saint Andrews (J)
+frameBufferEmulation\forceDepthBufferClear=1
+
+[CAL%20SPEED]
+Good_Name=California Speed (U)
+frameBufferEmulation\bufferSwapMode=1
+
+[CASTLEVANIA2]
+Good_Name=Castlevania - Legacy Of Darkness (E)(U)
+frameBufferEmulation\copyToRDRAM=1
+
+[DMPJ]
+Good_Name=Mario Artist Paint Studio (J) (64DD)
+frameBufferEmulation\copyAuxToRDRAM=1
+frameBufferEmulation\copyFromRDRAM=1
+frameBufferEmulation\nativeResFactor=1
+generalEmulation\rdramImageDitheringMode=0
+
+[DMTJ]
+Good_Name=Mario Artist Talent Studio (J) (64DD)
+frameBufferEmulation\copyAuxToRDRAM=1
+
+[DINO%20PLANET]
+Good_Name=Dinosaur Planet (Dec 2000 Beta)
+frameBufferEmulation\copyToRDRAM=1
+frameBufferEmulation\copyAuxToRDRAM=1
+
+[DONKEY%20KONG%2064]
+Good_Name=Donkey Kong 64 (E)(J)(U)
+frameBufferEmulation\copyDepthToRDRAM=0
+frameBufferEmulation\N64DepthCompare=0
+
+[DR.MARIO%2064]
+Good_Name=Dr. Mario 64 (U)
+frameBufferEmulation\fbInfoDisabled=0
+frameBufferEmulation\copyFromRDRAM=1
+frameBufferEmulation\copyToRDRAM=0
+
+[EXTREME_G]
+Good_Name=Extreme-G (E)
+frameBufferEmulation\N64DepthCompare=1
+
+[EXTREME-G]
+Good_Name=Extreme-G (J)
+frameBufferEmulation\N64DepthCompare=1
+
+[EXTREMEG]
+Good_Name=Extreme-G (U)
+frameBufferEmulation\N64DepthCompare=1
+
+[EXTREME%20G%202]
+Good_Name=Extreme-G XG2 (E) (U)
+frameBufferEmulation\N64DepthCompare=1
+
+[208E05CD]
+Good_Name=Extreme-G XG2 (J)
+frameBufferEmulation\N64DepthCompare=1
+
+[F1%20POLE%20POSITION%2064]
+Good_Name=F-1 Pole Position 64 (E)(U)
+frameBufferEmulation\copyToRDRAM=1
+
+[FLAPPYBIRD64]
+Good_Name=FlappyBird64
+frameBufferEmulation\fbInfoDisabled=0
+frameBufferEmulation\copyFromRDRAM=1
+frameBufferEmulation\copyToRDRAM=0
+
+[GOEMON2%20DERODERO]
+Good_Name=Ganbare Goemon - Dero Dero Douchuu Obake Tenkomori (J)
+graphics2D\enableNativeResTexrects=1
+
+[GOEMONS%20GREAT%20ADV]
+Good_Name=Goemons Great Adventure (U)
+graphics2D\enableNativeResTexrects=1
+
+[HARVESTMOON64]
+Good_Name=Harvest Moon 64 (U)
+frameBufferEmulation\N64DepthCompare=1
+
+[5CFA0A2E]
+Good_Name=Heiwa Pachinko World 64 (J)
+frameBufferEmulation\copyToRDRAM=1
+
+[HEXEN]
+Good_Name=Hexen (E)(F)(G)(J)(U)
+frameBufferEmulation\copyToRDRAM=1
+
+[HUMAN%20GRAND%20PRIX]
+Good_Name=Human Grand Prix - New Generation (J)
+frameBufferEmulation\copyToRDRAM=1
+
+[I%20S%20S%2064]
+Good_Name=International Superstar Soccer 64 (E) (U)
+frameBufferEmulation\N64DepthCompare=1
+
+[JET%20FORCE%20GEMINI]
+Good_Name=Jet Force Gemini (E)(U)
+frameBufferEmulation\fbInfoDisabled=0
+frameBufferEmulation\copyAuxToRDRAM=1
+frameBufferEmulation\copyToRDRAM=0
+
+[J%20F%20G%20DISPLAY]
+Good_Name=Jet Force Gemini Kiosk Demo (U)
+frameBufferEmulation\fbInfoDisabled=0
+frameBufferEmulation\copyAuxToRDRAM=1
+frameBufferEmulation\copyToRDRAM=0
+
+[J%20WORLD%20SOCCER3]
+Good_Name=Jikkyou World Soccer 3 (J)
+frameBufferEmulation\N64DepthCompare=1
+
+[301E07CC]
+Good_Name=Mahjong Master (J)
+frameBufferEmulation\N64DepthCompare=1
+
+[DMGJ]
+Good_Name=Mario Artist Polygon Studio (J)
+frameBufferEmulation\copyAuxToRDRAM=1
+
+[KEN%20GRIFFEY%20SLUGFEST]
+Good_Name=Ken Griffey Jr.'s Slugfest
+frameBufferEmulation\fbInfoDisabled=0
+
+[KIRBY64]
+Good_Name=Kirby 64 - The Crystal Shards (E)(J)(U)
+graphics2D\enableNativeResTexrects=1
+
+[MARIOGOLF64]
+Good_Name=Mario Golf (E)(J)(U)
+frameBufferEmulation\copyDepthToRDRAM=0
+
+[MARIOKART64]
+Good_Name=Mario Kart 64 (E)(J)(U)
+graphics2D\enableNativeResTexrects=1
+graphics2D\enableTexCoordBounds=1
+frameBufferEmulation\copyToRDRAM=2
+
+[MARIO%20STORY]
+Good_Name=Mario Story (J)
+frameBufferEmulation\copyToRDRAM=1
+
+[MEGA%20MAN%2064]
+Good_Name=Mega Man 64 (U)
+graphics2D\correctTexrectCoords=2
+
+[MEGAMAN%2064]
+Good_Name=Mega Man 64 (Proto)
+graphics2D\correctTexrectCoords=2
+
+[MLB%20FEATURING%20K%20G%20JR]
+Good_Name=Major League Baseball Featuring Ken Griffey Jr.
+frameBufferEmulation\fbInfoDisabled=0
+
+[MYSTICAL%20NINJA2%20SG]
+Good_Name=Mystical Ninja 2 Starring Goemon (E)
+graphics2D\enableNativeResTexrects=1
+
+[NASCAR%202000]
+Good_Name=NASCAR 2000 (U)
+frameBufferEmulation\copyToRDRAM=1
+
+[NASCAR%2099]
+Good_Name=NASCAR 99 (U)
+frameBufferEmulation\copyToRDRAM=1
+
+[NUD-DMPJ-JPN_convert]
+Good_Name=Mario Paint Studio (cart hack)
+frameBufferEmulation\copyFromRDRAM=1
+
+[NUD-DMTJ-JPN_convert]
+Good_Name=Mario Artist Talent Studio (cart hack)
+frameBufferEmulation\copyAuxToRDRAM=1
+
+[OGREBATTLE64]
+Good_Name=Ogre Battle 64 - Person of Lordly Caliber (U)
+graphics2D\enableTexCoordBounds=1
+
+[OLYMPIC%20HOCKEY]
+Good_Name=Olympic Hockey Nagano '98 (E)(J)(U)
+frameBufferEmulation\bufferSwapMode=1
+
+[PAPER%20MARIO]
+Good_Name=Paper Mario (E)(U)
+frameBufferEmulation\copyToRDRAM=1
+graphics2D\enableTexCoordBounds=1
+
+[PENNY%20RACERS]
+Good_Name=Penny Racers (E)(U)
+frameBufferEmulation\copyToRDRAM=0
+
+[PERFECT%20STRIKER]
+Good_Name=Jikkyou J.League Perfect Striker (J)
+frameBufferEmulation\N64DepthCompare=1
+
+[POKEMON%20SNAP]
+Good_Name=Pokemon Snap (U)
+generalEmulation\rdramImageDitheringMode=1
+frameBufferEmulation\copyAuxToRDRAM=1
+frameBufferEmulation\copyToRDRAM=1
+frameBufferEmulation\fbInfoDisabled=0
+
+[POKEMON%20STADIUM]
+Good_Name=Pokemon Stadium (U)
+frameBufferEmulation\copyDepthToRDRAM=0
+
+[POKEMON%20STADIUM%202]
+Good_Name=Pokemon Stadium 2 (E)(F)(G)(I)(J)(S)(U)
+frameBufferEmulation\copyToRDRAM=0
+frameBufferEmulation\copyDepthToRDRAM=0
+
+[POKEMON%20STADIUM%20G%26S]
+Good_Name=Pokemon Stadium Kin Gin (J)
+frameBufferEmulation\copyToRDRAM=0
+frameBufferEmulation\copyDepthToRDRAM=0
+
+[PUZZLE%20LEAGUE%20N64]
+Good_Name=Pokemon Puzzle League (E)(F)(G)(U)
+texture\enableHalosRemoval=1
+
+[RAT%20ATTACK]
+Good_Name=Rat Attack
+frameBufferEmulation\fbInfoDisabled=0
+
+[RESIDENT%20EVIL%20II]
+Good_Name=Resident Evil 2 (E)(U)
+frameBufferEmulation\copyFromRDRAM=1
+frameBufferEmulation\copyToRDRAM=0
+frameBufferEmulation\copyDepthToRDRAM=0
+
+[ROCKMAN%20DASH]
+Good_Name=Rockman Dash - Hagane no Boukenshin (J)
+graphics2D\correctTexrectCoords=2
+
+[RUSH%202]
+Good_Name=Rush 2 - Extreme Racing USA (E)(U)
+frameBufferEmulation\bufferSwapMode=1
+graphics2D\correctTexrectCoords=2
+
+[SAN%20FRANCISCO%20RUSH]
+Good_Name=San Francisco Rush Extreme Racing (U)
+frameBufferEmulation\bufferSwapMode=1
+graphics2D\enableNativeResTexrects=2
+
+[S.F.RUSH]
+Good_Name=San Francisco Rush Extreme Racing (E)
+frameBufferEmulation\bufferSwapMode=1
+graphics2D\enableNativeResTexrects=2
+
+[S.F.%20RUSH]
+Good_Name=San Francisco Rush Extreme Racing (U)
+frameBufferEmulation\bufferSwapMode=1
+graphics2D\enableNativeResTexrects=2
+
+[SHADOWMAN]
+Good_Name=Shadow Man (B)(E)(F)(G)(U)
+frameBufferEmulation\copyDepthToRDRAM=0
+
+[SPACE%20INVADERS]
+Good_Name=Space Invaders (U)
+frameBufferEmulation\copyToRDRAM=0
+
+[SILICON%20VALLEY]
+Good_Name=Space Station Silicon Valley (U)
+frameBufferEmulation\copyToRDRAM=1
+
+[STAR%20TWINS]
+Good_Name=Star Twins (J)
+frameBufferEmulation\fbInfoDisabled=0
+frameBufferEmulation\copyAuxToRDRAM=1
+frameBufferEmulation\copyToRDRAM=0
+
+[TEST]
+Good_Name=Mario Artist Paint Studio (J) (1999-02-11 Prototype) (64DD)
+frameBufferEmulation\copyAuxToRDRAM=1
+frameBufferEmulation\copyFromRDRAM=1
+generalEmulation\rdramImageDitheringMode=0
+
+[TETRISPHERE]
+Good_Name=Tetrisphere (E)(U)
+graphics2D\correctTexrectCoords=2
+
+[TIGGER%27S%20HONEY%20HUNT]
+Good_Name=Tiggers Honey Hunt (E)(U)
+frameBufferEmulation\N64DepthCompare=1
+
+[TONIC%20TROUBLE]
+Good_Name=Tonic Trouble (E)(U)
+frameBufferEmulation\copyToRDRAM=1
+
+[TG%20RALLY%202]
+Good_Name=TG Rally 2 (E)
+frameBufferEmulation\copyToRDRAM=1
+
+[TOP%20GEAR%20RALLY%202]
+Good_Name=Top Gear Rally 2 (E)(J)(U)
+frameBufferEmulation\copyToRDRAM=1
+
+[TUROK_DINOSAUR_HUNTE]
+Good_Name=Turok - Dinosaur Hunter (E)(G)(U)(J)
+frameBufferEmulation\copyDepthToRDRAM=1
+
+[WAVE%20RACE%2064]
+Good_Name=Wave Race 64 (E)(U)(J)
+frameBufferEmulation\copyToRDRAM=1
+
+[W.G.%203DHOCKEY]
+Good_Name=Wayne Gretzky's 3D Hockey (E)(U)
+frameBufferEmulation\bufferSwapMode=1
+
+[WGHOCKEY]
+Good_Name=Wayne Gretzky's 3D Hockey (J)
+frameBufferEmulation\bufferSwapMode=1
diff --git a/app/src/main/engine/org/love2d/android/GameActivity.java b/app/src/main/engine/org/love2d/android/GameActivity.java
new file mode 100644
index 000000000..271e90b61
--- /dev/null
+++ b/app/src/main/engine/org/love2d/android/GameActivity.java
@@ -0,0 +1,880 @@
+/**
+ * Copyright (c) 2006-2023 LOVE Development Team
+ *
+ * This software is provided 'as-is', without any express or implied
+ * warranty. In no event will the authors be held liable for any damages
+ * arising from the use of this software.
+ *
+ * Permission is granted to anyone to use this software for any purpose,
+ * including commercial applications, and to alter it and redistribute it
+ * freely, subject to the following restrictions:
+ *
+ * 1. The origin of this software must not be misrepresented; you must not
+ * claim that you wrote the original software. If you use this software
+ * in a product, an acknowledgment in the product documentation would be
+ * appreciated but is not required.
+ * 2. Altered source versions must be plainly marked as such, and must not be
+ * misrepresented as being the original software.
+ * 3. This notice may not be removed or altered from any source distribution.
+ **/
+
+package org.love2d.android;
+
+// WinNative: this file is vendored unmodified from love-android 11.5a except
+// for this import. Upstream resolves R from its own package; here the only
+// R on the classpath is the host app's, and R.bool.embed is the single
+// resource it reads (false, so the game path arrives via the launch Intent).
+import com.winlator.cmod.R;
+import org.love2d.sdl.SDLActivity;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import android.Manifest;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.res.AssetManager;
+import android.media.AudioManager;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.Vibrator;
+import android.util.Log;
+import android.util.DisplayMetrics;
+import android.view.*;
+import android.content.pm.PackageManager;
+
+import androidx.annotation.Keep;
+import androidx.core.app.ActivityCompat;
+
+public class GameActivity extends SDLActivity {
+ private static DisplayMetrics metrics = null;
+ private static String gamePath = "";
+ private static Vibrator vibrator = null;
+ protected final int[] externalStorageRequestDummy = new int[1];
+ protected final int[] recordAudioRequestDummy = new int[1];
+ public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
+ public static final int RECORD_AUDIO_REQUEST_CODE = 3;
+ public static final int FILE_PICKER_REQUEST_CODE = 4;
+ public static final int FILE_CREATE_REQUEST_CODE = 5;
+ /** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
+ public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
+ // Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
+ // is dropped so RomImporter's existing folder scan finds it -- see
+ // src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
+ // save directory to getExternalFilesDir()/save/).
+ private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
+ private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
+ private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
+ private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
+ private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
+ private static final String EXPORT_DONE_FILENAME = "export_done.flag";
+ // Written when a SAF pick cannot be read at all, with the destination
+ // basename as its body, so RomImporter:focus can say so in the launcher
+ // instead of leaving the player on "No ROM imported" (issue #442).
+ private static final String PICK_ERROR_FILENAME = "pick_error.flag";
+ // Destination basename for the in-flight SAF pick (set by showFilePicker).
+ private String pendingPickFilename = PICKED_ROM_FILENAME;
+ // Suggested download name for the in-flight SAF create (set by showCreateDocument).
+ private String pendingCreateSuggestedName = "export.sav";
+ private static boolean immersiveActive = false;
+ private static boolean needToCopyGameInArchive = false;
+ private boolean storagePermissionUnnecessary = false;
+ private boolean shortEdgesMode = false;
+ public boolean embed = false;
+ public int safeAreaTop = 0;
+ public int safeAreaLeft = 0;
+ public int safeAreaBottom = 0;
+ public int safeAreaRight = 0;
+
+ private static native void nativeSetDefaultStreamValues(int sampleRate, int framesPerBurst);
+
+ @Override
+ protected String[] getLibraries() {
+ return new String[] {
+ "c++_shared",
+ "mpg123",
+ "openal",
+ "love",
+ };
+ }
+
+ @Override
+ protected String getMainSharedObject() {
+ String[] libs = getLibraries();
+ String libname = "lib" + libs[libs.length - 1] + ".so";
+
+ // Since Lollipop, you can simply pass "libname.so" to dlopen
+ // and it will resolve correct paths and load correct library.
+ // This is mandatory for extractNativeLibs=false support in
+ // Marshmallow.
+ if (android.os.Build.VERSION.SDK_INT >= 21) {
+ return libname;
+ } else {
+ return getContext().getApplicationInfo().nativeLibraryDir + "/" + libname;
+ }
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Log.d("GameActivity", "started");
+
+ int res = checkCallingOrSelfPermission(Manifest.permission.VIBRATE);
+ if (res == PackageManager.PERMISSION_GRANTED) {
+ vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
+ } else {
+ Log.d("GameActivity", "Vibration disabled: could not get vibration permission.");
+ }
+
+ // These 2 variables must be reset or it will use the existing value.
+ gamePath = "";
+ storagePermissionUnnecessary = false;
+ embed = getResources().getBoolean(R.bool.embed);
+ needToCopyGameInArchive = embed;
+
+ if (!embed) {
+ Intent intent = getIntent();
+ handleIntent(intent);
+ intent.setData(null);
+ }
+
+ super.onCreate(savedInstanceState);
+ metrics = getResources().getDisplayMetrics();
+
+ // Set low-latency audio values
+ nativeSetDefaultStreamValues(getAudioFreq(), getAudioSMP());
+
+ if (android.os.Build.VERSION.SDK_INT >= 28) {
+ getWindow().getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER;
+ shortEdgesMode = false;
+ }
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ Log.d("GameActivity", "onNewIntent() with " + intent);
+ if (!embed) {
+ handleIntent(intent);
+ resetNative();
+ startNative();
+ }
+ }
+
+ protected void handleIntent(Intent intent) {
+ Uri game = intent.getData();
+
+ if (!embed && game != null) {
+ String scheme = game.getScheme();
+ String path = game.getPath();
+ // If we have a game via the intent data we we try to figure out how we have to load it. We
+ // support the following variations:
+ // * a main.lua file: set gamePath to the directory containing main.lua
+ // * otherwise: set gamePath to the file
+ if (scheme.equals("file")) {
+ Log.d("GameActivity", "Received file:// intent with path: " + path);
+ // If we were given the path of a main.lua then use its
+ // directory. Otherwise use full path.
+ List path_segments = game.getPathSegments();
+ if (path_segments.get(path_segments.size() - 1).equals("main.lua")) {
+ gamePath = path.substring(0, path.length() - "main.lua".length());
+ } else {
+ gamePath = path;
+ }
+ } else if (scheme.equals("content")) {
+ Log.d("GameActivity", "Received content:// intent with path: " + path);
+ try {
+ String filename = "game.love";
+ String[] pathSegments = path.split("/");
+ if (pathSegments.length > 0) {
+ filename = pathSegments[pathSegments.length - 1];
+ }
+
+ // Sanitize filename to prevent PhysFS complaining later.
+ filename = filename.replaceAll("[^a-zA-Z0-9_\\\\-\\\\.]", "_");
+
+ String destination_file = this.getCacheDir().getPath() + "/" + filename;
+ InputStream data = getContentResolver().openInputStream(game);
+
+ // copyAssetFile automatically closes the InputStream
+ if (copyAssetFile(data, destination_file)) {
+ gamePath = destination_file;
+ storagePermissionUnnecessary = true;
+ }
+ } catch (Exception e) {
+ Log.d("GameActivity", "could not read content uri " + game.toString() + ": " + e.getMessage());
+ }
+ } else {
+ Log.e("GameActivity", "Unsupported scheme: '" + game.getScheme() + "'.");
+
+ AlertDialog.Builder alert_dialog = new AlertDialog.Builder(this);
+ alert_dialog.setMessage("Could not load LÖVE game '" + path
+ + "' as it uses unsupported scheme '" + game.getScheme()
+ + "'. Please contact the developer.");
+ alert_dialog.setTitle("LÖVE for Android Error");
+ alert_dialog.setPositiveButton("Exit",
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int id) {
+ finish();
+ }
+ });
+ alert_dialog.setCancelable(false);
+ alert_dialog.create().show();
+ }
+ }
+
+ Log.d("GameActivity", "new gamePath: " + gamePath);
+ }
+
+ private void copyGameInsideArchive() {
+ try {
+ // If we have a game.love in our assets folder copy it to the cache folder
+ // so that we can load it from native LÖVE code
+ AssetManager assetManager = getAssets();
+ InputStream gameStream = assetManager.open("game.love");
+ String destinationFile = this.getCacheDir().getPath() + "/game.love";
+
+ if (copyAssetFile(gameStream, destinationFile))
+ gamePath = destinationFile;
+ else
+ gamePath = "game.love";
+ storagePermissionUnnecessary = true;
+ } catch (IOException e) {
+ // There's no game.love in our assets
+ Log.d("GameActivity", "Could not open game.love from assets: " + e.getMessage());
+ }
+ }
+
+ protected void checkLovegameFolder() {
+ // If no game.love was found and embed flavor is not used, fall back to the game in
+ // /Android/data//games/lovegame
+ if (!embed) {
+ Log.d("GameActivity", "fallback to lovegame folder");
+ File ext = getExternalFilesDir("games");
+ if ((new File(ext, "/lovegame/main.lua")).exists()) {
+ gamePath = ext.getPath() + "/lovegame/";
+ storagePermissionUnnecessary = true;
+ } else if (android.os.Build.VERSION.SDK_INT <= 28) {
+ // Try to fallback to /sdcard/lovegame in Android 9 and earlier too.
+ if (hasExternalStoragePermission()) {
+ ext = Environment.getExternalStorageDirectory();
+ if ((new File(ext, "/lovegame/main.lua")).exists()) {
+ gamePath = ext.getPath() + "/lovegame/";
+ storagePermissionUnnecessary = false;
+ }
+ } else {
+ Log.d("GameActivity", "Cannot load game from /sdcard/lovegame: permission not granted");
+ }
+ }
+
+ Log.d("GameActivity", "lovegame directory: " + gamePath);
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (vibrator != null) {
+ Log.d("GameActivity", "Cancelling vibration");
+ vibrator.cancel();
+ }
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onPause() {
+ if (vibrator != null) {
+ Log.d("GameActivity", "Cancelling vibration");
+ vibrator.cancel();
+ }
+ super.onPause();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ }
+
+ @Keep
+ public void setImmersiveMode(boolean immersive_mode) {
+ if (android.os.Build.VERSION.SDK_INT >= 28) {
+ getWindow().getAttributes().layoutInDisplayCutoutMode = immersive_mode ?
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES :
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER;
+ shortEdgesMode = immersive_mode;
+ }
+
+ immersiveActive = immersive_mode;
+ }
+
+ @Keep
+ public boolean getImmersiveMode() {
+ return immersiveActive;
+ }
+
+ @Keep
+ public static String getGamePath() {
+ GameActivity self = (GameActivity) mSingleton; // use SDL provided one
+ Log.d("GameActivity", "called getGamePath(), game path = " + gamePath);
+
+ if (gamePath.length() > 0) {
+ if (self.storagePermissionUnnecessary || self.hasExternalStoragePermission()) {
+ return gamePath;
+ } else {
+ Log.d("GameActivity", "cannot open game " + gamePath + ": no external storage permission given!");
+ }
+ } else if (needToCopyGameInArchive) {
+ self.copyGameInsideArchive();
+ } else {
+ self.checkLovegameFolder();
+ }
+
+ return gamePath;
+ }
+
+ public static DisplayMetrics getMetrics() {
+ return metrics;
+ }
+
+ @Keep
+ public static void vibrate(double seconds) {
+ if (vibrator != null) {
+ vibrator.vibrate((long) (seconds * 1000.));
+ }
+ }
+
+ @Keep
+ public static boolean openURLFromLOVE(String url) {
+ Log.d("GameActivity", "opening url = " + url);
+ return openURL(url) == 0;
+ }
+
+ /**
+ * Shows the system document picker (Storage Access Framework) so the
+ * player can pick a ROM / mod / save from anywhere (Downloads, Drive,
+ * etc.) without needing to know where the app's external files folder
+ * is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
+ * arrives later in onActivityResult, not synchronously here.
+ *
+ * @param destFilename basename under the app save identity (e.g.
+ * picked_rom.gb, picked_mod.zip, picked_save.sav)
+ */
+ @Keep
+ public static boolean showFilePicker(String destFilename) {
+ if (android.os.Build.VERSION.SDK_INT < 19) return false;
+ GameActivity self = (GameActivity) mSingleton;
+ if (self == null) return false;
+ if (destFilename == null || destFilename.length() == 0) {
+ destFilename = PICKED_ROM_FILENAME;
+ }
+ // Reject path separators so a hostile JNI caller cannot escape the
+ // save identity directory.
+ if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
+ Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
+ return false;
+ }
+
+ self.pendingPickFilename = destFilename;
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("*/*");
+ try {
+ self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
+ return true;
+ } catch (Exception e) {
+ Log.d("GameActivity", "could not open file picker: " + e.getMessage());
+ return false;
+ }
+ }
+
+ /** ROM convenience wrapper; prefer showFilePicker with an explicit name. */
+ @Keep
+ public static boolean showRomFilePicker() {
+ return showFilePicker(PICKED_ROM_FILENAME);
+ }
+
+ /** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */
+ @Keep
+ public static boolean showModFilePicker() {
+ return showFilePicker(PICKED_MOD_FILENAME);
+ }
+
+ /** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */
+ @Keep
+ public static boolean showSaveFilePicker() {
+ return showFilePicker(PICKED_SAVE_FILENAME);
+ }
+
+ /**
+ * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
+ * (pending_export.sav in the app save identity) to Downloads / Drive /
+ * etc. Suggested name is the dialog's default filename.
+ */
+ @Keep
+ public static boolean showCreateDocument(String suggestedName) {
+ if (android.os.Build.VERSION.SDK_INT < 19) return false;
+ GameActivity self = (GameActivity) mSingleton;
+ if (self == null) return false;
+ if (suggestedName == null || suggestedName.length() == 0) {
+ suggestedName = "export.sav";
+ }
+ if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) {
+ Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
+ return false;
+ }
+ File source = new File(
+ new File(self.getExternalFilesDir(null), "save"),
+ ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
+ if (!source.isFile()) {
+ Log.d("GameActivity", "no pending export at " + source);
+ return false;
+ }
+
+ self.pendingCreateSuggestedName = suggestedName;
+ Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("application/octet-stream");
+ intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
+ try {
+ self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE);
+ return true;
+ } catch (Exception e) {
+ Log.d("GameActivity", "could not open create-document picker: " + e.getMessage());
+ return false;
+ }
+ }
+
+ private File saveIdentityDir() {
+ return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
+ }
+
+ /** Drops a small flag file in the save identity for Lua to consume on focus. */
+ private void writeSaveDirFlag(String name, String body) {
+ try {
+ FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
+ fos.write(body.getBytes());
+ fos.close();
+ } catch (IOException e) {
+ Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
+ }
+ }
+
+ private boolean copyFileToUri(File source, Uri destUri) {
+ InputStream in = null;
+ OutputStream out = null;
+ try {
+ in = new BufferedInputStream(new FileInputStream(source));
+ out = getContentResolver().openOutputStream(destUri);
+ if (out == null) return false;
+ byte[] buf = new byte[8192];
+ int n;
+ while ((n = in.read(buf)) != -1) {
+ out.write(buf, 0, n);
+ }
+ out.flush();
+ return true;
+ } catch (IOException e) {
+ Log.d("GameActivity", "copy to URI failed: " + e.getMessage());
+ return false;
+ } finally {
+ try { if (in != null) in.close(); } catch (IOException ignored) {}
+ try { if (out != null) out.close(); } catch (IOException ignored) {}
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == FILE_CREATE_REQUEST_CODE) {
+ if (resultCode != RESULT_OK || data == null || data.getData() == null) {
+ Log.d("GameActivity", "create-document cancelled");
+ return;
+ }
+ File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME);
+ if (!source.isFile()) {
+ Log.d("GameActivity", "pending export missing at result time");
+ return;
+ }
+ Uri uri = data.getData();
+ if (copyFileToUri(source, uri)) {
+ // Signal Lua on next focus that the SAF export finished.
+ writeSaveDirFlag(EXPORT_DONE_FILENAME, "ok");
+ // Keep pending_export.sav so a retry still works; Lua may remove it.
+ } else {
+ Log.d("GameActivity", "could not write export to " + uri);
+ }
+ return;
+ }
+ if (requestCode != FILE_PICKER_REQUEST_CODE) return;
+ if (resultCode != RESULT_OK || data == null || data.getData() == null) {
+ Log.d("GameActivity", "file picker returned no file (cancelled?)");
+ return;
+ }
+
+ Uri uri = data.getData();
+ File destDir = saveIdentityDir();
+ if (!destDir.exists() && !destDir.mkdirs()) {
+ Log.d("GameActivity", "could not create " + destDir);
+ return;
+ }
+ String destName = pendingPickFilename != null
+ ? pendingPickFilename : PICKED_ROM_FILENAME;
+ File destFile = new File(destDir, destName);
+
+ // ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
+ // some OEM shells (ColorOS) offer third-party file managers in a
+ // chooser, and those hand back either a provider URI this app has no
+ // grant for (SecurityException / FileNotFoundException) or a bare
+ // file:// path (unreadable without storage permission on targetSdk 34).
+ // Try the resolver, then the path, then tell Lua why nothing imported.
+ InputStream source = null;
+ try {
+ source = getContentResolver().openInputStream(uri);
+ } catch (Exception e) {
+ Log.d("GameActivity", "could not open picked file: " + e.getMessage());
+ }
+ if (source == null && "file".equals(uri.getScheme()) && uri.getPath() != null) {
+ try {
+ source = new FileInputStream(uri.getPath());
+ } catch (FileNotFoundException e) {
+ Log.d("GameActivity", "could not open picked path: " + e.getMessage());
+ }
+ }
+ if (source == null) {
+ Log.d("GameActivity", "no readable stream for picked file " + uri);
+ writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
+ return;
+ }
+ if (!copyAssetFile(source, destFile.getPath())) {
+ Log.d("GameActivity", "could not copy picked file to " + destFile);
+ // A truncated pick would only fail verification later, so drop it
+ // and report instead.
+ destFile.delete();
+ writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
+ }
+ }
+
+ /**
+ * Copies a given file from the assets folder to the destination.
+ *
+ * @return true if successful
+ */
+ boolean copyAssetFile(InputStream source, String destinationFileName) {
+ boolean success = false;
+
+ BufferedOutputStream destination = null;
+ try {
+ destination = new BufferedOutputStream(new FileOutputStream(destinationFileName, false));
+ } catch (IOException e) {
+ Log.d("GameActivity", "Could not open destination file: " + e.getMessage());
+ }
+
+ // perform the copying
+ int chunk_read;
+ int bytes_written = 0;
+
+ assert (source != null && destination != null);
+
+ try {
+ byte[] buf = new byte[1024];
+ chunk_read = source.read(buf);
+ do {
+ destination.write(buf, 0, chunk_read);
+ bytes_written += chunk_read;
+ chunk_read = source.read(buf);
+ } while (chunk_read != -1);
+ } catch (IOException e) {
+ Log.d("GameActivity", "Copying failed:" + e.getMessage());
+ }
+
+ // close streams
+ try {
+ source.close();
+ destination.close();
+ success = true;
+ } catch (IOException e) {
+ Log.d("GameActivity", "Copying failed: " + e.getMessage());
+ }
+
+ Log.d("GameActivity", "Successfully copied stream to " + destinationFileName + " (" + bytes_written + " bytes written).");
+ return success;
+ }
+
+ @Keep
+ public boolean hasBackgroundMusic() {
+ AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
+ return audioManager.isMusicActive();
+ }
+
+ @Keep
+ public void showRecordingAudioPermissionMissingDialog() {
+ Log.d("GameActivity", "showRecordingAudioPermissionMissingDialog()");
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ AlertDialog dialog = new AlertDialog.Builder(mSingleton)
+ .setTitle("Audio Recording Permission Missing")
+ .setMessage("It appears that this game uses mic capabilities. The game may not work correctly without mic permission!")
+ .setNeutralButton("Continue", new DialogInterface.OnClickListener() {
+ public void onClick(DialogInterface di, int id) {
+ synchronized (recordAudioRequestDummy) {
+ recordAudioRequestDummy.notify();
+ }
+ }
+ })
+ .create();
+ dialog.show();
+ }
+ });
+
+ synchronized (recordAudioRequestDummy) {
+ try {
+ recordAudioRequestDummy.wait();
+ } catch (InterruptedException e) {
+ Log.d("GameActivity", "mic permission dialog", e);
+ }
+ }
+ }
+
+ public void showExternalStoragePermissionMissingDialog() {
+ AlertDialog dialog = new AlertDialog.Builder(mSingleton)
+ .setTitle("Storage Permission Missing")
+ .setMessage("LÖVE for Android will not be able to run non-packaged games without storage permission.")
+ .setNeutralButton("Continue", null)
+ .create();
+ dialog.show();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ if (grantResults.length > 0) {
+ Log.d("GameActivity", "Received a request permission result");
+
+ switch (requestCode) {
+ case EXTERNAL_STORAGE_REQUEST_CODE: {
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ Log.d("GameActivity", "Permission granted");
+ } else {
+ Log.d("GameActivity", "Did not get permission.");
+ if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_EXTERNAL_STORAGE)) {
+ showExternalStoragePermissionMissingDialog();
+ }
+ }
+
+ Log.d("GameActivity", "Unlocking LÖVE thread");
+ synchronized (externalStorageRequestDummy) {
+ externalStorageRequestDummy[0] = grantResults[0];
+ externalStorageRequestDummy.notify();
+ }
+ break;
+ }
+ case RECORD_AUDIO_REQUEST_CODE: {
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ Log.d("GameActivity", "Mic permission granted");
+ } else {
+ Log.d("GameActivity", "Did not get mic permission.");
+ }
+
+ Log.d("GameActivity", "Unlocking LÖVE thread");
+ synchronized (recordAudioRequestDummy) {
+ recordAudioRequestDummy[0] = grantResults[0];
+ recordAudioRequestDummy.notify();
+ }
+ break;
+ }
+ default:
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ }
+ }
+ }
+
+ @Keep
+ public boolean hasExternalStoragePermission() {
+ if (ActivityCompat.checkSelfPermission(this,
+ Manifest.permission.READ_EXTERNAL_STORAGE)
+ == PackageManager.PERMISSION_GRANTED) {
+ return true;
+ }
+
+ Log.d("GameActivity", "Requesting permission and locking LÖVE thread until we have an answer.");
+ ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, EXTERNAL_STORAGE_REQUEST_CODE);
+
+ synchronized (externalStorageRequestDummy) {
+ try {
+ externalStorageRequestDummy.wait();
+ } catch (InterruptedException e) {
+ Log.d("GameActivity", "requesting external storage permission", e);
+ return false;
+ }
+ }
+
+ return ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED;
+ }
+
+ @Keep
+ public boolean hasRecordAudioPermission() {
+ return ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED;
+ }
+
+ @Keep
+ public void requestRecordAudioPermission() {
+ if (ActivityCompat.checkSelfPermission(this,
+ Manifest.permission.RECORD_AUDIO)
+ == PackageManager.PERMISSION_GRANTED) {
+ return;
+ }
+
+ Log.d("GameActivity", "Requesting mic permission and locking LÖVE thread until we have an answer.");
+ ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.RECORD_AUDIO}, RECORD_AUDIO_REQUEST_CODE);
+
+ synchronized (recordAudioRequestDummy) {
+ try {
+ recordAudioRequestDummy.wait();
+ } catch (InterruptedException e) {
+ Log.d("GameActivity", "requesting mic permission", e);
+ }
+ }
+ }
+
+ @Keep
+ public boolean initializeSafeArea() {
+ if (android.os.Build.VERSION.SDK_INT >= 28 && shortEdgesMode) {
+ DisplayCutout cutout = getWindow().getDecorView().getRootWindowInsets().getDisplayCutout();
+
+ if (cutout != null) {
+ safeAreaTop = cutout.getSafeInsetTop();
+ safeAreaLeft = cutout.getSafeInsetLeft();
+ safeAreaBottom = cutout.getSafeInsetBottom();
+ safeAreaRight = cutout.getSafeInsetRight();
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ @Keep
+ public String[] buildFileTree() {
+ // Map key is path, value is directory flag
+ HashMap map = buildFileTree(getAssets(), "", new HashMap());
+ ArrayList result = new ArrayList();
+
+ for (Map.Entry data: map.entrySet()) {
+ result.add((data.getValue() ? "d" : "f") + data.getKey());
+ }
+
+ String[] r = new String[result.size()];
+ result.toArray(r);
+ return r;
+ }
+
+ private HashMap buildFileTree(AssetManager assetManager, String dir, HashMap map) {
+ String strippedDir = dir.endsWith("/") ? dir.substring(0, dir.length() - 1) : dir;
+
+ // Try open dir
+ try {
+ InputStream test = assetManager.open(strippedDir);
+ // It's a file
+ test.close();
+ map.put(strippedDir, false);
+ } catch (FileNotFoundException e) {
+ // It's a directory
+ String[] list = null;
+
+ // List files
+ try {
+ list = assetManager.list(strippedDir);
+ } catch (IOException e2) {
+ Log.e("GameActivity", strippedDir, e2);
+ }
+
+ // Mark as file
+ map.put(dir, true);
+
+ // This Object comparison is intentional.
+ if (strippedDir != dir) {
+ map.put(strippedDir, true);
+ }
+
+ if (list != null) {
+ for (String path: list) {
+ buildFileTree(assetManager, dir + path + "/", map);
+ }
+ }
+ } catch (IOException e) {
+ Log.e("GameActivity", dir, e);
+ }
+
+ return map;
+ }
+
+ public int getAudioSMP() {
+ int smp = 256;
+
+ if (android.os.Build.VERSION.SDK_INT >= 17) {
+ AudioManager a = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
+ int b = Integer.parseInt(a.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER));
+ return b > 0 ? b : smp;
+ }
+
+ return smp;
+ }
+
+ public int getAudioFreq() {
+ int freq = 44100;
+
+ if (android.os.Build.VERSION.SDK_INT >= 17) {
+ AudioManager a = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
+ int b = Integer.parseInt(a.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE));
+ return b > 0 ? b : freq;
+ }
+
+ return freq;
+ }
+
+ public boolean isNativeLibsExtracted() {
+ ApplicationInfo appInfo = getApplicationInfo();
+
+ if (android.os.Build.VERSION.SDK_INT >= 23) {
+ return (appInfo.flags & ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS) != 0;
+ }
+
+ return true;
+ }
+
+ @Keep
+ public String getCRequirePath() {
+ ApplicationInfo applicationInfo = getApplicationInfo();
+
+ if (isNativeLibsExtracted()) {
+ return applicationInfo.nativeLibraryDir + "/?.so";
+ } else {
+ // The native libs are inside the APK and can be loaded directly.
+ // FIXME: What about split APKs?
+ String abi;
+
+ if (android.os.Build.VERSION.SDK_INT >= 21) {
+ abi = android.os.Build.SUPPORTED_ABIS[0];
+ } else {
+ // This codepath should NEVER be taken as if isNativeLibsExtracted()
+ // returns false, it's 100% safe to assume we're on API level 23 or later.
+ abi = android.os.Build.CPU_ABI;
+ }
+
+ return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
+ }
+ }
+}
diff --git a/app/src/main/engine/org/love2d/sdl/HIDDevice.java b/app/src/main/engine/org/love2d/sdl/HIDDevice.java
new file mode 100644
index 000000000..6058f7d17
--- /dev/null
+++ b/app/src/main/engine/org/love2d/sdl/HIDDevice.java
@@ -0,0 +1,22 @@
+package org.love2d.sdl;
+
+import android.hardware.usb.UsbDevice;
+
+interface HIDDevice
+{
+ public int getId();
+ public int getVendorId();
+ public int getProductId();
+ public String getSerialNumber();
+ public int getVersion();
+ public String getManufacturerName();
+ public String getProductName();
+ public UsbDevice getDevice();
+ public boolean open();
+ public int sendFeatureReport(byte[] report);
+ public int sendOutputReport(byte[] report);
+ public boolean getFeatureReport(byte[] report);
+ public void setFrozen(boolean frozen);
+ public void close();
+ public void shutdown();
+}
diff --git a/app/src/main/engine/org/love2d/sdl/HIDDeviceBLESteamController.java b/app/src/main/engine/org/love2d/sdl/HIDDeviceBLESteamController.java
new file mode 100644
index 000000000..b9a58808f
--- /dev/null
+++ b/app/src/main/engine/org/love2d/sdl/HIDDeviceBLESteamController.java
@@ -0,0 +1,650 @@
+package org.love2d.sdl;
+
+import android.content.Context;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothGattService;
+import android.hardware.usb.UsbDevice;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.os.*;
+
+//import com.android.internal.util.HexDump;
+
+import java.lang.Runnable;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.UUID;
+
+class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice {
+
+ private static final String TAG = "hidapi";
+ private HIDDeviceManager mManager;
+ private BluetoothDevice mDevice;
+ private int mDeviceId;
+ private BluetoothGatt mGatt;
+ private boolean mIsRegistered = false;
+ private boolean mIsConnected = false;
+ private boolean mIsChromebook = false;
+ private boolean mIsReconnecting = false;
+ private boolean mFrozen = false;
+ private LinkedList mOperations;
+ GattOperation mCurrentOperation = null;
+ private Handler mHandler;
+
+ private static final int TRANSPORT_AUTO = 0;
+ private static final int TRANSPORT_BREDR = 1;
+ private static final int TRANSPORT_LE = 2;
+
+ private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000;
+
+ static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3");
+ static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
+ static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3");
+ static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 };
+
+ static class GattOperation {
+ private enum Operation {
+ CHR_READ,
+ CHR_WRITE,
+ ENABLE_NOTIFICATION
+ }
+
+ Operation mOp;
+ UUID mUuid;
+ byte[] mValue;
+ BluetoothGatt mGatt;
+ boolean mResult = true;
+
+ private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) {
+ mGatt = gatt;
+ mOp = operation;
+ mUuid = uuid;
+ }
+
+ private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) {
+ mGatt = gatt;
+ mOp = operation;
+ mUuid = uuid;
+ mValue = value;
+ }
+
+ public void run() {
+ // This is executed in main thread
+ BluetoothGattCharacteristic chr;
+
+ switch (mOp) {
+ case CHR_READ:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Reading characteristic " + chr.getUuid());
+ if (!mGatt.readCharacteristic(chr)) {
+ Log.e(TAG, "Unable to read characteristic " + mUuid.toString());
+ mResult = false;
+ break;
+ }
+ mResult = true;
+ break;
+ case CHR_WRITE:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value));
+ chr.setValue(mValue);
+ if (!mGatt.writeCharacteristic(chr)) {
+ Log.e(TAG, "Unable to write characteristic " + mUuid.toString());
+ mResult = false;
+ break;
+ }
+ mResult = true;
+ break;
+ case ENABLE_NOTIFICATION:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Writing descriptor of " + chr.getUuid());
+ if (chr != null) {
+ BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
+ if (cccd != null) {
+ int properties = chr.getProperties();
+ byte[] value;
+ if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) {
+ value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
+ } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) {
+ value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;
+ } else {
+ Log.e(TAG, "Unable to start notifications on input characteristic");
+ mResult = false;
+ return;
+ }
+
+ mGatt.setCharacteristicNotification(chr, true);
+ cccd.setValue(value);
+ if (!mGatt.writeDescriptor(cccd)) {
+ Log.e(TAG, "Unable to write descriptor " + mUuid.toString());
+ mResult = false;
+ return;
+ }
+ mResult = true;
+ }
+ }
+ }
+ }
+
+ public boolean finish() {
+ return mResult;
+ }
+
+ private BluetoothGattCharacteristic getCharacteristic(UUID uuid) {
+ BluetoothGattService valveService = mGatt.getService(steamControllerService);
+ if (valveService == null)
+ return null;
+ return valveService.getCharacteristic(uuid);
+ }
+
+ static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) {
+ return new GattOperation(gatt, Operation.CHR_READ, uuid);
+ }
+
+ static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) {
+ return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value);
+ }
+
+ static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) {
+ return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid);
+ }
+ }
+
+ public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) {
+ mManager = manager;
+ mDevice = device;
+ mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier());
+ mIsRegistered = false;
+ mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
+ mOperations = new LinkedList();
+ mHandler = new Handler(Looper.getMainLooper());
+
+ mGatt = connectGatt();
+ // final HIDDeviceBLESteamController finalThis = this;
+ // mHandler.postDelayed(new Runnable() {
+ // @Override
+ // public void run() {
+ // finalThis.checkConnectionForChromebookIssue();
+ // }
+ // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
+ }
+
+ public String getIdentifier() {
+ return String.format("SteamController.%s", mDevice.getAddress());
+ }
+
+ public BluetoothGatt getGatt() {
+ return mGatt;
+ }
+
+ // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead
+ // of TRANSPORT_LE. Let's force ourselves to connect low energy.
+ private BluetoothGatt connectGatt(boolean managed) {
+ if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) {
+ try {
+ return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE);
+ } catch (Exception e) {
+ return mDevice.connectGatt(mManager.getContext(), managed, this);
+ }
+ } else {
+ return mDevice.connectGatt(mManager.getContext(), managed, this);
+ }
+ }
+
+ private BluetoothGatt connectGatt() {
+ return connectGatt(false);
+ }
+
+ protected int getConnectionState() {
+
+ Context context = mManager.getContext();
+ if (context == null) {
+ // We are lacking any context to get our Bluetooth information. We'll just assume disconnected.
+ return BluetoothProfile.STATE_DISCONNECTED;
+ }
+
+ BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE);
+ if (btManager == null) {
+ // This device doesn't support Bluetooth. We should never be here, because how did
+ // we instantiate a device to start with?
+ return BluetoothProfile.STATE_DISCONNECTED;
+ }
+
+ return btManager.getConnectionState(mDevice, BluetoothProfile.GATT);
+ }
+
+ public void reconnect() {
+
+ if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) {
+ mGatt.disconnect();
+ mGatt = connectGatt();
+ }
+
+ }
+
+ protected void checkConnectionForChromebookIssue() {
+ if (!mIsChromebook) {
+ // We only do this on Chromebooks, because otherwise it's really annoying to just attempt
+ // over and over.
+ return;
+ }
+
+ int connectionState = getConnectionState();
+
+ switch (connectionState) {
+ case BluetoothProfile.STATE_CONNECTED:
+ if (!mIsConnected) {
+ // We are in the Bad Chromebook Place. We can force a disconnect
+ // to try to recover.
+ Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect.");
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+ }
+ else if (!isRegistered()) {
+ if (mGatt.getServices().size() > 0) {
+ Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover.");
+ probeService(this);
+ }
+ else {
+ Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover.");
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+ }
+ }
+ else {
+ Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!");
+ return;
+ }
+ break;
+
+ case BluetoothProfile.STATE_DISCONNECTED:
+ Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover.");
+
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+
+ case BluetoothProfile.STATE_CONNECTING:
+ Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer.");
+ break;
+ }
+
+ final HIDDeviceBLESteamController finalThis = this;
+ mHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ finalThis.checkConnectionForChromebookIssue();
+ }
+ }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
+ }
+
+ private boolean isRegistered() {
+ return mIsRegistered;
+ }
+
+ private void setRegistered() {
+ mIsRegistered = true;
+ }
+
+ private boolean probeService(HIDDeviceBLESteamController controller) {
+
+ if (isRegistered()) {
+ return true;
+ }
+
+ if (!mIsConnected) {
+ return false;
+ }
+
+ Log.v(TAG, "probeService controller=" + controller);
+
+ for (BluetoothGattService service : mGatt.getServices()) {
+ if (service.getUuid().equals(steamControllerService)) {
+ Log.v(TAG, "Found Valve steam controller service " + service.getUuid());
+
+ for (BluetoothGattCharacteristic chr : service.getCharacteristics()) {
+ if (chr.getUuid().equals(inputCharacteristic)) {
+ Log.v(TAG, "Found input characteristic");
+ // Start notifications
+ BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
+ if (cccd != null) {
+ enableNotification(chr.getUuid());
+ }
+ }
+ }
+ return true;
+ }
+ }
+
+ if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) {
+ Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us.");
+ mIsConnected = false;
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ }
+
+ return false;
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ private void finishCurrentGattOperation() {
+ GattOperation op = null;
+ synchronized (mOperations) {
+ if (mCurrentOperation != null) {
+ op = mCurrentOperation;
+ mCurrentOperation = null;
+ }
+ }
+ if (op != null) {
+ boolean result = op.finish(); // TODO: Maybe in main thread as well?
+
+ // Our operation failed, let's add it back to the beginning of our queue.
+ if (!result) {
+ mOperations.addFirst(op);
+ }
+ }
+ executeNextGattOperation();
+ }
+
+ private void executeNextGattOperation() {
+ synchronized (mOperations) {
+ if (mCurrentOperation != null)
+ return;
+
+ if (mOperations.isEmpty())
+ return;
+
+ mCurrentOperation = mOperations.removeFirst();
+ }
+
+ // Run in main thread
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ synchronized (mOperations) {
+ if (mCurrentOperation == null) {
+ Log.e(TAG, "Current operation null in executor?");
+ return;
+ }
+
+ mCurrentOperation.run();
+ // now wait for the GATT callback and when it comes, finish this operation
+ }
+ }
+ });
+ }
+
+ private void queueGattOperation(GattOperation op) {
+ synchronized (mOperations) {
+ mOperations.add(op);
+ }
+ executeNextGattOperation();
+ }
+
+ private void enableNotification(UUID chrUuid) {
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid);
+ queueGattOperation(op);
+ }
+
+ public void writeCharacteristic(UUID uuid, byte[] value) {
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value);
+ queueGattOperation(op);
+ }
+
+ public void readCharacteristic(UUID uuid) {
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid);
+ queueGattOperation(op);
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ ////////////// BluetoothGattCallback overridden methods
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
+ //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState);
+ mIsReconnecting = false;
+ if (newState == 2) {
+ mIsConnected = true;
+ // Run directly, without GattOperation
+ if (!isRegistered()) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mGatt.discoverServices();
+ }
+ });
+ }
+ }
+ else if (newState == 0) {
+ mIsConnected = false;
+ }
+
+ // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent.
+ }
+
+ public void onServicesDiscovered(BluetoothGatt gatt, int status) {
+ //Log.v(TAG, "onServicesDiscovered status=" + status);
+ if (status == 0) {
+ if (gatt.getServices().size() == 0) {
+ Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack.");
+ mIsReconnecting = true;
+ mIsConnected = false;
+ gatt.disconnect();
+ mGatt = connectGatt(false);
+ }
+ else {
+ probeService(this);
+ }
+ }
+ }
+
+ public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid());
+
+ if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) {
+ mManager.HIDDeviceFeatureReport(getId(), characteristic.getValue());
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid());
+
+ if (characteristic.getUuid().equals(reportCharacteristic)) {
+ // Only register controller with the native side once it has been fully configured
+ if (!isRegistered()) {
+ Log.v(TAG, "Registering Steam Controller with ID: " + getId());
+ mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0);
+ setRegistered();
+ }
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+ // Enable this for verbose logging of controller input reports
+ //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue()));
+
+ if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) {
+ mManager.HIDDeviceInputReport(getId(), characteristic.getValue());
+ }
+ }
+
+ public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ //Log.v(TAG, "onDescriptorRead status=" + status);
+ }
+
+ public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ BluetoothGattCharacteristic chr = descriptor.getCharacteristic();
+ //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid());
+
+ if (chr.getUuid().equals(inputCharacteristic)) {
+ boolean hasWrittenInputDescriptor = true;
+ BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic);
+ if (reportChr != null) {
+ Log.v(TAG, "Writing report characteristic to enter valve mode");
+ reportChr.setValue(enterValveMode);
+ gatt.writeCharacteristic(reportChr);
+ }
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ public void onReliableWriteCompleted(BluetoothGatt gatt, int status) {
+ //Log.v(TAG, "onReliableWriteCompleted status=" + status);
+ }
+
+ public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
+ //Log.v(TAG, "onReadRemoteRssi status=" + status);
+ }
+
+ public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+ //Log.v(TAG, "onMtuChanged status=" + status);
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////// Public API
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ @Override
+ public int getId() {
+ return mDeviceId;
+ }
+
+ @Override
+ public int getVendorId() {
+ // Valve Corporation
+ final int VALVE_USB_VID = 0x28DE;
+ return VALVE_USB_VID;
+ }
+
+ @Override
+ public int getProductId() {
+ // We don't have an easy way to query from the Bluetooth device, but we know what it is
+ final int D0G_BLE2_PID = 0x1106;
+ return D0G_BLE2_PID;
+ }
+
+ @Override
+ public String getSerialNumber() {
+ // This will be read later via feature report by Steam
+ return "12345";
+ }
+
+ @Override
+ public int getVersion() {
+ return 0;
+ }
+
+ @Override
+ public String getManufacturerName() {
+ return "Valve Corporation";
+ }
+
+ @Override
+ public String getProductName() {
+ return "Steam Controller";
+ }
+
+ @Override
+ public UsbDevice getDevice() {
+ return null;
+ }
+
+ @Override
+ public boolean open() {
+ return true;
+ }
+
+ @Override
+ public int sendFeatureReport(byte[] report) {
+ if (!isRegistered()) {
+ Log.e(TAG, "Attempted sendFeatureReport before Steam Controller is registered!");
+ if (mIsConnected) {
+ probeService(this);
+ }
+ return -1;
+ }
+
+ // We need to skip the first byte, as that doesn't go over the air
+ byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
+ //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(actual_report));
+ writeCharacteristic(reportCharacteristic, actual_report);
+ return report.length;
+ }
+
+ @Override
+ public int sendOutputReport(byte[] report) {
+ if (!isRegistered()) {
+ Log.e(TAG, "Attempted sendOutputReport before Steam Controller is registered!");
+ if (mIsConnected) {
+ probeService(this);
+ }
+ return -1;
+ }
+
+ //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(report));
+ writeCharacteristic(reportCharacteristic, report);
+ return report.length;
+ }
+
+ @Override
+ public boolean getFeatureReport(byte[] report) {
+ if (!isRegistered()) {
+ Log.e(TAG, "Attempted getFeatureReport before Steam Controller is registered!");
+ if (mIsConnected) {
+ probeService(this);
+ }
+ return false;
+ }
+
+ //Log.v(TAG, "getFeatureReport");
+ readCharacteristic(reportCharacteristic);
+ return true;
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public void setFrozen(boolean frozen) {
+ mFrozen = frozen;
+ }
+
+ @Override
+ public void shutdown() {
+ close();
+
+ BluetoothGatt g = mGatt;
+ if (g != null) {
+ g.disconnect();
+ g.close();
+ mGatt = null;
+ }
+ mManager = null;
+ mIsRegistered = false;
+ mIsConnected = false;
+ mOperations.clear();
+ }
+
+}
+
diff --git a/app/src/main/engine/org/love2d/sdl/HIDDeviceManager.java b/app/src/main/engine/org/love2d/sdl/HIDDeviceManager.java
new file mode 100644
index 000000000..f37fe6e72
--- /dev/null
+++ b/app/src/main/engine/org/love2d/sdl/HIDDeviceManager.java
@@ -0,0 +1,684 @@
+package org.love2d.sdl;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.os.Build;
+import android.util.Log;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.hardware.usb.*;
+import android.os.Handler;
+import android.os.Looper;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+
+public class HIDDeviceManager {
+ private static final String TAG = "hidapi";
+ private static final String ACTION_USB_PERMISSION = "org.love2d.sdl.USB_PERMISSION";
+
+ private static HIDDeviceManager sManager;
+ private static int sManagerRefCount = 0;
+
+ public static HIDDeviceManager acquire(Context context) {
+ if (sManagerRefCount == 0) {
+ sManager = new HIDDeviceManager(context);
+ }
+ ++sManagerRefCount;
+ return sManager;
+ }
+
+ public static void release(HIDDeviceManager manager) {
+ if (manager == sManager) {
+ --sManagerRefCount;
+ if (sManagerRefCount == 0) {
+ sManager.close();
+ sManager = null;
+ }
+ }
+ }
+
+ private Context mContext;
+ private HashMap mDevicesById = new HashMap();
+ private HashMap mBluetoothDevices = new HashMap();
+ private int mNextDeviceId = 0;
+ private SharedPreferences mSharedPreferences = null;
+ private boolean mIsChromebook = false;
+ private UsbManager mUsbManager;
+ private Handler mHandler;
+ private BluetoothManager mBluetoothManager;
+ private List mLastBluetoothDevices;
+
+ private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) {
+ UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ handleUsbDeviceAttached(usbDevice);
+ } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) {
+ UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ handleUsbDeviceDetached(usbDevice);
+ } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) {
+ UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false));
+ }
+ }
+ };
+
+ private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ // Bluetooth device was connected. If it was a Steam Controller, handle it
+ if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) {
+ BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ Log.d(TAG, "Bluetooth device connected: " + device);
+
+ if (isSteamController(device)) {
+ connectBluetoothDevice(device);
+ }
+ }
+
+ // Bluetooth device was disconnected, remove from controller manager (if any)
+ if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) {
+ BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ Log.d(TAG, "Bluetooth device disconnected: " + device);
+
+ disconnectBluetoothDevice(device);
+ }
+ }
+ };
+
+ private HIDDeviceManager(final Context context) {
+ mContext = context;
+
+ HIDDeviceRegisterCallback();
+
+ mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE);
+ mIsChromebook = mContext.getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
+
+// if (shouldClear) {
+// SharedPreferences.Editor spedit = mSharedPreferences.edit();
+// spedit.clear();
+// spedit.commit();
+// }
+// else
+ {
+ mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0);
+ }
+ }
+
+ public Context getContext() {
+ return mContext;
+ }
+
+ public int getDeviceIDForIdentifier(String identifier) {
+ SharedPreferences.Editor spedit = mSharedPreferences.edit();
+
+ int result = mSharedPreferences.getInt(identifier, 0);
+ if (result == 0) {
+ result = mNextDeviceId++;
+ spedit.putInt("next_device_id", mNextDeviceId);
+ }
+
+ spedit.putInt(identifier, result);
+ spedit.commit();
+ return result;
+ }
+
+ private void initializeUSB() {
+ mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE);
+ if (mUsbManager == null) {
+ return;
+ }
+
+ /*
+ // Logging
+ for (UsbDevice device : mUsbManager.getDeviceList().values()) {
+ Log.i(TAG,"Path: " + device.getDeviceName());
+ Log.i(TAG,"Manufacturer: " + device.getManufacturerName());
+ Log.i(TAG,"Product: " + device.getProductName());
+ Log.i(TAG,"ID: " + device.getDeviceId());
+ Log.i(TAG,"Class: " + device.getDeviceClass());
+ Log.i(TAG,"Protocol: " + device.getDeviceProtocol());
+ Log.i(TAG,"Vendor ID " + device.getVendorId());
+ Log.i(TAG,"Product ID: " + device.getProductId());
+ Log.i(TAG,"Interface count: " + device.getInterfaceCount());
+ Log.i(TAG,"---------------------------------------");
+
+ // Get interface details
+ for (int index = 0; index < device.getInterfaceCount(); index++) {
+ UsbInterface mUsbInterface = device.getInterface(index);
+ Log.i(TAG," ***** *****");
+ Log.i(TAG," Interface index: " + index);
+ Log.i(TAG," Interface ID: " + mUsbInterface.getId());
+ Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass());
+ Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass());
+ Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol());
+ Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount());
+
+ // Get endpoint details
+ for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++)
+ {
+ UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi);
+ Log.i(TAG," ++++ ++++ ++++");
+ Log.i(TAG," Endpoint index: " + epi);
+ Log.i(TAG," Attributes: " + mEndpoint.getAttributes());
+ Log.i(TAG," Direction: " + mEndpoint.getDirection());
+ Log.i(TAG," Number: " + mEndpoint.getEndpointNumber());
+ Log.i(TAG," Interval: " + mEndpoint.getInterval());
+ Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize());
+ Log.i(TAG," Type: " + mEndpoint.getType());
+ }
+ }
+ }
+ Log.i(TAG," No more devices connected.");
+ */
+
+ // Register for USB broadcasts and permission completions
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
+ filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
+ filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION);
+ mContext.registerReceiver(mUsbBroadcast, filter);
+
+ for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) {
+ handleUsbDeviceAttached(usbDevice);
+ }
+ }
+
+ UsbManager getUSBManager() {
+ return mUsbManager;
+ }
+
+ private void shutdownUSB() {
+ try {
+ mContext.unregisterReceiver(mUsbBroadcast);
+ } catch (Exception e) {
+ // We may not have registered, that's okay
+ }
+ }
+
+ private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) {
+ if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) {
+ return true;
+ }
+ if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) {
+ return true;
+ }
+ return false;
+ }
+
+ private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) {
+ final int XB360_IFACE_SUBCLASS = 93;
+ final int XB360_IFACE_PROTOCOL = 1; // Wired
+ final int XB360W_IFACE_PROTOCOL = 129; // Wireless
+ final int[] SUPPORTED_VENDORS = {
+ 0x0079, // GPD Win 2
+ 0x044f, // Thrustmaster
+ 0x045e, // Microsoft
+ 0x046d, // Logitech
+ 0x056e, // Elecom
+ 0x06a3, // Saitek
+ 0x0738, // Mad Catz
+ 0x07ff, // Mad Catz
+ 0x0e6f, // PDP
+ 0x0f0d, // Hori
+ 0x1038, // SteelSeries
+ 0x11c9, // Nacon
+ 0x12ab, // Unknown
+ 0x1430, // RedOctane
+ 0x146b, // BigBen
+ 0x1532, // Razer Sabertooth
+ 0x15e4, // Numark
+ 0x162e, // Joytech
+ 0x1689, // Razer Onza
+ 0x1949, // Lab126, Inc.
+ 0x1bad, // Harmonix
+ 0x20d6, // PowerA
+ 0x24c6, // PowerA
+ 0x2c22, // Qanba
+ 0x2dc8, // 8BitDo
+ 0x9886, // ASTRO Gaming
+ };
+
+ if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
+ usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS &&
+ (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL ||
+ usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) {
+ int vendor_id = usbDevice.getVendorId();
+ for (int supportedVid : SUPPORTED_VENDORS) {
+ if (vendor_id == supportedVid) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) {
+ final int XB1_IFACE_SUBCLASS = 71;
+ final int XB1_IFACE_PROTOCOL = 208;
+ final int[] SUPPORTED_VENDORS = {
+ 0x03f0, // HP
+ 0x044f, // Thrustmaster
+ 0x045e, // Microsoft
+ 0x0738, // Mad Catz
+ 0x0e6f, // PDP
+ 0x0f0d, // Hori
+ 0x10f5, // Turtle Beach
+ 0x1532, // Razer Wildcat
+ 0x20d6, // PowerA
+ 0x24c6, // PowerA
+ 0x2dc8, // 8BitDo
+ 0x2e24, // Hyperkin
+ };
+
+ if (usbInterface.getId() == 0 &&
+ usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
+ usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS &&
+ usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) {
+ int vendor_id = usbDevice.getVendorId();
+ for (int supportedVid : SUPPORTED_VENDORS) {
+ if (vendor_id == supportedVid) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private void handleUsbDeviceAttached(UsbDevice usbDevice) {
+ connectHIDDeviceUSB(usbDevice);
+ }
+
+ private void handleUsbDeviceDetached(UsbDevice usbDevice) {
+ List devices = new ArrayList();
+ for (HIDDevice device : mDevicesById.values()) {
+ if (usbDevice.equals(device.getDevice())) {
+ devices.add(device.getId());
+ }
+ }
+ for (int id : devices) {
+ HIDDevice device = mDevicesById.get(id);
+ mDevicesById.remove(id);
+ device.shutdown();
+ HIDDeviceDisconnected(id);
+ }
+ }
+
+ private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) {
+ for (HIDDevice device : mDevicesById.values()) {
+ if (usbDevice.equals(device.getDevice())) {
+ boolean opened = false;
+ if (permission_granted) {
+ opened = device.open();
+ }
+ HIDDeviceOpenResult(device.getId(), opened);
+ }
+ }
+ }
+
+ private void connectHIDDeviceUSB(UsbDevice usbDevice) {
+ synchronized (this) {
+ int interface_mask = 0;
+ for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) {
+ UsbInterface usbInterface = usbDevice.getInterface(interface_index);
+ if (isHIDDeviceInterface(usbDevice, usbInterface)) {
+ // Check to see if we've already added this interface
+ // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive
+ int interface_id = usbInterface.getId();
+ if ((interface_mask & (1 << interface_id)) != 0) {
+ continue;
+ }
+ interface_mask |= (1 << interface_id);
+
+ HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index);
+ int id = device.getId();
+ mDevicesById.put(id, device);
+ HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol());
+ }
+ }
+ }
+ }
+
+ private void initializeBluetooth() {
+ Log.d(TAG, "Initializing Bluetooth");
+
+ if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ &&
+ mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) {
+ Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH");
+ return;
+ }
+
+ if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) || (Build.VERSION.SDK_INT < 18 /* Android 4.3 (JELLY_BEAN_MR2) */)) {
+ Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE");
+ return;
+ }
+
+ // Find bonded bluetooth controllers and create SteamControllers for them
+ mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE);
+ if (mBluetoothManager == null) {
+ // This device doesn't support Bluetooth.
+ return;
+ }
+
+ BluetoothAdapter btAdapter = mBluetoothManager.getAdapter();
+ if (btAdapter == null) {
+ // This device has Bluetooth support in the codebase, but has no available adapters.
+ return;
+ }
+
+ // Get our bonded devices.
+ for (BluetoothDevice device : btAdapter.getBondedDevices()) {
+
+ Log.d(TAG, "Bluetooth device available: " + device);
+ if (isSteamController(device)) {
+ connectBluetoothDevice(device);
+ }
+
+ }
+
+ // NOTE: These don't work on Chromebooks, to my undying dismay.
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED);
+ filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED);
+ mContext.registerReceiver(mBluetoothBroadcast, filter);
+
+ if (mIsChromebook) {
+ mHandler = new Handler(Looper.getMainLooper());
+ mLastBluetoothDevices = new ArrayList();
+
+ // final HIDDeviceManager finalThis = this;
+ // mHandler.postDelayed(new Runnable() {
+ // @Override
+ // public void run() {
+ // finalThis.chromebookConnectionHandler();
+ // }
+ // }, 5000);
+ }
+ }
+
+ private void shutdownBluetooth() {
+ try {
+ mContext.unregisterReceiver(mBluetoothBroadcast);
+ } catch (Exception e) {
+ // We may not have registered, that's okay
+ }
+ }
+
+ // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly.
+ // This function provides a sort of dummy version of that, watching for changes in the
+ // connected devices and attempting to add controllers as things change.
+ public void chromebookConnectionHandler() {
+ if (!mIsChromebook) {
+ return;
+ }
+
+ ArrayList disconnected = new ArrayList();
+ ArrayList connected = new ArrayList();
+
+ List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT);
+
+ for (BluetoothDevice bluetoothDevice : currentConnected) {
+ if (!mLastBluetoothDevices.contains(bluetoothDevice)) {
+ connected.add(bluetoothDevice);
+ }
+ }
+ for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) {
+ if (!currentConnected.contains(bluetoothDevice)) {
+ disconnected.add(bluetoothDevice);
+ }
+ }
+
+ mLastBluetoothDevices = currentConnected;
+
+ for (BluetoothDevice bluetoothDevice : disconnected) {
+ disconnectBluetoothDevice(bluetoothDevice);
+ }
+ for (BluetoothDevice bluetoothDevice : connected) {
+ connectBluetoothDevice(bluetoothDevice);
+ }
+
+ final HIDDeviceManager finalThis = this;
+ mHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ finalThis.chromebookConnectionHandler();
+ }
+ }, 10000);
+ }
+
+ public boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) {
+ Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice);
+ synchronized (this) {
+ if (mBluetoothDevices.containsKey(bluetoothDevice)) {
+ Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect");
+
+ HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
+ device.reconnect();
+
+ return false;
+ }
+ HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice);
+ int id = device.getId();
+ mBluetoothDevices.put(bluetoothDevice, device);
+ mDevicesById.put(id, device);
+
+ // The Steam Controller will mark itself connected once initialization is complete
+ }
+ return true;
+ }
+
+ public void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) {
+ synchronized (this) {
+ HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
+ if (device == null)
+ return;
+
+ int id = device.getId();
+ mBluetoothDevices.remove(bluetoothDevice);
+ mDevicesById.remove(id);
+ device.shutdown();
+ HIDDeviceDisconnected(id);
+ }
+ }
+
+ public boolean isSteamController(BluetoothDevice bluetoothDevice) {
+ // Sanity check. If you pass in a null device, by definition it is never a Steam Controller.
+ if (bluetoothDevice == null) {
+ return false;
+ }
+
+ // If the device has no local name, we really don't want to try an equality check against it.
+ if (bluetoothDevice.getName() == null) {
+ return false;
+ }
+
+ return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0);
+ }
+
+ private void close() {
+ shutdownUSB();
+ shutdownBluetooth();
+ synchronized (this) {
+ for (HIDDevice device : mDevicesById.values()) {
+ device.shutdown();
+ }
+ mDevicesById.clear();
+ mBluetoothDevices.clear();
+ HIDDeviceReleaseCallback();
+ }
+ }
+
+ public void setFrozen(boolean frozen) {
+ synchronized (this) {
+ for (HIDDevice device : mDevicesById.values()) {
+ device.setFrozen(frozen);
+ }
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ private HIDDevice getDevice(int id) {
+ synchronized (this) {
+ HIDDevice result = mDevicesById.get(id);
+ if (result == null) {
+ Log.v(TAG, "No device for id: " + id);
+ Log.v(TAG, "Available devices: " + mDevicesById.keySet());
+ }
+ return result;
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ ////////// JNI interface functions
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ public boolean initialize(boolean usb, boolean bluetooth) {
+ Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")");
+
+ if (usb) {
+ initializeUSB();
+ }
+ if (bluetooth) {
+ initializeBluetooth();
+ }
+ return true;
+ }
+
+ public boolean openDevice(int deviceID) {
+ Log.v(TAG, "openDevice deviceID=" + deviceID);
+ HIDDevice device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return false;
+ }
+
+ // Look to see if this is a USB device and we have permission to access it
+ UsbDevice usbDevice = device.getDevice();
+ if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) {
+ HIDDeviceOpenPending(deviceID);
+ try {
+ final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31
+ int flags;
+ if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) {
+ flags = FLAG_MUTABLE;
+ } else {
+ flags = 0;
+ }
+ mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, new Intent(HIDDeviceManager.ACTION_USB_PERMISSION), flags));
+ } catch (Exception e) {
+ Log.v(TAG, "Couldn't request permission for USB device " + usbDevice);
+ HIDDeviceOpenResult(deviceID, false);
+ }
+ return false;
+ }
+
+ try {
+ return device.open();
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ return false;
+ }
+
+ public int sendOutputReport(int deviceID, byte[] report) {
+ try {
+ //Log.v(TAG, "sendOutputReport deviceID=" + deviceID + " length=" + report.length);
+ HIDDevice device;
+ device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return -1;
+ }
+
+ return device.sendOutputReport(report);
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ return -1;
+ }
+
+ public int sendFeatureReport(int deviceID, byte[] report) {
+ try {
+ //Log.v(TAG, "sendFeatureReport deviceID=" + deviceID + " length=" + report.length);
+ HIDDevice device;
+ device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return -1;
+ }
+
+ return device.sendFeatureReport(report);
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ return -1;
+ }
+
+ public boolean getFeatureReport(int deviceID, byte[] report) {
+ try {
+ //Log.v(TAG, "getFeatureReport deviceID=" + deviceID);
+ HIDDevice device;
+ device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return false;
+ }
+
+ return device.getFeatureReport(report);
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ return false;
+ }
+
+ public void closeDevice(int deviceID) {
+ try {
+ Log.v(TAG, "closeDevice deviceID=" + deviceID);
+ HIDDevice device;
+ device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return;
+ }
+
+ device.close();
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ }
+
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ /////////////// Native methods
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ private native void HIDDeviceRegisterCallback();
+ private native void HIDDeviceReleaseCallback();
+
+ native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol);
+ native void HIDDeviceOpenPending(int deviceID);
+ native void HIDDeviceOpenResult(int deviceID, boolean opened);
+ native void HIDDeviceDisconnected(int deviceID);
+
+ native void HIDDeviceInputReport(int deviceID, byte[] report);
+ native void HIDDeviceFeatureReport(int deviceID, byte[] report);
+}
diff --git a/app/src/main/engine/org/love2d/sdl/HIDDeviceUSB.java b/app/src/main/engine/org/love2d/sdl/HIDDeviceUSB.java
new file mode 100644
index 000000000..dcd8daa4c
--- /dev/null
+++ b/app/src/main/engine/org/love2d/sdl/HIDDeviceUSB.java
@@ -0,0 +1,309 @@
+package org.love2d.sdl;
+
+import android.hardware.usb.*;
+import android.os.Build;
+import android.util.Log;
+import java.util.Arrays;
+
+class HIDDeviceUSB implements HIDDevice {
+
+ private static final String TAG = "hidapi";
+
+ protected HIDDeviceManager mManager;
+ protected UsbDevice mDevice;
+ protected int mInterfaceIndex;
+ protected int mInterface;
+ protected int mDeviceId;
+ protected UsbDeviceConnection mConnection;
+ protected UsbEndpoint mInputEndpoint;
+ protected UsbEndpoint mOutputEndpoint;
+ protected InputThread mInputThread;
+ protected boolean mRunning;
+ protected boolean mFrozen;
+
+ public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) {
+ mManager = manager;
+ mDevice = usbDevice;
+ mInterfaceIndex = interface_index;
+ mInterface = mDevice.getInterface(mInterfaceIndex).getId();
+ mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier());
+ mRunning = false;
+ }
+
+ public String getIdentifier() {
+ return String.format("%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex);
+ }
+
+ @Override
+ public int getId() {
+ return mDeviceId;
+ }
+
+ @Override
+ public int getVendorId() {
+ return mDevice.getVendorId();
+ }
+
+ @Override
+ public int getProductId() {
+ return mDevice.getProductId();
+ }
+
+ @Override
+ public String getSerialNumber() {
+ String result = null;
+ if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) {
+ try {
+ result = mDevice.getSerialNumber();
+ }
+ catch (SecurityException exception) {
+ //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage());
+ }
+ }
+ if (result == null) {
+ result = "";
+ }
+ return result;
+ }
+
+ @Override
+ public int getVersion() {
+ return 0;
+ }
+
+ @Override
+ public String getManufacturerName() {
+ String result = null;
+ if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) {
+ result = mDevice.getManufacturerName();
+ }
+ if (result == null) {
+ result = String.format("%x", getVendorId());
+ }
+ return result;
+ }
+
+ @Override
+ public String getProductName() {
+ String result = null;
+ if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) {
+ result = mDevice.getProductName();
+ }
+ if (result == null) {
+ result = String.format("%x", getProductId());
+ }
+ return result;
+ }
+
+ @Override
+ public UsbDevice getDevice() {
+ return mDevice;
+ }
+
+ public String getDeviceName() {
+ return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")";
+ }
+
+ @Override
+ public boolean open() {
+ mConnection = mManager.getUSBManager().openDevice(mDevice);
+ if (mConnection == null) {
+ Log.w(TAG, "Unable to open USB device " + getDeviceName());
+ return false;
+ }
+
+ // Force claim our interface
+ UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
+ if (!mConnection.claimInterface(iface, true)) {
+ Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName());
+ close();
+ return false;
+ }
+
+ // Find the endpoints
+ for (int j = 0; j < iface.getEndpointCount(); j++) {
+ UsbEndpoint endpt = iface.getEndpoint(j);
+ switch (endpt.getDirection()) {
+ case UsbConstants.USB_DIR_IN:
+ if (mInputEndpoint == null) {
+ mInputEndpoint = endpt;
+ }
+ break;
+ case UsbConstants.USB_DIR_OUT:
+ if (mOutputEndpoint == null) {
+ mOutputEndpoint = endpt;
+ }
+ break;
+ }
+ }
+
+ // Make sure the required endpoints were present
+ if (mInputEndpoint == null || mOutputEndpoint == null) {
+ Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName());
+ close();
+ return false;
+ }
+
+ // Start listening for input
+ mRunning = true;
+ mInputThread = new InputThread();
+ mInputThread.start();
+
+ return true;
+ }
+
+ @Override
+ public int sendFeatureReport(byte[] report) {
+ int res = -1;
+ int offset = 0;
+ int length = report.length;
+ boolean skipped_report_id = false;
+ byte report_number = report[0];
+
+ if (report_number == 0x0) {
+ ++offset;
+ --length;
+ skipped_report_id = true;
+ }
+
+ res = mConnection.controlTransfer(
+ UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT,
+ 0x09/*HID set_report*/,
+ (3/*HID feature*/ << 8) | report_number,
+ mInterface,
+ report, offset, length,
+ 1000/*timeout millis*/);
+
+ if (res < 0) {
+ Log.w(TAG, "sendFeatureReport() returned " + res + " on device " + getDeviceName());
+ return -1;
+ }
+
+ if (skipped_report_id) {
+ ++length;
+ }
+ return length;
+ }
+
+ @Override
+ public int sendOutputReport(byte[] report) {
+ int r = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000);
+ if (r != report.length) {
+ Log.w(TAG, "sendOutputReport() returned " + r + " on device " + getDeviceName());
+ }
+ return r;
+ }
+
+ @Override
+ public boolean getFeatureReport(byte[] report) {
+ int res = -1;
+ int offset = 0;
+ int length = report.length;
+ boolean skipped_report_id = false;
+ byte report_number = report[0];
+
+ if (report_number == 0x0) {
+ /* Offset the return buffer by 1, so that the report ID
+ will remain in byte 0. */
+ ++offset;
+ --length;
+ skipped_report_id = true;
+ }
+
+ res = mConnection.controlTransfer(
+ UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN,
+ 0x01/*HID get_report*/,
+ (3/*HID feature*/ << 8) | report_number,
+ mInterface,
+ report, offset, length,
+ 1000/*timeout millis*/);
+
+ if (res < 0) {
+ Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName());
+ return false;
+ }
+
+ if (skipped_report_id) {
+ ++res;
+ ++length;
+ }
+
+ byte[] data;
+ if (res == length) {
+ data = report;
+ } else {
+ data = Arrays.copyOfRange(report, 0, res);
+ }
+ mManager.HIDDeviceFeatureReport(mDeviceId, data);
+
+ return true;
+ }
+
+ @Override
+ public void close() {
+ mRunning = false;
+ if (mInputThread != null) {
+ while (mInputThread.isAlive()) {
+ mInputThread.interrupt();
+ try {
+ mInputThread.join();
+ } catch (InterruptedException e) {
+ // Keep trying until we're done
+ }
+ }
+ mInputThread = null;
+ }
+ if (mConnection != null) {
+ UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
+ mConnection.releaseInterface(iface);
+ mConnection.close();
+ mConnection = null;
+ }
+ }
+
+ @Override
+ public void shutdown() {
+ close();
+ mManager = null;
+ }
+
+ @Override
+ public void setFrozen(boolean frozen) {
+ mFrozen = frozen;
+ }
+
+ protected class InputThread extends Thread {
+ @Override
+ public void run() {
+ int packetSize = mInputEndpoint.getMaxPacketSize();
+ byte[] packet = new byte[packetSize];
+ while (mRunning) {
+ int r;
+ try
+ {
+ r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000);
+ }
+ catch (Exception e)
+ {
+ Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e);
+ break;
+ }
+ if (r < 0) {
+ // Could be a timeout or an I/O error
+ }
+ if (r > 0) {
+ byte[] data;
+ if (r == packetSize) {
+ data = packet;
+ } else {
+ data = Arrays.copyOfRange(packet, 0, r);
+ }
+
+ if (!mFrozen) {
+ mManager.HIDDeviceInputReport(mDeviceId, data);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/engine/org/love2d/sdl/SDL.java b/app/src/main/engine/org/love2d/sdl/SDL.java
new file mode 100644
index 000000000..1c79abde4
--- /dev/null
+++ b/app/src/main/engine/org/love2d/sdl/SDL.java
@@ -0,0 +1,86 @@
+package org.love2d.sdl;
+
+import android.content.Context;
+
+import java.lang.Class;
+import java.lang.reflect.Method;
+
+/**
+ SDL library initialization
+*/
+public class SDL {
+
+ // This function should be called first and sets up the native code
+ // so it can call into the Java classes
+ public static void setupJNI() {
+ SDLActivity.nativeSetupJNI();
+ SDLAudioManager.nativeSetupJNI();
+ SDLControllerManager.nativeSetupJNI();
+ }
+
+ // This function should be called each time the activity is started
+ public static void initialize() {
+ setContext(null);
+
+ SDLActivity.initialize();
+ SDLAudioManager.initialize();
+ SDLControllerManager.initialize();
+ }
+
+ // This function stores the current activity (SDL or not)
+ public static void setContext(Context context) {
+ SDLAudioManager.setContext(context);
+ mContext = context;
+ }
+
+ public static Context getContext() {
+ return mContext;
+ }
+
+ public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException {
+
+ if (libraryName == null) {
+ throw new NullPointerException("No library name provided.");
+ }
+
+ try {
+ // Let's see if we have ReLinker available in the project. This is necessary for
+ // some projects that have huge numbers of local libraries bundled, and thus may
+ // trip a bug in Android's native library loader which ReLinker works around. (If
+ // loadLibrary works properly, ReLinker will simply use the normal Android method
+ // internally.)
+ //
+ // To use ReLinker, just add it as a dependency. For more information, see
+ // https://github.com/KeepSafe/ReLinker for ReLinker's repository.
+ //
+ Class> relinkClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker");
+ Class> relinkListenerClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener");
+ Class> contextClass = mContext.getClassLoader().loadClass("android.content.Context");
+ Class> stringClass = mContext.getClassLoader().loadClass("java.lang.String");
+
+ // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if
+ // they've changed during updates.
+ Method forceMethod = relinkClass.getDeclaredMethod("force");
+ Object relinkInstance = forceMethod.invoke(null);
+ Class> relinkInstanceClass = relinkInstance.getClass();
+
+ // Actually load the library!
+ Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass);
+ loadMethod.invoke(relinkInstance, mContext, libraryName, null, null);
+ }
+ catch (final Throwable e) {
+ // Fall back
+ try {
+ System.loadLibrary(libraryName);
+ }
+ catch (final UnsatisfiedLinkError ule) {
+ throw ule;
+ }
+ catch (final SecurityException se) {
+ throw se;
+ }
+ }
+ }
+
+ protected static Context mContext;
+}
diff --git a/app/src/main/engine/org/love2d/sdl/SDLActivity.java b/app/src/main/engine/org/love2d/sdl/SDLActivity.java
new file mode 100644
index 000000000..f703e5ea6
--- /dev/null
+++ b/app/src/main/engine/org/love2d/sdl/SDLActivity.java
@@ -0,0 +1,2148 @@
+package org.love2d.sdl;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.UiModeManager;
+import android.content.ClipboardManager;
+import android.content.ClipData;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.hardware.Sensor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.Selection;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.Display;
+import android.view.Gravity;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.PointerIcon;
+import android.view.Surface;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.util.Hashtable;
+import java.util.Locale;
+
+
+/**
+ SDL Activity
+*/
+public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener {
+ private static final String TAG = "SDL";
+ private static final int SDL_MAJOR_VERSION = 2;
+ private static final int SDL_MINOR_VERSION = 28;
+ private static final int SDL_MICRO_VERSION = 5;
+/*
+ // Display InputType.SOURCE/CLASS of events and devices
+ //
+ // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]");
+ // SDLActivity.debugSource(event.getSource(), "event");
+ public static void debugSource(int sources, String prefix) {
+ int s = sources;
+ int s_copy = sources;
+ String cls = "";
+ String src = "";
+ int tst = 0;
+ int FLAG_TAINTED = 0x80000000;
+
+ if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON";
+ if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK";
+ if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER";
+ if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION";
+ if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL";
+
+
+ int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits
+ s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON
+ | InputDevice.SOURCE_CLASS_JOYSTICK
+ | InputDevice.SOURCE_CLASS_POINTER
+ | InputDevice.SOURCE_CLASS_POSITION
+ | InputDevice.SOURCE_CLASS_TRACKBALL);
+
+ if (s2 != 0) cls += "Some_Unkown";
+
+ s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class;
+
+ if (Build.VERSION.SDK_INT >= 23) {
+ tst = InputDevice.SOURCE_BLUETOOTH_STYLUS;
+ if ((s & tst) == tst) src += " BLUETOOTH_STYLUS";
+ s2 &= ~tst;
+ }
+
+ tst = InputDevice.SOURCE_DPAD;
+ if ((s & tst) == tst) src += " DPAD";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_GAMEPAD;
+ if ((s & tst) == tst) src += " GAMEPAD";
+ s2 &= ~tst;
+
+ if (Build.VERSION.SDK_INT >= 21) {
+ tst = InputDevice.SOURCE_HDMI;
+ if ((s & tst) == tst) src += " HDMI";
+ s2 &= ~tst;
+ }
+
+ tst = InputDevice.SOURCE_JOYSTICK;
+ if ((s & tst) == tst) src += " JOYSTICK";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_KEYBOARD;
+ if ((s & tst) == tst) src += " KEYBOARD";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_MOUSE;
+ if ((s & tst) == tst) src += " MOUSE";
+ s2 &= ~tst;
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ tst = InputDevice.SOURCE_MOUSE_RELATIVE;
+ if ((s & tst) == tst) src += " MOUSE_RELATIVE";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_ROTARY_ENCODER;
+ if ((s & tst) == tst) src += " ROTARY_ENCODER";
+ s2 &= ~tst;
+ }
+ tst = InputDevice.SOURCE_STYLUS;
+ if ((s & tst) == tst) src += " STYLUS";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_TOUCHPAD;
+ if ((s & tst) == tst) src += " TOUCHPAD";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_TOUCHSCREEN;
+ if ((s & tst) == tst) src += " TOUCHSCREEN";
+ s2 &= ~tst;
+
+ if (Build.VERSION.SDK_INT >= 18) {
+ tst = InputDevice.SOURCE_TOUCH_NAVIGATION;
+ if ((s & tst) == tst) src += " TOUCH_NAVIGATION";
+ s2 &= ~tst;
+ }
+
+ tst = InputDevice.SOURCE_TRACKBALL;
+ if ((s & tst) == tst) src += " TRACKBALL";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_ANY;
+ if ((s & tst) == tst) src += " ANY";
+ s2 &= ~tst;
+
+ if (s == FLAG_TAINTED) src += " FLAG_TAINTED";
+ s2 &= ~FLAG_TAINTED;
+
+ if (s2 != 0) src += " Some_Unkown";
+
+ Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src);
+ }
+*/
+
+ public static boolean mIsResumedCalled, mHasFocus;
+ public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */);
+
+ // Cursor types
+ // private static final int SDL_SYSTEM_CURSOR_NONE = -1;
+ private static final int SDL_SYSTEM_CURSOR_ARROW = 0;
+ private static final int SDL_SYSTEM_CURSOR_IBEAM = 1;
+ private static final int SDL_SYSTEM_CURSOR_WAIT = 2;
+ private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3;
+ private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4;
+ private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5;
+ private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6;
+ private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7;
+ private static final int SDL_SYSTEM_CURSOR_SIZENS = 8;
+ private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9;
+ private static final int SDL_SYSTEM_CURSOR_NO = 10;
+ private static final int SDL_SYSTEM_CURSOR_HAND = 11;
+
+ protected static final int SDL_ORIENTATION_UNKNOWN = 0;
+ protected static final int SDL_ORIENTATION_LANDSCAPE = 1;
+ protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2;
+ protected static final int SDL_ORIENTATION_PORTRAIT = 3;
+ protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4;
+
+ protected static int mCurrentOrientation;
+ protected static Locale mCurrentLocale;
+
+ // Handle the state of the native layer
+ public enum NativeState {
+ INIT, RESUMED, PAUSED
+ }
+
+ public static NativeState mNextNativeState;
+ public static NativeState mCurrentNativeState;
+
+ public static boolean mExitCalledFromJava; // love2d-mod: allow restarting of the native thread
+
+ /** If shared libraries (e.g. SDL or the native application) could not be loaded. */
+ public static boolean mBrokenLibraries = true;
+
+ // Main components
+ protected static SDLActivity mSingleton;
+ protected static SDLSurface mSurface;
+ protected static DummyEdit mTextEdit;
+ protected static boolean mScreenKeyboardShown;
+ protected static ViewGroup mLayout;
+ protected static SDLClipboardHandler mClipboardHandler;
+ protected static Hashtable mCursors;
+ protected static int mLastCursorID;
+ protected static SDLGenericMotionListener_API12 mMotionListener;
+ protected static HIDDeviceManager mHIDDeviceManager;
+
+ // This is what SDL runs in. It invokes SDL_main(), eventually
+ protected static Thread mSDLThread;
+
+ protected static SDLGenericMotionListener_API12 getMotionListener() {
+ if (mMotionListener == null) {
+ if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) {
+ mMotionListener = new SDLGenericMotionListener_API26();
+ } else if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ mMotionListener = new SDLGenericMotionListener_API24();
+ } else {
+ mMotionListener = new SDLGenericMotionListener_API12();
+ }
+ }
+
+ return mMotionListener;
+ }
+
+ /**
+ * This method returns the name of the shared object with the application entry point
+ * It can be overridden by derived classes.
+ */
+ protected String getMainSharedObject() {
+ String library;
+ String[] libraries = SDLActivity.mSingleton.getLibraries();
+ if (libraries.length > 0) {
+ library = "lib" + libraries[libraries.length - 1] + ".so";
+ } else {
+ library = "libmain.so";
+ }
+ return getContext().getApplicationInfo().nativeLibraryDir + "/" + library;
+ }
+
+ /**
+ * This method returns the name of the application entry point
+ * It can be overridden by derived classes.
+ */
+ protected String getMainFunction() {
+ return "SDL_main";
+ }
+
+ /**
+ * This method is called by SDL before loading the native shared libraries.
+ * It can be overridden to provide names of shared libraries to be loaded.
+ * The default implementation returns the defaults. It never returns null.
+ * An array returned by a new implementation must at least contain "SDL2".
+ * Also keep in mind that the order the libraries are loaded may matter.
+ * @return names of shared libraries to be loaded (e.g. "SDL2", "main").
+ */
+ protected String[] getLibraries() {
+ return new String[] {
+ "SDL2",
+ // "SDL2_image",
+ // "SDL2_mixer",
+ // "SDL2_net",
+ // "SDL2_ttf",
+ "main"
+ };
+ }
+
+ // Load the .so
+ public void loadLibraries() {
+ for (String lib : getLibraries()) {
+ SDL.loadLibrary(lib);
+ }
+ }
+
+ /**
+ * This method is called by SDL before starting the native application thread.
+ * It can be overridden to provide the arguments after the application name.
+ * The default implementation returns an empty array. It never returns null.
+ * @return arguments for the native application.
+ */
+ protected String[] getArguments() {
+ return new String[0];
+ }
+
+ public static void initialize() {
+ // The static nature of the singleton and Android quirkyness force us to initialize everything here
+ // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values
+ mSingleton = null;
+ mSurface = null;
+ mTextEdit = null;
+ mLayout = null;
+ mClipboardHandler = null;
+ mCursors = new Hashtable();
+ mLastCursorID = 0;
+ mSDLThread = null;
+ mExitCalledFromJava = false; // love2d-mod: allow restarting of the native thread
+ mIsResumedCalled = false;
+ mHasFocus = true;
+ mNextNativeState = NativeState.INIT;
+ mCurrentNativeState = NativeState.INIT;
+ }
+
+ protected SDLSurface createSDLSurface(Context context) {
+ return new SDLSurface(context);
+ }
+
+ // Setup
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Log.v(TAG, "Device: " + Build.DEVICE);
+ Log.v(TAG, "Model: " + Build.MODEL);
+ Log.v(TAG, "onCreate()");
+ super.onCreate(savedInstanceState);
+
+ try {
+ Thread.currentThread().setName("SDLActivity");
+ } catch (Exception e) {
+ Log.v(TAG, "modify thread properties failed " + e.toString());
+ }
+
+ // Load shared libraries
+ String errorMsgBrokenLib = "";
+ try {
+ loadLibraries();
+ mBrokenLibraries = false; /* success */
+ } catch(UnsatisfiedLinkError e) {
+ System.err.println(e.getMessage());
+ mBrokenLibraries = true;
+ errorMsgBrokenLib = e.getMessage();
+ } catch(Exception e) {
+ System.err.println(e.getMessage());
+ mBrokenLibraries = true;
+ errorMsgBrokenLib = e.getMessage();
+ }
+
+ if (!mBrokenLibraries) {
+ String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." +
+ String.valueOf(SDL_MINOR_VERSION) + "." +
+ String.valueOf(SDL_MICRO_VERSION);
+ String version = nativeGetVersion();
+ if (!version.equals(expected_version)) {
+ mBrokenLibraries = true;
+ errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")";
+ }
+ }
+
+ if (mBrokenLibraries) {
+ mSingleton = this;
+ AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this);
+ dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall."
+ + System.getProperty("line.separator")
+ + System.getProperty("line.separator")
+ + "Error: " + errorMsgBrokenLib);
+ dlgAlert.setTitle("SDL Error");
+ dlgAlert.setPositiveButton("Exit",
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog,int id) {
+ // if this button is clicked, close current activity
+ SDLActivity.mSingleton.finish();
+ }
+ });
+ dlgAlert.setCancelable(false);
+ dlgAlert.create().show();
+
+ return;
+ }
+
+ startNative(); // love2d-mod: allow restarting of the native thread
+ }
+
+ // love2d-mod-start: allow restarting of the native thread
+ public void startNative() {
+ boolean hadSDLThread = SDLActivity.mSDLThread != null;
+
+ // Set up JNI
+ SDL.setupJNI();
+
+ // Initialize state
+ SDL.initialize();
+
+ // So we can call stuff from static callbacks
+ mSingleton = this;
+ SDL.setContext(this);
+
+ mClipboardHandler = new SDLClipboardHandler();
+
+ mHIDDeviceManager = HIDDeviceManager.acquire(this);
+
+ // Set up the surface
+ mSurface = createSDLSurface(this);
+
+ mLayout = new RelativeLayout(this);
+ mLayout.addView(mSurface);
+
+ // Get our current screen orientation and pass it down.
+ mCurrentOrientation = SDLActivity.getCurrentOrientation();
+ // Only record current orientation
+ SDLActivity.onNativeOrientationChanged(mCurrentOrientation);
+
+ try {
+ if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) {
+ mCurrentLocale = getContext().getResources().getConfiguration().locale;
+ } else {
+ mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0);
+ }
+ } catch(Exception ignored) {
+ }
+
+ setContentView(mLayout);
+
+ setWindowStyle(false);
+
+ getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this);
+
+ // Get filename from "Open with" of another application
+ Intent intent = getIntent();
+ if (intent != null && intent.getData() != null) {
+ String filename = intent.getData().getPath();
+ if (filename != null) {
+ Log.v(TAG, "Got filename: " + filename);
+ SDLActivity.onNativeDropFile(filename);
+ }
+ }
+
+ if (hadSDLThread) {
+ resumeNativeThread();
+ }
+ }
+ // love2d-mod-end: allow restarting of the native thread
+
+ protected void pauseNativeThread() {
+ mNextNativeState = NativeState.PAUSED;
+ mIsResumedCalled = false;
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ SDLActivity.handleNativeState();
+ }
+
+ protected void resumeNativeThread() {
+ mNextNativeState = NativeState.RESUMED;
+ mIsResumedCalled = true;
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ SDLActivity.handleNativeState();
+ }
+
+ // Events
+ @Override
+ protected void onPause() {
+ Log.v(TAG, "onPause()");
+ super.onPause();
+
+ if (mHIDDeviceManager != null) {
+ mHIDDeviceManager.setFrozen(true);
+ }
+ if (!mHasMultiWindow) {
+ pauseNativeThread();
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ Log.v(TAG, "onResume()");
+ super.onResume();
+
+ if (mHIDDeviceManager != null) {
+ mHIDDeviceManager.setFrozen(false);
+ }
+ if (!mHasMultiWindow) {
+ resumeNativeThread();
+ }
+ }
+
+ @Override
+ protected void onStop() {
+ Log.v(TAG, "onStop()");
+ super.onStop();
+ if (mHasMultiWindow) {
+ pauseNativeThread();
+ }
+ }
+
+ @Override
+ protected void onStart() {
+ Log.v(TAG, "onStart()");
+ super.onStart();
+ if (mHasMultiWindow) {
+ resumeNativeThread();
+ }
+ }
+
+ public static int getCurrentOrientation() {
+ int result = SDL_ORIENTATION_UNKNOWN;
+
+ Activity activity = (Activity)getContext();
+ if (activity == null) {
+ return result;
+ }
+ Display display = activity.getWindowManager().getDefaultDisplay();
+
+ switch (display.getRotation()) {
+ case Surface.ROTATION_0:
+ result = SDL_ORIENTATION_PORTRAIT;
+ break;
+
+ case Surface.ROTATION_90:
+ result = SDL_ORIENTATION_LANDSCAPE;
+ break;
+
+ case Surface.ROTATION_180:
+ result = SDL_ORIENTATION_PORTRAIT_FLIPPED;
+ break;
+
+ case Surface.ROTATION_270:
+ result = SDL_ORIENTATION_LANDSCAPE_FLIPPED;
+ break;
+ }
+
+ return result;
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ Log.v(TAG, "onWindowFocusChanged(): " + hasFocus);
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ mHasFocus = hasFocus;
+ if (hasFocus) {
+ mNextNativeState = NativeState.RESUMED;
+ SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded();
+
+ SDLActivity.handleNativeState();
+ nativeFocusChanged(true);
+
+ } else {
+ nativeFocusChanged(false);
+ if (!mHasMultiWindow) {
+ mNextNativeState = NativeState.PAUSED;
+ SDLActivity.handleNativeState();
+ }
+ }
+ }
+
+ @Override
+ public void onLowMemory() {
+ Log.v(TAG, "onLowMemory()");
+ super.onLowMemory();
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ SDLActivity.nativeLowMemory();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ Log.v(TAG, "onConfigurationChanged()");
+ super.onConfigurationChanged(newConfig);
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) {
+ mCurrentLocale = newConfig.locale;
+ SDLActivity.onNativeLocaleChanged();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ Log.v(TAG, "onDestroy()");
+
+ if (mHIDDeviceManager != null) {
+ HIDDeviceManager.release(mHIDDeviceManager);
+ mHIDDeviceManager = null;
+ }
+
+ SDLAudioManager.release(this);
+
+ if (SDLActivity.mBrokenLibraries) {
+ super.onDestroy();
+ return;
+ }
+
+ appQuitFinish(); // love2d-mod: allow restarting of the native thread
+ super.onDestroy();
+ }
+
+ // love2d-mod-start: allow restarting of the native thread
+ public void resetNative() {
+ Log.v("SDL", "resetNative()");
+
+ SDLActivity.mExitCalledFromJava = true; // love2d-mod: allow restarting of the native thread
+
+ appQuitFinish();
+ }
+
+ private void appQuitFinish() {
+ if (SDLActivity.mSDLThread != null) {
+
+ // Send Quit event to "SDLThread" thread
+ SDLActivity.nativeSendQuit();
+
+ // Wait for "SDLThread" thread to end
+ try {
+ SDLActivity.mSDLThread.join();
+ } catch(Exception e) {
+ Log.v(TAG, "Problem stopping SDLThread: " + e);
+ }
+ }
+
+ SDLActivity.nativeQuit();
+ }
+ // love2d-mod-end: allow restarting of the native thread
+
+ @Override
+ public void onBackPressed() {
+ // Check if we want to block the back button in case of mouse right click.
+ //
+ // If we do, the normal hardware back button will no longer work and people have to use home,
+ // but the mouse right click will work.
+ //
+ boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false);
+ if (trapBack) {
+ // Exit and let the mouse handler handle this button (if appropriate)
+ return;
+ }
+
+ // Default system back button behavior.
+ if (!isFinishing()) {
+ super.onBackPressed();
+ }
+ }
+
+ // Called by JNI from SDL.
+ public static void manualBackButton() {
+ mSingleton.pressBackButton();
+ }
+
+ // Used to get us onto the activity's main thread
+ public void pressBackButton() {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (!SDLActivity.this.isFinishing()) {
+ SDLActivity.this.superOnBackPressed();
+ }
+ }
+ });
+ }
+
+ // Used to access the system back behavior.
+ public void superOnBackPressed() {
+ super.onBackPressed();
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+
+ if (SDLActivity.mBrokenLibraries) {
+ return false;
+ }
+
+ int keyCode = event.getKeyCode();
+ // Ignore certain special keys so they're handled by Android
+ if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN ||
+ keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
+ keyCode == KeyEvent.KEYCODE_CAMERA ||
+ keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */
+ keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */
+ ) {
+ return false;
+ }
+ return super.dispatchKeyEvent(event);
+ }
+
+ /* Transition to next state */
+ public static void handleNativeState() {
+
+ if (mNextNativeState == mCurrentNativeState) {
+ // Already in same state, discard.
+ return;
+ }
+
+ // Try a transition to init state
+ if (mNextNativeState == NativeState.INIT) {
+
+ mCurrentNativeState = mNextNativeState;
+ return;
+ }
+
+ // Try a transition to paused state
+ if (mNextNativeState == NativeState.PAUSED) {
+ if (mSDLThread != null) {
+ nativePause();
+ }
+ if (mSurface != null) {
+ mSurface.handlePause();
+ }
+ mCurrentNativeState = mNextNativeState;
+ return;
+ }
+
+ // Try a transition to resumed state
+ if (mNextNativeState == NativeState.RESUMED) {
+ if (mSurface.mIsSurfaceReady && mHasFocus && mIsResumedCalled) {
+ if (mSDLThread == null) {
+ // This is the entry point to the C app.
+ // Start up the C app thread and enable sensor input for the first time
+ // FIXME: Why aren't we enabling sensor input at start?
+
+ mSDLThread = new Thread(new SDLMain(), "SDLThread");
+ mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true);
+ mSDLThread.start();
+
+ // No nativeResume(), don't signal Android_ResumeSem
+ } else {
+ nativeResume();
+ }
+ mSurface.handleResume();
+
+ mCurrentNativeState = mNextNativeState;
+ }
+ }
+ }
+
+ // Messages from the SDLMain thread
+ static final int COMMAND_CHANGE_TITLE = 1;
+ static final int COMMAND_CHANGE_WINDOW_STYLE = 2;
+ static final int COMMAND_TEXTEDIT_HIDE = 3;
+ static final int COMMAND_SET_KEEP_SCREEN_ON = 5;
+
+ protected static final int COMMAND_USER = 0x8000;
+
+ protected static boolean mFullscreenModeActive;
+
+ /**
+ * This method is called by SDL if SDL did not handle a message itself.
+ * This happens if a received message contains an unsupported command.
+ * Method can be overwritten to handle Messages in a different class.
+ * @param command the command of the message.
+ * @param param the parameter of the message. May be null.
+ * @return if the message was handled in overridden method.
+ */
+ protected boolean onUnhandledMessage(int command, Object param) {
+ return false;
+ }
+
+ /**
+ * A Handler class for Messages from native SDL applications.
+ * It uses current Activities as target (e.g. for the title).
+ * static to prevent implicit references to enclosing object.
+ */
+ protected static class SDLCommandHandler extends Handler {
+ @Override
+ public void handleMessage(Message msg) {
+ Context context = SDL.getContext();
+ if (context == null) {
+ Log.e(TAG, "error handling message, getContext() returned null");
+ return;
+ }
+ switch (msg.arg1) {
+ case COMMAND_CHANGE_TITLE:
+ if (context instanceof Activity) {
+ ((Activity) context).setTitle((String)msg.obj);
+ } else {
+ Log.e(TAG, "error handling message, getContext() returned no Activity");
+ }
+ break;
+ case COMMAND_CHANGE_WINDOW_STYLE:
+ if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) {
+ if (context instanceof Activity) {
+ Window window = ((Activity) context).getWindow();
+ if (window != null) {
+ if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) {
+ int flags = View.SYSTEM_UI_FLAG_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE;
+ window.getDecorView().setSystemUiVisibility(flags);
+ window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
+ SDLActivity.mFullscreenModeActive = true;
+ } else {
+ int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE;
+ window.getDecorView().setSystemUiVisibility(flags);
+ window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
+ window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ SDLActivity.mFullscreenModeActive = false;
+ }
+ }
+ } else {
+ Log.e(TAG, "error handling message, getContext() returned no Activity");
+ }
+ }
+ break;
+ case COMMAND_TEXTEDIT_HIDE:
+ if (mTextEdit != null) {
+ // Note: On some devices setting view to GONE creates a flicker in landscape.
+ // Setting the View's sizes to 0 is similar to GONE but without the flicker.
+ // The sizes will be set to useful values when the keyboard is shown again.
+ mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0));
+
+ InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0);
+
+ mScreenKeyboardShown = false;
+
+ mSurface.requestFocus();
+ }
+ break;
+ case COMMAND_SET_KEEP_SCREEN_ON:
+ {
+ if (context instanceof Activity) {
+ Window window = ((Activity) context).getWindow();
+ if (window != null) {
+ if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ } else {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ }
+ }
+ }
+ break;
+ }
+ default:
+ if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) {
+ Log.e(TAG, "error handling message, command is " + msg.arg1);
+ }
+ }
+ }
+ }
+
+ // Handler for the messages
+ Handler commandHandler = new SDLCommandHandler();
+
+ // Send a message from the SDLMain thread
+ boolean sendCommand(int command, Object data) {
+ Message msg = commandHandler.obtainMessage();
+ msg.arg1 = command;
+ msg.obj = data;
+ boolean result = commandHandler.sendMessage(msg);
+
+ if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) {
+ if (command == COMMAND_CHANGE_WINDOW_STYLE) {
+ // Ensure we don't return until the resize has actually happened,
+ // or 500ms have passed.
+
+ boolean bShouldWait = false;
+
+ if (data instanceof Integer) {
+ // Let's figure out if we're already laid out fullscreen or not.
+ Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
+ DisplayMetrics realMetrics = new DisplayMetrics();
+ display.getRealMetrics(realMetrics);
+
+ boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) &&
+ (realMetrics.heightPixels == mSurface.getHeight()));
+
+ if ((Integer) data == 1) {
+ // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going
+ // to change size and should wait for surfaceChanged() before we return, so the size
+ // is right back in native code. If we're already laid out fullscreen, though, we're
+ // not going to change size even if we change decor modes, so we shouldn't wait for
+ // surfaceChanged() -- which may not even happen -- and should return immediately.
+ bShouldWait = !bFullscreenLayout;
+ } else {
+ // If we're laid out fullscreen (even if the status bar and nav bar are present),
+ // or are actively in fullscreen, we're going to change size and should wait for
+ // surfaceChanged before we return, so the size is right back in native code.
+ bShouldWait = bFullscreenLayout;
+ }
+ }
+
+ if (bShouldWait && (SDLActivity.getContext() != null)) {
+ // We'll wait for the surfaceChanged() method, which will notify us
+ // when called. That way, we know our current size is really the
+ // size we need, instead of grabbing a size that's still got
+ // the navigation and/or status bars before they're hidden.
+ //
+ // We'll wait for up to half a second, because some devices
+ // take a surprisingly long time for the surface resize, but
+ // then we'll just give up and return.
+ //
+ synchronized (SDLActivity.getContext()) {
+ try {
+ SDLActivity.getContext().wait(500);
+ } catch (InterruptedException ie) {
+ ie.printStackTrace();
+ }
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+
+ // C functions we call
+ public static native String nativeGetVersion();
+ public static native int nativeSetupJNI();
+ public static native int nativeRunMain(String library, String function, Object arguments);
+ public static native void nativeLowMemory();
+ public static native void nativeSendQuit();
+ public static native void nativeQuit();
+ public static native void nativePause();
+ public static native void nativeResume();
+ public static native void nativeFocusChanged(boolean hasFocus);
+ public static native void onNativeDropFile(String filename);
+ public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float rate);
+ public static native void onNativeResize();
+ public static native void onNativeKeyDown(int keycode);
+ public static native void onNativeKeyUp(int keycode);
+ public static native boolean onNativeSoftReturnKey();
+ public static native void onNativeKeyboardFocusLost();
+ public static native void onNativeMouse(int button, int action, float x, float y, boolean relative);
+ public static native void onNativeTouch(int touchDevId, int pointerFingerId,
+ int action, float x,
+ float y, float p);
+ public static native void onNativeAccel(float x, float y, float z);
+ public static native void onNativeClipboardChanged();
+ public static native void onNativeSurfaceCreated();
+ public static native void onNativeSurfaceChanged();
+ public static native void onNativeSurfaceDestroyed();
+ public static native String nativeGetHint(String name);
+ public static native boolean nativeGetHintBoolean(String name, boolean default_value);
+ public static native void nativeSetenv(String name, String value);
+ public static native void onNativeOrientationChanged(int orientation);
+ public static native void nativeAddTouch(int touchId, String name);
+ public static native void nativePermissionResult(int requestCode, boolean result);
+ public static native void onNativeLocaleChanged();
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean setActivityTitle(String title) {
+ // Called from SDLMain() thread and can't directly affect the view
+ return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void setWindowStyle(boolean fullscreen) {
+ // Called from SDLMain() thread and can't directly affect the view
+ mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ * This is a static method for JNI convenience, it calls a non-static method
+ * so that is can be overridden
+ */
+ public static void setOrientation(int w, int h, boolean resizable, String hint)
+ {
+ if (mSingleton != null) {
+ mSingleton.setOrientationBis(w, h, resizable, hint);
+ }
+ }
+
+ /**
+ * This can be overridden
+ */
+ public void setOrientationBis(int w, int h, boolean resizable, String hint)
+ {
+ int orientation_landscape = -1;
+ int orientation_portrait = -1;
+
+ /* If set, hint "explicitly controls which UI orientations are allowed". */
+ if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) {
+ orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
+ } else if (hint.contains("LandscapeLeft")) {
+ orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
+ } else if (hint.contains("LandscapeRight")) {
+ orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
+ }
+
+ /* exact match to 'Portrait' to distinguish with PortraitUpsideDown */
+ boolean contains_Portrait = hint.contains("Portrait ") || hint.endsWith("Portrait");
+
+ if (contains_Portrait && hint.contains("PortraitUpsideDown")) {
+ orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT;
+ } else if (contains_Portrait) {
+ orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+ } else if (hint.contains("PortraitUpsideDown")) {
+ orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT;
+ }
+
+ boolean is_landscape_allowed = (orientation_landscape != -1);
+ boolean is_portrait_allowed = (orientation_portrait != -1);
+ int req; /* Requested orientation */
+
+ /* No valid hint, nothing is explicitly allowed */
+ if (!is_portrait_allowed && !is_landscape_allowed) {
+ if (resizable) {
+ /* All orientations are allowed */
+ req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
+ } else {
+ /* Fixed window and nothing specified. Get orientation from w/h of created window */
+ req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT);
+ }
+ } else {
+ /* At least one orientation is allowed */
+ if (resizable) {
+ if (is_portrait_allowed && is_landscape_allowed) {
+ /* hint allows both landscape and portrait, promote to full sensor */
+ req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
+ } else {
+ /* Use the only one allowed "orientation" */
+ req = (is_landscape_allowed ? orientation_landscape : orientation_portrait);
+ }
+ } else {
+ /* Fixed window and both orientations are allowed. Choose one. */
+ if (is_portrait_allowed && is_landscape_allowed) {
+ req = (w > h ? orientation_landscape : orientation_portrait);
+ } else {
+ /* Use the only one allowed "orientation" */
+ req = (is_landscape_allowed ? orientation_landscape : orientation_portrait);
+ }
+ }
+ }
+
+ Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint);
+ mSingleton.setRequestedOrientation(req);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void minimizeWindow() {
+
+ if (mSingleton == null) {
+ return;
+ }
+
+ Intent startMain = new Intent(Intent.ACTION_MAIN);
+ startMain.addCategory(Intent.CATEGORY_HOME);
+ startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ mSingleton.startActivity(startMain);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean shouldMinimizeOnFocusLoss() {
+/*
+ if (Build.VERSION.SDK_INT >= 24) {
+ if (mSingleton == null) {
+ return true;
+ }
+
+ if (mSingleton.isInMultiWindowMode()) {
+ return false;
+ }
+
+ if (mSingleton.isInPictureInPictureMode()) {
+ return false;
+ }
+ }
+
+ return true;
+*/
+ return false;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isScreenKeyboardShown()
+ {
+ if (mTextEdit == null) {
+ return false;
+ }
+
+ if (!mScreenKeyboardShown) {
+ return false;
+ }
+
+ InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ return imm.isAcceptingText();
+
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean supportsRelativeMouse()
+ {
+ // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under
+ // Android 7 APIs, and simply returns no data under Android 8 APIs.
+ //
+ // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and
+ // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result,
+ // we should stick to relative mode.
+ //
+ if (Build.VERSION.SDK_INT < 27 /* Android 8.1 (O_MR1) */ && isDeXMode()) {
+ return false;
+ }
+
+ return SDLActivity.getMotionListener().supportsRelativeMouse();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean setRelativeMouseEnabled(boolean enabled)
+ {
+ if (enabled && !supportsRelativeMouse()) {
+ return false;
+ }
+
+ return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean sendMessage(int command, int param) {
+ if (mSingleton == null) {
+ return false;
+ }
+ return mSingleton.sendCommand(command, param);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static Context getContext() {
+ return SDL.getContext();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isAndroidTV() {
+ UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE);
+ if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) {
+ return true;
+ }
+ if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) {
+ return true;
+ }
+ if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.equals("X96-W")) {
+ return true;
+ }
+ return Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.startsWith("TV");
+ }
+
+ public static double getDiagonal()
+ {
+ DisplayMetrics metrics = new DisplayMetrics();
+ Activity activity = (Activity)getContext();
+ if (activity == null) {
+ return 0.0;
+ }
+ activity.getWindowManager().getDefaultDisplay().getMetrics(metrics);
+
+ double dWidthInches = metrics.widthPixels / (double)metrics.xdpi;
+ double dHeightInches = metrics.heightPixels / (double)metrics.ydpi;
+
+ return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches));
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isTablet() {
+ // If our diagonal size is seven inches or greater, we consider ourselves a tablet.
+ return (getDiagonal() >= 7.0);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isChromebook() {
+ if (getContext() == null) {
+ return false;
+ }
+ return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isDeXMode() {
+ if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) {
+ return false;
+ }
+ try {
+ final Configuration config = getContext().getResources().getConfiguration();
+ final Class> configClass = config.getClass();
+ return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass)
+ == configClass.getField("semDesktopModeEnabled").getInt(config);
+ } catch(Exception ignored) {
+ return false;
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static DisplayMetrics getDisplayDPI() {
+ return getContext().getResources().getDisplayMetrics();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean getManifestEnvironmentVariables() {
+ try {
+ if (getContext() == null) {
+ return false;
+ }
+
+ ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA);
+ Bundle bundle = applicationInfo.metaData;
+ if (bundle == null) {
+ return false;
+ }
+ String prefix = "SDL_ENV.";
+ final int trimLength = prefix.length();
+ for (String key : bundle.keySet()) {
+ if (key.startsWith(prefix)) {
+ String name = key.substring(trimLength);
+ String value = bundle.get(key).toString();
+ nativeSetenv(name, value);
+ }
+ }
+ /* environment variables set! */
+ return true;
+ } catch (Exception e) {
+ Log.v(TAG, "exception " + e.toString());
+ }
+ return false;
+ }
+
+ // This method is called by SDLControllerManager's API 26 Generic Motion Handler.
+ public static View getContentView() {
+ return mLayout;
+ }
+
+ static class ShowTextInputTask implements Runnable {
+ /*
+ * This is used to regulate the pan&scan method to have some offset from
+ * the bottom edge of the input region and the top edge of an input
+ * method (soft keyboard)
+ */
+ static final int HEIGHT_PADDING = 15;
+
+ public int x, y, w, h;
+
+ public ShowTextInputTask(int x, int y, int w, int h) {
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+
+ /* Minimum size of 1 pixel, so it takes focus. */
+ if (this.w <= 0) {
+ this.w = 1;
+ }
+ if (this.h + HEIGHT_PADDING <= 0) {
+ this.h = 1 - HEIGHT_PADDING;
+ }
+ }
+
+ @Override
+ public void run() {
+ RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING);
+ params.leftMargin = x;
+ params.topMargin = y;
+
+ if (mTextEdit == null) {
+ mTextEdit = new DummyEdit(SDL.getContext());
+
+ mLayout.addView(mTextEdit, params);
+ } else {
+ mTextEdit.setLayoutParams(params);
+ }
+
+ mTextEdit.setVisibility(View.VISIBLE);
+ mTextEdit.requestFocus();
+
+ InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.showSoftInput(mTextEdit, 0);
+
+ mScreenKeyboardShown = true;
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean showTextInput(int x, int y, int w, int h) {
+ // Transfer the task to the main thread as a Runnable
+ return mSingleton.commandHandler.post(new ShowTextInputTask(x, y, w, h));
+ }
+
+ public static boolean isTextInputEvent(KeyEvent event) {
+
+ // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT
+ if (event.isCtrlPressed()) {
+ return false;
+ }
+
+ return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE;
+ }
+
+ public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) {
+ int deviceId = event.getDeviceId();
+ int source = event.getSource();
+
+ if (source == InputDevice.SOURCE_UNKNOWN) {
+ InputDevice device = InputDevice.getDevice(deviceId);
+ if (device != null) {
+ source = device.getSources();
+ }
+ }
+
+// if (event.getAction() == KeyEvent.ACTION_DOWN) {
+// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source);
+// } else if (event.getAction() == KeyEvent.ACTION_UP) {
+// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source);
+// }
+
+ // Dispatch the different events depending on where they come from
+ // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD
+ // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD
+ //
+ // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and
+ // SOURCE_JOYSTICK, while its key events arrive from the keyboard source
+ // So, retrieve the device itself and check all of its sources
+ if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) {
+ // Note that we process events with specific key codes here
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
+ if (SDLControllerManager.onNativePadDown(deviceId, keyCode) == 0) {
+ return true;
+ }
+ } else if (event.getAction() == KeyEvent.ACTION_UP) {
+ if (SDLControllerManager.onNativePadUp(deviceId, keyCode) == 0) {
+ return true;
+ }
+ }
+ }
+
+ if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) {
+ // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses
+ // they are ignored here because sending them as mouse input to SDL is messy
+ if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) {
+ switch (event.getAction()) {
+ case KeyEvent.ACTION_DOWN:
+ case KeyEvent.ACTION_UP:
+ // mark the event as handled or it will be handled by system
+ // handling KEYCODE_BACK by system will call onBackPressed()
+ return true;
+ }
+ }
+ }
+
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
+ if (isTextInputEvent(event)) {
+ if (ic != null) {
+ ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1);
+ } else {
+ SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1);
+ }
+ }
+ onNativeKeyDown(keyCode);
+ return true;
+ } else if (event.getAction() == KeyEvent.ACTION_UP) {
+ onNativeKeyUp(keyCode);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static Surface getNativeSurface() {
+ if (SDLActivity.mSurface == null) {
+ return null;
+ }
+ return SDLActivity.mSurface.getNativeSurface();
+ }
+
+ // Input
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void initTouch() {
+ int[] ids = InputDevice.getDeviceIds();
+
+ for (int id : ids) {
+ InputDevice device = InputDevice.getDevice(id);
+ /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */
+ if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN
+ || device.isVirtual())) {
+
+ int touchDevId = device.getId();
+ /*
+ * Prevent id to be -1, since it's used in SDL internal for synthetic events
+ * Appears when using Android emulator, eg:
+ * adb shell input mouse tap 100 100
+ * adb shell input touchscreen tap 100 100
+ */
+ if (touchDevId < 0) {
+ touchDevId -= 1;
+ }
+ nativeAddTouch(touchDevId, device.getName());
+ }
+ }
+ }
+
+ // Messagebox
+
+ /** Result of current messagebox. Also used for blocking the calling thread. */
+ protected final int[] messageboxSelection = new int[1];
+
+ /**
+ * This method is called by SDL using JNI.
+ * Shows the messagebox from UI thread and block calling thread.
+ * buttonFlags, buttonIds and buttonTexts must have same length.
+ * @param buttonFlags array containing flags for every button.
+ * @param buttonIds array containing id for every button.
+ * @param buttonTexts array containing text for every button.
+ * @param colors null for default or array of length 5 containing colors.
+ * @return button id or -1.
+ */
+ public int messageboxShowMessageBox(
+ final int flags,
+ final String title,
+ final String message,
+ final int[] buttonFlags,
+ final int[] buttonIds,
+ final String[] buttonTexts,
+ final int[] colors) {
+
+ messageboxSelection[0] = -1;
+
+ // sanity checks
+
+ if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) {
+ return -1; // implementation broken
+ }
+
+ // collect arguments for Dialog
+
+ final Bundle args = new Bundle();
+ args.putInt("flags", flags);
+ args.putString("title", title);
+ args.putString("message", message);
+ args.putIntArray("buttonFlags", buttonFlags);
+ args.putIntArray("buttonIds", buttonIds);
+ args.putStringArray("buttonTexts", buttonTexts);
+ args.putIntArray("colors", colors);
+
+ // trigger Dialog creation on UI thread
+
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ messageboxCreateAndShow(args);
+ }
+ });
+
+ // block the calling thread
+
+ synchronized (messageboxSelection) {
+ try {
+ messageboxSelection.wait();
+ } catch (InterruptedException ex) {
+ ex.printStackTrace();
+ return -1;
+ }
+ }
+
+ // return selected value
+
+ return messageboxSelection[0];
+ }
+
+ protected void messageboxCreateAndShow(Bundle args) {
+
+ // TODO set values from "flags" to messagebox dialog
+
+ // get colors
+
+ int[] colors = args.getIntArray("colors");
+ int backgroundColor;
+ int textColor;
+ int buttonBorderColor;
+ int buttonBackgroundColor;
+ int buttonSelectedColor;
+ if (colors != null) {
+ int i = -1;
+ backgroundColor = colors[++i];
+ textColor = colors[++i];
+ buttonBorderColor = colors[++i];
+ buttonBackgroundColor = colors[++i];
+ buttonSelectedColor = colors[++i];
+ } else {
+ backgroundColor = Color.TRANSPARENT;
+ textColor = Color.TRANSPARENT;
+ buttonBorderColor = Color.TRANSPARENT;
+ buttonBackgroundColor = Color.TRANSPARENT;
+ buttonSelectedColor = Color.TRANSPARENT;
+ }
+
+ // create dialog with title and a listener to wake up calling thread
+
+ final AlertDialog dialog = new AlertDialog.Builder(this).create();
+ dialog.setTitle(args.getString("title"));
+ dialog.setCancelable(false);
+ dialog.setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface unused) {
+ synchronized (messageboxSelection) {
+ messageboxSelection.notify();
+ }
+ }
+ });
+
+ // create text
+
+ TextView message = new TextView(this);
+ message.setGravity(Gravity.CENTER);
+ message.setText(args.getString("message"));
+ if (textColor != Color.TRANSPARENT) {
+ message.setTextColor(textColor);
+ }
+
+ // create buttons
+
+ int[] buttonFlags = args.getIntArray("buttonFlags");
+ int[] buttonIds = args.getIntArray("buttonIds");
+ String[] buttonTexts = args.getStringArray("buttonTexts");
+
+ final SparseArray mapping = new SparseArray();
+
+ LinearLayout buttons = new LinearLayout(this);
+ buttons.setOrientation(LinearLayout.HORIZONTAL);
+ buttons.setGravity(Gravity.CENTER);
+ for (int i = 0; i < buttonTexts.length; ++i) {
+ Button button = new Button(this);
+ final int id = buttonIds[i];
+ button.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ messageboxSelection[0] = id;
+ dialog.dismiss();
+ }
+ });
+ if (buttonFlags[i] != 0) {
+ // see SDL_messagebox.h
+ if ((buttonFlags[i] & 0x00000001) != 0) {
+ mapping.put(KeyEvent.KEYCODE_ENTER, button);
+ }
+ if ((buttonFlags[i] & 0x00000002) != 0) {
+ mapping.put(KeyEvent.KEYCODE_ESCAPE, button); /* API 11 */
+ }
+ }
+ button.setText(buttonTexts[i]);
+ if (textColor != Color.TRANSPARENT) {
+ button.setTextColor(textColor);
+ }
+ if (buttonBorderColor != Color.TRANSPARENT) {
+ // TODO set color for border of messagebox button
+ }
+ if (buttonBackgroundColor != Color.TRANSPARENT) {
+ Drawable drawable = button.getBackground();
+ if (drawable == null) {
+ // setting the color this way removes the style
+ button.setBackgroundColor(buttonBackgroundColor);
+ } else {
+ // setting the color this way keeps the style (gradient, padding, etc.)
+ drawable.setColorFilter(buttonBackgroundColor, PorterDuff.Mode.MULTIPLY);
+ }
+ }
+ if (buttonSelectedColor != Color.TRANSPARENT) {
+ // TODO set color for selected messagebox button
+ }
+ buttons.addView(button);
+ }
+
+ // create content
+
+ LinearLayout content = new LinearLayout(this);
+ content.setOrientation(LinearLayout.VERTICAL);
+ content.addView(message);
+ content.addView(buttons);
+ if (backgroundColor != Color.TRANSPARENT) {
+ content.setBackgroundColor(backgroundColor);
+ }
+
+ // add content to dialog and return
+
+ dialog.setView(content);
+ dialog.setOnKeyListener(new Dialog.OnKeyListener() {
+ @Override
+ public boolean onKey(DialogInterface d, int keyCode, KeyEvent event) {
+ Button button = mapping.get(keyCode);
+ if (button != null) {
+ if (event.getAction() == KeyEvent.ACTION_UP) {
+ button.performClick();
+ }
+ return true; // also for ignored actions
+ }
+ return false;
+ }
+ });
+
+ dialog.show();
+ }
+
+ private final Runnable rehideSystemUi = new Runnable() {
+ @Override
+ public void run() {
+ if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) {
+ int flags = View.SYSTEM_UI_FLAG_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE;
+
+ SDLActivity.this.getWindow().getDecorView().setSystemUiVisibility(flags);
+ }
+ }
+ };
+
+ public void onSystemUiVisibilityChange(int visibility) {
+ if (SDLActivity.mFullscreenModeActive && ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0 || (visibility & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0)) {
+
+ Handler handler = getWindow().getDecorView().getHandler();
+ if (handler != null) {
+ handler.removeCallbacks(rehideSystemUi); // Prevent a hide loop.
+ handler.postDelayed(rehideSystemUi, 2000);
+ }
+
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean clipboardHasText() {
+ return mClipboardHandler.clipboardHasText();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static String clipboardGetText() {
+ return mClipboardHandler.clipboardGetText();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void clipboardSetText(String string) {
+ mClipboardHandler.clipboardSetText(string);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static int createCustomCursor(int[] colors, int width, int height, int hotSpotX, int hotSpotY) {
+ Bitmap bitmap = Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888);
+ ++mLastCursorID;
+
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ try {
+ mCursors.put(mLastCursorID, PointerIcon.create(bitmap, hotSpotX, hotSpotY));
+ } catch (Exception e) {
+ return 0;
+ }
+ } else {
+ return 0;
+ }
+ return mLastCursorID;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void destroyCustomCursor(int cursorID) {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ try {
+ mCursors.remove(cursorID);
+ } catch (Exception e) {
+ }
+ }
+ return;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean setCustomCursor(int cursorID) {
+
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ try {
+ mSurface.setPointerIcon(mCursors.get(cursorID));
+ } catch (Exception e) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean setSystemCursor(int cursorID) {
+ int cursor_type = 0; //PointerIcon.TYPE_NULL;
+ switch (cursorID) {
+ case SDL_SYSTEM_CURSOR_ARROW:
+ cursor_type = 1000; //PointerIcon.TYPE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_IBEAM:
+ cursor_type = 1008; //PointerIcon.TYPE_TEXT;
+ break;
+ case SDL_SYSTEM_CURSOR_WAIT:
+ cursor_type = 1004; //PointerIcon.TYPE_WAIT;
+ break;
+ case SDL_SYSTEM_CURSOR_CROSSHAIR:
+ cursor_type = 1007; //PointerIcon.TYPE_CROSSHAIR;
+ break;
+ case SDL_SYSTEM_CURSOR_WAITARROW:
+ cursor_type = 1004; //PointerIcon.TYPE_WAIT;
+ break;
+ case SDL_SYSTEM_CURSOR_SIZENWSE:
+ cursor_type = 1017; //PointerIcon.TYPE_TOP_LEFT_DIAGONAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_SIZENESW:
+ cursor_type = 1016; //PointerIcon.TYPE_TOP_RIGHT_DIAGONAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_SIZEWE:
+ cursor_type = 1014; //PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_SIZENS:
+ cursor_type = 1015; //PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_SIZEALL:
+ cursor_type = 1020; //PointerIcon.TYPE_GRAB;
+ break;
+ case SDL_SYSTEM_CURSOR_NO:
+ cursor_type = 1012; //PointerIcon.TYPE_NO_DROP;
+ break;
+ case SDL_SYSTEM_CURSOR_HAND:
+ cursor_type = 1002; //PointerIcon.TYPE_HAND;
+ break;
+ }
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ try {
+ mSurface.setPointerIcon(PointerIcon.getSystemIcon(SDL.getContext(), cursor_type));
+ } catch (Exception e) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void requestPermission(String permission, int requestCode) {
+ if (Build.VERSION.SDK_INT < 23 /* Android 6.0 (M) */) {
+ nativePermissionResult(requestCode, true);
+ return;
+ }
+
+ Activity activity = (Activity)getContext();
+ if (activity.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
+ activity.requestPermissions(new String[]{permission}, requestCode);
+ } else {
+ nativePermissionResult(requestCode, true);
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ boolean result = (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED);
+ nativePermissionResult(requestCode, result);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static int openURL(String url)
+ {
+ try {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse(url));
+
+ int flags = Intent.FLAG_ACTIVITY_NO_HISTORY | Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
+ if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) {
+ flags |= Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
+ } else {
+ flags |= Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET;
+ }
+ i.addFlags(flags);
+
+ mSingleton.startActivity(i);
+ } catch (Exception ex) {
+ return -1;
+ }
+ return 0;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static int showToast(String message, int duration, int gravity, int xOffset, int yOffset)
+ {
+ if(null == mSingleton) {
+ return - 1;
+ }
+
+ try
+ {
+ class OneShotTask implements Runnable {
+ String mMessage;
+ int mDuration;
+ int mGravity;
+ int mXOffset;
+ int mYOffset;
+
+ OneShotTask(String message, int duration, int gravity, int xOffset, int yOffset) {
+ mMessage = message;
+ mDuration = duration;
+ mGravity = gravity;
+ mXOffset = xOffset;
+ mYOffset = yOffset;
+ }
+
+ public void run() {
+ try
+ {
+ Toast toast = Toast.makeText(mSingleton, mMessage, mDuration);
+ if (mGravity >= 0) {
+ toast.setGravity(mGravity, mXOffset, mYOffset);
+ }
+ toast.show();
+ } catch(Exception ex) {
+ Log.e(TAG, ex.getMessage());
+ }
+ }
+ }
+ mSingleton.runOnUiThread(new OneShotTask(message, duration, gravity, xOffset, yOffset));
+ } catch(Exception ex) {
+ return -1;
+ }
+ return 0;
+ }
+}
+
+/**
+ Simple runnable to start the SDL application
+*/
+class SDLMain implements Runnable {
+ @Override
+ public void run() {
+ // Runs SDL_main()
+ String library = SDLActivity.mSingleton.getMainSharedObject();
+ String function = SDLActivity.mSingleton.getMainFunction();
+ String[] arguments = SDLActivity.mSingleton.getArguments();
+
+ try {
+ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_DISPLAY);
+ } catch (Exception e) {
+ Log.v("SDL", "modify thread properties failed " + e.toString());
+ }
+
+ Log.v("SDL", "Running main function " + function + " from library " + library);
+
+ SDLActivity.nativeRunMain(library, function, arguments);
+
+ Log.v("SDL", "Finished main function");
+
+ // love2d-mod-start: allow restarting of the native thread
+ if (!SDLActivity.mExitCalledFromJava) {
+ if (SDLActivity.mSingleton != null && !SDLActivity.mSingleton.isFinishing()) {
+ // Let's finish the Activity
+ SDLActivity.mSDLThread = null;
+ SDLActivity.mSingleton.finish();
+ } // else: Activity is already being destroyed
+ }
+ // love2d-mod-end: allow restarting of the native thread
+ }
+}
+
+/* This is a fake invisible editor view that receives the input and defines the
+ * pan&scan region
+ */
+class DummyEdit extends View implements View.OnKeyListener {
+ InputConnection ic;
+
+ public DummyEdit(Context context) {
+ super(context);
+ setFocusableInTouchMode(true);
+ setFocusable(true);
+ setOnKeyListener(this);
+ }
+
+ @Override
+ public boolean onCheckIsTextEditor() {
+ return true;
+ }
+
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ return SDLActivity.handleKeyEvent(v, keyCode, event, ic);
+ }
+
+ //
+ @Override
+ public boolean onKeyPreIme (int keyCode, KeyEvent event) {
+ // As seen on StackOverflow: http://stackoverflow.com/questions/7634346/keyboard-hide-event
+ // FIXME: Discussion at http://bugzilla.libsdl.org/show_bug.cgi?id=1639
+ // FIXME: This is not a 100% effective solution to the problem of detecting if the keyboard is showing or not
+ // FIXME: A more effective solution would be to assume our Layout to be RelativeLayout or LinearLayout
+ // FIXME: And determine the keyboard presence doing this: http://stackoverflow.com/questions/2150078/how-to-check-visibility-of-software-keyboard-in-android
+ // FIXME: An even more effective way would be if Android provided this out of the box, but where would the fun be in that :)
+ if (event.getAction()==KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
+ if (SDLActivity.mTextEdit != null && SDLActivity.mTextEdit.getVisibility() == View.VISIBLE) {
+ SDLActivity.onNativeKeyboardFocusLost();
+ }
+ }
+ return super.onKeyPreIme(keyCode, event);
+ }
+
+ @Override
+ public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
+ ic = new SDLInputConnection(this, true);
+
+ outAttrs.inputType = InputType.TYPE_CLASS_TEXT |
+ InputType.TYPE_TEXT_FLAG_MULTI_LINE;
+ outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI |
+ EditorInfo.IME_FLAG_NO_FULLSCREEN /* API 11 */;
+
+ return ic;
+ }
+}
+
+class SDLInputConnection extends BaseInputConnection {
+
+ protected EditText mEditText;
+ protected String mCommittedText = "";
+
+ public SDLInputConnection(View targetView, boolean fullEditor) {
+ super(targetView, fullEditor);
+ mEditText = new EditText(SDL.getContext());
+ }
+
+ @Override
+ public Editable getEditable() {
+ return mEditText.getEditableText();
+ }
+
+ @Override
+ public boolean sendKeyEvent(KeyEvent event) {
+ /*
+ * This used to handle the keycodes from soft keyboard (and IME-translated input from hardkeyboard)
+ * However, as of Ice Cream Sandwich and later, almost all soft keyboard doesn't generate key presses
+ * and so we need to generate them ourselves in commitText. To avoid duplicates on the handful of keys
+ * that still do, we empty this out.
+ */
+
+ /*
+ * Return DOES still generate a key event, however. So rather than using it as the 'click a button' key
+ * as we do with physical keyboards, let's just use it to hide the keyboard.
+ */
+
+ if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) {
+ if (SDLActivity.onNativeSoftReturnKey()) {
+ return true;
+ }
+ }
+
+ return super.sendKeyEvent(event);
+ }
+
+ @Override
+ public boolean commitText(CharSequence text, int newCursorPosition) {
+ if (!super.commitText(text, newCursorPosition)) {
+ return false;
+ }
+ updateText();
+ return true;
+ }
+
+ @Override
+ public boolean setComposingText(CharSequence text, int newCursorPosition) {
+ if (!super.setComposingText(text, newCursorPosition)) {
+ return false;
+ }
+ updateText();
+ return true;
+ }
+
+ @Override
+ public boolean deleteSurroundingText(int beforeLength, int afterLength) {
+ if (Build.VERSION.SDK_INT <= 29 /* Android 10.0 (Q) */) {
+ // Workaround to capture backspace key. Ref: http://stackoverflow.com/questions>/14560344/android-backspace-in-webview-baseinputconnection
+ // and https://bugzilla.libsdl.org/show_bug.cgi?id=2265
+ if (beforeLength > 0 && afterLength == 0) {
+ // backspace(s)
+ while (beforeLength-- > 0) {
+ nativeGenerateScancodeForUnichar('\b');
+ }
+ return true;
+ }
+ }
+
+ if (!super.deleteSurroundingText(beforeLength, afterLength)) {
+ return false;
+ }
+ updateText();
+ return true;
+ }
+
+ protected void updateText() {
+ final Editable content = getEditable();
+ if (content == null) {
+ return;
+ }
+
+ String text = content.toString();
+ int compareLength = Math.min(text.length(), mCommittedText.length());
+ int matchLength, offset;
+
+ /* Backspace over characters that are no longer in the string */
+ for (matchLength = 0; matchLength < compareLength; ) {
+ int codePoint = mCommittedText.codePointAt(matchLength);
+ if (codePoint != text.codePointAt(matchLength)) {
+ break;
+ }
+ matchLength += Character.charCount(codePoint);
+ }
+ /* FIXME: This doesn't handle graphemes, like '🌬️' */
+ for (offset = matchLength; offset < mCommittedText.length(); ) {
+ int codePoint = mCommittedText.codePointAt(offset);
+ nativeGenerateScancodeForUnichar('\b');
+ offset += Character.charCount(codePoint);
+ }
+
+ if (matchLength < text.length()) {
+ String pendingText = text.subSequence(matchLength, text.length()).toString();
+ for (offset = 0; offset < pendingText.length(); ) {
+ int codePoint = pendingText.codePointAt(offset);
+ if (codePoint == '\n') {
+ if (SDLActivity.onNativeSoftReturnKey()) {
+ return;
+ }
+ }
+ /* Higher code points don't generate simulated scancodes */
+ if (codePoint < 128) {
+ nativeGenerateScancodeForUnichar((char)codePoint);
+ }
+ offset += Character.charCount(codePoint);
+ }
+ SDLInputConnection.nativeCommitText(pendingText, 0);
+ }
+ mCommittedText = text;
+ }
+
+ public static native void nativeCommitText(String text, int newCursorPosition);
+
+ public static native void nativeGenerateScancodeForUnichar(char c);
+}
+
+class SDLClipboardHandler implements
+ ClipboardManager.OnPrimaryClipChangedListener {
+
+ protected ClipboardManager mClipMgr;
+
+ SDLClipboardHandler() {
+ mClipMgr = (ClipboardManager) SDL.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ mClipMgr.addPrimaryClipChangedListener(this);
+ }
+
+ public boolean clipboardHasText() {
+ return mClipMgr.hasPrimaryClip();
+ }
+
+ public String clipboardGetText() {
+ ClipData clip = mClipMgr.getPrimaryClip();
+ if (clip != null) {
+ ClipData.Item item = clip.getItemAt(0);
+ if (item != null) {
+ CharSequence text = item.getText();
+ if (text != null) {
+ return text.toString();
+ }
+ }
+ }
+ return null;
+ }
+
+ public void clipboardSetText(String string) {
+ mClipMgr.removePrimaryClipChangedListener(this);
+ ClipData clip = ClipData.newPlainText(null, string);
+ mClipMgr.setPrimaryClip(clip);
+ mClipMgr.addPrimaryClipChangedListener(this);
+ }
+
+ @Override
+ public void onPrimaryClipChanged() {
+ SDLActivity.onNativeClipboardChanged();
+ }
+}
+
diff --git a/app/src/main/engine/org/love2d/sdl/SDLAudioManager.java b/app/src/main/engine/org/love2d/sdl/SDLAudioManager.java
new file mode 100644
index 000000000..d03c1f5b8
--- /dev/null
+++ b/app/src/main/engine/org/love2d/sdl/SDLAudioManager.java
@@ -0,0 +1,514 @@
+package org.love2d.sdl;
+
+import android.content.Context;
+import android.media.AudioDeviceCallback;
+import android.media.AudioDeviceInfo;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioRecord;
+import android.media.AudioTrack;
+import android.media.MediaRecorder;
+import android.os.Build;
+import android.util.Log;
+
+import java.util.Arrays;
+
+public class SDLAudioManager {
+ protected static final String TAG = "SDLAudio";
+
+ protected static AudioTrack mAudioTrack;
+ protected static AudioRecord mAudioRecord;
+ protected static Context mContext;
+
+ private static final int[] NO_DEVICES = {};
+
+ private static AudioDeviceCallback mAudioDeviceCallback;
+
+ public static void initialize() {
+ mAudioTrack = null;
+ mAudioRecord = null;
+ mAudioDeviceCallback = null;
+
+ if(Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */)
+ {
+ mAudioDeviceCallback = new AudioDeviceCallback() {
+ @Override
+ public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) {
+ Arrays.stream(addedDevices).forEach(deviceInfo -> addAudioDevice(deviceInfo.isSink(), deviceInfo.getId()));
+ }
+
+ @Override
+ public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) {
+ Arrays.stream(removedDevices).forEach(deviceInfo -> removeAudioDevice(deviceInfo.isSink(), deviceInfo.getId()));
+ }
+ };
+ }
+ }
+
+ public static void setContext(Context context) {
+ mContext = context;
+ if (context != null) {
+ registerAudioDeviceCallback();
+ }
+ }
+
+ public static void release(Context context) {
+ unregisterAudioDeviceCallback(context);
+ }
+
+ // Audio
+
+ protected static String getAudioFormatString(int audioFormat) {
+ switch (audioFormat) {
+ case AudioFormat.ENCODING_PCM_8BIT:
+ return "8-bit";
+ case AudioFormat.ENCODING_PCM_16BIT:
+ return "16-bit";
+ case AudioFormat.ENCODING_PCM_FLOAT:
+ return "float";
+ default:
+ return Integer.toString(audioFormat);
+ }
+ }
+
+ protected static int[] open(boolean isCapture, int sampleRate, int audioFormat, int desiredChannels, int desiredFrames, int deviceId) {
+ int channelConfig;
+ int sampleSize;
+ int frameSize;
+
+ Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", requested " + desiredFrames + " frames of " + desiredChannels + " channel " + getAudioFormatString(audioFormat) + " audio at " + sampleRate + " Hz");
+
+ /* On older devices let's use known good settings */
+ if (Build.VERSION.SDK_INT < 21 /* Android 5.0 (LOLLIPOP) */) {
+ if (desiredChannels > 2) {
+ desiredChannels = 2;
+ }
+ }
+
+ /* AudioTrack has sample rate limitation of 48000 (fixed in 5.0.2) */
+ if (Build.VERSION.SDK_INT < 22 /* Android 5.1 (LOLLIPOP_MR1) */) {
+ if (sampleRate < 8000) {
+ sampleRate = 8000;
+ } else if (sampleRate > 48000) {
+ sampleRate = 48000;
+ }
+ }
+
+ if (audioFormat == AudioFormat.ENCODING_PCM_FLOAT) {
+ int minSDKVersion = (isCapture ? 23 /* Android 6.0 (M) */ : 21 /* Android 5.0 (LOLLIPOP) */);
+ if (Build.VERSION.SDK_INT < minSDKVersion) {
+ audioFormat = AudioFormat.ENCODING_PCM_16BIT;
+ }
+ }
+ switch (audioFormat)
+ {
+ case AudioFormat.ENCODING_PCM_8BIT:
+ sampleSize = 1;
+ break;
+ case AudioFormat.ENCODING_PCM_16BIT:
+ sampleSize = 2;
+ break;
+ case AudioFormat.ENCODING_PCM_FLOAT:
+ sampleSize = 4;
+ break;
+ default:
+ Log.v(TAG, "Requested format " + audioFormat + ", getting ENCODING_PCM_16BIT");
+ audioFormat = AudioFormat.ENCODING_PCM_16BIT;
+ sampleSize = 2;
+ break;
+ }
+
+ if (isCapture) {
+ switch (desiredChannels) {
+ case 1:
+ channelConfig = AudioFormat.CHANNEL_IN_MONO;
+ break;
+ case 2:
+ channelConfig = AudioFormat.CHANNEL_IN_STEREO;
+ break;
+ default:
+ Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo");
+ desiredChannels = 2;
+ channelConfig = AudioFormat.CHANNEL_IN_STEREO;
+ break;
+ }
+ } else {
+ switch (desiredChannels) {
+ case 1:
+ channelConfig = AudioFormat.CHANNEL_OUT_MONO;
+ break;
+ case 2:
+ channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
+ break;
+ case 3:
+ channelConfig = AudioFormat.CHANNEL_OUT_STEREO | AudioFormat.CHANNEL_OUT_FRONT_CENTER;
+ break;
+ case 4:
+ channelConfig = AudioFormat.CHANNEL_OUT_QUAD;
+ break;
+ case 5:
+ channelConfig = AudioFormat.CHANNEL_OUT_QUAD | AudioFormat.CHANNEL_OUT_FRONT_CENTER;
+ break;
+ case 6:
+ channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
+ break;
+ case 7:
+ channelConfig = AudioFormat.CHANNEL_OUT_5POINT1 | AudioFormat.CHANNEL_OUT_BACK_CENTER;
+ break;
+ case 8:
+ if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) {
+ channelConfig = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND;
+ } else {
+ Log.v(TAG, "Requested " + desiredChannels + " channels, getting 5.1 surround");
+ desiredChannels = 6;
+ channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
+ }
+ break;
+ default:
+ Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo");
+ desiredChannels = 2;
+ channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
+ break;
+ }
+
+/*
+ Log.v(TAG, "Speaker configuration (and order of channels):");
+
+ if ((channelConfig & 0x00000004) != 0) {
+ Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT");
+ }
+ if ((channelConfig & 0x00000008) != 0) {
+ Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT");
+ }
+ if ((channelConfig & 0x00000010) != 0) {
+ Log.v(TAG, " CHANNEL_OUT_FRONT_CENTER");
+ }
+ if ((channelConfig & 0x00000020) != 0) {
+ Log.v(TAG, " CHANNEL_OUT_LOW_FREQUENCY");
+ }
+ if ((channelConfig & 0x00000040) != 0) {
+ Log.v(TAG, " CHANNEL_OUT_BACK_LEFT");
+ }
+ if ((channelConfig & 0x00000080) != 0) {
+ Log.v(TAG, " CHANNEL_OUT_BACK_RIGHT");
+ }
+ if ((channelConfig & 0x00000100) != 0) {
+ Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT_OF_CENTER");
+ }
+ if ((channelConfig & 0x00000200) != 0) {
+ Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT_OF_CENTER");
+ }
+ if ((channelConfig & 0x00000400) != 0) {
+ Log.v(TAG, " CHANNEL_OUT_BACK_CENTER");
+ }
+ if ((channelConfig & 0x00000800) != 0) {
+ Log.v(TAG, " CHANNEL_OUT_SIDE_LEFT");
+ }
+ if ((channelConfig & 0x00001000) != 0) {
+ Log.v(TAG, " CHANNEL_OUT_SIDE_RIGHT");
+ }
+*/
+ }
+ frameSize = (sampleSize * desiredChannels);
+
+ // Let the user pick a larger buffer if they really want -- but ye
+ // gods they probably shouldn't, the minimums are horrifyingly high
+ // latency already
+ int minBufferSize;
+ if (isCapture) {
+ minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);
+ } else {
+ minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat);
+ }
+ desiredFrames = Math.max(desiredFrames, (minBufferSize + frameSize - 1) / frameSize);
+
+ int[] results = new int[4];
+
+ if (isCapture) {
+ if (mAudioRecord == null) {
+ mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT, sampleRate,
+ channelConfig, audioFormat, desiredFrames * frameSize);
+
+ // see notes about AudioTrack state in audioOpen(), above. Probably also applies here.
+ if (mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
+ Log.e(TAG, "Failed during initialization of AudioRecord");
+ mAudioRecord.release();
+ mAudioRecord = null;
+ return null;
+ }
+
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */ && deviceId != 0) {
+ mAudioRecord.setPreferredDevice(getOutputAudioDeviceInfo(deviceId));
+ }
+
+ mAudioRecord.startRecording();
+ }
+
+ results[0] = mAudioRecord.getSampleRate();
+ results[1] = mAudioRecord.getAudioFormat();
+ results[2] = mAudioRecord.getChannelCount();
+
+ } else {
+ if (mAudioTrack == null) {
+ mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, channelConfig, audioFormat, desiredFrames * frameSize, AudioTrack.MODE_STREAM);
+
+ // Instantiating AudioTrack can "succeed" without an exception and the track may still be invalid
+ // Ref: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/media/java/android/media/AudioTrack.java
+ // Ref: http://developer.android.com/reference/android/media/AudioTrack.html#getState()
+ if (mAudioTrack.getState() != AudioTrack.STATE_INITIALIZED) {
+ /* Try again, with safer values */
+
+ Log.e(TAG, "Failed during initialization of Audio Track");
+ mAudioTrack.release();
+ mAudioTrack = null;
+ return null;
+ }
+
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */ && deviceId != 0) {
+ mAudioTrack.setPreferredDevice(getInputAudioDeviceInfo(deviceId));
+ }
+
+ mAudioTrack.play();
+ }
+
+ results[0] = mAudioTrack.getSampleRate();
+ results[1] = mAudioTrack.getAudioFormat();
+ results[2] = mAudioTrack.getChannelCount();
+ }
+ results[3] = desiredFrames;
+
+ Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", got " + results[3] + " frames of " + results[2] + " channel " + getAudioFormatString(results[1]) + " audio at " + results[0] + " Hz");
+
+ return results;
+ }
+
+ private static AudioDeviceInfo getInputAudioDeviceInfo(int deviceId) {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
+ return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS))
+ .filter(deviceInfo -> deviceInfo.getId() == deviceId)
+ .findFirst()
+ .orElse(null);
+ } else {
+ return null;
+ }
+ }
+
+ private static AudioDeviceInfo getOutputAudioDeviceInfo(int deviceId) {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
+ return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS))
+ .filter(deviceInfo -> deviceInfo.getId() == deviceId)
+ .findFirst()
+ .orElse(null);
+ } else {
+ return null;
+ }
+ }
+
+ private static void registerAudioDeviceCallback() {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
+ audioManager.registerAudioDeviceCallback(mAudioDeviceCallback, null);
+ }
+ }
+
+ private static void unregisterAudioDeviceCallback(Context context) {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+ audioManager.unregisterAudioDeviceCallback(mAudioDeviceCallback);
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static int[] getAudioOutputDevices() {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
+ return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)).mapToInt(AudioDeviceInfo::getId).toArray();
+ } else {
+ return NO_DEVICES;
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static int[] getAudioInputDevices() {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
+ return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)).mapToInt(AudioDeviceInfo::getId).toArray();
+ } else {
+ return NO_DEVICES;
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static int[] audioOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames, int deviceId) {
+ return open(false, sampleRate, audioFormat, desiredChannels, desiredFrames, deviceId);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void audioWriteFloatBuffer(float[] buffer) {
+ if (mAudioTrack == null) {
+ Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
+ return;
+ }
+
+ if (android.os.Build.VERSION.SDK_INT < 21 /* Android 5.0 (LOLLIPOP) */) {
+ Log.e(TAG, "Attempted to make an incompatible audio call with uninitialized audio! (floating-point output is supported since Android 5.0 Lollipop)");
+ return;
+ }
+
+ for (int i = 0; i < buffer.length;) {
+ int result = mAudioTrack.write(buffer, i, buffer.length - i, AudioTrack.WRITE_BLOCKING);
+ if (result > 0) {
+ i += result;
+ } else if (result == 0) {
+ try {
+ Thread.sleep(1);
+ } catch(InterruptedException e) {
+ // Nom nom
+ }
+ } else {
+ Log.w(TAG, "SDL audio: error return from write(float)");
+ return;
+ }
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void audioWriteShortBuffer(short[] buffer) {
+ if (mAudioTrack == null) {
+ Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
+ return;
+ }
+
+ for (int i = 0; i < buffer.length;) {
+ int result = mAudioTrack.write(buffer, i, buffer.length - i);
+ if (result > 0) {
+ i += result;
+ } else if (result == 0) {
+ try {
+ Thread.sleep(1);
+ } catch(InterruptedException e) {
+ // Nom nom
+ }
+ } else {
+ Log.w(TAG, "SDL audio: error return from write(short)");
+ return;
+ }
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void audioWriteByteBuffer(byte[] buffer) {
+ if (mAudioTrack == null) {
+ Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
+ return;
+ }
+
+ for (int i = 0; i < buffer.length; ) {
+ int result = mAudioTrack.write(buffer, i, buffer.length - i);
+ if (result > 0) {
+ i += result;
+ } else if (result == 0) {
+ try {
+ Thread.sleep(1);
+ } catch(InterruptedException e) {
+ // Nom nom
+ }
+ } else {
+ Log.w(TAG, "SDL audio: error return from write(byte)");
+ return;
+ }
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static int[] captureOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames, int deviceId) {
+ return open(true, sampleRate, audioFormat, desiredChannels, desiredFrames, deviceId);
+ }
+
+ /** This method is called by SDL using JNI. */
+ public static int captureReadFloatBuffer(float[] buffer, boolean blocking) {
+ if (Build.VERSION.SDK_INT < 23 /* Android 6.0 (M) */) {
+ return 0;
+ } else {
+ return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
+ }
+ }
+
+ /** This method is called by SDL using JNI. */
+ public static int captureReadShortBuffer(short[] buffer, boolean blocking) {
+ if (Build.VERSION.SDK_INT < 23 /* Android 6.0 (M) */) {
+ return mAudioRecord.read(buffer, 0, buffer.length);
+ } else {
+ return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
+ }
+ }
+
+ /** This method is called by SDL using JNI. */
+ public static int captureReadByteBuffer(byte[] buffer, boolean blocking) {
+ if (Build.VERSION.SDK_INT < 23 /* Android 6.0 (M) */) {
+ return mAudioRecord.read(buffer, 0, buffer.length);
+ } else {
+ return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
+ }
+ }
+
+ /** This method is called by SDL using JNI. */
+ public static void audioClose() {
+ if (mAudioTrack != null) {
+ mAudioTrack.stop();
+ mAudioTrack.release();
+ mAudioTrack = null;
+ }
+ }
+
+ /** This method is called by SDL using JNI. */
+ public static void captureClose() {
+ if (mAudioRecord != null) {
+ mAudioRecord.stop();
+ mAudioRecord.release();
+ mAudioRecord = null;
+ }
+ }
+
+ /** This method is called by SDL using JNI. */
+ public static void audioSetThreadPriority(boolean iscapture, int device_id) {
+ try {
+
+ /* Set thread name */
+ if (iscapture) {
+ Thread.currentThread().setName("SDLAudioC" + device_id);
+ } else {
+ Thread.currentThread().setName("SDLAudioP" + device_id);
+ }
+
+ /* Set thread priority */
+ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO);
+
+ } catch (Exception e) {
+ Log.v(TAG, "modify thread properties failed " + e.toString());
+ }
+ }
+
+ public static native int nativeSetupJNI();
+
+ public static native void removeAudioDevice(boolean isCapture, int deviceId);
+
+ public static native void addAudioDevice(boolean isCapture, int deviceId);
+
+}
diff --git a/app/src/main/engine/org/love2d/sdl/SDLControllerManager.java b/app/src/main/engine/org/love2d/sdl/SDLControllerManager.java
new file mode 100644
index 000000000..5cd55c7c8
--- /dev/null
+++ b/app/src/main/engine/org/love2d/sdl/SDLControllerManager.java
@@ -0,0 +1,854 @@
+package org.love2d.sdl;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import android.content.Context;
+import android.os.Build;
+import android.os.VibrationEffect;
+import android.os.Vibrator;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+
+
+public class SDLControllerManager
+{
+
+ public static native int nativeSetupJNI();
+
+ public static native int nativeAddJoystick(int device_id, String name, String desc,
+ int vendor_id, int product_id,
+ boolean is_accelerometer, int button_mask,
+ int naxes, int axis_mask, int nhats, int nballs);
+ public static native int nativeRemoveJoystick(int device_id);
+ public static native int nativeAddHaptic(int device_id, String name);
+ public static native int nativeRemoveHaptic(int device_id);
+ public static native int onNativePadDown(int device_id, int keycode);
+ public static native int onNativePadUp(int device_id, int keycode);
+ public static native void onNativeJoy(int device_id, int axis,
+ float value);
+ public static native void onNativeHat(int device_id, int hat_id,
+ int x, int y);
+
+ protected static SDLJoystickHandler mJoystickHandler;
+ protected static SDLHapticHandler mHapticHandler;
+
+ private static final String TAG = "SDLControllerManager";
+
+ public static void initialize() {
+ if (mJoystickHandler == null) {
+ if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) {
+ mJoystickHandler = new SDLJoystickHandler_API19();
+ } else {
+ mJoystickHandler = new SDLJoystickHandler_API16();
+ }
+ }
+
+ if (mHapticHandler == null) {
+ if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) {
+ mHapticHandler = new SDLHapticHandler_API26();
+ } else {
+ mHapticHandler = new SDLHapticHandler();
+ }
+ }
+ }
+
+ // Joystick glue code, just a series of stubs that redirect to the SDLJoystickHandler instance
+ public static boolean handleJoystickMotionEvent(MotionEvent event) {
+ return mJoystickHandler.handleMotionEvent(event);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void pollInputDevices() {
+ mJoystickHandler.pollInputDevices();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void pollHapticDevices() {
+ mHapticHandler.pollHapticDevices();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void hapticRun(int device_id, float intensity, int length) {
+ mHapticHandler.run(device_id, intensity, length);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void hapticStop(int device_id)
+ {
+ mHapticHandler.stop(device_id);
+ }
+
+ // Check if a given device is considered a possible SDL joystick
+ public static boolean isDeviceSDLJoystick(int deviceId) {
+ InputDevice device = InputDevice.getDevice(deviceId);
+ // We cannot use InputDevice.isVirtual before API 16, so let's accept
+ // only nonnegative device ids (VIRTUAL_KEYBOARD equals -1)
+ if ((device == null) || (deviceId < 0)) {
+ return false;
+ }
+ int sources = device.getSources();
+
+ /* This is called for every button press, so let's not spam the logs */
+ /*
+ if ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
+ Log.v(TAG, "Input device " + device.getName() + " has class joystick.");
+ }
+ if ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) {
+ Log.v(TAG, "Input device " + device.getName() + " is a dpad.");
+ }
+ if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
+ Log.v(TAG, "Input device " + device.getName() + " is a gamepad.");
+ }
+ */
+
+ return ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 ||
+ ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) ||
+ ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD)
+ );
+ }
+
+}
+
+class SDLJoystickHandler {
+
+ /**
+ * Handles given MotionEvent.
+ * @param event the event to be handled.
+ * @return if given event was processed.
+ */
+ public boolean handleMotionEvent(MotionEvent event) {
+ return false;
+ }
+
+ /**
+ * Handles adding and removing of input devices.
+ */
+ public void pollInputDevices() {
+ }
+}
+
+/* Actual joystick functionality available for API >= 12 devices */
+class SDLJoystickHandler_API16 extends SDLJoystickHandler {
+
+ static class SDLJoystick {
+ public int device_id;
+ public String name;
+ public String desc;
+ public ArrayList axes;
+ public ArrayList hats;
+ }
+ static class RangeComparator implements Comparator {
+ @Override
+ public int compare(InputDevice.MotionRange arg0, InputDevice.MotionRange arg1) {
+ // Some controllers, like the Moga Pro 2, return AXIS_GAS (22) for right trigger and AXIS_BRAKE (23) for left trigger - swap them so they're sorted in the right order for SDL
+ int arg0Axis = arg0.getAxis();
+ int arg1Axis = arg1.getAxis();
+ if (arg0Axis == MotionEvent.AXIS_GAS) {
+ arg0Axis = MotionEvent.AXIS_BRAKE;
+ } else if (arg0Axis == MotionEvent.AXIS_BRAKE) {
+ arg0Axis = MotionEvent.AXIS_GAS;
+ }
+ if (arg1Axis == MotionEvent.AXIS_GAS) {
+ arg1Axis = MotionEvent.AXIS_BRAKE;
+ } else if (arg1Axis == MotionEvent.AXIS_BRAKE) {
+ arg1Axis = MotionEvent.AXIS_GAS;
+ }
+
+ // Make sure the AXIS_Z is sorted between AXIS_RY and AXIS_RZ.
+ // This is because the usual pairing are:
+ // - AXIS_X + AXIS_Y (left stick).
+ // - AXIS_RX, AXIS_RY (sometimes the right stick, sometimes triggers).
+ // - AXIS_Z, AXIS_RZ (sometimes the right stick, sometimes triggers).
+ // This sorts the axes in the above order, which tends to be correct
+ // for Xbox-ish game pads that have the right stick on RX/RY and the
+ // triggers on Z/RZ.
+ //
+ // Gamepads that don't have AXIS_Z/AXIS_RZ but use
+ // AXIS_LTRIGGER/AXIS_RTRIGGER are unaffected by this.
+ //
+ // References:
+ // - https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input
+ // - https://www.kernel.org/doc/html/latest/input/gamepad.html
+ if (arg0Axis == MotionEvent.AXIS_Z) {
+ arg0Axis = MotionEvent.AXIS_RZ - 1;
+ } else if (arg0Axis > MotionEvent.AXIS_Z && arg0Axis < MotionEvent.AXIS_RZ) {
+ --arg0Axis;
+ }
+ if (arg1Axis == MotionEvent.AXIS_Z) {
+ arg1Axis = MotionEvent.AXIS_RZ - 1;
+ } else if (arg1Axis > MotionEvent.AXIS_Z && arg1Axis < MotionEvent.AXIS_RZ) {
+ --arg1Axis;
+ }
+
+ return arg0Axis - arg1Axis;
+ }
+ }
+
+ private final ArrayList mJoysticks;
+
+ public SDLJoystickHandler_API16() {
+
+ mJoysticks = new ArrayList();
+ }
+
+ @Override
+ public void pollInputDevices() {
+ int[] deviceIds = InputDevice.getDeviceIds();
+
+ for (int device_id : deviceIds) {
+ if (SDLControllerManager.isDeviceSDLJoystick(device_id)) {
+ SDLJoystick joystick = getJoystick(device_id);
+ if (joystick == null) {
+ InputDevice joystickDevice = InputDevice.getDevice(device_id);
+ joystick = new SDLJoystick();
+ joystick.device_id = device_id;
+ joystick.name = joystickDevice.getName();
+ joystick.desc = getJoystickDescriptor(joystickDevice);
+ joystick.axes = new ArrayList();
+ joystick.hats = new ArrayList();
+
+ List ranges = joystickDevice.getMotionRanges();
+ Collections.sort(ranges, new RangeComparator());
+ for (InputDevice.MotionRange range : ranges) {
+ if ((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
+ if (range.getAxis() == MotionEvent.AXIS_HAT_X || range.getAxis() == MotionEvent.AXIS_HAT_Y) {
+ joystick.hats.add(range);
+ } else {
+ joystick.axes.add(range);
+ }
+ }
+ }
+
+ mJoysticks.add(joystick);
+ SDLControllerManager.nativeAddJoystick(joystick.device_id, joystick.name, joystick.desc,
+ getVendorId(joystickDevice), getProductId(joystickDevice), false,
+ getButtonMask(joystickDevice), joystick.axes.size(), getAxisMask(joystick.axes), joystick.hats.size()/2, 0);
+ }
+ }
+ }
+
+ /* Check removed devices */
+ ArrayList removedDevices = null;
+ for (SDLJoystick joystick : mJoysticks) {
+ int device_id = joystick.device_id;
+ int i;
+ for (i = 0; i < deviceIds.length; i++) {
+ if (device_id == deviceIds[i]) break;
+ }
+ if (i == deviceIds.length) {
+ if (removedDevices == null) {
+ removedDevices = new ArrayList();
+ }
+ removedDevices.add(device_id);
+ }
+ }
+
+ if (removedDevices != null) {
+ for (int device_id : removedDevices) {
+ SDLControllerManager.nativeRemoveJoystick(device_id);
+ for (int i = 0; i < mJoysticks.size(); i++) {
+ if (mJoysticks.get(i).device_id == device_id) {
+ mJoysticks.remove(i);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ protected SDLJoystick getJoystick(int device_id) {
+ for (SDLJoystick joystick : mJoysticks) {
+ if (joystick.device_id == device_id) {
+ return joystick;
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public boolean handleMotionEvent(MotionEvent event) {
+ int actionPointerIndex = event.getActionIndex();
+ int action = event.getActionMasked();
+ if (action == MotionEvent.ACTION_MOVE) {
+ SDLJoystick joystick = getJoystick(event.getDeviceId());
+ if (joystick != null) {
+ for (int i = 0; i < joystick.axes.size(); i++) {
+ InputDevice.MotionRange range = joystick.axes.get(i);
+ /* Normalize the value to -1...1 */
+ float value = (event.getAxisValue(range.getAxis(), actionPointerIndex) - range.getMin()) / range.getRange() * 2.0f - 1.0f;
+ SDLControllerManager.onNativeJoy(joystick.device_id, i, value);
+ }
+ for (int i = 0; i < joystick.hats.size() / 2; i++) {
+ int hatX = Math.round(event.getAxisValue(joystick.hats.get(2 * i).getAxis(), actionPointerIndex));
+ int hatY = Math.round(event.getAxisValue(joystick.hats.get(2 * i + 1).getAxis(), actionPointerIndex));
+ SDLControllerManager.onNativeHat(joystick.device_id, i, hatX, hatY);
+ }
+ }
+ }
+ return true;
+ }
+
+ public String getJoystickDescriptor(InputDevice joystickDevice) {
+ String desc = joystickDevice.getDescriptor();
+
+ if (desc != null && !desc.isEmpty()) {
+ return desc;
+ }
+
+ return joystickDevice.getName();
+ }
+ public int getProductId(InputDevice joystickDevice) {
+ return 0;
+ }
+ public int getVendorId(InputDevice joystickDevice) {
+ return 0;
+ }
+ public int getAxisMask(List ranges) {
+ return -1;
+ }
+ public int getButtonMask(InputDevice joystickDevice) {
+ return -1;
+ }
+}
+
+class SDLJoystickHandler_API19 extends SDLJoystickHandler_API16 {
+
+ @Override
+ public int getProductId(InputDevice joystickDevice) {
+ return joystickDevice.getProductId();
+ }
+
+ @Override
+ public int getVendorId(InputDevice joystickDevice) {
+ return joystickDevice.getVendorId();
+ }
+
+ @Override
+ public int getAxisMask(List ranges) {
+ // For compatibility, keep computing the axis mask like before,
+ // only really distinguishing 2, 4 and 6 axes.
+ int axis_mask = 0;
+ if (ranges.size() >= 2) {
+ // ((1 << SDL_GAMEPAD_AXIS_LEFTX) | (1 << SDL_GAMEPAD_AXIS_LEFTY))
+ axis_mask |= 0x0003;
+ }
+ if (ranges.size() >= 4) {
+ // ((1 << SDL_GAMEPAD_AXIS_RIGHTX) | (1 << SDL_GAMEPAD_AXIS_RIGHTY))
+ axis_mask |= 0x000c;
+ }
+ if (ranges.size() >= 6) {
+ // ((1 << SDL_GAMEPAD_AXIS_LEFT_TRIGGER) | (1 << SDL_GAMEPAD_AXIS_RIGHT_TRIGGER))
+ axis_mask |= 0x0030;
+ }
+ // Also add an indicator bit for whether the sorting order has changed.
+ // This serves to disable outdated gamecontrollerdb.txt mappings.
+ boolean have_z = false;
+ boolean have_past_z_before_rz = false;
+ for (InputDevice.MotionRange range : ranges) {
+ int axis = range.getAxis();
+ if (axis == MotionEvent.AXIS_Z) {
+ have_z = true;
+ } else if (axis > MotionEvent.AXIS_Z && axis < MotionEvent.AXIS_RZ) {
+ have_past_z_before_rz = true;
+ }
+ }
+ if (have_z && have_past_z_before_rz) {
+ // If both these exist, the compare() function changed sorting order.
+ // Set a bit to indicate this fact.
+ axis_mask |= 0x8000;
+ }
+ return axis_mask;
+ }
+
+ @Override
+ public int getButtonMask(InputDevice joystickDevice) {
+ int button_mask = 0;
+ int[] keys = new int[] {
+ KeyEvent.KEYCODE_BUTTON_A,
+ KeyEvent.KEYCODE_BUTTON_B,
+ KeyEvent.KEYCODE_BUTTON_X,
+ KeyEvent.KEYCODE_BUTTON_Y,
+ KeyEvent.KEYCODE_BACK,
+ KeyEvent.KEYCODE_MENU,
+ KeyEvent.KEYCODE_BUTTON_MODE,
+ KeyEvent.KEYCODE_BUTTON_START,
+ KeyEvent.KEYCODE_BUTTON_THUMBL,
+ KeyEvent.KEYCODE_BUTTON_THUMBR,
+ KeyEvent.KEYCODE_BUTTON_L1,
+ KeyEvent.KEYCODE_BUTTON_R1,
+ KeyEvent.KEYCODE_DPAD_UP,
+ KeyEvent.KEYCODE_DPAD_DOWN,
+ KeyEvent.KEYCODE_DPAD_LEFT,
+ KeyEvent.KEYCODE_DPAD_RIGHT,
+ KeyEvent.KEYCODE_BUTTON_SELECT,
+ KeyEvent.KEYCODE_DPAD_CENTER,
+
+ // These don't map into any SDL controller buttons directly
+ KeyEvent.KEYCODE_BUTTON_L2,
+ KeyEvent.KEYCODE_BUTTON_R2,
+ KeyEvent.KEYCODE_BUTTON_C,
+ KeyEvent.KEYCODE_BUTTON_Z,
+ KeyEvent.KEYCODE_BUTTON_1,
+ KeyEvent.KEYCODE_BUTTON_2,
+ KeyEvent.KEYCODE_BUTTON_3,
+ KeyEvent.KEYCODE_BUTTON_4,
+ KeyEvent.KEYCODE_BUTTON_5,
+ KeyEvent.KEYCODE_BUTTON_6,
+ KeyEvent.KEYCODE_BUTTON_7,
+ KeyEvent.KEYCODE_BUTTON_8,
+ KeyEvent.KEYCODE_BUTTON_9,
+ KeyEvent.KEYCODE_BUTTON_10,
+ KeyEvent.KEYCODE_BUTTON_11,
+ KeyEvent.KEYCODE_BUTTON_12,
+ KeyEvent.KEYCODE_BUTTON_13,
+ KeyEvent.KEYCODE_BUTTON_14,
+ KeyEvent.KEYCODE_BUTTON_15,
+ KeyEvent.KEYCODE_BUTTON_16,
+ };
+ int[] masks = new int[] {
+ (1 << 0), // A -> A
+ (1 << 1), // B -> B
+ (1 << 2), // X -> X
+ (1 << 3), // Y -> Y
+ (1 << 4), // BACK -> BACK
+ (1 << 6), // MENU -> START
+ (1 << 5), // MODE -> GUIDE
+ (1 << 6), // START -> START
+ (1 << 7), // THUMBL -> LEFTSTICK
+ (1 << 8), // THUMBR -> RIGHTSTICK
+ (1 << 9), // L1 -> LEFTSHOULDER
+ (1 << 10), // R1 -> RIGHTSHOULDER
+ (1 << 11), // DPAD_UP -> DPAD_UP
+ (1 << 12), // DPAD_DOWN -> DPAD_DOWN
+ (1 << 13), // DPAD_LEFT -> DPAD_LEFT
+ (1 << 14), // DPAD_RIGHT -> DPAD_RIGHT
+ (1 << 4), // SELECT -> BACK
+ (1 << 0), // DPAD_CENTER -> A
+ (1 << 15), // L2 -> ??
+ (1 << 16), // R2 -> ??
+ (1 << 17), // C -> ??
+ (1 << 18), // Z -> ??
+ (1 << 20), // 1 -> ??
+ (1 << 21), // 2 -> ??
+ (1 << 22), // 3 -> ??
+ (1 << 23), // 4 -> ??
+ (1 << 24), // 5 -> ??
+ (1 << 25), // 6 -> ??
+ (1 << 26), // 7 -> ??
+ (1 << 27), // 8 -> ??
+ (1 << 28), // 9 -> ??
+ (1 << 29), // 10 -> ??
+ (1 << 30), // 11 -> ??
+ (1 << 31), // 12 -> ??
+ // We're out of room...
+ 0xFFFFFFFF, // 13 -> ??
+ 0xFFFFFFFF, // 14 -> ??
+ 0xFFFFFFFF, // 15 -> ??
+ 0xFFFFFFFF, // 16 -> ??
+ };
+ boolean[] has_keys = joystickDevice.hasKeys(keys);
+ for (int i = 0; i < keys.length; ++i) {
+ if (has_keys[i]) {
+ button_mask |= masks[i];
+ }
+ }
+ return button_mask;
+ }
+}
+
+class SDLHapticHandler_API26 extends SDLHapticHandler {
+ @Override
+ public void run(int device_id, float intensity, int length) {
+ SDLHaptic haptic = getHaptic(device_id);
+ if (haptic != null) {
+ Log.d("SDL", "Rtest: Vibe with intensity " + intensity + " for " + length);
+ if (intensity == 0.0f) {
+ stop(device_id);
+ return;
+ }
+
+ int vibeValue = Math.round(intensity * 255);
+
+ if (vibeValue > 255) {
+ vibeValue = 255;
+ }
+ if (vibeValue < 1) {
+ stop(device_id);
+ return;
+ }
+ try {
+ haptic.vib.vibrate(VibrationEffect.createOneShot(length, vibeValue));
+ }
+ catch (Exception e) {
+ // Fall back to the generic method, which uses DEFAULT_AMPLITUDE, but works even if
+ // something went horribly wrong with the Android 8.0 APIs.
+ haptic.vib.vibrate(length);
+ }
+ }
+ }
+}
+
+class SDLHapticHandler {
+
+ static class SDLHaptic {
+ public int device_id;
+ public String name;
+ public Vibrator vib;
+ }
+
+ private final ArrayList mHaptics;
+
+ public SDLHapticHandler() {
+ mHaptics = new ArrayList();
+ }
+
+ public void run(int device_id, float intensity, int length) {
+ SDLHaptic haptic = getHaptic(device_id);
+ if (haptic != null) {
+ haptic.vib.vibrate(length);
+ }
+ }
+
+ public void stop(int device_id) {
+ SDLHaptic haptic = getHaptic(device_id);
+ if (haptic != null) {
+ haptic.vib.cancel();
+ }
+ }
+
+ public void pollHapticDevices() {
+
+ final int deviceId_VIBRATOR_SERVICE = 999999;
+ boolean hasVibratorService = false;
+
+ int[] deviceIds = InputDevice.getDeviceIds();
+ // It helps processing the device ids in reverse order
+ // For example, in the case of the XBox 360 wireless dongle,
+ // so the first controller seen by SDL matches what the receiver
+ // considers to be the first controller
+
+ for (int i = deviceIds.length - 1; i > -1; i--) {
+ SDLHaptic haptic = getHaptic(deviceIds[i]);
+ if (haptic == null) {
+ InputDevice device = InputDevice.getDevice(deviceIds[i]);
+ Vibrator vib = device.getVibrator();
+ if (vib.hasVibrator()) {
+ haptic = new SDLHaptic();
+ haptic.device_id = deviceIds[i];
+ haptic.name = device.getName();
+ haptic.vib = vib;
+ mHaptics.add(haptic);
+ SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name);
+ }
+ }
+ }
+
+ /* Check VIBRATOR_SERVICE */
+ Vibrator vib = (Vibrator) SDL.getContext().getSystemService(Context.VIBRATOR_SERVICE);
+ if (vib != null) {
+ hasVibratorService = vib.hasVibrator();
+
+ if (hasVibratorService) {
+ SDLHaptic haptic = getHaptic(deviceId_VIBRATOR_SERVICE);
+ if (haptic == null) {
+ haptic = new SDLHaptic();
+ haptic.device_id = deviceId_VIBRATOR_SERVICE;
+ haptic.name = "VIBRATOR_SERVICE";
+ haptic.vib = vib;
+ mHaptics.add(haptic);
+ SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name);
+ }
+ }
+ }
+
+ /* Check removed devices */
+ ArrayList removedDevices = null;
+ for (SDLHaptic haptic : mHaptics) {
+ int device_id = haptic.device_id;
+ int i;
+ for (i = 0; i < deviceIds.length; i++) {
+ if (device_id == deviceIds[i]) break;
+ }
+
+ if (device_id != deviceId_VIBRATOR_SERVICE || !hasVibratorService) {
+ if (i == deviceIds.length) {
+ if (removedDevices == null) {
+ removedDevices = new ArrayList();
+ }
+ removedDevices.add(device_id);
+ }
+ } // else: don't remove the vibrator if it is still present
+ }
+
+ if (removedDevices != null) {
+ for (int device_id : removedDevices) {
+ SDLControllerManager.nativeRemoveHaptic(device_id);
+ for (int i = 0; i < mHaptics.size(); i++) {
+ if (mHaptics.get(i).device_id == device_id) {
+ mHaptics.remove(i);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ protected SDLHaptic getHaptic(int device_id) {
+ for (SDLHaptic haptic : mHaptics) {
+ if (haptic.device_id == device_id) {
+ return haptic;
+ }
+ }
+ return null;
+ }
+}
+
+class SDLGenericMotionListener_API12 implements View.OnGenericMotionListener {
+ // Generic Motion (mouse hover, joystick...) events go here
+ @Override
+ public boolean onGenericMotion(View v, MotionEvent event) {
+ float x, y;
+ int action;
+
+ switch ( event.getSource() ) {
+ case InputDevice.SOURCE_JOYSTICK:
+ return SDLControllerManager.handleJoystickMotionEvent(event);
+
+ case InputDevice.SOURCE_MOUSE:
+ action = event.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_SCROLL:
+ x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
+ y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
+ SDLActivity.onNativeMouse(0, action, x, y, false);
+ return true;
+
+ case MotionEvent.ACTION_HOVER_MOVE:
+ x = event.getX(0);
+ y = event.getY(0);
+
+ SDLActivity.onNativeMouse(0, action, x, y, false);
+ return true;
+
+ default:
+ break;
+ }
+ break;
+
+ default:
+ break;
+ }
+
+ // Event was not managed
+ return false;
+ }
+
+ public boolean supportsRelativeMouse() {
+ return false;
+ }
+
+ public boolean inRelativeMode() {
+ return false;
+ }
+
+ public boolean setRelativeMouseEnabled(boolean enabled) {
+ return false;
+ }
+
+ public void reclaimRelativeMouseModeIfNeeded()
+ {
+
+ }
+
+ public float getEventX(MotionEvent event) {
+ return event.getX(0);
+ }
+
+ public float getEventY(MotionEvent event) {
+ return event.getY(0);
+ }
+
+}
+
+class SDLGenericMotionListener_API24 extends SDLGenericMotionListener_API12 {
+ // Generic Motion (mouse hover, joystick...) events go here
+
+ private boolean mRelativeModeEnabled;
+
+ @Override
+ public boolean onGenericMotion(View v, MotionEvent event) {
+
+ // Handle relative mouse mode
+ if (mRelativeModeEnabled) {
+ if (event.getSource() == InputDevice.SOURCE_MOUSE) {
+ int action = event.getActionMasked();
+ if (action == MotionEvent.ACTION_HOVER_MOVE) {
+ float x = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
+ float y = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
+ SDLActivity.onNativeMouse(0, action, x, y, true);
+ return true;
+ }
+ }
+ }
+
+ // Event was not managed, call SDLGenericMotionListener_API12 method
+ return super.onGenericMotion(v, event);
+ }
+
+ @Override
+ public boolean supportsRelativeMouse() {
+ return true;
+ }
+
+ @Override
+ public boolean inRelativeMode() {
+ return mRelativeModeEnabled;
+ }
+
+ @Override
+ public boolean setRelativeMouseEnabled(boolean enabled) {
+ mRelativeModeEnabled = enabled;
+ return true;
+ }
+
+ @Override
+ public float getEventX(MotionEvent event) {
+ if (mRelativeModeEnabled) {
+ return event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
+ } else {
+ return event.getX(0);
+ }
+ }
+
+ @Override
+ public float getEventY(MotionEvent event) {
+ if (mRelativeModeEnabled) {
+ return event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
+ } else {
+ return event.getY(0);
+ }
+ }
+}
+
+class SDLGenericMotionListener_API26 extends SDLGenericMotionListener_API24 {
+ // Generic Motion (mouse hover, joystick...) events go here
+ private boolean mRelativeModeEnabled;
+
+ @Override
+ public boolean onGenericMotion(View v, MotionEvent event) {
+ float x, y;
+ int action;
+
+ switch ( event.getSource() ) {
+ case InputDevice.SOURCE_JOYSTICK:
+ return SDLControllerManager.handleJoystickMotionEvent(event);
+
+ case InputDevice.SOURCE_MOUSE:
+ // DeX desktop mouse cursor is a separate non-standard input type.
+ case InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN:
+ action = event.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_SCROLL:
+ x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
+ y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
+ SDLActivity.onNativeMouse(0, action, x, y, false);
+ return true;
+
+ case MotionEvent.ACTION_HOVER_MOVE:
+ x = event.getX(0);
+ y = event.getY(0);
+ SDLActivity.onNativeMouse(0, action, x, y, false);
+ return true;
+
+ default:
+ break;
+ }
+ break;
+
+ case InputDevice.SOURCE_MOUSE_RELATIVE:
+ action = event.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_SCROLL:
+ x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
+ y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
+ SDLActivity.onNativeMouse(0, action, x, y, false);
+ return true;
+
+ case MotionEvent.ACTION_HOVER_MOVE:
+ x = event.getX(0);
+ y = event.getY(0);
+ SDLActivity.onNativeMouse(0, action, x, y, true);
+ return true;
+
+ default:
+ break;
+ }
+ break;
+
+ default:
+ break;
+ }
+
+ // Event was not managed
+ return false;
+ }
+
+ @Override
+ public boolean supportsRelativeMouse() {
+ return (!SDLActivity.isDeXMode() || Build.VERSION.SDK_INT >= 27 /* Android 8.1 (O_MR1) */);
+ }
+
+ @Override
+ public boolean inRelativeMode() {
+ return mRelativeModeEnabled;
+ }
+
+ @Override
+ public boolean setRelativeMouseEnabled(boolean enabled) {
+ if (!SDLActivity.isDeXMode() || Build.VERSION.SDK_INT >= 27 /* Android 8.1 (O_MR1) */) {
+ if (enabled) {
+ SDLActivity.getContentView().requestPointerCapture();
+ } else {
+ SDLActivity.getContentView().releasePointerCapture();
+ }
+ mRelativeModeEnabled = enabled;
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public void reclaimRelativeMouseModeIfNeeded()
+ {
+ if (mRelativeModeEnabled && !SDLActivity.isDeXMode()) {
+ SDLActivity.getContentView().requestPointerCapture();
+ }
+ }
+
+ @Override
+ public float getEventX(MotionEvent event) {
+ // Relative mouse in capture mode will only have relative for X/Y
+ return event.getX(0);
+ }
+
+ @Override
+ public float getEventY(MotionEvent event) {
+ // Relative mouse in capture mode will only have relative for X/Y
+ return event.getY(0);
+ }
+}
diff --git a/app/src/main/engine/org/love2d/sdl/SDLSurface.java b/app/src/main/engine/org/love2d/sdl/SDLSurface.java
new file mode 100644
index 000000000..f0c0e0fb1
--- /dev/null
+++ b/app/src/main/engine/org/love2d/sdl/SDLSurface.java
@@ -0,0 +1,405 @@
+package org.love2d.sdl;
+
+
+import android.content.Context;
+import android.content.pm.ActivityInfo;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.os.Build;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.Display;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.View;
+import android.view.WindowManager;
+
+
+/**
+ SDLSurface. This is what we draw on, so we need to know when it's created
+ in order to do anything useful.
+
+ Because of this, that's where we set up the SDL thread
+*/
+public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback,
+ View.OnKeyListener, View.OnTouchListener, SensorEventListener {
+
+ // Sensors
+ protected SensorManager mSensorManager;
+ protected Display mDisplay;
+
+ // Keep track of the surface size to normalize touch events
+ protected float mWidth, mHeight;
+
+ // Is SurfaceView ready for rendering
+ public boolean mIsSurfaceReady;
+
+ // Startup
+ public SDLSurface(Context context) {
+ super(context);
+ getHolder().addCallback(this);
+
+ setFocusable(true);
+ setFocusableInTouchMode(true);
+ requestFocus();
+ setOnKeyListener(this);
+ setOnTouchListener(this);
+
+ mDisplay = ((WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
+ mSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
+
+ setOnGenericMotionListener(SDLActivity.getMotionListener());
+
+ // Some arbitrary defaults to avoid a potential division by zero
+ mWidth = 1.0f;
+ mHeight = 1.0f;
+
+ mIsSurfaceReady = false;
+ }
+
+ public void handlePause() {
+ enableSensor(Sensor.TYPE_ACCELEROMETER, false);
+ }
+
+ public void handleResume() {
+ setFocusable(true);
+ setFocusableInTouchMode(true);
+ requestFocus();
+ setOnKeyListener(this);
+ setOnTouchListener(this);
+ enableSensor(Sensor.TYPE_ACCELEROMETER, true);
+ }
+
+ public Surface getNativeSurface() {
+ return getHolder().getSurface();
+ }
+
+ // Called when we have a valid drawing surface
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ Log.v("SDL", "surfaceCreated()");
+ SDLActivity.onNativeSurfaceCreated();
+ }
+
+ // Called when we lose the surface
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ Log.v("SDL", "surfaceDestroyed()");
+
+ // Transition to pause, if needed
+ SDLActivity.mNextNativeState = SDLActivity.NativeState.PAUSED;
+ SDLActivity.handleNativeState();
+
+ mIsSurfaceReady = false;
+ SDLActivity.onNativeSurfaceDestroyed();
+ }
+
+ // Called when the surface is resized
+ @Override
+ public void surfaceChanged(SurfaceHolder holder,
+ int format, int width, int height) {
+ Log.v("SDL", "surfaceChanged()");
+
+ if (SDLActivity.mSingleton == null) {
+ return;
+ }
+
+ mWidth = width;
+ mHeight = height;
+ int nDeviceWidth = width;
+ int nDeviceHeight = height;
+ try
+ {
+ if (Build.VERSION.SDK_INT >= 17 /* Android 4.2 (JELLY_BEAN_MR1) */) {
+ DisplayMetrics realMetrics = new DisplayMetrics();
+ mDisplay.getRealMetrics( realMetrics );
+ nDeviceWidth = realMetrics.widthPixels;
+ nDeviceHeight = realMetrics.heightPixels;
+ }
+ } catch(Exception ignored) {
+ }
+
+ synchronized(SDLActivity.getContext()) {
+ // In case we're waiting on a size change after going fullscreen, send a notification.
+ SDLActivity.getContext().notifyAll();
+ }
+
+ Log.v("SDL", "Window size: " + width + "x" + height);
+ Log.v("SDL", "Device size: " + nDeviceWidth + "x" + nDeviceHeight);
+ SDLActivity.nativeSetScreenResolution(width, height, nDeviceWidth, nDeviceHeight, mDisplay.getRefreshRate());
+ SDLActivity.onNativeResize();
+
+ // Prevent a screen distortion glitch,
+ // for instance when the device is in Landscape and a Portrait App is resumed.
+ boolean skip = false;
+ int requestedOrientation = SDLActivity.mSingleton.getRequestedOrientation();
+
+ if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT) {
+ if (mWidth > mHeight) {
+ skip = true;
+ }
+ } else if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) {
+ if (mWidth < mHeight) {
+ skip = true;
+ }
+ }
+
+ // Special Patch for Square Resolution: Black Berry Passport
+ if (skip) {
+ double min = Math.min(mWidth, mHeight);
+ double max = Math.max(mWidth, mHeight);
+
+ if (max / min < 1.20) {
+ Log.v("SDL", "Don't skip on such aspect-ratio. Could be a square resolution.");
+ skip = false;
+ }
+ }
+
+ // Don't skip in MultiWindow.
+ if (skip) {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ if (SDLActivity.mSingleton.isInMultiWindowMode()) {
+ Log.v("SDL", "Don't skip in Multi-Window");
+ skip = false;
+ }
+ }
+ }
+
+ if (skip) {
+ Log.v("SDL", "Skip .. Surface is not ready.");
+ mIsSurfaceReady = false;
+ return;
+ }
+
+ /* If the surface has been previously destroyed by onNativeSurfaceDestroyed, recreate it here */
+ SDLActivity.onNativeSurfaceChanged();
+
+ /* Surface is ready */
+ mIsSurfaceReady = true;
+
+ SDLActivity.mNextNativeState = SDLActivity.NativeState.RESUMED;
+ SDLActivity.handleNativeState();
+ }
+
+ // Key events
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ return SDLActivity.handleKeyEvent(v, keyCode, event, null);
+ }
+
+ // Touch events
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ /* Ref: http://developer.android.com/training/gestures/multi.html */
+ int touchDevId = event.getDeviceId();
+ final int pointerCount = event.getPointerCount();
+ int action = event.getActionMasked();
+ int pointerFingerId;
+ int i = -1;
+ float x,y,p;
+
+ /*
+ * Prevent id to be -1, since it's used in SDL internal for synthetic events
+ * Appears when using Android emulator, eg:
+ * adb shell input mouse tap 100 100
+ * adb shell input touchscreen tap 100 100
+ */
+ if (touchDevId < 0) {
+ touchDevId -= 1;
+ }
+
+ // 12290 = Samsung DeX mode desktop mouse
+ // 12290 = 0x3002 = 0x2002 | 0x1002 = SOURCE_MOUSE | SOURCE_TOUCHSCREEN
+ // 0x2 = SOURCE_CLASS_POINTER
+ if (event.getSource() == InputDevice.SOURCE_MOUSE || event.getSource() == (InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN)) {
+ int mouseButton = 1;
+ try {
+ Object object = event.getClass().getMethod("getButtonState").invoke(event);
+ if (object != null) {
+ mouseButton = (Integer) object;
+ }
+ } catch(Exception ignored) {
+ }
+
+ // We need to check if we're in relative mouse mode and get the axis offset rather than the x/y values
+ // if we are. We'll leverage our existing mouse motion listener
+ SDLGenericMotionListener_API12 motionListener = SDLActivity.getMotionListener();
+ x = motionListener.getEventX(event);
+ y = motionListener.getEventY(event);
+
+ SDLActivity.onNativeMouse(mouseButton, action, x, y, motionListener.inRelativeMode());
+ } else {
+ switch(action) {
+ case MotionEvent.ACTION_MOVE:
+ for (i = 0; i < pointerCount; i++) {
+ pointerFingerId = event.getPointerId(i);
+ x = event.getX(i) / mWidth;
+ y = event.getY(i) / mHeight;
+ p = event.getPressure(i);
+ if (p > 1.0f) {
+ // may be larger than 1.0f on some devices
+ // see the documentation of getPressure(i)
+ p = 1.0f;
+ }
+ SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
+ }
+ break;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_DOWN:
+ // Primary pointer up/down, the index is always zero
+ i = 0;
+ /* fallthrough */
+ case MotionEvent.ACTION_POINTER_UP:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ // Non primary pointer up/down
+ if (i == -1) {
+ i = event.getActionIndex();
+ }
+
+ pointerFingerId = event.getPointerId(i);
+ x = event.getX(i) / mWidth;
+ y = event.getY(i) / mHeight;
+ p = event.getPressure(i);
+ if (p > 1.0f) {
+ // may be larger than 1.0f on some devices
+ // see the documentation of getPressure(i)
+ p = 1.0f;
+ }
+ SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
+ break;
+
+ case MotionEvent.ACTION_CANCEL:
+ for (i = 0; i < pointerCount; i++) {
+ pointerFingerId = event.getPointerId(i);
+ x = event.getX(i) / mWidth;
+ y = event.getY(i) / mHeight;
+ p = event.getPressure(i);
+ if (p > 1.0f) {
+ // may be larger than 1.0f on some devices
+ // see the documentation of getPressure(i)
+ p = 1.0f;
+ }
+ SDLActivity.onNativeTouch(touchDevId, pointerFingerId, MotionEvent.ACTION_UP, x, y, p);
+ }
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ return true;
+ }
+
+ // Sensor events
+ public void enableSensor(int sensortype, boolean enabled) {
+ // TODO: This uses getDefaultSensor - what if we have >1 accels?
+ if (enabled) {
+ mSensorManager.registerListener(this,
+ mSensorManager.getDefaultSensor(sensortype),
+ SensorManager.SENSOR_DELAY_GAME, null);
+ } else {
+ mSensorManager.unregisterListener(this,
+ mSensorManager.getDefaultSensor(sensortype));
+ }
+ }
+
+ @Override
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {
+ // TODO
+ }
+
+ @Override
+ public void onSensorChanged(SensorEvent event) {
+ if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
+
+ // Since we may have an orientation set, we won't receive onConfigurationChanged events.
+ // We thus should check here.
+ int newOrientation;
+
+ float x, y;
+ switch (mDisplay.getRotation()) {
+ case Surface.ROTATION_90:
+ x = -event.values[1];
+ y = event.values[0];
+ newOrientation = SDLActivity.SDL_ORIENTATION_LANDSCAPE;
+ break;
+ case Surface.ROTATION_270:
+ x = event.values[1];
+ y = -event.values[0];
+ newOrientation = SDLActivity.SDL_ORIENTATION_LANDSCAPE_FLIPPED;
+ break;
+ case Surface.ROTATION_180:
+ x = -event.values[0];
+ y = -event.values[1];
+ newOrientation = SDLActivity.SDL_ORIENTATION_PORTRAIT_FLIPPED;
+ break;
+ case Surface.ROTATION_0:
+ default:
+ x = event.values[0];
+ y = event.values[1];
+ newOrientation = SDLActivity.SDL_ORIENTATION_PORTRAIT;
+ break;
+ }
+
+ if (newOrientation != SDLActivity.mCurrentOrientation) {
+ SDLActivity.mCurrentOrientation = newOrientation;
+ SDLActivity.onNativeOrientationChanged(newOrientation);
+ }
+
+ SDLActivity.onNativeAccel(-x / SensorManager.GRAVITY_EARTH,
+ y / SensorManager.GRAVITY_EARTH,
+ event.values[2] / SensorManager.GRAVITY_EARTH);
+
+
+ }
+ }
+
+ // Captured pointer events for API 26.
+ public boolean onCapturedPointerEvent(MotionEvent event)
+ {
+ int action = event.getActionMasked();
+
+ float x, y;
+ switch (action) {
+ case MotionEvent.ACTION_SCROLL:
+ x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
+ y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
+ SDLActivity.onNativeMouse(0, action, x, y, false);
+ return true;
+
+ case MotionEvent.ACTION_HOVER_MOVE:
+ case MotionEvent.ACTION_MOVE:
+ x = event.getX(0);
+ y = event.getY(0);
+ SDLActivity.onNativeMouse(0, action, x, y, true);
+ return true;
+
+ case MotionEvent.ACTION_BUTTON_PRESS:
+ case MotionEvent.ACTION_BUTTON_RELEASE:
+
+ // Change our action value to what SDL's code expects.
+ if (action == MotionEvent.ACTION_BUTTON_PRESS) {
+ action = MotionEvent.ACTION_DOWN;
+ } else { /* MotionEvent.ACTION_BUTTON_RELEASE */
+ action = MotionEvent.ACTION_UP;
+ }
+
+ x = event.getX(0);
+ y = event.getY(0);
+ int button = event.getButtonState();
+
+ SDLActivity.onNativeMouse(button, action, x, y, true);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/src/main/feature/library/GameSettings.kt b/app/src/main/feature/library/GameSettings.kt
index c79f8eca3..c5a119b11 100644
--- a/app/src/main/feature/library/GameSettings.kt
+++ b/app/src/main/feature/library/GameSettings.kt
@@ -136,6 +136,7 @@ import com.winlator.cmod.runtime.reshade.ReshadeCatalogEntry
import com.winlator.cmod.runtime.reshade.ReshadeDownloader
import com.winlator.cmod.runtime.reshade.ReshadeLoadout
import com.winlator.cmod.runtime.reshade.ReshadeManager
+import com.winlator.cmod.shared.theme.GameSettingsStyle
import com.winlator.cmod.runtime.wine.WineThemeManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -166,27 +167,26 @@ import com.winlator.cmod.shared.ui.widget.EnvVarsView
import com.winlator.cmod.shared.ui.widget.chasingBorder
import kotlin.math.roundToInt
-private val BgDeep = Color(0xFF11111C)
-private val SidebarBg = Color(0xFF11111C)
-private val ContentBg = Color(0xFF11111C)
-private val CardSurface = Color(0xFF1C1C2A)
-private val CardBorder = Color(0xFF2A2A3A)
-private val InputSurface = Color(0xFF171722)
-private val InputBorder = Color(0xFF2A2A3A)
-private val AccentBlue = Color(0xFF1A9FFF)
-private val TextPrimary = Color(0xFFF0F4FF)
-private val TextSecondary = Color(0xFF7A8FA8)
-private val TextDim = Color(0xFF6E7681)
-private val DividerColor = Color(0xFF2A2A3A)
-private val CheckBorder = Color(0xFF2A2A3A)
-private val SliderInactive = Color(0xFF21212A)
-private val ChipSurface = Color(0xFF171722)
-private val ChipBorder = Color(0xFF2A2A3A)
-private val DangerRed = Color(0xFFFF6B6B)
-private val WarningAmber = Color(0xFFFFB74D)
+private val BgDeep = GameSettingsStyle.BgDeep
+private val SidebarBg = GameSettingsStyle.SidebarBg
+private val ContentBg = GameSettingsStyle.ContentBg
+private val CardSurface = GameSettingsStyle.CardSurface
+private val CardBorder = GameSettingsStyle.CardBorder
+private val InputSurface = GameSettingsStyle.InputSurface
+private val InputBorder = GameSettingsStyle.InputBorder
+private val AccentBlue = GameSettingsStyle.AccentBlue
+private val TextPrimary = GameSettingsStyle.TextPrimary
+private val TextSecondary = GameSettingsStyle.TextSecondary
+private val TextDim = GameSettingsStyle.TextDim
+private val DividerColor = GameSettingsStyle.Divider
+private val CheckBorder = GameSettingsStyle.CheckBorder
+private val SliderInactive = GameSettingsStyle.SliderInactive
+private val ChipSurface = GameSettingsStyle.ChipSurface
+private val ChipBorder = GameSettingsStyle.ChipBorder
+private val DangerRed = GameSettingsStyle.DangerRed
+private val WarningAmber = GameSettingsStyle.WarningAmber
private val SelectableDriveLetters = ('D'..'Y').filter { it != 'E' }.map { "$it" }
-
-private val NavHighlight = Color(0xFF4FC3F7)
+private val NavHighlight = GameSettingsStyle.NavHighlight
@Stable
class GameSettingsNav {
diff --git a/app/src/main/feature/retro/DolphinCloudSync.kt b/app/src/main/feature/retro/DolphinCloudSync.kt
new file mode 100644
index 000000000..9ef05973f
--- /dev/null
+++ b/app/src/main/feature/retro/DolphinCloudSync.kt
@@ -0,0 +1,144 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import org.dolphinemu.dolphinemu.wn.DolphinEmulationActivity
+import java.io.File
+
+object DolphinCloudSync {
+ private fun userDir(context: Context) = File(context.filesDir, "dolphin-embed/User")
+
+ fun stagingDir(context: Context, cloudId: String): File =
+ File(context.filesDir, "dolphin-embed/cloud/$cloudId")
+
+ private fun pendingFile(context: Context) = File(context.filesDir, "dolphin-embed/.pending_cloud")
+
+ fun stage(context: Context, stageKey: String, gameId: String?): Boolean =
+ runCatching {
+ val user = userDir(context)
+ val staging = stagingDir(context, stageKey)
+ staging.deleteRecursively()
+ var any = false
+ fun copy(f: File) {
+ f.copyTo(File(staging, f.relativeTo(user).path).apply { parentFile?.mkdirs() }, overwrite = true)
+ any = true
+ }
+ fun copyTree(root: File, accept: (File) -> Boolean) {
+ if (!root.isDirectory) return
+ root.walkTopDown().filter { it.isFile && accept(it) }.forEach(::copy)
+ }
+ copyTree(File(user, "GC")) { it.extension.equals("gci", true) || it.name.equals("SRAM.raw", true) }
+ copyTree(File(user, "Wii/title")) { true }
+ if (!gameId.isNullOrBlank()) {
+ File(user, "StateSaves").listFiles()
+ ?.filter { it.isFile && it.name.startsWith(gameId) }
+ ?.forEach(::copy)
+ }
+ any
+ }.getOrDefault(false)
+
+ fun stagedFingerprint(context: Context, stageKey: String): String {
+ val staging = stagingDir(context, stageKey)
+ if (!staging.isDirectory) return ""
+ val md = java.security.MessageDigest.getInstance("SHA-256")
+ staging.walkTopDown().filter { it.isFile }.sortedBy { it.path }.forEach { f ->
+ md.update("${f.relativeTo(staging).path}:${f.length()}:${f.lastModified()}".toByteArray())
+ }
+ return md.digest().joinToString("") { "%02x".format(it) }
+ }
+
+ fun refreshForBackup(context: Context, systemId: String, gameName: String) {
+ val system = RetroSystems.fromId(systemId) ?: return
+ if (!RetroCoreManager.usesDolphinCore(system)) return
+ val stageKey = RetroSaveStates.cloudGameId(system.id, gameName)
+ val gameId =
+ androidx.preference.PreferenceManager.getDefaultSharedPreferences(context)
+ .getString("retro_gc_gameid_$stageKey", "")
+ stage(context, stageKey, gameId)
+ }
+
+ fun applyStaged(context: Context, cloudId: String) {
+ runCatching {
+ val user = userDir(context)
+ val staging = stagingDir(context, cloudId)
+ if (!staging.isDirectory) return
+ staging.walkTopDown().filter { it.isFile }.forEach { f ->
+ val rel = f.relativeTo(staging).path
+ f.copyTo(File(user, rel).apply { parentFile?.mkdirs() }, overwrite = true)
+ }
+ }
+ }
+
+ fun localTimestamp(context: Context, cloudId: String): Long {
+ val staging = stagingDir(context, cloudId)
+ if (!staging.isDirectory) return 0L
+ return staging.walkTopDown().filter { it.isFile }.maxOfOrNull { it.lastModified() } ?: 0L
+ }
+
+ fun writePending(context: Context, cloudId: String, gameName: String, fingerprint: String) {
+ runCatching { pendingFile(context).writeText("$cloudId\n$gameName\n$fingerprint") }
+ }
+
+ fun stageAndQueue(activity: android.app.Activity) {
+ val intent = activity.intent
+ val systemId = intent.getStringExtra(DolphinEmulationActivity.EXTRA_SYSTEM_ID)
+ val system = RetroSystems.fromId(systemId) ?: return
+ val gameName = intent.getStringExtra(DolphinEmulationActivity.EXTRA_GAME_NAME) ?: return
+ val shortcutPath = intent.getStringExtra(DolphinEmulationActivity.EXTRA_SHORTCUT_PATH) ?: return
+ val enabled =
+ runCatching {
+ val cm = com.winlator.cmod.runtime.container.ContainerManager(activity)
+ val cid = intent.getIntExtra(DolphinEmulationActivity.EXTRA_CONTAINER_ID, 0)
+ val container =
+ if (cid == com.winlator.cmod.runtime.container.ContainerManager.RETRO_CONTAINER_ID) {
+ cm.retroContainer
+ } else {
+ cm.getContainerById(cid)
+ }
+ container?.let { com.winlator.cmod.runtime.container.Shortcut(it, java.io.File(shortcutPath)) }
+ ?.getExtra("cloud_sync_enabled", "1") != "0"
+ }.getOrDefault(true)
+ if (!enabled) {
+ android.util.Log.i("WnDolphin", "cloud stage skipped (sync disabled)")
+ return
+ }
+ val stageKey = RetroSaveStates.cloudGameId(system.id, gameName)
+ val cloudId =
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.customGameId(
+ intent.getIntExtra(DolphinEmulationActivity.EXTRA_CONTAINER_ID, 0),
+ java.io.File(shortcutPath).name,
+ )
+ val gameId = DolphinEmulationActivity.currentGameId
+ androidx.preference.PreferenceManager.getDefaultSharedPreferences(activity)
+ .edit().putString("retro_gc_gameid_$stageKey", gameId).apply()
+ if (!stage(activity, stageKey, gameId)) {
+ android.util.Log.i("WnDolphin", "cloud stage found no save files (gameId=$gameId)")
+ return
+ }
+ val fingerprint = stagedFingerprint(activity, stageKey)
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(activity)
+ if (fingerprint == prefs.getString("retro_cloud_fp_$cloudId", null)) {
+ android.util.Log.i("WnDolphin", "cloud stage unchanged, skip upload id=$cloudId")
+ return
+ }
+ writePending(activity, cloudId, gameName, fingerprint)
+ android.util.Log.i("WnDolphin", "cloud staged id=$cloudId gameId=$gameId")
+ }
+
+ data class Pending(val cloudId: String, val gameName: String, val fingerprint: String)
+
+ fun peekPending(context: Context): Pending? =
+ runCatching {
+ val f = pendingFile(context)
+ if (!f.isFile) return null
+ val lines = f.readText().split('\n')
+ if (lines.size >= 2 && lines[0].isNotBlank()) {
+ Pending(lines[0], lines[1], lines.getOrElse(2) { "" })
+ } else {
+ null
+ }
+ }.getOrNull()
+
+ fun clearPending(context: Context) {
+ runCatching { pendingFile(context).delete() }
+ }
+}
diff --git a/app/src/main/feature/retro/DolphinEmbedLaunch.kt b/app/src/main/feature/retro/DolphinEmbedLaunch.kt
new file mode 100644
index 000000000..d7b97227c
--- /dev/null
+++ b/app/src/main/feature/retro/DolphinEmbedLaunch.kt
@@ -0,0 +1,193 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.content.Intent
+import com.winlator.cmod.R
+import com.winlator.cmod.runtime.container.Shortcut
+import org.dolphinemu.dolphinemu.wn.DolphinEmulationActivity
+import org.dolphinemu.dolphinemu.wn.DolphinNetplay
+
+object DolphinEmbedLaunch {
+ const val DRIVER_PREF = "wn.gc.driver"
+
+ private fun syncCloudSaves(context: Context, shortcut: Shortcut) {
+ if (context !is android.app.Activity) return
+ if (shortcut.getExtra("cloud_sync_enabled", "1") == "0") return
+ val system = RetroShortcuts.systemForShortcut(shortcut) ?: return
+ val gameName = shortcut.getExtra("custom_name", shortcut.name)
+ val stageKey = RetroSaveStates.cloudGameId(system.id, gameName)
+ val cloudId =
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.customGameId(
+ shortcut.container.id,
+ shortcut.file.name,
+ )
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context)
+ runCatching {
+ kotlinx.coroutines.runBlocking {
+ kotlinx.coroutines.withTimeout(12_000L) {
+ val entries =
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.listGoogleHistory(
+ context,
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.GameSource.CUSTOM,
+ cloudId,
+ com.winlator.cmod.feature.sync.google.GoogleAuthMode.RESUME,
+ )
+ val latest = entries.maxByOrNull { it.timestampMs } ?: return@withTimeout
+ val localTs = DolphinCloudSync.localTimestamp(context, stageKey)
+ val mark = prefs.getLong("retro_cloud_mark_$cloudId", 0L)
+ val restore: suspend () -> Unit = {
+ val result =
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.restoreFromGoogle(
+ context,
+ latest,
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.GameSource.CUSTOM,
+ cloudId,
+ com.winlator.cmod.feature.sync.google.GoogleAuthMode.RESUME,
+ customSaveDir = DolphinCloudSync.stagingDir(context, stageKey),
+ )
+ if (result.success) {
+ DolphinCloudSync.applyStaged(context, stageKey)
+ prefs.edit().putLong("retro_cloud_mark_$cloudId", latest.timestampMs).apply()
+ }
+ }
+ if (localTs == 0L) {
+ restore()
+ } else if (latest.timestampMs > localTs + 120_000L && latest.timestampMs > mark) {
+ val useCloud = askCloudConflict(context, gameName)
+ if (useCloud) {
+ restore()
+ } else {
+ prefs.edit().putLong("retro_cloud_mark_$cloudId", latest.timestampMs).apply()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun askCloudConflict(activity: android.app.Activity, gameName: String): Boolean {
+ val latch = java.util.concurrent.CountDownLatch(1)
+ val useCloud = java.util.concurrent.atomic.AtomicBoolean(false)
+ activity.runOnUiThread {
+ androidx.appcompat.app.AlertDialog.Builder(activity)
+ .setTitle(activity.getString(R.string.retro_lr_cloud_save))
+ .setMessage(activity.getString(R.string.retro_lr_cloud_conflict_message, gameName))
+ .setCancelable(false)
+ .setPositiveButton(activity.getString(R.string.retro_lr_use_cloud_save)) { _, _ ->
+ useCloud.set(true)
+ latch.countDown()
+ }
+ .setNegativeButton(activity.getString(R.string.retro_scr_keep_local_save)) { _, _ ->
+ latch.countDown()
+ }
+ .show()
+ }
+ latch.await()
+ return useCloud.get()
+ }
+
+ private fun gcProcess(context: Context): android.app.ActivityManager.RunningAppProcessInfo? =
+ runCatching {
+ val am = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager
+ am.runningAppProcesses?.firstOrNull { it.processName.endsWith(":gc") }
+ }.getOrNull()
+
+ fun launch(context: Context, shortcut: Shortcut) {
+ val romPath = RetroShortcuts.romPath(shortcut)
+ val newTask = context !is android.app.Activity
+ kotlin.concurrent.thread(name = "WnDolphinLaunch") {
+ syncCloudSaves(context, shortcut)
+ val intent = launchIntent(context, shortcut)
+ if (newTask) intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ val proc = gcProcess(context)
+ if (proc != null) {
+ val marker = java.io.File(context.filesDir, "dolphin-embed/.running_rom")
+ val running = runCatching { marker.takeIf { it.isFile }?.readText() }.getOrNull()
+ if (running != romPath) {
+ if (running != null) {
+ android.util.Log.i("WnDolphin", "killing running :gc pid=" + proc.pid)
+ android.os.Process.killProcess(proc.pid)
+ }
+ val deadline = android.os.SystemClock.uptimeMillis() + 12000
+ while (android.os.SystemClock.uptimeMillis() < deadline && gcProcess(context) != null) {
+ Thread.sleep(150)
+ }
+ }
+ }
+ android.os.Handler(android.os.Looper.getMainLooper()).post {
+ runCatching { context.startActivity(intent) }
+ }
+ }
+ }
+
+ fun launchIntent(
+ context: Context,
+ shortcut: Shortcut,
+ ): Intent {
+ val system = RetroShortcuts.systemForShortcut(shortcut)
+ val sysId = system?.id ?: RetroSystems.GAMECUBE.id
+ val vars = RetroShortcuts.resolvedCoreVariables(context, shortcut)
+ applyGpuDriver(context, vars)
+ vars["wn_touch"] =
+ shortcut.getExtra(RetroShortcuts.KEY_TOUCH_CONTROLS).ifEmpty {
+ if (RetroDefaults.touchControls(context, sysId)) "1" else "0"
+ }
+ vars["wn_audio"] =
+ shortcut.getExtra(RetroShortcuts.KEY_AUDIO).ifEmpty {
+ if (RetroDefaults.audio(context, sysId)) "1" else "0"
+ }
+ vars["wn_hud"] = if (RetroHudSupport.hudEnabled(context, shortcut, sysId)) "1" else "0"
+ vars["wn_adaptive"] =
+ shortcut.getExtra(RetroShortcuts.KEY_ADAPTIVE_STICKS).ifEmpty {
+ if (RetroDefaults.adaptiveSticks(context, sysId)) "1" else "0"
+ }
+ val netplayArmed = RetroDefaults.netplayEnabled(context, sysId)
+ val netplayHost = RetroDefaults.netplayHostMode(context, sysId)
+ val netplayTraversal = RetroDefaults.netplayTraversal(context, sysId)
+ val netplayAddress = RetroDefaults.netplayHost(context, sysId)
+ val netplayHostCode = RetroDefaults.netplayHostCode(context, sysId)
+ val netplayPort = RetroDefaults.netplayPort(context, sysId)
+ val netplayNick = RetroDefaults.netplayPlayerName(context)
+ if (netplayArmed) RetroDefaults.setNetplayEnabled(context, sysId, false)
+ return Intent(context, DolphinEmulationActivity::class.java).apply {
+ putExtra(DolphinEmulationActivity.EXTRA_ROM_PATH, RetroShortcuts.romPath(shortcut))
+ putExtra(DolphinEmulationActivity.EXTRA_SYSTEM_ID, system?.id)
+ putExtra(
+ DolphinEmulationActivity.EXTRA_GAME_NAME,
+ shortcut.getExtra("custom_name", shortcut.name),
+ )
+ putExtra(DolphinEmulationActivity.EXTRA_SHORTCUT_PATH, shortcut.file.absolutePath)
+ putExtra(DolphinEmulationActivity.EXTRA_CONTAINER_ID, shortcut.container.id)
+ putExtra(DolphinEmulationActivity.EXTRA_VARIABLES, vars)
+ if (netplayArmed) {
+ putExtra(DolphinNetplay.EXTRA_ENABLE, true)
+ putExtra(DolphinNetplay.EXTRA_HOST, netplayHost)
+ putExtra(DolphinNetplay.EXTRA_TRAVERSAL, netplayTraversal)
+ putExtra(DolphinNetplay.EXTRA_ADDRESS, netplayAddress)
+ putExtra(DolphinNetplay.EXTRA_HOST_CODE, netplayHostCode)
+ putExtra(DolphinNetplay.EXTRA_PORT, netplayPort)
+ putExtra(DolphinNetplay.EXTRA_NICKNAME, netplayNick)
+ }
+ if (RetroAchievementsManager.isEnabled(context) && RetroAchievementsManager.isLoggedIn(context)) {
+ putExtra(DolphinEmulationActivity.EXTRA_RA_ENABLED, true)
+ putExtra(DolphinEmulationActivity.EXTRA_RA_USER, RetroAchievementsManager.username(context) ?: "")
+ putExtra(DolphinEmulationActivity.EXTRA_RA_TOKEN, RetroAchievementsManager.apiToken(context) ?: "")
+ putExtra(DolphinEmulationActivity.EXTRA_RA_HARDCORE, RetroAchievementsManager.isHardcorePreferred(context))
+ }
+ }
+ }
+
+ private fun applyGpuDriver(
+ context: Context,
+ vars: HashMap,
+ ) {
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context)
+ val driverId = (prefs.getString(DRIVER_PREF, "") ?: "").trim()
+ if (driverId.isEmpty() || driverId.equals("system", ignoreCase = true)) return
+ val driver =
+ com.armsx2.CustomDriver.listInstalled(context).firstOrNull { it.id == driverId }
+ ?: return
+ vars["dolphin_gpu_driver"] = driver.driverDir.absolutePath
+ vars["dolphin_gpu_driver_lib"] = driver.libraryName
+ }
+}
diff --git a/app/src/main/feature/retro/DolphinGameOverlay.kt b/app/src/main/feature/retro/DolphinGameOverlay.kt
new file mode 100644
index 000000000..5d25a101a
--- /dev/null
+++ b/app/src/main/feature/retro/DolphinGameOverlay.kt
@@ -0,0 +1,628 @@
+package com.winlator.cmod.feature.retro
+
+import android.widget.FrameLayout
+import android.widget.Toast
+import androidx.activity.addCallback
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Home
+import androidx.compose.material.icons.outlined.Person
+import androidx.compose.material.icons.outlined.Wifi
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.platform.ComposeView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.winlator.cmod.R
+import com.winlator.cmod.shared.theme.GameSettingsStyle
+import com.winlator.cmod.runtime.container.ContainerManager
+import com.winlator.cmod.runtime.container.Shortcut
+import com.winlator.cmod.runtime.display.ui.FrameRating
+import com.winlator.cmod.shared.theme.WinNativeTheme
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import org.dolphinemu.dolphinemu.wn.DolphinEmulationActivity
+import org.dolphinemu.dolphinemu.wn.DolphinHost
+import org.dolphinemu.dolphinemu.wn.DolphinNetplay
+import java.io.File
+
+object DolphinGameOverlay {
+ private const val SLOT_COUNT = 4
+
+ private fun dolphinAnyController(): Boolean =
+ android.view.InputDevice.getDeviceIds().any {
+ com.winlator.cmod.runtime.input.controls.ExternalController.isGameController(
+ android.view.InputDevice.getDevice(it),
+ )
+ }
+
+ private fun buildDolphinNetplay(activity: DolphinEmulationActivity): List =
+ buildList {
+ add(
+ RetroMenuEntry.Action(
+ label =
+ activity.getString(
+ if (DolphinNetplayHud.hosting) {
+ R.string.retro_netplay_hosting_room
+ } else {
+ R.string.retro_netplay_in_room
+ },
+ ),
+ icon = Icons.Outlined.Wifi,
+ active = true,
+ onClick = {},
+ ),
+ )
+ DolphinNetplayHud.hostCode?.let { code ->
+ add(
+ RetroMenuEntry.Action(
+ label = "${activity.getString(R.string.retro_netplay_host_code)}: $code",
+ icon = Icons.Outlined.Wifi,
+ onClick = {},
+ ),
+ )
+ }
+ val members = DolphinNetplayHud.members
+ if (members.isEmpty()) {
+ add(
+ RetroMenuEntry.Action(
+ label = activity.getString(R.string.retro_netplay_waiting_players),
+ icon = Icons.Outlined.Person,
+ onClick = {},
+ ),
+ )
+ } else {
+ members.forEach { name ->
+ add(RetroMenuEntry.Action(label = name, icon = Icons.Outlined.Person, onClick = {}))
+ }
+ }
+ }
+
+ @Composable
+ private fun DolphinNetplayBanner(hosting: Boolean, hostCode: String?, members: List) {
+ AnimatedVisibility(
+ visible = hosting && members.size < 2,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Box(
+ Modifier.fillMaxSize().padding(top = 56.dp),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ val parts =
+ buildList {
+ add(stringResource(R.string.retro_netplay_hosting_room))
+ if (hostCode != null) {
+ add("${stringResource(R.string.retro_netplay_host_code)}: $hostCode")
+ }
+ add(stringResource(R.string.retro_netplay_waiting_players))
+ }
+ Text(
+ parts.joinToString(" · "),
+ color = GameSettingsStyle.TextPrimary,
+ fontSize = 13.sp,
+ modifier =
+ Modifier
+ .background(Color(0xF0121824), RoundedCornerShape(10.dp))
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ )
+ }
+ }
+ }
+
+ fun install() {
+ DolphinHost.attachOverlay = { activity ->
+ if (activity is DolphinEmulationActivity) attach(activity)
+ }
+ DolphinHost.onStageSaves = { activity -> DolphinCloudSync.stageAndQueue(activity) }
+ val main = android.os.Handler(android.os.Looper.getMainLooper())
+ DolphinHost.onNetplayStatus = { hosting, code, members ->
+ main.post { DolphinNetplayHud.update(hosting, code, members) }
+ }
+ }
+
+ private fun loadShortcut(activity: DolphinEmulationActivity): Shortcut? {
+ val containerId = activity.intent.getIntExtra(DolphinEmulationActivity.EXTRA_CONTAINER_ID, -1)
+ val path = activity.intent.getStringExtra(DolphinEmulationActivity.EXTRA_SHORTCUT_PATH)
+ if (containerId < 0 || path.isNullOrBlank()) return null
+ val file = File(path)
+ if (!file.isFile) return null
+ return runCatching {
+ val cm = ContainerManager(activity)
+ val container =
+ if (containerId == ContainerManager.RETRO_CONTAINER_ID) cm.retroContainer else cm.getContainerById(containerId)
+ container?.let { Shortcut(it, file) }
+ }.getOrNull()
+ }
+
+ private fun attach(activity: DolphinEmulationActivity) {
+ val root = activity.findViewById(android.R.id.content) ?: return
+ val system =
+ RetroSystems.fromId(activity.intent.getStringExtra(DolphinEmulationActivity.EXTRA_SYSTEM_ID))
+ ?: RetroSystems.GAMECUBE
+ var shortcut: Shortcut? = null
+ val pendingExtras = linkedMapOf()
+ val menu = RetroMenuController()
+ val dolphinScreen = mutableStateOf(null)
+ lateinit var input: RetroInputView
+ val vars = DolphinEmulationActivity.currentVariables
+ var touchControls = vars["wn_touch"] != "0"
+ var adaptiveSticks = vars["wn_adaptive"] == "1"
+ var audioOn = vars["wn_audio"] != "0"
+ var hudOn = vars["wn_hud"] == "1"
+ var savesLoadMode = false
+ var frameRating: FrameRating? = null
+ var hudStyle = RetroHudSupport.loadGlobalHudStyle(activity)
+ var hudElements = RetroHudSupport.loadGlobalHudElements(activity)
+ @Suppress("OPT_IN_USAGE")
+ GlobalScope.launch(Dispatchers.IO) {
+ val sc = loadShortcut(activity)
+ activity.runOnUiThread {
+ shortcut = sc
+ if (sc != null && pendingExtras.isNotEmpty()) {
+ val queued = pendingExtras.toMap()
+ pendingExtras.clear()
+ GlobalScope.launch(Dispatchers.IO) {
+ runCatching {
+ queued.forEach { (k, v) -> sc.putExtra(k, v) }
+ sc.saveData()
+ }
+ }
+ }
+ }
+ }
+
+ val gameName =
+ activity.intent.getStringExtra(DolphinEmulationActivity.EXTRA_GAME_NAME) ?: "game"
+ val stageKey = RetroSaveStates.cloudGameId(system.id, gameName)
+ val cloudId =
+ activity.intent.getStringExtra(DolphinEmulationActivity.EXTRA_SHORTCUT_PATH)?.let { path ->
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.customGameId(
+ activity.intent.getIntExtra(DolphinEmulationActivity.EXTRA_CONTAINER_ID, 0),
+ java.io.File(path).name,
+ )
+ } ?: stageKey
+
+ fun cloudSyncEnabled(): Boolean = shortcut?.getExtra("cloud_sync_enabled", "1") != "0"
+
+ fun stageCloudBackup(onDone: (() -> Unit)? = null) {
+ if (!cloudSyncEnabled()) {
+ onDone?.let { activity.runOnUiThread(it) }
+ return
+ }
+ val gameId = DolphinEmulationActivity.currentGameId
+ @Suppress("OPT_IN_USAGE")
+ GlobalScope.launch(Dispatchers.IO) {
+ if (DolphinCloudSync.stage(activity, stageKey, gameId)) {
+ val fp = DolphinCloudSync.stagedFingerprint(activity, stageKey)
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(activity)
+ if (fp != prefs.getString("retro_cloud_fp_$cloudId", null)) {
+ DolphinCloudSync.writePending(activity, cloudId, gameName, fp)
+ }
+ }
+ onDone?.let { activity.runOnUiThread(it) }
+ }
+ }
+
+ fun persistExtra(key: String, value: String) {
+ val sc = shortcut
+ if (sc == null) {
+ pendingExtras[key] = value
+ return
+ }
+ @Suppress("OPT_IN_USAGE")
+ GlobalScope.launch(Dispatchers.IO) {
+ runCatching {
+ sc.putExtra(key, value)
+ sc.saveData()
+ }
+ }
+ }
+
+ fun applyVar(key: String, value: String) {
+ activity.applyVariable(key, value)
+ persistExtra(RetroShortcuts.VAR_PREFIX + key, value)
+ }
+
+ fun showHud() {
+ var rating = frameRating
+ if (rating == null) {
+ rating = RetroHudSupport.createFrameRating(activity, "Vulkan")
+ frameRating = rating
+ RetroHudSupport.attachFrameRating(root, rating)
+ RetroHudSupport.applyStyle(rating, hudStyle, hudElements)
+ org.dolphinemu.dolphinemu.NativeLibrary.frameListener =
+ Runnable { frameRating?.recordGameFrame() }
+ }
+ rating.visibility = android.view.View.VISIBLE
+ rating.reset()
+ }
+
+ fun setHudVisible(value: Boolean) {
+ hudOn = value
+ activity.applyVariable("wn_hud", if (value) "1" else "0")
+ persistExtra(RetroShortcuts.KEY_HUD, if (value) "1" else "0")
+ if (value) showHud() else frameRating?.visibility = android.view.View.GONE
+ }
+
+ val listener =
+ object : RetroInputView.Listener {
+ override fun onButton(keyCode: Int, down: Boolean) {
+ if (!menu.visible) activity.sendOverlayButton(keyCode, down)
+ }
+
+ override fun onDpad(x: Float, y: Float) {
+ if (!menu.visible) activity.sendOverlayDpad(x, y)
+ }
+
+ override fun onStick(x: Float, y: Float) {
+ if (!menu.visible) activity.sendOverlayLeftStick(x, y)
+ }
+
+ override fun onRightStick(x: Float, y: Float) {
+ if (!menu.visible) activity.sendOverlayRightStick(x, y)
+ }
+
+ override fun onMenu() {
+ activity.runOnUiThread {
+ input.releaseAll()
+ menu.open()
+ }
+ }
+ }
+
+ input = RetroInputView(activity, listener, system)
+ input.hapticStrength =
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(activity)
+ .getFloat("retro_haptic_strength", 0.4f)
+ input.adaptiveSticks = adaptiveSticks
+ input.showL3R3 =
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(activity)
+ .getBoolean(RetroControlsMenu.l3r3PrefKey(system.id), true)
+ input.setCustomColors(RetroControlLayouts.loadColors(activity, system.id))
+ input.loadStickInversion()
+ var controllerConnected = dolphinAnyController()
+ var manualTouchOverride = false
+ fun touchEffective() = touchControls && (!controllerConnected || manualTouchOverride)
+ input.visibility = if (touchEffective()) android.view.View.VISIBLE else android.view.View.GONE
+ fun updateGameArea() {
+ val w = root.width.toFloat()
+ val h = root.height.toFloat()
+ if (w <= 0f || h <= 0f) return
+ val gameWidth = (h * 4f / 3f).coerceAtMost(w)
+ val left = (w - gameWidth) * 0.5f
+ val box = android.graphics.RectF(left, 0f, left + gameWidth, h)
+ input.setGameArea(box)
+ activity.setSurfaceBounds(if (touchEffective()) box else null)
+ }
+ root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> updateGameArea() }
+ root.post { updateGameArea() }
+ fun refreshDolphinController() {
+ val was = controllerConnected
+ controllerConnected = dolphinAnyController()
+ if (controllerConnected != was) manualTouchOverride = false
+ val show = touchEffective()
+ input.visibility = if (show) android.view.View.VISIBLE else android.view.View.GONE
+ updateGameArea()
+ if (show) input.post { updateGameArea(); input.relayout() }
+ }
+ activity.getSystemService(android.hardware.input.InputManager::class.java)
+ ?.registerInputDeviceListener(
+ object : android.hardware.input.InputManager.InputDeviceListener {
+ override fun onInputDeviceAdded(deviceId: Int) = refreshDolphinController()
+
+ override fun onInputDeviceRemoved(deviceId: Int) = refreshDolphinController()
+
+ override fun onInputDeviceChanged(deviceId: Int) = refreshDolphinController()
+ },
+ null,
+ )
+ root.addView(
+ input,
+ FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ ),
+ )
+
+ menu.tabs = RetroDrawerTabs.build(activity, includeNetplay = DolphinNetplay.active)
+ menu.entriesProvider = { pane ->
+ when (pane) {
+ null ->
+ buildMain(
+ activity,
+ menu,
+ hudOn,
+ system.id == RetroSystems.WII.id,
+ onAchievements = {
+ dolphinScreen.value = "achievements"
+ menu.close()
+ },
+ onHud = ::setHudVisible,
+ ) { load ->
+ savesLoadMode = load
+ menu.showPane(RetroPane.SAVES)
+ }
+ RetroPane.SAVES -> buildMemoryCards(activity, menu, savesLoadMode) { stageCloudBackup() }
+ RetroPane.NETWORK -> buildDolphinNetplay(activity)
+ RetroPane.DISPLAY -> buildDisplay(activity, menu, system, ::applyVar)
+ RetroPane.HUD ->
+ RetroHudSupport.buildHudEntries(
+ context = activity,
+ hudVisible = hudOn,
+ style = hudStyle,
+ elements = hudElements,
+ onMaster = { setHudVisible(it) },
+ onStyle = { next ->
+ hudStyle = next
+ frameRating?.let { RetroHudSupport.applyStyle(it, next, hudElements) }
+ RetroHudSupport.saveGlobalHudStyle(activity, next)
+ },
+ onElements = { next ->
+ hudElements = next
+ frameRating?.let { RetroHudSupport.applyStyle(it, hudStyle, next) }
+ RetroHudSupport.saveGlobalHudElements(activity, next)
+ },
+ onRebuild = { menu.rebuild() },
+ )
+ RetroPane.SOUND ->
+ listOf(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_lr_sound), checked = audioOn) { value ->
+ audioOn = value
+ applyVar("wn_audio", if (value) "1" else "0")
+ persistExtra(RetroShortcuts.KEY_AUDIO, if (value) "1" else "0")
+ menu.rebuild()
+ },
+ )
+ RetroPane.CONTROLS ->
+ RetroControlsMenu.build(
+ RetroControlsMenu.Host(
+ context = activity,
+ overlay = input,
+ menu = menu,
+ systemId = system.id,
+ touchControls = { touchControls },
+ onTouchControls = { value ->
+ touchControls = value
+ if (controllerConnected) manualTouchOverride = true
+ input.visibility =
+ if (touchEffective()) android.view.View.VISIBLE else android.view.View.GONE
+ updateGameArea()
+ persistExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (value) "1" else "0")
+ },
+ adaptiveSticks = { adaptiveSticks },
+ onAdaptiveSticks = { value ->
+ adaptiveSticks = value
+ persistExtra(RetroShortcuts.KEY_ADAPTIVE_STICKS, if (value) "1" else "0")
+ },
+ orientationLabel = { activity.getString(R.string.retro_lr_landscape) },
+ onCloseMenu = { menu.close() },
+ showStickInversion = true,
+ ),
+ )
+ else -> emptyList()
+ }
+ }
+ menu.bottomProvider = { buildBottom(activity, menu) { activity.stopGame() } }
+
+ val compose =
+ ComposeView(activity).apply {
+ elevation = 2000f
+ setContent {
+ WinNativeTheme {
+ Box(Modifier.fillMaxSize()) {
+ val screen by dolphinScreen
+ if (screen == "achievements") {
+ val dismiss = { dolphinScreen.value = null }
+ androidx.activity.compose.BackHandler(enabled = true) { dismiss() }
+ RetroAchievementsScreen(
+ systemId = system.id,
+ gameName = activity.intent.getStringExtra(
+ DolphinEmulationActivity.EXTRA_GAME_NAME,
+ ) ?: system.displayName,
+ romPath = activity.intent.getStringExtra(
+ DolphinEmulationActivity.EXTRA_ROM_PATH,
+ ).orEmpty(),
+ inSession = true,
+ onClose = dismiss,
+ floatingOverGame = true,
+ nativeJson = {
+ org.dolphinemu.dolphinemu.features.settings.model.AchievementModel
+ .getAchievementsJSON()
+ },
+ )
+ } else {
+ val hosting = DolphinNetplayHud.hosting
+ val code = DolphinNetplayHud.hostCode
+ val members = DolphinNetplayHud.members
+ LaunchedEffect(hosting, code, members) {
+ if (menu.visible && menu.pane == RetroPane.NETWORK) menu.rebuild()
+ }
+ DolphinNetplayBanner(hosting, code, members)
+ RetroDrawerMenu(menu)
+ }
+ }
+ }
+ }
+ }
+ root.addView(
+ compose,
+ FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ ),
+ )
+
+ if (hudOn) root.post { showHud() }
+
+ activity.onBackPressedDispatcher.addCallback(activity) {
+ when {
+ input.editMode -> input.finishEdit()
+ menu.visible -> menu.close()
+ else -> menu.open()
+ }
+ }
+ activity.menuButtonHandler = { if (menu.visible) menu.close() else menu.open() }
+ }
+
+ private fun buildMain(
+ activity: DolphinEmulationActivity,
+ menu: RetroMenuController,
+ hudOn: Boolean,
+ isWii: Boolean,
+ onAchievements: () -> Unit,
+ onHud: (Boolean) -> Unit,
+ openMemoryCards: (load: Boolean) -> Unit,
+ ): List =
+ buildList {
+ if (isWii) {
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_dolphin_wii_home), Icons.Outlined.Home) {
+ activity.pressWiiHome()
+ menu.close()
+ },
+ )
+ }
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_achievements), RetroDrawerIcons.Achievements) {
+ onAchievements()
+ },
+ )
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_lr_save_state), RetroDrawerIcons.Save) {
+ openMemoryCards(false)
+ },
+ )
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_lr_load_save_state), RetroDrawerIcons.Load) {
+ openMemoryCards(true)
+ },
+ )
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_lr_screenshot), RetroDrawerIcons.Hud) {
+ runCatching { org.dolphinemu.dolphinemu.NativeLibrary.SaveScreenShot() }
+ menu.close()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_lr_hud), checked = hudOn) { value ->
+ onHud(value)
+ menu.rebuild()
+ },
+ )
+ }
+
+ private fun buildMemoryCards(
+ activity: DolphinEmulationActivity,
+ menu: RetroMenuController,
+ loadMode: Boolean,
+ onSaved: () -> Unit,
+ ): List =
+ (1..SLOT_COUNT).map { slot ->
+ val ts = activity.stateSlotTime(slot)
+ RetroMenuEntry.SaveSlot(
+ slot = slot,
+ title = activity.getString(R.string.retro_lr_memory_card_slot, slot),
+ subtitle = RetroSaveStates.relativeTime(ts * 1000),
+ filled = ts > 0L,
+ onClick = {
+ if (loadMode) {
+ if (ts > 0L) {
+ activity.loadStateSlot(slot)
+ menu.close()
+ Toast
+ .makeText(activity, activity.getString(R.string.retro_lr_loaded_slot, slot), Toast.LENGTH_SHORT)
+ .show()
+ }
+ } else {
+ activity.saveStateSlot(slot)
+ onSaved()
+ Toast
+ .makeText(activity, activity.getString(R.string.retro_lr_saved_to_slot, slot), Toast.LENGTH_SHORT)
+ .show()
+ menu.rebuild()
+ }
+ },
+ onRename = {},
+ )
+ }
+
+ private fun buildDisplay(
+ activity: DolphinEmulationActivity,
+ menu: RetroMenuController,
+ system: RetroSystem,
+ applyVar: (String, String) -> Unit,
+ ): List =
+ buildList {
+ val vars = DolphinEmulationActivity.currentVariables
+ RetroCoreOptions.forSystem(system).forEach { option ->
+ val current = vars[option.key] ?: option.defaultValue
+ val index = option.values.indexOf(current).coerceAtLeast(0)
+ add(
+ RetroMenuEntry.Choice(
+ option.labelText(activity),
+ option.valueLabels,
+ index,
+ ) { next ->
+ applyVar(option.key, option.values[next])
+ menu.rebuild()
+ },
+ )
+ }
+ }
+
+ private fun buildBottom(
+ activity: DolphinEmulationActivity,
+ menu: RetroMenuController,
+ onExit: () -> Unit,
+ ): List =
+ listOf(
+ if (activity.gamePaused) {
+ RetroMenuEntry.Action(activity.getString(R.string.retro_lr_resume), RetroDrawerIcons.Resume, active = true) {
+ activity.resumeGame()
+ menu.close()
+ }
+ } else {
+ RetroMenuEntry.Action(activity.getString(R.string.retro_lr_pause), RetroDrawerIcons.Pause) {
+ activity.pauseGame()
+ menu.close()
+ }
+ },
+ RetroMenuEntry.Action(activity.getString(R.string.retro_lr_exit), RetroDrawerIcons.Exit, danger = true) {
+ onExit()
+ },
+ )
+}
+
+object DolphinNetplayHud {
+ var hosting by mutableStateOf(false)
+ private set
+ var hostCode by mutableStateOf(null)
+ private set
+ var members by mutableStateOf>(emptyList())
+ private set
+
+ fun update(hosting: Boolean, hostCode: String?, members: List) {
+ this.hosting = hosting
+ this.hostCode = hostCode
+ this.members = members
+ }
+}
diff --git a/app/src/main/feature/retro/DolphinNetplayUi.kt b/app/src/main/feature/retro/DolphinNetplayUi.kt
new file mode 100644
index 000000000..963aa0d42
--- /dev/null
+++ b/app/src/main/feature/retro/DolphinNetplayUi.kt
@@ -0,0 +1,135 @@
+package com.winlator.cmod.feature.retro
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.material3.Text
+import com.winlator.cmod.R
+import com.winlator.cmod.shared.theme.GameSettingsStyle
+import org.dolphinemu.dolphinemu.wn.DolphinNetplay
+
+@Composable
+fun DolphinNetplaySettingsSection(
+ systemId: String,
+ version: Int,
+ onChanged: () -> Unit,
+) {
+ val context = LocalContext.current
+ @Suppress("UNUSED_EXPRESSION")
+ version
+
+ val enabled = RetroDefaults.netplayEnabled(context, systemId)
+ val isHost = RetroDefaults.netplayHostMode(context, systemId)
+ val traversal = RetroDefaults.netplayTraversal(context, systemId)
+ val player = RetroDefaults.netplayPlayerName(context)
+ val host = RetroDefaults.netplayHost(context, systemId)
+ val hostCode = RetroDefaults.netplayHostCode(context, systemId)
+ val port = RetroDefaults.netplayPort(context, systemId)
+
+ RetroSettingGroup {
+ RetroSettingSwitch(
+ stringResource(R.string.retro_gs_netplay_enable),
+ enabled,
+ subtitle = stringResource(R.string.retro_gs_netplay_enable_subtitle),
+ ) {
+ RetroDefaults.setNetplayEnabled(context, systemId, it)
+ onChanged()
+ }
+
+ if (enabled) {
+ RetroSettingTextField(
+ stringResource(R.string.retro_netplay_player_name),
+ player,
+ stringResource(R.string.retro_netplay_player_name_hint),
+ ) {
+ RetroDefaults.setNetplayPlayerName(context, it)
+ onChanged()
+ }
+
+ RetroSettingDropdown(
+ stringResource(R.string.retro_netplay_launch_mode),
+ listOf(
+ stringResource(R.string.retro_netplay_mode_host),
+ stringResource(R.string.retro_netplay_mode_join),
+ ),
+ if (isHost) 0 else 1,
+ ) { index ->
+ RetroDefaults.setNetplayHostMode(context, systemId, index == 0)
+ onChanged()
+ }
+
+ RetroSettingDropdown(
+ stringResource(R.string.retro_netplay_connection),
+ listOf(
+ stringResource(R.string.retro_netplay_conn_direct),
+ stringResource(R.string.retro_netplay_conn_traversal),
+ ),
+ if (traversal) 1 else 0,
+ ) { index ->
+ RetroDefaults.setNetplayTraversal(context, systemId, index == 1)
+ onChanged()
+ }
+
+ if (!isHost && traversal) {
+ RetroNetplayEditField(
+ label = stringResource(R.string.retro_netplay_host_code),
+ value = hostCode,
+ placeholder = stringResource(R.string.retro_netplay_host_code_hint),
+ ) {
+ RetroDefaults.setNetplayHostCode(context, systemId, it)
+ onChanged()
+ }
+ } else if (!isHost) {
+ RetroNetplayEditField(
+ label = stringResource(R.string.retro_gs_netplay_host),
+ value = host,
+ placeholder = stringResource(R.string.retro_gs_netplay_host_hint),
+ ) {
+ RetroDefaults.setNetplayHost(context, systemId, it)
+ onChanged()
+ }
+ }
+
+ if (!traversal) {
+ RetroNetplayEditField(
+ label = stringResource(R.string.retro_gs_netplay_port),
+ value = port.toString(),
+ placeholder = DolphinNetplay.DEFAULT_PORT.toString(),
+ numeric = true,
+ ) { entered ->
+ val p = entered.toIntOrNull() ?: DolphinNetplay.DEFAULT_PORT
+ RetroDefaults.setNetplayPort(context, systemId, p)
+ onChanged()
+ }
+ }
+
+ val hint =
+ when {
+ isHost && traversal -> R.string.retro_netplay_dolphin_host_online_hint
+ isHost -> R.string.retro_netplay_dolphin_host_lan_hint
+ traversal -> R.string.retro_netplay_dolphin_join_online_hint
+ else -> R.string.retro_netplay_dolphin_join_lan_hint
+ }
+ Spacer(Modifier.height(8.dp))
+ Text(
+ stringResource(hint),
+ color = GameSettingsStyle.TextDim,
+ fontSize = 11.sp,
+ lineHeight = 15.sp,
+ )
+ Text(
+ stringResource(R.string.retro_netplay_dolphin_help),
+ color = GameSettingsStyle.TextDim,
+ fontSize = 11.sp,
+ lineHeight = 15.sp,
+ modifier = Modifier.padding(top = 6.dp),
+ )
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/Gen1CloudSync.kt b/app/src/main/feature/retro/Gen1CloudSync.kt
new file mode 100644
index 000000000..11874d4b0
--- /dev/null
+++ b/app/src/main/feature/retro/Gen1CloudSync.kt
@@ -0,0 +1,270 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.util.Log
+import androidx.preference.PreferenceManager
+import com.winlator.cmod.runtime.container.Shortcut
+import java.io.File
+
+/**
+ * Puts the 3D engine's saves into WinNative's cloud sync.
+ *
+ * The engine is not a libretro core, so none of its progress lives where the
+ * cloud backup looks. A libretro Game Boy game keeps an .srm and its save
+ * states in one directory per game, and that directory IS what gets uploaded.
+ * The engine instead writes Lua save files into its own LOVE save directory,
+ * shared by every Gen 1 game, and spread across two places:
+ *
+ * saves//slot*.lua the playthroughs themselves
+ * options.lua which slots exist, which is active, their names
+ *
+ * Both are needed. The slot registry is ONLY in options.lua -- the engine does
+ * not rediscover slot files by listing the directory (see ensureVersionSlots in
+ * src/core/SaveData.lua), so restoring the slot files alone would put the saves
+ * back on disk and leave the game unable to see them.
+ *
+ * Since the backup wants one directory per game and the engine's layout is
+ * neither one directory nor per-game, this stages a copy, exactly as the
+ * Dolphin path does for the same reason. The staging directory is what the
+ * cloud uploads and what a restore writes into; [stage] fills it before an
+ * upload and [applyStaged] copies it back afterwards.
+ */
+object Gen1CloudSync {
+ private const val TAG = "WnGen1Cloud"
+
+ /** Records the fingerprint of a staging copy this class made itself. */
+ private const val STAGE_MARKER = ".winnative-staged"
+
+ /**
+ * The engine's LOVE save directory. Fixed by LOVE from the game's identity
+ * rather than chosen here; [Gen1EngineBridge] resolves the same path.
+ */
+ private fun saveRoot(context: Context): File =
+ File(context.getExternalFilesDir(null), "save/pokemon-love2d")
+
+ fun stagingDir(context: Context, cloudId: String): File =
+ File(context.filesDir, "gen1-engine/cloud/$cloudId")
+
+ /**
+ * Which game version this shortcut is, remembered at launch.
+ *
+ * Working it out means hashing the ROM, and a backup runs with no ROM in
+ * hand and no reason to pay for that. Recorded once when the game starts;
+ * until then staging falls back to taking every version, which is
+ * wasteful but never wrong.
+ */
+ private fun versionKey(cloudId: String) = "gen1_cloud_version_$cloudId"
+
+ fun rememberVersion(context: Context, cloudId: String, version: String) {
+ if (version.isBlank()) return
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit().putString(versionKey(cloudId), version).apply()
+ }
+
+ private fun rememberedVersion(context: Context, cloudId: String): String? =
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .getString(versionKey(cloudId), null)
+ ?.takeIf { it.isNotBlank() }
+
+ /** The cloud id a retro shortcut backs up under. */
+ fun cloudId(shortcut: Shortcut): String =
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.customGameId(
+ shortcut.container.id,
+ shortcut.file.name,
+ )
+
+ /**
+ * Whether this shortcut's saves belong to the engine rather than to a
+ * libretro core.
+ *
+ * The toggle decides it, not the system: a Game Boy shortcut with 3D off
+ * is an ordinary libretro game whose saves are an .srm, and the same
+ * shortcut with 3D on has its progress inside the engine instead. They are
+ * genuinely different saves, so they sync from different places.
+ */
+ fun isEngineShortcut(shortcut: Shortcut?): Boolean =
+ shortcut != null && Gen1EmbedLaunch.isEnabled(shortcut)
+
+ /**
+ * Copies the engine's saves for this game into its staging directory.
+ * Returns true when there was anything to copy.
+ *
+ * Paths are kept relative to the LOVE save root, so [applyStaged] can put
+ * them back without knowing what they are.
+ */
+ fun stage(context: Context, cloudId: String): Boolean =
+ runCatching {
+ val root = saveRoot(context)
+ if (!root.isDirectory) return false
+ val staging = stagingDir(context, cloudId)
+ staging.deleteRecursively()
+
+ var any = false
+ fun copy(file: File) {
+ if (!file.isFile) return
+ val target = File(staging, file.relativeTo(root).path)
+ target.parentFile?.mkdirs()
+ file.copyTo(target, overwrite = true)
+ any = true
+ }
+
+ // The registry and every engine setting. Global to the engine
+ // rather than per game -- see the note on restore below.
+ copy(File(root, "options.lua"))
+
+ val version = rememberedVersion(context, cloudId)
+ val slotRoots =
+ if (version != null) {
+ listOf(File(root, "saves/$version"))
+ } else {
+ // Version not yet known: take them all rather than guess.
+ File(root, "saves").listFiles()?.filter { it.isDirectory }.orEmpty()
+ }
+ slotRoots.forEach { dir ->
+ dir.listFiles()?.forEach(::copy)
+ }
+
+ // Pre-slots saves, which the engine still reads when a version has
+ // no slot registered.
+ copy(File(root, if (version == null) "save.lua" else legacyName(version)))
+
+ if (any) markStaged(context, cloudId)
+ any
+ }.onFailure { Log.w(TAG, "stage failed: ${it.message}") }.getOrDefault(false)
+
+ /**
+ * Records what this staging directory held when WE filled it.
+ *
+ * A restore writes into the same directory -- that is how the cloud hands
+ * a save back -- and nothing else tells us it happened. So the fingerprint
+ * of our own copy is written alongside it: if what is there later does not
+ * match, the contents came from the cloud rather than from here, and they
+ * still have to be copied into the engine's save directory to mean
+ * anything. Without this, restoring from the Cloud Saves screen would
+ * report success and change nothing the player can see.
+ */
+ private fun markerFile(context: Context, cloudId: String) =
+ File(stagingDir(context, cloudId), STAGE_MARKER)
+
+ private fun markStaged(context: Context, cloudId: String) {
+ runCatching { markerFile(context, cloudId).writeText(stagedFingerprint(context, cloudId)) }
+ }
+
+ /** True when the staging directory holds something we did not put there. */
+ fun hasRestoredContent(context: Context, cloudId: String): Boolean {
+ val staging = stagingDir(context, cloudId)
+ if (!staging.isDirectory) return false
+ if (staging.walkTopDown().none { it.isFile && it.name != STAGE_MARKER }) return false
+ val marker = markerFile(context, cloudId)
+ val recorded = runCatching { marker.takeIf { it.isFile }?.readText() }.getOrNull()
+ // No marker at all means the directory was created by a restore.
+ return recorded != stagedFingerprint(context, cloudId)
+ }
+
+ /**
+ * Copies a restored staging directory into the engine, but only when the
+ * staging directory actually holds a restore. Called at launch so a
+ * restore made from the Cloud Saves screen takes effect on the next start.
+ */
+ fun applyRestoreIfPending(context: Context, cloudId: String) {
+ if (!hasRestoredContent(context, cloudId)) return
+ applyStaged(context, cloudId)
+ markStaged(context, cloudId)
+ }
+
+ /** The flat save name the engine uses for a version before slots exist. */
+ private fun legacyName(version: String): String =
+ if (version == "red") "save.lua" else "save_$version.lua"
+
+ /**
+ * Copies a restored staging directory back into the engine's save
+ * directory.
+ *
+ * Note this includes options.lua, which the engine shares across every Gen
+ * 1 game: restoring one game's cloud save also restores the engine
+ * settings and the slot registries as they were when that backup was made.
+ * That is the right answer for a single Gen 1 game, and it is what makes a
+ * restore visible at all -- without the registry the engine cannot see the
+ * slot files sitting next to it.
+ */
+ fun applyStaged(context: Context, cloudId: String) {
+ runCatching {
+ val staging = stagingDir(context, cloudId)
+ if (!staging.isDirectory) return
+ val root = saveRoot(context)
+ root.mkdirs()
+ staging.walkTopDown().filter { it.isFile }.forEach { file ->
+ val target = File(root, file.relativeTo(staging).path)
+ target.parentFile?.mkdirs()
+ file.copyTo(target, overwrite = true)
+ }
+ Log.i(TAG, "restored engine saves for $cloudId")
+ }.onFailure { Log.w(TAG, "applyStaged failed: ${it.message}") }
+ }
+
+ /**
+ * Identifies the staged content, so an unchanged save is not uploaded
+ * again on every exit.
+ */
+ fun stagedFingerprint(context: Context, cloudId: String): String {
+ val staging = stagingDir(context, cloudId)
+ if (!staging.isDirectory) return ""
+ val digest = java.security.MessageDigest.getInstance("SHA-256")
+ // The marker is excluded, or recording the fingerprint inside the
+ // directory would change the fingerprint it records.
+ staging.walkTopDown()
+ .filter { it.isFile && it.name != STAGE_MARKER }
+ .sortedBy { it.path }
+ .forEach { f -> digest.update("${f.relativeTo(staging).path}:${f.length()}".toByteArray()) }
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+
+ /** How recent the local saves are, for the launch-time conflict check. */
+ fun localTimestamp(context: Context, cloudId: String): Long {
+ val root = saveRoot(context)
+ if (!root.isDirectory) return 0L
+ val version = rememberedVersion(context, cloudId)
+ val dirs =
+ if (version != null) {
+ listOf(File(root, "saves/$version"))
+ } else {
+ File(root, "saves").listFiles()?.filter { it.isDirectory }.orEmpty()
+ }
+ return dirs.flatMap { it.listFiles()?.toList().orEmpty() }
+ .filter { it.isFile }
+ .maxOfOrNull { it.lastModified() } ?: 0L
+ }
+
+ /** Refreshes the staging copy just before a backup reads it. */
+ fun refreshForBackup(context: Context, shortcut: Shortcut) {
+ if (!isEngineShortcut(shortcut)) return
+ stage(context, cloudId(shortcut))
+ }
+
+ /**
+ * Queues an upload of this game's engine saves.
+ *
+ * Uses the same two preferences the libretro path uses, so the existing
+ * uploader in UnifiedActivity picks this up with no change: it already
+ * runs whatever is pending the next time the app is in the foreground and
+ * Drive is connected.
+ */
+ fun queueBackup(context: Context, cloudId: String, gameName: String) {
+ if (!stage(context, cloudId)) {
+ Log.i(TAG, "nothing to back up for $cloudId")
+ return
+ }
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ val fingerprint = stagedFingerprint(context, cloudId)
+ if (fingerprint.isNotEmpty() && fingerprint == prefs.getString("retro_cloud_fp_$cloudId", null)) {
+ Log.i(TAG, "engine saves unchanged, skipping upload for $cloudId")
+ return
+ }
+ prefs.edit()
+ .putString("retro_pending_backup_id", cloudId)
+ .putString("retro_pending_backup_name", gameName)
+ .putString("retro_cloud_fp_$cloudId", fingerprint)
+ .apply()
+ Log.i(TAG, "queued engine save backup for $cloudId")
+ }
+}
diff --git a/app/src/main/feature/retro/Gen1EmbedLaunch.kt b/app/src/main/feature/retro/Gen1EmbedLaunch.kt
new file mode 100644
index 000000000..b33afd387
--- /dev/null
+++ b/app/src/main/feature/retro/Gen1EmbedLaunch.kt
@@ -0,0 +1,216 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import com.winlator.cmod.runtime.container.Shortcut
+import java.io.File
+import java.security.MessageDigest
+
+/**
+ * Launches a compatible Game Boy title into the 3D engine instead of the
+ * libretro core, mirroring DolphinEmbedLaunch: WinNative resolves the settings
+ * and hands them to the hosting activity, so the engine never shows a UI of its
+ * own.
+ *
+ * Compatibility is decided by the ROM's SHA-1 rather than its filename, because
+ * that is what the engine itself verifies on import -- a renamed or hacked dump
+ * that would be rejected there must not be offered the toggle here.
+ */
+object Gen1EmbedLaunch {
+ /** Per-game extra: "1" launches into the 3D engine. */
+ const val KEY_ENGINE_3D = "retro_engine_3d"
+
+ /** Mod id of the voxel renderer, as it appears in the engine's options. */
+ const val VOXEL_MOD_ID = "DRAMATIC_SHAPE"
+
+ private val COMPATIBLE = mapOf(
+ "ea9bcae617fdf159b045185467ae58b2e4a48b9a" to "red",
+ "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2" to "blue",
+ "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1" to "yellow",
+ )
+
+ /**
+ * The engine's version id for this ROM, or null when the ROM is not one of
+ * the three the engine accepts. Hashing a 1 MiB file is cheap, but this is
+ * still called off the UI thread by its callers.
+ */
+ fun versionForRom(rom: File): String? {
+ if (!rom.isFile || rom.length() != 1024L * 1024L) return null
+ val digest = MessageDigest.getInstance("SHA-1")
+ rom.inputStream().use { input ->
+ val buf = ByteArray(64 * 1024)
+ while (true) {
+ val n = input.read(buf)
+ if (n <= 0) break
+ digest.update(buf, 0, n)
+ }
+ }
+ val sha1 = digest.digest().joinToString("") { "%02x".format(it) }
+ return COMPATIBLE[sha1]
+ }
+
+ /** Whether this shortcut is a game the 3D engine can run at all. */
+ fun isCompatible(context: Context, shortcut: Shortcut): Boolean =
+ Gen1EngineActivity.isInstalled(context) &&
+ versionForRom(File(RetroShortcuts.romPath(shortcut))) != null
+
+ /** Whether the user has actually turned the 3D toggle on for this game. */
+ fun isEnabled(shortcut: Shortcut): Boolean =
+ shortcut.getExtra(KEY_ENGINE_3D) == "1"
+
+ fun shouldLaunch(context: Context, shortcut: Shortcut): Boolean =
+ isEnabled(shortcut) && isCompatible(context, shortcut)
+
+ fun launch(context: Context, shortcut: Shortcut) {
+ val intent = launchIntent(context, shortcut) ?: return
+ if (context !is android.app.Activity) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ }
+
+ /**
+ * The Intent for this shortcut, or null if the 3D engine cannot run it.
+ *
+ * Deciding and building are one step on purpose. Both need the ROM's
+ * version, and working that out means hashing the file -- so a caller that
+ * asked [shouldLaunch] first and then built the Intent hashed the same
+ * megabyte twice, and the second time was usually on the main thread. Call
+ * this from a background thread and start the Intent it returns.
+ */
+ fun launchIntentIfSupported(context: Context, shortcut: Shortcut): Intent? {
+ if (!Gen1EngineActivity.isInstalled(context)) return null
+ val intent = launchIntent(context, shortcut) ?: return null
+ // After the Intent, because building it is what resolves the version,
+ // and before the Intent is started, because the engine reads its saves
+ // as it boots.
+ prepareLaunch(context, shortcut, intent.getStringExtra(Gen1EngineActivity.EXTRA_VERSION).orEmpty())
+ return intent
+ }
+
+ /**
+ * Brings this game's engine saves down from the cloud before it starts, if
+ * the cloud has something newer.
+ *
+ * The same shape as the Dolphin path: a restore writes into the staging
+ * directory, which is then copied into the engine's own save directory --
+ * it has to happen before the engine boots, because the engine reads its
+ * slot registry once at startup.
+ *
+ * Runs on the caller's thread and blocks; call it from the launch worker,
+ * not the main thread.
+ */
+ private fun syncCloudSaves(context: Context, shortcut: Shortcut) {
+ if (context !is android.app.Activity) return
+ if (shortcut.getExtra("cloud_sync_enabled", "1") == "0") return
+ val gameName = shortcut.getExtra("custom_name", shortcut.name)
+ val cloudId = Gen1CloudSync.cloudId(shortcut)
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context)
+ runCatching {
+ kotlinx.coroutines.runBlocking {
+ kotlinx.coroutines.withTimeout(12_000L) {
+ val entries =
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.listGoogleHistory(
+ context,
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.GameSource.CUSTOM,
+ cloudId,
+ com.winlator.cmod.feature.sync.google.GoogleAuthMode.RESUME,
+ )
+ val latest = entries.maxByOrNull { it.timestampMs } ?: return@withTimeout
+ val localTs = Gen1CloudSync.localTimestamp(context, cloudId)
+ val mark = prefs.getLong("retro_cloud_mark_$cloudId", 0L)
+ val restore: suspend () -> Unit = {
+ val result =
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.restoreFromGoogle(
+ context,
+ latest,
+ com.winlator.cmod.feature.sync.google.GameSaveBackupManager.GameSource.CUSTOM,
+ cloudId,
+ com.winlator.cmod.feature.sync.google.GoogleAuthMode.RESUME,
+ customSaveDir = Gen1CloudSync.stagingDir(context, cloudId),
+ )
+ if (result.success) {
+ Gen1CloudSync.applyStaged(context, cloudId)
+ prefs.edit().putLong("retro_cloud_mark_$cloudId", latest.timestampMs).apply()
+ }
+ }
+ if (localTs == 0L) {
+ // Nothing here yet: a new device, or this game's first
+ // run. Take the cloud copy without asking.
+ restore()
+ } else if (latest.timestampMs > localTs + 120_000L && latest.timestampMs > mark) {
+ if (askCloudConflict(context, gameName)) {
+ restore()
+ } else {
+ // Remembered so the same choice is not asked again
+ // for the same cloud save.
+ prefs.edit().putLong("retro_cloud_mark_$cloudId", latest.timestampMs).apply()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun askCloudConflict(activity: android.app.Activity, gameName: String): Boolean {
+ val latch = java.util.concurrent.CountDownLatch(1)
+ val useCloud = java.util.concurrent.atomic.AtomicBoolean(false)
+ activity.runOnUiThread {
+ androidx.appcompat.app.AlertDialog.Builder(activity)
+ .setTitle(activity.getString(com.winlator.cmod.R.string.retro_lr_cloud_save))
+ .setMessage(
+ activity.getString(com.winlator.cmod.R.string.retro_lr_cloud_conflict_message, gameName),
+ )
+ .setCancelable(false)
+ .setPositiveButton(activity.getString(com.winlator.cmod.R.string.retro_lr_use_cloud_save)) { _, _ ->
+ useCloud.set(true)
+ latch.countDown()
+ }
+ .setNegativeButton(activity.getString(com.winlator.cmod.R.string.retro_scr_keep_local_save)) { _, _ ->
+ latch.countDown()
+ }
+ .show()
+ }
+ latch.await()
+ return useCloud.get()
+ }
+
+ /**
+ * Everything that must happen before the engine starts: pull a newer cloud
+ * save down, and record which game version this shortcut is so a later
+ * backup does not have to hash the ROM again to find out.
+ */
+ fun prepareLaunch(context: Context, shortcut: Shortcut, version: String) {
+ val cloudId = Gen1CloudSync.cloudId(shortcut)
+ Gen1CloudSync.rememberVersion(context, cloudId, version)
+ // A restore made from the Cloud Saves screen landed in the staging
+ // directory and has been waiting for a launch to be copied in.
+ Gen1CloudSync.applyRestoreIfPending(context, cloudId)
+ syncCloudSaves(context, shortcut)
+ }
+
+ fun launchIntent(context: Context, shortcut: Shortcut): Intent? {
+ val rom = File(RetroShortcuts.romPath(shortcut))
+ val version = versionForRom(rom) ?: return null
+
+ return Intent(context, Gen1EngineActivity::class.java).apply {
+ // GameActivity takes its game path from the Intent data when the
+ // embed resource is false, which is how the engine archive can live
+ // in the retro bundle and still be found.
+ data = Uri.fromFile(Gen1EngineActivity.gameArchive(context))
+ putExtra(Gen1EngineActivity.EXTRA_ROM_PATH, rom.absolutePath)
+ putExtra(Gen1EngineActivity.EXTRA_VERSION, version)
+ putExtra(
+ Gen1EngineActivity.EXTRA_GAME_NAME,
+ shortcut.getExtra("custom_name", shortcut.name),
+ )
+ putExtra(Gen1EngineActivity.EXTRA_SHORTCUT_PATH, shortcut.file.absolutePath)
+ // The loading screen shown during a first-boot ROM import uses the
+ // game's own artwork, so the player sees the game they picked
+ // rather than the engine's splash.
+ putExtra(
+ Gen1EngineActivity.EXTRA_ARTWORK_PATH,
+ shortcut.getExtra("customCoverArtPath"),
+ )
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/Gen1EngineActivity.kt b/app/src/main/feature/retro/Gen1EngineActivity.kt
new file mode 100644
index 000000000..e3b9d61fc
--- /dev/null
+++ b/app/src/main/feature/retro/Gen1EngineActivity.kt
@@ -0,0 +1,1398 @@
+package com.winlator.cmod.feature.retro
+
+import android.os.Bundle
+import android.system.Os
+import android.util.Log
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.VolumeUp
+import androidx.compose.material.icons.outlined.Bolt
+import androidx.compose.material.icons.outlined.Monitor
+import androidx.compose.material.icons.outlined.SportsEsports
+import androidx.compose.material.icons.outlined.Tune
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+import com.winlator.cmod.R
+import com.winlator.cmod.runtime.container.ContainerManager
+import com.winlator.cmod.runtime.container.Shortcut
+import com.winlator.cmod.runtime.display.ui.FrameRating
+import com.winlator.cmod.shared.theme.WinNativeTheme
+import org.love2d.sdl.SDLActivity
+import java.io.File
+
+/**
+ * Hosts the LOVE engine inside WinNative, the way DolphinEmulationActivity hosts
+ * Dolphin: the engine runs in this app's own process and WinNative keeps the
+ * menus, the settings and the controls.
+ *
+ * The engine's own launcher never appears, and its own on-screen D-pad and
+ * options menu are never used. What the player sees is WinNative's Game Boy pad
+ * and WinNative's Retro drawer -- the same drawer the GB, GBC and GBA libretro
+ * paths use, built from the same composable against the same controller.
+ *
+ * Two things make that possible and are worth knowing before changing anything
+ * here:
+ *
+ * - The drawer is not tied to libretro. RetroMenuController takes callbacks
+ * that return rows, so the rows can come from anywhere; this activity's come
+ * from the engine over [Gen1EngineBridge] rather than from a libretro core's
+ * variables.
+ *
+ * - SDLActivity extends plain Activity, not ComponentActivity, so none of the
+ * owners Compose expects to find on the view tree exist. This activity
+ * supplies them itself; see the lifecycle plumbing below.
+ */
+class Gen1EngineActivity :
+ org.love2d.android.GameActivity(),
+ RetroInputView.Listener,
+ LifecycleOwner,
+ ViewModelStoreOwner,
+ SavedStateRegistryOwner {
+ private var pad: RetroInputView? = null
+ private var menuView: ComposeView? = null
+ private lateinit var bridge: Gen1EngineBridge
+
+ /**
+ * Whether the on-screen pad is drawn. When it is off the picture gets the
+ * whole screen, which is the point of turning it off -- a player on a
+ * controller should not keep a Game Boy-shaped letterbox.
+ */
+ private var touchControls = true
+
+ /** Cached so the shortcut file is read once rather than on every write. */
+ private var persistShortcut: Shortcut? = null
+
+ /**
+ * True until the engine reports a booted game.
+ *
+ * Held as Compose state so the loading screen can come down the moment the
+ * game is up. It starts true because the engine takes a moment to publish
+ * anything at all, and the first thing on screen would otherwise be the
+ * engine's own splash -- the thing the loading screen exists to cover.
+ */
+ private var loadingVisible by androidx.compose.runtime.mutableStateOf(true)
+ private var importState by
+ androidx.compose.runtime.mutableStateOf(null)
+ /**
+ * The shortcut's cover, decoded off the main thread. Compose state because
+ * it arrives after the loading screen is already up.
+ */
+ private var artwork by
+ androidx.compose.runtime.mutableStateOf(null)
+
+ /**
+ * Which job the slot list is doing when it opens.
+ *
+ * Save and Load both land on the same list of slots, the way the games
+ * themselves work -- you choose a slot to save into, and you choose a slot
+ * to load from. One list, two jobs, so it has to be told which.
+ */
+ private enum class SlotAction { SAVE, LOAD }
+
+ private var slotAction = SlotAction.LOAD
+ private val menu = RetroMenuController()
+ private val handler = android.os.Handler(android.os.Looper.getMainLooper())
+
+ // ------------------------------------------------------------- lifecycle
+ //
+ // ComposeView refuses to compose unless it can find a LifecycleOwner, a
+ // ViewModelStoreOwner and a SavedStateRegistryOwner on its view tree.
+ // ComponentActivity would provide all three, but SDL's activity predates it
+ // and extends Activity directly -- and rebasing SDL's Java glue onto
+ // ComponentActivity would be a much larger change to vendored upstream code
+ // than implementing three small interfaces here.
+
+ private val lifecycleRegistry = LifecycleRegistry(this)
+ private val savedStateController = SavedStateRegistryController.create(this)
+ private val store = ViewModelStore()
+
+ override val lifecycle: Lifecycle get() = lifecycleRegistry
+ override val viewModelStore: ViewModelStore get() = store
+ override val savedStateRegistry: SavedStateRegistry get() = savedStateController.savedStateRegistry
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ savedStateController.performSave(outState)
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun onStart() {
+ super.onStart()
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ bridge.startPolling(::onEngineState)
+ }
+
+ override fun onStop() {
+ // The catch-all for a save the host never saw: the player using the
+ // game's own SAVE menu writes a slot file with nothing on this side
+ // being told. Staging on the way out picks that up whatever route the
+ // save took. Cheap when nothing changed -- the fingerprint check drops
+ // an unchanged set before any upload is queued.
+ queueCloudBackup()
+ // Nothing is on screen to show the state, so stop reading it. The
+ // thread stays alive: a command can still be queued from a lifecycle
+ // path, and the engine keeps running behind us.
+ bridge.stopPolling()
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
+ super.onStop()
+ }
+
+ override fun onDestroy() {
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ handler.removeCallbacksAndMessages(null)
+ bridge.shutdown()
+ store.clear()
+ super.onDestroy()
+ }
+
+ // ----------------------------------------------------------------- input
+
+ /**
+ * Which directions the D-pad is currently holding down. The pad reports an
+ * analogue position on every move, but the engine wants key edges, so this
+ * is diffed against the new position and only the changes are sent -- a
+ * repeated "still holding up" must not re-issue a key-down, and rolling
+ * from up to up-left has to press left without releasing up.
+ */
+ private val heldDirections = HashSet()
+
+ /**
+ * The engine reads SDL, and SDL translates Android key codes itself, so the
+ * pad drives it by injecting the key codes gen1recomp binds in
+ * src/core/Input.lua: z=A, x=B, escape=start, tab=select, arrows for the
+ * D-pad. Enter is deliberately not used for start -- the engine binds
+ * "return" to A.
+ *
+ * Note this is only ever called from the pad's own touch handling. The menu
+ * does NOT reach the engine this way; it uses the bridge. Injecting keys
+ * from the menu is what used to deadlock SDL.
+ */
+ private fun sendKey(keyCode: Int, down: Boolean) {
+ runCatching {
+ if (down) SDLActivity.onNativeKeyDown(keyCode) else SDLActivity.onNativeKeyUp(keyCode)
+ }.onFailure { Log.w(TAG, "key inject failed: ${it.message}") }
+ }
+
+ override fun onButton(keyCode: Int, down: Boolean) {
+ // While the drawer is open the pad belongs to the drawer, not the game
+ // -- otherwise pressing A to pick a menu row also presses A in the game
+ // behind it. This is what RetroActivity does on the libretro path.
+ if (menu.visible) {
+ menu.handleKey(
+ keyCode,
+ if (down) android.view.KeyEvent.ACTION_DOWN else android.view.KeyEvent.ACTION_UP,
+ )
+ return
+ }
+ val mapped = when (keyCode) {
+ android.view.KeyEvent.KEYCODE_BUTTON_A -> android.view.KeyEvent.KEYCODE_Z
+ android.view.KeyEvent.KEYCODE_BUTTON_B -> android.view.KeyEvent.KEYCODE_X
+ android.view.KeyEvent.KEYCODE_BUTTON_START -> android.view.KeyEvent.KEYCODE_ESCAPE
+ android.view.KeyEvent.KEYCODE_BUTTON_SELECT -> android.view.KeyEvent.KEYCODE_TAB
+ else -> return
+ }
+ sendKey(mapped, down)
+ }
+
+ override fun onDpad(x: Float, y: Float) {
+ if (menu.visible) {
+ menu.handleAxis(x, y)
+ return
+ }
+ // Deadzone keeps a resting thumb from chattering the direction keys.
+ val wanted = HashSet(4)
+ if (x <= -DPAD_DEADZONE) wanted.add(android.view.KeyEvent.KEYCODE_DPAD_LEFT)
+ if (x >= DPAD_DEADZONE) wanted.add(android.view.KeyEvent.KEYCODE_DPAD_RIGHT)
+ if (y <= -DPAD_DEADZONE) wanted.add(android.view.KeyEvent.KEYCODE_DPAD_UP)
+ if (y >= DPAD_DEADZONE) wanted.add(android.view.KeyEvent.KEYCODE_DPAD_DOWN)
+
+ for (k in heldDirections - wanted) sendKey(k, false)
+ for (k in wanted - heldDirections) sendKey(k, true)
+ heldDirections.clear()
+ heldDirections.addAll(wanted)
+ }
+
+ // The Game Boy has no analogue sticks; the layout does not draw them, and
+ // an unexpected event must not be translated into a direction key.
+ override fun onStick(x: Float, y: Float) = Unit
+
+ override fun onRightStick(x: Float, y: Float) = Unit
+
+ override fun onMenu() {
+ runOnUiThread { openMenu() }
+ }
+
+ private fun openMenu() {
+ // Release first so a direction held when the menu opened does not stay
+ // down behind it, which is what the libretro path does too.
+ releaseAllKeys()
+ // Built from the last state the bridge read, which is at most one idle
+ // poll old. Reading the file here instead would put storage work in the
+ // middle of the frame that opens the drawer, to save a couple of
+ // hundred milliseconds on values that rarely change while playing --
+ // and pollFaster below closes that gap anyway.
+ menu.open()
+ pollFaster()
+ }
+
+ private fun releaseAllKeys() {
+ for (k in heldDirections) sendKey(k, false)
+ heldDirections.clear()
+ }
+
+ /**
+ * A hardware key -- a real controller, the emulator's keyboard, or the
+ * system Back gesture.
+ *
+ * Intercepted at dispatch, not in onKeyDown. SDL's surface holds focus and
+ * is its own OnKeyListener, so it consumes every key before the activity's
+ * onKeyDown or onBackPressed would ever run -- which is why Back was
+ * reaching the engine and opening its in-game menu instead of WinNative's.
+ * dispatchKeyEvent is the one point that sees the event first.
+ *
+ * While the drawer is open every key belongs to it, for the same reason the
+ * pad does: a press meant for the menu must not also reach the game behind
+ * it.
+ */
+ override fun dispatchKeyEvent(event: android.view.KeyEvent): Boolean {
+ val keyCode = event.keyCode
+ if (keyCode == android.view.KeyEvent.KEYCODE_BACK) {
+ // Acted on the up event; the down is swallowed so auto-repeat
+ // cannot toggle the drawer open and shut.
+ if (event.action == android.view.KeyEvent.ACTION_UP) {
+ // Inside the drawer Back steps back a pane and then closes it;
+ // outside it, Back is how a player with no MENU button opens it.
+ if (menu.visible) {
+ menu.handleKey(keyCode, android.view.KeyEvent.ACTION_UP)
+ } else {
+ openMenu()
+ }
+ }
+ return true
+ }
+ if (menu.visible) {
+ menu.handleKey(keyCode, event.action)
+ return true
+ }
+ return super.dispatchKeyEvent(event)
+ }
+
+ // ------------------------------------------------------------- menu model
+
+ /**
+ * The drawer's tabs for this path.
+ *
+ * Built here rather than through RetroDrawerTabs.build because the engine
+ * does not have every surface a libretro core does: there is no netplay and
+ * there are no memory cards, and a tab that opened onto nothing would be
+ * worse than an absent one. Everything else a Game Boy game offers is here,
+ * and the HUD and Controls panes are the same ones the libretro path builds.
+ */
+ private fun buildTabs(): List =
+ listOf(
+ RetroTabSpec(null, RetroDrawerIcons.Play, getString(R.string.retro_tab_menu)),
+ RetroTabSpec(
+ RetroPane.DISPLAY,
+ Icons.Outlined.Monitor,
+ getString(R.string.retro_tab_display),
+ ),
+ RetroTabSpec(
+ RetroPane.SOUND,
+ Icons.AutoMirrored.Outlined.VolumeUp,
+ getString(R.string.retro_tab_sound),
+ ),
+ RetroTabSpec(
+ RetroPane.PERFORMANCE,
+ Icons.Outlined.Bolt,
+ getString(R.string.retro_ps2_tab_performance),
+ ),
+ RetroTabSpec(
+ RetroPane.HUD,
+ RetroDrawerIcons.Hud,
+ getString(R.string.retro_tab_hud),
+ ),
+ RetroTabSpec(
+ RetroPane.CONTROLS,
+ Icons.Outlined.SportsEsports,
+ getString(R.string.retro_tab_controls),
+ ),
+ RetroTabSpec(
+ RetroPane.SYSTEM,
+ Icons.Outlined.Tune,
+ getString(R.string.retro_tab_system),
+ ),
+ )
+
+ /**
+ * Which pane an engine option row belongs on.
+ *
+ * The engine publishes a flat list -- its own OPTIONS menu is one long
+ * column -- so the grouping is WinNative's, to match how every other system
+ * in the app presents settings. Unknown ids deliberately fall through to
+ * SYSTEM rather than being dropped: an upstream sync that adds a row must
+ * make it reachable without a change here.
+ */
+ private fun paneForRow(id: String): RetroPane =
+ when {
+ Gen1EngineBridge.isModRow(id) -> RetroPane.DISPLAY
+ id in SOUND_ROWS -> RetroPane.SOUND
+ id in DISPLAY_ROWS -> RetroPane.DISPLAY
+ id in PERFORMANCE_ROWS -> RetroPane.PERFORMANCE
+ id in CONTROL_ROWS -> RetroPane.CONTROLS
+ else -> RetroPane.SYSTEM
+ }
+
+ /**
+ * One engine option row as a drawer entry.
+ *
+ * A row the engine could describe the whole value ladder for becomes a
+ * dropdown, which is how every other system's settings are presented in
+ * this app: the player sees what the choices are instead of discovering
+ * them by pressing an arrow repeatedly. A row it could not -- an option
+ * added upstream that the bridge has no ladder for -- keeps the arrows,
+ * so it still works rather than disappearing.
+ */
+ private fun rowEntry(row: Gen1EngineBridge.Row): RetroMenuEntry =
+ when {
+ row.values.isNotEmpty() ->
+ RetroMenuEntry.Choice(row.label, row.values, row.selectedIndex) { index ->
+ bridge.setRow(row.id, index)
+ pollFaster()
+ }
+ row.steppable ->
+ RetroMenuEntry.Stepper(row.label, row.value) { direction ->
+ bridge.step(row.id, direction)
+ pollFaster()
+ }
+ else ->
+ // An activate row opens one of the engine's own sub-screens
+ // (its mod manager, its key rebinder). Those are engine UI, so
+ // the drawer closes and hands the screen over rather than
+ // drawing on top of it.
+ RetroMenuEntry.Action(row.label, RetroDrawerIcons.EditLayout, subtitle = row.value) {
+ bridge.activate(row.id)
+ menu.close()
+ }
+ }
+
+ /**
+ * A two-value engine row as one half of a paired on/off row.
+ *
+ * Which end of the ladder counts as "on" is the row's, not a guess from the
+ * label: the engine may translate ON and OFF, but the order it lists them
+ * in is fixed by the row itself. The state under the label is the engine's
+ * own word for it (ON, BORDERLESS) rather than a generic Enabled, because
+ * that is what the same setting says everywhere else in the engine.
+ */
+ private fun toggleHalf(
+ row: Gen1EngineBridge.Row,
+ label: String,
+ onIndex: Int,
+ ): RetroMenuEntry.Toggle {
+ val offIndex = if (onIndex == 0) 1 else 0
+ return RetroMenuEntry.Toggle(
+ label = label,
+ subtitle = row.values.getOrNull(row.selectedIndex) ?: row.value,
+ checked = row.selectedIndex == onIndex,
+ ) { wanted ->
+ bridge.setRow(row.id, if (wanted) onIndex else offIndex)
+ pollFaster()
+ }
+ }
+
+ /** Engine rows for one pane, as drawer entries. */
+ private fun engineRows(pane: RetroPane): List {
+ val rows =
+ bridge.state.rows.filter { paneForRow(it.id) == pane && it.id !in HIDDEN_ROWS }
+ // The mod's rows lead the Display pane: they are what the player turned
+ // 3D mode on for, and the engine's own display rows still follow.
+ val ordered =
+ if (pane == RetroPane.DISPLAY) {
+ rows.filter { Gen1EngineBridge.isModRow(it.id) } +
+ rows.filterNot { Gen1EngineBridge.isModRow(it.id) }
+ } else {
+ rows
+ }
+
+ // BATTLE ANIMATION and VIDEO MODE are the only two rows here that are
+ // genuinely on-or-off, and a dropdown of two values is a worse control
+ // than a button. They share a row: VIDEO MODE keeps its place in the
+ // ladder order, and BATTLE ANIMATION -- which comes first -- is where
+ // the pair sits.
+ val animations = ordered.firstOrNull { it.id == ANIMATIONS_ROW }?.takeIf { it.values.size == 2 }
+ val videoMode = ordered.firstOrNull { it.id == VIDEO_MODE_ROW }?.takeIf { it.values.size == 2 }
+ val pair =
+ if (animations != null && videoMode != null) {
+ RetroMenuEntry.TogglePair(
+ left = toggleHalf(animations, animations.label, onIndex = 0),
+ // VideoMode.MODES is { windowed, borderless }, so the
+ // second entry is the fullscreen one.
+ right = toggleHalf(videoMode, getString(R.string.retro_engine_fullscreen), onIndex = 1),
+ )
+ } else {
+ null
+ }
+
+ return buildList {
+ for (row in ordered) {
+ when {
+ pair != null && row.id == ANIMATIONS_ROW -> add(pair)
+ pair != null && row.id == VIDEO_MODE_ROW -> Unit
+ else -> add(rowEntry(row))
+ }
+ }
+ }
+ }
+
+ private fun buildEntriesFor(pane: RetroPane?): List =
+ when (pane) {
+ null -> buildMainEntries()
+ RetroPane.SAVES -> buildSaveEntries()
+ RetroPane.CONTROLS -> buildControlEntries() + engineRows(RetroPane.CONTROLS)
+ RetroPane.HUD -> buildHudEntries()
+ else -> engineRows(pane)
+ }
+
+ private fun buildMainEntries(): List =
+ buildList {
+ // Save and Load each open the slot list, which is how saving works
+ // in these games and how save states work on the libretro path.
+ // The engine owns the slots -- it is not a libretro core, so there
+ // is no state blob for WinNative to serialise; picking a slot tells
+ // the engine to write its own save there.
+ val activeSlot = bridge.state.slots.firstOrNull { it.active }?.name
+ add(
+ RetroMenuEntry.Action(
+ getString(R.string.retro_engine_save),
+ RetroDrawerIcons.Save,
+ subtitle = activeSlot,
+ ) {
+ slotAction = SlotAction.SAVE
+ menu.showPane(RetroPane.SAVES)
+ },
+ )
+ add(
+ RetroMenuEntry.Action(
+ getString(R.string.retro_engine_load),
+ RetroDrawerIcons.Load,
+ subtitle = activeSlot,
+ ) {
+ slotAction = SlotAction.LOAD
+ menu.showPane(RetroPane.SAVES)
+ },
+ )
+ add(
+ // Drives the engine's own speed setting rather than a host-side
+ // frame skip, so audio keeps its pitch -- the engine scales its
+ // logic step and deliberately leaves sound alone.
+ RetroMenuEntry.Action(
+ getString(R.string.retro_lr_fast_forward),
+ RetroDrawerIcons.FastForward,
+ active = bridge.state.fastForward,
+ ) {
+ bridge.setFastForward(!bridge.state.fastForward)
+ pollFaster()
+ },
+ )
+ add(
+ RetroMenuEntry.Action(
+ getString(R.string.retro_lr_hud),
+ RetroDrawerIcons.Hud,
+ active = hudVisible,
+ ) { setHudVisible(!hudVisible) },
+ )
+ add(
+ RetroMenuEntry.Action(getString(R.string.retro_lr_reset), RetroDrawerIcons.Reset) {
+ bridge.reset()
+ menu.close()
+ },
+ )
+ add(
+ RetroMenuEntry.Action(
+ getString(R.string.retro_lr_achievements),
+ RetroDrawerIcons.Achievements,
+ subtitle = getString(R.string.retro_engine_achievements_unavailable),
+ ) {
+ // Stated plainly rather than opening an empty achievement
+ // list. This engine is a reimplementation, not an emulator:
+ // it never executes the ROM and has no Game Boy memory for
+ // RetroAchievements to watch, so nothing here can be
+ // tracked yet.
+ toast(getString(R.string.retro_engine_achievements_unavailable))
+ },
+ )
+ }
+
+ /**
+ * The progress line under a save slot: play time, badges and Pokedex
+ * count, whichever of them the engine could report, with the active slot
+ * marked. An empty slot gets no line rather than a row of zeroes.
+ */
+ private fun slotSubtitle(slot: Gen1EngineBridge.Slot): String {
+ val parts = mutableListOf()
+ if (slot.exists) {
+ if (slot.playTime.isNotEmpty()) parts += slot.playTime
+ if (slot.badges > 0) parts += resources.getQuantityString(R.plurals.retro_engine_badges, slot.badges, slot.badges)
+ if (slot.caught > 0) parts += getString(R.string.retro_engine_caught, slot.caught)
+ }
+ if (slot.active) parts += getString(R.string.retro_engine_slot_active_only)
+ return parts.joinToString(SUBTITLE_SEPARATOR)
+ }
+
+ private fun buildSaveEntries(): List =
+ buildList {
+ val slots = bridge.state.slots
+ slots.forEachIndexed { index, slot ->
+ add(
+ RetroMenuEntry.SaveSlot(
+ slot = index,
+ title = slot.name,
+ subtitle = slotSubtitle(slot),
+ filled = slot.exists,
+ onClick = {
+ when (slotAction) {
+ SlotAction.SAVE -> {
+ bridge.saveToSlot(slot.id)
+ toast(getString(R.string.retro_engine_saved_to, slot.name))
+ queueCloudBackupAfterSave()
+ pollFaster()
+ menu.close()
+ }
+ // A registered slot that has never been written
+ // to has no save to read. Saying so beats
+ // closing the menu onto an unchanged game and
+ // leaving the player to wonder whether Load
+ // works at all.
+ SlotAction.LOAD ->
+ if (!slot.exists) {
+ toast(getString(R.string.retro_engine_slot_empty))
+ } else {
+ bridge.loadSlot(slot.id)
+ toast(getString(R.string.retro_engine_loaded_from, slot.name))
+ pollFaster()
+ menu.close()
+ }
+ }
+ },
+ // The engine owns the slot files, so the rename goes
+ // through it rather than being written here. The
+ // drawer's own prompt collects the name, the same one
+ // the libretro path uses for save-state slots.
+ onRename = {
+ menu.renamePrompt =
+ RetroRenamePrompt(
+ title = getString(R.string.retro_engine_rename_slot),
+ initial = slot.name,
+ ) { entered ->
+ val name = entered?.trim().orEmpty()
+ if (name.isNotEmpty() && name != slot.name) {
+ bridge.renameSlot(slot.id, name)
+ pollFaster()
+ }
+ }
+ },
+ ),
+ )
+ }
+ if (slots.isEmpty()) {
+ add(
+ RetroMenuEntry.Action(
+ getString(R.string.retro_engine_no_slots),
+ RetroDrawerIcons.Save,
+ ) {},
+ )
+ }
+ // Only offered when saving: creating a slot to load from would
+ // hand the player an empty one.
+ if (slotAction == SlotAction.SAVE) {
+ add(
+ RetroMenuEntry.Action(getString(R.string.retro_engine_new_slot), RetroDrawerIcons.Add) {
+ bridge.newSlot()
+ queueCloudBackupAfterSave()
+ pollFaster()
+ menu.close()
+ },
+ )
+ }
+ }
+
+ /**
+ * WinNative's own control settings, which belong to the pad rather than to
+ * the engine -- the engine never sees the pad, only the keys it sends.
+ *
+ * The same builder the libretro path uses, so the player gets the identical
+ * pane: the on-screen controls switch, the layout editor, opacity, colours
+ * and haptics. Stick inversion is off because a Game Boy has no sticks.
+ */
+ private fun buildControlEntries(): List =
+ RetroControlsMenu.build(
+ RetroControlsMenu.Host(
+ context = this,
+ overlay = pad,
+ menu = menu,
+ systemId = RetroSystems.GAMEBOY.id,
+ touchControls = { touchControls },
+ onTouchControls = { value ->
+ touchControls = value
+ applyTouchControls()
+ persistExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (value) "1" else "0")
+ },
+ // No sticks on this system, so the adaptive-stick setting has
+ // nothing to act on; reported as off and ignored.
+ adaptiveSticks = { false },
+ onAdaptiveSticks = { },
+ orientationLabel = {
+ val host = mLayout
+ if ((host?.height ?: 0) > (host?.width ?: 0)) {
+ getString(R.string.retro_lr_portrait)
+ } else {
+ getString(R.string.retro_lr_landscape)
+ }
+ },
+ onCloseMenu = { menu.close() },
+ showStickInversion = false,
+ ),
+ )
+
+ // ------------------------------------------------------------------- HUD
+ //
+ // The performance overlay is WinNative's own view and measures the frames
+ // it is drawn on, so it works over the engine exactly as it does over a
+ // libretro core. Its settings are the global ones, shared with every other
+ // system rather than kept separately for this path.
+
+ private var hudVisible = false
+ private var hudStyle = HudStyle()
+ private var hudElements = RetroHudSupport.defaultElements()
+ private var frameRating: FrameRating? = null
+
+ private fun buildHudEntries(): List =
+ RetroHudSupport.buildHudEntries(
+ context = this,
+ hudVisible = hudVisible,
+ style = hudStyle,
+ elements = hudElements,
+ onMaster = { setHudVisible(it) },
+ onStyle = { next ->
+ hudStyle = next
+ frameRating?.let { RetroHudSupport.applyStyle(it, next, hudElements) }
+ RetroHudSupport.saveGlobalHudStyle(this, next)
+ },
+ onElements = { next ->
+ hudElements = next
+ frameRating?.let { RetroHudSupport.applyStyle(it, hudStyle, next) }
+ RetroHudSupport.saveGlobalHudElements(this, next)
+ },
+ onRebuild = { menu.rebuild() },
+ )
+
+ private fun setHudVisible(value: Boolean) {
+ hudVisible = value
+ if (value) {
+ showHud()
+ } else {
+ frameRating?.visibility = android.view.View.GONE
+ handler.removeCallbacks(hudTick)
+ }
+ persistExtra(RetroShortcuts.KEY_HUD, if (value) "1" else "0")
+ menu.rebuild()
+ }
+
+ /**
+ * Feeds the overlay one tick per engine frame.
+ *
+ * The overlay works out its frame rate from how often it is told a frame
+ * happened, and nothing on this side ever sees one -- SDL presents on its
+ * own thread, so there is no callback to hang this on and the overlay would
+ * sit at zero. The engine reports its own rate instead, and this reproduces
+ * it at the right cadence. It stops while paused, so the overlay reads zero
+ * exactly when the game is not running.
+ */
+ private val hudTick =
+ object : Runnable {
+ override fun run() {
+ val rating = frameRating ?: return
+ val fps = bridge.state.fps
+ if (!hudVisible || bridge.state.paused || fps <= 0) {
+ handler.postDelayed(this, HUD_IDLE_TICK_MS)
+ return
+ }
+ rating.recordGameFrame()
+ handler.postDelayed(this, (1000L / fps).coerceAtLeast(1L))
+ }
+ }
+
+ private fun showHud() {
+ val host = mLayout ?: return
+ var rating = frameRating
+ if (rating == null) {
+ rating = RetroHudSupport.createFrameRating(this, ENGINE_RENDERER_LABEL)
+ frameRating = rating
+ // Below the drawer so the menu is never drawn underneath the
+ // overlay, which is the order the libretro path uses too.
+ host.addView(rating, host.indexOfChild(menuView).coerceAtLeast(0))
+ RetroHudSupport.applyStyle(rating, hudStyle, hudElements)
+ }
+ rating.visibility = android.view.View.VISIBLE
+ rating.reset()
+ handler.removeCallbacks(hudTick)
+ handler.post(hudTick)
+ }
+
+ private fun buildBottomEntries(): List =
+ buildList {
+ // Pause stops the engine's game loop but not its command polling,
+ // so Resume can reach it. The drawer stays open on Pause and closes
+ // on Resume, which is how the libretro path behaves.
+ if (bridge.state.paused) {
+ add(
+ RetroMenuEntry.Action(
+ getString(R.string.retro_lr_resume),
+ RetroDrawerIcons.Resume,
+ active = true,
+ ) {
+ bridge.setPaused(false)
+ pollFaster()
+ menu.close()
+ },
+ )
+ } else {
+ add(
+ RetroMenuEntry.Action(getString(R.string.retro_lr_pause), RetroDrawerIcons.Pause) {
+ bridge.setPaused(true)
+ pollFaster()
+ },
+ )
+ }
+ add(
+ RetroMenuEntry.Action(getString(R.string.retro_lr_exit), RetroDrawerIcons.Exit, danger = true) {
+ // Save before leaving, the way closing a game on the libretro
+ // path writes its state: the engine's save is the only record
+ // of progress on this path.
+ bridge.saveGame()
+ queueCloudBackupAfterSave()
+ menu.close()
+ handler.postDelayed({ finish() }, EXIT_SAVE_GRACE_MS)
+ },
+ )
+ }
+
+ /**
+ * Shows or hides the pad and re-lays the picture around it.
+ *
+ * Both halves matter: leaving the surface at its old size would waste the
+ * space the pad just gave up, and hiding the pad without resizing would
+ * leave the game in a letterbox for no reason.
+ */
+ private fun applyTouchControls() {
+ pad?.visibility = if (touchControls) android.view.View.VISIBLE else android.view.View.GONE
+ val host = mLayout ?: return
+ pad?.let { view -> host.post { updateGameArea(host, view) } }
+ }
+
+ /**
+ * Writes a setting back to the game's shortcut, so it is remembered per
+ * game and agrees with what the settings screen shows -- the same place the
+ * libretro path keeps these.
+ */
+ private fun persistExtra(key: String, value: String) {
+ val path = intent.getStringExtra(EXTRA_SHORTCUT_PATH) ?: return
+ Thread {
+ runCatching {
+ val shortcut =
+ persistShortcut ?: run {
+ val file = File(path)
+ if (!file.isFile) return@runCatching
+ val cm = ContainerManager(this)
+ Shortcut(cm.retroContainer, file)
+ .also { persistShortcut = it }
+ }
+ shortcut.putExtra(key, value)
+ shortcut.saveData()
+ }.onFailure { Log.w(TAG, "could not persist $key: ${it.message}") }
+ }.start()
+ }
+
+ /**
+ * Reads back the per-game settings this activity owns. Falls back to the
+ * system default for anything the shortcut has never had set, which is what
+ * the libretro path does for a freshly added game.
+ */
+ private fun loadPersistedSettings() {
+ val path = intent.getStringExtra(EXTRA_SHORTCUT_PATH)
+ val shortcut =
+ runCatching {
+ path?.let { File(it) }?.takeIf { it.isFile }?.let { file ->
+ Shortcut(
+ ContainerManager(this).retroContainer,
+ file,
+ )
+ }
+ }.getOrNull()
+ persistShortcut = shortcut
+
+ touchControls =
+ shortcut?.getExtra(RetroShortcuts.KEY_TOUCH_CONTROLS)?.takeIf { it.isNotEmpty() }?.let { it != "0" }
+ ?: RetroDefaults.touchControls(this, RetroSystems.GAMEBOY.id)
+ hudVisible = shortcut?.getExtra(RetroShortcuts.KEY_HUD)?.takeIf { it.isNotEmpty() }?.let { it != "0" } ?: false
+ hudStyle = RetroHudSupport.loadGlobalHudStyle(this)
+ hudElements = RetroHudSupport.loadGlobalHudElements(this)
+ }
+
+ /**
+ * Queues this game's engine saves for cloud backup.
+ *
+ * Called after a save the menu asked for, and again when the activity goes
+ * away -- that second one is what covers a save made from inside the game,
+ * which the host never sees happen. The queue is the same one the libretro
+ * path uses, so the upload runs the next time the app is in the foreground
+ * with Drive connected; nothing here waits on the network.
+ *
+ * Off the main thread because staging copies files.
+ */
+ private fun queueCloudBackup() {
+ val shortcut = persistShortcut ?: return
+ if (shortcut.getExtra("cloud_sync_enabled", "1") == "0") return
+ val gameName = intent.getStringExtra(EXTRA_GAME_NAME).orEmpty()
+ .ifBlank { shortcut.getExtra("custom_name", shortcut.name) }
+ val app = applicationContext
+ Thread {
+ runCatching { Gen1CloudSync.queueBackup(app, Gen1CloudSync.cloudId(shortcut), gameName) }
+ .onFailure { Log.w(TAG, "could not queue cloud backup: ${it.message}") }
+ }.apply { name = "gen1-cloud-queue"; start() }
+ }
+
+ /**
+ * Gives the engine time to write the save it was just asked for, then
+ * queues it. The command is only queued on the bridge when this returns,
+ * and the engine polls for it -- so staging immediately would copy the
+ * previous save.
+ */
+ private fun queueCloudBackupAfterSave() {
+ handler.postDelayed({ queueCloudBackup() }, SAVE_SETTLE_MS)
+ }
+
+ private fun toast(text: String) {
+ android.widget.Toast.makeText(this, text, android.widget.Toast.LENGTH_SHORT).show()
+ }
+
+ // --------------------------------------------------------------- polling
+
+ /**
+ * What to do when the engine's published state changes.
+ *
+ * The reading and parsing happen on the bridge's own thread; this is only
+ * the part that touches the UI, so it is the only part on the main thread.
+ * It is not called every tick -- the bridge stays quiet unless something
+ * drawn here actually moved.
+ */
+ private fun onEngineState(state: Gen1EngineBridge.State, menuChanged: Boolean) {
+ importState = state.import
+ // Down as soon as the engine says a game is running. Also driven by the
+ // import line disappearing, so a run that never needed an import --
+ // every launch after the first -- does not sit behind the screen
+ // waiting for progress that never comes.
+ if (loadingVisible && state.booted) loadingVisible = false
+ if (menuChanged && menu.visible) menu.rebuild()
+ }
+
+ /**
+ * Asks for a read now, because something just changed the engine's state
+ * and the menu is showing it.
+ *
+ * The command that caused the change is still queued on the bridge's
+ * thread, and this lands behind it -- so the read always sees the engine
+ * after the command reached it, not before. The cadence is left alone; that
+ * belongs to whether the drawer is open, not to a single tap.
+ */
+ private fun pollFaster() = bridge.pollNow()
+
+ // ------------------------------------------------------------- SDL wiring
+
+ /**
+ * The engine ships in the retro bundle, not in the APK, so the default
+ * System.loadLibrary (which only searches the APK's own library dir) cannot
+ * find it. Load by absolute path out of the bundle instead -- the same
+ * thing DolphinEmulationActivity does for libmain.so. Order matters: each
+ * library here is linked against the ones above it.
+ */
+ override fun loadLibraries() {
+ val dir = engineLibDir(this)
+ for (lib in ENGINE_LIBS) {
+ val so = File(dir, "lib$lib.so")
+ if (!so.isFile) {
+ throw UnsatisfiedLinkError("engine library missing from bundle: ${so.absolutePath}")
+ }
+ System.load(so.absolutePath)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ // Must precede any lifecycle event, and the ON_CREATE below must follow
+ // it -- SavedStateRegistry enforces that order.
+ savedStateController.performRestore(savedInstanceState)
+
+ val rom = intent.getStringExtra(EXTRA_ROM_PATH)
+ val version = intent.getStringExtra(EXTRA_VERSION)
+ Log.i(TAG, "onCreate rom=$rom version=$version")
+
+ // Started first and joined last, so this storage work overlaps the
+ // engine coming up in super.onCreate rather than adding to it. The
+ // values it produces are not needed until the very end of onCreate.
+ val prefetch = startSettingsPrefetch()
+
+ // This one has to be synchronous, and before super.onCreate: that call
+ // starts the engine, and the engine enumerates its mods once while it
+ // loads. Installing afterwards would not appear until the next launch.
+ // The common path is a stat and a short stamp read; the unpack only
+ // happens on the first launch after the bundle ships a new mod.
+ Gen1ModInstaller.ensureInstalled(this)
+
+ bridge = Gen1EngineBridge(this)
+ // Anything left from the last run describes a game that is no longer
+ // loaded, and the drawer would show it for the second or so before the
+ // engine publishes fresh state.
+ bridge.clearStale()
+
+ // Belt and braces only. A file-written probe inside love.load showed
+ // os.getenv returning nil for both of these, so the process environment
+ // is NOT the channel the engine reads -- the real handoff is the command
+ // line below. This stays because it costs nothing and keeps anything
+ // that does read the environment consistent with what the engine was
+ // told. Calling SDL's nativeSetenv instead was tried and is worse: it is
+ // only bound after super.onCreate, and calling it there made the activity
+ // exit before love.load ever ran.
+ runCatching {
+ if (!rom.isNullOrEmpty()) Os.setenv("POKEPORT_IMPORT_ROM", rom, true)
+ if (!version.isNullOrEmpty()) Os.setenv("POKEPORT_VERSION", version, true)
+ }.onFailure { Log.w(TAG, "could not set engine environment: ${it.message}") }
+
+ super.onCreate(savedInstanceState)
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
+
+ menu.entriesProvider = { pane -> buildEntriesFor(pane) }
+ menu.bottomProvider = { buildBottomEntries() }
+ menu.tabs = buildTabs()
+
+ // The owners have to be on the window ROOT, not just on the ComposeView.
+ // Compose creates its recomposer per window and looks the lifecycle
+ // owner up from the root view, so setting them only on the ComposeView
+ // still throws "ViewTreeLifecycleOwner not found" the moment it attaches.
+ // ComponentActivity does this on the decor view too, which is exactly
+ // what this stands in for.
+ window.decorView.let { root ->
+ root.setViewTreeLifecycleOwner(this)
+ root.setViewTreeViewModelStoreOwner(this)
+ root.setViewTreeSavedStateRegistryOwner(this)
+ }
+
+ // Both the pad and the drawer go into SDL's own layout rather than
+ // through addContentView: SDL builds its surface inside mLayout and sets
+ // that as the content view, so a view added to the activity's content
+ // frame instead lands outside the tree SDL manages -- which blanked the
+ // engine's rendering entirely.
+ val host = mLayout
+ if (host == null) {
+ Log.w(TAG, "SDL layout missing; pad and menu not attached")
+ return
+ }
+
+ pad = RetroInputView(this, this, RetroSystems.GAMEBOY).also { view ->
+ // The same setup RetroActivity does. setGameArea is the one that
+ // matters most: the pad lays its buttons out around the game
+ // rectangle, so without an area it renders nothing at all.
+ view.loadStickInversion()
+ view.hapticStrength =
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(this)
+ .getFloat(PREF_HAPTIC, DEFAULT_HAPTIC)
+ view.setCustomColors(RetroControlLayouts.loadColors(this, RetroSystems.GAMEBOY.id))
+
+ host.addView(
+ view,
+ android.widget.RelativeLayout.LayoutParams(
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT,
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT,
+ ),
+ )
+ host.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ updateGameArea(host, view)
+ }
+ host.post { updateGameArea(host, view) }
+ }
+
+ val menuComposeView =
+ ComposeView(this).apply {
+ // Above both SDL's surface and the pad, so the drawer is not
+ // drawn underneath the buttons it is replacing.
+ elevation = MENU_ELEVATION
+ setViewTreeLifecycleOwner(this@Gen1EngineActivity)
+ setViewTreeViewModelStoreOwner(this@Gen1EngineActivity)
+ setViewTreeSavedStateRegistryOwner(this@Gen1EngineActivity)
+ setContent {
+ WinNativeTheme {
+ // The drawer can close itself -- B and Back are handled
+ // inside the controller -- so the cadence is driven off
+ // the state that actually decides it rather than from
+ // the call sites that happen to open it. Runs on the
+ // main thread and only when one of the two flips.
+ androidx.compose.runtime.LaunchedEffect(menu.visible, loadingVisible) {
+ bridge.setPollFast(menu.visible || loadingVisible)
+ }
+ Box(Modifier.fillMaxSize()) {
+ RetroDrawerMenu(menu)
+ // Above the drawer: during the import there is no
+ // game to configure, and the menu must not be
+ // reachable behind it.
+ Gen1LoadingScreen(
+ gameName = intent.getStringExtra(EXTRA_GAME_NAME).orEmpty(),
+ artwork = artwork,
+ state = importState,
+ visible = loadingVisible,
+ )
+ }
+ }
+ }
+ }
+ menuView = menuComposeView
+ host.addView(
+ menuComposeView,
+ android.widget.RelativeLayout.LayoutParams(
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT,
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT,
+ ),
+ )
+
+ // Joined here, not earlier: these are the first two lines that read
+ // what it fetched, and by now it has had the whole of super.onCreate to
+ // finish. Nothing has been laid out against a default yet, so there is
+ // no flicker to correct.
+ prefetch.join()
+
+ // Applied after both views exist: it sizes the picture around the pad,
+ // and shows the overlay if this game had it on last time.
+ applyTouchControls()
+ if (hudVisible) host.post { showHud() }
+ }
+
+ /**
+ * Reads this activity's persisted settings and the shortcut's artwork off
+ * the main thread.
+ *
+ * All of it is storage work: a Shortcut is a file, the defaults behind it
+ * are SharedPreferences, and ContainerManager -- which a Shortcut needs to
+ * be constructed -- lists the container directory and parses a JSON config
+ * for each one it finds. That is a variable amount of disk and parsing on
+ * the thread that is also trying to start a game.
+ *
+ * Returned rather than awaited, so the caller decides how much of its own
+ * work to overlap with it.
+ */
+ private fun startSettingsPrefetch(): Thread =
+ Thread {
+ runCatching { loadPersistedSettings() }
+ .onFailure { Log.w(TAG, "could not read saved settings: ${it.message}") }
+
+ // Warms the preference file so the pad's own reads, which have to
+ // happen on the main thread because they configure a view, come
+ // out of the in-memory cache instead of parsing XML there.
+ runCatching {
+ androidx.preference.PreferenceManager.getDefaultSharedPreferences(this)
+ }
+
+ // Decoded here rather than in the composable, and downsampled --
+ // it is drawn at a couple of hundred dp, so decoding a full-size
+ // cover at 1:1 would cost far more memory and time than the picture
+ // is worth. A missing or unreadable file simply means no picture on
+ // the loading screen, not a failure to start the game.
+ val decoded =
+ intent.getStringExtra(EXTRA_ARTWORK_PATH)
+ ?.takeIf { it.isNotBlank() }
+ ?.let { path -> decodeArtwork(path) }
+ // Compose state, so the loading screen picks the picture up whenever
+ // it lands -- it is on screen before this finishes, and showing the
+ // title without the cover for a moment is the right trade.
+ if (decoded != null) handler.post { artwork = decoded }
+ }.apply {
+ name = "gen1-settings"
+ priority = Thread.NORM_PRIORITY - 1
+ start()
+ }
+
+ /** Decodes the shortcut's cover no larger than the loading screen draws it. */
+ private fun decodeArtwork(path: String): android.graphics.Bitmap? =
+ runCatching {
+ val bounds =
+ android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ android.graphics.BitmapFactory.decodeFile(path, bounds)
+ val longest = maxOf(bounds.outWidth, bounds.outHeight)
+ if (longest <= 0) return@runCatching null
+ val target = (ARTWORK_MAX_DP * resources.displayMetrics.density).toInt().coerceAtLeast(1)
+ var sample = 1
+ while (longest / (sample * 2) >= target) sample *= 2
+ android.graphics.BitmapFactory.decodeFile(
+ path,
+ android.graphics.BitmapFactory.Options().apply { inSampleSize = sample },
+ )
+ }.getOrNull()
+
+ /**
+ * Where the Game Boy picture sits inside the window, which is what the pad
+ * arranges itself around. Mirrors RetroActivity.updateOverlayArea with its
+ * overlayPush() of 0: the picture keeps its aspect and is centred in
+ * landscape, top-aligned in portrait so the buttons get the space below it.
+ */
+ private fun updateGameArea(host: android.view.ViewGroup, view: RetroInputView) {
+ val w = host.width
+ val h = host.height
+ if (w <= 0 || h <= 0) return
+ val portrait = h >= w
+
+ // Sized to a whole multiple of the Game Boy's 160x144 rather than to
+ // whatever fits exactly.
+ //
+ // The engine scales by an integer factor and centres what is left over
+ // (Renderer:fitScale is a floor), so handing it a rectangle that is not
+ // a multiple of 160x144 buys nothing: it draws the same picture and
+ // pads the difference with its own black border. Rounding down here
+ // moves that border outside the surface, which means the picture sits
+ // flush against the top of the screen in portrait instead of floating
+ // below a black band, and the pad gets the leftover height rather than
+ // the engine wasting it.
+ // With the pad hidden there is nothing to leave room for, so the
+ // picture takes the whole screen -- which is the reason to turn the pad
+ // off in the first place.
+ val budgetHeight = if (portrait && touchControls) (h * PORTRAIT_GAME_HEIGHT_FRACTION).toInt() else h
+ val scale = minOf(w / GB_WIDTH, budgetHeight / GB_HEIGHT).coerceAtLeast(1)
+ val gameWidth = (GB_WIDTH * scale).toFloat()
+ val gameHeight = (GB_HEIGHT * scale).toFloat()
+
+ // Centred across, and in portrait pushed to the top so every pixel the
+ // picture does not use goes to the buttons underneath it.
+ val left = (w - gameWidth) * 0.5f
+ val top = if (portrait && touchControls) 0f else (h - gameHeight) * 0.5f
+ val area = android.graphics.RectF(left, top, left + gameWidth, top + gameHeight)
+
+ view.setGameArea(area)
+ applySurfaceBounds(area)
+ applyFillScale(area, w, h)
+ }
+
+ /**
+ * Stretches the picture over the whole display when the pad is hidden.
+ *
+ * With the buttons gone there is nothing to leave room for, so the black
+ * bars either side serve no purpose -- the screen should be the game. The
+ * surface keeps its exact 160x144-multiple size, which is what keeps the
+ * pixels sharp, and the view is scaled up from there to cover the display.
+ *
+ * This does not preserve the Game Boy's 10:9 aspect: filling a 16:9 screen
+ * means stretching horizontally, which is the trade the setting asks for.
+ * With the pad shown the scale is reset to 1 and the aspect is exact.
+ */
+ private fun applyFillScale(area: android.graphics.RectF, hostWidth: Int, hostHeight: Int) {
+ val surface = mSurface ?: return
+ if (touchControls || area.width() <= 0f || area.height() <= 0f) {
+ surface.scaleX = 1f
+ surface.scaleY = 1f
+ return
+ }
+ // Scaled about its own centre, and the rectangle is centred in the host
+ // whenever the pad is hidden, so the result lands flush with the edges.
+ surface.pivotX = area.width() * 0.5f
+ surface.pivotY = area.height() * 0.5f
+ surface.scaleX = hostWidth / area.width()
+ surface.scaleY = hostHeight / area.height()
+ }
+
+ /**
+ * Puts the engine's own surface exactly where the pad reserved space for it.
+ *
+ * SDL adds its surface to mLayout with no layout parameters, so it fills the
+ * whole window and LOVE letterboxes the 160x144 picture inside that. The pad,
+ * meanwhile, lays its buttons out around the rectangle it was given -- so the
+ * two disagreed: the picture floated in the middle of the window with a black
+ * band above it and its lower part behind the button panel.
+ *
+ * Rather than trying to predict where LOVE will letterbox and matching the pad
+ * to it, the surface is given the pad's rectangle. Its aspect already matches
+ * the Game Boy's, so LOVE has no letterboxing left to do and the two cannot
+ * drift apart.
+ */
+ private fun applySurfaceBounds(area: android.graphics.RectF) {
+ val want =
+ android.graphics.Rect(
+ area.left.toInt(),
+ area.top.toInt(),
+ area.right.toInt(),
+ area.bottom.toInt(),
+ )
+ if (want.isEmpty || want == surfaceBounds) return
+ val surface = mSurface ?: return
+ // Cached and compared because assigning layout parameters requests
+ // another layout pass, which calls straight back into here.
+ surfaceBounds = want
+ surface.layoutParams =
+ android.widget.RelativeLayout.LayoutParams(want.width(), want.height()).apply {
+ leftMargin = want.left
+ topMargin = want.top
+ }
+ }
+
+ private var surfaceBounds: android.graphics.Rect? = null
+
+ override fun onPause() {
+ // A key held when the activity goes away would otherwise stay down in
+ // the engine and keep the player walking on return.
+ releaseAllKeys()
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+ super.onPause()
+ }
+
+ /**
+ * SDL hands this straight to LOVE as argv, and LOVE puts argv into Lua's
+ * `arg`, so this is a channel the engine demonstrably reads -- gen1recomp's
+ * own conf.lua already parses `arg` for --editor. The engine side pairs with
+ * this in scripts/winnative_boot_args.sh, which teaches main.lua to accept
+ * these two flags alongside the POKEPORT_* environment variables it already
+ * supports.
+ */
+ override fun getArguments(): Array {
+ val rom = intent?.getStringExtra(EXTRA_ROM_PATH)
+ val version = intent?.getStringExtra(EXTRA_VERSION)
+ val args = ArrayList(4)
+ if (!rom.isNullOrEmpty()) { args.add("--import-rom"); args.add(rom) }
+ if (!version.isNullOrEmpty()) { args.add("--game-version"); args.add(version) }
+ // Nothing here turns off the engine's own on-screen D-pad: passing a
+ // flag for it (either "--touch-controls 0" or "--touch-controls=0")
+ // made LOVE die silently just after SDL_main, so argv is kept to the
+ // two settings that are known to survive it. The overlay is suppressed
+ // on the engine side instead, by scripts/winnative_boot_args.sh, which
+ // is the better place for it anyway: this fork is only ever hosted by
+ // WinNative, and WinNative always supplies its own pad.
+ Log.i(TAG, "engine argv: $args")
+ return args.toTypedArray()
+ }
+
+ companion object {
+ private const val TAG = "WnGen1Engine"
+
+ const val EXTRA_ROM_PATH = "wn.engine.rom"
+ const val EXTRA_VERSION = "wn.engine.version"
+ const val EXTRA_GAME_NAME = "wn.engine.game_name"
+ const val EXTRA_SHORTCUT_PATH = "wn.engine.shortcut"
+ const val EXTRA_ARTWORK_PATH = "wn.engine.artwork"
+
+ private const val DPAD_DEADZONE = 0.35f
+
+ private const val PREF_HAPTIC = "retro_haptic_strength"
+ private const val DEFAULT_HAPTIC = 0.4f
+
+ /**
+ * Named on the HUD's renderer line. The engine draws through LOVE on
+ * OpenGL ES, so this says what is actually presenting the frames rather
+ * than borrowing the libretro label.
+ */
+ private const val ENGINE_RENDERER_LABEL = "LOVE / GLES"
+
+ /** Above SDL's surface and the pad. */
+ private const val MENU_ELEVATION = 2000f
+
+ /** Between the parts of a save slot's progress line. */
+ private const val SUBTITLE_SEPARATOR = " \u00b7 "
+
+ /** How often the HUD feeder rechecks when there is nothing to report. */
+ private const val HUD_IDLE_TICK_MS = 250L
+
+ /**
+ * Longest edge the loading screen's cover is ever drawn at, which is
+ * what it gets decoded to rather than at full size.
+ */
+ private const val ARTWORK_MAX_DP = 240f
+
+ /** Long enough for the engine to pick the save command up and run it. */
+ private const val EXIT_SAVE_GRACE_MS = 400L
+
+ /**
+ * How long to let the engine actually write a save before copying it
+ * for the cloud. The command reaches the engine on its next poll, so
+ * staging any sooner would copy the previous save.
+ */
+ private const val SAVE_SETTLE_MS = 700L
+
+ // Engine option row ids, grouped the way WinNative presents settings.
+ // Anything not listed lands on System; see paneForRow.
+ /** The two Display rows that pair up as buttons instead of dropdowns. */
+ private const val ANIMATIONS_ROW = "animations"
+ private const val VIDEO_MODE_ROW = "videoMode"
+
+ /**
+ * Engine rows that decide nothing on this path, and so are not shown.
+ *
+ * An unknown row is deliberately kept and shown -- that is how an option
+ * added upstream stays reachable without a change here. This is the
+ * opposite case: a row that is present but inert, which is worse than an
+ * absent one because it invites the player to change something that
+ * cannot change.
+ *
+ * TOUCH PAD switches the engine's own on-screen D-pad, which this fork
+ * builds with that overlay forced off (see WINNATIVE_TOUCH_ARG in
+ * TouchControls.lua) because WinNative draws the pad itself. Toggling it
+ * would write a value nothing reads, and the switch the player actually
+ * wants is on the Controls pane.
+ */
+ private val HIDDEN_ROWS = setOf("touchControls")
+
+ private val SOUND_ROWS = setOf("musicVol", "sfxVol", "pikaVol", "musicFilter")
+ private val DISPLAY_ROWS =
+ setOf("colors", "tilt", "gbcfx", "zoom", "voidFill", "videoMode", "animations")
+ private val PERFORMANCE_ROWS = setOf("fpsCap", "speed")
+ private val CONTROL_ROWS = setOf("controls")
+
+ /** The Game Boy's screen, which is also the engine's UI surface size. */
+ private const val GB_WIDTH = 160
+ private const val GB_HEIGHT = 144
+
+ /**
+ * Most of the height the picture may take in portrait, leaving the rest
+ * for the buttons. Only ever reduces the scale factor, so on a tall
+ * screen the picture stops growing before it crowds the pad out.
+ */
+ private const val PORTRAIT_GAME_HEIGHT_FRACTION = 0.6f
+
+ /** Dependency order; liblove links against the three above it. */
+ private val ENGINE_LIBS = listOf("c++_shared", "mpg123", "openal", "love")
+
+ fun engineDir(context: android.content.Context): File =
+ File(RetroBundle.root(context), "data/gen1recomp")
+
+ fun engineLibDir(context: android.content.Context): File =
+ File(engineDir(context), "lib")
+
+ /** The Lua engine archive the GameActivity is pointed at via the Intent. */
+ fun gameArchive(context: android.content.Context): File =
+ File(engineDir(context), "game.love")
+
+ fun isInstalled(context: android.content.Context): Boolean =
+ gameArchive(context).isFile &&
+ ENGINE_LIBS.all { File(engineLibDir(context), "lib$it.so").isFile }
+ }
+}
diff --git a/app/src/main/feature/retro/Gen1EngineBridge.kt b/app/src/main/feature/retro/Gen1EngineBridge.kt
new file mode 100644
index 000000000..c9426e778
--- /dev/null
+++ b/app/src/main/feature/retro/Gen1EngineBridge.kt
@@ -0,0 +1,442 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.util.Log
+import java.io.File
+
+/**
+ * The host half of the engine control channel.
+ *
+ * The engine publishes its own OPTIONS rows and save slots as text, and takes
+ * commands the same way; see src/core/WinNativeBridge.lua in the gen1recomp
+ * fork for the other end and for why it works this way.
+ *
+ * This exists because the Retro menu has to show real values. WinNative owns
+ * the menu but not the settings, so a row that says "VOXEL: ON" has to have
+ * read that from the engine -- and the engine is a separate runtime with its
+ * own state, not a libretro core whose variables the host already holds. Key
+ * injection, which is what this replaces, could only ever ask for a change and
+ * never observe one.
+ *
+ * Both files live in the engine's LOVE save directory, which sits under
+ * WinNative's own external files directory, so no permission is involved.
+ *
+ * Every one of those file operations happens on this class's own thread, never
+ * on the caller's. The channel is a file poll, so the alternative was reading
+ * and parsing the state file on the main thread several times a second for as
+ * long as a game is open, and writing one from inside a menu tap -- storage
+ * work on the thread that has to draw the next frame. Callers hand over a
+ * command and move on; the last state read is kept in a volatile field so the
+ * menu can still be built synchronously from whatever is current.
+ *
+ * One thread rather than a pool, deliberately: commands must reach the engine
+ * in the order they were made (choosing a save slot then writing to it is two
+ * commands whose order is the whole point), and a single looper gives that for
+ * free without a lock.
+ */
+class Gen1EngineBridge(context: Context) {
+ private val dir = File(context.getExternalFilesDir(null), "$SAVE_SUBDIR/$BRIDGE_SUBDIR")
+ private val statePath = File(dir, "state.txt")
+ private val commandPath = File(dir, "cmd.txt")
+ private val commandTmp = File(dir, "cmd.host.tmp")
+
+ /**
+ * Background priority: this must never compete with the engine's own
+ * render thread. A menu tap arriving a few milliseconds later is invisible;
+ * a dropped frame in the game is not.
+ */
+ private val ioThread =
+ android.os.HandlerThread(THREAD_NAME, android.os.Process.THREAD_PRIORITY_BACKGROUND)
+ .apply { start() }
+ private val io = android.os.Handler(ioThread.looper)
+ private val main = android.os.Handler(android.os.Looper.getMainLooper())
+
+ /** Called on the main thread when something the menu draws has changed. */
+ private var listener: ((State, Boolean) -> Unit)? = null
+
+ @Volatile
+ private var polling = false
+
+ @Volatile
+ private var pollFast = false
+
+ /** One engine option, exactly as its own OPTIONS menu would show it. */
+ data class Row(
+ val id: String,
+ val label: String,
+ val value: String,
+ /**
+ * Whether the row cycles through values or just does something. The
+ * engine reports this rather than the host guessing, because a mod can
+ * add rows of either kind.
+ */
+ val steppable: Boolean,
+ /**
+ * Every value the row can take, in the engine's own ladder order, or
+ * empty when the engine could not describe the row -- an option it has
+ * no ladder for, which is what an upstream sync adds. Empty means the
+ * menu falls back to stepping, so a new row still works.
+ */
+ val values: List = emptyList(),
+ /** Where [value] sits in [values]; -1 when there is no ladder. */
+ val selectedIndex: Int = -1,
+ )
+
+ data class Slot(
+ val id: String,
+ /** The slot's custom label, or the player character's name if unlabelled. */
+ val name: String,
+ /** Play time as the engine formats it, e.g. "3:14". Empty for an unused slot. */
+ val playTime: String,
+ val badges: Int,
+ /** How many Pokedex entries have been caught. */
+ val caught: Int,
+ val active: Boolean,
+ /** False for a registered slot that has never been written to. */
+ val exists: Boolean,
+ )
+
+ /** Where a first-boot ROM import has got to. */
+ data class Import(val stage: String, val progress: Float)
+
+ data class State(
+ val seq: Long,
+ /**
+ * False while the engine is still importing the ROM. The menu shows
+ * this rather than an empty settings list, which would look broken.
+ */
+ val booted: Boolean,
+ /** The host asked the engine to hold still; its update loop is stopped. */
+ val paused: Boolean,
+ val fastForward: Boolean,
+ /** The engine's own frame rate; nothing on this side can observe it. */
+ val fps: Int,
+ /**
+ * Set only while a first-boot ROM import is running. Null the rest of
+ * the time, which is how the loading screen knows to come down.
+ */
+ val import: Import?,
+ val version: String,
+ val rows: List,
+ val slots: List,
+ )
+
+ /**
+ * The last state read. Kept so the menu can be built synchronously from
+ * whatever is current, while polling refreshes it in the background.
+ */
+ @Volatile
+ var state: State = EMPTY
+ private set
+
+ // ------------------------------------------------------------ the poll
+ //
+ // The engine publishes on its own schedule, so the host polls. All of it
+ // runs on the io thread; only the notification crosses back to the main
+ // thread, and only when it has something to say.
+
+ private val pollTask =
+ object : Runnable {
+ override fun run() {
+ if (!polling) return
+ val before = state
+ val moved = refresh()
+ val now = state
+ // Deliberately not every tick. The frame rate changes on every
+ // read and the HUD picks it up from `state` on its own, so
+ // waking the main thread for it would be ten pointless
+ // recompositions a second. Only a menu-visible change, the
+ // import progress, and the game coming up are worth a hop.
+ if (moved || now.import != before.import || now.booted != before.booted) {
+ val callback = listener
+ if (callback != null) main.post { callback(now, moved) }
+ }
+ io.postDelayed(this, if (pollFast) POLL_ACTIVE_MS else POLL_IDLE_MS)
+ }
+ }
+
+ /**
+ * Begins polling. [onChanged] is called on the main thread, with the state
+ * and whether the menu's sequence number moved.
+ */
+ fun startPolling(onChanged: (State, Boolean) -> Unit) {
+ listener = onChanged
+ polling = true
+ io.removeCallbacks(pollTask)
+ io.post(pollTask)
+ }
+
+ /** Stops polling without tearing the thread down, for a backgrounded game. */
+ fun stopPolling() {
+ polling = false
+ io.removeCallbacks(pollTask)
+ }
+
+ /**
+ * Fast while the drawer is open or has just been touched, so a changed row
+ * shows its new value promptly; slow the rest of the time, when nothing is
+ * reading the values.
+ */
+ fun setPollFast(fast: Boolean) {
+ if (pollFast == fast) return
+ pollFast = fast
+ if (polling) pollNow()
+ }
+
+ /** Reads once immediately, then resumes the normal cadence. */
+ fun pollNow() {
+ if (!polling) return
+ io.removeCallbacks(pollTask)
+ io.post(pollTask)
+ }
+
+ /**
+ * Drops the thread. After this the bridge is dead; anything still queued is
+ * discarded rather than run against a torn-down activity.
+ */
+ fun shutdown() {
+ polling = false
+ listener = null
+ io.removeCallbacksAndMessages(null)
+ ioThread.quitSafely()
+ }
+
+ /**
+ * Reads the published state, and returns true when the sequence number
+ * moved -- the caller's signal to rebuild the menu.
+ *
+ * The state is taken every time regardless, because not everything in it
+ * belongs on the menu: the frame rate changes on every poll and the engine
+ * deliberately does not advance the sequence for it, so it has to be picked
+ * up without triggering a rebuild.
+ *
+ * Runs on whichever thread calls it and touches storage, so in this app
+ * that is only ever the io thread.
+ */
+ private fun refresh(): Boolean {
+ val text =
+ runCatching { if (statePath.isFile) statePath.readText() else null }
+ .getOrNull() ?: return false
+ val parsed = runCatching { parse(text) }.getOrNull() ?: return false
+ val moved = parsed.seq != state.seq
+ state = parsed
+ return moved
+ }
+
+ private fun parse(text: String): State {
+ var seq = 0L
+ var booted = false
+ var paused = false
+ var fastForward = false
+ var fps = 0
+ var import: Import? = null
+ var version = ""
+ val rows = ArrayList()
+ val slots = ArrayList()
+ // Ladders arrive as their own records rather than extra fields on the
+ // row, so that a build of this app older than the engine it is running
+ // against ignores them and still shows a working menu.
+ val ladders = HashMap>>()
+
+ for (line in text.lineSequence()) {
+ if (line.isEmpty()) continue
+ val f = line.split('\t')
+ when (f.getOrNull(0)) {
+ "seq" -> seq = f.getOrNull(1)?.toLongOrNull() ?: 0L
+ "booted" -> booted = f.getOrNull(1) == "1"
+ "paused" -> paused = f.getOrNull(1) == "1"
+ "ff" -> fastForward = f.getOrNull(1) == "1"
+ "fps" -> fps = f.getOrNull(1)?.toIntOrNull() ?: 0
+ "import" ->
+ if (f.size >= 3) {
+ import =
+ Import(
+ stage = f[1],
+ // Sent in thousandths: the wire format is text,
+ // and an integer cannot drift the way a
+ // formatted float can across locales.
+ progress = ((f[2].toIntOrNull() ?: 0) / 1000f).coerceIn(0f, 1f),
+ )
+ }
+ "version" -> version = f.getOrNull(1).orEmpty()
+ "row" ->
+ if (f.size >= 5) {
+ rows.add(Row(f[1], f[2], f[3], f[4] == "step"))
+ }
+ "vals" ->
+ if (f.size >= 4) {
+ ladders[f[1]] = (f[2].toIntOrNull() ?: 0) to f.subList(3, f.size).toList()
+ }
+ "save" ->
+ if (f.size >= 8) {
+ slots.add(
+ Slot(
+ id = f[1],
+ name = f[2],
+ playTime = f[3],
+ badges = f[4].toIntOrNull() ?: 0,
+ caught = f[5].toIntOrNull() ?: 0,
+ active = f[6] == "1",
+ exists = f[7] == "1",
+ ),
+ )
+ }
+ }
+ }
+ val withLadders =
+ rows.map { row ->
+ val (index, values) = ladders[row.id] ?: return@map row
+ row.copy(values = values, selectedIndex = index.coerceIn(0, values.size - 1))
+ }
+ return State(seq, booted, paused, fastForward, fps, import, version, withLadders, slots)
+ }
+
+ fun row(id: String): Row? = state.rows.firstOrNull { it.id == id }
+
+ /**
+ * Queues commands for the engine.
+ *
+ * Returns as soon as the work is handed to the io thread, so a menu tap
+ * costs the frame nothing. Ordering still holds: one looper runs every
+ * write, so two taps reach the engine in the order they were made.
+ *
+ * Appends to any batch the engine has not consumed yet rather than
+ * replacing it: the two processes share no lock, so a player who taps two
+ * rows quickly must not lose the first. Written through a temporary file
+ * and renamed so the engine can never read a half-written batch.
+ */
+ fun send(vararg commands: String) {
+ if (commands.isEmpty()) return
+ io.post { writeCommands(commands) }
+ }
+
+ private fun writeCommands(commands: Array) {
+ runCatching {
+ dir.mkdirs()
+ val pending = if (commandPath.isFile) commandPath.readText() else ""
+ val body = buildString {
+ append(pending)
+ if (pending.isNotEmpty() && !pending.endsWith("\n")) append('\n')
+ for (c in commands) {
+ append(c)
+ append('\n')
+ }
+ }
+ commandTmp.writeText(body)
+ if (!commandTmp.renameTo(commandPath)) {
+ // Same directory, so a rename should never fail; falling back
+ // keeps a menu tap working if it somehow does.
+ commandPath.writeText(body)
+ commandTmp.delete()
+ }
+ }.onFailure { Log.w(TAG, "could not queue engine command: ${it.message}") }
+ }
+
+ fun step(
+ id: String,
+ direction: Int,
+ ) = send("step\t$id\t${if (direction < 0) -1 else 1}")
+
+ /**
+ * Picks a value out of a row's ladder by position.
+ *
+ * The engine still gets there by stepping its own row, so choosing a value
+ * from the menu and cycling to it are the same operation with the same side
+ * effects; this only spares the player the taps.
+ */
+ fun setRow(
+ id: String,
+ index: Int,
+ ) = send("set\t$id\t$index")
+
+ fun activate(id: String) = send("activate\t$id")
+
+ fun saveGame() = send("save")
+
+ fun loadSlot(id: String) = send("loadslot\t$id")
+
+ /** Writes the game into a chosen slot, making it the active one. */
+ fun saveToSlot(id: String) = send("saveslot\t$id")
+
+ fun newSlot() = send("newslot")
+
+ /**
+ * Tabs and newlines separate fields on the wire, so a name containing one
+ * would be read back as extra fields. Spaces are fine and common.
+ */
+ fun renameSlot(
+ id: String,
+ name: String,
+ ) = send("renameslot\t$id\t${name.replace('\t', ' ').replace('\n', ' ').trim()}")
+
+ fun reset() = send("reset")
+
+ /**
+ * Holds the game still. Only the engine's game loop stops -- it keeps
+ * reading commands, or nothing could unpause it, and it keeps drawing, so
+ * the paused frame stays on screen.
+ */
+ fun setPaused(paused: Boolean) = send("pause\t${if (paused) 1 else 0}")
+
+ /** Drives the engine's own speed setting, and restores it when turned off. */
+ fun setFastForward(on: Boolean) = send("ff\t${if (on) 1 else 0}")
+
+ /**
+ * Clears state left by a previous run, so the menu cannot show the last
+ * session's values for the few hundred milliseconds before the engine
+ * publishes its first state. Called once at launch.
+ */
+ fun clearStale() {
+ io.post {
+ runCatching {
+ statePath.delete()
+ commandPath.delete()
+ commandTmp.delete()
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "WnGen1Bridge"
+ private const val THREAD_NAME = "gen1-bridge-io"
+
+ /**
+ * How often the state file is read. Slow while playing, because nothing
+ * is looking at the values; fast while the drawer is open, so a row
+ * shows its new value about as quickly as a tap can register.
+ */
+ private const val POLL_ACTIVE_MS = 200L
+ private const val POLL_IDLE_MS = 700L
+
+ /**
+ * LOVE derives this from the game's identity, so it is fixed by the
+ * engine rather than chosen here.
+ */
+ private const val SAVE_SUBDIR = "save/pokemon-love2d"
+ private const val BRIDGE_SUBDIR = "winnative"
+
+ val EMPTY =
+ State(
+ seq = -1L, booted = false, paused = false, fastForward = false, fps = 0,
+ import = null, version = "", rows = emptyList(), slots = emptyList(),
+ )
+
+ /**
+ * Row ids belonging to the 3D mod rather than the engine, matched by
+ * prefix so a setting the mod adds later needs no change here. The mod
+ * registers two render pipelines ("pipeline:voxel",
+ * "pipeline:tiltshift") and its own settings under its manifest id.
+ *
+ * This only decides the ORDER rows appear in -- 3D first on the
+ * Display pane, since that is what the player turned this mode on for.
+ * Values and labels always come from the engine, and a row that is not
+ * there (mod disabled, or absent from the bundle) simply does not show.
+ */
+ private val MOD_ROW_PREFIXES = listOf("pipeline:", "$VOXEL_MOD_ROW_OWNER:")
+
+ fun isModRow(id: String): Boolean = MOD_ROW_PREFIXES.any { id.startsWith(it) }
+
+ /** Matches the mod's manifest id; see voxelmod/manifest.json. */
+ private const val VOXEL_MOD_ROW_OWNER = "DRAMATIC_SHAPE"
+ }
+}
diff --git a/app/src/main/feature/retro/Gen1LoadingScreen.kt b/app/src/main/feature/retro/Gen1LoadingScreen.kt
new file mode 100644
index 000000000..11ab7a34a
--- /dev/null
+++ b/app/src/main/feature/retro/Gen1LoadingScreen.kt
@@ -0,0 +1,66 @@
+package com.winlator.cmod.feature.retro
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeOut
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import com.winlator.cmod.R
+import com.winlator.cmod.shared.ui.dialog.PreloaderDialogContent
+import com.winlator.cmod.shared.ui.dialog.PreloaderDialogState
+
+/**
+ * Covers the screen while a game is imported for the first time.
+ *
+ * The engine draws its own importer screen -- its logo, its progress bars --
+ * and on this path the player never asked for that: they picked a game in
+ * WinNative and pressed Play, so what they should see is WinNative starting a
+ * game, exactly as it looks for every other game in the library.
+ *
+ * So this is the app's own preloader rather than anything built for the engine.
+ * The only two differences are the ones this screen has that a PC game's does
+ * not: the shortcut's cover art in the middle, and a real progress bar along
+ * the bottom, because the engine reports how far the import has actually got.
+ *
+ * Only ever seen once per game. After the import the engine boots straight into
+ * the game and this never appears again.
+ */
+@Composable
+fun Gen1LoadingScreen(
+ gameName: String,
+ artwork: android.graphics.Bitmap?,
+ state: Gen1EngineBridge.Import?,
+ visible: Boolean,
+) {
+ AnimatedVisibility(
+ visible = visible,
+ // No enter transition: this is already on screen when the activity
+ // opens. Fading in would show the engine's splash underneath it first,
+ // which is the thing it exists to hide.
+ enter = androidx.compose.animation.EnterTransition.None,
+ exit = fadeOut(tween(320)),
+ ) {
+ val context = LocalContext.current
+ val preloader = remember { PreloaderDialogState() }
+
+ preloader.title.value = gameName.ifBlank { stringResource(R.string.preloader_default_name) }
+ preloader.subtitle.value = stringResource(R.string.retro_engine_loading_subtitle)
+ preloader.artwork.value = artwork
+ preloader.bottomProgressBar.value = true
+ // The engine names the stage it is on ("Verifying ...", "Preparing
+ // private game data"), which is more use than a percentage on its own.
+ // Before the first report there is nothing truthful to say beyond that
+ // it is loading.
+ preloader.text.value =
+ state?.stage?.takeIf { it.isNotBlank() }
+ ?: context.getString(R.string.retro_engine_loading)
+ // Indeterminate until the engine reports a figure, so the bar never
+ // claims a progress it does not have.
+ preloader.isIndeterminate.value = state == null
+ preloader.progress.intValue = ((state?.progress ?: 0f) * 100f).toInt().coerceIn(0, 100)
+
+ PreloaderDialogContent(preloader)
+ }
+}
diff --git a/app/src/main/feature/retro/Gen1ModInstaller.kt b/app/src/main/feature/retro/Gen1ModInstaller.kt
new file mode 100644
index 000000000..cda5b3c71
--- /dev/null
+++ b/app/src/main/feature/retro/Gen1ModInstaller.kt
@@ -0,0 +1,127 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.util.Log
+import java.io.File
+import java.util.zip.ZipFile
+
+/**
+ * Installs the 3D mod into the engine's own mod directory.
+ *
+ * The engine discovers mods by listing `mods/` inside its LOVE save directory,
+ * so a mod has to be unpacked there as loose files -- it cannot be read out of
+ * the bundle where it ships. This does that unpacking, and it has to be
+ * WinNative that does it rather than any outside tool:
+ *
+ * On Android the save directory lives under the app's external files
+ * directory, which is served through FUSE. Files written there by a different
+ * uid are only half visible to the app afterwards -- the engine could list the
+ * mod's directories and even see the file NAMES, but every regular file
+ * stat'ed as absent, so the loader found the mod folder, failed to read its
+ * manifest, and reported zero mods installed with no error to explain it.
+ * Files the app writes itself do not have that problem. That is the whole
+ * reason this class exists rather than a shell script.
+ *
+ * The mod ships in the retro bundle as a zip, which is also what the mod's own
+ * packaging tool (modkit) produces, so what is installed here is byte-for-byte
+ * what was published.
+ */
+object Gen1ModInstaller {
+ private const val TAG = "WnGen1Mod"
+
+ /** Matches the manifest id; the engine keys everything off this. */
+ const val MOD_ID = "DRAMATIC_SHAPE"
+
+ private const val SAVE_SUBDIR = "save/pokemon-love2d"
+
+ /**
+ * Written next to the installed mod so a reinstall only happens when the
+ * bundle actually ships something different. Holds the source zip's size
+ * and modification time, which is enough to notice an update without
+ * hashing a few megabytes on every launch.
+ */
+ private const val STAMP = ".winnative-installed"
+
+ fun modsDir(context: Context): File =
+ File(context.getExternalFilesDir(null), "$SAVE_SUBDIR/mods")
+
+ fun installedDir(context: Context): File = File(modsDir(context), MOD_ID)
+
+ /** Where the bundle publishes the mod. */
+ fun sourceZip(context: Context): File =
+ File(RetroBundle.root(context), "data/gen1recomp-mods/$MOD_ID.zip")
+
+ fun isInstalled(context: Context): Boolean =
+ File(installedDir(context), "manifest.json").isFile
+
+ /**
+ * Unpacks the mod if it is missing or out of date. Returns true when the
+ * mod is present and usable afterwards.
+ *
+ * Safe to call on every launch: the common case is one stat of the stamp
+ * file. Runs on the caller's thread, so call it before the engine starts
+ * rather than from the UI thread mid-frame.
+ */
+ fun ensureInstalled(context: Context): Boolean {
+ val zip = sourceZip(context)
+ if (!zip.isFile) {
+ // Not an error: the bundle simply does not carry the mod, and the
+ // engine runs perfectly well in 2D without it.
+ Log.i(TAG, "no mod in bundle at ${zip.absolutePath}")
+ return isInstalled(context)
+ }
+
+ val target = installedDir(context)
+ val stamp = File(target, STAMP)
+ val want = "${zip.length()}:${zip.lastModified()}"
+ if (isInstalled(context) && runCatching { stamp.readText() }.getOrNull() == want) {
+ return true
+ }
+
+ Log.i(TAG, "installing $MOD_ID from ${zip.name}")
+ return runCatching {
+ // Replaced wholesale rather than merged: a file the new version
+ // dropped would otherwise linger and still be loaded.
+ target.deleteRecursively()
+ target.mkdirs()
+ unzipInto(zip, target)
+ stamp.writeText(want)
+ val ok = isInstalled(context)
+ if (!ok) Log.w(TAG, "unpacked $MOD_ID but no manifest.json in it")
+ ok
+ }.getOrElse {
+ Log.w(TAG, "could not install $MOD_ID: ${it.message}")
+ // A half-unpacked mod is worse than none: the loader would read a
+ // partial tree and fail in a way that looks like a broken mod.
+ runCatching { target.deleteRecursively() }
+ false
+ }
+ }
+
+ private fun unzipInto(zip: File, target: File) {
+ val root = target.canonicalPath
+ ZipFile(zip).use { archive ->
+ val entries = archive.entries()
+ while (entries.hasMoreElements()) {
+ val entry = entries.nextElement()
+ val out = File(target, entry.name)
+ // Refuse anything that would escape the mod directory. The zip
+ // comes from our own bundle, but path traversal is cheap to
+ // rule out and expensive to discover later.
+ if (!out.canonicalPath.startsWith(root + File.separator) &&
+ out.canonicalPath != root
+ ) {
+ throw SecurityException("zip entry escapes mod directory: ${entry.name}")
+ }
+ if (entry.isDirectory) {
+ out.mkdirs()
+ continue
+ }
+ out.parentFile?.mkdirs()
+ archive.getInputStream(entry).use { input ->
+ out.outputStream().use { input.copyTo(it) }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/Ps2CheatStaging.kt b/app/src/main/feature/retro/Ps2CheatStaging.kt
new file mode 100644
index 000000000..70785faf1
--- /dev/null
+++ b/app/src/main/feature/retro/Ps2CheatStaging.kt
@@ -0,0 +1,71 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import com.armsx2.PatchRepo
+import com.armsx2.runtime.MainActivityRuntime
+import kr.co.iefriends.pcsx2.NativeApp
+import java.io.File
+
+object Ps2CheatStaging {
+ private fun dir(ctx: Context, isPatch: Boolean): File =
+ File(MainActivityRuntime.assetCopyRoot(ctx), if (isPatch) "patches" else "cheats").apply { mkdirs() }
+
+ private fun stagingFile(ctx: Context, serial: String, isPatch: Boolean): File =
+ File(dir(ctx, isPatch), "$serial.pnach")
+
+ fun read(ctx: Context, serial: String, isPatch: Boolean): List {
+ val f = stagingFile(ctx, serial, isPatch)
+ if (!f.isFile) return emptyList()
+ val src = if (isPatch) "patches" else "custom"
+ return runCatching {
+ PatchRepo.parseInstalled(f.readText(), if (isPatch) "patches" else "cheats").second
+ .map { PatchRepo.Entry(it.name, it.description, it.body, src) }
+ }.getOrDefault(emptyList())
+ }
+
+ fun write(ctx: Context, serial: String, isPatch: Boolean, title: String, entries: List) {
+ val f = stagingFile(ctx, serial, isPatch)
+ if (entries.isEmpty()) {
+ f.delete()
+ return
+ }
+ runCatching { f.writeText(PatchRepo.buildPnach(title.ifBlank { serial }, entries)) }
+ }
+
+ fun applyAll(ctx: Context, serialRaw: String, crcRaw: String) {
+ val serial = serialRaw.trim().uppercase()
+ val crc = crcRaw.trim().uppercase()
+ if (serial.isBlank() || crc.length != 8) return
+ val suffix = "_$crc"
+
+ val stagedPatches = read(ctx, serial, true)
+ val dnas = Ps2DnasBypass.sectionsFor(ctx, serial, crc)
+ val patchByName = LinkedHashMap()
+ stagedPatches.forEach { patchByName[it.name] = it }
+ dnas.forEach { s -> if (s.name !in patchByName) patchByName[s.name] = PatchRepo.Entry(s.name, "", s.body, "patches") }
+ val patchFile = File(dir(ctx, true), "$serial$suffix.pnach")
+ if (patchByName.isEmpty()) {
+ runCatching { patchFile.delete() }
+ } else {
+ runCatching { patchFile.writeText(PatchRepo.buildPnach(serial, patchByName.values.toList())) }
+ }
+ val enabledPatches = (stagedPatches.map { it.name } + dnas.filter { it.enabledByDefault }.map { it.name }).distinct()
+ runCatching { NativeApp.setEnabledPatches(false, patchByName.keys.toTypedArray(), enabledPatches.toTypedArray()) }
+
+ val stagedCheats = read(ctx, serial, false)
+ val cheatFile = File(dir(ctx, false), "$serial$suffix.pnach")
+ if (stagedCheats.isEmpty()) {
+ runCatching { cheatFile.delete() }
+ } else {
+ runCatching { cheatFile.writeText(PatchRepo.buildPnach(serial, stagedCheats)) }
+ runCatching {
+ NativeApp.setSetting("EmuCore", "EnableCheats", "bool", "true")
+ NativeApp.commitSettings()
+ }
+ }
+ val cheatNames = stagedCheats.map { it.name }.toTypedArray()
+ runCatching { NativeApp.setEnabledPatches(true, cheatNames, cheatNames) }
+
+ runCatching { NativeApp.reloadPatches() }
+ }
+}
diff --git a/app/src/main/feature/retro/Ps2DnasBypass.kt b/app/src/main/feature/retro/Ps2DnasBypass.kt
new file mode 100644
index 000000000..386f571b1
--- /dev/null
+++ b/app/src/main/feature/retro/Ps2DnasBypass.kt
@@ -0,0 +1,174 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import com.armsx2.runtime.MainActivityRuntime
+import kr.co.iefriends.pcsx2.NativeApp
+import org.json.JSONObject
+import java.io.File
+
+object Ps2DnasBypass {
+ const val PREF = "wn.ps2.net.dnasbypass"
+
+ @Volatile
+ private var db: Map? = null
+
+ private data class Variant(val name: String, val codes: List, val crc: String?, val auto: Boolean)
+
+ private data class Game(val title: String, val variants: List)
+
+ private fun loadDb(ctx: Context): Map {
+ db?.let { return it }
+ val parsed =
+ runCatching {
+ val text = ctx.assets.open("dnas/dnas_bypass.json").bufferedReader().use { it.readText() }
+ val root = JSONObject(text)
+ val map = HashMap()
+ val keys = root.keys()
+ while (keys.hasNext()) {
+ val serial = keys.next()
+ val g = root.getJSONObject(serial)
+ val vs = g.getJSONArray("cheats")
+ val variants = ArrayList()
+ for (i in 0 until vs.length()) {
+ val v = vs.getJSONObject(i)
+ val codesArr = v.getJSONArray("codes")
+ val codes = (0 until codesArr.length()).map { codesArr.getString(it) }
+ variants.add(
+ Variant(
+ v.getString("name"),
+ codes,
+ v.optString("crc").takeIf { it.isNotBlank() },
+ v.optBoolean("auto", false),
+ ),
+ )
+ }
+ map[serial.uppercase()] = Game(g.getString("title"), variants)
+ }
+ map
+ }.getOrDefault(emptyMap())
+ db = parsed
+ return parsed
+ }
+
+ fun hasBypass(ctx: Context, serial: String?): Boolean =
+ serial != null && loadDb(ctx).containsKey(serial.trim().uppercase())
+
+ fun isEnabled(ctx: Context): Boolean =
+ ctx.getSharedPreferences("ARMSX2", Context.MODE_PRIVATE).getBoolean(PREF, true)
+
+ fun setEnabled(ctx: Context, on: Boolean) {
+ ctx.getSharedPreferences("ARMSX2", Context.MODE_PRIVATE).edit().putBoolean(PREF, on).apply()
+ }
+
+ data class BypassEntry(val name: String, val body: String, val auto: Boolean)
+
+ private fun disabledKey(serial: String) = "wn.ps2.dnas.disabled.${serial.trim().uppercase()}"
+
+ fun disabledNames(ctx: Context, serial: String): Set {
+ val raw = ctx.getSharedPreferences("ARMSX2", Context.MODE_PRIVATE).getString(disabledKey(serial), "") ?: ""
+ if (raw.isBlank()) return emptySet()
+ return runCatching {
+ val arr = org.json.JSONArray(raw)
+ (0 until arr.length()).map { arr.getString(it) }.toSet()
+ }.getOrDefault(emptySet())
+ }
+
+ fun setDisabledNames(ctx: Context, serial: String, names: Set) {
+ val arr = org.json.JSONArray()
+ names.forEach { arr.put(it) }
+ ctx.getSharedPreferences("ARMSX2", Context.MODE_PRIVATE).edit()
+ .putString(disabledKey(serial), arr.toString()).apply()
+ }
+
+ fun ensureSingleDnasEnabled(ctx: Context, serial: String, allNames: Set): Set {
+ if (allNames.isEmpty()) return emptySet()
+ val disabled = disabledNames(ctx, serial).toMutableSet()
+ val enabled = allNames.filter { it !in disabled }
+ if (enabled.size <= 1) return disabled
+ val keep = enabled.first()
+ val next = allNames - keep
+ setDisabledNames(ctx, serial, next)
+ return next
+ }
+
+ fun bypassEntries(ctx: Context, serial: String?): List {
+ val game = loadDb(ctx)[serial?.trim()?.uppercase()] ?: return emptyList()
+ val used = HashMap()
+ return game.variants.mapNotNull { v ->
+ val base = v.name
+ val n = (used[base] ?: 0) + 1
+ used[base] = n
+ val nm = if (n > 1) "$base ($n)" else base
+ val lines = v.codes.mapNotNull { toPatchLine(it) }
+ if (lines.isEmpty()) {
+ null
+ } else {
+ val body = buildString {
+ append("[").append(nm).append("]\n")
+ lines.forEach { append(it).append('\n') }
+ }
+ BypassEntry(nm, body, v.auto)
+ }
+ }
+ }
+
+ private fun toPatchLine(raw: String): String? {
+ val parts = raw.trim().split(Regex("\\s+"))
+ if (parts.size != 2) return null
+ val a = parts[0].uppercase()
+ val v = parts[1].uppercase()
+ if (a.length != 8 || v.length != 8) return null
+ if (!a.all { it.isDigit() || it in 'A'..'F' } || !v.all { it.isDigit() || it in 'A'..'F' }) return null
+ return "patch=1,EE,$a,extended,$v"
+ }
+
+ data class Section(val name: String, val body: String, val enabledByDefault: Boolean)
+
+ fun sectionsFor(ctx: Context, serialRaw: String, crcRaw: String): List {
+ val serial = serialRaw.trim().uppercase()
+ val crc = crcRaw.trim().uppercase()
+ val game = loadDb(ctx)[serial] ?: return emptyList()
+ val globalOn = isEnabled(ctx)
+ val disabled = disabledNames(ctx, serial)
+ val used = HashMap()
+ val out = ArrayList()
+ for (v in game.variants) {
+ val base = v.name
+ val n = (used[base] ?: 0) + 1
+ used[base] = n
+ val nm = if (n > 1) "$base ($n)" else base
+ val lines = v.codes.mapNotNull { toPatchLine(it) }
+ if (lines.isEmpty()) continue
+ val body = buildString {
+ append("[").append(nm).append("]\n")
+ lines.forEach { append(it).append('\n') }
+ }
+ val crcOk = v.crc == null || v.crc.equals(crc, ignoreCase = true)
+ val on = globalOn && v.auto && nm !in disabled && crcOk
+ out.add(Section(nm, body, on))
+ }
+ val enabled = out.filter { it.enabledByDefault }
+ if (enabled.size <= 1) return out
+ val keep = enabled.first().name
+ return out.map { if (it.enabledByDefault && it.name != keep) it.copy(enabledByDefault = false) else it }
+ }
+
+ fun applyWhenReady(ctx: Context) {
+ var tries = 0
+ while (tries < 60) {
+ val s = runCatching { NativeApp.getGameSerial() }.getOrNull()?.takeIf { it.isNotBlank() }
+ val c = runCatching { NativeApp.getGameCRC() }.getOrNull()?.takeIf { it.length == 8 && it != "00000000" }
+ if (s != null && c != null) {
+ runCatching { Ps2CheatStaging.applyAll(ctx, s, c) }
+ .onFailure { android.util.Log.w("Ps2DnasBypass", "applyAll failed for $s/$c", it) }
+ return
+ }
+ try {
+ Thread.sleep(500)
+ } catch (e: InterruptedException) {
+ return
+ }
+ tries++
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/Ps2GameOverlay.kt b/app/src/main/feature/retro/Ps2GameOverlay.kt
new file mode 100644
index 000000000..e3ed277d6
--- /dev/null
+++ b/app/src/main/feature/retro/Ps2GameOverlay.kt
@@ -0,0 +1,1320 @@
+package com.winlator.cmod.feature.retro
+
+import android.view.KeyEvent
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.viewinterop.AndroidView
+import com.armsx2.WinNativeHost
+import com.winlator.cmod.R
+import com.armsx2.runtime.MainActivityRuntime
+import com.armsx2.ui.WindowImpl
+import com.winlator.cmod.runtime.container.ContainerManager
+import com.winlator.cmod.runtime.container.Shortcut
+import com.winlator.cmod.runtime.display.ui.FrameRating
+import com.winlator.cmod.shared.theme.WinNativeTheme
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import kr.co.iefriends.pcsx2.NativeApp
+
+const val PS2_DEFAULT_DNS = "45.7.228.197"
+
+data class Ps2NetHost(val url: String, val ip: String)
+
+object Ps2GameOverlay {
+ private const val FULL = 32767
+
+ @Volatile
+ private var overlayAttached = false
+
+ fun install() {
+ overlayAttached = false
+ WinNativeHost.attachOverlay = attach@{ activity ->
+ if (overlayAttached) return@attach
+ overlayAttached = true
+ attach(activity)
+ }
+ WinNativeHost.applyBootSettings = { ctx -> applyBootConfig(ctx) }
+ }
+
+ private fun loadShortcut(activity: android.app.Activity): Shortcut? {
+ val containerId = activity.intent?.getIntExtra(RetroShortcuts.EXTRA_PS2_CONTAINER_ID, -1) ?: -1
+ val path = activity.intent?.getStringExtra(RetroShortcuts.EXTRA_PS2_SHORTCUT_PATH)
+ if (containerId < 0 || path.isNullOrBlank()) return null
+ val file = java.io.File(path)
+ if (!file.isFile) return null
+ return runCatching {
+ val cm = ContainerManager(activity)
+ val container =
+ if (containerId == ContainerManager.RETRO_CONTAINER_ID) cm.retroContainer else cm.getContainerById(containerId)
+ container?.let { Shortcut(it, file) }
+ }.getOrNull()
+ }
+
+ private fun ps2Prefs(ctx: android.content.Context) =
+ ctx.getSharedPreferences("ARMSX2", android.content.Context.MODE_PRIVATE)
+
+ private fun fmvAspectName(value: Int): String =
+ when (value) {
+ 1 -> "Auto 4:3/3:2"
+ 2 -> "4:3"
+ 3 -> "16:9"
+ else -> "Off"
+ }
+
+ private fun writePatchSettings(ctx: android.content.Context) {
+ val prefs = ps2Prefs(ctx)
+ NativeApp.setSetting("EmuCore", "EnableWideScreenPatches", "bool", prefs.getBoolean("wn.ps2.widescreen", false).toString())
+ NativeApp.setSetting("EmuCore", "EnableNoInterlacingPatches", "bool", prefs.getBoolean("wn.ps2.nointerlace", false).toString())
+ NativeApp.setSetting("EmuCore", "EnableFastBoot", "bool", prefs.getBoolean("wn.ps2.fastboot", true).toString())
+ }
+
+ private fun blankHddFile(ctx: android.content.Context): java.io.File =
+ java.io.File(ctx.filesDir, "hdd/DEV9hdd.raw")
+
+ private fun resolveHdd(ctx: android.content.Context): Pair {
+ val prefs = ps2Prefs(ctx)
+ val image = RetroHddImport.imageFile(ctx, prefs.getString("wn.ps2.hddimage", ""))
+ if (image != null) return image to true
+ if (prefs.getBoolean("wn.ps2.hdd", false)) return blankHddFile(ctx) to true
+ return null to false
+ }
+
+ private fun ensureHddImage(ctx: android.content.Context) {
+ val prefs = ps2Prefs(ctx)
+ if (RetroHddImport.imageFile(ctx, prefs.getString("wn.ps2.hddimage", "")) != null) return
+ if (!prefs.getBoolean("wn.ps2.hdd", false)) return
+ val img = blankHddFile(ctx)
+ if (img.exists() && img.length() > 0L) return
+ runCatching {
+ img.parentFile?.mkdirs()
+ java.io.RandomAccessFile(img, "rw").use { it.setLength(8L * 1024 * 1024 * 1024) }
+ }
+ }
+
+ private fun readHosts(prefs: android.content.SharedPreferences): List {
+ val raw = prefs.getString("wn.ps2.net.hosts", "") ?: ""
+ if (raw.isBlank()) return emptyList()
+ return runCatching {
+ val arr = org.json.JSONArray(raw)
+ (0 until arr.length()).map { i ->
+ val o = arr.getJSONObject(i)
+ Ps2NetHost(o.optString("url"), o.optString("ip", "0.0.0.0"))
+ }.filter { it.url.isNotBlank() }
+ }.getOrDefault(emptyList())
+ }
+
+ private fun writeDev9Settings(ctx: android.content.Context) {
+ val prefs = ps2Prefs(ctx)
+ val on = prefs.getBoolean("wn.ps2.net.enable", true)
+ NativeApp.setSetting("DEV9/Eth", "EthEnable", "bool", on.toString())
+ NativeApp.setSetting("DEV9/Eth", "EthApi", "string", "Sockets")
+ NativeApp.setSetting("DEV9/Eth", "EthDevice", "string", (prefs.getString("wn.ps2.net.ethdevice", "Auto") ?: "Auto").ifBlank { "Auto" })
+ NativeApp.setSetting("DEV9/Eth", "InterceptDHCP", "bool", prefs.getBoolean("wn.ps2.net.dhcp", true).toString())
+ NativeApp.setSetting("DEV9/Eth", "AutoMask", "bool", "true")
+ NativeApp.setSetting("DEV9/Eth", "AutoGateway", "bool", "true")
+ val hosts = readHosts(prefs)
+ val chosen = prefs.getString("wn.ps2.net.dnsmode", "Manual") ?: "Manual"
+ val mode =
+ when {
+ hosts.isNotEmpty() -> "Internal"
+ chosen.equals("Manual", ignoreCase = true) -> "Manual"
+ else -> "Internal"
+ }
+ NativeApp.setSetting("DEV9/Eth", "ModeDNS1", "string", mode)
+ NativeApp.setSetting("DEV9/Eth", "ModeDNS2", "string", "Auto")
+ NativeApp.setSetting("DEV9/Eth", "DNS1", "string", (prefs.getString("wn.ps2.net.dns1", PS2_DEFAULT_DNS) ?: PS2_DEFAULT_DNS).ifBlank { PS2_DEFAULT_DNS })
+ NativeApp.setSetting("DEV9/Eth", "DNS2", "string", (prefs.getString("wn.ps2.net.dns2", "") ?: "").ifBlank { "0.0.0.0" })
+ NativeApp.setSetting("DEV9/Eth/Hosts", "Count", "int", hosts.size.toString())
+ hosts.forEachIndexed { i, h ->
+ NativeApp.setSetting("DEV9/Eth/Hosts/Host$i", "Url", "string", h.url)
+ NativeApp.setSetting("DEV9/Eth/Hosts/Host$i", "Desc", "string", "WinNative")
+ NativeApp.setSetting("DEV9/Eth/Hosts/Host$i", "Address", "string", h.ip.ifBlank { "0.0.0.0" })
+ NativeApp.setSetting("DEV9/Eth/Hosts/Host$i", "Enabled", "bool", "true")
+ }
+ val (hddFile, hddOn) = resolveHdd(ctx)
+ NativeApp.setSetting("DEV9/Hdd", "HddEnable", "bool", hddOn.toString())
+ NativeApp.setSetting("DEV9/Hdd", "HddFile", "string", (hddFile ?: blankHddFile(ctx)).absolutePath)
+ }
+
+ fun applyBootConfig(ctx: android.content.Context) {
+ runCatching {
+ applyTurnipDebug(ctx)
+ ensureHddImage(ctx)
+ writeDev9Settings(ctx)
+ writePatchSettings(ctx)
+ RetroHudSupport.suppressNativePs2Osd()
+ applyDisplayAspect(ctx, live = false)
+ }
+ }
+
+ private fun applyDisplayAspect(
+ ctx: android.content.Context,
+ live: Boolean,
+ ) {
+ val prefs = ps2Prefs(ctx)
+ val aspect = resolveBootAspect(prefs)
+ val name =
+ when (aspect) {
+ 3 -> "16:9"
+ else -> "4:3"
+ }
+ runCatching {
+ NativeApp.setSetting("EmuCore/GS", "AspectRatio", "string", name)
+ NativeApp.setAspectRatio(aspect)
+ if (!live) {
+ NativeApp.commitSettings()
+ }
+ }
+ }
+
+ private fun resolveBootAspect(prefs: android.content.SharedPreferences): Int {
+ val aspect = prefs.getInt("wn.ps2.aspect", 1).coerceIn(0, 3)
+ return when (aspect) {
+ 3 -> 3
+ 2 -> 2
+ else -> 2
+ }
+ }
+
+ private fun applyTurnipDebug(ctx: android.content.Context) {
+ val prefs = ps2Prefs(ctx)
+ val driver = (prefs.getString("wn.ps2.driver", "") ?: "").trim()
+ val usingTurnip = driver.isNotEmpty() && !driver.equals("system", ignoreCase = true)
+ val flags = (prefs.getString("wn.ps2.turnipflags", "") ?: "").trim()
+ runCatching {
+ if (usingTurnip && flags.isNotEmpty()) {
+ android.system.Os.setenv("TU_DEBUG", flags, true)
+ } else {
+ android.system.Os.unsetenv("TU_DEBUG")
+ }
+ }
+ }
+
+ private fun mapFace(keyCode: Int): Int =
+ when (keyCode) {
+ KeyEvent.KEYCODE_BUTTON_X -> KeyEvent.KEYCODE_BUTTON_Y
+ KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_Y -> KeyEvent.KEYCODE_BUTTON_X
+ KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_BUTTON_B
+ else -> keyCode
+ }
+
+ private fun emitAxis(pos: Int, neg: Int, v: Float) {
+ val mag = (kotlin.math.abs(v) * FULL).toInt().coerceAtMost(FULL)
+ NativeApp.setPadButton(pos, if (v > 0f) mag else 0, v > 0f)
+ NativeApp.setPadButton(neg, if (v < 0f) mag else 0, v < 0f)
+ }
+
+ private fun attach(activity: ComponentActivity) {
+ val menu = RetroMenuController()
+ val ps2Screen = mutableStateOf(null)
+ var savesLoadMode = false
+ var pad: RetroInputView? = null
+ val touchVisible =
+ mutableStateOf(
+ ps2Prefs(activity).getBoolean("wn.ps2.touchcontrols", RetroDefaults.touchControls(activity, RetroSystems.PS2.id)),
+ )
+ fun anyGameController(): Boolean =
+ android.view.InputDevice.getDeviceIds().any {
+ com.winlator.cmod.runtime.input.controls.ExternalController.isGameController(android.view.InputDevice.getDevice(it))
+ }
+ val controllerConnected = mutableStateOf(anyGameController())
+ val manualTouchOverride = mutableStateOf(false)
+ val launchRomPath = activity.intent?.getStringExtra(RetroShortcuts.EXTRA_PS2_ROM_PATH).orEmpty()
+ val launchGameName = activity.intent?.getStringExtra(RetroShortcuts.EXTRA_PS2_GAME_NAME).orEmpty()
+ val inputManager = activity.getSystemService(android.hardware.input.InputManager::class.java)
+ inputManager?.registerInputDeviceListener(
+ object : android.hardware.input.InputManager.InputDeviceListener {
+ private fun refreshPadPresence() {
+ val was = controllerConnected.value
+ controllerConnected.value = anyGameController()
+ if (controllerConnected.value != was) manualTouchOverride.value = false
+ val showPad = touchVisible.value && (!controllerConnected.value || manualTouchOverride.value)
+ RetroAchievementOverlayState.syncPlacement(showPad, controllerConnected.value)
+ MainActivityRuntime.runWhenNativeReady {
+ runCatching { RetroPs2OsdPlacement.apply(showPad, controllerConnected.value) }
+ }
+ }
+
+ override fun onInputDeviceAdded(deviceId: Int) = refreshPadPresence()
+
+ override fun onInputDeviceRemoved(deviceId: Int) = refreshPadPresence()
+
+ override fun onInputDeviceChanged(deviceId: Int) = refreshPadPresence()
+ },
+ null,
+ )
+ var customColors = RetroControlLayouts.loadColors(activity, RetroSystems.PS2.id)
+ var wnPaused = false
+ val prefs = activity.getSharedPreferences("ARMSX2", android.content.Context.MODE_PRIVATE)
+ var frameRating: FrameRating? = null
+ var shortcut: Shortcut? = null
+ val pendingExtras = LinkedHashMap()
+ fun persistExtra(key: String, value: String) {
+ val target = shortcut
+ if (target == null) {
+ pendingExtras[key] = value
+ return
+ }
+ @Suppress("OPT_IN_USAGE")
+ GlobalScope.launch(Dispatchers.IO) {
+ runCatching {
+ target.putExtra(key, value)
+ target.saveData()
+ }
+ }
+ }
+ var hudVisible = RetroHudSupport.consoleHudDefault(activity, RetroSystems.PS2.id)
+ var hudStyle = RetroHudSupport.loadPs2HudStyle(activity)
+ var hudElements = RetroHudSupport.loadPs2Elements(activity)
+ val menuToggleGate = RetroHudSupport.MenuToggleGate()
+ val frameSource =
+ RetroHudSupport.Ps2FrameSource(
+ ratingProvider = { frameRating },
+ enabledProvider = { hudVisible },
+ )
+
+ fun persistColors() {
+ RetroControlLayouts.saveColors(activity, RetroSystems.PS2.id, customColors)
+ pad?.setCustomColors(customColors)
+ menu.rebuild()
+ }
+
+ fun applyHudToRating() {
+ val rating = frameRating ?: return
+ RetroHudSupport.applyStyle(rating, hudStyle, hudElements)
+ }
+
+ fun showHud() {
+ var rating = frameRating
+ if (rating == null) {
+ val root = activity.findViewById(android.R.id.content) ?: return
+ val renderer =
+ when (prefs.getString("wn.ps2.renderer", "vulkan")) {
+ "opengl" -> "OpenGL"
+ "software" -> "Software"
+ else -> "Vulkan"
+ }
+ rating = RetroHudSupport.createFrameRating(activity, renderer)
+ frameRating = rating
+ RetroHudSupport.attachFrameRating(root, rating)
+ applyHudToRating()
+ }
+ rating.visibility = View.VISIBLE
+ rating.reset()
+ frameSource.start()
+ }
+
+ fun hideHud() {
+ frameRating?.visibility = View.GONE
+ }
+
+ fun setHudVisible(value: Boolean) {
+ hudVisible = value
+ persistExtra(RetroShortcuts.KEY_HUD, if (value) "1" else "0")
+ if (value) showHud() else hideHud()
+ menu.rebuild()
+ }
+
+ @Suppress("OPT_IN_USAGE")
+ GlobalScope.launch(Dispatchers.IO) {
+ val loaded = loadShortcut(activity)
+ activity.runOnUiThread {
+ shortcut = loaded
+ if (loaded != null) {
+ val perGame = loaded.getExtra(RetroShortcuts.KEY_HUD)
+ if (perGame.isNotEmpty() && (perGame == "1") != hudVisible) {
+ setHudVisible(perGame == "1")
+ }
+ if (pendingExtras.isNotEmpty()) {
+ val queued = pendingExtras.toMap()
+ pendingExtras.clear()
+ @Suppress("OPT_IN_USAGE")
+ GlobalScope.launch(Dispatchers.IO) {
+ runCatching {
+ queued.forEach { (k, v) -> loaded.putExtra(k, v) }
+ loaded.saveData()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun gsSet(key: String, type: String, value: String) {
+ runCatching {
+ NativeApp.setSetting("EmuCore/GS", key, type, value)
+ NativeApp.commitSettings()
+ NativeApp.applyGSSettingsLive()
+ }
+ }
+
+ fun bg(block: () -> Unit) {
+ MainActivityRuntime.runWhenNativeReady { runCatching { block() } }
+ }
+
+ fun nativeUp(): Boolean = MainActivityRuntime.isNativeReady()
+
+ fun gsSetAsync(key: String, type: String, value: String) = bg { gsSet(key, type, value) }
+
+ fun spSet(key: String, type: String, value: String) {
+ bg {
+ NativeApp.setSetting("EmuCore/Speedhacks", key, type, value)
+ NativeApp.commitSettings()
+ }
+ }
+
+ fun spu2Set(key: String, type: String, value: String) {
+ bg {
+ NativeApp.setSetting("SPU2/Output", key, type, value)
+ NativeApp.commitSettings()
+ }
+ }
+
+ fun ensureHddImage() = ensureHddImage(activity)
+
+ fun writeNetworkSettings() = writeDev9Settings(activity)
+
+ MainActivityRuntime.runWhenNativeReady {
+ kotlin.concurrent.thread(name = "ps2-dnas-bypass") {
+ Ps2DnasBypass.applyWhenReady(activity)
+ }
+ }
+
+ bg {
+ NativeApp.setAudioVolume(prefs.getInt("wn.ps2.volume", 100))
+ NativeApp.setAudioMuted(prefs.getBoolean("wn.ps2.muted", false))
+ NativeApp.setAudioSwapChannels(prefs.getBoolean("wn.ps2.swap", false))
+ val customDriver = (prefs.getString("wn.ps2.driver", "") ?: "").trim()
+ .let { it.isNotEmpty() && !it.equals("system", ignoreCase = true) }
+ when {
+ customDriver -> NativeApp.renderVulkan()
+ prefs.getString("wn.ps2.renderer", "vulkan") == "opengl" -> NativeApp.renderOpenGL()
+ prefs.getString("wn.ps2.renderer", "vulkan") == "software" -> NativeApp.renderSoftware()
+ else -> NativeApp.renderVulkan()
+ }
+ NativeApp.renderUpscalemultiplier(prefs.getFloat("wn.ps2.upscale", 1f))
+ RetroHudSupport.suppressNativePs2Osd()
+ val touchOn = prefs.getBoolean("wn.ps2.touchcontrols", RetroDefaults.touchControls(activity, RetroSystems.PS2.id))
+ RetroPs2OsdPlacement.apply(touchOn, false)
+ applyDisplayAspect(activity, live = true)
+ NativeApp.setFrameSkip(prefs.getInt("wn.ps2.frameskip", 0).coerceIn(0, 3))
+ NativeApp.speedhackEecyclerate(prefs.getInt("wn.ps2.eeRate", 0).coerceIn(-3, 3))
+ NativeApp.speedhackEecycleskip(prefs.getInt("wn.ps2.eeSkip", 0).coerceIn(0, 3))
+ NativeApp.setInstantVU1(prefs.getBoolean("wn.ps2.instantVu1", true))
+ NativeApp.renderTvShader(prefs.getInt("wn.ps2.tvshader", 0).coerceIn(0, 7))
+ NativeApp.setSetting("EmuCore/GS", "linear_present_mode", "int", prefs.getInt("wn.ps2.displayfilter", 1).coerceIn(0, 2).toString())
+ NativeApp.setSetting("EmuCore/GS", "filter", "int", prefs.getInt("wn.ps2.filter", 2).coerceIn(0, 3).toString())
+ NativeApp.setSetting("EmuCore/GS", "accurate_blending_unit", "int", prefs.getInt("wn.ps2.blend", 1).coerceIn(0, 5).toString())
+ NativeApp.setSetting("EmuCore/GS", "hw_mipmap", "bool", prefs.getBoolean("wn.ps2.mipmap", true).toString())
+ NativeApp.setSetting("EmuCore/Speedhacks", "vuThread", "bool", prefs.getBoolean("wn.ps2.mtvu", true).toString())
+ NativeApp.setSetting("EmuCore/Speedhacks", "fastCDVD", "bool", prefs.getBoolean("wn.ps2.fastCdvd", false).toString())
+ NativeApp.setSetting("EmuCore/GS", "deinterlace_mode", "int", prefs.getInt("wn.ps2.deinterlace", 0).coerceIn(0, 9).toString())
+ NativeApp.setSetting("EmuCore/GS", "FMVAspectRatioSwitch", "string", fmvAspectName(prefs.getInt("wn.ps2.fmvaspect", 0)))
+ NativeApp.setSetting("EmuCore/GS", "pcrtc_antiblur", "bool", prefs.getBoolean("wn.ps2.antiblur", true).toString())
+ NativeApp.setSetting("EmuCore/Speedhacks", "vuFlagHack", "bool", prefs.getBoolean("wn.ps2.vuFlagHack", true).toString())
+ NativeApp.setSetting("EmuCore/Speedhacks", "IntcStat", "bool", prefs.getBoolean("wn.ps2.intc", true).toString())
+ NativeApp.setSetting("EmuCore/Speedhacks", "WaitLoop", "bool", prefs.getBoolean("wn.ps2.waitloop", true).toString())
+ NativeApp.setSetting("SPU2/Output", "SyncMode", "string", if (prefs.getBoolean("wn.ps2.timestretch", true)) "TimeStretch" else "Disabled")
+ NativeApp.setSetting("SPU2/Output", "BufferMS", "int", prefs.getInt("wn.ps2.audiobuffer", 50).coerceIn(10, 200).toString())
+ NativeApp.setSetting("SPU2/Output", "OutputLatencyMS", "int", prefs.getInt("wn.ps2.audiolatency", 20).coerceIn(5, 200).toString())
+ prefs.getString("wn.ps2.mc.slot1", null)?.takeIf { it.isNotBlank() }?.let { name ->
+ NativeApp.setSetting("MemoryCards", "Slot1_Filename", "string", name)
+ NativeApp.setSetting("MemoryCards", "Slot1_Enable", "bool", "true")
+ }
+ prefs.getString("wn.ps2.mc.slot2", null)?.takeIf { it.isNotBlank() }?.let { name ->
+ NativeApp.setSetting("MemoryCards", "Slot2_Filename", "string", name)
+ NativeApp.setSetting("MemoryCards", "Slot2_Enable", "bool", "true")
+ }
+ ensureHddImage()
+ writeNetworkSettings()
+ runCatching { NativeApp.applyGSSettingsLive() }
+ applyDisplayAspect(activity, live = true)
+ }
+
+ fun openWinNativeScreen(screen: String) {
+ menu.close()
+ ps2Screen.value = screen
+ }
+
+ fun mainEntries(): List =
+ buildList {
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_save_state), RetroDrawerIcons.Save) {
+ savesLoadMode = false
+ menu.showPane(RetroPane.SAVES)
+ },
+ )
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_load_save_state), RetroDrawerIcons.Load) {
+ savesLoadMode = true
+ menu.showPane(RetroPane.SAVES)
+ },
+ )
+ add(RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_achievements), RetroDrawerIcons.Achievements) { openWinNativeScreen("achievements") })
+ add(RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_cheats), RetroDrawerIcons.Cheats) { openWinNativeScreen("cheats") })
+ add(RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_memory_cards), RetroDrawerIcons.Save) { menu.showPane(RetroPane.MEMCARDS) })
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_fast_forward), checked = MainActivityRuntime.fastForwardToggleActive) {
+ (activity as? MainActivityRuntime)?.toggleFastForward()
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_reset), RetroDrawerIcons.Reset) {
+ menu.close()
+ MainActivityRuntime.restart()
+ },
+ )
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_swap_disc), RetroDrawerIcons.Disc) {
+ menu.close()
+ MainActivityRuntime.promptSwapDisc()
+ },
+ )
+ }
+
+ fun saveSlotEntries(): List =
+ (1..8).map { ui ->
+ val slot = ui - 1
+ val filled = runCatching {
+ NativeApp.getGamePathSlot(slot)?.takeIf { it.isNotBlank() }?.let { java.io.File(it).exists() }
+ }.getOrNull() == true
+ RetroMenuEntry.SaveSlot(
+ slot = ui,
+ title = activity.getString(R.string.retro_ps2_slot, ui),
+ subtitle =
+ when {
+ savesLoadMode && filled -> activity.getString(R.string.retro_ps2_slot_tap_to_load)
+ savesLoadMode -> activity.getString(R.string.retro_ps2_slot_empty)
+ filled -> activity.getString(R.string.retro_ps2_slot_tap_to_overwrite)
+ else -> activity.getString(R.string.retro_ps2_slot_empty_tap_to_save)
+ },
+ filled = filled,
+ onClick = {
+ if (savesLoadMode) {
+ if (!filled) {
+ Toast.makeText(activity, activity.getString(R.string.retro_ps2_slot_is_empty, ui), Toast.LENGTH_SHORT).show()
+ } else {
+ menu.close()
+ Thread {
+ val ok = runCatching { NativeApp.loadStateFromSlot(slot) }.getOrDefault(false)
+ activity.runOnUiThread {
+ Toast.makeText(activity, if (ok) activity.getString(R.string.retro_ps2_loaded_slot, ui) else activity.getString(R.string.retro_ps2_could_not_load_slot, ui), Toast.LENGTH_SHORT).show()
+ if (ok) { wnPaused = false; MainActivityRuntime.resume() }
+ }
+ }.start()
+ }
+ } else {
+ Thread {
+ val ok = runCatching { NativeApp.saveStateToSlot(slot) }.getOrDefault(false)
+ activity.runOnUiThread {
+ Toast.makeText(activity, if (ok) activity.getString(R.string.retro_ps2_saved_to_slot, ui) else activity.getString(R.string.retro_ps2_save_failed), Toast.LENGTH_SHORT).show()
+ menu.rebuild()
+ }
+ }.start()
+ }
+ },
+ onRename = {},
+ )
+ }
+
+ var launchMemcardImport: (() -> Unit)? = null
+
+ fun applyMemSlot(slot: Int, name: String?) {
+ prefs.edit().apply {
+ if (name == null) remove("wn.ps2.mc.slot$slot") else putString("wn.ps2.mc.slot$slot", name)
+ }.apply()
+ bg {
+ NativeApp.setSetting("MemoryCards", "Slot${slot}_Enable", "bool", "false")
+ if (name != null) {
+ NativeApp.setSetting("MemoryCards", "Slot${slot}_Filename", "string", name)
+ NativeApp.setSetting("MemoryCards", "Slot${slot}_Enable", "bool", "true")
+ }
+ NativeApp.commitSettings()
+ }
+ }
+
+ fun memcardEntries(): List =
+ buildList {
+ val cards = runCatching { listMemcards(activity) }.getOrDefault(emptyList())
+ val slot1 = prefs.getString("wn.ps2.mc.slot1", "").orEmpty()
+ val slot2 = prefs.getString("wn.ps2.mc.slot2", "").orEmpty()
+ cards.forEach { card ->
+ val assigned = when (card.name) { slot1 -> 1; slot2 -> 2; else -> 0 }
+ add(
+ RetroMenuEntry.SaveSlot(
+ slot = assigned,
+ title = card.name.removeSuffix(".ps2"),
+ subtitle = humanSize(card.length()) +
+ when (assigned) { 1 -> activity.getString(R.string.retro_ps2_slot_1_suffix); 2 -> activity.getString(R.string.retro_ps2_slot_2_suffix); else -> activity.getString(R.string.retro_ps2_tap_to_use_suffix) },
+ filled = assigned != 0,
+ onClick = {
+ when (assigned) {
+ 0 -> applyMemSlot(1, card.name)
+ 1 -> { applyMemSlot(1, null); applyMemSlot(2, card.name) }
+ else -> applyMemSlot(2, null)
+ }
+ menu.rebuild()
+ },
+ onRename = {},
+ ),
+ )
+ }
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_new_memory_card), RetroDrawerIcons.Save) {
+ bg {
+ val existing = runCatching { listMemcards(activity).map { it.name }.toHashSet() }.getOrDefault(hashSetOf())
+ var n = 1
+ var name = "Mcd%03d.ps2".format(n)
+ while (name in existing) { n++; name = "Mcd%03d.ps2".format(n) }
+ runCatching { NativeApp.createMemoryCard(name, 1, 1) }
+ activity.runOnUiThread { menu.rebuild() }
+ }
+ },
+ )
+ add(RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_import_card), RetroDrawerIcons.Load) { launchMemcardImport?.invoke() })
+ }
+
+ fun controlsEntries(): List =
+ buildList {
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_onscreen_controls), checked = touchVisible.value) { value ->
+ touchVisible.value = value
+ if (controllerConnected.value) manualTouchOverride.value = true
+ ps2Prefs(activity).edit().putBoolean("wn.ps2.touchcontrols", value).apply()
+ persistExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (value) "1" else "0")
+ val showPad = value && (!controllerConnected.value || manualTouchOverride.value)
+ RetroAchievementOverlayState.syncPlacement(showPad, controllerConnected.value)
+ bg {
+ RetroPs2OsdPlacement.apply(showPad, controllerConnected.value)
+ }
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(
+ activity.getString(R.string.retro_ps2_adaptive_sticks),
+ subtitle = activity.getString(R.string.retro_ps2_adaptive_sticks_subtitle),
+ checked = ps2Prefs(activity).getBoolean("wn.ps2.adaptivesticks", false),
+ ) { value ->
+ ps2Prefs(activity).edit().putBoolean("wn.ps2.adaptivesticks", value).apply()
+ persistExtra(RetroShortcuts.KEY_ADAPTIVE_STICKS, if (value) "1" else "0")
+ pad?.adaptiveSticks = value
+ pad?.invalidate()
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(
+ activity.getString(R.string.retro_ps2_show_l3r3),
+ subtitle = activity.getString(R.string.retro_ps2_show_l3r3_subtitle),
+ checked = ps2Prefs(activity).getBoolean("wn.ps2.showl3r3", true),
+ ) { value ->
+ ps2Prefs(activity).edit().putBoolean("wn.ps2.showl3r3", value).apply()
+ pad?.showL3R3 = value
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_edit_layout), RetroDrawerIcons.EditLayout) {
+ menu.close()
+ touchVisible.value = true
+ pad?.enterEdit()
+ },
+ )
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_reset_layout), RetroDrawerIcons.Reset) {
+ pad?.resetLayout()
+ Toast.makeText(activity, activity.getString(R.string.retro_ps2_layout_reset), Toast.LENGTH_SHORT).show()
+ },
+ )
+ val invPrefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(activity)
+ fun invToggle(label: String, key: String) =
+ RetroMenuEntry.Toggle(label, checked = invPrefs.getBoolean(key, false)) { value ->
+ invPrefs.edit().putBoolean(key, value).apply()
+ pad?.loadStickInversion()
+ menu.rebuild()
+ }
+ add(invToggle(activity.getString(R.string.retro_ps2_left_stick_invert_x), "retro_inv_lx_ps2"))
+ add(invToggle(activity.getString(R.string.retro_ps2_left_stick_invert_y), "retro_inv_ly_ps2"))
+ add(invToggle(activity.getString(R.string.retro_ps2_right_stick_invert_x), "retro_inv_rx_ps2"))
+ add(invToggle(activity.getString(R.string.retro_ps2_right_stick_invert_y), "retro_inv_ry_ps2"))
+ add(
+ RetroMenuEntry.ColorPick(activity.getString(R.string.retro_ps2_button_color), customColors.button) { value ->
+ customColors = customColors.copy(button = value)
+ persistColors()
+ },
+ )
+ add(
+ RetroMenuEntry.ColorPick(activity.getString(R.string.retro_ps2_letter_color), customColors.text) { value ->
+ customColors = customColors.copy(text = value)
+ persistColors()
+ },
+ )
+ add(
+ RetroMenuEntry.ColorPick(activity.getString(R.string.retro_ps2_shadow_color), customColors.shadow) { value ->
+ customColors = customColors.copy(shadow = value)
+ persistColors()
+ },
+ )
+ add(
+ RetroMenuEntry.ColorPick(activity.getString(R.string.retro_ps2_background_color), customColors.body) { value ->
+ customColors = customColors.copy(body = value)
+ persistColors()
+ },
+ )
+ add(
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_reset_colors), RetroDrawerIcons.Reset) {
+ customColors = RetroCustomColors()
+ persistColors()
+ },
+ )
+ }
+
+ fun displayEntries(): List =
+ buildList {
+ val scales = listOf(1f, 1.5f, 2f, 3f, 4f)
+ val scaleLabels = listOf(
+ activity.getString(R.string.retro_ps2_scale_1x_native),
+ activity.getString(R.string.retro_ps2_scale_1_5x),
+ activity.getString(R.string.retro_ps2_scale_2x),
+ activity.getString(R.string.retro_ps2_scale_3x),
+ activity.getString(R.string.retro_ps2_scale_4x),
+ )
+ val scaleIdx = scales.indexOfFirst { kotlin.math.abs(it - prefs.getFloat("wn.ps2.upscale", 1f)) < 0.01f }.coerceAtLeast(0)
+ add(
+ RetroMenuEntry.Choice(activity.getString(R.string.retro_ps2_resolution_scale), scaleLabels, scaleIdx) { next ->
+ prefs.edit().putFloat("wn.ps2.upscale", scales[next]).apply()
+ bg { NativeApp.renderUpscalemultiplier(scales[next]) }
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Choice(
+ activity.getString(R.string.retro_ps2_aspect_ratio),
+ listOf(
+ activity.getString(R.string.retro_ps2_aspect_stretch),
+ activity.getString(R.string.retro_ps2_aspect_auto_standard),
+ activity.getString(R.string.retro_ps2_aspect_4_3),
+ activity.getString(R.string.retro_ps2_aspect_16_9),
+ ),
+ prefs.getInt("wn.ps2.aspect", 1).coerceIn(0, 3),
+ ) { next ->
+ prefs.edit().putInt("wn.ps2.aspect", next).apply()
+ bg { NativeApp.setAspectRatio(next) }
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Choice(
+ activity.getString(R.string.retro_ps2_fmv_aspect_ratio),
+ listOf(
+ activity.getString(R.string.retro_ps2_shader_off),
+ activity.getString(R.string.retro_ps2_aspect_auto_standard),
+ activity.getString(R.string.retro_ps2_aspect_4_3),
+ activity.getString(R.string.retro_ps2_aspect_16_9),
+ ),
+ prefs.getInt("wn.ps2.fmvaspect", 0).coerceIn(0, 3),
+ ) { next ->
+ prefs.edit().putInt("wn.ps2.fmvaspect", next).apply()
+ gsSetAsync("FMVAspectRatioSwitch", "string", fmvAspectName(next))
+ menu.rebuild()
+ },
+ )
+ val deintLabels = listOf(
+ activity.getString(R.string.retro_ps2_deint_auto),
+ activity.getString(R.string.retro_ps2_deint_off),
+ activity.getString(R.string.retro_ps2_deint_weave_tff),
+ activity.getString(R.string.retro_ps2_deint_weave_bff),
+ activity.getString(R.string.retro_ps2_deint_bob_tff),
+ activity.getString(R.string.retro_ps2_deint_bob_bff),
+ activity.getString(R.string.retro_ps2_deint_blend_tff),
+ activity.getString(R.string.retro_ps2_deint_blend_bff),
+ activity.getString(R.string.retro_ps2_deint_adaptive_tff),
+ activity.getString(R.string.retro_ps2_deint_adaptive_bff),
+ )
+ add(
+ RetroMenuEntry.Choice(activity.getString(R.string.retro_ps2_deinterlace_mode), deintLabels, prefs.getInt("wn.ps2.deinterlace", 0).coerceIn(0, 9)) { next ->
+ prefs.edit().putInt("wn.ps2.deinterlace", next).apply()
+ gsSetAsync("deinterlace_mode", "int", next.toString())
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Choice(
+ activity.getString(R.string.retro_ps2_display_filter),
+ listOf(
+ activity.getString(R.string.retro_ps2_filter_nearest),
+ activity.getString(R.string.retro_ps2_filter_bilinear_smooth),
+ activity.getString(R.string.retro_ps2_filter_bilinear_sharp),
+ ),
+ prefs.getInt("wn.ps2.displayfilter", 1).coerceIn(0, 2),
+ ) { next ->
+ prefs.edit().putInt("wn.ps2.displayfilter", next).apply()
+ gsSetAsync("linear_present_mode", "int", next.toString())
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Choice(
+ activity.getString(R.string.retro_ps2_texture_filter),
+ listOf(
+ activity.getString(R.string.retro_ps2_filter_nearest),
+ activity.getString(R.string.retro_ps2_filter_bilinear_forced),
+ activity.getString(R.string.retro_ps2_filter_bilinear_ps2),
+ activity.getString(R.string.retro_ps2_filter_bilinear_sprites),
+ ),
+ prefs.getInt("wn.ps2.filter", 2).coerceIn(0, 3),
+ ) { next ->
+ prefs.edit().putInt("wn.ps2.filter", next).apply()
+ gsSetAsync("filter", "int", next.toString())
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_mipmapping), checked = prefs.getBoolean("wn.ps2.mipmap", true)) { value ->
+ prefs.edit().putBoolean("wn.ps2.mipmap", value).apply()
+ gsSetAsync("hw_mipmap", "bool", value.toString())
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Choice(
+ activity.getString(R.string.retro_ps2_blending_accuracy),
+ listOf(
+ activity.getString(R.string.retro_ps2_blend_minimum),
+ activity.getString(R.string.retro_ps2_blend_basic),
+ activity.getString(R.string.retro_ps2_blend_medium),
+ activity.getString(R.string.retro_ps2_blend_high),
+ activity.getString(R.string.retro_ps2_blend_full),
+ activity.getString(R.string.retro_ps2_blend_maximum),
+ ),
+ prefs.getInt("wn.ps2.blend", 1).coerceIn(0, 5),
+ ) { next ->
+ prefs.edit().putInt("wn.ps2.blend", next).apply()
+ gsSetAsync("accurate_blending_unit", "int", next.toString())
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_anti_blur), checked = prefs.getBoolean("wn.ps2.antiblur", true)) { value ->
+ prefs.edit().putBoolean("wn.ps2.antiblur", value).apply()
+ gsSetAsync("pcrtc_antiblur", "bool", value.toString())
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Choice(
+ activity.getString(R.string.retro_ps2_crt_tv_shader),
+ listOf(
+ activity.getString(R.string.retro_ps2_shader_off),
+ activity.getString(R.string.retro_ps2_shader_scanline),
+ activity.getString(R.string.retro_ps2_shader_diagonal),
+ activity.getString(R.string.retro_ps2_shader_triangular),
+ activity.getString(R.string.retro_ps2_shader_wave),
+ activity.getString(R.string.retro_ps2_shader_lottes),
+ activity.getString(R.string.retro_ps2_shader_4xrgss),
+ activity.getString(R.string.retro_ps2_shader_nxagss),
+ ),
+ prefs.getInt("wn.ps2.tvshader", 0).coerceIn(0, 7),
+ ) { next ->
+ prefs.edit().putInt("wn.ps2.tvshader", next).apply()
+ bg { NativeApp.renderTvShader(next) }
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Choice(
+ activity.getString(R.string.retro_ps2_frame_skip),
+ listOf(
+ activity.getString(R.string.retro_ps2_frameskip_off),
+ activity.getString(R.string.retro_ps2_frameskip_1),
+ activity.getString(R.string.retro_ps2_frameskip_2),
+ activity.getString(R.string.retro_ps2_frameskip_3),
+ ),
+ prefs.getInt("wn.ps2.frameskip", 0).coerceIn(0, 3),
+ ) { next ->
+ prefs.edit().putInt("wn.ps2.frameskip", next).apply()
+ bg { NativeApp.setFrameSkip(next) }
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_widescreen_patches), checked = prefs.getBoolean("wn.ps2.widescreen", false)) { value ->
+ prefs.edit().putBoolean("wn.ps2.widescreen", value).apply()
+ bg {
+ NativeApp.setSetting("EmuCore", "EnableWideScreenPatches", "bool", value.toString())
+ NativeApp.commitSettings()
+ NativeApp.reloadPatches()
+ }
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_no_interlace_patches), checked = prefs.getBoolean("wn.ps2.nointerlace", false)) { value ->
+ prefs.edit().putBoolean("wn.ps2.nointerlace", value).apply()
+ bg {
+ NativeApp.setSetting("EmuCore", "EnableNoInterlacingPatches", "bool", value.toString())
+ NativeApp.commitSettings()
+ NativeApp.reloadPatches()
+ }
+ menu.rebuild()
+ },
+ )
+ }
+
+ fun soundEntries(): List =
+ buildList {
+ val muted = prefs.getBoolean("wn.ps2.muted", false)
+ val volume = prefs.getInt("wn.ps2.volume", 100)
+ val swap = prefs.getBoolean("wn.ps2.swap", false)
+ add(
+ RetroMenuEntry.Slider(
+ label = activity.getString(R.string.retro_ps2_volume),
+ valueText = activity.getString(R.string.retro_ps2_volume_percent, volume),
+ value = volume.toFloat(),
+ min = 0f,
+ max = 200f,
+ step = 5f,
+ ) { value ->
+ val v = value.toInt()
+ prefs.edit().putInt("wn.ps2.volume", v).apply()
+ bg { NativeApp.setAudioVolume(v) }
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_mute), checked = muted) { value ->
+ prefs.edit().putBoolean("wn.ps2.muted", value).apply()
+ bg { NativeApp.setAudioMuted(value) }
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_time_stretch), checked = prefs.getBoolean("wn.ps2.timestretch", true)) { value ->
+ prefs.edit().putBoolean("wn.ps2.timestretch", value).apply()
+ spu2Set("SyncMode", "string", if (value) "TimeStretch" else "Disabled")
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_swap_stereo_channels), checked = swap) { value ->
+ prefs.edit().putBoolean("wn.ps2.swap", value).apply()
+ bg { NativeApp.setAudioSwapChannels(value) }
+ menu.rebuild()
+ },
+ )
+ }
+
+ fun performanceEntries(): List =
+ buildList {
+ val rateValues = listOf(-3, -2, -1, 0, 1, 2, 3)
+ val rateLabels = listOf(
+ activity.getString(R.string.retro_ps2_rate_50),
+ activity.getString(R.string.retro_ps2_rate_60),
+ activity.getString(R.string.retro_ps2_rate_75),
+ activity.getString(R.string.retro_ps2_rate_100_default),
+ activity.getString(R.string.retro_ps2_rate_130),
+ activity.getString(R.string.retro_ps2_rate_180),
+ activity.getString(R.string.retro_ps2_rate_300),
+ )
+ val curRate = prefs.getInt("wn.ps2.eeRate", 0).coerceIn(-3, 3)
+ add(
+ RetroMenuEntry.Choice(activity.getString(R.string.retro_ps2_ee_cycle_rate), rateLabels, rateValues.indexOf(curRate).coerceAtLeast(0)) { next ->
+ prefs.edit().putInt("wn.ps2.eeRate", rateValues[next]).apply()
+ bg { NativeApp.speedhackEecyclerate(rateValues[next]) }
+ spSet("EECycleRate", "int", rateValues[next].toString())
+ menu.rebuild()
+ },
+ )
+ val skipLabels = listOf(
+ activity.getString(R.string.retro_ps2_skip_off),
+ activity.getString(R.string.retro_ps2_skip_1),
+ activity.getString(R.string.retro_ps2_skip_2),
+ activity.getString(R.string.retro_ps2_skip_3),
+ )
+ add(
+ RetroMenuEntry.Choice(activity.getString(R.string.retro_ps2_ee_cycle_skip), skipLabels, prefs.getInt("wn.ps2.eeSkip", 0).coerceIn(0, 3)) { next ->
+ prefs.edit().putInt("wn.ps2.eeSkip", next).apply()
+ bg { NativeApp.speedhackEecycleskip(next) }
+ spSet("EECycleSkip", "int", next.toString())
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_instant_vu1), checked = prefs.getBoolean("wn.ps2.instantVu1", true)) { value ->
+ prefs.edit().putBoolean("wn.ps2.instantVu1", value).apply()
+ bg { NativeApp.setInstantVU1(value) }
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_vu_flag_hack), checked = prefs.getBoolean("wn.ps2.vuFlagHack", true)) { value ->
+ prefs.edit().putBoolean("wn.ps2.vuFlagHack", value).apply()
+ spSet("vuFlagHack", "bool", value.toString())
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_intc_spin), checked = prefs.getBoolean("wn.ps2.intc", true)) { value ->
+ prefs.edit().putBoolean("wn.ps2.intc", value).apply()
+ spSet("IntcStat", "bool", value.toString())
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_wait_loop), checked = prefs.getBoolean("wn.ps2.waitloop", true)) { value ->
+ prefs.edit().putBoolean("wn.ps2.waitloop", value).apply()
+ spSet("WaitLoop", "bool", value.toString())
+ menu.rebuild()
+ },
+ )
+ add(
+ RetroMenuEntry.Toggle(activity.getString(R.string.retro_ps2_fast_cdvd), checked = prefs.getBoolean("wn.ps2.fastCdvd", false)) { value ->
+ prefs.edit().putBoolean("wn.ps2.fastCdvd", value).apply()
+ spSet("fastCDVD", "bool", value.toString())
+ menu.rebuild()
+ },
+ )
+ }
+
+ fun hudEntries(): List =
+ RetroHudSupport.buildHudEntries(
+ context = activity,
+ hudVisible = hudVisible,
+ style = hudStyle,
+ elements = hudElements,
+ onMaster = { setHudVisible(it) },
+ onStyle = { next ->
+ hudStyle = next
+ RetroHudSupport.savePs2HudStyle(activity, next)
+ applyHudToRating()
+ },
+ onElements = { next ->
+ hudElements = next
+ RetroHudSupport.savePs2Elements(activity, next)
+ applyHudToRating()
+ },
+ onRebuild = { menu.rebuild() },
+ )
+
+ menu.tabs = RetroDrawerTabs.build(activity, includePerformance = true)
+ menu.entriesProvider = { pane ->
+ when (pane) {
+ null -> mainEntries()
+ RetroPane.DISPLAY -> displayEntries()
+ RetroPane.PERFORMANCE -> performanceEntries()
+ RetroPane.SOUND -> soundEntries()
+ RetroPane.SAVES -> saveSlotEntries()
+ RetroPane.MEMCARDS -> memcardEntries()
+ RetroPane.CONTROLS -> controlsEntries()
+ RetroPane.HUD -> hudEntries()
+ RetroPane.NETWORK -> emptyList()
+ RetroPane.SYSTEM -> emptyList()
+ }
+ }
+ menu.bottomProvider = {
+ listOf(
+ if (wnPaused) {
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_resume), RetroDrawerIcons.Resume, active = true) {
+ wnPaused = false
+ MainActivityRuntime.resume()
+ menu.close()
+ }
+ } else {
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_pause), RetroDrawerIcons.Pause) {
+ wnPaused = true
+ menu.rebuild()
+ }
+ },
+ RetroMenuEntry.Action(activity.getString(R.string.retro_ps2_exit), RetroDrawerIcons.Exit, danger = true) {
+ menu.close()
+ runCatching { NativeApp.shutdown() }
+ activity.finish()
+ },
+ )
+ }
+
+ val listener =
+ object : RetroInputView.Listener {
+ override fun onButton(keyCode: Int, down: Boolean) {
+ if (!nativeUp()) return
+ NativeApp.setPadButton(mapFace(keyCode), if (down) FULL else 0, down)
+ }
+
+ override fun onDpad(x: Float, y: Float) {
+ if (!nativeUp()) return
+ NativeApp.setPadButton(KeyEvent.KEYCODE_DPAD_LEFT, if (x < -0.3f) FULL else 0, x < -0.3f)
+ NativeApp.setPadButton(KeyEvent.KEYCODE_DPAD_RIGHT, if (x > 0.3f) FULL else 0, x > 0.3f)
+ NativeApp.setPadButton(KeyEvent.KEYCODE_DPAD_UP, if (y < -0.3f) FULL else 0, y < -0.3f)
+ NativeApp.setPadButton(KeyEvent.KEYCODE_DPAD_DOWN, if (y > 0.3f) FULL else 0, y > 0.3f)
+ }
+
+ override fun onStick(x: Float, y: Float) {
+ if (!nativeUp()) return
+ emitAxis(111, 113, x)
+ emitAxis(112, 110, y)
+ }
+
+ override fun onRightStick(x: Float, y: Float) {
+ if (!nativeUp()) return
+ emitAxis(121, 123, x)
+ emitAxis(122, 120, y)
+ }
+
+ override fun onMenu() {
+ if (!nativeUp()) return
+ activity.runOnUiThread {
+ pad?.releaseAll()
+ menu.rebuild()
+ menu.open()
+ }
+ }
+ }
+
+ WinNativeHost.openMenu = {
+ activity.runOnUiThread {
+ if (activity.isFinishing || activity.isDestroyed) return@runOnUiThread
+ if (!nativeUp()) return@runOnUiThread
+ if (menu.visible) {
+ if (!menuToggleGate.allow()) return@runOnUiThread
+ menu.close()
+ } else {
+ pad?.releaseAll()
+ menu.rebuild()
+ menu.open()
+ }
+ }
+ }
+ WinNativeHost.isMenuOpen = { menu.visible && ps2Screen.value == null }
+ WinNativeHost.menuKeyHandler = handler@{ event ->
+ if (!menu.visible || ps2Screen.value != null) return@handler false
+ if (event.keyCode == KeyEvent.KEYCODE_BACK) return@handler false
+ menu.handleKey(event.keyCode, event.action)
+ }
+ WinNativeHost.menuAxisHandler = { x, y ->
+ if (!menu.visible || ps2Screen.value != null) false
+ else menu.handleAxis(x, y)
+ }
+
+ if (hudVisible) {
+ activity.window.decorView.post {
+ if (!activity.isFinishing && !activity.isDestroyed && hudVisible) showHud()
+ }
+ }
+
+ bg {
+ Ps2RaBridge.pushSharedLogin(activity)
+ NativeApp.setAchievementsOption("notifications", false)
+ NativeApp.setAchievementsOption("leaderboardNotifications", false)
+ NativeApp.setSetting("Achievements", "Notifications", "bool", "false")
+ NativeApp.setSetting("Achievements", "LeaderboardNotifications", "bool", "false")
+ NativeApp.commitSettings()
+ }
+
+ val achievementPollHandler = android.os.Handler(android.os.Looper.getMainLooper())
+ var knownUnlocked = emptySet()
+ var achievementsSeeded = false
+ val achievementPoll =
+ object : Runnable {
+ override fun run() {
+ if (activity.isFinishing || activity.isDestroyed) return
+ if (!nativeUp()) {
+ achievementPollHandler.postDelayed(this, 1500L)
+ return
+ }
+ if (ps2Screen.value == null) {
+ runCatching {
+ val json = NativeApp.getAchievementsJSON() ?: return@runCatching
+ val items = com.armsx2.ui.achievements.parseAchievementItems(json)
+ val unlocked = items.filter { it.unlocked }.map { it.id }.toSet()
+ if (!achievementsSeeded) {
+ knownUnlocked = unlocked
+ achievementsSeeded = true
+ } else {
+ val newly = unlocked - knownUnlocked
+ if (newly.isNotEmpty()) {
+ knownUnlocked = unlocked
+ items.filter { it.id in newly }.forEach { item ->
+ val showPad =
+ touchVisible.value &&
+ (pad?.editMode == true || (!controllerConnected.value || manualTouchOverride.value))
+ RetroAchievementOverlayState.syncPlacement(showPad, controllerConnected.value)
+ RetroAchievementOverlayState.show(item.title, item.points, item.description)
+ }
+ }
+ }
+ }
+ }
+ achievementPollHandler.postDelayed(this, 1500L)
+ }
+ }
+ achievementPollHandler.postDelayed(achievementPoll, 3000L)
+
+ val overlayView =
+ ComposeView(activity).apply {
+ elevation = 2000f
+ setContent {
+ WinNativeTheme {
+ androidx.compose.foundation.layout.Box(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ val memImport = androidx.activity.compose.rememberLauncherForActivityResult(
+ androidx.activity.result.contract.ActivityResultContracts.OpenDocument(),
+ ) { uri ->
+ if (uri != null) {
+ Thread {
+ runCatching {
+ val name = queryName(activity, uri) ?: "Imported.ps2"
+ val fileName = if (name.endsWith(".ps2", true)) name else "$name.ps2"
+ val target = uniqueMemcard(memcardDir(activity), fileName)
+ activity.contentResolver.openInputStream(uri)?.use { input -> target.outputStream().use(input::copyTo) }
+ }
+ activity.runOnUiThread { menu.rebuild() }
+ }.start()
+ }
+ }
+ androidx.compose.runtime.LaunchedEffect(Unit) {
+ launchMemcardImport = { memImport.launch(arrayOf("*/*")) }
+ }
+ val menuVisible = menu.visible
+ androidx.compose.runtime.LaunchedEffect(menuVisible) {
+ if (!menuVisible && !wnPaused && ps2Screen.value == null) {
+ MainActivityRuntime.resume()
+ }
+ }
+ val screen by ps2Screen
+ BackHandler(enabled = screen == null) {
+ when {
+ pad?.editMode == true -> pad?.finishEdit()
+ menu.visible -> menu.handleKey(KeyEvent.KEYCODE_BACK, KeyEvent.ACTION_UP)
+ else -> {
+ pad?.releaseAll()
+ menu.rebuild()
+ menu.open()
+ }
+ }
+ }
+ if (screen != null) {
+ val dismiss = {
+ ps2Screen.value = null
+ wnPaused = false
+ MainActivityRuntime.resume()
+ }
+ BackHandler(enabled = true) { dismiss() }
+ when (screen) {
+ "cheats" -> Ps2CheatsScreen(activity, dismiss)
+ "achievements" ->
+ Ps2AchievementsScreen(
+ activity,
+ romPath = launchRomPath,
+ launchGameName = launchGameName,
+ onBack = dismiss,
+ )
+ }
+ return@WinNativeTheme
+ }
+ val covered = WindowImpl.frontendCovers
+ val showPad = touchVisible.value && (pad?.editMode == true || (!controllerConnected.value || manualTouchOverride.value))
+ if (!covered && showPad) {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { ctx ->
+ RetroInputView(ctx, listener, RetroSystems.PS2).also { view ->
+ view.loadStickInversion()
+ view.adaptiveSticks = ps2Prefs(ctx).getBoolean("wn.ps2.adaptivesticks", false)
+ view.showL3R3 = ps2Prefs(ctx).getBoolean("wn.ps2.showl3r3", true)
+ view.hapticStrength =
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(ctx)
+ .getFloat("retro_haptic_strength", 0.4f)
+ view.setCustomColors(customColors)
+ view.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
+ val w = v.width.toFloat()
+ val h = v.height.toFloat()
+ if (w > 0f && h > 0f) {
+ val aspect = 4f / 3f
+ val gw = (h * aspect).coerceAtMost(w)
+ val gh = gw / aspect
+ val left = (w - gw) * 0.5f
+ val top = (h - gh) * 0.5f
+ view.setGameArea(android.graphics.RectF(left, top, left + gw, top + gh))
+ }
+ }
+ pad = view
+ }
+ },
+ )
+ }
+ if (!covered) {
+ val showPadForHud = touchVisible.value && (pad?.editMode == true || (!controllerConnected.value || manualTouchOverride.value))
+ RetroAchievementOverlayState.syncPlacement(showPadForHud, controllerConnected.value)
+ RetroAchievementOverlayBanner()
+ RetroDrawerMenu(menu)
+ }
+ }
+ }
+ }
+ }
+ activity.addContentView(
+ overlayView,
+ ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
+ )
+
+ val aspectPinHandler = android.os.Handler(android.os.Looper.getMainLooper())
+ var aspectPinTicks = 0
+ val aspectPin =
+ object : Runnable {
+ override fun run() {
+ if (activity.isFinishing || activity.isDestroyed) return
+ applyDisplayAspect(activity, live = true)
+ aspectPinTicks++
+ if (aspectPinTicks < 20) {
+ aspectPinHandler.postDelayed(this, 100L)
+ }
+ }
+ }
+ aspectPinHandler.post(aspectPin)
+
+ val prevCallback = activity.window.callback
+ if (prevCallback != null) {
+ activity.window.callback =
+ object : android.view.Window.Callback by prevCallback {
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (ps2Screen.value == null && menu.visible &&
+ event.keyCode != KeyEvent.KEYCODE_BACK &&
+ menu.handleKey(event.keyCode, event.action)
+ ) {
+ return true
+ }
+ return prevCallback.dispatchKeyEvent(event)
+ }
+
+ override fun dispatchGenericMotionEvent(event: android.view.MotionEvent): Boolean {
+ if (ps2Screen.value == null && menu.visible &&
+ event.source and android.view.InputDevice.SOURCE_JOYSTICK == android.view.InputDevice.SOURCE_JOYSTICK
+ ) {
+ val x = event.getAxisValue(android.view.MotionEvent.AXIS_HAT_X).takeIf { kotlin.math.abs(it) > 0.5f }
+ ?: event.getAxisValue(android.view.MotionEvent.AXIS_X)
+ val y = event.getAxisValue(android.view.MotionEvent.AXIS_HAT_Y).takeIf { kotlin.math.abs(it) > 0.5f }
+ ?: event.getAxisValue(android.view.MotionEvent.AXIS_Y)
+ activity.runOnUiThread { menu.handleAxis(x, y) }
+ return true
+ }
+ return prevCallback.dispatchGenericMotionEvent(event)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/Ps2IsoSerial.kt b/app/src/main/feature/retro/Ps2IsoSerial.kt
new file mode 100644
index 000000000..29d64d6a0
--- /dev/null
+++ b/app/src/main/feature/retro/Ps2IsoSerial.kt
@@ -0,0 +1,108 @@
+package com.winlator.cmod.feature.retro
+
+import java.io.File
+import java.io.RandomAccessFile
+
+object Ps2IsoSerial {
+ private const val LOGICAL = 2048
+
+ private data class Layout(val sectorSize: Int, val dataOffset: Int)
+
+ fun serialOf(file: File): String? {
+ if (!file.isFile || file.length() < 17L * LOGICAL) return null
+ return runCatching {
+ RandomAccessFile(file, "r").use { raf ->
+ val layout = detectLayout(raf) ?: return null
+ val pvd = readLogical(raf, layout, 16)
+ val rootLba = le32(pvd, 156 + 2)
+ val rootSize = le32(pvd, 156 + 10)
+ if (rootLba <= 0 || rootSize <= 0) return null
+ val cnf = findFile(raf, layout, rootLba, rootSize, "SYSTEM.CNF") ?: return null
+ val text = readExtent(raf, layout, cnf.first, cnf.second.coerceAtMost(64 * 1024))
+ .toString(Charsets.US_ASCII)
+ parseBoot(text)
+ }
+ }.getOrNull()
+ }
+
+ private fun detectLayout(raf: RandomAccessFile): Layout? {
+ for (layout in listOf(Layout(2048, 0), Layout(2352, 16), Layout(2352, 24))) {
+ val sec = runCatching { readLogical(raf, layout, 16) }.getOrNull() ?: continue
+ if (sec[0].toInt() == 1 &&
+ sec[1] == 'C'.code.toByte() && sec[2] == 'D'.code.toByte() &&
+ sec[3] == '0'.code.toByte() && sec[4] == '0'.code.toByte() && sec[5] == '1'.code.toByte()
+ ) {
+ return layout
+ }
+ }
+ return null
+ }
+
+ private fun readLogical(raf: RandomAccessFile, layout: Layout, lba: Int): ByteArray {
+ val buf = ByteArray(LOGICAL)
+ raf.seek(lba.toLong() * layout.sectorSize + layout.dataOffset)
+ raf.readFully(buf)
+ return buf
+ }
+
+ private fun readExtent(raf: RandomAccessFile, layout: Layout, lba: Int, size: Int): ByteArray {
+ val out = ByteArray(size)
+ var done = 0
+ var sector = lba
+ while (done < size) {
+ val sec = readLogical(raf, layout, sector)
+ val n = minOf(LOGICAL, size - done)
+ System.arraycopy(sec, 0, out, done, n)
+ done += n
+ sector++
+ }
+ return out
+ }
+
+ private fun findFile(
+ raf: RandomAccessFile,
+ layout: Layout,
+ dirLba: Int,
+ dirSize: Int,
+ target: String,
+ ): Pair? {
+ var remaining = dirSize
+ var sector = dirLba
+ while (remaining > 0) {
+ val sec = readLogical(raf, layout, sector)
+ var off = 0
+ while (off < LOGICAL) {
+ val recLen = sec[off].toInt() and 0xFF
+ if (recLen == 0) break
+ val nameLen = sec[off + 32].toInt() and 0xFF
+ if (nameLen > 0 && off + 33 + nameLen <= LOGICAL) {
+ val rawName = String(sec, off + 33, nameLen, Charsets.US_ASCII)
+ val name = rawName.substringBefore(';').trim()
+ if (name.equals(target, ignoreCase = true)) {
+ return le32(sec, off + 2) to le32(sec, off + 10)
+ }
+ }
+ off += recLen
+ }
+ remaining -= LOGICAL
+ sector++
+ }
+ return null
+ }
+
+ private fun le32(b: ByteArray, off: Int): Int =
+ (b[off].toInt() and 0xFF) or
+ ((b[off + 1].toInt() and 0xFF) shl 8) or
+ ((b[off + 2].toInt() and 0xFF) shl 16) or
+ ((b[off + 3].toInt() and 0xFF) shl 24)
+
+ private fun parseBoot(text: String): String? {
+ val line = text.lineSequence().firstOrNull { it.trimStart().startsWith("BOOT", ignoreCase = true) && it.contains("cdrom", ignoreCase = true) }
+ ?: return null
+ val afterColon = line.substringAfter("cdrom0:", "").trimStart('\\', '/', ' ')
+ val token = afterColon.substringBefore(';').substringBefore(' ').trim()
+ if (token.isBlank()) return null
+ val serial = token.replace("_", "-").replace(".", "").uppercase()
+ return serial.takeIf { it.length in 8..12 && it.contains('-') }
+ }
+}
diff --git a/app/src/main/feature/retro/Ps2MenuScreens.kt b/app/src/main/feature/retro/Ps2MenuScreens.kt
new file mode 100644
index 000000000..dbf776d77
--- /dev/null
+++ b/app/src/main/feature/retro/Ps2MenuScreens.kt
@@ -0,0 +1,637 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.armsx2.runtime.MainActivityRuntime
+import com.winlator.cmod.R
+import com.winlator.cmod.shared.ui.nav.LocalPaneNav
+import com.winlator.cmod.shared.ui.nav.PaneNavRegistry
+import com.winlator.cmod.shared.ui.nav.bindPaneNav
+import com.winlator.cmod.shared.ui.nav.paneNavItem
+import java.io.File
+import kotlinx.coroutines.launch
+import kr.co.iefriends.pcsx2.NativeApp
+
+internal fun memcardDir(context: Context): File =
+ File(MainActivityRuntime.assetCopyRoot(context), "memcards").apply { mkdirs() }
+
+internal fun listMemcards(context: Context): List =
+ memcardDir(context).listFiles().orEmpty()
+ .filter { it.isFile && it.extension.equals("ps2", true) }
+ .sortedBy { it.name.lowercase() }
+
+internal fun humanSize(bytes: Long): String =
+ when {
+ bytes >= 1_048_576 -> "${bytes / 1_048_576} MB"
+ bytes >= 1024 -> "${bytes / 1024} KB"
+ else -> "$bytes B"
+ }
+
+internal fun queryName(context: Context, uri: android.net.Uri): String? =
+ runCatching {
+ context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
+ if (it.moveToFirst()) it.getString(0) else null
+ }
+ }.getOrNull()
+
+internal fun uniqueMemcard(dir: File, name: String): File {
+ dir.mkdirs()
+ val base = name.substringBeforeLast('.', name)
+ val ext = name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }
+ var target = File(dir, name)
+ var i = 2
+ while (target.exists()) target = File(dir, "$base-$i$ext").also { i++ }
+ return target
+}
+
+@Composable
+fun Ps2CheatsScreen(
+ context: Context,
+ onBack: () -> Unit,
+) {
+ val scope = androidx.compose.runtime.rememberCoroutineScope()
+ var loading by remember { mutableStateOf(true) }
+ var status by remember { mutableStateOf("") }
+ var title by remember { mutableStateOf("") }
+ var serial by remember { mutableStateOf("") }
+ var crc by remember { mutableStateOf("") }
+ var entries by remember { mutableStateOf>(emptyList()) }
+ var selected by remember { mutableStateOf>(emptySet()) }
+ var patchEntries by remember { mutableStateOf>(emptyList()) }
+ var selectedPatches by remember { mutableStateOf>(emptySet()) }
+ var repoCheatNames by remember { mutableStateOf>(emptySet()) }
+ var repoPatchNames by remember { mutableStateOf>(emptySet()) }
+ var dnasEntries by remember { mutableStateOf>(emptyList()) }
+ var dnasGlobalOn by remember { mutableStateOf(true) }
+ var dnasDisabled by remember { mutableStateOf>(emptySet()) }
+
+ androidx.compose.runtime.LaunchedEffect(Unit) {
+ data class LocalCheats(
+ val s: String,
+ val c: String?,
+ val stagedC: List,
+ val stagedP: List,
+ val dnas: List,
+ val globalOn: Boolean,
+ val disabled: Set,
+ )
+ val local = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ val s = runCatching { NativeApp.getGameSerial() }.getOrNull()?.takeIf { it.isNotBlank() }
+ val c = runCatching { NativeApp.getGameCRC() }.getOrNull()?.takeIf { it.length == 8 }
+ if (s == null) null
+ else {
+ val stagedC = Ps2CheatStaging.read(context, s, false)
+ val stagedP = Ps2CheatStaging.read(context, s, true)
+ val dnas = Ps2DnasBypass.bypassEntries(context, s).filter { it.auto }
+ val globalOn = context.getSharedPreferences("ARMSX2", Context.MODE_PRIVATE).getBoolean(Ps2DnasBypass.PREF, true)
+ val disabled = Ps2DnasBypass.ensureSingleDnasEnabled(context, s, dnas.map { it.name }.toSet())
+ LocalCheats(s, c, stagedC, stagedP, dnas, globalOn, disabled)
+ }
+ }
+ if (local == null) {
+ status = context.getString(R.string.retro_scr_no_game_serial)
+ loading = false
+ } else {
+ serial = local.s
+ crc = local.c.orEmpty()
+ title = local.s
+ entries = local.stagedC
+ patchEntries = local.stagedP
+ selected = local.stagedC.map { it.name }.toSet()
+ selectedPatches = local.stagedP.map { it.name }.toSet()
+ dnasEntries = local.dnas
+ dnasGlobalOn = local.globalOn
+ dnasDisabled = local.disabled
+ loading = false
+ val remote = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ runCatching {
+ if (local.c != null) com.armsx2.PatchRepo.fetchForGame(local.s, local.c)
+ else com.armsx2.PatchRepo.fetchForSerial(local.s)
+ }.getOrNull()
+ }
+ if (remote != null) {
+ title = remote.gametitle.takeIf { it.isNotBlank() } ?: local.s
+ status = remote.error.orEmpty()
+ val repoCheats = remote.entries.filter { it.source != "patches" }
+ val repoPatches = remote.entries.filter { it.source == "patches" }
+ repoCheatNames = repoCheats.map { it.name }.toSet()
+ repoPatchNames = repoPatches.map { it.name }.toSet()
+ entries = repoCheats + local.stagedC.filter { st -> repoCheats.none { it.name == st.name } }
+ patchEntries = repoPatches + local.stagedP.filter { st -> repoPatches.none { it.name == st.name } }
+ }
+ }
+ }
+
+ fun persist() {
+ if (serial.isBlank()) return
+ Ps2CheatStaging.write(context, serial, false, serial, entries.filter { it.name in selected })
+ Ps2CheatStaging.write(context, serial, true, serial, patchEntries.filter { it.name in selectedPatches })
+ }
+
+ fun apply() {
+ persist()
+ val s = serial
+ val c = crc
+ scope.launch {
+ val ok = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ runCatching { Ps2CheatStaging.applyAll(context, s, c); true }.getOrDefault(false)
+ }
+ status = if (ok) {
+ context.getString(R.string.retro_scr_applied_both, selected.size, selectedPatches.size + dnasEntries.count { dnasGlobalOn && it.name !in dnasDisabled })
+ } else {
+ context.getString(R.string.retro_scr_couldnt_apply_cheats)
+ }
+ }
+ }
+
+ fun toggleDnas(name: String, currentlyOn: Boolean) {
+ if (serial.isBlank()) return
+ if (currentlyOn) {
+ dnasDisabled = dnasDisabled + name
+ } else {
+ if (!dnasGlobalOn) { Ps2DnasBypass.setEnabled(context, true); dnasGlobalOn = true }
+ val all = dnasEntries.map { it.name }.toSet()
+ dnasDisabled = all - name
+ }
+ Ps2DnasBypass.setDisabledNames(context, serial, dnasDisabled)
+ }
+
+ var showAdd by remember { mutableStateOf(false) }
+ var newName by remember { mutableStateOf("") }
+ var newCodes by remember { mutableStateOf("") }
+ var newIsPatch by remember { mutableStateOf(false) }
+
+ val importLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
+ androidx.activity.result.contract.ActivityResultContracts.OpenDocument(),
+ ) { uri ->
+ if (uri != null) {
+ scope.launch {
+ val text = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ runCatching { context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } }.getOrNull()
+ }
+ val imported = if (text != null) parsePnachFile(text, if (newIsPatch) "patches" else "custom") else emptyList()
+ if (imported.isNotEmpty()) {
+ if (newIsPatch) {
+ patchEntries = patchEntries.filterNot { e -> imported.any { it.name == e.name } } + imported
+ selectedPatches = selectedPatches + imported.map { it.name }
+ } else {
+ entries = entries.filterNot { e -> imported.any { it.name == e.name } } + imported
+ selected = selected + imported.map { it.name }
+ }
+ persist()
+ status = ""
+ showAdd = false
+ } else {
+ status = context.getString(R.string.retro_scr_cheat_invalid)
+ }
+ }
+ }
+ }
+
+ Ps2WindowedScaffold(title = stringResource(R.string.retro_scr_cheats), onBack = onBack, header = {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ val openAdd = { newName = ""; newCodes = ""; newIsPatch = false; showAdd = true }
+ OutlinedButton(
+ onClick = openAdd,
+ modifier = Modifier.paneNavItem(onActivate = openAdd),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp),
+ ) {
+ Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.width(18.dp).height(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(stringResource(R.string.retro_scr_add))
+ }
+ if (!loading && (entries.isNotEmpty() || patchEntries.isNotEmpty() || serial.isNotBlank())) {
+ OutlinedButton(onClick = { apply() }, modifier = Modifier.paneNavItem(onActivate = { apply() })) {
+ Text(stringResource(R.string.retro_scr_apply))
+ }
+ }
+ }
+ }) {
+ when {
+ loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ androidx.compose.material3.CircularProgressIndicator()
+ }
+ entries.isEmpty() && patchEntries.isEmpty() && dnasEntries.isEmpty() -> Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
+ Text(status.ifBlank { stringResource(R.string.retro_scr_no_cheats_or_patches) }, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ else -> Column(Modifier.fillMaxSize()) {
+ if (status.isNotBlank()) {
+ Text(status, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
+ }
+ LazyColumn(
+ Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (dnasEntries.isNotEmpty()) {
+ item(key = "hdr_dnas") { Ps2SectionHeader(stringResource(R.string.retro_gs_dnas_group)) }
+ items(dnasEntries, key = { "dnas_" + it.name }) { e ->
+ val on = dnasGlobalOn && e.name !in dnasDisabled
+ Ps2CheatRow(
+ com.armsx2.PatchRepo.Entry(e.name, stringResource(R.string.retro_gs_dnas_entry_desc), e.body, "dnas"),
+ on, isPatch = true, onToggle = { toggleDnas(e.name, on) },
+ )
+ }
+ }
+ if (entries.isNotEmpty()) {
+ item(key = "hdr_cheats") { Ps2SectionHeader(stringResource(R.string.retro_scr_cheats_section)) }
+ items(entries, key = { "cheat_" + it.name }) { entry ->
+ val checked = entry.name in selected
+ val toggle = {
+ selected = if (entry.name in selected) selected - entry.name else selected + entry.name
+ persist()
+ }
+ val isCustom = entry.name !in repoCheatNames
+ Ps2CheatRow(
+ entry, checked, isPatch = false, onToggle = toggle,
+ onDelete = if (isCustom) {
+ {
+ entries = entries.filterNot { it.name == entry.name }
+ selected = selected - entry.name
+ persist()
+ }
+ } else null,
+ )
+ }
+ }
+ if (patchEntries.isNotEmpty()) {
+ item(key = "hdr_patches") { Ps2SectionHeader(stringResource(R.string.retro_scr_patches_section)) }
+ items(patchEntries, key = { "patch_" + it.name }) { entry ->
+ val checked = entry.name in selectedPatches
+ val toggle = {
+ selectedPatches = if (entry.name in selectedPatches) selectedPatches - entry.name else selectedPatches + entry.name
+ persist()
+ }
+ val isCustom = entry.name !in repoPatchNames
+ Ps2CheatRow(
+ entry, checked, isPatch = true, onToggle = toggle,
+ onDelete = if (isCustom) {
+ {
+ patchEntries = patchEntries.filterNot { it.name == entry.name }
+ selectedPatches = selectedPatches - entry.name
+ persist()
+ }
+ } else null,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (showAdd) {
+ AlertDialog(
+ onDismissRequest = { showAdd = false },
+ title = { Text(stringResource(R.string.retro_scr_add_cheat)) },
+ text = {
+ Column {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ val selCheat = { newIsPatch = false }
+ val selPatch = { newIsPatch = true }
+ OutlinedButton(
+ onClick = selCheat,
+ modifier = Modifier.weight(1f).paneNavItem(onActivate = selCheat),
+ colors = if (!newIsPatch)
+ androidx.compose.material3.ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
+ else androidx.compose.material3.ButtonDefaults.outlinedButtonColors(),
+ ) { Text(stringResource(R.string.retro_scr_target_cheat)) }
+ OutlinedButton(
+ onClick = selPatch,
+ modifier = Modifier.weight(1f).paneNavItem(onActivate = selPatch),
+ colors = if (newIsPatch)
+ androidx.compose.material3.ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
+ else androidx.compose.material3.ButtonDefaults.outlinedButtonColors(),
+ ) { Text(stringResource(R.string.retro_scr_target_patch)) }
+ }
+ androidx.compose.foundation.layout.Spacer(Modifier.padding(4.dp))
+ OutlinedTextField(
+ value = newName,
+ onValueChange = { newName = it },
+ label = { Text(stringResource(R.string.retro_scr_cheat_name)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ androidx.compose.foundation.layout.Spacer(Modifier.padding(4.dp))
+ OutlinedTextField(
+ value = newCodes,
+ onValueChange = { newCodes = it },
+ label = { Text(stringResource(R.string.retro_scr_cheat_codes)) },
+ placeholder = { Text("2021A268 00000000") },
+ modifier = Modifier.fillMaxWidth(),
+ minLines = 3,
+ )
+ androidx.compose.foundation.layout.Spacer(Modifier.padding(4.dp))
+ OutlinedButton(
+ onClick = { importLauncher.launch(arrayOf("*/*")) },
+ modifier = Modifier.fillMaxWidth().paneNavItem(onActivate = { importLauncher.launch(arrayOf("*/*")) }),
+ ) { Text(stringResource(R.string.retro_scr_import_from_file)) }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ val entry = buildCustomPnachEntry(context, newName, newCodes, if (newIsPatch) "patches" else "custom")
+ if (entry != null) {
+ if (newIsPatch) {
+ patchEntries = patchEntries.filterNot { it.name == entry.name } + entry
+ selectedPatches = selectedPatches + entry.name
+ } else {
+ entries = entries.filterNot { it.name == entry.name } + entry
+ selected = selected + entry.name
+ }
+ persist()
+ status = ""
+ showAdd = false
+ } else {
+ status = context.getString(R.string.retro_scr_cheat_invalid)
+ }
+ }) { Text(stringResource(R.string.retro_scr_add)) }
+ },
+ dismissButton = {
+ TextButton(onClick = { showAdd = false }) { Text(stringResource(R.string.retro_scr_cancel)) }
+ },
+ )
+ }
+}
+
+internal fun buildCustomPnachEntry(context: Context, name: String, codes: String, source: String): com.armsx2.PatchRepo.Entry? {
+ val lines =
+ codes.lineSequence().mapNotNull { raw ->
+ val t = raw.trim()
+ when {
+ t.isEmpty() || t.startsWith("//") -> null
+ t.startsWith("patch=") -> t
+ else -> {
+ val parts = t.split(Regex("\\s+"))
+ if (parts.size == 2 && parts[0].length == 8 && parts[1].length == 8 &&
+ parts[0].all { it.isDigit() || it.uppercaseChar() in 'A'..'F' } &&
+ parts[1].all { it.isDigit() || it.uppercaseChar() in 'A'..'F' }
+ ) {
+ "patch=1,EE,${parts[0].uppercase()},extended,${parts[1].uppercase()}"
+ } else {
+ null
+ }
+ }
+ }
+ }.toList()
+ if (name.isBlank() || lines.isEmpty()) return null
+ val body = buildString {
+ append("[").append(name.trim()).append("]\n")
+ lines.forEach { append(it).append("\n") }
+ }
+ val desc = context.getString(if (source == "patches") R.string.retro_scr_custom_patch else R.string.retro_scr_custom_cheat)
+ return com.armsx2.PatchRepo.Entry(name.trim(), desc, body, source)
+}
+
+internal fun parsePnachFile(text: String, source: String): List =
+ runCatching {
+ com.armsx2.PatchRepo.parseInstalled(text, if (source == "patches") "patches" else "cheats").second
+ .map { com.armsx2.PatchRepo.Entry(it.name, it.description, it.body, source) }
+ }.getOrDefault(emptyList())
+
+@Composable
+internal fun Ps2SectionHeader(text: String) {
+ Text(
+ text,
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(top = 4.dp, bottom = 2.dp),
+ )
+}
+
+@Composable
+internal fun Ps2CheatRow(
+ entry: com.armsx2.PatchRepo.Entry,
+ checked: Boolean,
+ isPatch: Boolean,
+ onToggle: () -> Unit,
+ onDelete: (() -> Unit)? = null,
+) {
+ Surface(
+ onClick = onToggle,
+ modifier = Modifier.fillMaxWidth().paneNavItem(cornerRadius = 12.dp, onActivate = onToggle),
+ shape = RoundedCornerShape(12.dp),
+ color = if (checked) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant,
+ border = if (checked) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ if (isPatch) {
+ Surface(
+ shape = RoundedCornerShape(4.dp),
+ color = if (checked) MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.15f) else MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
+ ) {
+ Text(
+ stringResource(R.string.retro_scr_patch_badge),
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold,
+ color = if (checked) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 1.dp),
+ )
+ }
+ }
+ Text(
+ entry.name,
+ fontWeight = FontWeight.SemiBold,
+ color = if (checked) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ if (entry.description.isNotBlank()) {
+ Text(
+ entry.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = if (checked) MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.75f) else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (checked) {
+ Spacer(Modifier.width(12.dp))
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = null,
+ tint = if (checked) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ if (onDelete != null) {
+ Spacer(Modifier.width(4.dp))
+ IconButton(onClick = onDelete, modifier = Modifier.paneNavItem(onActivate = onDelete)) {
+ Icon(
+ Icons.Outlined.Delete,
+ contentDescription = stringResource(R.string.retro_scr_delete),
+ tint = if (checked) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun Ps2AchievementsScreen(
+ context: Context,
+ romPath: String,
+ launchGameName: String,
+ onBack: () -> Unit,
+) {
+ val gameName =
+ remember(romPath, launchGameName) {
+ launchGameName.takeIf { it.isNotBlank() }
+ ?: romPath.takeIf { it.isNotBlank() }?.let { java.io.File(it).nameWithoutExtension }
+ ?: context.getString(R.string.retro_ps2_tab_menu)
+ }
+ RetroAchievementsScreen(
+ systemId = RetroSystems.PS2.id,
+ gameName = gameName,
+ romPath = romPath,
+ inSession = true,
+ onClose = onBack,
+ useNativePs2 = true,
+ floatingOverGame = true,
+ )
+}
+
+@Composable
+fun Ps2WindowedScaffold(
+ title: String,
+ onBack: () -> Unit,
+ header: @Composable (() -> Unit)? = null,
+ content: @Composable () -> Unit,
+) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val registry = remember { PaneNavRegistry() }
+ androidx.compose.runtime.DisposableEffect(Unit) {
+ val activity = context as? android.app.Activity
+ val restore = activity?.window?.bindPaneNav(registry, onDismiss = onBack)
+ onDispose { restore?.invoke() }
+ }
+ androidx.compose.runtime.CompositionLocalProvider(LocalPaneNav provides registry) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(androidx.compose.ui.graphics.Color.Transparent)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onBack,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Surface(
+ modifier = Modifier
+ .padding(24.dp)
+ .widthIn(max = 560.dp)
+ .fillMaxWidth()
+ .fillMaxHeight(0.8f)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = {},
+ ),
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 3.dp,
+ shadowElevation = 8.dp,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
+ ) {
+ Column(Modifier.fillMaxSize()) {
+ Row(
+ Modifier.fillMaxWidth().height(56.dp).padding(start = 8.dp, end = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onBack, modifier = Modifier.paneNavItem(onActivate = onBack)) {
+ Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.retro_scr_back), tint = MaterialTheme.colorScheme.onSurface)
+ }
+ Text(title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(start = 4.dp))
+ Spacer(Modifier.weight(1f))
+ header?.invoke()
+ }
+ Box(Modifier.fillMaxSize()) { content() }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun Ps2OverlayScaffold(
+ title: String,
+ onBack: () -> Unit,
+ action: @Composable (() -> Unit)? = null,
+ content: @Composable () -> Unit,
+) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val registry = remember { PaneNavRegistry() }
+ androidx.compose.runtime.DisposableEffect(Unit) {
+ val activity = context as? android.app.Activity
+ val restore = activity?.window?.bindPaneNav(registry, onDismiss = onBack)
+ onDispose { restore?.invoke() }
+ }
+ androidx.compose.runtime.CompositionLocalProvider(LocalPaneNav provides registry) {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onBack, modifier = Modifier.paneNavItem(onActivate = onBack)) {
+ Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.retro_scr_back), tint = MaterialTheme.colorScheme.onSurface)
+ }
+ Text(title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(start = 4.dp))
+ Spacer(Modifier.width(0.dp).weight(1f))
+ action?.invoke()
+ }
+ Box(Modifier.fillMaxSize()) { content() }
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/Ps2RaBridge.kt b/app/src/main/feature/retro/Ps2RaBridge.kt
new file mode 100644
index 000000000..14448615c
--- /dev/null
+++ b/app/src/main/feature/retro/Ps2RaBridge.kt
@@ -0,0 +1,28 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import kr.co.iefriends.pcsx2.NativeApp
+
+object Ps2RaBridge {
+ private const val EMUCORE_HARDCORE_REGISTERED = false
+
+ fun pushSharedLogin(context: Context): Boolean {
+ if (!RetroAchievementsManager.isLoggedIn(context)) return false
+ val ra = context.getSharedPreferences("retro_achievements", Context.MODE_PRIVATE)
+ val user = ra.getString("username", null)?.takeIf { it.isNotBlank() } ?: return false
+ val token = ra.getString("token", null)?.takeIf { it.isNotBlank() } ?: return false
+ val hardcore =
+ EMUCORE_HARDCORE_REGISTERED && RetroAchievementsManager.isHardcorePreferred(context)
+ return runCatching {
+ NativeApp.setSetting("Achievements", "Enabled", "bool", "true")
+ NativeApp.setSetting("Achievements", "Username", "string", user)
+ NativeApp.setSetting("Achievements", "Token", "string", token)
+ NativeApp.setSetting("Achievements", "ChallengeMode", "bool", hardcore.toString())
+ NativeApp.setSetting("Achievements", "HardcoreMode", "bool", hardcore.toString())
+ NativeApp.commitSettings()
+ runCatching { NativeApp.setHardcoreMode(hardcore) }
+ NativeApp.clearAchievementsHostOverride()
+ true
+ }.getOrDefault(false)
+ }
+}
diff --git a/app/src/main/feature/retro/RetroAchievementOverlay.kt b/app/src/main/feature/retro/RetroAchievementOverlay.kt
new file mode 100644
index 000000000..267b094c2
--- /dev/null
+++ b/app/src/main/feature/retro/RetroAchievementOverlay.kt
@@ -0,0 +1,200 @@
+package com.winlator.cmod.feature.retro
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.zIndex
+import kotlinx.coroutines.delay
+
+object RetroAchievementOverlayState {
+ data class Banner(
+ val title: String,
+ val points: Int,
+ val description: String = "",
+ )
+
+ var banner by mutableStateOf(null)
+ private set
+ var sessionMessage by mutableStateOf(null)
+ private set
+ var useDisplayArea by mutableStateOf(false)
+
+ fun syncPlacement(
+ touchControlsVisible: Boolean,
+ controllerConnected: Boolean,
+ ) {
+ useDisplayArea = touchControlsVisible && !controllerConnected
+ }
+
+ fun show(
+ title: String,
+ points: Int,
+ description: String = "",
+ ) {
+ if (title.contains("Unknown Emulator", ignoreCase = true)) return
+ banner = Banner(title, points, description)
+ }
+
+ fun showMessage(message: String) {
+ val trimmed = message.trim()
+ if (trimmed.isEmpty()) return
+ sessionMessage = trimmed
+ }
+
+ fun clear() {
+ banner = null
+ }
+
+ fun clearMessage() {
+ sessionMessage = null
+ }
+}
+
+object RetroPs2OsdPlacement {
+ const val TOP_LEFT = 1
+ const val TOP_CENTER = 2
+
+ fun messagePos(touchControlsVisible: Boolean, controllerConnected: Boolean): Int =
+ if (touchControlsVisible && !controllerConnected) TOP_CENTER else TOP_LEFT
+
+ fun apply(
+ touchControlsVisible: Boolean,
+ controllerConnected: Boolean,
+ ) {
+ val pos = messagePos(touchControlsVisible, controllerConnected)
+ runCatching {
+ kr.co.iefriends.pcsx2.NativeApp.setSetting("EmuCore/GS", "OsdMessagesPos", "int", pos.toString())
+ kr.co.iefriends.pcsx2.NativeApp.commitSettings()
+ kr.co.iefriends.pcsx2.NativeApp.applyGSSettingsLive()
+ }
+ }
+}
+
+@Composable
+fun BoxScope.RetroAchievementOverlayBanner() {
+ val banner = RetroAchievementOverlayState.banner
+ val message = RetroAchievementOverlayState.sessionMessage
+ val displayArea = RetroAchievementOverlayState.useDisplayArea
+
+ LaunchedEffect(banner) {
+ if (banner != null) {
+ delay(4500)
+ if (RetroAchievementOverlayState.banner == banner) {
+ RetroAchievementOverlayState.clear()
+ }
+ }
+ }
+ LaunchedEffect(message) {
+ if (message != null) {
+ delay(2800)
+ if (RetroAchievementOverlayState.sessionMessage == message) {
+ RetroAchievementOverlayState.clearMessage()
+ }
+ }
+ }
+
+ val align = if (displayArea) Alignment.TopCenter else Alignment.TopStart
+ val hPad = if (displayArea) 60.dp else 10.dp
+ val tPad = if (displayArea) 18.dp else 10.dp
+
+ Column(
+ modifier =
+ Modifier
+ .align(align)
+ .zIndex(1000f)
+ .padding(start = hPad, end = hPad, top = tPad)
+ .widthIn(max = 285.dp),
+ horizontalAlignment = if (displayArea) Alignment.CenterHorizontally else Alignment.Start,
+ ) {
+ AnimatedVisibility(
+ visible = banner != null,
+ enter = fadeIn() + slideInVertically { -it / 2 },
+ exit = fadeOut() + slideOutVertically { -it / 2 },
+ ) {
+ val b = banner
+ if (b != null) {
+ Column(
+ modifier =
+ Modifier
+ .background(Color(0xF0121824), RoundedCornerShape(9.dp))
+ .border(1.dp, Color(0xFF1A9FFF).copy(alpha = 0.55f), RoundedCornerShape(9.dp))
+ .padding(horizontal = 10.dp, vertical = 7.dp),
+ ) {
+ Text(
+ "ACHIEVEMENT UNLOCKED",
+ color = Color(0xFF1A9FFF),
+ fontSize = 8.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp,
+ )
+ Text(
+ b.title,
+ color = Color(0xFFF0F4FF),
+ fontSize = 11.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ if (b.description.isNotBlank()) {
+ Text(
+ b.description,
+ color = Color(0xFF93A6BC),
+ fontSize = 9.sp,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+ Text(
+ "+${b.points}",
+ color = Color(0xFF35D0BA),
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(top = 3.dp),
+ )
+ }
+ }
+ }
+ AnimatedVisibility(
+ visible = message != null,
+ enter = fadeIn() + slideInVertically { -it / 2 },
+ exit = fadeOut() + slideOutVertically { -it / 2 },
+ modifier = Modifier.padding(top = if (banner != null) 8.dp else 0.dp),
+ ) {
+ val text = message
+ if (text != null) {
+ Text(
+ text = text,
+ color = Color(0xFFF0F4FF),
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .background(Color(0xE0121824), RoundedCornerShape(10.dp))
+ .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(10.dp))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/RetroAchievementsActivity.kt b/app/src/main/feature/retro/RetroAchievementsActivity.kt
new file mode 100644
index 000000000..82735eff8
--- /dev/null
+++ b/app/src/main/feature/retro/RetroAchievementsActivity.kt
@@ -0,0 +1,718 @@
+package com.winlator.cmod.feature.retro
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.EmojiEvents
+import androidx.compose.material.icons.outlined.Lock
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import coil.compose.AsyncImage
+import com.winlator.cmod.R
+import com.winlator.cmod.shared.ui.nav.bindPaneNav
+import com.winlator.cmod.shared.ui.nav.paneNavItem
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private val BgDark = Color(0xFF12121B)
+private val SurfaceDark = Color(0xFF171722)
+private val CardBorder = Color(0xFF2A2A3A)
+private val Accent = Color(0xFF1A9FFF)
+private val AccentGlow = Color(0xFF58A6FF)
+private val TextPrimary = Color(0xFFF0F4FF)
+private val TextSecondary = Color(0xFF93A6BC)
+private val Gold = Color(0xFFF2C14E)
+private val Scrim = Color(0xFF000000)
+private val StatusOnline = Color(0xFF3FB950)
+
+class RetroAchievementsActivity : ComponentActivity() {
+ companion object {
+ const val EXTRA_SYSTEM_ID = "system_id"
+ const val EXTRA_GAME_NAME = "game_name"
+ const val EXTRA_ROM_PATH = "rom_path"
+ const val EXTRA_IN_SESSION = "in_session"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false)
+ val controller = androidx.core.view.WindowInsetsControllerCompat(window, window.decorView)
+ controller.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
+ controller.systemBarsBehavior =
+ androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ val systemId = intent.getStringExtra(EXTRA_SYSTEM_ID)
+ val gameName = intent.getStringExtra(EXTRA_GAME_NAME) ?: "Retro Game"
+ val romPath = intent.getStringExtra(EXTRA_ROM_PATH) ?: ""
+ val inSession = intent.getBooleanExtra(EXTRA_IN_SESSION, false)
+ setContent {
+ com.winlator.cmod.shared.theme.WinNativeTheme {
+ androidx.compose.runtime.CompositionLocalProvider(
+ com.winlator.cmod.shared.ui.nav.LocalPaneNav provides navRegistry,
+ ) {
+ RetroAchievementsScreen(
+ systemId = systemId,
+ gameName = gameName,
+ romPath = romPath,
+ inSession = inSession,
+ onClose = { finish() },
+ )
+ }
+ }
+ }
+ }
+
+ private val navRegistry = com.winlator.cmod.shared.ui.nav.PaneNavRegistry()
+ private var restoreNav: (() -> Unit)? = null
+
+ override fun onResume() {
+ super.onResume()
+ restoreNav = window.bindPaneNav(navRegistry, onDismiss = { finish() })
+ }
+
+ override fun onPause() {
+ restoreNav?.invoke()
+ restoreNav = null
+ super.onPause()
+ }
+}
+
+@Composable
+internal fun RetroAchievementsScreen(
+ systemId: String?,
+ gameName: String,
+ romPath: String,
+ inSession: Boolean,
+ onClose: () -> Unit,
+ useNativePs2: Boolean = false,
+ floatingOverGame: Boolean = false,
+ nativeJson: (() -> String)? = null,
+) {
+ val context = LocalContext.current
+ val refreshScope = rememberCoroutineScope()
+ val nativeMode = useNativePs2 || nativeJson != null
+ fun nativeAchievementsJson(): String =
+ nativeJson?.invoke() ?: kr.co.iefriends.pcsx2.NativeApp.getAchievementsJSON().orEmpty()
+ var loggedIn by remember {
+ mutableStateOf(
+ RetroAchievementsManager.isLoggedIn(context) ||
+ (useNativePs2 &&
+ runCatching {
+ val raw = kr.co.iefriends.pcsx2.NativeApp.getAchievementsJSON().orEmpty()
+ if (raw.isBlank()) false else org.json.JSONObject(raw).optBoolean("loggedIn")
+ }.getOrDefault(false)),
+ )
+ }
+ var enabled by remember { mutableStateOf(RetroAchievementsManager.isEnabled(context)) }
+ var hardcore by remember { mutableStateOf(RetroAchievementsManager.isHardcorePreferred(context)) }
+ var confirmHardcore by remember { mutableStateOf(false) }
+ var loading by remember { mutableStateOf(loggedIn) }
+ var summary by remember { mutableStateOf(null) }
+ var achievements by remember { mutableStateOf>(emptyList()) }
+
+ fun refreshFromManager() {
+ refreshScope.launch {
+ val s = withContext(Dispatchers.IO) { RetroAchievementsManager.getSummary() }
+ val a = withContext(Dispatchers.IO) { RetroAchievementsManager.getAchievements() }
+ if (s != null) summary = s
+ if (a.isNotEmpty()) achievements = a
+ }
+ }
+
+ fun refreshFromPs2Native() {
+ refreshScope.launch {
+ val pair =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val raw = nativeAchievementsJson()
+ if (raw.isBlank()) return@runCatching null
+ val root = org.json.JSONObject(raw)
+ val items =
+ runCatching { com.armsx2.ui.achievements.parseAchievementItems(raw) }
+ .getOrDefault(emptyList())
+ val mapped =
+ items.map { a ->
+ RetroAchievement(
+ id = a.id.toLong(),
+ title = a.title,
+ description = a.description,
+ unlocked = a.unlocked,
+ points = a.points,
+ progress = a.progress,
+ badgeUrl = a.iconUrl,
+ badgeLockedUrl = a.iconUrl,
+ bucket = a.subsetId,
+ )
+ }
+ val unlocked = mapped.count { it.unlocked }
+ val pointsUnlocked = mapped.filter { it.unlocked }.sumOf { it.points }
+ val pointsTotal = mapped.sumOf { it.points }
+ val s =
+ RetroGameSummary(
+ gameId = 0,
+ title = root.optString("gameTitle").ifBlank { gameName },
+ badgeUrl = root.optString("gameIconUrl"),
+ total = mapped.size,
+ unlocked = unlocked,
+ pointsTotal = pointsTotal,
+ pointsUnlocked = pointsUnlocked,
+ )
+ Triple(root.optBoolean("loggedIn"), s, mapped)
+ }.getOrNull()
+ }
+ if (pair != null) {
+ if (pair.first || RetroAchievementsManager.isLoggedIn(context)) {
+ loggedIn = true
+ }
+ summary = pair.second
+ if (pair.third.isNotEmpty()) achievements = pair.third
+ } else if (RetroAchievementsManager.isLoggedIn(context)) {
+ loggedIn = true
+ }
+ }
+ }
+
+ fun refresh() {
+ if (nativeMode) refreshFromPs2Native() else refreshFromManager()
+ }
+
+ DisposableEffect(Unit) {
+ if (!nativeMode) {
+ RetroAchievementsManager.stateListener = { refresh() }
+ }
+ onDispose { RetroAchievementsManager.stateListener = null }
+ }
+
+ LaunchedEffect(Unit) {
+ if (useNativePs2 && RetroAchievementsManager.isLoggedIn(context)) {
+ loggedIn = true
+ if (!inSession) withContext(Dispatchers.IO) { Ps2RaBridge.pushSharedLogin(context) }
+ }
+ }
+
+ LaunchedEffect(loggedIn) {
+ if (!loggedIn) {
+ loading = false
+ return@LaunchedEffect
+ }
+ if (nativeMode) {
+ loading = true
+ if (useNativePs2 && !inSession) withContext(Dispatchers.IO) { Ps2RaBridge.pushSharedLogin(context) }
+ var attempts = 0
+ var last: List = emptyList()
+ while (attempts < 24) {
+ last =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val raw = nativeAchievementsJson()
+ com.armsx2.ui.achievements.parseAchievementItems(raw).map { a ->
+ RetroAchievement(
+ id = a.id.toLong(),
+ title = a.title,
+ description = a.description,
+ unlocked = a.unlocked,
+ points = a.points,
+ progress = a.progress,
+ badgeUrl = a.iconUrl,
+ badgeLockedUrl = a.iconUrl,
+ bucket = a.subsetId,
+ )
+ }
+ }.getOrDefault(emptyList())
+ }
+ if (last.isNotEmpty()) break
+ delay(400)
+ attempts++
+ }
+ achievements = last
+ if (last.isNotEmpty()) {
+ val unlocked = last.count { it.unlocked }
+ summary =
+ RetroGameSummary(
+ gameId = 0,
+ title = gameName,
+ badgeUrl = "",
+ total = last.size,
+ unlocked = unlocked,
+ pointsTotal = last.sumOf { it.points },
+ pointsUnlocked = last.filter { it.unlocked }.sumOf { it.points },
+ )
+ loading = false
+ return@LaunchedEffect
+ }
+ if (romPath.isNotBlank()) {
+ RetroAchievementsManager.loadGameForBrowsing(context, systemId, romPath)
+ var browseAttempts = 0
+ while (RetroAchievementsManager.loadState == RetroAchievementsManager.LOAD_LOADING &&
+ browseAttempts < 80
+ ) {
+ delay(250)
+ browseAttempts++
+ }
+ withContext(Dispatchers.IO) { RetroAchievementsManager.getSummary() }
+ ?.let { summary = it }
+ withContext(Dispatchers.IO) { RetroAchievementsManager.getAchievements() }
+ .takeIf { it.isNotEmpty() }
+ ?.let { achievements = it }
+ }
+ loading = false
+ return@LaunchedEffect
+ }
+ if (!inSession) {
+ RetroAchievementsManager.loadGameForBrowsing(context, systemId, romPath)
+ }
+ loading = RetroAchievementsManager.loadState == RetroAchievementsManager.LOAD_LOADING
+ var attempts = 0
+ while (RetroAchievementsManager.loadState == RetroAchievementsManager.LOAD_LOADING && attempts < 80) {
+ delay(250)
+ attempts++
+ }
+ summary = withContext(Dispatchers.IO) { RetroAchievementsManager.getSummary() }
+ achievements = withContext(Dispatchers.IO) { RetroAchievementsManager.getAchievements() }
+ loading = false
+ }
+
+ if (confirmHardcore) {
+ RetroHardcoreConfirmDialog(
+ onConfirm = {
+ confirmHardcore = false
+ hardcore = true
+ RetroAchievementsManager.setHardcorePreferred(context, true)
+ },
+ onDismiss = { confirmHardcore = false },
+ )
+ }
+
+ BoxWithConstraints(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .then(
+ if (floatingOverGame) {
+ Modifier.background(Color.Transparent)
+ } else {
+ Modifier
+ .background(Scrim.copy(alpha = 0.62f))
+ .windowInsetsPadding(WindowInsets.systemBars)
+ },
+ )
+ .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onClose() },
+ contentAlignment = Alignment.Center,
+ ) {
+ val dialogWidth = (maxWidth - if (floatingOverGame) 48.dp else 32.dp).coerceAtMost(560.dp)
+ val dialogHeight =
+ if (floatingOverGame) {
+ maxHeight * 0.8f
+ } else {
+ (maxHeight - 40.dp).coerceIn(340.dp, 680.dp)
+ }
+ Surface(
+ modifier =
+ Modifier
+ .then(if (floatingOverGame) Modifier.padding(24.dp) else Modifier)
+ .widthIn(min = 320.dp, max = dialogWidth)
+ .fillMaxWidth()
+ .then(
+ if (floatingOverGame) {
+ Modifier.fillMaxHeight(0.8f)
+ } else {
+ Modifier.height(dialogHeight)
+ },
+ )
+ .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
+ shape = RoundedCornerShape(if (floatingOverGame) 20.dp else 16.dp),
+ color = BgDark,
+ border = BorderStroke(1.dp, CardBorder),
+ tonalElevation = if (floatingOverGame) 3.dp else 8.dp,
+ shadowElevation = if (floatingOverGame) 8.dp else 0.dp,
+ ) {
+ Column(Modifier.fillMaxSize()) {
+ Header(gameName, summary, onClose)
+ HorizontalDivider(color = CardBorder, thickness = 0.5.dp)
+ Box(Modifier.fillMaxWidth().weight(1f)) {
+ when {
+ !loggedIn ->
+ LoginPane { user, pass, onResult ->
+ RetroAchievementsManager.login(context, user, pass) { ok, msg ->
+ if (ok && useNativePs2) {
+ refreshScope.launch(Dispatchers.IO) {
+ runCatching { Ps2RaBridge.pushSharedLogin(context) }
+ }
+ }
+ onResult(ok, msg)
+ if (ok) loggedIn = true
+ }
+ }
+ loading ->
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(color = Accent, modifier = Modifier.size(32.dp))
+ }
+ else ->
+ AchievementsList(
+ achievements = achievements,
+ summary = summary,
+ enabled = enabled,
+ hardcore = hardcore,
+ onEnabledChange = {
+ enabled = it
+ RetroAchievementsManager.setEnabled(context, it)
+ },
+ onHardcoreChange = {
+ if (it) {
+ confirmHardcore = true
+ } else {
+ hardcore = false
+ RetroAchievementsManager.setHardcorePreferred(context, false)
+ }
+ },
+ onLogout = {
+ RetroAchievementsManager.logout(context)
+ loggedIn = false
+ summary = null
+ achievements = emptyList()
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun Header(gameName: String, summary: RetroGameSummary?, onClose: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Box(
+ Modifier.size(38.dp).clip(RoundedCornerShape(10.dp)).background(Gold.copy(alpha = 0.16f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.EmojiEvents, contentDescription = null, tint = Gold, modifier = Modifier.size(22.dp))
+ }
+ Column(Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.retro_ach_brand),
+ color = TextSecondary,
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp,
+ )
+ Text(
+ gameName,
+ style = MaterialTheme.typography.titleMedium,
+ color = TextPrimary,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (summary != null && summary.total > 0) {
+ Surface(color = Gold.copy(alpha = 0.14f), shape = RoundedCornerShape(8.dp)) {
+ Text(
+ "${summary.unlocked} / ${summary.total}",
+ color = Gold,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
+ )
+ }
+ }
+ IconButton(onClick = onClose, modifier = Modifier.size(40.dp).paneNavItem(onActivate = onClose)) {
+ Icon(Icons.Outlined.Close, contentDescription = "Close", tint = TextSecondary, modifier = Modifier.size(22.dp))
+ }
+ }
+}
+
+@Composable
+private fun LoginPane(onLogin: (String, String, (Boolean, String?) -> Unit) -> Unit) {
+ val context = LocalContext.current
+ var username by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var busy by remember { mutableStateOf(false) }
+ var error by remember { mutableStateOf(null) }
+ Column(
+ Modifier.fillMaxSize().padding(24.dp).verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Text(
+ stringResource(R.string.retro_ach_login_intro),
+ color = TextSecondary,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ val fieldColors =
+ OutlinedTextFieldDefaults.colors(
+ focusedTextColor = TextPrimary,
+ unfocusedTextColor = TextPrimary,
+ disabledTextColor = TextSecondary,
+ cursorColor = AccentGlow,
+ focusedBorderColor = AccentGlow,
+ unfocusedBorderColor = CardBorder,
+ focusedLabelColor = AccentGlow,
+ unfocusedLabelColor = TextSecondary,
+ focusedContainerColor = SurfaceDark,
+ unfocusedContainerColor = SurfaceDark,
+ )
+ OutlinedTextField(
+ value = username,
+ onValueChange = { username = it },
+ label = { Text(stringResource(R.string.retro_ach_username)) },
+ singleLine = true,
+ colors = fieldColors,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ label = { Text(stringResource(R.string.retro_ach_password)) },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ colors = fieldColors,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ error?.let { Text(it, color = Color(0xFFE07B6B), style = MaterialTheme.typography.labelMedium) }
+ val submit = {
+ if (username.isBlank() || password.isBlank()) {
+ error = context.getString(R.string.retro_ach_enter_credentials)
+ } else {
+ busy = true
+ error = null
+ onLogin(username.trim(), password) { ok, msg ->
+ busy = false
+ if (!ok) error = msg ?: context.getString(R.string.retro_ach_login_failed)
+ }
+ }
+ }
+ Button(
+ onClick = submit,
+ enabled = !busy,
+ colors = androidx.compose.material3.ButtonDefaults.buttonColors(containerColor = Accent, contentColor = Color.White),
+ modifier = Modifier.fillMaxWidth().paneNavItem(isEntry = true, onActivate = { submit() }),
+ ) {
+ if (busy) {
+ CircularProgressIndicator(color = Color.White, modifier = Modifier.size(18.dp))
+ } else {
+ Text(stringResource(R.string.retro_ach_sign_in))
+ }
+ }
+ Text(
+ stringResource(R.string.retro_ach_password_disclaimer),
+ color = TextSecondary,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+}
+
+@Composable
+private fun AchievementsList(
+ achievements: List,
+ summary: RetroGameSummary?,
+ enabled: Boolean,
+ hardcore: Boolean,
+ onEnabledChange: (Boolean) -> Unit,
+ onHardcoreChange: (Boolean) -> Unit,
+ onLogout: () -> Unit,
+) {
+ val ordered = achievements.sortedWith(
+ compareByDescending { it.unlocked }.thenBy { it.title },
+ )
+ Column(Modifier.fillMaxSize()) {
+ if (summary != null && summary.total > 0) {
+ LinearProgressIndicator(
+ progress = { summary.unlocked.toFloat() / summary.total },
+ color = Gold,
+ trackColor = SurfaceDark,
+ modifier = Modifier.fillMaxWidth().height(4.dp),
+ )
+ }
+ Column(
+ Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()).padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ SettingsRow(stringResource(R.string.retro_ach_achievements_enabled), enabled, onEnabledChange)
+ if (RetroAchievementsManager.HARDCORE_SUPPORTED) {
+ SettingsRow(stringResource(R.string.retro_ach_hardcore_mode), hardcore, onHardcoreChange)
+ }
+ if (summary != null) {
+ Text(
+ stringResource(R.string.retro_ach_points, summary.pointsUnlocked, summary.pointsTotal),
+ color = TextSecondary,
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(top = 2.dp, start = 4.dp),
+ )
+ }
+ if (achievements.isEmpty()) {
+ Text(
+ stringResource(R.string.retro_ach_none_found),
+ color = TextSecondary,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(24.dp),
+ )
+ } else {
+ ordered.forEach { AchievementRow(it) }
+ }
+ Spacer(Modifier.height(8.dp))
+ TextButton(onClick = onLogout, modifier = Modifier.fillMaxWidth().paneNavItem(onActivate = onLogout)) {
+ Text(stringResource(R.string.retro_ach_sign_out), color = TextSecondary)
+ }
+ }
+ }
+}
+
+@Composable
+private fun SettingsRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = SurfaceDark.copy(alpha = 0.5f),
+ border = BorderStroke(1.dp, CardBorder),
+ modifier = Modifier.fillMaxWidth().paneNavItem(cornerRadius = 12.dp, onActivate = { onChange(!checked) }),
+ ) {
+ Row(
+ Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(label, color = TextPrimary, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
+ Switch(
+ checked = checked,
+ onCheckedChange = onChange,
+ colors =
+ androidx.compose.material3.SwitchDefaults.colors(
+ checkedThumbColor = Color.White,
+ checkedTrackColor = Accent,
+ uncheckedTrackColor = SurfaceDark,
+ uncheckedBorderColor = CardBorder,
+ ),
+ )
+ }
+ }
+}
+
+@Composable
+private fun AchievementRow(ach: RetroAchievement) {
+ val url = if (ach.unlocked) ach.badgeUrl else ach.badgeLockedUrl.ifBlank { ach.badgeUrl }
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = if (ach.unlocked) Gold.copy(alpha = 0.07f) else SurfaceDark.copy(alpha = 0.5f),
+ border = BorderStroke(1.dp, if (ach.unlocked) Gold.copy(alpha = 0.25f) else CardBorder),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier.size(52.dp).clip(RoundedCornerShape(8.dp)).background(BgDark),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (url.isNotBlank()) {
+ AsyncImage(
+ model = url,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(52.dp).clip(RoundedCornerShape(8.dp)),
+ alpha = if (ach.unlocked) 1f else 0.5f,
+ )
+ } else {
+ Icon(Icons.Outlined.Lock, contentDescription = null, tint = TextSecondary, modifier = Modifier.size(24.dp))
+ }
+ }
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ ach.title,
+ color = if (ach.unlocked) TextPrimary else TextSecondary,
+ fontWeight = FontWeight.SemiBold,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (ach.description.isNotBlank()) {
+ Text(
+ ach.description,
+ color = TextSecondary,
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (ach.progress.isNotBlank() && !ach.unlocked) {
+ Text(
+ ach.progress,
+ color = AccentGlow,
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+ }
+ Text(
+ "${ach.points}",
+ color = if (ach.unlocked) Gold else TextSecondary,
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.labelLarge,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/RetroAchievementsManager.kt b/app/src/main/feature/retro/RetroAchievementsManager.kt
new file mode 100644
index 000000000..161e0ea45
--- /dev/null
+++ b/app/src/main/feature/retro/RetroAchievementsManager.kt
@@ -0,0 +1,401 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import com.swordfish.libretrodroid.RetroAchievements
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.ByteArrayOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+
+data class RetroAchievement(
+ val id: Long,
+ val title: String,
+ val description: String,
+ val unlocked: Boolean,
+ val points: Int,
+ val progress: String,
+ val badgeUrl: String,
+ val badgeLockedUrl: String,
+ val bucket: Int,
+)
+
+data class RetroGameSummary(
+ val gameId: Long,
+ val title: String,
+ val badgeUrl: String,
+ val total: Int,
+ val unlocked: Int,
+ val pointsTotal: Int,
+ val pointsUnlocked: Int,
+)
+
+data class RetroUnlock(
+ val id: Long,
+ val title: String,
+ val description: String,
+ val points: Int,
+ val badgeUrl: String,
+)
+
+object RetroAchievementsManager {
+ private const val PREFS_NAME = "retro_achievements"
+ private const val KEY_USERNAME = "username"
+ private const val KEY_TOKEN = "token"
+ private const val KEY_DISPLAY = "display_name"
+ private const val KEY_ENABLED = "enabled"
+ private const val KEY_HARDCORE = "hardcore"
+
+ private const val CLIENT_VERSION = "1.0.0"
+ private const val RCHEEVOS_VERSION = "12.3.0"
+
+ @Volatile private var userAgent = "WinNative/$CLIENT_VERSION rcheevos/$RCHEEVOS_VERSION"
+
+ private fun coreTag(systemId: String?): String {
+ val core = RetroSystems.fromId(systemId)?.coreFileName ?: return "unknown"
+ return core.removePrefix("lib").removeSuffix(".so").removeSuffix("_libretro_android")
+ }
+
+ private fun buildUserAgent(systemId: String?): String =
+ "WinNative/$CLIENT_VERSION (Android ${android.os.Build.VERSION.RELEASE}) ${coreTag(systemId)} rcheevos/$RCHEEVOS_VERSION"
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private var pumpJob: Job? = null
+
+ private fun postMain(block: () -> Unit) = mainHandler.post(block)
+
+ const val LOAD_IDLE = 0
+ const val LOAD_LOADING = 1
+ const val LOAD_LOADED = 2
+ const val LOAD_EMPTY = 3
+
+ @Volatile private var clientReady = false
+ @Volatile private var loadedRomPath: String? = null
+ @Volatile var loadState = LOAD_IDLE
+ private set
+ @Volatile var connected = true
+ private set
+
+ var unlockListener: ((RetroUnlock) -> Unit)? = null
+ var stateListener: (() -> Unit)? = null
+ var resetListener: (() -> Unit)? = null
+ var hardcoreNoticeListener: ((String) -> Unit)? = null
+
+ private var loginCallback: ((Boolean, String?) -> Unit)? = null
+ private var appContext: Context? = null
+
+ private fun prefs(context: Context) =
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ fun isEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_ENABLED, true)
+
+ fun setEnabled(context: Context, value: Boolean) {
+ prefs(context).edit().putBoolean(KEY_ENABLED, value).apply()
+ }
+
+ const val HARDCORE_SUPPORTED = false
+
+ fun isHardcorePreferred(context: Context): Boolean =
+ HARDCORE_SUPPORTED && prefs(context).getBoolean(KEY_HARDCORE, false)
+
+ fun setHardcorePreferred(context: Context, value: Boolean) {
+ prefs(context).edit().putBoolean(KEY_HARDCORE, value).apply()
+ if (clientReady) RetroAchievements.INSTANCE.nativeSetHardcore(value)
+ }
+
+ fun isLoggedIn(context: Context): Boolean =
+ prefs(context).getString(KEY_TOKEN, null).isNullOrBlank().not()
+
+ fun displayName(context: Context): String? = prefs(context).getString(KEY_DISPLAY, null)
+ ?: prefs(context).getString(KEY_USERNAME, null)
+
+ fun username(context: Context): String? = prefs(context).getString(KEY_USERNAME, null)
+
+ fun apiToken(context: Context): String? = prefs(context).getString(KEY_TOKEN, null)
+
+ fun consoleId(systemId: String?): Int =
+ when (systemId) {
+ RetroSystems.NES.id -> 7
+ RetroSystems.SNES.id -> 3
+ RetroSystems.GAMEBOY.id -> 4
+ RetroSystems.GAMEBOY_COLOR.id -> 6
+ RetroSystems.GBA.id -> 5
+ RetroSystems.GENESIS.id -> 1
+ RetroSystems.MASTER_SYSTEM.id -> 11
+ RetroSystems.GAME_GEAR.id -> 15
+ RetroSystems.N64.id -> 2
+ RetroSystems.PSX.id -> 12
+ RetroSystems.PS2.id -> 21
+ RetroSystems.GAMECUBE.id -> 16
+ RetroSystems.WII.id -> 19
+ else -> 0
+ }
+
+ private fun ensureClient(context: Context) {
+ appContext = context.applicationContext
+ if (!clientReady) {
+ RetroAchievements.INSTANCE.nativeInit(isHardcorePreferred(context))
+ clientReady = true
+ }
+ startPumps()
+ }
+
+ fun login(context: Context, username: String, password: String, onResult: (Boolean, String?) -> Unit) {
+ ensureClient(context)
+ prefs(context).edit().putString(KEY_USERNAME, username).apply()
+ loginCallback = onResult
+ RetroAchievements.INSTANCE.nativeLoginPassword(username, password)
+ }
+
+ fun logout(context: Context) {
+ prefs(context).edit()
+ .remove(KEY_TOKEN)
+ .remove(KEY_DISPLAY)
+ .apply()
+ loadedRomPath = null
+ loadState = LOAD_IDLE
+ endSession()
+ }
+
+ fun prefetch(context: Context, systemId: String?, romPath: String) {
+ if (!isEnabled(context) || !isLoggedIn(context)) return
+ if (consoleId(systemId) == 0) return
+ loadGameForBrowsing(context, systemId, romPath)
+ }
+
+ fun startSession(context: Context, systemId: String?, romPath: String) {
+ if (!isEnabled(context) || !isLoggedIn(context)) return
+ val console = consoleId(systemId)
+ if (console == 0) return
+ ensureClient(context)
+ val username = prefs(context).getString(KEY_USERNAME, null) ?: return
+ val token = prefs(context).getString(KEY_TOKEN, null) ?: return
+ RetroAchievements.active = true
+ userAgent = buildUserAgent(systemId)
+ if (loadedRomPath == romPath && loadState != LOAD_LOADING) return
+ loadedRomPath = romPath
+ loadState = LOAD_LOADING
+ scope.launch {
+ RetroAchievements.INSTANCE.nativeLoginToken(username, token)
+ RetroAchievements.INSTANCE.nativeLoadGame(console, romPath)
+ }
+ }
+
+ fun loadGameForBrowsing(context: Context, systemId: String?, romPath: String) {
+ if (!isLoggedIn(context)) return
+ val console = consoleId(systemId)
+ if (console == 0) return
+ ensureClient(context)
+ val username = prefs(context).getString(KEY_USERNAME, null) ?: return
+ val token = prefs(context).getString(KEY_TOKEN, null) ?: return
+ userAgent = buildUserAgent(systemId)
+ if (loadedRomPath == romPath && loadState != LOAD_LOADING) return
+ loadedRomPath = romPath
+ loadState = LOAD_LOADING
+ scope.launch {
+ RetroAchievements.INSTANCE.nativeLoginToken(username, token)
+ RetroAchievements.INSTANCE.nativeLoadGame(console, romPath)
+ }
+ }
+
+ fun endSession() {
+ RetroAchievements.active = false
+ loadedRomPath = null
+ loadState = LOAD_IDLE
+ stopPumps()
+ if (clientReady) RetroAchievements.INSTANCE.nativeUnloadGame()
+ }
+
+ fun idle() {
+ if (clientReady) RetroAchievements.INSTANCE.nativeIdle()
+ }
+
+ fun onEmulatorReset() {
+ if (clientReady) RetroAchievements.INSTANCE.nativeReset()
+ }
+
+ fun isHardcoreActive(): Boolean =
+ HARDCORE_SUPPORTED && clientReady && RetroAchievements.INSTANCE.nativeGetHardcore()
+
+ fun getAchievements(): List {
+ if (!clientReady) return emptyList()
+ return RetroAchievements.INSTANCE.nativeGetAchievements().mapNotNull { row ->
+ val parts = row.split('\t')
+ if (parts.size < 9) return@mapNotNull null
+ RetroAchievement(
+ id = parts[0].toLongOrNull() ?: 0L,
+ title = parts[1],
+ description = parts[2],
+ unlocked = parts[3] == "1",
+ points = parts[4].toIntOrNull() ?: 0,
+ progress = parts[5],
+ badgeUrl = parts[6],
+ badgeLockedUrl = parts[7],
+ bucket = parts[8].toIntOrNull() ?: 0,
+ )
+ }
+ }
+
+ fun getSummary(): RetroGameSummary? {
+ if (!clientReady) return null
+ val text = RetroAchievements.INSTANCE.nativeGetGameSummary()
+ if (text.isBlank()) return null
+ val parts = text.split('\t')
+ if (parts.size < 7) return null
+ return RetroGameSummary(
+ gameId = parts[0].toLongOrNull() ?: 0L,
+ title = parts[1],
+ badgeUrl = parts[2],
+ total = parts[3].toIntOrNull() ?: 0,
+ unlocked = parts[4].toIntOrNull() ?: 0,
+ pointsTotal = parts[5].toIntOrNull() ?: 0,
+ pointsUnlocked = parts[6].toIntOrNull() ?: 0,
+ )
+ }
+
+ private fun startPumps() {
+ if (pumpJob?.isActive == true) return
+ pumpJob = scope.launch {
+ while (isActive && clientReady) {
+ pumpHttp()
+ pumpEvents()
+ delay(50)
+ }
+ }
+ }
+
+ private fun stopPumps() {
+ pumpJob?.cancel()
+ pumpJob = null
+ }
+
+ fun stopPolling() = stopPumps()
+
+ private suspend fun CoroutineScope.pumpHttp() {
+ while (isActive) {
+ val request = RetroAchievements.INSTANCE.nativePollHttpRequest() ?: break
+ val id = request[0].toIntOrNull() ?: continue
+ val url = request[1]
+ val postData = request[2]
+ val contentType = request[3]
+ launch { performHttp(id, url, postData, contentType) }
+ }
+ }
+
+ private suspend fun performHttp(id: Int, url: String, postData: String, contentType: String) {
+ var status = 0
+ var body: ByteArray? = null
+ try {
+ val connection = (URL(url).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 30000
+ readTimeout = 30000
+ setRequestProperty("User-Agent", userAgent)
+ if (postData.isNotEmpty()) {
+ doOutput = true
+ requestMethod = "POST"
+ if (contentType.isNotEmpty()) setRequestProperty("Content-Type", contentType)
+ outputStream.use { it.write(postData.toByteArray(Charsets.UTF_8)) }
+ } else {
+ requestMethod = "GET"
+ }
+ }
+ status = connection.responseCode
+ val stream = if (status in 200..299) connection.inputStream else connection.errorStream
+ body = stream?.use { input ->
+ val buffer = ByteArrayOutputStream()
+ val chunk = ByteArray(8192)
+ while (true) {
+ val read = input.read(chunk)
+ if (read < 0) break
+ buffer.write(chunk, 0, read)
+ }
+ buffer.toByteArray()
+ }
+ connection.disconnect()
+ } catch (e: Exception) {
+ status = 0
+ body = null
+ }
+ withContext(Dispatchers.IO) {
+ RetroAchievements.INSTANCE.nativeProvideResponse(id, status, body)
+ }
+ }
+
+ private fun pumpEvents() {
+ val events = RetroAchievements.INSTANCE.nativePollEvents()
+ for (event in events) {
+ val parts = event.split('\t')
+ when (parts.getOrNull(0)) {
+ "unlock" -> {
+ val unlock = RetroUnlock(
+ id = parts.getOrNull(1)?.toLongOrNull() ?: 0L,
+ title = parts.getOrNull(2) ?: "",
+ description = parts.getOrNull(3) ?: "",
+ points = parts.getOrNull(4)?.toIntOrNull() ?: 0,
+ badgeUrl = parts.getOrNull(5) ?: "",
+ )
+ postMain {
+ unlockListener?.invoke(unlock)
+ stateListener?.invoke()
+ }
+ }
+ "login_ok" -> {
+ val display = parts.getOrNull(1) ?: ""
+ val token = parts.getOrNull(2) ?: ""
+ appContext?.let { ctx ->
+ prefs(ctx).edit()
+ .putString(KEY_DISPLAY, display)
+ .putString(KEY_TOKEN, token)
+ .apply()
+ }
+ val callback = loginCallback
+ loginCallback = null
+ postMain { callback?.invoke(true, display) }
+ }
+ "login_failed" -> {
+ val message = parts.getOrNull(2) ?: "Login failed"
+ val callback = loginCallback
+ loginCallback = null
+ postMain { callback?.invoke(false, message) }
+ }
+ "game_loaded" -> {
+ loadState = LOAD_LOADED
+ if (RetroAchievements.INSTANCE.nativeGetHardcore() &&
+ !RetroAchievements.INSTANCE.nativeHardcoreCompatible()
+ ) {
+ RetroAchievements.INSTANCE.nativeSetHardcore(false)
+ postMain {
+ hardcoreNoticeListener?.invoke(
+ "Hardcore disabled: a core option is not allowed in hardcore mode",
+ )
+ }
+ }
+ postMain { stateListener?.invoke() }
+ }
+ "game_unidentified", "game_load_failed" -> {
+ loadState = LOAD_EMPTY
+ postMain { stateListener?.invoke() }
+ }
+ "game_completed" -> postMain { stateListener?.invoke() }
+ "reset" -> postMain { resetListener?.invoke() }
+ "disconnected" -> {
+ connected = false
+ postMain { stateListener?.invoke() }
+ }
+ "reconnected" -> {
+ connected = true
+ postMain { stateListener?.invoke() }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt
new file mode 100644
index 000000000..a80bf925f
--- /dev/null
+++ b/app/src/main/feature/retro/RetroActivity.kt
@@ -0,0 +1,1900 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.SharedPreferences
+import android.content.pm.ActivityInfo
+import android.content.res.Configuration
+import android.graphics.RectF
+import android.hardware.input.InputManager
+import android.os.Bundle
+import android.view.InputDevice
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.View
+import android.view.WindowManager
+import android.widget.FrameLayout
+import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.addCallback
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.unit.dp
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.lifecycle.lifecycleScope
+import com.swordfish.libretrodroid.GLRetroView
+import com.swordfish.libretrodroid.GLRetroViewData
+import com.swordfish.libretrodroid.LibretroDroid
+import com.swordfish.libretrodroid.ShaderConfig
+import com.swordfish.libretrodroid.Variable
+import com.swordfish.libretrodroid.ViewportAlignment
+import com.winlator.cmod.R
+import com.winlator.cmod.feature.sync.google.GameSaveBackupManager
+import com.winlator.cmod.feature.sync.google.GoogleAuthMode
+import com.winlator.cmod.runtime.container.ContainerManager
+import com.winlator.cmod.runtime.container.Shortcut
+import com.winlator.cmod.runtime.display.ui.FrameRating
+import com.winlator.cmod.runtime.input.controls.ExternalController
+import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity
+import com.winlator.cmod.shared.theme.WinNativeTheme
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+import java.io.File
+import kotlin.math.abs
+
+class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener {
+ companion object {
+ const val EXTRA_ROM_PATH = "retro_rom_path"
+ const val EXTRA_SYSTEM_ID = "retro_system_id"
+ const val EXTRA_GAME_NAME = "retro_game_name"
+ const val EXTRA_SHORTCUT_PATH = "retro_shortcut_path"
+ const val EXTRA_CONTAINER_ID = "retro_container_id"
+ const val EXTRA_SHADER = "retro_shader"
+ const val EXTRA_TOUCH_CONTROLS = "retro_touch_controls"
+ const val EXTRA_ADAPTIVE_STICKS = "retro_adaptive_sticks"
+ const val EXTRA_AUDIO = "retro_audio"
+ const val EXTRA_HUD = "retro_hud"
+ const val EXTRA_VARIABLES = "retro_variables"
+
+ const val EXTRA_UPSCALE = "retro_upscale"
+ const val EXTRA_SGSR = "retro_sgsr"
+
+ private val SHADER_KEYS = listOf("default", "crt", "lcd", "sharp")
+ private val SHADER_LABEL_RES =
+ listOf(R.string.retro_lr_shader_default, R.string.retro_lr_shader_crt, R.string.retro_lr_shader_lcd, R.string.retro_lr_shader_sharp)
+ private val UPSCALE_KEYS = listOf("2x", "4x", "native")
+ }
+
+ private lateinit var retroView: GLRetroView
+ private var overlay: RetroInputView? = null
+ private val menu = RetroMenuController()
+ private var retroReady = false
+ private var gameName = "game"
+ private var fastForward = false
+ private var audioEnabledSetting = true
+ private var touchControlsSetting = true
+ private var adaptiveSticksSetting = false
+ private var currentShaderKey = "default"
+ private var sgsrEnabled = false
+ private var coreVars = HashMap()
+ private var diskCount = 0
+ private var currentDisk = 0
+ private var system: RetroSystem? = null
+ private var persistShortcut: Shortcut? = null
+ private var playtimePrefs: SharedPreferences? = null
+ private var sessionStart = 0L
+ private var emulationPaused = false
+ private var controllerConnected = false
+ private var manualTouchOverride = false
+ private var inputManager: InputManager? = null
+ private var hudVisible = false
+ private var currentUpscaleKey = "native"
+ private var hudAlpha = 1f
+ private var hudBgDecoupled = false
+ private var hudBgAlpha = 1f
+ private var hudScale = 1f
+ private var hudElements = booleanArrayOf(true, true, true, true, true, true, true, true, false)
+ private var hudFrametimeNumeric = false
+ private var hudDualBattery = false
+ private var frameRating: FrameRating? = null
+ private val biosPicker =
+ registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) {
+ RetroBiosImport.importFromUri(this, uri)
+ .onSuccess {
+ Toast.makeText(this, getString(R.string.retro_lr_bios_imported, it), Toast.LENGTH_SHORT).show()
+ recreate()
+ }
+ .onFailure {
+ Toast.makeText(this, it.message ?: getString(R.string.retro_lr_invalid_bios_file), Toast.LENGTH_LONG).show()
+ }
+ } else {
+ finish()
+ }
+ }
+ private var rootLayout: FrameLayout? = null
+ private var menuComposeView: ComposeView? = null
+ private var surfaceReady = false
+ private var customColors = RetroCustomColors()
+ private var savesLoadMode = false
+ private var achievementsSessionStarted = false
+ private var cloudSyncEnabled = false
+ private var conflictChecked = false
+ private var retroCloudId = ""
+ private var netplayLocalPort = 0
+ private var netplayArmedThisSession = false
+
+ private val isPortrait: Boolean
+ get() = resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT
+
+ private val gameAspect: Float
+ get() =
+ when (system?.id) {
+ RetroSystems.GAMEBOY.id, RetroSystems.GAMEBOY_COLOR.id -> 10f / 9f
+ RetroSystems.GBA.id -> 3f / 2f
+ else -> 4f / 3f
+ }
+
+ private fun overlayPush(): Float = 0f
+
+ private fun updateOverlayArea() {
+ val rootWidth = rootLayout?.width ?: 0
+ val rootHeight = rootLayout?.height ?: 0
+ if (rootWidth <= 0 || rootHeight <= 0) return
+ val push = overlayPush()
+ val area =
+ if (isPortrait) {
+ val top = push * rootHeight
+ val gameHeight = rootWidth / gameAspect
+ RectF(0f, top, rootWidth.toFloat(), (top + gameHeight).coerceAtMost(rootHeight.toFloat()))
+ } else {
+ val availHeight = rootHeight * (1f - push)
+ val gameWidth = (availHeight * gameAspect).coerceAtMost(rootWidth.toFloat())
+ val left = (rootWidth - gameWidth) * 0.5f
+ val gameHeight = gameWidth / gameAspect
+ val top = push * rootHeight + (availHeight - gameHeight) * 0.5f
+ RectF(left, top, left + gameWidth, top + gameHeight)
+ }
+ overlay?.setGameArea(area)
+ }
+
+ private fun applyDisplayGeometry() {
+ updateOverlayArea()
+ if (!surfaceReady || !retroReady) return
+ retroView.viewportAlignment = if (isPortrait) ViewportAlignment.TOP else ViewportAlignment.CENTER
+ retroView.viewport = RectF(0f, overlayPush(), 1f, 1f)
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+ overlay?.releaseAll()
+ rootLayout?.post {
+ overlay?.relayout()
+ applyDisplayGeometry()
+ }
+ }
+
+ private val inputDeviceListener =
+ object : InputManager.InputDeviceListener {
+ override fun onInputDeviceAdded(deviceId: Int) = refreshControllerPresence()
+
+ override fun onInputDeviceRemoved(deviceId: Int) = refreshControllerPresence()
+
+ override fun onInputDeviceChanged(deviceId: Int) = refreshControllerPresence()
+ }
+
+ private val stickIsAnalog: Boolean
+ get() = system?.id == RetroSystems.N64.id || RetroCoreManager.usesDolphinCore(system)
+
+ private fun anyGameControllerConnected(): Boolean =
+ InputDevice.getDeviceIds().any { id ->
+ ExternalController.isGameController(InputDevice.getDevice(id))
+ }
+
+ private fun refreshControllerPresence() {
+ val wasConnected = controllerConnected
+ controllerConnected = anyGameControllerConnected()
+ if (controllerConnected != wasConnected) manualTouchOverride = false
+ updateOverlayVisibility()
+ syncInGameOverlayPlacement()
+ }
+
+ private val touchControlsEffective: Boolean
+ get() = touchControlsSetting && (!controllerConnected || manualTouchOverride)
+
+ private fun updateOverlayVisibility() {
+ overlay?.visibility =
+ if (overlay?.editMode == true || touchControlsEffective) View.VISIBLE else View.GONE
+ }
+
+ private fun pauseEmulation() {
+ if (emulationPaused || !retroReady) return
+ emulationPaused = true
+ retroView.onPause()
+ LibretroDroid.pause()
+ RetroAchievementsManager.idle()
+ }
+
+ private fun resumeEmulation() {
+ if (!emulationPaused || !retroReady) return
+ emulationPaused = false
+ LibretroDroid.resume()
+ retroView.onResume()
+ }
+
+ private fun startAchievementsSession() {
+ if (achievementsSessionStarted) return
+ val sys = system ?: return
+ val rom = intent.getStringExtra(EXTRA_ROM_PATH) ?: return
+ if (!RetroAchievementsManager.isEnabled(this) || !RetroAchievementsManager.isLoggedIn(this)) return
+ achievementsSessionStarted = true
+ RetroAchievementsManager.unlockListener = { unlock ->
+ runOnUiThread { showAchievementUnlock(unlock) }
+ }
+ syncInGameOverlayPlacement()
+ RetroAchievementsManager.resetListener = {
+ runOnUiThread {
+ if (retroReady) retroView.reset()
+ RetroAchievementsManager.onEmulatorReset()
+ }
+ }
+ RetroAchievementsManager.hardcoreNoticeListener = { message ->
+ runOnUiThread { showInGameMessage(message) }
+ }
+ RetroAchievementsManager.startSession(this, sys.id, rom)
+ }
+
+ private fun syncInGameOverlayPlacement() {
+ RetroAchievementOverlayState.syncPlacement(
+ touchControlsVisible = touchControlsEffective,
+ controllerConnected = controllerConnected,
+ )
+ }
+
+ private fun showInGameMessage(message: String) {
+ syncInGameOverlayPlacement()
+ if (RetroAchievementOverlayState.useDisplayArea) {
+ RetroAchievementOverlayState.showMessage(message)
+ } else {
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun showAchievementUnlock(unlock: RetroUnlock) {
+ syncInGameOverlayPlacement()
+ RetroAchievementOverlayState.show(unlock.title, unlock.points, unlock.description)
+ }
+
+ fun cheatsAllowed(): Boolean = !(achievementsSessionStarted && RetroAchievementsManager.isHardcoreActive())
+
+ private fun applyCheats() {
+ if (!retroReady) return
+ runCatching { retroView.resetCheat() }
+ if (!cheatsAllowed()) return
+ val enabled = RetroCheats.load(this, gameName).filter { it.enabled }
+ if (enabled.isEmpty()) return
+ enabled.forEachIndexed { index, cheat ->
+ runCatching { retroView.setCheat(index, true, cheat.code) }
+ }
+ if (achievementsSessionStarted) {
+ RetroAchievementsManager.endSession()
+ achievementsSessionStarted = false
+ Toast.makeText(this, getString(R.string.retro_lr_cheats_enabled_achievements_disabled), Toast.LENGTH_LONG).show()
+ }
+ }
+
+ private fun openCheatsScreen() {
+ val sys = system ?: return
+ startActivity(
+ android.content.Intent(this, RetroCheatsActivity::class.java).apply {
+ putExtra(RetroCheatsActivity.EXTRA_SYSTEM_ID, sys.id)
+ putExtra(RetroCheatsActivity.EXTRA_GAME_NAME, gameName)
+ },
+ )
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ hideSystemBars()
+
+ val romPath = intent.getStringExtra(EXTRA_ROM_PATH)
+ val systemId = intent.getStringExtra(EXTRA_SYSTEM_ID)
+ gameName = intent.getStringExtra(EXTRA_GAME_NAME) ?: "game"
+ val resolvedSystem = RetroSystems.fromId(systemId)
+ system = resolvedSystem
+
+ if (romPath.isNullOrBlank() || resolvedSystem == null) {
+ Toast.makeText(this, getString(R.string.retro_lr_invalid_retro_game), Toast.LENGTH_LONG).show()
+ finish()
+ return
+ }
+
+ val sourceFile = File(romPath)
+ if (!sourceFile.isFile) {
+ Toast.makeText(this, getString(R.string.retro_lr_rom_not_found, romPath), Toast.LENGTH_LONG).show()
+ finish()
+ return
+ }
+ val romFile =
+ if (RetroRomArchive.isArchive(romPath)) {
+ RetroRomArchive.extractTo(this, romPath) ?: run {
+ Toast.makeText(this, getString(R.string.retro_lr_could_not_read_rom_archive), Toast.LENGTH_LONG).show()
+ finish()
+ return
+ }
+ } else {
+ sourceFile
+ }
+
+ requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_USER
+
+ netplayArmedThisSession = RetroDefaults.netplayEnabled(this, resolvedSystem.id)
+ if (!netplayArmedThisSession && RetroNetplayLobby.isInRoomSession()) {
+ RetroNetplayLobby.leave(silent = true)
+ }
+ val wantGbaMulti =
+ resolvedSystem.id == RetroSystems.GBA.id &&
+ netplayArmedThisSession &&
+ (
+ RetroDefaults.netplayLaunchMode(this, resolvedSystem.id) == "host" ||
+ RetroDefaults.netplayLaunchMode(this, resolvedSystem.id) == "join" ||
+ RetroNetplayLobby.isGameLink ||
+ RetroNetplayLobby.isInRoomSession()
+ )
+ val coreFile =
+ if (wantGbaMulti) {
+ RetroCoreManager.multiplayerCoreFile(this, resolvedSystem)
+ } else {
+ RetroCoreManager.coreFile(this, resolvedSystem)
+ }
+ if (!coreFile.isFile) {
+ val name =
+ if (wantGbaMulti) RetroSystems.GBA_MULTIPLAYER_CORE else resolvedSystem.coreFileName
+ val message =
+ if (RetroBundle.isInstalled(this)) {
+ getString(R.string.retro_lr_core_not_installed, name)
+ } else {
+ getString(R.string.retro_bundle_required)
+ }
+ Toast.makeText(this, message, Toast.LENGTH_LONG).show()
+ finish()
+ return
+ }
+
+ if (RetroCoreManager.missingBios(this, resolvedSystem)) {
+ showBiosRequiredDialog(resolvedSystem)
+ return
+ }
+
+ if (resolvedSystem.id == RetroSystems.N64.id) RetroCoreManager.ensureGlideN64Ini(this)
+ if (RetroCoreManager.usesDolphinCore(resolvedSystem)) {
+ RetroCoreManager.ensureDolphinSys(this)
+ RetroCoreManager.ensureDolphinUser(this)
+ }
+ val savesDir = RetroCoreManager.savesDir(this)
+ RetroSaveStates.migrateLegacy(this, gameName)
+ RetroSaveStates.recordIdentity(this, gameName, resolvedSystem.id)
+ val sramFile = RetroSaveStates.sramFile(this, gameName)
+ currentShaderKey = intent.getStringExtra(EXTRA_SHADER)?.lowercase() ?: "default"
+ sgsrEnabled = intent.getBooleanExtra(EXTRA_SGSR, false)
+ if (currentShaderKey == "sgsr") {
+ currentShaderKey = "default"
+ sgsrEnabled = true
+ }
+ if (currentShaderKey !in SHADER_KEYS) currentShaderKey = "default"
+ currentUpscaleKey = intent.getStringExtra(EXTRA_UPSCALE)?.lowercase() ?: "native"
+ if (currentUpscaleKey !in UPSCALE_KEYS) currentUpscaleKey = "native"
+ audioEnabledSetting = intent.getBooleanExtra(EXTRA_AUDIO, true)
+ touchControlsSetting = intent.getBooleanExtra(EXTRA_TOUCH_CONTROLS, true)
+ adaptiveSticksSetting = intent.getBooleanExtra(EXTRA_ADAPTIVE_STICKS, false)
+ @Suppress("UNCHECKED_CAST", "DEPRECATION")
+ coreVars = (intent.getSerializableExtra(EXTRA_VARIABLES) as? HashMap) ?: HashMap()
+ RetroCoreOptions.defaultVariables(resolvedSystem).forEach { (key, value) ->
+ if (!coreVars.containsKey(key)) coreVars[key] = value
+ }
+ if (RetroCoreManager.usesDolphinCore(resolvedSystem)) {
+ RetroCoreOptions.sanitizeDolphinVariables(coreVars)
+ }
+
+ val root = FrameLayout(this)
+ rootLayout = root
+
+ val callback = object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (retroReady) {
+ if (menu.visible) {
+ menu.close()
+ return
+ }
+ overlay?.releaseAll()
+ menu.open()
+ }
+ }
+ }
+
+ onBackPressedDispatcher.addCallback(this, callback)
+
+ val inputView = RetroInputView(this, this, resolvedSystem)
+ inputView.loadStickInversion()
+ inputView.hapticStrength =
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(this)
+ .getFloat("retro_haptic_strength", 0.4f)
+ inputView.adaptiveSticks = adaptiveSticksSetting
+ inputView.showL3R3 =
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(this)
+ .getBoolean(RetroControlsMenu.l3r3PrefKey(resolvedSystem.id), true)
+ customColors = RetroControlLayouts.loadColors(this, resolvedSystem.id)
+ inputView.setCustomColors(customColors)
+ inputView.onEditStateChanged = { editing ->
+ runOnUiThread {
+ frameRating?.visibility =
+ if (!editing && hudVisible) View.VISIBLE else View.GONE
+ updateOverlayVisibility()
+ menu.rebuild()
+ }
+ }
+ overlay = inputView
+ root.addView(
+ inputView,
+ FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ ),
+ )
+ root.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> updateOverlayArea() }
+
+ menu.entriesProvider = { pane -> buildEntriesFor(pane) }
+ menu.bottomProvider = { buildBottomEntries() }
+ val coreOpts = RetroCoreOptions.forSystem(system)
+ menu.tabs =
+ RetroDrawerTabs.build(
+ this,
+ includePerformance = coreOpts.any { it.category == RetroOptionCategory.PERFORMANCE },
+ includeNetplay = RetroOnlineSupport.supportsMultiplayerUi(system?.id),
+ includeSystem = coreOpts.any { it.category == RetroOptionCategory.SYSTEM },
+ )
+ hudVisible = intent.getBooleanExtra(EXTRA_HUD, false)
+ RetroNetplayLobby.bindLocalName(this)
+ val menuView =
+ ComposeView(this).apply {
+ elevation = 2000f
+ setContent {
+ WinNativeTheme {
+ Box(Modifier.fillMaxSize()) {
+ syncInGameOverlayPlacement()
+ RetroAchievementOverlayBanner()
+ val netPhase = RetroNetplayLobby.phase
+ val netMembers = RetroNetplayLobby.members
+ val netDiscovered = RetroNetplayLobby.discovered
+ val netStatus = RetroNetplayLobby.statusText
+ LaunchedEffect(netPhase, netMembers, netDiscovered, netStatus) {
+ if (menu.visible && menu.pane == RetroPane.NETWORK) {
+ menu.rebuild()
+ }
+ }
+ Box(
+ Modifier
+ .fillMaxSize()
+ .padding(top = 56.dp),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ RetroNetplayRoomBanner(
+ onLeave = { leaveNetplayRoom() },
+ onJoinRoom = { room -> joinDiscoveredRoom(room) },
+ )
+ }
+ Box(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.BottomCenter,
+ ) {
+ RetroNetplayEventToast(
+ Modifier.padding(bottom = 72.dp),
+ )
+ }
+ RetroDrawerMenu(menu)
+ }
+ }
+ }
+ }
+ menuComposeView = menuView
+ root.addView(
+ menuView,
+ FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ ),
+ )
+
+ setContentView(root)
+ inputManager = getSystemService(InputManager::class.java)
+ inputManager?.registerInputDeviceListener(inputDeviceListener, null)
+ refreshControllerPresence()
+ retroReady = false
+ if (hudVisible) {
+ root.post {
+ if (!isFinishing && !isDestroyed && hudVisible) showHud()
+ }
+ }
+ onBackPressedDispatcher.addCallback(
+ this,
+ object : androidx.activity.OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (overlay?.editMode == true) {
+ overlay?.finishEdit()
+ } else if (menu.visible) {
+ menu.handleKey(KeyEvent.KEYCODE_BACK, KeyEvent.ACTION_UP)
+ } else {
+ openMenu()
+ }
+ }
+ },
+ )
+ loadHudSettings()
+ recordLaunchStats()
+
+ val sixtyRequested = requestSixtyHzDisplayMode()
+ var waitAttempts = 0
+ lateinit var startWhenReady: Runnable
+ startWhenReady =
+ Runnable {
+ if (isFinishing || isDestroyed) return@Runnable
+ val rate =
+ runCatching { windowManager.defaultDisplay.refreshRate }.getOrDefault(60f)
+ if (sixtyRequested && abs(rate - 60f) > 2f && waitAttempts < 12) {
+ waitAttempts++
+ root.postDelayed(startWhenReady, 100)
+ return@Runnable
+ }
+ val data =
+ GLRetroViewData(this).apply {
+ coreFilePath = coreFile.absolutePath
+ gameFilePath = romFile.absolutePath
+ systemDirectory = RetroCoreManager.systemDir(this@RetroActivity).absolutePath
+ savesDirectory = savesDir.absolutePath
+ shader = effectiveShader()
+ variables = coreVars.map { Variable(it.key, it.value) }.toTypedArray()
+ rumbleEventsEnabled = true
+ preferLowLatencyAudio = !RetroCoreManager.usesDolphinCore(resolvedSystem)
+ skipDuplicateFrames = false
+ viewportAlignment =
+ if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) {
+ ViewportAlignment.TOP
+ } else {
+ ViewportAlignment.CENTER
+ }
+ if (sramFile.isFile) saveRAMState = runCatching { sramFile.readBytes() }.getOrNull()
+ }
+ val view = GLRetroView(this, data)
+ if (android.os.Build.VERSION.SDK_INT >= 30) {
+ view.holder.addCallback(
+ object : android.view.SurfaceHolder.Callback {
+ override fun surfaceCreated(holder: android.view.SurfaceHolder) {
+ runCatching {
+ holder.surface.setFrameRate(
+ 60f,
+ android.view.Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
+ )
+ }
+ }
+
+ override fun surfaceChanged(
+ holder: android.view.SurfaceHolder,
+ format: Int,
+ width: Int,
+ height: Int,
+ ) {}
+
+ override fun surfaceDestroyed(holder: android.view.SurfaceHolder) {}
+ },
+ )
+ }
+ retroView = view
+ root.addView(
+ view,
+ 0,
+ FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ ),
+ )
+ lifecycle.addObserver(view)
+ retroReady = true
+ refreshControllerPresence()
+ observeErrors()
+ observeEvents()
+ scheduleCloudConflictCheck()
+ }
+ retroCloudId =
+ intent.getStringExtra(EXTRA_SHORTCUT_PATH)?.let { path ->
+ GameSaveBackupManager.customGameId(intent.getIntExtra(EXTRA_CONTAINER_ID, 0), File(path).name)
+ } ?: RetroSaveStates.cloudGameId(resolvedSystem.id, gameName)
+ lifecycleScope.launch(Dispatchers.IO) {
+ cloudSyncEnabled =
+ runCatching { loadShortcut()?.getExtra("cloud_sync_enabled", "1") != "0" }.getOrDefault(true)
+ if (cloudSyncEnabled && !RetroSaveStates.sramFile(this@RetroActivity, gameName).isFile) {
+ runCatching {
+ withTimeout(12_000L) {
+ val entries =
+ GameSaveBackupManager.listGoogleHistory(
+ this@RetroActivity,
+ GameSaveBackupManager.GameSource.CUSTOM,
+ retroCloudId,
+ GoogleAuthMode.RESUME,
+ )
+ val latest = entries.maxByOrNull { it.timestampMs }
+ if (latest != null) {
+ val result =
+ GameSaveBackupManager.restoreFromGoogle(
+ this@RetroActivity,
+ latest,
+ GameSaveBackupManager.GameSource.CUSTOM,
+ retroCloudId,
+ GoogleAuthMode.RESUME,
+ customSaveDir = RetroSaveStates.gameDir(this@RetroActivity, gameName),
+ )
+ if (result.success) {
+ RetroSaveStates.migrateLegacyCloudLayout(this@RetroActivity, gameName)
+ setCloudMark(latest.timestampMs)
+ runOnUiThread {
+ Toast
+ .makeText(this@RetroActivity, getString(R.string.retro_lr_cloud_save_restored), Toast.LENGTH_SHORT)
+ .show()
+ }
+ }
+ }
+ }
+ }
+ }
+ runOnUiThread {
+ if (!isFinishing && !isDestroyed) root.post(startWhenReady)
+ }
+ }
+ }
+
+ private fun scheduleCloudConflictCheck() {
+ if (!cloudSyncEnabled || conflictChecked) return
+ conflictChecked = true
+ lifecycleScope.launch(Dispatchers.IO) {
+ runCatching {
+ val sram = RetroSaveStates.sramFile(this@RetroActivity, gameName)
+ val localTs = if (sram.isFile) sram.lastModified() else 0L
+ if (localTs <= 0L) return@runCatching
+ val entries =
+ GameSaveBackupManager.listGoogleHistory(
+ this@RetroActivity,
+ GameSaveBackupManager.GameSource.CUSTOM,
+ retroCloudId,
+ GoogleAuthMode.RESUME,
+ )
+ val mark = cloudMark()
+ val newer =
+ entries
+ .filter { it.timestampMs > localTs + 120_000L && it.timestampMs > mark }
+ .sortedByDescending { it.timestampMs }
+ if (newer.isEmpty()) return@runCatching
+ val top = newer.take(5)
+ runOnUiThread {
+ if (isFinishing || isDestroyed) return@runOnUiThread
+ pauseEmulation()
+ menu.conflictPrompt =
+ RetroConflictPrompt(
+ message = getString(R.string.retro_lr_cloud_conflict_message, gameName),
+ options =
+ top.map { entry ->
+ (entry.label ?: getString(R.string.retro_lr_cloud_save)) + " — " +
+ RetroSaveStates.relativeTime(entry.timestampMs)
+ },
+ onKeepLocal = {
+ setCloudMark(top.first().timestampMs)
+ menu.conflictPrompt = null
+ resumeEmulation()
+ },
+ onPick = { index ->
+ menu.conflictPrompt = null
+ restoreCloudEntry(top[index])
+ },
+ )
+ }
+ }
+ }
+ }
+
+ private fun restoreCloudEntry(entry: GameSaveBackupManager.BackupHistoryEntry) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ val result =
+ runCatching {
+ GameSaveBackupManager.restoreFromGoogle(
+ this@RetroActivity,
+ entry,
+ GameSaveBackupManager.GameSource.CUSTOM,
+ retroCloudId,
+ GoogleAuthMode.INTERACTIVE,
+ customSaveDir = RetroSaveStates.gameDir(this@RetroActivity, gameName),
+ )
+ }.getOrNull()
+ runOnUiThread {
+ if (isFinishing || isDestroyed) return@runOnUiThread
+ if (result?.success == true) {
+ RetroSaveStates.migrateLegacyCloudLayout(this@RetroActivity, gameName)
+ setCloudMark(entry.timestampMs)
+ runCatching {
+ val sram = RetroSaveStates.sramFile(this@RetroActivity, gameName)
+ if (sram.isFile) retroView.unserializeSRAM(sram.readBytes())
+ }
+ Toast.makeText(this@RetroActivity, getString(R.string.retro_lr_cloud_save_restored), Toast.LENGTH_SHORT).show()
+ } else {
+ Toast
+ .makeText(this@RetroActivity, result?.message ?: getString(R.string.retro_lr_restore_failed), Toast.LENGTH_SHORT)
+ .show()
+ }
+ resumeEmulation()
+ menu.rebuild()
+ }
+ }
+ }
+
+ private fun cloudMark(): Long =
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(this)
+ .getLong("retro_cloud_mark_$retroCloudId", 0L)
+
+ private fun setCloudMark(value: Long) {
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(this)
+ .edit()
+ .putLong("retro_cloud_mark_$retroCloudId", value)
+ .apply()
+ }
+
+ private fun launchExitCloudBackup() {
+ if (!cloudSyncEnabled) return
+ val hasSaves =
+ RetroSaveStates.gameDir(this, gameName).walkTopDown().any { it.isFile }
+ if (!hasSaves) return
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(this)
+ .edit()
+ .putString("retro_pending_backup_id", retroCloudId)
+ .putString("retro_pending_backup_name", gameName)
+ .apply()
+ }
+
+ override fun onDestroy() {
+ inputManager?.unregisterInputDeviceListener(inputDeviceListener)
+ shutdownNetplaySession(callNativeStop = false)
+ RetroAchievementsManager.stopPolling()
+ if (achievementsSessionStarted) {
+ RetroAchievementsManager.unlockListener = null
+ RetroAchievementsManager.resetListener = null
+ RetroAchievementsManager.hardcoreNoticeListener = null
+ RetroAchievementsManager.endSession()
+ }
+ super.onDestroy()
+ }
+
+ private fun shutdownNetplaySession(callNativeStop: Boolean = true) {
+ val sysId = system?.id
+ val view =
+ if (callNativeStop && ::retroView.isInitialized && retroReady) retroView else null
+ runCatching {
+ RetroNetplayLobby.leave(silent = true, retroView = view)
+ }
+ netplayLocalPort = 0
+ netplayArmedThisSession = false
+ if (sysId != null) {
+ RetroDefaults.clearNetplayArm(this, sysId)
+ }
+ }
+
+ private fun showHud() {
+ var rating = frameRating
+ if (rating == null) {
+ val root = rootLayout ?: return
+ rating =
+ RetroHudSupport.createFrameRating(
+ this,
+ RetroHudSupport.libretroRendererLabel(),
+ )
+ frameRating = rating
+ val menuIndex = menuComposeView?.let { root.indexOfChild(it) } ?: -1
+ if (menuIndex >= 0) root.addView(rating, menuIndex) else root.addView(rating)
+ rating.addOnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
+ if (bottom - top != oldBottom - oldTop) rootLayout?.post { applyDisplayGeometry() }
+ }
+ applyRetroHudSettings(rating)
+ }
+ rating.visibility = View.VISIBLE
+ rating.reset()
+ rating.post { applyDisplayGeometry() }
+ }
+
+ private fun applyRetroHudSettings(rating: FrameRating) {
+ rating.setHudAlpha(hudAlpha)
+ rating.setBackgroundAlphaDecoupled(hudBgDecoupled)
+ rating.setHudBackgroundAlpha(hudBgAlpha)
+ rating.setHudScale(hudScale)
+ rating.setFrametimeNumericMode(hudFrametimeNumeric)
+ rating.setDualSeriesBattery(hudDualBattery)
+ hudElements.forEachIndexed { index, enabled -> rating.toggleElement(index, enabled) }
+ }
+
+ private fun loadHudSettings() {
+ val style = RetroHudSupport.loadGlobalHudStyle(this)
+ hudAlpha = style.alpha
+ hudBgDecoupled = style.bgDecoupled
+ hudBgAlpha = style.bgAlpha
+ hudScale = style.scale
+ hudFrametimeNumeric = style.frametimeNumeric
+ hudDualBattery = style.dualBattery
+ hudElements = RetroHudSupport.loadGlobalHudElements(this)
+ }
+
+ private fun saveHudSettings() {
+ RetroHudSupport.saveGlobalHudStyle(
+ this,
+ HudStyle(hudAlpha, hudBgDecoupled, hudBgAlpha, hudScale, hudFrametimeNumeric, hudDualBattery),
+ )
+ RetroHudSupport.saveGlobalHudElements(this, hudElements)
+ }
+
+ private fun recordLaunchStats() {
+ val prefs = getSharedPreferences("playtime_stats", MODE_PRIVATE)
+ playtimePrefs = prefs
+ sessionStart = System.currentTimeMillis()
+ prefs
+ .edit()
+ .putInt("${gameName}_play_count", prefs.getInt("${gameName}_play_count", 0) + 1)
+ .putLong("${gameName}_last_played", sessionStart)
+ .apply()
+ }
+
+ private fun accumulatePlaytime() {
+ val prefs = playtimePrefs ?: return
+ val now = System.currentTimeMillis()
+ val delta = now - sessionStart
+ if (delta > 0) {
+ prefs
+ .edit()
+ .putLong("${gameName}_playtime", prefs.getLong("${gameName}_playtime", 0L) + delta)
+ .apply()
+ }
+ sessionStart = now
+ }
+
+ private fun observeEvents() {
+ retroView
+ .getGLRetroEvents()
+ .onEach { event ->
+ when (event) {
+ is GLRetroView.GLRetroEvents.FrameRendered -> {
+ if (hudVisible) frameRating?.recordGameFrame()
+ RetroNetplayLobby.onFrameRendered()
+ }
+ is GLRetroView.GLRetroEvents.SurfaceCreated -> {
+ if (!audioEnabledSetting) retroView.audioEnabled = false
+ surfaceReady = true
+ applyDisplayGeometry()
+ if (RetroCoreManager.usesDolphinCore(system)) {
+ runCatching {
+ val joypad = 1
+ retroView.setControllerType(0, joypad)
+ retroView.setControllerType(1, joypad)
+ retroView.setControllerType(2, joypad)
+ retroView.setControllerType(3, joypad)
+ }
+ }
+ lifecycleScope.launch(Dispatchers.Default) {
+ runCatching {
+ diskCount = retroView.getAvailableDisks()
+ currentDisk = retroView.getCurrentDisk()
+ }
+ }
+ startAchievementsSession()
+ applyCheats()
+ startNetplayIfNeeded()
+ }
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun startNetplayIfNeeded() {
+ val sysId = system?.id ?: return
+ if (!RetroOnlineSupport.supportsMultiplayerUi(sysId)) return
+
+ if (!netplayArmedThisSession && !RetroDefaults.netplayEnabled(this, sysId)) {
+ if (RetroNetplayLobby.isInRoomSession() ||
+ RetroNetplayLobby.phase == RetroNetplayPhase.SCANNING ||
+ RetroNetplayLobby.phase == RetroNetplayPhase.SCAN_RESULTS
+ ) {
+ RetroNetplayLobby.leave(silent = true, retroView = retroView)
+ }
+ return
+ }
+ netplayArmedThisSession = true
+
+ if (RetroNetplayLobby.attachEmulator(retroView)) {
+ if (!RetroNetplayLobby.isGameLink) {
+ RetroNetplayLobby.prepareMultiplayerPads(retroView)
+ }
+ netplayLocalPort =
+ when {
+ RetroNetplayLobby.isGameLink -> 0
+ RetroNetplayLobby.isHost -> 0
+ else -> 1
+ }
+ menu.rebuild()
+ return
+ }
+
+ if (!RetroNetplayLobby.canStartSession() ||
+ RetroNetplayLobby.phase == RetroNetplayPhase.SCANNING
+ ) {
+ return
+ }
+ val mode = RetroDefaults.netplayLaunchMode(this, sysId)
+ if (mode != "host" && mode != "join") return
+
+ val port =
+ if (RetroOnlineSupport.supportsGameLink(sysId)) {
+ RetroGameLink.clampPort(RetroDefaults.netplayPort(this, sysId))
+ } else {
+ RetroDefaults.netplayPort(this, sysId)
+ }
+ val name = RetroNetplayLobby.defaultPlayerName(this)
+ if (!RetroOnlineSupport.supportsGameLink(sysId)) {
+ RetroNetplayLobby.prepareMultiplayerPads(retroView)
+ }
+ when (mode) {
+ "host" -> {
+ netplayLocalPort = 0
+ RetroNetplayLobby.host(
+ systemId = sysId,
+ gameName = gameName,
+ playerName = name,
+ port = port,
+ context = this,
+ retroView = retroView,
+ )
+ }
+ "join" -> {
+ val host = RetroDefaults.netplayHost(this, sysId).trim()
+ if (host.isBlank()) return
+ netplayLocalPort = if (RetroOnlineSupport.supportsGameLink(sysId)) 0 else 1
+ RetroNetplayLobby.join(
+ host = host,
+ port = port,
+ playerName = name,
+ gameName = gameName,
+ systemId = sysId,
+ retroView = retroView,
+ )
+ }
+ }
+ menu.rebuild()
+ }
+
+ private fun leaveNetplayRoom() {
+ when (RetroNetplayLobby.phase) {
+ RetroNetplayPhase.SCANNING -> RetroNetplayLobby.stopScan()
+ RetroNetplayPhase.SCAN_RESULTS -> RetroNetplayLobby.dismissScanResults()
+ RetroNetplayPhase.IDLE -> {
+ if (RetroNetplayLobby.discovered.isNotEmpty() ||
+ RetroNetplayLobby.statusText.isNotBlank()
+ ) {
+ RetroNetplayLobby.dismissScanResults()
+ }
+ }
+ else -> {
+ RetroNetplayLobby.leave(
+ silent = false,
+ retroView = if (retroReady) retroView else null,
+ )
+ netplayLocalPort = 0
+ }
+ }
+ menu.rebuild()
+ }
+
+ private fun joinDiscoveredRoom(room: RetroNetplayDiscoveredRoom) {
+ if (!retroReady) return
+ val sysId = system?.id
+ val name = RetroNetplayLobby.defaultPlayerName(this)
+ netplayLocalPort = if (RetroOnlineSupport.supportsGameLink(sysId)) 0 else 1
+ RetroNetplayLobby.join(
+ host = room.hostAddress,
+ port = room.port,
+ playerName = name,
+ gameName = room.gameName.ifBlank { gameName },
+ systemId = sysId,
+ retroView = retroView,
+ )
+ menu.close()
+ menu.rebuild()
+ }
+
+ private fun localNetplayPort(): Int {
+ if (RetroNetplayLobby.isGameLink) return 0
+ return when (RetroNetplayLobby.phase) {
+ RetroNetplayPhase.HOSTING, RetroNetplayPhase.IN_ROOM, RetroNetplayPhase.CONNECTING ->
+ if (RetroNetplayLobby.isHost) 0 else 1
+ else -> netplayLocalPort
+ }
+ }
+
+ private fun observeErrors() {
+ retroView
+ .getGLRetroErrors()
+ .onEach { error ->
+ val message =
+ when (error) {
+ GLRetroView.ERROR_LOAD_LIBRARY -> getString(R.string.retro_lr_failed_load_core)
+ GLRetroView.ERROR_LOAD_GAME -> getString(R.string.retro_lr_failed_load_rom)
+ GLRetroView.ERROR_GL_NOT_COMPATIBLE -> getString(R.string.retro_lr_graphics_not_supported)
+ else -> getString(R.string.retro_lr_emulator_error)
+ }
+ Toast.makeText(this@RetroActivity, message, Toast.LENGTH_LONG).show()
+ finish()
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun sgsrPrePasses(): Int =
+ when (currentUpscaleKey) {
+ "4x" -> 2
+ "native" -> 3
+ else -> 1
+ }
+
+ private fun effectiveShader(): ShaderConfig =
+ if (sgsrEnabled) {
+ ShaderConfig.SGSR(sgsrPrePasses(), currentShaderKey)
+ } else {
+ when (currentShaderKey) {
+ "crt" -> ShaderConfig.CRT
+ "lcd" -> ShaderConfig.LCD
+ "sharp" -> ShaderConfig.Sharp
+ else -> ShaderConfig.Default
+ }
+ }
+
+ private fun loadShortcut(): Shortcut? {
+ persistShortcut?.let { return it }
+ val containerId = intent.getIntExtra(EXTRA_CONTAINER_ID, 0)
+ val path = intent.getStringExtra(EXTRA_SHORTCUT_PATH)
+ if (containerId < 0 || path.isNullOrBlank()) return null
+ val file = File(path)
+ if (!file.isFile) return null
+ return runCatching {
+ val cm = ContainerManager(this)
+ val container =
+ if (containerId == ContainerManager.RETRO_CONTAINER_ID) cm.retroContainer else cm.getContainerById(containerId)
+ container?.let { Shortcut(it, file) }
+ }.getOrNull()?.also { persistShortcut = it }
+ }
+
+ private fun persistExtra(
+ key: String,
+ value: String,
+ ) {
+ lifecycleScope.launch(Dispatchers.IO) {
+ runCatching {
+ val shortcut = loadShortcut()
+ shortcut?.putExtra(key, value)
+ shortcut?.saveData()
+ }
+ }
+ }
+
+ private fun buildEntriesFor(pane: RetroPane?): List =
+ when (pane) {
+ null -> buildMainEntries()
+ RetroPane.DISPLAY ->
+ buildList {
+ SHADER_KEYS.forEachIndexed { index, key ->
+ add(
+ RetroMenuEntry.Radio(
+ label = getString(SHADER_LABEL_RES[index]),
+ selected = currentShaderKey == key,
+ ) {
+ currentShaderKey = key
+ retroView.shader = effectiveShader()
+ persistExtra(RetroShortcuts.KEY_SHADER, key)
+ menu.rebuild()
+ },
+ )
+ }
+ add(
+ RetroMenuEntry.Toggle(getString(R.string.retro_lr_sgsr), checked = sgsrEnabled) { value ->
+ sgsrEnabled = value
+ retroView.shader = effectiveShader()
+ persistExtra(RetroShortcuts.KEY_SGSR, if (value) "1" else "0")
+ menu.rebuild()
+ },
+ )
+ val upscaleIndex = UPSCALE_KEYS.indexOf(currentUpscaleKey).coerceAtLeast(0)
+ add(
+ RetroMenuEntry.Choice(
+ getString(R.string.retro_lr_sgsr_upscale),
+ listOf("2x", "4x", getString(R.string.retro_lr_upscale_native)),
+ upscaleIndex,
+ visible = sgsrEnabled,
+ ) { next ->
+ currentUpscaleKey = UPSCALE_KEYS[next]
+ persistExtra(RetroShortcuts.KEY_UPSCALE, currentUpscaleKey)
+ if (sgsrEnabled) retroView.shader = effectiveShader()
+ menu.rebuild()
+ },
+ )
+ addAll(coreOptionEntries(RetroOptionCategory.DISPLAY))
+ }
+ RetroPane.SOUND ->
+ buildList {
+ add(
+ RetroMenuEntry.Toggle(getString(R.string.retro_lr_sound), checked = audioEnabledSetting) { value ->
+ audioEnabledSetting = value
+ retroView.audioEnabled = value
+ persistExtra(RetroShortcuts.KEY_AUDIO, if (value) "1" else "0")
+ menu.rebuild()
+ },
+ )
+ addAll(coreOptionEntries(RetroOptionCategory.SOUND))
+ }
+ RetroPane.CONTROLS -> buildControlsEntries() + coreOptionEntries(RetroOptionCategory.CONTROLS)
+ RetroPane.HUD -> buildHudEntries()
+ RetroPane.SAVES -> buildSaveSlotEntries()
+ RetroPane.PERFORMANCE -> coreOptionEntries(RetroOptionCategory.PERFORMANCE)
+ RetroPane.SYSTEM -> coreOptionEntries(RetroOptionCategory.SYSTEM)
+ RetroPane.MEMCARDS -> emptyList()
+ RetroPane.NETWORK -> buildNetplayEntries()
+ }
+
+ private fun coreOptionEntries(category: RetroOptionCategory): List {
+ val options =
+ RetroCoreOptions.forSystem(system)
+ .filter { it.category == category }
+ .filter { opt -> opt.isApplicable { key -> coreVars[key] ?: optionDefault(key) } }
+ if (options.isEmpty()) return emptyList()
+
+ fun entryFor(option: RetroCoreOption): RetroMenuEntry {
+ val current = coreVars[option.key] ?: option.defaultValue
+ val index = option.values.indexOf(current).coerceAtLeast(0)
+ return RetroMenuEntry.Choice(option.labelText(this), option.valueLabels, index) { next ->
+ val newValue = option.values[next]
+ coreVars[option.key] = newValue
+ retroView.updateVariables(Variable(option.key, newValue))
+ persistExtra(RetroShortcuts.VAR_PREFIX + option.key, newValue)
+ menu.rebuild()
+ }
+ }
+
+ return (options.filterNot { it.advanced } + options.filter { it.advanced })
+ .map(::entryFor)
+ }
+
+ private fun optionDefault(key: String): String? =
+ RetroCoreOptions.forSystem(system).firstOrNull { it.key == key }?.defaultValue
+
+ private fun buildNetplayEntries(): List {
+ val sysId = system?.id ?: return emptyList()
+ if (!RetroOnlineSupport.supportsMultiplayerUi(sysId)) return emptyList()
+ val phase = RetroNetplayLobby.phase
+ val port =
+ if (RetroOnlineSupport.supportsGameLink(sysId)) {
+ RetroGameLink.clampPort(RetroDefaults.netplayPort(this, sysId))
+ } else {
+ RetroDefaults.netplayPort(this, sysId)
+ }
+ val name = RetroNetplayLobby.defaultPlayerName(this)
+ val savedHost = RetroDefaults.netplayHost(this, sysId)
+ val entries = mutableListOf()
+
+ when (phase) {
+ RetroNetplayPhase.HOSTING, RetroNetplayPhase.CONNECTING, RetroNetplayPhase.IN_ROOM -> {
+ val localIp = RetroNetplayLobby.localIpv4Addresses().firstOrNull()
+ entries +=
+ RetroMenuEntry.Action(
+ label =
+ when (phase) {
+ RetroNetplayPhase.HOSTING -> getString(R.string.retro_netplay_hosting_room)
+ RetroNetplayPhase.CONNECTING -> getString(R.string.retro_netplay_connecting)
+ else -> getString(R.string.retro_netplay_in_room)
+ },
+ icon = RetroDrawerIcons.Group,
+ active = true,
+ subtitle =
+ when (phase) {
+ RetroNetplayPhase.HOSTING ->
+ if (localIp != null) {
+ "$localIp:$port"
+ } else {
+ getString(R.string.retro_netplay_waiting_players)
+ }
+ else ->
+ RetroNetplayLobby.roomTitle.ifBlank { gameName }
+ },
+ ) {}
+ val members =
+ RetroNetplayLobby.members.ifEmpty {
+ listOf(RetroNetplayMember(name, isHost = RetroNetplayLobby.isHost, isLocal = true))
+ }
+ members.forEach { member ->
+ val title =
+ buildString {
+ append(member.name)
+ if (member.isHost) append(" · Host")
+ if (member.isLocal) append(" · You")
+ if (!member.isHost && !member.isLocal) append(" · Joined")
+ }
+ entries +=
+ RetroMenuEntry.Action(
+ label = title,
+ icon = RetroDrawerIcons.Group,
+ active = member.isLocal,
+ ) {}
+ }
+ entries +=
+ RetroMenuEntry.Action(
+ label = getString(R.string.retro_netplay_leave),
+ icon = RetroDrawerIcons.Exit,
+ danger = true,
+ ) {
+ leaveNetplayRoom()
+ }
+ }
+
+ RetroNetplayPhase.IDLE, RetroNetplayPhase.SCANNING, RetroNetplayPhase.SCAN_RESULTS -> {
+ if (!netplayArmedThisSession) {
+ entries +=
+ RetroMenuEntry.Action(
+ label = getString(R.string.retro_netplay_title),
+ icon = RetroDrawerIcons.Group,
+ subtitle = getString(R.string.retro_netplay_enable_before_launch),
+ ) {}
+ return entries
+ }
+ entries +=
+ RetroMenuEntry.Action(
+ label = getString(R.string.retro_netplay_title),
+ icon = RetroDrawerIcons.Group,
+ active = phase != RetroNetplayPhase.IDLE,
+ subtitle =
+ when (phase) {
+ RetroNetplayPhase.SCANNING -> getString(R.string.retro_netplay_scanning)
+ RetroNetplayPhase.SCAN_RESULTS ->
+ RetroNetplayLobby.statusText.ifBlank {
+ getString(R.string.retro_netplay_scan_results)
+ }
+ else ->
+ if (RetroOnlineSupport.supportsGameLink(sysId)) {
+ getString(R.string.retro_netplay_game_link_hint)
+ } else {
+ getString(R.string.retro_netplay_menu_hint)
+ }
+ },
+ ) {}
+ if (phase != RetroNetplayPhase.SCANNING) {
+ val hostIp = RetroNetplayLobby.localIpv4Addresses().firstOrNull()
+ entries +=
+ RetroMenuEntry.Action(
+ label = getString(R.string.retro_netplay_host_action),
+ icon = RetroDrawerIcons.Add,
+ subtitle =
+ if (hostIp != null) {
+ "${getString(R.string.retro_gs_netplay_port)} $port\n$hostIp"
+ } else {
+ "${getString(R.string.retro_gs_netplay_port)} $port"
+ },
+ ) {
+ netplayLocalPort = 0
+ RetroNetplayLobby.host(
+ systemId = sysId,
+ gameName = gameName,
+ playerName = name,
+ port = port,
+ context = this,
+ retroView = retroView,
+ )
+ menu.rebuild()
+ }
+ }
+ entries +=
+ RetroMenuEntry.Action(
+ label =
+ when (phase) {
+ RetroNetplayPhase.SCANNING -> getString(R.string.retro_netplay_stop_scan)
+ RetroNetplayPhase.SCAN_RESULTS -> getString(R.string.retro_netplay_scan_again)
+ else -> getString(R.string.retro_netplay_scan_action)
+ },
+ icon = RetroDrawerIcons.Search,
+ active = phase == RetroNetplayPhase.SCANNING || phase == RetroNetplayPhase.SCAN_RESULTS,
+ subtitle =
+ when (phase) {
+ RetroNetplayPhase.SCANNING ->
+ RetroNetplayLobby.statusText.ifBlank {
+ getString(R.string.retro_netplay_scanning)
+ }
+ RetroNetplayPhase.SCAN_RESULTS ->
+ RetroNetplayLobby.statusText.ifBlank {
+ getString(R.string.retro_netplay_scan_results)
+ }
+ else -> getString(R.string.retro_netplay_scan_hint)
+ },
+ ) {
+ if (phase == RetroNetplayPhase.SCANNING) {
+ RetroNetplayLobby.stopScan()
+ menu.rebuild()
+ } else {
+ menu.close()
+ RetroNetplayLobby.scan(this, sysId)
+ }
+ }
+ if (phase == RetroNetplayPhase.SCAN_RESULTS) {
+ entries +=
+ RetroMenuEntry.Action(
+ label = getString(R.string.retro_netplay_dismiss),
+ icon = RetroDrawerIcons.Exit,
+ ) {
+ RetroNetplayLobby.dismissScanResults()
+ menu.rebuild()
+ }
+ }
+ RetroNetplayLobby.discovered.forEach { room ->
+ entries +=
+ RetroMenuEntry.Action(
+ label = "${room.gameName} · ${room.hostPlayerName}",
+ icon = RetroDrawerIcons.Play,
+ subtitle = "${room.hostAddress}:${room.port} · ${getString(R.string.retro_netplay_join_room)}",
+ ) {
+ joinDiscoveredRoom(room)
+ }
+ }
+ if (phase != RetroNetplayPhase.SCANNING) {
+ entries +=
+ RetroMenuEntry.Action(
+ label = getString(R.string.retro_netplay_join_action),
+ icon = RetroDrawerIcons.Link,
+ subtitle =
+ if (savedHost.isNotBlank()) {
+ "$savedHost:$port"
+ } else {
+ getString(R.string.retro_gs_netplay_host_hint)
+ },
+ ) {
+ promptJoinByAddress(sysId, port, name, savedHost)
+ }
+ }
+ }
+ }
+ return entries
+ }
+
+ private fun promptJoinByAddress(
+ sysId: String,
+ port: Int,
+ playerName: String,
+ initialHost: String,
+ ) {
+ menu.renamePrompt =
+ RetroRenamePrompt(
+ title = getString(R.string.retro_gs_netplay_host),
+ initial = initialHost,
+ ) { entered ->
+ val host = entered?.trim().orEmpty()
+ if (host.isBlank()) return@RetroRenamePrompt
+ netplayLocalPort = 1
+ RetroNetplayLobby.join(
+ host = host,
+ port = port,
+ playerName = playerName,
+ gameName = gameName,
+ systemId = sysId,
+ retroView = retroView,
+ )
+ menu.close()
+ menu.rebuild()
+ }
+ }
+
+ private fun buildControlsEntries(): List =
+ RetroControlsMenu.build(
+ RetroControlsMenu.Host(
+ context = this,
+ overlay = overlay,
+ menu = menu,
+ systemId = system?.id,
+ touchControls = { touchControlsSetting },
+ onTouchControls = { value ->
+ touchControlsSetting = value
+ if (controllerConnected) manualTouchOverride = true
+ updateOverlayVisibility()
+ syncInGameOverlayPlacement()
+ persistExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (value) "1" else "0")
+ },
+ adaptiveSticks = { adaptiveSticksSetting },
+ onAdaptiveSticks = { value ->
+ adaptiveSticksSetting = value
+ persistExtra(RetroShortcuts.KEY_ADAPTIVE_STICKS, if (value) "1" else "0")
+ },
+ orientationLabel = {
+ if ((rootLayout?.height ?: 0) > (rootLayout?.width ?: 0)) {
+ getString(R.string.retro_lr_portrait)
+ } else {
+ getString(R.string.retro_lr_landscape)
+ }
+ },
+ onCloseMenu = { menu.close() },
+ showStickInversion = system?.id == RetroSystems.PSX.id || RetroCoreManager.usesDolphinCore(system),
+ ),
+ )
+
+ private fun setHudVisible(value: Boolean) {
+ hudVisible = value
+ if (value) {
+ showHud()
+ } else {
+ frameRating?.visibility = View.GONE
+ applyDisplayGeometry()
+ }
+ persistExtra(RetroShortcuts.KEY_HUD, if (value) "1" else "0")
+ menu.rebuild()
+ }
+
+ private fun buildHudEntries(): List {
+ val style =
+ HudStyle(
+ alpha = hudAlpha,
+ bgDecoupled = hudBgDecoupled,
+ bgAlpha = hudBgAlpha,
+ scale = hudScale,
+ frametimeNumeric = hudFrametimeNumeric,
+ dualBattery = hudDualBattery,
+ )
+ return RetroHudSupport.buildHudEntries(
+ context = this,
+ hudVisible = hudVisible,
+ style = style,
+ elements = hudElements,
+ onMaster = { setHudVisible(it) },
+ onStyle = { next ->
+ hudAlpha = next.alpha
+ hudBgDecoupled = next.bgDecoupled
+ hudBgAlpha = next.bgAlpha
+ hudScale = next.scale
+ hudFrametimeNumeric = next.frametimeNumeric
+ hudDualBattery = next.dualBattery
+ frameRating?.let { RetroHudSupport.applyStyle(it, next, hudElements) }
+ saveHudSettings()
+ },
+ onElements = { next ->
+ hudElements = next
+ frameRating?.let { RetroHudSupport.applyStyle(it, style, next) }
+ saveHudSettings()
+ },
+ onRebuild = { menu.rebuild() },
+ )
+ }
+
+ private fun buildMainEntries(): List {
+ val entries = mutableListOf()
+ val hardcoreActive = achievementsSessionStarted && RetroAchievementsManager.isHardcoreActive()
+ entries +=
+ RetroMenuEntry.Action(getString(R.string.retro_lr_save_state), RetroDrawerIcons.Save) {
+ savesLoadMode = false
+ menu.showPane(RetroPane.SAVES)
+ }
+ entries +=
+ RetroMenuEntry.Action(getString(R.string.retro_lr_load_save_state), RetroDrawerIcons.Load) {
+ if (hardcoreActive) {
+ Toast.makeText(this, getString(R.string.retro_lr_loading_states_disabled_hardcore), Toast.LENGTH_SHORT).show()
+ } else {
+ savesLoadMode = true
+ menu.showPane(RetroPane.SAVES)
+ }
+ }
+ if (RetroAchievementsManager.isLoggedIn(this) && RetroAchievementsManager.consoleId(system?.id) != 0) {
+ entries +=
+ RetroMenuEntry.Action(getString(R.string.retro_lr_achievements), RetroDrawerIcons.Achievements) {
+ menu.close()
+ openAchievementsScreen()
+ }
+ }
+ entries +=
+ RetroMenuEntry.Action(getString(R.string.retro_lr_cheats), RetroDrawerIcons.Cheats, active = !cheatsAllowed()) {
+ if (!cheatsAllowed()) {
+ Toast.makeText(this, getString(R.string.retro_lr_cheats_disabled_hardcore), Toast.LENGTH_SHORT).show()
+ } else {
+ menu.close()
+ openCheatsScreen()
+ }
+ }
+ entries +=
+ RetroMenuEntry.Action(getString(R.string.retro_lr_reset), RetroDrawerIcons.Reset) {
+ menu.close()
+ retroView.reset()
+ RetroAchievementsManager.onEmulatorReset()
+ }
+ entries +=
+ RetroMenuEntry.Action(getString(R.string.retro_lr_fast_forward), RetroDrawerIcons.FastForward, active = fastForward) {
+ if (hardcoreActive) {
+ Toast.makeText(this, getString(R.string.retro_lr_fast_forward_disabled_hardcore), Toast.LENGTH_SHORT).show()
+ } else {
+ fastForward = !fastForward
+ retroView.frameSpeed = if (fastForward) 2 else 1
+ menu.rebuild()
+ }
+ }
+ entries +=
+ RetroMenuEntry.Action(getString(R.string.retro_lr_hud), RetroDrawerIcons.Hud, active = hudVisible) {
+ setHudVisible(!hudVisible)
+ }
+ if (diskCount > 1) {
+ entries +=
+ RetroMenuEntry.Action(getString(R.string.retro_lr_disc, currentDisk + 1, diskCount), RetroDrawerIcons.Disc) {
+ val next = (currentDisk + 1) % diskCount
+ lifecycleScope.launch(Dispatchers.Default) {
+ runCatching { retroView.changeDisk(next) }
+ currentDisk = next
+ runOnUiThread { menu.rebuild() }
+ }
+ }
+ }
+ return entries
+ }
+
+ private fun buildBottomEntries(): List =
+ listOf(
+ if (emulationPaused) {
+ RetroMenuEntry.Action(getString(R.string.retro_lr_resume), RetroDrawerIcons.Resume, active = true) {
+ resumeEmulation()
+ menu.close()
+ }
+ } else {
+ RetroMenuEntry.Action(getString(R.string.retro_lr_pause), RetroDrawerIcons.Pause) {
+ pauseEmulation()
+ menu.close()
+ }
+ },
+ RetroMenuEntry.Action(getString(R.string.retro_lr_exit), RetroDrawerIcons.Exit, danger = true) { finish() },
+ )
+
+ private fun requestSixtyHzDisplayMode(): Boolean {
+ val display = runCatching { windowManager.defaultDisplay }.getOrNull() ?: return false
+ val current = runCatching { display.mode }.getOrNull() ?: return false
+ val rate = display.refreshRate
+ val multiple = Math.round(rate / 60f)
+ if (multiple >= 1 && kotlin.math.abs(rate - multiple * 60f) < 2f) return false
+ val sixtyModes = display.supportedModes.filter { abs(it.refreshRate - 60f) < 1f }
+ val target =
+ sixtyModes.firstOrNull {
+ it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
+ } ?: sixtyModes.firstOrNull() ?: return false
+ val attributes = window.attributes
+ attributes.preferredDisplayModeId = target.modeId
+ window.attributes = attributes
+ return true
+ }
+
+ private fun openMenu() {
+ if (!retroReady) {
+ return
+ }
+ overlay?.releaseAll()
+ menu.open()
+ }
+
+ private fun mapPhysicalKey(keyCode: Int): Int =
+ when (keyCode) {
+ KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_BUTTON_B
+ KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_X -> KeyEvent.KEYCODE_BUTTON_Y
+ KeyEvent.KEYCODE_BUTTON_Y -> KeyEvent.KEYCODE_BUTTON_X
+ else -> keyCode
+ }
+
+ private fun isGamepadSource(event: KeyEvent): Boolean {
+ val source = event.device?.sources ?: return false
+ return source and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
+ source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
+ }
+
+ private val forwardedKeys =
+ setOf(
+ KeyEvent.KEYCODE_BUTTON_A,
+ KeyEvent.KEYCODE_BUTTON_B,
+ KeyEvent.KEYCODE_BUTTON_X,
+ KeyEvent.KEYCODE_BUTTON_Y,
+ KeyEvent.KEYCODE_BUTTON_L1,
+ KeyEvent.KEYCODE_BUTTON_R1,
+ KeyEvent.KEYCODE_BUTTON_L2,
+ KeyEvent.KEYCODE_BUTTON_R2,
+ KeyEvent.KEYCODE_BUTTON_THUMBL,
+ KeyEvent.KEYCODE_BUTTON_THUMBR,
+ KeyEvent.KEYCODE_BUTTON_START,
+ KeyEvent.KEYCODE_BUTTON_SELECT,
+ KeyEvent.KEYCODE_DPAD_UP,
+ KeyEvent.KEYCODE_DPAD_DOWN,
+ KeyEvent.KEYCODE_DPAD_LEFT,
+ KeyEvent.KEYCODE_DPAD_RIGHT,
+ )
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ val keyCode = event.keyCode
+ if (keyCode == KeyEvent.KEYCODE_BUTTON_MODE && isGamepadSource(event)) {
+ if (event.action == KeyEvent.ACTION_UP) {
+ if (menu.visible) menu.close() else if (retroReady) openMenu()
+ }
+ return true
+ }
+ if (menu.visible && isGamepadSource(event)) {
+ menu.handleKey(keyCode, event.action)
+ return true
+ }
+ if (retroReady && isGamepadSource(event)) {
+ if (keyCode in forwardedKeys) {
+ val mapped = mapPhysicalKey(keyCode)
+ val port = localNetplayPort()
+ retroView.sendKeyEvent(event.action, mapped, port)
+ if (RetroNetplayLobby.activeSession()?.isRunning == true) {
+ RetroNetplayLobby.sendLocalKey(mapped, event.action)
+ }
+ return true
+ }
+ }
+ return super.dispatchKeyEvent(event)
+ }
+
+ override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+ if (menu.visible && event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK) {
+ val x = event.getAxisValue(MotionEvent.AXIS_HAT_X).takeIf { abs(it) > 0.5f } ?: event.getAxisValue(MotionEvent.AXIS_X)
+ val y = event.getAxisValue(MotionEvent.AXIS_HAT_Y).takeIf { abs(it) > 0.5f } ?: event.getAxisValue(MotionEvent.AXIS_Y)
+ menu.handleAxis(x, y)
+ return true
+ }
+ if (retroReady &&
+ event.action == MotionEvent.ACTION_MOVE &&
+ event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
+ ) {
+ val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X)
+ val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
+ val stickX = if (overlay?.invertLX == true) -event.getAxisValue(MotionEvent.AXIS_X) else event.getAxisValue(MotionEvent.AXIS_X)
+ val stickY = if (overlay?.invertLY == true) -event.getAxisValue(MotionEvent.AXIS_Y) else event.getAxisValue(MotionEvent.AXIS_Y)
+ val port = localNetplayPort()
+ val netActive = RetroNetplayLobby.activeSession()?.isRunning == true
+ if (stickIsAnalog) {
+ retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_DPAD, hatX, hatY, port)
+ retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, stickX, stickY, port)
+ if (netActive) {
+ RetroNetplayLobby.sendLocalMotion(GLRetroView.MOTION_SOURCE_DPAD, hatX, hatY)
+ RetroNetplayLobby.sendLocalMotion(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, stickX, stickY)
+ }
+ } else {
+ val deadzone = 0.45f
+ val dpadX =
+ when {
+ abs(hatX) > 0.5f -> hatX
+ stickX > deadzone -> 1f
+ stickX < -deadzone -> -1f
+ else -> 0f
+ }
+ val dpadY =
+ when {
+ abs(hatY) > 0.5f -> hatY
+ stickY > deadzone -> 1f
+ stickY < -deadzone -> -1f
+ else -> 0f
+ }
+ retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_DPAD, dpadX, dpadY, port)
+ retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, stickX, stickY, port)
+ if (netActive) {
+ RetroNetplayLobby.sendLocalMotion(GLRetroView.MOTION_SOURCE_DPAD, dpadX, dpadY)
+ RetroNetplayLobby.sendLocalMotion(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, stickX, stickY)
+ }
+ }
+ val rx = if (overlay?.invertRX == true) -event.getAxisValue(MotionEvent.AXIS_Z) else event.getAxisValue(MotionEvent.AXIS_Z)
+ val ry = if (overlay?.invertRY == true) -event.getAxisValue(MotionEvent.AXIS_RZ) else event.getAxisValue(MotionEvent.AXIS_RZ)
+ retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_RIGHT, rx, ry, port)
+ if (netActive) {
+ RetroNetplayLobby.sendLocalMotion(GLRetroView.MOTION_SOURCE_ANALOG_RIGHT, rx, ry)
+ }
+ return true
+ }
+ return super.dispatchGenericMotionEvent(event)
+ }
+
+ override fun onButton(
+ keyCode: Int,
+ down: Boolean,
+ ) {
+ if (!retroReady || menu.visible) return
+ val action = if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP
+ val port = localNetplayPort()
+ retroView.sendKeyEvent(action, keyCode, port)
+ if (RetroNetplayLobby.activeSession()?.isRunning == true) {
+ RetroNetplayLobby.sendLocalKey(keyCode, action)
+ }
+ }
+
+ override fun onDpad(
+ x: Float,
+ y: Float,
+ ) {
+ if (!retroReady || menu.visible) return
+ val port = localNetplayPort()
+ retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_DPAD, x, y, port)
+ if (RetroNetplayLobby.activeSession()?.isRunning == true) {
+ RetroNetplayLobby.sendLocalMotion(GLRetroView.MOTION_SOURCE_DPAD, x, y)
+ }
+ }
+
+ override fun onStick(
+ x: Float,
+ y: Float,
+ ) {
+ if (!retroReady || menu.visible) return
+ val port = localNetplayPort()
+ retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, x, y, port)
+ if (RetroNetplayLobby.activeSession()?.isRunning == true) {
+ RetroNetplayLobby.sendLocalMotion(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, x, y)
+ }
+ }
+
+ override fun onRightStick(
+ x: Float,
+ y: Float,
+ ) {
+ if (!retroReady || menu.visible) return
+ val port = localNetplayPort()
+ retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_RIGHT, x, y, port)
+ if (RetroNetplayLobby.activeSession()?.isRunning == true) {
+ RetroNetplayLobby.sendLocalMotion(GLRetroView.MOTION_SOURCE_ANALOG_RIGHT, x, y)
+ }
+ }
+
+ override fun onMenu() {
+ runOnUiThread { openMenu() }
+ }
+
+ private fun saveState(slot: Int) {
+ runCatching {
+ val bytes = retroView.serializeState()
+ check(RetroSaveStates.writeSlot(this, gameName, slot, bytes))
+ }.onSuccess {
+ showInGameMessage(getString(R.string.retro_lr_saved_to_slot, slot))
+ launchExitCloudBackup()
+ }.onFailure {
+ showInGameMessage(getString(R.string.retro_lr_could_not_save_state))
+ }
+ }
+
+ private fun showBiosRequiredDialog(system: RetroSystem) {
+ androidx.appcompat.app.AlertDialog.Builder(this)
+ .setTitle(getString(R.string.retro_lr_bios_required_title, system.shortName))
+ .setMessage(getString(R.string.retro_lr_bios_required_message))
+ .setCancelable(false)
+ .setPositiveButton(getString(R.string.retro_lr_import_bios)) { _, _ ->
+ runCatching { biosPicker.launch(arrayOf("*/*")) }
+ .onFailure { finish() }
+ }
+ .setNegativeButton(getString(R.string.retro_lr_cancel)) { _, _ -> finish() }
+ .show()
+ }
+
+ private fun openAchievementsScreen() {
+ val sys = system ?: return
+ val rom = intent.getStringExtra(EXTRA_ROM_PATH) ?: return
+ startActivity(
+ android.content.Intent(this, RetroAchievementsActivity::class.java).apply {
+ putExtra(RetroAchievementsActivity.EXTRA_SYSTEM_ID, sys.id)
+ putExtra(RetroAchievementsActivity.EXTRA_GAME_NAME, gameName)
+ putExtra(RetroAchievementsActivity.EXTRA_ROM_PATH, rom)
+ putExtra(RetroAchievementsActivity.EXTRA_IN_SESSION, true)
+ },
+ )
+ }
+
+ private fun loadState(slot: Int) {
+ if (achievementsSessionStarted && RetroAchievementsManager.isHardcoreActive()) {
+ showInGameMessage(getString(R.string.retro_lr_loading_states_disabled_hardcore))
+ return
+ }
+ val bytes = RetroSaveStates.readSlot(this, gameName, slot)
+ if (bytes == null) {
+ showInGameMessage(getString(R.string.retro_lr_slot_empty, slot))
+ return
+ }
+ runCatching { check(retroView.unserializeState(bytes)) }
+ .onSuccess { showInGameMessage(getString(R.string.retro_lr_loaded_slot, slot)) }
+ .onFailure { showInGameMessage(getString(R.string.retro_lr_could_not_load_state)) }
+ }
+
+ private fun buildSaveSlotEntries(): List =
+ RetroSaveStates.listSlots(this, gameName).map { info ->
+ RetroMenuEntry.SaveSlot(
+ slot = info.slot,
+ title = info.customName ?: getString(R.string.retro_lr_slot_title, info.slot),
+ subtitle = RetroSaveStates.relativeTime(info.timestampMs),
+ filled = info.exists,
+ onClick = {
+ if (savesLoadMode) {
+ if (info.exists) {
+ menu.close()
+ loadState(info.slot)
+ }
+ } else {
+ saveState(info.slot)
+ menu.rebuild()
+ }
+ },
+ onRename = {
+ menu.renamePrompt =
+ RetroRenamePrompt(
+ title = getString(R.string.retro_lr_rename_slot, info.slot),
+ initial = info.customName ?: "",
+ ) { newName ->
+ RetroSaveStates.renameSlot(this, gameName, info.slot, newName)
+ menu.rebuild()
+ }
+ },
+ )
+ }
+
+ private fun persistSram() {
+ if (!retroReady) return
+ runCatching {
+ val sram = retroView.serializeSRAM()
+ if (sram.isNotEmpty()) {
+ RetroSaveStates.sramFile(this, gameName).writeBytes(sram)
+ }
+ }
+ }
+
+ override fun onPause() {
+ persistSram()
+ accumulatePlaytime()
+ if (isFinishing) launchExitCloudBackup()
+ super.onPause()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (retroReady && surfaceReady) applyCheats()
+ if (emulationPaused && retroReady) {
+ window.decorView.post {
+ if (emulationPaused && retroReady) {
+ retroView.onPause()
+ LibretroDroid.pause()
+ }
+ }
+ }
+ }
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (hasFocus) hideSystemBars()
+ }
+
+ private fun hideSystemBars() {
+ val controller = WindowInsetsControllerCompat(window, window.decorView)
+ controller.hide(WindowInsetsCompat.Type.systemBars())
+ controller.systemBarsBehavior =
+ WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ window.decorView.systemUiVisibility =
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
+ View.SYSTEM_UI_FLAG_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ }
+}
diff --git a/app/src/main/feature/retro/RetroBiosImport.kt b/app/src/main/feature/retro/RetroBiosImport.kt
new file mode 100644
index 000000000..730b753a2
--- /dev/null
+++ b/app/src/main/feature/retro/RetroBiosImport.kt
@@ -0,0 +1,95 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.net.Uri
+import java.io.File
+import java.security.MessageDigest
+
+object RetroBiosImport {
+ private val KNOWN_BIOS: Map =
+ mapOf(
+ "8dd7d5296a650fac7319bce665a6a53c" to "scph5500.bin",
+ "490f666e1afb15b7362b406ed1cea246" to "scph5501.bin",
+ "32736f17079d0b2b7024407c39bd3050" to "scph5502.bin",
+ "924e392ed05558ffdb115408c263dccf" to "scph1001.bin",
+ "1e68c231d0896b7eadcad1d7d8e76129" to "scph7001.bin",
+ )
+
+ fun importFromUri(
+ context: Context,
+ uri: Uri,
+ ): Result =
+ runCatching {
+ val bytes =
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ ?: throw IllegalStateException("Could not read file")
+ val md5 = MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) }
+ val canonical =
+ KNOWN_BIOS[md5]
+ ?: throw IllegalArgumentException("Not a recognized PlayStation BIOS")
+ val dir = RetroCoreManager.systemDir(context)
+ val target = File(dir, canonical)
+ val tmp = File(dir, "$canonical.tmp")
+ tmp.writeBytes(bytes)
+ if (!tmp.renameTo(target)) {
+ target.writeBytes(bytes)
+ tmp.delete()
+ }
+ canonical
+ }
+
+ fun ps2BiosDir(context: Context): File =
+ File(context.getExternalFilesDir(null) ?: context.filesDir, "bios").apply { mkdirs() }
+
+ fun installedPs2Bios(context: Context): List =
+ ps2BiosDir(context).listFiles().orEmpty()
+ .filter { it.isFile && it.length() >= 3L * 1024 * 1024 }
+ .map { it.name }
+ .sorted()
+
+ fun deletePs1Bios(context: Context): Int {
+ val dir = RetroCoreManager.systemDir(context)
+ var removed = 0
+ RetroSystems.PSX.biosFiles.forEach { val f = File(dir, it); if (f.isFile && f.delete()) removed++ }
+ return removed
+ }
+
+ fun deletePs2Bios(context: Context): Int {
+ var removed = 0
+ ps2BiosDir(context).listFiles().orEmpty().forEach { if (it.isFile && it.delete()) removed++ }
+ context.getSharedPreferences("ARMSX2", Context.MODE_PRIVATE).edit().remove("bios").apply()
+ return removed
+ }
+
+ fun importPs2FromUri(
+ context: Context,
+ uri: Uri,
+ ): Result =
+ runCatching {
+ val name =
+ context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
+ if (it.moveToFirst()) it.getString(0) else null
+ } ?: "ps2-bios.bin"
+ val bytes =
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ ?: throw IllegalStateException("Could not read file")
+ if (bytes.size !in (3 * 1024 * 1024)..(8 * 1024 * 1024)) {
+ throw IllegalArgumentException("Not a PS2 BIOS — expected a single merged .bin dump (3–8MB), not a split ROM0/MEC/NVM set.")
+ }
+ val head = String(bytes, 0, minOf(bytes.size, 4096), Charsets.ISO_8859_1)
+ if (!head.contains("PS2", true) && !head.contains("Sony", true) && !head.contains("ROMDIR", true)) {
+ throw IllegalArgumentException("This file doesn't look like a PS2 BIOS dump.")
+ }
+ val safe = name.ifBlank { "ps2-bios.bin" }.let { if (it.endsWith(".bin", true)) it else "$it.bin" }
+ val target = File(ps2BiosDir(context), safe)
+ val tmp = File(ps2BiosDir(context), "$safe.tmp")
+ tmp.writeBytes(bytes)
+ if (!tmp.renameTo(target)) {
+ target.writeBytes(bytes)
+ tmp.delete()
+ }
+ context.getSharedPreferences("ARMSX2", Context.MODE_PRIVATE)
+ .edit().putString("bios", target.absolutePath).apply()
+ safe
+ }
+}
diff --git a/app/src/main/feature/retro/RetroBoxart.kt b/app/src/main/feature/retro/RetroBoxart.kt
new file mode 100644
index 000000000..fe3283282
--- /dev/null
+++ b/app/src/main/feature/retro/RetroBoxart.kt
@@ -0,0 +1,357 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.LinearGradient
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.RadialGradient
+import android.graphics.RectF
+import android.graphics.Shader
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+import java.net.URLDecoder
+import java.net.URLEncoder
+import java.util.Locale
+
+object RetroBoxart {
+ private const val BASE = "https://thumbnails.libretro.com"
+ private const val OUT_W = 920
+ private const val OUT_H = 430
+ private const val INDEX_TTL_MS = 7L * 24 * 3600 * 1000
+
+ private val FOLDERS =
+ mapOf(
+ "nes" to "Nintendo - Nintendo Entertainment System",
+ "snes" to "Nintendo - Super Nintendo Entertainment System",
+ "gb" to "Nintendo - Game Boy",
+ "gbc" to "Nintendo - Game Boy Color",
+ "gba" to "Nintendo - Game Boy Advance",
+ "genesis" to "Sega - Mega Drive - Genesis",
+ "sms" to "Sega - Master System - Mark III",
+ "gg" to "Sega - Game Gear",
+ "n64" to "Nintendo - Nintendo 64",
+ "psx" to "Sony - PlayStation",
+ "ps2" to "Sony - PlayStation 2",
+ "gc" to "Nintendo - GameCube",
+ "wii" to "Nintendo - Wii",
+ )
+
+ private data class CaseStyle(
+ val body: Int,
+ val accent: Int,
+ val cartAspect: Float,
+ val isDisc: Boolean,
+ val labelPortrait: Boolean,
+ )
+
+ private val STYLES =
+ mapOf(
+ "nes" to CaseStyle(0xFF9A9AA2.toInt(), 0xFF3B3B42.toInt(), 0.80f, false, true),
+ "snes" to CaseStyle(0xFFB6B1AB.toInt(), 0xFF8A5BA5.toInt(), 1.45f, false, false),
+ "gb" to CaseStyle(0xFFB9B8C2.toInt(), 0xFF4A4A55.toInt(), 1.0f, false, true),
+ "gbc" to CaseStyle(0xFF6E5FD8.toInt(), 0xFF2F2A66.toInt(), 1.0f, false, true),
+ "gba" to CaseStyle(0xFF5F58C7.toInt(), 0xFF29255C.toInt(), 1.5f, false, false),
+ "genesis" to CaseStyle(0xFF232329.toInt(), 0xFFC9A24A.toInt(), 0.82f, false, true),
+ "sms" to CaseStyle(0xFF24242C.toInt(), 0xFFD03A3A.toInt(), 0.82f, false, true),
+ "gg" to CaseStyle(0xFF1C1C22.toInt(), 0xFF3E7BD6.toInt(), 0.95f, false, true),
+ "n64" to CaseStyle(0xFF515158.toInt(), 0xFFCF3B3B.toInt(), 1.28f, false, false),
+ "psx" to CaseStyle(0xFFE8E8EC.toInt(), 0xFF2F2F36.toInt(), 1.0f, true, false),
+ "ps2" to CaseStyle(0xFF1E2C6E.toInt(), 0xFF0E1436.toInt(), 0.74f, true, true),
+ "gc" to CaseStyle(0xFF4A4E57.toInt(), 0xFF6B3FA0.toInt(), 0.74f, true, true),
+ "wii" to CaseStyle(0xFFE8E8EC.toInt(), 0xFF1BA0D8.toInt(), 0.74f, true, true),
+ )
+
+ val artVersion = androidx.compose.runtime.mutableStateOf(0)
+ private val working = java.util.concurrent.atomic.AtomicBoolean(false)
+
+ fun ensureArtworkAsync(context: Context) {
+ if (!working.compareAndSet(false, true)) return
+ val app = context.applicationContext
+ Thread {
+ try {
+ val retro =
+ runCatching {
+ com.winlator.cmod.runtime.container.ContainerManager(app)
+ .loadShortcuts()
+ .filter { RetroShortcuts.isRetroShortcut(it) }
+ }.getOrDefault(emptyList())
+ retro.forEach { shortcut ->
+ if (com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork.findIconArtworkPath(shortcut) != null) return@forEach
+ val existing = shortcut.getExtra("customCoverArtPath")
+ if (existing.isNotBlank() && File(existing).isFile) return@forEach
+ val system = RetroShortcuts.systemForShortcut(shortcut) ?: return@forEach
+ val name = shortcut.getExtra("custom_name", shortcut.name)
+ val uuid = com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork.ensureShortcutUuid(shortcut)
+ val artFile = com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork.buildManagedCustomGameArtworkFile(app, uuid)
+ if (fetchAndCompose(app, system, name, artFile)) {
+ shortcut.putExtra("customCoverArtPath", artFile.absolutePath)
+ shortcut.saveData()
+ artVersion.value++
+ }
+ }
+ } finally {
+ working.set(false)
+ }
+ }.start()
+ }
+
+ fun caseArtEnabled(context: Context): Boolean =
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getBoolean("retro_case_art", true)
+
+ fun setCaseArtEnabled(context: Context, enabled: Boolean) {
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .edit().putBoolean("retro_case_art", enabled).apply()
+ }
+
+ fun fetchAndCompose(context: Context, system: RetroSystem, gameName: String, outFile: File): Boolean =
+ runCatching {
+ val boxart = fetchBoxart(context, system, gameName)
+ val bmp = compose(context, system, gameName, boxart)
+ outFile.parentFile?.mkdirs()
+ outFile.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 95, it) }
+ bmp.recycle()
+ boxart?.recycle()
+ outFile.isFile
+ }.getOrDefault(false)
+
+ fun composeCustom(context: Context, system: RetroSystem, gameName: String, source: Bitmap, outFile: File): Boolean =
+ runCatching {
+ val bmp = compose(context, system, gameName, source)
+ outFile.parentFile?.mkdirs()
+ outFile.outputStream().use { bmp.compress(Bitmap.CompressFormat.PNG, 95, it) }
+ bmp.recycle()
+ outFile.isFile
+ }.getOrDefault(false)
+
+ private fun fetchBoxart(context: Context, system: RetroSystem, gameName: String): Bitmap? {
+ val folder = FOLDERS[system.id] ?: return null
+ val names = index(context, system.id, folder) ?: return null
+ val match = bestMatch(gameName, names) ?: return null
+ val url = "$BASE/${encodePath(folder)}/Named_Boxarts/${encodePath(match)}"
+ return download(url)
+ }
+
+ private fun index(context: Context, sysId: String, folder: String): List? {
+ val cache = File(File(context.filesDir, "retro/boxart_index"), "$sysId.txt")
+ if (cache.isFile && System.currentTimeMillis() - cache.lastModified() < INDEX_TTL_MS) {
+ return cache.readLines().filter { it.isNotBlank() }
+ }
+ val html =
+ runCatching {
+ val conn = URL("$BASE/${encodePath(folder)}/Named_Boxarts/").openConnection() as HttpURLConnection
+ conn.connectTimeout = 12000
+ conn.readTimeout = 30000
+ conn.inputStream.bufferedReader().use { it.readText() }
+ }.getOrNull() ?: return if (cache.isFile) cache.readLines().filter { it.isNotBlank() } else null
+ val names =
+ Regex("href=\"([^\"?]+\\.png)\"").findAll(html)
+ .map { URLDecoder.decode(it.groupValues[1], "UTF-8") }
+ .filter { !it.startsWith("/") }
+ .toList()
+ if (names.isEmpty()) return null
+ cache.parentFile?.mkdirs()
+ cache.writeText(names.joinToString("\n"))
+ return names
+ }
+
+ private fun normalize(name: String): String =
+ name.substringBeforeLast('.')
+ .replace(Regex("[\\(\\[][^\\)\\]]*[\\)\\]]"), " ")
+ .lowercase(Locale.US)
+ .replace(Regex("[^a-z0-9]+"), " ")
+ .trim()
+
+ private fun bestMatch(gameName: String, names: List): String? {
+ val target = normalize(gameName)
+ if (target.isBlank()) return null
+ val byNorm = LinkedHashMap>()
+ names.forEach { byNorm.getOrPut(normalize(it)) { ArrayList() }.add(it) }
+ val exact = byNorm[target]
+ if (exact != null) return preferRegion(exact)
+ val targetTokens = target.split(' ').toSet()
+ var best: String? = null
+ var bestScore = 0f
+ byNorm.forEach { (norm, files) ->
+ val tokens = norm.split(' ').toSet()
+ val overlap = (targetTokens intersect tokens).size.toFloat()
+ if (overlap == 0f) return@forEach
+ val score = overlap * 2f / (targetTokens.size + tokens.size)
+ val boosted = if (norm.startsWith(target) || target.startsWith(norm)) score + 0.3f else score
+ if (boosted > bestScore) {
+ bestScore = boosted
+ best = preferRegion(files)
+ }
+ }
+ return if (bestScore >= 0.6f) best else null
+ }
+
+ private fun preferRegion(files: List): String =
+ files.firstOrNull { it.contains("(USA", true) }
+ ?: files.firstOrNull { it.contains("(World", true) }
+ ?: files.firstOrNull { it.contains("(Europe", true) }
+ ?: files.first()
+
+ private fun download(url: String): Bitmap? =
+ runCatching {
+ val conn = URL(url).openConnection() as HttpURLConnection
+ conn.connectTimeout = 12000
+ conn.readTimeout = 30000
+ conn.inputStream.use { BitmapFactory.decodeStream(it) }
+ }.getOrNull()
+
+ private fun encodePath(segment: String): String =
+ URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
+
+ private fun compose(context: Context, system: RetroSystem, gameName: String, boxart: Bitmap?): Bitmap {
+ val style = STYLES[system.id] ?: STYLES.getValue("nes")
+ val out = Bitmap.createBitmap(OUT_W, OUT_H, Bitmap.Config.ARGB_8888)
+ val c = Canvas(out)
+ val p = Paint(Paint.ANTI_ALIAS_FLAG)
+
+ p.shader = LinearGradient(0f, 0f, 0f, OUT_H.toFloat(), 0xFF16161E.toInt(), 0xFF0B0B10.toInt(), Shader.TileMode.CLAMP)
+ c.drawRect(0f, 0f, OUT_W.toFloat(), OUT_H.toFloat(), p)
+ p.shader = RadialGradient(OUT_W * 0.5f, OUT_H * 0.42f, OUT_W * 0.5f, 0x2AFFFFFF, 0x00000000, Shader.TileMode.CLAMP)
+ c.drawRect(0f, 0f, OUT_W.toFloat(), OUT_H.toFloat(), p)
+ p.shader = null
+
+ if (!caseArtEnabled(context) && boxart != null) {
+ drawFitted(c, boxart, RectF(0f, 0f, OUT_W.toFloat(), OUT_H.toFloat()), 24f, 0.94f)
+ return out
+ }
+
+ val caseH = OUT_H * 0.86f
+ val caseW = (caseH * style.cartAspect).coerceAtMost(OUT_W * 0.62f)
+ val cx = OUT_W * 0.5f
+ val top = (OUT_H - caseH) * 0.5f
+ val body = RectF(cx - caseW / 2f, top, cx + caseW / 2f, top + caseH)
+ val corner = if (style.isDisc) 14f else 22f
+
+ p.color = 0x66000000
+ c.drawRoundRect(RectF(body.left + 8f, body.top + 12f, body.right + 8f, body.bottom + 12f), corner, corner, p)
+
+ p.shader = LinearGradient(body.left, body.top, body.left, body.bottom, lighten(style.body, 0.14f), darken(style.body, 0.18f), Shader.TileMode.CLAMP)
+ c.drawRoundRect(body, corner, corner, p)
+ p.shader = null
+ p.style = Paint.Style.STROKE
+ p.strokeWidth = 3f
+ p.color = darken(style.body, 0.4f)
+ c.drawRoundRect(body, corner, corner, p)
+ p.style = Paint.Style.FILL
+
+ val label: RectF
+ if (style.isDisc) {
+ val spineW = body.width() * 0.085f
+ p.color = style.accent
+ c.drawRoundRect(RectF(body.left, body.top, body.left + spineW + corner, body.bottom), corner, corner, p)
+ c.drawRect(RectF(body.left + spineW, body.top, body.left + spineW + corner, body.bottom), p)
+ label = RectF(body.left + spineW + 10f, body.top + 10f, body.right - 10f, body.bottom - 10f)
+ p.color = 0x30FFFFFF
+ c.drawRect(RectF(body.left + spineW + 4f, body.top, body.left + spineW + 8f, body.bottom), p)
+ } else {
+ p.color = darken(style.body, 0.28f)
+ val grooveTop = body.top + body.height() * 0.055f
+ for (i in 0 until 3) {
+ val y = grooveTop + i * body.height() * 0.028f
+ c.drawRoundRect(RectF(body.left + body.width() * 0.12f, y, body.right - body.width() * 0.12f, y + body.height() * 0.012f), 4f, 4f, p)
+ }
+ val inset = body.width() * 0.10f
+ val labelTop = body.top + body.height() * 0.17f
+ val labelBottom = body.bottom - body.height() * 0.09f
+ label = RectF(body.left + inset, labelTop, body.right - inset, labelBottom)
+ p.color = darken(style.body, 0.35f)
+ c.drawRoundRect(RectF(label.left - 5f, label.top - 5f, label.right + 5f, label.bottom + 5f), 10f, 10f, p)
+ }
+
+ if (boxart != null) {
+ drawFitted(c, boxart, label, 8f, 1f)
+ } else {
+ p.shader = LinearGradient(label.left, label.top, label.left, label.bottom, lighten(style.accent, 0.18f), darken(style.accent, 0.25f), Shader.TileMode.CLAMP)
+ c.drawRoundRect(label, 8f, 8f, p)
+ p.shader = null
+ drawTitle(c, gameName, system.shortName, label)
+ }
+
+ p.shader = LinearGradient(body.left, body.top, body.left, body.top + body.height() * 0.20f, 0x33FFFFFF, 0x00FFFFFF, Shader.TileMode.CLAMP)
+ c.drawRoundRect(RectF(body.left, body.top, body.right, body.top + body.height() * 0.22f), corner, corner, p)
+ p.shader = null
+ return out
+ }
+
+ private fun drawFitted(c: Canvas, bmp: Bitmap, area: RectF, radius: Float, maxFill: Float) {
+ val scale = minOf(area.width() * maxFill / bmp.width, area.height() * maxFill / bmp.height)
+ val w = bmp.width * scale
+ val h = bmp.height * scale
+ val dst = RectF(area.centerX() - w / 2f, area.centerY() - h / 2f, area.centerX() + w / 2f, area.centerY() + h / 2f)
+ val p = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
+ val path = Path().apply { addRoundRect(dst, radius, radius, Path.Direction.CW) }
+ c.save()
+ c.clipPath(path)
+ c.drawBitmap(bmp, null, dst, p)
+ c.restore()
+ }
+
+ private fun drawTitle(c: Canvas, gameName: String, shortName: String, area: RectF) {
+ val p = Paint(Paint.ANTI_ALIAS_FLAG)
+ p.color = Color.WHITE
+ p.textAlign = Paint.Align.CENTER
+ p.isFakeBoldText = true
+ var size = area.height() * 0.16f
+ p.textSize = size
+ val words = gameName.trim().split(Regex("\\s+"))
+ var lines = wrap(words, p, area.width() * 0.88f)
+ while (lines.size > 3 && size > area.height() * 0.09f) {
+ size *= 0.88f
+ p.textSize = size
+ lines = wrap(words, p, area.width() * 0.88f)
+ }
+ if (lines.size > 3) lines = lines.take(3)
+ val lineH = p.textSize * 1.18f
+ var y = area.centerY() - (lines.size - 1) * lineH / 2f + p.textSize * 0.35f
+ lines.forEach {
+ c.drawText(it, area.centerX(), y, p)
+ y += lineH
+ }
+ p.isFakeBoldText = false
+ p.textSize = area.height() * 0.08f
+ p.color = 0xB3FFFFFF.toInt()
+ c.drawText(shortName.uppercase(Locale.US), area.centerX(), area.bottom - area.height() * 0.06f, p)
+ }
+
+ private fun wrap(words: List, p: Paint, maxW: Float): List {
+ val lines = ArrayList()
+ var line = StringBuilder()
+ words.forEach { w ->
+ val candidate = if (line.isEmpty()) w else "$line $w"
+ if (p.measureText(candidate) <= maxW || line.isEmpty()) {
+ line = StringBuilder(candidate)
+ } else {
+ lines.add(line.toString())
+ line = StringBuilder(w)
+ }
+ }
+ if (line.isNotEmpty()) lines.add(line.toString())
+ return lines
+ }
+
+ private fun mix(a: Int, b: Int, f: Float): Int {
+ val inv = 1f - f
+ return Color.argb(
+ 255,
+ (Color.red(a) * inv + Color.red(b) * f).toInt(),
+ (Color.green(a) * inv + Color.green(b) * f).toInt(),
+ (Color.blue(a) * inv + Color.blue(b) * f).toInt(),
+ )
+ }
+
+ private fun lighten(color: Int, f: Float) = mix(color, Color.WHITE, f)
+
+ private fun darken(color: Int, f: Float) = mix(color, Color.BLACK, f)
+}
diff --git a/app/src/main/feature/retro/RetroBundle.kt b/app/src/main/feature/retro/RetroBundle.kt
new file mode 100644
index 000000000..50ffad661
--- /dev/null
+++ b/app/src/main/feature/retro/RetroBundle.kt
@@ -0,0 +1,179 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.widget.Toast
+import com.winlator.cmod.R
+import com.winlator.cmod.shared.io.TarCompressorUtils
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+import java.security.MessageDigest
+import org.json.JSONObject
+
+object RetroBundle {
+ private const val BASE = "https://github.com/WinNative-Emu/Retro-Consoles/releases/download/latest"
+ private const val ARCHIVE = "retro-consoles.tzst"
+ private const val INFO = "bundle-info.json"
+
+ fun root(context: Context): File = File(context.filesDir, "retro/bundle")
+
+ fun coresDir(context: Context): File = File(root(context), "cores")
+
+ fun dataDir(context: Context): File = File(root(context), "data")
+
+ data class Version(
+ val tag: String,
+ val buildDate: String,
+ val sha256: String,
+ val size: Long,
+ ) {
+ val day: String get() = buildDate.substringBefore('T')
+
+ fun toJson(): String =
+ JSONObject()
+ .put("tag", tag)
+ .put("buildDate", buildDate)
+ .put("sha256", sha256)
+ .put("size", size)
+ .toString()
+
+ companion object {
+ fun parse(json: String): Version {
+ val o = JSONObject(json)
+ return Version(
+ tag = o.getString("tag"),
+ buildDate = o.getString("buildDate"),
+ sha256 = o.getString("sha256").lowercase(),
+ size = o.getLong("size"),
+ )
+ }
+ }
+ }
+
+ private fun marker(context: Context): File = File(root(context), ".installed")
+
+ fun isInstalled(context: Context): Boolean = installed(context) != null
+
+ fun requireInstalled(context: Context): Boolean {
+ if (isInstalled(context)) return true
+ Toast.makeText(context, context.getString(R.string.retro_bundle_required), Toast.LENGTH_LONG).show()
+ return false
+ }
+
+ fun installed(context: Context): Version? =
+ runCatching { Version.parse(marker(context).readText()) }.getOrNull()
+
+ fun published(): Result = runCatching { Version.parse(fetchText("$BASE/$INFO")) }
+
+ sealed class Progress {
+ data class Downloading(val bytes: Long, val total: Long) : Progress()
+
+ data object Verifying : Progress()
+
+ data object Extracting : Progress()
+ }
+
+ fun install(
+ context: Context,
+ version: Version,
+ onProgress: (Progress) -> Unit = {},
+ ): Result =
+ runCatching {
+ val work = File(context.cacheDir, "retro-bundle").apply { deleteRecursively(); mkdirs() }
+ val archive = File(work, ARCHIVE)
+
+ download("$BASE/$ARCHIVE", archive) { got, total ->
+ onProgress(Progress.Downloading(got, if (total > 0) total else version.size))
+ }
+
+ onProgress(Progress.Verifying)
+ val actual = sha256(archive)
+ if (!actual.equals(version.sha256, ignoreCase = true)) {
+ throw IllegalStateException("Bundle checksum mismatch: expected ${version.sha256}, got $actual")
+ }
+
+ onProgress(Progress.Extracting)
+ val staging = File(work, "stage").apply { mkdirs() }
+ val ok =
+ TarCompressorUtils
+ .extractAsync(TarCompressorUtils.Type.ZSTD, archive, staging)
+ .get()
+ if (ok != true) throw IllegalStateException("Bundle extraction failed")
+
+ val destination = root(context)
+ destination.deleteRecursively()
+ destination.parentFile?.mkdirs()
+ if (!staging.renameTo(destination)) {
+ staging.copyRecursively(destination, overwrite = true)
+ staging.deleteRecursively()
+ }
+ marker(context).writeText(version.toJson())
+ work.deleteRecursively()
+ version
+ }
+
+ fun uninstall(context: Context) {
+ root(context).deleteRecursively()
+ }
+
+ private fun open(url: String): HttpURLConnection =
+ (URL(url).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 15000
+ readTimeout = 60000
+ instanceFollowRedirects = true
+ }
+
+ private fun fetchText(url: String): String =
+ open(url).let { c ->
+ try {
+ c.inputStream.bufferedReader().use { it.readText() }
+ } finally {
+ c.disconnect()
+ }
+ }
+
+ private fun download(
+ url: String,
+ target: File,
+ onProgress: (Long, Long) -> Unit,
+ ) {
+ val connection = open(url)
+ try {
+ val total = connection.contentLengthLong
+ target.parentFile?.mkdirs()
+ connection.inputStream.use { input ->
+ target.outputStream().use { output ->
+ val buffer = ByteArray(1 shl 16)
+ var got = 0L
+ var lastReport = 0L
+ while (true) {
+ val read = input.read(buffer)
+ if (read < 0) break
+ output.write(buffer, 0, read)
+ got += read
+ if (got - lastReport >= 1L shl 20) {
+ lastReport = got
+ onProgress(got, total)
+ }
+ }
+ onProgress(got, total)
+ }
+ }
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ private fun sha256(file: File): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.inputStream().use { input ->
+ val buffer = ByteArray(1 shl 16)
+ while (true) {
+ val read = input.read(buffer)
+ if (read < 0) break
+ digest.update(buffer, 0, read)
+ }
+ }
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+}
diff --git a/app/src/main/feature/retro/RetroCheats.kt b/app/src/main/feature/retro/RetroCheats.kt
new file mode 100644
index 000000000..97e15e8e6
--- /dev/null
+++ b/app/src/main/feature/retro/RetroCheats.kt
@@ -0,0 +1,72 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+import org.json.JSONArray
+import org.json.JSONObject
+
+data class RetroCheat(
+ val name: String,
+ val code: String,
+ val enabled: Boolean,
+)
+
+object RetroCheats {
+ private fun prefs(context: Context) = PreferenceManager.getDefaultSharedPreferences(context)
+
+ private fun safeName(gameName: String): String = gameName.replace(Regex("[^A-Za-z0-9._-]"), "_")
+
+ private fun key(gameName: String) = "retro_cheats_${safeName(gameName)}"
+
+ fun load(
+ context: Context,
+ gameName: String,
+ ): List {
+ val raw = prefs(context).getString(key(gameName), null) ?: return emptyList()
+ return runCatching {
+ val arr = JSONArray(raw)
+ (0 until arr.length()).map { i ->
+ val o = arr.getJSONObject(i)
+ RetroCheat(
+ name = o.optString("name"),
+ code = o.optString("code"),
+ enabled = o.optBoolean("enabled", false),
+ )
+ }
+ }.getOrDefault(emptyList())
+ }
+
+ fun save(
+ context: Context,
+ gameName: String,
+ cheats: List,
+ ) {
+ val arr = JSONArray()
+ cheats.forEach { c ->
+ arr.put(
+ JSONObject()
+ .put("name", c.name)
+ .put("code", c.code)
+ .put("enabled", c.enabled),
+ )
+ }
+ prefs(context).edit().putString(key(gameName), arr.toString()).apply()
+ }
+
+ fun hasEnabled(
+ context: Context,
+ gameName: String,
+ ): Boolean = load(context, gameName).any { it.enabled }
+
+ fun formatHint(systemId: String?): String =
+ when (systemId) {
+ RetroSystems.NES.id -> "Game Genie or raw codes (e.g. SXIOPO)"
+ RetroSystems.SNES.id -> "Game Genie or Pro Action Replay codes"
+ RetroSystems.GAMEBOY.id, RetroSystems.GAMEBOY_COLOR.id -> "Game Genie or GameShark codes"
+ RetroSystems.GBA.id -> "GameShark, Action Replay or Code Breaker codes"
+ RetroSystems.GENESIS.id, RetroSystems.MASTER_SYSTEM.id, RetroSystems.GAME_GEAR.id -> "Game Genie or raw codes"
+ RetroSystems.N64.id -> "GameShark codes (8-digit address + value)"
+ RetroSystems.PSX.id -> "GameShark codes (8-digit address + value)"
+ else -> "Cheat code"
+ }
+}
diff --git a/app/src/main/feature/retro/RetroCheatsActivity.kt b/app/src/main/feature/retro/RetroCheatsActivity.kt
new file mode 100644
index 000000000..489de1ded
--- /dev/null
+++ b/app/src/main/feature/retro/RetroCheatsActivity.kt
@@ -0,0 +1,359 @@
+package com.winlator.cmod.feature.retro
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Add
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.VideogameAsset
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.winlator.cmod.R
+import com.winlator.cmod.shared.ui.nav.LocalPaneNav
+import com.winlator.cmod.shared.ui.nav.PaneNavRegistry
+import com.winlator.cmod.shared.ui.nav.bindPaneNav
+import com.winlator.cmod.shared.ui.nav.paneNavItem
+
+private val BgDark = Color(0xFF12121B)
+private val SurfaceDark = Color(0xFF171722)
+private val CardBorder = Color(0xFF2A2A3A)
+private val Accent = Color(0xFF1A9FFF)
+private val TextPrimary = Color(0xFFF0F4FF)
+private val TextSecondary = Color(0xFF93A6BC)
+private val Danger = Color(0xFFE07B6B)
+private val ScrimColor = Color(0xFF000000)
+
+class RetroCheatsActivity : ComponentActivity() {
+ companion object {
+ const val EXTRA_SYSTEM_ID = "system_id"
+ const val EXTRA_GAME_NAME = "game_name"
+ }
+
+ private val navRegistry = PaneNavRegistry()
+ private var restoreNav: (() -> Unit)? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false)
+ val controller = androidx.core.view.WindowInsetsControllerCompat(window, window.decorView)
+ controller.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
+ controller.systemBarsBehavior =
+ androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ val systemId = intent.getStringExtra(EXTRA_SYSTEM_ID)
+ val gameName = intent.getStringExtra(EXTRA_GAME_NAME) ?: "Retro Game"
+ setContent {
+ com.winlator.cmod.shared.theme.WinNativeTheme {
+ CompositionLocalProvider(LocalPaneNav provides navRegistry) {
+ RetroCheatsScreen(systemId, gameName) { finish() }
+ }
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ restoreNav = window.bindPaneNav(navRegistry, onDismiss = { finish() })
+ }
+
+ override fun onPause() {
+ restoreNav?.invoke()
+ restoreNav = null
+ super.onPause()
+ }
+}
+
+@Composable
+private fun RetroCheatsScreen(
+ systemId: String?,
+ gameName: String,
+ onClose: () -> Unit,
+) {
+ val context = LocalContext.current
+ var cheats by remember { mutableStateOf(RetroCheats.load(context, gameName)) }
+ var editing by remember { mutableStateOf(false) }
+ var editIndex by remember { mutableStateOf(-1) }
+ var draftName by remember { mutableStateOf("") }
+ var draftCode by remember { mutableStateOf("") }
+
+ fun persist(next: List) {
+ cheats = next
+ RetroCheats.save(context, gameName, next)
+ }
+
+ BoxWithConstraints(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(ScrimColor.copy(alpha = 0.62f))
+ .windowInsetsPadding(WindowInsets.systemBars),
+ contentAlignment = Alignment.Center,
+ ) {
+ val dialogWidth = (maxWidth - 32.dp).coerceAtMost(560.dp)
+ val dialogHeight = (maxHeight - 40.dp).coerceIn(340.dp, 680.dp)
+ Surface(
+ modifier = Modifier.widthIn(min = 320.dp, max = dialogWidth).fillMaxWidth().height(dialogHeight),
+ shape = RoundedCornerShape(16.dp),
+ color = BgDark,
+ border = BorderStroke(1.dp, CardBorder),
+ tonalElevation = 8.dp,
+ ) {
+ Column(Modifier.fillMaxSize()) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Box(
+ Modifier.size(38.dp).background(Accent.copy(alpha = 0.16f), RoundedCornerShape(10.dp)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.VideogameAsset, contentDescription = null, tint = Accent, modifier = Modifier.size(22.dp))
+ }
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.retro_che_brand), color = TextSecondary, fontSize = 9.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
+ Text(
+ gameName,
+ style = MaterialTheme.typography.titleMedium,
+ color = TextPrimary,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ IconButton(onClick = onClose, modifier = Modifier.size(40.dp).paneNavItem(onActivate = onClose)) {
+ Icon(Icons.Outlined.Close, contentDescription = "Close", tint = TextSecondary, modifier = Modifier.size(22.dp))
+ }
+ }
+ Box(Modifier.fillMaxWidth().height(1.dp).background(CardBorder))
+
+ Column(
+ Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ if (editing) {
+ val fieldColors =
+ OutlinedTextFieldDefaults.colors(
+ focusedTextColor = TextPrimary,
+ unfocusedTextColor = TextPrimary,
+ cursorColor = Accent,
+ focusedBorderColor = Accent,
+ unfocusedBorderColor = CardBorder,
+ focusedLabelColor = Accent,
+ unfocusedLabelColor = TextSecondary,
+ focusedContainerColor = SurfaceDark,
+ unfocusedContainerColor = SurfaceDark,
+ )
+ OutlinedTextField(
+ value = draftName,
+ onValueChange = { draftName = it },
+ label = { Text(stringResource(R.string.retro_che_name)) },
+ singleLine = true,
+ colors = fieldColors,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = draftCode,
+ onValueChange = { draftCode = it },
+ label = { Text(stringResource(R.string.retro_che_code)) },
+ colors = fieldColors,
+ keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Text(
+ RetroCheats.formatHint(systemId),
+ color = TextSecondary,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
+ Button(
+ onClick = { editing = false },
+ colors = ButtonDefaults.buttonColors(containerColor = SurfaceDark, contentColor = TextPrimary),
+ modifier = Modifier.weight(1f).paneNavItem(onActivate = { editing = false }),
+ ) { Text(stringResource(R.string.retro_che_cancel)) }
+ Button(
+ onClick = {
+ val name = draftName.ifBlank { context.getString(R.string.retro_che_default_name, cheats.size + 1) }
+ val code = draftCode.trim()
+ if (code.isNotEmpty()) {
+ val entry = RetroCheat(name, code, true)
+ val next =
+ if (editIndex >= 0 && editIndex < cheats.size) {
+ cheats.toMutableList().also { it[editIndex] = entry }
+ } else {
+ cheats + entry
+ }
+ persist(next)
+ }
+ editing = false
+ },
+ colors = ButtonDefaults.buttonColors(containerColor = Accent, contentColor = Color.White),
+ modifier =
+ Modifier.weight(1f).paneNavItem(onActivate = {
+ val code = draftCode.trim()
+ if (code.isNotEmpty()) {
+ val entry = RetroCheat(draftName.ifBlank { context.getString(R.string.retro_che_default_name, cheats.size + 1) }, code, true)
+ val next =
+ if (editIndex >= 0 && editIndex < cheats.size) {
+ cheats.toMutableList().also { it[editIndex] = entry }
+ } else {
+ cheats + entry
+ }
+ persist(next)
+ }
+ editing = false
+ }),
+ ) { Text(stringResource(R.string.retro_che_save)) }
+ }
+ } else {
+ if (cheats.isEmpty()) {
+ Text(
+ stringResource(R.string.retro_che_empty),
+ color = TextSecondary,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+ }
+ cheats.forEachIndexed { index, cheat ->
+ CheatRow(
+ cheat = cheat,
+ onToggle = {
+ persist(cheats.toMutableList().also { it[index] = cheat.copy(enabled = !cheat.enabled) })
+ },
+ onEdit = {
+ editIndex = index
+ draftName = cheat.name
+ draftCode = cheat.code
+ editing = true
+ },
+ onDelete = {
+ persist(cheats.toMutableList().also { it.removeAt(index) })
+ },
+ )
+ }
+ Button(
+ onClick = {
+ editIndex = -1
+ draftName = ""
+ draftCode = ""
+ editing = true
+ },
+ colors = ButtonDefaults.buttonColors(containerColor = Accent, contentColor = Color.White),
+ modifier =
+ Modifier.fillMaxWidth().padding(top = 4.dp).paneNavItem(isEntry = true, onActivate = {
+ editIndex = -1
+ draftName = ""
+ draftCode = ""
+ editing = true
+ }),
+ ) {
+ Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.retro_che_add))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CheatRow(
+ cheat: RetroCheat,
+ onToggle: () -> Unit,
+ onEdit: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = SurfaceDark.copy(alpha = 0.5f),
+ border = BorderStroke(1.dp, CardBorder),
+ modifier = Modifier.fillMaxWidth().paneNavItem(cornerRadius = 12.dp, onActivate = onToggle, onSecondary = onDelete),
+ ) {
+ Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ cheat.name,
+ color = TextPrimary,
+ fontWeight = FontWeight.SemiBold,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ cheat.code,
+ color = TextSecondary,
+ style = MaterialTheme.typography.labelSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) {
+ Icon(Icons.Outlined.VideogameAsset, contentDescription = "Edit", tint = TextSecondary, modifier = Modifier.size(16.dp))
+ }
+ IconButton(onClick = onDelete, modifier = Modifier.size(36.dp).paneNavItem(onActivate = onDelete)) {
+ Icon(Icons.Outlined.Delete, contentDescription = "Delete", tint = Danger, modifier = Modifier.size(18.dp))
+ }
+ Switch(
+ checked = cheat.enabled,
+ onCheckedChange = { onToggle() },
+ colors =
+ SwitchDefaults.colors(
+ checkedThumbColor = Color.White,
+ checkedTrackColor = Accent,
+ uncheckedTrackColor = SurfaceDark,
+ uncheckedBorderColor = CardBorder,
+ ),
+ )
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/RetroControlLayouts.kt b/app/src/main/feature/retro/RetroControlLayouts.kt
new file mode 100644
index 000000000..b24209f46
--- /dev/null
+++ b/app/src/main/feature/retro/RetroControlLayouts.kt
@@ -0,0 +1,133 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+import org.json.JSONObject
+
+data class RetroControlOverride(
+ var x: Float,
+ var y: Float,
+ var scale: Float = 1f,
+ var visible: Boolean = true,
+)
+
+data class RetroCustomColors(
+ var body: Int? = null,
+ var button: Int? = null,
+ var text: Int? = null,
+ var shadow: Int? = null,
+)
+
+object RetroControlLayouts {
+ private fun layoutKey(
+ systemId: String,
+ portrait: Boolean,
+ ) = "retro_layout_v1_${systemId}_${if (portrait) "port" else "land"}"
+
+ private fun colorsKey(systemId: String) = "retro_colors_v1_$systemId"
+
+ fun load(
+ context: Context,
+ systemId: String?,
+ portrait: Boolean,
+ ): MutableMap {
+ val result = mutableMapOf()
+ if (systemId == null) return result
+ val json =
+ PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getString(layoutKey(systemId, portrait), null) ?: return result
+ runCatching {
+ val obj = JSONObject(json)
+ obj.keys().forEach { id ->
+ val c = obj.getJSONObject(id)
+ result[id] =
+ RetroControlOverride(
+ c.optDouble("x", 0.5).toFloat(),
+ c.optDouble("y", 0.5).toFloat(),
+ c.optDouble("s", 1.0).toFloat(),
+ c.optBoolean("v", true),
+ )
+ }
+ }
+ return result
+ }
+
+ fun save(
+ context: Context,
+ systemId: String?,
+ portrait: Boolean,
+ map: Map,
+ ) {
+ if (systemId == null) return
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ if (map.isEmpty()) {
+ prefs.edit().remove(layoutKey(systemId, portrait)).apply()
+ return
+ }
+ val obj = JSONObject()
+ map.forEach { (id, o) ->
+ obj.put(
+ id,
+ JSONObject()
+ .put("x", o.x.toDouble())
+ .put("y", o.y.toDouble())
+ .put("s", o.scale.toDouble())
+ .put("v", o.visible),
+ )
+ }
+ prefs.edit().putString(layoutKey(systemId, portrait), obj.toString()).apply()
+ }
+
+ fun reset(
+ context: Context,
+ systemId: String?,
+ portrait: Boolean,
+ ) {
+ if (systemId == null) return
+ PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .edit()
+ .remove(layoutKey(systemId, portrait))
+ .apply()
+ }
+
+ fun loadColors(
+ context: Context,
+ systemId: String?,
+ ): RetroCustomColors {
+ val colors = RetroCustomColors()
+ if (systemId == null) return colors
+ val json =
+ PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getString(colorsKey(systemId), null) ?: return colors
+ runCatching {
+ val obj = JSONObject(json)
+ if (obj.has("body")) colors.body = obj.getInt("body")
+ if (obj.has("button")) colors.button = obj.getInt("button")
+ if (obj.has("text")) colors.text = obj.getInt("text")
+ if (obj.has("shadow")) colors.shadow = obj.getInt("shadow")
+ }
+ return colors
+ }
+
+ fun saveColors(
+ context: Context,
+ systemId: String?,
+ colors: RetroCustomColors,
+ ) {
+ if (systemId == null) return
+ val prefs = PreferenceManager.getDefaultSharedPreferences(context)
+ if (colors.body == null && colors.button == null && colors.text == null && colors.shadow == null) {
+ prefs.edit().remove(colorsKey(systemId)).apply()
+ return
+ }
+ val obj = JSONObject()
+ colors.body?.let { obj.put("body", it) }
+ colors.button?.let { obj.put("button", it) }
+ colors.text?.let { obj.put("text", it) }
+ colors.shadow?.let { obj.put("shadow", it) }
+ prefs.edit().putString(colorsKey(systemId), obj.toString()).apply()
+ }
+}
diff --git a/app/src/main/feature/retro/RetroControlsMenu.kt b/app/src/main/feature/retro/RetroControlsMenu.kt
new file mode 100644
index 000000000..697e15c55
--- /dev/null
+++ b/app/src/main/feature/retro/RetroControlsMenu.kt
@@ -0,0 +1,153 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.widget.Toast
+import com.winlator.cmod.R
+
+object RetroControlsMenu {
+ class Host(
+ val context: Context,
+ val overlay: RetroInputView?,
+ val menu: RetroMenuController,
+ val systemId: String?,
+ val touchControls: () -> Boolean,
+ val onTouchControls: (Boolean) -> Unit,
+ val adaptiveSticks: () -> Boolean,
+ val onAdaptiveSticks: (Boolean) -> Unit,
+ val orientationLabel: () -> String,
+ val onCloseMenu: () -> Unit,
+ val showStickInversion: Boolean = false,
+ )
+
+ fun l3r3PrefKey(systemId: String?) = "retro_show_l3r3_" + (systemId ?: "default")
+
+ fun build(host: Host): List =
+ buildList {
+ val context = host.context
+ add(
+ RetroMenuEntry.Toggle(
+ context.getString(R.string.retro_lr_on_screen_controls),
+ checked = host.touchControls(),
+ ) { value ->
+ host.onTouchControls(value)
+ host.menu.rebuild()
+ },
+ )
+ if (host.showStickInversion) {
+ val invPrefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context)
+ val sys = host.systemId ?: "default"
+ fun invToggle(label: String, key: String) =
+ RetroMenuEntry.Toggle(label, checked = invPrefs.getBoolean(key, false)) { value ->
+ invPrefs.edit().putBoolean(key, value).apply()
+ host.overlay?.loadStickInversion()
+ host.menu.rebuild()
+ }
+ add(invToggle(context.getString(R.string.retro_lr_left_stick_invert_x), "retro_inv_lx_$sys"))
+ add(invToggle(context.getString(R.string.retro_lr_left_stick_invert_y), "retro_inv_ly_$sys"))
+ add(invToggle(context.getString(R.string.retro_lr_right_stick_invert_x), "retro_inv_rx_$sys"))
+ add(invToggle(context.getString(R.string.retro_lr_right_stick_invert_y), "retro_inv_ry_$sys"))
+ }
+ add(
+ RetroMenuEntry.Toggle(
+ context.getString(R.string.retro_lr_adaptive_sticks),
+ checked = host.adaptiveSticks(),
+ ) { value ->
+ host.onAdaptiveSticks(value)
+ host.overlay?.adaptiveSticks = value
+ host.menu.rebuild()
+ },
+ )
+ if (host.overlay?.supportsStickButtons == true) {
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context)
+ val key = l3r3PrefKey(host.systemId)
+ add(
+ RetroMenuEntry.Toggle(
+ context.getString(R.string.retro_ps2_show_l3r3),
+ checked = prefs.getBoolean(key, true),
+ ) { value ->
+ prefs.edit().putBoolean(key, value).apply()
+ host.overlay.showL3R3 = value
+ host.menu.rebuild()
+ },
+ )
+ }
+ add(
+ RetroMenuEntry.Slider(
+ label = context.getString(R.string.retro_lr_haptic_feedback),
+ valueText = host.overlay?.hapticStrength?.let { "${(it * 100).toInt()}%" } ?: "0%",
+ value = host.overlay?.hapticStrength ?: 0f,
+ min = 0f,
+ max = 1f,
+ step = 0.05f,
+ ) { value ->
+ host.overlay?.hapticStrength = value
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .edit()
+ .putFloat("retro_haptic_strength", value)
+ .apply()
+ host.menu.rebuild()
+ },
+ )
+ val orientationLabel = host.orientationLabel()
+ add(
+ RetroMenuEntry.Action(
+ context.getString(R.string.retro_lr_edit_layout, orientationLabel),
+ RetroDrawerIcons.EditLayout,
+ ) {
+ host.onCloseMenu()
+ host.overlay?.let {
+ it.visibility = android.view.View.VISIBLE
+ it.enterEdit()
+ }
+ },
+ )
+ add(
+ RetroMenuEntry.Action(
+ context.getString(R.string.retro_lr_reset_layout, orientationLabel),
+ RetroDrawerIcons.Reset,
+ ) {
+ host.overlay?.resetLayout()
+ Toast
+ .makeText(context, context.getString(R.string.retro_lr_layout_reset, orientationLabel), Toast.LENGTH_SHORT)
+ .show()
+ },
+ )
+ val colors = RetroControlLayouts.loadColors(context, host.systemId)
+ fun persistColors(mutate: (RetroCustomColors) -> Unit) {
+ mutate(colors)
+ RetroControlLayouts.saveColors(context, host.systemId, colors)
+ host.overlay?.setCustomColors(colors)
+ host.menu.rebuild()
+ }
+ add(
+ RetroMenuEntry.ColorPick(context.getString(R.string.retro_lr_button_color), colors.button) { value ->
+ persistColors { it.button = value }
+ },
+ )
+ add(
+ RetroMenuEntry.ColorPick(context.getString(R.string.retro_lr_letter_color), colors.text) { value ->
+ persistColors { it.text = value }
+ },
+ )
+ add(
+ RetroMenuEntry.ColorPick(context.getString(R.string.retro_lr_shadow_color), colors.shadow) { value ->
+ persistColors { it.shadow = value }
+ },
+ )
+ add(
+ RetroMenuEntry.ColorPick(context.getString(R.string.retro_lr_background_color), colors.body) { value ->
+ persistColors { it.body = value }
+ },
+ )
+ add(
+ RetroMenuEntry.Action(context.getString(R.string.retro_lr_reset_colors), RetroDrawerIcons.Reset) {
+ val fresh = RetroCustomColors()
+ RetroControlLayouts.saveColors(context, host.systemId, fresh)
+ host.overlay?.setCustomColors(fresh)
+ host.menu.rebuild()
+ Toast.makeText(context, context.getString(R.string.retro_lr_colors_reset), Toast.LENGTH_SHORT).show()
+ },
+ )
+ }
+}
diff --git a/app/src/main/feature/retro/RetroCoreCatalog.kt b/app/src/main/feature/retro/RetroCoreCatalog.kt
new file mode 100644
index 000000000..bdb6d7978
--- /dev/null
+++ b/app/src/main/feature/retro/RetroCoreCatalog.kt
@@ -0,0 +1,3228 @@
+package com.winlator.cmod.feature.retro
+
+import com.winlator.cmod.R
+
+internal object RetroCoreCatalog {
+ val FCEUMM: List = listOf(
+ RetroCoreOption(
+ key = "fceumm_arkanoid_mode",
+ label = "Arkanoid Mode",
+ values = listOf("abs_mouse", "mouse", "stelladaptor", "touchscreen"),
+ valueLabels = listOf("Absolute mouse", "Mouse", "Stelladaptor", "Touchscreen"),
+ defaultValue = "mouse",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_mouse_sensitivity",
+ label = "Mouse Sensitivity",
+ values = listOf("20", "30", "40", "50", "60", "70", "80", "90", "100", "110", "120", "130", "140", "150", "160", "170", "180", "190", "200"),
+ valueLabels = listOf("20", "30", "40", "50", "60", "70", "80", "90", "100", "110", "120", "130", "140", "150", "160", "170", "180", "190", "200"),
+ defaultValue = "100",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_show_crosshair",
+ label = "Show Zapper Crosshair",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_turbo_delay",
+ label = "Turbo Delay (in frames)",
+ values = listOf("1", "2", "3", "5", "10", "15", "30", "60"),
+ valueLabels = listOf("1", "2", "3", "5", "10", "15", "30", "60"),
+ defaultValue = "3",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_turbo_enable",
+ label = "Turbo Enable",
+ values = listOf("None", "Player 1", "Player 2", "Both"),
+ valueLabels = listOf("None", "Player 1", "Player 2", "Both"),
+ defaultValue = "None",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_up_down_allowed",
+ label = "Allow Opposing Directions",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_zapper_mode",
+ label = "Zapper Mode",
+ values = listOf("clightgun", "stlightgun", "touchscreen", "mouse"),
+ valueLabels = listOf("Crosshair light gun", "Sequential Targets light gun", "Touchscreen", "Mouse"),
+ defaultValue = "clightgun",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_zapper_sensor",
+ label = "Invert Zapper Sensor Signal",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_zapper_tolerance",
+ label = "Zapper Tolerance",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20"),
+ defaultValue = "6",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_zapper_trigger",
+ label = "Invert Zapper Trigger Signal",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_aspect",
+ label = "Aspect Ratio",
+ values = listOf("8:7 PAR", "4:3", "PP"),
+ valueLabels = listOf("8:7 PAR", "4:3", "Pixel Perfect"),
+ defaultValue = "8:7 PAR",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ labelRes = R.string.retro_co_aspect_ratio,
+ ),
+ RetroCoreOption(
+ key = "fceumm_ntsc_filter",
+ label = "NTSC Filter",
+ values = listOf("disabled", "composite", "svideo", "rgb", "monochrome"),
+ valueLabels = listOf("disabled", "Composite", "S-Video", "RGB", "Monochrome"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_overscan_h_left",
+ label = "Crop Horizontal Left Overscan",
+ values = listOf("0", "4", "8", "12", "16"),
+ valueLabels = listOf("0", "4", "8", "12", "16"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_overscan_h_right",
+ label = "Crop Horizontal Right Overscan",
+ values = listOf("0", "4", "8", "12", "16"),
+ valueLabels = listOf("0", "4", "8", "12", "16"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_overscan_v_bottom",
+ label = "Crop Vertical Bottom Overscan",
+ values = listOf("0", "4", "8", "12", "16", "20", "24"),
+ valueLabels = listOf("0", "4", "8", "12", "16", "20", "24"),
+ defaultValue = "8",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_overscan_v_top",
+ label = "Crop Vertical Top Overscan",
+ values = listOf("0", "4", "8", "12", "16", "20", "24"),
+ valueLabels = listOf("0", "4", "8", "12", "16", "20", "24"),
+ defaultValue = "8",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_palette",
+ label = "Color Palette",
+ values = listOf("default", "asqrealc", "restored-wii-vc", "wii-vc", "rgb", "yuv-v3", "unsaturated-final", "sony-cxa2025as-us", "pal", "bmf-final2", "bmf-final3", "smooth-fbx", "composite-direct-fbx", "pvm-style-d93-fbx", "ntsc-hardware-fbx", "nes-classic-fbx-fs", "nescap", "wavebeam", "digital-prime-fbx", "magnum-fbx", "smooth-v2-fbx", "nes-classic-fbx", "royaltea", "mugicha", "raw", "custom"),
+ valueLabels = listOf("Default", "AspiringSquire's Real", "Restored Wii VC", "Wii Virtual Console", "Nintendo RGB PPU", "FBX's YUV-V3", "FBX's Unsaturated-Final", "Sony CXA2025AS US", "PAL", "BMF's Final 2", "BMF's Final 3", "FBX's Smooth", "FBX's Composite Direct", "FBX's PVM Style D93", "FBX's NTSC Hardware", "FBX's NES Classic (fixed)", "RGBSource's NESCAP", "nakedarthur's Wavebeam", "FBX's Digital Prime", "FBX's Magnum", "FBX's Smooth V2", "FBX's NES Classic", "Royaltea (PVM-2530)", "Mugicha", "Raw", "Custom"),
+ defaultValue = "default",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_nospritelimit",
+ label = "No Sprite Limit",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_overclocking",
+ label = "Overclock",
+ values = listOf("disabled", "2x-Postrender", "2x-VBlank"),
+ valueLabels = listOf("disabled", "2x-Postrender", "2x-VBlank"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_ramstate",
+ label = "RAM Power-On Fill (Restart Required)",
+ values = listOf("fill \$ff", "fill \$00", "random"),
+ valueLabels = listOf("\$FF", "\$00", "Random"),
+ defaultValue = "fill \$ff",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_apu_1",
+ label = "Audio Channel 1 (Square 1)",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_apu_2",
+ label = "Audio Channel 2 (Square 2)",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_apu_3",
+ label = "Audio Channel 3 (Triangle)",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_apu_4",
+ label = "Audio Channel 4 (Noise)",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_apu_5",
+ label = "Audio Channel 5 (PCM)",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_apu_fds",
+ label = "Channel Volume (FDS)",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_apu_mmc5",
+ label = "Channel Volume (MMC5)",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_apu_n163",
+ label = "Channel Volume (N163)",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_apu_s5b",
+ label = "Channel Volume (S5B)",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_apu_vrc6",
+ label = "Channel Volume (VRC6)",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_apu_vrc7",
+ label = "Channel Volume (VRC7)",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_reducedmcpopping",
+ label = "Reduce DMC Channel Popping",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_removetrianglenoise",
+ label = "Reduce Triangle Channel Popping",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_sndlowpass",
+ label = "Audio RF Filter",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_sndquality",
+ label = "Sound Quality",
+ values = listOf("Low", "High", "Very High"),
+ valueLabels = listOf("Low", "High", "Very High"),
+ defaultValue = "Low",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_sndrate_hint",
+ label = "Sound Samplerate (Hint)",
+ values = listOf("Auto", "32KHz", "44KHz", "48KHz", "96KHz"),
+ valueLabels = listOf("Auto", "32KHz", "44KHz", "48KHz", "96KHz"),
+ defaultValue = "Auto",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_sndstereodelay",
+ label = "Stereo Sound Effect",
+ values = listOf("disabled", "01_ms_delay", "02_ms_delay", "03_ms_delay", "04_ms_delay", "05_ms_delay", "06_ms_delay", "07_ms_delay", "08_ms_delay", "09_ms_delay", "10_ms_delay", "11_ms_delay", "12_ms_delay", "13_ms_delay", "14_ms_delay", "15_ms_delay", "16_ms_delay", "17_ms_delay", "18_ms_delay", "19_ms_delay", "20_ms_delay", "21_ms_delay", "22_ms_delay", "23_ms_delay", "24_ms_delay", "25_ms_delay", "26_ms_delay", "27_ms_delay", "28_ms_delay", "29_ms_delay", "30_ms_delay", "31_ms_delay", "32_ms_delay"),
+ valueLabels = listOf("disabled", "1ms Delay", "2ms Delay", "3ms Delay", "4ms Delay", "5ms Delay", "6ms Delay", "7ms Delay", "8ms Delay", "9ms Delay", "10ms Delay", "11ms Delay", "12ms Delay", "13ms Delay", "14ms Delay", "15ms Delay (Default)", "16ms Delay", "17ms Delay", "18ms Delay", "19ms Delay", "20ms Delay", "21ms Delay", "22ms Delay", "23ms Delay", "24ms Delay", "25ms Delay", "26ms Delay", "27ms Delay", "28ms Delay", "29ms Delay", "30ms Delay", "31ms Delay", "32ms Delay"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_sndvolume",
+ label = "Master Volume",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
+ valueLabels = listOf("0%", "10%", "20%", "30%", "40%", "50%", "60%", "70%", "80%", "90%", "100%"),
+ defaultValue = "7",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "fceumm_swapduty",
+ label = "Swap Audio Duty Cycles",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_game_genie",
+ label = "Game Genie Add-On (Restart Required)",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "fceumm_region",
+ label = "Region",
+ values = listOf("Auto", "NTSC", "PAL", "Dendy"),
+ valueLabels = listOf("Auto", "NTSC", "PAL", "Dendy"),
+ defaultValue = "Auto",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ )
+
+ val SNES9X: List = listOf(
+ RetroCoreOption(
+ key = "snes9x_justifier1_color",
+ label = "Justifier 1 Color",
+ values = listOf("Blue", "Blue (blend)", "Violet", "Violet (blend)", "Pink", "Pink (blend)", "Purple", "Purple (blend)", "Black", "Black (blend)", "25% Grey", "25% Grey (blend)", "50% Grey", "50% Grey (blend)", "75% Grey", "75% Grey (blend)", "White", "White (blend)", "Red", "Red (blend)", "Orange", "Orange (blend)", "Yellow", "Yellow (blend)", "Green", "Green (blend)", "Cyan", "Cyan (blend)", "Sky", "Sky (blend)"),
+ valueLabels = listOf("Blue", "Blue (blend)", "Violet", "Violet (blend)", "Pink", "Pink (blend)", "Purple", "Purple (blend)", "Black", "Black (blend)", "25% Grey", "25% Grey (blend)", "50% Grey", "50% Grey (blend)", "75% Grey", "75% Grey (blend)", "White", "White (blend)", "Red", "Red (blend)", "Orange", "Orange (blend)", "Yellow", "Yellow (blend)", "Green", "Green (blend)", "Cyan", "Cyan (blend)", "Sky", "Sky (blend)"),
+ defaultValue = "Blue",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_justifier1_crosshair",
+ label = "Justifier 1 Crosshair",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"),
+ defaultValue = "4",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_justifier2_color",
+ label = "Justifier 2 Color",
+ values = listOf("Pink", "Pink (blend)", "Purple", "Purple (blend)", "Black", "Black (blend)", "25% Grey", "25% Grey (blend)", "50% Grey", "50% Grey (blend)", "75% Grey", "75% Grey (blend)", "White", "White (blend)", "Red", "Red (blend)", "Orange", "Orange (blend)", "Yellow", "Yellow (blend)", "Green", "Green (blend)", "Cyan", "Cyan (blend)", "Sky", "Sky (blend)", "Blue", "Blue (blend)", "Violet", "Violet (blend)"),
+ valueLabels = listOf("Pink", "Pink (blend)", "Purple", "Purple (blend)", "Black", "Black (blend)", "25% Grey", "25% Grey (blend)", "50% Grey", "50% Grey (blend)", "75% Grey", "75% Grey (blend)", "White", "White (blend)", "Red", "Red (blend)", "Orange", "Orange (blend)", "Yellow", "Yellow (blend)", "Green", "Green (blend)", "Cyan", "Cyan (blend)", "Sky", "Sky (blend)", "Blue", "Blue (blend)", "Violet", "Violet (blend)"),
+ defaultValue = "Pink",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_justifier2_crosshair",
+ label = "Justifier 2 Crosshair",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"),
+ defaultValue = "4",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_lightgun_mode",
+ label = "Light Gun Mode",
+ values = listOf("Lightgun", "Touchscreen"),
+ valueLabels = listOf("Light Gun", "Touchscreen"),
+ defaultValue = "Lightgun",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_rifle_color",
+ label = "M.A.C.S. Rifle Color",
+ values = listOf("White", "White (blend)", "Red", "Red (blend)", "Orange", "Orange (blend)", "Yellow", "Yellow (blend)", "Green", "Green (blend)", "Cyan", "Cyan (blend)", "Sky", "Sky (blend)", "Blue", "Blue (blend)", "Violet", "Violet (blend)", "Pink", "Pink (blend)", "Purple", "Purple (blend)", "Black", "Black (blend)", "25% Grey", "25% Grey (blend)", "50% Grey", "50% Grey (blend)", "75% Grey", "75% Grey (blend)"),
+ valueLabels = listOf("White", "White (blend)", "Red", "Red (blend)", "Orange", "Orange (blend)", "Yellow", "Yellow (blend)", "Green", "Green (blend)", "Cyan", "Cyan (blend)", "Sky", "Sky (blend)", "Blue", "Blue (blend)", "Violet", "Violet (blend)", "Pink", "Pink (blend)", "Purple", "Purple (blend)", "Black", "Black (blend)", "25% Grey", "25% Grey (blend)", "50% Grey", "50% Grey (blend)", "75% Grey", "75% Grey (blend)"),
+ defaultValue = "White",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_rifle_crosshair",
+ label = "M.A.C.S. Rifle Crosshair",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"),
+ defaultValue = "2",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_superscope_color",
+ label = "Super Scope Color",
+ values = listOf("White", "White (blend)", "Red", "Red (blend)", "Orange", "Orange (blend)", "Yellow", "Yellow (blend)", "Green", "Green (blend)", "Cyan", "Cyan (blend)", "Sky", "Sky (blend)", "Blue", "Blue (blend)", "Violet", "Violet (blend)", "Pink", "Pink (blend)", "Purple", "Purple (blend)", "Black", "Black (blend)", "25% Grey", "25% Grey (blend)", "50% Grey", "50% Grey (blend)", "75% Grey", "75% Grey (blend)"),
+ valueLabels = listOf("White", "White (blend)", "Red", "Red (blend)", "Orange", "Orange (blend)", "Yellow", "Yellow (blend)", "Green", "Green (blend)", "Cyan", "Cyan (blend)", "Sky", "Sky (blend)", "Blue", "Blue (blend)", "Violet", "Violet (blend)", "Pink", "Pink (blend)", "Purple", "Purple (blend)", "Black", "Black (blend)", "25% Grey", "25% Grey (blend)", "50% Grey", "50% Grey (blend)", "75% Grey", "75% Grey (blend)"),
+ defaultValue = "White",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_superscope_crosshair",
+ label = "Super Scope Crosshair",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"),
+ defaultValue = "2",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_superscope_reverse_buttons",
+ label = "Super Scope Reverse Trigger Buttons",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_up_down_allowed",
+ label = "Allow Opposing Directions",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "snes9x_aspect",
+ label = "Preferred Aspect Ratio",
+ values = listOf("4:3", "4:3 scaled", "uncorrected", "auto", "ntsc", "pal"),
+ valueLabels = listOf("4:3", "4:3 (Preserved)", "Uncorrected", "Auto", "NTSC", "PAL"),
+ defaultValue = "4:3",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "snes9x_hires_blend",
+ label = "Hi-Res Blending",
+ values = listOf("disabled", "merge", "blur"),
+ valueLabels = listOf("disabled", "Merge", "Blur"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "snes9x_overscan",
+ label = "Crop Overscan",
+ values = listOf("enabled", "12_pixels", "16_pixels", "auto", "disabled"),
+ valueLabels = listOf("~8 Pixels", "12 Pixels", "16 Pixels", "Auto (~8 Pixels)", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "snes9x_block_invalid_vram_access",
+ label = "Block Invalid VRAM Access",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_echo_buffer_hack",
+ label = "Echo Buffer Hack (Unsafe, only enable for old addmusic hacks)",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_overclock_cycles",
+ label = "Reduce Slowdown (Hack, Unsafe)",
+ values = listOf("disabled", "light", "compatible", "max"),
+ valueLabels = listOf("disabled", "Light", "Compatible", "Max"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "snes9x_overclock_superfx",
+ label = "SuperFX Overclocking",
+ values = listOf("50%", "60%", "70%", "80%", "90%", "100%", "150%", "200%", "250%", "300%", "350%", "400%", "450%", "500%"),
+ valueLabels = listOf("50%", "60%", "70%", "80%", "90%", "100%", "150%", "200%", "250%", "300%", "350%", "400%", "450%", "500%"),
+ defaultValue = "100%",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "snes9x_randomize_memory",
+ label = "Randomize Memory (Unsafe)",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_reduce_sprite_flicker",
+ label = "Reduce Flickering (Hack, Unsafe)",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "snes9x_audio_interpolation",
+ label = "Audio Interpolation",
+ values = listOf("gaussian", "cubic", "sinc", "none", "linear"),
+ valueLabels = listOf("Gaussian", "Cubic", "Sinc", "None", "Linear"),
+ defaultValue = "gaussian",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "snes9x_gfx_clip",
+ label = "Enable Graphic Clip Windows",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_gfx_transp",
+ label = "Enable Transparency Effects",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_layer_1",
+ label = "Show Layer 1",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_layer_2",
+ label = "Show Layer 2",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_layer_3",
+ label = "Show Layer 3",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_layer_4",
+ label = "Show Layer 4",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_layer_5",
+ label = "Show Sprite Layer",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_sndchan_volume_1",
+ label = "Volume % for Sound Channel 1",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_sndchan_volume_2",
+ label = "Volume % for Sound Channel 2",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_sndchan_volume_3",
+ label = "Volume % for Sound Channel 3",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_sndchan_volume_4",
+ label = "Volume % for Sound Channel 4",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_sndchan_volume_5",
+ label = "Volume % for Sound Channel 5",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_sndchan_volume_6",
+ label = "Volume % for Sound Channel 6",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_sndchan_volume_7",
+ label = "Volume % for Sound Channel 7",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_sndchan_volume_8",
+ label = "Volume % for Sound Channel 8",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "snes9x_blargg",
+ label = "Blargg NTSC Filter",
+ values = listOf("disabled", "monochrome", "rf", "composite", "s-video", "rgb"),
+ valueLabels = listOf("disabled", "Monochrome", "RF", "Composite", "S-Video", "RGB"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ labelRes = R.string.retro_co_ntsc_filter,
+ ),
+ RetroCoreOption(
+ key = "snes9x_region",
+ label = "Console Region (Reload Core)",
+ values = listOf("auto", "ntsc", "pal"),
+ valueLabels = listOf("Auto", "NTSC", "PAL"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ )
+
+ val GAMBATTE: List = listOf(
+ RetroCoreOption(
+ key = "gambatte_gb_link_mode",
+ label = "Game Link Mode",
+ values = listOf("Not Connected", "Network Server", "Network Client"),
+ valueLabels = listOf("Not Connected", "Network Server", "Network Client"),
+ defaultValue = "Not Connected",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gb_link_network_port",
+ label = "Network Link Port",
+ values = listOf("56400", "56401", "56402", "56403", "56404", "56405", "56406", "56407", "56408", "56409", "56410", "56411", "56412", "56413", "56414", "56415", "56416", "56417", "56418", "56419", "56420"),
+ valueLabels = listOf("56400", "56401", "56402", "56403", "56404", "56405", "56406", "56407", "56408", "56409", "56410", "56411", "56412", "56413", "56414", "56415", "56416", "56417", "56418", "56419", "56420"),
+ defaultValue = "56400",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "gambatte_rumble_level",
+ label = "Controller Rumble Strength",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
+ defaultValue = "10",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gambatte_turbo_period",
+ label = "Turbo Button Period",
+ values = listOf("4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "100", "101", "102", "103", "104", "105", "106", "107", "108", "109", "110", "111", "112", "113", "114", "115", "116", "117", "118", "119", "120"),
+ valueLabels = listOf("4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "100", "101", "102", "103", "104", "105", "106", "107", "108", "109", "110", "111", "112", "113", "114", "115", "116", "117", "118", "119", "120"),
+ defaultValue = "4",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gambatte_up_down_allowed",
+ label = "Allow Opposing Directions",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "gambatte_dark_filter_level",
+ label = "Dark Filter Level (%)",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gb_colorization",
+ label = "GB Colorization",
+ values = listOf("disabled", "auto", "GBC", "SGB", "internal", "custom"),
+ valueLabels = listOf("disabled", "Auto", "GBC", "SGB", "Internal", "Custom"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ labelRes = R.string.retro_co_colorization,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gb_internal_palette",
+ label = "Internal Palette",
+ values = listOf("GB - DMG", "GB - Pocket", "GB - Light", "GBC - Blue", "GBC - Brown", "GBC - Dark Blue", "GBC - Dark Brown", "GBC - Dark Green", "GBC - Grayscale", "GBC - Green", "GBC - Inverted", "GBC - Orange", "GBC - Pastel Mix", "GBC - Red", "GBC - Yellow", "SGB - 1A", "SGB - 1B", "SGB - 1C", "SGB - 1D", "SGB - 1E", "SGB - 1F", "SGB - 1G", "SGB - 1H", "SGB - 2A", "SGB - 2B", "SGB - 2C", "SGB - 2D", "SGB - 2E", "SGB - 2F", "SGB - 2G", "SGB - 2H", "SGB - 3A", "SGB - 3B", "SGB - 3C", "SGB - 3D", "SGB - 3E", "SGB - 3F", "SGB - 3G", "SGB - 3H", "SGB - 4A", "SGB - 4B", "SGB - 4C", "SGB - 4D", "SGB - 4E", "SGB - 4F", "SGB - 4G", "SGB - 4H", "Special 1", "Special 2", "Special 3", "Special 4 (TI-83 Legacy)", "TWB64 - Pack 1", "TWB64 - Pack 2", "TWB64 - Pack 3", "PixelShift - Pack 1"),
+ valueLabels = listOf("GB - DMG", "GB - Pocket", "GB - Light", "GBC - Blue", "GBC - Brown", "GBC - Dark Blue", "GBC - Dark Brown", "GBC - Dark Green", "GBC - Grayscale", "GBC - Green", "GBC - Inverted", "GBC - Orange", "GBC - Pastel Mix", "GBC - Red", "GBC - Yellow", "SGB - 1A", "SGB - 1B", "SGB - 1C", "SGB - 1D", "SGB - 1E", "SGB - 1F", "SGB - 1G", "SGB - 1H", "SGB - 2A", "SGB - 2B", "SGB - 2C", "SGB - 2D", "SGB - 2E", "SGB - 2F", "SGB - 2G", "SGB - 2H", "SGB - 3A", "SGB - 3B", "SGB - 3C", "SGB - 3D", "SGB - 3E", "SGB - 3F", "SGB - 3G", "SGB - 3H", "SGB - 4A", "SGB - 4B", "SGB - 4C", "SGB - 4D", "SGB - 4E", "SGB - 4F", "SGB - 4G", "SGB - 4H", "Special 1", "Special 2", "Special 3", "Special 4 (TI-83 Legacy)", "TWB64 - Pack 1", "TWB64 - Pack 2", "TWB64 - Pack 3", "PixelShift - Pack 1"),
+ defaultValue = "GB - DMG",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gb_palette_pixelshift_1",
+ label = "> PixelShift - Pack 1 Palette",
+ values = listOf("PixelShift 01 - Arctic Green", "PixelShift 02 - Arduboy", "PixelShift 03 - BGB 0.3 Emulator", "PixelShift 04 - Camouflage", "PixelShift 05 - Chocolate Bar", "PixelShift 06 - CMYK", "PixelShift 07 - Cotton Candy", "PixelShift 08 - Easy Greens", "PixelShift 09 - Gamate", "PixelShift 10 - Game Boy Light", "PixelShift 11 - Game Boy Pocket", "PixelShift 12 - Game Boy Pocket Alt", "PixelShift 13 - Game Pocket Computer", "PixelShift 14 - Game & Watch Ball", "PixelShift 15 - GB Backlight Blue", "PixelShift 16 - GB Backlight Faded", "PixelShift 17 - GB Backlight Orange", "PixelShift 18 - GB Backlight White", "PixelShift 19 - GB Backlight Yellow Dark", "PixelShift 20 - GB Bootleg", "PixelShift 21 - GB Hunter", "PixelShift 22 - GB Kiosk", "PixelShift 23 - GB Kiosk 2", "PixelShift 24 - GB New", "PixelShift 25 - GB Nuked", "PixelShift 26 - GB Old", "PixelShift 27 - GBP Bivert", "PixelShift 28 - GB Washed Yellow Backlight", "PixelShift 29 - Ghost", "PixelShift 30 - Glow In The Dark", "PixelShift 31 - Gold Bar", "PixelShift 32 - Grapefruit", "PixelShift 33 - Gray Green Mix", "PixelShift 34 - Missingno", "PixelShift 35 - MS-Dos", "PixelShift 36 - Newspaper", "PixelShift 37 - Pip-Boy", "PixelShift 38 - Pocket Girl", "PixelShift 39 - Silhouette", "PixelShift 40 - Sunburst", "PixelShift 41 - Technicolor", "PixelShift 42 - Tron", "PixelShift 43 - Vaporwave", "PixelShift 44 - Virtual Boy", "PixelShift 45 - Wish"),
+ valueLabels = listOf("PixelShift 01 - Arctic Green", "PixelShift 02 - Arduboy", "PixelShift 03 - BGB 0.3 Emulator", "PixelShift 04 - Camouflage", "PixelShift 05 - Chocolate Bar", "PixelShift 06 - CMYK", "PixelShift 07 - Cotton Candy", "PixelShift 08 - Easy Greens", "PixelShift 09 - Gamate", "PixelShift 10 - Game Boy Light", "PixelShift 11 - Game Boy Pocket", "PixelShift 12 - Game Boy Pocket Alt", "PixelShift 13 - Game Pocket Computer", "PixelShift 14 - Game & Watch Ball", "PixelShift 15 - GB Backlight Blue", "PixelShift 16 - GB Backlight Faded", "PixelShift 17 - GB Backlight Orange", "PixelShift 18 - GB Backlight White", "PixelShift 19 - GB Backlight Yellow Dark", "PixelShift 20 - GB Bootleg", "PixelShift 21 - GB Hunter", "PixelShift 22 - GB Kiosk", "PixelShift 23 - GB Kiosk 2", "PixelShift 24 - GB New", "PixelShift 25 - GB Nuked", "PixelShift 26 - GB Old", "PixelShift 27 - GBP Bivert", "PixelShift 28 - GB Washed Yellow Backlight", "PixelShift 29 - Ghost", "PixelShift 30 - Glow In The Dark", "PixelShift 31 - Gold Bar", "PixelShift 32 - Grapefruit", "PixelShift 33 - Gray Green Mix", "PixelShift 34 - Missingno", "PixelShift 35 - MS-Dos", "PixelShift 36 - Newspaper", "PixelShift 37 - Pip-Boy", "PixelShift 38 - Pocket Girl", "PixelShift 39 - Silhouette", "PixelShift 40 - Sunburst", "PixelShift 41 - Technicolor", "PixelShift 42 - Tron", "PixelShift 43 - Vaporwave", "PixelShift 44 - Virtual Boy", "PixelShift 45 - Wish"),
+ defaultValue = "PixelShift 01 - Arctic Green",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gb_palette_twb64_1",
+ label = "> TWB64 - Pack 1 Palette",
+ values = listOf("TWB64 001 - Aqours Blue", "TWB64 002 - Anime Expo Ver.", "TWB64 003 - SpongeBob Yellow", "TWB64 004 - Patrick Star Pink", "TWB64 005 - Neon Red", "TWB64 006 - Neon Blue", "TWB64 007 - Neon Yellow", "TWB64 008 - Neon Green", "TWB64 009 - Neon Pink", "TWB64 010 - Mario Red", "TWB64 011 - Nick Orange", "TWB64 012 - Virtual Vision", "TWB64 013 - Golden Wild", "TWB64 014 - DMG-099", "TWB64 015 - Classic Blurple", "TWB64 016 - 765 Production Ver.", "TWB64 017 - Superball Ivory", "TWB64 018 - Crunchyroll Orange", "TWB64 019 - Muse Pink", "TWB64 020 - School Idol Blue", "TWB64 021 - Gamate Ver.", "TWB64 022 - Greenscale Ver.", "TWB64 023 - Odyssey Gold", "TWB64 024 - Super Saiyan God", "TWB64 025 - Super Saiyan Blue", "TWB64 026 - ANIMAX BLUE", "TWB64 027 - BMO Ver.", "TWB64 028 - Game.com Ver.", "TWB64 029 - Sanrio Pink", "TWB64 030 - Timmy Turner Pink", "TWB64 031 - Fairly OddPalette", "TWB64 032 - Danny Phantom Silver", "TWB64 033 - Link's Awakening DX Ver.", "TWB64 034 - Travel Wood", "TWB64 035 - Pokemon Ver.", "TWB64 036 - Game Grump Orange", "TWB64 037 - Scooby-Doo Mystery Ver.", "TWB64 038 - Pokemon mini Ver.", "TWB64 039 - Supervision Ver.", "TWB64 040 - DMG Ver.", "TWB64 041 - Pocket Ver.", "TWB64 042 - Light Ver.", "TWB64 043 - All Might Hero Palette", "TWB64 044 - U.A. High School Uniform", "TWB64 045 - Pikachu Yellow", "TWB64 046 - Eevee Brown", "TWB64 047 - Microvision Ver.", "TWB64 048 - TI-83 Ver.", "TWB64 049 - Aegis Cherry", "TWB64 050 - Labo Fawn", "TWB64 051 - MILLION LIVE GOLD!", "TWB64 052 - Squidward Sea Foam Green", "TWB64 053 - VMU Ver.", "TWB64 054 - Game Master Ver.", "TWB64 055 - Android Green", "TWB64 056 - Amazon Vision", "TWB64 057 - Google Red", "TWB64 058 - Google Blue", "TWB64 059 - Google Yellow", "TWB64 060 - Google Green", "TWB64 061 - WonderSwan Ver.", "TWB64 062 - Neo Geo Pocket Ver.", "TWB64 063 - Dew Green", "TWB64 064 - Coca-Cola Vision", "TWB64 065 - GameKing Ver.", "TWB64 066 - Do The Dew Ver.", "TWB64 067 - Digivice Ver.", "TWB64 068 - Bikini Bottom Ver.", "TWB64 069 - Blossom Pink", "TWB64 070 - Bubbles Blue", "TWB64 071 - Buttercup Green", "TWB64 072 - NASCAR Ver.", "TWB64 073 - Lemon-Lime Green", "TWB64 074 - Mega Man V Ver.", "TWB64 075 - Tamagotchi Ver.", "TWB64 076 - Phantom Red", "TWB64 077 - Halloween Ver.", "TWB64 078 - Christmas Ver.", "TWB64 079 - Cardcaptor Pink", "TWB64 080 - Pretty Guardian Gold", "TWB64 081 - Camouflage Ver.", "TWB64 082 - Legendary Super Saiyan", "TWB64 083 - Super Saiyan Rose", "TWB64 084 - Super Saiyan", "TWB64 085 - Perfected Ultra Instinct", "TWB64 086 - Saint Snow Red", "TWB64 087 - Yellow Banana", "TWB64 088 - Green Banana", "TWB64 089 - Super Saiyan 3", "TWB64 090 - Super Saiyan Blue Evolved", "TWB64 091 - Pocket Tales Ver.", "TWB64 092 - Investigation Yellow", "TWB64 093 - S.E.E.S. Blue", "TWB64 094 - Ultra Instinct Sign", "TWB64 095 - Hokage Orange", "TWB64 096 - Straw Hat Red", "TWB64 097 - Sword Art Cyan", "TWB64 098 - Deku Alpha Emerald", "TWB64 099 - Blue Stripes Ver.", "TWB64 100 - Precure Marble Raspberry"),
+ valueLabels = listOf("TWB64 001 - Aqours Blue", "TWB64 002 - Anime Expo Ver.", "TWB64 003 - SpongeBob Yellow", "TWB64 004 - Patrick Star Pink", "TWB64 005 - Neon Red", "TWB64 006 - Neon Blue", "TWB64 007 - Neon Yellow", "TWB64 008 - Neon Green", "TWB64 009 - Neon Pink", "TWB64 010 - Mario Red", "TWB64 011 - Nick Orange", "TWB64 012 - Virtual Vision", "TWB64 013 - Golden Wild", "TWB64 014 - DMG-099", "TWB64 015 - Classic Blurple", "TWB64 016 - 765 Production Ver.", "TWB64 017 - Superball Ivory", "TWB64 018 - Crunchyroll Orange", "TWB64 019 - Muse Pink", "TWB64 020 - School Idol Blue", "TWB64 021 - Gamate Ver.", "TWB64 022 - Greenscale Ver.", "TWB64 023 - Odyssey Gold", "TWB64 024 - Super Saiyan God", "TWB64 025 - Super Saiyan Blue", "TWB64 026 - ANIMAX BLUE", "TWB64 027 - BMO Ver.", "TWB64 028 - Game.com Ver.", "TWB64 029 - Sanrio Pink", "TWB64 030 - Timmy Turner Pink", "TWB64 031 - Fairly OddPalette", "TWB64 032 - Danny Phantom Silver", "TWB64 033 - Link's Awakening DX Ver.", "TWB64 034 - Travel Wood", "TWB64 035 - Pokemon Ver.", "TWB64 036 - Game Grump Orange", "TWB64 037 - Scooby-Doo Mystery Ver.", "TWB64 038 - Pokemon mini Ver.", "TWB64 039 - Supervision Ver.", "TWB64 040 - DMG Ver.", "TWB64 041 - Pocket Ver.", "TWB64 042 - Light Ver.", "TWB64 043 - All Might Hero Palette", "TWB64 044 - U.A. High School Uniform", "TWB64 045 - Pikachu Yellow", "TWB64 046 - Eevee Brown", "TWB64 047 - Microvision Ver.", "TWB64 048 - TI-83 Ver.", "TWB64 049 - Aegis Cherry", "TWB64 050 - Labo Fawn", "TWB64 051 - MILLION LIVE GOLD!", "TWB64 052 - Squidward Sea Foam Green", "TWB64 053 - VMU Ver.", "TWB64 054 - Game Master Ver.", "TWB64 055 - Android Green", "TWB64 056 - Amazon Vision", "TWB64 057 - Google Red", "TWB64 058 - Google Blue", "TWB64 059 - Google Yellow", "TWB64 060 - Google Green", "TWB64 061 - WonderSwan Ver.", "TWB64 062 - Neo Geo Pocket Ver.", "TWB64 063 - Dew Green", "TWB64 064 - Coca-Cola Vision", "TWB64 065 - GameKing Ver.", "TWB64 066 - Do The Dew Ver.", "TWB64 067 - Digivice Ver.", "TWB64 068 - Bikini Bottom Ver.", "TWB64 069 - Blossom Pink", "TWB64 070 - Bubbles Blue", "TWB64 071 - Buttercup Green", "TWB64 072 - NASCAR Ver.", "TWB64 073 - Lemon-Lime Green", "TWB64 074 - Mega Man V Ver.", "TWB64 075 - Tamagotchi Ver.", "TWB64 076 - Phantom Red", "TWB64 077 - Halloween Ver.", "TWB64 078 - Christmas Ver.", "TWB64 079 - Cardcaptor Pink", "TWB64 080 - Pretty Guardian Gold", "TWB64 081 - Camouflage Ver.", "TWB64 082 - Legendary Super Saiyan", "TWB64 083 - Super Saiyan Rose", "TWB64 084 - Super Saiyan", "TWB64 085 - Perfected Ultra Instinct", "TWB64 086 - Saint Snow Red", "TWB64 087 - Yellow Banana", "TWB64 088 - Green Banana", "TWB64 089 - Super Saiyan 3", "TWB64 090 - Super Saiyan Blue Evolved", "TWB64 091 - Pocket Tales Ver.", "TWB64 092 - Investigation Yellow", "TWB64 093 - S.E.E.S. Blue", "TWB64 094 - Ultra Instinct Sign", "TWB64 095 - Hokage Orange", "TWB64 096 - Straw Hat Red", "TWB64 097 - Sword Art Cyan", "TWB64 098 - Deku Alpha Emerald", "TWB64 099 - Blue Stripes Ver.", "TWB64 100 - Precure Marble Raspberry"),
+ defaultValue = "TWB64 001 - Aqours Blue",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gb_palette_twb64_2",
+ label = "> TWB64 - Pack 2 Palette",
+ values = listOf("TWB64 101 - 765PRO Pink", "TWB64 102 - CINDERELLA Blue", "TWB64 103 - MILLION Yellow!", "TWB64 104 - SideM Green", "TWB64 105 - SHINY Sky Blue", "TWB64 106 - Angry Volcano Ver.", "TWB64 107 - NBA Vision", "TWB64 108 - NFL Vision", "TWB64 109 - MLB Vision", "TWB64 110 - Anime Digivice Ver.", "TWB64 111 - Aquatic Iro", "TWB64 112 - Tea Midori", "TWB64 113 - Sakura Pink", "TWB64 114 - Wisteria Murasaki", "TWB64 115 - Oni Aka", "TWB64 116 - Golden Kiiro", "TWB64 117 - Silver Shiro", "TWB64 118 - Fruity Orange", "TWB64 119 - AKB48 Pink", "TWB64 120 - Miku Blue", "TWB64 121 - Tri Digivice Ver.", "TWB64 122 - Survey Corps Uniform", "TWB64 123 - Island Green", "TWB64 124 - Nogizaka46 Purple", "TWB64 125 - Ninja Turtle Green", "TWB64 126 - Slime Blue", "TWB64 127 - Lime Midori", "TWB64 128 - Ghostly Aoi", "TWB64 129 - Retro Bogeda", "TWB64 130 - Royal Blue", "TWB64 131 - Neon Purple", "TWB64 132 - Neon Orange", "TWB64 133 - Moonlight Vision", "TWB64 134 - Rising Sun Red", "TWB64 135 - Burger King Color Combo", "TWB64 136 - Grand Zeno Coat", "TWB64 137 - Pac-Man Yellow", "TWB64 138 - Irish Green", "TWB64 139 - Goku Gi", "TWB64 140 - Dragon Ball Orange", "TWB64 141 - Christmas Gold", "TWB64 142 - Pepsi Vision", "TWB64 143 - Bubblun Green", "TWB64 144 - Bobblun Blue", "TWB64 145 - Baja Blast Storm", "TWB64 146 - Olympic Gold", "TWB64 147 - LisAni Orange!", "TWB64 148 - Liella Purple!", "TWB64 149 - Olympic Silver", "TWB64 150 - Olympic Bronze", "TWB64 151 - ANA Flight Blue", "TWB64 152 - Nijigasaki Orange", "TWB64 153 - Holoblue", "TWB64 154 - WWE White and Red", "TWB64 155 - Yoshi Egg Green", "TWB64 156 - Pokedex Red", "TWB64 157 - FamilyMart Vision", "TWB64 158 - Xbox Green", "TWB64 159 - Sonic Mega Blue", "TWB64 160 - Sprite Green", "TWB64 161 - Scarlett Green", "TWB64 162 - Glitchy Blue", "TWB64 163 - Classic LCD", "TWB64 164 - 3DS Virtual Console Ver.", "TWB64 165 - PocketStation Ver.", "TWB64 166 - Timeless Gold and Red", "TWB64 167 - Smurfy Blue", "TWB64 168 - Swampy Ogre Green", "TWB64 169 - Sailor Spinach Green", "TWB64 170 - Shenron Green", "TWB64 171 - Berserk Blood", "TWB64 172 - Super Star Pink", "TWB64 173 - Gamebuino Classic Ver.", "TWB64 174 - Barbie Pink", "TWB64 175 - YOASOBI AMARANTH", "TWB64 176 - Nokia 3310 Ver.", "TWB64 177 - Clover Green", "TWB64 178 - Goku GT Gi", "TWB64 179 - Famicom Disk Yellow", "TWB64 180 - Team Rocket Uniform", "TWB64 181 - SEIKO Timely Vision", "TWB64 182 - PASTEL109", "TWB64 183 - Doraemon Tricolor", "TWB64 184 - Fury Blue", "TWB64 185 - GOOD SMILE VISION", "TWB64 186 - Puyo Puyo Green", "TWB64 187 - Circle K Color Combo", "TWB64 188 - Pizza Hut Red", "TWB64 189 - Emerald Green", "TWB64 190 - Grand Ivory", "TWB64 191 - Demon's Gold", "TWB64 192 - SEGA Tokyo Blue", "TWB64 193 - Champion's Tunic", "TWB64 194 - DK Barrel Brown", "TWB64 195 - EVA-01", "TWB64 196 - Wild West Vision", "TWB64 197 - Optimus Prime Palette", "TWB64 198 - Niconico Sea Green", "TWB64 199 - Duracell Copper", "TWB64 200 - TOKYO SKYTREE CLOUDY BLUE"),
+ valueLabels = listOf("TWB64 101 - 765PRO Pink", "TWB64 102 - CINDERELLA Blue", "TWB64 103 - MILLION Yellow!", "TWB64 104 - SideM Green", "TWB64 105 - SHINY Sky Blue", "TWB64 106 - Angry Volcano Ver.", "TWB64 107 - NBA Vision", "TWB64 108 - NFL Vision", "TWB64 109 - MLB Vision", "TWB64 110 - Anime Digivice Ver.", "TWB64 111 - Aquatic Iro", "TWB64 112 - Tea Midori", "TWB64 113 - Sakura Pink", "TWB64 114 - Wisteria Murasaki", "TWB64 115 - Oni Aka", "TWB64 116 - Golden Kiiro", "TWB64 117 - Silver Shiro", "TWB64 118 - Fruity Orange", "TWB64 119 - AKB48 Pink", "TWB64 120 - Miku Blue", "TWB64 121 - Tri Digivice Ver.", "TWB64 122 - Survey Corps Uniform", "TWB64 123 - Island Green", "TWB64 124 - Nogizaka46 Purple", "TWB64 125 - Ninja Turtle Green", "TWB64 126 - Slime Blue", "TWB64 127 - Lime Midori", "TWB64 128 - Ghostly Aoi", "TWB64 129 - Retro Bogeda", "TWB64 130 - Royal Blue", "TWB64 131 - Neon Purple", "TWB64 132 - Neon Orange", "TWB64 133 - Moonlight Vision", "TWB64 134 - Rising Sun Red", "TWB64 135 - Burger King Color Combo", "TWB64 136 - Grand Zeno Coat", "TWB64 137 - Pac-Man Yellow", "TWB64 138 - Irish Green", "TWB64 139 - Goku Gi", "TWB64 140 - Dragon Ball Orange", "TWB64 141 - Christmas Gold", "TWB64 142 - Pepsi Vision", "TWB64 143 - Bubblun Green", "TWB64 144 - Bobblun Blue", "TWB64 145 - Baja Blast Storm", "TWB64 146 - Olympic Gold", "TWB64 147 - LisAni Orange!", "TWB64 148 - Liella Purple!", "TWB64 149 - Olympic Silver", "TWB64 150 - Olympic Bronze", "TWB64 151 - ANA Flight Blue", "TWB64 152 - Nijigasaki Orange", "TWB64 153 - Holoblue", "TWB64 154 - WWE White and Red", "TWB64 155 - Yoshi Egg Green", "TWB64 156 - Pokedex Red", "TWB64 157 - FamilyMart Vision", "TWB64 158 - Xbox Green", "TWB64 159 - Sonic Mega Blue", "TWB64 160 - Sprite Green", "TWB64 161 - Scarlett Green", "TWB64 162 - Glitchy Blue", "TWB64 163 - Classic LCD", "TWB64 164 - 3DS Virtual Console Ver.", "TWB64 165 - PocketStation Ver.", "TWB64 166 - Timeless Gold and Red", "TWB64 167 - Smurfy Blue", "TWB64 168 - Swampy Ogre Green", "TWB64 169 - Sailor Spinach Green", "TWB64 170 - Shenron Green", "TWB64 171 - Berserk Blood", "TWB64 172 - Super Star Pink", "TWB64 173 - Gamebuino Classic Ver.", "TWB64 174 - Barbie Pink", "TWB64 175 - YOASOBI AMARANTH", "TWB64 176 - Nokia 3310 Ver.", "TWB64 177 - Clover Green", "TWB64 178 - Goku GT Gi", "TWB64 179 - Famicom Disk Yellow", "TWB64 180 - Team Rocket Uniform", "TWB64 181 - SEIKO Timely Vision", "TWB64 182 - PASTEL109", "TWB64 183 - Doraemon Tricolor", "TWB64 184 - Fury Blue", "TWB64 185 - GOOD SMILE VISION", "TWB64 186 - Puyo Puyo Green", "TWB64 187 - Circle K Color Combo", "TWB64 188 - Pizza Hut Red", "TWB64 189 - Emerald Green", "TWB64 190 - Grand Ivory", "TWB64 191 - Demon's Gold", "TWB64 192 - SEGA Tokyo Blue", "TWB64 193 - Champion's Tunic", "TWB64 194 - DK Barrel Brown", "TWB64 195 - EVA-01", "TWB64 196 - Wild West Vision", "TWB64 197 - Optimus Prime Palette", "TWB64 198 - Niconico Sea Green", "TWB64 199 - Duracell Copper", "TWB64 200 - TOKYO SKYTREE CLOUDY BLUE"),
+ defaultValue = "TWB64 101 - 765PRO Pink",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gb_palette_twb64_3",
+ label = "> TWB64 - Pack 3 Palette",
+ values = listOf("TWB64 201 - DMG-GOLD", "TWB64 202 - LCD Clock Green", "TWB64 203 - Famicom Frenzy", "TWB64 204 - DK Arcade Blue", "TWB64 205 - Advanced Indigo", "TWB64 206 - Ultra Black", "TWB64 207 - Chaos Emerald Green", "TWB64 208 - Blue Bomber Vision", "TWB64 209 - Krispy Kreme Vision", "TWB64 210 - Steam Gray", "TWB64 211 - Dream Land GB Ver.", "TWB64 212 - Pokemon Pinball Ver.", "TWB64 213 - Poketch Ver.", "TWB64 214 - COLLECTION of SaGa Ver.", "TWB64 215 - Rocky-Valley Holiday", "TWB64 216 - Giga Kiwi DMG", "TWB64 217 - DMG Pea Green", "TWB64 218 - Timing Hero Ver.", "TWB64 219 - Invincible Yellow and Blue", "TWB64 220 - Grinchy Green", "TWB64 221 - Animate Vision", "TWB64 222 - School Idol Mix", "TWB64 223 - Green Awakening", "TWB64 224 - Goomba Brown", "TWB64 225 - WarioWare MicroBlue", "TWB64 226 - KonoSuba Sherbet", "TWB64 227 - Spooky Purple", "TWB64 228 - Treasure Gold", "TWB64 229 - Cherry Blossom Pink", "TWB64 230 - Golden Trophy", "TWB64 231 - Glacial Winter Blue", "TWB64 232 - Leprechaun Green", "TWB64 233 - SAITAMA SUPER BLUE", "TWB64 234 - SAITAMA SUPER GREEN", "TWB64 235 - Duolingo Green", "TWB64 236 - Super Mushroom Vision", "TWB64 237 - Ancient Hisuian Brown", "TWB64 238 - Sky Pop Ivory", "TWB64 239 - LAWSON BLUE", "TWB64 240 - Anime Expo Red", "TWB64 241 - Brilliant Diamond Blue", "TWB64 242 - Shining Pearl Pink", "TWB64 243 - Funimation Melon", "TWB64 244 - Teyvat Brown", "TWB64 245 - Chozo Blue", "TWB64 246 - Spotify Green", "TWB64 247 - Dr Pepper Red", "TWB64 248 - NHK Silver Gray", "TWB64 249 - Dunkin' Vision", "TWB64 250 - Deku Gamma Palette", "TWB64 251 - Universal Studios Blue", "TWB64 252 - Hogwarts Goldius", "TWB64 253 - Kentucky Fried Red", "TWB64 254 - Cheeto Orange", "TWB64 255 - Namco Idol Pink", "TWB64 256 - Domino's Pizza Vision", "TWB64 257 - Pac-Man Vision", "TWB64 258 - Bill's PC Screen", "TWB64 259 - Ebott Prolouge", "TWB64 260 - Fool's Gold and Silver", "TWB64 261 - UTA VISION", "TWB64 262 - Metallic Paldea Brass", "TWB64 263 - Classy Christmas", "TWB64 264 - Winter Christmas", "TWB64 265 - IDOL WORLD TRICOLOR!!!", "TWB64 266 - Inkling Tricolor", "TWB64 267 - 7-Eleven Color Combo", "TWB64 268 - PAC-PALETTE", "TWB64 269 - Vulnerable Blue", "TWB64 270 - Nightvision Green", "TWB64 271 - Bandai Namco Tricolor", "TWB64 272 - Gold, Silver, and Bronze", "TWB64 273 - Deku Vigilante Palette", "TWB64 274 - Super Famicom Supreme", "TWB64 275 - Absorbent and Yellow", "TWB64 276 - 765PRO TRICOLOR", "TWB64 277 - GameCube Glimmer", "TWB64 278 - 1st Vision Pastel", "TWB64 279 - Perfect Majin Emperor", "TWB64 280 - J-Pop Idol Sherbet", "TWB64 281 - Ryuuguu Sunset", "TWB64 282 - Tropical Starfall", "TWB64 283 - Colorful Horizons", "TWB64 284 - BLACKPINK BLINK PINK", "TWB64 285 - DMG-SWITCH", "TWB64 286 - POCKET SWITCH", "TWB64 287 - Sunny Passion Paradise", "TWB64 288 - Saiyan Beast Silver", "TWB64 289 - RADIANT SMILE RAMP", "TWB64 290 - A-RISE BLUE", "TWB64 291 - TROPICAL TWICE APRICOT", "TWB64 292 - Odyssey Boy", "TWB64 293 - Frog Coin Green", "TWB64 294 - Garfield Vision", "TWB64 295 - Bedrock Caveman Vision", "TWB64 296 - BANGTAN ARMY PURPLE", "TWB64 297 - LE SSERAFIM FEARLESS BLUE", "TWB64 298 - Baja Blast Beach", "TWB64 299 - 3DS Virtual Console Green", "TWB64 300 - Wonder Purple"),
+ valueLabels = listOf("TWB64 201 - DMG-GOLD", "TWB64 202 - LCD Clock Green", "TWB64 203 - Famicom Frenzy", "TWB64 204 - DK Arcade Blue", "TWB64 205 - Advanced Indigo", "TWB64 206 - Ultra Black", "TWB64 207 - Chaos Emerald Green", "TWB64 208 - Blue Bomber Vision", "TWB64 209 - Krispy Kreme Vision", "TWB64 210 - Steam Gray", "TWB64 211 - Dream Land GB Ver.", "TWB64 212 - Pokemon Pinball Ver.", "TWB64 213 - Poketch Ver.", "TWB64 214 - COLLECTION of SaGa Ver.", "TWB64 215 - Rocky-Valley Holiday", "TWB64 216 - Giga Kiwi DMG", "TWB64 217 - DMG Pea Green", "TWB64 218 - Timing Hero Ver.", "TWB64 219 - Invincible Yellow and Blue", "TWB64 220 - Grinchy Green", "TWB64 221 - Animate Vision", "TWB64 222 - School Idol Mix", "TWB64 223 - Green Awakening", "TWB64 224 - Goomba Brown", "TWB64 225 - WarioWare MicroBlue", "TWB64 226 - KonoSuba Sherbet", "TWB64 227 - Spooky Purple", "TWB64 228 - Treasure Gold", "TWB64 229 - Cherry Blossom Pink", "TWB64 230 - Golden Trophy", "TWB64 231 - Glacial Winter Blue", "TWB64 232 - Leprechaun Green", "TWB64 233 - SAITAMA SUPER BLUE", "TWB64 234 - SAITAMA SUPER GREEN", "TWB64 235 - Duolingo Green", "TWB64 236 - Super Mushroom Vision", "TWB64 237 - Ancient Hisuian Brown", "TWB64 238 - Sky Pop Ivory", "TWB64 239 - LAWSON BLUE", "TWB64 240 - Anime Expo Red", "TWB64 241 - Brilliant Diamond Blue", "TWB64 242 - Shining Pearl Pink", "TWB64 243 - Funimation Melon", "TWB64 244 - Teyvat Brown", "TWB64 245 - Chozo Blue", "TWB64 246 - Spotify Green", "TWB64 247 - Dr Pepper Red", "TWB64 248 - NHK Silver Gray", "TWB64 249 - Dunkin' Vision", "TWB64 250 - Deku Gamma Palette", "TWB64 251 - Universal Studios Blue", "TWB64 252 - Hogwarts Goldius", "TWB64 253 - Kentucky Fried Red", "TWB64 254 - Cheeto Orange", "TWB64 255 - Namco Idol Pink", "TWB64 256 - Domino's Pizza Vision", "TWB64 257 - Pac-Man Vision", "TWB64 258 - Bill's PC Screen", "TWB64 259 - Ebott Prolouge", "TWB64 260 - Fool's Gold and Silver", "TWB64 261 - UTA VISION", "TWB64 262 - Metallic Paldea Brass", "TWB64 263 - Classy Christmas", "TWB64 264 - Winter Christmas", "TWB64 265 - IDOL WORLD TRICOLOR!!!", "TWB64 266 - Inkling Tricolor", "TWB64 267 - 7-Eleven Color Combo", "TWB64 268 - PAC-PALETTE", "TWB64 269 - Vulnerable Blue", "TWB64 270 - Nightvision Green", "TWB64 271 - Bandai Namco Tricolor", "TWB64 272 - Gold, Silver, and Bronze", "TWB64 273 - Deku Vigilante Palette", "TWB64 274 - Super Famicom Supreme", "TWB64 275 - Absorbent and Yellow", "TWB64 276 - 765PRO TRICOLOR", "TWB64 277 - GameCube Glimmer", "TWB64 278 - 1st Vision Pastel", "TWB64 279 - Perfect Majin Emperor", "TWB64 280 - J-Pop Idol Sherbet", "TWB64 281 - Ryuuguu Sunset", "TWB64 282 - Tropical Starfall", "TWB64 283 - Colorful Horizons", "TWB64 284 - BLACKPINK BLINK PINK", "TWB64 285 - DMG-SWITCH", "TWB64 286 - POCKET SWITCH", "TWB64 287 - Sunny Passion Paradise", "TWB64 288 - Saiyan Beast Silver", "TWB64 289 - RADIANT SMILE RAMP", "TWB64 290 - A-RISE BLUE", "TWB64 291 - TROPICAL TWICE APRICOT", "TWB64 292 - Odyssey Boy", "TWB64 293 - Frog Coin Green", "TWB64 294 - Garfield Vision", "TWB64 295 - Bedrock Caveman Vision", "TWB64 296 - BANGTAN ARMY PURPLE", "TWB64 297 - LE SSERAFIM FEARLESS BLUE", "TWB64 298 - Baja Blast Beach", "TWB64 299 - 3DS Virtual Console Green", "TWB64 300 - Wonder Purple"),
+ defaultValue = "TWB64 201 - DMG-GOLD",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gbc_color_correction",
+ label = "Color Correction",
+ values = listOf("GBC only", "always", "disabled"),
+ valueLabels = listOf("GBC Only", "Always", "disabled"),
+ defaultValue = "GBC only",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gbc_color_correction_mode",
+ label = "Color Correction Mode",
+ values = listOf("accurate", "fast"),
+ valueLabels = listOf("Accurate", "Fast"),
+ defaultValue = "accurate",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gambatte_audio_resampler",
+ label = "Audio Resampler",
+ values = listOf("sinc", "cc"),
+ valueLabels = listOf("Sinc", "Cosine"),
+ defaultValue = "cc",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gb_bootloader",
+ label = "Use Official Bootloader (Restart Required)",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gb_hwmode",
+ label = "Emulated Hardware (Restart Required)",
+ values = listOf("Auto", "GB", "GBC", "GBA"),
+ valueLabels = listOf("Auto", "GB", "GBC", "GBA"),
+ defaultValue = "Auto",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gambatte_gbc_frontlight_position",
+ label = "Color Correction - Frontlight Position",
+ values = listOf("central", "above screen", "below screen"),
+ valueLabels = listOf("Central", "Above Screen", "Below Screen"),
+ defaultValue = "central",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "gambatte_mix_frames",
+ label = "Interframe Blending",
+ values = listOf("disabled", "mix", "lcd_ghosting", "lcd_ghosting_fast"),
+ valueLabels = listOf("disabled", "Simple", "LCD Ghosting (Accurate)", "LCD Ghosting (Fast)"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ )
+
+ val MGBA: List = listOf(
+ RetroCoreOption(
+ key = "mgba_allow_opposing_directions",
+ label = "Allow Opposing Directional Input",
+ values = listOf("no", "yes"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "no",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_force_gbp",
+ label = "Game Boy Player Rumble (Restart)",
+ values = listOf("OFF", "ON"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "OFF",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_solar_sensor_level",
+ label = "Solar Sensor Level",
+ values = listOf("sensor", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
+ valueLabels = listOf("Use device sensor if available", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
+ defaultValue = "0",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_color_correction",
+ label = "Color Correction",
+ values = listOf("OFF", "GBA", "GBC", "Auto"),
+ valueLabels = listOf("disabled", "Game Boy Advance", "Game Boy Color", "Auto"),
+ defaultValue = "OFF",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ labelRes = R.string.retro_co_color_correction,
+ ),
+ RetroCoreOption(
+ key = "mgba_gb_colors",
+ label = "Default Game Boy Palette",
+ values = listOf("Grayscale"),
+ valueLabels = listOf("Grayscale"),
+ defaultValue = "Grayscale",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_gb_colors_preset",
+ label = "Hardware Preset Game Boy Palettes (Restart)",
+ values = listOf("0", "1", "2", "3"),
+ valueLabels = listOf("Default Game Boy preset", "Game Boy Color presets only", "Super Game Boy presets only", "Any available presets"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_interframe_blending",
+ label = "Interframe Blending",
+ values = listOf("OFF", "mix", "mix_smart", "lcd_ghosting", "lcd_ghosting_fast"),
+ valueLabels = listOf("disabled", "Simple", "Smart", "LCD Ghosting (Accurate)", "LCD Ghosting (Fast)"),
+ defaultValue = "OFF",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_sgb_borders",
+ label = "Use Super Game Boy Borders (Restart)",
+ values = listOf("ON", "OFF"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "ON",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_frameskip",
+ label = "Frameskip",
+ values = listOf("disabled", "auto", "auto_threshold", "fixed_interval"),
+ valueLabels = listOf("disabled", "Auto", "Auto (Threshold)", "Fixed Interval"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_frameskip_interval",
+ label = "Frameskip Interval",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
+ defaultValue = "0",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_frameskip_threshold",
+ label = "Frameskip Threshold (%)",
+ values = listOf("15", "18", "21", "24", "27", "30", "33", "36", "39", "42", "45", "48", "51", "54", "57", "60"),
+ valueLabels = listOf("15", "18", "21", "24", "27", "30", "33", "36", "39", "42", "45", "48", "51", "54", "57", "60"),
+ defaultValue = "33",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_idle_optimization",
+ label = "Idle Loop Removal",
+ values = listOf("Remove Known", "Detect and Remove", "Don't Remove"),
+ valueLabels = listOf("Remove Known", "Detect and Remove", "Don't Remove"),
+ defaultValue = "Remove Known",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_audio_low_pass_filter",
+ label = "Audio Filter",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_audio_low_pass_range",
+ label = "Audio Filter Level",
+ values = listOf("5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95"),
+ valueLabels = listOf("5%", "10%", "15%", "20%", "25%", "30%", "35%", "40%", "45%", "50%", "55%", "60%", "65%", "70%", "75%", "80%", "85%", "90%", "95%"),
+ defaultValue = "60",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_gb_model",
+ label = "Game Boy Model (Restart)",
+ values = listOf("Autodetect", "Game Boy", "Super Game Boy", "Game Boy Color", "Super Game Boy Color", "Game Boy Advance"),
+ valueLabels = listOf("Autodetect", "Game Boy", "Super Game Boy", "Game Boy Color", "Super Game Boy Color", "Game Boy Advance"),
+ defaultValue = "Autodetect",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_skip_bios",
+ label = "Skip BIOS Intro (Restart)",
+ values = listOf("OFF", "ON"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "OFF",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mgba_use_bios",
+ label = "Use BIOS File if Found (Restart)",
+ values = listOf("ON", "OFF"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "ON",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ )
+
+ val GENESIS_PLUS_GX: List = listOf(
+ RetroCoreOption(
+ key = "genesis_plus_gx_gun_cursor",
+ label = "Show Light Gun Crosshair",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_gun_input",
+ label = "Light Gun Input",
+ values = listOf("lightgun", "touchscreen"),
+ valueLabels = listOf("Light Gun", "Touchscreen"),
+ defaultValue = "lightgun",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_invert_mouse",
+ label = "Invert Mouse Y-Axis",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_aspect_ratio",
+ label = "Core-Provided Aspect Ratio",
+ values = listOf("auto", "NTSC PAR", "PAL PAR", "4:3", "Uncorrected"),
+ valueLabels = listOf("Auto", "NTSC PAR", "PAL PAR", "4:3", "Uncorrected"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_blargg_ntsc_filter",
+ label = "Blargg NTSC Filter",
+ values = listOf("disabled", "monochrome", "composite", "svideo", "rgb"),
+ valueLabels = listOf("disabled", "Monochrome", "Composite", "S-Video", "RGB"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ labelRes = R.string.retro_co_ntsc_filter,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_frameskip",
+ label = "Frameskip",
+ values = listOf("disabled", "auto", "manual"),
+ valueLabels = listOf("disabled", "Auto", "Manual"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_frameskip_threshold",
+ label = "Frameskip Threshold (%)",
+ values = listOf("15", "18", "21", "24", "27", "30", "33", "36", "39", "42", "45", "48", "51", "54", "57", "60"),
+ valueLabels = listOf("15", "18", "21", "24", "27", "30", "33", "36", "39", "42", "45", "48", "51", "54", "57", "60"),
+ defaultValue = "33",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_gg_extra",
+ label = "Game Gear Extended Screen",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_lcd_filter",
+ label = "LCD Ghosting Filter",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_left_border",
+ label = "Hide Master System Side Borders",
+ values = listOf("disabled", "left border", "left & right borders"),
+ valueLabels = listOf("disabled", "Left Border Only", "Left & Right Borders"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_overscan",
+ label = "Borders",
+ values = listOf("disabled", "top/bottom", "left/right", "full"),
+ valueLabels = listOf("disabled", "Top/Bottom", "Left/Right", "Full"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_render",
+ label = "Interlaced Mode 2 Output",
+ values = listOf("single field", "double field"),
+ valueLabels = listOf("Single Field", "Double Field"),
+ defaultValue = "single field",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_addr_error",
+ label = "68K Address Error",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_cd_latency",
+ label = "CD Access Time",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_cd_precache",
+ label = "CD Image Cache",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_enhanced_vscroll",
+ label = "Enhanced per-tile vertical scroll",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_enhanced_vscroll_limit",
+ label = "Enhanced per-tile vertical scroll limit",
+ values = listOf("2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"),
+ valueLabels = listOf("2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16"),
+ defaultValue = "8",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_force_dtack",
+ label = "System Lock-Ups",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_no_sprite_limit",
+ label = "Remove Per-Line Sprite Limit",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ labelRes = R.string.retro_co_remove_sprite_limit,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_overclock",
+ label = "CPU Speed",
+ values = listOf("100", "125", "150", "175", "200", "225", "250", "275", "300", "325", "350", "375", "400", "425", "450", "475", "500"),
+ valueLabels = listOf("100%", "125%", "150%", "175%", "200%", "225%", "250%", "275%", "300%", "325%", "350%", "375%", "400%", "425%", "450%", "475%", "500%"),
+ defaultValue = "100%",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_audio_eq_high",
+ label = "EQ High",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_audio_eq_low",
+ label = "EQ Low",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_audio_eq_mid",
+ label = "EQ Mid",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_audio_filter",
+ label = "Audio Filter",
+ values = listOf("disabled", "low-pass", "EQ"),
+ valueLabels = listOf("disabled", "Low-Pass", "EQ"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_cdda_volume",
+ label = "CD-DA Volume",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_fm_preamp",
+ label = "FM Preamp Level",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100", "105", "110", "115", "120", "125", "130", "135", "140", "145", "150", "155", "160", "165", "170", "175", "180", "185", "190", "195", "200"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100", "105", "110", "115", "120", "125", "130", "135", "140", "145", "150", "155", "160", "165", "170", "175", "180", "185", "190", "195", "200"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_lowpass_range",
+ label = "Low-Pass Filter %",
+ values = listOf("5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95"),
+ valueLabels = listOf("5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95"),
+ defaultValue = "60",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_md_channel_0_volume",
+ label = "Mega Drive/Genesis FM Channel 0 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_md_channel_1_volume",
+ label = "Mega Drive/Genesis FM Channel 1 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_md_channel_2_volume",
+ label = "Mega Drive/Genesis FM Channel 2 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_md_channel_3_volume",
+ label = "Mega Drive/Genesis FM Channel 3 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_md_channel_4_volume",
+ label = "Mega Drive/Genesis FM Channel 4 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_md_channel_5_volume",
+ label = "Mega Drive/Genesis FM Channel 5 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_pcm_volume",
+ label = "PCM Volume",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_psg_channel_0_volume",
+ label = "PSG Tone Channel 0 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_psg_channel_1_volume",
+ label = "PSG Tone Channel 1 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_psg_channel_2_volume",
+ label = "PSG Tone Channel 2 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_psg_channel_3_volume",
+ label = "PSG Noise Channel 3 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_psg_preamp",
+ label = "PSG Preamp Level",
+ values = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100", "105", "110", "115", "120", "125", "130", "135", "140", "145", "150", "155", "160", "165", "170", "175", "180", "185", "190", "195", "200"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100", "105", "110", "115", "120", "125", "130", "135", "140", "145", "150", "155", "160", "165", "170", "175", "180", "185", "190", "195", "200"),
+ defaultValue = "150",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_sms_fm_channel_0_volume",
+ label = "Master System FM (YM2413) Channel 0 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_sms_fm_channel_1_volume",
+ label = "Master System FM (YM2413) Channel 1 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_sms_fm_channel_2_volume",
+ label = "Master System FM (YM2413) Channel 2 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_sms_fm_channel_3_volume",
+ label = "Master System FM (YM2413) Channel 3 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_sms_fm_channel_4_volume",
+ label = "Master System FM (YM2413) Channel 4 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_sms_fm_channel_5_volume",
+ label = "Master System FM (YM2413) Channel 5 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_sms_fm_channel_6_volume",
+ label = "Master System FM (YM2413) Channel 6 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_sms_fm_channel_7_volume",
+ label = "Master System FM (YM2413) Channel 7 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_sms_fm_channel_8_volume",
+ label = "Master System FM (YM2413) Channel 8 Volume %",
+ values = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ valueLabels = listOf("0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100"),
+ defaultValue = "100",
+ category = RetroOptionCategory.SOUND,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_sound_output",
+ label = "Sound Output",
+ values = listOf("stereo", "mono"),
+ valueLabels = listOf("Stereo", "Mono"),
+ defaultValue = "stereo",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_ym2413",
+ label = "Master System FM (YM2413)",
+ values = listOf("auto", "disabled", "enabled"),
+ valueLabels = listOf("Auto", "disabled", "enabled"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_ym2413_core",
+ label = "Master System FM (YM2413) Core",
+ values = listOf("mame", "nuked"),
+ valueLabels = listOf("MAME", "Nuked"),
+ defaultValue = "mame",
+ category = RetroOptionCategory.SOUND,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_add_on",
+ label = "CD add-on (MD mode) (Requires Restart)",
+ values = listOf("auto", "sega/mega cd", "megasd", "none"),
+ valueLabels = listOf("Auto", "Sega/Mega CD", "MegaSD", "None"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_bios",
+ label = "System Boot ROM",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_cart_bram",
+ label = "CD Backup Cart BRAM (Requires Restart)",
+ values = listOf("per cart", "per game"),
+ valueLabels = listOf("Per-Cart", "Per-Game"),
+ defaultValue = "per cart",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_cart_size",
+ label = "CD Backup Cart BRAM Size (Requires Restart)",
+ values = listOf("disabled", "128k", "256k", "512k", "1meg", "2meg", "4meg"),
+ valueLabels = listOf("Disabled", "128Kbit", "256Kbit", "512Kbit", "1Mbit", "2Mbit", "4Mbit"),
+ defaultValue = "4meg",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_lock_on",
+ label = "Cartridge Lock-On",
+ values = listOf("disabled", "game genie", "action replay (pro)", "sonic & knuckles"),
+ valueLabels = listOf("disabled", "Game Genie", "Action Replay (Pro)", "Sonic & Knuckles"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_region_detect",
+ label = "System Region",
+ values = listOf("auto", "ntsc-u", "pal", "ntsc-j"),
+ valueLabels = listOf("Auto", "NTSC-U", "PAL", "NTSC-J"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_system_bram",
+ label = "CD System BRAM (Requires Restart)",
+ values = listOf("per bios", "per game"),
+ valueLabels = listOf("Per-BIOS", "Per-Game"),
+ defaultValue = "per bios",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_system_hw",
+ label = "System Hardware",
+ values = listOf("auto", "sg-1000", "sg-1000 II", "sg-1000 II + ram ext.", "mark-III", "master system", "master system II", "game gear", "mega drive / genesis"),
+ valueLabels = listOf("Auto", "SG-1000", "SG-1000 II", "SG-1000 II + RAM Ext.", "Mark III", "Master System", "Master System II", "Game Gear", "Mega Drive/Genesis"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_vdp_mode",
+ label = "Force VDP Mode",
+ values = listOf("auto", "60hz", "50hz"),
+ valueLabels = listOf("Disabled", "NTSC (60Hz)", "PAL (50Hz)"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "genesis_plus_gx_ym2612",
+ label = "Mega Drive/Genesis FM",
+ values = listOf("mame (ym2612)", "mame (asic ym3438)", "mame (enhanced ym3438)", "nuked (ym2612)", "nuked (ym3438)"),
+ valueLabels = listOf("MAME (YM2612)", "MAME (ASIC YM3438)", "MAME (Enhanced YM3438)", "Nuked (YM2612)", "Nuked (YM3438)"),
+ defaultValue = "mame (ym2612)",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ )
+
+ val BEETLE_PSX: List = listOf(
+ RetroCoreOption(
+ key = "beetle_psx_analog_calibration",
+ label = "Analog Self-Calibration",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_analog_toggle",
+ label = "DualShock Analog Mode Toggle",
+ values = listOf("disabled", "enabled", "enabled-analog"),
+ valueLabels = listOf("disabled", "enabled", "Default-Analog"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_analog_toggle_combo",
+ label = "DualShock Analog Mode Combo",
+ values = listOf("l1+l2+r1+r2+start+select", "l1+r1+select", "l1+r1+start", "l1+r1+l3", "l1+r1+r3", "l2+r2+select", "l2+r2+start", "l2+r2+l3", "l2+r2+r3", "l3+r3"),
+ valueLabels = listOf("L1 + L2 + R1 + R2 + Start + Select", "L1 + R1 + Select", "L1 + R1 + Start", "L1 + R1 + L3", "L1 + R1 + R3", "L2 + R2 + Select", "L2 + R2 + Start", "L2 + R2 + L3", "L2 + R2 + R3", "L3 + R3"),
+ defaultValue = "l1+r1+select",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_analog_toggle_hold",
+ label = "DualShock Analog Mode Combo Hold Delay",
+ values = listOf("0", "1", "2", "3", "4", "5"),
+ valueLabels = listOf("0 Second Delay", "1 Second Delay", "2 Second Delay", "3 Second Delay", "4 Second Delay", "5 Second Delay"),
+ defaultValue = "1",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_crosshair_color_p1",
+ label = "Gun Crosshair Color: Port 1",
+ values = listOf("red", "blue", "green", "orange", "yellow", "cyan", "pink", "purple", "black", "white"),
+ valueLabels = listOf("Red", "Blue", "Green", "Orange", "Yellow", "Cyan", "Pink", "Purple", "Black", "White"),
+ defaultValue = "red",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_crosshair_color_p2",
+ label = "Gun Crosshair Color: Port 2",
+ values = listOf("blue", "red", "green", "orange", "yellow", "cyan", "pink", "purple", "black", "white"),
+ valueLabels = listOf("Blue", "Red", "Green", "Orange", "Yellow", "Cyan", "Pink", "Purple", "Black", "White"),
+ defaultValue = "blue",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_enable_multitap_port1",
+ label = "Multitap on Port 1",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_enable_multitap_port2",
+ label = "Multitap on Port 2",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_gun_cursor",
+ label = "Gun Cursor",
+ values = listOf("off", "cross", "dot"),
+ valueLabels = listOf("disabled", "Cross", "Dot"),
+ defaultValue = "cross",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_gun_input_mode",
+ label = "Gun Input Mode",
+ values = listOf("lightgun", "touchscreen"),
+ valueLabels = listOf("Light Gun", "Touchscreen"),
+ defaultValue = "lightgun",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_mouse_sensitivity",
+ label = "Mouse Sensitivity",
+ values = listOf("5%", "10%", "15%", "20%", "25%", "30%", "35%", "40%", "45%", "50%", "55%", "60%", "65%", "70%", "75%", "80%", "85%", "90%", "95%", "100%", "105%", "110%", "115%", "120%", "125%", "130%", "135%", "140%", "145%", "150%", "155%", "160%", "165%", "170%", "175%", "180%", "185%", "190%", "195%", "200%"),
+ valueLabels = listOf("5%", "10%", "15%", "20%", "25%", "30%", "35%", "40%", "45%", "50%", "55%", "60%", "65%", "70%", "75%", "80%", "85%", "90%", "95%", "100%", "105%", "110%", "115%", "120%", "125%", "130%", "135%", "140%", "145%", "150%", "155%", "160%", "165%", "170%", "175%", "180%", "185%", "190%", "195%", "200%"),
+ defaultValue = "100%",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_negcon_deadzone",
+ label = "neGcon Twist Deadzone",
+ values = listOf("0%", "5%", "10%", "15%", "20%", "25%", "30%"),
+ valueLabels = listOf("0%", "5%", "10%", "15%", "20%", "25%", "30%"),
+ defaultValue = "0%",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_negcon_response",
+ label = "neGcon Twist Response",
+ values = listOf("linear", "quadratic", "cubic"),
+ valueLabels = listOf("Linear", "Quadratic", "Cubic"),
+ defaultValue = "linear",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_adaptive_smoothing",
+ label = "Adaptive Smoothing",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_aspect_ratio",
+ label = "Core Aspect Ratio",
+ values = listOf("corrected", "uncorrected", "4:3", "ntsc", "16:9"),
+ valueLabels = listOf("Corrected", "Uncorrected", "Force 4:3", "Force NTSC", "Force 16:9"),
+ defaultValue = "corrected",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_core_timing_fps",
+ label = "Core-Reported FPS Timing",
+ values = listOf("auto_toggle", "force_progressive", "force_interlaced"),
+ valueLabels = listOf("Automatic Toggling", "Force Progressive Rate", "Force Interlaced Rate"),
+ defaultValue = "auto_toggle",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_crop_overscan",
+ label = "Crop Overscan",
+ values = listOf("disabled", "static", "smart"),
+ valueLabels = listOf("Disabled", "Horizontal", "Horizontal + Vertical"),
+ defaultValue = "smart",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_deinterlacer",
+ label = "Deinterlace Method",
+ values = listOf("weave", "bob", "bob_offset", "fastmad", "off"),
+ valueLabels = listOf("Weave", "Bob", "Bob (Offset)", "FastMAD (Motion Adaptive)", "Off"),
+ defaultValue = "weave",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_depth",
+ label = "Internal Color Depth",
+ values = listOf("16bpp(native)", "32bpp"),
+ valueLabels = listOf("16 bpp (Native)", "32 bpp"),
+ defaultValue = "16bpp(native)",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_dither_mode",
+ label = "Dithering Pattern",
+ values = listOf("1x(native)", "internal resolution", "disabled"),
+ valueLabels = listOf("1x (Native)", "Internal Resolution", "disabled"),
+ defaultValue = "1x(native)",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ labelRes = R.string.retro_co_dithering,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_dump_textures",
+ label = "Dump Textures",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_filter",
+ label = "Texture Filtering",
+ values = listOf("nearest", "SABR", "xBR", "bilinear", "3-point", "JINC2"),
+ valueLabels = listOf("Nearest", "SABR", "xBR", "Bilinear", "3-Point", "JINC2"),
+ defaultValue = "nearest",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_filter_exclude_2d_polygon",
+ label = "Exclude 2D Polygons from Filtering",
+ values = listOf("disabled", "opaque", "all"),
+ valueLabels = listOf("disabled", "Opaque Only", "Opaque and Semi-Transparent"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_filter_exclude_sprite",
+ label = "Exclude Sprites from Filtering",
+ values = listOf("disabled", "opaque", "all"),
+ valueLabels = listOf("disabled", "Opaque Only", "Opaque and Semi-Transparent"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_frame_duping",
+ label = "Frame Duping",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_image_crop",
+ label = "Additional Cropping",
+ values = listOf("disabled", "1px", "2px", "3px", "4px", "5px", "6px", "7px", "8px", "9px", "10px", "11px", "12px", "13px", "14px", "15px", "16px", "17px", "18px", "19px", "20px"),
+ valueLabels = listOf("0", "1px", "2px", "3px", "4px", "5px", "6px", "7px", "8px", "9px", "10px", "11px", "12px", "13px", "14px", "15px", "16px", "17px", "18px", "19px", "20px"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_image_offset",
+ label = "Offset Cropped Image",
+ values = listOf("-12px", "-11px", "-10px", "-9px", "-8px", "-7px", "-6px", "-5px", "-4px", "-3px", "-2px", "-1px", "disabled", "+1px", "+2px", "+3px", "+4px", "+5px", "+6px", "+7px", "+8px", "+9px", "+10px", "+11px", "+12px"),
+ valueLabels = listOf("-12px", "-11px", "-10px", "-9px", "-8px", "-7px", "-6px", "-5px", "-4px", "-3px", "-2px", "-1px", "0", "+1px", "+2px", "+3px", "+4px", "+5px", "+6px", "+7px", "+8px", "+9px", "+10px", "+11px", "+12px"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_image_offset_cycles",
+ label = "Horizontal Image Offset (GPU Cycles)",
+ values = listOf("-40", "-39", "-38", "-37", "-36", "-35", "-34", "-33", "-32", "-31", "-30", "-29", "-28", "-27", "-26", "-25", "-24", "-23", "-22", "-21", "-20", "-19", "-18", "-17", "-16", "-15", "-14", "-13", "-12", "-11", "-10", "-9", "-8", "-7", "-6", "-5", "-4", "-3", "-2", "-1", "0", "+1", "+2", "+3", "+4", "+5", "+6", "+7", "+8", "+9", "+10", "+11", "+12", "+13", "+14", "+15", "+16", "+17", "+18", "+19", "+20", "+21", "+22", "+23", "+24", "+25", "+26", "+27", "+28", "+29", "+30", "+31", "+32", "+33", "+34", "+35", "+36", "+37", "+38", "+39", "+40"),
+ valueLabels = listOf("-40", "-39", "-38", "-37", "-36", "-35", "-34", "-33", "-32", "-31", "-30", "-29", "-28", "-27", "-26", "-25", "-24", "-23", "-22", "-21", "-20", "-19", "-18", "-17", "-16", "-15", "-14", "-13", "-12", "-11", "-10", "-9", "-8", "-7", "-6", "-5", "-4", "-3", "-2", "-1", "0", "+1", "+2", "+3", "+4", "+5", "+6", "+7", "+8", "+9", "+10", "+11", "+12", "+13", "+14", "+15", "+16", "+17", "+18", "+19", "+20", "+21", "+22", "+23", "+24", "+25", "+26", "+27", "+28", "+29", "+30", "+31", "+32", "+33", "+34", "+35", "+36", "+37", "+38", "+39", "+40"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_initial_scanline",
+ label = "Initial Scan Line - NTSC",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40"),
+ valueLabels = listOf("0 (Default)", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_initial_scanline_pal",
+ label = "Initial Scan Line - PAL",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40"),
+ valueLabels = listOf("0 (Default)", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_internal_resolution",
+ label = "Internal GPU Resolution",
+ values = listOf("1x(native)", "2x", "4x", "8x", "16x"),
+ valueLabels = listOf("1x (Native)", "2x", "4x", "8x", "16x"),
+ defaultValue = "1x(native)",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_last_scanline",
+ label = "Last Scan Line - NTSC",
+ values = listOf("210", "211", "212", "213", "214", "215", "216", "217", "218", "219", "220", "221", "222", "223", "224", "225", "226", "227", "228", "229", "230", "231", "232", "233", "234", "235", "236", "237", "238", "239"),
+ valueLabels = listOf("210", "211", "212", "213", "214", "215", "216", "217", "218", "219", "220", "221", "222", "223", "224", "225", "226", "227", "228", "229", "230", "231", "232", "233", "234", "235", "236", "237", "238", "239 (Default)"),
+ defaultValue = "239",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_last_scanline_pal",
+ label = "Last Scan Line - PAL",
+ values = listOf("230", "231", "232", "233", "234", "235", "236", "237", "238", "239", "240", "241", "242", "243", "244", "245", "246", "247", "248", "249", "250", "251", "252", "253", "254", "255", "256", "257", "258", "259", "260", "261", "262", "263", "264", "265", "266", "267", "268", "269", "270", "271", "272", "273", "274", "275", "276", "277", "278", "279", "280", "281", "282", "283", "284", "285", "286", "287"),
+ valueLabels = listOf("230", "231", "232", "233", "234", "235", "236", "237", "238", "239", "240", "241", "242", "243", "244", "245", "246", "247", "248", "249", "250", "251", "252", "253", "254", "255", "256", "257", "258", "259", "260", "261", "262", "263", "264", "265", "266", "267", "268", "269", "270", "271", "272", "273", "274", "275", "276", "277", "278", "279", "280", "281", "282", "283", "284", "285", "286", "287 (Default)"),
+ defaultValue = "287",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_mdec_yuv",
+ label = "MDEC YUV Chroma Filter",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_msaa",
+ label = "Multi-Sampled Anti Aliasing",
+ values = listOf("1x", "2x", "4x", "8x", "16x"),
+ valueLabels = listOf("1x", "2x", "4x", "8x", "16x"),
+ defaultValue = "1x",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_pal_video_timing_override",
+ label = "PAL Video Timing Override",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_renderer_software_fb",
+ label = "Software Framebuffer",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_replace_textures",
+ label = "Replace Textures",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_scaled_uv_offset",
+ label = "Texture UV Offset",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_super_sampling",
+ label = "Supersampling (Downsample to Native Resolution)",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_track_textures",
+ label = "Track Textures",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_cpu_dynarec",
+ label = "CPU Dynarec",
+ values = listOf("disabled", "execute", "run_interpreter"),
+ valueLabels = listOf("Disabled (Beetle Interpreter)", "Max Performance", "Lightrec Interpreter"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_dynarec_eventcycles",
+ label = "Dynarec DMA/GPU/MDEC/Timer Event Cycles",
+ values = listOf("128", "256", "384", "512", "640", "768", "896", "1024", "1152", "1280", "1408", "1536", "1664", "1792", "1920", "2048"),
+ valueLabels = listOf("128 (Default)", "256", "384", "512", "640", "768", "896", "1024", "1152", "1280", "1408", "1536", "1664", "1792", "1920", "2048"),
+ defaultValue = "128",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_dynarec_invalidate",
+ label = "Dynarec Code Invalidation",
+ values = listOf("full", "dma"),
+ valueLabels = listOf("Full", "DMA Only (Slightly Faster)"),
+ defaultValue = "full",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_dynarec_op_cycles",
+ label = "Dynarec Cycles Per Instruction",
+ values = listOf("2", "1"),
+ valueLabels = listOf("2 (Default)", "1 (Accurate, Fixes Parasite Eve 2 etc.)"),
+ defaultValue = "2",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_dynarec_spgp_opt",
+ label = "Dynarec SP GP Hit RAM Optimization",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_dynarec_spu_samples",
+ label = "Dynarec SPU Samples",
+ values = listOf("1", "4", "16"),
+ valueLabels = listOf("1 (Default)", "4", "16"),
+ defaultValue = "1",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_line_render",
+ label = "Line-to-Quad Hack",
+ values = listOf("default", "aggressive", "disabled"),
+ valueLabels = listOf("Default", "Aggressive", "disabled"),
+ defaultValue = "default",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_pgxp_2d_tol",
+ label = "PGXP 2D Geometry Tolerance",
+ values = listOf("disabled", "0px", "1px", "2px", "3px", "4px", "5px", "6px", "7px", "8px"),
+ valueLabels = listOf("disabled", "0px", "1px", "2px", "3px", "4px", "5px", "6px", "7px", "8px"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_pgxp_mode",
+ label = "PGXP Operation Mode",
+ values = listOf("disabled", "memory only", "memory + CPU"),
+ valueLabels = listOf("disabled", "Memory Only", "Memory + CPU (Buggy)"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_pgxp_nclip",
+ label = "PGXP Primitive Culling",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_pgxp_texture",
+ label = "PGXP Perspective Correct Texturing",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_pgxp_vertex",
+ label = "PGXP Vertex Cache",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_spu_silent_voice",
+ label = "SPU Silent Voice Optimization",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_widescreen_hack",
+ label = "Widescreen Mode Hack",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ labelRes = R.string.retro_co_widescreen_hack,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_widescreen_hack_aspect_ratio",
+ label = "Widescreen Mode Hack Aspect Ratio",
+ values = listOf("16:9", "16:10", "18:9", "19:9", "20:9", "21:9", "32:9"),
+ valueLabels = listOf("16:9", "16:10", "18:9", "19:9", "20:9", "21:9", "32:9"),
+ defaultValue = "16:9",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_cd_access_method",
+ label = "CD Access Method",
+ values = listOf("sync", "async", "precache"),
+ valueLabels = listOf("Synchronous", "Asynchronous", "Pre-Cache"),
+ defaultValue = "sync",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_cd_fastload",
+ label = "CD Loading Speed",
+ values = listOf("2x(native)", "4x", "6x", "8x", "10x", "12x", "14x"),
+ valueLabels = listOf("2x (Native)", "4x", "6x", "8x", "10x", "12x", "14x"),
+ defaultValue = "2x(native)",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_cpu_freq_scale",
+ label = "CPU Frequency Scaling (Overclock)",
+ values = listOf("50%", "60%", "70%", "80%", "90%", "100%", "110%", "120%", "130%", "140%", "150%", "160%", "170%", "180%", "190%", "200%", "210%", "220%", "230%", "240%", "250%", "260%", "270%", "280%", "290%", "300%", "310%", "320%", "330%", "340%", "350%", "360%", "370%", "380%", "390%", "400%", "410%", "420%", "430%", "440%", "450%", "460%", "470%", "480%", "490%", "500%", "510%", "520%", "530%", "540%", "550%", "560%", "570%", "580%", "590%", "600%", "610%", "620%", "630%", "640%", "650%", "660%", "670%", "680%", "690%", "700%", "710%", "720%", "730%", "740%", "750%"),
+ valueLabels = listOf("50%", "60%", "70%", "80%", "90%", "100% (Native)", "110%", "120%", "130%", "140%", "150%", "160%", "170%", "180%", "190%", "200%", "210%", "220%", "230%", "240%", "250%", "260%", "270%", "280%", "290%", "300%", "310%", "320%", "330%", "340%", "350%", "360%", "370%", "380%", "390%", "400%", "410%", "420%", "430%", "440%", "450%", "460%", "470%", "480%", "490%", "500%", "510%", "520%", "530%", "540%", "550%", "560%", "570%", "580%", "590%", "600%", "610%", "620%", "630%", "640%", "650%", "660%", "670%", "680%", "690%", "700%", "710%", "720%", "730%", "740%", "750%"),
+ defaultValue = "100%",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_display_internal_fps",
+ label = "Display Internal FPS",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_display_osd",
+ label = "Display OSD Messages",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_display_vram",
+ label = "Display Full VRAM (Debug)",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_enable_memcard1",
+ label = "Memory Card Slot 2",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_gpu_overclock",
+ label = "GPU Rasterizer Overclock",
+ values = listOf("1x(native)", "2x", "4x", "8x", "16x", "32x"),
+ valueLabels = listOf("1x (Native)", "2x", "4x", "8x", "16x", "32x"),
+ defaultValue = "1x(native)",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_gte_overclock",
+ label = "GTE Overclock",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_memcard_left_index",
+ label = "Memory Card Slot 1 Index",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63"),
+ valueLabels = listOf("0 (Default)", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63"),
+ defaultValue = "0",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_memcard_right_index",
+ label = "Memory Card Slot 2 Index",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63"),
+ valueLabels = listOf("0", "1 (Default)", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63"),
+ defaultValue = "1",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_override_bios",
+ label = "Override BIOS",
+ values = listOf("disabled", "psxonpsp", "ps1_rom", "openbios"),
+ valueLabels = listOf("disabled", "PSP PS1 BIOS", "PS3 PS1 BIOS", "OpenBIOS"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_region",
+ label = "System Region",
+ values = listOf("auto", "ntsc-j", "ntsc-u", "pal"),
+ valueLabels = listOf("Auto", "NTSC-J (Japan)", "NTSC-U (North America)", "PAL (Europe)"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_shared_memory_cards",
+ label = "Shared Memory Cards",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_skip_bios",
+ label = "Skip BIOS",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "beetle_psx_use_mednafen_memcard0_method",
+ label = "Memory Card Method",
+ values = listOf("libretro", "mednafen"),
+ valueLabels = listOf("Libretro", "Mednafen"),
+ defaultValue = "libretro",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ )
+
+ val MUPEN64PLUS_NEXT: List = listOf(
+ RetroCoreOption(
+ key = "mupen64plus-alt-map",
+ label = "Independent C-button Controls",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-astick-deadzone",
+ label = "Analog Deadzone (percent)",
+ values = listOf("0", "5", "10", "15", "20", "25", "30"),
+ valueLabels = listOf("0", "5", "10", "15", "20", "25", "30"),
+ defaultValue = "15",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-astick-sensitivity",
+ label = "Analog Sensitivity (percent)",
+ values = listOf("50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100", "105", "110", "115", "120", "125", "130", "135", "140", "145", "150"),
+ valueLabels = listOf("50", "55", "60", "65", "70", "75", "80", "85", "90", "95", "100", "105", "110", "115", "120", "125", "130", "135", "140", "145", "150"),
+ defaultValue = "100",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-d-cbutton",
+ label = "Down C Button",
+ values = listOf("C1", "C2", "C3", "C4"),
+ valueLabels = listOf("C1", "C2", "C3", "C4"),
+ defaultValue = "C3",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-l-cbutton",
+ label = "Left C Button",
+ values = listOf("C1", "C2", "C3", "C4"),
+ valueLabels = listOf("C1", "C2", "C3", "C4"),
+ defaultValue = "C2",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-pak1",
+ label = "Player 1 Pak",
+ values = listOf("none", "memory", "rumble", "transfer"),
+ valueLabels = listOf("none", "memory", "rumble", "transfer"),
+ defaultValue = "memory",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-pak2",
+ label = "Player 2 Pak",
+ values = listOf("none", "memory", "rumble", "transfer"),
+ valueLabels = listOf("none", "memory", "rumble", "transfer"),
+ defaultValue = "none",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-pak3",
+ label = "Player 3 Pak",
+ values = listOf("none", "memory", "rumble", "transfer"),
+ valueLabels = listOf("none", "memory", "rumble", "transfer"),
+ defaultValue = "none",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-pak4",
+ label = "Player 4 Pak",
+ values = listOf("none", "memory", "rumble", "transfer"),
+ valueLabels = listOf("none", "memory", "rumble", "transfer"),
+ defaultValue = "none",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-r-cbutton",
+ label = "Right C Button",
+ values = listOf("C1", "C2", "C3", "C4"),
+ valueLabels = listOf("C1", "C2", "C3", "C4"),
+ defaultValue = "C1",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-u-cbutton",
+ label = "Up C Button",
+ values = listOf("C1", "C2", "C3", "C4"),
+ valueLabels = listOf("C1", "C2", "C3", "C4"),
+ defaultValue = "C4",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-169screensize",
+ label = "Wide Resolution",
+ values = listOf("640x360", "960x540", "1280x720", "1706x720", "1366x768", "1920x810", "1920x1080", "2560x1080", "2560x1440", "3414x1440", "3840x2160", "4096x2160", "5120x2160", "7680x3240", "7680x4320", "10240x4320"),
+ valueLabels = listOf("640x360 (16:9)", "960x540 (16:9)", "1280x720 (16:9)", "1706x720 (64:27)", "1366x768 (16:9)", "1920x810 (64:27)", "1920x1080 (16:9)", "2560x1080 (64:27)", "2560x1440 (16:9)", "3414x1440 (64:27)", "3840x2160 (16:9)", "4096x2160 (17:9)", "5120x2160 (64:27)", "7680x3240 (64:27)", "7680x4320 (16:9)", "10240x4320 (64:27)"),
+ defaultValue = "960x540",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-43screensize",
+ label = "4:3 Resolution",
+ values = listOf("320x240", "640x480", "960x720", "1280x960", "1440x1080", "1600x1200", "1920x1440", "2240x1680", "2560x1920", "2880x2160", "3200x2400", "3520x2640", "3840x2880"),
+ valueLabels = listOf("320x240", "640x480", "960x720", "1280x960", "1440x1080", "1600x1200", "1920x1440", "2240x1680", "2560x1920", "2880x2160", "3200x2400", "3520x2640", "3840x2880"),
+ defaultValue = "640x480",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ labelRes = R.string.retro_co_resolution,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-BackgroundMode",
+ label = "Background Mode",
+ values = listOf("Stripped", "OnePiece"),
+ valueLabels = listOf("Stripped", "OnePiece"),
+ defaultValue = "OnePiece",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-BilinearMode",
+ label = "Bilinear filtering mode",
+ values = listOf("3point", "standard"),
+ valueLabels = listOf("3point", "standard"),
+ defaultValue = "standard",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-CorrectTexrectCoords",
+ label = "Continuous texrect coords",
+ values = listOf("Off", "Auto", "Force"),
+ valueLabels = listOf("Off", "Auto", "Force"),
+ defaultValue = "Off",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-DitheringPattern",
+ label = "Dithering",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-DitheringQuantization",
+ label = "Dithering Quantization",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableCopyAuxToRDRAM",
+ label = "Copy auxiliary buffers to RDRAM",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableCopyColorFromRDRAM",
+ label = "Enable color buffer copy from RDRAM",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableCopyColorToRDRAM",
+ label = "Color buffer to RDRAM",
+ values = listOf("Off", "Sync", "Async", "TripleBuffer"),
+ valueLabels = listOf("Off", "Sync", "DoubleBuffer", "TripleBuffer"),
+ defaultValue = "Async",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableCopyDepthToRDRAM",
+ label = "Depth buffer to RDRAM",
+ values = listOf("Off", "Software", "FromMem"),
+ valueLabels = listOf("Off", "Software", "FromMem"),
+ defaultValue = "Software",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableEnhancedHighResStorage",
+ label = "Use enhanced Hi-Res Storage",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableEnhancedTextureStorage",
+ label = "Use enhanced Texture Storage",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableFBEmulation",
+ label = "Framebuffer Emulation",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ labelRes = R.string.retro_co_framebuffer_emulation,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableFragmentDepthWrite",
+ label = "GPU shader depth write",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableHWLighting",
+ label = "Hardware per-pixel lighting",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableHiResAltCRC",
+ label = "Use alternative method for High-Res Checksums",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableInaccurateTextureCoordinates",
+ label = "Enable inaccurate texture coordinates",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableLODEmulation",
+ label = "LOD Emulation",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableLegacyBlending",
+ label = "Less accurate blending mode",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableN64DepthCompare",
+ label = "N64 Depth Compare",
+ values = listOf("False", "True", "Compatible"),
+ valueLabels = listOf("Off", "Fast", "Compatible"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableNativeResFactor",
+ label = "Native Resolution Factor",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8"),
+ valueLabels = listOf("Disabled", "1x", "2x", "3x", "4x", "5x", "6x", "7x", "8x"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableNativeResTexrects",
+ label = "Native res. 2D texrects",
+ values = listOf("Disabled", "Unoptimized", "Optimized"),
+ valueLabels = listOf("Disabled", "Unoptimized", "Optimized"),
+ defaultValue = "Disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableOverscan",
+ label = "Overscan",
+ values = listOf("Disabled", "Enabled"),
+ valueLabels = listOf("Disabled", "Enabled"),
+ defaultValue = "Enabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableShadersStorage",
+ label = "Cache GPU Shaders",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableTexCoordBounds",
+ label = "Enable native-res boundaries for texture coordinates",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-EnableTextureCache",
+ label = "Cache Textures",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-FXAA",
+ label = "FXAA",
+ values = listOf("0", "1"),
+ valueLabels = listOf("0", "1"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-GLideN64IniBehaviour",
+ label = "INI Behaviour",
+ values = listOf("late", "early", "disabled"),
+ valueLabels = listOf("Prioritize INI over Core Options", "Prioritize Core Options over INI", "Disable INI"),
+ defaultValue = "late",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-HybridFilter",
+ label = "Hybrid Filter",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-MaxHiResTxVramLimit",
+ label = "Max High-Res VRAM Limit",
+ values = listOf("0", "500", "1000", "1500", "2000", "2500", "3000", "3500", "4000"),
+ valueLabels = listOf("0", "500", "1000", "1500", "2000", "2500", "3000", "3500", "4000"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-MaxTxCacheSize",
+ label = "Max texture cache size",
+ values = listOf("1500", "4000", "8000"),
+ valueLabels = listOf("1500", "4000", "8000"),
+ defaultValue = "1500",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-MultiSampling",
+ label = "MSAA level",
+ values = listOf("0", "2", "4", "8", "16"),
+ valueLabels = listOf("0", "2", "4", "8", "16"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-OverscanBottom",
+ label = "Overscan Offset (Bottom)",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-OverscanLeft",
+ label = "Overscan Offset (Left)",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-OverscanRight",
+ label = "Overscan Offset (Right)",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-OverscanTop",
+ label = "Overscan Offset (Top)",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-RDRAMImageDitheringMode",
+ label = "Image Dithering Mode",
+ values = listOf("False", "Bayer", "MagicSquare", "BlueNoise"),
+ valueLabels = listOf("Disabled", "Bayer", "Magic Square", "Blue Noise"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-ThreadedRenderer",
+ label = "Threaded Renderer",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-angrylion-multithread",
+ label = "Multi-threading",
+ values = listOf("all threads", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "75"),
+ valueLabels = listOf("all threads", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "75"),
+ defaultValue = "all threads",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "angrylion",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-angrylion-overscan",
+ label = "Hide overscan",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "angrylion",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-angrylion-sync",
+ label = "Thread sync level",
+ values = listOf("Low", "Medium", "High"),
+ valueLabels = listOf("Low", "Medium", "High"),
+ defaultValue = "Low",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "angrylion",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-angrylion-vioverlay",
+ label = "VI Overlay",
+ values = listOf("Filtered", "AA+Blur", "AA+Dedither", "AA only", "Unfiltered", "Depth", "Coverage"),
+ valueLabels = listOf("Filtered", "AA+Blur", "AA+Dedither", "AA only", "Unfiltered", "Depth", "Coverage"),
+ defaultValue = "Filtered",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "angrylion",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-aspect",
+ label = "Aspect Ratio",
+ values = listOf("4:3", "16:9", "16:9 adjusted"),
+ valueLabels = listOf("Original (4:3)", "Wide (Stretched)", "Wide (Adjusted)"),
+ defaultValue = "4:3",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ labelRes = R.string.retro_co_aspect_ratio,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-deinterlace-method",
+ label = "(ParaLLEl-RDP) Deinterlacing method",
+ values = listOf("Bob", "Weave"),
+ valueLabels = listOf("Bob", "Weave"),
+ defaultValue = "Bob",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-dither-filter",
+ label = "(ParaLLEl-RDP) VI dither filter",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-divot-filter",
+ label = "(ParaLLEl-RDP) VI Divot filter",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-downscaling",
+ label = "(ParaLLEl-RDP) Downsampling factor",
+ values = listOf("disable", "1/2", "1/4", "1/8"),
+ valueLabels = listOf("disable", "1/2", "1/4", "1/8"),
+ defaultValue = "disable",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-gamma-dither",
+ label = "(ParaLLEl-RDP) VI Gamma dither",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-native-tex-rect",
+ label = "(ParaLLEl-RDP) Native resolution TEX_RECT",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-native-texture-lod",
+ label = "(ParaLLEl-RDP) Native texture LOD",
+ values = listOf("False", "True"),
+ valueLabels = listOf("Disabled", "Enabled"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-overscan",
+ label = "(ParaLLEl-RDP) Crop overscan",
+ values = listOf("0", "2", "4", "6", "8", "10", "12", "14", "16", "18", "20", "22", "24", "26", "28", "30", "32", "34", "36", "38", "40", "42", "44", "46", "48", "50", "52", "54", "56", "58", "60", "62", "64"),
+ valueLabels = listOf("0", "2", "4", "6", "8", "10", "12", "14", "16", "18", "20", "22", "24", "26", "28", "30", "32", "34", "36", "38", "40", "42", "44", "46", "48", "50", "52", "54", "56", "58", "60", "62", "64"),
+ defaultValue = "0",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-super-sampled-read-back",
+ label = "(ParaLLEl-RDP) SSAA framebuffer effects (restart)",
+ values = listOf("False", "True"),
+ valueLabels = listOf("Disabled", "Enabled"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-super-sampled-read-back-dither",
+ label = "(ParaLLEl-RDP) Dither SSAA framebuffer effects (restart)",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-synchronous",
+ label = "(ParaLLEl-RDP) Synchronous RDP",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-upscaling",
+ label = "(ParaLLEl-RDP) Upscaling factor (restart)",
+ values = listOf("1x", "2x", "4x", "8x"),
+ valueLabels = listOf("1x", "2x", "4x", "8x"),
+ defaultValue = "1x",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-vi-aa",
+ label = "(ParaLLEl-RDP) VI anti-aliasing",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-parallel-rdp-vi-bilinear",
+ label = "(ParaLLEl-RDP) VI bilinear",
+ values = listOf("True", "False"),
+ valueLabels = listOf("Enabled", "Disabled"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "parallel",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-rdp-plugin",
+ label = "RDP Plugin",
+ values = listOf("angrylion", "parallel", "gliden64"),
+ valueLabels = listOf("Angrylion", "ParaLLEl-RDP", "GLideN64"),
+ defaultValue = "gliden64",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-txCacheCompression",
+ label = "Use High-Res Texture Cache Compression",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-txEnhancementMode",
+ label = "Texture Enhancement",
+ values = listOf("None", "As Is", "X2", "X2SAI", "HQ2X", "HQ2XS", "LQ2X", "LQ2XS", "HQ4X", "2xBRZ", "3xBRZ", "4xBRZ", "5xBRZ", "6xBRZ"),
+ valueLabels = listOf("None", "As Is", "X2", "X2SAI", "HQ2X", "HQ2XS", "LQ2X", "LQ2XS", "HQ4X", "2xBRZ", "3xBRZ", "4xBRZ", "5xBRZ", "6xBRZ"),
+ defaultValue = "None",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-txFilterIgnoreBG",
+ label = "Don't filter background textures",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "True",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-txFilterMode",
+ label = "Texture filter",
+ values = listOf("None", "Smooth filtering 1", "Smooth filtering 2", "Smooth filtering 3", "Smooth filtering 4", "Sharp filtering 1", "Sharp filtering 2"),
+ valueLabels = listOf("None", "Smooth filtering 1", "Smooth filtering 2", "Smooth filtering 3", "Smooth filtering 4", "Sharp filtering 1", "Sharp filtering 2"),
+ defaultValue = "None",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-txHiresEnable",
+ label = "Use High-Res textures",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-txHiresFullAlphaChannel",
+ label = "Use High-Res Full Alpha Channel",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = true,
+ visibleWhen = "mupen64plus-rdp-plugin" to "gliden64",
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-CountPerOp",
+ label = "Count Per Op",
+ values = listOf("0", "1", "2", "3", "4", "5"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5"),
+ defaultValue = "0",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-CountPerOpDenomPot",
+ label = "Count Per Op Divider (Overclock)",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"),
+ defaultValue = "0",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-FrameDuping",
+ label = "Frame Duplication",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "True",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-Framerate",
+ label = "Framerate",
+ values = listOf("Original", "Fullspeed"),
+ valueLabels = listOf("Original", "Fullspeed"),
+ defaultValue = "Original",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-IgnoreTLBExceptions",
+ label = "Ignore emulated TLB Exceptions",
+ values = listOf("False", "OnlyNotEnabled", "AlwaysIgnoreTLB"),
+ valueLabels = listOf("Don't Ignore", "Ignore TLB Exceptions if not using TLB", "Always Ignore TLB Exceptions"),
+ defaultValue = "False",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = true,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-cpucore",
+ label = "CPU Core",
+ values = listOf("pure_interpreter", "cached_interpreter", "dynamic_recompiler"),
+ valueLabels = listOf("Pure Interpreter", "Cached Interpreter", "Dynarec"),
+ defaultValue = "dynamic_recompiler",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-rsp-plugin",
+ label = "RSP Plugin",
+ values = listOf("cxd4", "parallel", "hle"),
+ valueLabels = listOf("CXD4", "ParaLLEl", "HLE"),
+ defaultValue = "hle",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-virefresh",
+ label = "VI Refresh (Overclock)",
+ values = listOf("Auto", "1500", "2200"),
+ valueLabels = listOf("Auto", "1500", "2200"),
+ defaultValue = "Auto",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "mupen64plus-ForceDisableExtraMem",
+ label = "Disable Expansion Pak",
+ values = listOf("False", "True"),
+ valueLabels = listOf("False", "True"),
+ defaultValue = "False",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = true,
+ ),
+ )
+
+ val GPSP: List = listOf(
+ RetroCoreOption(
+ key = "gpsp_rumble",
+ label = "Rumble support",
+ values = listOf("auto", "enabled", "disabled"),
+ valueLabels = listOf("auto", "enabled", "disabled"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_turbo_period",
+ label = "Turbo Button Period",
+ values = listOf("4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "100", "101", "102", "103", "104", "105", "106", "107", "108", "109", "110", "111", "112", "113", "114", "115", "116", "117", "118", "119", "120"),
+ valueLabels = listOf("4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "100", "101", "102", "103", "104", "105", "106", "107", "108", "109", "110", "111", "112", "113", "114", "115", "116", "117", "118", "119", "120"),
+ defaultValue = "4",
+ category = RetroOptionCategory.CONTROLS,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_color_correction",
+ label = "Color Correction",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_frameskip",
+ label = "Frameskip",
+ values = listOf("disabled", "auto", "auto_threshold", "fixed_interval"),
+ valueLabels = listOf("disabled", "Auto", "Auto (Threshold)", "Fixed Interval"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_frameskip_interval",
+ label = "Frameskip Interval",
+ values = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
+ valueLabels = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"),
+ defaultValue = "1",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_frameskip_threshold",
+ label = "Frameskip Threshold (%)",
+ values = listOf("15", "18", "21", "24", "27", "30", "33", "36", "39", "42", "45", "48", "51", "54", "57", "60"),
+ valueLabels = listOf("15", "18", "21", "24", "27", "30", "33", "36", "39", "42", "45", "48", "51", "54", "57", "60"),
+ defaultValue = "33",
+ category = RetroOptionCategory.PERFORMANCE,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_bios",
+ label = "BIOS",
+ values = listOf("auto", "builtin", "official"),
+ valueLabels = listOf("Auto select", "Builtin BIOS", "Original BIOS"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_boot_mode",
+ label = "Boot mode",
+ values = listOf("game", "bios"),
+ valueLabels = listOf("Boot to game", "Boot to BIOS"),
+ defaultValue = "game",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_drc",
+ label = "Dynamic Recompiler",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_frame_mixing",
+ label = "Interframe Blending",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("enabled", "disabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_rtc",
+ label = "RTC support",
+ values = listOf("auto", "enabled", "disabled"),
+ valueLabels = listOf("auto", "enabled", "disabled"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_serial",
+ label = "Link Cable Connectivity",
+ values = listOf("auto", "disabled", "rfu", "mul_poke", "mul_aw1", "mul_aw2"),
+ valueLabels = listOf("Automatic", "Disabled", "GBA Wireless Adapter", "Link Cable - Pokemon Gen3 mode", "Link Cable - Advance Wars 1 mode", "Link Cable - Advance Wars 2 mode"),
+ defaultValue = "auto",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_sound_rate",
+ label = "Sound Output Rate (Hz)",
+ values = listOf("65536", "32768"),
+ valueLabels = listOf("65536", "32768"),
+ defaultValue = "65536",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ RetroCoreOption(
+ key = "gpsp_sprlim",
+ label = "No Sprite Limit",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("disabled", "enabled"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ advanced = false,
+ ),
+ )
+
+}
diff --git a/app/src/main/feature/retro/RetroCoreManager.kt b/app/src/main/feature/retro/RetroCoreManager.kt
new file mode 100644
index 000000000..0480d6b84
--- /dev/null
+++ b/app/src/main/feature/retro/RetroCoreManager.kt
@@ -0,0 +1,174 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import java.io.File
+
+object RetroCoreManager {
+ private fun resolveCore(
+ context: Context,
+ fileName: String,
+ ): File =
+ if (fileName == RetroSystems.DOLPHIN_LIBRETRO_CORE) {
+ File(context.applicationInfo.nativeLibraryDir, fileName)
+ } else {
+ File(RetroBundle.coresDir(context), fileName)
+ }
+
+ fun coreFile(
+ context: Context,
+ system: RetroSystem,
+ ): File = resolveCore(context, system.coreFileName)
+
+ fun multiplayerCoreFile(
+ context: Context,
+ system: RetroSystem,
+ ): File =
+ if (system.id == RetroSystems.GBA.id) {
+ resolveCore(context, RetroSystems.GBA_MULTIPLAYER_CORE)
+ } else {
+ coreFile(context, system)
+ }
+
+ fun isCoreAvailable(
+ context: Context,
+ system: RetroSystem,
+ ): Boolean = coreFile(context, system).isFile
+
+ fun isMultiplayerCoreAvailable(
+ context: Context,
+ system: RetroSystem,
+ ): Boolean = multiplayerCoreFile(context, system).isFile
+
+ fun systemDir(context: Context): File = File(context.filesDir, "retro/system").also { it.mkdirs() }
+
+ fun savesDir(context: Context): File = File(context.filesDir, "retro/saves").also { it.mkdirs() }
+
+ fun statesDir(context: Context): File = File(context.filesDir, "retro/states").also { it.mkdirs() }
+
+ fun stateFile(
+ context: Context,
+ gameName: String,
+ slot: Int,
+ ): File {
+ val safe = gameName.replace(Regex("[^A-Za-z0-9._-]"), "_")
+ val suffix = if (slot <= 0) "state" else "state$slot"
+ return File(statesDir(context), "$safe.$suffix")
+ }
+
+ fun ensureGlideN64Ini(context: Context) {
+ val target = File(File(systemDir(context), "Mupen64plus").also { it.mkdirs() }, "GLideN64.custom.ini")
+ runCatching {
+ context.assets.open("retro/GLideN64.custom.ini").use { input ->
+ val bytes = input.readBytes()
+ if (!target.isFile || target.length() != bytes.size.toLong()) {
+ target.writeBytes(bytes)
+ }
+ }
+ }
+ }
+
+ fun ensureDolphinSys(context: Context) {
+ val marker = File(systemDir(context), "dolphin-emu/.wn_sys_ready")
+ if (marker.isFile) return
+ val destRoot = File(systemDir(context), "dolphin-emu").also { it.mkdirs() }
+ runCatching {
+ val fromBundle = File(RetroBundle.dataDir(context), "dolphin-emu")
+ if (!fromBundle.isDirectory) return
+ fromBundle.copyRecursively(destRoot, overwrite = true)
+ marker.parentFile?.mkdirs()
+ marker.writeText("1")
+ }
+ }
+
+ fun ensureDolphinUser(context: Context) {
+ val userRoot = File(savesDir(context), "User")
+ val gcRoot = File(userRoot, "GC").also { it.mkdirs() }
+ for (region in listOf("USA", "EUR", "JAP")) {
+ File(gcRoot, "$region/Card A").mkdirs()
+ File(gcRoot, "$region/Card B").mkdirs()
+ }
+ File(userRoot, "Config").mkdirs()
+ File(userRoot, "Wii").mkdirs()
+ File(userRoot, "GBA/Saves").mkdirs()
+ ensureDolphinIni(File(userRoot, "Config/Dolphin.ini"))
+ }
+
+ private fun ensureDolphinIni(ini: File) {
+ runCatching {
+ val existing = if (ini.isFile) ini.readText() else ""
+ val desired =
+ mapOf(
+ "SlotA" to "8",
+ "SlotB" to "255",
+ "MemcardAPath" to "",
+ "MemcardBPath" to "",
+ "GCIFolderAPath" to "",
+ "GCIFolderBPath" to "",
+ "MemoryCardSize" to "-1",
+ "CPUThread" to "False",
+ "Fastmem" to "False",
+ "FastmemArena" to "False",
+ )
+ val merged = mergeIniSection(existing, "Core", desired)
+ if (merged != existing) {
+ ini.parentFile?.mkdirs()
+ ini.writeText(merged)
+ }
+ }
+ }
+
+ private fun mergeIniSection(
+ source: String,
+ section: String,
+ keys: Map,
+ ): String {
+ if (keys.isEmpty()) return source
+ val lines = source.replace("\r\n", "\n").split('\n').toMutableList()
+ val header = "[$section]"
+ var sectionStart = lines.indexOfFirst { it.trim().equals(header, ignoreCase = true) }
+ if (sectionStart < 0) {
+ val builder = StringBuilder(source.trimEnd())
+ if (builder.isNotEmpty()) builder.append("\n\n")
+ builder.append(header).append('\n')
+ for ((k, v) in keys) builder.append(k).append(" = ").append(v).append('\n')
+ return builder.toString()
+ }
+ var sectionEnd = lines.size
+ for (i in (sectionStart + 1) until lines.size) {
+ val t = lines[i].trim()
+ if (t.startsWith("[") && t.endsWith("]")) {
+ sectionEnd = i
+ break
+ }
+ }
+ val remaining = keys.toMutableMap()
+ for (i in (sectionStart + 1) until sectionEnd) {
+ val raw = lines[i]
+ val trimmed = raw.trim()
+ if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith(";")) continue
+ val eq = trimmed.indexOf('=')
+ if (eq <= 0) continue
+ val key = trimmed.substring(0, eq).trim()
+ val wanted = remaining.remove(key) ?: continue
+ lines[i] = "$key = $wanted"
+ }
+ if (remaining.isNotEmpty()) {
+ val insertAt = sectionEnd
+ val block = remaining.map { (k, v) -> "$k = $v" }
+ lines.addAll(insertAt, block)
+ }
+ return lines.joinToString("\n").let { if (it.endsWith("\n")) it else "$it\n" }
+ }
+
+ fun usesDolphinCore(system: RetroSystem?): Boolean =
+ system?.id == RetroSystems.GAMECUBE.id || system?.id == RetroSystems.WII.id
+
+ fun missingBios(
+ context: Context,
+ system: RetroSystem,
+ ): Boolean {
+ if (!system.needsBios) return false
+ val dir = systemDir(context)
+ return system.biosFiles.none { File(dir, it).isFile }
+ }
+}
diff --git a/app/src/main/feature/retro/RetroCoreOptions.kt b/app/src/main/feature/retro/RetroCoreOptions.kt
new file mode 100644
index 000000000..75c5fd69a
--- /dev/null
+++ b/app/src/main/feature/retro/RetroCoreOptions.kt
@@ -0,0 +1,284 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import com.winlator.cmod.R
+
+enum class RetroOptionCategory {
+ DISPLAY,
+ SOUND,
+ PERFORMANCE,
+ CONTROLS,
+ SYSTEM,
+}
+
+data class RetroCoreOption(
+ val key: String,
+ val label: String,
+ val values: List,
+ val valueLabels: List,
+ val defaultValue: String,
+ val category: RetroOptionCategory = RetroOptionCategory.SYSTEM,
+ val advanced: Boolean = false,
+ @androidx.annotation.StringRes val labelRes: Int? = null,
+ val visibleWhen: Pair? = null,
+) {
+ fun labelText(context: Context): String = labelRes?.let { context.getString(it) } ?: label
+
+ fun isApplicable(current: (String) -> String?): Boolean {
+ val (key, value) = visibleWhen ?: return true
+ return (current(key) ?: return true) == value
+ }
+}
+
+object RetroCoreOptions {
+ private val DOLPHIN_COMMON_OPTIONS =
+ listOf(
+ RetroCoreOption(
+ key = "dolphin_renderer",
+ label = "Renderer",
+ values = listOf("Hardware", "Software"),
+ valueLabels = listOf("Hardware", "Software"),
+ defaultValue = "Hardware",
+ category = RetroOptionCategory.DISPLAY,
+ labelRes = R.string.retro_co_renderer,
+ ),
+ RetroCoreOption(
+ key = "dolphin_efb_scale",
+ label = "Internal Resolution",
+ values = listOf("1", "2", "3", "4", "5", "6"),
+ valueLabels = listOf("1x", "2x", "3x", "4x", "5x", "6x"),
+ defaultValue = "1",
+ category = RetroOptionCategory.DISPLAY,
+ labelRes = R.string.retro_co_internal_resolution,
+ ),
+ )
+
+ private fun dolphinAspectOption(defaultValue: String) =
+ RetroCoreOption(
+ key = "dolphin_aspect_ratio",
+ label = "Aspect Ratio",
+ values = listOf("0", "1", "2", "3"),
+ valueLabels = listOf("Auto", "16:9", "4:3", "Stretch"),
+ defaultValue = defaultValue,
+ category = RetroOptionCategory.DISPLAY,
+ labelRes = R.string.retro_co_aspect_ratio,
+ )
+
+ private val DOLPHIN_CPU_OPTIONS =
+ listOf(
+ RetroCoreOption(
+ key = "dolphin_cpu_core",
+ label = "CPU Core",
+ values = listOf("4", "5", "0"),
+ valueLabels = listOf("JIT", "Cached Interpreter", "Interpreter"),
+ defaultValue = "4",
+ category = RetroOptionCategory.PERFORMANCE,
+ labelRes = R.string.retro_co_cpu_core,
+ ),
+ RetroCoreOption(
+ key = "dolphin_main_cpu_thread",
+ label = "Dual Core",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("Off", "On"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ labelRes = R.string.retro_co_dual_core,
+ ),
+ )
+
+ private val DOLPHIN_CHEATS_OPTION =
+ RetroCoreOption(
+ key = "dolphin_cheats_enabled",
+ label = "Internal Cheats",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("Off", "On"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.SYSTEM,
+ labelRes = R.string.retro_co_internal_cheats,
+ )
+
+ private val DOLPHIN_VI_SKIP_OPTION =
+ RetroCoreOption(
+ key = "dolphin_vi_skip",
+ label = "VBI Skip",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("On", "Off"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.PERFORMANCE,
+ labelRes = R.string.retro_co_vbi_skip,
+ )
+
+ private val GAMECUBE_OPTIONS =
+ DOLPHIN_COMMON_OPTIONS +
+ dolphinAspectOption("2") +
+ listOf(
+ RetroCoreOption(
+ key = "dolphin_widescreen_hack",
+ label = "Widescreen Hack",
+ values = listOf("disabled", "enabled"),
+ valueLabels = listOf("Off", "On (16:9)"),
+ defaultValue = "disabled",
+ category = RetroOptionCategory.DISPLAY,
+ labelRes = R.string.retro_co_widescreen_hack,
+ ),
+ ) +
+ DOLPHIN_CPU_OPTIONS +
+ listOf(
+ RetroCoreOption(
+ key = "dolphin_skip_gc_bios",
+ label = "Skip GameCube BIOS",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("On", "Off"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.SYSTEM,
+ labelRes = R.string.retro_co_skip_gc_bios,
+ ),
+ DOLPHIN_VI_SKIP_OPTION,
+ DOLPHIN_CHEATS_OPTION,
+ )
+
+ private val WII_OPTIONS =
+ DOLPHIN_COMMON_OPTIONS +
+ dolphinAspectOption("1") +
+ listOf(
+ RetroCoreOption(
+ key = "dolphin_widescreen",
+ label = "Widescreen",
+ values = listOf("enabled", "disabled"),
+ valueLabels = listOf("On", "Off"),
+ defaultValue = "enabled",
+ category = RetroOptionCategory.DISPLAY,
+ labelRes = R.string.retro_co_widescreen,
+ ),
+ ) +
+ DOLPHIN_CPU_OPTIONS +
+ listOf(
+ RetroCoreOption(
+ key = "dolphin_sensor_bar_position",
+ label = "Sensor Bar Position",
+ values = listOf("0", "1"),
+ valueLabels = listOf("Bottom", "Top"),
+ defaultValue = "0",
+ category = RetroOptionCategory.CONTROLS,
+ labelRes = R.string.retro_co_sensor_bar,
+ ),
+ DOLPHIN_VI_SKIP_OPTION,
+ DOLPHIN_CHEATS_OPTION,
+ )
+
+ fun sanitizeDolphinVariables(vars: MutableMap) {
+ when (vars["dolphin_cpu_core"]?.trim()) {
+ "JITARM64", "JIT", "JIT64" -> vars["dolphin_cpu_core"] = "4"
+ "Cached Interpreter", "CachedInterpreter" -> vars["dolphin_cpu_core"] = "5"
+ "Interpreter" -> vars["dolphin_cpu_core"] = "0"
+ }
+ vars["dolphin_efb_scale"]?.let { raw ->
+ val t = raw.trim()
+ if (t.length == 1 && t[0].isDigit()) return@let
+ val digit =
+ Regex("""^x?(\d+)""", RegexOption.IGNORE_CASE).find(t)?.groupValues?.getOrNull(1)
+ if (digit != null) vars["dolphin_efb_scale"] = digit
+ }
+ DOLPHIN_PERF_DEFAULTS.forEach { (k, v) ->
+ if (vars[k].isNullOrBlank()) vars[k] = v
+ }
+ if (vars["dolphin_skip_dupe_frames"] == "enabled") {
+ vars["dolphin_skip_dupe_frames"] = "disabled"
+ }
+ }
+
+ private val DOLPHIN_PERF_DEFAULTS: Map =
+ mapOf(
+ "dolphin_renderer" to "Hardware",
+ "dolphin_efb_scale" to "1",
+ "dolphin_cpu_core" to "4",
+ "dolphin_main_cpu_thread" to "disabled",
+ "dolphin_dsp_hle" to "enabled",
+ "dolphin_fast_disc_speed" to "enabled",
+ "dolphin_vi_skip" to "disabled",
+ "dolphin_skip_dupe_frames" to "disabled",
+ "dolphin_cheats_enabled" to "disabled",
+ )
+
+ fun defaultVariables(system: RetroSystem?): Map =
+ when (system?.id) {
+ RetroSystems.N64.id ->
+ mapOf(
+ "mupen64plus-43screensize" to "640x480",
+ "mupen64plus-EnableFBEmulation" to "True",
+ "mupen64plus-aspect" to "4:3",
+ )
+ RetroSystems.PSX.id ->
+ mapOf(
+ "beetle_psx_skip_bios" to "enabled",
+ )
+ RetroSystems.GAMECUBE.id ->
+ DOLPHIN_PERF_DEFAULTS +
+ mapOf(
+ "dolphin_widescreen" to "disabled",
+ "dolphin_widescreen_hack" to "disabled",
+ "dolphin_aspect_ratio" to "2",
+ "dolphin_skip_gc_bios" to "enabled",
+ )
+ RetroSystems.WII.id ->
+ DOLPHIN_PERF_DEFAULTS +
+ mapOf(
+ "dolphin_widescreen" to "enabled",
+ "dolphin_aspect_ratio" to "1",
+ "dolphin_sensor_bar_position" to "0",
+ )
+ else -> emptyMap()
+ }
+
+ fun forSystem(system: RetroSystem?): List =
+ when (system?.id) {
+ RetroSystems.NES.id -> RetroCoreCatalog.FCEUMM
+ RetroSystems.SNES.id -> RetroCoreCatalog.SNES9X
+ RetroSystems.GAMEBOY.id, RetroSystems.GAMEBOY_COLOR.id -> RetroCoreCatalog.GAMBATTE
+ RetroSystems.GBA.id -> RetroCoreCatalog.MGBA
+ RetroSystems.GENESIS.id -> forGenesisFamily(RetroSystems.GENESIS.id)
+ RetroSystems.MASTER_SYSTEM.id -> forGenesisFamily(RetroSystems.MASTER_SYSTEM.id)
+ RetroSystems.GAME_GEAR.id -> forGenesisFamily(RetroSystems.GAME_GEAR.id)
+ RetroSystems.N64.id -> RetroCoreCatalog.MUPEN64PLUS_NEXT
+ RetroSystems.PSX.id -> RetroCoreCatalog.BEETLE_PSX
+ RetroSystems.GAMECUBE.id -> GAMECUBE_OPTIONS
+ RetroSystems.WII.id -> WII_OPTIONS
+ else -> emptyList()
+ }
+
+ private fun forGenesisFamily(systemId: String): List {
+ val promote =
+ when (systemId) {
+ RetroSystems.MASTER_SYSTEM.id ->
+ setOf(
+ "genesis_plus_gx_ym2413",
+ "genesis_plus_gx_ym2413_core",
+ "genesis_plus_gx_left_border",
+ )
+ RetroSystems.GAME_GEAR.id ->
+ setOf(
+ "genesis_plus_gx_gg_extra",
+ "genesis_plus_gx_lcd_filter",
+ )
+ else -> emptySet()
+ }
+ val demote =
+ when (systemId) {
+ RetroSystems.GENESIS.id ->
+ setOf(
+ "genesis_plus_gx_ym2413",
+ "genesis_plus_gx_ym2413_core",
+ "genesis_plus_gx_left_border",
+ "genesis_plus_gx_gg_extra",
+ )
+ else -> emptySet()
+ }
+ return RetroCoreCatalog.GENESIS_PLUS_GX.map { option ->
+ when (option.key) {
+ in promote -> option.copy(advanced = false)
+ in demote -> option.copy(advanced = true)
+ else -> option
+ }
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/RetroCreditsScreen.kt b/app/src/main/feature/retro/RetroCreditsScreen.kt
new file mode 100644
index 000000000..d9ab0aeeb
--- /dev/null
+++ b/app/src/main/feature/retro/RetroCreditsScreen.kt
@@ -0,0 +1,130 @@
+package com.winlator.cmod.feature.retro
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.winlator.cmod.R
+import com.winlator.cmod.feature.settings.SettingsNavBridge
+import com.winlator.cmod.shared.ui.focus.rememberSettingsContentNav
+import com.winlator.cmod.shared.ui.nav.LocalPaneNav
+import com.winlator.cmod.shared.ui.nav.paneNavItem
+
+private val CreditsBg = Color(0xFF101018)
+private val CreditsText = Color(0xFFF0F4FF)
+private val CreditsSub = Color(0xFF93A6BC)
+
+@Composable
+fun RetroCreditsScreen(bridge: SettingsNavBridge? = null) {
+ val context = LocalContext.current
+ val contentNav = rememberSettingsContentNav(bridge)
+
+ fun open(url: String) {
+ runCatching {
+ context.startActivity(
+ android.content.Intent(
+ android.content.Intent.ACTION_VIEW,
+ android.net.Uri.parse(url),
+ ),
+ )
+ }
+ }
+
+ CompositionLocalProvider(LocalPaneNav provides contentNav) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(CreditsBg)
+ .verticalScroll(rememberScrollState())
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ stringResource(R.string.retro_scr_credits_licenses),
+ color = CreditsSub,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ Text(
+ stringResource(R.string.retro_scr_credits_desc),
+ color = CreditsSub,
+ style = MaterialTheme.typography.labelMedium,
+ )
+ RetroSettingGroup {
+ RETRO_CREDITS.forEach { credit ->
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable { open(credit.url) }
+ .paneNavItem(
+ cornerRadius = 8.dp,
+ onActivate = { open(credit.url) },
+ highlightColor = Color(0xFF4FC3F7),
+ tapToSelect = true,
+ )
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(credit.name, color = CreditsText, style = MaterialTheme.typography.bodyMedium)
+ Text(credit.detail, color = CreditsSub, fontSize = 11.sp)
+ }
+ Text(credit.license, color = CreditsSub, fontSize = 11.sp)
+ }
+ }
+ }
+ }
+ }
+}
+
+internal data class RetroCredit(
+ val name: String,
+ val detail: String,
+ val license: String,
+ val url: String,
+)
+
+internal val RETRO_CREDITS =
+ listOf(
+ RetroCredit("ARMSX2", "PlayStation 2", "GPL-3.0", "https://github.com/ARMSX2/ARMSX2"),
+ RetroCredit("Beetle PSX", "PlayStation", "GPL-2.0", "https://github.com/libretro/beetle-psx-libretro"),
+ RetroCredit("Dolphin", "GameCube / Wii", "GPL-2.0", "https://github.com/dolphin-emu/dolphin"),
+ RetroCredit("FCEUmm", "NES", "GPL-2.0", "https://github.com/libretro/libretro-fceumm"),
+ RetroCredit("Gambatte", "Game Boy / Color", "GPL-2.0", "https://github.com/libretro/gambatte-libretro"),
+ RetroCredit("Genesis Plus GX", "Genesis / SMS / GG", "GPX", "https://github.com/libretro/Genesis-Plus-GX"),
+ RetroCredit("LibretroDroid", "libretro frontend", "GPL-3.0", "https://github.com/Swordfish90/LibretroDroid"),
+ RetroCredit("mGBA", "Game Boy Advance", "MPL-2.0", "https://github.com/libretro/mgba"),
+ RetroCredit("ParaLLEl N64", "Nintendo 64", "GPL-2.0", "https://github.com/libretro/parallel-n64"),
+ RetroCredit("PCSX2", "PS2 upstream of ARMSX2", "GPL-3.0", "https://github.com/pcsx2/pcsx2"),
+ RetroCredit("rcheevos", "RetroAchievements", "MIT", "https://github.com/RetroAchievements/rcheevos"),
+ RetroCredit("Snapdragon GSR", "Upscaling", "BSD-3", "https://github.com/quic/snapdragon-gsr"),
+ RetroCredit("Snes9x", "SNES", "Snes9x", "https://github.com/libretro/snes9x"),
+ RetroCredit("SwanStation", "PlayStation", "GPL-3.0", "https://github.com/libretro/swanstation"),
+ RetroCredit("Winlator", "Windows-on-Android base", "GPL-3.0", "https://github.com/brunodev85/winlator"),
+ )
diff --git a/app/src/main/feature/retro/RetroDefaults.kt b/app/src/main/feature/retro/RetroDefaults.kt
new file mode 100644
index 000000000..2fc1863d9
--- /dev/null
+++ b/app/src/main/feature/retro/RetroDefaults.kt
@@ -0,0 +1,165 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+
+object RetroDefaults {
+ private fun prefs(context: Context) = PreferenceManager.getDefaultSharedPreferences(context)
+
+ private fun key(
+ setting: String,
+ systemId: String,
+ ) = "retro_def_${setting}_$systemId"
+
+ private fun varKey(
+ systemId: String,
+ optionKey: String,
+ ) = "retro_def_var_${systemId}_$optionKey"
+
+ fun romsDir(context: Context): String? =
+ prefs(context).getString("retro_roms_dir", null)?.takeIf { it.isNotBlank() }
+
+ fun setRomsDir(context: Context, value: String?) =
+ prefs(context).edit().putString("retro_roms_dir", value).apply()
+
+ fun shader(context: Context, systemId: String): String =
+ prefs(context).getString(key("shader", systemId), "default") ?: "default"
+
+ fun setShader(context: Context, systemId: String, value: String) =
+ prefs(context).edit().putString(key("shader", systemId), value).apply()
+
+ fun sgsr(context: Context, systemId: String): Boolean =
+ prefs(context).getBoolean(key("sgsr", systemId), false)
+
+ fun setSgsr(context: Context, systemId: String, value: Boolean) =
+ prefs(context).edit().putBoolean(key("sgsr", systemId), value).apply()
+
+ fun upscale(context: Context, systemId: String): String =
+ prefs(context).getString(key("upscale", systemId), "native") ?: "native"
+
+ fun setUpscale(context: Context, systemId: String, value: String) =
+ prefs(context).edit().putString(key("upscale", systemId), value).apply()
+
+ fun audio(context: Context, systemId: String): Boolean =
+ prefs(context).getBoolean(key("audio", systemId), true)
+
+ fun setAudio(context: Context, systemId: String, value: Boolean) =
+ prefs(context).edit().putBoolean(key("audio", systemId), value).apply()
+
+ fun touchControls(context: Context, systemId: String): Boolean =
+ prefs(context).getBoolean(key("touch", systemId), true)
+
+ fun setTouchControls(context: Context, systemId: String, value: Boolean) =
+ prefs(context).edit().putBoolean(key("touch", systemId), value).apply()
+
+ fun adaptiveSticks(context: Context, systemId: String): Boolean =
+ prefs(context).getBoolean(key("adaptive", systemId), false)
+
+ fun setAdaptiveSticks(context: Context, systemId: String, value: Boolean) =
+ prefs(context).edit().putBoolean(key("adaptive", systemId), value).apply()
+
+ fun hud(context: Context, systemId: String): Boolean =
+ prefs(context).getBoolean("retro_def_hud_global", false)
+
+ fun setHud(context: Context, systemId: String, value: Boolean) =
+ prefs(context).edit().putBoolean("retro_def_hud_global", value).apply()
+
+ fun netplayEnabled(context: Context, systemId: String): Boolean =
+ prefs(context).getBoolean(key("netplay", systemId), false)
+
+ fun setNetplayEnabled(context: Context, systemId: String, value: Boolean) =
+ prefs(context).edit().putBoolean(key("netplay", systemId), value).apply()
+
+ fun clearNetplayArm(
+ context: Context,
+ systemId: String,
+ ) {
+ prefs(context)
+ .edit()
+ .putBoolean(key("netplay", systemId), false)
+ .putString(key("netplay_mode", systemId), "off")
+ .putBoolean(key("netplay_host_mode", systemId), false)
+ .apply()
+ }
+
+ fun netplayHost(context: Context, systemId: String): String =
+ prefs(context).getString(key("netplay_host", systemId), "") ?: ""
+
+ fun setNetplayHost(context: Context, systemId: String, value: String) =
+ prefs(context).edit().putString(key("netplay_host", systemId), value).apply()
+
+ fun netplayPort(context: Context, systemId: String): Int {
+ val fallback =
+ when {
+ RetroOnlineSupport.supportsGameLink(systemId) -> RetroGameLink.DEFAULT_PORT
+ RetroOnlineSupport.supportsDolphinNetplay(systemId) -> 2626
+ else -> 55435
+ }
+ return prefs(context).getInt(key("netplay_port", systemId), fallback)
+ }
+
+ fun setNetplayPort(context: Context, systemId: String, value: Int) =
+ prefs(context).edit().putInt(key("netplay_port", systemId), value.coerceIn(1, 65535)).apply()
+
+ fun netplayHostMode(context: Context, systemId: String): Boolean =
+ prefs(context).getBoolean(key("netplay_host_mode", systemId), true)
+
+ fun setNetplayHostMode(context: Context, systemId: String, value: Boolean) =
+ prefs(context).edit().putBoolean(key("netplay_host_mode", systemId), value).apply()
+
+ fun netplayTraversal(context: Context, systemId: String): Boolean =
+ prefs(context).getBoolean(key("netplay_traversal", systemId), false)
+
+ fun setNetplayTraversal(context: Context, systemId: String, value: Boolean) =
+ prefs(context).edit().putBoolean(key("netplay_traversal", systemId), value).apply()
+
+ fun netplayHostCode(context: Context, systemId: String): String =
+ prefs(context).getString(key("netplay_host_code", systemId), "") ?: ""
+
+ fun setNetplayHostCode(context: Context, systemId: String, value: String) =
+ prefs(context).edit().putString(key("netplay_host_code", systemId), value.trim()).apply()
+
+ fun netplayPlayerName(context: Context): String =
+ prefs(context).getString("retro_netplay_player_name", "") ?: ""
+
+ fun setNetplayPlayerName(context: Context, value: String) =
+ prefs(context).edit().putString("retro_netplay_player_name", value.trim().take(24)).apply()
+
+ fun netplayLaunchMode(context: Context, systemId: String): String {
+ val raw = prefs(context).getString(key("netplay_mode", systemId), null)
+ return when (raw?.lowercase()) {
+ "join" -> "join"
+ "host" -> "host"
+ "off", "manual", null -> "off"
+ else -> "off"
+ }
+ }
+
+ fun setNetplayLaunchMode(context: Context, systemId: String, value: String) {
+ val mode =
+ when (value.lowercase()) {
+ "join" -> "join"
+ "host" -> "host"
+ else -> "off"
+ }
+ prefs(context)
+ .edit()
+ .putString(key("netplay_mode", systemId), mode)
+ .putBoolean(key("netplay_host_mode", systemId), mode == "host")
+ .apply()
+ }
+
+ fun coreOption(
+ context: Context,
+ systemId: String,
+ optionKey: String,
+ fallback: String,
+ ): String = prefs(context).getString(varKey(systemId, optionKey), fallback) ?: fallback
+
+ fun setCoreOption(
+ context: Context,
+ systemId: String,
+ optionKey: String,
+ value: String,
+ ) = prefs(context).edit().putString(varKey(systemId, optionKey), value).apply()
+}
diff --git a/app/src/main/feature/retro/RetroDefaultsScreen.kt b/app/src/main/feature/retro/RetroDefaultsScreen.kt
new file mode 100644
index 000000000..1e56f3c6a
--- /dev/null
+++ b/app/src/main/feature/retro/RetroDefaultsScreen.kt
@@ -0,0 +1,1013 @@
+package com.winlator.cmod.feature.retro
+
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.sp
+import androidx.compose.runtime.CompositionLocalProvider
+import com.winlator.cmod.R
+import com.winlator.cmod.feature.settings.SettingsNavBridge
+import com.winlator.cmod.shared.ui.focus.rememberSettingsContentNav
+import com.winlator.cmod.shared.ui.nav.LocalPaneNav
+import com.winlator.cmod.shared.ui.nav.paneNavItem
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private val PageBg = Color(0xFF101018)
+private val PageText = Color(0xFFF0F4FF)
+private val PageSub = Color(0xFF93A6BC)
+
+private val SHADER_KEYS = listOf("default", "crt", "lcd", "sharp")
+private val UPSCALE_KEYS = listOf("2x", "4x", "native")
+
+@Composable
+fun RetroDefaultsScreen(bridge: SettingsNavBridge? = null) {
+ val context = LocalContext.current
+ val scope = androidx.compose.runtime.rememberCoroutineScope()
+ var expandedConsole by remember { mutableStateOf(null) }
+ var refresh by remember { mutableIntStateOf(0) }
+ var confirmHardcore by remember { mutableStateOf(false) }
+ val contentNav = rememberSettingsContentNav(bridge)
+
+ if (confirmHardcore) {
+ RetroHardcoreConfirmDialog(
+ onConfirm = {
+ confirmHardcore = false
+ RetroAchievementsManager.setHardcorePreferred(context, true)
+ refresh++
+ },
+ onDismiss = { confirmHardcore = false },
+ )
+ }
+
+ val biosPicker =
+ rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) {
+ scope.launch {
+ val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ RetroBiosImport.importFromUri(context, uri)
+ }
+ result
+ .onSuccess { Toast.makeText(context, context.getString(R.string.retro_scr_bios_imported, it), Toast.LENGTH_SHORT).show() }
+ .onFailure { Toast.makeText(context, it.message ?: context.getString(R.string.retro_scr_invalid_bios_file), Toast.LENGTH_LONG).show() }
+ refresh++
+ }
+ }
+ }
+
+ val ps2BiosPicker =
+ rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) {
+ scope.launch {
+ val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ RetroBiosImport.importPs2FromUri(context, uri)
+ }
+ result
+ .onSuccess { Toast.makeText(context, context.getString(R.string.retro_scr_ps2_bios_imported, it), Toast.LENGTH_SHORT).show() }
+ .onFailure { Toast.makeText(context, it.message ?: context.getString(R.string.retro_scr_invalid_ps2_bios_file), Toast.LENGTH_LONG).show() }
+ refresh++
+ }
+ }
+ }
+
+ @Suppress("UNUSED_EXPRESSION") refresh
+
+ CompositionLocalProvider(LocalPaneNav provides contentNav) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(PageBg)
+ .verticalScroll(rememberScrollState())
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Text(
+ stringResource(R.string.retro_scr_retro_defaults),
+ color = PageSub,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp,
+ )
+ Text(
+ stringResource(R.string.retro_scr_retro_defaults_desc),
+ color = PageSub,
+ style = MaterialTheme.typography.bodySmall,
+ )
+
+ RetroConsoleBundleGroup(refreshKey = refresh, onChanged = { refresh++ })
+
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_scr_retroachievements))
+ RetroInfoRow(
+ stringResource(R.string.retro_scr_account),
+ if (RetroAchievementsManager.isLoggedIn(context)) {
+ RetroAchievementsManager.displayName(context) ?: stringResource(R.string.retro_scr_signed_in)
+ } else {
+ stringResource(R.string.retro_scr_not_signed_in_hint)
+ },
+ )
+ RetroSettingSwitch(
+ stringResource(R.string.retro_scr_achievements_enabled),
+ RetroAchievementsManager.isEnabled(context),
+ ) {
+ RetroAchievementsManager.setEnabled(context, it)
+ refresh++
+ }
+ if (RetroAchievementsManager.HARDCORE_SUPPORTED) {
+ RetroSettingSwitch(
+ stringResource(R.string.retro_scr_hardcore_default),
+ RetroAchievementsManager.isHardcorePreferred(context),
+ ) {
+ if (it) {
+ confirmHardcore = true
+ } else {
+ RetroAchievementsManager.setHardcorePreferred(context, false)
+ refresh++
+ }
+ }
+ }
+ if (RetroAchievementsManager.isLoggedIn(context)) {
+ OutlinedButton(
+ onClick = {
+ RetroAchievementsManager.logout(context)
+ refresh++
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ .paneNavItem(
+ cornerRadius = 8.dp,
+ onActivate = {
+ RetroAchievementsManager.logout(context)
+ refresh++
+ },
+ highlightColor = Color(0xFF4FC3F7),
+ tapToSelect = true,
+ ),
+ ) {
+ Text(stringResource(R.string.retro_scr_sign_out))
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Box(Modifier.weight(1f)) {
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_scr_playstation_bios))
+ val dir = RetroCoreManager.systemDir(context)
+ val installed = RetroSystems.PSX.biosFiles.filter { File(dir, it).isFile }
+ RetroInfoRow(
+ stringResource(R.string.retro_scr_installed),
+ if (installed.isEmpty()) {
+ stringResource(R.string.retro_scr_none_ps1_bios)
+ } else {
+ installed.joinToString(", ")
+ },
+ )
+ Button(
+ onClick = { runCatching { biosPicker.launch(arrayOf("*/*")) } },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ .paneNavItem(
+ cornerRadius = 8.dp,
+ onActivate = { runCatching { biosPicker.launch(arrayOf("*/*")) } },
+ highlightColor = Color(0xFF4FC3F7),
+ tapToSelect = true,
+ ),
+ ) {
+ Text(stringResource(R.string.retro_scr_import_ps1_bios))
+ }
+ if (installed.isNotEmpty()) {
+ OutlinedButton(
+ onClick = {
+ val n = RetroBiosImport.deletePs1Bios(context)
+ Toast.makeText(
+ context,
+ if (n > 0) {
+ context.getString(R.string.retro_scr_ps1_bios_removed)
+ } else {
+ context.getString(R.string.retro_scr_no_bios_to_remove)
+ },
+ Toast.LENGTH_SHORT,
+ ).show()
+ refresh++
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ .paneNavItem(
+ cornerRadius = 8.dp,
+ onActivate = {
+ val n = RetroBiosImport.deletePs1Bios(context)
+ Toast.makeText(
+ context,
+ if (n > 0) {
+ context.getString(R.string.retro_scr_ps1_bios_removed)
+ } else {
+ context.getString(R.string.retro_scr_no_bios_to_remove)
+ },
+ Toast.LENGTH_SHORT,
+ ).show()
+ refresh++
+ },
+ highlightColor = Color(0xFF4FC3F7),
+ tapToSelect = true,
+ ),
+ ) {
+ Text(stringResource(R.string.retro_scr_remove_ps1_bios))
+ }
+ }
+ }
+ }
+ Box(Modifier.weight(1f)) {
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_scr_playstation_2_bios))
+ val ps2Installed = RetroBiosImport.installedPs2Bios(context)
+ RetroInfoRow(
+ stringResource(R.string.retro_scr_installed),
+ if (ps2Installed.isEmpty()) {
+ stringResource(R.string.retro_scr_none_ps2_bios)
+ } else {
+ ps2Installed.joinToString(", ")
+ },
+ )
+ Button(
+ onClick = { runCatching { ps2BiosPicker.launch(arrayOf("*/*")) } },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ .paneNavItem(
+ cornerRadius = 8.dp,
+ onActivate = { runCatching { ps2BiosPicker.launch(arrayOf("*/*")) } },
+ highlightColor = Color(0xFF4FC3F7),
+ tapToSelect = true,
+ ),
+ ) {
+ Text(stringResource(R.string.retro_scr_import_ps2_bios))
+ }
+ if (ps2Installed.isNotEmpty()) {
+ OutlinedButton(
+ onClick = {
+ val n = RetroBiosImport.deletePs2Bios(context)
+ Toast.makeText(
+ context,
+ if (n > 0) {
+ context.getString(R.string.retro_scr_ps2_bios_removed)
+ } else {
+ context.getString(R.string.retro_scr_no_bios_to_remove)
+ },
+ Toast.LENGTH_SHORT,
+ ).show()
+ refresh++
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ .paneNavItem(
+ cornerRadius = 8.dp,
+ onActivate = {
+ val n = RetroBiosImport.deletePs2Bios(context)
+ Toast.makeText(
+ context,
+ if (n > 0) {
+ context.getString(R.string.retro_scr_ps2_bios_removed)
+ } else {
+ context.getString(R.string.retro_scr_no_bios_to_remove)
+ },
+ Toast.LENGTH_SHORT,
+ ).show()
+ refresh++
+ },
+ highlightColor = Color(0xFF4FC3F7),
+ tapToSelect = true,
+ ),
+ ) {
+ Text(stringResource(R.string.retro_scr_remove_ps2_bios))
+ }
+ }
+ }
+ }
+ }
+
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_scr_library_artwork))
+ RetroSettingSwitch(
+ stringResource(R.string.retro_scr_retro_case_art),
+ RetroBoxart.caseArtEnabled(context),
+ ) { RetroBoxart.setCaseArtEnabled(context, it); refresh++ }
+ RetroInfoRow(
+ stringResource(R.string.retro_scr_box_art),
+ stringResource(R.string.retro_scr_box_art_desc),
+ )
+ }
+
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_scr_roms_folder))
+ val romsDir = RetroDefaults.romsDir(context)
+ RetroInfoRow(
+ stringResource(R.string.retro_scr_folder),
+ romsDir ?: stringResource(R.string.retro_scr_roms_not_set),
+ )
+ RetroInfoRow(
+ stringResource(R.string.retro_scr_auto_import),
+ stringResource(R.string.retro_scr_auto_import_desc),
+ )
+ val pickRomsFolder = {
+ val activity = context as? android.app.Activity
+ if (activity != null) {
+ com.winlator.cmod.shared.android.DirectoryPickerDialog.show(
+ activity = activity,
+ initialPath = romsDir
+ ?: android.os.Environment.getExternalStoragePublicDirectory(
+ android.os.Environment.DIRECTORY_DOWNLOADS,
+ ).absolutePath,
+ title = context.getString(R.string.retro_scr_select_roms_folder_title),
+ ) { path ->
+ RetroDefaults.setRomsDir(context, path)
+ Thread {
+ val result = RetroRomScanner.scan(context, File(path))
+ activity.runOnUiThread {
+ Toast.makeText(context, scanMessage(context, result), Toast.LENGTH_SHORT).show()
+ refresh++
+ }
+ }.start()
+ }
+ }
+ }
+ Button(
+ onClick = { pickRomsFolder() },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ .paneNavItem(
+ cornerRadius = 8.dp,
+ onActivate = { pickRomsFolder() },
+ highlightColor = Color(0xFF4FC3F7),
+ tapToSelect = true,
+ ),
+ ) {
+ Text(if (romsDir == null) stringResource(R.string.retro_scr_select_roms_folder_button) else stringResource(R.string.retro_scr_change_roms_folder_button))
+ }
+ if (romsDir != null) {
+ val scanNow = {
+ val activity = context as? android.app.Activity
+ Thread {
+ val result = RetroRomScanner.scanConfiguredFolder(context)
+ activity?.runOnUiThread {
+ Toast.makeText(context, scanMessage(context, result), Toast.LENGTH_SHORT).show()
+ refresh++
+ }
+ }.start()
+ }
+ OutlinedButton(
+ onClick = { scanNow() },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp)
+ .paneNavItem(
+ cornerRadius = 8.dp,
+ onActivate = { scanNow() },
+ highlightColor = Color(0xFF4FC3F7),
+ tapToSelect = true,
+ ),
+ ) {
+ Text(stringResource(R.string.retro_scr_scan_now))
+ }
+ }
+ }
+
+ RetroLibretroHudSection(RetroDefaults.hud(context, "")) {
+ RetroDefaults.setHud(context, "", it)
+ refresh++
+ }
+
+ Text(
+ stringResource(R.string.retro_scr_console_defaults),
+ color = PageSub,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 1.sp,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ Text(
+ stringResource(R.string.retro_scr_console_defaults_desc),
+ color = PageSub,
+ style = MaterialTheme.typography.labelMedium,
+ )
+
+ RetroSystems.ALL.sortedBy { it.displayName }.forEach { console ->
+ val sys = console.id
+ val expanded = expandedConsole == sys
+ RetroSettingGroup {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable { expandedConsole = if (expanded) null else sys }
+ .paneNavItem(
+ cornerRadius = 8.dp,
+ onActivate = { expandedConsole = if (expanded) null else sys },
+ highlightColor = Color(0xFF4FC3F7),
+ tapToSelect = true,
+ )
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ console.displayName,
+ color = PageText,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ if (expanded) "▲" else "▼",
+ color = PageSub,
+ )
+ }
+ if (expanded && console.isExternal) {
+ val ps2Prefs = context.getSharedPreferences("ARMSX2", android.content.Context.MODE_PRIVATE)
+ val rendererKeys = listOf("vulkan", "opengl", "software")
+ val rendererLabels = listOf(
+ stringResource(R.string.retro_ps2_renderer_vulkan),
+ stringResource(R.string.retro_ps2_renderer_opengl),
+ stringResource(R.string.retro_ps2_renderer_software),
+ )
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_renderer),
+ entries = rendererLabels,
+ selectedIndex = rendererKeys.indexOf(ps2Prefs.getString("wn.ps2.renderer", "vulkan")).coerceAtLeast(0),
+ onSelected = { ps2Prefs.edit().putString("wn.ps2.renderer", rendererKeys[it]).apply(); refresh++ },
+ )
+ val ps2Drivers = remember { com.armsx2.CustomDriver.listInstalled(context) }
+ val driverIds = listOf("") + ps2Drivers.map { it.id }
+ val driverLabels = listOf(stringResource(R.string.retro_gpu_driver_system)) + ps2Drivers.map { it.name }
+ val curDriver = (ps2Prefs.getString("wn.ps2.driver", "") ?: "").let { if (it.equals("system", true)) "" else it }
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_gpu_driver),
+ entries = driverLabels,
+ selectedIndex = driverIds.indexOf(curDriver).coerceAtLeast(0),
+ onSelected = { ps2Prefs.edit().putString("wn.ps2.driver", driverIds[it]).apply(); refresh++ },
+ )
+ if (ps2Drivers.isEmpty()) {
+ Text(
+ stringResource(R.string.retro_gpu_driver_hint),
+ color = PageSub,
+ fontSize = 11.sp,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+ com.winlator.cmod.feature.retro.Ps2TurnipFlags(ps2Prefs, refresh) { refresh++ }
+ val ps2Scales = listOf(1f, 1.5f, 2f, 3f, 4f)
+ val ps2ScaleLabels = listOf(
+ stringResource(R.string.retro_gs_scale_1x_native),
+ stringResource(R.string.retro_gs_scale_1_5x),
+ stringResource(R.string.retro_gs_scale_2x),
+ stringResource(R.string.retro_gs_scale_3x),
+ stringResource(R.string.retro_gs_scale_4x),
+ )
+ val curScale = ps2Prefs.getFloat("wn.ps2.upscale", 1f)
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_upscale_resolution),
+ entries = ps2ScaleLabels,
+ selectedIndex = ps2Scales.indexOfFirst { kotlin.math.abs(it - curScale) < 0.01f }.coerceAtLeast(0),
+ onSelected = { ps2Prefs.edit().putFloat("wn.ps2.upscale", ps2Scales[it]).apply(); refresh++ },
+ )
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_aspect_ratio),
+ entries = listOf(
+ stringResource(R.string.retro_scr_aspect_stretch),
+ stringResource(R.string.retro_scr_aspect_auto_standard),
+ stringResource(R.string.retro_scr_aspect_4_3),
+ stringResource(R.string.retro_scr_aspect_16_9),
+ ),
+ selectedIndex = ps2Prefs.getInt("wn.ps2.aspect", 1).coerceIn(0, 3),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.aspect", it).apply(); refresh++ },
+ )
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_gs_fmv_aspect_ratio),
+ entries = listOf(
+ stringResource(R.string.retro_gs_off),
+ stringResource(R.string.retro_gs_aspect_auto_standard),
+ stringResource(R.string.retro_gs_aspect_4_3),
+ stringResource(R.string.retro_gs_aspect_16_9),
+ ),
+ selectedIndex = ps2Prefs.getInt("wn.ps2.fmvaspect", 0).coerceIn(0, 3),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.fmvaspect", it).apply(); refresh++ },
+ )
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_gs_deinterlace_mode),
+ entries = listOf(
+ stringResource(R.string.retro_gs_deint_auto),
+ stringResource(R.string.retro_gs_deint_off),
+ stringResource(R.string.retro_gs_deint_weave_tff),
+ stringResource(R.string.retro_gs_deint_weave_bff),
+ stringResource(R.string.retro_gs_deint_bob_tff),
+ stringResource(R.string.retro_gs_deint_bob_bff),
+ stringResource(R.string.retro_gs_deint_blend_tff),
+ stringResource(R.string.retro_gs_deint_blend_bff),
+ stringResource(R.string.retro_gs_deint_adaptive_tff),
+ stringResource(R.string.retro_gs_deint_adaptive_bff),
+ ),
+ selectedIndex = ps2Prefs.getInt("wn.ps2.deinterlace", 0).coerceIn(0, 9),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.deinterlace", it).apply(); refresh++ },
+ )
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_display_filter),
+ entries = listOf(
+ stringResource(R.string.retro_scr_filter_nearest),
+ stringResource(R.string.retro_scr_filter_bilinear_smooth),
+ stringResource(R.string.retro_scr_filter_bilinear_sharp),
+ ),
+ selectedIndex = ps2Prefs.getInt("wn.ps2.displayfilter", 1).coerceIn(0, 2),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.displayfilter", it).apply(); refresh++ },
+ )
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_texture_filter),
+ entries = listOf(
+ stringResource(R.string.retro_scr_filter_nearest),
+ stringResource(R.string.retro_scr_filter_bilinear_forced),
+ stringResource(R.string.retro_scr_filter_bilinear_ps2),
+ stringResource(R.string.retro_scr_filter_bilinear_sprites),
+ ),
+ selectedIndex = ps2Prefs.getInt("wn.ps2.filter", 2).coerceIn(0, 3),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.filter", it).apply(); refresh++ },
+ )
+ RetroSettingSwitch(stringResource(R.string.retro_scr_mipmapping), ps2Prefs.getBoolean("wn.ps2.mipmap", true)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.mipmap", it).apply(); refresh++
+ }
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_blending_accuracy),
+ entries = listOf(
+ stringResource(R.string.retro_scr_blend_minimum),
+ stringResource(R.string.retro_scr_blend_basic),
+ stringResource(R.string.retro_scr_blend_medium),
+ stringResource(R.string.retro_scr_blend_high),
+ stringResource(R.string.retro_scr_blend_full),
+ stringResource(R.string.retro_scr_blend_maximum),
+ ),
+ selectedIndex = ps2Prefs.getInt("wn.ps2.blend", 1).coerceIn(0, 5),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.blend", it).apply(); refresh++ },
+ )
+ RetroSettingSwitch(stringResource(R.string.retro_gs_anti_blur), ps2Prefs.getBoolean("wn.ps2.antiblur", true)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.antiblur", it).apply(); refresh++
+ }
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_crt_tv_shader),
+ entries = listOf(
+ stringResource(R.string.retro_scr_shader_off),
+ stringResource(R.string.retro_scr_shader_scanline),
+ stringResource(R.string.retro_scr_shader_diagonal),
+ stringResource(R.string.retro_scr_shader_triangular),
+ stringResource(R.string.retro_scr_shader_wave),
+ stringResource(R.string.retro_scr_shader_lottes),
+ stringResource(R.string.retro_scr_shader_4xrgss),
+ stringResource(R.string.retro_scr_shader_nxagss),
+ ),
+ selectedIndex = ps2Prefs.getInt("wn.ps2.tvshader", 0).coerceIn(0, 7),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.tvshader", it).apply(); refresh++ },
+ )
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_frame_skip),
+ entries = listOf(
+ stringResource(R.string.retro_scr_skip_off),
+ stringResource(R.string.retro_scr_skip_1),
+ stringResource(R.string.retro_scr_skip_2),
+ stringResource(R.string.retro_scr_skip_3),
+ ),
+ selectedIndex = ps2Prefs.getInt("wn.ps2.frameskip", 0).coerceIn(0, 3),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.frameskip", it).apply(); refresh++ },
+ )
+ RetroSettingSwitch(stringResource(R.string.retro_gs_widescreen_patches), ps2Prefs.getBoolean("wn.ps2.widescreen", false)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.widescreen", it).apply(); refresh++
+ }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_no_interlace_patches), ps2Prefs.getBoolean("wn.ps2.nointerlace", false)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.nointerlace", it).apply(); refresh++
+ }
+ val eeRates = listOf(-3, -2, -1, 0, 1, 2, 3)
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_ee_cycle_rate),
+ entries = listOf(
+ stringResource(R.string.retro_scr_ee_rate_50),
+ stringResource(R.string.retro_scr_ee_rate_60),
+ stringResource(R.string.retro_scr_ee_rate_75),
+ stringResource(R.string.retro_scr_ee_rate_100_default),
+ stringResource(R.string.retro_scr_ee_rate_130),
+ stringResource(R.string.retro_scr_ee_rate_180),
+ stringResource(R.string.retro_scr_ee_rate_300),
+ ),
+ selectedIndex = eeRates.indexOf(ps2Prefs.getInt("wn.ps2.eeRate", 0).coerceIn(-3, 3)).coerceAtLeast(0),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.eeRate", eeRates[it]).apply(); refresh++ },
+ )
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_ee_cycle_skip),
+ entries = listOf(
+ stringResource(R.string.retro_scr_skip_off),
+ stringResource(R.string.retro_scr_skip_num_1),
+ stringResource(R.string.retro_scr_skip_num_2),
+ stringResource(R.string.retro_scr_skip_num_3),
+ ),
+ selectedIndex = ps2Prefs.getInt("wn.ps2.eeSkip", 0).coerceIn(0, 3),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.eeSkip", it).apply(); refresh++ },
+ )
+ RetroSettingSwitch(stringResource(R.string.retro_scr_mtvu), ps2Prefs.getBoolean("wn.ps2.mtvu", true)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.mtvu", it).apply(); refresh++
+ }
+ RetroSettingSwitch(stringResource(R.string.retro_scr_instant_vu1), ps2Prefs.getBoolean("wn.ps2.instantVu1", true)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.instantVu1", it).apply(); refresh++
+ }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_vu_flag_hack), ps2Prefs.getBoolean("wn.ps2.vuFlagHack", true)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.vuFlagHack", it).apply(); refresh++
+ }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_intc_spin), ps2Prefs.getBoolean("wn.ps2.intc", true)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.intc", it).apply(); refresh++
+ }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_wait_loop), ps2Prefs.getBoolean("wn.ps2.waitloop", true)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.waitloop", it).apply(); refresh++
+ }
+ RetroSettingSwitch(stringResource(R.string.retro_scr_fast_cdvd), ps2Prefs.getBoolean("wn.ps2.fastCdvd", false)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.fastCdvd", it).apply(); refresh++
+ }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_fast_boot), ps2Prefs.getBoolean("wn.ps2.fastboot", true)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.fastboot", it).apply(); refresh++
+ }
+ RetroSettingSwitch(
+ stringResource(R.string.retro_scr_touch_controls),
+ RetroDefaults.touchControls(context, sys),
+ ) { RetroDefaults.setTouchControls(context, sys, it); refresh++ }
+ RetroSettingSwitch(
+ stringResource(R.string.retro_gs_adaptive_sticks),
+ RetroDefaults.adaptiveSticks(context, sys),
+ subtitle = stringResource(R.string.retro_gs_adaptive_sticks_subtitle),
+ ) { RetroDefaults.setAdaptiveSticks(context, sys, it); refresh++ }
+ RetroSettingSwitch(
+ stringResource(R.string.retro_ps2_show_l3r3),
+ ps2Prefs.getBoolean("wn.ps2.showl3r3", true),
+ subtitle = stringResource(R.string.retro_ps2_show_l3r3_subtitle),
+ ) { ps2Prefs.edit().putBoolean("wn.ps2.showl3r3", it).apply(); refresh++ }
+ RetroSettingSwitch(
+ stringResource(R.string.retro_scr_sound),
+ !ps2Prefs.getBoolean("wn.ps2.muted", false),
+ ) { ps2Prefs.edit().putBoolean("wn.ps2.muted", !it).apply(); refresh++ }
+ RetroSettingSwitch(
+ stringResource(R.string.retro_scr_swap_stereo),
+ ps2Prefs.getBoolean("wn.ps2.swap", false),
+ ) { ps2Prefs.edit().putBoolean("wn.ps2.swap", it).apply(); refresh++ }
+ RetroSettingSwitch(
+ stringResource(R.string.retro_gs_time_stretch),
+ ps2Prefs.getBoolean("wn.ps2.timestretch", true),
+ ) { ps2Prefs.edit().putBoolean("wn.ps2.timestretch", it).apply(); refresh++ }
+ val bufferValues = listOf(40, 50, 60, 80, 100, 120, 160, 200)
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_gs_audio_buffer),
+ entries = bufferValues.map { context.getString(R.string.retro_gs_ms, it) },
+ selectedIndex = bufferValues.indexOf(ps2Prefs.getInt("wn.ps2.audiobuffer", 50)).coerceAtLeast(0),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.audiobuffer", bufferValues[it]).apply(); refresh++ },
+ )
+ val latencyValues = listOf(10, 15, 20, 30, 40, 60, 80, 100)
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_gs_audio_latency),
+ entries = latencyValues.map { context.getString(R.string.retro_gs_ms, it) },
+ selectedIndex = latencyValues.indexOf(ps2Prefs.getInt("wn.ps2.audiolatency", 20)).coerceAtLeast(0),
+ onSelected = { ps2Prefs.edit().putInt("wn.ps2.audiolatency", latencyValues[it]).apply(); refresh++ },
+ )
+ RetroSettingSwitch(
+ stringResource(R.string.retro_scr_performance_hud),
+ RetroHudSupport.resolvePs2HudEnabled(context),
+ ) {
+ RetroHudSupport.setPs2HudEnabled(context, it)
+ RetroDefaults.setHud(context, sys, it)
+ refresh++
+ }
+ RetroSettingSwitch(
+ stringResource(R.string.retro_scr_enable_online_dev9),
+ ps2Prefs.getBoolean("wn.ps2.net.enable", true),
+ ) { ps2Prefs.edit().putBoolean("wn.ps2.net.enable", it).apply(); refresh++ }
+ if (ps2Prefs.getBoolean("wn.ps2.net.enable", true)) {
+ val devices = listOf("Auto", "Wi-Fi")
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_ethernet_device),
+ entries = listOf(
+ stringResource(R.string.retro_scr_net_auto),
+ stringResource(R.string.retro_scr_net_wifi),
+ ),
+ selectedIndex = devices.indexOf(ps2Prefs.getString("wn.ps2.net.ethdevice", "Auto")).coerceAtLeast(0),
+ onSelected = { ps2Prefs.edit().putString("wn.ps2.net.ethdevice", devices[it]).apply(); refresh++ },
+ )
+ val dnsModes = listOf("Manual", "Auto", "Internal")
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_dns_mode),
+ entries = listOf(
+ stringResource(R.string.retro_scr_dns_manual),
+ stringResource(R.string.retro_scr_net_auto),
+ stringResource(R.string.retro_scr_dns_internal),
+ ),
+ selectedIndex = dnsModes.indexOf(ps2Prefs.getString("wn.ps2.net.dnsmode", "Manual")).coerceAtLeast(0),
+ onSelected = { ps2Prefs.edit().putString("wn.ps2.net.dnsmode", dnsModes[it]).apply(); refresh++ },
+ )
+ RetroSettingTextField(stringResource(R.string.retro_scr_primary_dns), ps2Prefs.getString("wn.ps2.net.dns1", PS2_DEFAULT_DNS).orEmpty(), PS2_DEFAULT_DNS) { ps2Prefs.edit().putString("wn.ps2.net.dns1", it).apply(); refresh++ }
+ RetroSettingTextField(stringResource(R.string.retro_scr_secondary_dns), ps2Prefs.getString("wn.ps2.net.dns2", "").orEmpty(), stringResource(R.string.retro_scr_optional)) { ps2Prefs.edit().putString("wn.ps2.net.dns2", it).apply(); refresh++ }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_dnas_bypass), ps2Prefs.getBoolean(com.winlator.cmod.feature.retro.Ps2DnasBypass.PREF, true), subtitle = stringResource(R.string.retro_gs_dnas_bypass_subtitle)) { ps2Prefs.edit().putBoolean(com.winlator.cmod.feature.retro.Ps2DnasBypass.PREF, it).apply(); refresh++ }
+ RetroSettingSwitch(stringResource(R.string.retro_scr_auto_ip_dhcp), ps2Prefs.getBoolean("wn.ps2.net.dhcp", true)) { ps2Prefs.edit().putBoolean("wn.ps2.net.dhcp", it).apply(); refresh++ }
+ }
+ }
+ if (expanded && !console.isExternal) {
+ if (RetroCoreManager.usesDolphinCore(console)) {
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context)
+ val gcDrivers = remember { com.armsx2.CustomDriver.listInstalled(context) }
+ val gcDriverIds = remember(gcDrivers) { listOf("") + gcDrivers.map { it.id } }
+ val gcDriverLabels = listOf(stringResource(R.string.retro_gpu_driver_system)) + gcDrivers.map { it.name }
+ val curGcDriver = (prefs.getString(DolphinEmbedLaunch.DRIVER_PREF, "") ?: "").let { if (it.equals("system", true)) "" else it }
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_gpu_driver),
+ entries = gcDriverLabels,
+ selectedIndex = gcDriverIds.indexOf(curGcDriver).coerceAtLeast(0),
+ onSelected = { prefs.edit().putString(DolphinEmbedLaunch.DRIVER_PREF, gcDriverIds[it]).apply(); refresh++ },
+ )
+ }
+ val embeddedDolphin =
+ RetroCoreManager.usesDolphinCore(console) &&
+ RetroShortcuts.embeddedDolphinEnabled(context)
+ if (!embeddedDolphin) {
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_shader),
+ entries = listOf(
+ stringResource(R.string.retro_scr_shader_default),
+ stringResource(R.string.retro_scr_shader_crt),
+ stringResource(R.string.retro_scr_shader_lcd),
+ stringResource(R.string.retro_scr_shader_sharp),
+ ),
+ selectedIndex = SHADER_KEYS.indexOf(RetroDefaults.shader(context, sys)).coerceAtLeast(0),
+ onSelected = { RetroDefaults.setShader(context, sys, SHADER_KEYS[it]); refresh++ },
+ )
+ RetroSettingSwitch(
+ stringResource(R.string.retro_scr_sgsr_upscaling),
+ RetroDefaults.sgsr(context, sys),
+ ) { RetroDefaults.setSgsr(context, sys, it); refresh++ }
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_scr_upscale_resolution),
+ entries = listOf("2x", "4x", stringResource(R.string.retro_scr_upscale_native)),
+ selectedIndex = UPSCALE_KEYS.indexOf(RetroDefaults.upscale(context, sys)).coerceAtLeast(0),
+ onSelected = { RetroDefaults.setUpscale(context, sys, UPSCALE_KEYS[it]); refresh++ },
+ )
+ }
+ val consoleOptions = RetroCoreOptions.forSystem(console)
+ (consoleOptions.filterNot { it.advanced } + consoleOptions.filter { it.advanced })
+ .forEach { option ->
+ val current = RetroDefaults.coreOption(context, sys, option.key, option.defaultValue)
+ RetroSettingDropdown(
+ label = option.labelText(context),
+ entries = option.valueLabels,
+ selectedIndex = option.values.indexOf(current).coerceAtLeast(0),
+ onSelected = { RetroDefaults.setCoreOption(context, sys, option.key, option.values[it]); refresh++ },
+ )
+ }
+
+ RetroSettingSwitch(
+ stringResource(R.string.retro_scr_touch_controls),
+ RetroDefaults.touchControls(context, sys),
+ ) { RetroDefaults.setTouchControls(context, sys, it); refresh++ }
+ RetroSettingSwitch(
+ stringResource(R.string.retro_gs_adaptive_sticks),
+ RetroDefaults.adaptiveSticks(context, sys),
+ subtitle = stringResource(R.string.retro_gs_adaptive_sticks_subtitle),
+ ) { RetroDefaults.setAdaptiveSticks(context, sys, it); refresh++ }
+ RetroSettingSwitch(
+ stringResource(R.string.retro_scr_sound),
+ RetroDefaults.audio(context, sys),
+ ) { RetroDefaults.setAudio(context, sys, it); refresh++ }
+ if (RetroOnlineSupport.supportsMultiplayerUi(sys)) {
+ RetroNetplaySettingsSection(
+ systemId = sys,
+ version = refresh,
+ onChanged = { refresh++ },
+ )
+ } else if (RetroOnlineSupport.supportsDolphinNetplay(sys)) {
+ DolphinNetplaySettingsSection(
+ systemId = sys,
+ version = refresh,
+ onChanged = { refresh++ },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun scanMessage(context: android.content.Context, result: RetroRomScanner.Result): String {
+ val parts = buildList {
+ if (result.added > 0) add(context.getString(R.string.retro_scan_added, result.added))
+ if (result.removed > 0) add(context.getString(R.string.retro_scan_removed, result.removed))
+ }
+ return if (parts.isEmpty()) {
+ context.getString(R.string.retro_scan_up_to_date)
+ } else {
+ context.getString(R.string.retro_scan_roms, parts.joinToString(", "))
+ }
+}
+
+@Composable
+fun RetroHardcoreConfirmDialog(
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val accent = Color(0xFF1A9FFF)
+ com.winlator.cmod.shared.ui.dialog.WinNativeDialogShell(
+ onDismiss = onDismiss,
+ title = stringResource(R.string.retro_scr_enable_hardcore_title),
+ ) {
+ Text(
+ stringResource(R.string.retro_scr_enable_hardcore_body),
+ color = com.winlator.cmod.shared.theme.WinNativeTextSecondary,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ )
+ Spacer(Modifier.height(16.dp))
+ Box(Modifier.fillMaxWidth().height(1.dp).background(com.winlator.cmod.shared.theme.WinNativeOutline))
+ Spacer(Modifier.height(16.dp))
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
+ com.winlator.cmod.shared.ui.dialog.WinNativeDialogButton(
+ label = stringResource(R.string.retro_scr_cancel),
+ textColor = com.winlator.cmod.shared.theme.WinNativeTextPrimary,
+ onClick = onDismiss,
+ )
+ com.winlator.cmod.shared.ui.dialog.WinNativeDialogButton(
+ label = stringResource(R.string.retro_scr_enable),
+ textColor = accent,
+ backgroundColor = accent.copy(alpha = 0.12f),
+ borderColor = accent.copy(alpha = 0.3f),
+ onClick = onConfirm,
+ )
+ }
+ }
+}
+
+@Composable
+private fun RetroConsoleBundleGroup(
+ refreshKey: Int,
+ onChanged: () -> Unit,
+) {
+ val context = LocalContext.current
+ val scope = androidx.compose.runtime.rememberCoroutineScope()
+ var busy by remember { mutableStateOf(false) }
+ var checking by remember { mutableStateOf(false) }
+ var step by remember { mutableStateOf(null) }
+ var downloaded by remember { mutableStateOf(0L to 0L) }
+ var checkFailed by remember { mutableStateOf(false) }
+ var installError by remember { mutableStateOf(null) }
+ var progress by remember { mutableStateOf(0f) }
+ var published by remember { mutableStateOf(null) }
+ val installed = remember(refreshKey, busy) { RetroBundle.installed(context) }
+ val updatable = published != null && published!!.sha256 != installed?.sha256
+
+ val verifyingText = stringResource(R.string.retro_scr_consoles_verifying)
+ val installingText = stringResource(R.string.retro_scr_consoles_installing)
+
+ suspend fun check(): RetroBundle.Version? {
+ checking = true
+ val result = withContext(Dispatchers.IO) { RetroBundle.published() }
+ checking = false
+ published = result.getOrNull() ?: published
+ checkFailed = result.isFailure
+ return result.getOrNull()
+ }
+
+ suspend fun install(version: RetroBundle.Version) {
+ busy = true
+ progress = 0f
+ step = null
+ downloaded = 0L to 0L
+ installError = null
+ val result =
+ withContext(Dispatchers.IO) {
+ RetroBundle.install(context, version) { p ->
+ when (p) {
+ is RetroBundle.Progress.Downloading -> {
+ downloaded = p.bytes to p.total
+ if (p.total > 0) progress = p.bytes.toFloat() / p.total
+ }
+ RetroBundle.Progress.Verifying -> step = verifyingText
+ RetroBundle.Progress.Extracting -> step = installingText
+ }
+ }
+ }
+ busy = false
+ step = null
+ installError = result.exceptionOrNull()?.let { it.message ?: it.toString() }
+ onChanged()
+ }
+
+ androidx.compose.runtime.LaunchedEffect(Unit) { check() }
+
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_scr_consoles_group))
+ RetroInfoRow(
+ stringResource(R.string.retro_scr_consoles_state),
+ when {
+ busy && step != null -> step!!
+ busy && downloaded.second > 0 ->
+ stringResource(
+ R.string.retro_scr_consoles_downloading,
+ downloaded.first / (1024 * 1024),
+ downloaded.second / (1024 * 1024),
+ )
+ busy -> stringResource(R.string.retro_scr_consoles_working)
+ installError != null -> installError!!
+ checking -> stringResource(R.string.retro_scr_consoles_checking)
+ installed == null -> stringResource(R.string.retro_scr_consoles_missing)
+ updatable ->
+ stringResource(
+ R.string.retro_scr_consoles_update_available,
+ published!!.day,
+ published!!.size / (1024 * 1024),
+ )
+ checkFailed -> stringResource(R.string.retro_scr_consoles_installed_no_check, installed.day)
+ else -> stringResource(R.string.retro_scr_consoles_installed, installed.day)
+ },
+ )
+ if (busy && progress > 0f) {
+ LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
+ )
+ }
+ OutlinedButton(
+ enabled = !busy && !checking,
+ onClick = {
+ scope.launch {
+ if (installed == null || updatable) {
+ (published ?: check())?.let { install(it) }
+ } else {
+ check()
+ }
+ }
+ },
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ ) {
+ Text(
+ stringResource(
+ when {
+ installed == null -> R.string.retro_scr_consoles_download
+ updatable -> R.string.retro_scr_consoles_update
+ else -> R.string.retro_scr_consoles_check
+ },
+ ),
+ )
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/RetroDrawerMenu.kt b/app/src/main/feature/retro/RetroDrawerMenu.kt
new file mode 100644
index 000000000..92c096117
--- /dev/null
+++ b/app/src/main/feature/retro/RetroDrawerMenu.kt
@@ -0,0 +1,1983 @@
+package com.winlator.cmod.feature.retro
+
+import android.view.KeyEvent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ExitToApp
+import androidx.compose.material.icons.automirrored.outlined.VolumeUp
+import androidx.compose.material.icons.outlined.Album
+import androidx.compose.material.icons.outlined.Bolt
+import androidx.compose.material.icons.outlined.Apps
+import androidx.compose.material.icons.outlined.Download
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material.icons.outlined.EmojiEvents
+import androidx.compose.material.icons.outlined.FastForward
+import androidx.compose.material.icons.outlined.Monitor
+import androidx.compose.material.icons.outlined.Pause
+import androidx.compose.material.icons.outlined.PlayArrow
+import androidx.compose.material.icons.outlined.Wifi
+import androidx.compose.material.icons.outlined.RestartAlt
+import androidx.compose.material.icons.outlined.Save
+import androidx.compose.material.icons.outlined.Speed
+import androidx.compose.material.icons.outlined.SportsEsports
+import androidx.compose.material.icons.outlined.Add
+import androidx.compose.material.icons.outlined.Group
+import androidx.compose.material.icons.outlined.Link
+import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material.icons.outlined.Tune
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.winlator.cmod.R
+import com.winlator.cmod.shared.theme.SessionDrawerStyle
+import com.winlator.cmod.shared.theme.WinNativeOutline
+import com.winlator.cmod.shared.theme.WinNativeSurface
+import com.winlator.cmod.shared.ui.outlinedSwitchColors
+import com.winlator.cmod.shared.ui.widget.chasingBorder
+
+private const val DrawerGradientLift = SessionDrawerStyle.GradientLift
+
+private val DrawerAccent = SessionDrawerStyle.Accent
+private val DrawerActiveAccent = SessionDrawerStyle.ActiveAccent
+private val DrawerFocusFill = SessionDrawerStyle.FocusFill
+private val DrawerTextPrimary = SessionDrawerStyle.TextPrimary
+private val DrawerTextSecondary = SessionDrawerStyle.TextSecondary
+private val RetroSheetColor = SessionDrawerStyle.Background
+private val TopRailSurfaceColor = SessionDrawerStyle.TopRailSurface
+private val PaneSurfacePressed = SessionDrawerStyle.PaneSurfacePressed
+private val PaneInnerResting = SessionDrawerStyle.PaneInnerResting
+private val PaneInnerPressed = SessionDrawerStyle.PaneInnerPressed
+private val TileExitResting = SessionDrawerStyle.TileExitResting
+private val TileExitPressed = SessionDrawerStyle.TileExitPressed
+private val RestingCardBorder = SessionDrawerStyle.RestingCardBorder
+private val ActiveCardBorder = SessionDrawerStyle.ActiveCardBorder
+private val GlassExitTint = SessionDrawerStyle.GlassExitTint
+private val DividerColor = SessionDrawerStyle.Divider
+
+private val DrawerWidth = SessionDrawerStyle.Width
+private val DrawerStartPadding = SessionDrawerStyle.StartPadding
+private val DrawerVerticalPadding = SessionDrawerStyle.VerticalPadding
+
+enum class RetroPane { DISPLAY, SOUND, CONTROLS, HUD, SAVES, PERFORMANCE, MEMCARDS, NETWORK, SYSTEM }
+
+class RetroRenamePrompt(
+ val title: String,
+ val initial: String,
+ val onConfirm: (String?) -> Unit,
+)
+
+class RetroConflictPrompt(
+ val message: String,
+ val options: List,
+ val onKeepLocal: () -> Unit,
+ val onPick: (Int) -> Unit,
+)
+
+data class RetroTabSpec(
+ val pane: RetroPane?,
+ val icon: ImageVector,
+ val label: String,
+)
+
+sealed class RetroMenuEntry {
+ class Action(
+ val label: String,
+ val icon: ImageVector,
+ val active: Boolean = false,
+ val danger: Boolean = false,
+ val subtitle: String? = null,
+ val onClick: () -> Unit,
+ ) : RetroMenuEntry()
+
+ class Toggle(
+ val label: String,
+ val subtitle: String? = null,
+ val checked: Boolean,
+ val onChange: (Boolean) -> Unit,
+ ) : RetroMenuEntry()
+
+ class Choice(
+ val label: String,
+ val values: List,
+ val selectedIndex: Int,
+ val visible: Boolean = true,
+ val onSelected: (Int) -> Unit,
+ ) : RetroMenuEntry()
+
+ class Radio(
+ val label: String,
+ val selected: Boolean,
+ val onSelect: () -> Unit,
+ ) : RetroMenuEntry()
+
+ /**
+ * A setting that cycles, where the host knows the current value but not the
+ * set of values it cycles through.
+ *
+ * Choice cannot express that: it needs the whole list up front to render its
+ * dropdown and to know where the current value sits in it. This is for
+ * settings owned by something outside WinNative -- the hosted Gen 1 engine
+ * publishes each of its option rows as a label and a value string, and
+ * stepping one runs the engine's own handler, which is what keeps the engine
+ * the single owner of its settings rather than the host mirroring them.
+ */
+ class Stepper(
+ val label: String,
+ val valueText: String,
+ val onStep: (Int) -> Unit,
+ ) : RetroMenuEntry()
+
+ /**
+ * Two on/off settings sharing one row, side by side.
+ *
+ * A pane is a single column, which is right for a setting whose value needs
+ * the width -- a dropdown showing MEDIUM or BORDERLESS. A plain on/off
+ * needs none of it, and two of them stacked leave a column of mostly empty
+ * cards. Pairing them keeps the row height the rest of the pane uses while
+ * fitting both, which is why this is a distinct entry rather than a column
+ * count the whole pane would have to share.
+ */
+ class TogglePair(
+ val left: Toggle,
+ val right: Toggle,
+ ) : RetroMenuEntry()
+
+ class Slider(
+ val label: String,
+ val valueText: String,
+ val value: Float,
+ val min: Float,
+ val max: Float,
+ val step: Float,
+ val onChange: (Float) -> Unit,
+ ) : RetroMenuEntry()
+
+ class Chips(
+ val label: String,
+ val items: List,
+ val states: List,
+ val onToggle: (Int) -> Unit,
+ ) : RetroMenuEntry()
+
+ class ColorPick(
+ val label: String,
+ val color: Int?,
+ val onPick: (Int?) -> Unit,
+ ) : RetroMenuEntry()
+
+ class SaveSlot(
+ val slot: Int,
+ val title: String,
+ val subtitle: String,
+ val filled: Boolean,
+ val onClick: () -> Unit,
+ val onRename: () -> Unit,
+ ) : RetroMenuEntry()
+
+ class TextInput(
+ val label: String,
+ val value: String,
+ val placeholder: String = "",
+ val onOpen: () -> Unit,
+ ) : RetroMenuEntry()
+}
+
+val RetroColorPalette: List =
+ listOf(
+ 0xFFFFFFFF, 0xFFB0B4BC, 0xFF6B7280, 0xFF2A2A30, 0xFF000000,
+ 0xFFE53935, 0xFFFF7043, 0xFFFFB300, 0xFFFFF176, 0xFF7CB342,
+ 0xFF2F9E44, 0xFF26A69A, 0xFF29B6F6, 0xFF2E63C9, 0xFF5E35B1,
+ 0xFF8E24AA, 0xFFD81B60, 0xFF8D6E63,
+ ).map { it.toInt() }
+
+class RetroMenuController {
+ var visible by mutableStateOf(false)
+ private set
+ var pane by mutableStateOf(null)
+ private set
+ var region by mutableIntStateOf(1)
+ var railIndex by mutableIntStateOf(0)
+ var contentIndex by mutableIntStateOf(0)
+ var chipIndex by mutableIntStateOf(0)
+ var bottomIndex by mutableIntStateOf(0)
+ var controllerActive by mutableStateOf(false)
+ var tabs by mutableStateOf>(emptyList())
+ var entries by mutableStateOf>(emptyList())
+ private set
+ var bottomEntries by mutableStateOf>(emptyList())
+ private set
+ var entriesProvider: ((RetroPane?) -> List)? = null
+ var bottomProvider: (() -> List)? = null
+ var renamePrompt by mutableStateOf(null)
+ var conflictPrompt by mutableStateOf(null)
+
+ val gridColumns: Int
+ get() = if (pane == null) 3 else 1
+
+ fun open() {
+ pane = null
+ railIndex = 0
+ region = 1
+ contentIndex = 0
+ bottomIndex = 0
+ controllerActive = false
+ rebuild()
+ visible = true
+ }
+
+ fun close() {
+ visible = false
+ }
+
+ fun showPane(target: RetroPane?) {
+ pane = target
+ rebuild()
+ region = 1
+ contentIndex = 0
+ railIndex = tabs.indexOfFirst { it.pane == target }.coerceAtLeast(0)
+ }
+
+ fun rebuild() {
+ entries = entriesProvider?.invoke(pane) ?: emptyList()
+ bottomEntries = bottomProvider?.invoke() ?: emptyList()
+ if (contentIndex >= entries.size) contentIndex = (entries.size - 1).coerceAtLeast(0)
+ if (bottomIndex >= bottomEntries.size) bottomIndex = (bottomEntries.size - 1).coerceAtLeast(0)
+ }
+
+ private fun activate(direction: Int) {
+ when (val entry = entries.getOrNull(contentIndex)) {
+ is RetroMenuEntry.Action -> if (direction == 0) entry.onClick()
+ is RetroMenuEntry.Toggle -> entry.onChange(!entry.checked)
+ // Left and Right pick the half, which is what they do everywhere
+ // else on a row; a centre press takes the left one, the same half
+ // the cursor arrives on.
+ is RetroMenuEntry.TogglePair -> {
+ val half = if (direction > 0) entry.right else entry.left
+ half.onChange(!half.checked)
+ }
+ is RetroMenuEntry.Choice -> {
+ val size = entry.values.size
+ if (size > 0) {
+ val step = if (direction < 0) -1 else 1
+ entry.onSelected((entry.selectedIndex + step + size) % size)
+ }
+ }
+ is RetroMenuEntry.Radio -> if (direction == 0) entry.onSelect()
+ // Centre steps forward, matching how a cycling row behaves when it
+ // is tapped rather than nudged left or right.
+ is RetroMenuEntry.Stepper -> entry.onStep(if (direction < 0) -1 else 1)
+ is RetroMenuEntry.Slider ->
+ if (direction != 0) {
+ entry.onChange((entry.value + direction * entry.step).coerceIn(entry.min, entry.max))
+ }
+ is RetroMenuEntry.Chips ->
+ if (direction == 0) {
+ entry.onToggle(chipIndex.coerceIn(0, entry.items.size - 1))
+ } else {
+ chipIndex = (chipIndex + direction + entry.items.size) % entry.items.size
+ }
+ is RetroMenuEntry.ColorPick -> {
+ val step = if (direction < 0) -1 else 1
+ val palette = RetroColorPalette
+ val current = entry.color?.let { palette.indexOf(it) } ?: -1
+ val span = palette.size + 1
+ val next = ((current + 1 + step + span) % span) - 1
+ entry.onPick(if (next < 0) null else palette[next])
+ }
+ is RetroMenuEntry.SaveSlot -> if (direction == 0) entry.onClick()
+ is RetroMenuEntry.TextInput -> if (direction == 0) entry.onOpen()
+ else -> {}
+ }
+ }
+
+ private fun moveContent(delta: Int) {
+ if (entries.isEmpty()) return
+ val next = contentIndex + delta
+ when {
+ next < 0 -> region = 0
+ next >= entries.size && pane == null -> region = 2
+ next in entries.indices -> contentIndex = next
+ }
+ }
+
+ private var axisLatched = false
+
+ fun handleAxis(x: Float, y: Float): Boolean {
+ if (!visible) return false
+ val dz = 0.5f
+ val dir =
+ when {
+ y < -dz -> KeyEvent.KEYCODE_DPAD_UP
+ y > dz -> KeyEvent.KEYCODE_DPAD_DOWN
+ x < -dz -> KeyEvent.KEYCODE_DPAD_LEFT
+ x > dz -> KeyEvent.KEYCODE_DPAD_RIGHT
+ else -> 0
+ }
+ if (dir == 0) {
+ axisLatched = false
+ return true
+ }
+ if (axisLatched) return true
+ axisLatched = true
+ handleKey(dir, KeyEvent.ACTION_DOWN)
+ handleKey(dir, KeyEvent.ACTION_UP)
+ return true
+ }
+
+ fun handleKey(
+ keyCode: Int,
+ action: Int,
+ ): Boolean {
+ if (!visible) return false
+ val navKeys =
+ setOf(
+ KeyEvent.KEYCODE_DPAD_UP,
+ KeyEvent.KEYCODE_DPAD_DOWN,
+ KeyEvent.KEYCODE_DPAD_LEFT,
+ KeyEvent.KEYCODE_DPAD_RIGHT,
+ KeyEvent.KEYCODE_DPAD_CENTER,
+ KeyEvent.KEYCODE_BUTTON_A,
+ KeyEvent.KEYCODE_BUTTON_B,
+ KeyEvent.KEYCODE_BACK,
+ KeyEvent.KEYCODE_BUTTON_MODE,
+ KeyEvent.KEYCODE_BUTTON_START,
+ )
+ if (keyCode !in navKeys) return true
+ if (action == KeyEvent.ACTION_DOWN) {
+ controllerActive = true
+ when (keyCode) {
+ KeyEvent.KEYCODE_DPAD_UP ->
+ when (region) {
+ 1 -> moveContent(-gridColumns)
+ 2 -> region = 1
+ }
+ KeyEvent.KEYCODE_DPAD_DOWN ->
+ when (region) {
+ 0 -> if (entries.isNotEmpty()) region = 1
+ 1 -> moveContent(gridColumns)
+ }
+ KeyEvent.KEYCODE_DPAD_LEFT ->
+ when (region) {
+ 0 -> railIndex = (railIndex - 1 + tabs.size) % tabs.size
+ 1 -> if (gridColumns > 1) moveContent(-1) else activate(-1)
+ 2 -> if (bottomEntries.isNotEmpty()) {
+ bottomIndex = (bottomIndex - 1 + bottomEntries.size) % bottomEntries.size
+ }
+ }
+ KeyEvent.KEYCODE_DPAD_RIGHT ->
+ when (region) {
+ 0 -> railIndex = (railIndex + 1) % tabs.size
+ 1 -> if (gridColumns > 1) moveContent(1) else activate(1)
+ 2 -> if (bottomEntries.isNotEmpty()) {
+ bottomIndex = (bottomIndex + 1) % bottomEntries.size
+ }
+ }
+ KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_BUTTON_A ->
+ when (region) {
+ 0 -> tabs.getOrNull(railIndex)?.let { showPane(it.pane) }
+ 1 -> activate(0)
+ 2 -> bottomEntries.getOrNull(bottomIndex)?.onClick?.invoke()
+ }
+ }
+ } else if (action == KeyEvent.ACTION_UP) {
+ when (keyCode) {
+ KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BACK,
+ -> if (pane != null) showPane(null) else close()
+ }
+ }
+ return true
+ }
+}
+
+@Composable
+fun RetroDrawerMenu(controller: RetroMenuController) {
+ val density = LocalDensity.current
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val portrait = maxHeight > maxWidth
+ val sheetHeight =
+ if (portrait) {
+ minOf(maxWidth, maxHeight - DrawerVerticalPadding * 2)
+ } else {
+ maxHeight - DrawerVerticalPadding * 2
+ }
+ val paneScale =
+ (sheetHeight.value / SessionDrawerStyle.PaneScaleReferenceHeightDp)
+ .coerceIn(SessionDrawerStyle.PaneScaleMin, 1f)
+ if (controller.visible) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) { controller.close() },
+ )
+ }
+
+ val closedOffset = -(DrawerWidth + DrawerStartPadding + 8.dp)
+ val sheetOffset by animateDpAsState(
+ targetValue = if (controller.visible) 0.dp else closedOffset,
+ animationSpec = tween(durationMillis = 200, easing = LinearEasing),
+ label = "retroDrawerOffset",
+ )
+ if (sheetOffset > closedOffset) {
+ Box(
+ Modifier
+ .padding(start = DrawerStartPadding, top = DrawerVerticalPadding, bottom = DrawerVerticalPadding)
+ .height(sheetHeight)
+ .width(DrawerWidth)
+ .offset { androidx.compose.ui.unit.IntOffset(with(density) { sheetOffset.roundToPx() }, 0) }
+ .clip(RoundedCornerShape(20.dp))
+ .background(RetroSheetColor)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) {},
+ ) {
+ Column(Modifier.fillMaxSize()) {
+ RetroTopRail(controller, paneScale)
+ ThinDivider()
+ Box(Modifier.weight(1f).fillMaxWidth()) {
+ if (controller.pane == null) {
+ RetroActionGrid(controller, paneScale)
+ } else {
+ RetroPaneList(controller, paneScale)
+ }
+ }
+ if (controller.pane == null && controller.bottomEntries.isNotEmpty()) {
+ Column {
+ ThinDivider()
+ RetroBottomActions(controller, paneScale)
+ }
+ }
+ }
+ }
+ }
+ controller.renamePrompt?.let { prompt ->
+ RetroRenameDialog(prompt) { controller.renamePrompt = null }
+ }
+ controller.conflictPrompt?.let { prompt ->
+ RetroConflictDialog(prompt)
+ }
+ }
+}
+
+private data class RailTileBounds(
+ val offsetX: Float,
+ val width: Float,
+ val height: Float,
+)
+
+@Composable
+private fun RetroTopRail(
+ controller: RetroMenuController,
+ paneScale: Float,
+) {
+ val density = LocalDensity.current
+ val tileBounds = remember { mutableStateMapOf() }
+ val railScroll = rememberScrollState()
+
+ val selectedIndex =
+ if (controller.controllerActive && controller.region == 0) {
+ controller.railIndex
+ } else {
+ controller.tabs.indexOfFirst { it.pane == controller.pane }.coerceAtLeast(0)
+ }
+ val selectedBounds = tileBounds[selectedIndex]
+
+ val indicatorX = selectedBounds?.let { with(density) { it.offsetX.toDp() } } ?: 0.dp
+ val indicatorWidth = selectedBounds?.let { with(density) { it.width.toDp() } } ?: 0.dp
+ val indicatorTileHeight = selectedBounds?.let { with(density) { it.height.toDp() } } ?: 0.dp
+ val indicatorAlpha = if (selectedBounds != null) 1f else 0f
+
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .background(TopRailSurfaceColor)
+ .padding(
+ start = (10f * paneScale).dp,
+ end = (10f * paneScale).dp,
+ top = (5f * paneScale).dp,
+ bottom = (2f * paneScale).dp,
+ ),
+ ) {
+ if (selectedBounds != null) {
+ Box(
+ modifier =
+ Modifier
+ .offset(
+ x = indicatorX - with(density) { railScroll.value.toDp() } + (6f * paneScale).dp,
+ y = indicatorTileHeight - (2f * paneScale).dp,
+ )
+ .width((indicatorWidth - (12f * paneScale).dp).coerceAtLeast(0.dp))
+ .height((2f * paneScale).dp)
+ .graphicsLayer { alpha = indicatorAlpha }
+ .clip(RoundedCornerShape(1.dp))
+ .background(DrawerAccent),
+ )
+ }
+ Row(
+ modifier = Modifier.horizontalScroll(railScroll),
+ horizontalArrangement = Arrangement.spacedBy((10f * paneScale).dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ controller.tabs.forEachIndexed { index, tab ->
+ RetroRailTile(
+ icon = tab.icon,
+ label = tab.label,
+ selected =
+ if (controller.controllerActive && controller.region == 0) {
+ controller.railIndex == index
+ } else {
+ controller.pane == tab.pane
+ },
+ highlighted = controller.controllerActive && controller.region == 0 && controller.railIndex == index,
+ paneScale = paneScale,
+ onBoundsChanged = { tileBounds[index] = it },
+ onClick = {
+ controller.railIndex = index
+ controller.showPane(tab.pane)
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroRailTile(
+ icon: ImageVector,
+ label: String,
+ selected: Boolean,
+ highlighted: Boolean,
+ paneScale: Float,
+ onBoundsChanged: (RailTileBounds) -> Unit,
+ onClick: () -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val pressed = interactionSource.collectIsPressedAsState().value
+ val scale by animateFloatAsState(
+ targetValue = if (pressed) 0.94f else 1f,
+ animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium),
+ label = "retroTileScale",
+ )
+ val bgColor by animateColorAsState(
+ targetValue =
+ when {
+ highlighted -> DrawerFocusFill
+ pressed && !selected -> PaneSurfacePressed
+ else -> Color.Transparent
+ },
+ animationSpec = tween(120),
+ label = "retroTileBg",
+ )
+ val tint by animateColorAsState(
+ targetValue = if (selected) DrawerAccent else DrawerTextPrimary,
+ animationSpec = tween(120),
+ label = "retroTileTint",
+ )
+ val cornerRadius = (12f * paneScale).dp
+ val shape = RoundedCornerShape(cornerRadius)
+ Column(
+ modifier =
+ Modifier
+ .defaultMinSize(minWidth = (60f * paneScale).dp)
+ .onGloballyPositioned { coords ->
+ val bounds = coords.boundsInParent()
+ onBoundsChanged(RailTileBounds(bounds.left, bounds.width, bounds.height))
+ }
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ }
+ .clip(shape)
+ .background(bgColor)
+ .then(
+ if (highlighted) {
+ Modifier.chasingBorder(cornerRadius = cornerRadius, borderWidth = 1.5.dp, animationDurationMs = 8200)
+ } else {
+ Modifier
+ },
+ )
+ .clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
+ .padding(
+ start = (10f * paneScale).dp,
+ end = (10f * paneScale).dp,
+ top = (10f * paneScale).dp,
+ bottom = (7f * paneScale).dp,
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(imageVector = icon, contentDescription = label, tint = tint, modifier = Modifier.size((22f * paneScale).dp))
+ Spacer(Modifier.height((2f * paneScale).dp))
+ Text(
+ text = label,
+ color = DrawerTextPrimary,
+ fontSize = (12f * paneScale).sp,
+ fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
+ letterSpacing = 0.2.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+@Composable
+private fun RetroActionGrid(
+ controller: RetroMenuController,
+ paneScale: Float,
+) {
+ val actions = controller.entries
+ val spacing = (8f * paneScale).dp
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = (10f * paneScale).dp, vertical = (10f * paneScale).dp),
+ verticalArrangement = Arrangement.spacedBy(spacing),
+ ) {
+ actions.chunked(controller.gridColumns).forEachIndexed { rowIndex, row ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(spacing),
+ ) {
+ row.forEachIndexed { colIndex, entry ->
+ val flatIndex = rowIndex * controller.gridColumns + colIndex
+ if (entry is RetroMenuEntry.Action) {
+ RetroActionCard(
+ entry = entry,
+ highlighted =
+ controller.controllerActive &&
+ controller.region == 1 &&
+ controller.contentIndex == flatIndex,
+ paneScale = paneScale,
+ modifier = Modifier.weight(1f).aspectRatio(1f),
+ onClick = {
+ controller.contentIndex = flatIndex
+ entry.onClick()
+ },
+ )
+ }
+ }
+ repeat(controller.gridColumns - row.size) {
+ Spacer(Modifier.weight(1f))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroActionCard(
+ entry: RetroMenuEntry.Action,
+ highlighted: Boolean,
+ paneScale: Float,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val pressed = interactionSource.collectIsPressedAsState().value
+ val scale by animateFloatAsState(
+ targetValue = if (pressed) 0.96f else 1f,
+ animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium),
+ label = "retroCardScale",
+ )
+ val bgColor by animateColorAsState(
+ targetValue =
+ when {
+ highlighted -> DrawerFocusFill
+ pressed -> PaneInnerPressed
+ else -> PaneInnerResting
+ },
+ animationSpec = tween(120),
+ label = "retroCardBg",
+ )
+ val borderColor by animateColorAsState(
+ targetValue = if (entry.active) ActiveCardBorder else RestingCardBorder,
+ animationSpec = tween(120),
+ label = "retroCardBorder",
+ )
+ val tint =
+ when {
+ entry.danger -> GlassExitTint
+ entry.active -> DrawerActiveAccent
+ else -> DrawerTextPrimary
+ }
+ val cornerRadius = (12f * paneScale).dp
+ val shape = RoundedCornerShape(cornerRadius)
+ val topColor =
+ Color(
+ red = (bgColor.red + (1f - bgColor.red) * DrawerGradientLift).coerceIn(0f, 1f),
+ green = (bgColor.green + (1f - bgColor.green) * DrawerGradientLift).coerceIn(0f, 1f),
+ blue = (bgColor.blue + (1f - bgColor.blue) * DrawerGradientLift).coerceIn(0f, 1f),
+ alpha = bgColor.alpha,
+ )
+ val shell =
+ modifier
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ }
+ .clip(shape)
+ .background(Brush.verticalGradient(listOf(topColor, bgColor)))
+ .border(1.dp, borderColor, shape)
+ .then(
+ if (highlighted) {
+ Modifier.chasingBorder(cornerRadius = cornerRadius, borderWidth = 1.5.dp, animationDurationMs = 8200)
+ } else {
+ Modifier
+ },
+ )
+ .clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
+ if (entry.subtitle != null) {
+ Row(
+ modifier =
+ shell
+ .defaultMinSize(minHeight = (72f * paneScale).dp)
+ .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = entry.icon,
+ contentDescription = entry.label,
+ tint = tint,
+ modifier = Modifier.size((22f * paneScale).dp),
+ )
+ Spacer(Modifier.width((10f * paneScale).dp))
+ Column(Modifier.weight(1f, fill = true)) {
+ Text(
+ text = entry.label,
+ color = DrawerTextPrimary,
+ fontSize = (13f * paneScale).sp,
+ fontWeight = if (entry.active) FontWeight.SemiBold else FontWeight.Medium,
+ maxLines = 3,
+ softWrap = true,
+ overflow = TextOverflow.Clip,
+ lineHeight = (16f * paneScale).sp,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = entry.subtitle,
+ color = DrawerTextSecondary,
+ fontSize = (11f * paneScale).sp,
+ maxLines = 3,
+ softWrap = true,
+ overflow = TextOverflow.Clip,
+ lineHeight = (14f * paneScale).sp,
+ )
+ }
+ }
+ } else {
+ Column(
+ modifier = shell,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = entry.icon,
+ contentDescription = entry.label,
+ tint = tint,
+ modifier = Modifier.size((24f * paneScale).dp),
+ )
+ Spacer(Modifier.height((4f * paneScale).dp))
+ Text(
+ text = entry.label,
+ color = DrawerTextPrimary,
+ fontSize = (12f * paneScale).sp,
+ fontWeight = if (entry.active) FontWeight.SemiBold else FontWeight.Medium,
+ letterSpacing = 0.2.sp,
+ textAlign = TextAlign.Center,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+private fun RetroPaneList(
+ controller: RetroMenuController,
+ paneScale: Float,
+) {
+ val columns = controller.gridColumns.coerceAtLeast(1)
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = (10f * paneScale).dp, vertical = (10f * paneScale).dp),
+ verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp),
+ ) {
+ controller.entries.chunked(columns).forEachIndexed { rowIndex, rowEntries ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp),
+ ) {
+ rowEntries.forEachIndexed { colIndex, entry ->
+ val index = rowIndex * columns + colIndex
+ val highlighted =
+ controller.controllerActive &&
+ controller.region == 1 &&
+ controller.contentIndex == index
+ Box(Modifier.weight(1f)) {
+ when (entry) {
+ is RetroMenuEntry.Toggle ->
+ RetroBooleanRow(
+ entry = entry,
+ highlighted = highlighted,
+ paneScale = paneScale,
+ onClick = {
+ controller.contentIndex = index
+ entry.onChange(!entry.checked)
+ },
+ )
+ is RetroMenuEntry.Choice ->
+ if (entry.visible) {
+ RetroChoiceRow(
+ entry = entry,
+ highlighted = highlighted,
+ paneScale = paneScale,
+ onFocus = { controller.contentIndex = index },
+ )
+ }
+ is RetroMenuEntry.TogglePair ->
+ RetroTogglePairRow(
+ entry = entry,
+ highlighted = highlighted,
+ paneScale = paneScale,
+ onFocus = { controller.contentIndex = index },
+ )
+ is RetroMenuEntry.Radio ->
+ RetroRadioRow(
+ entry = entry,
+ highlighted = highlighted,
+ paneScale = paneScale,
+ onClick = {
+ controller.contentIndex = index
+ entry.onSelect()
+ },
+ )
+ is RetroMenuEntry.Stepper ->
+ RetroStepperRow(
+ entry = entry,
+ highlighted = highlighted,
+ paneScale = paneScale,
+ onFocus = { controller.contentIndex = index },
+ )
+ is RetroMenuEntry.Action ->
+ RetroActionCard(
+ entry = entry,
+ highlighted = highlighted,
+ paneScale = paneScale,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .defaultMinSize(
+ minHeight =
+ if (entry.subtitle != null) {
+ (68f * paneScale).dp
+ } else {
+ (56f * paneScale).dp
+ },
+ ),
+ onClick = {
+ controller.contentIndex = index
+ entry.onClick()
+ },
+ )
+ is RetroMenuEntry.Slider ->
+ RetroSliderRow(
+ entry = entry,
+ highlighted = highlighted,
+ paneScale = paneScale,
+ onClick = { controller.contentIndex = index },
+ )
+ is RetroMenuEntry.Chips ->
+ RetroChipsGroup(
+ entry = entry,
+ highlighted = highlighted,
+ chipFocus = if (highlighted) controller.chipIndex else -1,
+ paneScale = paneScale,
+ onChipClick = { chip ->
+ controller.contentIndex = index
+ controller.chipIndex = chip
+ entry.onToggle(chip)
+ },
+ )
+ is RetroMenuEntry.ColorPick ->
+ RetroColorRow(
+ entry = entry,
+ highlighted = highlighted,
+ paneScale = paneScale,
+ onFocus = { controller.contentIndex = index },
+ )
+ is RetroMenuEntry.SaveSlot ->
+ RetroSaveSlotRow(
+ entry = entry,
+ highlighted = highlighted,
+ paneScale = paneScale,
+ onClick = {
+ controller.contentIndex = index
+ entry.onClick()
+ },
+ )
+ is RetroMenuEntry.TextInput ->
+ RetroTextInputRow(
+ entry = entry,
+ highlighted = highlighted,
+ paneScale = paneScale,
+ onClick = {
+ controller.contentIndex = index
+ entry.onOpen()
+ },
+ )
+ }
+ }
+ }
+ repeat(columns - rowEntries.size) {
+ Spacer(Modifier.weight(1f))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroRowShell(
+ highlighted: Boolean,
+ activeBorder: Boolean,
+ paneScale: Float,
+ onClick: () -> Unit,
+ content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val pressed = interactionSource.collectIsPressedAsState().value
+ val bgColor by animateColorAsState(
+ targetValue =
+ when {
+ highlighted -> DrawerFocusFill
+ pressed -> PaneInnerPressed
+ else -> PaneInnerResting
+ },
+ animationSpec = tween(140),
+ label = "retroRowBg",
+ )
+ val borderColor by animateColorAsState(
+ targetValue = if (activeBorder) ActiveCardBorder else RestingCardBorder,
+ animationSpec = tween(140),
+ label = "retroRowBorder",
+ )
+ val cornerRadius = (14f * paneScale).dp
+ val shape = RoundedCornerShape(cornerRadius)
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(shape)
+ .background(bgColor)
+ .border(1.dp, borderColor, shape)
+ .then(
+ if (highlighted) {
+ Modifier.chasingBorder(cornerRadius = cornerRadius, borderWidth = 1.5.dp, animationDurationMs = 8200)
+ } else {
+ Modifier
+ },
+ )
+ .clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
+ .padding(horizontal = (12f * paneScale).dp, vertical = (8f * paneScale).dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ content()
+ }
+}
+
+@Composable
+private fun RetroBooleanRow(
+ entry: RetroMenuEntry.Toggle,
+ highlighted: Boolean,
+ paneScale: Float,
+ onClick: () -> Unit,
+) {
+ RetroRowShell(highlighted = highlighted, activeBorder = entry.checked, paneScale = paneScale, onClick = onClick) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.label,
+ color = DrawerTextPrimary,
+ fontSize = (14f * paneScale).sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = entry.subtitle ?: if (entry.checked) stringResource(R.string.retro_scr_enabled) else stringResource(R.string.retro_scr_disabled),
+ color = DrawerTextSecondary,
+ fontSize = (12f * paneScale).sp,
+ )
+ }
+ Switch(
+ checked = entry.checked,
+ onCheckedChange = entry.onChange,
+ colors = outlinedSwitchColors(DrawerAccent, DrawerTextSecondary),
+ )
+ }
+}
+
+/**
+ * Two on/off settings on one row; see [RetroMenuEntry.TogglePair].
+ *
+ * Each half is a button rather than a labelled switch. A Switch beside a label
+ * needs the full width of a row to read cleanly, and at half width the two
+ * would crowd each other -- so the state is the button itself: ON or OFF in the
+ * accent colour, with the border lit while it is on. Same height as every other
+ * row on the pane, so the column still scans as a list.
+ */
+@Composable
+private fun RetroTogglePairRow(
+ entry: RetroMenuEntry.TogglePair,
+ highlighted: Boolean,
+ paneScale: Float,
+ onFocus: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp),
+ ) {
+ listOf(entry.left, entry.right).forEach { half ->
+ Box(Modifier.weight(1f)) {
+ RetroRowShell(
+ highlighted = highlighted,
+ activeBorder = half.checked,
+ paneScale = paneScale,
+ onClick = {
+ onFocus()
+ half.onChange(!half.checked)
+ },
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = half.label,
+ color = DrawerTextPrimary,
+ fontSize = (14f * paneScale).sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text =
+ half.subtitle ?: if (half.checked) {
+ stringResource(R.string.retro_scr_enabled)
+ } else {
+ stringResource(R.string.retro_scr_disabled)
+ },
+ color = if (half.checked) DrawerActiveAccent else DrawerTextSecondary,
+ fontSize = (12f * paneScale).sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroTextInputRow(
+ entry: RetroMenuEntry.TextInput,
+ highlighted: Boolean,
+ paneScale: Float,
+ onClick: () -> Unit,
+) {
+ RetroRowShell(highlighted = highlighted, activeBorder = entry.value.isNotBlank(), paneScale = paneScale, onClick = onClick) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.label,
+ color = DrawerTextPrimary,
+ fontSize = (14f * paneScale).sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = entry.value.ifBlank { entry.placeholder },
+ color = if (entry.value.isBlank()) DrawerTextSecondary else DrawerActiveAccent,
+ fontSize = (12f * paneScale).sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+private fun RetroChoiceRow(
+ entry: RetroMenuEntry.Choice,
+ highlighted: Boolean,
+ paneScale: Float,
+ onFocus: () -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ Box {
+ RetroRowShell(
+ highlighted = highlighted,
+ activeBorder = false,
+ paneScale = paneScale,
+ onClick = {
+ onFocus()
+ expanded = true
+ },
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.label,
+ color = DrawerTextPrimary,
+ fontSize = (14f * paneScale).sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = entry.values.getOrNull(entry.selectedIndex) ?: "",
+ color = DrawerActiveAccent,
+ fontSize = (12f * paneScale).sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ Text(
+ text = "▾",
+ color = DrawerTextSecondary,
+ fontSize = (16f * paneScale).sp,
+ )
+ }
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ containerColor = WinNativeSurface,
+ ) {
+ entry.values.forEachIndexed { index, value ->
+ DropdownMenuItem(
+ text = {
+ Text(
+ text = value,
+ color = if (index == entry.selectedIndex) DrawerActiveAccent else DrawerTextPrimary,
+ fontSize = (13f * paneScale).sp,
+ fontWeight = if (index == entry.selectedIndex) FontWeight.SemiBold else FontWeight.Medium,
+ )
+ },
+ onClick = {
+ expanded = false
+ entry.onSelected(index)
+ },
+ )
+ }
+ }
+ }
+}
+
+/**
+ * A cycling row whose value set the host does not know; see
+ * [RetroMenuEntry.Stepper].
+ *
+ * Laid out like [RetroChoiceRow] so the two are indistinguishable to the
+ * player, but with explicit arrows instead of a dropdown: with no list of
+ * values there is nothing to drop down, and stepping is the only thing the
+ * owner of the setting exposes. Tapping the row steps forward, which is what
+ * the chevron on a Choice row does too.
+ */
+@Composable
+private fun RetroStepperRow(
+ entry: RetroMenuEntry.Stepper,
+ highlighted: Boolean,
+ paneScale: Float,
+ onFocus: () -> Unit,
+) {
+ RetroRowShell(
+ highlighted = highlighted,
+ activeBorder = false,
+ paneScale = paneScale,
+ onClick = {
+ onFocus()
+ entry.onStep(1)
+ },
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.label,
+ color = DrawerTextPrimary,
+ fontSize = (14f * paneScale).sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = entry.valueText,
+ color = DrawerActiveAccent,
+ fontSize = (12f * paneScale).sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ // Separately tappable, so a value can be walked backwards without
+ // cycling all the way round -- some engine ladders are long.
+ Text(
+ text = "◂",
+ color = DrawerTextSecondary,
+ fontSize = (16f * paneScale).sp,
+ modifier =
+ Modifier
+ .clickable {
+ onFocus()
+ entry.onStep(-1)
+ }
+ .padding(horizontal = 6.dp),
+ )
+ Text(
+ text = "▸",
+ color = DrawerTextSecondary,
+ fontSize = (16f * paneScale).sp,
+ modifier =
+ Modifier
+ .clickable {
+ onFocus()
+ entry.onStep(1)
+ }
+ .padding(horizontal = 6.dp),
+ )
+ }
+}
+
+@Composable
+private fun RetroRadioRow(
+ entry: RetroMenuEntry.Radio,
+ highlighted: Boolean,
+ paneScale: Float,
+ onClick: () -> Unit,
+) {
+ RetroRowShell(highlighted = highlighted, activeBorder = entry.selected, paneScale = paneScale, onClick = onClick) {
+ Text(
+ text = entry.label,
+ color = if (entry.selected) DrawerActiveAccent else DrawerTextPrimary,
+ fontSize = (14f * paneScale).sp,
+ fontWeight = if (entry.selected) FontWeight.SemiBold else FontWeight.Medium,
+ modifier = Modifier.weight(1f),
+ )
+ if (entry.selected) {
+ Text(
+ text = "✓",
+ color = DrawerActiveAccent,
+ fontSize = (14f * paneScale).sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+}
+
+@Composable
+private fun RetroChipsGroup(
+ entry: RetroMenuEntry.Chips,
+ highlighted: Boolean,
+ chipFocus: Int,
+ paneScale: Float,
+ onChipClick: (Int) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp)) {
+ Text(
+ text = entry.label,
+ color = DrawerTextSecondary,
+ fontSize = (11f * paneScale).sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.8.sp,
+ )
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp),
+ verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp),
+ ) {
+ entry.items.forEachIndexed { index, item ->
+ RetroHudChip(
+ label = item,
+ checked = entry.states.getOrElse(index) { false },
+ focused = highlighted && chipFocus == index,
+ paneScale = paneScale,
+ onClick = { onChipClick(index) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroHudChip(
+ label: String,
+ checked: Boolean,
+ focused: Boolean,
+ paneScale: Float,
+ onClick: () -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val pressed = interactionSource.collectIsPressedAsState().value
+ val bgColor by animateColorAsState(
+ targetValue =
+ when {
+ focused -> DrawerFocusFill
+ pressed -> PaneInnerPressed
+ else -> PaneInnerResting
+ },
+ animationSpec = tween(140),
+ label = "retroChipBg",
+ )
+ val borderColor by animateColorAsState(
+ targetValue = if (checked) DrawerAccent else RestingCardBorder,
+ animationSpec = tween(140),
+ label = "retroChipBorder",
+ )
+ val cornerRadius = (12f * paneScale).dp
+ val shape = RoundedCornerShape(cornerRadius)
+ Row(
+ modifier =
+ Modifier
+ .clip(shape)
+ .background(bgColor)
+ .border(1.dp, borderColor, shape)
+ .then(
+ if (focused) {
+ Modifier.chasingBorder(cornerRadius = cornerRadius, borderWidth = 1.5.dp, animationDurationMs = 8200)
+ } else {
+ Modifier
+ },
+ )
+ .clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
+ .padding(horizontal = (10f * paneScale).dp, vertical = (9f * paneScale).dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .size((10f * paneScale).dp)
+ .clip(androidx.compose.foundation.shape.CircleShape)
+ .background(if (checked) DrawerAccent else Color(0x14FFFFFF)),
+ )
+ Spacer(Modifier.width((8f * paneScale).dp))
+ Text(
+ text = label,
+ color = DrawerTextPrimary,
+ fontSize = (13f * paneScale).sp,
+ fontWeight = if (checked) FontWeight.SemiBold else FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+@Composable
+private fun RetroSaveSlotRow(
+ entry: RetroMenuEntry.SaveSlot,
+ highlighted: Boolean,
+ paneScale: Float,
+ onClick: () -> Unit,
+) {
+ RetroRowShell(
+ highlighted = highlighted,
+ activeBorder = entry.filled,
+ paneScale = paneScale,
+ onClick = onClick,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .size((28f * paneScale).dp)
+ .clip(androidx.compose.foundation.shape.CircleShape)
+ .background(if (entry.filled) DrawerAccent.copy(alpha = 0.24f) else Color(0x14FFFFFF)),
+ ) {
+ Text(
+ text = entry.slot.toString(),
+ color = if (entry.filled) DrawerActiveAccent else DrawerTextSecondary,
+ fontSize = (12f * paneScale).sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ Spacer(Modifier.width((10f * paneScale).dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.title,
+ color = DrawerTextPrimary,
+ fontSize = (13f * paneScale).sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = entry.subtitle,
+ color = if (entry.filled) DrawerActiveAccent else DrawerTextSecondary,
+ fontSize = (11f * paneScale).sp,
+ )
+ }
+ if (entry.filled) {
+ Icon(
+ imageVector = Icons.Outlined.Edit,
+ contentDescription = stringResource(R.string.retro_scr_rename),
+ tint = DrawerTextSecondary,
+ modifier =
+ Modifier
+ .size((18f * paneScale).dp)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) { entry.onRename() },
+ )
+ }
+ }
+}
+
+@Composable
+internal fun RetroRenameDialog(
+ prompt: RetroRenamePrompt,
+ onDismiss: () -> Unit,
+) {
+ var text by remember(prompt) { mutableStateOf(prompt.initial) }
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(Color(0x99000000))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) { onDismiss() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .width(320.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(WinNativeSurface)
+ .border(1.dp, WinNativeOutline, RoundedCornerShape(16.dp))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) {}
+ .padding(16.dp),
+ ) {
+ Text(
+ text = prompt.title,
+ color = DrawerTextPrimary,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(Modifier.height(10.dp))
+ OutlinedTextField(
+ value = text,
+ onValueChange = { if (it.length <= 48) text = it },
+ singleLine = true,
+ textStyle = androidx.compose.ui.text.TextStyle(color = DrawerTextPrimary, fontSize = 14.sp),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(12.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Spacer(Modifier.weight(1f))
+ Text(
+ text = stringResource(R.string.retro_scr_cancel),
+ color = DrawerTextSecondary,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .clickable { onDismiss() }
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ )
+ Text(
+ text = stringResource(R.string.retro_scr_save),
+ color = DrawerActiveAccent,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .clickable {
+ prompt.onConfirm(text.trim().ifEmpty { null })
+ onDismiss()
+ }
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+internal fun RetroConflictDialog(prompt: RetroConflictPrompt) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(Color(0xCC000000))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) {},
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .width(340.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(WinNativeSurface)
+ .border(1.dp, WinNativeOutline, RoundedCornerShape(16.dp))
+ .padding(16.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.retro_scr_cloud_save_conflict),
+ color = DrawerTextPrimary,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = prompt.message,
+ color = DrawerTextSecondary,
+ fontSize = 12.sp,
+ )
+ Spacer(Modifier.height(12.dp))
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ prompt.options.forEachIndexed { index, label ->
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(10.dp))
+ .background(PaneInnerResting)
+ .border(1.dp, RestingCardBorder, RoundedCornerShape(10.dp))
+ .clickable { prompt.onPick(index) }
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ ) {
+ Text(
+ text = label,
+ color = DrawerTextPrimary,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ }
+ }
+ Spacer(Modifier.height(12.dp))
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(10.dp))
+ .background(DrawerFocusFill)
+ .border(1.dp, DrawerAccent, RoundedCornerShape(10.dp))
+ .clickable { prompt.onKeepLocal() }
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = stringResource(R.string.retro_scr_keep_local_save),
+ color = DrawerActiveAccent,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroColorRow(
+ entry: RetroMenuEntry.ColorPick,
+ highlighted: Boolean,
+ paneScale: Float,
+ onFocus: () -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ Column {
+ RetroRowShell(
+ highlighted = highlighted,
+ activeBorder = entry.color != null,
+ paneScale = paneScale,
+ onClick = {
+ onFocus()
+ expanded = !expanded
+ },
+ ) {
+ Text(
+ text = entry.label,
+ color = DrawerTextPrimary,
+ fontSize = (14f * paneScale).sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.weight(1f),
+ )
+ Box(
+ modifier =
+ Modifier
+ .size((20f * paneScale).dp)
+ .clip(androidx.compose.foundation.shape.CircleShape)
+ .background(entry.color?.let { Color(it) } ?: Color(0x14FFFFFF))
+ .border(
+ 1.dp,
+ if (entry.color != null) DrawerAccent else RestingCardBorder,
+ androidx.compose.foundation.shape.CircleShape,
+ ),
+ ) {
+ if (entry.color == null) {
+ Text(
+ text = stringResource(R.string.retro_scr_auto_color_abbrev),
+ color = DrawerTextSecondary,
+ fontSize = (10f * paneScale).sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ }
+ Spacer(Modifier.width((8f * paneScale).dp))
+ Text(
+ text = if (expanded) "▴" else "▾",
+ color = DrawerTextSecondary,
+ fontSize = (16f * paneScale).sp,
+ )
+ }
+ AnimatedVisibility(visible = expanded) {
+ FlowRow(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = (6f * paneScale).dp, vertical = (8f * paneScale).dp),
+ horizontalArrangement = Arrangement.spacedBy((7f * paneScale).dp),
+ verticalArrangement = Arrangement.spacedBy((7f * paneScale).dp),
+ ) {
+ RetroColorSwatch(
+ color = null,
+ selected = entry.color == null,
+ paneScale = paneScale,
+ ) {
+ entry.onPick(null)
+ }
+ RetroColorPalette.forEach { swatch ->
+ RetroColorSwatch(
+ color = swatch,
+ selected = entry.color == swatch,
+ paneScale = paneScale,
+ ) {
+ entry.onPick(swatch)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroColorSwatch(
+ color: Int?,
+ selected: Boolean,
+ paneScale: Float,
+ onClick: () -> Unit,
+) {
+ val shape = androidx.compose.foundation.shape.CircleShape
+ Box(
+ modifier =
+ Modifier
+ .size((26f * paneScale).dp)
+ .clip(shape)
+ .background(color?.let { Color(it) } ?: Color(0x14FFFFFF))
+ .border(
+ if (selected) 2.dp else 1.dp,
+ if (selected) DrawerActiveAccent else RestingCardBorder,
+ shape,
+ )
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onClick,
+ ),
+ ) {
+ if (color == null) {
+ Text(
+ text = stringResource(R.string.retro_scr_auto_color_abbrev),
+ color = DrawerTextSecondary,
+ fontSize = (11f * paneScale).sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ }
+}
+
+@Composable
+private fun RetroSliderRow(
+ entry: RetroMenuEntry.Slider,
+ highlighted: Boolean,
+ paneScale: Float,
+ onClick: () -> Unit,
+) {
+ RetroRowShell(highlighted = highlighted, activeBorder = false, paneScale = paneScale, onClick = onClick) {
+ Column(modifier = Modifier.weight(1f)) {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = entry.label,
+ color = DrawerTextPrimary,
+ fontSize = (14f * paneScale).sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = entry.valueText,
+ color = DrawerActiveAccent,
+ fontSize = (12f * paneScale).sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ val stepCount = (((entry.max - entry.min) / entry.step).toInt() - 1).coerceAtLeast(0)
+ Slider(
+ value = entry.value,
+ onValueChange = { raw ->
+ val snapped =
+ (kotlin.math.round((raw - entry.min) / entry.step) * entry.step + entry.min)
+ .coerceIn(entry.min, entry.max)
+ if (snapped != entry.value) entry.onChange(snapped)
+ },
+ valueRange = entry.min..entry.max,
+ steps = stepCount,
+ colors =
+ SliderDefaults.colors(
+ thumbColor = DrawerAccent,
+ activeTrackColor = DrawerAccent,
+ inactiveTrackColor = WinNativeOutline.copy(alpha = 0.5f),
+ activeTickColor = Color.Transparent,
+ inactiveTickColor = Color.Transparent,
+ ),
+ modifier = Modifier.fillMaxWidth().height((26f * paneScale).dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun RetroBottomActions(
+ controller: RetroMenuController,
+ paneScale: Float,
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = (10f * paneScale).dp, vertical = (8f * paneScale).dp),
+ horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ controller.bottomEntries.forEachIndexed { index, entry ->
+ RetroBottomActionButton(
+ entry = entry,
+ highlighted =
+ controller.controllerActive &&
+ controller.region == 2 &&
+ controller.bottomIndex == index,
+ paneScale = paneScale,
+ modifier = Modifier.weight(1f),
+ onClick = {
+ controller.bottomIndex = index
+ entry.onClick()
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun RetroBottomActionButton(
+ entry: RetroMenuEntry.Action,
+ highlighted: Boolean,
+ paneScale: Float,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val pressed = interactionSource.collectIsPressedAsState().value
+ val bgColor by animateColorAsState(
+ targetValue =
+ when {
+ highlighted -> DrawerFocusFill
+ entry.danger && pressed -> TileExitPressed
+ entry.danger -> TileExitResting
+ pressed -> PaneSurfacePressed
+ else -> PaneInnerResting
+ },
+ animationSpec = tween(120),
+ label = "retroBottomBg",
+ )
+ val borderColor =
+ when {
+ entry.danger -> GlassExitTint.copy(alpha = 0.34f)
+ entry.active -> ActiveCardBorder
+ else -> RestingCardBorder
+ }
+ val tint =
+ when {
+ entry.danger -> GlassExitTint
+ entry.active -> DrawerActiveAccent
+ else -> DrawerTextPrimary
+ }
+ val cornerRadius = (14f * paneScale).dp
+ val shape = RoundedCornerShape(cornerRadius)
+ Row(
+ modifier =
+ modifier
+ .clip(shape)
+ .background(bgColor)
+ .border(1.dp, borderColor, shape)
+ .then(
+ if (highlighted) {
+ Modifier.chasingBorder(cornerRadius = cornerRadius, borderWidth = 1.5.dp, animationDurationMs = 8200)
+ } else {
+ Modifier
+ },
+ )
+ .clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
+ .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = entry.icon,
+ contentDescription = entry.label,
+ tint = tint,
+ modifier = Modifier.size((18f * paneScale).dp),
+ )
+ Spacer(Modifier.width((8f * paneScale).dp))
+ Text(
+ text = entry.label,
+ color = tint,
+ fontSize = (13f * paneScale).sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+@Composable
+private fun ThinDivider() {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(DividerColor),
+ )
+}
+
+object RetroDrawerTabs {
+ fun build(
+ context: android.content.Context,
+ includePerformance: Boolean = false,
+ includeNetplay: Boolean = false,
+ includeSystem: Boolean = false,
+ ): List {
+ val tabs = mutableListOf()
+ tabs += RetroTabSpec(null, Icons.Outlined.Apps, context.getString(R.string.retro_tab_menu))
+ tabs += RetroTabSpec(RetroPane.DISPLAY, Icons.Outlined.Monitor, context.getString(R.string.retro_tab_display))
+ if (includePerformance) {
+ tabs += RetroTabSpec(RetroPane.PERFORMANCE, Icons.Outlined.Bolt, context.getString(R.string.retro_ps2_tab_performance))
+ }
+ tabs += RetroTabSpec(RetroPane.HUD, Icons.Outlined.Speed, context.getString(R.string.retro_tab_hud))
+ tabs += RetroTabSpec(RetroPane.SOUND, Icons.AutoMirrored.Outlined.VolumeUp, context.getString(R.string.retro_tab_sound))
+ tabs += RetroTabSpec(RetroPane.CONTROLS, Icons.Outlined.SportsEsports, context.getString(R.string.retro_tab_controls))
+ if (includeSystem) {
+ tabs += RetroTabSpec(RetroPane.SYSTEM, Icons.Outlined.Tune, context.getString(R.string.retro_tab_system))
+ }
+ if (includeNetplay) {
+ tabs += RetroTabSpec(RetroPane.NETWORK, Icons.Outlined.Wifi, context.getString(R.string.retro_tab_netplay))
+ }
+ return tabs
+ }
+}
+
+object RetroDrawerIcons {
+ val EditLayout = Icons.Outlined.Tune
+ val Resume = Icons.Outlined.PlayArrow
+ val Pause = Icons.Outlined.Pause
+ val Save = Icons.Outlined.Save
+ val Load = Icons.Outlined.Download
+ val Reset = Icons.Outlined.RestartAlt
+ val FastForward = Icons.Outlined.FastForward
+ val Disc = Icons.Outlined.Album
+ val Hud = Icons.Outlined.Speed
+ val Achievements = Icons.Outlined.EmojiEvents
+ val Cheats = Icons.Outlined.Bolt
+ val Add = Icons.Outlined.Add
+ val Play = Icons.Outlined.PlayArrow
+ val Group = Icons.Outlined.Group
+ val Link = Icons.Outlined.Link
+ val Search = Icons.Outlined.Search
+ val Exit = Icons.AutoMirrored.Outlined.ExitToApp
+}
diff --git a/app/src/main/feature/retro/RetroGameLink.kt b/app/src/main/feature/retro/RetroGameLink.kt
new file mode 100644
index 000000000..d32074f12
--- /dev/null
+++ b/app/src/main/feature/retro/RetroGameLink.kt
@@ -0,0 +1,142 @@
+package com.winlator.cmod.feature.retro
+
+import com.swordfish.libretrodroid.GLRetroView
+import com.swordfish.libretrodroid.Variable
+
+object RetroGameLink {
+ const val DEFAULT_PORT = 56400
+
+ const val MODE_KEY = "gambatte_gb_link_mode"
+ const val PORT_KEY = "gambatte_gb_link_network_port"
+ private const val IP_KEY_PREFIX = "gambatte_gb_link_network_server_ip_"
+
+ const val MODE_OFF = "Not Connected"
+ const val MODE_SERVER = "Network Server"
+ const val MODE_CLIENT = "Network Client"
+
+ fun isSupported(systemId: String?): Boolean =
+ systemId == RetroSystems.GAMEBOY.id ||
+ systemId == RetroSystems.GAMEBOY_COLOR.id ||
+ systemId == RetroSystems.GBA.id
+
+ const val GPSP_SERIAL_KEY = "gpsp_serial"
+ const val GPSP_SERIAL_RFU = "rfu"
+ const val GBA_NETPACKET_PORT = 55437
+
+ fun clampPort(port: Int): Int =
+ when {
+ port in 56400..56420 -> port
+ else -> DEFAULT_PORT
+ }
+
+ fun ipv4ToDigits(ip: String): List {
+ val parts =
+ ip.trim()
+ .split('.')
+ .mapNotNull { it.toIntOrNull()?.coerceIn(0, 255) }
+ if (parts.size != 4) return List(12) { "0" }
+ return parts.flatMap { octet ->
+ val s = "%03d".format(octet)
+ listOf(s[0].toString(), s[1].toString(), s[2].toString())
+ }
+ }
+
+ fun digitsToIpv4(digits: List): String {
+ if (digits.size < 12) return "0.0.0.0"
+ fun octet(a: Int) =
+ ((digits.getOrNull(a)?.toIntOrNull() ?: 0) * 100 +
+ (digits.getOrNull(a + 1)?.toIntOrNull() ?: 0) * 10 +
+ (digits.getOrNull(a + 2)?.toIntOrNull() ?: 0))
+ .coerceIn(0, 255)
+ return "${octet(0)}.${octet(3)}.${octet(6)}.${octet(9)}"
+ }
+
+ fun buildHostVariables(port: Int): List {
+ val p = clampPort(port)
+ return listOf(
+ Variable(MODE_KEY, MODE_SERVER),
+ Variable(PORT_KEY, p.toString()),
+ )
+ }
+
+ fun buildClientVariables(
+ hostIp: String,
+ port: Int,
+ ): List {
+ val p = clampPort(port)
+ val digits = ipv4ToDigits(hostIp)
+ val vars = mutableListOf(
+ Variable(MODE_KEY, MODE_CLIENT),
+ Variable(PORT_KEY, p.toString()),
+ )
+ digits.forEachIndexed { i, d ->
+ vars += Variable("$IP_KEY_PREFIX${i + 1}", d)
+ }
+ return vars
+ }
+
+ fun buildDisconnectVariables(): List =
+ listOf(Variable(MODE_KEY, MODE_OFF))
+
+ fun applyToCore(
+ retroView: GLRetroView,
+ variables: List,
+ ) {
+ if (variables.isEmpty()) return
+ runCatching {
+ retroView.updateVariables(*variables.toTypedArray())
+ }
+ }
+
+ fun startHost(
+ retroView: GLRetroView?,
+ port: Int,
+ ) {
+ val vars = buildHostVariables(port)
+ if (retroView != null) applyToCore(retroView, vars)
+ }
+
+ fun startClient(
+ retroView: GLRetroView?,
+ hostIp: String,
+ port: Int,
+ ) {
+ val vars = buildClientVariables(hostIp, port)
+ if (retroView != null) applyToCore(retroView, vars)
+ }
+
+ fun stop(retroView: GLRetroView?) {
+ runCatching { retroView?.netpacketStop() }
+ if (retroView != null) {
+ runCatching { applyToCore(retroView, buildDisconnectVariables()) }
+ runCatching {
+ retroView.updateVariables(Variable(GPSP_SERIAL_KEY, "disabled"))
+ }
+ }
+ }
+
+ fun startGbaHost(
+ retroView: GLRetroView,
+ port: Int = GBA_NETPACKET_PORT,
+ ): Boolean {
+ applyToCore(
+ retroView,
+ listOf(Variable(GPSP_SERIAL_KEY, GPSP_SERIAL_RFU)),
+ )
+ if (!retroView.netpacketHasCore()) return false
+ return retroView.netpacketStartHost(port)
+ }
+
+ fun startGbaClient(
+ retroView: GLRetroView,
+ hostIp: String,
+ port: Int = GBA_NETPACKET_PORT,
+ ): Boolean {
+ applyToCore(
+ retroView,
+ listOf(Variable(GPSP_SERIAL_KEY, GPSP_SERIAL_RFU)),
+ )
+ if (!retroView.netpacketHasCore()) return false
+ return retroView.netpacketStartClient(hostIp, port)
+ }
+}
diff --git a/app/src/main/feature/retro/RetroGameSettings.kt b/app/src/main/feature/retro/RetroGameSettings.kt
new file mode 100644
index 000000000..4c0999bab
--- /dev/null
+++ b/app/src/main/feature/retro/RetroGameSettings.kt
@@ -0,0 +1,2025 @@
+package com.winlator.cmod.feature.retro
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.VolumeUp
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material.icons.outlined.KeyboardArrowDown
+import androidx.compose.material.icons.outlined.Memory
+import androidx.compose.material.icons.outlined.Bolt
+import androidx.compose.material.icons.outlined.Code
+import androidx.compose.material.icons.outlined.Monitor
+import androidx.compose.material.icons.outlined.Public
+import androidx.compose.material.icons.outlined.Speed
+import androidx.compose.material.icons.outlined.SportsEsports
+import androidx.compose.material.icons.outlined.Tune
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.PopupProperties
+import com.winlator.cmod.R
+import com.winlator.cmod.shared.theme.GameSettingsStyle
+import com.winlator.cmod.shared.ui.settings.SharedGroupTitle
+import com.winlator.cmod.shared.ui.settings.SharedInfoRow
+import com.winlator.cmod.shared.ui.settings.SharedSettingGroup
+import com.winlator.cmod.shared.ui.settings.SharedSettingSwitch
+import com.winlator.cmod.feature.library.GameSettingsNav
+import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork
+import com.winlator.cmod.runtime.container.Shortcut
+import java.io.File
+import com.winlator.cmod.shared.ui.nav.LocalPaneNav
+import com.winlator.cmod.shared.ui.nav.PaneNavRegistry
+import com.winlator.cmod.shared.ui.nav.paneHighlight
+import com.winlator.cmod.shared.ui.nav.paneNavItem
+import com.winlator.cmod.shared.ui.outlinedSwitchColors
+import com.winlator.cmod.shared.ui.widget.chasingBorder
+
+private val BgDeep = GameSettingsStyle.BgDeep
+private val SidebarBg = GameSettingsStyle.SidebarBg
+private val ContentBg = GameSettingsStyle.ContentBg
+private val CardSurface = GameSettingsStyle.CardSurface
+private val CardBorder = GameSettingsStyle.CardBorder
+private val InputSurface = GameSettingsStyle.InputSurface
+private val InputBorder = GameSettingsStyle.InputBorder
+private val AccentBlue = GameSettingsStyle.AccentBlue
+private val TextPrimary = GameSettingsStyle.TextPrimary
+private val TextSecondary = GameSettingsStyle.TextSecondary
+private val TextDim = GameSettingsStyle.TextDim
+private val DividerColor = GameSettingsStyle.Divider
+private val NavHighlight = GameSettingsStyle.NavHighlight
+private val DangerRed = GameSettingsStyle.DangerRed
+
+private val LabelSize = 11.sp
+private val ValueSize = 12.sp
+private val GroupCorner = 12.dp
+private val GroupPadding = 12.dp
+private val FieldCorner = 8.dp
+private val ItemGap = 10.dp
+private val TightGap = 4.dp
+
+private val SHADER_KEYS = listOf("default", "crt", "lcd", "sharp")
+private val UPSCALE_KEYS = listOf("2x", "4x", "native")
+
+class RetroSettingsState(
+ val shortcut: Shortcut,
+ private val context: android.content.Context? = null,
+) {
+ val system: RetroSystem? = RetroShortcuts.systemForShortcut(shortcut)
+ val coreOptions: List = RetroCoreOptions.forSystem(system)
+ val name: String = shortcut.getExtra("custom_name", shortcut.name)
+ val romPath: String = shortcut.getExtra(RetroShortcuts.KEY_ROM)
+
+ private val sysId: String? = system?.id
+
+ var shader by mutableStateOf(
+ shortcut.getExtra(RetroShortcuts.KEY_SHADER)
+ .ifEmpty { if (context != null && sysId != null) RetroDefaults.shader(context, sysId) else "default" }
+ .lowercase().let { if (it in SHADER_KEYS) it else "default" },
+ )
+ var sgsr by mutableStateOf(
+ when {
+ shortcut.getExtra(RetroShortcuts.KEY_SGSR).isNotEmpty() -> shortcut.getExtra(RetroShortcuts.KEY_SGSR) == "1"
+ shortcut.getExtra(RetroShortcuts.KEY_SHADER, "default").lowercase() == "sgsr" -> true
+ context != null && sysId != null -> RetroDefaults.sgsr(context, sysId)
+ else -> false
+ },
+ )
+ var upscale by mutableStateOf(
+ shortcut.getExtra(RetroShortcuts.KEY_UPSCALE)
+ .ifEmpty { if (context != null && sysId != null) RetroDefaults.upscale(context, sysId) else "native" }
+ .lowercase().let { if (it in UPSCALE_KEYS) it else "native" },
+ )
+ // Off unless the player asks for it: the 3D engine replaces the libretro
+ // core entirely, so it must never become the default for a game.
+ var engine3d by mutableStateOf(shortcut.getExtra(Gen1EmbedLaunch.KEY_ENGINE_3D) == "1")
+ var touchControls by mutableStateOf(
+ shortcut.getExtra(RetroShortcuts.KEY_TOUCH_CONTROLS)
+ .ifEmpty { if (context != null && sysId != null) (if (RetroDefaults.touchControls(context, sysId)) "1" else "0") else "1" } != "0",
+ )
+ var adaptiveSticks by mutableStateOf(
+ shortcut.getExtra(RetroShortcuts.KEY_ADAPTIVE_STICKS)
+ .ifEmpty { if (context != null && sysId != null) (if (RetroDefaults.adaptiveSticks(context, sysId)) "1" else "0") else "0" } == "1",
+ )
+ var hddImage by mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_HDD_IMAGE))
+ var hddEnable by mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_HDD_ENABLE) == "1")
+ var audio by mutableStateOf(
+ shortcut.getExtra(RetroShortcuts.KEY_AUDIO)
+ .ifEmpty { if (context != null && sysId != null) (if (RetroDefaults.audio(context, sysId)) "1" else "0") else "1" } != "0",
+ )
+ var hud by mutableStateOf(
+ shortcut.getExtra(RetroShortcuts.KEY_HUD)
+ .ifEmpty {
+ if (context != null && sysId != null) {
+ if (RetroHudSupport.consoleHudDefault(context, sysId)) "1" else "0"
+ } else {
+ "0"
+ }
+ } == "1",
+ )
+
+ val optionValues =
+ mutableStateMapOf().apply {
+ coreOptions.forEach { option ->
+ put(
+ option.key,
+ shortcut.getExtra(RetroShortcuts.VAR_PREFIX + option.key).ifEmpty {
+ if (context != null && sysId != null) {
+ RetroDefaults.coreOption(context, sysId, option.key, option.defaultValue)
+ } else {
+ option.defaultValue
+ }
+ },
+ )
+ }
+ }
+ val artworkSelected = mutableStateMapOf()
+ var currentSection by mutableIntStateOf(0)
+ var biosRefresh by mutableIntStateOf(0)
+
+ var hddRefresh by mutableIntStateOf(0)
+ var hddImporting by mutableStateOf(false)
+ var requestImportHdd: (() -> Unit)? = null
+
+ val gameSerial: String? by lazy {
+ if (context != null && system?.id == RetroSystems.PS2.id) Ps2IsoSerial.serialOf(File(romPath)) else null
+ }
+ var cheatsRefresh by mutableIntStateOf(0)
+ var pendingImportIsPatch = false
+ var requestImportCheatFile: ((isPatch: Boolean) -> Unit)? = null
+
+ init {
+ syncArtwork()
+ }
+
+ fun syncArtwork() {
+ LibraryShortcutArtwork.LibraryArtworkSlot.entries.forEach { slot ->
+ val path = shortcut.getExtra(slot.extraKey)
+ artworkSelected[slot] = path.isNotBlank() && File(path).isFile
+ }
+ }
+
+ fun save() {
+ shortcut.putExtra(RetroShortcuts.KEY_SHADER, shader)
+ shortcut.putExtra(RetroShortcuts.KEY_SGSR, if (sgsr) "1" else "0")
+ shortcut.putExtra(RetroShortcuts.KEY_UPSCALE, upscale)
+ shortcut.putExtra(Gen1EmbedLaunch.KEY_ENGINE_3D, if (engine3d) "1" else "0")
+ shortcut.putExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (touchControls) "1" else "0")
+ shortcut.putExtra(RetroShortcuts.KEY_ADAPTIVE_STICKS, if (adaptiveSticks) "1" else "0")
+ shortcut.putExtra(RetroShortcuts.KEY_HDD_IMAGE, hddImage)
+ shortcut.putExtra(RetroShortcuts.KEY_HDD_ENABLE, if (hddEnable) "1" else "0")
+ shortcut.putExtra(RetroShortcuts.KEY_AUDIO, if (audio) "1" else "0")
+ shortcut.putExtra(RetroShortcuts.KEY_HUD, if (hud) "1" else "0")
+ coreOptions.forEach { option ->
+ val consoleDefault =
+ if (context != null && sysId != null) {
+ RetroDefaults.coreOption(context, sysId, option.key, option.defaultValue)
+ } else {
+ option.defaultValue
+ }
+ val chosen = optionValues[option.key] ?: consoleDefault
+ shortcut.putExtra(
+ RetroShortcuts.VAR_PREFIX + option.key,
+ if (chosen == consoleDefault) null else chosen,
+ )
+ }
+ shortcut.saveData()
+ }
+}
+
+private enum class RetroSectionId {
+ GENERAL,
+ GRAPHICS,
+ PERFORMANCE,
+ HUD,
+ INPUT,
+ AUDIO,
+ ONLINE,
+ CHEATS,
+}
+
+private data class RetroSection(
+ val id: RetroSectionId,
+ val icon: ImageVector,
+ val labelRes: Int,
+)
+
+private fun buildRetroSections(state: RetroSettingsState): List {
+ val sections = mutableListOf()
+ val systemId = state.system?.id
+ sections += RetroSection(RetroSectionId.GENERAL, Icons.Outlined.Tune, R.string.retro_gs_section_general)
+ sections += RetroSection(RetroSectionId.GRAPHICS, Icons.Outlined.Monitor, R.string.retro_gs_section_graphics)
+ if (state.system?.isExternal == true) {
+ sections += RetroSection(RetroSectionId.PERFORMANCE, Icons.Outlined.Bolt, R.string.retro_gs_section_performance)
+ }
+ sections += RetroSection(RetroSectionId.HUD, Icons.Outlined.Speed, R.string.retro_gs_section_hud)
+ sections += RetroSection(RetroSectionId.INPUT, Icons.Outlined.SportsEsports, R.string.retro_gs_section_input)
+ sections += RetroSection(RetroSectionId.AUDIO, Icons.AutoMirrored.Outlined.VolumeUp, R.string.retro_gs_section_audio)
+ if (RetroOnlineSupport.supportsDev9(systemId) ||
+ RetroOnlineSupport.supportsMultiplayerUi(systemId) ||
+ RetroOnlineSupport.supportsDolphinNetplay(systemId)
+ ) {
+ sections += RetroSection(RetroSectionId.ONLINE, Icons.Outlined.Public, R.string.retro_gs_section_online)
+ }
+ if (state.system?.isExternal == true) {
+ sections += RetroSection(RetroSectionId.CHEATS, Icons.Outlined.Code, R.string.retro_gs_group_cheats)
+ }
+ return sections
+}
+
+@Composable
+fun RetroGameSettingsContent(
+ state: RetroSettingsState,
+ nav: GameSettingsNav? = null,
+ onPickArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null,
+ onRemoveArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null,
+ onImportBios: (() -> Unit)? = null,
+ onSave: () -> Unit,
+ onCancel: () -> Unit,
+) {
+ val sections = remember(state) { buildRetroSections(state) }
+ val selectedIdx = state.currentSection
+
+ if (nav != null) {
+ SideEffect {
+ nav.sidebarCount = sections.size
+ nav.onSelectSection = { state.currentSection = it }
+ nav.onSave = onSave
+ nav.onCancel = onCancel
+ }
+ }
+
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .clip(RoundedCornerShape(16.dp))
+ .background(BgDeep),
+ ) {
+ Row(modifier = Modifier.fillMaxSize()) {
+ RetroSidebar(
+ title = state.name,
+ subtitle = state.system?.displayName ?: "",
+ sections = sections,
+ currentIndex = selectedIdx,
+ onSectionSelected = { state.currentSection = it },
+ onSave = onSave,
+ onCancel = onCancel,
+ nav = nav,
+ modifier = Modifier.width(220.dp).fillMaxHeight(),
+ )
+ Box(Modifier.width(1.dp).fillMaxHeight().background(DividerColor))
+ Box(
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .background(ContentBg),
+ ) {
+ RetroSectionContent(
+ sectionIndex = selectedIdx,
+ state = state,
+ nav = nav,
+ onPickArtwork = onPickArtwork,
+ onRemoveArtwork = onRemoveArtwork,
+ onImportBios = onImportBios,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroSectionContent(
+ sectionIndex: Int,
+ state: RetroSettingsState,
+ nav: GameSettingsNav? = null,
+ onPickArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null,
+ onRemoveArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null,
+ onImportBios: (() -> Unit)? = null,
+) {
+ run {
+ val idx = sectionIndex
+ val contentNav = remember(nav) { nav?.let { PaneNavRegistry(initialSignal = it.contentSignal) } }
+ val isCurrent = true
+ if (nav != null && contentNav != null) {
+ contentNav.controllerActive = nav.active && nav.inContent && isCurrent
+ contentNav.onEdgeLeft = { nav.exitToSidebar() }
+ if (isCurrent) {
+ nav.onContentBack = {
+ if (contentNav.overlay != null) {
+ contentNav.overlayClose?.invoke()
+ true
+ } else {
+ false
+ }
+ }
+ }
+ LaunchedEffect(nav.contentSignal) {
+ if (isCurrent) contentNav.processNav(nav.contentSignal, nav.contentDir)
+ }
+ LaunchedEffect(nav.contentResetSignal) {
+ if (isCurrent) contentNav.reset()
+ }
+ }
+ val sections = remember(state) { buildRetroSections(state) }
+ val sectionBody: @Composable () -> Unit = {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp, vertical = 14.dp),
+ ) {
+ when (sections.getOrNull(idx)?.id) {
+ RetroSectionId.GENERAL -> RetroGeneralSection(state, onPickArtwork, onRemoveArtwork, onImportBios)
+ RetroSectionId.GRAPHICS -> RetroGraphicsSection(state)
+ RetroSectionId.PERFORMANCE -> RetroPs2PerformanceSection()
+ RetroSectionId.HUD ->
+ if (state.system?.isExternal == true) {
+ RetroPs2HudSection(state)
+ } else {
+ RetroLibretroHudSection(state.hud) { state.hud = it }
+ }
+ RetroSectionId.INPUT -> RetroInputSection(state)
+ RetroSectionId.AUDIO -> RetroAudioSection(state)
+ RetroSectionId.ONLINE -> {
+ when {
+ RetroOnlineSupport.supportsDev9(state.system?.id) ->
+ RetroPs2OnlineSection(state)
+ RetroOnlineSupport.supportsDolphinNetplay(state.system?.id) ->
+ RetroDolphinNetplaySection(state)
+ else -> RetroNetplaySection(state)
+ }
+ }
+ RetroSectionId.CHEATS -> RetroPs2CheatsSection(state)
+ null -> Unit
+ }
+ Spacer(Modifier.height(12.dp))
+ }
+ }
+ if (contentNav != null) {
+ CompositionLocalProvider(LocalPaneNav provides contentNav) { sectionBody() }
+ } else {
+ sectionBody()
+ }
+ }
+}
+
+@Composable
+private fun RetroSidebar(
+ title: String,
+ subtitle: String,
+ sections: List,
+ currentIndex: Int,
+ onSectionSelected: (Int) -> Unit,
+ onSave: () -> Unit,
+ onCancel: () -> Unit,
+ nav: GameSettingsNav? = null,
+ modifier: Modifier = Modifier,
+) {
+ val cancelHighlighted = nav != null && nav.active && !nav.inContent && nav.onActionRow && nav.actionCol == 0
+ val saveHighlighted = nav != null && nav.active && !nav.inContent && nav.onActionRow && nav.actionCol == 1
+ Column(
+ modifier =
+ modifier
+ .background(SidebarBg)
+ .padding(top = 14.dp, bottom = 12.dp),
+ ) {
+ Text(
+ text = title,
+ color = TextPrimary,
+ fontSize = LabelSize,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.2.sp,
+ lineHeight = 15.sp,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 2.dp),
+ )
+ if (subtitle.isNotBlank()) {
+ Text(
+ text = subtitle,
+ color = TextSecondary,
+ fontSize = 10.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 10.dp),
+ )
+ }
+ Box(
+ modifier =
+ Modifier
+ .padding(horizontal = 12.dp)
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(DividerColor),
+ )
+ Spacer(Modifier.height(8.dp))
+
+ Column(
+ modifier =
+ Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ .padding(bottom = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(1.dp),
+ ) {
+ sections.forEachIndexed { index, section ->
+ RetroSidebarItem(
+ icon = section.icon,
+ label = stringResource(section.labelRes),
+ isSelected = currentIndex == index,
+ navHighlighted =
+ nav != null && nav.active && !nav.inContent && !nav.onActionRow &&
+ nav.sidebarIndex == index,
+ onClick = {
+ if (nav != null) nav.tapSection(index) else onSectionSelected(index)
+ },
+ )
+ }
+ }
+
+ Box(
+ modifier =
+ Modifier
+ .padding(horizontal = 12.dp)
+ .fillMaxWidth()
+ .height(1.dp)
+ .background(DividerColor),
+ )
+ Spacer(Modifier.height(8.dp))
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .weight(1f)
+ .height(30.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .border(1.dp, CardBorder, RoundedCornerShape(8.dp))
+ .background(CardSurface)
+ .paneHighlight(cancelHighlighted, cornerRadius = 8.dp, highlightColor = NavHighlight)
+ .clickable {
+ nav?.tapAction(0)
+ onCancel()
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(stringResource(R.string.retro_gs_cancel), color = TextSecondary, fontSize = LabelSize, fontWeight = FontWeight.Medium)
+ }
+ Box(
+ modifier =
+ Modifier
+ .weight(1f)
+ .height(30.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .border(1.dp, AccentBlue.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
+ .background(AccentBlue.copy(alpha = 0.1f))
+ .paneHighlight(saveHighlighted, cornerRadius = 8.dp, highlightColor = NavHighlight)
+ .clickable {
+ nav?.tapAction(1)
+ onSave()
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(stringResource(R.string.retro_gs_save), color = AccentBlue, fontSize = LabelSize, fontWeight = FontWeight.Medium)
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroSidebarItem(
+ icon: ImageVector,
+ label: String,
+ isSelected: Boolean,
+ navHighlighted: Boolean = false,
+ onClick: () -> Unit,
+) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .chasingBorder(isFocused = isSelected, cornerRadius = 8.dp, borderWidth = 2.dp)
+ .paneHighlight(navHighlighted, cornerRadius = 8.dp, highlightColor = NavHighlight)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onClick,
+ )
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ icon,
+ contentDescription = null,
+ tint = if (isSelected) AccentBlue else TextDim,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(Modifier.width(10.dp))
+ Text(
+ label,
+ color = if (isSelected) TextPrimary else TextSecondary,
+ fontSize = ValueSize,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+@Composable
+internal fun RetroSettingGroup(content: @Composable () -> Unit) {
+ SharedSettingGroup(content)
+}
+
+@Composable
+internal fun RetroGroupTitle(text: String) {
+ SharedGroupTitle(text)
+}
+
+@Composable
+internal fun RetroInfoRow(
+ label: String,
+ value: String,
+ singleLineValue: Boolean = false,
+) {
+ SharedInfoRow(label, value, singleLineValue = singleLineValue)
+}
+
+@Composable
+internal fun RetroSettingDropdown(
+ label: String,
+ entries: List,
+ selectedIndex: Int,
+ onSelected: (Int) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ val selectedText = entries.getOrElse(selectedIndex) { "" }
+ val parentNav = LocalPaneNav.current
+ val optionRegistry = remember { PaneNavRegistry() }
+ LaunchedEffect(expanded) {
+ if (expanded) {
+ optionRegistry.reset()
+ optionRegistry.controllerActive = true
+ parentNav?.overlay = optionRegistry
+ parentNav?.overlayClose = { expanded = false }
+ } else if (parentNav?.overlay === optionRegistry) {
+ parentNav.overlay = null
+ parentNav.overlayClose = null
+ }
+ }
+
+ Column(modifier = Modifier.fillMaxWidth().padding(bottom = ItemGap)) {
+ Text(
+ label,
+ color = TextSecondary,
+ fontSize = LabelSize,
+ fontWeight = FontWeight.Medium,
+ letterSpacing = 0.3.sp,
+ modifier = Modifier.padding(bottom = TightGap),
+ )
+ Box {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(FieldCorner))
+ .background(InputSurface)
+ .border(1.dp, InputBorder, RoundedCornerShape(FieldCorner))
+ .paneNavItem(
+ cornerRadius = FieldCorner,
+ onActivate = { expanded = true },
+ highlightColor = NavHighlight,
+ )
+ .clickable { expanded = true }
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ selectedText,
+ color = TextPrimary,
+ fontSize = ValueSize,
+ modifier = Modifier.weight(1f),
+ maxLines = 1,
+ )
+ Icon(
+ Icons.Outlined.KeyboardArrowDown,
+ contentDescription = null,
+ tint = TextDim,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ shape = RoundedCornerShape(8.dp),
+ containerColor = CardSurface,
+ properties = PopupProperties(focusable = false),
+ ) {
+ CompositionLocalProvider(LocalPaneNav provides optionRegistry) {
+ entries.forEachIndexed { index, entry ->
+ DropdownMenuItem(
+ text = {
+ Text(
+ entry,
+ color = if (index == selectedIndex) AccentBlue else TextPrimary,
+ fontSize = ValueSize,
+ fontWeight = if (index == selectedIndex) FontWeight.Medium else FontWeight.Normal,
+ )
+ },
+ onClick = {
+ onSelected(index)
+ expanded = false
+ },
+ modifier =
+ (
+ if (index == selectedIndex) {
+ Modifier.background(AccentBlue.copy(alpha = 0.06f))
+ } else {
+ Modifier
+ }
+ ).paneNavItem(
+ cornerRadius = 6.dp,
+ onActivate = {
+ onSelected(index)
+ expanded = false
+ },
+ isEntry = index == selectedIndex,
+ highlightColor = NavHighlight,
+ ),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun RetroSettingSwitch(
+ label: String,
+ checked: Boolean,
+ subtitle: String? = null,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ SharedSettingSwitch(label, checked, subtitle, onCheckedChange)
+}
+
+@Composable
+internal fun RetroSettingTextField(
+ label: String,
+ value: String,
+ placeholder: String,
+ onChange: (String) -> Unit,
+) {
+ var editing by remember { mutableStateOf(false) }
+ var draft by remember(value) { mutableStateOf(value) }
+ if (editing) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = { editing = false },
+ title = { Text(label) },
+ text = {
+ androidx.compose.material3.OutlinedTextField(
+ value = draft,
+ onValueChange = { draft = it },
+ singleLine = true,
+ placeholder = { Text(placeholder) },
+ )
+ },
+ confirmButton = {
+ androidx.compose.material3.TextButton(onClick = {
+ onChange(draft.trim())
+ editing = false
+ }) { Text(stringResource(R.string.retro_gs_save)) }
+ },
+ dismissButton = {
+ androidx.compose.material3.TextButton(onClick = { editing = false }) { Text(stringResource(R.string.retro_gs_cancel)) }
+ },
+ )
+ }
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) { draft = value; editing = true }
+ .paneNavItem(
+ cornerRadius = 8.dp,
+ onActivate = { draft = value; editing = true },
+ highlightColor = NavHighlight,
+ tapToSelect = true,
+ )
+ .padding(vertical = TightGap + 2.dp),
+ ) {
+ Text(
+ label,
+ color = TextSecondary,
+ fontSize = LabelSize,
+ fontWeight = FontWeight.Medium,
+ maxLines = 2,
+ softWrap = true,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ value.ifBlank { placeholder },
+ color = if (value.isBlank()) TextSecondary else AccentBlue,
+ fontSize = ValueSize,
+ fontWeight = FontWeight.Medium,
+ maxLines = 3,
+ softWrap = true,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
+ )
+ }
+}
+
+private fun coreDisplayName(system: RetroSystem?): String =
+ when {
+ system == null -> ""
+ system.id == RetroSystems.PS2.id -> "ARMSX2"
+ RetroCoreManager.usesDolphinCore(system) -> "Dolphin"
+ else -> system.coreFileName
+ }
+
+@Composable
+private fun RetroConsoleEditPen(onClick: () -> Unit) {
+ val green = Color(0xFF3FB950)
+ Box(
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(9.dp))
+ .background(green.copy(alpha = 0.12f))
+ .border(1.dp, green.copy(alpha = 0.55f), RoundedCornerShape(9.dp))
+ .clickable { onClick() }
+ .padding(horizontal = 10.dp, vertical = 6.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Outlined.Edit,
+ contentDescription = stringResource(R.string.retro_gs_change_console),
+ tint = green,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+}
+
+@Composable
+private fun RetroConsolePickerDialog(
+ current: RetroSystem?,
+ onDismiss: () -> Unit,
+ onPick: (RetroSystem) -> Unit,
+) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.retro_gs_identify_as)) },
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ RetroSystems.ALL.sortedBy { it.displayName }.forEach { sys ->
+ val selected = sys.id == current?.id
+ Text(
+ sys.displayName,
+ color = if (selected) GameSettingsStyle.AccentBlue else GameSettingsStyle.TextPrimary,
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable { onPick(sys) }
+ .padding(vertical = 12.dp),
+ )
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = {
+ androidx.compose.material3.TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.retro_gs_cancel))
+ }
+ },
+ )
+}
+
+@Composable
+private fun RetroGeneralSection(
+ state: RetroSettingsState,
+ onPickArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null,
+ onRemoveArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null,
+ onImportBios: (() -> Unit)? = null,
+) {
+ val ctx = androidx.compose.ui.platform.LocalContext.current
+ var currentSystem by remember { mutableStateOf(state.system) }
+ var showConsolePicker by remember { mutableStateOf(false) }
+ SharedSettingGroup {
+ SharedGroupTitle(stringResource(R.string.retro_gs_group_game))
+ SharedInfoRow(stringResource(R.string.retro_gs_label_name), state.name)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(Modifier.weight(1f)) {
+ SharedInfoRow(stringResource(R.string.retro_gs_label_system), currentSystem?.displayName ?: "")
+ }
+ RetroConsoleEditPen { showConsolePicker = true }
+ }
+ SharedInfoRow(
+ stringResource(R.string.retro_gs_label_emulator_core),
+ coreDisplayName(currentSystem),
+ singleLineValue = true,
+ )
+ SharedInfoRow(
+ stringResource(R.string.retro_gs_label_rom_path),
+ state.romPath,
+ singleLineValue = true,
+ )
+ if (currentSystem?.isExternal == true) {
+ val ps2Prefs = remember(ctx) { ctx.getSharedPreferences("ARMSX2", android.content.Context.MODE_PRIVATE) }
+ var version by remember { androidx.compose.runtime.mutableIntStateOf(0) }
+ @Suppress("UNUSED_EXPRESSION") version
+ SharedSettingSwitch(stringResource(R.string.retro_gs_fast_boot), ps2Prefs.getBoolean("wn.ps2.fastboot", true)) {
+ ps2Prefs.edit().putBoolean("wn.ps2.fastboot", it).apply(); version++
+ }
+ }
+ }
+ if (showConsolePicker) {
+ RetroConsolePickerDialog(
+ current = currentSystem,
+ onDismiss = { showConsolePicker = false },
+ ) { picked ->
+ showConsolePicker = false
+ if (picked.id != currentSystem?.id) {
+ currentSystem = picked
+ kotlin.concurrent.thread {
+ runCatching {
+ state.shortcut.putExtra(RetroShortcuts.KEY_SYSTEM, picked.id)
+ state.shortcut.saveData()
+ }
+ }
+ android.widget.Toast.makeText(
+ ctx,
+ ctx.getString(R.string.retro_gs_console_changed, picked.displayName),
+ android.widget.Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ }
+ val biosSystem = currentSystem
+ if (biosSystem?.needsBios == true && onImportBios != null) {
+ val context = ctx
+ Spacer(Modifier.height(ItemGap))
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_bios, biosSystem.shortName.uppercase()))
+ state.biosRefresh
+ val dir = RetroCoreManager.systemDir(context)
+ val installed = biosSystem.biosFiles.filter { java.io.File(dir, it).isFile }
+ RetroInfoRow(
+ stringResource(R.string.retro_gs_label_installed),
+ if (installed.isEmpty()) stringResource(R.string.retro_gs_bios_none, biosSystem.shortName) else installed.joinToString(", "),
+ )
+ androidx.compose.material3.Button(
+ onClick = { onImportBios() },
+ modifier = Modifier.fillMaxWidth().padding(top = ItemGap),
+ ) {
+ androidx.compose.material3.Text(stringResource(R.string.retro_gs_import_bios))
+ }
+ if (installed.isNotEmpty()) {
+ androidx.compose.material3.OutlinedButton(
+ onClick = {
+ installed.forEach { java.io.File(dir, it).delete() }
+ state.biosRefresh++
+ },
+ modifier = Modifier.fillMaxWidth().padding(top = ItemGap),
+ ) {
+ androidx.compose.material3.Text(stringResource(R.string.retro_gs_remove_bios))
+ }
+ }
+ }
+ }
+ if (onPickArtwork != null && onRemoveArtwork != null) {
+ Spacer(Modifier.height(ItemGap))
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_library_artwork))
+ Row(horizontalArrangement = Arrangement.spacedBy(ItemGap)) {
+ Box(Modifier.weight(1f)) {
+ RetroArtworkRow(
+ title = stringResource(R.string.retro_gs_artwork_game_card),
+ selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD] == true,
+ onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD) },
+ onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD) },
+ )
+ }
+ Box(Modifier.weight(1f)) {
+ RetroArtworkRow(
+ title = stringResource(R.string.retro_gs_artwork_grid),
+ selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.GRID] == true,
+ onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GRID) },
+ onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GRID) },
+ )
+ }
+ }
+ Spacer(Modifier.height(ItemGap))
+ Row(horizontalArrangement = Arrangement.spacedBy(ItemGap)) {
+ Box(Modifier.weight(1f)) {
+ RetroArtworkRow(
+ title = stringResource(R.string.retro_gs_artwork_carousel),
+ selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL] == true,
+ onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL) },
+ onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL) },
+ )
+ }
+ Box(Modifier.weight(1f)) {
+ RetroArtworkRow(
+ title = stringResource(R.string.retro_gs_artwork_list),
+ selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.LIST] == true,
+ onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.LIST) },
+ onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.LIST) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroArtworkRow(
+ title: String,
+ selected: Boolean,
+ onPick: () -> Unit,
+ onRemove: () -> Unit,
+) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(GroupCorner))
+ .background(InputSurface)
+ .border(1.dp, InputBorder, RoundedCornerShape(GroupCorner))
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ color = TextPrimary,
+ fontSize = ValueSize,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (selected) {
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = stringResource(R.string.retro_gs_artwork_custom_set),
+ color = TextSecondary,
+ fontSize = LabelSize,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (!selected) {
+ RetroArtworkActionButton(stringResource(R.string.retro_gs_artwork_set_image), AccentBlue, onPick)
+ } else {
+ RetroArtworkActionButton(stringResource(R.string.retro_gs_artwork_remove), DangerRed, onRemove)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroArtworkActionButton(
+ text: String,
+ tint: Color,
+ onClick: () -> Unit,
+) {
+ Box(
+ modifier =
+ Modifier
+ .clip(RoundedCornerShape(9.dp))
+ .background(tint.copy(alpha = 0.08f))
+ .border(1.dp, tint.copy(alpha = 0.2f), RoundedCornerShape(9.dp))
+ .paneNavItem(cornerRadius = 9.dp, onActivate = { onClick() }, highlightColor = NavHighlight)
+ .clickable { onClick() }
+ .padding(horizontal = 10.dp, vertical = 6.dp),
+ ) {
+ Text(
+ text = text,
+ color = tint,
+ fontSize = LabelSize,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+}
+
+@Composable
+private fun RetroGraphicsSection(state: RetroSettingsState) {
+ if (state.system?.isExternal == true) {
+ RetroPs2GraphicsSection()
+ return
+ }
+ // First on the Graphics pane, because it decides what draws the game at
+ // all -- every row below it configures the standard core, which this
+ // replaces. It lived under Input for a while, next to the on-screen
+ // controls, which is where nobody looked for it.
+ //
+ // Only offered for the handful of games the 3D engine actually supports.
+ // Compatibility is the ROM's SHA-1, so it means hashing a file: resolved
+ // once off the composition thread rather than on every recomposition.
+ val engineCtx = androidx.compose.ui.platform.LocalContext.current
+ var engine3dSupported by remember { mutableStateOf(false) }
+ LaunchedEffect(state.shortcut.file.absolutePath) {
+ engine3dSupported =
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ runCatching { Gen1EmbedLaunch.isCompatible(engineCtx, state.shortcut) }
+ .getOrDefault(false)
+ }
+ }
+ if (engine3dSupported) {
+ RetroSettingSwitch(
+ label = stringResource(R.string.retro_gs_engine_3d),
+ checked = state.engine3d,
+ subtitle = stringResource(R.string.retro_gs_engine_3d_subtitle),
+ onCheckedChange = { state.engine3d = it },
+ )
+ }
+ val shaderLabels = listOf(
+ stringResource(R.string.retro_gs_shader_default),
+ stringResource(R.string.retro_gs_shader_crt),
+ stringResource(R.string.retro_gs_shader_lcd),
+ stringResource(R.string.retro_gs_shader_sharp),
+ )
+ val upscaleLabels = listOf(
+ stringResource(R.string.retro_gs_upscale_2x),
+ stringResource(R.string.retro_gs_upscale_4x),
+ stringResource(R.string.retro_gs_upscale_native),
+ )
+ val embeddedDolphinContext = androidx.compose.ui.platform.LocalContext.current
+ val embeddedDolphin =
+ RetroCoreManager.usesDolphinCore(state.system) &&
+ RetroShortcuts.embeddedDolphinEnabled(embeddedDolphinContext)
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_video))
+ if (embeddedDolphin) {
+ DolphinGpuDriverDropdown()
+ }
+ if (!embeddedDolphin) {
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_gs_video_filter),
+ entries = shaderLabels,
+ selectedIndex = SHADER_KEYS.indexOf(state.shader).coerceAtLeast(0),
+ onSelected = { state.shader = SHADER_KEYS[it] },
+ )
+ RetroSettingSwitch(
+ label = stringResource(R.string.retro_gs_sgsr),
+ checked = state.sgsr,
+ onCheckedChange = { state.sgsr = it },
+ )
+ if (state.sgsr) {
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_gs_sgsr_upscale),
+ entries = upscaleLabels,
+ selectedIndex = UPSCALE_KEYS.indexOf(state.upscale).coerceAtLeast(0),
+ onSelected = { state.upscale = UPSCALE_KEYS[it] },
+ )
+ }
+ }
+ }
+ if (state.coreOptions.isNotEmpty()) {
+ Spacer(Modifier.height(12.dp))
+ RetroSettingGroup {
+ RetroGroupTitle((state.system?.shortName ?: stringResource(R.string.retro_gs_group_core)).uppercase())
+ val ctx = androidx.compose.ui.platform.LocalContext.current
+ val ordered =
+ state.coreOptions.filterNot { it.advanced } + state.coreOptions.filter { it.advanced }
+ ordered.forEach { option ->
+ val current = state.optionValues[option.key] ?: option.defaultValue
+ RetroSettingDropdown(
+ label = option.labelText(ctx),
+ entries = option.valueLabels,
+ selectedIndex = option.values.indexOf(current).coerceAtLeast(0),
+ onSelected = { state.optionValues[option.key] = option.values[it] },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DolphinGpuDriverDropdown() {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val prefs = remember(context) { androidx.preference.PreferenceManager.getDefaultSharedPreferences(context) }
+ var driverVersion by remember { mutableIntStateOf(0) }
+ @Suppress("UNUSED_EXPRESSION") driverVersion
+ val gcDrivers = remember { com.armsx2.CustomDriver.listInstalled(context) }
+ val gcDriverIds = remember(gcDrivers) { listOf("") + gcDrivers.map { it.id } }
+ val gcDriverLabels = listOf(stringResource(R.string.retro_gpu_driver_system)) + gcDrivers.map { it.name }
+ val curGcDriver = (prefs.getString(DolphinEmbedLaunch.DRIVER_PREF, "") ?: "").let { if (it.equals("system", true)) "" else it }
+ RetroSettingDropdown(
+ label = stringResource(R.string.retro_gpu_driver),
+ entries = gcDriverLabels,
+ selectedIndex = gcDriverIds.indexOf(curGcDriver).coerceAtLeast(0),
+ onSelected = {
+ prefs.edit().putString(DolphinEmbedLaunch.DRIVER_PREF, gcDriverIds[it]).apply()
+ driverVersion++
+ },
+ )
+}
+
+@Composable
+internal fun RetroLibretroHudSection(
+ hudOn: Boolean,
+ onHud: (Boolean) -> Unit,
+) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ var version by remember { androidx.compose.runtime.mutableIntStateOf(0) }
+ @Suppress("UNUSED_EXPRESSION") version
+ val elements = remember(version) { RetroHudSupport.loadGlobalHudElements(context) }
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_performance_hud))
+ RetroSettingSwitch(stringResource(R.string.retro_lr_performance_hud), hudOn) {
+ onHud(it)
+ version++
+ }
+ if (hudOn) {
+ RetroHudElementButtons(elements) { index, on ->
+ val next = elements.copyOf()
+ next[index] = on
+ RetroHudSupport.saveGlobalHudElements(context, next)
+ version++
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroHudElementButtons(
+ elements: BooleanArray,
+ onToggle: (index: Int, on: Boolean) -> Unit,
+) {
+ RetroHudSupport.ELEMENT_ORDER.toList().chunked(3).forEach { rowIndices ->
+ Row(
+ Modifier.fillMaxWidth().padding(top = 6.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ rowIndices.forEach { index ->
+ val on = elements[index]
+ HudToggleButton(
+ label = stringResource(RetroHudSupport.ELEMENT_LABEL_RES[index]),
+ on = on,
+ modifier = Modifier.weight(1f),
+ ) { onToggle(index, !on) }
+ }
+ repeat(3 - rowIndices.size) { Spacer(Modifier.weight(1f)) }
+ }
+ }
+}
+
+@Composable
+private fun HudToggleButton(
+ label: String,
+ on: Boolean,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+) {
+ OutlinedButton(
+ onClick = onClick,
+ modifier = modifier.height(40.dp),
+ shape = RoundedCornerShape(8.dp),
+ contentPadding = PaddingValues(horizontal = 6.dp, vertical = 4.dp),
+ border =
+ BorderStroke(
+ 1.dp,
+ if (on) GameSettingsStyle.AccentBlue else GameSettingsStyle.CardBorder,
+ ),
+ colors =
+ ButtonDefaults.outlinedButtonColors(
+ containerColor =
+ if (on) GameSettingsStyle.AccentBlue.copy(alpha = 0.15f) else Color.Transparent,
+ contentColor =
+ if (on) GameSettingsStyle.AccentBlue else GameSettingsStyle.TextSecondary,
+ ),
+ ) {
+ Text(
+ label,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+internal val PS2_TURNIP_FLAGS: List> = listOf(
+ "sysmem" to "Force system-memory rendering",
+ "gmem" to "Force GMEM (tiled GPU memory) rendering",
+ "flushall" to "Flush all caches every draw",
+ "nolrz" to "Disable low-resolution Z (LRZ)",
+ "nolrzfc" to "Disable LRZ fast-clear",
+ "noubwc" to "Disable bandwidth compression (UBWC)",
+ "noconform" to "Skip non-conformant fast paths",
+ "syncdraw" to "Synchronise every draw",
+ "forcebin" to "Force binning (tiled) mode",
+)
+
+@Composable
+internal fun Ps2TurnipFlags(prefs: android.content.SharedPreferences, refreshKey: Int, bump: () -> Unit) {
+ Text(
+ stringResource(R.string.retro_ps2_turnip_flags),
+ color = TextSecondary,
+ fontSize = LabelSize,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(top = ItemGap, bottom = TightGap),
+ )
+ val usingTurnip = remember(refreshKey) {
+ val driver = (prefs.getString("wn.ps2.driver", "") ?: "").trim()
+ driver.isNotEmpty() && !driver.equals("system", ignoreCase = true)
+ }
+ if (!usingTurnip) {
+ Text(
+ stringResource(R.string.retro_ps2_turnip_flags_note),
+ color = TextDim,
+ fontSize = LabelSize,
+ modifier = Modifier.padding(bottom = TightGap),
+ )
+ }
+ val active = remember(refreshKey) {
+ (prefs.getString("wn.ps2.turnipflags", "") ?: "")
+ .split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet()
+ }
+ PS2_TURNIP_FLAGS.forEach { (flag, hint) ->
+ RetroSettingSwitch(flag, flag in active, subtitle = hint) { on ->
+ val next = if (on) active + flag else active - flag
+ prefs.edit().putString("wn.ps2.turnipflags", next.joinToString(",")).apply()
+ bump()
+ }
+ }
+}
+
+@Composable
+private fun RetroPs2GraphicsSection() {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val prefs = remember(context) { context.getSharedPreferences("ARMSX2", android.content.Context.MODE_PRIVATE) }
+ var version by remember { androidx.compose.runtime.mutableIntStateOf(0) }
+
+ @Suppress("UNUSED_EXPRESSION") version
+
+ fun putInt(key: String, value: Int) { prefs.edit().putInt(key, value).apply(); version++ }
+ fun putBool(key: String, value: Boolean) { prefs.edit().putBoolean(key, value).apply(); version++ }
+ fun putStr(key: String, value: String) { prefs.edit().putString(key, value).apply(); version++ }
+ fun putFloat(key: String, value: Float) { prefs.edit().putFloat(key, value).apply(); version++ }
+
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_video))
+ val rendererKeys = listOf("vulkan", "opengl", "software")
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_renderer),
+ listOf(
+ stringResource(R.string.retro_gs_renderer_vulkan),
+ stringResource(R.string.retro_gs_renderer_opengl),
+ stringResource(R.string.retro_gs_renderer_software),
+ ),
+ rendererKeys.indexOf(prefs.getString("wn.ps2.renderer", "vulkan")).coerceAtLeast(0),
+ ) { putStr("wn.ps2.renderer", rendererKeys[it]) }
+ val ps2Drivers = remember { com.armsx2.CustomDriver.listInstalled(context) }
+ val driverIds = remember(ps2Drivers) { listOf("") + ps2Drivers.map { it.id } }
+ val driverLabels = listOf(stringResource(R.string.retro_gpu_driver_system)) + ps2Drivers.map { it.name }
+ val curDriver = (prefs.getString("wn.ps2.driver", "") ?: "").let { if (it.equals("system", true)) "" else it }
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gpu_driver),
+ driverLabels,
+ driverIds.indexOf(curDriver).coerceAtLeast(0),
+ ) { putStr("wn.ps2.driver", driverIds[it]) }
+ if (ps2Drivers.isEmpty()) {
+ Text(
+ stringResource(R.string.retro_gpu_driver_hint),
+ color = TextDim,
+ fontSize = LabelSize,
+ modifier = Modifier.padding(top = TightGap),
+ )
+ }
+ Ps2TurnipFlags(prefs, version) { version++ }
+ val scales = listOf(1f, 1.5f, 2f, 3f, 4f)
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_resolution_scale),
+ listOf(
+ stringResource(R.string.retro_gs_scale_1x_native),
+ stringResource(R.string.retro_gs_scale_1_5x),
+ stringResource(R.string.retro_gs_scale_2x),
+ stringResource(R.string.retro_gs_scale_3x),
+ stringResource(R.string.retro_gs_scale_4x),
+ ),
+ scales.indexOfFirst { kotlin.math.abs(it - prefs.getFloat("wn.ps2.upscale", 1f)) < 0.01f }.coerceAtLeast(0),
+ ) { putFloat("wn.ps2.upscale", scales[it]) }
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_aspect_ratio),
+ listOf(
+ stringResource(R.string.retro_gs_aspect_stretch),
+ stringResource(R.string.retro_gs_aspect_auto_standard),
+ stringResource(R.string.retro_gs_aspect_4_3),
+ stringResource(R.string.retro_gs_aspect_16_9),
+ ),
+ prefs.getInt("wn.ps2.aspect", 1).coerceIn(0, 3),
+ ) { putInt("wn.ps2.aspect", it) }
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_fmv_aspect_ratio),
+ listOf(
+ stringResource(R.string.retro_gs_off),
+ stringResource(R.string.retro_gs_aspect_auto_standard),
+ stringResource(R.string.retro_gs_aspect_4_3),
+ stringResource(R.string.retro_gs_aspect_16_9),
+ ),
+ prefs.getInt("wn.ps2.fmvaspect", 0).coerceIn(0, 3),
+ ) { putInt("wn.ps2.fmvaspect", it) }
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_deinterlace_mode),
+ listOf(
+ stringResource(R.string.retro_gs_deint_auto),
+ stringResource(R.string.retro_gs_deint_off),
+ stringResource(R.string.retro_gs_deint_weave_tff),
+ stringResource(R.string.retro_gs_deint_weave_bff),
+ stringResource(R.string.retro_gs_deint_bob_tff),
+ stringResource(R.string.retro_gs_deint_bob_bff),
+ stringResource(R.string.retro_gs_deint_blend_tff),
+ stringResource(R.string.retro_gs_deint_blend_bff),
+ stringResource(R.string.retro_gs_deint_adaptive_tff),
+ stringResource(R.string.retro_gs_deint_adaptive_bff),
+ ),
+ prefs.getInt("wn.ps2.deinterlace", 0).coerceIn(0, 9),
+ ) { putInt("wn.ps2.deinterlace", it) }
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_display_filter),
+ listOf(
+ stringResource(R.string.retro_gs_filter_nearest),
+ stringResource(R.string.retro_gs_filter_bilinear_smooth),
+ stringResource(R.string.retro_gs_filter_bilinear_sharp),
+ ),
+ prefs.getInt("wn.ps2.displayfilter", 1).coerceIn(0, 2),
+ ) { putInt("wn.ps2.displayfilter", it) }
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_texture_filter),
+ listOf(
+ stringResource(R.string.retro_gs_filter_nearest),
+ stringResource(R.string.retro_gs_filter_bilinear_forced),
+ stringResource(R.string.retro_gs_filter_bilinear_ps2),
+ stringResource(R.string.retro_gs_filter_bilinear_sprites),
+ ),
+ prefs.getInt("wn.ps2.filter", 2).coerceIn(0, 3),
+ ) { putInt("wn.ps2.filter", it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_mipmapping), prefs.getBoolean("wn.ps2.mipmap", true)) { putBool("wn.ps2.mipmap", it) }
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_blending_accuracy),
+ listOf(
+ stringResource(R.string.retro_gs_blend_minimum),
+ stringResource(R.string.retro_gs_blend_basic),
+ stringResource(R.string.retro_gs_blend_medium),
+ stringResource(R.string.retro_gs_blend_high),
+ stringResource(R.string.retro_gs_blend_full),
+ stringResource(R.string.retro_gs_blend_maximum),
+ ),
+ prefs.getInt("wn.ps2.blend", 1).coerceIn(0, 5),
+ ) { putInt("wn.ps2.blend", it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_anti_blur), prefs.getBoolean("wn.ps2.antiblur", true)) { putBool("wn.ps2.antiblur", it) }
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_crt_tv_shader),
+ listOf(
+ stringResource(R.string.retro_gs_off),
+ stringResource(R.string.retro_gs_tvshader_scanline),
+ stringResource(R.string.retro_gs_tvshader_diagonal),
+ stringResource(R.string.retro_gs_tvshader_triangular),
+ stringResource(R.string.retro_gs_tvshader_wave),
+ stringResource(R.string.retro_gs_tvshader_lottes),
+ stringResource(R.string.retro_gs_tvshader_4xrgss),
+ stringResource(R.string.retro_gs_tvshader_nxagss),
+ ),
+ prefs.getInt("wn.ps2.tvshader", 0).coerceIn(0, 7),
+ ) { putInt("wn.ps2.tvshader", it) }
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_frame_skip),
+ listOf(
+ stringResource(R.string.retro_gs_off),
+ stringResource(R.string.retro_gs_frameskip_1),
+ stringResource(R.string.retro_gs_frameskip_2),
+ stringResource(R.string.retro_gs_frameskip_3),
+ ),
+ prefs.getInt("wn.ps2.frameskip", 0).coerceIn(0, 3),
+ ) { putInt("wn.ps2.frameskip", it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_widescreen_patches), prefs.getBoolean("wn.ps2.widescreen", false)) { putBool("wn.ps2.widescreen", it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_no_interlace_patches), prefs.getBoolean("wn.ps2.nointerlace", false)) { putBool("wn.ps2.nointerlace", it) }
+ }
+}
+
+@Composable
+private fun RetroPs2PerformanceSection() {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val prefs = remember(context) { context.getSharedPreferences("ARMSX2", android.content.Context.MODE_PRIVATE) }
+ var version by remember { androidx.compose.runtime.mutableIntStateOf(0) }
+
+ @Suppress("UNUSED_EXPRESSION") version
+
+ fun putInt(key: String, value: Int) { prefs.edit().putInt(key, value).apply(); version++ }
+ fun putBool(key: String, value: Boolean) { prefs.edit().putBoolean(key, value).apply(); version++ }
+
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_performance))
+ val rateValues = listOf(-3, -2, -1, 0, 1, 2, 3)
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_ee_cycle_rate),
+ listOf(
+ stringResource(R.string.retro_gs_rate_50),
+ stringResource(R.string.retro_gs_rate_60),
+ stringResource(R.string.retro_gs_rate_75),
+ stringResource(R.string.retro_gs_rate_100_default),
+ stringResource(R.string.retro_gs_rate_130),
+ stringResource(R.string.retro_gs_rate_180),
+ stringResource(R.string.retro_gs_rate_300),
+ ),
+ rateValues.indexOf(prefs.getInt("wn.ps2.eeRate", 0).coerceIn(-3, 3)).coerceAtLeast(0),
+ ) { putInt("wn.ps2.eeRate", rateValues[it]) }
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_ee_cycle_skip),
+ listOf(
+ stringResource(R.string.retro_gs_off),
+ stringResource(R.string.retro_gs_num_1),
+ stringResource(R.string.retro_gs_num_2),
+ stringResource(R.string.retro_gs_num_3),
+ ),
+ prefs.getInt("wn.ps2.eeSkip", 0).coerceIn(0, 3),
+ ) { putInt("wn.ps2.eeSkip", it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_mtvu), prefs.getBoolean("wn.ps2.mtvu", true)) { putBool("wn.ps2.mtvu", it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_instant_vu1), prefs.getBoolean("wn.ps2.instantVu1", true)) { putBool("wn.ps2.instantVu1", it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_vu_flag_hack), prefs.getBoolean("wn.ps2.vuFlagHack", true)) { putBool("wn.ps2.vuFlagHack", it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_intc_spin), prefs.getBoolean("wn.ps2.intc", true)) { putBool("wn.ps2.intc", it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_wait_loop), prefs.getBoolean("wn.ps2.waitloop", true)) { putBool("wn.ps2.waitloop", it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_fast_cdvd), prefs.getBoolean("wn.ps2.fastCdvd", false)) { putBool("wn.ps2.fastCdvd", it) }
+ }
+}
+
+@Composable
+private fun RetroPs2HudSection(state: RetroSettingsState) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ var version by remember { androidx.compose.runtime.mutableIntStateOf(0) }
+
+ @Suppress("UNUSED_EXPRESSION") version
+
+ val hudOn = state.hud
+ val elements = remember(version) { RetroHudSupport.loadPs2Elements(context) }
+
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_performance_hud))
+ RetroSettingSwitch(stringResource(R.string.retro_lr_performance_hud), hudOn) {
+ state.hud = it
+ version++
+ }
+ if (hudOn) {
+ RetroHudElementButtons(elements) { index, on ->
+ val next = elements.copyOf()
+ next[index] = on
+ RetroHudSupport.savePs2Elements(context, next)
+ version++
+ }
+ }
+ }
+}
+
+@Composable
+private fun RetroNetplaySection(state: RetroSettingsState) {
+ val systemId = state.system?.id ?: return
+ var version by remember { androidx.compose.runtime.mutableIntStateOf(0) }
+ RetroNetplaySettingsSection(
+ systemId = systemId,
+ version = version,
+ onChanged = { version++ },
+ )
+}
+
+@Composable
+private fun RetroDolphinNetplaySection(state: RetroSettingsState) {
+ val systemId = state.system?.id ?: return
+ var version by remember { androidx.compose.runtime.mutableIntStateOf(0) }
+ DolphinNetplaySettingsSection(
+ systemId = systemId,
+ version = version,
+ onChanged = { version++ },
+ )
+}
+
+@Composable
+private fun RetroPs2OnlineSection(state: RetroSettingsState) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val prefs = remember(context) { context.getSharedPreferences("ARMSX2", android.content.Context.MODE_PRIVATE) }
+ var version by remember { androidx.compose.runtime.mutableIntStateOf(0) }
+
+ @Suppress("UNUSED_EXPRESSION") version
+
+ fun putBool(key: String, value: Boolean) { prefs.edit().putBoolean(key, value).apply(); version++ }
+ fun putStr(key: String, value: String) { prefs.edit().putString(key, value).apply(); version++ }
+
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_online_dev9))
+ val onlineEnabled = prefs.getBoolean("wn.ps2.net.enable", true)
+ RetroSettingSwitch(stringResource(R.string.retro_gs_enable_online), onlineEnabled) { putBool("wn.ps2.net.enable", it) }
+ val hddImages = remember(version, state.hddRefresh) { RetroHddImport.installed(context).map { it.name } }
+ val hddOptions = listOf(stringResource(R.string.retro_scr_none)) + hddImages
+ val hddSelected = (hddImages.indexOf(state.hddImage) + 1).coerceAtLeast(0)
+ RetroSettingDropdown(stringResource(R.string.retro_gs_hdd_image), hddOptions, hddSelected) { idx ->
+ state.hddImage = if (idx <= 0) "" else hddImages[idx - 1]
+ version++
+ }
+ remember(state.hddImage, state.hddRefresh) { RetroHddImport.imageFile(context, state.hddImage) }?.let { f ->
+ Text(
+ stringResource(R.string.retro_gs_hdd_image_size, humanSize(f.length())),
+ color = TextDim,
+ fontSize = LabelSize,
+ modifier = Modifier.padding(top = TightGap),
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = ItemGap),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ androidx.compose.material3.OutlinedButton(
+ onClick = { if (!state.hddImporting) state.requestImportHdd?.invoke() },
+ enabled = !state.hddImporting,
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 6.dp),
+ ) {
+ Text(
+ if (state.hddImporting) stringResource(R.string.retro_scr_importing) else stringResource(R.string.retro_scr_import_hdd_image),
+ fontSize = ValueSize,
+ )
+ }
+ if (state.hddImage.isNotBlank()) {
+ androidx.compose.material3.OutlinedButton(
+ onClick = {
+ val name = state.hddImage
+ RetroHddImport.delete(context, name)
+ state.hddImage = ""
+ state.hddRefresh++
+ version++
+ },
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 6.dp),
+ colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors(contentColor = DangerRed),
+ ) { Text(stringResource(R.string.retro_gs_hdd_remove_short), fontSize = ValueSize) }
+ }
+ }
+ RetroSettingSwitch(stringResource(R.string.retro_ps2_hdd), state.hddEnable) { state.hddEnable = it; version++ }
+ Text(
+ stringResource(R.string.retro_ps2_hdd_desc),
+ color = TextDim,
+ fontSize = LabelSize,
+ modifier = Modifier.padding(top = TightGap),
+ )
+ if (onlineEnabled) {
+ val deviceKeys = listOf("Auto", "Wi-Fi")
+ val deviceLabels = listOf(
+ stringResource(R.string.retro_gs_net_auto),
+ stringResource(R.string.retro_gs_net_wifi),
+ )
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_ethernet_device), deviceLabels,
+ deviceKeys.indexOf(prefs.getString("wn.ps2.net.ethdevice", "Auto")).coerceAtLeast(0),
+ ) { putStr("wn.ps2.net.ethdevice", deviceKeys[it]) }
+ val dnsModeKeys = listOf("Manual", "Auto", "Internal")
+ val dnsModeLabels = listOf(
+ stringResource(R.string.retro_gs_dns_manual),
+ stringResource(R.string.retro_gs_net_auto),
+ stringResource(R.string.retro_gs_dns_internal),
+ )
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_dns_mode), dnsModeLabels,
+ dnsModeKeys.indexOf(prefs.getString("wn.ps2.net.dnsmode", "Manual")).coerceAtLeast(0),
+ ) { putStr("wn.ps2.net.dnsmode", dnsModeKeys[it]) }
+ RetroSettingTextField(stringResource(R.string.retro_gs_primary_dns), prefs.getString("wn.ps2.net.dns1", PS2_DEFAULT_DNS).orEmpty(), PS2_DEFAULT_DNS) { putStr("wn.ps2.net.dns1", it) }
+ RetroSettingTextField(stringResource(R.string.retro_gs_secondary_dns), prefs.getString("wn.ps2.net.dns2", "").orEmpty(), stringResource(R.string.retro_gs_dns_optional)) { putStr("wn.ps2.net.dns2", it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_dnas_bypass), prefs.getBoolean(Ps2DnasBypass.PREF, true), subtitle = stringResource(R.string.retro_gs_dnas_bypass_subtitle)) { putBool(Ps2DnasBypass.PREF, it) }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_auto_ip_dhcp), prefs.getBoolean("wn.ps2.net.dhcp", true)) { putBool("wn.ps2.net.dhcp", it) }
+ }
+ }
+}
+
+@Composable
+private fun RetroPs2CheatsSection(state: RetroSettingsState) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val serial = remember(state) { state.gameSerial }
+
+ if (serial == null) {
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_cheats))
+ Text(
+ stringResource(R.string.retro_gs_cheats_detect_hint),
+ color = TextSecondary,
+ fontSize = ValueSize,
+ modifier = Modifier.padding(vertical = TightGap),
+ )
+ }
+ return
+ }
+
+ var loading by remember { mutableStateOf(true) }
+ var cheats by remember { mutableStateOf>(emptyList()) }
+ var patches by remember { mutableStateOf>(emptyList()) }
+ var selCheats by remember { mutableStateOf>(emptySet()) }
+ var selPatches by remember { mutableStateOf>(emptySet()) }
+ var repoCheatNames by remember { mutableStateOf>(emptySet()) }
+ var repoPatchNames by remember { mutableStateOf>(emptySet()) }
+ var dnasEntries by remember { mutableStateOf>(emptyList()) }
+ var dnasGlobalOn by remember { mutableStateOf(true) }
+ var dnasDisabled by remember { mutableStateOf>(emptySet()) }
+
+ LaunchedEffect(serial, state.cheatsRefresh) {
+ val stagedC = Ps2CheatStaging.read(context, serial, false)
+ val stagedP = Ps2CheatStaging.read(context, serial, true)
+ cheats = stagedC
+ patches = stagedP
+ selCheats = stagedC.map { it.name }.toSet()
+ selPatches = stagedP.map { it.name }.toSet()
+ dnasEntries = Ps2DnasBypass.bypassEntries(context, serial).filter { it.auto }
+ dnasGlobalOn = context.getSharedPreferences("ARMSX2", android.content.Context.MODE_PRIVATE).getBoolean(Ps2DnasBypass.PREF, true)
+ dnasDisabled = Ps2DnasBypass.ensureSingleDnasEnabled(context, serial, dnasEntries.map { it.name }.toSet())
+ loading = false
+ val repo = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ runCatching { com.armsx2.PatchRepo.fetchForSerial(serial) }.getOrNull()
+ }
+ if (repo != null) {
+ val repoCheats = repo.entries.filter { it.source != "patches" }
+ val repoPatches = repo.entries.filter { it.source == "patches" }
+ repoCheatNames = repoCheats.map { it.name }.toSet()
+ repoPatchNames = repoPatches.map { it.name }.toSet()
+ cheats = repoCheats + stagedC.filter { s -> repoCheats.none { it.name == s.name } }
+ patches = repoPatches + stagedP.filter { s -> repoPatches.none { it.name == s.name } }
+ }
+ }
+
+ fun toggleDnas(name: String, currentlyOn: Boolean) {
+ if (currentlyOn) {
+ dnasDisabled = dnasDisabled + name
+ } else {
+ if (!dnasGlobalOn) { Ps2DnasBypass.setEnabled(context, true); dnasGlobalOn = true }
+ val all = dnasEntries.map { it.name }.toSet()
+ dnasDisabled = all - name
+ }
+ Ps2DnasBypass.setDisabledNames(context, serial, dnasDisabled)
+ }
+
+ fun persist() {
+ Ps2CheatStaging.write(context, serial, false, serial, cheats.filter { it.name in selCheats })
+ Ps2CheatStaging.write(context, serial, true, serial, patches.filter { it.name in selPatches })
+ }
+
+ var showAdd by remember { mutableStateOf(false) }
+ var newName by remember { mutableStateOf("") }
+ var newCodes by remember { mutableStateOf("") }
+ var newIsPatch by remember { mutableStateOf(false) }
+
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_cheats))
+ Text(
+ stringResource(R.string.retro_gs_cheats_apply_note),
+ color = TextDim,
+ fontSize = LabelSize,
+ modifier = Modifier.padding(bottom = TightGap),
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = ItemGap)) {
+ androidx.compose.material3.OutlinedButton(
+ onClick = { newName = ""; newCodes = ""; newIsPatch = false; showAdd = true },
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 6.dp),
+ ) { Text(stringResource(R.string.retro_scr_add), fontSize = ValueSize) }
+ androidx.compose.material3.OutlinedButton(
+ onClick = { state.requestImportCheatFile?.invoke(true) },
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 6.dp),
+ ) { Text(stringResource(R.string.retro_scr_import_from_file), fontSize = ValueSize) }
+ }
+ when {
+ loading -> Text(stringResource(R.string.retro_scr_loading), color = TextSecondary, fontSize = ValueSize)
+ cheats.isEmpty() && patches.isEmpty() && dnasEntries.isEmpty() ->
+ Text(stringResource(R.string.retro_scr_no_cheats_or_patches), color = TextSecondary, fontSize = ValueSize)
+ else -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (dnasEntries.isNotEmpty()) {
+ Ps2SectionHeader(stringResource(R.string.retro_gs_dnas_group))
+ dnasEntries.forEach { e ->
+ val on = dnasGlobalOn && e.name !in dnasDisabled
+ Ps2CheatRow(
+ com.armsx2.PatchRepo.Entry(e.name, stringResource(R.string.retro_gs_dnas_entry_desc), e.body, "dnas"),
+ on, isPatch = true,
+ onToggle = { toggleDnas(e.name, on) },
+ )
+ }
+ }
+ if (cheats.isNotEmpty()) {
+ Ps2SectionHeader(stringResource(R.string.retro_scr_cheats_section))
+ cheats.forEach { entry ->
+ val isCustom = entry.name !in repoCheatNames
+ Ps2CheatRow(
+ entry, entry.name in selCheats, isPatch = false,
+ onToggle = {
+ selCheats = if (entry.name in selCheats) selCheats - entry.name else selCheats + entry.name
+ persist()
+ },
+ onDelete = if (isCustom) {
+ {
+ cheats = cheats.filterNot { it.name == entry.name }
+ selCheats = selCheats - entry.name
+ persist()
+ }
+ } else null,
+ )
+ }
+ }
+ if (patches.isNotEmpty()) {
+ Ps2SectionHeader(stringResource(R.string.retro_scr_patches_section))
+ patches.forEach { entry ->
+ val isCustom = entry.name !in repoPatchNames
+ Ps2CheatRow(
+ entry, entry.name in selPatches, isPatch = true,
+ onToggle = {
+ selPatches = if (entry.name in selPatches) selPatches - entry.name else selPatches + entry.name
+ persist()
+ },
+ onDelete = if (isCustom) {
+ {
+ patches = patches.filterNot { it.name == entry.name }
+ selPatches = selPatches - entry.name
+ persist()
+ }
+ } else null,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (showAdd) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = { showAdd = false },
+ title = { Text(stringResource(R.string.retro_scr_add_cheat)) },
+ text = {
+ Column {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ androidx.compose.material3.OutlinedButton(
+ onClick = { newIsPatch = false }, modifier = Modifier.weight(1f),
+ colors = if (!newIsPatch)
+ androidx.compose.material3.ButtonDefaults.buttonColors()
+ else androidx.compose.material3.ButtonDefaults.outlinedButtonColors(),
+ ) { Text(stringResource(R.string.retro_scr_target_cheat)) }
+ androidx.compose.material3.OutlinedButton(
+ onClick = { newIsPatch = true }, modifier = Modifier.weight(1f),
+ colors = if (newIsPatch)
+ androidx.compose.material3.ButtonDefaults.buttonColors()
+ else androidx.compose.material3.ButtonDefaults.outlinedButtonColors(),
+ ) { Text(stringResource(R.string.retro_scr_target_patch)) }
+ }
+ Spacer(Modifier.height(8.dp))
+ androidx.compose.material3.OutlinedTextField(
+ value = newName, onValueChange = { newName = it },
+ label = { Text(stringResource(R.string.retro_scr_cheat_name)) },
+ singleLine = true, modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(Modifier.height(8.dp))
+ androidx.compose.material3.OutlinedTextField(
+ value = newCodes, onValueChange = { newCodes = it },
+ label = { Text(stringResource(R.string.retro_scr_cheat_codes)) },
+ placeholder = { Text("2021A268 00000000") },
+ modifier = Modifier.fillMaxWidth(), minLines = 3,
+ )
+ }
+ },
+ confirmButton = {
+ androidx.compose.material3.TextButton(onClick = {
+ val entry = buildCustomPnachEntry(context, newName, newCodes, if (newIsPatch) "patches" else "custom")
+ if (entry != null) {
+ if (newIsPatch) {
+ patches = patches.filterNot { it.name == entry.name } + entry
+ selPatches = selPatches + entry.name
+ } else {
+ cheats = cheats.filterNot { it.name == entry.name } + entry
+ selCheats = selCheats + entry.name
+ }
+ persist()
+ showAdd = false
+ }
+ }) { Text(stringResource(R.string.retro_scr_add)) }
+ },
+ dismissButton = {
+ androidx.compose.material3.TextButton(onClick = { showAdd = false }) { Text(stringResource(R.string.retro_scr_cancel)) }
+ },
+ )
+ }
+}
+
+@Composable
+private fun RetroInputSection(state: RetroSettingsState) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val prefs =
+ remember(context) {
+ androidx.preference.PreferenceManager.getDefaultSharedPreferences(context)
+ }
+ var haptic by remember { mutableStateOf(prefs.getFloat("retro_haptic_strength", 0.4f)) }
+ val ps2 = state.system?.isExternal == true
+ val ps2Prefs = if (ps2) remember(context) { context.getSharedPreferences("ARMSX2", android.content.Context.MODE_PRIVATE) } else null
+ var ps2Ver by remember { androidx.compose.runtime.mutableIntStateOf(0) }
+ @Suppress("UNUSED_EXPRESSION") ps2Ver
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_input))
+ if (ps2 && ps2Prefs != null) {
+ RetroSettingSwitch(
+ label = stringResource(R.string.retro_gs_on_screen_controls),
+ checked = ps2Prefs.getBoolean("wn.ps2.touchcontrols", true),
+ onCheckedChange = { ps2Prefs.edit().putBoolean("wn.ps2.touchcontrols", it).apply(); ps2Ver++ },
+ )
+ RetroSettingSwitch(
+ label = stringResource(R.string.retro_gs_adaptive_sticks),
+ checked = ps2Prefs.getBoolean("wn.ps2.adaptivesticks", false),
+ subtitle = stringResource(R.string.retro_gs_adaptive_sticks_subtitle),
+ onCheckedChange = { ps2Prefs.edit().putBoolean("wn.ps2.adaptivesticks", it).apply(); ps2Ver++ },
+ )
+ RetroSettingSwitch(
+ label = stringResource(R.string.retro_ps2_show_l3r3),
+ checked = ps2Prefs.getBoolean("wn.ps2.showl3r3", true),
+ subtitle = stringResource(R.string.retro_ps2_show_l3r3_subtitle),
+ onCheckedChange = { ps2Prefs.edit().putBoolean("wn.ps2.showl3r3", it).apply(); ps2Ver++ },
+ )
+ } else {
+ RetroSettingSwitch(
+ label = stringResource(R.string.retro_gs_on_screen_controls),
+ checked = state.touchControls,
+ onCheckedChange = { state.touchControls = it },
+ )
+ RetroSettingSwitch(
+ label = stringResource(R.string.retro_gs_adaptive_sticks),
+ checked = state.adaptiveSticks,
+ subtitle = stringResource(R.string.retro_gs_adaptive_sticks_subtitle),
+ onCheckedChange = { state.adaptiveSticks = it },
+ )
+ val sysId = state.system?.id
+ if (sysId == RetroSystems.PSX.id || RetroCoreManager.usesDolphinCore(state.system)) {
+ var invVer by remember { androidx.compose.runtime.mutableIntStateOf(0) }
+ @Suppress("UNUSED_EXPRESSION") invVer
+ listOf(
+ R.string.retro_lr_left_stick_invert_x to "retro_inv_lx_$sysId",
+ R.string.retro_lr_left_stick_invert_y to "retro_inv_ly_$sysId",
+ R.string.retro_lr_right_stick_invert_x to "retro_inv_rx_$sysId",
+ R.string.retro_lr_right_stick_invert_y to "retro_inv_ry_$sysId",
+ ).forEach { (labelRes, key) ->
+ RetroSettingSwitch(
+ label = stringResource(labelRes),
+ checked = prefs.getBoolean(key, false),
+ onCheckedChange = { prefs.edit().putBoolean(key, it).apply(); invVer++ },
+ )
+ }
+ }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = TightGap),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ stringResource(R.string.retro_gs_haptic_feedback),
+ color = TextPrimary,
+ fontSize = ValueSize,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ "${(haptic * 100).toInt()}%",
+ color = TextSecondary,
+ fontSize = ValueSize,
+ )
+ }
+ androidx.compose.material3.Slider(
+ value = haptic,
+ onValueChange = { value ->
+ haptic = value
+ prefs.edit().putFloat("retro_haptic_strength", value).apply()
+ },
+ valueRange = 0f..1f,
+ modifier = Modifier.fillMaxWidth().height(26.dp),
+ )
+ }
+}
+
+@Composable
+private fun RetroAudioSection(state: RetroSettingsState) {
+ if (state.system?.isExternal == true) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val prefs = remember(context) { context.getSharedPreferences("ARMSX2", android.content.Context.MODE_PRIVATE) }
+ var version by remember { androidx.compose.runtime.mutableIntStateOf(0) }
+ @Suppress("UNUSED_EXPRESSION") version
+ var vol by remember { mutableStateOf(prefs.getInt("wn.ps2.volume", 100)) }
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_audio))
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = TightGap),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(stringResource(R.string.retro_gs_volume), color = TextPrimary, fontSize = ValueSize, modifier = Modifier.weight(1f))
+ Text("$vol%", color = TextSecondary, fontSize = ValueSize)
+ }
+ androidx.compose.material3.Slider(
+ value = vol.toFloat(),
+ onValueChange = { vol = it.toInt() },
+ onValueChangeFinished = { prefs.edit().putInt("wn.ps2.volume", vol).apply() },
+ valueRange = 0f..200f,
+ modifier = Modifier.fillMaxWidth().height(26.dp),
+ )
+ RetroSettingSwitch(stringResource(R.string.retro_gs_mute), prefs.getBoolean("wn.ps2.muted", false)) {
+ prefs.edit().putBoolean("wn.ps2.muted", it).apply(); version++
+ }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_time_stretch), prefs.getBoolean("wn.ps2.timestretch", true)) {
+ prefs.edit().putBoolean("wn.ps2.timestretch", it).apply(); version++
+ }
+ val bufferValues = listOf(40, 50, 60, 80, 100, 120, 160, 200)
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_audio_buffer),
+ bufferValues.map { context.getString(R.string.retro_gs_ms, it) },
+ bufferValues.indexOf(prefs.getInt("wn.ps2.audiobuffer", 50)).coerceAtLeast(0),
+ ) {
+ prefs.edit().putInt("wn.ps2.audiobuffer", bufferValues[it]).apply(); version++
+ }
+ val latencyValues = listOf(10, 15, 20, 30, 40, 60, 80, 100)
+ RetroSettingDropdown(
+ stringResource(R.string.retro_gs_audio_latency),
+ latencyValues.map { context.getString(R.string.retro_gs_ms, it) },
+ latencyValues.indexOf(prefs.getInt("wn.ps2.audiolatency", 20)).coerceAtLeast(0),
+ ) {
+ prefs.edit().putInt("wn.ps2.audiolatency", latencyValues[it]).apply(); version++
+ }
+ RetroSettingSwitch(stringResource(R.string.retro_gs_swap_stereo), prefs.getBoolean("wn.ps2.swap", false)) {
+ prefs.edit().putBoolean("wn.ps2.swap", it).apply(); version++
+ }
+ }
+ return
+ }
+ RetroSettingGroup {
+ RetroGroupTitle(stringResource(R.string.retro_gs_group_audio))
+ RetroSettingSwitch(
+ label = stringResource(R.string.retro_gs_sound),
+ checked = state.audio,
+ onCheckedChange = { state.audio = it },
+ )
+ }
+}
diff --git a/app/src/main/feature/retro/RetroHddImport.kt b/app/src/main/feature/retro/RetroHddImport.kt
new file mode 100644
index 000000000..227df33dd
--- /dev/null
+++ b/app/src/main/feature/retro/RetroHddImport.kt
@@ -0,0 +1,64 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.net.Uri
+import java.io.File
+import java.util.zip.ZipInputStream
+
+object RetroHddImport {
+ const val BLANK_IMAGE = "DEV9hdd.raw"
+
+ fun hddDir(context: Context): File =
+ File(context.getExternalFilesDir(null) ?: context.filesDir, "hdd").apply { mkdirs() }
+
+ fun installed(context: Context): List =
+ hddDir(context).listFiles().orEmpty()
+ .filter { it.isFile && it.extension.equals("raw", true) && it.name != BLANK_IMAGE }
+ .sortedBy { it.name.lowercase() }
+
+ fun imageFile(context: Context, name: String?): File? {
+ if (name.isNullOrBlank()) return null
+ val f = File(hddDir(context), name)
+ return f.takeIf { it.isFile }
+ }
+
+ fun delete(context: Context, name: String): Boolean =
+ File(hddDir(context), name).let { it.isFile && it.delete() }
+
+ private fun displayName(context: Context, uri: Uri): String? =
+ runCatching {
+ context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
+ if (it.moveToFirst()) it.getString(0) else null
+ }
+ }.getOrNull()
+
+ fun importFromUri(context: Context, uri: Uri): Result =
+ runCatching {
+ val name = displayName(context, uri) ?: "hdd.raw"
+ val dir = hddDir(context)
+ if (name.endsWith(".zip", ignoreCase = true)) {
+ var extracted: String? = null
+ context.contentResolver.openInputStream(uri)?.use { input ->
+ ZipInputStream(input.buffered()).use { zin ->
+ var entry = zin.nextEntry
+ while (entry != null && extracted == null) {
+ val entryName = entry.name.substringAfterLast('/')
+ if (!entry.isDirectory && entryName.endsWith(".raw", ignoreCase = true)) {
+ File(dir, entryName).outputStream().buffered().use { out -> zin.copyTo(out, 1 shl 20) }
+ extracted = entryName
+ }
+ zin.closeEntry()
+ entry = zin.nextEntry
+ }
+ }
+ } ?: throw IllegalStateException("Could not read file")
+ extracted ?: throw IllegalArgumentException("No .raw HDD image found inside the ZIP")
+ } else {
+ val outName = if (name.endsWith(".raw", ignoreCase = true)) name else "$name.raw"
+ context.contentResolver.openInputStream(uri)?.use { input ->
+ File(dir, outName).outputStream().buffered().use { out -> input.copyTo(out, 1 shl 20) }
+ } ?: throw IllegalStateException("Could not read file")
+ outName
+ }
+ }
+}
diff --git a/app/src/main/feature/retro/RetroHudSupport.kt b/app/src/main/feature/retro/RetroHudSupport.kt
new file mode 100644
index 000000000..803134ae7
--- /dev/null
+++ b/app/src/main/feature/retro/RetroHudSupport.kt
@@ -0,0 +1,474 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.preference.PreferenceManager
+import com.winlator.cmod.R
+import com.winlator.cmod.runtime.display.ui.FrameRating
+import java.util.HashMap
+
+object RetroHudSupport {
+ const val HUD_ELEVATION = 2500f
+
+ val ELEMENT_LABEL_RES =
+ intArrayOf(
+ R.string.retro_lr_hud_fps,
+ R.string.retro_lr_hud_console,
+ R.string.retro_lr_hud_gpu,
+ R.string.retro_lr_hud_cpu,
+ R.string.retro_lr_hud_ram,
+ R.string.retro_lr_hud_battery,
+ R.string.retro_lr_hud_temp,
+ R.string.retro_lr_hud_graph,
+ R.string.retro_lr_hud_cpu_temp,
+ )
+
+ val ELEMENT_ORDER = intArrayOf(1, 2, 3, 8, 4, 5, 6, 0, 7)
+
+ const val PS2_HUD_PREF = "wn.ps2.hud"
+ private const val PS2_ELEMENTS_PREF = "wn.ps2.hud.elements"
+ private const val PS2_ALPHA_PREF = "wn.ps2.hud.alpha"
+ private const val PS2_BG_DECOUPLED_PREF = "wn.ps2.hud.bg_decoupled"
+ private const val PS2_BG_ALPHA_PREF = "wn.ps2.hud.bg_alpha"
+ private const val PS2_SCALE_PREF = "wn.ps2.hud.scale"
+
+ fun consoleHudDefault(
+ context: Context,
+ systemId: String,
+ ): Boolean =
+ if (systemId == RetroSystems.PS2.id) resolvePs2HudEnabled(context) else RetroDefaults.hud(context, systemId)
+
+ fun hudEnabled(
+ context: Context,
+ shortcut: com.winlator.cmod.runtime.container.Shortcut?,
+ systemId: String,
+ ): Boolean {
+ val perGame = shortcut?.getExtra(RetroShortcuts.KEY_HUD).orEmpty()
+ return if (perGame.isEmpty()) consoleHudDefault(context, systemId) else perGame == "1"
+ }
+
+ fun defaultElements(): BooleanArray = booleanArrayOf(true, true, true, true, true, true, true, true, false)
+
+ fun ps2Prefs(context: Context) =
+ context.getSharedPreferences("ARMSX2", Context.MODE_PRIVATE)
+
+ fun resolvePs2HudEnabled(context: Context): Boolean {
+ val prefs = ps2Prefs(context)
+ if (prefs.contains(PS2_HUD_PREF)) {
+ return prefs.getBoolean(PS2_HUD_PREF, false)
+ }
+ val legacy =
+ listOf("fps", "speed", "res", "cpu", "gpu", "frametimes", "gsstats", "inputs", "hwinfo", "version")
+ .any { prefs.getBoolean("wn.osd.$it", false) }
+ if (legacy) {
+ prefs.edit().putBoolean(PS2_HUD_PREF, true).apply()
+ return true
+ }
+ return RetroDefaults.hud(context, RetroSystems.PS2.id)
+ }
+
+ fun setPs2HudEnabled(context: Context, enabled: Boolean) {
+ ps2Prefs(context).edit().putBoolean(PS2_HUD_PREF, enabled).apply()
+ }
+
+ fun loadPs2Elements(context: Context): BooleanArray {
+ val prefs = ps2Prefs(context)
+ val raw = prefs.getString(PS2_ELEMENTS_PREF, null)
+ if (!raw.isNullOrBlank()) {
+ val parts = raw.split(',')
+ val out = defaultElements()
+ for (i in out.indices) {
+ if (i < parts.size) out[i] = parts[i] == "1" || parts[i].equals("true", true)
+ }
+ return out
+ }
+ if (listOf("fps", "cpu", "gpu", "frametimes").any { prefs.contains("wn.osd.$it") }) {
+ val out = defaultElements()
+ out[0] = prefs.getBoolean("wn.osd.fps", true)
+ out[2] = prefs.getBoolean("wn.osd.gpu", true)
+ out[3] = prefs.getBoolean("wn.osd.cpu", true)
+ out[7] = prefs.getBoolean("wn.osd.frametimes", true)
+ savePs2Elements(context, out)
+ return out
+ }
+ return defaultElements()
+ }
+
+ fun savePs2Elements(context: Context, elements: BooleanArray) {
+ val raw = elements.joinToString(",") { if (it) "1" else "0" }
+ ps2Prefs(context).edit().putString(PS2_ELEMENTS_PREF, raw).apply()
+ }
+
+ fun loadPs2HudStyle(context: Context): HudStyle {
+ val prefs = ps2Prefs(context)
+ val pm = PreferenceManager.getDefaultSharedPreferences(context)
+ val alpha = prefs.getFloat(PS2_ALPHA_PREF, pm.getFloat(FrameRating.PREF_HUD_ALPHA, 1f))
+ val scale = prefs.getFloat(PS2_SCALE_PREF, pm.getFloat(FrameRating.PREF_HUD_SCALE, 1f))
+ val bgDecoupled = prefs.getBoolean(PS2_BG_DECOUPLED_PREF, false)
+ val bgAlpha =
+ prefs.getFloat(
+ PS2_BG_ALPHA_PREF,
+ (alpha * FrameRating.BACKDROP_BASE_ALPHA).coerceIn(0.1f, 1f),
+ )
+ val frametimeNumeric = pm.getBoolean(FrameRating.PREF_HUD_FRAMETIME_NUMERIC, false)
+ val dualBattery = pm.getBoolean(FrameRating.PREF_HUD_DUAL_SERIES_BATTERY, false)
+ return HudStyle(alpha, bgDecoupled, bgAlpha, scale, frametimeNumeric, dualBattery)
+ }
+
+ fun savePs2HudStyle(context: Context, style: HudStyle) {
+ ps2Prefs(context)
+ .edit()
+ .putFloat(PS2_ALPHA_PREF, style.alpha)
+ .putBoolean(PS2_BG_DECOUPLED_PREF, style.bgDecoupled)
+ .putFloat(PS2_BG_ALPHA_PREF, style.bgAlpha)
+ .putFloat(PS2_SCALE_PREF, style.scale)
+ .apply()
+ PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .edit()
+ .putBoolean(FrameRating.PREF_HUD_FRAMETIME_NUMERIC, style.frametimeNumeric)
+ .putBoolean(FrameRating.PREF_HUD_DUAL_SERIES_BATTERY, style.dualBattery)
+ .apply()
+ }
+
+ fun applyStyle(
+ rating: FrameRating,
+ style: HudStyle,
+ elements: BooleanArray,
+ ) {
+ rating.setHudAlpha(style.alpha)
+ rating.setBackgroundAlphaDecoupled(style.bgDecoupled)
+ rating.setHudBackgroundAlpha(style.bgAlpha)
+ rating.setHudScale(style.scale)
+ rating.setFrametimeNumericMode(style.frametimeNumeric)
+ rating.setDualSeriesBattery(style.dualBattery)
+ elements.forEachIndexed { index, enabled -> rating.toggleElement(index, enabled) }
+ }
+
+ fun loadGlobalHudElements(context: Context): BooleanArray {
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context)
+ val csv = prefs.getString("retro_hud_elements", null) ?: return defaultElements()
+ val parts = csv.split(',')
+ val defaults = defaultElements()
+ return BooleanArray(defaults.size) { i -> parts.getOrNull(i)?.toBoolean() ?: defaults[i] }
+ }
+
+ fun saveGlobalHudElements(context: Context, elements: BooleanArray) {
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .edit()
+ .putString("retro_hud_elements", elements.joinToString(","))
+ .apply()
+ }
+
+ fun loadGlobalHudStyle(context: Context): HudStyle {
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context)
+ return HudStyle(
+ alpha = prefs.getFloat("retro_hud_alpha", 1f),
+ bgDecoupled = prefs.getBoolean("retro_hud_bg_decoupled", false),
+ bgAlpha = prefs.getFloat("retro_hud_bg_alpha", FrameRating.BACKDROP_BASE_ALPHA),
+ scale = prefs.getFloat("retro_hud_scale", 1f),
+ frametimeNumeric = prefs.getBoolean(FrameRating.PREF_HUD_FRAMETIME_NUMERIC, false),
+ dualBattery = prefs.getBoolean(FrameRating.PREF_HUD_DUAL_SERIES_BATTERY, false),
+ )
+ }
+
+ fun saveGlobalHudStyle(context: Context, style: HudStyle) {
+ androidx.preference.PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .edit()
+ .putFloat("retro_hud_alpha", style.alpha)
+ .putBoolean("retro_hud_bg_decoupled", style.bgDecoupled)
+ .putFloat("retro_hud_bg_alpha", style.bgAlpha)
+ .putFloat("retro_hud_scale", style.scale)
+ .putBoolean(FrameRating.PREF_HUD_FRAMETIME_NUMERIC, style.frametimeNumeric)
+ .putBoolean(FrameRating.PREF_HUD_DUAL_SERIES_BATTERY, style.dualBattery)
+ .apply()
+ }
+
+ fun loadContainerHudSettings(
+ context: Context,
+ containerId: Int,
+ ): Pair? {
+ if (containerId <= 0) return null
+ return runCatching {
+ val json =
+ com.winlator.cmod.runtime.container.ContainerManager(context)
+ .getContainerById(containerId)
+ ?.getExtra("hudSettings")
+ if (json.isNullOrEmpty()) return null
+ val obj = org.json.JSONObject(json)
+ val transparency = obj.optDouble("transparency", 1.0).toFloat()
+ val style =
+ HudStyle(
+ alpha = transparency,
+ bgDecoupled = obj.optBoolean("backgroundAlphaDecoupled", false),
+ bgAlpha =
+ obj
+ .optDouble(
+ "backgroundTransparency",
+ (transparency * FrameRating.BACKDROP_BASE_ALPHA).toDouble(),
+ ).toFloat(),
+ scale = obj.optDouble("scale", 1.0).toFloat(),
+ )
+ val legacyCpuRam = obj.optBoolean("showCpuRam", true)
+ val legacyBattTemp = obj.optBoolean("showBattTemp", true)
+ val elements =
+ booleanArrayOf(
+ obj.optBoolean("showFPS", true),
+ obj.optBoolean("showRenderer", true),
+ obj.optBoolean("showGPU", true),
+ obj.optBoolean("showCPU", legacyCpuRam),
+ obj.optBoolean("showRAM", legacyCpuRam),
+ obj.optBoolean("showBattery", legacyBattTemp),
+ obj.optBoolean("showTemp", legacyBattTemp),
+ obj.optBoolean("showGraph", true),
+ obj.optBoolean("showCpuTemp", false),
+ )
+ style to elements
+ }.getOrNull()
+ }
+
+ fun saveContainerHudSettings(
+ context: Context,
+ containerId: Int,
+ style: HudStyle,
+ elements: BooleanArray,
+ ) {
+ if (containerId <= 0) return
+ runCatching {
+ val container =
+ com.winlator.cmod.runtime.container.ContainerManager(context)
+ .getContainerById(containerId) ?: return
+ val obj = org.json.JSONObject()
+ obj.put("transparency", style.alpha.toDouble())
+ obj.put("backgroundAlphaDecoupled", style.bgDecoupled)
+ obj.put("backgroundTransparency", style.bgAlpha.toDouble())
+ obj.put("scale", style.scale.toDouble())
+ obj.put("showFPS", elements.getOrElse(0) { true })
+ obj.put("showRenderer", elements.getOrElse(1) { true })
+ obj.put("showGPU", elements.getOrElse(2) { true })
+ obj.put("showCPU", elements.getOrElse(3) { true })
+ obj.put("showRAM", elements.getOrElse(4) { true })
+ obj.put("showBattery", elements.getOrElse(5) { true })
+ obj.put("showTemp", elements.getOrElse(6) { true })
+ obj.put("showGraph", elements.getOrElse(7) { true })
+ obj.put("showCpuTemp", elements.getOrElse(8) { false })
+ container.putExtra("hudSettings", obj.toString())
+ container.saveData()
+ }
+ }
+
+ fun libretroRendererLabel(): String = "OpenGL"
+
+ fun createFrameRating(
+ context: Context,
+ rendererLabel: String,
+ ): FrameRating {
+ val rating = FrameRating(context, HashMap())
+ rating.setRenderer(rendererLabel)
+ rating.setHudElevation(HUD_ELEVATION)
+ rating.visibility = View.GONE
+ return rating
+ }
+
+ fun attachFrameRating(
+ parent: ViewGroup,
+ rating: FrameRating,
+ below: View? = null,
+ ) {
+ if (rating.parent != null) return
+ val lp =
+ FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ )
+ val index = below?.let { parent.indexOfChild(it) } ?: -1
+ if (index >= 0) parent.addView(rating, index, lp) else parent.addView(rating, lp)
+ }
+
+ fun buildHudEntries(
+ context: Context,
+ hudVisible: Boolean,
+ style: HudStyle,
+ elements: BooleanArray,
+ onMaster: (Boolean) -> Unit,
+ onStyle: (HudStyle) -> Unit,
+ onElements: (BooleanArray) -> Unit,
+ onRebuild: () -> Unit,
+ ): List {
+ val entries = mutableListOf()
+ entries +=
+ RetroMenuEntry.Toggle(context.getString(R.string.retro_lr_performance_hud), checked = hudVisible) { value ->
+ onMaster(value)
+ onRebuild()
+ }
+ if (!hudVisible) return entries
+ entries +=
+ RetroMenuEntry.Slider(
+ label = context.getString(R.string.retro_lr_alpha),
+ valueText = "${(style.alpha * 100).toInt()}%",
+ value = style.alpha,
+ min = 0.1f,
+ max = 1f,
+ step = 0.05f,
+ ) { value ->
+ val next =
+ if (!style.bgDecoupled) {
+ style.copy(
+ alpha = value,
+ bgAlpha = (value * FrameRating.BACKDROP_BASE_ALPHA).coerceIn(0.1f, 1f),
+ )
+ } else {
+ style.copy(alpha = value)
+ }
+ onStyle(next)
+ onRebuild()
+ }
+ entries +=
+ RetroMenuEntry.Toggle(context.getString(R.string.retro_lr_background_alpha), checked = style.bgDecoupled) { value ->
+ val next =
+ if (!value) {
+ style.copy(
+ bgDecoupled = false,
+ bgAlpha = (style.alpha * FrameRating.BACKDROP_BASE_ALPHA).coerceIn(0.1f, 1f),
+ )
+ } else {
+ style.copy(bgDecoupled = true)
+ }
+ onStyle(next)
+ onRebuild()
+ }
+ if (style.bgDecoupled) {
+ entries +=
+ RetroMenuEntry.Slider(
+ label = context.getString(R.string.retro_lr_background),
+ valueText = "${(style.bgAlpha * 100).toInt()}%",
+ value = style.bgAlpha,
+ min = 0.1f,
+ max = 1f,
+ step = 0.05f,
+ ) { value ->
+ onStyle(style.copy(bgAlpha = value))
+ onRebuild()
+ }
+ }
+ entries +=
+ RetroMenuEntry.Slider(
+ label = context.getString(R.string.retro_lr_scale),
+ valueText = "${(style.scale * 100).toInt()}%",
+ value = style.scale,
+ min = 0.3f,
+ max = 2f,
+ step = 0.05f,
+ ) { value ->
+ onStyle(style.copy(scale = value))
+ onRebuild()
+ }
+ entries +=
+ RetroMenuEntry.Toggle(context.getString(R.string.retro_lr_numeric_frametime), checked = style.frametimeNumeric) { value ->
+ onStyle(style.copy(frametimeNumeric = value))
+ onRebuild()
+ }
+ entries +=
+ RetroMenuEntry.Toggle(context.getString(R.string.retro_lr_dual_series_battery), checked = style.dualBattery) { value ->
+ onStyle(style.copy(dualBattery = value))
+ onRebuild()
+ }
+ entries +=
+ RetroMenuEntry.Chips(
+ label = context.getString(R.string.retro_lr_hud_elements),
+ items = ELEMENT_ORDER.map { context.getString(ELEMENT_LABEL_RES[it]) },
+ states = ELEMENT_ORDER.map { elements[it] },
+ ) { position ->
+ val index = ELEMENT_ORDER[position]
+ val next = elements.copyOf()
+ next[index] = !next[index]
+ onElements(next)
+ onRebuild()
+ }
+ return entries
+ }
+
+ fun suppressNativePs2Osd() {
+ runCatching {
+ kr.co.iefriends.pcsx2.NativeApp.osdShowFPS(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowVPS(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowSpeed(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowCPU(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowGPU(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowResolution(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowGSStats(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowFrameTimes(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowHardwareInfo(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowVersion(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowSettings(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowInputs(false)
+ kr.co.iefriends.pcsx2.NativeApp.osdShowGpuStats(false)
+ }
+ }
+
+ class Ps2FrameSource(
+ private val ratingProvider: () -> FrameRating?,
+ private val enabledProvider: () -> Boolean,
+ ) {
+ private val handler = Handler(Looper.getMainLooper())
+ private var lastCount = -1
+ private var running = false
+ private val tick =
+ object : Runnable {
+ override fun run() {
+ if (!running) return
+ val rating = ratingProvider()
+ if (enabledProvider() && rating != null &&
+ com.armsx2.runtime.MainActivityRuntime.isNativeReady()
+ ) {
+ val count = runCatching { kr.co.iefriends.pcsx2.NativeApp.getPresentedFrameCount() }.getOrDefault(0)
+ if (lastCount >= 0 && count >= lastCount) {
+ val delta = (count - lastCount).coerceAtMost(8)
+ repeat(delta) { rating.recordGameFrame() }
+ }
+ lastCount = count
+ }
+ handler.postDelayed(this, 16L)
+ }
+ }
+
+ fun start() {
+ if (running) return
+ running = true
+ lastCount = -1
+ handler.post(tick)
+ }
+
+ fun stop() {
+ running = false
+ handler.removeCallbacks(tick)
+ }
+ }
+
+ class MenuToggleGate(private val minIntervalMs: Long = 350L) {
+ private var lastToggleMs = 0L
+
+ fun allow(): Boolean {
+ val now = SystemClock.uptimeMillis()
+ if (now - lastToggleMs < minIntervalMs) return false
+ lastToggleMs = now
+ return true
+ }
+ }
+}
+
+data class HudStyle(
+ val alpha: Float = 1f,
+ val bgDecoupled: Boolean = false,
+ val bgAlpha: Float = FrameRating.BACKDROP_BASE_ALPHA,
+ val scale: Float = 1f,
+ val frametimeNumeric: Boolean = false,
+ val dualBattery: Boolean = false,
+)
diff --git a/app/src/main/feature/retro/RetroInputView.kt b/app/src/main/feature/retro/RetroInputView.kt
new file mode 100644
index 000000000..434cc2c62
--- /dev/null
+++ b/app/src/main/feature/retro/RetroInputView.kt
@@ -0,0 +1,2517 @@
+package com.winlator.cmod.feature.retro
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.DashPathEffect
+import android.graphics.LinearGradient
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.RadialGradient
+import android.graphics.RectF
+import android.graphics.Shader
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.View
+import com.winlator.cmod.runtime.input.controls.GameHubLayout
+import kotlin.math.hypot
+import kotlin.math.max
+import kotlin.math.min
+
+class RetroInputView(
+ context: Context,
+ private val listener: Listener,
+ private val system: RetroSystem? = null,
+) : View(context) {
+ interface Listener {
+ fun onButton(
+ keyCode: Int,
+ down: Boolean,
+ )
+
+ fun onDpad(
+ x: Float,
+ y: Float,
+ )
+
+ fun onStick(
+ x: Float,
+ y: Float,
+ )
+
+ fun onRightStick(
+ x: Float,
+ y: Float,
+ )
+
+ fun onMenu()
+ }
+
+ private enum class GlassShape { CIRCLE, PILL, TRIGGER_LT, TRIGGER_LB, TRIGGER_RT, TRIGGER_RB }
+
+ private class GlassButton(
+ val keyCode: Int,
+ val label: String,
+ val shape: GlassShape,
+ val textScale: Float = 1f,
+ val bounds: RectF = RectF(),
+ )
+
+ private class CButton(
+ val dx: Float,
+ val dy: Float,
+ val glyph: String,
+ val bounds: RectF = RectF(),
+ )
+
+ private data class OverlayConfig(
+ val hasXY: Boolean,
+ val hasShoulders: Boolean,
+ val hasTriggers: Boolean,
+ val hasStick: Boolean,
+ val leftTriggerLabel: String = "L2",
+ val rightTriggerLabel: String = "R2",
+ val leftShoulderLabel: String = "L",
+ val rightShoulderLabel: String = "R",
+ val showLeftShoulder: Boolean = true,
+ val showRightTrigger: Boolean = true,
+ val flatFaces: Boolean = false,
+ val hasDualSticks: Boolean = false,
+ val faceTop: String = "X",
+ val faceBottom: String = "B",
+ val faceLeft: String = "Y",
+ val faceRight: String = "A",
+ )
+
+ private val config =
+ when (system?.id) {
+ RetroSystems.NES.id ->
+ OverlayConfig(
+ hasXY = false,
+ hasShoulders = false,
+ hasTriggers = false,
+ hasStick = false,
+ flatFaces = true,
+ )
+ RetroSystems.SNES.id -> OverlayConfig(hasXY = true, hasShoulders = true, hasTriggers = false, hasStick = false)
+ RetroSystems.GBA.id -> OverlayConfig(hasXY = false, hasShoulders = true, hasTriggers = false, hasStick = false)
+ RetroSystems.GENESIS.id -> OverlayConfig(hasXY = true, hasShoulders = true, hasTriggers = false, hasStick = false)
+ RetroSystems.N64.id ->
+ OverlayConfig(
+ hasXY = false,
+ hasShoulders = true,
+ hasTriggers = true,
+ hasStick = true,
+ leftTriggerLabel = "Z",
+ showRightTrigger = false,
+ )
+ RetroSystems.GAMECUBE.id ->
+ OverlayConfig(
+ hasXY = true,
+ hasShoulders = true,
+ hasTriggers = true,
+ hasStick = false,
+ hasDualSticks = true,
+ leftTriggerLabel = "L",
+ rightTriggerLabel = "R",
+ leftShoulderLabel = "Z",
+ rightShoulderLabel = "Z",
+ showLeftShoulder = false,
+ faceTop = "X",
+ faceBottom = "B",
+ faceLeft = "Y",
+ faceRight = "A",
+ )
+ RetroSystems.WII.id ->
+ OverlayConfig(
+ hasXY = true,
+ hasShoulders = true,
+ hasTriggers = true,
+ hasStick = false,
+ hasDualSticks = true,
+ leftTriggerLabel = "ZL",
+ rightTriggerLabel = "ZR",
+ leftShoulderLabel = "L",
+ rightShoulderLabel = "R",
+ faceTop = "X",
+ faceBottom = "B",
+ faceLeft = "Y",
+ faceRight = "A",
+ )
+ RetroSystems.PS2.id ->
+ OverlayConfig(
+ hasXY = true,
+ hasShoulders = true,
+ hasTriggers = true,
+ hasStick = false,
+ hasDualSticks = true,
+ faceTop = "\u25b3",
+ faceBottom = "\u2715",
+ faceLeft = "\u25a1",
+ faceRight = "\u25cb",
+ )
+ RetroSystems.PSX.id ->
+ OverlayConfig(
+ hasXY = true,
+ hasShoulders = true,
+ hasTriggers = true,
+ hasStick = false,
+ faceTop = "△",
+ faceBottom = "✕",
+ faceLeft = "□",
+ faceRight = "○",
+ )
+ else -> OverlayConfig(hasXY = false, hasShoulders = false, hasTriggers = false, hasStick = false)
+ }
+
+ private fun faceGlyphScale(label: String): Float =
+ when (label) {
+ "\u25cb" -> 1.85f
+ "\u25b3" -> 1.55f
+ "\u25a1" -> 1.45f
+ "\u2715" -> 1f
+ else -> 1f
+ }
+
+ private val buttons = mutableListOf()
+ private val cButtons = mutableListOf()
+ private val menuButton = GlassButton(0, "MENU", GlassShape.PILL, textScale = 0.75f)
+ private var snap = 0f
+ private var cStickX = 0f
+ private var cStickY = 0f
+
+ private class RetroTheme(
+ val body: Int,
+ val dpad: Int,
+ val pill: Int,
+ val pillText: Int,
+ val button: Int,
+ val buttonText: Int,
+ val buttonColors: Map = emptyMap(),
+ val buttonTextColors: Map = emptyMap(),
+ val backplate: Int = 0,
+ val clusterPlate: Int = 0,
+ val stickCap: Int = 0xFFB4B4BA.toInt(),
+ val cButton: Int = 0xFFE3BE2A.toInt(),
+ val cText: Int = 0xFF4A3B08.toInt(),
+ )
+
+ private val theme: RetroTheme =
+ when (system?.id) {
+ RetroSystems.NES.id ->
+ RetroTheme(
+ body = 0xFFD8D5CD.toInt(),
+ dpad = 0xFF26262A.toInt(),
+ pill = 0xFF303034.toInt(),
+ pillText = 0xFFD8D5CD.toInt(),
+ button = 0xFFC03A30.toInt(),
+ buttonText = 0xFFF6EAE8.toInt(),
+ backplate = 0xFFEDEBE6.toInt(),
+ )
+ RetroSystems.SNES.id ->
+ RetroTheme(
+ body = 0xFFCBCBD3.toInt(),
+ dpad = 0xFF3E3E46.toInt(),
+ pill = 0xFF8F8F98.toInt(),
+ pillText = 0xFF2F2F36.toInt(),
+ button = 0xFF564A9E.toInt(),
+ buttonText = 0xFFEDEBFA.toInt(),
+ buttonColors =
+ mapOf(
+ "X" to 0xFFBCB6DF.toInt(),
+ "Y" to 0xFFBCB6DF.toInt(),
+ ),
+ buttonTextColors =
+ mapOf(
+ "X" to 0xFF423D66.toInt(),
+ "Y" to 0xFF423D66.toInt(),
+ ),
+ clusterPlate = 0xFFB2B1BB.toInt(),
+ )
+ RetroSystems.GAMEBOY.id ->
+ RetroTheme(
+ body = 0xFFC9C6C1.toInt(),
+ dpad = 0xFF2E2E33.toInt(),
+ pill = 0xFF8A8A86.toInt(),
+ pillText = 0xFF33333A.toInt(),
+ button = 0xFF8E3162.toInt(),
+ buttonText = 0xFFF2DEE8.toInt(),
+ )
+ RetroSystems.GAMEBOY_COLOR.id ->
+ RetroTheme(
+ body = 0xFF5F45AC.toInt(),
+ dpad = 0xFF26262B.toInt(),
+ pill = 0xFF453D78.toInt(),
+ pillText = 0xFFD9D3F2.toInt(),
+ button = 0xFF37305E.toInt(),
+ buttonText = 0xFFCFC8EE.toInt(),
+ )
+ RetroSystems.GBA.id ->
+ RetroTheme(
+ body = 0xFF7C74C6.toInt(),
+ dpad = 0xFF33333A.toInt(),
+ pill = 0xFF9C96CC.toInt(),
+ pillText = 0xFF3C3866.toInt(),
+ button = 0xFFD3CFEA.toInt(),
+ buttonText = 0xFF4A4670.toInt(),
+ )
+ RetroSystems.N64.id ->
+ RetroTheme(
+ body = 0xFFA7A7AD.toInt(),
+ dpad = 0xFF4C4C54.toInt(),
+ pill = 0xFF77777F.toInt(),
+ pillText = 0xFFEFEFF4.toInt(),
+ button = 0xFF77777F.toInt(),
+ buttonText = 0xFFEFEFF4.toInt(),
+ buttonColors =
+ mapOf(
+ "A" to 0xFF2E63C9.toInt(),
+ "B" to 0xFF2F9E44.toInt(),
+ "START" to 0xFFC8362E.toInt(),
+ ),
+ stickCap = 0xFF9EA0A6.toInt(),
+ )
+ RetroSystems.GAMECUBE.id ->
+ RetroTheme(
+ body = 0xFF6B6F78.toInt(),
+ dpad = 0xFF3A3D44.toInt(),
+ pill = 0xFF555861.toInt(),
+ pillText = 0xFFEDEFF4.toInt(),
+ button = 0xFF555861.toInt(),
+ buttonText = 0xFFEDEFF4.toInt(),
+ buttonColors =
+ mapOf(
+ "A" to 0xFF2F8F4E.toInt(),
+ "B" to 0xFFC8362E.toInt(),
+ "X" to 0xFFE8E8EC.toInt(),
+ "Y" to 0xFFE8E8EC.toInt(),
+ "START" to 0xFF3A3D44.toInt(),
+ "Z" to 0xFF5B3FA0.toInt(),
+ ),
+ buttonTextColors =
+ mapOf(
+ "X" to 0xFF3A3D44.toInt(),
+ "Y" to 0xFF3A3D44.toInt(),
+ ),
+ stickCap = 0xFF8A8E98.toInt(),
+ )
+ RetroSystems.WII.id ->
+ RetroTheme(
+ body = 0xFFE4E6EA.toInt(),
+ dpad = 0xFF2C2E34.toInt(),
+ pill = 0xFFB8BCC4.toInt(),
+ pillText = 0xFF2C2E34.toInt(),
+ button = 0xFF2C2E34.toInt(),
+ buttonText = 0xFFF2F4F8.toInt(),
+ buttonColors =
+ mapOf(
+ "A" to 0xFF1BA0D8.toInt(),
+ "B" to 0xFF2C2E34.toInt(),
+ "X" to 0xFFD8DCE4.toInt(),
+ "Y" to 0xFFD8DCE4.toInt(),
+ "+" to 0xFF1BA0D8.toInt(),
+ "START" to 0xFF1BA0D8.toInt(),
+ ),
+ buttonTextColors =
+ mapOf(
+ "X" to 0xFF2C2E34.toInt(),
+ "Y" to 0xFF2C2E34.toInt(),
+ ),
+ stickCap = 0xFFC8CCD4.toInt(),
+ )
+ RetroSystems.GENESIS.id, RetroSystems.MASTER_SYSTEM.id, RetroSystems.GAME_GEAR.id ->
+ RetroTheme(
+ body = 0xFF1D1D21.toInt(),
+ dpad = 0xFF0E0E11.toInt(),
+ pill = 0xFF2C2C33.toInt(),
+ pillText = 0xFFB9B9C2.toInt(),
+ button = 0xFF303038.toInt(),
+ buttonText = 0xFFB9B9C2.toInt(),
+ )
+ RetroSystems.PSX.id, RetroSystems.PS2.id ->
+ RetroTheme(
+ body = 0xFFB7B7C0.toInt(),
+ dpad = 0xFF50505A.toInt(),
+ pill = 0xFF5A5A64.toInt(),
+ pillText = 0xFFE9E9F0.toInt(),
+ button = 0xFF3D3D45.toInt(),
+ buttonText = 0xFFE9E9F0.toInt(),
+ buttonTextColors =
+ mapOf(
+ "△" to 0xFF43B26A.toInt(),
+ "○" to 0xFFD64B45.toInt(),
+ "✕" to 0xFF8FA9DF.toInt(),
+ "□" to 0xFFD387BE.toInt(),
+ ),
+ )
+ else ->
+ RetroTheme(
+ body = 0xFFC9C6C1.toInt(),
+ dpad = 0xFF2E2E33.toInt(),
+ pill = 0xFF8A8A86.toInt(),
+ pillText = 0xFF33333A.toInt(),
+ button = 0xFF55555C.toInt(),
+ buttonText = 0xFFEFEFF4.toInt(),
+ )
+ }
+
+ private var gameArea: RectF? = null
+ var hapticStrength = 0f
+ var adaptiveSticks: Boolean = false
+ set(value) {
+ if (field != value) {
+ field = value
+ stickActive = false
+ stick2Active = false
+ invalidate()
+ }
+ }
+ val supportsStickButtons: Boolean get() = config.hasDualSticks
+ var showL3R3: Boolean = true
+ set(value) {
+ if (field != value) {
+ field = value
+ if (width > 0 && height > 0) {
+ relayout()
+ invalidate()
+ }
+ }
+ }
+ var invertLX = false
+ var invertLY = false
+ var invertRX = false
+ var invertRY = false
+
+ fun loadStickInversion() {
+ val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context)
+ val sys = system?.id ?: return
+ invertLX = prefs.getBoolean("retro_inv_lx_$sys", false)
+ invertLY = prefs.getBoolean("retro_inv_ly_$sys", false)
+ invertRX = prefs.getBoolean("retro_inv_rx_$sys", false)
+ invertRY = prefs.getBoolean("retro_inv_ry_$sys", false)
+ }
+
+ private fun ix(v: Float, inv: Boolean) = if (inv) -v else v
+ private val vibrator: Vibrator? = context.getSystemService(Vibrator::class.java)
+
+ var editMode = false
+ private set
+ var onEditStateChanged: ((Boolean) -> Unit)? = null
+ private var customColors = RetroCustomColors()
+ private var overrides: MutableMap = mutableMapOf()
+ private var overridesLoaded = false
+ private var overridesPortrait = false
+ private var selectedId: String? = null
+ private var dragId: String? = null
+ private var dragPointerId = -1
+ private var dragOffsetX = 0f
+ private var dragOffsetY = 0f
+ private var dragActive = false
+ private var dragDownX = 0f
+ private var dragDownY = 0f
+ private var swallowInput = false
+ private var dpadVisible = true
+ private var stickVisible = true
+ private var stick2Visible = true
+ private val hiddenControls = mutableListOf>()
+ private val toolbarPills = mutableListOf>()
+ private val trayPills = mutableListOf>()
+
+ private val editUnit: Float
+ get() = snap * (if (height > width) 2f else 1f)
+
+ fun setCustomColors(colors: RetroCustomColors) {
+ customColors = colors
+ invalidate()
+ }
+
+ private fun cancelDrag() {
+ dragId = null
+ dragPointerId = -1
+ dragActive = false
+ }
+
+ fun enterEdit() {
+ if (editMode) return
+ releaseAll()
+ editMode = true
+ selectedId = null
+ cancelDrag()
+ onEditStateChanged?.invoke(true)
+ relayout()
+ }
+
+ fun finishEdit() {
+ if (!editMode) return
+ editMode = false
+ selectedId = null
+ cancelDrag()
+ swallowInput = true
+ saveOverrides()
+ onEditStateChanged?.invoke(false)
+ relayout()
+ }
+
+ fun resetLayout() {
+ overrides.clear()
+ RetroControlLayouts.reset(context, system?.id, height > width)
+ selectedId = null
+ relayout()
+ }
+
+ private fun saveOverrides() {
+ RetroControlLayouts.save(context, system?.id, height > width, overrides)
+ }
+
+ fun setGameArea(area: RectF?) {
+ if (area == gameArea) return
+ gameArea = area
+ relayout()
+ }
+
+ private fun hapticTick() {
+ if (hapticStrength <= 0.01f) return
+ val amplitude = (hapticStrength * 255f).toInt().coerceIn(1, 255)
+ runCatching { vibrator?.vibrate(VibrationEffect.createOneShot(15, amplitude.toInt())) }
+ }
+
+ private fun mix(
+ a: Int,
+ b: Int,
+ f: Float,
+ ): Int {
+ val inv = 1f - f
+ return Color.argb(
+ 255,
+ (Color.red(a) * inv + Color.red(b) * f).toInt(),
+ (Color.green(a) * inv + Color.green(b) * f).toInt(),
+ (Color.blue(a) * inv + Color.blue(b) * f).toInt(),
+ )
+ }
+
+ private fun lighten(
+ color: Int,
+ f: Float,
+ ) = mix(color, Color.WHITE, f)
+
+ private fun darken(
+ color: Int,
+ f: Float,
+ ) = mix(color, Color.BLACK, f)
+
+ private var dpadCx = 0f
+ private var dpadCy = 0f
+ private var dpadRadius = 0f
+
+ private var stickCx = 0f
+ private var stickCy = 0f
+ private var stickRadius = 0f
+ private var stickPointerId = -1
+ private var stickX = 0f
+ private var stickY = 0f
+ private var stick2Cx = 0f
+ private var stick2Cy = 0f
+ private var stick2Radius = 0f
+ private var stick2PointerId = -1
+ private var stick2X = 0f
+ private var stick2Y = 0f
+
+ private var stickActive = false
+ private var stickOriginX = 0f
+ private var stickOriginY = 0f
+ private var stick2Active = false
+ private var stick2OriginX = 0f
+ private var stick2OriginY = 0f
+
+ private val DOUBLE_TAP_MS = 500L
+ private val TAP_MAX_MS = 300L
+ private val TAP_MAX_MAG = 0.35f
+ private val DEAD_ZONE_PX = 10f
+ private val FACE_KEYCODES = setOf(
+ KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B,
+ KeyEvent.KEYCODE_BUTTON_X, KeyEvent.KEYCODE_BUTTON_Y,
+ )
+ private var stickDownTime = 0L
+ private var stickMaxMag = 0f
+ private var stickLastTapTime = 0L
+ private var stickL3Held = false
+ private var stick2DownTime = 0L
+ private var stick2MaxMag = 0f
+ private var stick2LastTapTime = 0L
+ private var stickR3Held = false
+
+ private val pressedButtons = HashSet()
+ private var dpadX = 0f
+ private var dpadY = 0f
+ private var menuLatched = false
+
+ private val pointerButton = HashMap()
+ private val pointerCButton = HashMap()
+ private var dpadPointerId = -1
+ private var menuPointerId = -1
+
+ private var strokeWidth = 4f
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val path = Path()
+ private val arrowCenter = FloatArray(2)
+
+ init {
+ isFocusable = false
+ isFocusableInTouchMode = false
+ }
+
+ override fun onSizeChanged(
+ w: Int,
+ h: Int,
+ oldw: Int,
+ oldh: Int,
+ ) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ relayout()
+ }
+
+ fun relayout() {
+ val width = width.toFloat()
+ val height = height.toFloat()
+ if (width <= 0f || height <= 0f) return
+ val portrait = height > width
+ if (!overridesLoaded || overridesPortrait != portrait) {
+ overrides = RetroControlLayouts.load(context, system?.id, portrait)
+ overridesLoaded = true
+ overridesPortrait = portrait
+ cancelDrag()
+ }
+ buttons.clear()
+ cButtons.clear()
+ if (portrait) {
+ layoutPortrait(width, height)
+ } else {
+ layoutLandscape(width, height)
+ }
+ applyOverrides(width, height)
+ if (editMode) layoutEditChrome(width, height)
+ invalidate()
+ }
+
+ private fun idFor(button: GlassButton): String =
+ if (button === menuButton) {
+ "menu"
+ } else {
+ when (button.keyCode) {
+ KeyEvent.KEYCODE_BUTTON_A -> "btn_a"
+ KeyEvent.KEYCODE_BUTTON_B -> "btn_b"
+ KeyEvent.KEYCODE_BUTTON_X -> "btn_x"
+ KeyEvent.KEYCODE_BUTTON_Y -> "btn_y"
+ KeyEvent.KEYCODE_BUTTON_L1 -> "l1"
+ KeyEvent.KEYCODE_BUTTON_R1 -> "r1"
+ KeyEvent.KEYCODE_BUTTON_L2 -> "l2"
+ KeyEvent.KEYCODE_BUTTON_R2 -> "r2"
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> "l3"
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> "r3"
+ KeyEvent.KEYCODE_BUTTON_START -> "start"
+ KeyEvent.KEYCODE_BUTTON_SELECT -> "select"
+ else -> "btn_${button.keyCode}"
+ }
+ }
+
+ private fun reposition(
+ button: GlassButton,
+ o: RetroControlOverride,
+ w: Float,
+ h: Float,
+ ) {
+ val hw = button.bounds.width() * 0.5f * o.scale
+ val hh = button.bounds.height() * 0.5f * o.scale
+ button.bounds.set(o.x * w - hw, o.y * h - hh, o.x * w + hw, o.y * h + hh)
+ }
+
+ private fun applyOverrides(
+ w: Float,
+ h: Float,
+ ) {
+ dpadVisible = true
+ stickVisible = true
+ stick2Visible = true
+ hiddenControls.clear()
+ var hideCpad = false
+ overrides.forEach { (id, o) ->
+ when (id) {
+ "dpad" ->
+ if (!o.visible) {
+ dpadVisible = false
+ hiddenControls += id to "DPAD"
+ } else {
+ dpadCx = o.x * w
+ dpadCy = o.y * h
+ dpadRadius *= o.scale
+ }
+ "stick" ->
+ if (config.hasStick || config.hasDualSticks) {
+ if (!o.visible) {
+ stickVisible = false
+ hiddenControls += id to if (config.hasDualSticks) "L-STICK" else "STICK"
+ } else {
+ stickCx = o.x * w
+ stickCy = o.y * h
+ stickRadius *= o.scale
+ }
+ }
+ "stick2" ->
+ if (config.hasDualSticks) {
+ if (!o.visible) {
+ stick2Visible = false
+ hiddenControls += id to "R-STICK"
+ } else {
+ stick2Cx = o.x * w
+ stick2Cy = o.y * h
+ stick2Radius *= o.scale
+ }
+ }
+ "cpad" ->
+ if (cButtons.isNotEmpty()) {
+ if (!o.visible) {
+ hideCpad = true
+ hiddenControls += id to "C-PAD"
+ } else {
+ val cx = cButtons.map { it.bounds.centerX() }.average().toFloat()
+ val cy = cButtons.map { it.bounds.centerY() }.average().toFloat()
+ val nx = o.x * w
+ val ny = o.y * h
+ cButtons.forEach { c ->
+ val rx = (c.bounds.centerX() - cx) * o.scale
+ val ry = (c.bounds.centerY() - cy) * o.scale
+ val half = c.bounds.width() * 0.5f * o.scale
+ c.bounds.set(nx + rx - half, ny + ry - half, nx + rx + half, ny + ry + half)
+ }
+ }
+ }
+ "menu" -> reposition(menuButton, o, w, h)
+ else -> {
+ val target = buttons.firstOrNull { idFor(it) == id } ?: return@forEach
+ if (!o.visible) {
+ buttons.remove(target)
+ hiddenControls += id to target.label
+ } else {
+ reposition(target, o, w, h)
+ }
+ }
+ }
+ }
+ if (hideCpad) cButtons.clear()
+ if (selectedId != null && controlRect(selectedId!!) == null) selectedId = null
+ }
+
+ private fun controlRect(id: String): RectF? =
+ when (id) {
+ "dpad" ->
+ if (dpadVisible && dpadRadius > 0f) {
+ RectF(dpadCx - dpadRadius, dpadCy - dpadRadius, dpadCx + dpadRadius, dpadCy + dpadRadius)
+ } else {
+ null
+ }
+ "stick" ->
+ if ((config.hasStick || config.hasDualSticks) && stickVisible && stickRadius > 0f) {
+ RectF(stickCx - stickRadius, stickCy - stickRadius, stickCx + stickRadius, stickCy + stickRadius)
+ } else {
+ null
+ }
+ "stick2" ->
+ if (config.hasDualSticks && stick2Visible && stick2Radius > 0f) {
+ RectF(
+ stick2Cx - stick2Radius,
+ stick2Cy - stick2Radius,
+ stick2Cx + stick2Radius,
+ stick2Cy + stick2Radius,
+ )
+ } else {
+ null
+ }
+ "cpad" ->
+ if (cButtons.isNotEmpty()) {
+ RectF(
+ cButtons.minOf { it.bounds.left },
+ cButtons.minOf { it.bounds.top },
+ cButtons.maxOf { it.bounds.right },
+ cButtons.maxOf { it.bounds.bottom },
+ )
+ } else {
+ null
+ }
+ "menu" -> menuButton.bounds
+ else -> buttons.firstOrNull { idFor(it) == id }?.bounds
+ }
+
+ private fun allControlIds(): List =
+ buildList {
+ addAll(buttons.map { idFor(it) })
+ add("menu")
+ add("dpad")
+ if (config.hasStick || config.hasDualSticks) add("stick")
+ if (config.hasDualSticks) add("stick2")
+ add("cpad")
+ }
+
+ private fun ensureOverride(id: String): RetroControlOverride {
+ overrides[id]?.let { return it }
+ val r = controlRect(id)
+ val o =
+ RetroControlOverride(
+ (r?.centerX() ?: width * 0.5f) / width,
+ (r?.centerY() ?: height * 0.5f) / height,
+ )
+ overrides[id] = o
+ return o
+ }
+
+ private fun moveControl(
+ id: String,
+ cx: Float,
+ cy: Float,
+ ) {
+ val o = ensureOverride(id)
+ o.x = (cx / width).coerceIn(0.02f, 0.98f)
+ o.y = (cy / height).coerceIn(0.02f, 0.98f)
+ relayout()
+ }
+
+ private fun scaleControl(
+ id: String,
+ delta: Float,
+ ) {
+ val o = ensureOverride(id)
+ o.scale = (o.scale + delta).coerceIn(0.6f, 1.8f)
+ saveOverrides()
+ relayout()
+ }
+
+ private fun setControlVisible(
+ id: String,
+ visible: Boolean,
+ ) {
+ val o = ensureOverride(id)
+ o.visible = visible
+ saveOverrides()
+ relayout()
+ }
+
+ private fun layoutLandscape(
+ width: Float,
+ height: Float,
+ ) {
+ if (config.hasStick) {
+ layoutN64(width, height)
+ return
+ }
+ snap = width / 100f
+ val margin = snap * 2.5f
+ val bottomGap = snap * 9f
+ var faceRadius = snap * 3f
+ strokeWidth = max(2f, snap * 0.18f)
+
+ val trigW = snap * 10.4f
+ val trigH = snap * 5.2f
+ val trigGap = snap * 1.5f
+
+ var leftCursor = margin
+ var rightCursor = margin
+ if (config.hasTriggers) {
+ val lt =
+ GlassButton(KeyEvent.KEYCODE_BUTTON_L2, config.leftTriggerLabel, GlassShape.TRIGGER_LT, textScale = 1.3f)
+ lt.bounds.set(margin, leftCursor, margin + trigW, leftCursor + trigH)
+ buttons += lt
+ leftCursor += trigH + trigGap
+ if (config.showRightTrigger) {
+ val rt =
+ GlassButton(
+ KeyEvent.KEYCODE_BUTTON_R2,
+ config.rightTriggerLabel,
+ GlassShape.TRIGGER_RT,
+ textScale = 1.3f,
+ )
+ rt.bounds.set(width - margin - trigW, rightCursor, width - margin, rightCursor + trigH)
+ buttons += rt
+ rightCursor += trigH + trigGap
+ }
+ }
+ if (config.hasShoulders) {
+ val leftShape = if (config.hasTriggers) GlassShape.TRIGGER_LB else GlassShape.TRIGGER_LT
+ val rightShape = if (config.hasTriggers) GlassShape.TRIGGER_RB else GlassShape.TRIGGER_RT
+ if (config.showLeftShoulder) {
+ val lb =
+ GlassButton(
+ KeyEvent.KEYCODE_BUTTON_L1,
+ config.leftShoulderLabel,
+ leftShape,
+ textScale = 1.3f,
+ )
+ lb.bounds.set(margin, leftCursor, margin + trigW, leftCursor + trigH)
+ buttons += lb
+ }
+ val rb =
+ GlassButton(
+ KeyEvent.KEYCODE_BUTTON_R1,
+ config.rightShoulderLabel,
+ rightShape,
+ textScale = 1.3f,
+ )
+ rb.bounds.set(width - margin - trigW, rightCursor, width - margin, rightCursor + trigH)
+ buttons += rb
+ leftCursor += trigH + trigGap
+ }
+
+ var spread = snap * 5.5f
+ var clusterCx = width - margin - faceRadius - spread
+ val rightBarWidth = gameArea?.let { width - it.right } ?: 0f
+ if (rightBarWidth > snap * 8f) {
+ val clusterHalf = if (config.hasXY) faceRadius + spread else faceRadius * 2.45f
+ val avail = rightBarWidth * 0.5f - snap
+ if (clusterHalf > avail) {
+ val fit = (avail / clusterHalf).coerceAtLeast(0.62f)
+ faceRadius *= fit
+ spread *= fit
+ }
+ clusterCx = width - rightBarWidth * 0.5f
+ }
+ val clusterCy =
+ if (config.hasDualSticks) {
+ dpadCy - dpadRadius + spread + faceRadius
+ } else {
+ height - bottomGap - faceRadius - spread
+ }
+ var clusterTop = height
+ fun addFace(
+ keyCode: Int,
+ label: String,
+ cx: Float,
+ cy: Float,
+ ) {
+ val button = GlassButton(keyCode, label, GlassShape.CIRCLE, textScale = faceGlyphScale(label))
+ button.bounds.set(cx - faceRadius, cy - faceRadius, cx + faceRadius, cy + faceRadius)
+ buttons += button
+ clusterTop = min(clusterTop, button.bounds.top)
+ }
+ if (config.hasXY) {
+ addFace(KeyEvent.KEYCODE_BUTTON_X, config.faceTop, clusterCx, clusterCy - spread)
+ addFace(KeyEvent.KEYCODE_BUTTON_B, config.faceBottom, clusterCx, clusterCy + spread)
+ addFace(KeyEvent.KEYCODE_BUTTON_Y, config.faceLeft, clusterCx - spread, clusterCy)
+ addFace(KeyEvent.KEYCODE_BUTTON_A, config.faceRight, clusterCx + spread, clusterCy)
+ } else if (config.flatFaces) {
+ addFace(KeyEvent.KEYCODE_BUTTON_B, "B", clusterCx - faceRadius * 1.35f, clusterCy + spread * 0.5f)
+ addFace(KeyEvent.KEYCODE_BUTTON_A, "A", clusterCx + faceRadius * 1.35f, clusterCy + spread * 0.5f)
+ } else {
+ addFace(KeyEvent.KEYCODE_BUTTON_B, "B", clusterCx - faceRadius * 1.1f, clusterCy + spread * 0.5f + faceRadius * 0.5f)
+ addFace(KeyEvent.KEYCODE_BUTTON_A, "A", clusterCx + faceRadius * 1.1f, clusterCy + spread * 0.5f - faceRadius * 1.1f)
+ }
+
+ val pillW = snap * 6f
+ val pillH = snap * 3f
+ val pillGap = snap * 1.2f
+ stickRadius = 0f
+ dpadRadius = snap * 7.5f
+ dpadCx = margin + dpadRadius
+ val leftBarWidth = gameArea?.left ?: 0f
+ if (leftBarWidth > snap * 8f) {
+ val avail = leftBarWidth - snap * 2f
+ if (dpadRadius * 2f > avail) dpadRadius = (avail * 0.5f).coerceAtLeast(snap * 5f)
+ dpadCx = leftBarWidth * 0.5f
+ }
+ dpadCy = height - bottomGap - dpadRadius
+
+ if (config.hasDualSticks) {
+ stickRadius = snap * 5.5f
+ stick2Radius = stickRadius
+ dpadCy -= stickRadius * 0.9f
+ stickCx = dpadCx + dpadRadius + snap * 0.8f + stickRadius
+ stickCy = height - snap * 4.5f - stickRadius
+ stick2Cx = width - stickCx
+ stick2Cy = stickCy
+
+ if (showL3R3) {
+ val l3r = faceRadius * 0.82f
+ val l3cy = height - snap * 3.2f - l3r
+ val l3cx = margin + l3r + snap * 0.5f
+ val r3cx = width - margin - l3r - snap * 0.5f
+ val l3 = GlassButton(KeyEvent.KEYCODE_BUTTON_THUMBL, "L3", GlassShape.CIRCLE, textScale = 0.8f)
+ l3.bounds.set(l3cx - l3r, l3cy - l3r, l3cx + l3r, l3cy + l3r)
+ buttons += l3
+ val r3 = GlassButton(KeyEvent.KEYCODE_BUTTON_THUMBR, "R3", GlassShape.CIRCLE, textScale = 0.8f)
+ r3.bounds.set(r3cx - l3r, l3cy - l3r, r3cx + l3r, l3cy + l3r)
+ buttons += r3
+ }
+ }
+
+ if (config.hasTriggers && config.hasXY) {
+ val rowY = height - snap * 2.5f - pillH
+ var pillX = (width - pillW * 3f - pillGap * 2f) * 0.5f
+ menuButton.bounds.set(pillX, rowY, pillX + pillW, rowY + pillH)
+ pillX += pillW + pillGap
+ val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL, textScale = 0.75f)
+ select.bounds.set(pillX, rowY, pillX + pillW, rowY + pillH)
+ buttons += select
+ pillX += pillW + pillGap
+ val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL, textScale = 0.75f)
+ start.bounds.set(pillX, rowY, pillX + pillW, rowY + pillH)
+ buttons += start
+ return
+ }
+
+ val pillY = clusterTop - pillH - snap * 3.5f
+ val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL, textScale = 0.75f)
+ start.bounds.set(width - margin - pillW, pillY, width - margin, pillY + pillH)
+ buttons += start
+ val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL, textScale = 0.75f)
+ select.bounds.set(
+ width - margin - pillW * 2 - pillGap,
+ pillY,
+ width - margin - pillW - pillGap,
+ pillY + pillH,
+ )
+ buttons += select
+
+ val menuW = snap * 6f
+ val menuY =
+ min(
+ max(pillY, leftCursor),
+ dpadCy - dpadRadius - pillH - snap * 3f,
+ )
+ menuButton.bounds.set(dpadCx - menuW * 0.5f, menuY, dpadCx + menuW * 0.5f, menuY + pillH)
+ }
+
+ private fun layoutN64(
+ width: Float,
+ height: Float,
+ ) {
+ snap = width / 100f
+ strokeWidth = max(2f, snap * 0.18f)
+ val margin = snap * 2.5f
+ val trigW = snap * 10.4f
+ val trigH = snap * 5.2f
+ val trigGap = snap * 1.5f
+
+ val lb = GlassButton(KeyEvent.KEYCODE_BUTTON_L1, "L", GlassShape.TRIGGER_LT, textScale = 1.3f)
+ lb.bounds.set(margin, margin, margin + trigW, margin + trigH)
+ buttons += lb
+ val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RT, textScale = 1.3f)
+ rb.bounds.set(width - margin - trigW, margin, width - margin, margin + trigH)
+ buttons += rb
+ val z =
+ GlassButton(KeyEvent.KEYCODE_BUTTON_L2, config.leftTriggerLabel, GlassShape.TRIGGER_RB, textScale = 1.3f)
+ z.bounds.set(
+ width - margin - trigW,
+ margin + trigH + trigGap,
+ width - margin,
+ margin + trigH * 2 + trigGap,
+ )
+ buttons += z
+
+ val faceRadius = snap * 3f
+ val spread = snap * 5.5f
+ var clusterCx = width - margin - faceRadius - spread
+ val rightBarWidth = gameArea?.let { width - it.right } ?: 0f
+ if (rightBarWidth >= (faceRadius + spread) * 2f + snap * 2f) {
+ clusterCx = width - rightBarWidth * 0.5f
+ }
+ val clusterCy = height - snap * 4.5f - faceRadius - spread
+ val bCx = clusterCx - faceRadius * 0.9f
+ val bCy = clusterCy + spread * 0.5f - faceRadius * 0.9f
+ val bButton = GlassButton(KeyEvent.KEYCODE_BUTTON_Y, "B", GlassShape.CIRCLE)
+ bButton.bounds.set(bCx - faceRadius, bCy - faceRadius, bCx + faceRadius, bCy + faceRadius)
+ buttons += bButton
+ val aCx = clusterCx + faceRadius * 0.9f
+ val aCy = clusterCy + spread * 0.5f + faceRadius * 0.9f
+ val aButton = GlassButton(KeyEvent.KEYCODE_BUTTON_B, "A", GlassShape.CIRCLE)
+ aButton.bounds.set(aCx - faceRadius, aCy - faceRadius, aCx + faceRadius, aCy + faceRadius)
+ buttons += aButton
+
+ val pillW = snap * 6f
+ val pillH = snap * 3f
+ val pillGap = snap * 1.2f
+ val pillY = height - snap * 2.5f - pillH
+ var pillX = (width - pillW * 3f - pillGap * 2f) * 0.5f
+ menuButton.bounds.set(pillX, pillY, pillX + pillW, pillY + pillH)
+ pillX += pillW + pillGap
+ val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL, textScale = 0.75f)
+ select.bounds.set(pillX, pillY, pillX + pillW, pillY + pillH)
+ buttons += select
+ pillX += pillW + pillGap
+ val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL, textScale = 0.75f)
+ start.bounds.set(pillX, pillY, pillX + pillW, pillY + pillH)
+ buttons += start
+
+ stickRadius = snap * 7f
+ stickCx = margin + stickRadius + snap * 1f
+ val leftBarWidth = gameArea?.left ?: 0f
+ if (leftBarWidth >= stickRadius * 2f + snap * 2f) {
+ stickCx = leftBarWidth * 0.5f
+ }
+ stickCy = height - snap * 5.5f - stickRadius
+ dpadRadius = snap * 6.5f
+ dpadCx = stickCx
+ dpadCy = stickCy - stickRadius - snap * 2f - dpadRadius
+
+ val cRadius = snap * 2.4f
+ val cSpread = snap * 3.6f
+ val cCx = clusterCx + snap * 2f
+ val topOfFaces = bCy - faceRadius
+ val bottomOfTriggers = margin + trigH * 2 + trigGap
+ val cCy =
+ min(
+ (bottomOfTriggers + topOfFaces) * 0.5f + snap * 2f,
+ topOfFaces - snap * 1.5f - cSpread - cRadius,
+ )
+ fun addC(
+ dx: Float,
+ dy: Float,
+ glyph: String,
+ x: Float,
+ y: Float,
+ ) {
+ val c = CButton(dx, dy, glyph)
+ c.bounds.set(x - cRadius, y - cRadius, x + cRadius, y + cRadius)
+ cButtons += c
+ }
+ addC(0f, -1f, "▲", cCx, cCy - cSpread)
+ addC(0f, 1f, "▼", cCx, cCy + cSpread)
+ addC(-1f, 0f, "◀", cCx - cSpread, cCy)
+ addC(1f, 0f, "▶", cCx + cSpread, cCy)
+ }
+
+ private val portraitGameAspect: Float
+ get() =
+ when (system?.id) {
+ RetroSystems.GAMEBOY.id, RetroSystems.GAMEBOY_COLOR.id -> 0.9f
+ RetroSystems.GBA.id -> 2f / 3f
+ else -> 0.75f
+ }
+
+ private fun portraitZoneTop(
+ width: Float,
+ height: Float,
+ ): Float = max(width * portraitGameAspect + snap * 8f, height * 0.42f)
+
+ private fun layoutPortrait(
+ width: Float,
+ height: Float,
+ ) {
+ if (config.hasStick) {
+ layoutPortraitN64(width, height)
+ return
+ }
+ snap = width / 100f
+ strokeWidth = max(2f, snap * 0.4f)
+ stickRadius = 0f
+ val zoneTop = portraitZoneTop(width, height)
+ val zoneH = height - zoneTop
+ val margin = snap * 5f
+ val trigW = snap * 24f
+ val trigH = snap * 8f
+ val trigGap = snap * 1.5f
+
+ var sideCursor = zoneTop + snap * 2f
+ if (config.hasTriggers) {
+ val lt = GlassButton(KeyEvent.KEYCODE_BUTTON_L2, config.leftTriggerLabel, GlassShape.TRIGGER_LT, textScale = 1.3f)
+ lt.bounds.set(margin, sideCursor, margin + trigW, sideCursor + trigH)
+ buttons += lt
+ if (config.showRightTrigger) {
+ val rt =
+ GlassButton(
+ KeyEvent.KEYCODE_BUTTON_R2,
+ config.rightTriggerLabel,
+ GlassShape.TRIGGER_RT,
+ textScale = 1.3f,
+ )
+ rt.bounds.set(width - margin - trigW, sideCursor, width - margin, sideCursor + trigH)
+ buttons += rt
+ }
+ sideCursor += trigH + trigGap
+ }
+ if (config.hasShoulders) {
+ val leftShape = if (config.hasTriggers) GlassShape.TRIGGER_LB else GlassShape.TRIGGER_LT
+ val rightShape = if (config.hasTriggers) GlassShape.TRIGGER_RB else GlassShape.TRIGGER_RT
+ if (config.showLeftShoulder) {
+ val lb =
+ GlassButton(
+ KeyEvent.KEYCODE_BUTTON_L1,
+ config.leftShoulderLabel,
+ leftShape,
+ textScale = 1.3f,
+ )
+ lb.bounds.set(margin, sideCursor, margin + trigW, sideCursor + trigH)
+ buttons += lb
+ }
+ val rb =
+ GlassButton(
+ KeyEvent.KEYCODE_BUTTON_R1,
+ config.rightShoulderLabel,
+ rightShape,
+ textScale = 1.3f,
+ )
+ rb.bounds.set(width - margin - trigW, sideCursor, width - margin, sideCursor + trigH)
+ buttons += rb
+ sideCursor += trigH + trigGap
+ }
+
+ val rowCy = max(zoneTop + zoneH * 0.45f, sideCursor + snap * 18f)
+ dpadRadius = snap * 15f
+ dpadCx = margin + dpadRadius
+ dpadCy = rowCy
+
+ fun addFace(
+ keyCode: Int,
+ label: String,
+ cx: Float,
+ cy: Float,
+ radius: Float,
+ ) {
+ val button = GlassButton(keyCode, label, GlassShape.CIRCLE, textScale = faceGlyphScale(label))
+ button.bounds.set(cx - radius, cy - radius, cx + radius, cy + radius)
+ buttons += button
+ }
+ if (config.hasXY) {
+ val faceRadius = snap * 6.5f
+ val spread = snap * 12f
+ val clusterCx = width - margin - faceRadius - spread
+ addFace(KeyEvent.KEYCODE_BUTTON_X, config.faceTop, clusterCx, rowCy - spread, faceRadius)
+ addFace(KeyEvent.KEYCODE_BUTTON_B, config.faceBottom, clusterCx, rowCy + spread, faceRadius)
+ addFace(KeyEvent.KEYCODE_BUTTON_Y, config.faceLeft, clusterCx - spread, rowCy, faceRadius)
+ addFace(KeyEvent.KEYCODE_BUTTON_A, config.faceRight, clusterCx + spread, rowCy, faceRadius)
+ } else if (config.flatFaces) {
+ val faceRadius = snap * 8.5f
+ addFace(KeyEvent.KEYCODE_BUTTON_A, "A", width - margin - faceRadius, rowCy, faceRadius)
+ addFace(
+ KeyEvent.KEYCODE_BUTTON_B,
+ "B",
+ width - margin - faceRadius * 3.7f,
+ rowCy,
+ faceRadius,
+ )
+ } else {
+ val faceRadius = snap * 8.5f
+ addFace(KeyEvent.KEYCODE_BUTTON_A, "A", width - margin - faceRadius, rowCy - faceRadius * 0.9f, faceRadius)
+ addFace(
+ KeyEvent.KEYCODE_BUTTON_B,
+ "B",
+ width - margin - faceRadius * 3f - snap * 2f,
+ rowCy + faceRadius * 0.9f,
+ faceRadius,
+ )
+ }
+
+ val pillW = snap * 13f
+ val pillH = snap * 5.5f
+ val pillGap = snap * 2f
+ val pillY = height - margin - pillH
+ val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL, textScale = 0.75f)
+ select.bounds.set(width * 0.5f - pillGap * 0.5f - pillW, pillY, width * 0.5f - pillGap * 0.5f, pillY + pillH)
+ buttons += select
+ val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL, textScale = 0.75f)
+ start.bounds.set(width * 0.5f + pillGap * 0.5f, pillY, width * 0.5f + pillGap * 0.5f + pillW, pillY + pillH)
+ buttons += start
+
+ val menuW = snap * 13f
+ menuButton.bounds.set(
+ width * 0.5f - menuW * 0.5f,
+ zoneTop + snap * 1.5f,
+ width * 0.5f + menuW * 0.5f,
+ zoneTop + snap * 1.5f + pillH,
+ )
+ }
+
+ private fun layoutPortraitN64(
+ width: Float,
+ height: Float,
+ ) {
+ snap = width / 100f
+ strokeWidth = max(2f, snap * 0.4f)
+ val zoneTop = portraitZoneTop(width, height)
+ val margin = snap * 5f
+ val trigW = snap * 24f
+ val trigH = snap * 8f
+ val trigGap = snap * 1.5f
+
+ val lb = GlassButton(KeyEvent.KEYCODE_BUTTON_L1, "L", GlassShape.TRIGGER_LT, textScale = 1.3f)
+ lb.bounds.set(margin, zoneTop + snap * 2f, margin + trigW, zoneTop + snap * 2f + trigH)
+ buttons += lb
+ val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RT, textScale = 1.3f)
+ rb.bounds.set(width - margin - trigW, zoneTop + snap * 2f, width - margin, zoneTop + snap * 2f + trigH)
+ buttons += rb
+ val z = GlassButton(KeyEvent.KEYCODE_BUTTON_L2, config.leftTriggerLabel, GlassShape.TRIGGER_RB, textScale = 1.3f)
+ z.bounds.set(
+ width - margin - trigW,
+ zoneTop + snap * 2f + trigH + trigGap,
+ width - margin,
+ zoneTop + snap * 2f + trigH * 2 + trigGap,
+ )
+ buttons += z
+
+ val pillW = snap * 13f
+ val pillH = snap * 5.5f
+ val pillGap = snap * 2f
+ val pillY = height - margin - pillH
+ val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL, textScale = 0.75f)
+ select.bounds.set(width * 0.5f - pillGap * 0.5f - pillW, pillY, width * 0.5f - pillGap * 0.5f, pillY + pillH)
+ buttons += select
+ val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL, textScale = 0.75f)
+ start.bounds.set(width * 0.5f + pillGap * 0.5f, pillY, width * 0.5f + pillGap * 0.5f + pillW, pillY + pillH)
+ buttons += start
+
+ val menuW = snap * 13f
+ menuButton.bounds.set(
+ width * 0.5f - menuW * 0.5f,
+ zoneTop + snap * 1.5f,
+ width * 0.5f + menuW * 0.5f,
+ zoneTop + snap * 1.5f + pillH,
+ )
+
+ stickRadius = snap * 12f
+ stickCx = margin + stickRadius
+ stickCy = pillY - snap * 2f - stickRadius
+ dpadRadius = snap * 10f
+ dpadCx = stickCx
+ dpadCy = stickCy - stickRadius - snap * 2f - dpadRadius
+
+ val faceRadius = snap * 7.5f
+ val clusterCx = width - margin - faceRadius - snap * 9f
+ val aCy = stickCy
+ val aCx = clusterCx + faceRadius * 0.9f
+ val bCx = clusterCx - faceRadius * 0.9f
+ val bCy = aCy - faceRadius * 1.8f
+ val bButton = GlassButton(KeyEvent.KEYCODE_BUTTON_Y, "B", GlassShape.CIRCLE)
+ bButton.bounds.set(bCx - faceRadius, bCy - faceRadius, bCx + faceRadius, bCy + faceRadius)
+ buttons += bButton
+ val aButton = GlassButton(KeyEvent.KEYCODE_BUTTON_B, "A", GlassShape.CIRCLE)
+ aButton.bounds.set(aCx - faceRadius, aCy - faceRadius, aCx + faceRadius, aCy + faceRadius)
+ buttons += aButton
+
+ val cRadius = snap * 4.5f
+ val cSpread = snap * 6.5f
+ val cCx = clusterCx + snap * 1f
+ val shouldersBottom = zoneTop + snap * 2f + trigH * 2 + trigGap
+ val facesTop = bCy - faceRadius
+ val cCy = (shouldersBottom + facesTop) * 0.5f
+ fun addC(
+ dx: Float,
+ dy: Float,
+ glyph: String,
+ x: Float,
+ y: Float,
+ ) {
+ val c = CButton(dx, dy, glyph)
+ c.bounds.set(x - cRadius, y - cRadius, x + cRadius, y + cRadius)
+ cButtons += c
+ }
+ addC(0f, -1f, "▲", cCx, cCy - cSpread)
+ addC(0f, 1f, "▼", cCx, cCy + cSpread)
+ addC(-1f, 0f, "◀", cCx - cSpread, cCy)
+ addC(1f, 0f, "▶", cCx + cSpread, cCy)
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ if (gameArea == null && !editMode) return
+ paint.strokeJoin = Paint.Join.ROUND
+ paint.strokeCap = Paint.Cap.ROUND
+ drawShellBackground(canvas)
+ if (editMode) canvas.drawColor(0x2E000000)
+ drawClusterPlate(canvas)
+ drawFacePlate(canvas)
+ if (dpadVisible) drawDpad(canvas)
+ val adaptive = adaptiveSticks && !editMode
+ if ((config.hasStick || config.hasDualSticks) && stickVisible && (!adaptive || stickActive)) {
+ drawStick(canvas, if (adaptive) stickOriginX else stickCx, if (adaptive) stickOriginY else stickCy)
+ }
+ if (config.hasDualSticks && stick2Visible && (!adaptive || stick2Active)) {
+ drawStick2(canvas, adaptive)
+ }
+ buttons.forEach { drawThemedButton(canvas, it, pressedButtons.contains(it.keyCode)) }
+ cButtons.forEach { drawCButton(canvas, it) }
+ drawThemedButton(canvas, menuButton, menuLatched)
+ if (editMode) drawEditChrome(canvas)
+ }
+
+ private fun shadowTint(alpha: Int): Int {
+ val c = customColors.shadow ?: return Color.argb(alpha, 0, 0, 0)
+ return Color.argb(alpha, Color.red(c), Color.green(c), Color.blue(c))
+ }
+
+ private fun luminance(color: Int): Float =
+ (0.299f * Color.red(color) + 0.587f * Color.green(color) + 0.114f * Color.blue(color)) / 255f
+
+ private fun drawShellBackground(canvas: Canvas) {
+ val area = gameArea ?: return
+ val w = width.toFloat()
+ val h = height.toFloat()
+ paint.shader = null
+ paint.style = Paint.Style.FILL
+ paint.color = customColors.body ?: theme.body
+ if (area.top > 0f) canvas.drawRect(0f, 0f, w, area.top, paint)
+ if (area.bottom < h) canvas.drawRect(0f, area.bottom, w, h, paint)
+ if (area.left > 0f) canvas.drawRect(0f, area.top, area.left, area.bottom, paint)
+ if (area.right < w) canvas.drawRect(area.right, area.top, w, area.bottom, paint)
+ }
+
+ private fun drawFacePlate(canvas: Canvas) {
+ if (theme.backplate == 0) return
+ val faces = buttons.filter { it.shape == GlassShape.CIRCLE }
+ if (faces.isEmpty()) return
+ val pad = faces.first().bounds.width() * 0.28f
+ val left = faces.minOf { it.bounds.left } - pad
+ val top = faces.minOf { it.bounds.top } - pad
+ val right = faces.maxOf { it.bounds.right } + pad
+ val bottom = faces.maxOf { it.bounds.bottom } + pad
+ if (right - left > faces.first().bounds.width() * 3.6f ||
+ bottom - top > faces.first().bounds.height() * 3.6f
+ ) {
+ return
+ }
+ val corner = (bottom - top) * 0.24f
+ paint.shader = null
+ paint.style = Paint.Style.FILL
+ paint.color = theme.backplate
+ canvas.drawRoundRect(left, top, right, bottom, corner, corner, paint)
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = strokeWidth
+ paint.color = darken(theme.backplate, 0.16f)
+ canvas.drawRoundRect(left, top, right, bottom, corner, corner, paint)
+ }
+
+ private fun drawClusterPlate(canvas: Canvas) {
+ if (theme.clusterPlate == 0 || !config.hasXY) return
+ val faces = buttons.filter { it.shape == GlassShape.CIRCLE }
+ if (faces.size < 3) return
+ val cx = faces.map { it.bounds.centerX() }.average().toFloat()
+ val cy = faces.map { it.bounds.centerY() }.average().toFloat()
+ val reach =
+ faces.maxOf {
+ hypot(it.bounds.centerX() - cx, it.bounds.centerY() - cy) + it.bounds.width() * 0.5f
+ }
+ if (reach > faces.first().bounds.width() * 2.2f) return
+ val radius = reach + snap * 1.4f
+ paint.shader =
+ RadialGradient(
+ cx,
+ cy,
+ radius,
+ intArrayOf(theme.clusterPlate, theme.clusterPlate, darken(theme.clusterPlate, 0.12f)),
+ floatArrayOf(0f, 0.8f, 1f),
+ Shader.TileMode.CLAMP,
+ )
+ paint.style = Paint.Style.FILL
+ canvas.drawCircle(cx, cy, radius, paint)
+ paint.shader = null
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = strokeWidth
+ paint.color = darken(theme.clusterPlate, 0.22f)
+ canvas.drawCircle(cx, cy, radius, paint)
+ }
+
+ private fun buttonFill(button: GlassButton): Int =
+ customColors.button
+ ?: theme.buttonColors[button.label]
+ ?: if (button.shape == GlassShape.CIRCLE) theme.button else theme.pill
+
+ private fun buttonTextColor(button: GlassButton): Int =
+ customColors.text
+ ?: theme.buttonTextColors[button.label]
+ ?: if (button.shape == GlassShape.CIRCLE) theme.buttonText else theme.pillText
+
+ private fun drawThemedButton(
+ canvas: Canvas,
+ button: GlassButton,
+ pressed: Boolean,
+ ) {
+ val b = button.bounds
+ val color = buttonFill(button)
+ if (button.shape == GlassShape.CIRCLE) {
+ drawRoundButton(
+ canvas,
+ b.centerX(),
+ b.centerY(),
+ b.width() * 0.5f,
+ color,
+ buttonTextColor(button),
+ button.label,
+ pressed,
+ if (button.label.length > 1) 0.62f else 0.92f * button.textScale,
+ )
+ return
+ }
+ val depth = b.height() * 0.08f
+ val dy = if (pressed) depth * 0.7f else 0f
+ buildShapePath(button)
+ path.offset(0f, depth)
+ paint.shader = null
+ paint.style = Paint.Style.FILL
+ paint.color = shadowTint(if (pressed) 40 else 70)
+ canvas.drawPath(path, paint)
+ buildShapePath(button)
+ path.offset(0f, dy)
+ paint.alpha = 255
+ paint.shader =
+ LinearGradient(
+ b.left,
+ b.top + dy,
+ b.left,
+ b.bottom + dy,
+ intArrayOf(
+ lighten(color, if (pressed) 0.06f else 0.24f),
+ color,
+ darken(color, if (pressed) 0.24f else 0.14f),
+ ),
+ floatArrayOf(0f, 0.5f, 1f),
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawPath(path, paint)
+ paint.shader = null
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = strokeWidth
+ paint.color = darken(color, 0.38f)
+ canvas.drawPath(path, paint)
+ paint.style = Paint.Style.FILL
+ paint.color = buttonTextColor(button)
+ paint.textAlign = Paint.Align.CENTER
+ paint.isFakeBoldText = true
+ paint.textSize = b.height() * (if (button.shape == GlassShape.PILL) 0.44f else 0.5f) * button.textScale / 0.75f * 0.75f
+ val maxTextWidth = b.width() - b.height() * 0.5f
+ if (button.label.isNotEmpty() && paint.measureText(button.label) > maxTextWidth) {
+ paint.textSize = paint.textSize * maxTextWidth / paint.measureText(button.label)
+ }
+ val textY = b.centerY() + dy - (paint.descent() + paint.ascent()) * 0.5f
+ canvas.drawText(button.label, b.centerX(), textY, paint)
+ paint.isFakeBoldText = false
+ }
+
+ private fun drawRoundButton(
+ canvas: Canvas,
+ cx: Float,
+ cy: Float,
+ radius: Float,
+ color: Int,
+ textColor: Int,
+ label: String,
+ pressed: Boolean,
+ textScale: Float,
+ ) {
+ val depth = radius * 0.12f
+ val dy = if (pressed) depth * 0.7f else 0f
+ val bodyCy = cy + dy
+
+ paint.style = Paint.Style.FILL
+ paint.alpha = 255
+ paint.shader =
+ RadialGradient(
+ cx,
+ cy + depth,
+ radius * 1.18f,
+ shadowTint(if (pressed) 55 else 95),
+ Color.TRANSPARENT,
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawCircle(cx, cy + depth, radius * 1.18f, paint)
+
+ paint.shader =
+ RadialGradient(
+ cx - radius * 0.35f,
+ bodyCy - radius * 0.45f,
+ radius * 1.9f,
+ intArrayOf(
+ lighten(color, if (pressed) 0.08f else 0.3f),
+ color,
+ darken(color, if (pressed) 0.28f else 0.16f),
+ ),
+ floatArrayOf(0f, 0.55f, 1f),
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawCircle(cx, bodyCy, radius, paint)
+ paint.shader = null
+
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = strokeWidth
+ paint.color = darken(color, 0.4f)
+ canvas.drawCircle(cx, bodyCy, radius - strokeWidth * 0.5f, paint)
+
+ if (!pressed) {
+ paint.style = Paint.Style.FILL
+ paint.shader =
+ RadialGradient(
+ cx - radius * 0.34f,
+ bodyCy - radius * 0.4f,
+ radius * 0.62f,
+ Color.argb(70, 255, 255, 255),
+ Color.TRANSPARENT,
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawCircle(cx - radius * 0.34f, bodyCy - radius * 0.4f, radius * 0.62f, paint)
+ paint.shader = null
+ }
+
+ if (label.isNotEmpty()) {
+ paint.style = Paint.Style.FILL
+ paint.color = textColor
+ paint.textAlign = Paint.Align.CENTER
+ paint.isFakeBoldText = true
+ paint.textSize = radius * textScale * 1.05f
+ val maxTextWidth = radius * (if (label.length == 1) 2.4f else 1.7f)
+ if (paint.measureText(label) > maxTextWidth) {
+ paint.textSize = paint.textSize * maxTextWidth / paint.measureText(label)
+ }
+ val textY = bodyCy - (paint.descent() + paint.ascent()) * 0.5f
+ canvas.drawText(label, cx, textY, paint)
+ paint.isFakeBoldText = false
+ }
+ }
+
+ private fun drawCButton(
+ canvas: Canvas,
+ button: CButton,
+ ) {
+ val b = button.bounds
+ val pressed =
+ (button.dx != 0f && cStickX == button.dx) || (button.dy != 0f && cStickY == button.dy)
+ drawRoundButton(
+ canvas,
+ b.centerX(),
+ b.centerY(),
+ b.width() * 0.5f,
+ customColors.button ?: theme.cButton,
+ customColors.text ?: theme.cText,
+ button.glyph,
+ pressed,
+ 0.8f,
+ )
+ }
+
+ private fun buildShapePath(button: GlassButton) {
+ val b = button.bounds
+ when (button.shape) {
+ GlassShape.CIRCLE -> {
+ path.reset()
+ path.addCircle(b.centerX(), b.centerY(), b.width() * 0.5f, Path.Direction.CW)
+ }
+ GlassShape.PILL -> {
+ path.reset()
+ val r = b.height() * 0.5f
+ path.addRoundRect(b.left, b.top, b.right, b.bottom, r, r, Path.Direction.CW)
+ }
+ GlassShape.TRIGGER_LT ->
+ GameHubLayout.buildTriggerPath(path, GameHubLayout.RenderShape.TRIGGER_LT, b.left, b.top, b.right, b.bottom)
+ GlassShape.TRIGGER_LB ->
+ GameHubLayout.buildTriggerPath(path, GameHubLayout.RenderShape.TRIGGER_LB, b.left, b.top, b.right, b.bottom)
+ GlassShape.TRIGGER_RT ->
+ GameHubLayout.buildTriggerPath(path, GameHubLayout.RenderShape.TRIGGER_RT, b.left, b.top, b.right, b.bottom)
+ GlassShape.TRIGGER_RB ->
+ GameHubLayout.buildTriggerPath(path, GameHubLayout.RenderShape.TRIGGER_RB, b.left, b.top, b.right, b.bottom)
+ }
+ }
+
+ private fun drawDpad(canvas: Canvas) {
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ val path = Path()
+ val color = customColors.button ?: theme.dpad
+ val arm = dpadRadius * 0.36f
+ val corner = arm * 0.5f
+ val cx = dpadCx
+ val cy = dpadCy
+ val r = dpadRadius
+ val depth = r * 0.05f
+ val pressedUp = dpadY < -0.1f
+ val pressedDown = dpadY > 0.1f
+ val pressedLeft = dpadX < -0.1f
+ val pressedRight = dpadX > 0.1f
+
+ path.reset()
+ path.addRoundRect(cx - r, cy - arm + depth, cx + r, cy + arm + depth, corner, corner, Path.Direction.CW)
+ path.addRoundRect(cx - arm, cy - r + depth, cx + arm, cy + r + depth, corner, corner, Path.Direction.CW)
+ paint.shader = null
+ paint.style = Paint.Style.FILL
+ paint.color = shadowTint(80)
+ canvas.drawPath(path, paint)
+
+ path.reset()
+ path.addRoundRect(cx - r, cy - arm, cx + r, cy + arm, corner, corner, Path.Direction.CW)
+ path.addRoundRect(cx - arm, cy - r, cx + arm, cy + r, corner, corner, Path.Direction.CW)
+ paint.alpha = 255
+ paint.shader =
+ LinearGradient(
+ cx,
+ cy - r,
+ cx,
+ cy + r,
+ intArrayOf(lighten(color, 0.2f), color, darken(color, 0.2f)),
+ floatArrayOf(0f, 0.5f, 1f),
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawPath(path, paint)
+ paint.shader = null
+
+ fun pressArm(
+ left: Float,
+ top: Float,
+ right: Float,
+ bottom: Float,
+ ) {
+ paint.style = Paint.Style.FILL
+ paint.color = Color.argb(90, 0, 0, 0)
+ canvas.drawRoundRect(left, top, right, bottom, corner, corner, paint)
+ }
+ if (pressedUp) pressArm(cx - arm, cy - r, cx + arm, cy - arm * 0.4f)
+ if (pressedDown) pressArm(cx - arm, cy + arm * 0.4f, cx + arm, cy + r)
+ if (pressedLeft) pressArm(cx - r, cy - arm, cx - arm * 0.4f, cy + arm)
+ if (pressedRight) pressArm(cx + arm * 0.4f, cy - arm, cx + r, cy + arm)
+
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = strokeWidth
+ paint.color = darken(color, 0.45f)
+ path.reset()
+ path.addRoundRect(cx - r, cy - arm, cx + r, cy + arm, corner, corner, Path.Direction.CW)
+ val vertical = Path()
+ vertical.addRoundRect(cx - arm, cy - r, cx + arm, cy + r, corner, corner, Path.Direction.CW)
+ path.op(vertical, Path.Op.UNION)
+ canvas.drawPath(path, paint)
+
+ paint.style = Paint.Style.FILL
+ paint.shader =
+ RadialGradient(
+ cx,
+ cy,
+ arm * 0.85f,
+ darken(color, 0.3f),
+ color,
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawCircle(cx, cy, arm * 0.85f, paint)
+ paint.shader = null
+
+ val tip = arm * 0.5f
+ val arrowBase =
+ customColors.text
+ ?: if (luminance(color) < 0.55f) 0xFFF3F4F7.toInt() else 0xFF17181C.toInt()
+ fun arrowPath(
+ tx: Float,
+ ty: Float,
+ dxu: Float,
+ dyu: Float,
+ ) {
+ path.reset()
+ path.moveTo(tx + dxu * tip, ty + dyu * tip)
+ path.lineTo(tx - dyu * tip * 0.8f - dxu * tip * 0.5f, ty + dxu * tip * 0.8f - dyu * tip * 0.5f)
+ path.lineTo(tx + dyu * tip * 0.8f - dxu * tip * 0.5f, ty - dxu * tip * 0.8f - dyu * tip * 0.5f)
+ path.close()
+ }
+ fun arrow(
+ tx: Float,
+ ty: Float,
+ dxu: Float,
+ dyu: Float,
+ ) {
+ arrowPath(tx, ty + tip * 0.14f, dxu, dyu)
+ paint.color = shadowTint(120)
+ canvas.drawPath(path, paint)
+ arrowPath(tx, ty, dxu, dyu)
+ paint.color = arrowBase
+ canvas.drawPath(path, paint)
+ }
+ val arrowDist = r * 0.66f
+ arrow(cx, cy - arrowDist, 0f, -1f)
+ arrow(cx, cy + arrowDist, 0f, 1f)
+ arrow(cx - arrowDist, cy, -1f, 0f)
+ arrow(cx + arrowDist, cy, 1f, 0f)
+ }
+
+ private fun drawStick2(
+ canvas: Canvas,
+ adaptive: Boolean = false,
+ ) {
+ val ocx = stickCx
+ val ocy = stickCy
+ val orad = stickRadius
+ val ox = stickX
+ val oy = stickY
+ val op = stickPointerId
+ stickCx = stick2Cx
+ stickCy = stick2Cy
+ stickRadius = stick2Radius
+ stickX = stick2X
+ stickY = stick2Y
+ stickPointerId = stick2PointerId
+ if (adaptive) {
+ drawStick(canvas, stick2OriginX, stick2OriginY)
+ } else {
+ drawStick(canvas)
+ }
+ stickCx = ocx
+ stickCy = ocy
+ stickRadius = orad
+ stickX = ox
+ stickY = oy
+ stickPointerId = op
+ }
+
+ private fun drawPsStick(
+ canvas: Canvas,
+ cx: Float = stickCx,
+ cy: Float = stickCy,
+ ) {
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ val engaged = stickPointerId != -1
+ val wellColor = darken(customColors.body ?: theme.body, 0.16f)
+ paint.shader = null
+ paint.style = Paint.Style.FILL
+ paint.alpha = 255
+ paint.shader =
+ RadialGradient(
+ cx,
+ cy,
+ stickRadius,
+ intArrayOf(darken(wellColor, 0.32f), wellColor, lighten(wellColor, 0.1f)),
+ floatArrayOf(0f, 0.78f, 1f),
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawCircle(cx, cy, stickRadius, paint)
+ paint.shader = null
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = strokeWidth
+ paint.color = darken(wellColor, 0.4f)
+ canvas.drawCircle(cx, cy, stickRadius, paint)
+
+ val thumbX = cx + stickX * stickRadius * 0.5f
+ val thumbY = cy + stickY * stickRadius * 0.5f
+ val thumbRadius = stickRadius * 0.6f
+ val capColor = customColors.button ?: 0xFF33333A.toInt()
+ paint.style = Paint.Style.FILL
+ paint.shader =
+ RadialGradient(
+ thumbX,
+ thumbY + thumbRadius * 0.2f,
+ thumbRadius * 1.3f,
+ shadowTint(100),
+ Color.TRANSPARENT,
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawCircle(thumbX, thumbY + thumbRadius * 0.18f, thumbRadius * 1.3f, paint)
+ paint.alpha = 255
+ paint.shader =
+ RadialGradient(
+ thumbX - thumbRadius * 0.28f,
+ thumbY - thumbRadius * 0.32f,
+ thumbRadius * 1.9f,
+ intArrayOf(
+ lighten(capColor, if (engaged) 0.12f else 0.28f),
+ capColor,
+ darken(capColor, 0.3f),
+ ),
+ floatArrayOf(0f, 0.5f, 1f),
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawCircle(thumbX, thumbY, thumbRadius, paint)
+ paint.shader =
+ RadialGradient(
+ thumbX,
+ thumbY + thumbRadius * 0.08f,
+ thumbRadius * 0.72f,
+ intArrayOf(
+ darken(capColor, if (engaged) 0.34f else 0.24f),
+ darken(capColor, 0.12f),
+ lighten(capColor, 0.18f),
+ ),
+ floatArrayOf(0f, 0.75f, 1f),
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawCircle(thumbX, thumbY, thumbRadius * 0.72f, paint)
+ paint.shader = null
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = strokeWidth
+ paint.color = darken(capColor, 0.42f)
+ canvas.drawCircle(thumbX, thumbY, thumbRadius, paint)
+ paint.strokeWidth = strokeWidth * 0.7f
+ paint.color = lighten(capColor, 0.08f)
+ canvas.drawCircle(thumbX, thumbY, thumbRadius * 0.72f, paint)
+ }
+
+ private fun drawStick(
+ canvas: Canvas,
+ cx: Float = stickCx,
+ cy: Float = stickCy,
+ ) {
+ if (config.hasDualSticks) {
+ drawPsStick(canvas, cx, cy)
+ return
+ }
+ val engaged = stickPointerId != -1
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ val wellColor = darken(customColors.button ?: theme.dpad, 0.1f)
+ paint.shader = null
+ paint.style = Paint.Style.FILL
+
+ path.reset()
+ for (i in 0 until 8) {
+ val angle = Math.toRadians((i * 45 - 22.5).toDouble())
+ val px = cx + stickRadius * Math.cos(angle).toFloat()
+ val py = cy + stickRadius * Math.sin(angle).toFloat()
+ if (i == 0) path.moveTo(px, py) else path.lineTo(px, py)
+ }
+ path.close()
+ paint.shader =
+ RadialGradient(
+ cx,
+ cy,
+ stickRadius,
+ intArrayOf(darken(wellColor, 0.35f), wellColor, lighten(wellColor, 0.08f)),
+ floatArrayOf(0f, 0.75f, 1f),
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawPath(path, paint)
+ paint.shader = null
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = strokeWidth
+ paint.color = darken(wellColor, 0.4f)
+ canvas.drawPath(path, paint)
+
+ val thumbX = cx + stickX * stickRadius * 0.5f
+ val thumbY = cy + stickY * stickRadius * 0.5f
+ val thumbRadius = stickRadius * 0.5f
+ val capColor = customColors.button ?: theme.stickCap
+ paint.style = Paint.Style.FILL
+ paint.shader =
+ RadialGradient(
+ thumbX,
+ thumbY + thumbRadius * 0.18f,
+ thumbRadius * 1.25f,
+ shadowTint(90),
+ Color.TRANSPARENT,
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawCircle(thumbX, thumbY + thumbRadius * 0.18f, thumbRadius * 1.25f, paint)
+ paint.shader =
+ RadialGradient(
+ thumbX - thumbRadius * 0.3f,
+ thumbY - thumbRadius * 0.35f,
+ thumbRadius * 1.8f,
+ intArrayOf(
+ lighten(capColor, if (engaged) 0.1f else 0.3f),
+ capColor,
+ darken(capColor, 0.22f),
+ ),
+ floatArrayOf(0f, 0.55f, 1f),
+ Shader.TileMode.CLAMP,
+ )
+ canvas.drawCircle(thumbX, thumbY, thumbRadius, paint)
+ paint.shader = null
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = strokeWidth
+ paint.color = darken(capColor, 0.35f)
+ canvas.drawCircle(thumbX, thumbY, thumbRadius - strokeWidth * 0.5f, paint)
+ canvas.drawCircle(thumbX, thumbY, thumbRadius * 0.55f, paint)
+ }
+
+ private fun layoutEditChrome(
+ width: Float,
+ height: Float,
+ ) {
+ toolbarPills.clear()
+ trayPills.clear()
+ val u = editUnit
+ val pillH = u * 3.4f
+ val gap = u * 0.9f
+ val textPaint = paint
+ textPaint.textSize = pillH * 0.42f
+ textPaint.isFakeBoldText = true
+ val actions =
+ listOf(
+ "done" to "DONE",
+ "reset" to "RESET",
+ "minus" to "−",
+ "plus" to "+",
+ "hide" to "HIDE",
+ )
+ val widths =
+ actions.map { (_, label) ->
+ max(textPaint.measureText(label) + pillH * 1.1f, pillH * 1.4f)
+ }
+ textPaint.isFakeBoldText = false
+ val totalW = widths.sum() + gap * (actions.size - 1)
+ var x = (width - totalW) * 0.5f
+ val y = u * 1.2f
+ actions.forEachIndexed { index, (action, label) ->
+ toolbarPills += Triple(action, label, RectF(x, y, x + widths[index], y + pillH))
+ x += widths[index] + gap
+ }
+ if (hiddenControls.isNotEmpty()) {
+ textPaint.textSize = pillH * 0.38f
+ val trayY = y + pillH + gap
+ val trayWidths =
+ hiddenControls.map { (_, label) ->
+ max(textPaint.measureText("+ $label") + pillH * 1.0f, pillH * 1.3f)
+ }
+ var trayX = (width - trayWidths.sum() - gap * (hiddenControls.size - 1)) * 0.5f
+ hiddenControls.forEachIndexed { index, (id, label) ->
+ trayPills += Triple(id, "+ $label", RectF(trayX, trayY, trayX + trayWidths[index], trayY + pillH))
+ trayX += trayWidths[index] + gap
+ }
+ }
+ }
+
+ private fun drawChromePill(
+ canvas: Canvas,
+ rect: RectF,
+ label: String,
+ accent: Boolean,
+ enabled: Boolean = true,
+ ) {
+ val r = rect.height() * 0.5f
+ paint.shader = null
+ paint.style = Paint.Style.FILL
+ paint.color = if (accent) 0xF22196F3.toInt() else 0xF21C222C.toInt()
+ canvas.drawRoundRect(rect, r, r, paint)
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = strokeWidth
+ paint.color = if (accent) 0xFF64B5F6.toInt() else 0xFF3A4354.toInt()
+ canvas.drawRoundRect(rect, r, r, paint)
+ paint.style = Paint.Style.FILL
+ paint.color = if (enabled) 0xFFF2F5FA.toInt() else 0x66F2F5FA
+ paint.textAlign = Paint.Align.CENTER
+ paint.isFakeBoldText = true
+ paint.textSize = rect.height() * (if (label.startsWith("+ ")) 0.38f else 0.42f)
+ val textY = rect.centerY() - (paint.descent() + paint.ascent()) * 0.5f
+ canvas.drawText(label, rect.centerX(), textY, paint)
+ paint.isFakeBoldText = false
+ }
+
+ private fun drawEditChrome(canvas: Canvas) {
+ val u = editUnit
+ selectedId?.let { id ->
+ controlRect(id)?.let { rect ->
+ val pad = u * 0.7f
+ paint.shader = null
+ paint.style = Paint.Style.STROKE
+ paint.strokeWidth = max(2f, u * 0.22f)
+ paint.color = 0xFF29B6F6.toInt()
+ paint.pathEffect = DashPathEffect(floatArrayOf(u * 0.9f, u * 0.6f), 0f)
+ canvas.drawRoundRect(
+ rect.left - pad,
+ rect.top - pad,
+ rect.right + pad,
+ rect.bottom + pad,
+ u,
+ u,
+ paint,
+ )
+ paint.pathEffect = null
+ }
+ }
+ val hideEnabled = selectedId != null && selectedId != "menu"
+ val sizeEnabled = selectedId != null
+ toolbarPills.forEach { (action, label, rect) ->
+ val enabled =
+ when (action) {
+ "hide" -> hideEnabled
+ "minus", "plus" -> sizeEnabled
+ else -> true
+ }
+ drawChromePill(canvas, rect, label, accent = action == "done", enabled = enabled)
+ }
+ trayPills.forEach { (_, label, rect) ->
+ drawChromePill(canvas, rect, label, accent = false)
+ }
+ val hintY = (toolbarPills.firstOrNull()?.third?.bottom ?: 0f) +
+ (if (trayPills.isEmpty()) u * 2.2f else u * 6.2f)
+ paint.style = Paint.Style.FILL
+ paint.color = 0xCCFFFFFF.toInt()
+ paint.textAlign = Paint.Align.CENTER
+ paint.textSize = u * 1.4f
+ canvas.drawText(
+ if (selectedId == null) "Tap a control to select, drag to move" else "Drag to move · +/− to resize",
+ width * 0.5f,
+ hintY,
+ paint,
+ )
+ }
+
+ private fun controlAt(
+ x: Float,
+ y: Float,
+ ): String? {
+ val u = editUnit
+ var best: String? = null
+ var bestArea = Float.MAX_VALUE
+ allControlIds().forEach { id ->
+ val rect = controlRect(id) ?: return@forEach
+ if (x >= rect.left - u && x <= rect.right + u && y >= rect.top - u && y <= rect.bottom + u) {
+ val area = rect.width() * rect.height()
+ if (area < bestArea) {
+ bestArea = area
+ best = id
+ }
+ }
+ }
+ return best
+ }
+
+ private fun editTouch(event: MotionEvent) {
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
+ val index = event.actionIndex
+ val x = event.getX(index)
+ val y = event.getY(index)
+ toolbarPills.firstOrNull { it.third.contains(x, y) }?.let { (action, _, _) ->
+ hapticTick()
+ cancelDrag()
+ when (action) {
+ "done" -> finishEdit()
+ "reset" -> resetLayout()
+ "minus" -> selectedId?.let { scaleControl(it, -0.1f) }
+ "plus" -> selectedId?.let { scaleControl(it, 0.1f) }
+ "hide" ->
+ selectedId?.takeIf { it != "menu" }?.let {
+ setControlVisible(it, false)
+ selectedId = null
+ }
+ }
+ return
+ }
+ trayPills.firstOrNull { it.third.contains(x, y) }?.let { (id, _, _) ->
+ hapticTick()
+ cancelDrag()
+ setControlVisible(id, true)
+ selectedId = id
+ return
+ }
+ if (dragId == null) {
+ val hit = controlAt(x, y)
+ selectedId = hit
+ if (hit != null) {
+ val rect = controlRect(hit)
+ dragId = hit
+ dragPointerId = event.getPointerId(index)
+ dragActive = false
+ dragDownX = x
+ dragDownY = y
+ dragOffsetX = (rect?.centerX() ?: x) - x
+ dragOffsetY = (rect?.centerY() ?: y) - y
+ hapticTick()
+ }
+ }
+ }
+ MotionEvent.ACTION_MOVE -> {
+ val id = dragId ?: return
+ val pointerIndex = event.findPointerIndex(dragPointerId)
+ if (pointerIndex < 0) return
+ val x = event.getX(pointerIndex)
+ val y = event.getY(pointerIndex)
+ if (!dragActive && hypot(x - dragDownX, y - dragDownY) < editUnit * 0.6f) return
+ dragActive = true
+ moveControl(id, x + dragOffsetX, y + dragOffsetY)
+ }
+ MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
+ val ended =
+ event.actionMasked == MotionEvent.ACTION_UP ||
+ event.actionMasked == MotionEvent.ACTION_CANCEL ||
+ event.getPointerId(event.actionIndex) == dragPointerId
+ if (ended && dragId != null) {
+ val moved = dragActive
+ cancelDrag()
+ if (moved) saveOverrides()
+ }
+ }
+ }
+ }
+
+ fun releaseAll() {
+ for (keyCode in pressedButtons) listener.onButton(keyCode, false)
+ pressedButtons.clear()
+ pointerButton.clear()
+ pointerCButton.clear()
+ dpadPointerId = -1
+ menuPointerId = -1
+ if (dpadX != 0f || dpadY != 0f) {
+ dpadX = 0f
+ dpadY = 0f
+ listener.onDpad(0f, 0f)
+ }
+ if (stickPointerId != -1 || stickX != 0f || stickY != 0f) {
+ stickPointerId = -1
+ stickX = 0f
+ stickY = 0f
+ listener.onStick(0f, 0f)
+ }
+ stickActive = false
+ if (stickL3Held) {
+ stickL3Held = false
+ listener.onButton(KeyEvent.KEYCODE_BUTTON_THUMBL, false)
+ }
+ if (cStickX != 0f || cStickY != 0f) {
+ cStickX = 0f
+ cStickY = 0f
+ listener.onRightStick(0f, 0f)
+ }
+ if (stick2PointerId != -1 || stick2X != 0f || stick2Y != 0f) {
+ stick2PointerId = -1
+ stick2X = 0f
+ stick2Y = 0f
+ listener.onRightStick(0f, 0f)
+ }
+ stick2Active = false
+ if (stickR3Held) {
+ stickR3Held = false
+ listener.onButton(KeyEvent.KEYCODE_BUTTON_THUMBR, false)
+ }
+ menuLatched = false
+ invalidate()
+ }
+
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN,
+ MotionEvent.ACTION_POINTER_DOWN,
+ MotionEvent.ACTION_MOVE,
+ MotionEvent.ACTION_POINTER_UP,
+ MotionEvent.ACTION_UP,
+ MotionEvent.ACTION_CANCEL,
+ ->
+ if (editMode) {
+ editTouch(event)
+ } else if (swallowInput) {
+ if (event.actionMasked == MotionEvent.ACTION_UP ||
+ event.actionMasked == MotionEvent.ACTION_CANCEL
+ ) {
+ swallowInput = false
+ }
+ } else {
+ recompute(event)
+ }
+ else -> return false
+ }
+ invalidate()
+ return true
+ }
+
+ private fun hitButton(
+ button: GlassButton,
+ x: Float,
+ y: Float,
+ ): Boolean {
+ val b = button.bounds
+ val inflate = snap * 1.2f
+ return if (button.shape == GlassShape.CIRCLE) {
+ val r = b.width() * 0.5f + inflate
+ hypot(x - b.centerX(), y - b.centerY()) <= r
+ } else {
+ x >= b.left - inflate && x <= b.right + inflate &&
+ y >= b.top - inflate && y <= b.bottom + inflate
+ }
+ }
+
+ private fun inStickHomeZone(
+ x: Float,
+ y: Float,
+ rightHalf: Boolean,
+ ): Boolean {
+ val w = width.toFloat()
+ val h = height.toFloat()
+ if (w <= 0f || h <= 0f) return false
+ val top = h * 0.42f
+ val midX = w * 0.5f
+ val inHalf = if (rightHalf) x >= midX else x < midX
+ return inHalf && y >= top
+ }
+
+ private fun hitsOtherControl(
+ x: Float,
+ y: Float,
+ ): Boolean {
+ val pad = DEAD_ZONE_PX
+ fun inPadded(b: RectF): Boolean =
+ x >= b.left - pad && x <= b.right + pad && y >= b.top - pad && y <= b.bottom + pad
+ if (menuPointerId == -1 && inPadded(menuButton.bounds)) return true
+ for (button in buttons) {
+ if (inPadded(button.bounds)) return true
+ }
+ val face = buttons.filter { it.keyCode in FACE_KEYCODES }
+ if (face.size >= 3) {
+ val l = face.minOf { it.bounds.left } - pad
+ val t = face.minOf { it.bounds.top } - pad
+ val r = face.maxOf { it.bounds.right } + pad
+ val b = face.maxOf { it.bounds.bottom } + pad
+ if (x in l..r && y in t..b) return true
+ }
+ for (c in cButtons) {
+ val reach = c.bounds.width() * 0.5f + snap * 1.2f + pad
+ if (hypot(x - c.bounds.centerX(), y - c.bounds.centerY()) <= reach) return true
+ }
+ if (dpadVisible && hypot(x - dpadCx, y - dpadCy) <= dpadRadius * 1.4f + pad) return true
+ return false
+ }
+
+ private fun recompute(event: MotionEvent) {
+ val adaptive = adaptiveSticks
+ val now = event.eventTime
+ val released =
+ event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL
+ val liftedPointer =
+ if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) event.actionIndex else -1
+ val downId =
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN ->
+ event.getPointerId(event.actionIndex)
+ else -> -1
+ }
+
+ val activeIds = HashSet