diff --git a/assets/localization/ar-AE.json b/assets/localization/ar-AE.json index 7a2966fcd..4fed62306 100644 --- a/assets/localization/ar-AE.json +++ b/assets/localization/ar-AE.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "تم كسب الكأس!" + "trophy.toast.earned": "تم كسب الكأس!", + "user.guest": "ضيف" } diff --git a/assets/localization/cs-CZ.json b/assets/localization/cs-CZ.json index 220d7f355..9ab98b0ec 100644 --- a/assets/localization/cs-CZ.json +++ b/assets/localization/cs-CZ.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trofej získána!" + "trophy.toast.earned": "Trofej získána!", + "user.guest": "Host" } diff --git a/assets/localization/da-DK.json b/assets/localization/da-DK.json index 6cd65295c..6c66e82dd 100644 --- a/assets/localization/da-DK.json +++ b/assets/localization/da-DK.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trofæ optjent!" + "trophy.toast.earned": "Trofæ optjent!", + "user.guest": "Gæst" } diff --git a/assets/localization/de-DE.json b/assets/localization/de-DE.json index 656714df9..693077448 100644 --- a/assets/localization/de-DE.json +++ b/assets/localization/de-DE.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trophäe erhalten!" + "trophy.toast.earned": "Trophäe erhalten!", + "user.guest": "Gast" } diff --git a/assets/localization/el-GR.json b/assets/localization/el-GR.json index f5a79d5e8..85eec3c7d 100644 --- a/assets/localization/el-GR.json +++ b/assets/localization/el-GR.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Κερδίσατε ένα τρόπαιο!" + "trophy.toast.earned": "Κερδίσατε ένα τρόπαιο!", + "user.guest": "Επισκ." } diff --git a/assets/localization/en-GB.json b/assets/localization/en-GB.json index 60d5d98b9..13fb4ea1e 100644 --- a/assets/localization/en-GB.json +++ b/assets/localization/en-GB.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trophy earned!" + "trophy.toast.earned": "Trophy earned!", + "user.guest": "Guest" } diff --git a/assets/localization/en-US.json b/assets/localization/en-US.json index 60d5d98b9..13fb4ea1e 100644 --- a/assets/localization/en-US.json +++ b/assets/localization/en-US.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trophy earned!" + "trophy.toast.earned": "Trophy earned!", + "user.guest": "Guest" } diff --git a/assets/localization/es-419.json b/assets/localization/es-419.json index d0ee7dac2..9744f0eb4 100644 --- a/assets/localization/es-419.json +++ b/assets/localization/es-419.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "¡Ganaste un trofeo!" + "trophy.toast.earned": "¡Ganaste un trofeo!", + "user.guest": "Invitado" } diff --git a/assets/localization/es-ES.json b/assets/localization/es-ES.json index d0ee7dac2..9744f0eb4 100644 --- a/assets/localization/es-ES.json +++ b/assets/localization/es-ES.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "¡Ganaste un trofeo!" + "trophy.toast.earned": "¡Ganaste un trofeo!", + "user.guest": "Invitado" } diff --git a/assets/localization/fi-FI.json b/assets/localization/fi-FI.json index c1b65a822..cf6fc9286 100644 --- a/assets/localization/fi-FI.json +++ b/assets/localization/fi-FI.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Pokaali ansaittu!" + "trophy.toast.earned": "Pokaali ansaittu!", + "user.guest": "Vieras" } diff --git a/assets/localization/fr-CA.json b/assets/localization/fr-CA.json index 374c3a402..f500adc21 100644 --- a/assets/localization/fr-CA.json +++ b/assets/localization/fr-CA.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trophée obtenu !" + "trophy.toast.earned": "Trophée obtenu !", + "user.guest": "Invité" } diff --git a/assets/localization/fr-FR.json b/assets/localization/fr-FR.json index 374c3a402..f500adc21 100644 --- a/assets/localization/fr-FR.json +++ b/assets/localization/fr-FR.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trophée obtenu !" + "trophy.toast.earned": "Trophée obtenu !", + "user.guest": "Invité" } diff --git a/assets/localization/hu-HU.json b/assets/localization/hu-HU.json index 6ba5f1b69..6e43d90c7 100644 --- a/assets/localization/hu-HU.json +++ b/assets/localization/hu-HU.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trófea megszerezve!" + "trophy.toast.earned": "Trófea megszerezve!", + "user.guest": "Vendég" } diff --git a/assets/localization/id-ID.json b/assets/localization/id-ID.json index d350a7c9c..95b50307e 100644 --- a/assets/localization/id-ID.json +++ b/assets/localization/id-ID.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trofi diperoleh!" + "trophy.toast.earned": "Trofi diperoleh!", + "user.guest": "Tamu" } diff --git a/assets/localization/it-IT.json b/assets/localization/it-IT.json index d18492708..35e3aac21 100644 --- a/assets/localization/it-IT.json +++ b/assets/localization/it-IT.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trofeo ottenuto!" + "trophy.toast.earned": "Trofeo ottenuto!", + "user.guest": "Ospite" } diff --git a/assets/localization/ja-JP.json b/assets/localization/ja-JP.json index ee8b0cbfb..defb8f631 100644 --- a/assets/localization/ja-JP.json +++ b/assets/localization/ja-JP.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "トロフィーを獲得しました!" + "trophy.toast.earned": "トロフィーを獲得しました!", + "user.guest": "ゲスト" } diff --git a/assets/localization/ko-KR.json b/assets/localization/ko-KR.json index 9a6cbd914..60e141b42 100644 --- a/assets/localization/ko-KR.json +++ b/assets/localization/ko-KR.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "트로피를 획득했습니다!" + "trophy.toast.earned": "트로피를 획득했습니다!", + "user.guest": "게스트" } diff --git a/assets/localization/nl-NL.json b/assets/localization/nl-NL.json index b13eb0e86..79dc7338f 100644 --- a/assets/localization/nl-NL.json +++ b/assets/localization/nl-NL.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trofee behaald!" + "trophy.toast.earned": "Trofee behaald!", + "user.guest": "Gast" } diff --git a/assets/localization/no-NO.json b/assets/localization/no-NO.json index 084cb7acc..966a9591e 100644 --- a/assets/localization/no-NO.json +++ b/assets/localization/no-NO.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trofé opptjent!" + "trophy.toast.earned": "Trofé opptjent!", + "user.guest": "Gjest" } diff --git a/assets/localization/pl-PL.json b/assets/localization/pl-PL.json index 7d1c624e4..8441cffea 100644 --- a/assets/localization/pl-PL.json +++ b/assets/localization/pl-PL.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Zdobyto trofeum!" + "trophy.toast.earned": "Zdobyto trofeum!", + "user.guest": "Gość" } diff --git a/assets/localization/pt-BR.json b/assets/localization/pt-BR.json index d875ba3ec..057b5e257 100644 --- a/assets/localization/pt-BR.json +++ b/assets/localization/pt-BR.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Troféu conquistado!" + "trophy.toast.earned": "Troféu conquistado!", + "user.guest": "Convidado" } diff --git a/assets/localization/pt-PT.json b/assets/localization/pt-PT.json index d875ba3ec..057b5e257 100644 --- a/assets/localization/pt-PT.json +++ b/assets/localization/pt-PT.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Troféu conquistado!" + "trophy.toast.earned": "Troféu conquistado!", + "user.guest": "Convidado" } diff --git a/assets/localization/ro-RO.json b/assets/localization/ro-RO.json index e7ec3de59..282be2ff3 100644 --- a/assets/localization/ro-RO.json +++ b/assets/localization/ro-RO.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trofeu câștigat!" + "trophy.toast.earned": "Trofeu câștigat!", + "user.guest": "Invitat" } diff --git a/assets/localization/ru-RU.json b/assets/localization/ru-RU.json index fbf60fed5..90ca597f8 100644 --- a/assets/localization/ru-RU.json +++ b/assets/localization/ru-RU.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Трофей получен!" + "trophy.toast.earned": "Трофей получен!", + "user.guest": "Гость" } diff --git a/assets/localization/sv-SE.json b/assets/localization/sv-SE.json index 1c6fe8d4f..cd94342e8 100644 --- a/assets/localization/sv-SE.json +++ b/assets/localization/sv-SE.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Trofé intjänad!" + "trophy.toast.earned": "Trofé intjänad!", + "user.guest": "Gäst" } diff --git a/assets/localization/th-TH.json b/assets/localization/th-TH.json index 8adfadb9f..3093405d5 100644 --- a/assets/localization/th-TH.json +++ b/assets/localization/th-TH.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "ได้รับถ้วยรางวัลแล้ว!" + "trophy.toast.earned": "ได้รับถ้วยรางวัลแล้ว!", + "user.guest": "แขก" } diff --git a/assets/localization/tr-TR.json b/assets/localization/tr-TR.json index db282fb07..9b10b312a 100644 --- a/assets/localization/tr-TR.json +++ b/assets/localization/tr-TR.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Kupa kazanıldı!" + "trophy.toast.earned": "Kupa kazanıldı!", + "user.guest": "Misafir" } diff --git a/assets/localization/vi-VN.json b/assets/localization/vi-VN.json index 6bbe5b9ee..d7786665b 100644 --- a/assets/localization/vi-VN.json +++ b/assets/localization/vi-VN.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "Đã nhận cúp!" + "trophy.toast.earned": "Đã nhận cúp!", + "user.guest": "Khách" } diff --git a/assets/localization/zh-Hans.json b/assets/localization/zh-Hans.json index 2288c9293..4872ccf04 100644 --- a/assets/localization/zh-Hans.json +++ b/assets/localization/zh-Hans.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "已获得奖杯!" + "trophy.toast.earned": "已获得奖杯!", + "user.guest": "访客" } diff --git a/assets/localization/zh-Hant.json b/assets/localization/zh-Hant.json index d62862330..aa70cd8dc 100644 --- a/assets/localization/zh-Hant.json +++ b/assets/localization/zh-Hant.json @@ -1,3 +1,4 @@ { - "trophy.toast.earned": "已獲得獎盃!" + "trophy.toast.earned": "已獲得獎盃!", + "user.guest": "訪客" } diff --git a/src/common/emulatorConfig.cpp b/src/common/emulatorConfig.cpp index cd90b7939..98a90adb7 100644 --- a/src/common/emulatorConfig.cpp +++ b/src/common/emulatorConfig.cpp @@ -24,6 +24,7 @@ void Load(const ConfigOptions& cfg) { EXIT_IF(cfg.user_name.empty() || cfg.user_name.size() > MAX_USER_NAME_LENGTH); EXIT_IF(!IsConfiguredUserIdValid(cfg.user_id)); EXIT_IF(cfg.controller_speaker_volume > 100 || cfg.controller_vibration_intensity > 100); + EXIT_IF(cfg.keyboard_player > static_cast(MAX_PLAYERS)); *g_config = cfg; } @@ -60,6 +61,27 @@ uint32_t GetControllerVibrationIntensity() { return g_config->controller_vibration_intensity; } +uint32_t GetKeyboardPlayer() { + return g_config->keyboard_player; +} + +int32_t GetPlayerUserId(int player) { + return g_config->user_id + player * PLAYER_USER_ID_STEP; +} + +int GetPlayerFromUserId(int32_t user_id) { + for (int player = 0; player < MAX_PLAYERS; player++) { + if (user_id == GetPlayerUserId(player)) { + return player; + } + } + return -1; +} + +bool IsGuestUserId(int32_t user_id) { + return GetPlayerFromUserId(user_id) > 0; +} + PresentMode GetPresentMode() { return g_config->present_mode; } diff --git a/src/common/emulatorConfig.h b/src/common/emulatorConfig.h index fb5cb6deb..b46a6c133 100644 --- a/src/common/emulatorConfig.h +++ b/src/common/emulatorConfig.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -35,10 +36,16 @@ constexpr uint32_t MAX_CONSOLE_LANGUAGE = 29; constexpr std::size_t MAX_USER_NAME_LENGTH = 16; constexpr int32_t DEFAULT_USER_ID = 1000; +constexpr int MAX_PLAYERS = 4; +constexpr int32_t PLAYER_USER_ID_STEP = 0x100; +constexpr int32_t MAX_USER_ID = + std::numeric_limits::max() - (MAX_PLAYERS - 1) * PLAYER_USER_ID_STEP; + constexpr bool IsConfiguredUserIdValid(int32_t user_id) { constexpr int32_t USER_ID_EVERYONE = 0xfe; constexpr int32_t USER_ID_SYSTEM = 0xff; - return user_id >= 0 && user_id != USER_ID_EVERYONE && user_id != USER_ID_SYSTEM; + return user_id >= 0 && user_id <= MAX_USER_ID && user_id != USER_ID_EVERYONE && + user_id != USER_ID_SYSTEM; } struct ConfigOptions { @@ -50,6 +57,8 @@ struct ConfigOptions { std::optional controller_color; uint32_t controller_speaker_volume = 50; uint32_t controller_vibration_intensity = 100; + // 0 shares player 1 with the first gamepad; 1..MAX_PLAYERS dedicates that player to the keyboard. + uint32_t keyboard_player = 0; PresentMode present_mode = PresentMode::Mailbox; int32_t gpu_index = -1; bool fullscreen_enabled = false; @@ -100,6 +109,13 @@ const std::string& GetAudioInputDevice(); const std::optional& GetControllerColor(); uint32_t GetControllerSpeakerVolume(); uint32_t GetControllerVibrationIntensity(); +uint32_t GetKeyboardPlayer(); +// Player 0 is the configured user; later players derive their IDs from it. +int32_t GetPlayerUserId(int player); +// Returns -1 when the user ID does not belong to any player. +int GetPlayerFromUserId(int32_t user_id); +// Players after the first are guests: they have no profile, trophies or NP account. +bool IsGuestUserId(int32_t user_id); PresentMode GetPresentMode(); int32_t GetGpuIndex(); bool FullscreenEnabled(); diff --git a/src/graphics/presentation/window/hostInput.cpp b/src/graphics/presentation/window/hostInput.cpp index fdda4afca..7c4ed81c3 100644 --- a/src/graphics/presentation/window/hostInput.cpp +++ b/src/graphics/presentation/window/hostInput.cpp @@ -254,7 +254,8 @@ public: } const bool reserved = binding.key == SDLK_ESCAPE || binding.key == SDLK_F1 || - binding.key == SDLK_F7 || binding.key == SDLK_F11; + binding.key == SDLK_F7 || binding.key == SDLK_F8 || + binding.key == SDLK_F11; if (binding.control == INVALID_CONTROL || reserved || (binding.key == SDLK_UNKNOWN && binding.mouse_button == 0)) { EXIT("Invalid input mapping: %s\n", value.c_str()); diff --git a/src/graphics/presentation/window/window.cpp b/src/graphics/presentation/window/window.cpp index 8b636720e..206c67fd6 100644 --- a/src/graphics/presentation/window/window.cpp +++ b/src/graphics/presentation/window/window.cpp @@ -466,6 +466,13 @@ void WindowContext::ProcessEvent(double time_s) { } return; } + if ((event->type == SDL_EVENT_KEY_DOWN || event->type == SDL_EVENT_KEY_UP) && + event->key.key == SDLK_F8) { + if (event->type == SDL_EVENT_KEY_DOWN && event->key.repeat == 0) { + Controller::CycleKeyboardPlayer(); + } + return; + } if (ProcessSystemOverlayInput(*event)) { return; } diff --git a/src/launcher/forms/configuration_edit_dialog.ui b/src/launcher/forms/configuration_edit_dialog.ui index 792da6a37..bf56d1c10 100644 --- a/src/launcher/forms/configuration_edit_dialog.ui +++ b/src/launcher/forms/configuration_edit_dialog.ui @@ -66,7 +66,6 @@ Local user ID exposed to games - 2147483647 @@ -347,6 +346,16 @@ + + + Keyboard player: + + + + + Gamepads fill the remaining players in connection order. Every player uses the same input mapping. + + diff --git a/src/launcher/include/configuration.h b/src/launcher/include/configuration.h index 51cdca0eb..af03a9548 100644 --- a/src/launcher/include/configuration.h +++ b/src/launcher/include/configuration.h @@ -48,11 +48,14 @@ struct ControllerSettings { QString color; int speaker_volume = 50; int vibration_intensity = 100; + // 0 shares player 1 with the first gamepad; 1..MAX_PLAYERS dedicates that player to the keyboard. + int keyboard_player = 0; void WriteSettings(QSettings* s) const { s->setValue("controller_color", color); s->setValue("controller_speaker_volume", speaker_volume); s->setValue("controller_vibration_intensity", vibration_intensity); + s->setValue("controller_keyboard_player", keyboard_player); } void ReadSettings(QSettings* s) { @@ -65,6 +68,8 @@ struct ControllerSettings { }; speaker_volume = read_percent("controller_speaker_volume", 50); vibration_intensity = read_percent("controller_vibration_intensity", 100); + keyboard_player = qBound(0, s->value("controller_keyboard_player", 0).toInt(), + Config::MAX_PLAYERS); } }; diff --git a/src/launcher/src/configurationEditDialog.cpp b/src/launcher/src/configurationEditDialog.cpp index c1aa5c9ab..1e6c69e33 100644 --- a/src/launcher/src/configurationEditDialog.cpp +++ b/src/launcher/src/configurationEditDialog.cpp @@ -209,10 +209,19 @@ static void ListInit(QComboBox* combo, T value) { void ConfigurationEditDialog::Init(const Configuration& info) { m_ui->lineEdit_user_name->setMaxLength(static_cast(Config::MAX_USER_NAME_LENGTH)); m_ui->lineEdit_user_name->setText(info.user_name); + m_ui->spinBox_user_id->setMaximum(Config::MAX_USER_ID); m_ui->spinBox_user_id->setValue(info.user_id); UpdateControllerColorButton(m_ui->button_controller_color, info.controller.color); m_ui->slider_controller_vibration->setValue(info.controller.vibration_intensity); m_ui->slider_controller_volume->setValue(info.controller.speaker_volume); + auto* keyboard_player = m_ui->comboBox_keyboard_player; + keyboard_player->clear(); + keyboard_player->addItem(tr("Shared with player 1 (default)"), 0); + for (int player = 1; player <= Config::MAX_PLAYERS; player++) { + keyboard_player->addItem(tr("Player %1").arg(player), player); + } + keyboard_player->setCurrentIndex( + qMax(0, keyboard_player->findData(info.controller.keyboard_player))); auto* microphone = m_ui->comboBox_audio_input_device; microphone->clear(); microphone->addItem(tr("None"), QString {}); @@ -458,6 +467,7 @@ static void UpdateInfo(Configuration& info, Ui::ConfigurationEditDialog& ui, boo info.controller.color = ui.button_controller_color->property("controllerColor").toString(); info.controller.vibration_intensity = ui.slider_controller_vibration->value(); info.controller.speaker_volume = ui.slider_controller_volume->value(); + info.controller.keyboard_player = ui.comboBox_keyboard_player->currentData().toInt(); } info.screen_resolution = TextToEnum(ui.comboBox_screen_resolution->currentText()); diff --git a/src/launcher/src/mainDialog.cpp b/src/launcher/src/mainDialog.cpp index 93b6c27ff..90debf16e 100644 --- a/src/launcher/src/mainDialog.cpp +++ b/src/launcher/src/mainDialog.cpp @@ -246,6 +246,7 @@ static QStringList CreateEmulatorArgs(const Configuration& info) { } args << "--controller-volume" << QString::number(info.controller.speaker_volume); args << "--controller-vibration" << QString::number(info.controller.vibration_intensity); + args << "--keyboard-player" << QString::number(info.controller.keyboard_player); args << "--present-mode" << EnumToText(info.present_mode); if (info.gpu_index >= 0) { args << "--gpu" << QString::number(info.gpu_index); diff --git a/src/libs/controller.cpp b/src/libs/controller.cpp index 5c6c853ab..ba4c32ce7 100644 --- a/src/libs/controller.cpp +++ b/src/libs/controller.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include namespace Libs::Controller { @@ -107,18 +108,19 @@ struct ControllerState { class GameController { public: - GameController() = default; + explicit GameController(int player): m_player(player) {} KYTY_CLASS_NO_COPY(GameController); + [[nodiscard]] int Handle() const { return PAD_HANDLE + m_player; } + int InitPort(); int OpenPort(int user_id, int type, int index); int GetPortHandle(int user_id, int type, int index); int ClosePort(int handle); int CheckPortHandle(int handle); - void Connect(int id); - void Disconnect(int id); + void SetDevices(int active_id); void Button(int id, uint32_t button, bool down); void Axis(int id, Axis axis, int value); void RightStick(int id, int x, int y); @@ -128,7 +130,6 @@ public: void ResetOrientation(); void ToggleGyroEmulation(int id); void ResetInputState(); - void ReleaseHostPads(); void GetConnectionInfo(bool* flag, int* count); void SetVibration(uint8_t large_motor, uint8_t small_motor); int GetActiveControllerId(); @@ -145,8 +146,10 @@ private: enum class PortState { Uninitialized, Closed, Open }; int CheckPortHandleLocked(int handle) const; + [[nodiscard]] bool PadOpenArgsAreValid(int user_id, int type, int index) const; + + const int m_player; - void CheckActive(); void AddState(); // Emulated tilt: the right stick drives pitch and roll instead of the stick axes. void UpdateEmulatedTilt(); @@ -157,7 +160,6 @@ private: Common::Mutex m_mutex; PortState m_port_state = PortState::Uninitialized; - std::vector m_connected_ids; int m_active_id = -1; bool m_connected = false; int m_connected_count = 0; @@ -184,7 +186,39 @@ private: std::array, 3> m_setting_steps {}; }; -static GameController* g_controller = nullptr; +static std::array, Config::MAX_PLAYERS> g_players; + +// Maps host gamepads to players; the keyboard follows the configured keyboard player. +static Common::Mutex g_router_mutex; +static std::vector> g_pad_players; + +static uint32_t g_keyboard_player = 0; + +static void RefreshPlayers(); +static void StopHostPad(SDL_Gamepad* pad); + +static int KeyboardPlayer() { + return g_keyboard_player == 0 ? 0 : static_cast(g_keyboard_player) - 1; +} + +// Caller holds g_router_mutex. +static GameController* PlayerForDevice(int id) { + if (id == HOST_INPUT_CONTROLLER_ID) { + return g_players[KeyboardPlayer()].get(); + } + for (const auto& [pad, player]: g_pad_players) { + if (pad == id) { + return player >= 0 ? g_players[player].get() : nullptr; + } + } + return nullptr; +} + +static GameController* PlayerForHandle(int handle) { + return handle >= PAD_HANDLE && handle < PAD_HANDLE + Config::MAX_PLAYERS + ? g_players[handle - PAD_HANDLE].get() + : nullptr; +} static void pad_fill_data(PadData* data, const ControllerState& state, bool connected, int connected_count) { @@ -228,13 +262,15 @@ constexpr std::array SPEAKER_VOLUME = {1.0f, 0.0f, 0.02f, 0.12f, 0.42f}; constexpr std::array INTENSITY = {1.0f, 0.0f, 0.33f, 0.66f}; void CycleSetting(Setting setting) { - if (g_controller != nullptr) { - g_controller->CycleSetting(setting); + for (const auto& player: g_players) { + if (player != nullptr) { + player->CycleSetting(setting); + } } } float GetSettingScale(Setting setting) { - return g_controller != nullptr ? g_controller->GetSettingScale(setting) : 1.0f; + return g_players[0] != nullptr ? g_players[0]->GetSettingScale(setting) : 1.0f; } void GameController::CycleSetting(Setting setting) { @@ -404,35 +440,53 @@ static int32_t TriggerEffectState(const PadTriggerEffectCommand& command, int va } void Initialize() { - EXIT_IF(g_controller != nullptr); + EXIT_IF(g_players[0] != nullptr); - g_controller = new GameController; - g_controller->Connect(HOST_INPUT_CONTROLLER_ID); + for (int i = 0; i < Config::MAX_PLAYERS; i++) { + g_players[i] = std::make_unique(i); + } + g_keyboard_player = Config::GetKeyboardPlayer(); + RefreshPlayers(); } void Shutdown() { EmergencyShutdown(); - delete g_controller; - g_controller = nullptr; + for (auto& player: g_players) { + player.reset(); + } } void EmergencyShutdown() { - if (g_controller != nullptr) { - g_controller->ReleaseHostPads(); + Common::LockGuard lock(g_router_mutex); + DualSenseHaptics::Shutdown(); + std::vector pads; + for (const auto& [id, player]: g_pad_players) { + if (auto* pad = SDL_GetGamepadFromID(static_cast(id))) { + StopHostPad(pad); + pads.push_back(pad); + } + } + if (!pads.empty()) { + SDL_Delay(RELEASE_FLUSH_MS); + for (auto* pad: pads) { + SDL_CloseGamepad(pad); + } + } + g_pad_players.clear(); + for (const auto& player: g_players) { + if (player != nullptr) { + player->SetDevices(-1); + } } } -void GameController::Connect(int id) { +void GameController::SetDevices(int active_id) { Common::LockGuard lock(m_mutex); - - if (std::find(m_connected_ids.begin(), m_connected_ids.end(), id) != m_connected_ids.end()) { + if (m_active_id == active_id) { return; } - - m_connected_ids.push_back(id); - - if (id != HOST_INPUT_CONTROLLER_ID) { - if (auto* pad = SDL_GetGamepadFromID(static_cast(id)); + if (active_id != HOST_INPUT_CONTROLLER_ID && active_id != -1) { + if (auto* pad = SDL_GetGamepadFromID(static_cast(active_id)); pad != nullptr) { if (const auto& color = Config::GetControllerColor()) { (void)SDL_SetGamepadLED(pad, (*color)[0], (*color)[1], (*color)[2]); @@ -446,42 +500,11 @@ void GameController::Connect(int id) { } } - CheckActive(); -} - -void GameController::Disconnect(int id) { - Common::LockGuard lock(m_mutex); - - const auto it = std::find(m_connected_ids.begin(), m_connected_ids.end(), id); - EXIT_IF(it == m_connected_ids.end()); - - m_connected_ids.erase(it); - - CheckActive(); -} - -void GameController::CheckActive() { - int new_active_id = -1; - bool new_connected = false; - - if (!m_connected_ids.empty()) { - new_active_id = m_connected_ids[0]; - for (const auto id: m_connected_ids) { - if (id != HOST_INPUT_CONTROLLER_ID) { - new_active_id = id; - break; - } - } - new_connected = true; - } - - if (m_connected == new_connected && m_active_id == new_active_id) { - return; - } + const bool new_connected = active_id != -1; if (!m_connected && new_connected) { m_connected_count++; } - m_active_id = new_active_id; + m_active_id = active_id; m_connected = new_connected; m_state = {}; m_gyro_time = 0; @@ -513,7 +536,7 @@ void GameController::AddState() { void GameController::Button(int id, uint32_t button, bool down) { Common::LockGuard lock(m_mutex); - // The keyboard shares the player-1 pad with the active gamepad. + // Keyboard events are routed to the configured player's port. if (m_active_id == id || id == HOST_INPUT_CONTROLLER_ID) { m_state.time = LibKernel::KernelGetProcessTime(); @@ -798,39 +821,16 @@ void GameController::ResetInputState() { AddState(); } -void GameController::ReleaseHostPads() { - Common::LockGuard lock(m_mutex); - DualSenseHaptics::Shutdown(); - - std::vector pads; - for (const auto id: m_connected_ids) { - if (id == HOST_INPUT_CONTROLLER_ID) { - continue; - } - if (auto* pad = SDL_GetGamepadFromID(static_cast(id)); - pad != nullptr) { - if (SDL_GetGamepadType(pad) == SDL_GAMEPAD_TYPE_PS5) { - DualSenseEffects effect {}; - effect.enable_bits = 0x0c; - effect.right_trigger[0] = 0x05; - effect.left_trigger[0] = 0x05; - (void)SDL_SendGamepadEffect(pad, &effect, sizeof(effect)); - } - (void)SDL_RumbleGamepad(pad, 0, 0, 0); - (void)SDL_SetGamepadLED(pad, 0, 0, 0); - pads.push_back(pad); - } +static void StopHostPad(SDL_Gamepad* pad) { + if (SDL_GetGamepadType(pad) == SDL_GAMEPAD_TYPE_PS5) { + DualSenseEffects effect {}; + effect.enable_bits = 0x0c; + effect.right_trigger[0] = 0x05; + effect.left_trigger[0] = 0x05; + (void)SDL_SendGamepadEffect(pad, &effect, sizeof(effect)); } - - if (!pads.empty()) { - SDL_Delay(RELEASE_FLUSH_MS); - for (auto* pad: pads) { - SDL_CloseGamepad(pad); - } - } - - m_connected_ids.clear(); - CheckActive(); + (void)SDL_RumbleGamepad(pad, 0, 0, 0); + (void)SDL_SetGamepadLED(pad, 0, 0, 0); } void GameController::SetVibration(uint8_t large_motor, uint8_t small_motor) { @@ -847,7 +847,9 @@ void GameController::ApplyVibration() { const auto scale = duration != 0 ? GetSettingScale(Setting::VibrationIntensity) : 0.0f; const auto large_motor = Scale(m_vibration[0], scale); const auto small_motor = Scale(m_vibration[1], scale); - if (DualSenseHaptics::SetVibration(m_active_id, large_motor, small_motor, duration)) { + // The DualSense haptics backend drives a single pad, so only player 1 uses it. + if (m_player == 0 && + DualSenseHaptics::SetVibration(m_active_id, large_motor, small_motor, duration)) { return; } @@ -999,49 +1001,149 @@ int GameController::ReadStates(ControllerState* states, int states_num, bool* fl return ret_num; } +// Keep every opened pad, including those waiting for a free player slot. +// Caller holds g_router_mutex. +static void RefreshPlayers() { + std::array taken {}; + const int keyboard = KeyboardPlayer(); + if (g_keyboard_player != 0) { + taken[keyboard] = true; + } + for (auto& [id, player]: g_pad_players) { + if (g_keyboard_player != 0 && player == keyboard) { + player = -1; + } + if (player >= 0) { + taken[player] = true; + } + } + for (auto& [id, player]: g_pad_players) { + if (player >= 0) { + continue; + } + const auto free = std::find(taken.begin(), taken.end(), false); + if (free != taken.end()) { + player = static_cast(free - taken.begin()); + *free = true; + LOGF("Controller %d assigned to player %d\n", id, player + 1); + } + } + std::array active; + active.fill(-1); + active[keyboard] = HOST_INPUT_CONTROLLER_ID; + for (const auto& [id, player]: g_pad_players) { + if (player >= 0) { + active[player] = id; + } + } + // Stop old owners before configuring new owners of the same physical pad. + for (int player = 0; player < Config::MAX_PLAYERS; player++) { + const int previous = g_players[player]->GetActiveControllerId(); + if (previous != active[player]) { + if (player == 0) { + (void)DualSenseHaptics::SetVibration(previous, 0, 0, 0); + } + if (auto* pad = SDL_GetGamepadFromID(static_cast(previous))) { + StopHostPad(pad); + } + } + } + for (int player = 0; player < Config::MAX_PLAYERS; player++) { + g_players[player]->SetDevices(active[player]); + } +} + void Connect(int id) { - g_controller->Connect(id); + Common::LockGuard lock(g_router_mutex); + if (std::any_of(g_pad_players.begin(), g_pad_players.end(), + [id](const auto& pad) { return pad.first == id; })) { + return; + } + g_pad_players.emplace_back(id, -1); + RefreshPlayers(); } void Disconnect(int id) { - g_controller->Disconnect(id); + Common::LockGuard lock(g_router_mutex); + std::erase_if(g_pad_players, [id](const auto& pad) { return pad.first == id; }); + RefreshPlayers(); +} + +void CycleKeyboardPlayer() { + Common::LockGuard lock(g_router_mutex); + g_keyboard_player = (g_keyboard_player + 1) % (Config::MAX_PLAYERS + 1); + RefreshPlayers(); + if (g_keyboard_player == 0) { + LOGF("Keyboard shared with player 1\n"); + } else { + LOGF("Keyboard assigned to player %u\n", g_keyboard_player); + } } void SetButton(int id, uint32_t button, bool down) { - g_controller->Button(id, button, down); + Common::LockGuard lock(g_router_mutex); + if (auto* player = PlayerForDevice(id)) { + player->Button(id, button, down); + } } void SetAxis(int id, Axis axis, int value) { - g_controller->Axis(id, axis, value); + Common::LockGuard lock(g_router_mutex); + if (auto* player = PlayerForDevice(id)) { + player->Axis(id, axis, value); + } } void SetRightStick(int id, int x, int y) { - g_controller->RightStick(id, x, y); + Common::LockGuard lock(g_router_mutex); + if (auto* player = PlayerForDevice(id)) { + player->RightStick(id, x, y); + } } void SetTouchPad(int id, int finger, bool down, float x, float y) { - g_controller->TouchPad(id, finger, down, x, y); + Common::LockGuard lock(g_router_mutex); + if (auto* player = PlayerForDevice(id)) { + player->TouchPad(id, finger, down, x, y); + } } void SetSensor(int id, Sensor sensor, const float* data, uint64_t time_us) { - g_controller->Motion(id, sensor, data, time_us); + Common::LockGuard lock(g_router_mutex); + if (auto* player = PlayerForDevice(id)) { + player->Motion(id, sensor, data, time_us); + } } void ToggleGyroEmulation(int id) { - g_controller->ToggleGyroEmulation(id); + Common::LockGuard lock(g_router_mutex); + if (auto* player = PlayerForDevice(id)) { + player->ToggleGyroEmulation(id); + } } void ResetInputState() { - g_controller->ResetInputState(); + for (const auto& player: g_players) { + if (player != nullptr) { + player->ResetInputState(); + } + } } int GetActiveControllerId() { - return g_controller != nullptr ? g_controller->GetActiveControllerId() : -1; + return g_players[0] != nullptr ? g_players[0]->GetActiveControllerId() : -1; } -static bool PadOpenArgsAreValid(int user_id, int type, int index) { - // The host keyboard and gamepad feed one standard port for the configured user. - return user_id == Config::GetUserId() && type == 0 && index == 0; +bool IsPlayerConnected(int player) { + if (player < 0 || player >= Config::MAX_PLAYERS || g_players[player] == nullptr) { + return false; + } + return g_players[player]->GetActiveControllerId() != -1; +} + +bool GameController::PadOpenArgsAreValid(int user_id, int type, int index) const { + // The host keyboard and gamepads of a player feed one standard port for that player's user. + return user_id == Config::GetPlayerUserId(m_player) && type == 0 && index == 0; } int GameController::InitPort() { @@ -1064,7 +1166,7 @@ int GameController::OpenPort(int user_id, int type, int index) { return PAD_ERROR_ALREADY_OPENED; } m_port_state = PortState::Open; - return PAD_HANDLE; + return Handle(); } int GameController::GetPortHandle(int user_id, int type, int index) { @@ -1072,15 +1174,16 @@ int GameController::GetPortHandle(int user_id, int type, int index) { if (m_port_state == PortState::Uninitialized) { return PAD_ERROR_NOT_INITIALIZED; } - return m_port_state == PortState::Open && PadOpenArgsAreValid(user_id, type, index) ? PAD_HANDLE - : PAD_ERROR_NO_HANDLE; + return m_port_state == PortState::Open && PadOpenArgsAreValid(user_id, type, index) + ? Handle() + : PAD_ERROR_NO_HANDLE; } int GameController::CheckPortHandleLocked(int handle) const { if (m_port_state == PortState::Uninitialized) { return PAD_ERROR_NOT_INITIALIZED; } - return m_port_state == PortState::Open && handle == PAD_HANDLE ? OK : PAD_ERROR_INVALID_HANDLE; + return m_port_state == PortState::Open && handle == Handle() ? OK : PAD_ERROR_INVALID_HANDLE; } int GameController::CheckPortHandle(int handle) { @@ -1098,12 +1201,19 @@ int GameController::ClosePort(int handle) { } int PadCheckHandle(int handle) { - return g_controller->CheckPortHandle(handle); + if (g_players[0] == nullptr) { + return PAD_ERROR_NOT_INITIALIZED; + } + auto* player = PlayerForHandle(handle); + return player != nullptr ? player->CheckPortHandle(handle) : PAD_ERROR_INVALID_HANDLE; } int KYTY_SYSV_ABI PadInit() { PRINT_NAME(); - return g_controller->InitPort(); + for (const auto& player: g_players) { + (void)player->InitPort(); + } + return OK; } int KYTY_SYSV_ABI PadOpen(int user_id, int type, int index, const void* param) { @@ -1115,7 +1225,8 @@ int KYTY_SYSV_ABI PadOpen(int user_id, int type, int index, const void* param) { "\t param = 0x%016" PRIx64 "\n", user_id, type, index, reinterpret_cast(param)); - return g_controller->OpenPort(user_id, type, index); + const int player = Config::GetPlayerFromUserId(user_id); + return player >= 0 ? g_players[player]->OpenPort(user_id, type, index) : PAD_ERROR_INVALID_ARG; } int KYTY_SYSV_ABI PadGetHandle(int user_id, int type, int index) { @@ -1126,13 +1237,19 @@ int KYTY_SYSV_ABI PadGetHandle(int user_id, int type, int index) { "\t index = %d\n", user_id, type, index); - return g_controller->GetPortHandle(user_id, type, index); + const int player = Config::GetPlayerFromUserId(user_id); + return player >= 0 ? g_players[player]->GetPortHandle(user_id, type, index) + : PAD_ERROR_NO_HANDLE; } int KYTY_SYSV_ABI PadClose(int handle) { PRINT_NAME(); LOGF("\t handle = %d\n", handle); - return g_controller->ClosePort(handle); + auto* player = PlayerForHandle(handle); + if (player == nullptr) { + return PAD_ERROR_INVALID_HANDLE; + } + return player->ClosePort(handle); } int KYTY_SYSV_ABI PadSetMotionSensorState(int handle, bool enable) { @@ -1143,7 +1260,7 @@ int KYTY_SYSV_ABI PadSetMotionSensorState(int handle, bool enable) { } LOGF("\t enable = %s\n", (enable ? "true" : "false")); - g_controller->SetMotionSensorState(enable); + PlayerForHandle(handle)->SetMotionSensorState(enable); return OK; } @@ -1167,7 +1284,7 @@ int KYTY_SYSV_ABI PadResetOrientation(int handle) { return error; } - g_controller->ResetOrientation(); + PlayerForHandle(handle)->ResetOrientation(); return OK; } @@ -1183,7 +1300,7 @@ int KYTY_SYSV_ABI PadGetControllerInformation(int handle, PadControllerInformati int connected_count = 0; bool connected = false; - g_controller->GetConnectionInfo(&connected, &connected_count); + PlayerForHandle(handle)->GetConnectionInfo(&connected, &connected_count); std::memset(info, 0, sizeof(*info)); @@ -1228,7 +1345,7 @@ int KYTY_SYSV_ABI PadReadState(int handle, PadData* data) { bool connected = false; ControllerState state; - g_controller->ReadState(&state, &connected, &connected_count); + PlayerForHandle(handle)->ReadState(&state, &connected, &connected_count); pad_fill_data(data, state, connected, connected_count); @@ -1252,11 +1369,12 @@ int KYTY_SYSV_ABI PadRead(int handle, PadData* data, int num) { bool connected = false; ControllerState states[64] = {}; - int ret_num = g_controller->ReadStates(states, num, &connected, &connected_count); + auto* player = PlayerForHandle(handle); + int ret_num = player->ReadStates(states, num, &connected, &connected_count); if (!connected || ret_num == 0) { if (connected) { - g_controller->ReadState(&states[0], &connected, &connected_count); + player->ReadState(&states[0], &connected, &connected_count); } ret_num = 1; } @@ -1270,6 +1388,7 @@ int KYTY_SYSV_ABI PadRead(int handle, PadData* data, int num) { int KYTY_SYSV_ABI PadSetVibration(int handle, const PadVibrationParam* param) { PRINT_NAME(); + Common::LockGuard lock(g_router_mutex); if (const auto error = PadCheckHandle(handle); error != OK) { return error; @@ -1282,7 +1401,7 @@ int KYTY_SYSV_ABI PadSetVibration(int handle, const PadVibrationParam* param) { "\t small_motor = %d\n", static_cast(param->large_motor), static_cast(param->small_motor)); - g_controller->SetVibration(param->large_motor, param->small_motor); + PlayerForHandle(handle)->SetVibration(param->large_motor, param->small_motor); return OK; } @@ -1299,6 +1418,7 @@ int KYTY_SYSV_ABI PadResetLightBar(int handle) { int KYTY_SYSV_ABI PadSetLightBar(int handle, const PadLightBarParam* param) { PRINT_NAME(); + Common::LockGuard lock(g_router_mutex); if (const auto error = PadCheckHandle(handle); error != OK) { return error; @@ -1307,13 +1427,14 @@ int KYTY_SYSV_ABI PadSetLightBar(int handle, const PadLightBarParam* param) { return PAD_ERROR_INVALID_ARG; } - g_controller->SetLightBar(param->r, param->g, param->b); + PlayerForHandle(handle)->SetLightBar(param->r, param->g, param->b); return OK; } int KYTY_SYSV_ABI PadSetTriggerEffect(int handle, const PadTriggerEffectParam* param) { PRINT_NAME(); + Common::LockGuard lock(g_router_mutex); if (const auto error = PadCheckHandle(handle); error != OK) { return error; @@ -1322,7 +1443,7 @@ int KYTY_SYSV_ABI PadSetTriggerEffect(int handle, const PadTriggerEffectParam* p return PAD_ERROR_INVALID_ARG; } - return g_controller->SetTriggerEffect(*param) ? OK : PAD_ERROR_INVALID_ARG; + return PlayerForHandle(handle)->SetTriggerEffect(*param) ? OK : PAD_ERROR_INVALID_ARG; } int KYTY_SYSV_ABI PadGetTriggerEffectState(int handle, PadTriggerEffectStateInformation* info) { @@ -1337,7 +1458,7 @@ int KYTY_SYSV_ABI PadGetTriggerEffectState(int handle, PadTriggerEffectStateInfo return PAD_ERROR_INVALID_ARG; } - g_controller->GetTriggerEffectState(info); + PlayerForHandle(handle)->GetTriggerEffectState(info); return OK; } diff --git a/src/libs/controller.h b/src/libs/controller.h index c53641261..bdae417ac 100644 --- a/src/libs/controller.h +++ b/src/libs/controller.h @@ -68,6 +68,8 @@ inline int controller_get_axis(int min, int max, int value) { void Connect(int id); void Disconnect(int id); +// Moves the keyboard to the next player (shared with player 1, then 1-4) while the game runs. +void CycleKeyboardPlayer(); void SetButton(int id, uint32_t button, bool down); void SetAxis(int id, Axis axis, int value); void SetRightStick(int id, int x, int y); @@ -76,6 +78,8 @@ void SetSensor(int id, Sensor sensor, const float* data, uint64_t time_us); void ToggleGyroEmulation(int id); void ResetInputState(); int GetActiveControllerId(); +// True when the player has a keyboard or gamepad assigned. +bool IsPlayerConnected(int player); enum class Setting { SpeakerVolume, VibrationIntensity, TriggerEffectIntensity }; diff --git a/src/libs/libSaveData.cpp b/src/libs/libSaveData.cpp index 21e02c0eb..0b24e296e 100644 --- a/src/libs/libSaveData.cpp +++ b/src/libs/libSaveData.cpp @@ -1,6 +1,7 @@ #include "common/abi.h" #include "common/assert.h" #include "common/common.h" +#include "common/emulatorConfig.h" #include "common/file.h" #include "common/logging/log.h" #include "common/stringUtils.h" @@ -300,15 +301,23 @@ static std::string memory_dir_name(uint32_t slot_id) { return "sce_sdmemory" + (slot_id == 0 ? std::string() : std::to_string(slot_id)); } +static std::filesystem::path save_user_directory(std::string_view title_id, int32_t user_id) { + auto directory = std::filesystem::path(SAVE_DATA_DIR); + if (Config::IsGuestUserId(user_id)) { + directory /= std::filesystem::path("[users]") / std::to_string(user_id); + } + return directory / title_id; +} + static std::filesystem::path save_directory(std::string_view title_id, std::string_view dir_name, int32_t user_id) { - const auto directory = std::filesystem::path(SAVE_DATA_DIR) / title_id / dir_name; for (uint32_t slot = 0; slot < 4; slot++) { if (dir_name == memory_dir_name(slot)) { - return directory / std::to_string(user_id); + return std::filesystem::path(SAVE_DATA_DIR) / title_id / dir_name / + std::to_string(user_id); } } - return directory; + return save_user_directory(title_id, user_id) / dir_name; } static std::filesystem::path memory_directory(int32_t user_id, uint32_t slot_id) { @@ -590,7 +599,7 @@ int KYTY_SYSV_ABI SaveDataDirNameSearch(const SaveDataDirNameSearchCond* cond, Common::LockGuard lock(g_mount_mutex); const std::string title_id = cond->title_id != nullptr ? cond->title_id->data : get_title_id(); - const auto root = std::filesystem::path(SAVE_DATA_DIR) / title_id; + const auto root = save_user_directory(title_id, cond->user_id); std::vector dir_list; if (Common::File::IsDirectoryExisting(root)) { diff --git a/src/libs/libTrophy.cpp b/src/libs/libTrophy.cpp index 4c2fba237..65f83fc3e 100644 --- a/src/libs/libTrophy.cpp +++ b/src/libs/libTrophy.cpp @@ -105,6 +105,8 @@ struct TrophyState { const Trophies::Package* package = nullptr; Trophies::UnlockData unlocks; std::filesystem::path path; + // Guests (players 2+) have no profile: nothing is loaded, saved or announced. + bool guest = false; }; struct TrophyContext { @@ -153,9 +155,12 @@ static TrophyState* LoadTrophyState(TrophyKey key) { package.event_rules.size(), uds_filename.c_str()); } } - auto path = Trophies::UnlocksPath({}, title_id, key.first, key.second); - auto unlocks = Trophies::LoadUnlockData(path); - return &g_states.emplace(key, TrophyState {&package, std::move(unlocks), std::move(path)}) + const bool guest = Config::IsGuestUserId(key.first); + auto path = guest ? std::filesystem::path {} + : Trophies::UnlocksPath({}, title_id, key.first, key.second); + auto unlocks = guest ? Trophies::UnlockData {} : Trophies::LoadUnlockData(path); + return &g_states + .emplace(key, TrophyState {&package, std::move(unlocks), std::move(path), guest}) .first->second; } @@ -240,6 +245,10 @@ static void FillGroupInfo(const TrophyState& state, int group_id, const std::str } static void RecordTrophyUnlock(TrophyState& state, int trophy_id, bool from_stat = false) { + if (state.guest) { + LOGF("[Trophy] guest users cannot earn trophies (trophy %d)\n", trophy_id); + return; + } const auto found = state.package->trophies.find(trophy_id); if (found == state.package->trophies.end()) { LOGF("[Trophy] ignored unknown trophy ID %d\n", trophy_id); diff --git a/src/libs/libUserService.cpp b/src/libs/libUserService.cpp index 1084a894d..0380f9ba2 100644 --- a/src/libs/libUserService.cpp +++ b/src/libs/libUserService.cpp @@ -1,12 +1,14 @@ #include "common/abi.h" #include "common/assert.h" #include "common/emulatorConfig.h" +#include "common/localization.h" +#include "libs/controller.h" #include "libs/errno.h" #include "libs/libs.h" #include "loader/symbolDatabase.h" -#include #include +#include namespace Libs { @@ -39,6 +41,15 @@ struct UserServiceGamePresets { static_assert(sizeof(UserServiceGamePresets) == 40); +static bool IsPlayerLoggedIn(int player) { + return player == 0 || (player > 0 && Controller::IsPlayerConnected(player)); +} + +static int GetLoggedInPlayer(int user_id) { + const int player = Config::GetPlayerFromUserId(user_id); + return IsPlayerLoggedIn(player) ? player : -1; +} + static KYTY_SYSV_ABI int UserServiceInitialize(const void* /*params*/) { PRINT_NAME(); @@ -66,13 +77,16 @@ static KYTY_SYSV_ABI int UserServiceGetEvent(SceUserServiceEvent* event) { EXIT_NOT_IMPLEMENTED(event == nullptr); - static bool logged_in = false; + static bool logged_in[Config::MAX_PLAYERS] = {}; - if (!logged_in) { - logged_in = true; - event->event_type = UserServiceEventTypeLogin; - event->user_id = Config::GetUserId(); - return OK; + for (int player = 0; player < Config::MAX_PLAYERS; player++) { + const bool connected = IsPlayerLoggedIn(player); + if (connected != logged_in[player]) { + logged_in[player] = connected; + event->event_type = connected ? UserServiceEventTypeLogin : UserServiceEventTypeLogout; + event->user_id = Config::GetPlayerUserId(player); + return OK; + } } return USER_SERVICE_ERROR_NO_EVENT; @@ -83,24 +97,36 @@ static KYTY_SYSV_ABI int UserServiceGetLoginUserIdList(UserServiceLoginUserIdLis EXIT_NOT_IMPLEMENTED(user_id_list == nullptr); - user_id_list->user_id[0] = Config::GetUserId(); - user_id_list->user_id[1] = -1; - user_id_list->user_id[2] = -1; - user_id_list->user_id[3] = -1; + int count = 0; + for (int player = 0; player < Config::MAX_PLAYERS; player++) { + if (IsPlayerLoggedIn(player)) { + user_id_list->user_id[count++] = Config::GetPlayerUserId(player); + } + } + while (count < Config::MAX_PLAYERS) { + user_id_list->user_id[count++] = -1; + } return OK; } static KYTY_SYSV_ABI int UserServiceGetUserName(int user_id, char* name, size_t size) { - if (user_id != Config::GetUserId()) { + if (name == nullptr) { + return USER_SERVICE_ERROR_INVALID_ARGUMENT; + } + const int player = GetLoggedInPlayer(user_id); + if (player < 0) { return USER_SERVICE_ERROR_NOT_LOGGED_IN; } - EXIT_NOT_IMPLEMENTED(size < 5); - const auto& user_name = Config::GetUserName(); - int s = snprintf(name, size, "%s", user_name.c_str()); - - EXIT_NOT_IMPLEMENTED(static_cast(s) >= size); + const auto user_name = + player > 0 ? std::string(Common::Localization::Get(Config::GetConsoleLanguage(), "user.guest")) + + " " + std::to_string(player + 1) + : Config::GetUserName(); + if (size <= user_name.size()) { + return USER_SERVICE_ERROR_BUFFER_TOO_SHORT; + } + std::memcpy(name, user_name.c_str(), user_name.size() + 1); return OK; } @@ -111,11 +137,12 @@ static KYTY_SYSV_ABI int UserServiceGetUserNumber(int user_id, int32_t* number) if (number == nullptr) { return USER_SERVICE_ERROR_INVALID_ARGUMENT; } - if (user_id != Config::GetUserId()) { + const int player = GetLoggedInPlayer(user_id); + if (player < 0) { return USER_SERVICE_ERROR_NOT_LOGGED_IN; } - *number = 1; + *number = player + 1; return OK; } @@ -126,7 +153,7 @@ static KYTY_SYSV_ABI int UserServiceGetGamePresets(int user_id, UserServiceGameP if (presets == nullptr) { return USER_SERVICE_ERROR_INVALID_ARGUMENT; } - if (user_id != Config::GetUserId()) { + if (GetLoggedInPlayer(user_id) < 0) { return USER_SERVICE_ERROR_NOT_LOGGED_IN; } @@ -147,7 +174,7 @@ static KYTY_SYSV_ABI int UserServiceGetAccessibilityVibration(int user_id, int32 if (vibration == nullptr) { return USER_SERVICE_ERROR_INVALID_ARGUMENT; } - if (user_id != Config::GetUserId()) { + if (GetLoggedInPlayer(user_id) < 0) { return USER_SERVICE_ERROR_NOT_LOGGED_IN; } @@ -163,7 +190,7 @@ static KYTY_SYSV_ABI int UserServiceGetAccessibilityTriggerEffect(int user_ if (trigger_effect == nullptr) { return USER_SERVICE_ERROR_INVALID_ARGUMENT; } - if (user_id != Config::GetUserId()) { + if (GetLoggedInPlayer(user_id) < 0) { return USER_SERVICE_ERROR_NOT_LOGGED_IN; } @@ -178,7 +205,7 @@ static KYTY_SYSV_ABI int UserServiceGetAgeLevel(int user_id, uint32_t* age_level if (age_level == nullptr) { return USER_SERVICE_ERROR_INVALID_ARGUMENT; } - if (user_id != Config::GetUserId()) { + if (GetLoggedInPlayer(user_id) < 0) { return USER_SERVICE_ERROR_NOT_LOGGED_IN; } @@ -194,7 +221,7 @@ static KYTY_SYSV_ABI int UserServiceGetAccessibilityChatTranscription(int u if (chat_transcription == nullptr) { return USER_SERVICE_ERROR_INVALID_ARGUMENT; } - if (user_id != Config::GetUserId()) { + if (GetLoggedInPlayer(user_id) < 0) { return USER_SERVICE_ERROR_NOT_LOGGED_IN; } @@ -210,7 +237,7 @@ UserServiceGetAccessibilityPressAndHoldDelay(int user_id, int32_t* press_and_hol if (press_and_hold_delay == nullptr) { return USER_SERVICE_ERROR_INVALID_ARGUMENT; } - if (user_id != Config::GetUserId()) { + if (GetLoggedInPlayer(user_id) < 0) { return USER_SERVICE_ERROR_NOT_LOGGED_IN; } @@ -226,7 +253,7 @@ static KYTY_SYSV_ABI int UserServiceGetAccessibilityZoomEnabled(int user_id if (zoom_enabled == nullptr) { return USER_SERVICE_ERROR_INVALID_ARGUMENT; } - if (user_id != Config::GetUserId()) { + if (GetLoggedInPlayer(user_id) < 0) { return USER_SERVICE_ERROR_NOT_LOGGED_IN; } @@ -242,7 +269,7 @@ static KYTY_SYSV_ABI int UserServiceGetAccessibilityZoomFollowFocus(int use if (zoom_follow_focus == nullptr) { return USER_SERVICE_ERROR_INVALID_ARGUMENT; } - if (user_id != Config::GetUserId()) { + if (GetLoggedInPlayer(user_id) < 0) { return USER_SERVICE_ERROR_NOT_LOGGED_IN; } diff --git a/src/main.cpp b/src/main.cpp index aba07ef11..1846af168 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -57,6 +57,7 @@ static void PrintUsage() { ::printf(" --controller-color <#RRGGBB> Override the controller lightbar color.\n"); ::printf(" --controller-volume <0-100> DualSense speaker volume. Default: 50.\n"); ::printf(" --controller-vibration <0-100> DualSense vibration intensity. Default: 100.\n"); + ::printf(" --keyboard-player <0-4> Keyboard player; 0 shares player 1 with the gamepad. Default: 0.\n"); ::printf( " --present-mode Fifo, Mailbox, or Immediate. Default: Mailbox.\n"); ::printf( @@ -378,6 +379,12 @@ static bool ParseArgs(int argc, char* argv[], RunOptions& options, bool& show_he ::printf("invalid controller vibration intensity: %s\n", value.c_str()); return false; } + } else if (arg == "--keyboard-player") { + if (!ParseUint32(value, options.config.keyboard_player) || + options.config.keyboard_player > static_cast(Config::MAX_PLAYERS)) { + ::printf("invalid keyboard player: %s\n", value.c_str()); + return false; + } } else if (arg == "--present-mode") { if (!ParseEnum(value, options.config.present_mode)) { ::printf("invalid present mode: %s\n", value.c_str()); diff --git a/tests/ControllerSettingsTests.cpp b/tests/ControllerSettingsTests.cpp index 0ab53f532..fe01678d3 100644 --- a/tests/ControllerSettingsTests.cpp +++ b/tests/ControllerSettingsTests.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace { @@ -23,6 +24,9 @@ std::vector rumble; std::vector haptics; std::vector> effects; bool haptics_handles_rumble = false; +std::array pad_rumble {}; +std::vector closed_pads; +std::array, 6> pad_colors {}; } // namespace namespace Fake { @@ -30,14 +34,15 @@ Uint64 GetTicks() { return now; } SDL_Gamepad* GetGamepadFromID(SDL_JoystickID id) { - return id == 1 || id == 2 ? reinterpret_cast(static_cast(id)) + return id >= 1 && id <= 5 ? reinterpret_cast(static_cast(id)) : nullptr; } SDL_GamepadType GetGamepadType(SDL_Gamepad*) { return SDL_GAMEPAD_TYPE_PS5; } -bool RumbleGamepad(SDL_Gamepad*, Uint16 large, Uint16 small, Uint32 duration) { +bool RumbleGamepad(SDL_Gamepad* pad, Uint16 large, Uint16 small, Uint32 duration) { rumble.push_back({large, small, duration}); + pad_rumble[reinterpret_cast(pad)] = rumble.back(); return true; } bool SendGamepadEffect(SDL_Gamepad*, const void* data, int size) { @@ -47,7 +52,8 @@ bool SendGamepadEffect(SDL_Gamepad*, const void* data, int size) { effects.push_back(effect); return true; } -bool SetGamepadLED(SDL_Gamepad*, Uint8, Uint8, Uint8) { +bool SetGamepadLED(SDL_Gamepad* pad, Uint8 r, Uint8 g, Uint8 b) { + pad_colors[reinterpret_cast(pad)] = {r, g, b}; return true; } bool GamepadHasSensor(SDL_Gamepad*, SDL_SensorType) { @@ -56,7 +62,9 @@ bool GamepadHasSensor(SDL_Gamepad*, SDL_SensorType) { bool SetGamepadSensorEnabled(SDL_Gamepad*, SDL_SensorType, bool) { return true; } -void CloseGamepad(SDL_Gamepad*) {} +void CloseGamepad(SDL_Gamepad* pad) { + closed_pads.push_back(static_cast(reinterpret_cast(pad))); +} void Delay(Uint32) {} } // namespace Fake @@ -368,6 +376,10 @@ void TestIndependentOutputsAndPadSwitch() { "speaker volume resent controller effects"); Connect(2); Disconnect(1); + Check(GetActiveControllerId() == HOST_INPUT_CONTROLLER_ID, + "disconnecting player 1 stole player 2's pad"); + Disconnect(2); + Connect(2); Check(GetActiveControllerId() == 2, "active controller did not change"); haptics.clear(); effects.clear(); @@ -394,6 +406,126 @@ void TestIndependentOutputsAndPadSwitch() { } } +void TestPlayerUserIds() { + Check(Config::IsConfiguredUserIdValid(Config::MAX_USER_ID) && + !Config::IsConfiguredUserIdValid(Config::MAX_USER_ID + 1) && + !Config::IsConfiguredUserIdValid(254) && !Config::IsConfiguredUserIdValid(255), + "configured user IDs did not reserve a valid range for guests"); + Config::ConfigOptions options; + options.user_id = Config::MAX_USER_ID; + Config::Load(options); + for (int player = 0; player < Config::MAX_PLAYERS; player++) { + Check(Config::GetPlayerFromUserId(Config::GetPlayerUserId(player)) == player, + "player user ID did not round-trip at the upper limit"); + } + Check(Config::GetPlayerUserId(3) == std::numeric_limits::max() && + Config::GetPlayerFromUserId(-1) == -1, + "guest user ID overflowed or accepted an unknown user"); + Config::Load(Config::ConfigOptions {}); +} + +void TestMultiplayerRouting() { + Controller controller; + for (int player = 1; player < Config::MAX_PLAYERS; player++) { + Check(PadOpen(Config::GetPlayerUserId(player), 0, 0, nullptr) == player + 1, + "a guest could not open its own pad port"); + } + for (int id = 2; id <= 5; id++) { + Connect(id); + } + Connect(2); // Duplicate device reports must not consume another port. + const auto read = [](int handle) { + PadData data {}; + Check(PadReadState(handle, &data) == OK, "player pad read failed"); + return data; + }; + SetButton(1, PAD_BUTTON_CROSS, true); + SetButton(2, PAD_BUTTON_CIRCLE, true); + SetButton(5, PAD_BUTTON_TRIANGLE, true); + Check(read(1).buttons == PAD_BUTTON_CROSS && read(2).buttons == PAD_BUTTON_CIRCLE && + read(3).buttons == 0 && read(4).buttons == 0, + "physical pads did not route independently or overflow reached a player"); + + SetRumble(100, 50); + CycleKeyboardPlayer(); // Dedicated player 1; physical pad 1 waits. + Check(GetActiveControllerId() == HOST_INPUT_CONTROLLER_ID && + read(2).buttons == PAD_BUTTON_CIRCLE && pad_rumble[1].large == 0 && + pad_rumble[1].small == 0, + "keyboard reassignment changed an unaffected player or left pad output " + "running"); + for (int mode = 2; mode <= Config::MAX_PLAYERS; mode++) { + CycleKeyboardPlayer(); + } + CycleKeyboardPlayer(); // Shared player 1; all original pads must return. + ResetInputState(); + for (int id = 1; id <= 5; id++) { + SetButton(id, 1u << id, true); + } + for (int handle = 1; handle <= Config::MAX_PLAYERS; handle++) { + Check(read(handle).buttons == (1u << handle), + "cycling keyboard players lost or duplicated a connected pad"); + } + + // Moving the keyboard clears its held input from the old player. + SetButton(HOST_INPUT_CONTROLLER_ID, PAD_BUTTON_TRIANGLE, true); + CycleKeyboardPlayer(); + CycleKeyboardPlayer(); + SetButton(HOST_INPUT_CONTROLLER_ID, PAD_BUTTON_TRIANGLE, true); + CycleKeyboardPlayer(); // Keyboard now moves from player 2 to player 3. + Check((read(2).buttons & PAD_BUTTON_TRIANGLE) == 0, + "moving the keyboard retained a held button on its old player"); + CycleKeyboardPlayer(); + CycleKeyboardPlayer(); + Disconnect(2); + SetButton(5, PAD_BUTTON_SQUARE, true); + Check(read(2).buttons == PAD_BUTTON_SQUARE && GetActiveControllerId() == 1, + "waiting pad did not fill a free slot or an unaffected pad moved"); + Disconnect(99); + Check(PadCheckHandle(std::numeric_limits::min()) == PAD_ERROR_INVALID_HANDLE && + PadClose(std::numeric_limits::max()) == PAD_ERROR_INVALID_HANDLE, + "an extreme handle aliased a player port"); + closed_pads.clear(); + EmergencyShutdown(); + std::sort(closed_pads.begin(), closed_pads.end()); + Check(closed_pads == std::vector {1, 3, 4, 5}, + "shutdown did not close each registered host pad exactly once"); +} + +void TestDedicatedKeyboardOutputs() { + Config::ConfigOptions options; + options.keyboard_player = 1; + options.controller_color = Config::ControllerColor {30, 40, 50}; + Config::Load(options); + Initialize(); + PadInit(); + for (int player = 0; player < Config::MAX_PLAYERS; player++) { + Check(PadOpen(Config::GetPlayerUserId(player), 0, 0, nullptr) == player + 1, + "dedicated keyboard port did not open"); + } + Connect(1); + Connect(2); + const PadVibrationParam request {100, 50}; + Check(PadSetVibration(3, &request) == OK, "player three vibration failed"); + SetButton(2, PAD_BUTTON_CROSS, true); + CycleKeyboardPlayer(); // Pad 1 moves from player 2 to player 1. + PadData data {}; + Check(GetActiveControllerId() == 1 && pad_colors[1] == *options.controller_color, + "moving a pad to an earlier slot cleared its configured lightbar"); + Check(PadReadState(3, &data) == OK && data.buttons == PAD_BUTTON_CROSS && + pad_rumble[2].large == 100 * 257 && pad_rumble[2].small == 50 * 257, + "keyboard reassignment reset an unaffected player's input or output"); + haptics_handles_rumble = true; + SetRumble(200, 100); + for (int mode = 0; mode < 4; mode++) { + CycleKeyboardPlayer(); + } + Check(GetActiveControllerId() == HOST_INPUT_CONTROLLER_ID && haptics.back().large == 0 && + haptics.back().small == 0 && haptics.back().duration == 0, + "moving player one's pad retained its DualSense vibration request"); + Shutdown(); + Config::Load(Config::ConfigOptions {}); +} + void TestTriggerEffectState() { Controller controller; PadTriggerEffectStateInformation info {{-1, -1}}; @@ -553,6 +685,7 @@ void TestTriggerEffectState() { int main() { Config::Initialize(); + TestPlayerUserIds(); TestPadPortOwnership(); TestSettingCycles(); TestGlobalControllerLevels(); @@ -561,6 +694,8 @@ int main() { TestMaskedTriggersAndValidation(); TestIndependentOutputsAndPadSwitch(); TestTriggerEffectState(); + TestMultiplayerRouting(); + TestDedicatedKeyboardOutputs(); Config::Shutdown(); return 0; } diff --git a/tests/SaveDataMemoryTests.cpp b/tests/SaveDataMemoryTests.cpp index 87d14fb3f..8f976d2bc 100644 --- a/tests/SaveDataMemoryTests.cpp +++ b/tests/SaveDataMemoryTests.cpp @@ -378,7 +378,7 @@ void TestClassicSavePaths() { CHECK(SaveDataUmount2(0, &result.mount_point) == OK); } Reset("CLASSIC"); - for (const char* text: {"", ".", "..", "../save1", "save/1", "save\\1"}) { + for (const char* text: {"", ".", "..", "../save1", "save/1", "save\\1", "[users]"}) { auto name = DirName(text); struct SaveDataMount3 mount {}; mount.user_id = 1; @@ -396,6 +396,57 @@ void TestClassicSavePaths() { CHECK(Search(1) == names); } +void TestGuestSaveIsolation() { + Reset("MULTIUSER"); + const auto name = DirName("slot"); + const std::array users {Config::GetUserId(), Config::GetPlayerUserId(1), + Config::GetPlayerUserId(2)}; + std::array paths; + for (size_t i = 0; i < users.size(); ++i) { + CHECK(Search(users[i]).empty()); + struct SaveDataMount3 mount {}; + mount.user_id = users[i]; + mount.dir_name = &name; + mount.mount_mode = 4; + mount.blocks = 48; + SaveDataMountResult result {}; + CHECK(SaveDataMount3(&mount, &result) == OK); + paths[i] = g_mounted_directory; + std::ofstream(paths[i] / "progress") << users[i]; + CHECK(SaveDataUmount2(0, &result.mount_point) == OK); + CHECK(Search(users[i]) == std::vector {"slot"}); + } + CHECK(paths[0] == fs::path("_SaveData") / "MULTIUSER" / "slot"); + CHECK(paths[0] != paths[1] && paths[1] != paths[2] && paths[0] != paths[2]); + SceSaveDataTitleId title {}; + std::strcpy(title.data, "MULTIUSER"); + Reset("OTHER"); + for (size_t i = 0; i < users.size(); ++i) { + CHECK(Search(users[i]).empty()); + CHECK(Search(users[i], &title) == std::vector {"slot"}); + struct SaveDataTransferringMount transfer {}; + transfer.user_id = users[i]; + transfer.title_id = &title; + transfer.dir_name = &name; + SaveDataMountResult result {}; + CHECK(SaveDataTransferringMount(&transfer, &result) == OK); + CHECK(g_mounted_directory == paths[i]); + std::ifstream progress(paths[i] / "progress"); + int saved_user = -1; + progress >> saved_user; + CHECK(saved_user == users[i]); + CHECK(SaveDataUmount2(0, &result.mount_point) == OK); + } + struct SaveDataDelete remove {}; + remove.user_id = users[1]; + remove.title_id = &title; + remove.dir_name = &name; + CHECK(SaveDataDelete(&remove) == OK); + CHECK(Search(users[1], &title).empty()); + CHECK(Search(users[0], &title) == std::vector {"slot"}); + CHECK(Search(users[2], &title) == std::vector {"slot"}); +} + void TestSaveAllocations() { Reset("CAPACITY"); std::array names {DirName("Options"), DirName("Player")}; @@ -596,6 +647,7 @@ void RunChild(const fs::path& executable, const char* mode) { } // namespace int main(int argc, char** argv) { + Config::Initialize(); if (argc == 2) { if (std::string_view(argv[1]) == "write") { WriteRestartFixture(); @@ -603,6 +655,7 @@ int main(int argc, char** argv) { CHECK(std::string_view(argv[1]) == "read"); ReadRestartFixture(); } + Config::Shutdown(); return 0; } const auto executable = fs::absolute(argv[0]); @@ -617,10 +670,12 @@ int main(int argc, char** argv) { TestIsolationAndSync(); TestFailedWritePreservesSave(); TestClassicSavePaths(); + TestGuestSaveIsolation(); TestSaveAllocations(); TestClassicSaveParams(); CHECK(SaveDataTerminate() == OK); fs::current_path(previous); fs::remove_all(temp); + Config::Shutdown(); return 0; }