diff --git a/platforms/android/app/src/main/assets/resources/GameIndex.yaml b/platforms/android/app/src/main/assets/resources/GameIndex.yaml index 9a17512626..22305453bc 100644 --- a/platforms/android/app/src/main/assets/resources/GameIndex.yaml +++ b/platforms/android/app/src/main/assets/resources/GameIndex.yaml @@ -2972,6 +2972,9 @@ SCCS-60002: SCED-50041: name: "Tekken Tag Tournament [Demo]" region: "PAL-E" + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SCED-50065: @@ -3282,6 +3285,9 @@ SCED-51111: SCED-51120: name: "Tekken 4 [Demo]" region: "PAL-E" + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SCED-51140: @@ -4834,6 +4840,9 @@ SCES-50001: name: "Tekken Tag Tournament" region: "PAL-M5" compat: 5 + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SCES-50002: @@ -5205,6 +5214,9 @@ SCES-50878: name: "Tekken 4" region: "PAL-M5" compat: 5 + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SCES-50885: @@ -7645,6 +7657,9 @@ SCKA-20045: name: "철권 4 [PlayStation 2 Big Hit Series]" name-en: "Tekken 4 [PlayStation 2 Big Hit Series]" region: "NTSC-K" + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SCKA-20046: @@ -10798,6 +10813,9 @@ SCPS-55017: name-en: "Tekken 4" region: "NTSC-J" compat: 3 + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 SCPS-55018: @@ -11085,6 +11103,9 @@ SCPS-56002: name-en: "Tekken Tag Tournament" region: "NTSC-K" compat: 5 + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SCPS-56003: @@ -11112,6 +11133,9 @@ SCPS-56006: name-en: "Tekken 4" region: "NTSC-K" compat: 5 + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 SCPS-56007: @@ -23955,6 +23979,8 @@ SLES-53518: SLES-53521: name: "Musashi - Samurai Legend" region: "PAL-M4" + gsHWFixes: + mergeSprite: 1 # Fixes ghosting. SLES-53523: name: "Gun" region: "PAL-M4" @@ -25442,6 +25468,8 @@ SLES-53955: SLES-53956: name: "Aeon Flux" region: "PAL-E" + speedHacks: + eeCycleRate: 1 # Improves performance. compat: 5 SLES-53957: name: "Namco Museum - 50th Anniversary" @@ -26089,6 +26117,8 @@ SLES-54168: SLES-54169: name: "Aeon Flux" region: "PAL-M4" + speedHacks: + eeCycleRate: 1 # Improves performance. SLES-54170: name: "Jaws Unleashed" region: "PAL-M5" @@ -27493,7 +27523,7 @@ SLES-54586: name: "Ar tonelico - Melody of Elemia" region: "PAL-E" gsHWFixes: - roundSprite: 1 # Fixes vertical lines in FMVs, character portraits and other sprites. + roundSprite: 2 # Fixes vertical lines in FMVs, character portraits and other sprites. SLES-54587: name: "Raw Danger" region: "PAL-E" @@ -55077,6 +55107,9 @@ SLPS-20015: name-en: "Tekken Tag Tournament" region: "NTSC-J" compat: 5 + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SLPS-20016: @@ -58185,6 +58218,9 @@ SLPS-25100: name-en: "Tekken 4" region: "NTSC-J" compat: 5 + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SLPS-25101: @@ -61251,7 +61287,7 @@ SLPS-25604: name-en: "Ar tonelico - Sekai no Owari de Utai Tsuzukeru Shoujo" region: "NTSC-J" gsHWFixes: - roundSprite: 1 # Fixes vertical lines in FMVs, character portraits and other sprites. + roundSprite: 2 # Fixes vertical lines in FMVs, character portraits and other sprites. SLPS-25605: name: "NEOGEOオンラインコレクション Vol.3 THE KING OF FIGHTERS ~オロチ編~ [通常版]" name-sort: "ねおじおおんらいんこれくしょん Vol. 3 ざ きんぐ おぶ ふぁいたーず おろちへん [つうじょうばん]" @@ -63672,6 +63708,9 @@ SLPS-71501: name-sort: "てっけんたっぐとーなめんと [MEGA HITS!]" name-en: "Tekken Tag Tournament [MEGA HITS!]" region: "NTSC-J" + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SLPS-71502: @@ -63768,6 +63807,9 @@ SLPS-73104: name-sort: "てっけんたっぐとーなめんと [PlayStation2 the Best]" name-en: "Tekken Tag Tournament [PlayStation2 the Best]" region: "NTSC-J" + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SLPS-73105: @@ -63904,6 +63946,9 @@ SLPS-73209: name-sort: "てっけん4 [PlayStation2 the Best]" name-en: "Tekken 4 [PlayStation2 the Best]" region: "NTSC-J" + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SLPS-73210: @@ -64296,7 +64341,7 @@ SLPS-73249: name-en: "Ar tonelico - Sekai no Owari de Utai Tsuzukeru Shoujo [PlayStation2 the Best]" region: "NTSC-J" gsHWFixes: - roundSprite: 1 # Fixes vertical lines in FMVs, character portraits and other sprites. + roundSprite: 2 # Fixes vertical lines in FMVs, character portraits and other sprites. SLPS-73250: name: "エースコンバット・ゼロ・ザ・ベルカン・ウォー [PlayStation2 the Best]" name-sort: "えーすこんばっと・ぜろ・ざ・べるかん・うぉー [PlayStation2 the Best]" @@ -64673,6 +64718,9 @@ SLUS-20001: name: "Tekken Tag Tournament" region: "NTSC-U" compat: 5 + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SLUS-20002: @@ -66078,6 +66126,9 @@ SLUS-20328: name: "Tekken 4" region: "NTSC-U" compat: 5 + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SLUS-20329: @@ -69698,6 +69749,8 @@ SLUS-20983: name: "Musashi Samurai Legend" region: "NTSC-U" compat: 5 + gsHWFixes: + mergeSprite: 1 # Fixes ghosting. SLUS-20984: name: "Resident Evil Outbreak - File #2" region: "NTSC-U" @@ -71080,6 +71133,8 @@ SLUS-21204: SLUS-21205: name: "Aeon Flux" region: "NTSC-U" + speedHacks: + eeCycleRate: 1 # Improves performance. compat: 5 SLUS-21206: name: "Shining Force Neo" @@ -72811,7 +72866,7 @@ SLUS-21445: region: "NTSC-U" compat: 5 gsHWFixes: - roundSprite: 1 # Fixes vertical lines in FMVs, character portraits and other sprites. + roundSprite: 2 # Fixes vertical lines in FMVs, character portraits and other sprites. SLUS-21446: name: "Family Feud" region: "NTSC-U" @@ -75493,6 +75548,9 @@ SLUS-28010: SLUS-28012: name: "Tekken 4 [Trade Demo]" region: "NTSC-U" + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SLUS-28013: @@ -75787,6 +75845,9 @@ SLUS-29033: SLUS-29034: name: "Tekken 4 [Regular Demo]" region: "NTSC-U" + clampModes: + eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove. + vu1ClampMode: 2 # Fixes broken effects. gsHWFixes: alignSprite: 1 # Fixes vertical lines. SLUS-29035: diff --git a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt index 53aed8b7ba..737440a07d 100644 --- a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt +++ b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt @@ -222,6 +222,8 @@ val EN: Map = mapOf( "app.theme.system" to "System", "app.theme.light" to "Light", "app.theme.dark" to "Dark", + "app.theme.black" to "Black", + "app.theme.oled" to "OLED", "app.bootLogo" to "Boot animation", "app.bootLogo.desc" to "Play the ARMSX2 intro video when the app starts.", "app.toolbarPosition" to "Library toolbar", @@ -1043,6 +1045,7 @@ val EN: Map = mapOf( "setup.step.appData.title" to "App Data Folder", "setup.step.bios.description" to "Pick a folder of PS2 BIOS files to start playing — every BIOS inside is added.", "setup.step.bios.title" to "BIOS Location", + "bios.boot.title" to "Boot BIOS", "setup.step.rom.description" to "Pick one or more folders where you keep your PS2 games. Supports ISO, CHD, BIN, IMG, MDF, and GZ.", "setup.step.rom.title" to "ROM Location", "setup.storageChooser.customFolder" to "Custom Folder…", diff --git a/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt b/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt index 354724dad2..7c56219b96 100644 --- a/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt +++ b/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt @@ -752,6 +752,13 @@ object ControllerMappings { // consume (B = exit) — can be captured. Normal nav resumes when it clears. val padCapturing = mutableStateOf(false) + /** A pad-button capture in progress from the Controls screen (ControllerManagerScreen). While + * set, MainActivityRuntime.dispatchKeyEvent binds the next pressed keycode via this lambda — + * handled there, like [captureHotkey], instead of a focus-stealing AlertDialog whose separate + * window swallowed controller keys before Compose's onPreviewKeyEvent could see them (the + * 2.6.0 "can't remap buttons" bug). The lambda binds the key and returns true. */ + val capturePadAction = mutableStateOf<((Int) -> Boolean)?>(null) + // Capture bridge: the Hotkeys tab calls [beginHotkeyCapture]; the next // button(s) seen by MainActivityRuntime.dispatchKeyEvent are bound to it. Press one button // for a single bind, or two together for a combo. Observed for UI feedback. diff --git a/platforms/android/app/src/main/java/com/armsx2/navigation/NavigationDrawer.kt b/platforms/android/app/src/main/java/com/armsx2/navigation/NavigationDrawer.kt index eb37018eed..59da29b48a 100644 --- a/platforms/android/app/src/main/java/com/armsx2/navigation/NavigationDrawer.kt +++ b/platforms/android/app/src/main/java/com/armsx2/navigation/NavigationDrawer.kt @@ -64,8 +64,11 @@ private val TrophyGold = Color(0xFFFFC93C) private data class DrawerItem( val titleKey: String, val glyph: String, - val destination: AppRoute, + // A nav destination OR an action (onAction). Action rows (e.g. Boot BIOS) run onAction and + // close the drawer instead of navigating to a screen. + val destination: AppRoute? = null, val iconRes: Int? = null, + val onAction: (() -> Unit)? = null, ) @Composable @@ -111,17 +114,20 @@ fun NavigationDrawer( border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.55f)), shadowElevation = 18.dp, ) { - DrawerContent(selected, onNavigate) + DrawerContent(selected, onNavigate, onDismiss) } } } } @Composable -private fun DrawerContent(selected: AppRoute, onNavigate: (AppRoute) -> Unit) { +private fun DrawerContent(selected: AppRoute, onNavigate: (AppRoute) -> Unit, onDismiss: () -> Unit) { val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE val primary = listOf( DrawerItem("games.section.library", "▦", AppRoute.Home), + // Boot straight into the PS2 system BIOS with no disc — distinct from "BIOS Location" + // below, which only points the emulator at your BIOS file. + DrawerItem("bios.boot.title", "▶", onAction = { MainActivityRuntime.startBios(); onDismiss() }), DrawerItem("ra.title", "★", AppRoute.Achievements, iconRes = com.armsx2.R.drawable.ic_trophy), DrawerItem("action.settings", "⚙", AppRoute.Settings()), ) @@ -175,12 +181,12 @@ private fun DrawerSection( ) items.forEachIndexed { index, item -> DrawerRow( - controllerId = "drawer.${item.destination::class.simpleName}", + controllerId = "drawer.${item.destination?.let { it::class.simpleName } ?: item.titleKey}", title = str(item.titleKey), glyph = item.glyph, iconRes = item.iconRes, - selected = sameDestination(selected, item.destination), - onClick = { onNavigate(item.destination) }, + selected = item.destination != null && sameDestination(selected, item.destination), + onClick = { item.onAction?.invoke() ?: item.destination?.let(onNavigate) }, ) } } diff --git a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt index dfb2b70117..2bec357b2a 100644 --- a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt +++ b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt @@ -1515,7 +1515,9 @@ open class MainActivityRuntime : ComponentActivity() { resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK != Configuration.UI_MODE_NIGHT_YES com.armsx2.ui.theme.ThemeMode.Light -> true - com.armsx2.ui.theme.ThemeMode.Dark -> false + com.armsx2.ui.theme.ThemeMode.Dark, + com.armsx2.ui.theme.ThemeMode.Black, + com.armsx2.ui.theme.ThemeMode.Oled -> false } WindowInsetsControllerCompat(window, window.decorView).let { controller -> controller.show(WindowInsetsCompat.Type.systemBars()) @@ -1585,7 +1587,9 @@ open class MainActivityRuntime : ComponentActivity() { val darkTheme = when (com.armsx2.ui.theme.ThemePreferences.mode.value) { com.armsx2.ui.theme.ThemeMode.System -> androidx.compose.foundation.isSystemInDarkTheme() com.armsx2.ui.theme.ThemeMode.Light -> false - com.armsx2.ui.theme.ThemeMode.Dark -> true + com.armsx2.ui.theme.ThemeMode.Dark, + com.armsx2.ui.theme.ThemeMode.Black, + com.armsx2.ui.theme.ThemeMode.Oled -> true } androidx.compose.runtime.SideEffect { applySystemBarTheme(darkTheme = darkTheme, showSystemBars = showSystemBars) @@ -1901,6 +1905,18 @@ open class MainActivityRuntime : ComponentActivity() { } return true // swallow down + up while capturing } + // Pad-button capture (Controls screen): bind here — like the hotkey capture above — + // instead of via Compose's onPreviewKeyEvent, because the bind prompt is no longer a + // focus-stealing dialog (a Dialog window would swallow controller keys before Compose or + // this handler saw them — the 2.6.0 "can't remap buttons" bug). Bind the first real key + // press; swallow down+up so nav (B = exit, A = confirm) can't fire mid-capture. + val padCapture = ControllerMappings.capturePadAction.value + if (padCapture != null) { + if (kc != KeyEvent.KEYCODE_UNKNOWN && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + padCapture(kc) + } + return true + } // Pad-button capture (Pad tab): let every key fall through to Compose's // onPreviewKeyEvent so ANY button binds — without this the overlay nav // below eats B (exit), A (confirm), Y, D-pad and L1/R1 before they reach diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/InGameOverlay.kt b/platforms/android/app/src/main/java/com/armsx2/ui/InGameOverlay.kt index cd58522822..54ad13dec0 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/InGameOverlay.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/InGameOverlay.kt @@ -15,6 +15,11 @@ object InGameOverlay { val hardcoreOn = mutableStateOf(false) val frameLimitOn = mutableStateOf(true) + /** Per-tab scroll offset (px) of the in-game pause menu, retained across menu open/close so + * reopening a tab — especially the long Fixes list — returns to where you were instead of + * snapping back to the top. Keyed by EmulationMenuTab.name to avoid coupling to that enum. */ + val menuTabScroll = HashMap() + fun saveSettings(updated: Settings) { val previous = settingsState.value settingsState.value = updated diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerScreen.kt index ae3c002d4d..40c1dcfaa0 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -88,14 +87,11 @@ fun ControllerManagerScreen(onBack: () -> Unit, viewModel: ControllerManagerView } } - state.capturingAction?.let { action -> - AlertDialog( - onDismissRequest = viewModel::cancelCapture, - title = { Text("${str("pad.action.bind")}: ${action.label}") }, - text = { Text(str("hotkeys.capturePrompt")) }, - confirmButton = { TextButton(onClick = viewModel::cancelCapture) { Text(str("action.cancel")) } }, - ) - } + // No capture dialog. A Compose AlertDialog is a separate focused WINDOW that swallowed all + // controller key events before either this Activity's dispatchKeyEvent or the main Compose + // tree could see them — so no button ever bound (the 2.6.0 "can't remap buttons" bug). The + // bind prompt now shows inline on the row (highlight + "press a button" text, like hotkeys), + // and the key is captured in MainActivityRuntime.dispatchKeyEvent via capturePadAction. } @Composable @@ -163,7 +159,7 @@ private fun ControllerBindings(state: ControllerManagerUiState, viewModel: Contr label = action.label, binding = ControllerMappings.labelForKey(ControllerMappings.physicalFor(action, state.player)), capturing = state.capturingAction == action, - onBind = { viewModel.beginCapture(action) }, + onBind = { if (state.capturingAction == action) viewModel.cancelCapture() else viewModel.beginCapture(action) }, onClear = { viewModel.clear(action) }, ) } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerViewModel.kt b/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerViewModel.kt index bbc4d6452d..26a4636585 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerViewModel.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerViewModel.kt @@ -41,6 +41,9 @@ class ControllerManagerViewModel(application: Application) : AndroidViewModel(ap fun beginCapture(action: ControllerMappings.Action) { ControllerMappings.padCapturing.value = true + // Bind via the Activity's dispatchKeyEvent (see ControllerMappings.capturePadAction) so any + // button binds even though the bind prompt is no longer a focus-stealing dialog. + ControllerMappings.capturePadAction.value = { kc -> captureKey(kc) } state.value = state.value.copy(capturingAction = action) } @@ -48,12 +51,14 @@ class ControllerManagerViewModel(application: Application) : AndroidViewModel(ap val action = state.value.capturingAction ?: return false ControllerMappings.bind(action, keyCode, state.value.player) ControllerMappings.padCapturing.value = false + ControllerMappings.capturePadAction.value = null state.value = state.value.copy(capturingAction = null, tick = state.value.tick + 1) return true } fun cancelCapture() { ControllerMappings.padCapturing.value = false + ControllerMappings.capturePadAction.value = null state.value = state.value.copy(capturingAction = null) } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt index 3695e8d80e..d0c716e3ec 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt @@ -205,7 +205,16 @@ private fun MenuPage( modifier: Modifier, ) { val tabScrollStates = remember { - EmulationMenuTab.entries.associateWith { ScrollState(initial = 0) } + EmulationMenuTab.entries.associateWith { + ScrollState(initial = InGameOverlay.menuTabScroll[it.name] ?: 0) + } + } + // Remember each tab's scroll offset when the menu closes so reopening a tab (especially + // the long Fixes list) returns to where you were instead of snapping back to the top. + androidx.compose.runtime.DisposableEffect(Unit) { + onDispose { + tabScrollStates.forEach { (tab, ss) -> InGameOverlay.menuTabScroll[tab.name] = ss.value } + } } val scrollState = tabScrollStates.getValue(state.tab) // Provide the pane's scroll state to the settings widgets so the Fixes pane's @@ -432,6 +441,12 @@ private fun MenuHeader(compact: Boolean, hardcore: Boolean, richPresence: String Spacer(Modifier.width(8.dp)) HardcoreBadge() } + // File-type chip after the HC badge (ISO / CHD / …), mirroring the library + // list view so the pause/RA header shows the same at-a-glance file info. + game?.let { g -> + Spacer(Modifier.width(6.dp)) + com.armsx2.ui.common.StatusChip(g.extension.ifBlank { g.platform.key.uppercase() }) + } } if (!game?.serial.isNullOrBlank()) { Spacer(Modifier.height(2.dp)) diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuViewModel.kt b/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuViewModel.kt index f5ff90ec1f..9f57e04201 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuViewModel.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuViewModel.kt @@ -187,7 +187,10 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic } fun setUpscale(value: Float) { - val normalized = value.coerceIn(1f, 8f) + // Allow sub-native (0.25x–0.75x, issue #207) — the in-game menu offers them via + // UPSCALE_OPTIONS, so don't clamp them up to Native like the old 1f floor did (that + // made every below-Native pick silently apply as Native). Matches the settings tab. + val normalized = value.coerceIn(0.25f, 8f) updateSettings { it.copy(upscaleFloat = normalized) } MainActivityRuntime.upscale.value = normalized NativeApp.renderUpscalemultiplier(normalized) diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt index a70e93a6c9..ad542e07b5 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt @@ -73,17 +73,24 @@ fun AppTab() { SegmentedRow( label = str("app.theme"), - options = listOf(str("app.theme.system"), str("app.theme.light"), str("app.theme.dark")), + options = listOf( + str("app.theme.system"), str("app.theme.light"), str("app.theme.dark"), + str("app.theme.black"), str("app.theme.oled"), + ), selectedIndex = when (ThemePreferences.mode.value) { ThemeMode.System -> 0 ThemeMode.Light -> 1 ThemeMode.Dark -> 2 + ThemeMode.Black -> 3 + ThemeMode.Oled -> 4 }, onChange = { index -> ThemePreferences.set( when (index) { 1 -> ThemeMode.Light 2 -> ThemeMode.Dark + 3 -> ThemeMode.Black + 4 -> ThemeMode.Oled else -> ThemeMode.System }, ) diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsScreen.kt index a1bdf747c4..ef7a4f8b3a 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsScreen.kt @@ -67,6 +67,13 @@ import com.armsx2.ui.settings.LocalSettingsScrollState private data class SettingsSection(val category: SettingsCategory, val titleKey: String, val glyph: String) +/** Retains the settings page's scroll offset across close/reopen. The selected category already + * persists in the view-model, so restoring this one offset returns you to where you were (esp. + * useful in long lists like Fixes) instead of snapping to the top every time. */ +private object SettingsScrollMemory { + var lastOffset = 0 +} + @Composable fun SettingsScreen( initialCategory: SettingsCategory, @@ -91,7 +98,10 @@ fun SettingsScreen( pendingJump = label } } - val screenScroll = rememberScrollState() + val screenScroll = rememberScrollState(initial = SettingsScrollMemory.lastOffset) + androidx.compose.runtime.DisposableEffect(Unit) { + onDispose { SettingsScrollMemory.lastOffset = screenScroll.value } + } LaunchedEffect(initialCategory, game?.uri) { viewModel.load(initialCategory, game) } // When the controller focus returns to the category-chip row (the top-most // navigable element), snap the whole page back to the top so the title bar + diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/theme/Theme.kt b/platforms/android/app/src/main/java/com/armsx2/ui/theme/Theme.kt index 57218319c3..40b7a4f96d 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/theme/Theme.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/theme/Theme.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.graphics.Color import com.armsx2.runtime.MainActivityRuntime import androidx.core.content.edit -enum class ThemeMode { System, Dark, Light } +enum class ThemeMode { System, Dark, Light, Black, Oled } object ThemePreferences { private const val PreferenceKey = "ui.theme.mode" @@ -21,6 +21,8 @@ object ThemePreferences { mode.value = when (MainActivityRuntime.prefs.getString(PreferenceKey, ThemeMode.System.name)) { ThemeMode.System.name -> ThemeMode.System ThemeMode.Light.name -> ThemeMode.Light + ThemeMode.Black.name -> ThemeMode.Black + ThemeMode.Oled.name -> ThemeMode.Oled else -> ThemeMode.Dark } } @@ -139,15 +141,43 @@ private val DayScheme = lightColorScheme( surfaceTint = Color.Transparent, ) +// Neutral dark schemes derived from Night — same accents, but black/neutral-grey backgrounds and +// selection highlights instead of Night's blue-tinted ones (users asked for a black, not-blue UI). +// Black = a slightly-lifted neutral dark; Oled = true #000000 for OLED panels (deepest black, less +// power). Only surfaces/containers/outlines change; the primary accent stays for contrast/usability. +private val BlackScheme = NightScheme.copy( + background = Color(0xFF0B0B0B), + surface = Color(0xFF111111), + surfaceVariant = Color(0xFF1B1B1B), + primaryContainer = Color(0xFF242424), + onPrimaryContainer = Color(0xFFEAEAEA), + secondaryContainer = Color(0xFF1A1A1A), + outline = Color(0xFF333333), + outlineVariant = Color(0xFF242424), +) + +private val OledScheme = NightScheme.copy( + background = Color.Black, + surface = Color.Black, + surfaceVariant = Color(0xFF0E0E0E), + primaryContainer = Color(0xFF1A1A1A), + onPrimaryContainer = Color(0xFFEAEAEA), + secondaryContainer = Color(0xFF121212), + outline = Color(0xFF262626), + outlineVariant = Color(0xFF161616), +) + @Composable fun Armsx2Theme(content: @Composable () -> Unit) { - val dark = when (ThemePreferences.mode.value) { - ThemeMode.System -> isSystemInDarkTheme() - ThemeMode.Dark -> true - ThemeMode.Light -> false + val scheme = when (ThemePreferences.mode.value) { + ThemeMode.System -> if (isSystemInDarkTheme()) NightScheme else DayScheme + ThemeMode.Dark -> NightScheme + ThemeMode.Light -> DayScheme + ThemeMode.Black -> BlackScheme + ThemeMode.Oled -> OledScheme } MaterialTheme( - colorScheme = if (dark) NightScheme else DayScheme, + colorScheme = scheme, typography = ArmsTypography, content = content, ) diff --git a/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java b/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java index a95d112017..9a3e1ab5cc 100644 --- a/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java +++ b/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java @@ -336,21 +336,35 @@ public class NativeApp { // Uses MediaPlayer, not SoundPool: SoundPool decoded/resampled the 44.1 kHz // stereo PCM oddly and it came out "weird". MediaPlayer plays the .wav straight, // matching desktop PCSX2. One short-lived player per shot, released on complete. + // Strong references to the currently-playing sound players. Without this the + // MediaPlayer below is a pure local; once start() returns and the worker thread + // exits, nothing roots it, so the GC (very active under a running emulator) could + // finalize and release it MID-PLAYBACK — that's why unlock sounds dropped at random + // with no error logged. Held from before start() until the completion/error callback. + private static final java.util.Set sActiveSounds = + java.util.Collections.synchronizedSet(new java.util.HashSet<>()); + public static void playSound(String path) { if (path == null || path.isEmpty()) return; + // Cap concurrent players — a burst of simultaneous unlocks (combo/milestone) could + // otherwise exhaust the device's MediaPlayer/codec pool and make start() no-op. + if (sActiveSounds.size() >= 4) return; new Thread(() -> { + android.media.MediaPlayer mp = null; try { - android.media.MediaPlayer mp = new android.media.MediaPlayer(); + mp = new android.media.MediaPlayer(); mp.setAudioAttributes(new android.media.AudioAttributes.Builder() .setUsage(android.media.AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION) .build()); mp.setDataSource(path); - mp.setOnCompletionListener(m -> { try { m.release(); } catch (Throwable ignore) {} }); - mp.setOnErrorListener((m, what, extra) -> { try { m.release(); } catch (Throwable ignore) {} return true; }); + mp.setOnCompletionListener(m -> { sActiveSounds.remove(m); try { m.release(); } catch (Throwable ignore) {} }); + mp.setOnErrorListener((m, what, extra) -> { sActiveSounds.remove(m); try { m.release(); } catch (Throwable ignore) {} return true; }); + sActiveSounds.add(mp); mp.prepare(); mp.start(); } catch (Throwable t) { + if (mp != null) { sActiveSounds.remove(mp); try { mp.release(); } catch (Throwable ignore) {} } android.util.Log.e("ARMSX2", "playSound failed: " + path, t); } }, "armsx2-ra-sound").start();