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.
This commit is contained in:
Tom Pratt
2026-05-12 16:12:35 -07:00
parent b6d8bc299e
commit 6877e436fb
5 changed files with 133 additions and 6 deletions

View File

@@ -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<Unit>(Channel.CONFLATED)
val notAllPlayersHaveGame = _notAllPlayersHaveGame.receiveAsFlow()
private val _dualCoreWarning = Channel<Unit>(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),

View File

@@ -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,

View File

@@ -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<GameFile>,
notAllPlayersHaveGame: Flow<Unit>,
dualCoreWarning: Flow<Unit>,
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,

View File

@@ -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)

View File

@@ -1012,6 +1012,9 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="netplay_start">Start</string>
<string name="netplay_start_warning_title">Warning</string>
<string name="netplay_start_warning_not_all_players_have_game">Not all players have the game. Do you really want to start?</string>
<string name="netplay_dual_core_warning_title">Dual Core Enabled</string>
<string name="netplay_dual_core_warning_message">Dual core during netplay is not recommended. It can cause performance issues and prevent certain games from booting.</string>
<string name="netplay_dont_warn_again">Don\'t warn again</string>
<string name="netplay_chat_label">Chat</string>
<string name="netplay_chat_send">Send</string>
<string name="netplay_message_game_changed">Game changed to %1$s</string>