From 6877e436fbf1511bea91eeef569b94d0354771fc Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Tue, 12 May 2026 16:12:35 -0700 Subject: [PATCH] Android: Dual core warning when hosting netplay Using dual core during netplay seems sufficiently detrimental that its worth showing this warning message and the option to turn dual core off when starting netplay. --- .../netplay/model/NetplayViewModel.kt | 30 +++++- .../features/netplay/ui/NetplayActivity.kt | 3 + .../features/netplay/ui/NetplayScreen.kt | 100 +++++++++++++++++- .../features/settings/model/BooleanSetting.kt | 3 +- .../app/src/main/res/values/strings.xml | 3 + 5 files changed, 133 insertions(+), 6 deletions(-) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt index adea1a13a5..0764448824 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt @@ -11,15 +11,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.dolphinemu.dolphinemu.features.netplay.NetplaySession +import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting import org.dolphinemu.dolphinemu.features.settings.model.IntSetting import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig import org.dolphinemu.dolphinemu.features.settings.model.StringSetting @@ -104,18 +105,37 @@ class NetplayViewModel( private val _notAllPlayersHaveGame = Channel(Channel.CONFLATED) val notAllPlayersHaveGame = _notAllPlayersHaveGame.receiveAsFlow() + private val _dualCoreWarning = Channel(Channel.CONFLATED) + val dualCoreWarning = _dualCoreWarning.receiveAsFlow() + fun startGame() { - if (netplaySession.doAllPlayersHaveGame()) { - netplaySession.startGame() - } else { + if (!netplaySession.doAllPlayersHaveGame()) { _notAllPlayersHaveGame.trySend(Unit) + return } + + if (BooleanSetting.MAIN_CPU_THREAD.boolean && + !BooleanSetting.NETPLAY_SKIP_DUAL_CORE_WARNING.boolean + ) { + _dualCoreWarning.trySend(Unit) + return + } + + confirmStartGame() } fun confirmStartGame() { netplaySession.startGame() } + fun setDualCoreEnabled(enabled: Boolean) { + BooleanSetting.MAIN_CPU_THREAD.setBoolean(NativeConfig.LAYER_BASE, enabled) + } + + fun skipDualCoreWarning() { + BooleanSetting.NETPLAY_SKIP_DUAL_CORE_WARNING.setBoolean(NativeConfig.LAYER_BASE, true) + } + fun sendMessage(message: String) { val trimmedMessage = message.trim() if (trimmedMessage.isEmpty()) { @@ -176,12 +196,14 @@ class NetplayViewModel( JoinInfoType.EXTERNAL to JoinAddress.Loading, ) } + is TraversalState.Connected -> { _joinAddresses.value += mapOf( JoinInfoType.ROOM_ID to JoinAddress.Loaded(state.hostCode), JoinInfoType.EXTERNAL to JoinAddress.Loaded(state.externalAddress), ) } + is TraversalState.Failure -> { _joinAddresses.value += mapOf( JoinInfoType.ROOM_ID to JoinAddress.Unknown(retry), diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt index eb9cecec20..28e0e4ca16 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt @@ -58,6 +58,9 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { onGameSelected = viewModel::changeGame, gameFiles = viewModel.gameFiles.collectAsState().value, notAllPlayersHaveGame = viewModel.notAllPlayersHaveGame, + dualCoreWarning = viewModel.dualCoreWarning, + onSetDualCoreEnabled = viewModel::setDualCoreEnabled, + onSkipDualCoreWarning = viewModel::skipDualCoreWarning, onConfirmStartGame = viewModel::confirmStartGame, players = viewModel.players.collectAsState().value, hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value, diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt index 789a3cef17..7df8ea35ee 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt @@ -5,6 +5,7 @@ package org.dolphinemu.dolphinemu.features.netplay.ui import android.content.Intent import android.content.res.Configuration import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -37,6 +38,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Share @@ -59,6 +61,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SheetValue +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -72,7 +75,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -87,7 +93,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.sp import androidx.window.core.layout.WindowSizeClass import coil.compose.AsyncImage @@ -129,6 +134,9 @@ fun NetplayScreen( onGameSelected: (GameFile) -> Unit, gameFiles: List, notAllPlayersHaveGame: Flow, + dualCoreWarning: Flow, + onSetDualCoreEnabled: (Boolean) -> Unit, + onSkipDualCoreWarning: () -> Unit, onConfirmStartGame: () -> Unit, hostInputAuthorityEnabled: Boolean, networkMode: NetworkMode, @@ -252,6 +260,11 @@ fun NetplayScreen( notAllPlayersHaveGame.collect { showNotAllPlayersHaveGame = true } } + var showDualCoreWarning by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + dualCoreWarning.collect { showDualCoreWarning = true } + } + var dismissSaveTransferProgressDialog by rememberSaveable { mutableStateOf(false) } if (saveTransferProgress == null) { dismissSaveTransferProgressDialog = false @@ -323,6 +336,21 @@ fun NetplayScreen( onDismissRequest = { showNotAllPlayersHaveGame = false }, ) } + + showDualCoreWarning -> { + DualCoreWarningDialog( + onSetDualCoreEnabled = onSetDualCoreEnabled, + onStartGame = { + showDualCoreWarning = false + onConfirmStartGame() + }, + onSkipWarning = { + showDualCoreWarning = false + onSkipDualCoreWarning() + }, + onDismiss = { showDualCoreWarning = false }, + ) + } } } } @@ -1273,6 +1301,73 @@ private fun GameDigestPlayerRow( } } +@Composable +private fun DualCoreWarningDialog( + onSetDualCoreEnabled: (Boolean) -> Unit, + onStartGame: () -> Unit, + onSkipWarning: () -> Unit, + onDismiss: () -> Unit, +) { + var dualCoreEnabled by rememberSaveable { mutableStateOf(true) } + AlertDialog( + title = { Text(stringResource(R.string.netplay_dual_core_warning_title)) }, + text = { + Column { + Text( + text = stringResource(R.string.netplay_dual_core_warning_message), + ) + val ripplePadding = 12.dp + Spacer(Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .layout { measurable, constraints -> + // Enlarge the ripple area beyond the dialog's padding + val hPx = ripplePadding.roundToPx() + val placeable = measurable.measure( + constraints.copy( + minWidth = constraints.maxWidth + hPx * 2, + maxWidth = constraints.maxWidth + hPx * 2 + ) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.place(-hPx, 0) + } + } + .clip(MaterialTheme.shapes.small) + .clickable { + dualCoreEnabled = !dualCoreEnabled + onSetDualCoreEnabled(dualCoreEnabled) + } + .padding(ripplePadding), + ) { + Text( + text = stringResource(R.string.dual_core), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Switch( + checked = dualCoreEnabled, + onCheckedChange = null, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onStartGame) { + Text(stringResource(R.string.netplay_start)) + } + }, + dismissButton = { + TextButton(onClick = onSkipWarning) { + Text(stringResource(R.string.netplay_dont_warn_again)) + } + }, + onDismissRequest = onDismiss, + ) +} + @Composable private fun NetplayMessage.color(): Color { val isDark = isSystemInDarkTheme() @@ -1361,6 +1456,9 @@ private fun PreviewNetplayScreen() { onGameSelected = {}, gameFiles = emptyList(), notAllPlayersHaveGame = emptyFlow(), + dualCoreWarning = emptyFlow(), + onSetDualCoreEnabled = {}, + onSkipDualCoreWarning = {}, onConfirmStartGame = {}, hostInputAuthorityEnabled = true, networkMode = NetworkMode.HOST_INPUT_AUTHORITY, diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt index 68c859082c..47f05f14ca 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/model/BooleanSetting.kt @@ -947,7 +947,8 @@ enum class BooleanSetting( "ProgressEnabled", false ), - NETPLAY_USE_UPNP(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "UseUPNP", false); + NETPLAY_USE_UPNP(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "UseUPNP", false), + NETPLAY_SKIP_DUAL_CORE_WARNING(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "SkipDualCoreWarning", false); override val isOverridden: Boolean get() = NativeConfig.isOverridden(file, section, key) diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 9d97169d95..a6419ec83b 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1012,6 +1012,9 @@ It can efficiently compress both junk data and encrypted Wii data. Start Warning Not all players have the game. Do you really want to start? + Dual Core Enabled + Dual core during netplay is not recommended. It can cause performance issues and prevent certain games from booting. + Don\'t warn again Chat Send Game changed to %1$s