Add ability to choose game when hosting

Also fix bottom sheets so they survive rotation
This commit is contained in:
Tom Pratt 2026-05-03 10:05:08 +02:00
parent 309090520e
commit 39d17b2faf
9 changed files with 267 additions and 14 deletions

View File

@ -141,6 +141,7 @@ dependencies {
implementation(libs.androidx.profileinstaller)
// Kotlin extensions for lifecycle components
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
@ -151,6 +152,7 @@ dependencies {
// For loading game covers from disk and GameTDB
implementation(libs.coil)
implementation(libs.coil.compose)
// For loading custom GPU drivers
implementation(libs.kotlinx.serialization.json)
@ -164,6 +166,7 @@ dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material.icons)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.androidx.compose.ui)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.tooling.preview)

View File

@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress
import org.dolphinemu.dolphinemu.model.GameFile
import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage
import org.dolphinemu.dolphinemu.features.netplay.model.Player
import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress
@ -149,6 +150,8 @@ class NetplaySession(
fun adjustPadBufferSize(buffer: Int) = nativeAdjustPadBufferSize(buffer)
fun changeGame(gameFile: GameFile) = nativeChangeGame(gameFile)
fun startGame() = nativeStartGame()
fun consumeBootSessionData(): Long {
@ -228,6 +231,8 @@ class NetplaySession(
private external fun nativeReleaseBootSessionData(pointer: Long)
private external fun nativeChangeGame(gameFile: GameFile)
private external fun nativeStartGame()
// NetPlayUI callbacks

View File

@ -4,17 +4,22 @@ package org.dolphinemu.dolphinemu.features.netplay.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.dolphinemu.dolphinemu.features.netplay.NetplaySession
import org.dolphinemu.dolphinemu.features.settings.model.IntSetting
import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig
import org.dolphinemu.dolphinemu.features.settings.model.StringSetting
import org.dolphinemu.dolphinemu.model.GameFile
import org.dolphinemu.dolphinemu.services.GameFileCacheManager
class NetplayViewModel(
private val netplaySession: NetplaySession,
@ -41,10 +46,24 @@ class NetplayViewModel(
private val _maxBuffer = MutableStateFlow(IntSetting.NETPLAY_CLIENT_BUFFER_SIZE.int)
val maxBuffer = _maxBuffer.asStateFlow()
val gameFiles = GameFileCacheManager.getGameFiles().asFlow()
.map { it.toList() }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
GameFileCacheManager.getGameFiles().value?.toList() ?: emptyList()
)
val saveTransferProgress = netplaySession.saveTransferProgress
val gameDigestProgress = netplaySession.gameDigestProgress
init {
if (netplaySession.isHosting) {
setInitialGame()
}
}
fun startGame() {
netplaySession.startGame()
}
@ -64,6 +83,21 @@ class NetplayViewModel(
netplaySession.adjustPadBufferSize(buffer)
}
fun changeGame(gameFile: GameFile) {
StringSetting.NETPLAY_GAME.setString(NativeConfig.LAYER_BASE, gameFile.getGameId())
netplaySession.changeGame(gameFile)
}
private fun setInitialGame() {
val game = gameFiles.value
.find { it.getGameId() == StringSetting.NETPLAY_GAME.string }
?: gameFiles.value.firstOrNull()
if (game != null) {
changeGame(game)
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onCleared() {
super.onCleared()

View File

@ -9,6 +9,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.livedata.observeAsState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.flowWithLifecycle
@ -53,6 +54,8 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider {
game = viewModel.game.collectAsState().value,
isHosting = viewModel.isHosting,
onStartGame = viewModel::startGame,
onGameSelected = viewModel::changeGame,
gameFiles = viewModel.gameFiles.collectAsState().value,
players = viewModel.players.collectAsState().value,
hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value,
maxBuffer = viewModel.maxBuffer.collectAsState().value,

View File

@ -3,12 +3,14 @@
package org.dolphinemu.dolphinemu.features.netplay.ui
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@ -19,6 +21,9 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
@ -30,6 +35,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
@ -42,9 +48,10 @@ import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -55,6 +62,8 @@ 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.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@ -66,8 +75,11 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
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 coil.compose.AsyncImage
import coil.request.ImageRequest
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import org.dolphinemu.dolphinemu.R
@ -75,10 +87,12 @@ import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress
import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage
import org.dolphinemu.dolphinemu.features.netplay.model.Player
import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress
import org.dolphinemu.dolphinemu.model.GameFile
import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme
import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer
import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox
import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme
import org.dolphinemu.dolphinemu.utils.CoilUtils
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@ -91,6 +105,8 @@ fun NetplayScreen(
onSendMessage: (String) -> Unit,
game: String,
onStartGame: () -> Unit,
onGameSelected: (GameFile) -> Unit,
gameFiles: List<GameFile>,
hostInputAuthorityEnabled: Boolean,
maxBuffer: Int,
onMaxBufferChanged: (Int) -> Unit,
@ -125,12 +141,21 @@ fun NetplayScreen(
.consumeWindowInsets(innerPadding)
.padding(innerPadding)
var showChat by rememberSaveable { mutableStateOf(false) }
var showGamePicker by rememberSaveable { mutableStateOf(false) }
if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE) {
LandscapeContent(
isHosting = isHosting,
messages = messages,
onSendMessage = onSendMessage,
showChat = showChat,
onShowChatChanged = { showChat = it },
game = game,
gameFiles = gameFiles,
onGameSelected = onGameSelected,
showGamePicker = showGamePicker,
onShowGamePickerChanged = { showGamePicker = it },
players = players,
hostInputAuthorityEnabled = hostInputAuthorityEnabled,
maxBuffer = maxBuffer,
@ -142,7 +167,13 @@ fun NetplayScreen(
isHosting = isHosting,
messages = messages,
onSendMessage = onSendMessage,
showChat = showChat,
onShowChatChanged = { showChat = it },
game = game,
gameFiles = gameFiles,
onGameSelected = onGameSelected,
showGamePicker = showGamePicker,
onShowGamePickerChanged = { showGamePicker = it },
players = players,
hostInputAuthorityEnabled = hostInputAuthorityEnabled,
maxBuffer = maxBuffer,
@ -201,7 +232,13 @@ private fun PortraitContent(
isHosting: Boolean,
messages: List<NetplayMessage>,
onSendMessage: (String) -> Unit,
showChat: Boolean,
onShowChatChanged: (Boolean) -> Unit,
game: String,
gameFiles: List<GameFile>,
onGameSelected: (GameFile) -> Unit,
showGamePicker: Boolean,
onShowGamePickerChanged: (Boolean) -> Unit,
players: List<Player>,
hostInputAuthorityEnabled: Boolean,
maxBuffer: Int,
@ -215,6 +252,8 @@ private fun PortraitContent(
Chat(
messages = messages,
onSendMessage = onSendMessage,
showBottomSheet = showChat,
onShowBottomSheetChanged = onShowChatChanged,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
@ -225,10 +264,15 @@ private fun PortraitContent(
PLayersAndSettings(
game = game,
gameFiles = gameFiles,
onGameSelected = onGameSelected,
showGamePicker = showGamePicker,
onShowGamePickerChanged = onShowGamePickerChanged,
players = players,
hostInputAuthorityEnabled = hostInputAuthorityEnabled,
maxBuffer = maxBuffer,
onMaxBufferChanged = onMaxBufferChanged,
isHosting = isHosting,
modifier = Modifier
.padding(horizontal = DolphinTheme.scaffoldPadding),
)
@ -244,7 +288,13 @@ private fun LandscapeContent(
isHosting: Boolean,
messages: List<NetplayMessage>,
onSendMessage: (String) -> Unit,
showChat: Boolean,
onShowChatChanged: (Boolean) -> Unit,
game: String,
gameFiles: List<GameFile>,
onGameSelected: (GameFile) -> Unit,
showGamePicker: Boolean,
onShowGamePickerChanged: (Boolean) -> Unit,
players: List<Player>,
hostInputAuthorityEnabled: Boolean,
maxBuffer: Int,
@ -257,6 +307,8 @@ private fun LandscapeContent(
Chat(
messages = messages,
onSendMessage = onSendMessage,
showBottomSheet = showChat,
onShowBottomSheetChanged = onShowChatChanged,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
@ -270,10 +322,15 @@ private fun LandscapeContent(
) {
PLayersAndSettings(
game = game,
gameFiles = gameFiles,
onGameSelected = onGameSelected,
showGamePicker = showGamePicker,
onShowGamePickerChanged = onShowGamePickerChanged,
players = players,
hostInputAuthorityEnabled = hostInputAuthorityEnabled,
maxBuffer = maxBuffer,
onMaxBufferChanged = onMaxBufferChanged,
isHosting = isHosting,
modifier = Modifier
.padding(horizontal = DolphinTheme.scaffoldPadding)
)
@ -288,22 +345,27 @@ private fun LandscapeContent(
@Composable
private fun PLayersAndSettings(
game: String,
gameFiles: List<GameFile>,
onGameSelected: (GameFile) -> Unit,
showGamePicker: Boolean,
onShowGamePickerChanged: (Boolean) -> Unit,
players: List<Player>,
hostInputAuthorityEnabled: Boolean,
maxBuffer: Int,
onMaxBufferChanged: (Int) -> Unit,
isHosting: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
) {
OutlinedTextField(
value = game,
onValueChange = {},
label = { Text(stringResource(R.string.netplay_game_label)) },
readOnly = true,
modifier = Modifier
.fillMaxWidth()
GamePicker(
game = game,
gameFiles = gameFiles,
onGameSelected = onGameSelected,
showGamePicker = showGamePicker,
onShowGamePickerChanged = onShowGamePickerChanged,
isHosting = isHosting,
)
MenuSpacer()
@ -345,6 +407,8 @@ private fun PLayersAndSettings(
private fun Chat(
messages: List<NetplayMessage>,
onSendMessage: (String) -> Unit,
showBottomSheet: Boolean,
onShowBottomSheetChanged: (Boolean) -> Unit,
modifier: Modifier,
) {
val context = LocalContext.current
@ -361,12 +425,18 @@ private fun Chat(
draftMessage = ""
}
var showBottomSheet by remember { mutableStateOf(false) }
val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val density = LocalDensity.current
val bottomSheetState = remember {
SheetState(
skipPartiallyExpanded = true,
density = density,
initialValue = if (showBottomSheet) SheetValue.Expanded else SheetValue.Hidden,
)
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
onDismissRequest = { onShowBottomSheetChanged(false) },
sheetState = bottomSheetState,
modifier = Modifier
.statusBarsPadding()
@ -407,7 +477,7 @@ private fun Chat(
}
OutlinedBox(
onClick = { showBottomSheet = true },
onClick = { onShowBottomSheetChanged(true) },
label = { Text(stringResource(R.string.netplay_chat_label)) },
modifier = modifier
) {
@ -422,6 +492,123 @@ private fun Chat(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GamePicker(
game: String,
gameFiles: List<GameFile>,
onGameSelected: (GameFile) -> Unit,
showGamePicker: Boolean,
onShowGamePickerChanged: (Boolean) -> Unit,
isHosting: Boolean,
) {
val density = LocalDensity.current
val bottomSheetState = remember {
SheetState(
skipPartiallyExpanded = true,
density = density,
initialValue = if (showGamePicker) SheetValue.Expanded else SheetValue.Hidden,
)
}
if (showGamePicker) {
ModalBottomSheet(
onDismissRequest = { onShowGamePickerChanged(false) },
sheetState = bottomSheetState,
modifier = Modifier.statusBarsPadding()
) {
GameList(
gameFiles = gameFiles,
onGameSelected = { gameFile ->
onGameSelected(gameFile)
onShowGamePickerChanged(false)
},
contentPadding = PaddingValues(
start = DolphinTheme.scaffoldPadding,
end = DolphinTheme.scaffoldPadding,
bottom = 16.dp
),
)
}
}
Box(
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = game,
onValueChange = {},
label = { Text(stringResource(R.string.netplay_game_label)) },
readOnly = true,
modifier = Modifier.fillMaxWidth()
)
if (isHosting) {
Box(
modifier = Modifier
.matchParentSize()
.padding(top = 8.dp)
.clip(MaterialTheme.shapes.extraSmall)
.clickable { onShowGamePickerChanged(true) }
)
}
}
}
@Composable
private fun GameList(
gameFiles: List<GameFile>,
onGameSelected: (GameFile) -> Unit,
contentPadding: PaddingValues = PaddingValues(),
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(gameFiles, key = { it.getPath() }) { gameFile ->
GameGridItem(
gameFile = gameFile,
onClick = { onGameSelected(gameFile) },
)
}
}
}
@Composable
private fun GameGridItem(
gameFile: GameFile,
onClick: () -> Unit,
) {
Card(
onClick = onClick,
) {
Column {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(gameFile)
.error(R.drawable.no_banner)
.build(),
contentDescription = gameFile.getTitle(),
contentScale = ContentScale.Crop,
imageLoader = CoilUtils.imageLoader,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.7f)
)
Text(
text = gameFile.getTitle(),
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
minLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(8.dp)
)
}
}
}
/**
* A table arranged into columns sized to wrap the largest item. Except the
* first column which takes up the remaining space left by the other columns.
@ -787,6 +974,8 @@ private fun PreviewNetplayScreen() {
game = "Game name",
isHosting = true,
onStartGame = {},
onGameSelected = {},
gameFiles = emptyList(),
hostInputAuthorityEnabled = true,
maxBuffer = 10,
onMaxBufferChanged = {},

View File

@ -115,7 +115,8 @@ enum class StringSetting(
""
),
NETPLAY_ADDRESS(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Address", "127.0.0.1"),
NETPLAY_NICKNAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Nickname", "Player");
NETPLAY_NICKNAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Nickname", "Player"),
NETPLAY_GAME(Settings.FILE_DOLPHIN, Settings.SECTION_INI_NETPLAY, "Game", "");
override val isOverridden: Boolean
get() = NativeConfig.isOverridden(file, section, key)

View File

@ -66,7 +66,7 @@ class GameCoverKeyer : Keyer<GameFile> {
}
object CoilUtils {
private val imageLoader = ImageLoader.Builder(DolphinApplication.getAppContext())
val imageLoader = ImageLoader.Builder(DolphinApplication.getAppContext())
.components {
add(GameCoverKeyer())
add(GameCoverFetcher.Factory())

View File

@ -35,6 +35,7 @@ androidx-cardview = { group = "androidx.cardview", name = "cardview", version.re
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
@ -45,6 +46,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" }
androidx-leanback = { group = "androidx.leanback", name = "leanback", version.ref = "leanback" }
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
@ -55,6 +57,7 @@ androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "s
androidx-tvprovider = { group = "androidx.tvprovider", name = "tvprovider", version.ref = "tvprovider" }
androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
desugar_jdk_libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
filepicker = { group = "com.nononsenseapps", name = "filepicker", version.ref = "filepicker" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }

View File

@ -148,6 +148,21 @@ Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeHost(JNIEnv
return reinterpret_cast<jlong>(server);
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeChangeGame(JNIEnv* env,
jobject obj,
jobject jgame_file)
{
auto* server = GetServerPointer(env, obj);
if (!server)
return;
const auto& game_file = *reinterpret_cast<std::shared_ptr<const UICommon::GameFile>*>(
env->GetLongField(jgame_file, IDCache::GetGameFilePointer()));
server->ChangeGame(game_file->GetSyncIdentifier(), game_file->GetLongName());
}
JNIEXPORT void JNICALL
Java_org_dolphinemu_dolphinemu_features_netplay_NetplaySession_nativeStartGame(JNIEnv* env,
jobject obj)