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/.gitmodules b/.gitmodules index e1ec52737..4bdfced71 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "app/src/main/cpp/adrenotools"] path = app/src/main/cpp/adrenotools url = https://github.com/Pipetto-crypto/libadrenotools.git +[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/README.md b/README.md index 7f2622749..8544385fb 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,24 @@ Designed for enthusiasts and power users, WinNative delivers the full Winlator e --- +### 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 | PCSX-ReARMed | `.cue` `.chd` `.pbp` `.m3u` `.iso` | + +**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 BIOS files (e.g. `scph5501.bin`) can be placed in the app's `files/retro/system` folder for better compatibility. + ### 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. @@ -56,3 +74,5 @@ Please match the existing code style and ensure any AI-assisted code is thorough - **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 (FCEUmm, Snes9x, Gambatte, mGBA, Genesis Plus GX, Mupen64Plus-Next, PCSX-ReARMed) diff --git a/app/build.gradle b/app/build.gradle index 9a83a7dea..768c1eef1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -280,6 +280,8 @@ dependencies { implementation libs.playServicesGamesV2 implementation libs.workRuntimeKtx + + implementation project(':libretrodroid') } spotless { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dcb6bc9e1..86467ead4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -127,6 +127,14 @@ android:screenOrientation="sensorLandscape" android:exported="false" /> + + diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 66f62fb8f..801b8df6f 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -134,6 +134,7 @@ internal fun LibraryGameLaunchScreen( lastPlayedMillis: Long, installSizeText: String?, isCustom: Boolean, + isRetro: Boolean = false, hasPinnedShortcut: Boolean, steamMenuEnabled: Boolean = false, areSteamActionsEnabled: Boolean = true, @@ -165,7 +166,7 @@ internal fun LibraryGameLaunchScreen( val actionIconSize = 46.dp val actionIconSpacing = 8.dp // Action icons: Settings, Boot, CloudSync, Shortcut, Delete. - val actionIconCount = 5 + val actionIconCount = if (isRetro) 2 else 5 val actionWidth = actionIconSize * actionIconCount + actionIconSpacing * (actionIconCount - 1) val playHeight = 56.dp val contentGap = 18.dp @@ -391,27 +392,29 @@ 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, - ) - LaunchIconActionButton( - icon = Icons.Outlined.CloudSync, - contentDescription = stringResource(R.string.cloud_saves_title), - size = actionIconSize, - onClick = onCloudSaves, - ) - LaunchIconActionButton( - icon = Icons.Outlined.Home, - contentDescription = - stringResource( - if (hasPinnedShortcut) R.string.common_ui_remove else R.string.common_ui_shortcut, - ), - size = actionIconSize, - onClick = onShortcut, - ) + if (!isRetro) { + LaunchIconActionButton( + icon = Icons.Outlined.DesktopWindows, + contentDescription = stringResource(R.string.hero_boot_to_desktop_title), + size = actionIconSize, + onClick = onBootToDesktop, + ) + LaunchIconActionButton( + icon = Icons.Outlined.CloudSync, + contentDescription = stringResource(R.string.cloud_saves_title), + size = actionIconSize, + onClick = onCloudSaves, + ) + LaunchIconActionButton( + icon = Icons.Outlined.Home, + contentDescription = + stringResource( + if (hasPinnedShortcut) R.string.common_ui_remove else R.string.common_ui_shortcut, + ), + size = actionIconSize, + onClick = onShortcut, + ) + } Box { LaunchIconActionButton( icon = Icons.Outlined.Delete, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index bd99fd0a6..0a3da213e 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -94,6 +94,7 @@ import androidx.compose.ui.zIndex import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester @@ -114,6 +115,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -365,6 +367,8 @@ class UnifiedActivity : var libraryPlaytimeRefreshSignal by mutableIntStateOf(0) private var hasCompletedInitialResume = false + private var retroBadgeByAppId by mutableStateOf>(emptyMap()) + // Activity-level so task progress survives game-detail dialog teardown. private var taskProgressInfo by mutableStateOf(null) private var taskProgressGameName by mutableStateOf("") @@ -3468,6 +3472,7 @@ class UnifiedActivity : localLibraryRefreshKey++ } val allShortcuts = cm.loadShortcuts() + val badges = HashMap() val apps = allShortcuts .mapNotNull { shortcut -> @@ -3479,7 +3484,7 @@ class UnifiedActivity : shortcut .getExtra("custom_name", shortcut.name) .ifBlank { shortcut.name } - + val uuid = shortcut.getExtra("uuid") val customId = if (uuid.isNotEmpty()) { -(uuid.hashCode().and(0x7FFFFFFF) + 1) @@ -3487,6 +3492,13 @@ class UnifiedActivity : -(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.badgeLabel } + SteamApp( id = customId, name = displayName, @@ -3499,13 +3511,14 @@ class UnifiedActivity : ) } - allShortcuts to apps + Triple(allShortcuts, apps, badges) } }.getOrNull() if (shortcutScanResult != null) { cachedShortcuts = shortcutScanResult.first customApps = shortcutScanResult.second + retroBadgeByAppId = shortcutScanResult.third } shortcutsLoaded = true @@ -5687,6 +5700,22 @@ class UnifiedActivity : val isGog = gogGame != null val epicId = if (isEpic) app.id - 2000000000 else 0 + var retroSystemId by remember(app.id) { mutableStateOf(null) } + LaunchedEffect(app.id, isCustom) { + if (isCustom) { + withContext(Dispatchers.IO) { + val sc = findLibraryShortcutForGame(ContainerManager(context), app, true, false, 0) + retroSystemId = + sc + ?.getExtra(com.winlator.cmod.feature.retro.RetroShortcuts.KEY_SYSTEM) + ?.takeIf { it.isNotEmpty() } + } + } else { + retroSystemId = null + } + } + val isRetro = retroSystemId != null + val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), ) @@ -6330,6 +6359,7 @@ class UnifiedActivity : lastPlayedMillis = lastPlayed, installSizeText = installSizeText, isCustom = isCustom, + isRetro = isRetro, hasPinnedShortcut = hasPinnedShortcut, playEnabled = playEnabled, playDisabledLabel = playDisabledLabel, @@ -6349,7 +6379,13 @@ class UnifiedActivity : }, onSettings = { val shortcut = resolveOrCreateShortcut() - if (shortcut != null) { + if (shortcut != null && + com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut) + ) { + com.winlator.cmod.feature.retro + .RetroSettingsDialog(this@UnifiedActivity, shortcut) + .show() + } else if (shortcut != null) { // Layer the settings dialog on top; keep the detail dialog open underneath. ShortcutSettingsComposeDialog(this@UnifiedActivity, shortcut).show() } @@ -7178,6 +7214,9 @@ class UnifiedActivity : .clip(RoundedCornerShape(8.dp)), ) { ArtContent(Modifier.fillMaxSize()) + retroBadgeByAppId[app.id]?.let { badge -> + RetroConsoleRibbon(badge, Modifier.align(Alignment.CenterStart)) + } } Spacer(Modifier.width(14.dp)) @@ -7225,6 +7264,9 @@ class UnifiedActivity : .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), ) { ArtContent(Modifier.fillMaxSize()) + retroBadgeByAppId[app.id]?.let { badge -> + RetroConsoleRibbon(badge, Modifier.align(Alignment.CenterStart)) + } } Text( @@ -11544,6 +11586,12 @@ class UnifiedActivity : return@launch } + if (com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut)) { + 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) @@ -12158,31 +12206,52 @@ class UnifiedActivity : 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) } 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 extension = path.substringAfterLast('.', "").lowercase(java.util.Locale.US) + val detectedRetro = com.winlator.cmod.feature.retro.RetroSystems.detectForFile(path) val launchable = path.endsWith(".exe", ignoreCase = true) || path.endsWith(".bat", ignoreCase = true) || path.endsWith(".cmd", ignoreCase = true) - if (!launchable || !java.io.File(path).isFile) { + if ((!launchable && detectedRetro == null) || !java.io.File(path).isFile) { com.winlator.cmod.shared.ui.toast.WinToast.show( context, R.string.common_ui_select_valid_exe_file, @@ -12192,8 +12261,13 @@ class UnifiedActivity : } selectedExePath = path - gameFolder = LibraryShortcutUtils.detectCustomGameFolder(path) - // Auto-generate a game name from the EXE name (without extension) + retroSystem = detectedRetro + gameFolder = + if (detectedRetro != null) { + java.io.File(path).parent + } else { + LibraryShortcutUtils.detectCustomGameFolder(path) + } if (gameName.isBlank()) { gameName = java.io @@ -12262,7 +12336,9 @@ class UnifiedActivity : android.os.Environment.DIRECTORY_DOWNLOADS, ).absolutePath, title = getString(R.string.common_ui_select_exe), - allowedExtensions = setOf("exe", "bat", "cmd"), + allowedExtensions = + setOf("exe", "bat", "cmd") + + com.winlator.cmod.feature.retro.RetroSystems.allExtensions, dimAmount = 0.5f, preserveBackdropBlur = true, extraRoots = driveRoots(includeInternal = true), @@ -12275,7 +12351,7 @@ class UnifiedActivity : Icon(Icons.Outlined.FolderOpen, contentDescription = null, tint = Accent, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(8.dp)) Text( - selectedExePath ?: "Select Executable (.exe)", + 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, @@ -12311,63 +12387,130 @@ class UnifiedActivity : Spacer(Modifier.height(8.dp)) - // Game folder — single compact row - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(Color.White.copy(alpha = 0.05f)) - .padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Outlined.Folder, - contentDescription = null, - tint = StatusOnline.copy(alpha = 0.7f), - modifier = Modifier.size(14.dp), - ) - Spacer(Modifier.width(6.dp)) - Column(Modifier.weight(1f)) { - Text( - stringResource(R.string.library_games_game_folder_mapped_drive), - color = TextSecondary, - fontSize = 9.sp, - ) - Text( - gameFolder ?: stringResource(R.string.common_ui_auto_detected), - color = if (gameFolder != null) TextPrimary else TextSecondary, - fontSize = 10.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - val openFolderPicker = { - if (ensureAllFilesAccessForImports(context)) { - DirectoryPickerDialog.show( - activity = this@UnifiedActivity, - initialPath = gameFolder, - title = getString(R.string.common_ui_select_folder), - dimAmount = 0.5f, - preserveBackdropBlur = true, - extraRoots = driveRoots(includeInternal = true), - ) { path -> gameFolder = path } + val activeRetroSystem = retroSystem + if (activeRetroSystem != null) { + 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 + }, + ) + } } } - IconButton( - onClick = openFolderPicker, + } else { + // Game folder — single compact row + Row( modifier = - Modifier.size(28.dp).paneNavItem( - cornerRadius = 8.dp, - onActivate = openFolderPicker, - ), + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, ) { Icon( - Icons.Outlined.Edit, - contentDescription = stringResource(R.string.common_ui_change), - tint = Accent, + Icons.Outlined.Folder, + contentDescription = null, + tint = StatusOnline.copy(alpha = 0.7f), modifier = Modifier.size(14.dp), ) + Spacer(Modifier.width(6.dp)) + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.library_games_game_folder_mapped_drive), + color = TextSecondary, + fontSize = 9.sp, + ) + Text( + gameFolder ?: stringResource(R.string.common_ui_auto_detected), + color = if (gameFolder != null) TextPrimary else TextSecondary, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + val openFolderPicker = { + if (ensureAllFilesAccessForImports(context)) { + DirectoryPickerDialog.show( + activity = this@UnifiedActivity, + initialPath = gameFolder, + title = getString(R.string.common_ui_select_folder), + dimAmount = 0.5f, + preserveBackdropBlur = true, + extraRoots = driveRoots(includeInternal = true), + ) { path -> gameFolder = path } + } + } + IconButton( + onClick = openFolderPicker, + modifier = + Modifier.size(28.dp).paneNavItem( + cornerRadius = 8.dp, + onActivate = openFolderPicker, + ), + ) { + Icon( + Icons.Outlined.Edit, + contentDescription = stringResource(R.string.common_ui_change), + tint = Accent, + modifier = Modifier.size(14.dp), + ) + } } } } @@ -12598,6 +12741,46 @@ class UnifiedActivity : } } +@Composable +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 + } + } + } + @Composable fun ControllerBadge( text: String, 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/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt new file mode 100644 index 000000000..672142f72 --- /dev/null +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -0,0 +1,1094 @@ +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.compose.ui.platform.ComposeView +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.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 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_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_LABELS = listOf("Default", "CRT", "LCD", "Sharp") + private val UPSCALE_KEYS = listOf("2x", "4x", "native") + private val UPSCALE_LABELS = listOf("2x", "4x", "Native") + private val HUD_ELEMENT_LABELS = + listOf("FPS", "Console", "GPU", "CPU", "RAM", "Battery", "Temp", "Graph", "CPU Temp") + private val HUD_ELEMENT_ORDER = listOf(1, 2, 3, 8, 4, 5, 6, 0, 7) + } + + 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 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 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 var rootLayout: FrameLayout? = null + private var menuComposeView: ComposeView? = null + private var surfaceReady = 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 applyDisplayGeometry() { + if (!surfaceReady || !retroReady) return + retroView.viewportAlignment = if (isPortrait) ViewportAlignment.TOP else ViewportAlignment.CENTER + val rating = frameRating + val rootWidth = rootLayout?.width ?: 0 + val rootHeight = rootLayout?.height ?: 0 + val push = + if (isPortrait && hudVisible && rating != null && + rating.visibility == View.VISIBLE && rating.height > 0 && rootHeight > 0 + ) { + ((rating.y + rating.height + rating.height * 0.15f) / rootHeight).coerceIn(0f, 0.3f) + } else { + 0f + } + retroView.viewport = RectF(0f, push, 1f, 1f) + if (rootWidth > 0 && rootHeight > 0) { + 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) + } + } + + 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 + + private fun anyGameControllerConnected(): Boolean = + InputDevice.getDeviceIds().any { id -> + ExternalController.isGameController(InputDevice.getDevice(id)) + } + + private fun refreshControllerPresence() { + controllerConnected = anyGameControllerConnected() + updateOverlayVisibility() + } + + private fun updateOverlayVisibility() { + overlay?.visibility = + if (touchControlsSetting && !controllerConnected) View.VISIBLE else View.GONE + } + + private fun pauseEmulation() { + if (emulationPaused || !retroReady) return + emulationPaused = true + retroView.onPause() + LibretroDroid.pause() + } + + private fun resumeEmulation() { + if (!emulationPaused || !retroReady) return + emulationPaused = false + LibretroDroid.resume() + retroView.onResume() + } + + 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, "Invalid retro game", Toast.LENGTH_LONG).show() + finish() + return + } + + val romFile = File(romPath) + if (!romFile.isFile) { + Toast.makeText(this, "ROM not found: $romPath", Toast.LENGTH_LONG).show() + finish() + return + } + + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_USER + + val coreFile = RetroCoreManager.coreFile(this, resolvedSystem) + if (!coreFile.isFile) { + Toast.makeText(this, "Core not installed: ${resolvedSystem.coreFileName}", Toast.LENGTH_LONG).show() + finish() + return + } + + if (RetroCoreManager.missingBios(this, resolvedSystem)) { + Toast.makeText( + this, + "${resolvedSystem.shortName} needs a BIOS in ${RetroCoreManager.systemDir(this)}", + Toast.LENGTH_LONG, + ).show() + } + + if (resolvedSystem.id == RetroSystems.N64.id) RetroCoreManager.ensureGlideN64Ini(this) + val savesDir = RetroCoreManager.savesDir(this) + val sramFile = File(savesDir, sramName()) + 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) + @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 + } + + val root = FrameLayout(this) + rootLayout = root + + val inputView = RetroInputView(this, this, resolvedSystem) + inputView.hapticStrength = + androidx.preference.PreferenceManager + .getDefaultSharedPreferences(this) + .getFloat("retro_haptic_strength", 0.4f) + overlay = inputView + root.addView( + inputView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + + menu.entriesProvider = { pane -> buildEntriesFor(pane) } + menu.bottomProvider = { buildBottomEntries() } + menu.tabs = RetroDrawerTabs.build(resolvedSystem, RetroCoreOptions.forSystem(resolvedSystem).isNotEmpty()) + hudVisible = intent.getBooleanExtra(EXTRA_HUD, false) + val menuView = + ComposeView(this).apply { + elevation = 2000f + setContent { + WinNativeTheme { + 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 (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 = true + 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() + } + root.post(startWhenReady) + } + + override fun onDestroy() { + inputManager?.unregisterInputDeviceListener(inputDeviceListener) + super.onDestroy() + } + + private fun showHud() { + var rating = frameRating + if (rating == null) { + val root = rootLayout ?: return + rating = FrameRating(this, HashMap()) + rating.setRenderer(system?.shortName ?: "Retro") + rating.visibility = View.GONE + 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 prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(this) + hudFrametimeNumeric = prefs.getBoolean(FrameRating.PREF_HUD_FRAMETIME_NUMERIC, false) + hudDualBattery = prefs.getBoolean(FrameRating.PREF_HUD_DUAL_SERIES_BATTERY, false) + lifecycleScope.launch(Dispatchers.IO) { + val json = + runCatching { + val containerId = intent.getIntExtra(EXTRA_CONTAINER_ID, 0) + if (containerId <= 0) return@runCatching null + ContainerManager(this@RetroActivity) + .getContainerById(containerId) + ?.getExtra("hudSettings") + }.getOrNull() + if (json.isNullOrEmpty()) return@launch + runCatching { + val obj = org.json.JSONObject(json) + val transparency = obj.optDouble("transparency", 1.0).toFloat() + val decoupled = obj.optBoolean("backgroundAlphaDecoupled", false) + val backgroundTransparency = + obj + .optDouble( + "backgroundTransparency", + (transparency * FrameRating.BACKDROP_BASE_ALPHA).toDouble(), + ).toFloat() + val 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), + ) + runOnUiThread { + hudAlpha = transparency + hudBgDecoupled = decoupled + hudBgAlpha = backgroundTransparency + hudScale = scale + hudElements = elements + frameRating?.let { applyRetroHudSettings(it) } + menu.rebuild() + } + } + } + } + + private fun saveHudSettings() { + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + val containerId = intent.getIntExtra(EXTRA_CONTAINER_ID, 0) + if (containerId <= 0) return@runCatching + val container = + ContainerManager(this@RetroActivity).getContainerById(containerId) ?: return@runCatching + val obj = org.json.JSONObject() + obj.put("transparency", hudAlpha.toDouble()) + obj.put("backgroundAlphaDecoupled", hudBgDecoupled) + obj.put("backgroundTransparency", hudBgAlpha.toDouble()) + obj.put("scale", hudScale.toDouble()) + obj.put("showFPS", hudElements[0]) + obj.put("showRenderer", hudElements[1]) + obj.put("showGPU", hudElements[2]) + obj.put("showCPU", hudElements[3]) + obj.put("showRAM", hudElements[4]) + obj.put("showBattery", hudElements[5]) + obj.put("showTemp", hudElements[6]) + obj.put("showGraph", hudElements[7]) + obj.put("showCpuTemp", hudElements[8]) + container.putExtra("hudSettings", obj.toString()) + container.saveData() + } + } + } + + 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() + } + is GLRetroView.GLRetroEvents.SurfaceCreated -> { + if (!audioEnabledSetting) retroView.audioEnabled = false + surfaceReady = true + applyDisplayGeometry() + lifecycleScope.launch(Dispatchers.Default) { + runCatching { + diskCount = retroView.getAvailableDisks() + currentDisk = retroView.getCurrentDisk() + } + } + } + } + }.launchIn(lifecycleScope) + } + + private fun observeErrors() { + retroView + .getGLRetroErrors() + .onEach { error -> + val message = + when (error) { + GLRetroView.ERROR_LOAD_LIBRARY -> "Failed to load emulator core" + GLRetroView.ERROR_LOAD_GAME -> "Failed to load ROM" + GLRetroView.ERROR_GL_NOT_COMPATIBLE -> "Graphics not supported for this core" + else -> "Emulator error" + } + Toast.makeText(this@RetroActivity, message, Toast.LENGTH_LONG).show() + finish() + }.launchIn(lifecycleScope) + } + + private fun sramName(): String { + val safe = gameName.replace(Regex("[^A-Za-z0-9._-]"), "_") + return "$safe.srm" + } + + 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 persistExtra( + key: String, + value: String, + ) { + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + val shortcut = + persistShortcut ?: run { + val containerId = intent.getIntExtra(EXTRA_CONTAINER_ID, 0) + val path = intent.getStringExtra(EXTRA_SHORTCUT_PATH) + if (containerId <= 0 || path.isNullOrBlank()) return@run null + val file = File(path) + if (!file.isFile) return@run null + ContainerManager(this@RetroActivity) + .getContainerById(containerId) + ?.let { Shortcut(it, file) } + }?.also { persistShortcut = it } + 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 = SHADER_LABELS[index], + selected = currentShaderKey == key, + ) { + currentShaderKey = key + retroView.shader = effectiveShader() + persistExtra(RetroShortcuts.KEY_SHADER, key) + menu.rebuild() + }, + ) + } + add( + RetroMenuEntry.Toggle("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("SGSR Upscale", UPSCALE_LABELS, upscaleIndex) { next -> + currentUpscaleKey = UPSCALE_KEYS[next] + persistExtra(RetroShortcuts.KEY_UPSCALE, currentUpscaleKey) + if (sgsrEnabled) retroView.shader = effectiveShader() + menu.rebuild() + }, + ) + } + RetroPane.SYSTEM -> + RetroCoreOptions.forSystem(system).map { option -> + val current = coreVars[option.key] ?: option.defaultValue + val index = option.values.indexOf(current).coerceAtLeast(0) + RetroMenuEntry.Choice(option.label, 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() + } + } + RetroPane.SOUND -> + listOf( + RetroMenuEntry.Toggle("Sound", checked = audioEnabledSetting) { value -> + audioEnabledSetting = value + retroView.audioEnabled = value + persistExtra(RetroShortcuts.KEY_AUDIO, if (value) "1" else "0") + menu.rebuild() + }, + ) + RetroPane.CONTROLS -> + listOf( + RetroMenuEntry.Toggle("On-screen Controls", checked = touchControlsSetting) { value -> + touchControlsSetting = value + updateOverlayVisibility() + persistExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (value) "1" else "0") + menu.rebuild() + }, + RetroMenuEntry.Slider( + label = "Haptic Feedback", + valueText = + overlay?.hapticStrength?.let { "${(it * 100).toInt()}%" } ?: "0%", + value = overlay?.hapticStrength ?: 0f, + min = 0f, + max = 1f, + step = 0.05f, + ) { value -> + overlay?.hapticStrength = value + androidx.preference.PreferenceManager + .getDefaultSharedPreferences(this) + .edit() + .putFloat("retro_haptic_strength", value) + .apply() + menu.rebuild() + }, + ) + RetroPane.HUD -> buildHudEntries() + } + + 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 entries = mutableListOf() + entries += + RetroMenuEntry.Toggle("Performance HUD", checked = hudVisible) { value -> + setHudVisible(value) + } + if (!hudVisible) return entries + entries += + RetroMenuEntry.Slider( + label = "Alpha", + valueText = "${(hudAlpha * 100).toInt()}%", + value = hudAlpha, + min = 0.1f, + max = 1f, + step = 0.05f, + ) { value -> + hudAlpha = value + frameRating?.setHudAlpha(value) + if (!hudBgDecoupled) { + hudBgAlpha = (value * FrameRating.BACKDROP_BASE_ALPHA).coerceIn(0.1f, 1f) + frameRating?.setHudBackgroundAlpha(hudBgAlpha) + } + saveHudSettings() + menu.rebuild() + } + entries += + RetroMenuEntry.Toggle("Background Alpha", checked = hudBgDecoupled) { value -> + hudBgDecoupled = value + frameRating?.setBackgroundAlphaDecoupled(value) + if (!value) { + hudBgAlpha = (hudAlpha * FrameRating.BACKDROP_BASE_ALPHA).coerceIn(0.1f, 1f) + frameRating?.setHudBackgroundAlpha(hudBgAlpha) + } + saveHudSettings() + menu.rebuild() + } + if (hudBgDecoupled) { + entries += + RetroMenuEntry.Slider( + label = "Background", + valueText = "${(hudBgAlpha * 100).toInt()}%", + value = hudBgAlpha, + min = 0.1f, + max = 1f, + step = 0.05f, + ) { value -> + hudBgAlpha = value + frameRating?.setHudBackgroundAlpha(value) + saveHudSettings() + menu.rebuild() + } + } + entries += + RetroMenuEntry.Slider( + label = "Scale", + valueText = "${(hudScale * 100).toInt()}%", + value = hudScale, + min = 0.3f, + max = 2f, + step = 0.05f, + ) { value -> + hudScale = value + frameRating?.setHudScale(value) + saveHudSettings() + menu.rebuild() + } + entries += + RetroMenuEntry.Toggle("Numeric Frametime", checked = hudFrametimeNumeric) { value -> + hudFrametimeNumeric = value + frameRating?.setFrametimeNumericMode(value) + menu.rebuild() + } + entries += + RetroMenuEntry.Toggle("Dual-series Battery", checked = hudDualBattery) { value -> + hudDualBattery = value + frameRating?.setDualSeriesBattery(value) + menu.rebuild() + } + entries += + RetroMenuEntry.Chips( + label = "HUD ELEMENTS", + items = HUD_ELEMENT_ORDER.map { HUD_ELEMENT_LABELS[it] }, + states = HUD_ELEMENT_ORDER.map { hudElements[it] }, + ) { position -> + val index = HUD_ELEMENT_ORDER[position] + val value = !hudElements[index] + hudElements[index] = value + frameRating?.toggleElement(index, value) + saveHudSettings() + menu.rebuild() + } + return entries + } + + private fun buildMainEntries(): List { + val entries = mutableListOf() + entries += + RetroMenuEntry.Action("Save State", RetroDrawerIcons.Save) { + menu.close() + saveState() + } + entries += + RetroMenuEntry.Action("Load State", RetroDrawerIcons.Load) { + menu.close() + loadState() + } + entries += + RetroMenuEntry.Action("Reset", RetroDrawerIcons.Reset) { + menu.close() + retroView.reset() + } + entries += + RetroMenuEntry.Action("Fast Forward", RetroDrawerIcons.FastForward, active = fastForward) { + fastForward = !fastForward + retroView.frameSpeed = if (fastForward) 2 else 1 + menu.rebuild() + } + entries += + RetroMenuEntry.Action("HUD", RetroDrawerIcons.Hud, active = hudVisible) { + setHudVisible(!hudVisible) + } + if (diskCount > 1) { + entries += + RetroMenuEntry.Action("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("Resume", RetroDrawerIcons.Resume, active = true) { + resumeEmulation() + menu.close() + } + } else { + RetroMenuEntry.Action("Pause", RetroDrawerIcons.Pause) { + pauseEmulation() + menu.close() + } + }, + RetroMenuEntry.Action("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 (menu.visible && isGamepadSource(event)) { + menu.handleKey(keyCode, event.action) + return true + } + if (retroReady && isGamepadSource(event)) { + if (keyCode == KeyEvent.KEYCODE_BUTTON_MODE) { + if (event.action == KeyEvent.ACTION_UP) openMenu() + return true + } + if (keyCode in forwardedKeys) { + retroView.sendKeyEvent(event.action, mapPhysicalKey(keyCode), 0) + return true + } + } + return super.dispatchKeyEvent(event) + } + + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (menu.visible && event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK) { + 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 = event.getAxisValue(MotionEvent.AXIS_X) + val stickY = event.getAxisValue(MotionEvent.AXIS_Y) + if (stickIsAnalog) { + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_DPAD, hatX, hatY, 0) + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, stickX, stickY, 0) + } 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, 0) + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, stickX, stickY, 0) + } + retroView.sendMotionEvent( + GLRetroView.MOTION_SOURCE_ANALOG_RIGHT, + event.getAxisValue(MotionEvent.AXIS_Z), + event.getAxisValue(MotionEvent.AXIS_RZ), + 0, + ) + return true + } + return super.dispatchGenericMotionEvent(event) + } + + override fun onButton( + keyCode: Int, + down: Boolean, + ) { + if (!retroReady || menu.visible) return + retroView.sendKeyEvent(if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP, keyCode, 0) + } + + override fun onDpad( + x: Float, + y: Float, + ) { + if (!retroReady || menu.visible) return + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_DPAD, x, y, 0) + } + + override fun onStick( + x: Float, + y: Float, + ) { + if (!retroReady || menu.visible) return + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, x, y, 0) + } + + override fun onRightStick( + x: Float, + y: Float, + ) { + if (!retroReady || menu.visible) return + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_RIGHT, x, y, 0) + } + + override fun onMenu() { + runOnUiThread { openMenu() } + } + + private fun saveState() { + runCatching { + val bytes = retroView.serializeState() + RetroCoreManager.stateFile(this, gameName, 0).writeBytes(bytes) + }.onSuccess { + Toast.makeText(this, "State saved", Toast.LENGTH_SHORT).show() + }.onFailure { + Toast.makeText(this, "Could not save state", Toast.LENGTH_SHORT).show() + } + } + + private fun loadState() { + val file = RetroCoreManager.stateFile(this, gameName, 0) + if (!file.isFile) { + Toast.makeText(this, "No saved state", Toast.LENGTH_SHORT).show() + return + } + runCatching { retroView.unserializeState(file.readBytes()) } + .onSuccess { Toast.makeText(this, "State loaded", Toast.LENGTH_SHORT).show() } + .onFailure { Toast.makeText(this, "Could not load state", Toast.LENGTH_SHORT).show() } + } + + private fun persistSram() { + if (!retroReady) return + runCatching { + val sram = retroView.serializeSRAM() + if (sram.isNotEmpty()) { + File(RetroCoreManager.savesDir(this), sramName()).writeBytes(sram) + } + } + } + + override fun onPause() { + persistSram() + accumulatePlaytime() + super.onPause() + } + + override fun onResume() { + super.onResume() + 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/RetroCoreManager.kt b/app/src/main/feature/retro/RetroCoreManager.kt new file mode 100644 index 000000000..892ba5b1a --- /dev/null +++ b/app/src/main/feature/retro/RetroCoreManager.kt @@ -0,0 +1,53 @@ +package com.winlator.cmod.feature.retro + +import android.content.Context +import java.io.File + +object RetroCoreManager { + fun coreFile( + context: Context, + system: RetroSystem, + ): File = File(context.applicationInfo.nativeLibraryDir, system.coreFileName) + + fun isCoreAvailable( + context: Context, + system: RetroSystem, + ): Boolean = coreFile(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 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..498afa085 --- /dev/null +++ b/app/src/main/feature/retro/RetroCoreOptions.kt @@ -0,0 +1,139 @@ +package com.winlator.cmod.feature.retro + +data class RetroCoreOption( + val key: String, + val label: String, + val values: List, + val valueLabels: List, + val defaultValue: String, +) + +object RetroCoreOptions { + private val NES_OPTIONS = + listOf( + RetroCoreOption( + key = "fceumm_aspect", + label = "Aspect Ratio", + values = listOf("8:7 PAR", "4:3"), + valueLabels = listOf("8:7 (Native)", "4:3 (TV)"), + defaultValue = "8:7 PAR", + ), + ) + + private val SNES_OPTIONS = + listOf( + RetroCoreOption( + key = "snes9x_blargg", + label = "NTSC Filter", + values = listOf("disabled", "rf", "composite", "s-video", "rgb"), + valueLabels = listOf("Off", "RF", "Composite", "S-Video", "RGB"), + defaultValue = "disabled", + ), + ) + + private val GB_OPTIONS = + listOf( + RetroCoreOption( + key = "gambatte_gb_colorization", + label = "Colorization", + values = listOf("disabled", "auto"), + valueLabels = listOf("Off", "Auto"), + defaultValue = "disabled", + ), + ) + + private val GBA_OPTIONS = + listOf( + RetroCoreOption( + key = "mgba_color_correction", + label = "Color Correction", + values = listOf("OFF", "GBA", "GBC"), + valueLabels = listOf("Off", "GBA Screen", "GBC Screen"), + defaultValue = "OFF", + ), + ) + + private val GENESIS_OPTIONS = + listOf( + RetroCoreOption( + key = "genesis_plus_gx_no_sprite_limit", + label = "Remove Sprite Limit", + values = listOf("disabled", "enabled"), + valueLabels = listOf("Off", "On"), + defaultValue = "disabled", + ), + RetroCoreOption( + key = "genesis_plus_gx_blargg_ntsc_filter", + label = "NTSC Filter", + values = listOf("Off", "Composite", "S-Video", "RGB"), + valueLabels = listOf("Off", "Composite", "S-Video", "RGB"), + defaultValue = "Off", + ), + ) + + private val N64_OPTIONS = + listOf( + RetroCoreOption( + key = "parallel-n64-screensize", + label = "Resolution", + values = listOf("320x240", "640x480", "960x720", "1280x960", "1440x1080"), + valueLabels = listOf("320x240 (Native)", "640x480", "960x720", "1280x960", "1440x1080"), + defaultValue = "640x480", + ), + RetroCoreOption( + key = "parallel-n64-gfxplugin", + label = "Video Plugin", + values = listOf("glide64", "gln64", "rice", "auto"), + valueLabels = listOf("Glide64", "GLN64", "Rice", "Auto"), + defaultValue = "glide64", + ), + RetroCoreOption( + key = "parallel-n64-gfxplugin-accuracy", + label = "Plugin Accuracy", + values = listOf("high", "veryhigh", "medium", "low"), + valueLabels = listOf("High", "Very High", "Medium", "Low"), + defaultValue = "high", + ), + ) + + private val PSX_OPTIONS = + listOf( + RetroCoreOption( + key = "pcsx_rearmed_neon_enhancement_enable", + label = "Enhanced Resolution", + values = listOf("disabled", "enabled"), + valueLabels = listOf("Off", "On (2x)"), + defaultValue = "disabled", + ), + RetroCoreOption( + key = "pcsx_rearmed_dithering", + label = "Dithering", + values = listOf("enabled", "disabled"), + valueLabels = listOf("On", "Off"), + defaultValue = "enabled", + ), + ) + + fun defaultVariables(system: RetroSystem?): Map = + when (system?.id) { + RetroSystems.N64.id -> + mapOf( + "parallel-n64-gfxplugin" to "glide64", + "parallel-n64-screensize" to "640x480", + "parallel-n64-gfxplugin-accuracy" to "high", + ) + else -> emptyMap() + } + + fun forSystem(system: RetroSystem?): List = + when (system?.id) { + RetroSystems.NES.id -> NES_OPTIONS + RetroSystems.SNES.id -> SNES_OPTIONS + RetroSystems.GAMEBOY.id -> GB_OPTIONS + RetroSystems.GBA.id -> GBA_OPTIONS + RetroSystems.GENESIS.id, RetroSystems.MASTER_SYSTEM.id, RetroSystems.GAME_GEAR.id -> GENESIS_OPTIONS + RetroSystems.N64.id -> N64_OPTIONS + RetroSystems.PSX.id -> PSX_OPTIONS + else -> emptyList() + } +} diff --git a/app/src/main/feature/retro/RetroDrawerMenu.kt b/app/src/main/feature/retro/RetroDrawerMenu.kt new file mode 100644 index 000000000..81263f5e7 --- /dev/null +++ b/app/src/main/feature/retro/RetroDrawerMenu.kt @@ -0,0 +1,1275 @@ +package com.winlator.cmod.feature.retro + +import android.view.KeyEvent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +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.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +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.Apps +import androidx.compose.material.icons.outlined.Download +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.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.Tune +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +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.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.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.shared.theme.WinNativeBackground +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativePanel +import com.winlator.cmod.shared.theme.WinNativeSurface +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.widget.chasingBorder + +private const val DrawerSheetAlpha = 0.86f +private const val DrawerSurfaceAlpha = 0.72f +private const val DrawerPressedAlpha = 0.88f +private const val DrawerGradientLift = 0.014f + +private val DrawerAccent = Color(0xFF2196F3) +private val DrawerActiveAccent = Color(0xFF29B6F6) +private val DrawerFocusFill = Color(0xFF0E2438) +private val DrawerTextPrimary = WinNativeTextPrimary.copy(alpha = 0.88f) +private val DrawerTextSecondary = WinNativeTextSecondary.copy(alpha = 0.82f) +private val RetroSheetColor = WinNativeBackground.copy(alpha = DrawerSheetAlpha) +private val TopRailSurfaceColor = WinNativeSurface.copy(alpha = DrawerSheetAlpha) +private val PaneSurfacePressed = Color(0xFF232B3A).copy(alpha = DrawerPressedAlpha) +private val PaneInnerResting = WinNativePanel.copy(alpha = DrawerSurfaceAlpha) +private val PaneInnerPressed = Color(0xFF242B3A).copy(alpha = DrawerPressedAlpha) +private val TileExitResting = Color(0xFF3A2125).copy(alpha = DrawerSurfaceAlpha) +private val TileExitPressed = Color(0xFF4A2A30).copy(alpha = DrawerPressedAlpha) +private val RestingCardBorder = WinNativeOutline.copy(alpha = 0.72f) +private val ActiveCardBorder = DrawerActiveAccent +private val GlassExitTint = Color(0xFFE07B6B) +private val DividerColor = WinNativeOutline.copy(alpha = 0.6f) + +private val DrawerWidth = 300.dp +private val DrawerStartPadding = 6.dp +private val DrawerVerticalPadding = 6.dp + +enum class RetroPane { DISPLAY, SYSTEM, SOUND, CONTROLS, HUD } + +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 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 onSelected: (Int) -> Unit, + ) : RetroMenuEntry() + + class Radio( + val label: String, + val selected: Boolean, + val onSelect: () -> Unit, + ) : 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 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 + + val gridColumns: Int + get() = + when (pane) { + null -> 3 + RetroPane.DISPLAY -> 2 + 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) + 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() + 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 + } + 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 + } + } + + 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, + KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_START, + -> 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 / 520f).coerceIn(0.78f, 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()) { + AnimatedContent( + targetState = controller.pane, + transitionSpec = { + fadeIn(tween(220, easing = FastOutSlowInEasing)) togetherWith + fadeOut(tween(220, easing = FastOutSlowInEasing)) + }, + label = "retroDrawerBody", + ) { pane -> + if (pane == null) { + RetroActionGrid(controller, paneScale) + } else { + RetroPaneList(controller, paneScale) + } + } + } + AnimatedVisibility(visible = controller.pane == null && controller.bottomEntries.isNotEmpty()) { + Column { + ThinDivider() + RetroBottomActions(controller, paneScale) + } + } + } + } + } + } +} + +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 indicatorAnimSpec = tween(durationMillis = 240, easing = FastOutSlowInEasing) + val indicatorX by animateDpAsState( + targetValue = selectedBounds?.let { with(density) { it.offsetX.toDp() } } ?: 0.dp, + animationSpec = indicatorAnimSpec, + label = "retroRailX", + ) + val indicatorWidth by animateDpAsState( + targetValue = selectedBounds?.let { with(density) { it.width.toDp() } } ?: 0.dp, + animationSpec = indicatorAnimSpec, + label = "retroRailW", + ) + val indicatorTileHeight by animateDpAsState( + targetValue = selectedBounds?.let { with(density) { it.height.toDp() } } ?: 0.dp, + animationSpec = indicatorAnimSpec, + label = "retroRailH", + ) + val indicatorAlpha by animateFloatAsState( + targetValue = if (selectedBounds != null) 1f else 0f, + animationSpec = tween(160), + label = "retroRailA", + ) + + 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, + ) + Column( + modifier = + 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), + 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 -> + RetroChoiceRow( + 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.Action -> + RetroActionCard( + entry = entry, + highlighted = highlighted, + paneScale = paneScale, + modifier = Modifier.fillMaxWidth().height((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) + }, + ) + } + } + } + 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) "Enabled" else "Disabled", + color = DrawerTextSecondary, + fontSize = (12f * paneScale).sp, + ) + } + Switch( + checked = entry.checked, + onCheckedChange = entry.onChange, + colors = outlinedSwitchColors(DrawerAccent, DrawerTextSecondary), + ) + } +} + +@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) + }, + ) + } + } + } +} + +@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 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( + system: RetroSystem?, + hasCoreOptions: Boolean, + ): List { + val tabs = mutableListOf() + tabs += RetroTabSpec(null, Icons.Outlined.Apps, "Menu") + tabs += RetroTabSpec(RetroPane.DISPLAY, Icons.Outlined.Monitor, "Display") + tabs += RetroTabSpec(RetroPane.HUD, Icons.Outlined.Speed, "HUD") + if (hasCoreOptions) { + tabs += RetroTabSpec(RetroPane.SYSTEM, Icons.Outlined.Tune, system?.shortName ?: "System") + } + tabs += RetroTabSpec(RetroPane.SOUND, Icons.AutoMirrored.Outlined.VolumeUp, "Sound") + tabs += RetroTabSpec(RetroPane.CONTROLS, Icons.Outlined.SportsEsports, "Controls") + return tabs + } +} + +object RetroDrawerIcons { + 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 Exit = Icons.AutoMirrored.Outlined.ExitToApp +} diff --git a/app/src/main/feature/retro/RetroGameSettings.kt b/app/src/main/feature/retro/RetroGameSettings.kt new file mode 100644 index 000000000..d0a234316 --- /dev/null +++ b/app/src/main/feature/retro/RetroGameSettings.kt @@ -0,0 +1,889 @@ +package com.winlator.cmod.feature.retro + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.core.tween +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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.KeyboardArrowDown +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.SportsEsports +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.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.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.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 = 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 NavHighlight = Color(0xFF4FC3F7) +private val DangerRed = Color(0xFFFF6B6B) + +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 SHADER_LABELS = listOf("Default", "CRT", "LCD", "Sharp") +private val UPSCALE_KEYS = listOf("2x", "4x", "native") +private val UPSCALE_LABELS = listOf("2x", "4x", "Native") + +class RetroSettingsState( + val shortcut: Shortcut, +) { + 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) + + var shader by mutableStateOf( + shortcut.getExtra(RetroShortcuts.KEY_SHADER, "default").lowercase().let { + if (it in SHADER_KEYS) it else "default" + }, + ) + var sgsr by mutableStateOf( + shortcut.getExtra(RetroShortcuts.KEY_SGSR, "0") == "1" || + shortcut.getExtra(RetroShortcuts.KEY_SHADER, "default").lowercase() == "sgsr", + ) + var upscale by mutableStateOf( + shortcut.getExtra(RetroShortcuts.KEY_UPSCALE, "native").lowercase().let { + if (it in UPSCALE_KEYS) it else "native" + }, + ) + var touchControls by mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, "1") != "0") + var audio by mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_AUDIO, "1") != "0") + val optionValues = + mutableStateMapOf().apply { + coreOptions.forEach { option -> + put( + option.key, + shortcut.getExtra(RetroShortcuts.VAR_PREFIX + option.key).ifEmpty { option.defaultValue }, + ) + } + } + val artworkSelected = mutableStateMapOf() + var currentSection by mutableIntStateOf(0) + + 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(RetroShortcuts.KEY_TOUCH_CONTROLS, if (touchControls) "1" else "0") + shortcut.putExtra(RetroShortcuts.KEY_AUDIO, if (audio) "1" else "0") + coreOptions.forEach { option -> + shortcut.putExtra( + RetroShortcuts.VAR_PREFIX + option.key, + optionValues[option.key] ?: option.defaultValue, + ) + } + shortcut.saveData() + } +} + +private data class RetroSection( + val icon: ImageVector, + val label: String, +) + +private fun buildRetroSections(state: RetroSettingsState): List { + val sections = mutableListOf() + sections += RetroSection(Icons.Outlined.Tune, "General") + sections += RetroSection(Icons.Outlined.Monitor, "Graphics") + if (state.coreOptions.isNotEmpty()) { + sections += RetroSection(Icons.Outlined.Memory, "${state.system?.shortName ?: "Core"} Options") + } + sections += RetroSection(Icons.Outlined.SportsEsports, "Input") + sections += RetroSection(Icons.AutoMirrored.Outlined.VolumeUp, "Audio") + return sections +} + +@Composable +fun RetroGameSettingsContent( + state: RetroSettingsState, + nav: GameSettingsNav? = null, + onPickArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, + onRemoveArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, + onSave: () -> Unit, + onCancel: () -> Unit, +) { + val sections = remember(state) { buildRetroSections(state) } + val hasSystemSection = state.coreOptions.isNotEmpty() + 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, + hasSystemSection = hasSystemSection, + state = state, + nav = nav, + onPickArtwork = onPickArtwork, + onRemoveArtwork = onRemoveArtwork, + ) + } + } + } +} + +@Composable +private fun RetroSectionContent( + sectionIndex: Int, + hasSystemSection: Boolean, + state: RetroSettingsState, + nav: GameSettingsNav? = null, + onPickArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, + onRemoveArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, +) { + AnimatedContent( + targetState = sectionIndex, + transitionSpec = { + val direction = if (targetState > initialState) 1 else -1 + ( + slideInHorizontally(animationSpec = tween(220)) { direction * it / 6 } + + fadeIn(tween(200)) + ).togetherWith( + slideOutHorizontally(animationSpec = tween(180)) { -direction * it / 6 } + + fadeOut(tween(120)), + ) + }, + label = "RetroSectionTransition", + ) { idx -> + val contentNav = remember(nav) { nav?.let { PaneNavRegistry(initialSignal = it.contentSignal) } } + val isCurrent = idx == sectionIndex + 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 sectionBody: @Composable () -> Unit = { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 14.dp), + ) { + val systemIdx = 2 + when { + idx == 0 -> RetroGeneralSection(state, onPickArtwork, onRemoveArtwork) + idx == 1 -> RetroGraphicsSection(state) + hasSystemSection && idx == systemIdx -> RetroSystemSection(state) + idx == (if (hasSystemSection) 3 else 2) -> RetroInputSection(state) + else -> RetroAudioSection(state) + } + 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 = section.label, + 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("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("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 +private fun RetroSettingGroup(content: @Composable () -> Unit) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(GroupCorner)) + .background(CardSurface) + .border(1.dp, CardBorder, RoundedCornerShape(GroupCorner)) + .padding(GroupPadding), + ) { + content() + } +} + +@Composable +private fun RetroGroupTitle(text: String) { + Text( + text = text, + color = TextSecondary, + fontSize = LabelSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.4.sp, + modifier = Modifier.padding(bottom = ItemGap), + ) +} + +@Composable +private fun RetroInfoRow( + label: String, + value: String, +) { + Column(Modifier.fillMaxWidth().padding(bottom = ItemGap)) { + Text( + label, + color = TextSecondary, + fontSize = LabelSize, + fontWeight = FontWeight.Medium, + letterSpacing = 0.3.sp, + modifier = Modifier.padding(bottom = TightGap), + ) + Text( + value.ifBlank { "-" }, + color = TextPrimary, + fontSize = ValueSize, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private 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 +private fun RetroSettingSwitch( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onCheckedChange(!checked) } + .paneNavItem( + cornerRadius = 8.dp, + onActivate = { onCheckedChange(!checked) }, + highlightColor = NavHighlight, + tapToSelect = true, + ) + .padding(vertical = TightGap), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + color = TextPrimary, + fontSize = ValueSize, + modifier = Modifier.weight(1f), + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = outlinedSwitchColors(accentColor = AccentBlue, textSecondaryColor = TextSecondary), + ) + } +} + +@Composable +private fun RetroGeneralSection( + state: RetroSettingsState, + onPickArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, + onRemoveArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, +) { + RetroSettingGroup { + RetroGroupTitle("GAME") + RetroInfoRow("Name", state.name) + RetroInfoRow("System", state.system?.displayName ?: "") + RetroInfoRow("Emulator Core", state.system?.coreFileName ?: "") + RetroInfoRow("ROM Path", state.romPath) + } + if (onPickArtwork != null && onRemoveArtwork != null) { + Spacer(Modifier.height(ItemGap)) + RetroSettingGroup { + RetroGroupTitle("LIBRARY ARTWORK") + Row(horizontalArrangement = Arrangement.spacedBy(ItemGap)) { + Box(Modifier.weight(1f)) { + RetroArtworkRow( + title = "Game Card Image", + 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 = "Grid Image", + 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 = "Carousel Image", + selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL] == true, + onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL) }, + onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL) }, + ) + } + Box(Modifier.weight(1f)) { + RetroArtworkRow( + title = "List Image", + 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 = "Custom image set", + color = TextSecondary, + fontSize = LabelSize, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!selected) { + RetroArtworkActionButton("Set image", AccentBlue, onPick) + } else { + RetroArtworkActionButton("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) { + RetroSettingGroup { + RetroGroupTitle("VIDEO") + RetroSettingDropdown( + label = "Video Filter", + entries = SHADER_LABELS, + selectedIndex = SHADER_KEYS.indexOf(state.shader).coerceAtLeast(0), + onSelected = { state.shader = SHADER_KEYS[it] }, + ) + RetroSettingSwitch( + label = "SGSR", + checked = state.sgsr, + onCheckedChange = { state.sgsr = it }, + ) + RetroSettingDropdown( + label = "SGSR Upscale", + entries = UPSCALE_LABELS, + selectedIndex = UPSCALE_KEYS.indexOf(state.upscale).coerceAtLeast(0), + onSelected = { state.upscale = UPSCALE_KEYS[it] }, + ) + } +} + +@Composable +private fun RetroSystemSection(state: RetroSettingsState) { + RetroSettingGroup { + RetroGroupTitle((state.system?.shortName ?: "CORE").uppercase() + " OPTIONS") + state.coreOptions.forEach { option -> + val current = state.optionValues[option.key] ?: option.defaultValue + RetroSettingDropdown( + label = option.label, + entries = option.valueLabels, + selectedIndex = option.values.indexOf(current).coerceAtLeast(0), + onSelected = { state.optionValues[option.key] = option.values[it] }, + ) + } + } +} + +@Composable +private fun RetroInputSection(state: RetroSettingsState) { + RetroSettingGroup { + RetroGroupTitle("INPUT") + RetroSettingSwitch( + label = "On-screen controls", + checked = state.touchControls, + onCheckedChange = { state.touchControls = it }, + ) + } +} + +@Composable +private fun RetroAudioSection(state: RetroSettingsState) { + RetroSettingGroup { + RetroGroupTitle("AUDIO") + RetroSettingSwitch( + label = "Sound", + checked = state.audio, + onCheckedChange = { state.audio = it }, + ) + } +} diff --git a/app/src/main/feature/retro/RetroInputView.kt b/app/src/main/feature/retro/RetroInputView.kt new file mode 100644 index 000000000..062a40abd --- /dev/null +++ b/app/src/main/feature/retro/RetroInputView.kt @@ -0,0 +1,1410 @@ +package com.winlator.cmod.feature.retro + +import android.content.Context +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 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 showRightTrigger: Boolean = true, + val flatFaces: 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.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 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.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 -> + 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 + private val vibrator: Vibrator? = context.getSystemService(Vibrator::class.java) + + 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 val pressedButtons = HashSet() + private var dpadX = 0f + private var dpadY = 0f + private var menuLatched = false + + 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 + buttons.clear() + cButtons.clear() + if (height > width) { + layoutPortrait(width, height) + } else { + layoutLandscape(width, height) + } + invalidate() + } + + 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 lb = GlassButton(KeyEvent.KEYCODE_BUTTON_L1, "L", GlassShape.TRIGGER_LB, textScale = 1.3f) + lb.bounds.set(margin, leftCursor, margin + trigW, leftCursor + trigH) + buttons += lb + val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RB, 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 = height - bottomGap - faceRadius - spread + var clusterTop = height + fun addFace( + keyCode: Int, + label: String, + cx: Float, + cy: Float, + ) { + val button = GlassButton(keyCode, label, GlassShape.CIRCLE) + 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.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_LB, textScale = 1.3f) + lb.bounds.set(margin, margin, margin + trigW, margin + trigH) + buttons += lb + val z = + GlassButton(KeyEvent.KEYCODE_BUTTON_L2, config.leftTriggerLabel, GlassShape.TRIGGER_RT, textScale = 1.3f) + z.bounds.set(width - margin - trigW, margin, width - margin, margin + trigH) + buttons += z + val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RB, textScale = 1.3f) + rb.bounds.set( + width - margin - trigW, + margin + trigH + trigGap, + width - margin, + margin + trigH * 2 + trigGap, + ) + buttons += rb + + 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 lb = GlassButton(KeyEvent.KEYCODE_BUTTON_L1, "L", GlassShape.TRIGGER_LB, textScale = 1.3f) + lb.bounds.set(margin, sideCursor, margin + trigW, sideCursor + trigH) + buttons += lb + val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RB, 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) + 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_LB, textScale = 1.3f) + lb.bounds.set(margin, zoneTop + snap * 2f, margin + trigW, zoneTop + snap * 2f + trigH) + buttons += lb + val z = GlassButton(KeyEvent.KEYCODE_BUTTON_L2, config.leftTriggerLabel, GlassShape.TRIGGER_RT, textScale = 1.3f) + z.bounds.set(width - margin - trigW, zoneTop + snap * 2f, width - margin, zoneTop + snap * 2f + trigH) + buttons += z + val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RB, textScale = 1.3f) + rb.bounds.set( + width - margin - trigW, + zoneTop + snap * 2f + trigH + trigGap, + width - margin, + zoneTop + snap * 2f + trigH * 2 + trigGap, + ) + buttons += rb + + 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) + paint.strokeJoin = Paint.Join.ROUND + paint.strokeCap = Paint.Cap.ROUND + drawShellBackground(canvas) + drawClusterPlate(canvas) + drawFacePlate(canvas) + drawDpad(canvas) + if (config.hasStick) drawStick(canvas) + buttons.forEach { drawThemedButton(canvas, it, pressedButtons.contains(it.keyCode)) } + cButtons.forEach { drawCButton(canvas, it) } + drawThemedButton(canvas, menuButton, menuLatched) + } + + 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 = 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 + 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 < 4) 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 + } + 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 = + theme.buttonColors[button.label] + ?: if (button.shape == GlassShape.CIRCLE) theme.button else theme.pill + + private fun buttonTextColor(button: GlassButton): Int = + 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, + ) + 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 = Color.argb(if (pressed) 40 else 70, 0, 0, 0) + canvas.drawPath(path, paint) + buildShapePath(button) + path.offset(0f, dy) + 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.shader = + RadialGradient( + cx, + cy + depth, + radius * 1.18f, + Color.argb(if (pressed) 55 else 95, 0, 0, 0), + 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 * 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, + theme.cButton, + 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 color = 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 = Color.argb(80, 0, 0, 0) + 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.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.42f + fun arrow( + tx: Float, + ty: Float, + dxu: Float, + dyu: Float, + engaged: Boolean, + ) { + 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() + paint.color = if (engaged) lighten(color, 0.5f) else lighten(color, 0.16f) + canvas.drawPath(path, paint) + } + val inset = arm * 0.62f + arrow(cx, cy - r + inset, 0f, -1f, pressedUp) + arrow(cx, cy + r - inset, 0f, 1f, pressedDown) + arrow(cx - r + inset, cy, -1f, 0f, pressedLeft) + arrow(cx + r - inset, cy, 1f, 0f, pressedRight) + } + + private fun drawStick(canvas: Canvas) { + val engaged = stickPointerId != -1 + val wellColor = darken(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 = stickCx + stickRadius * Math.cos(angle).toFloat() + val py = stickCy + stickRadius * Math.sin(angle).toFloat() + if (i == 0) path.moveTo(px, py) else path.lineTo(px, py) + } + path.close() + paint.shader = + RadialGradient( + stickCx, + stickCy, + 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 = stickCx + stickX * stickRadius * 0.5f + val thumbY = stickCy + stickY * stickRadius * 0.5f + val thumbRadius = stickRadius * 0.5f + val capColor = theme.stickCap + paint.style = Paint.Style.FILL + paint.shader = + RadialGradient( + thumbX, + thumbY + thumbRadius * 0.18f, + thumbRadius * 1.25f, + Color.argb(90, 0, 0, 0), + 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) + } + + fun releaseAll() { + for (keyCode in pressedButtons) listener.onButton(keyCode, false) + pressedButtons.clear() + 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) + } + if (cStickX != 0f || cStickY != 0f) { + cStickX = 0f + cStickY = 0f + listener.onRightStick(0f, 0f) + } + 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, + -> 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 recompute(event: MotionEvent) { + 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 newPressed = HashSet() + var newDpadX = 0f + var newDpadY = 0f + var newCX = 0f + var newCY = 0f + var menuTouched = false + var stickSeen = false + var newStickX = stickX + var newStickY = stickY + + if (!released) { + for (i in 0 until event.pointerCount) { + if (i == liftedPointer) continue + val x = event.getX(i) + val y = event.getY(i) + val pointerId = event.getPointerId(i) + + if (config.hasStick) { + if (pointerId == stickPointerId) { + stickSeen = true + newStickX = ((x - stickCx) / stickRadius).coerceIn(-1f, 1f) + newStickY = ((y - stickCy) / stickRadius).coerceIn(-1f, 1f) + continue + } + if (stickPointerId == -1 && + hypot(x - stickCx, y - stickCy) <= stickRadius * 1.3f + ) { + stickPointerId = pointerId + stickSeen = true + hapticTick() + newStickX = ((x - stickCx) / stickRadius).coerceIn(-1f, 1f) + newStickY = ((y - stickCy) / stickRadius).coerceIn(-1f, 1f) + continue + } + } + + if (hitButton(menuButton, x, y)) { + menuTouched = true + continue + } + + var cHit = false + for (c in cButtons) { + val reach = c.bounds.width() * 0.5f + snap * 1.2f + if (hypot(x - c.bounds.centerX(), y - c.bounds.centerY()) <= reach) { + if (c.dx != 0f) newCX = c.dx + if (c.dy != 0f) newCY = c.dy + cHit = true + break + } + } + if (cHit) continue + + var buttonHit = false + for (button in buttons) { + if (hitButton(button, x, y)) { + newPressed.add(button.keyCode) + buttonHit = true + break + } + } + if (buttonHit) continue + + val dxToPad = x - dpadCx + val dyToPad = y - dpadCy + if (hypot(dxToPad, dyToPad) <= dpadRadius * 1.4f) { + val dz = dpadRadius * 0.24f + if (dxToPad > dz) newDpadX = 1f else if (dxToPad < -dz) newDpadX = -1f + if (dyToPad > dz) newDpadY = 1f else if (dyToPad < -dz) newDpadY = -1f + } + } + } + + if (newCX != cStickX || newCY != cStickY) { + if (newCX != 0f || newCY != 0f) hapticTick() + cStickX = newCX + cStickY = newCY + listener.onRightStick(cStickX, cStickY) + } + + if (!stickSeen && stickPointerId != -1) { + stickPointerId = -1 + newStickX = 0f + newStickY = 0f + } + if (newStickX != stickX || newStickY != stickY) { + stickX = newStickX + stickY = newStickY + listener.onStick(stickX, stickY) + } + + for (keyCode in pressedButtons) { + if (!newPressed.contains(keyCode)) listener.onButton(keyCode, false) + } + for (keyCode in newPressed) { + if (!pressedButtons.contains(keyCode)) { + hapticTick() + listener.onButton(keyCode, true) + } + } + pressedButtons.clear() + pressedButtons.addAll(newPressed) + + if (newDpadX != dpadX || newDpadY != dpadY) { + if (newDpadX != 0f || newDpadY != 0f) hapticTick() + dpadX = newDpadX + dpadY = newDpadY + listener.onDpad(dpadX, dpadY) + } + + if (menuTouched && !menuLatched) { + menuLatched = true + hapticTick() + listener.onMenu() + } else if (!menuTouched) { + menuLatched = false + } + } +} diff --git a/app/src/main/feature/retro/RetroSettingsDialog.kt b/app/src/main/feature/retro/RetroSettingsDialog.kt new file mode 100644 index 000000000..32d91b77e --- /dev/null +++ b/app/src/main/feature/retro/RetroSettingsDialog.kt @@ -0,0 +1,184 @@ +package com.winlator.cmod.feature.retro + +import android.app.Activity +import android.app.Dialog +import android.net.Uri +import android.os.Build +import android.view.ViewGroup +import android.view.Window +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.winlator.cmod.R +import com.winlator.cmod.feature.library.GameSettingsNav +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.shared.android.ImageUtils +import com.winlator.cmod.shared.io.FileUtils +import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PaneNavWindowHandlers +import com.winlator.cmod.shared.ui.nav.bindPaneNav +import com.winlator.cmod.shared.ui.toast.WinToast + +class RetroSettingsDialog( + private val activity: Activity, + private val shortcut: Shortcut, +) { + private val state = RetroSettingsState(shortcut) + private val nav = GameSettingsNav() + private var restorePaneNav: (() -> Unit)? = null + private var pendingArtworkSlot = LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD + + private val artworkPickerLauncher: ActivityResultLauncher>? = + (activity as? ComponentActivity)?.activityResultRegistry?.register( + "retro_artwork_picker", + ActivityResultContracts.OpenDocument(), + ) { uri: Uri? -> + if (uri != null) saveSelectedArtwork(uri) + } + + private fun saveSelectedArtwork(uri: Uri) { + val bitmap = ImageUtils.getBitmapFromUri(activity, uri, 1024) + if (bitmap == null) { + WinToast.show(activity, R.string.shortcuts_library_artwork_failed, Toast.LENGTH_SHORT) + return + } + val slot = pendingArtworkSlot + val previousPath = shortcut.getExtra(slot.extraKey) + val outputFile = LibraryShortcutArtwork.buildManagedViewArtworkFile(activity, shortcut, slot) + if (!FileUtils.saveBitmapToFile(bitmap, outputFile)) { + WinToast.show(activity, R.string.shortcuts_library_artwork_failed, Toast.LENGTH_SHORT) + return + } + if (previousPath.isNotBlank() && previousPath != outputFile.absolutePath) { + LibraryShortcutArtwork.deleteManagedArtwork(activity, previousPath) + } + shortcut.putExtra(slot.extraKey, outputFile.absolutePath) + shortcut.saveData() + state.syncArtwork() + } + + private fun clearArtwork(slot: LibraryShortcutArtwork.LibraryArtworkSlot) { + LibraryShortcutArtwork.deleteManagedArtwork(activity, shortcut.getExtra(slot.extraKey)) + shortcut.putExtra(slot.extraKey, null) + shortcut.saveData() + state.syncArtwork() + } + + private val dialog: Dialog = + Dialog(activity, R.style.ContentDialog).apply { + requestWindowFeature(Window.FEATURE_NO_TITLE) + setCancelable(true) + setCanceledOnTouchOutside(false) + setOwnerActivity(activity) + window?.apply { + setBackgroundDrawableResource(android.R.color.transparent) + setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + ) + setDimAmount(0.5f) + addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + isNavigationBarContrastEnforced = false + } + } + setOnDismissListener { + restorePaneNav?.invoke() + restorePaneNav = null + } + } + + init { + val composeView = + ComposeView(activity).apply { + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setViewTreeLifecycleOwner(activity as LifecycleOwner) + setViewTreeSavedStateRegistryOwner(activity as SavedStateRegistryOwner) + setContent { + WinNativeTheme { + val defaultDensity = LocalDensity.current + CompositionLocalProvider( + LocalDensity provides Density(defaultDensity.density, fontScale = 1f), + ) { + RetroGameSettingsContent( + state = state, + nav = nav, + onPickArtwork = { slot -> + pendingArtworkSlot = slot + artworkPickerLauncher?.launch(arrayOf("image/*")) + }, + onRemoveArtwork = { slot -> clearArtwork(slot) }, + onSave = { + state.save() + dialog.dismiss() + }, + onCancel = { dialog.dismiss() }, + ) + } + } + } + } + dialog.setContentView(composeView) + (activity as LifecycleOwner).lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + if (dialog.isShowing) dialog.dismiss() + } + }, + ) + } + + fun show() { + dialog.show() + restorePaneNav?.invoke() + restorePaneNav = + dialog.window?.bindPaneNav( + PaneNavWindowHandlers( + onDir = { nav.dpad(it) }, + onActivate = { nav.dpad(PANE_DIR_ACTIVATE) }, + onDismiss = { if (nav.onContentBack?.invoke() != true) dialog.dismiss() }, + onStart = { nav.onSave?.invoke() }, + ), + ) + dialog.window?.apply { + applyDialogLayout() + decorView.post { applyDialogLayout() } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val params = attributes + params.flags = params.flags or WindowManager.LayoutParams.FLAG_BLUR_BEHIND + params.blurBehindRadius = 10 + attributes = params + } + } + } + + private fun Window.applyDialogLayout() { + val dm = activity.resources.displayMetrics + val hostView = activity.window?.decorView + val hostWidth = hostView?.width?.takeIf { it > 0 } ?: dm.widthPixels + val hostHeight = hostView?.height?.takeIf { it > 0 } ?: dm.heightPixels + val screenWidthDp = hostWidth / dm.density + val needsNearFullWidth = screenWidthDp < 820f + val widthFactor = if (needsNearFullWidth) 0.96f else 0.88f + val heightFactor = if (needsNearFullWidth) 0.90f else 0.88f + setLayout((hostWidth * widthFactor).toInt(), (hostHeight * heightFactor).toInt()) + } +} diff --git a/app/src/main/feature/retro/RetroShortcuts.kt b/app/src/main/feature/retro/RetroShortcuts.kt new file mode 100644 index 000000000..6e0ad709a --- /dev/null +++ b/app/src/main/feature/retro/RetroShortcuts.kt @@ -0,0 +1,103 @@ +package com.winlator.cmod.feature.retro + +import android.content.Context +import android.content.Intent +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.shared.io.FileUtils +import java.io.File +import java.util.UUID + +object RetroShortcuts { + const val KEY_SYSTEM = "retro_system" + const val KEY_ROM = "rom_path" + const val KEY_CORE = "retro_core" + const val KEY_SHADER = "retro_shader" + const val KEY_UPSCALE = "retro_upscale" + const val KEY_SGSR = "retro_sgsr" + const val KEY_TOUCH_CONTROLS = "retro_touch_controls" + const val KEY_AUDIO = "retro_audio" + const val KEY_HUD = "retro_hud" + const val VAR_PREFIX = "retro_var_" + + fun coreVariables(shortcut: Shortcut): HashMap { + val vars = HashMap() + val extras = shortcut.extraData + val keys = extras.keys() + while (keys.hasNext()) { + val key = keys.next() + if (key.startsWith(VAR_PREFIX)) { + val value = shortcut.getExtra(key) + if (value.isNotEmpty()) vars[key.removePrefix(VAR_PREFIX)] = value + } + } + return vars + } + + @JvmStatic + fun isRetroShortcut(shortcut: Shortcut): Boolean = shortcut.getExtra(KEY_SYSTEM).isNotEmpty() + + fun systemForShortcut(shortcut: Shortcut): RetroSystem? = RetroSystems.fromId(shortcut.getExtra(KEY_SYSTEM)) + + fun romPath(shortcut: Shortcut): String = shortcut.getExtra(KEY_ROM) + + fun create( + context: Context, + name: String, + romPath: String, + system: RetroSystem, + ): Boolean { + val containerManager = ContainerManager(context) + val container = SetupWizardActivity.getPreferredGameContainer(context, containerManager) ?: return false + + val desktopDir = container.desktopDir + if (!desktopDir.exists()) desktopDir.mkdirs() + + val safeName = name.replace("/", "_").replace("\\", "_") + val shortcutFile = File(desktopDir, "$safeName.desktop") + val shortcutUuid = UUID.randomUUID().toString() + + val content = + buildString { + append("[Desktop Entry]\n") + append("Type=Application\n") + append("Name=$name\n") + append("Exec=retro:${system.id}\n") + append("Icon=custom_game\n") + append("\n[Extra Data]\n") + append("game_source=CUSTOM\n") + append("custom_name=$name\n") + append("$KEY_SYSTEM=${system.id}\n") + append("$KEY_ROM=$romPath\n") + append("$KEY_CORE=${system.coreFileName}\n") + append("uuid=$shortcutUuid\n") + append("container_id=${container.id}\n") + append("use_container_defaults=1\n") + } + + FileUtils.writeString(shortcutFile, content) + container.saveData() + return true + } + + @JvmStatic + fun launchIntent( + context: Context, + shortcut: Shortcut, + ): Intent = + Intent(context, RetroActivity::class.java).apply { + putExtra(RetroActivity.EXTRA_ROM_PATH, shortcut.getExtra(KEY_ROM)) + putExtra(RetroActivity.EXTRA_SYSTEM_ID, shortcut.getExtra(KEY_SYSTEM)) + putExtra(RetroActivity.EXTRA_GAME_NAME, shortcut.getExtra("custom_name", shortcut.name)) + putExtra(RetroActivity.EXTRA_SHORTCUT_PATH, shortcut.file.absolutePath) + putExtra(RetroActivity.EXTRA_CONTAINER_ID, shortcut.container.id) + putExtra(RetroActivity.EXTRA_SHADER, shortcut.getExtra(KEY_SHADER, "default")) + putExtra(RetroActivity.EXTRA_UPSCALE, shortcut.getExtra(KEY_UPSCALE, "native")) + putExtra(RetroActivity.EXTRA_SGSR, shortcut.getExtra(KEY_SGSR, "0") == "1") + putExtra(RetroActivity.EXTRA_TOUCH_CONTROLS, shortcut.getExtra(KEY_TOUCH_CONTROLS, "1") != "0") + putExtra(RetroActivity.EXTRA_AUDIO, shortcut.getExtra(KEY_AUDIO, "1") != "0") + putExtra(RetroActivity.EXTRA_HUD, shortcut.getExtra(KEY_HUD, "0") == "1") + putExtra(RetroActivity.EXTRA_VARIABLES, coreVariables(shortcut)) + } +} diff --git a/app/src/main/feature/retro/RetroSystem.kt b/app/src/main/feature/retro/RetroSystem.kt new file mode 100644 index 000000000..8e2d64792 --- /dev/null +++ b/app/src/main/feature/retro/RetroSystem.kt @@ -0,0 +1,142 @@ +package com.winlator.cmod.feature.retro + +import java.util.Locale + +data class RetroSystem( + val id: String, + val displayName: String, + val shortName: String, + val coreFileName: String, + val extensions: Set, + val needsBios: Boolean = false, + val biosFiles: List = emptyList(), + val badgeLabel: String = shortName, +) + +object RetroSystems { + val NES = + RetroSystem( + id = "nes", + displayName = "Nintendo Entertainment System", + shortName = "NES", + coreFileName = "libfceumm_libretro_android.so", + extensions = setOf("nes", "unf", "unif"), + ) + val SNES = + RetroSystem( + id = "snes", + displayName = "Super Nintendo", + shortName = "SNES", + coreFileName = "libsnes9x_libretro_android.so", + extensions = setOf("smc", "sfc", "swc", "fig", "bs"), + ) + val GAMEBOY = + RetroSystem( + id = "gb", + displayName = "Game Boy", + shortName = "GB", + coreFileName = "libgambatte_libretro_android.so", + extensions = setOf("gb"), + ) + val GAMEBOY_COLOR = + RetroSystem( + id = "gbc", + displayName = "Game Boy Color", + shortName = "GBC", + coreFileName = "libgambatte_libretro_android.so", + extensions = setOf("gbc"), + ) + val GBA = + RetroSystem( + id = "gba", + displayName = "Game Boy Advance", + shortName = "GBA", + coreFileName = "libmgba_libretro_android.so", + extensions = setOf("gba"), + ) + val GENESIS = + RetroSystem( + id = "genesis", + displayName = "Sega Genesis / Mega Drive", + shortName = "Genesis", + coreFileName = "libgenesis_plus_gx_libretro_android.so", + extensions = setOf("gen", "md", "smd", "bin"), + badgeLabel = "SEGA", + ) + val MASTER_SYSTEM = + RetroSystem( + id = "sms", + displayName = "Sega Master System", + shortName = "Master System", + coreFileName = "libgenesis_plus_gx_libretro_android.so", + extensions = setOf("sms"), + badgeLabel = "SMS", + ) + val GAME_GEAR = + RetroSystem( + id = "gg", + displayName = "Sega Game Gear", + shortName = "Game Gear", + coreFileName = "libgenesis_plus_gx_libretro_android.so", + extensions = setOf("gg"), + badgeLabel = "GG", + ) + val N64 = + RetroSystem( + id = "n64", + displayName = "Nintendo 64", + shortName = "N64", + coreFileName = "libparallel_n64_libretro_android.so", + extensions = setOf("n64", "z64", "v64"), + ) + val PSX = + RetroSystem( + id = "psx", + displayName = "Sony PlayStation", + shortName = "PS1", + coreFileName = "libpcsx_rearmed_libretro_android.so", + extensions = setOf("cue", "chd", "pbp", "m3u", "iso"), + needsBios = false, + biosFiles = listOf("scph5501.bin", "scph5500.bin", "scph5502.bin", "scph1001.bin"), + ) + + val ALL = + listOf(NES, SNES, GAMEBOY, GAMEBOY_COLOR, GBA, GENESIS, MASTER_SYSTEM, GAME_GEAR, N64, PSX) + + private val PSX_ONLY_EXTENSIONS = setOf("cue", "chd", "pbp", "m3u") + + val allExtensions: Set = ALL.flatMap { it.extensions }.toSet() - "exe" + + fun fromId(id: String?): RetroSystem? { + if (id.isNullOrBlank()) return null + val normalized = id.trim().lowercase(Locale.US) + return ALL.firstOrNull { it.id == normalized } + } + + fun fromExtension(extension: String?): RetroSystem? { + if (extension.isNullOrBlank()) return null + val ext = extension.trim().lowercase(Locale.US).removePrefix(".") + if (ext == "exe") return null + if (ext in PSX_ONLY_EXTENSIONS || ext == "iso") return PSX + return ALL.firstOrNull { ext in it.extensions } + } + + fun fromRomPath(path: String?): RetroSystem? { + if (path.isNullOrBlank()) return null + val ext = path.substringAfterLast('.', "") + return fromExtension(ext) + } + + fun detectForFile(path: String): RetroSystem? { + val ext = path.substringAfterLast('.', "").lowercase(Locale.US) + val detected = fromExtension(ext) ?: return null + if (ext == "bin" && java.io.File(path).length() > 16L * 1024 * 1024) return PSX + return detected + } + + fun isRetroRom(path: String?): Boolean { + if (path.isNullOrBlank()) return false + val ext = path.substringAfterLast('.', "").lowercase(Locale.US) + return ext in allExtensions + } +} diff --git a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt index 05604ba5d..ca7530306 100644 --- a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt +++ b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt @@ -2303,6 +2303,12 @@ class ShortcutSettingsComposeDialog private constructor( // Show / Dismiss fun show() { + if (com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut)) { + com.winlator.cmod.feature.retro + .RetroSettingsDialog(activity, shortcut) + .show() + return + } dialog.show() restorePaneNav?.invoke() restorePaneNav = dialog.window?.bindPaneNav( diff --git a/app/src/main/feature/shortcuts/ShortcutsFragment.java b/app/src/main/feature/shortcuts/ShortcutsFragment.java index 7924d7cd3..713cc69b2 100644 --- a/app/src/main/feature/shortcuts/ShortcutsFragment.java +++ b/app/src/main/feature/shortcuts/ShortcutsFragment.java @@ -221,6 +221,12 @@ private void runFromShortcut(Shortcut shortcut) { Activity activity = getActivity(); if (activity == null) return; + if (com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut)) { + activity.startActivity( + com.winlator.cmod.feature.retro.RetroShortcuts.launchIntent(activity, shortcut)); + return; + } + Intent intent = new Intent(activity, XServerDisplayActivity.class); intent.putExtra("container_id", shortcut.container.id); intent.putExtra("shortcut_path", shortcut.file.getPath()); diff --git a/app/src/main/jniLibs/arm64-v8a/libfceumm_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libfceumm_libretro_android.so new file mode 100755 index 000000000..62d399674 --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libfceumm_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0150618be202d6c727cf2ae937b0170b24346304d4060460f09be530dcf10ea9 +size 4250816 diff --git a/app/src/main/jniLibs/arm64-v8a/libgambatte_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libgambatte_libretro_android.so new file mode 100755 index 000000000..f4f51a884 --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libgambatte_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0023ff382f245fa760af233c02eea9d0af0b66ebd50f89da6cf51d888865a453 +size 4981904 diff --git a/app/src/main/jniLibs/arm64-v8a/libgenesis_plus_gx_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libgenesis_plus_gx_libretro_android.so new file mode 100755 index 000000000..644c2a4d5 --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libgenesis_plus_gx_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d197a4a12983834a1f49fdb7dbaedd94d215a935493e79197cf29c6ca4f07530 +size 12945240 diff --git a/app/src/main/jniLibs/arm64-v8a/libmgba_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libmgba_libretro_android.so new file mode 100755 index 000000000..e42fb459e --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libmgba_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e1eaf81ca43d0473fcfa25d16c99fb4cd545e58ae1eb92616ab268cdecc483ec +size 3168096 diff --git a/app/src/main/jniLibs/arm64-v8a/libmupen64plus_next_gles3_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libmupen64plus_next_gles3_libretro_android.so new file mode 100755 index 000000000..592b8bb06 --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libmupen64plus_next_gles3_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:baf70c367e9ee0576333baf46786cd0d8465d8634dd58c6cff4c5c444ee6b696 +size 6866504 diff --git a/app/src/main/jniLibs/arm64-v8a/libparallel_n64_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libparallel_n64_libretro_android.so new file mode 100755 index 000000000..85de14d65 --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libparallel_n64_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0e1ed31b47701300353abc227f26dea2be779fc6babd7f822ee201e083167b5 +size 8121000 diff --git a/app/src/main/jniLibs/arm64-v8a/libpcsx_rearmed_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libpcsx_rearmed_libretro_android.so new file mode 100755 index 000000000..603bd406e --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libpcsx_rearmed_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c48e95b3b1535ad94231141a3347b6bbe2c13f71fafcb21150523e9aaf7b9b1 +size 1591688 diff --git a/app/src/main/jniLibs/arm64-v8a/libsnes9x_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libsnes9x_libretro_android.so new file mode 100755 index 000000000..ffae2be5b --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libsnes9x_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f2f6637389d30cb260034f3c61095eb78ee65bfbaaf59d4b8671928312f37880 +size 2911328 diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index c8508b6f3..42accb685 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -1222,6 +1222,14 @@ public void handleOnBackPressed() { if (shortcutPath != null && !shortcutPath.isEmpty()) { shortcut = new Shortcut(container, new File(shortcutPath)); } + + if (shortcut != null + && com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut)) { + startActivity(com.winlator.cmod.feature.retro.RetroShortcuts.launchIntent(this, shortcut)); + finish(); + return; + } + loadScreenEffectsSettings(); boolean recordToFile = preferences.getBoolean("hud_record_to_file", false); diff --git a/app/src/main/runtime/display/ui/FrameRating.java b/app/src/main/runtime/display/ui/FrameRating.java index 705b704c2..57b614f73 100644 --- a/app/src/main/runtime/display/ui/FrameRating.java +++ b/app/src/main/runtime/display/ui/FrameRating.java @@ -182,6 +182,12 @@ public void setFrameObserver(FrameObserver observer) { private Drawable plugIcon; private boolean dualSeriesBattery; private boolean frametimeNumericMode; + private final java.util.ArrayList hudOrderedViews = new java.util.ArrayList<>(); + private LinearLayout wrapRowTop; + private LinearLayout wrapRowBottom; + private boolean hudWrapped = false; + private View wrapHiddenSeparator; + private int wrapHiddenSeparatorVisibility = View.VISIBLE; public FrameRating(Context context, HashMap graphicsDriverConfig) { this(context, graphicsDriverConfig, null); @@ -269,6 +275,7 @@ public FrameRating( this.sep4 = view.findViewById(R.id.Sep4); this.sep5 = view.findViewById(R.id.Sep5); this.sep6 = view.findViewById(R.id.Sep6); + for (int i = 0; i < getChildCount(); i++) hudOrderedViews.add(getChildAt(i)); this.graphView = new FrametimeGraphView(context); if (this.graphContainer != null) { this.graphContainer.addView(this.graphView); @@ -797,6 +804,7 @@ private void applyAnchor(int anchor, boolean persist) { } private void applyDisplayMode() { + if (hudWrapped) unwrapStructure(); boolean horizontal; boolean showBackdrop; switch (displayMode) { @@ -876,6 +884,177 @@ private void applyDisplayMode() { applyFrametimeDisplayVisibility(); updateSeparators(horizontal); requestLayout(); + post(this::updateWrapState); + } + + private boolean isHorizontalDisplayMode() { + return displayMode != 2 && displayMode != 3; + } + + private boolean isSeparatorView(View v) { + return v == sep0 || v == sep1 || v == sep2 || v == sep3 || v == sep4 || v == sep5 || v == sep6; + } + + private int measureViewRowWidth(View v) { + ViewGroup.LayoutParams lp = v.getLayoutParams(); + int wSpec = + lp != null && lp.width > 0 + ? MeasureSpec.makeMeasureSpec(lp.width, MeasureSpec.EXACTLY) + : MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + int hSpec = + lp != null && lp.height > 0 + ? MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY) + : MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + v.measure(wSpec, hSpec); + int margins = 0; + if (lp instanceof MarginLayoutParams) { + margins = ((MarginLayoutParams) lp).leftMargin + ((MarginLayoutParams) lp).rightMargin; + } + return v.getMeasuredWidth() + margins; + } + + private int measureNaturalRowWidth() { + int total = getPaddingLeft() + getPaddingRight(); + for (View v : hudOrderedViews) { + if (v == null || v.getVisibility() == View.GONE) continue; + total += measureViewRowWidth(v); + } + return total; + } + + public void updateWrapState() { + if (!(getParent() instanceof View)) return; + int limit = ((View) getParent()).getWidth(); + if (limit <= 0) return; + float scale = Math.max(getScaleX(), 0.01f); + int natural = (int) (measureNaturalRowWidth() * scale); + boolean shouldWrap; + if (hudWrapped) { + shouldWrap = natural >= (int) (limit * 0.95f); + } else { + shouldWrap = natural > limit; + } + if (!isHorizontalDisplayMode()) shouldWrap = false; + if (shouldWrap == hudWrapped) return; + if (shouldWrap) { + wrapStructure(); + } else { + unwrapStructure(); + setOrientation(LinearLayout.HORIZONTAL); + setGravity(android.view.Gravity.CENTER_VERTICAL); + requestLayout(); + } + } + + private void rewrap() { + if (hudWrapped) { + unwrapStructure(); + setOrientation(LinearLayout.HORIZONTAL); + setGravity(android.view.Gravity.CENTER_VERTICAL); + } + updateWrapState(); + } + + private void wrapStructure() { + java.util.ArrayList> groups = new java.util.ArrayList<>(); + java.util.ArrayList pendingSeps = new java.util.ArrayList<>(); + for (View v : hudOrderedViews) { + if (v == null) continue; + if (isSeparatorView(v)) { + pendingSeps.add(v); + continue; + } + java.util.ArrayList group = new java.util.ArrayList<>(pendingSeps); + pendingSeps.clear(); + group.add(v); + groups.add(group); + } + if (!pendingSeps.isEmpty()) { + if (groups.isEmpty()) groups.add(new java.util.ArrayList<>()); + groups.get(groups.size() - 1).addAll(pendingSeps); + } + if (groups.size() < 2) return; + + int totalWidth = 0; + int[] groupWidths = new int[groups.size()]; + for (int i = 0; i < groups.size(); i++) { + int w = 0; + for (View v : groups.get(i)) { + if (v.getVisibility() == View.GONE) continue; + w += measureViewRowWidth(v); + } + groupWidths[i] = w; + totalWidth += w; + } + + int firstRowWidth = 0; + int splitIndex = groups.size() - 1; + for (int i = 0; i < groups.size() - 1; i++) { + if (firstRowWidth + groupWidths[i] * 0.5f > totalWidth * 0.5f && i > 0) { + splitIndex = i; + break; + } + firstRowWidth += groupWidths[i]; + } + + removeAllViews(); + wrapRowTop = new LinearLayout(getContext()); + wrapRowTop.setOrientation(LinearLayout.HORIZONTAL); + wrapRowTop.setGravity(android.view.Gravity.CENTER_VERTICAL); + wrapRowBottom = new LinearLayout(getContext()); + wrapRowBottom.setOrientation(LinearLayout.HORIZONTAL); + wrapRowBottom.setGravity(android.view.Gravity.CENTER_VERTICAL); + for (int i = 0; i < groups.size(); i++) { + LinearLayout row = i < splitIndex ? wrapRowTop : wrapRowBottom; + for (View v : groups.get(i)) row.addView(v); + } + + restoreWrapHiddenSeparator(); + for (int i = 0; i < wrapRowBottom.getChildCount(); i++) { + View child = wrapRowBottom.getChildAt(i); + if (!isSeparatorView(child)) break; + if (child.getVisibility() != View.GONE) { + wrapHiddenSeparator = child; + wrapHiddenSeparatorVisibility = child.getVisibility(); + child.setVisibility(View.GONE); + break; + } + } + + setOrientation(LinearLayout.VERTICAL); + setGravity(android.view.Gravity.CENTER_HORIZONTAL); + LinearLayout.LayoutParams topLp = + new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + topLp.gravity = android.view.Gravity.CENTER_HORIZONTAL; + LinearLayout.LayoutParams bottomLp = + new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + bottomLp.gravity = android.view.Gravity.CENTER_HORIZONTAL; + bottomLp.topMargin = 2; + addView(wrapRowTop, topLp); + addView(wrapRowBottom, bottomLp); + hudWrapped = true; + requestLayout(); + } + + private void restoreWrapHiddenSeparator() { + if (wrapHiddenSeparator != null) { + wrapHiddenSeparator.setVisibility(wrapHiddenSeparatorVisibility); + wrapHiddenSeparator = null; + } + } + + private void unwrapStructure() { + if (!hudWrapped) return; + restoreWrapHiddenSeparator(); + if (wrapRowTop != null) wrapRowTop.removeAllViews(); + if (wrapRowBottom != null) wrapRowBottom.removeAllViews(); + removeAllViews(); + for (View v : hudOrderedViews) { + if (v != null) addView(v); + } + wrapRowTop = null; + wrapRowBottom = null; + hudWrapped = false; } public void setRenderer(String renderer) { @@ -1054,6 +1233,7 @@ public void setHudScale(float scale) { setPivotX(0); setPivotY(0); this.preferences.edit().putFloat(PREF_HUD_SCALE, scale).apply(); + post(this::rewrap); } public void setDualSeriesBattery(boolean dualSeriesBattery) { @@ -1126,7 +1306,8 @@ public void toggleElement(int elementIndex, boolean visible) { if (this.tvCpuTemp != null) this.tvCpuTemp.setVisibility(v); break; } - updateSeparators(getOrientation() == LinearLayout.HORIZONTAL); + updateSeparators(isHorizontalDisplayMode()); + post(this::rewrap); } private void updateSeparators(boolean horizontal) { @@ -1159,6 +1340,7 @@ private void updateSeparators(boolean horizontal) { if (sep3 != null) sep3.setVisibility(vRam && (vBat || vTmp || vFps) ? View.VISIBLE : View.GONE); if (sep4 != null) sep4.setVisibility(vBat && (vTmp || vFps) ? View.VISIBLE : View.GONE); if (sep5 != null) sep5.setVisibility(vTmp && vFps ? View.VISIBLE : View.GONE); + if (hudWrapped && wrapHiddenSeparator != null) wrapHiddenSeparator.setVisibility(View.GONE); } /** Called when the guest submits a new frame to the X presentation path. */ @@ -1573,7 +1755,8 @@ this.lastFPS, this.currentMs, this.gpuLoad, this.cpuPercent, ramPercentValue(), this.tvFrametime.setVisibility(View.GONE); } - if (getOrientation() == LinearLayout.HORIZONTAL) updateSeparators(true); + if (isHorizontalDisplayMode()) updateSeparators(true); + updateWrapState(); } private void append(SpannableStringBuilder b, String t, int c) { diff --git a/build.gradle b/build.gradle index 70ec1c14c..7cebf38d6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.kotlinAndroid) apply false alias(libs.plugins.kotlinCompose) apply false alias(libs.plugins.ksp) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 614327ea6..3e16b76e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -75,6 +75,7 @@ workRuntimeKtx = { module = "androidx.work:work-runtime-ktx", version.ref = "wor [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlinCompose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/libretrodroid/.gitignore b/libretrodroid/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/libretrodroid/.gitignore @@ -0,0 +1 @@ +/build diff --git a/libretrodroid/build.gradle b/libretrodroid/build.gradle new file mode 100644 index 000000000..ab9410149 --- /dev/null +++ b/libretrodroid/build.gradle @@ -0,0 +1,56 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) +} + +android { + namespace = "com.swordfish.libretrodroid" + compileSdk 35 + ndkVersion '27.3.13750724' + + defaultConfig { + minSdk 26 + + consumerProguardFiles 'consumer-rules.pro' + + externalNativeBuild { + cmake { + arguments '-DANDROID_STL=c++_static', + "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384" + } + } + + ndk { + abiFilters 'arm64-v8a' + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + externalNativeBuild { + cmake { + version '3.22.1' + path 'src/main/cpp/CMakeLists.txt' + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation libs.coreKtx + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' +} diff --git a/libretrodroid/consumer-rules.pro b/libretrodroid/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/libretrodroid/proguard-rules.pro b/libretrodroid/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/libretrodroid/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/libretrodroid/src/main/AndroidManifest.xml b/libretrodroid/src/main/AndroidManifest.xml new file mode 100644 index 000000000..f47302e5f --- /dev/null +++ b/libretrodroid/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + diff --git a/libretrodroid/src/main/cpp/CMakeLists.txt b/libretrodroid/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..5a845df04 --- /dev/null +++ b/libretrodroid/src/main/cpp/CMakeLists.txt @@ -0,0 +1,102 @@ +cmake_minimum_required(VERSION 3.13.2) + +# now build app's shared lib +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++17 -Wall") + +add_definitions("-DVFS_FRONTEND -DHAVE_STRL") + +# Let's include oboe +set (OBOE_DIR oboe) +add_subdirectory (${OBOE_DIR} oboe) +include_directories (${OBOE_DIR}/include) +include_directories (${OBOE_DIR}/src) + +include_directories("libretro/libretro-common/include") + +set (LIBRETRO_COMMON + libretro/libretro-common/vfs/vfs_implementation.c + libretro/libretro-common/string/stdstring.c + libretro/libretro-common/encodings/encoding_utf.c + libretro/libretro-common/file/file_path.c + libretro/libretro-common/time/rtime.c +) + +add_library(libretrodroid SHARED + libretrodroidjni.h + libretrodroidjni.cpp + libretrodroid.h + libretrodroid.cpp + log.h + core.h + core.cpp + video.h + video.cpp + immersivemode.h + immersivemode.cpp + videolayout.h + videolayout.cpp + renderers/renderer.h + renderers/renderer.cpp + renderers/es3/es3utils.h + renderers/es3/es3utils.cpp + renderers/es3/framebufferrenderer.h + renderers/es3/framebufferrenderer.cpp + renderers/es2/imagerendereres2.h + renderers/es2/imagerendereres2.cpp + renderers/es3/imagerendereres3.h + renderers/es3/imagerendereres3.cpp + audio.h + audio.cpp + resamplers/resampler.h + resamplers/linearresampler.h + resamplers/linearresampler.cpp + resamplers/sincresampler.h + resamplers/sincresampler.cpp + fpssync.h + fpssync.cpp + environment.h + environment.cpp + input.h + input.cpp + shadermanager.h + shadermanager.cpp + rumble.h + rumble.cpp + rumblestate.h + rumblestate.cpp + utils/javautils.h + utils/javautils.cpp + utils/utils.cpp + utils/utils.h + utils/jnistring.h + utils/jnistring.cpp + utils/libretrodroidexception.h + utils/libretrodroidexception.cpp + utils/rect.h + utils/rect.cpp + errorcodes.h + errorcodes.cpp + vfs/vfs.h + vfs/vfs.cpp + vfs/vfsfile.h + vfs/vfsfile.cpp + vfs/fdwrapper.h + vfs/fdwrapper.cpp + microphone/microphone.h + microphone/microphone.cpp + microphone/microphoneinterface.h + microphone/microphoneinterface.cpp + ${LIBRETRO_COMMON} +) + +# Support 16k pages for Android 15 +target_link_options(libretrodroid PRIVATE "-Wl,-z,max-page-size=16384") + +# add lib dependencies +target_link_libraries(libretrodroid + android + log + EGL + oboe + GLESv3 +) diff --git a/libretrodroid/src/main/cpp/SGSR_LICENSE b/libretrodroid/src/main/cpp/SGSR_LICENSE new file mode 100644 index 000000000..d9d513c75 --- /dev/null +++ b/libretrodroid/src/main/cpp/SGSR_LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2023, Qualcomm Innovation Center, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +SPDX-License-Identifier: BSD-3-Clause \ No newline at end of file diff --git a/libretrodroid/src/main/cpp/audio.cpp b/libretrodroid/src/main/cpp/audio.cpp new file mode 100644 index 000000000..c6e721a8e --- /dev/null +++ b/libretrodroid/src/main/cpp/audio.cpp @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "log.h" + +#include "audio.h" +#include +#include + +namespace libretrodroid { + +Audio::Audio(int32_t sampleRate, double refreshRate, bool preferLowLatencyAudio) { + LOGI("Audio initialization has been called with input sample rate %d", sampleRate); + + contentRefreshRate = refreshRate; + inputSampleRate = sampleRate; + audioLatencySettings = findBestLatencySettings(preferLowLatencyAudio); + initializeStream(); +} + +bool Audio::initializeStream() { + LOGI("Using low latency stream: %d", audioLatencySettings->useLowLatencyStream); + + int32_t audioBufferSize = computeAudioBufferSize(); + + oboe::AudioStreamBuilder builder; + builder.setChannelCount(2); + builder.setDirection(oboe::Direction::Output); + builder.setFormat(oboe::AudioFormat::I16); + builder.setDataCallback(this); + builder.setErrorCallback(this); + + if (audioLatencySettings->useLowLatencyStream) { + builder.setPerformanceMode(oboe::PerformanceMode::LowLatency); + } else { + builder.setFramesPerCallback(audioBufferSize / 10); + } + + oboe::Result result = builder.openManagedStream(stream); + if (result == oboe::Result::OK) { + baseConversionFactor = (double) inputSampleRate / stream->getSampleRate(); + fifoBuffer = std::make_unique(2, audioBufferSize); + temporaryAudioBuffer = std::unique_ptr(new int16_t[audioBufferSize]); + latencyTuner = std::make_unique(*stream); + return true; + } else { + LOGE("Failed to create stream. Error: %s", oboe::convertToText(result)); + stream = nullptr; + latencyTuner = nullptr; + return false; + } +} + +std::unique_ptr Audio::findBestLatencySettings(bool preferLowLatencyAudio) { + if (oboe::AudioStreamBuilder::isAAudioRecommended() && preferLowLatencyAudio) { + return std::make_unique(LOW_LATENCY_SETTINGS); + } else { + return std::make_unique(DEFAULT_LATENCY_SETTINGS); + } +} + +int32_t Audio::computeAudioBufferSize() { + double maxLatency = computeMaximumLatency(); + LOGI("Average audio latency set to: %f ms", maxLatency * 0.5); + double sampleRateDivisor = 500.0 / maxLatency; + return roundToEven(inputSampleRate / sampleRateDivisor); +} + +double Audio::computeMaximumLatency() const { + double maxLatency = (audioLatencySettings->bufferSizeInVideoFrames / contentRefreshRate) * 1000; + return std::max(maxLatency, 32.0); +} + +void Audio::start() { + startRequested = true; + if (stream != nullptr) + stream->requestStart(); +} + +void Audio::stop() { + startRequested = false; + if (stream != nullptr) + stream->requestStop(); +} + +void Audio::write(const int16_t *data, size_t frames) { + fifoBuffer->write(data, frames * 2); +} + +void Audio::setPlaybackSpeed(const double newPlaybackSpeed) { + playbackSpeed = newPlaybackSpeed; +} + +oboe::DataCallbackResult Audio::onAudioReady(oboe::AudioStream *oboeStream, void *audioData, int32_t numFrames) { + double dynamicBufferFactor = computeDynamicBufferConversionFactor(0.001 * numFrames); + double finalConversionFactor = baseConversionFactor * dynamicBufferFactor * playbackSpeed; + + // When using low-latency stream, numFrames is very low (~100) and the dynamic buffer scaling doesn't work with rounding. + // By keeping track of the "fractional" frames we can keep the error smaller. + framesToSubmit += numFrames * finalConversionFactor; + int32_t currentFramesToSubmit = std::round(framesToSubmit); + framesToSubmit -= currentFramesToSubmit; + + fifoBuffer->readNow(temporaryAudioBuffer.get(), currentFramesToSubmit * 2); + + auto outputArray = reinterpret_cast(audioData); + resampler.resample(temporaryAudioBuffer.get(), currentFramesToSubmit, outputArray, numFrames); + + latencyTuner->tune(); + + return oboe::DataCallbackResult::Continue; +} + +// To prevent audio buffer overruns or underruns we set up a PI controller. The idea is to run the +// audio slower when the buffer is empty and faster when it's full. +double Audio::computeDynamicBufferConversionFactor(double dt) { + double framesCapacityInBuffer = fifoBuffer->getBufferCapacityInFrames(); + double framesAvailableInBuffer = fifoBuffer->getFullFramesAvailable(); + + // Error is represented by normalized distance to half buffer utilization. Range [-1.0, 1.0] + double errorMeasure = (framesCapacityInBuffer - 2.0f * framesAvailableInBuffer) / framesCapacityInBuffer; + + errorIntegral += errorMeasure * dt; + + // Wikipedia states that human ear resolution is around 3.6 Hz within the octave of 1000–2000 Hz. + // This changes continuously, so we should try to keep it a very low value. + double proportionalAdjustment = std::clamp(kp * errorMeasure, -maxp, maxp); + + // Ki is a lot lower, so it's safe if it exceeds the ear threshold. Hopefully convergence will + // be slow enough to be not perceptible. We need to battle test this value. + double integralAdjustment = std::clamp(ki * errorIntegral, -maxi, maxi); + + double finalAdjustment = proportionalAdjustment + integralAdjustment; + + LOGD("Audio speed adjustments (p: %f) (i: %f)", proportionalAdjustment, integralAdjustment); + + return 1.0 - (finalAdjustment); +} + +int32_t Audio::roundToEven(int32_t x) { + return (x / 2) * 2; +} + +void Audio::onErrorAfterClose(oboe::AudioStream* oldStream, oboe::Result result) { + AudioStreamErrorCallback::onErrorAfterClose(oldStream, result); + LOGI("Stream error in oboe::onErrorAfterClose %s", oboe::convertToText(result)); + + if (result != oboe::Result::ErrorDisconnected) + return; + + initializeStream(); + if (startRequested) { + start(); + } +} + +} //namespace libretrodroid diff --git a/libretrodroid/src/main/cpp/audio.h b/libretrodroid/src/main/cpp/audio.h new file mode 100644 index 000000000..b0cb5ed28 --- /dev/null +++ b/libretrodroid/src/main/cpp/audio.h @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_AUDIO_H +#define LIBRETRODROID_AUDIO_H + +#include +#include +#include +#include + +#include "resamplers/linearresampler.h" + +namespace libretrodroid { + +class Audio: public oboe::AudioStreamDataCallback, oboe::AudioStreamErrorCallback { +private: + struct AudioLatencySettings { + unsigned bufferSizeInVideoFrames; + bool useLowLatencyStream; + }; + + const AudioLatencySettings DEFAULT_LATENCY_SETTINGS { 8, false }; + const AudioLatencySettings LOW_LATENCY_SETTINGS { 4, true }; + +public: + Audio(int32_t sampleRate, double refreshRate, bool preferLowLatencyAudio); + ~Audio() override = default; + + void start(); + void stop(); + + oboe::DataCallbackResult onAudioReady( + oboe::AudioStream *oboeStream, + void *audioData, + int32_t numFrames + ) override; + + void onErrorAfterClose(oboe::AudioStream *oldStream, oboe::Result result) override; + +public: + void write(const int16_t *data, size_t frames); + void setPlaybackSpeed(const double newPlaybackSpeed); + +private: + static int32_t roundToEven(int32_t x); + double computeDynamicBufferConversionFactor(double dt); + int32_t computeAudioBufferSize(); + bool initializeStream(); + std::unique_ptr findBestLatencySettings(bool preferLowLatencyAudio); + double computeMaximumLatency() const; + +private: + const double kp = 0.006; + const double ki = 0.00002; + const double maxp = 0.003; + const double maxi = 0.02; + + LinearResampler resampler; + std::unique_ptr fifoBuffer = nullptr; + std::unique_ptr temporaryAudioBuffer = nullptr; + + oboe::ManagedStream stream = nullptr; + std::unique_ptr latencyTuner = nullptr; + + bool startRequested = false; + int32_t inputSampleRate; + double contentRefreshRate = 60.0; + + double baseConversionFactor = 1.0; + + double framesToSubmit = 0.0; + double errorIntegral = 0.0; + + double playbackSpeed = 1.0; + + std::unique_ptr audioLatencySettings; +}; + +} // namespace libretrodroid + +#endif //LIBRETRODROID_AUDIO_H diff --git a/libretrodroid/src/main/cpp/core.cpp b/libretrodroid/src/main/cpp/core.cpp new file mode 100644 index 000000000..f72d78d2a --- /dev/null +++ b/libretrodroid/src/main/cpp/core.cpp @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include "core.h" + +#include "log.h" + +namespace libretrodroid { + +Core::Core(const std::string& soCorePath) { + open(soCorePath); +} + +void* get_symbol(void* handle, const char* symbol) { + void* result = dlsym(handle, symbol); + if (!result) { + LOGE("Cannot get symbol %s... Quitting", symbol); + throw std::runtime_error("Missing libretro core symbol"); + } + return result; +} + +void Core::open(const std::string& soCorePath) { + libHandle = dlopen(soCorePath.c_str(), RTLD_LOCAL | RTLD_LAZY); + if (!libHandle) { + LOGE("Cannot dlopen library, closing"); + throw std::runtime_error("Cannot dlopen library"); + } + retro_cheat_reset = (void (*)()) get_symbol(libHandle, "retro_cheat_reset"); + retro_cheat_set = (void (*)(unsigned, bool, const char*)) get_symbol(libHandle, "retro_cheat_set"); + retro_init = (void (*)()) get_symbol(libHandle, "retro_init"); + retro_deinit = (void (*)()) get_symbol(libHandle, "retro_deinit"); + retro_api_version = (unsigned (*)()) get_symbol(libHandle, "retro_api_version"); + retro_get_system_info = (void (*)(struct retro_system_info*)) get_symbol(libHandle, "retro_get_system_info"); + retro_get_system_av_info = (void (*)(struct retro_system_av_info*)) get_symbol(libHandle, "retro_get_system_av_info"); + retro_set_controller_port_device = (void (*)(unsigned, unsigned)) get_symbol(libHandle, "retro_set_controller_port_device"); + retro_reset = (void (*)()) get_symbol(libHandle, "retro_reset"); + retro_run = (void (*)()) get_symbol(libHandle, "retro_run"); + retro_serialize_size = (size_t (*)()) get_symbol(libHandle, "retro_serialize_size"); + retro_serialize = (bool (*)(void*, size_t)) get_symbol(libHandle, "retro_serialize"); + retro_unserialize = (bool (*)(const void*, size_t)) get_symbol(libHandle, "retro_unserialize"); + retro_get_memory_size = (size_t (*)(unsigned)) get_symbol(libHandle, "retro_get_memory_size"); + retro_get_memory_data = (void* (*)(unsigned)) get_symbol(libHandle, "retro_get_memory_data"); + retro_load_game = (bool (*)(const struct retro_game_info*)) get_symbol(libHandle, "retro_load_game"); + retro_unload_game = (void (*)()) get_symbol(libHandle, "retro_unload_game"); + retro_set_video_refresh = (void (*)(retro_video_refresh_t)) get_symbol(libHandle, "retro_set_video_refresh"); + retro_set_environment = (void (*)(retro_environment_t)) get_symbol(libHandle, "retro_set_environment"); + retro_set_audio_sample = (void (*)(retro_audio_sample_t)) get_symbol(libHandle, "retro_set_audio_sample"); + retro_set_audio_sample_batch = (void (*)(retro_audio_sample_batch_t)) get_symbol(libHandle, "retro_set_audio_sample_batch"); + retro_set_input_poll = (void (*)(retro_input_poll_t)) get_symbol(libHandle, "retro_set_input_poll"); + retro_set_input_state = (void (*)(retro_input_state_t)) get_symbol(libHandle, "retro_set_input_state"); +} + +void Core::close() { + if (libHandle) { + dlclose(libHandle); + libHandle = nullptr; + } +} + +Core::~Core() { + close(); +} + +} //namespace libretrodroid diff --git a/libretrodroid/src/main/cpp/core.h b/libretrodroid/src/main/cpp/core.h new file mode 100644 index 000000000..2957566e5 --- /dev/null +++ b/libretrodroid/src/main/cpp/core.h @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_CORE_H +#define LIBRETRODROID_CORE_H + +#include + +#include "../../libretro-common/include/libretro.h" + +namespace libretrodroid { + +class Core { +public: + void (*retro_init)(void); + void (*retro_deinit)(void); + unsigned (*retro_api_version)(void); + void (*retro_cheat_reset)(void); + void (*retro_cheat_set)(unsigned index, bool enabled, const char *code); + void (*retro_get_system_info)(struct retro_system_info *info); + void (*retro_get_system_av_info)(struct retro_system_av_info *info); + void (*retro_set_controller_port_device)(unsigned port, unsigned device); + void (*retro_reset)(void); + void (*retro_run)(void); + size_t (*retro_serialize_size)(void); + bool (*retro_serialize)(void *data, size_t size); + bool (*retro_unserialize)(const void *data, size_t size); + size_t (*retro_get_memory_size)(unsigned id); + void* (*retro_get_memory_data)(unsigned id); + bool (*retro_load_game)(const struct retro_game_info *game); + void (*retro_unload_game)(void); + void (*retro_set_video_refresh)(retro_video_refresh_t); + void (*retro_set_environment)(retro_environment_t); + void (*retro_set_audio_sample)(retro_audio_sample_t); + void (*retro_set_audio_sample_batch)(retro_audio_sample_batch_t); + void (*retro_set_input_poll)(retro_input_poll_t); + void (*retro_set_input_state)(retro_input_state_t); + + Core(const std::string& soCorePath); + ~Core(); + +private: + void open(const std::string& soCorePath); + void close(); + + void* libHandle = nullptr; +}; + +} + + + +#endif //LIBRETRODROID_CORE_H diff --git a/libretrodroid/src/main/cpp/environment.cpp b/libretrodroid/src/main/cpp/environment.cpp new file mode 100644 index 000000000..941de81a7 --- /dev/null +++ b/libretrodroid/src/main/cpp/environment.cpp @@ -0,0 +1,482 @@ +/* + * Copyright (C) 2020 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#define MODULE_NAME_CORE "Libretro Core" + +#include +#include +#include +#include +#include +#include +#include + +#include "../../libretro-common/include/libretro.h" +#include "log.h" +#include "environment.h" +#include "vfs/vfs.h" +#include "microphone/microphoneinterface.h" + +void Environment::initialize( + const std::string &requiredSystemDirectory, + const std::string &requiredSavesDirectory, + retro_hw_get_current_framebuffer_t required_callback_get_current_framebuffer +) { + callback_get_current_framebuffer = required_callback_get_current_framebuffer; + systemDirectory = requiredSystemDirectory; + savesDirectory = requiredSavesDirectory; +} + +void Environment::deinitialize() { + callback_get_current_framebuffer = nullptr; + hw_context_reset = nullptr; + hw_context_destroy = nullptr; + + retro_disk_control_callback = nullptr; + + savesDirectory = std::string(); + systemDirectory = std::string(); + language = RETRO_LANGUAGE_ENGLISH; + + pixelFormat = RETRO_PIXEL_FORMAT_RGB565; + useHWAcceleration = false; + useDepth = false; + useStencil = false; + bottomLeftOrigin = false; + screenRotation = 0; + + gameGeometryUpdated = false; + gameGeometryWidth = 0; + gameGeometryHeight = 0; + gameGeometryAspectRatio = -1.0f; + + rumbleStates.fill(libretrodroid::RumbleState {}); +} + +void Environment::updateVariable(const std::string& key, const std::string& value) { + auto current = variables[key]; + current.key = key; + + if (value != current.value) { + current.value = value; + variables[key] = current; + dirtyVariables = true; + } +} + +bool Environment::environment_handle_set_variables(const struct retro_variable* received) { + unsigned count = 0; + while (received[count].key != nullptr) { + LOGD("Received variable %s: %s", received[count].key, received[count].value); + + std::string key(received[count].key); + std::string description(received[count].value); + std::string value(received[count].value); + + auto firstValueStart = value.find(';') + 2; + auto firstValueEnd = value.find('|', firstValueStart); + value = value.substr(firstValueStart, firstValueEnd - firstValueStart); + + auto currentVariable = variables[key]; + currentVariable.key = key; + currentVariable.description = description; + + if (currentVariable.value.empty()) { + currentVariable.value = value; + } + + variables[key] = currentVariable; + LOGD("Assigning variable %s: %s", currentVariable.key.c_str(), currentVariable.value.c_str()); + + count++; + } + + return true; +} + +bool Environment::environment_handle_get_variable(struct retro_variable* requested) { + LOGD("Variable requested %s", requested->key); + auto foundVariable = variables.find(std::string(requested->key)); + + if (foundVariable == variables.end()) { + return false; + } + + requested->value = foundVariable->second.value.c_str(); + return true; +} + +bool Environment::environment_handle_set_controller_info(const struct retro_controller_info* received) { + controllers.clear(); + + unsigned player = 0; + while (received[player].types != nullptr) { + + auto currentPlayer = received[player]; + + controllers.emplace_back(); + + unsigned controller = 0; + while (controller < currentPlayer.num_types && currentPlayer.types[controller].desc != nullptr) { + auto currentController = currentPlayer.types[controller]; + LOGD("Received controller for player %d: %d %s", player, currentController.id, currentController.desc); + + controllers[player].push_back(Controller { currentController.id, currentController.desc }); + controller++; + } + + player++; + } + + return true; +} + +bool Environment::environment_handle_set_hw_render(struct retro_hw_render_callback* hw_render_callback) { + useHWAcceleration = true; + useDepth = hw_render_callback->depth; + useStencil = hw_render_callback->stencil; + bottomLeftOrigin = hw_render_callback->bottom_left_origin; + + hw_context_destroy = hw_render_callback->context_destroy; + hw_context_reset = hw_render_callback->context_reset; + hw_render_callback->get_current_framebuffer = callback_get_current_framebuffer; + hw_render_callback->get_proc_address = &eglGetProcAddress; + + return true; +} + +bool Environment::environment_handle_get_vfs_interface(struct retro_vfs_interface_info* vfsInterfaceInfo) { + if (!useVirtualFileSystem) { + return false; + } + + vfsInterfaceInfo->required_interface_version = libretrodroid::VFS::SUPPORTED_VERSION; + vfsInterfaceInfo->iface = libretrodroid::VFS::getInterface(); + return true; +} + +bool Environment::environment_handle_get_microphone_interface(struct retro_microphone_interface* microphone_interface) { + if (!enableMicrophone) { + return false; + } + + *microphone_interface = *libretrodroid::MicrophoneInterface::getInterface(); + return true; +} + +void Environment::callback_retro_log(enum retro_log_level level, const char *fmt, ...) { + va_list argptr; + va_start(argptr, fmt); + + switch (level) { +#if VERBOSE_LOGGING + case RETRO_LOG_DEBUG: + __android_log_vprint(ANDROID_LOG_DEBUG, MODULE_NAME_CORE, fmt, argptr); + break; +#endif + case RETRO_LOG_INFO: + __android_log_vprint(ANDROID_LOG_INFO, MODULE_NAME_CORE, fmt, argptr); + break; + case RETRO_LOG_WARN: + __android_log_vprint(ANDROID_LOG_WARN, MODULE_NAME_CORE, fmt, argptr); + break; + case RETRO_LOG_ERROR: + __android_log_vprint(ANDROID_LOG_ERROR, MODULE_NAME_CORE, fmt, argptr); + break; + default: + // Log nothing in here. + break; + } +} + +bool Environment::callback_set_rumble_state(unsigned port, enum retro_rumble_effect effect, uint16_t strength) { + return Environment::getInstance().handle_callback_set_rumble_state(port, effect, strength); +} + +bool Environment::handle_callback_set_rumble_state(unsigned port, enum retro_rumble_effect effect, uint16_t strength) { + LOGV("Setting rumble strength for port %i to %i", port, strength); + if (port < 0 || port > 3) return false; + + if (effect == RETRO_RUMBLE_STRONG) { + rumbleStates[port].strengthStrong = strength; + } else if (effect == RETRO_RUMBLE_WEAK) { + rumbleStates[port].strengthWeak = strength; + } + + return true; +} + +bool Environment::callback_environment(unsigned cmd, void *data) { + return Environment::getInstance().handle_callback_environment(cmd, data); +} + +bool Environment::handle_callback_environment(unsigned cmd, void *data) { + switch (cmd) { + case RETRO_ENVIRONMENT_GET_CAN_DUPE: + *((bool*) data) = true; + return true; + + case RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: { + LOGD("Called SET_PIXEL_FORMAT"); + pixelFormat = *static_cast(data); + return true; + } + + case RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS: + LOGD("Called SET_INPUT_DESCRIPTORS"); + return false; + + case RETRO_ENVIRONMENT_GET_VARIABLE: + LOGD("Called RETRO_ENVIRONMENT_GET_VARIABLE"); + return environment_handle_get_variable(static_cast(data)); + + case RETRO_ENVIRONMENT_SET_VARIABLES: + LOGD("Called RETRO_ENVIRONMENT_SET_VARIABLES"); + return environment_handle_set_variables(static_cast(data)); + + case RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE: { + LOGD("Called RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE. Is dirty?: %d", dirtyVariables); + *((bool*) data) = dirtyVariables; + dirtyVariables = false; + return true; + } + + case RETRO_ENVIRONMENT_GET_PREFERRED_HW_RENDER: { + LOGD("Called RETRO_ENVIRONMENT_GET_PREFERRED_HW_RENDER"); + *((unsigned*) data) = retro_hw_context_type::RETRO_HW_CONTEXT_OPENGLES3; + return true; + } + + case RETRO_ENVIRONMENT_SET_HW_RENDER: + LOGD("Called RETRO_ENVIRONMENT_SET_HW_RENDER"); + return environment_handle_set_hw_render(static_cast(data)); + + case RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE: + LOGD("Called RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE"); + ((struct retro_rumble_interface*) data)->set_rumble_state = &callback_set_rumble_state; + return true; + + case RETRO_ENVIRONMENT_GET_LOG_INTERFACE: + LOGD("Called RETRO_ENVIRONMENT_GET_LOG_INTERFACE"); + ((struct retro_log_callback*) data)->log = &callback_retro_log; + return true; + + case RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: + LOGD("Called RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY"); + *(const char**) data = savesDirectory.c_str(); + return !savesDirectory.empty(); + + case RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY: + LOGD("Called RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY"); + *(const char**) data = systemDirectory.c_str(); + return !systemDirectory.empty(); + + case RETRO_ENVIRONMENT_SET_ROTATION: { + LOGD("Called RETRO_ENVIRONMENT_SET_ROTATION"); + unsigned screenRotationIndex = (*static_cast(data)); + screenRotation = screenRotationIndex * (float) (-M_PI / 2.0); + screenRotationUpdated = true; + return true; + } + + case RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE: { + LOGD("Called RETRO_ENVIRONMENT_SET_ROTATION"); + retro_disk_control_callback = static_cast(data); + return true; + } + + case RETRO_ENVIRONMENT_GET_PERF_INTERFACE: + LOGD("Called RETRO_ENVIRONMENT_GET_PERF_INTERFACE"); + return false; + + // TODO... RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO can also change frame-rate + case RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO: + case RETRO_ENVIRONMENT_SET_GEOMETRY: { + struct retro_game_geometry *geometry = static_cast(data); + gameGeometryHeight = geometry->base_height; + gameGeometryWidth = geometry->base_width; + gameGeometryAspectRatio = geometry->aspect_ratio; + gameGeometryUpdated = true; + return true; + } + + case RETRO_ENVIRONMENT_SET_CONTROLLER_INFO: + LOGD("Called RETRO_ENVIRONMENT_SET_CONTROLLER_INFO"); + return environment_handle_set_controller_info(static_cast(data)); + + case RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE: + LOGD("Called RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE"); + return false; + + case RETRO_ENVIRONMENT_GET_LANGUAGE: + LOGD("Called RETRO_ENVIRONMENT_GET_LANGUAGE"); + *((unsigned*) data) = language; + return true; + + case RETRO_ENVIRONMENT_GET_VFS_INTERFACE: + LOGD("Called RETRO_ENVIRONMENT_GET_VFS_INTERFACE"); + return environment_handle_get_vfs_interface(static_cast(data)); + + case RETRO_ENVIRONMENT_GET_MICROPHONE_INTERFACE: + LOGD("Called RETRO_ENVIRONMENT_GET_MICROPHONE_INTERFACE"); + return environment_handle_get_microphone_interface(static_cast(data)); + + default: + LOGD("callback environment has been called: %u", cmd); + return false; + } +} + +void Environment::setLanguage(const std::string& androidLanguage) { + std::unordered_map languages { + { "en", RETRO_LANGUAGE_ENGLISH }, + { "jp", RETRO_LANGUAGE_JAPANESE }, + { "fr", RETRO_LANGUAGE_FRENCH }, + { "es", RETRO_LANGUAGE_SPANISH }, + { "de", RETRO_LANGUAGE_GERMAN }, + { "it", RETRO_LANGUAGE_ITALIAN }, + { "nl", RETRO_LANGUAGE_DUTCH }, + { "pt", RETRO_LANGUAGE_PORTUGUESE_PORTUGAL }, + { "ru", RETRO_LANGUAGE_RUSSIAN }, + { "ko", RETRO_LANGUAGE_KOREAN }, + { "zh", RETRO_LANGUAGE_CHINESE_TRADITIONAL }, + { "eo", RETRO_LANGUAGE_ESPERANTO }, + { "pl", RETRO_LANGUAGE_POLISH }, + { "vi", RETRO_LANGUAGE_VIETNAMESE }, + { "ar", RETRO_LANGUAGE_ARABIC }, + { "el", RETRO_LANGUAGE_GREEK }, + { "tr", RETRO_LANGUAGE_TURKISH } + }; + + if (languages.find(androidLanguage) != languages.end()) { + language = languages[androidLanguage]; + } +} + +retro_hw_context_reset_t Environment::getHwContextReset() const { + return hw_context_reset; +} + +retro_hw_context_reset_t Environment::getHwContextDestroy() const { + return hw_context_destroy; +} + +struct retro_disk_control_callback* Environment::getRetroDiskControlCallback() const { + return retro_disk_control_callback; +} + +int Environment::getPixelFormat() const { + return pixelFormat; +} + +bool Environment::isUseHwAcceleration() const { + return useHWAcceleration; +} + +bool Environment::isUseDepth() const { + return useDepth; +} + +bool Environment::isUseStencil() const { + return useStencil; +} + +bool Environment::isBottomLeftOrigin() const { + return bottomLeftOrigin; +} + +float Environment::getScreenRotation() const { + return screenRotation; +} + +bool Environment::isGameGeometryUpdated() const { + return gameGeometryUpdated; +} + +void Environment::clearGameGeometryUpdated() { + gameGeometryUpdated = false; +} + +unsigned int Environment::getGameGeometryWidth() const { + return gameGeometryWidth; +} + +unsigned int Environment::getGameGeometryHeight() const { + return gameGeometryHeight; +} + +float Environment::getGameGeometryAspectRatio() const { + return gameGeometryAspectRatio; +} + +const std::vector Environment::getVariables() const { + std::vector result; + + std::for_each( + variables.begin(), + variables.end(), + [&](std::pair item) { + result.push_back(item.second); + } + ); + + std::sort( + result.begin(), + result.end(), + [](struct Variable v1, struct Variable v2) { + return v1.key < v2.key; + } + ); + + return result; +} + +const std::vector> &Environment::getControllers() const { + return controllers; +} + +float Environment::retrieveGameSpecificAspectRatio() { + if (getGameGeometryAspectRatio() > 0) { + return getGameGeometryAspectRatio(); + } + + if (getGameGeometryWidth() > 0 && getGameGeometryHeight() > 0) { + return (float) getGameGeometryWidth() / (float) getGameGeometryHeight(); + } + + return -1.0f; +} + +bool Environment::isScreenRotationUpdated() const { + return screenRotationUpdated; +} + +void Environment::clearScreenRotationUpdated() { + screenRotationUpdated = false; +} + +std::array& Environment::getLastRumbleStates() { + return rumbleStates; +} + +void Environment::setEnableVirtualFileSystem(bool value) { + this->useVirtualFileSystem = value; +} + +void Environment::setEnableMicrophone(bool value) { + this->enableMicrophone = value; +} diff --git a/libretrodroid/src/main/cpp/environment.h b/libretrodroid/src/main/cpp/environment.h new file mode 100644 index 000000000..9f4a06c67 --- /dev/null +++ b/libretrodroid/src/main/cpp/environment.h @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2020 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_ENVIRONMENT_H +#define LIBRETRODROID_ENVIRONMENT_H + +#define MODULE_NAME_CORE "Libretro Core" + +#include +#include +#include +#include +#include +#include +#include + +#include "../../libretro-common/include/libretro.h" +#include "log.h" +#include "rumblestate.h" + +class Environment { +public: + static Environment& getInstance() + { + static Environment instance; + return instance; + } + Environment(Environment const&) = delete; + void operator=(Environment const&) = delete; + + static void callback_retro_log(enum retro_log_level level, const char *fmt, ...); + + static bool callback_set_rumble_state( + unsigned port, + enum retro_rumble_effect effect, + uint16_t strength + ); + + static bool callback_environment(unsigned cmd, void *data); + + void setEnableVirtualFileSystem(bool value); + void setEnableMicrophone(bool value); + +private: + Environment() {} + +public: + void initialize( + const std::string &requiredSystemDirectory, + const std::string &requiredSavesDirectory, + retro_hw_get_current_framebuffer_t required_callback_get_current_framebuffer + ); + + void deinitialize(); + + void updateVariable(const std::string &key, const std::string &value); + + void setLanguage(const std::string &androidLanguage); + + float retrieveGameSpecificAspectRatio(); + + bool handle_callback_set_rumble_state( + unsigned port, + enum retro_rumble_effect effect, + uint16_t strength + ); + + bool handle_callback_environment(unsigned cmd, void *data); + + retro_hw_context_reset_t getHwContextReset() const; + retro_hw_context_reset_t getHwContextDestroy() const; + + struct retro_disk_control_callback* getRetroDiskControlCallback() const; + + int getPixelFormat() const; + bool isUseHwAcceleration() const; + bool isUseDepth() const; + bool isUseStencil() const; + bool isBottomLeftOrigin() const; + + float getScreenRotation() const; + bool isScreenRotationUpdated() const; + void clearScreenRotationUpdated(); + + unsigned int getGameGeometryWidth() const; + unsigned int getGameGeometryHeight() const; + float getGameGeometryAspectRatio() const; + bool isGameGeometryUpdated() const; + void clearGameGeometryUpdated(); + + std::array & getLastRumbleStates(); + + const std::vector getVariables() const; + + const std::vector> &getControllers() const; + +private: + bool environment_handle_set_variables(const struct retro_variable* received); + bool environment_handle_get_variable(struct retro_variable* requested); + bool environment_handle_set_controller_info(const struct retro_controller_info* received); + bool environment_handle_set_hw_render(struct retro_hw_render_callback* hw_render_callback); + bool environment_handle_get_vfs_interface(struct retro_vfs_interface_info* vfs_interface_info); + bool environment_handle_get_microphone_interface(struct retro_microphone_interface* microphone_interface); + +private: + retro_hw_context_reset_t hw_context_reset = nullptr; + retro_hw_context_reset_t hw_context_destroy = nullptr; + struct retro_disk_control_callback *retro_disk_control_callback = nullptr; + + std::string savesDirectory; + std::string systemDirectory; + retro_hw_get_current_framebuffer_t callback_get_current_framebuffer = nullptr; + unsigned language = RETRO_LANGUAGE_ENGLISH; + bool useVirtualFileSystem = false; + bool enableMicrophone = false; + + int pixelFormat = RETRO_PIXEL_FORMAT_RGB565; + bool useHWAcceleration = false; + bool useDepth = false; + bool useStencil = false; + bool bottomLeftOrigin = false; + + float screenRotation = 0; + bool screenRotationUpdated = false; + + bool gameGeometryUpdated = false; + unsigned gameGeometryWidth = 0; + unsigned gameGeometryHeight = 0; + float gameGeometryAspectRatio = -1.0f; + + std::array rumbleStates; + + std::unordered_map variables; + bool dirtyVariables = false; + + std::vector> controllers; +}; + +struct Variable { +public: + std::string key; + std::string value; + std::string description; +}; + +struct Controller { +public: + unsigned id; + std::string description; +}; + +#endif //LIBRETRODROID_ENVIRONMENT_H + diff --git a/libretrodroid/src/main/cpp/errorcodes.cpp b/libretrodroid/src/main/cpp/errorcodes.cpp new file mode 100644 index 000000000..264069b22 --- /dev/null +++ b/libretrodroid/src/main/cpp/errorcodes.cpp @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_ERRORCODES_H +#define LIBRETRODROID_ERRORCODES_H + +namespace libretrodroid { + int ERROR_LOAD_LIBRARY = 0; + int ERROR_LOAD_GAME = 1; + int ERROR_GL_NOT_COMPATIBLE = 2; + int ERROR_SERIALIZATION = 3; + int ERROR_CHEAT = 4; + int ERROR_GENERIC = -1; +} + +#endif //LIBRETRODROID_ERRORCODES_H diff --git a/libretrodroid/src/main/cpp/errorcodes.h b/libretrodroid/src/main/cpp/errorcodes.h new file mode 100644 index 000000000..3524a7fc6 --- /dev/null +++ b/libretrodroid/src/main/cpp/errorcodes.h @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_ERRORCODES_H +#define LIBRETRODROID_ERRORCODES_H + +namespace libretrodroid { + extern int ERROR_LOAD_LIBRARY; + extern int ERROR_LOAD_GAME; + extern int ERROR_GL_NOT_COMPATIBLE; + extern int ERROR_SERIALIZATION; + extern int ERROR_CHEAT; + extern int ERROR_GENERIC; +} + +#endif //LIBRETRODROID_ERRORCODES_H diff --git a/libretrodroid/src/main/cpp/fpssync.cpp b/libretrodroid/src/main/cpp/fpssync.cpp new file mode 100644 index 000000000..f0ab777d8 --- /dev/null +++ b/libretrodroid/src/main/cpp/fpssync.cpp @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include "fpssync.h" +#include "log.h" + +namespace libretrodroid { + +unsigned FPSSync::advanceFrames() { + if (useVSync) { + if (vsyncMultiple <= 1) return 1; + tickCounter = (tickCounter + 1) % vsyncMultiple; + return tickCounter == 0 ? 1 : 0; + } + + if (lastFrame == MIN_TIME) { + start(); + } + + auto now = std::chrono::steady_clock::now(); + auto frames = std::max((now - lastFrame) / sampleInterval, (long long) 1); + lastFrame = lastFrame + sampleInterval * frames; + + return frames; +} + +FPSSync::FPSSync(double contentRefreshRate, double screenRefreshRate) { + this->contentRefreshRate = contentRefreshRate; + this->screenRefreshRate = screenRefreshRate; + this->useVSync = std::abs(contentRefreshRate - screenRefreshRate) < FPS_TOLERANCE; + if (!useVSync && contentRefreshRate > 0) { + auto multiple = (unsigned) std::lround(screenRefreshRate / contentRefreshRate); + if (multiple >= 2 && + std::abs(screenRefreshRate - multiple * contentRefreshRate) < FPS_TOLERANCE * multiple) { + this->useVSync = true; + this->vsyncMultiple = multiple; + } + } + this->sampleInterval = std::chrono::microseconds((long) ((1000000L / contentRefreshRate))); + LOGI("FPS sync: content %f screen %f vsync: %d multiple: %d", contentRefreshRate, screenRefreshRate, useVSync, vsyncMultiple); + reset(); +} + +void FPSSync::start() { + LOGI("Starting game with fps %f on a screen with refresh rate %f. Using vsync: %d multiple: %d", contentRefreshRate, screenRefreshRate, useVSync, vsyncMultiple); + lastFrame = std::chrono::steady_clock::now(); +} + +void FPSSync::reset() { + lastFrame = MIN_TIME; +} + +double FPSSync::getTimeStretchFactor() { + return useVSync ? (contentRefreshRate * vsyncMultiple) / screenRefreshRate : 1.0; +} + +void FPSSync::wait() { + if (useVSync) return; + std::this_thread::sleep_until(lastFrame); +} + +} //namespace libretrodroid diff --git a/libretrodroid/src/main/cpp/fpssync.h b/libretrodroid/src/main/cpp/fpssync.h new file mode 100644 index 000000000..118c422ab --- /dev/null +++ b/libretrodroid/src/main/cpp/fpssync.h @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_FPSSYNC_H +#define LIBRETRODROID_FPSSYNC_H + +#include +#include + +namespace libretrodroid { + +typedef std::chrono::steady_clock::time_point TimePoint; +typedef std::chrono::duration Duration; + +class FPSSync { +public: + FPSSync(double contentRefreshRate, double screenRefreshRate); + ~FPSSync() { } + + void reset(); + unsigned advanceFrames(); + void wait(); + double getTimeStretchFactor(); +private: + + double screenRefreshRate; + double contentRefreshRate; + bool useVSync; + unsigned vsyncMultiple = 1; + unsigned tickCounter = 0; + const double FPS_TOLERANCE = 5; + + const TimePoint MIN_TIME = TimePoint::min(); + void start(); + + TimePoint lastFrame = MIN_TIME; + Duration sampleInterval; +}; + +} + + +#endif //LIBRETRODROID_FPSSYNC_H diff --git a/libretrodroid/src/main/cpp/immersivemode.cpp b/libretrodroid/src/main/cpp/immersivemode.cpp new file mode 100644 index 000000000..af9235010 --- /dev/null +++ b/libretrodroid/src/main/cpp/immersivemode.cpp @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2025 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "immersivemode.h" + +#include +#include + +#include "log.h" + +namespace libretrodroid { + +void ImmersiveMode::initializeShaders() { + if (blendShaderProgram != 0) return; + + GLuint blendingVertexShader = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(blendingVertexShader, 1, &blendingVertexShaderSource, nullptr); + glCompileShader(blendingVertexShader); + + GLuint blendingFragmentShader = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(blendingFragmentShader, 1, &blendingFragmentShaderSource, nullptr); + glCompileShader(blendingFragmentShader); + + blendShaderProgram = glCreateProgram(); + glAttachShader(blendShaderProgram, blendingVertexShader); + glAttachShader(blendShaderProgram, blendingFragmentShader); + glBindAttribLocation(blendShaderProgram, 0, "aPosition"); + glBindAttribLocation(blendShaderProgram, 1, "aTexCoord"); + glLinkProgram(blendShaderProgram); + + glDeleteShader(blendingVertexShader); + glDeleteShader(blendingFragmentShader); + + blendTextureHandle = glGetUniformLocation(blendShaderProgram, "currentFrame"); + blendPrevTextureHandle = glGetUniformLocation(blendShaderProgram, "previousFrame"); + blendFactorHandle = glGetUniformLocation(blendShaderProgram, "blendFactor"); + + std::string fragmentShaderSource = generateBlurShader(); + + GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vertexShader, 1, &defaultVertexShaderSource, nullptr); + glCompileShader(vertexShader); + + GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); + const char* c_str = fragmentShaderSource.c_str(); + glShaderSource(fragmentShader, 1, &c_str, nullptr); + glCompileShader(fragmentShader); + + blurShaderProgram = glCreateProgram(); + glAttachShader(blurShaderProgram, vertexShader); + glAttachShader(blurShaderProgram, fragmentShader); + glBindAttribLocation(blurShaderProgram, 0, "aPosition"); + glBindAttribLocation(blurShaderProgram, 1, "aTexCoord"); + glLinkProgram(blurShaderProgram); + + glDeleteShader(vertexShader); + glDeleteShader(fragmentShader); + + blurPositionHandle = glGetAttribLocation(blurShaderProgram, "aPosition"); + blurTextureCoordinatesHandle = glGetAttribLocation(blurShaderProgram, "aTexCoord"); + blurTextureHandle = glGetUniformLocation(blurShaderProgram, "texture"); + blurDirectionHandle = glGetUniformLocation(blurShaderProgram, "direction"); + + GLuint displayVertexShader = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(displayVertexShader, 1, &defaultVertexShaderSource, nullptr); + glCompileShader(displayVertexShader); + + GLuint displayFragmentShader = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(displayFragmentShader, 1, &displayFragmentShaderSource, nullptr); + glCompileShader(displayFragmentShader); + + displayShaderProgram = glCreateProgram(); + glAttachShader(displayShaderProgram, displayVertexShader); + glAttachShader(displayShaderProgram, displayFragmentShader); + glBindAttribLocation(displayShaderProgram, 0, "aPosition"); + glBindAttribLocation(displayShaderProgram, 1, "aTexCoord"); + glLinkProgram(displayShaderProgram); + + glDeleteShader(displayVertexShader); + glDeleteShader(displayFragmentShader); + + displayPositionHandle = glGetAttribLocation(blurShaderProgram, "aPosition"); + displayForegroundBoundsHandle = glGetUniformLocation(displayShaderProgram, "foregroundBounds"); + displayTextureCoordinatesHandle = glGetAttribLocation(blurShaderProgram, "aTexCoord"); + displayTextureHandle = glGetUniformLocation(displayShaderProgram, "texture"); +} + +void ImmersiveMode::initializeFramebuffers() { + if (!blurFramebuffers.empty()) return; + + for (int i = 0; i < 3; i++) { + blurFramebuffers.push_back( + ES3Utils::createFramebuffer( + downscaledWidth, downscaledHeight, true, false, false, false + ) + ); + } + + // Last framebuffer needs GL_MIRROR_REPEAT + blurFramebuffers.push_back( + ES3Utils::createFramebuffer( + downscaledWidth, downscaledHeight, true, true, false, false + ) + ); +} + +void ImmersiveMode::renderToFramebuffer(uintptr_t texture, GLfloat* gBackgroundVertices) { + int blendFramebufferReadIndex = (blendFramebufferWriteIndex + 1) % 2; + + glViewport(0, 0, downscaledWidth, downscaledHeight); + + glVertexAttribPointer(blurPositionHandle, 2, GL_FLOAT, GL_FALSE, 0, gBackgroundVertices); + glEnableVertexAttribArray(blurPositionHandle); + + glVertexAttribPointer(blurTextureCoordinatesHandle, 2, GL_FLOAT, GL_FALSE, 0, gTextureCoords); + glEnableVertexAttribArray(blurTextureCoordinatesHandle); + + glBindFramebuffer(GL_FRAMEBUFFER, blurFramebuffers[blendFramebufferWriteIndex]->framebuffer); + + glClear(GL_COLOR_BUFFER_BIT); + + glUseProgram(blendShaderProgram); + glUniform1f(blendFactorHandle, blendFactor); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glUniform1i(blendTextureHandle, 0); + + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, blurFramebuffers[blendFramebufferReadIndex]->texture); + glUniform1i(blendPrevTextureHandle, 1); + + glDrawArrays(GL_TRIANGLES, 0, 6); + + glUseProgram(blurShaderProgram); + + glBindFramebuffer(GL_FRAMEBUFFER, blurFramebuffers[2]->framebuffer); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, blurFramebuffers[blendFramebufferWriteIndex]->texture); + glUniform1i(blurTextureHandle, 0); + glUniform2f(blurDirectionHandle, 1.0 / downscaledWidth, 0.0); + + glDrawArrays(GL_TRIANGLES, 0, 6); + + glBindFramebuffer(GL_FRAMEBUFFER, blurFramebuffers[3]->framebuffer); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, blurFramebuffers[2]->texture); + glUniform1i(blurTextureHandle, 0); + glUniform2f(blurDirectionHandle, 0.0, 1.0 / downscaledHeight); + + glDrawArrays(GL_TRIANGLES, 0, 6); + + blendFramebufferWriteIndex = blendFramebufferReadIndex; +} + +void ImmersiveMode::renderToFinalOutput( + unsigned screenWidth, + unsigned screenHeight, + std::array backgroundVertices, + std::array foregroundBounds +) { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, screenWidth, screenHeight); + glUseProgram(displayShaderProgram); + + glVertexAttribPointer(displayPositionHandle, 2, GL_FLOAT, GL_FALSE, 0, backgroundVertices.data()); + glEnableVertexAttribArray(displayPositionHandle); + + glVertexAttribPointer(displayTextureCoordinatesHandle, 2, GL_FLOAT, GL_FALSE, 0, gTextureCoords); + glEnableVertexAttribArray(displayTextureCoordinatesHandle); + + glUniform4f( + displayForegroundBoundsHandle, + foregroundBounds[0], + foregroundBounds[1], + foregroundBounds[2], + foregroundBounds[3] + ); + + glUniform1i(displayTextureHandle, 0); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, blurFramebuffers[3]->texture); + glUniform1i(displayTextureHandle, 0); + glDrawArrays(GL_TRIANGLES, 0, 6); + + glDisableVertexAttribArray(displayPositionHandle); + glDisableVertexAttribArray(displayTextureCoordinatesHandle); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindTexture(GL_TEXTURE_2D, 0); + glUseProgram(0); +} + +void ImmersiveMode::renderBackground( + unsigned screenWidth, + unsigned screenHeight, + std::array backgroundVertices, + std::array foregroundBounds, + GLfloat* framebufferVertices, + uintptr_t texture +) { + initializeShaders(); + initializeFramebuffers(); + + if (blendFramebufferCurrent == 0) { + renderToFramebuffer(texture, framebufferVertices); + } + + // Let's update the blurred texture every other frame. + blendFramebufferCurrent = (blendFramebufferCurrent + 1) % blurSkipUpdate; + + renderToFinalOutput(screenWidth, screenHeight, backgroundVertices, foregroundBounds); +} + +std::vector ImmersiveMode::generateSmoothingWeights(int size, float brightness) { + std::vector kernel(size); + float sigma = size / 3.0f; + float sum = 0.0f; + int halfSize = size / 2; + + for (int i = 0; i < size; i++) { + int x = i - halfSize; + kernel[i] = exp(-(x * x) / (2 * sigma * sigma)); + sum += kernel[i]; + } + + for (int i = 0; i < size; i++) { + kernel[i] = (kernel[i] / sum) * brightness; + } + + return kernel; +} + +std::string ImmersiveMode::generateBlurShader() { + if (blurMaskSize % 2 == 0) { + LOGE("Error: maskSize should be an odd number!"); + return ""; + } + + std::vector kernel = generateSmoothingWeights(blurMaskSize, sqrt(blurBrightness)); + + std::ostringstream result; + result << R"( + precision mediump float; + varying vec2 vTexCoord; + uniform sampler2D texture; + uniform vec2 direction; + + void main() { + lowp vec4 result = vec4(0.0); + lowp float kernel[)" << blurMaskSize << R"(]; + + )"; + + for (int i = 0; i < blurMaskSize; i++) { + result << " kernel[" << i << "] = " << kernel[i] << ";\n"; + } + + int halfMask = blurMaskSize / 2; + result << R"( + for (int i = -)" << halfMask << "; i <= " << halfMask << R"(; i++) { + lowp vec2 offset = vec2(float(i)) * direction; + result += texture2D(texture, vTexCoord + offset) * kernel[i + )" << halfMask << R"(]; + } + gl_FragColor = result; + } + )"; + + return result.str(); +} + +} // namespace libretrodroid diff --git a/libretrodroid/src/main/cpp/immersivemode.h b/libretrodroid/src/main/cpp/immersivemode.h new file mode 100644 index 000000000..50f066ee8 --- /dev/null +++ b/libretrodroid/src/main/cpp/immersivemode.h @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2025 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#ifndef LIBRETRODROID_IMMERSIVEMODE_H +#define LIBRETRODROID_IMMERSIVEMODE_H + +#include +#include +#include +#include +#include + +#include "renderers/es3/es3utils.h" + +namespace libretrodroid { + +class ImmersiveMode { +public: + struct Config { + Config( + int downscaledWidth = 8, + int downscaledHeight = 8, + int blurMaskSize = 5, + float blurBrightness = 0.5F, + int blurSkipUpdate = 2, + float blendFactor = 0.1F + ) : + downscaledWidth(downscaledWidth), + downscaledHeight(downscaledHeight), + blurMaskSize(blurMaskSize), + blurBrightness(blurBrightness), + blurSkipUpdate(blurSkipUpdate), + blendFactor(blendFactor) + {} + + int downscaledWidth; + int downscaledHeight; + int blurMaskSize; + float blurBrightness; + int blurSkipUpdate; + float blendFactor; + }; + + explicit ImmersiveMode(const Config& config = Config()) : + downscaledWidth(config.downscaledWidth), + downscaledHeight(config.downscaledHeight), + blurMaskSize(config.blurMaskSize), + blurBrightness(config.blurBrightness), + blurSkipUpdate(config.blurSkipUpdate > 0 ? config.blurSkipUpdate : 1), + blendFactor(config.blendFactor) + {} + + void renderBackground( + unsigned screenWidth, + unsigned screenHeight, + std::array backgroundVertices, + std::array foregroundBounds, + GLfloat* framebufferVertices, + uintptr_t texture + ); + +private: + std::string generateBlurShader(); + static std::vector generateSmoothingWeights(int size, float brightness); + + void initializeShaders(); + + void initializeFramebuffers(); + + void renderToFramebuffer(uintptr_t texture, GLfloat* gBackgroundVertices); + + void renderToFinalOutput( + unsigned screenWidth, + unsigned screenHeight, + std::array backgroundVertices, + std::array foregroundBounds + ); + +private: + const char* defaultVertexShaderSource = R"( + attribute mediump vec2 aPosition; + attribute mediump vec2 aTexCoord; + varying mediump vec2 vTexCoord; + void main() { + gl_Position = vec4(aPosition, 0.0, 1.0); + vTexCoord = aTexCoord; + } + )"; + + const char* displayFragmentShaderSource = R"( + precision mediump float; + varying mediump vec2 vTexCoord; + uniform lowp sampler2D texture; + uniform vec4 foregroundBounds; // (minX, minY, maxX, maxY) + + void main() { + mediump vec2 normalizedCoords = (vTexCoord - foregroundBounds.xy) / abs(foregroundBounds.zw - foregroundBounds.xy); + lowp vec2 speed = abs(foregroundBounds.zw - foregroundBounds.xy); + + mediump vec2 adjustedCoords = vec2(0.0); + adjustedCoords -= speed * normalizedCoords; + adjustedCoords += (1.0 + speed) * step(vec2(0.0), normalizedCoords) * normalizedCoords; + adjustedCoords -= (1.0 - speed) * step(vec2(1.0), normalizedCoords) * (normalizedCoords - 1.0); + + gl_FragColor = texture2D(texture, adjustedCoords); + } + )"; + + const char* blendingVertexShaderSource = R"( + attribute mediump vec2 aPosition; + attribute mediump vec2 aTexCoord; + varying mediump vec2 vTexCoord; + void main() { + gl_Position = vec4(aPosition, 0.0, 1.0); + vTexCoord = aTexCoord; + } + )"; + + const char* blendingFragmentShaderSource = R"( + precision mediump float; + varying mediump vec2 vTexCoord; + uniform lowp sampler2D currentFrame; + uniform lowp sampler2D previousFrame; + uniform float blendFactor; + + void main() { + lowp float margin = -0.125; + mediump vec2 adjustedCoord = vTexCoord * (1.0 - 2.0 * margin) + margin; + lowp vec4 currentColor = texture2D(currentFrame, adjustedCoord); + lowp vec4 prevColor = texture2D(previousFrame, vTexCoord); + gl_FragColor = mix(prevColor, currentColor, blendFactor); + } + )"; + + GLfloat gTextureCoords[12] = { + 0.0F, + 0.0F, + + 0.0F, + 1.0F, + + 1.0F, + 0.0F, + + 1.0F, + 0.0F, + + 0.0F, + 1.0F, + + 1.0F, + 1.0F, + }; + + GLuint blurShaderProgram = 0; + std::vector> blurFramebuffers; + + GLint blurPositionHandle = -1; + GLint blurTextureCoordinatesHandle = -1; + GLint blurTextureHandle = -1; + + GLint blurDirectionHandle = -1; + + GLuint displayShaderProgram = 0; + GLint displayTextureHandle = -1; + GLint displayPositionHandle = -1; + GLint displayForegroundBoundsHandle = -1; + GLint displayTextureCoordinatesHandle = -1; + + int downscaledWidth; + int downscaledHeight; + int blurMaskSize; + float blurBrightness; + int blurSkipUpdate; + float blendFactor; + + GLuint blendShaderProgram = 0; + GLint blendTextureHandle = -1; + GLint blendPrevTextureHandle = -1; + GLint blendFactorHandle = -1; + int blendFramebufferWriteIndex = 0; + int blendFramebufferCurrent = 0; +}; + +} // libretrodroid + +#endif //LIBRETRODROID_IMMERSIVEMODE_H diff --git a/libretrodroid/src/main/cpp/input.cpp b/libretrodroid/src/main/cpp/input.cpp new file mode 100644 index 000000000..f52cec742 --- /dev/null +++ b/libretrodroid/src/main/cpp/input.cpp @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "input.h" + +#include + +#include +#include + +#include "../../libretro-common/include/libretro.h" + +namespace libretrodroid { + +int16_t Input::getInputState(unsigned port, unsigned device, unsigned index, unsigned id) { + if (port >= 4 || port < 0) return 0; + + switch (device) { + case RETRO_DEVICE_JOYPAD: { + switch (id) { + case RETRO_DEVICE_ID_JOYPAD_LEFT: { + bool axis = pads[port].dpadXAxis == -1; + bool buttons = anyPressed( + port, + RETRO_DEVICE_ID_JOYPAD_LEFT, + Input::RETRO_DEVICE_ID_JOYPAD_DOWN_LEFT, + Input::RETRO_DEVICE_ID_JOYPAD_UP_LEFT + ); + return axis || buttons; + } + case RETRO_DEVICE_ID_JOYPAD_RIGHT: { + bool axis = pads[port].dpadXAxis == 1; + bool buttons = anyPressed( + port, + RETRO_DEVICE_ID_JOYPAD_RIGHT, + Input::RETRO_DEVICE_ID_JOYPAD_UP_RIGHT, + Input::RETRO_DEVICE_ID_JOYPAD_DOWN_RIGHT + ); + return axis || buttons; + } + case RETRO_DEVICE_ID_JOYPAD_UP: { + bool axis = pads[port].dpadYAxis == -1; + bool buttons = anyPressed( + port, + RETRO_DEVICE_ID_JOYPAD_UP, + Input::RETRO_DEVICE_ID_JOYPAD_UP_LEFT, + Input::RETRO_DEVICE_ID_JOYPAD_UP_RIGHT + ); + return axis || buttons; + } + case RETRO_DEVICE_ID_JOYPAD_DOWN: { + bool axis = pads[port].dpadYAxis == 1; + bool buttons = anyPressed( + port, + RETRO_DEVICE_ID_JOYPAD_DOWN, + Input::RETRO_DEVICE_ID_JOYPAD_DOWN_LEFT, + Input::RETRO_DEVICE_ID_JOYPAD_DOWN_RIGHT + ); + return axis || buttons; + } + default: + return anyPressed(port, id); + } + } + + case RETRO_DEVICE_ANALOG: { + switch (index) { + case RETRO_DEVICE_INDEX_ANALOG_LEFT: + switch (id) { + case RETRO_DEVICE_ID_ANALOG_X: + return (int16_t) (pads[port].joypadLeftXAxis * MAX_RANGE_MOTION); + case RETRO_DEVICE_ID_ANALOG_Y: + return (int16_t) (pads[port].joypadLeftYAxis * MAX_RANGE_MOTION); + default: + return 0; + } + case RETRO_DEVICE_INDEX_ANALOG_RIGHT: + switch (id) { + case RETRO_DEVICE_ID_ANALOG_X: + return (int16_t) (pads[port].joypadRightXAxis * MAX_RANGE_MOTION); + case RETRO_DEVICE_ID_ANALOG_Y: + return (int16_t) (pads[port].joypadRightYAxis * MAX_RANGE_MOTION); + default: + return 0; + } + default: + return 0; + } + } + + case RETRO_DEVICE_POINTER: { + // TODO... Here we should hanlde multitouch... + if (index > 0) { + return 0; + } + + switch (id) { + case RETRO_DEVICE_ID_POINTER_PRESSED: { + bool isXActive = pads[port].pointerScreenXAxis >= 0; + bool isYActive = pads[port].pointerScreenYAxis >= 0; + return (int16_t) (isXActive && isYActive ? 1 : 0); + } + + case RETRO_DEVICE_ID_POINTER_X: + return (int16_t) (2.0 * (pads[port].pointerScreenXAxis - 0.5f) * MAX_RANGE_MOTION); + + case RETRO_DEVICE_ID_POINTER_Y: + return (int16_t) (2.0 * (pads[port].pointerScreenYAxis - 0.5f) * MAX_RANGE_MOTION); + + default: + return 0; + } + } + + default: + return 0; + } +} + +int Input::convertAndroidToLibretroKey(int keyCode) const { + switch (keyCode) { + case AKEYCODE_BUTTON_START: + return RETRO_DEVICE_ID_JOYPAD_START; + case AKEYCODE_BUTTON_SELECT: + return RETRO_DEVICE_ID_JOYPAD_SELECT; + case AKEYCODE_BUTTON_A: + return RETRO_DEVICE_ID_JOYPAD_A; + case AKEYCODE_BUTTON_X: + return RETRO_DEVICE_ID_JOYPAD_X; + case AKEYCODE_BUTTON_Y: + return RETRO_DEVICE_ID_JOYPAD_Y; + case AKEYCODE_BUTTON_B: + return RETRO_DEVICE_ID_JOYPAD_B; + case AKEYCODE_BUTTON_L1: + return RETRO_DEVICE_ID_JOYPAD_L; + case AKEYCODE_BUTTON_L2: + return RETRO_DEVICE_ID_JOYPAD_L2; + case AKEYCODE_BUTTON_R1: + return RETRO_DEVICE_ID_JOYPAD_R; + case AKEYCODE_BUTTON_R2: + return RETRO_DEVICE_ID_JOYPAD_R2; + case AKEYCODE_BUTTON_THUMBL: + return RETRO_DEVICE_ID_JOYPAD_L3; + case AKEYCODE_BUTTON_THUMBR: + return RETRO_DEVICE_ID_JOYPAD_R3; + case AKEYCODE_DPAD_UP: + return RETRO_DEVICE_ID_JOYPAD_UP; + case AKEYCODE_DPAD_DOWN: + return RETRO_DEVICE_ID_JOYPAD_DOWN; + case AKEYCODE_DPAD_LEFT: + return RETRO_DEVICE_ID_JOYPAD_LEFT; + case AKEYCODE_DPAD_RIGHT: + return RETRO_DEVICE_ID_JOYPAD_RIGHT; + case AKEYCODE_DPAD_UP_RIGHT: + return Input::RETRO_DEVICE_ID_JOYPAD_UP_RIGHT; + case AKEYCODE_DPAD_UP_LEFT: + return Input::RETRO_DEVICE_ID_JOYPAD_UP_LEFT; + case AKEYCODE_DPAD_DOWN_RIGHT: + return Input::RETRO_DEVICE_ID_JOYPAD_DOWN_RIGHT; + case AKEYCODE_DPAD_DOWN_LEFT: + return Input::RETRO_DEVICE_ID_JOYPAD_DOWN_LEFT; + default: + return UNKNOWN_KEY; + } +} + +void Input::onKeyEvent(unsigned int port, int action, int keyCode) { + int retroKeyCode = convertAndroidToLibretroKey(keyCode); + if (retroKeyCode == UNKNOWN_KEY) { + return; + } + + if (action == AKEY_EVENT_ACTION_DOWN) { + pads[port].pressedKeys.insert(retroKeyCode); + } else if (action == AKEY_EVENT_ACTION_UP) { + pads[port].pressedKeys.erase(retroKeyCode); + } +} + +void Input::onMotionEvent(int port, int motionSource, float xAxis, float yAxis) { + switch (motionSource) { + case Input::MOTION_SOURCE_DPAD: + pads[port].dpadXAxis = (int) round(xAxis); + pads[port].dpadYAxis = (int) round(yAxis); + break; + + case Input::MOTION_SOURCE_ANALOG_LEFT: + pads[port].joypadLeftXAxis = xAxis; + pads[port].joypadLeftYAxis = yAxis; + break; + + case Input::MOTION_SOURCE_ANALOG_RIGHT: + pads[port].joypadRightXAxis = xAxis; + pads[port].joypadRightYAxis = yAxis; + break; + + case Input::MOTION_SOURCE_POINTER: + pads[port].pointerScreenXAxis = xAxis; + pads[port].pointerScreenYAxis = yAxis; + break; + } +} + +template +bool Input::anyPressed(unsigned int port, unsigned int id, T &... args) const { + return anyPressed(port, id) || anyPressed(port, args...); +} + +bool Input::anyPressed(unsigned int port, unsigned int id) const { + return pads[port].pressedKeys.count(id) > 0; +} + +} //namespace libretrodroid diff --git a/libretrodroid/src/main/cpp/input.h b/libretrodroid/src/main/cpp/input.h new file mode 100644 index 000000000..9d7c44b1e --- /dev/null +++ b/libretrodroid/src/main/cpp/input.h @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_INPUT_H +#define LIBRETRODROID_INPUT_H + +#include +#include +#include + +namespace libretrodroid { + +class Input { + +private: + struct GamePadState { + std::unordered_set pressedKeys; + + int dpadXAxis = 0; + int dpadYAxis = 0; + float joypadLeftXAxis = 0; + float joypadLeftYAxis = 0; + float joypadRightXAxis = 0; + float joypadRightYAxis = 0; + float pointerScreenXAxis = -1; + float pointerScreenYAxis = -1; + }; + +public: + static constexpr int MOTION_SOURCE_DPAD = 0; + static constexpr int MOTION_SOURCE_ANALOG_LEFT = 1; + static constexpr int MOTION_SOURCE_ANALOG_RIGHT = 2; + static constexpr int MOTION_SOURCE_POINTER = 3; + static constexpr int MAX_RANGE_MOTION = 0x7fff; + + static constexpr int RETRO_DEVICE_ID_JOYPAD_UP_LEFT = 50; + static constexpr int RETRO_DEVICE_ID_JOYPAD_UP_RIGHT = 51; + static constexpr int RETRO_DEVICE_ID_JOYPAD_DOWN_LEFT = 52; + static constexpr int RETRO_DEVICE_ID_JOYPAD_DOWN_RIGHT = 53; + + int16_t getInputState(unsigned port, unsigned device, unsigned index, unsigned id); + + void onKeyEvent(unsigned int port, int action, int keyCode); + void onMotionEvent(int port, int motionSource, float xAxis, float yAxis); + +private: + const int UNKNOWN_KEY = -1; + + template + bool anyPressed(unsigned int port, unsigned int id, T&... args) const; + bool anyPressed(unsigned int port, unsigned int id) const; + int convertAndroidToLibretroKey(int keyCode) const; + + GamePadState pads[4]; +}; + +} + +#endif //LIBRETRODROID_INPUT_H diff --git a/libretrodroid/src/main/cpp/libretro/libretro-common b/libretrodroid/src/main/cpp/libretro/libretro-common new file mode 160000 index 000000000..b0c348ea5 --- /dev/null +++ b/libretrodroid/src/main/cpp/libretro/libretro-common @@ -0,0 +1 @@ +Subproject commit b0c348ea5543c4d7fb0bc479258aa6988b20c0c9 diff --git a/libretrodroid/src/main/cpp/libretrodroid.cpp b/libretrodroid/src/main/cpp/libretrodroid.cpp new file mode 100644 index 000000000..08bcf4844 --- /dev/null +++ b/libretrodroid/src/main/cpp/libretrodroid.cpp @@ -0,0 +1,658 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include + +#include + +#include +#include +#include +#include + +#include "libretrodroid.h" +#include "utils/libretrodroidexception.h" +#include "log.h" +#include "core.h" +#include "audio.h" +#include "video.h" +#include "renderers/renderer.h" +#include "fpssync.h" +#include "input.h" +#include "rumble.h" +#include "shadermanager.h" +#include "utils/javautils.h" +#include "environment.h" +#include "renderers/es3/framebufferrenderer.h" +#include "renderers/es2/imagerendereres2.h" +#include "renderers/es3/imagerendereres3.h" +#include "utils/utils.h" +#include "utils/rect.h" +#include "errorcodes.h" +#include "vfs/vfs.h" + +namespace libretrodroid { + +uintptr_t LibretroDroid::callback_get_current_framebuffer() { + return LibretroDroid::getInstance().handleGetCurrentFrameBuffer(); +} + +void LibretroDroid::callback_hw_video_refresh( + const void *data, + unsigned width, + unsigned height, + size_t pitch +) { + LOGD("hw video refresh callback called %i %i", width, height); + LibretroDroid::getInstance().handleVideoRefresh(data, width, height, pitch); +} + +void LibretroDroid::callback_audio_sample(int16_t left, int16_t right) { + LOGE("callback audio sample (left, right) has been called"); +} + +size_t LibretroDroid::callback_set_audio_sample_batch(const int16_t *data, size_t frames) { + return LibretroDroid::getInstance().handleAudioCallback(data, frames); +} + +void LibretroDroid::callback_retro_set_input_poll() { + // Do nothing in here... +} + +int16_t LibretroDroid::callback_set_input_state( + unsigned int port, + unsigned int device, + unsigned int index, + unsigned int id +) { + return LibretroDroid::getInstance().handleSetInputState(port, device, index, id); +} + +void LibretroDroid::updateAudioSampleRateMultiplier() { + if (audio) { + audio->setPlaybackSpeed(frameSpeed); + } +} + +// TODO... Do we really need this? +void LibretroDroid::resetGlobalVariables() { + core = nullptr; + audio = nullptr; + video = nullptr; + fpsSync = nullptr; + input = nullptr; + rumble = nullptr; +} + +int LibretroDroid::availableDisks() { + return Environment::getInstance().getRetroDiskControlCallback() != nullptr + ? Environment::getInstance().getRetroDiskControlCallback()->get_num_images() + : 0; +} + +int LibretroDroid::currentDisk() { + return Environment::getInstance().getRetroDiskControlCallback() != nullptr + ? Environment::getInstance().getRetroDiskControlCallback()->get_image_index() + : 0; +} + +void LibretroDroid::changeDisk(unsigned int index) { + if (Environment::getInstance().getRetroDiskControlCallback() == nullptr) { + LOGE("Cannot swap disk. This platform does not support it."); + return; + } + + if (index < 0 || index >= Environment::getInstance().getRetroDiskControlCallback()->get_num_images()) { + LOGE("Requested image index is not valid."); + return; + } + + if (Environment::getInstance().getRetroDiskControlCallback()->get_image_index() != index) { + Environment::getInstance().getRetroDiskControlCallback()->set_eject_state(true); + Environment::getInstance().getRetroDiskControlCallback()->set_image_index((unsigned) index); + Environment::getInstance().getRetroDiskControlCallback()->set_eject_state(false); + } +} + +void LibretroDroid::updateVariable(const Variable& variable) { + Environment::getInstance().updateVariable(variable.key, variable.value); +} + +std::vector LibretroDroid::getVariables() { + return Environment::getInstance().getVariables(); +} + +std::vector> LibretroDroid::getControllers() { + return Environment::getInstance().getControllers(); +} + +void LibretroDroid::setControllerType(unsigned int port, unsigned int type) { + core->retro_set_controller_port_device(port, type); +} + +bool LibretroDroid::unserializeState(int8_t *data, size_t size) { + std::lock_guard lock(coreLock); + + return core->retro_unserialize(data, size); +} + +JNIEXPORT jboolean JNICALL LibretroDroid::unserializeSRAM(int8_t* data, size_t size) { + std::lock_guard lock(coreLock); + + size_t sramSize = core->retro_get_memory_size(RETRO_MEMORY_SAVE_RAM); + void *sramState = core->retro_get_memory_data(RETRO_MEMORY_SAVE_RAM); + + if (sramState == nullptr) { + LOGE("Cannot load SRAM: nullptr in retro_get_memory_data"); + return false; + } + + if (size > sramSize) { + LOGE("Cannot load SRAM: size mismatch"); + return false; + } + + memcpy(sramState, data, size); + + return true; +} + +std::pair LibretroDroid::serializeSRAM() { + std::lock_guard lock(coreLock); + + size_t size = core->retro_get_memory_size(RETRO_MEMORY_SAVE_RAM); + auto* data = new int8_t[size]; + memcpy(data, (int8_t*) core->retro_get_memory_data(RETRO_MEMORY_SAVE_RAM), size); + + return std::pair(data, size); +} + +void LibretroDroid::onSurfaceChanged(unsigned int width, unsigned int height) { + LOGD("Performing libretrodroid onSurfaceChanged"); + video->updateScreenSize(width, height); +} + +void LibretroDroid::onSurfaceCreated() { + LOGD("Performing libretrodroid onSurfaceCreated"); + + struct retro_system_av_info system_av_info {}; + core->retro_get_system_av_info(&system_av_info); + + video = nullptr; + + Video::RenderingOptions renderingOptions { + Environment::getInstance().isUseHwAcceleration(), + system_av_info.geometry.base_width, + system_av_info.geometry.base_height, + Environment::getInstance().isUseDepth(), + Environment::getInstance().isUseStencil(), + openglESVersion, + Environment::getInstance().getPixelFormat() + }; + + auto newVideo = new Video( + renderingOptions, + fragmentShaderConfig, + Environment::getInstance().isBottomLeftOrigin(), + Environment::getInstance().getScreenRotation(), + skipDuplicateFrames, + immersiveModeEnabled, + viewportRect, + immersiveModeConfig, + viewportAlignment + ); + + video = std::unique_ptr