From be5c2f772c58399fd11ddbce6e19dd4600df2425 Mon Sep 17 00:00:00 2001 From: Zephyron Date: Mon, 10 Nov 2025 17:16:45 +1000 Subject: [PATCH] fix: Multiplayer network fixes and airplane mode - Auto-select network interface for direct connect/host room - Always recreate ENet client on join for fresh bindings - Add airplane mode toggle (Desktop & Android) - Fix JWT verification with empty verify_uid - Improve content-type handling for JWT endpoints Signed-off-by: Zephyron --- .../features/settings/model/BooleanSetting.kt | 3 ++- .../features/settings/model/Settings.kt | 1 + .../settings/ui/SettingsFragmentPresenter.kt | 16 ++++++++++++++ .../app/src/main/res/values/strings.xml | 5 +++++ .../configuration/configure_network.cpp | 11 +++++++++- src/citron/configuration/configure_network.ui | 10 +++++++++ src/citron/multiplayer/direct_connect.cpp | 6 +++++ src/citron/multiplayer/host_room.cpp | 22 ++++++++++++++----- src/citron/multiplayer/lobby.cpp | 4 +++- src/common/settings.h | 1 + src/core/hle/result.h | 1 + src/core/hle/service/fatal/fatal.cpp | 19 ++++++++++++++-- src/core/hle/service/ldn/ldn_results.h | 10 +++++++++ .../internal_network/network_interface.cpp | 6 +++++ src/network/room_member.cpp | 19 +++++++++++++--- src/web_service/web_backend.cpp | 10 ++++++++- 16 files changed, 129 insertions(+), 15 deletions(-) diff --git a/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/BooleanSetting.kt index 91166e4e1..f0b07bd80 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/BooleanSetting.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/BooleanSetting.kt @@ -31,7 +31,8 @@ enum class BooleanSetting(override val key: String) : AbstractBooleanSetting { SHOW_RAM_METER("show_ram_meter"), SHOW_SHADER_BUILDING_OVERLAY("show_shader_building_overlay"), SHOW_PERFORMANCE_GRAPH("show_performance_graph"), - USE_CONDITIONAL_RENDERING("use_conditional_rendering"); + USE_CONDITIONAL_RENDERING("use_conditional_rendering"), + AIRPLANE_MODE("airplane_mode"); override fun getBoolean(needsGlobal: Boolean): Boolean = NativeConfig.getBoolean(key, needsGlobal) diff --git a/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/Settings.kt index 90deb71ca..28e09bada 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/Settings.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/features/settings/model/Settings.kt @@ -24,6 +24,7 @@ object Settings { SECTION_INPUT_PLAYER_EIGHT, SECTION_THEME(R.string.preferences_theme), SECTION_DEBUG(R.string.preferences_debug), + SECTION_NETWORK(R.string.preferences_network), SECTION_ZEP_ZONE(R.string.preferences_zep_zone), SECTION_APPLETS_ANDROID(R.string.preferences_applets_android); } diff --git a/src/android/app/src/main/java/org/citron/citron_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/citron/citron_emu/features/settings/ui/SettingsFragmentPresenter.kt index 418912c8f..f7d7ab2e0 100644 --- a/src/android/app/src/main/java/org/citron/citron_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/citron/citron_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -99,6 +99,7 @@ class SettingsFragmentPresenter( MenuTag.SECTION_INPUT_PLAYER_EIGHT -> addInputPlayer(sl, 7) MenuTag.SECTION_THEME -> addThemeSettings(sl) MenuTag.SECTION_DEBUG -> addDebugSettings(sl) + MenuTag.SECTION_NETWORK -> addNetworkSettings(sl) MenuTag.SECTION_ZEP_ZONE -> addZepZoneSettings(sl) MenuTag.SECTION_APPLETS_ANDROID -> addAppletsAndroidSettings(sl) } @@ -144,6 +145,14 @@ class SettingsFragmentPresenter( menuKey = MenuTag.SECTION_DEBUG ) ) + add( + SubmenuSetting( + titleId = R.string.preferences_network, + descriptionId = R.string.preferences_network_description, + iconId = R.drawable.ic_settings, + menuKey = MenuTag.SECTION_NETWORK + ) + ) add( SubmenuSetting( titleId = R.string.preferences_zep_zone, @@ -1002,6 +1011,13 @@ class SettingsFragmentPresenter( } } + private fun addNetworkSettings(sl: ArrayList) { + sl.apply { + add(HeaderSetting(R.string.network_settings_header)) + add(BooleanSetting.AIRPLANE_MODE.key) + } + } + private fun addZepZoneSettings(sl: ArrayList) { sl.apply { add(HeaderSetting(R.string.memory_layout_header)) diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index b9d0615d4..18dc01d4c 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -421,11 +421,16 @@ Theme and color Debug CPU/GPU debugging, graphics API, fastmem + Network + Network interface and airplane mode settings Zep Zone Advanced emulation settings Applets on Android System applet configuration settings + + Network Settings + Memory Layout ASTC Settings diff --git a/src/citron/configuration/configure_network.cpp b/src/citron/configuration/configure_network.cpp index 43765a574..7757638b1 100644 --- a/src/citron/configuration/configure_network.cpp +++ b/src/citron/configuration/configure_network.cpp @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: Copyright 2019 yuzu Emulator Project +// SPDX-FileCopyrightText: Copyright 2025 Citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include @@ -23,6 +24,7 @@ ConfigureNetwork::ConfigureNetwork(const Core::System& system_, QWidget* parent) ConfigureNetwork::~ConfigureNetwork() = default; void ConfigureNetwork::ApplyConfiguration() { + Settings::values.airplane_mode = ui->airplane_mode->isChecked(); Settings::values.network_interface = ui->network_interface->currentText().toStdString(); } @@ -41,8 +43,15 @@ void ConfigureNetwork::RetranslateUI() { void ConfigureNetwork::SetConfiguration() { const bool runtime_lock = !system.IsPoweredOn(); + ui->airplane_mode->setChecked(Settings::values.airplane_mode.GetValue()); + ui->airplane_mode->setEnabled(runtime_lock); + const std::string& network_interface = Settings::values.network_interface.GetValue(); ui->network_interface->setCurrentText(QString::fromStdString(network_interface)); - ui->network_interface->setEnabled(runtime_lock); + ui->network_interface->setEnabled(runtime_lock && !ui->airplane_mode->isChecked()); + + connect(ui->airplane_mode, &QCheckBox::toggled, this, [this](bool checked) { + ui->network_interface->setEnabled(!checked && !system.IsPoweredOn()); + }); } diff --git a/src/citron/configuration/configure_network.ui b/src/citron/configuration/configure_network.ui index f10e973b1..17dc1a458 100644 --- a/src/citron/configuration/configure_network.ui +++ b/src/citron/configuration/configure_network.ui @@ -25,6 +25,16 @@ General + + + + Airplane Mode + + + Disable all network functionality, similar to Nintendo Switch airplane mode + + + diff --git a/src/citron/multiplayer/direct_connect.cpp b/src/citron/multiplayer/direct_connect.cpp index 3255c7acc..eafd964b4 100644 --- a/src/citron/multiplayer/direct_connect.cpp +++ b/src/citron/multiplayer/direct_connect.cpp @@ -7,6 +7,7 @@ #include #include #include +#include "common/logging/log.h" #include "common/settings.h" #include "core/core.h" #include "core/internal_network/network_interface.h" @@ -57,6 +58,11 @@ void DirectConnectWindow::RetranslateUi() { } void DirectConnectWindow::Connect() { + if (!Network::GetSelectedNetworkInterface()) { + LOG_INFO(WebService, "Automatically selected network interface for room network."); + Network::SelectFirstNetworkInterface(); + } + if (!Network::GetSelectedNetworkInterface()) { NetworkMessage::ErrorManager::ShowError( NetworkMessage::ErrorManager::NO_INTERFACE_SELECTED); diff --git a/src/citron/multiplayer/host_room.cpp b/src/citron/multiplayer/host_room.cpp index 5852146bc..cefb5c1ea 100644 --- a/src/citron/multiplayer/host_room.cpp +++ b/src/citron/multiplayer/host_room.cpp @@ -112,6 +112,11 @@ std::unique_ptr HostRoomWindow::CreateVerifyBacken } void HostRoomWindow::Host() { + if (!Network::GetSelectedNetworkInterface()) { + LOG_INFO(WebService, "Automatically selected network interface for room network."); + Network::SelectFirstNetworkInterface(); + } + if (!Network::GetSelectedNetworkInterface()) { NetworkMessage::ErrorManager::ShowError( NetworkMessage::ErrorManager::NO_INTERFACE_SELECTED); @@ -208,12 +213,17 @@ void HostRoomWindow::Host() { Settings::values.citron_username.GetValue(), Settings::values.citron_token.GetValue()); if (auto room = room_network.GetRoom().lock()) { - token = client.GetExternalJWT(room->GetVerifyUID()).returned_data; - } - if (token.empty()) { - LOG_ERROR(WebService, "Could not get external JWT, verification may fail"); - } else { - LOG_INFO(WebService, "Successfully requested external JWT: size={}", token.size()); + const std::string verify_uid = room->GetVerifyUID(); + if (!verify_uid.empty()) { + token = client.GetExternalJWT(verify_uid).returned_data; + if (token.empty()) { + LOG_ERROR(WebService, "Could not get external JWT, verification may fail"); + } else { + LOG_INFO(WebService, "Successfully requested external JWT: size={}", token.size()); + } + } else { + LOG_DEBUG(WebService, "Skipping JWT request: verify_uid is empty (room may not require verification)"); + } } } #endif diff --git a/src/citron/multiplayer/lobby.cpp b/src/citron/multiplayer/lobby.cpp index 918573d08..f086289ff 100644 --- a/src/citron/multiplayer/lobby.cpp +++ b/src/citron/multiplayer/lobby.cpp @@ -188,7 +188,7 @@ void Lobby::OnJoinRoom(const QModelIndex& source) { std::string token; #ifdef ENABLE_WEB_SERVICE if (!Settings::values.citron_username.GetValue().empty() && - !Settings::values.citron_token.GetValue().empty()) { + !Settings::values.citron_token.GetValue().empty() && !verify_uid.empty()) { WebService::Client client(Settings::values.web_api_url.GetValue(), Settings::values.citron_username.GetValue(), Settings::values.citron_token.GetValue()); @@ -198,6 +198,8 @@ void Lobby::OnJoinRoom(const QModelIndex& source) { } else { LOG_INFO(WebService, "Successfully requested external JWT: size={}", token.size()); } + } else if (verify_uid.empty()) { + LOG_DEBUG(WebService, "Skipping JWT request: verify_uid is empty (room may not require verification)"); } #endif if (auto room_member = room_network.GetRoomMember().lock()) { diff --git a/src/common/settings.h b/src/common/settings.h index 9f94ca78d..ca54239ec 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -660,6 +660,7 @@ struct Values { Setting use_dev_keys{linkage, false, "use_dev_keys", Category::Miscellaneous}; // Network + Setting airplane_mode{linkage, false, "airplane_mode", Category::Network}; Setting network_interface{linkage, std::string(), "network_interface", Category::Network}; Setting lobby_api_url{linkage, "https://api.ynet-fun.xyz", "lobby_api_url", diff --git a/src/core/hle/result.h b/src/core/hle/result.h index e2eb807d7..5e4f8968f 100644 --- a/src/core/hle/result.h +++ b/src/core/hle/result.h @@ -52,6 +52,7 @@ enum class ErrorModule : u32 { Util = 33, TIPC = 35, ANIF = 37, + Module38 = 38, // Unknown/Undefined module - stubbed for multiplayer compatibility CRT = 39, ETHC = 100, I2C = 101, diff --git a/src/core/hle/service/fatal/fatal.cpp b/src/core/hle/service/fatal/fatal.cpp index da0e594db..317cd457c 100644 --- a/src/core/hle/service/fatal/fatal.cpp +++ b/src/core/hle/service/fatal/fatal.cpp @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project +// SPDX-FileCopyrightText: Copyright 2025 Citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include @@ -65,16 +66,30 @@ enum class FatalType : u32 { static void GenerateErrorReport(Core::System& system, Result error_code, const FatalInfo& info) { const auto title_id = system.GetApplicationProcessProgramID(); + const auto module = static_cast(error_code.GetModule()); + const auto description = static_cast(error_code.GetDescription()); + + // Check if this is module 38 (undefined/unknown module) + std::string module_note; + if (module == 38) { + module_note = fmt::format( + "\n⚠️ WARNING: Error module 38 is undefined/unknown!\n" + "This error may be game-generated or from an unimplemented service.\n" + "Error code: 2038-{:04d} (0x{:08X})\n" + "If you're experiencing multiplayer issues, this may be a stubbing issue.\n\n", + description, error_code.raw); + } + std::string crash_report = fmt::format( "Citron {}-{} crash report\n" "Title ID: {:016x}\n" "Result: 0x{:X} ({:04}-{:04d})\n" "Set flags: 0x{:16X}\n" "Program entry point: 0x{:16X}\n" + "{}" "\n", Common::g_scm_branch, Common::g_scm_desc, title_id, error_code.raw, - 2000 + static_cast(error_code.GetModule()), - static_cast(error_code.GetDescription()), info.set_flags, info.program_entry_point); + 2000 + module, description, info.set_flags, info.program_entry_point, module_note); if (info.backtrace_size != 0x0) { crash_report += "Registers:\n"; for (size_t i = 0; i < info.registers.size(); i++) { diff --git a/src/core/hle/service/ldn/ldn_results.h b/src/core/hle/service/ldn/ldn_results.h index f340bda42..6e29780b7 100644 --- a/src/core/hle/service/ldn/ldn_results.h +++ b/src/core/hle/service/ldn/ldn_results.h @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: Copyright 2022 yuzu Emulator Project +// SPDX-FileCopyrightText: Copyright 2025 citron Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later #pragma once @@ -24,4 +25,13 @@ constexpr Result ResultLocalCommunicationIdNotFound{ErrorModule::LDN, 97}; constexpr Result ResultLocalCommunicationVersionTooLow{ErrorModule::LDN, 113}; constexpr Result ResultLocalCommunicationVersionTooHigh{ErrorModule::LDN, 114}; +// Module 38 error codes - Unknown/undefined module +// These are stubbed to prevent crashes during multiplayer +// Error code format: 2038-XXXX where XXXX is the description +constexpr Result ResultModule38Error2618{ErrorModule::Module38, 2618}; // Reported during multiplayer +constexpr Result ResultModule38Generic{ErrorModule::Module38, 0}; // Generic module 38 error +constexpr Result ResultModule38NetworkError{ErrorModule::Module38, 100}; // Network-related +constexpr Result ResultModule38ConnectionFailed{ErrorModule::Module38, 200}; // Connection failure +constexpr Result ResultModule38Timeout{ErrorModule::Module38, 300}; // Operation timeout + } // namespace Service::LDN diff --git a/src/core/internal_network/network_interface.cpp b/src/core/internal_network/network_interface.cpp index 7c37f660b..62a026e6e 100644 --- a/src/core/internal_network/network_interface.cpp +++ b/src/core/internal_network/network_interface.cpp @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: Copyright 2021 yuzu Emulator Project +// SPDX-FileCopyrightText: Copyright 2025 Citron Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later #include @@ -187,6 +188,11 @@ std::vector GetAvailableNetworkInterfaces() { #endif std::optional GetSelectedNetworkInterface() { + // If airplane mode is enabled, return no interface (similar to Switch's airplane mode) + if (Settings::values.airplane_mode.GetValue()) { + return std::nullopt; + } + const auto& selected_network_interface = Settings::values.network_interface.GetValue(); const auto network_interfaces = Network::GetAvailableNetworkInterfaces(); if (network_interfaces.empty()) { diff --git a/src/network/room_member.cpp b/src/network/room_member.cpp index 7519fb2d7..6399bbcfa 100644 --- a/src/network/room_member.cpp +++ b/src/network/room_member.cpp @@ -9,6 +9,7 @@ #include #include "common/assert.h" #include "common/socket_types.h" +#include "core/internal_network/network_interface.h" #include "enet/enet.h" #include "network/packet.h" #include "network/room_member.h" @@ -601,11 +602,23 @@ void RoomMember::Join(const std::string& nick, const char* server_addr, u16 serv room_member_impl->loop_thread.reset(); } - if (!room_member_impl->client) { - room_member_impl->client = enet_host_create(nullptr, 1, NumChannels, 0, 0); - ASSERT_MSG(room_member_impl->client != nullptr, "Could not create client"); + // Always recreate the client to ensure it uses the current network interface settings + // This is necessary because the client might have been created with different settings + if (room_member_impl->client) { + enet_host_destroy(room_member_impl->client); + room_member_impl->client = nullptr; } + // For client connections, bind to ENET_HOST_ANY (0.0.0.0) to allow the OS to route + // based on the destination address. The selected network interface will be used + // by the OS routing table when connecting to the server. + ENetAddress bind_address{}; + bind_address.host = ENET_HOST_ANY; + bind_address.port = 0; // Let the system choose an available port + + room_member_impl->client = enet_host_create(&bind_address, 1, NumChannels, 0, 0); + ASSERT_MSG(room_member_impl->client != nullptr, "Could not create client"); + room_member_impl->SetState(State::Joining); ENetAddress address{}; diff --git a/src/web_service/web_backend.cpp b/src/web_service/web_backend.cpp index 4c3d790b4..710fe09ba 100644 --- a/src/web_service/web_backend.cpp +++ b/src/web_service/web_backend.cpp @@ -138,7 +138,15 @@ struct Client::Impl { return WebResult{WebResult::Code::WrongContent, "", ""}; } - if (content_type->second.find(accept) == std::string::npos) { + // For GetExternalJWT, be more lenient with content-type to handle error responses + // The API may return text/plain for errors, but we still want to process the response + bool content_type_valid = content_type->second.find(accept) != std::string::npos; + if (!content_type_valid && accept == "text/html") { + // Also accept text/plain for JWT endpoints (error responses) + content_type_valid = content_type->second.find("text/plain") != std::string::npos; + } + + if (!content_type_valid) { LOG_ERROR(WebService, "{} to {} returned wrong content: {}", method, host + path, content_type->second); return WebResult{WebResult::Code::WrongContent, "Wrong content", ""};