From 873fde41d03742db5e8e6a9f203385969b3f6e73 Mon Sep 17 00:00:00 2001 From: John Date: Sat, 7 Nov 2026 02:26:31 +0100 Subject: [PATCH] [common, controller] [AI] Controller assignment modes, Controllers tab, Steam Gamemode/Gamescope fixes (gyroscope, controller) (#4583) ## Summary Automatic controller assignment for Eden, aimed at couch co-op and handheld PCs (Steam Deck, Legion Go, ROG Ally, AYN Odin), plus a set of fixes for running Eden in Steam's Game Mode (gamescope) and under Steam Input. ## Controller Assignment Handler Settings > Controls gains a **Controllers** tab. The handler is a segmented choice: | Handler | Behaviour | |---|---| | **Modern Console** | Automatic: each controller that connects becomes the next player (up to 8). | | **Manual** (default) | Players are set in each player's tab, as before. Its Controller Applet choice: **Disabled**, **Custom Frontend** or **Real Hardware** (the console's own applet, LLE). | Under Modern Console, **Portable Handheld Gaming Device with Built-in Controller** (on/off) makes external controllers take the players first, with the handheld's own controls used only when none is connected. In the automatic modes: - Players follow their controllers across reconnects: a controller that leaves keeps its player for 45 seconds and takes it back when it returns. Each controller keeps its own mappings when it changes player, for the session. - At most eight players; a ninth controller waits until a player is free. - The user's saved controller setup is never rewritten; remaps made in the Controls tab last for the session, and input profiles are what persist. - Input profiles can be picked or removed per player, from the Controllers tab or each player tab. - A controller whose player has no controller mappings (a fresh setup) gets its default mapping. A DSU (cemuhook) motion binding from the manual setup is kept, for handhelds whose own gyro needs it. ## Gameplay options - **Pause When a Controller Disconnects** (on by default): the game pauses when a player's controller is turned off, or has been gone for 3 seconds, and resumes when a controller takes that player again. Pressing + on another controller goes on without it. - **Use Any Controller as Joy-Cons**: a game that only takes Joy-Cons gets them automatically (dual, or a single sideways one with the buttons arranged to fit). - **Button Layout**: By Position or By Label, for Xbox and PlayStation style controllers. - **Player Lights**: controllers that have them show their player number. ## Controllers tab and player tabs - Connected Players: each player with its controller and battery, profile picker, Swap, Config, Turn Off (Bluetooth), reorder arrows, drag to swap, and click to rumble. Controllers can be given a name for the session. - Assign Handheld Controller: auto-detect, or pick/Identify the handheld's own pad. - P1-P8 tabs show each player's battery and can be dragged or moved with arrows to change the player order. - In Modern Console the Controllers and player tabs use a modern card layout that follows the light and dark themes, including the system's dark mode (macOS). ## On-screen display - **Controller Overlay** (View menu, Ctrl+Shift+V): players, their controllers and batteries, and each player's recent buttons, including stick moves (LS/RS) and clicks (L3/R3). How many inputs are kept and for how long can be set. - **Controller Hints**: names a controller in the bottom-left corner when it joins or leaves a player, and says when the game pauses for a disconnected one. Works without the overlay. Both are drawn into the presented frame as crisp overlay panels (not scaled with the game, not in screenshots), so they show inside gamescope. ## Steam Input and gamescope fixes - **Native gyro under Steam Input.** Steam's virtual pads carry no motion sensors. Eden now opens the real motion-capable controller behind each virtual pad for its sensors only (Steam Deck, Steam Controller incl. the 2026 model, Sony, Nintendo), and turns on the Deck's IMU, which Steam keeps off unless the layout uses the gyro. No DSU server or Steam layout change needed. - **Bypass Steam Input** (Controllers tab, shown when launched through Steam): Eden opens the real controllers itself instead of Steam's virtual pads. - **Controller identity under Steam.** All virtual pads share one GUID and Steam moves physical controllers between them; controllers are tracked by their Steam handle and slot instead. - **Handheld detection under Steam.** The Deck's own controls are recognised behind Steam Input without mistaking standalone controllers (e.g. the Steam Controller) for them. - **Windows no longer rescale on hover in Game Mode.** gamescope scales a window to fit the screen together with any tooltip reaching past it, so the main window and Settings grew and shrank on hover. Under gamescope they now fill the screen. - **VSync modes in Game Mode.** gamescope offers only some present modes, so Immediate and FIFO Relaxed vanished silently. They are now listed greyed out with a tooltip pointing to Steam's Disable Frame Limit and Allow Tearing settings. - **Stick range testers on small screens.** The controller drawing scales down to fit on 800-pixel screens instead of cutting off the stick range circles. - **Midnight Blue themes.** A hovered menu-bar item is readable again, and hovered tabs no longer change size (which made the Settings tabs shake). ## Fixes - Crash when HID received a controller update before NPad's applet resource was set up. ## Testing Tested on a Steam Deck (Desktop and Game Mode, direct and Steam launches) with Xbox controllers, plus a stress test with up to 10 virtual controllers connecting, disconnecting and pressing at random in both automatic modes (no assignment errors, never more than 8 players, disconnect pauses resume as expected). ## AI disclosure Developed with Claude Opus 5.5 (implementation and debugging). I reviewed, tested and can explain every change. - [x] I have read and followed the [Contribution Guidelines](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/CONTRIBUTING.md#code-contributions). - [x] I have read and followed the [AI Policy](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/docs/policies/AI.md). - [x] I have read and followed the [Coding Guidelines](https://git.eden-emu.dev/eden-emu/eden/src/branch/master/docs/policies/Coding.md) to the best of my ability. --- Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/4583 Reviewed-by: CamilleLaVey --- .../qdarkstyle_midnight_blue/style.qss | 4 +- src/common/controller_console_state.h | 125 + src/common/overlay_panels.h | 107 + src/common/settings.h | 60 + src/common/settings_enums.h | 2 +- src/hid_core/hid_core.cpp | 9 + src/hid_core/hid_core.h | 10 + src/hid_core/resources/npad/npad.cpp | 20 +- src/input_common/drivers/sdl_driver.cpp | 241 +- src/input_common/drivers/sdl_driver.h | 17 + .../helpers/stick_from_buttons.cpp | 33 +- src/input_common/input_poller.cpp | 21 +- src/input_common/main.cpp | 17 + src/input_common/main.h | 13 +- src/qt_common/config/shared_translation.cpp | 40 +- src/qt_common/config/uisettings.cpp | 3 +- src/qt_common/config/uisettings.h | 2 +- .../renderer_vulkan/present/filters.cpp | 38 +- .../renderer_vulkan/present/filters.h | 20 +- .../present/window_adapt_pass.cpp | 156 +- .../present/window_adapt_pass.h | 39 +- .../renderer_vulkan/renderer_vulkan.cpp | 2 + .../renderer_vulkan/vk_blit_screen.cpp | 27 +- .../renderer_vulkan/vk_blit_screen.h | 7 + src/yuzu/CMakeLists.txt | 8 + src/yuzu/applets/qt_software_keyboard.cpp | 7 +- src/yuzu/configuration/configure_applets.cpp | 25 + src/yuzu/configuration/configure_applets.h | 7 +- src/yuzu/configuration/configure_dialog.cpp | 17 + src/yuzu/configuration/configure_dialog.h | 3 + src/yuzu/configuration/configure_graphics.cpp | 37 +- src/yuzu/configuration/configure_input.cpp | 839 ++- src/yuzu/configuration/configure_input.h | 88 +- .../configuration/configure_input_player.cpp | 665 ++ .../configuration/configure_input_player.h | 66 + .../configure_input_player_widget.cpp | 8 + .../configure_modern_per_game.cpp | 53 + .../configuration/configure_modern_per_game.h | 36 + src/yuzu/configuration/configure_per_game.cpp | 3 + src/yuzu/configuration/configure_per_game.h | 2 + .../configure_player_assignment.cpp | 1634 +++++ .../configure_player_assignment.h | 160 + src/yuzu/configuration/input_profiles.cpp | 47 +- src/yuzu/configuration/input_profiles.h | 6 + src/yuzu/configuration/shared_widget.cpp | 37 +- src/yuzu/game/game_list.cpp | 40 + src/yuzu/game/game_list.h | 3 + src/yuzu/main.ui | 18 + src/yuzu/main_window.cpp | 5701 ++++++++++++++++- src/yuzu/main_window.h | 265 +- src/yuzu/overlay_panel.cpp | 84 + src/yuzu/overlay_panel.h | 56 + src/yuzu/util/controller_navigation.cpp | 17 + src/yuzu/util/controller_navigation.h | 4 + src/yuzu/util/gamescope.cpp | 449 ++ src/yuzu/util/gamescope.h | 19 + src/yuzu/util/overlay_dialog.cpp | 7 +- 57 files changed, 11334 insertions(+), 90 deletions(-) create mode 100644 src/common/controller_console_state.h create mode 100644 src/common/overlay_panels.h create mode 100644 src/yuzu/configuration/configure_modern_per_game.cpp create mode 100644 src/yuzu/configuration/configure_modern_per_game.h create mode 100644 src/yuzu/configuration/configure_player_assignment.cpp create mode 100644 src/yuzu/configuration/configure_player_assignment.h create mode 100644 src/yuzu/overlay_panel.cpp create mode 100644 src/yuzu/overlay_panel.h create mode 100644 src/yuzu/util/gamescope.cpp create mode 100644 src/yuzu/util/gamescope.h diff --git a/dist/qt_themes/qdarkstyle_midnight_blue/style.qss b/dist/qt_themes/qdarkstyle_midnight_blue/style.qss index f968ad18b7..17d5dfef1c 100644 --- a/dist/qt_themes/qdarkstyle_midnight_blue/style.qss +++ b/dist/qt_themes/qdarkstyle_midnight_blue/style.qss @@ -375,7 +375,9 @@ QMenuBar::item { } QMenuBar::item:selected { - background: transparent; + /* A hovered menu stays readable */ + background-color: #1464A0; + color: #F0F0F0; border: 0px solid #32414B; } diff --git a/src/common/controller_console_state.h b/src/common/controller_console_state.h new file mode 100644 index 0000000000..5a01cd122b --- /dev/null +++ b/src/common/controller_console_state.h @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/settings.h" + +namespace Common { + +/// State shared between the console-mode engine (in the main window) and the Controllers tab: +/// the UI reads and requests, the engine applies on its next tick. +struct ControllerConsoleState { + std::mutex mutex; + /// Live pads as (display name, identity): possible handheld controls first + std::vector> pads; + /// How many of `pads` could be a handheld's controls (the first ones) + std::size_t builtin_pads = 0; + /// Set by the Identify button: capture the next pad that has a button pressed + bool identify_armed = false; + std::chrono::steady_clock::time_point identify_armed_at{}; + + /// One assigned player, as the Controllers tab lists it + struct PlayerRow { + int player; ///< 0-based player index + std::string name; + bool active; ///< the pad has an input held (or was just tapped) + bool leaving; ///< the controller left and nothing took its place: shown red, then gone + int flash; ///< 0 none, 1 green (a controller took the player), 2 red (one left) + long long flash_since; ///< ms since epoch; the flash blinks for 1.5 seconds from here + std::string profile; ///< the input profile the player's controller carries ("" none) + /// Turn Off: 0 not Bluetooth, 1 not yet (identical ones not told apart), 2 yes + int turn_off = 0; + /// The player its controller started as (-1: this one); a reconnect's guessed player + int origin = -1; + bool guessed = false; + /// The name given to its controller ("" none): `name` is then "Name (Xbox)" + std::string custom_name; + /// Battery percent (-1 unknown), charging, wired + int battery = -1; + bool charging = false; + bool wired = false; + }; + /// True while an automatic assignment mode runs (the engine owns the players) + bool engine_active = false; + /// The players the engine assigned, in player order + std::vector players; + /// The handheld's own pad: its name ("" when not identified) and whether it holds a player + std::string handheld_name; + bool handheld_has_player = false; + /// The Controllers tab asked to remove the handheld's controller (its ✕) + bool builtin_remove_request = false; + /// The removed controller auto-detect leaves alone, by name ("" when not connected) + std::string handheld_blocked_name; + /// Swaps requested from the settings and the overlay, for this session only + std::vector> swap_requests; + /// Players whose Bluetooth controller is to be turned off; the player is freed + std::vector remove_requests; + /// Names given to players' controllers from the Controllers tab ("" takes it off) + std::vector> rename_requests; + /// Players whose controller the Controllers tab asked to rumble (a click on its row) + std::vector rumble_requests; + /// An input profile picked for a player, put on its controller's set. No name takes it off + /// (`restore`: the set's mappings from before this session's first profile come back). + struct ProfileRequest { + int player; + std::string name; + bool restore = false; + }; + std::vector profile_requests; + /// Bypass Steam Input took effect at startup: SDL opens the real controllers + std::atomic steam_bypassed{false}; + /// Whether Steam's virtual pads are around, and the models Steam hid that Eden opened again + /// for their motion (vendor << 16 | product) + std::atomic steam_pads_present{false}; + std::set steam_hidden_models; + /// Bumped when the engine reloads the emulated controllers: the player tabs refresh + unsigned long long generation = 0; + /// Use Any Controller as Joy-Cons: each player playing as a single Joy-Con (0 no, 1 held + /// sideways, 2 upright). Its mapping is the engine's then: the player tab can't edit it. + std::array joycon_held{}; +}; + +[[nodiscard]] inline ControllerConsoleState& GetControllerConsoleState() { + static ControllerConsoleState instance; + return instance; +} + +/// True when Eden was launched through Steam: Steam Input then wraps the controllers in +/// virtual ones +[[nodiscard]] inline bool RunningUnderSteam() { + static const bool launched = + std::getenv("SteamGameId") != nullptr || std::getenv("SteamAppId") != nullptr; + return launched; +} + +/// True when Steam Input's virtual pads are the players: under Steam, not bypassed, with +/// Steam's pads around. Otherwise the real controllers are, as outside Steam. +[[nodiscard]] inline bool SteamPadsInUse() { + const auto& state = GetControllerConsoleState(); + return RunningUnderSteam() && !state.steam_bypassed && state.steam_pads_present; +} + +/// True for a controller model Steam hid that Eden opened again for its motion (never a player) +[[nodiscard]] inline bool SteamHidModel(unsigned vendor, unsigned product) { + auto& state = GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + return state.steam_hidden_models.contains(vendor << 16 | product); +} + +/// Debug Probes: detailed controller traces in the log, for bug reports +[[nodiscard]] inline bool ControllerProbes() { + return Settings::values.console_debug_probes.GetValue(); +} + +} // namespace Common diff --git a/src/common/overlay_panels.h b/src/common/overlay_panels.h new file mode 100644 index 0000000000..7cd13eb3e6 --- /dev/null +++ b/src/common/overlay_panels.h @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "common/common_types.h" + +namespace Common { + +/// The screen corner a panel is placed against +enum class OverlayAnchor { + TopLeft, + BottomLeft, + TopRight, +}; + +/// An image drawn over the game inside the presented frame, so it also shows under gamescope, +/// where no Qt widget can composite over the game. The frontend fills it (RGBA8); the presenter +/// blends it in last. +struct OverlayPanel { + std::mutex mutex; + std::vector pixels; ///< RGBA8, width * height * 4 + u32 width = 0; + u32 height = 0; + bool enabled = false; + /// Incremented on every push so presenters can cache by version + u64 version = 0; + OverlayAnchor anchor = OverlayAnchor::TopLeft; + /// A top-anchored panel sits this share of the screen's height lower (clear of other + /// overlays in the corner) + float drop = 0.0f; +}; + +/// The panels, in drawing order +enum class OverlayPanelId : std::size_t { + Players, ///< The controller players (top left) + Hints, ///< The controller hints (bottom left) + Cheats, ///< The cheats (top right) + Count, +}; + +inline constexpr std::size_t OverlayPanelCount = static_cast(OverlayPanelId::Count); + +[[nodiscard]] inline std::array& GetOverlayPanels() { + static std::array panels; + static const bool placed = [] { + panels[static_cast(OverlayPanelId::Hints)].anchor = + OverlayAnchor::BottomLeft; + panels[static_cast(OverlayPanelId::Cheats)].anchor = + OverlayAnchor::TopRight; + return true; + }(); + (void)placed; + return panels; +} + +[[nodiscard]] inline OverlayPanel& GetOverlayPanel(OverlayPanelId id) { + return GetOverlayPanels()[static_cast(id)]; +} + +/// A panel's place, in the pixels of the screen area the game is drawn in +struct OverlayRect { + float x; + float y; + float width; + float height; +}; + +/// Where a panel goes on a screen area (left, top, right, bottom), for both the presenter and +/// the mouse handling. Whole pixels, or the image's edge blends into a dark line. Call with the +/// panel's mutex held. +[[nodiscard]] inline OverlayRect PlaceOverlayPanel(const OverlayPanel& panel, float left, + float top, float right, float bottom) { + constexpr float margin = 4.0f; + const float width = static_cast(panel.width); + const float height = static_cast(panel.height); + const float x = panel.anchor == OverlayAnchor::TopRight ? right - width - margin + : left + margin; + const float y = panel.anchor == OverlayAnchor::BottomLeft + ? bottom - height - margin + : top + margin + (bottom - top) * panel.drop; + return {std::floor(x), std::floor(y), width, height}; +} + +/// The width a top-left panel has before it reaches the panels shown at the top right. Takes +/// the other panels' mutexes: never call it with one held. +[[nodiscard]] inline float OverlayRoomTopLeft(float screen_width) { + constexpr float margin = 4.0f; + constexpr float apart = 8.0f; + float right_taken = 0.0f; + for (auto& panel : GetOverlayPanels()) { + std::scoped_lock lock{panel.mutex}; + if (panel.enabled && panel.anchor == OverlayAnchor::TopRight) { + right_taken = (std::max)(right_taken, static_cast(panel.width) + margin + apart); + } + } + return screen_width - margin - right_taken; +} + +} // namespace Common diff --git a/src/common/settings.h b/src/common/settings.h index e611759429..427850c3d2 100644 --- a/src/common/settings.h +++ b/src/common/settings.h @@ -202,6 +202,66 @@ struct Values { // Core SwitchableSetting use_multi_core{linkage, true, "use_multi_core", Category::Core}; + + /// Show the controller debug overlay (the real pad state plus the logic's actions) + SwitchableSetting console_debug_overlay{linkage, true, "console_debug_overlay", + Category::LibraryApplet, + Specialization::Default, true, true}; + /// Modern Console: each player's starting input profile, comma-separated in player order + Setting modern_seat_profiles{linkage, std::string{}, "modern_seat_profiles", + Category::LibraryApplet}; + /// Name a controller on screen for a moment when it takes a player or leaves one + SwitchableSetting console_hints{linkage, true, "console_hints", Category::LibraryApplet, + Specialization::Default, true, true}; + /// The overlay's button history: shown, how many inputs it keeps, how long each lasts (in + /// seconds), and the time between inputs + SwitchableSetting console_history{linkage, true, "console_history", + Category::LibraryApplet}; + SwitchableSetting console_history_size{ + linkage, 10, 5, 20, "console_history_size", Category::LibraryApplet}; + SwitchableSetting console_history_seconds{ + linkage, 5, 1, 30, "console_history_seconds", Category::LibraryApplet}; + SwitchableSetting console_history_gaps{linkage, false, "console_history_gaps", + Category::LibraryApplet}; + /// Under Steam: Eden reads the real controllers instead of Steam's virtual ones. At startup. + SwitchableSetting console_bypass_steam{linkage, false, "console_bypass_steam", + Category::LibraryApplet}; + /// Modern Console: when a game only takes Joy-Cons, controllers act as Joy-Cons + SwitchableSetting console_joycon_auto{linkage, true, "console_joycon_auto", + Category::LibraryApplet}; + /// Modern Console: pause when a player's controller is turned off or gone for 3 seconds, + /// resume when a controller takes the player again + SwitchableSetting console_pause_disconnect{linkage, true, "console_pause_disconnect", + Category::LibraryApplet}; + /// Modern Console: controllers' player lights and light bars show their player + SwitchableSetting console_player_lights{linkage, true, "console_player_lights", + Category::LibraryApplet}; + /// Modern Console: Xbox and PlayStation style buttons by label instead of position + SwitchableSetting console_button_labels{linkage, false, "console_button_labels", + Category::LibraryApplet}; + /// Detailed controller traces in the log, for bug reports + SwitchableSetting console_debug_probes{linkage, false, "console_debug_probes", + Category::LibraryApplet}; + + /// The user picked the handheld's own pad (picker or Identify): auto-detect keeps it + SwitchableSetting builtin_pad_manual{linkage, false, "builtin_pad_manual", + Category::Core}; + + /// The handheld's own pad identity, latched when Handheld Console Mode engages + SwitchableSetting builtin_pad_identity{linkage, std::string{}, + "builtin_pad_identity", Category::Core}; + + /// A controller the user removed as the handheld's own (Steam handle, else identity): + /// auto-detect skips it until it is allowed again + SwitchableSetting builtin_pad_blocked{linkage, std::string{}, + "builtin_pad_blocked", Category::Core}; + /// Under Steam, the handheld's own pad's Steam handle: stable across sessions, unlike the + /// virtual pads' shared GUID + SwitchableSetting builtin_pad_steam_handle{ + linkage, std::string{}, "builtin_pad_steam_handle", Category::Core}; + /// The user picked the Steam handle (picker or Identify): kept whatever the pad reports + SwitchableSetting builtin_pad_steam_manual{linkage, false, "builtin_pad_steam_manual", + Category::Core}; SwitchableSetting memory_layout_mode{linkage, MemoryLayout::Memory_4Gb, "memory_layout_mode", diff --git a/src/common/settings_enums.h b/src/common/settings_enums.h index eaf3ec541d..aba53e918b 100644 --- a/src/common/settings_enums.h +++ b/src/common/settings_enums.h @@ -150,7 +150,7 @@ ENUM(ScalingFilter, NearestNeighbor, Bilinear, Bicubic, Gaussian, Lanczos, Scale ENUM(AntiAliasing, None, Fxaa, Smaa); ENUM(AspectRatio, R16_9, R4_3, R21_9, R16_10, Stretch); ENUM(ConsoleMode, Handheld, Docked); -ENUM(AppletMode, HLE, LLE, Disabled); +ENUM(AppletMode, HLE, LLE, Disabled, ConsoleMode, HandheldConsoleMode); ENUM(SpirvOptimizeMode, Never, OnLoad, Always); ENUM(GpuClock, Normal, Boost, Overclock) ENUM(GpuUnswizzleSize, VerySmall, Small, Normal, Large, VeryLarge) diff --git a/src/hid_core/hid_core.cpp b/src/hid_core/hid_core.cpp index cbc193b741..fcf87d6d63 100644 --- a/src/hid_core/hid_core.cpp +++ b/src/hid_core/hid_core.cpp @@ -130,6 +130,14 @@ NpadStyleTag HIDCore::GetSupportedStyleTag() const { return supported_style_tag; } +void HIDCore::SetJoyHoldHorizontal(bool horizontal) { + joy_hold_horizontal = horizontal; +} + +bool HIDCore::IsJoyHoldHorizontal() const { + return joy_hold_horizontal; +} + s8 HIDCore::GetPlayerCount() const { s8 active_players = 0; for (std::size_t player_index = 0; player_index < available_controllers - 2; ++player_index) { @@ -196,6 +204,7 @@ void HIDCore::DisableAllControllerConfiguration() { } void HIDCore::ReloadInputDevices() { + std::scoped_lock lock{reload_mutex}; player_1->ReloadFromSettings(); player_2->ReloadFromSettings(); player_3->ReloadFromSettings(); diff --git a/src/hid_core/hid_core.h b/src/hid_core/hid_core.h index 7da11897ca..1cd3427724 100644 --- a/src/hid_core/hid_core.h +++ b/src/hid_core/hid_core.h @@ -6,7 +6,9 @@ #pragma once +#include #include +#include #include "common/common_funcs.h" #include "hid_core/hid_types.h" @@ -46,6 +48,10 @@ public: void SetSupportedStyleTag(NpadStyleTag style_tag); NpadStyleTag GetSupportedStyleTag() const; + /// How the running game holds a single Joy-Con: sideways (horizontal) or upright (the default) + void SetJoyHoldHorizontal(bool horizontal); + bool IsJoyHoldHorizontal() const; + /// Counts the connected players from P1-P8 s8 GetPlayerCount() const; @@ -76,6 +82,9 @@ public: /// Number of emulated controllers static constexpr std::size_t available_controllers{10}; + /// Reloads come from the HID service (starting a game) and the frontend (settings, the + /// console modes): one at a time, or two threads rebuild the same controllers + std::mutex reload_mutex; std::unique_ptr player_1; std::unique_ptr player_2; std::unique_ptr player_3; @@ -90,6 +99,7 @@ public: std::unique_ptr devices; Kernel::KernelCore& kernel; NpadStyleTag supported_style_tag{NpadStyleSet::All}; + std::atomic joy_hold_horizontal{false}; NpadIdType last_active_controller{NpadIdType::Handheld}; }; diff --git a/src/hid_core/resources/npad/npad.cpp b/src/hid_core/resources/npad/npad.cpp index 2b04e2c670..485f4a8f80 100644 --- a/src/hid_core/resources/npad/npad.cpp +++ b/src/hid_core/resources/npad/npad.cpp @@ -45,6 +45,8 @@ NPad::NPad(Core::HID::HIDCore& hid_core_, KernelHelpers::ServiceContext& service AbstractPad{hid_core_.kernel}, }} { + // (A new game holds its Joy-Cons upright until it says otherwise) + hid_core.SetJoyHoldHorizontal(false); for (std::size_t aruid_index = 0; aruid_index < AruidIndexMax; ++aruid_index) { for (std::size_t i = 0; i < controller_data[aruid_index].size(); ++i) { auto& controller = controller_data[aruid_index][i]; @@ -149,6 +151,18 @@ void NPad::ControllerUpdate(Kernel::KernelCore& kernel, Core::HID::ControllerTri ControllerUpdate(kernel, Core::HID::ControllerTriggerType::Battery, controller_idx); return; } + // Only connection and battery changes concern the npad here. Motion arrives hundreds of + // times a second from its own thread (a DSU server's socket), and the callbacks are live + // from the constructor on, before SetNpadExternals provides the applet resource: an update + // in that window during boot dereferenced a null applet resource (SIGSEGV). + if (type != Core::HID::ControllerTriggerType::Connected && + type != Core::HID::ControllerTriggerType::Disconnected && + type != Core::HID::ControllerTriggerType::Battery) { + return; + } + if (!applet_resource_holder.applet_resource) { + return; + } for (std::size_t aruid_index = 0; aruid_index < AruidIndexMax; aruid_index++) { if (controller_idx >= controller_data[aruid_index].size()) { @@ -703,7 +717,11 @@ Result NPad::SetSupportedNpadIdType(Kernel::KernelCore& kernel, u64 aruid, std:: Result NPad::SetNpadJoyHoldType(u64 aruid, NpadJoyHoldType hold_type) { std::scoped_lock lock{mutex}; - return npad_resource.SetNpadJoyHoldType(aruid, hold_type); + const Result result = npad_resource.SetNpadJoyHoldType(aruid, hold_type); + if (result.IsSuccess()) { + hid_core.SetJoyHoldHorizontal(hold_type == NpadJoyHoldType::Horizontal); + } + return result; } Result NPad::GetNpadJoyHoldType(u64 aruid, NpadJoyHoldType& out_hold_type) const { diff --git a/src/input_common/drivers/sdl_driver.cpp b/src/input_common/drivers/sdl_driver.cpp index c3e9a066b3..588d7fa8cd 100644 --- a/src/input_common/drivers/sdl_driver.cpp +++ b/src/input_common/drivers/sdl_driver.cpp @@ -3,8 +3,19 @@ // SPDX-FileCopyrightText: 2018 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include #include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include "common/controller_console_state.h" #include "common/logging.h" #include "common/math_util.h" #include "common/param_package.h" @@ -450,6 +461,29 @@ std::shared_ptr SDLDriver::GetSDLJoystickBySDLID(SDL_JoystickID sdl return *vec_it; } +std::optional> SDLDriver::GetBindingForSDLID(SDL_JoystickID sdl_id) { + const auto joystick = GetSDLJoystickBySDLID(sdl_id); + if (!joystick) { + return std::nullopt; + } + return std::pair{joystick->GetGUID().RawString(), joystick->GetPort()}; +} + +void SDLDriver::MarkActivity(SDL_JoystickID sdl_id) { + std::scoped_lock lock{activity_mutex}; + last_activity[sdl_id] = std::chrono::steady_clock::now(); +} + +std::optional SDLDriver::GetLastActivity( + SDL_JoystickID sdl_id) const { + std::scoped_lock lock{activity_mutex}; + const auto it = last_activity.find(sdl_id); + if (it == last_activity.end()) { + return std::nullopt; + } + return it->second; +} + std::shared_ptr SDLDriver::GetSDLJoystickByGamepadID(SDL_JoystickID sdl_id) { auto* const sdl_gamepad = SDL_GetGamepadFromID(sdl_id); if (sdl_gamepad == nullptr) { @@ -535,6 +569,11 @@ void SDLDriver::InitJoystick(SDL_JoystickID joystick_id) { [](const auto& joystick) { return !joystick->GetSDLJoystick(); }); if (joystick_it != joystick_guid_list.end()) { + const int port = static_cast(joystick_it - joystick_guid_list.begin()); + if (Common::ControllerProbes()) { + LOG_INFO(Input, "input: attach guid={} port={} name={} (reusing a freed slot)", + guid.RawString(), port, SDL_GetJoystickName(sdl_joystick)); + } (*joystick_it)->SetSDLJoystick(sdl_joystick, sdl_gamecontroller); (*joystick_it)->EnableMotion(); SetBattery((*joystick_it)->GetPadIdentifier(), @@ -563,6 +602,10 @@ void SDLDriver::CloseJoystick(SDL_Joystick* sdl_joystick) { }); if (joystick_it != joystick_guid_list.end()) { + if (Common::ControllerProbes()) { + LOG_INFO(Input, "input: detach guid={} port={}", guid.RawString(), + static_cast(joystick_it - joystick_guid_list.begin())); + } (*joystick_it)->SetSDLJoystick(nullptr, nullptr); } } @@ -583,6 +626,7 @@ void SDLDriver::HandleGameControllerEvent(const SDL_Event& event) { break; } case SDL_EVENT_JOYSTICK_BUTTON_DOWN: { + MarkActivity(event.jbutton.which); if (const auto joystick = GetSDLJoystickBySDLID(event.jbutton.which)) { const PadIdentifier identifier = joystick->GetPadIdentifier(); SetButton(identifier, event.jbutton.button, true); @@ -590,6 +634,9 @@ void SDLDriver::HandleGameControllerEvent(const SDL_Event& event) { break; } case SDL_EVENT_JOYSTICK_HAT_MOTION: { + if (event.jhat.value != SDL_HAT_CENTERED) { + MarkActivity(event.jhat.which); + } if (const auto joystick = GetSDLJoystickBySDLID(event.jhat.which)) { const PadIdentifier identifier = joystick->GetPadIdentifier(); SetHatButton(identifier, event.jhat.hat, event.jhat.value); @@ -597,8 +644,20 @@ void SDLDriver::HandleGameControllerEvent(const SDL_Event& event) { break; } case SDL_EVENT_JOYSTICK_AXIS_MOTION: { - if (const auto joystick = GetSDLJoystickBySDLID(event.jaxis.which)) { - const PadIdentifier identifier = joystick->GetPadIdentifier(); + // Half travel, so resting drift never counts. A trigger rests at one end of its range + // (-32768 here), so for it half travel is past the middle. + Sint16 rest = 0; + const auto moved = GetSDLJoystickBySDLID(event.jaxis.which); + SDL_Joystick* const moved_js = moved ? moved->GetSDLJoystick() : nullptr; + const bool trigger = + moved_js && SDL_GetJoystickAxisInitialState(moved_js, event.jaxis.axis, &rest) && + rest < -16384; + if (trigger ? event.jaxis.value > 0 + : event.jaxis.value > 16384 || event.jaxis.value < -16384) { + MarkActivity(event.jaxis.which); + } + if (moved) { + const PadIdentifier identifier = moved->GetPadIdentifier(); SetAxis(identifier, event.jaxis.axis, event.jaxis.value / 32767.0f); } break; @@ -640,6 +699,152 @@ void SDLDriver::CloseJoysticks() { joystick_map.clear(); } +namespace { + +/// Controllers with motion sensors that Steam Input hides behind its sensorless virtual pads +bool IsMotionController(unsigned vendor, unsigned product) { + switch (vendor) { + case 0x28DE: + // The Steam Deck only: SDL's driver would talk to a Steam Controller alongside Steam + // (Bypass Steam Input gives SDL Steam Controllers, motion included) + return product == 0x1205; + case 0x054C: // Sony + case 0x057E: // Nintendo + return true; + default: + return false; + } +} + +/// Controllers Steam Input hid that Eden opened again for their motion (vendor << 16 | product). +/// Motion sources only: never offered as input devices. +std::set& UnhiddenControllers() { + static std::set unhidden; + return unhidden; +} + +/// Logs the system's motion sensors and HID devices once (Debug Probes), to identify handhelds +/// from users' logs: a built-in gyro is often a system sensor (IIO) that SDL never sees. +void LogMotionHardware() { +#ifdef __linux__ + if (!Common::ControllerProbes()) { + return; + } + namespace fs = std::filesystem; + const auto read_line = [](const fs::path& path) { + std::ifstream file{path}; + std::string line; + std::getline(file, line); + return line; + }; + std::error_code error; + std::string sensors; + // (No exceptions: a device can disappear while it is read) + for (fs::directory_iterator it{"/sys/bus/iio/devices", error}, end; !error && it != end; + it.increment(error)) { + const fs::path dir = it->path(); + std::error_code ignored; + const bool gyro = fs::exists(dir / "in_anglvel_x_raw", ignored); + const bool accel = fs::exists(dir / "in_accel_x_raw", ignored); + if (!gyro && !accel) { + continue; + } + std::string mount = read_line(dir / "in_anglvel_mount_matrix"); + if (mount.empty()) { + mount = read_line(dir / "in_accel_mount_matrix"); + } + const fs::path device = fs::canonical(dir / "device", ignored); + sensors += fmt::format("\n {} name={} gyro={} accel={} mount=[{}] at={}", + dir.filename().string(), read_line(dir / "name"), gyro, accel, mount, + ignored ? std::string{"?"} : device.string()); + } + LOG_INFO(Input, "Motion sensors of the system (IIO):{}", + sensors.empty() ? std::string{" none"} : sensors); + + std::string hid; + error.clear(); + for (fs::directory_iterator it{"/sys/class/hidraw", error}, end; !error && it != end; + it.increment(error)) { + const auto& entry = *it; + std::ifstream uevent{entry.path() / "device" / "uevent"}; + std::string line; + std::string id; + std::string name; + while (std::getline(uevent, line)) { + if (line.starts_with("HID_ID=")) { + // bus:vendor:product, each zero-padded to 8 hex digits + unsigned bus = 0, vendor = 0, product = 0; + if (std::sscanf(line.c_str() + 7, "%x:%x:%x", &bus, &vendor, &product) == 3) { + id = fmt::format("{:04x}:{:04x} bus={}", vendor, product, bus); + } + } else if (line.starts_with("HID_NAME=")) { + name = line.substr(9); + } + } + hid += fmt::format("\n {} {} name={}", entry.path().filename().string(), id, name); + } + LOG_INFO(Input, "HID devices:{}", hid.empty() ? std::string{" none"} : hid); +#endif +} + +/// A controller Eden opened again under Steam Input for its motion alone +bool IsUnhiddenController(SDL_Joystick* joystick) { + const u32 id = + static_cast(SDL_GetJoystickVendor(joystick)) << 16 | SDL_GetJoystickProduct(joystick); + return UnhiddenControllers().contains(id); +} + +/// Under Steam, takes the motion controllers off Steam's hide list, so SDL also opens the real +/// devices next to Steam's sensorless virtual pads +void UnhideMotionControllers() { + if (!Common::RunningUnderSteam()) { + return; + } + const char* env = std::getenv("SDL_GAMECONTROLLER_IGNORE_DEVICES"); + std::string list = env ? env : ""; + if (list.starts_with('@')) { + std::ifstream file{list.substr(1)}; + list.assign(std::istreambuf_iterator{file}, std::istreambuf_iterator{}); + } + std::string kept; + std::string dropped; + std::stringstream entries{list}; + for (std::string entry; std::getline(entries, entry, ',');) { + entry.erase(std::remove_if(entry.begin(), entry.end(), + [](unsigned char c) { return std::isspace(c); }), + entry.end()); + unsigned vendor = 0; + unsigned product = 0; + if (entry.empty()) { + continue; + } + if (std::sscanf(entry.c_str(), "%x/%x", &vendor, &product) == 2 && + IsMotionController(vendor, product)) { + dropped += entry + ','; + UnhiddenControllers().insert(vendor << 16 | product); + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.steam_hidden_models.insert(vendor << 16 | product); + } + continue; + } + kept += entry + ','; + } + if (!kept.empty()) { + kept.pop_back(); + } + if (Common::ControllerProbes()) { + LOG_INFO(Input, "Steam Input: opening motion controllers for their sensors: {}", + dropped.empty() ? std::string{"none hidden"} : dropped); + } + SDL_SetHintWithPriority(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES, kept.c_str(), + SDL_HINT_OVERRIDE); + SDL_SetHintWithPriority(SDL_HINT_JOYSTICK_HIDAPI_STEAMDECK, "1", SDL_HINT_OVERRIDE); +} + +} // Anonymous namespace + SDLDriver::SDLDriver(std::string input_engine_) : InputEngine(std::move(input_engine_)) { // Set our application name. Currently passed to DBus by SDL and visible to the user through // their desktop environment. @@ -657,6 +862,24 @@ SDLDriver::SDLDriver(std::string input_engine_) : InputEngine(std::move(input_en // SDL3 defaults Steam Controller Bluetooth HIDAPI support to off, which can disable gyro. SDL_SetHint(SDL_HINT_JOYSTICK_HIDAPI_STEAM, "1"); + // Bypass Steam Input (Modern Console, under Steam): SDL takes every controller Steam hides, + // and the engine uses those instead of Steam's virtual pads + const auto mode = Settings::values.controller_applet_mode.GetValue(); + const bool automatic = mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode; + if (Common::RunningUnderSteam() && automatic && + Settings::values.console_bypass_steam.GetValue()) { + SDL_SetHintWithPriority(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES, "", SDL_HINT_OVERRIDE); + SDL_SetHintWithPriority(SDL_HINT_JOYSTICK_HIDAPI_STEAMDECK, "1", SDL_HINT_OVERRIDE); + Common::GetControllerConsoleState().steam_bypassed = true; + if (Common::ControllerProbes()) { + LOG_INFO(Input, "Steam Input bypassed: SDL opens every controller"); + } + } else if (automatic) { + // (Only the automatic modes read their motion) + UnhideMotionControllers(); + } + LogMotionHardware(); SDL_SetHint(SDL_HINT_GAMECONTROLLER_SENSOR_FUSION, "1"); SDL_SetHint(SDL_HINT_AUTO_UPDATE_SENSORS, "1"); @@ -727,8 +950,17 @@ std::vector SDLDriver::GetInputDevices() const { if (!joystick->GetSDLJoystick()) { continue; } + // Steam's virtual pads carry the real controller's name: mark them. Their GUID + // keeps Steam's ids (Valve 0x28DE, virtual gamepad 0x11FF, at bytes 4 and 8). + const SDL_GUID sdl_guid = SDL_GetJoystickGUID(joystick->GetSDLJoystick()); + const bool steam_virtual = sdl_guid.data[4] == 0xDE && sdl_guid.data[5] == 0x28 && + sdl_guid.data[8] == 0xFF && sdl_guid.data[9] == 0x11; + if (!steam_virtual && IsUnhiddenController(joystick->GetSDLJoystick())) { + continue; + } const std::string name = - fmt::format("{} {}", joystick->GetControllerName(), joystick->GetPort()); + fmt::format("{} {}{}", joystick->GetControllerName(), joystick->GetPort(), + steam_virtual ? " (Steam)" : ""); devices.emplace_back(Common::ParamPackage{ {"engine", GetEngineName()}, {"display", std::move(name)}, @@ -744,7 +976,8 @@ std::vector SDLDriver::GetInputDevices() const { // Add dual controllers for (const auto& [key, value] : joystick_map) { for (const auto& joystick : value) { - if (joystick->IsJoyconRight()) { + if (joystick->IsJoyconRight() && joystick->GetSDLJoystick() && + !IsUnhiddenController(joystick->GetSDLJoystick())) { if (!joycon_pairs.contains(joystick->GetPort())) { continue; } diff --git a/src/input_common/drivers/sdl_driver.h b/src/input_common/drivers/sdl_driver.h index e6751b6688..d97811f9fc 100644 --- a/src/input_common/drivers/sdl_driver.h +++ b/src/input_common/drivers/sdl_driver.h @@ -7,8 +7,12 @@ #pragma once #include +#include #include +#include +#include #include +#include #include "common/container/unordered_map.h" #include @@ -45,6 +49,15 @@ public: std::shared_ptr GetSDLJoystickBySDLID(SDL_JoystickID sdl_id); std::shared_ptr GetSDLJoystickByGamepadID(SDL_JoystickID sdl_id); + /// The guid and port that bindings use to address this SDL instance, if the driver has it + /// open. Must not be called while holding SDL_LockJoysticks (the driver locks its map). + std::optional> GetBindingForSDLID(SDL_JoystickID sdl_id); + + /// When this SDL instance last had a deliberate input: a button or d-pad press, or a + /// stick or trigger past half travel (not drift, not motion) + std::optional GetLastActivity( + SDL_JoystickID sdl_id) const; + /** * Check how many identical joysticks (by guid) were connected before the one with sdl_id and so * tie it to a SDLJoystick with the same guid and that port @@ -117,6 +130,10 @@ private: ::Common::unordered_map>> joystick_map; std::mutex joystick_map_mutex; + void MarkActivity(SDL_JoystickID sdl_id); + std::unordered_map last_activity; + mutable std::mutex activity_mutex; + std::atomic initialized = false; bool start_thread; }; diff --git a/src/input_common/helpers/stick_from_buttons.cpp b/src/input_common/helpers/stick_from_buttons.cpp index 576fd0244c..6c5ad4e2c1 100644 --- a/src/input_common/helpers/stick_from_buttons.cpp +++ b/src/input_common/helpers/stick_from_buttons.cpp @@ -25,10 +25,25 @@ public: using Button = std::unique_ptr; Stick(Button up_, Button down_, Button left_, Button right_, Button modifier_, Button updater_, - float modifier_scale_, float modifier_angle_) + Button stick_, float modifier_scale_, float modifier_angle_) : up(std::move(up_)), down(std::move(down_)), left(std::move(left_)), right(std::move(right_)), modifier(std::move(modifier_)), updater(std::move(updater_)), - modifier_scale(modifier_scale_), modifier_angle(modifier_angle_) { + stick(std::move(stick_)), modifier_scale(modifier_scale_), + modifier_angle(modifier_angle_) { + // A real stick alongside the buttons: it moves the stick while no button is held + stick_status.x.properties = properties; + stick_status.y.properties = properties; + if (stick) { + stick->SetCallback({ + .on_change = + [this](const Common::Input::CallbackStatus& callback_) { + stick_status = callback_.stick_status; + if (amplitude == 0) { + SoftUpdate(); + } + }, + }); + } up->SetCallback({ .on_change = [this](const Common::Input::CallbackStatus& callback_) { @@ -220,6 +235,9 @@ public: left->ForceUpdate(); right->ForceUpdate(); modifier->ForceUpdate(); + if (stick) { + stick->ForceUpdate(); + } } void SoftUpdate() { @@ -237,6 +255,9 @@ public: } Common::Input::StickStatus GetStatus() const { + if (stick && amplitude == 0) { + return stick_status; + } Common::Input::StickStatus status{}; status.x.properties = properties; status.y.properties = properties; @@ -270,6 +291,8 @@ private: Button right; Button modifier; Button updater; + Button stick; + Common::Input::StickStatus stick_status{}; float modifier_scale{}; float modifier_angle{}; float angle{}; @@ -296,9 +319,13 @@ std::unique_ptr StickFromButton::Create( auto updater = Common::Input::CreateInputDeviceFromString("engine:updater,button:0"); auto modifier_scale = params.Get("modifier_scale", 0.5f); auto modifier_angle = params.Get("modifier_angle", 5.5f); + // (Optional: a real stick the buttons move alongside) + Stick::Button stick = params.Has("stick") + ? Common::Input::CreateInputDeviceFromString(params.Get("stick", "")) + : nullptr; return std::make_unique(std::move(up), std::move(down), std::move(left), std::move(right), std::move(modifier), std::move(updater), - modifier_scale, modifier_angle); + std::move(stick), modifier_scale, modifier_angle); } } // namespace InputCommon diff --git a/src/input_common/input_poller.cpp b/src/input_common/input_poller.cpp index 188e862d7f..bbabbb9d70 100644 --- a/src/input_common/input_poller.cpp +++ b/src/input_common/input_poller.cpp @@ -1,3 +1,5 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2021 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -557,9 +559,9 @@ private: class InputFromMotion final : public Common::Input::InputDevice { public: explicit InputFromMotion(PadIdentifier identifier_, int motion_sensor_, float gyro_threshold_, - InputEngine* input_engine_) + int turn_, InputEngine* input_engine_) : identifier(identifier_), motion_sensor(motion_sensor_), gyro_threshold(gyro_threshold_), - input_engine(input_engine_) { + turn(turn_), input_engine(input_engine_) { UpdateCallback engine_callback{[this]() { OnChange(); }}; const InputIdentifier input_identifier{ .identifier = identifier, @@ -589,6 +591,17 @@ public: status.gyro.x = {.raw_value = basic_motion.gyro_x, .properties = properties}; status.gyro.y = {.raw_value = basic_motion.gyro_y, .properties = properties}; status.gyro.z = {.raw_value = basic_motion.gyro_z, .properties = properties}; + // Turned a quarter about the face for a controller standing in for a sideways Joy-Con + // (1: clockwise, as a right one; -1: the other way): the Joy-Con's x is the + // controller's -y, its y the controller's x (the left one: y, and -x) + if (turn != 0) { + for (auto* axes : {&status.accel, &status.gyro}) { + const float x = axes->x.raw_value; + const float y = axes->y.raw_value; + axes->x.raw_value = turn > 0 ? -y : y; + axes->y.raw_value = turn > 0 ? x : -x; + } + } status.delta_timestamp = basic_motion.delta_timestamp; return status; } @@ -606,6 +619,7 @@ private: const PadIdentifier identifier; const int motion_sensor; const float gyro_threshold; + const int turn; int callback_key; InputEngine* input_engine; }; @@ -1069,9 +1083,10 @@ std::unique_ptr InputFactory::CreateMotionDevice( if (params.Has("motion")) { const auto motion_sensor = params.Get("motion", 0); const auto gyro_threshold = params.Get("threshold", 0.007f); + const auto turn = std::clamp(params.Get("turn", 0), -1, 1); input_engine->PreSetController(identifier); input_engine->PreSetMotion(identifier, motion_sensor); - return std::make_unique(identifier, motion_sensor, gyro_threshold, + return std::make_unique(identifier, motion_sensor, gyro_threshold, turn, input_engine.get()); } diff --git a/src/input_common/main.cpp b/src/input_common/main.cpp index 6c8443ff62..765d44fdd7 100644 --- a/src/input_common/main.cpp +++ b/src/input_common/main.cpp @@ -442,6 +442,23 @@ std::vector InputSubsystem::GetInputDevices() const { return impl->GetInputDevices(); } +std::optional> InputSubsystem::GetSDLBinding(u32 sdl_instance) const { +#ifdef HAVE_SDL3 + return impl->sdl->GetBindingForSDLID(static_cast(sdl_instance)); +#else + return std::nullopt; +#endif +} + +std::optional InputSubsystem::GetSDLLastActivity( + u32 sdl_instance) const { +#ifdef HAVE_SDL3 + return impl->sdl->GetLastActivity(static_cast(sdl_instance)); +#else + return std::nullopt; +#endif +} + AnalogMapping InputSubsystem::GetAnalogMappingForDevice(const Common::ParamPackage& device) const { return impl->GetAnalogMappingForDevice(device); } diff --git a/src/input_common/main.h b/src/input_common/main.h index 3b33ef30eb..cf9bffb414 100644 --- a/src/input_common/main.h +++ b/src/input_common/main.h @@ -6,10 +6,14 @@ #pragma once +#include #include +#include #include -#include "common/container/unordered_map.h" +#include #include +#include "common/common_types.h" +#include "common/container/unordered_map.h" namespace Common { class ParamPackage; @@ -152,6 +156,13 @@ public: /// Returns true if axis of a stick aren't mapped in the correct direction [[nodiscard]] bool IsStickInverted(const Common::ParamPackage& device) const; + /// The SDL guid and port that bindings use for an SDL joystick instance, if SDL has it open. + [[nodiscard]] std::optional> GetSDLBinding(u32 sdl_instance) const; + + /// When an SDL joystick last had a deliberate input (a stick or trigger past half travel) + [[nodiscard]] std::optional GetSDLLastActivity( + u32 sdl_instance) const; + /// Reloads the input devices. void ReloadInputDevices(); diff --git a/src/qt_common/config/shared_translation.cpp b/src/qt_common/config/shared_translation.cpp index f7561686dc..737ea2a90d 100644 --- a/src/qt_common/config/shared_translation.cpp +++ b/src/qt_common/config/shared_translation.cpp @@ -30,9 +30,41 @@ std::unique_ptr InitializeTranslations(QObject* parent) { // A setting can be ignored by giving it a blank name + // Toggled from View > Show Controller Overlay + INSERT(Settings, console_debug_overlay, tr("Controller overlay"), + tr("The players, their controllers and their last buttons in the top-left corner.")); + INSERT(Settings, console_hints, tr("Controller hints"), + tr("Names a controller in the bottom-left corner when it joins or leaves.")); + INSERT(Settings, console_history, QString(), QString()); + INSERT(Settings, console_history_size, QString(), QString()); + INSERT(Settings, console_history_seconds, QString(), QString()); + INSERT(Settings, console_history_gaps, QString(), QString()); + INSERT(Settings, console_debug_probes, QString(), QString()); + INSERT(Settings, console_bypass_steam, QString(), QString()); + INSERT(Settings, console_joycon_auto, tr("Use any controller as Joy-Cons"), + tr("When the game only takes Joy-Cons, controllers act as Joy-Cons.")); + INSERT(Settings, console_pause_disconnect, tr("Pause when a controller disconnects"), + tr("Pauses when a player's controller is turned off, or has been gone for 3 seconds. " + "+ on another controller goes on without that player.")); + INSERT(Settings, console_player_lights, tr("Player lights"), + tr("Controllers' player lights show their player.")); + INSERT(Settings, console_button_labels, tr("Button layout by label"), + tr("Xbox and PlayStation style controllers' buttons by their labels instead of their " + "positions.")); + INSERT(Settings, modern_seat_profiles, QString(), QString()); + INSERT(Settings, builtin_pad_identity, QString(), QString()); + INSERT(Settings, builtin_pad_steam_handle, QString(), QString()); + INSERT(Settings, builtin_pad_blocked, QString(), QString()); + INSERT(Settings, builtin_pad_manual, QString(), QString()); + INSERT(Settings, builtin_pad_steam_manual, QString(), QString()); + // Applets INSERT(Settings, cabinet_applet_mode, tr("Amiibo editor"), QString()); - INSERT(Settings, controller_applet_mode, tr("Controller configuration"), QString()); + // Chosen in Controls > Players ("Assign players") + INSERT(Settings, controller_applet_mode, tr("Controller configuration"), + tr("How games get their players: Modern Console and Handheld Console assign them " + "automatically; Custom Frontend and Real Hardware show a controller applet; " + "Disabled uses the players set in each player's tab.")); INSERT(Settings, data_erase_applet_mode, tr("Data erase"), QString()); INSERT(Settings, error_applet_mode, tr("Error"), QString()); INSERT(Settings, net_connect_applet_mode, tr("Net connect"), QString()); @@ -369,9 +401,11 @@ std::unique_ptr ComboboxEnumeration(QObject* parent) { // Intentionally skipping VSyncMode to let the UI fill that one out translations->insert({Settings::EnumMetadata::Index(), { - PAIR(AppletMode, HLE, tr("Custom frontend")), - PAIR(AppletMode, LLE, tr("Real applet")), + PAIR(AppletMode, HLE, tr("Custom Frontend")), + PAIR(AppletMode, LLE, tr("Real Hardware")), PAIR(AppletMode, Disabled, tr("Disabled")), + PAIR(AppletMode, ConsoleMode, tr("Modern Console")), + PAIR(AppletMode, HandheldConsoleMode, tr("Handheld Console")), }}); translations->insert({Settings::EnumMetadata::Index(), diff --git a/src/qt_common/config/uisettings.cpp b/src/qt_common/config/uisettings.cpp index f4f5688781..00e7095c0c 100644 --- a/src/qt_common/config/uisettings.cpp +++ b/src/qt_common/config/uisettings.cpp @@ -30,7 +30,7 @@ namespace UISettings { // This must be in alphabetical order according to action name as it must have the same order as // UISetting::values.shortcuts, which is alphabetically ordered. // clang-format off -const std::array default_hotkeys{{ +const std::array default_hotkeys{{ {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Mute/Unmute")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+M"), std::string("Home+Dpad_Right"), Qt::WindowShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Down")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("-"), std::string("Home+Dpad_Down"), Qt::ApplicationShortcut, true}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Audio Volume Up")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("="), std::string("Home+Dpad_Up"), Qt::ApplicationShortcut, true}}, @@ -64,6 +64,7 @@ const std::array default_hotkeys{{ {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Renderdoc Capture")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string(""), std::string(""), Qt::ApplicationShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Status Bar")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+S"), std::string(""), Qt::WindowShortcut, false}}, {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Performance Overlay")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+V"), std::string(""), Qt::WindowShortcut, false}}, + {QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Toggle Controller Overlay")).toStdString(), QStringLiteral(QT_TRANSLATE_NOOP("Hotkeys", "Main Window")).toStdString(), {std::string("Ctrl+Shift+V"), std::string(""), Qt::WindowShortcut, false}}, }}; // clang-format on diff --git a/src/qt_common/config/uisettings.h b/src/qt_common/config/uisettings.h index 6b4ed43e76..b027b5f8eb 100644 --- a/src/qt_common/config/uisettings.h +++ b/src/qt_common/config/uisettings.h @@ -239,7 +239,7 @@ void SaveWindowState(); void RestoreWindowState(std::unique_ptr& qtConfig); // sync with uisettings.cpp -extern const std::array default_hotkeys; +extern const std::array default_hotkeys; } // namespace UISettings diff --git a/src/video_core/renderer_vulkan/present/filters.cpp b/src/video_core/renderer_vulkan/present/filters.cpp index 6203b41218..d8dc2291f6 100644 --- a/src/video_core/renderer_vulkan/present/filters.cpp +++ b/src/video_core/renderer_vulkan/present/filters.cpp @@ -30,29 +30,29 @@ namespace Vulkan { -std::unique_ptr MakeNearestNeighbor(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateNearestNeighborSampler(device), +std::unique_ptr MakeNearestNeighbor(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateNearestNeighborSampler(device), BuildShader(device, VULKAN_PRESENT_FRAG_SPV)); } -std::unique_ptr MakeBilinear(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), +std::unique_ptr MakeBilinear(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), BuildShader(device, VULKAN_PRESENT_FRAG_SPV)); } -std::unique_ptr MakeSpline1(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), +std::unique_ptr MakeSpline1(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), BuildShader(device, PRESENT_SPLINE1_FRAG_SPV)); } -std::unique_ptr MakeBicubic(const Device& device, VkFormat frame_format, VkCubicFilterWeightsQCOM qcom_weights) { +std::unique_ptr MakeBicubic(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format, VkCubicFilterWeightsQCOM qcom_weights) { // No need for handrolled shader -- if the VK impl can do it for us ;) // Catmull-Rom is default bicubic for all implementations... if (device.IsExtFilterCubicSupported() && (device.IsQcomFilterCubicWeightsSupported() || qcom_weights == VK_CUBIC_FILTER_WEIGHTS_CATMULL_ROM_QCOM)) { - return std::make_unique(device, frame_format, CreateCubicSampler(device, + return std::make_unique(device, memory_allocator, frame_format, CreateCubicSampler(device, qcom_weights), BuildShader(device, VULKAN_PRESENT_FRAG_SPV)); } else { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), [&](){ + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), [&](){ switch (qcom_weights) { case VK_CUBIC_FILTER_WEIGHTS_CATMULL_ROM_QCOM: return BuildShader(device, PRESENT_BICUBIC_FRAG_SPV); @@ -69,32 +69,32 @@ std::unique_ptr MakeBicubic(const Device& device, VkFormat fram } } -std::unique_ptr MakeGaussian(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), +std::unique_ptr MakeGaussian(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), BuildShader(device, PRESENT_GAUSSIAN_FRAG_SPV)); } -std::unique_ptr MakeLanczos(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), +std::unique_ptr MakeLanczos(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), BuildShader(device, PRESENT_LANCZOS_FRAG_SPV)); } -std::unique_ptr MakeScaleForce(const Device& device, VkFormat frame_format) { +std::unique_ptr MakeScaleForce(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { auto const select_fn = [&]() { return device.IsFloat16Supported() ? BuildShader(device, VULKAN_PRESENT_SCALEFORCE_FP16_FRAG_SPV) : BuildShader(device, VULKAN_PRESENT_SCALEFORCE_FP32_FRAG_SPV); }; - return std::make_unique(device, frame_format, CreateBilinearSampler(device), select_fn()); + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), select_fn()); } -std::unique_ptr MakeArea(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateBilinearSampler(device), +std::unique_ptr MakeArea(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateBilinearSampler(device), BuildShader(device, PRESENT_AREA_FRAG_SPV)); } -std::unique_ptr MakeMmpx(const Device& device, VkFormat frame_format) { - return std::make_unique(device, frame_format, CreateNearestNeighborSampler(device), +std::unique_ptr MakeMmpx(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format) { + return std::make_unique(device, memory_allocator, frame_format, CreateNearestNeighborSampler(device), BuildShader(device, PRESENT_MMPX_FRAG_SPV)); } diff --git a/src/video_core/renderer_vulkan/present/filters.h b/src/video_core/renderer_vulkan/present/filters.h index afc3ba29a0..84e3c4068e 100644 --- a/src/video_core/renderer_vulkan/present/filters.h +++ b/src/video_core/renderer_vulkan/present/filters.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: Copyright 2024 Torzu Emulator Project @@ -15,14 +15,14 @@ namespace Vulkan { class MemoryAllocator; -std::unique_ptr MakeNearestNeighbor(const Device& device, VkFormat frame_format); -std::unique_ptr MakeBilinear(const Device& device, VkFormat frame_format); -std::unique_ptr MakeBicubic(const Device& device, VkFormat frame_format, VkCubicFilterWeightsQCOM qcom_weights); -std::unique_ptr MakeSpline1(const Device& device, VkFormat frame_format); -std::unique_ptr MakeGaussian(const Device& device, VkFormat frame_format); -std::unique_ptr MakeLanczos(const Device& device, VkFormat frame_format); -std::unique_ptr MakeScaleForce(const Device& device, VkFormat frame_format); -std::unique_ptr MakeArea(const Device& device, VkFormat frame_format); -std::unique_ptr MakeMmpx(const Device& device, VkFormat frame_format); +std::unique_ptr MakeNearestNeighbor(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeBilinear(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeBicubic(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format, VkCubicFilterWeightsQCOM qcom_weights); +std::unique_ptr MakeSpline1(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeGaussian(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeLanczos(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeScaleForce(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeArea(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); +std::unique_ptr MakeMmpx(const Device& device, MemoryAllocator& memory_allocator, VkFormat frame_format); } // namespace Vulkan diff --git a/src/video_core/renderer_vulkan/present/window_adapt_pass.cpp b/src/video_core/renderer_vulkan/present/window_adapt_pass.cpp index 549b0660c1..024d31adc7 100644 --- a/src/video_core/renderer_vulkan/present/window_adapt_pass.cpp +++ b/src/video_core/renderer_vulkan/present/window_adapt_pass.cpp @@ -6,10 +6,17 @@ #include "core/frontend/framebuffer_layout.h" #include "video_core/framebuffer_config.h" +#include "video_core/host_shaders/vulkan_present_frag_spv.h" #include "video_core/host_shaders/vulkan_present_vert_spv.h" #include "video_core/renderer_vulkan/present/layer.h" #include "video_core/renderer_vulkan/present/present_push_constants.h" #include "video_core/renderer_vulkan/present/util.h" +#include + +#include "common/overlay_panels.h" +#include "common/logging.h" +#include "video_core/renderer_vulkan/present/present_push_constants.h" +#include "video_core/renderer_vulkan/present/util.h" #include "video_core/renderer_vulkan/present/window_adapt_pass.h" #include "video_core/renderer_vulkan/vk_present_manager.h" #include "video_core/renderer_vulkan/vk_shader_util.h" @@ -18,8 +25,11 @@ namespace Vulkan { -WindowAdaptPass::WindowAdaptPass(const Device& device, VkFormat frame_format, vk::Sampler&& sampler_, vk::ShaderModule&& fragment_shader_) - : sampler(std::move(sampler_)) +WindowAdaptPass::WindowAdaptPass(const Device& device, MemoryAllocator& memory_allocator_, + VkFormat frame_format, vk::Sampler&& sampler_, + vk::ShaderModule&& fragment_shader_) + : memory_allocator{memory_allocator_} + , sampler(std::move(sampler_)) , fragment_shader(std::move(fragment_shader_)) { CreateDescriptorSetLayout(device); @@ -27,6 +37,10 @@ WindowAdaptPass::WindowAdaptPass(const Device& device, VkFormat frame_format, vk CreateVertexShader(device); CreateRenderPass(device, frame_format); CreatePipelines(device); + overlay_shader = BuildShader(device, VULKAN_PRESENT_FRAG_SPV); + overlay_sampler = CreateNearestNeighborSampler(device); + overlay_pipeline = CreateWrappedCoverageBlendingPipeline( + device, render_pass, pipeline_layout, std::tie(vertex_shader, overlay_shader)); } WindowAdaptPass::~WindowAdaptPass() = default; @@ -34,7 +48,8 @@ WindowAdaptPass::~WindowAdaptPass() = default; void WindowAdaptPass::Draw(const Device& device, RasterizerVulkan& rasterizer, Scheduler& scheduler, size_t image_index, std::list& layers, std::span configs, - const Layout::FramebufferLayout& layout, Frame* dst) { + const Layout::FramebufferLayout& layout, Frame* dst, + bool overlay_panels) { const VkFramebuffer host_framebuffer{*dst->framebuffer}; const VkRenderPass renderpass{*render_pass}; @@ -69,6 +84,129 @@ void WindowAdaptPass::Draw(const Device& device, RasterizerVulkan& rasterizer, S layer_it++; } + // The overlay panels (see common/overlay_panels.h): one more quad each, straight-alpha, + // drawn last. + std::array overlay_draw{}; + std::array overlay_push{}; + std::array overlay_set{}; + auto& panels = Common::GetOverlayPanels(); + for (size_t o = 0; overlay_panels && o < panels.size(); ++o) { + auto& overlay_frame = panels[o]; + auto& quad = overlays[o]; + std::scoped_lock lock{overlay_frame.mutex}; + if (!overlay_frame.enabled || overlay_frame.pixels.empty() || overlay_frame.width == 0 || + overlay_frame.height == 0) { + continue; + } + constexpr VkFormat overlay_format = VK_FORMAT_R8G8B8A8_UNORM; + const VkExtent2D extent{overlay_frame.width, overlay_frame.height}; + if (!quad.ready || quad.extent.width != extent.width || + quad.extent.height != extent.height) { + // (A frame still on the GPU may be drawing the old image: replaced once it's done) + scheduler.Wait(quad.used_tick); + quad.image = CreateWrappedImage(memory_allocator, extent, overlay_format); + quad.view = CreateWrappedImageView(device, quad.image, overlay_format); + quad.pool = CreateWrappedDescriptorPool(device, 1, 1); + const std::vector layouts(1u, *descriptor_set_layout); + quad.sets = CreateWrappedDescriptorSets(quad.pool, layouts); + quad.extent = extent; + quad.ready = true; + quad.version = 0; + } + if (quad.version != overlay_frame.version) { + // Copied through one of the panel's two staging buffers: only that buffer's last + // copy is waited for, never the whole GPU + const std::size_t k = quad.next_staging; + quad.next_staging ^= 1; + scheduler.Wait(quad.staging_tick[k]); + auto& staging = quad.staging[k]; + const std::size_t bytes = overlay_frame.pixels.size(); + if (!staging || quad.staging_size[k] < bytes) { + const VkBufferCreateInfo staging_ci{ + .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, + .pNext = nullptr, + .flags = 0, + .size = bytes, + .usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE, + .queueFamilyIndexCount = 0, + .pQueueFamilyIndices = nullptr, + }; + staging = memory_allocator.CreateBuffer(staging_ci, MemoryUsage::Upload); + quad.staging_size[k] = bytes; + } + std::ranges::copy(overlay_frame.pixels, staging.Mapped().begin()); + staging.Flush(); + const VkBuffer source = *staging; + const VkImage target = *quad.image; + const VkBufferImageCopy region{ + .bufferOffset = 0, + .bufferRowLength = extent.width, + .bufferImageHeight = extent.height, + .imageSubresource{.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .mipLevel = 0, + .baseArrayLayer = 0, + .layerCount = 1}, + .imageOffset{}, + .imageExtent{.width = extent.width, .height = extent.height, .depth = 1}, + }; + scheduler.RequestOutsideRenderPassOperationContext(); + scheduler.Record([source, target, region](vk::CommandBuffer cmdbuf) { + TransitionImageLayout(cmdbuf, target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_IMAGE_LAYOUT_UNDEFINED); + cmdbuf.CopyBufferToImage(source, target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + std::array{region}); + TransitionImageLayout(cmdbuf, target, VK_IMAGE_LAYOUT_GENERAL, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL); + }); + quad.staging_tick[k] = scheduler.CurrentTick(); + quad.version = overlay_frame.version; + const VkDescriptorImageInfo image_info{ + .sampler = *overlay_sampler, + .imageView = *quad.view, + .imageLayout = VK_IMAGE_LAYOUT_GENERAL, + }; + const VkWriteDescriptorSet write{ + .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, + .dstSet = quad.sets[0], + .dstBinding = 0, + .dstArrayElement = 0, + .descriptorCount = 1, + .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + .pImageInfo = &image_info, + .pBufferInfo = nullptr, + .pTexelBufferView = nullptr, + }; + device.GetLogical().UpdateDescriptorSets(std::array{write}, {}); + } + overlay_set[o] = quad.sets[0]; + + auto& push = overlay_push[o]; + push.modelview_matrix = + MakeOrthographicMatrix(f32(layout.width), static_cast(layout.height)); + const auto& screen = layout.screen; + const auto place = Common::PlaceOverlayPanel(overlay_frame, f32(screen.left), + f32(screen.top), f32(screen.right), + f32(screen.bottom)); + const f32 w = place.width; + const f32 h = place.height; + const f32 x = place.x; + const f32 y = place.y; + push.vertices[0] = ScreenRectVertex(x, y, 0.f, 0.f); + push.vertices[1] = ScreenRectVertex(x + w, y, 1.f, 0.f); + push.vertices[2] = ScreenRectVertex(x, y + h, 0.f, 1.f); + push.vertices[3] = ScreenRectVertex(x + w, y + h, 1.f, 1.f); + overlay_draw[o] = true; + quad.used_tick = scheduler.CurrentTick(); + + static u32 logged_quads = 0; + if (logged_quads < 3) { + ++logged_quads; + LOG_INFO(Render_Vulkan, "overlay: quad drawn {}x{} at {},{}", extent.width, + extent.height, x, y); + } + } + scheduler.Record([=](vk::CommandBuffer cmdbuf) { const f32 bg_red = Settings::values.bg_red.GetValue() / 255.0f; const f32 bg_green = Settings::values.bg_green.GetValue() / 255.0f; @@ -103,6 +241,18 @@ void WindowAdaptPass::Draw(const Device& device, RasterizerVulkan& rasterizer, S cmdbuf.Draw(4, 1, 0, 0); } + for (size_t o = 0; o < overlay_draw.size(); ++o) { + if (!overlay_draw[o]) { + continue; + } + cmdbuf.BindPipeline(VK_PIPELINE_BIND_POINT_GRAPHICS, *overlay_pipeline); + cmdbuf.PushConstants(graphics_pipeline_layout, VK_SHADER_STAGE_VERTEX_BIT, + overlay_push[o]); + cmdbuf.BindDescriptorSets(VK_PIPELINE_BIND_POINT_GRAPHICS, graphics_pipeline_layout, + 0, overlay_set[o], {}); + cmdbuf.Draw(4, 1, 0, 0); + } + cmdbuf.EndRenderPass(); }); } diff --git a/src/video_core/renderer_vulkan/present/window_adapt_pass.h b/src/video_core/renderer_vulkan/present/window_adapt_pass.h index 58d6fc01c0..ec9004dc38 100644 --- a/src/video_core/renderer_vulkan/present/window_adapt_pass.h +++ b/src/video_core/renderer_vulkan/present/window_adapt_pass.h @@ -6,9 +6,12 @@ #pragma once +#include +#include #include #include "common/math_util.h" +#include "common/overlay_panels.h" #include "video_core/vulkan_common/vulkan_wrapper.h" namespace Layout { @@ -22,6 +25,7 @@ struct FramebufferConfig; namespace Vulkan { class Device; +class MemoryAllocator; struct Frame; class Layer; class Scheduler; @@ -29,13 +33,15 @@ class RasterizerVulkan; class WindowAdaptPass final { public: - explicit WindowAdaptPass(const Device& device, VkFormat frame_format, vk::Sampler&& sampler, + explicit WindowAdaptPass(const Device& device, MemoryAllocator& memory_allocator, + VkFormat frame_format, vk::Sampler&& sampler, vk::ShaderModule&& fragment_shader); ~WindowAdaptPass(); + /// (With the overlay panels on top only where asked: the screen's, not a capture) void Draw(const Device& device, RasterizerVulkan& rasterizer, Scheduler& scheduler, size_t image_index, std::list& layers, std::span configs, - const Layout::FramebufferLayout& layout, Frame* dst); + const Layout::FramebufferLayout& layout, Frame* dst, bool overlay_panels = false); VkDescriptorSetLayout GetDescriptorSetLayout(); VkRenderPass GetRenderPass(); @@ -47,6 +53,8 @@ private: void CreateRenderPass(const Device& device, VkFormat frame_format); void CreatePipelines(const Device& device); + MemoryAllocator& memory_allocator; + vk::DescriptorSetLayout descriptor_set_layout; vk::PipelineLayout pipeline_layout; vk::Sampler sampler; @@ -56,6 +64,33 @@ private: vk::Pipeline opaque_pipeline; vk::Pipeline premultiplied_pipeline; vk::Pipeline coverage_pipeline; + + // The overlay panels' own pipeline: the plain present shader, pixel for pixel (the scaling + // filter's shader would blur their text) + vk::ShaderModule overlay_shader; + vk::Sampler overlay_sampler; + vk::Pipeline overlay_pipeline; + + // The overlay panels (common/overlay_panels.h): a straight-alpha quad each, composited on + // top of the layers, for both the accelerated and the unaccelerated present paths. + struct OverlayQuad { + vk::Image image; + vk::ImageView view; + vk::DescriptorPool pool; + vk::DescriptorSets sets; + VkExtent2D extent{}; + std::uint64_t version = 0; + bool ready = false; + /// Staging buffers, used in turn: each is written again only once the GPU is past its + /// last copy (normally no wait) + std::array staging; + std::array staging_size{}; + std::array staging_tick{}; + std::size_t next_staging = 0; + /// The GPU tick that last drew the image: replaced only once the GPU is past it + std::uint64_t used_tick = 0; + }; + std::array overlays; }; } // namespace Vulkan diff --git a/src/video_core/renderer_vulkan/renderer_vulkan.cpp b/src/video_core/renderer_vulkan/renderer_vulkan.cpp index 27ca531186..a1c6c4d907 100644 --- a/src/video_core/renderer_vulkan/renderer_vulkan.cpp +++ b/src/video_core/renderer_vulkan/renderer_vulkan.cpp @@ -180,6 +180,8 @@ try , frame_gen(memory_allocator, scheduler) #endif { + // The overlay panels go on the screen only: not in screenshots or the applets' capture + blit_swapchain.DrawOverlayPanels(true); if (Settings::values.renderer_force_max_clock.GetValue() && device.ShouldBoostClocks()) { turbo_mode.emplace(instance, dld); diff --git a/src/video_core/renderer_vulkan/vk_blit_screen.cpp b/src/video_core/renderer_vulkan/vk_blit_screen.cpp index 205382f81a..4228440423 100644 --- a/src/video_core/renderer_vulkan/vk_blit_screen.cpp +++ b/src/video_core/renderer_vulkan/vk_blit_screen.cpp @@ -44,44 +44,44 @@ void BlitScreen::SetWindowAdaptPass(const Device& device) { switch (scaling_filter) { case Settings::ScalingFilter::NearestNeighbor: - window_adapt = MakeNearestNeighbor(device, swapchain_view_format); + window_adapt = MakeNearestNeighbor(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Bicubic: - window_adapt = MakeBicubic(device, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_CATMULL_ROM_QCOM); + window_adapt = MakeBicubic(device, memory_allocator, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_CATMULL_ROM_QCOM); break; case Settings::ScalingFilter::ZeroTangent: - window_adapt = MakeBicubic(device, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_ZERO_TANGENT_CARDINAL_QCOM); + window_adapt = MakeBicubic(device, memory_allocator, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_ZERO_TANGENT_CARDINAL_QCOM); break; case Settings::ScalingFilter::BSpline: - window_adapt = MakeBicubic(device, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_B_SPLINE_QCOM); + window_adapt = MakeBicubic(device, memory_allocator, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_B_SPLINE_QCOM); break; case Settings::ScalingFilter::Mitchell: - window_adapt = MakeBicubic(device, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_MITCHELL_NETRAVALI_QCOM); + window_adapt = MakeBicubic(device, memory_allocator, swapchain_view_format, VK_CUBIC_FILTER_WEIGHTS_MITCHELL_NETRAVALI_QCOM); break; case Settings::ScalingFilter::Spline1: - window_adapt = MakeSpline1(device, swapchain_view_format); + window_adapt = MakeSpline1(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Gaussian: - window_adapt = MakeGaussian(device, swapchain_view_format); + window_adapt = MakeGaussian(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Lanczos: - window_adapt = MakeLanczos(device, swapchain_view_format); + window_adapt = MakeLanczos(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::ScaleForce: - window_adapt = MakeScaleForce(device, swapchain_view_format); + window_adapt = MakeScaleForce(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Area: - window_adapt = MakeArea(device, swapchain_view_format); + window_adapt = MakeArea(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Mmpx: - window_adapt = MakeMmpx(device, swapchain_view_format); + window_adapt = MakeMmpx(device, memory_allocator, swapchain_view_format); break; case Settings::ScalingFilter::Fsr: case Settings::ScalingFilter::Sgsr: case Settings::ScalingFilter::SgsrEdge: case Settings::ScalingFilter::Bilinear: default: - window_adapt = MakeBilinear(device, swapchain_view_format); + window_adapt = MakeBilinear(device, memory_allocator, swapchain_view_format); break; } } @@ -155,7 +155,8 @@ void BlitScreen::DrawToFrame(const Device& device, RasterizerVulkan& rasterizer, } } - window_adapt->Draw(device, rasterizer, scheduler, image_index, layers, framebuffers, layout, frame); + window_adapt->Draw(device, rasterizer, scheduler, image_index, layers, framebuffers, layout, frame, + overlay_panels); if (++image_index >= image_count) { image_index = 0; diff --git a/src/video_core/renderer_vulkan/vk_blit_screen.h b/src/video_core/renderer_vulkan/vk_blit_screen.h index 56b429ae53..719e348d43 100644 --- a/src/video_core/renderer_vulkan/vk_blit_screen.h +++ b/src/video_core/renderer_vulkan/vk_blit_screen.h @@ -62,6 +62,12 @@ public: void PrepareFrame(const Device& device, Frame* frame, const Layout::FramebufferLayout& layout); + /// Draws the overlay panels on top (the screen's pass only: never in a screenshot or the + /// applets' capture) + void DrawOverlayPanels(bool draw) { + overlay_panels = draw; + } + [[nodiscard]] vk::Framebuffer CreateFramebuffer(const Device& device, const Layout::FramebufferLayout& layout, VkImageView image_view, VkFormat current_view_format); @@ -83,6 +89,7 @@ private: Settings::ScalingFilter scaling_filter{}; std::unique_ptr window_adapt{}; + bool overlay_panels = false; std::list layers{}; }; diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index b93080f2a1..6d7c25e15b 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -43,6 +43,8 @@ add_executable(yuzu configuration/configure_applets.cpp configuration/configure_applets.h configuration/configure_applets.ui + configuration/configure_modern_per_game.cpp + configuration/configure_modern_per_game.h configuration/configure_audio.cpp configuration/configure_audio.h configuration/configure_audio.ui @@ -90,6 +92,8 @@ add_executable(yuzu configuration/configure_input_advanced.cpp configuration/configure_input_advanced.h configuration/configure_input_advanced.ui + configuration/configure_player_assignment.cpp + configuration/configure_player_assignment.h configuration/configure_input_per_game.cpp configuration/configure_input_per_game.h configuration/configure_input_per_game.ui @@ -194,6 +198,8 @@ add_executable(yuzu multiplayer/state.cpp multiplayer/state.h multiplayer/validation.h + overlay_panel.cpp + overlay_panel.h startup_checks.cpp startup_checks.h set_play_time_dialog.cpp @@ -204,6 +210,8 @@ add_executable(yuzu util/controller_navigation.h util/limitable_input_dialog.cpp util/limitable_input_dialog.h + util/gamescope.cpp + util/gamescope.h util/overlay_dialog.cpp util/overlay_dialog.h util/overlay_dialog.ui diff --git a/src/yuzu/applets/qt_software_keyboard.cpp b/src/yuzu/applets/qt_software_keyboard.cpp index aa77e082bd..583565fdbf 100644 --- a/src/yuzu/applets/qt_software_keyboard.cpp +++ b/src/yuzu/applets/qt_software_keyboard.cpp @@ -19,6 +19,7 @@ #include "yuzu/applets/qt_software_keyboard.h" #include "yuzu/main_window.h" #include "yuzu/util/overlay_dialog.h" +#include "yuzu/util/gamescope.h" namespace { @@ -42,8 +43,12 @@ QtSoftwareKeyboardDialog::QtSoftwareKeyboardDialog( is_inline{is_inline_}, initialize_parameters{std::move(initialize_parameters_)} { ui->setupUi(this); + // Under gamescope only one normal window shows at a time: a window bypassing the window + // manager is drawn over the game instead of in its place + const bool gamescope = Gamescope::IsRunningUnder(); setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | Qt::WindowTitleHint | - Qt::WindowSystemMenuHint | Qt::CustomizeWindowHint); + Qt::WindowSystemMenuHint | Qt::CustomizeWindowHint | + (gamescope ? Qt::X11BypassWindowManagerHint : Qt::WindowFlags{})); setWindowModality(Qt::WindowModal); setAttribute(Qt::WA_DeleteOnClose); setAttribute(Qt::WA_TranslucentBackground); diff --git a/src/yuzu/configuration/configure_applets.cpp b/src/yuzu/configuration/configure_applets.cpp index 139bfa9da3..2ed88f1f77 100644 --- a/src/yuzu/configuration/configure_applets.cpp +++ b/src/yuzu/configuration/configure_applets.cpp @@ -1,6 +1,21 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2024 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "common/controller_console_state.h" #include "common/settings.h" #include "core/core.h" #include "ui_configure_applets.h" @@ -47,6 +62,16 @@ void ConfigureApplets::Setup(const ConfigurationShared::Builder& builder) { push(Settings::values.linkage.by_category[Settings::Category::LibraryApplet]); for (auto setting : settings) { + // The Modern Console options are the Controllers tab's (and a game's Modern Console + // tab). Skipped before they are built: building registers how to apply them. + if (setting->Id() == Settings::values.console_debug_overlay.Id() || + setting->Id() == Settings::values.console_hints.Id() || + setting->Id() == Settings::values.console_joycon_auto.Id() || + setting->Id() == Settings::values.console_pause_disconnect.Id() || + setting->Id() == Settings::values.console_player_lights.Id() || + setting->Id() == Settings::values.console_button_labels.Id()) { + continue; + } ConfigurationShared::Widget* widget = builder.BuildWidget(setting, apply_funcs); if (widget == nullptr) { diff --git a/src/yuzu/configuration/configure_applets.h b/src/yuzu/configuration/configure_applets.h index b794b49e0b..acd91dff8d 100644 --- a/src/yuzu/configuration/configure_applets.h +++ b/src/yuzu/configuration/configure_applets.h @@ -6,12 +6,17 @@ #pragma once +#include #include #include "yuzu/configuration/configuration_shared.h" class QCheckBox; -class QLineEdit; +class QComboBox; class QDateTimeEdit; +class QLabel; +class QLineEdit; +class QPushButton; +class QTimer; namespace Core { class System; } diff --git a/src/yuzu/configuration/configure_dialog.cpp b/src/yuzu/configuration/configure_dialog.cpp index 547008262e..f4d94a47b3 100644 --- a/src/yuzu/configuration/configure_dialog.cpp +++ b/src/yuzu/configuration/configure_dialog.cpp @@ -121,6 +121,23 @@ ConfigureDialog::~ConfigureDialog() = default; void ConfigureDialog::SetConfiguration() {} +void ConfigureDialog::OpenPlayerTab(int player) { + QWidget* tab = input_tab->PlayerTab(player); + if (!tab) { + return; + } + // Controls is the page whose tabs are the input tab's + for (int row = 0; row < ui->selectorList->count(); ++row) { + const auto tabs = + qvariant_cast>(ui->selectorList->item(row)->data(Qt::UserRole)); + if (tabs.contains(tab)) { + ui->selectorList->setCurrentRow(row); + ui->tabWidget->setCurrentWidget(tab); + return; + } + } +} + void ConfigureDialog::ApplyConfiguration() { general_tab->ApplyConfiguration(); ui_tab->ApplyConfiguration(); diff --git a/src/yuzu/configuration/configure_dialog.h b/src/yuzu/configuration/configure_dialog.h index 73db11238e..65a086e5c9 100644 --- a/src/yuzu/configuration/configure_dialog.h +++ b/src/yuzu/configuration/configure_dialog.h @@ -55,6 +55,9 @@ public: Core::System& system_, bool enable_web_config = true); ~ConfigureDialog() override; + /// Opens on a player's tab of the Controls page (0-based player) + void OpenPlayerTab(int player); + void ApplyConfiguration(); private slots: diff --git a/src/yuzu/configuration/configure_graphics.cpp b/src/yuzu/configuration/configure_graphics.cpp index 641f65d82a..c0d6b90d8a 100644 --- a/src/yuzu/configuration/configure_graphics.cpp +++ b/src/yuzu/configuration/configure_graphics.cpp @@ -4,6 +4,7 @@ // SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +38,7 @@ #include "yuzu/configuration/configuration_shared.h" #include "yuzu/configuration/configure_graphics.h" #include "yuzu/configuration/shared_widget.h" +#include "yuzu/util/gamescope.h" #ifdef HAS_RESHADE #include "yuzu/configuration/configure_post_processing.h" @@ -79,7 +82,10 @@ ConfigureGraphics::ConfigureGraphics( } index++; } - if (static_cast(index) < vsync_mode_combobox_enum_map.size()) { + // (A mode greyed out for Game Mode is never selected) + const auto* vsync_model = qobject_cast(vsync_mode_combobox->model()); + if (static_cast(index) < vsync_mode_combobox_enum_map.size() && + (!vsync_model || vsync_model->item(index)->isEnabled())) { vsync_mode_combobox->setCurrentIndex(index); } @@ -169,6 +175,35 @@ void ConfigureGraphics::PopulateVSyncModeSelection(bool use_setting) { index++; } + // In Steam's Game Mode, gamescope offers only some present modes: the others are listed + // greyed out, saying how to get them, rather than missing without a word + const bool gamescope = Gamescope::IsRunningUnder(); + if (gamescope && backend == Settings::RendererBackend::Vulkan) { + auto* model = qobject_cast(vsync_mode_combobox->model()); + for (const auto missing : {VK_PRESENT_MODE_IMMEDIATE_KHR, VK_PRESENT_MODE_FIFO_RELAXED_KHR, + VK_PRESENT_MODE_MAILBOX_KHR}) { + if (std::find(present_modes.begin(), present_modes.end(), missing) != + present_modes.end()) { + continue; + } + const auto mode_name = TranslateVSyncMode(missing, backend); + if (mode_name.isEmpty() || !model) { + continue; + } + vsync_mode_combobox->insertItem(index, + tr("%1 - unavailable in Game Mode").arg(mode_name)); + vsync_mode_combobox->setItemData( + index, + tr("Steam's Game Mode does not offer this mode by default. In Steam's Performance " + "settings, turn off Disable Frame Limit and turn on Allow Tearing, then open " + "these settings again."), + Qt::ToolTipRole); + model->item(index)->setEnabled(false); + vsync_mode_combobox_enum_map.push_back(missing); + index++; + } + } + if (!Settings::IsConfiguringGlobal()) { vsync_restore_global_button->setVisible(!Settings::values.vsync_mode.UsingGlobal()); diff --git a/src/yuzu/configuration/configure_input.cpp b/src/yuzu/configuration/configure_input.cpp index 4473df901e..24dcbb665b 100644 --- a/src/yuzu/configuration/configure_input.cpp +++ b/src/yuzu/configuration/configure_input.cpp @@ -4,9 +4,34 @@ // SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "common/controller_console_state.h" #include "common/settings.h" #include "common/settings_enums.h" #include "core/core.h" @@ -15,6 +40,7 @@ #include "core/hle/service/sm/sm.h" #include "hid_core/frontend/emulated_controller.h" #include "hid_core/hid_core.h" +#include "qt_common/config/uisettings.h" #include "qt_common/qt_compat.h" #include "ui_configure_input.h" #include "ui_configure_input_advanced.h" @@ -25,6 +51,7 @@ #include "yuzu/configuration/configure_input_advanced.h" #include "yuzu/configuration/configure_input_player.h" #include "yuzu/configuration/configure_motion_touch.h" +#include "yuzu/configuration/configure_player_assignment.h" #include "yuzu/configuration/configure_ringcon.h" #include "yuzu/configuration/configure_touchscreen_advanced.h" #include "yuzu/configuration/configure_vibration.h" @@ -142,6 +169,334 @@ void ConfigureInput::Initialize(InputCommon::InputSubsystem* input_subsystem, connect(player_controllers[0], &ConfigureInputPlayer::HandheldStateChanged, [this](bool is_handheld) { UpdateDockedState(is_handheld); }); + // The Controllers tab comes first: how players are assigned, and who is which player now + assignment = new ConfigurePlayerAssignment(this); + assignment->setAccessibleName(tr("Controllers")); + assignment->SetOpenPlayerTab([this](int player) { + for (QWidget* parent = player_tabs[player]->parentWidget(); parent; + parent = parent->parentWidget()) { + if (auto* tabs = qobject_cast(parent)) { + tabs->setCurrentWidget(player_tabs[player]); + return; + } + } + }); + + // Console Mode first in the Controllers tab: a mirror of the player tabs' Docked/Handheld + // choice, which stays the one applied + { + auto* console_box = new QGroupBox(tr("Console Mode"), assignment); + auto* console_layout = new QHBoxLayout(console_box); + auto* console_group = new QButtonGroup(console_box); + console_layout->addWidget(ConfigurePlayerAssignment::MakeSegmented( + console_box, {tr("Docked"), tr("Handheld")}, {0, 1}, console_group)); + console_layout->addStretch(1); + auto* docked = console_group->button(0); + auto* handheld = console_group->button(1); + assignment->AddTopWidget(console_box); + const auto mirror = [this, docked, handheld] { + docked->setChecked(ui->radioDocked->isChecked()); + handheld->setChecked(ui->radioUndocked->isChecked()); + docked->setEnabled(ui->radioDocked->isEnabled()); + handheld->setEnabled(ui->radioUndocked->isEnabled()); + }; + connect(ui->radioDocked, &QRadioButton::toggled, this, mirror); + connect(ui->radioUndocked, &QRadioButton::toggled, this, mirror); + connect(docked, &QAbstractButton::clicked, this, + [this] { ui->radioDocked->setChecked(true); }); + connect(handheld, &QAbstractButton::clicked, this, + [this] { ui->radioUndocked->setChecked(true); }); + mirror_console_mode = mirror; + mirror(); + } + + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + followed_generation = state.generation; + } + follow_timer = new QTimer(this); + connect(follow_timer, &QTimer::timeout, this, [this] { + unsigned long long generation = 0; + std::vector rows; + bool engine_active = false; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + generation = state.generation; + engine_active = state.engine_active; + rows = state.players; + } + last_rows = rows; + // In the automatic modes only the players' tabs show, and connection can't be toggled + const auto mode = Settings::values.controller_applet_mode.GetValue(); + const bool automatic = mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode; + QTabWidget* tabs = nullptr; + for (QWidget* parent = player_tabs[0]->parentWidget(); parent && !tabs; + parent = parent->parentWidget()) { + tabs = qobject_cast(parent); + } + // The tabs follow the engine's rows and flashes, like the overlay + constexpr long long flash_ms = 1500; + const long long now = QDateTime::currentMSecsSinceEpoch(); + // The player tabs' choice can be locked (player 1 set to Handheld) without toggling + if (mirror_console_mode) { + mirror_console_mode(); + } + // The tabs show the battery percent only while they all fit with it (else the bar + // scrolls, or squeezes the labels on macOS). Judged with "100%" so it doesn't flicker. + bool tab_percents = true; + if (tabs) { + QFont bold = tabs->tabBar()->font(); + bold.setBold(true); + const QFontMetrics fm{bold}; + int shown_extra = 0; + int most_extra = 0; + for (int t = 0; t < tabs->count(); ++t) { + if (!tabs->isTabVisible(t) || tabs->tabIcon(t).isNull()) { + continue; + } + const QString text = tabs->tabText(t); + const int at = text.lastIndexOf(QLatin1Char(' ')); + if (text.endsWith(QLatin1Char('%')) && at > 0) { + shown_extra += fm.horizontalAdvance(text.mid(at)); + } + most_extra += fm.horizontalAdvance(QStringLiteral(" 100%")); + } + const int need = tabs->tabBar()->sizeHint().width() - shown_extra + most_extra; + tab_percents = need <= tabs->width() - 8; + } + // Several players: the tabs drag and carry arrows; one alone has nothing to reorder + const bool several = + std::count_if(rows.begin(), rows.end(), [](const auto& r) { return !r.leaving; }) > 1; + for (std::size_t i = 0; i < player_tabs.size(); ++i) { + const auto row = std::find_if(rows.begin(), rows.end(), [i](const auto& r) { + return r.player == static_cast(i); + }); + bool shown = true; + const QColor label; // the tab's own text colour: the bubbles colour themselves + tab_lit[i] = 0; + if (automatic) { + shown = engine_active && row != rows.end(); + // Lit as the menu bar's chips are + const long long elapsed = shown ? now - row->flash_since : 0; + const bool blink = + shown && row->flash != 0 && elapsed < flash_ms && (elapsed / 125) % 2 == 0; + if (blink && row->flash == 2) { + tab_lit[i] = 3; + } else if (shown && row->active && !row->leaving) { + tab_lit[i] = 1; + } else if (blink) { + tab_lit[i] = 2; + } + } + if (tabs && !tab_dragging) { + if (const int index = tabs->indexOf(player_tabs[i]); index >= 0) { + if (player_tab_titles[i].isEmpty()) { + player_tab_titles[i] = tabs->tabText(index); + } + // Only what changed: each of these lays the tab bar out again + const auto set_text = [tabs, index](const QString& text) { + if (tabs->tabText(index) != text) { + tabs->setTabText(index, text); + } + }; + const auto set_tip = [tabs, index](const QString& tip) { + if (tabs->tabToolTip(index) != tip) { + tabs->setTabToolTip(index, tip); + } + }; + if (tabs->isTabVisible(index) != shown) { + tabs->setTabVisible(index, shown); + } + if (tabs->tabBar()->tabTextColor(index) != label) { + tabs->tabBar()->setTabTextColor(index, label); + } + // The controller's battery as the tab's icon, when it tells its charge + const int battery = automatic && shown && row->battery >= 0 && !row->leaving + ? row->battery * 2 + (row->charging ? 1 : 0) + : -1; + // (Also when the tab lost it: the dialog re-adds the tabs on page changes) + if (tab_battery[i] != battery || + tabs->tabIcon(index).isNull() != (battery < 0)) { + tab_battery[i] = battery; + tabs->setTabIcon(index, + battery < 0 ? QIcon{} + : QIcon(ConfigurePlayerAssignment::Battery( + row->battery, row->charging))); + } + if (!automatic) { + set_text(player_tab_titles[i]); + set_tip({}); + } else if (shown) { + // The grip shows the tab drags; the percent is in the label so the tab + // has room for it (the icon is drawn after it) + const QString percent = battery < 0 || !tab_percents + ? QString{} + : QStringLiteral(" %1%").arg(row->battery); + set_text((several ? QStringLiteral("\u283F P%1").arg(i + 1) + : QStringLiteral("P%1").arg(i + 1)) + + percent); + const QString battery_tip = + battery < 0 ? QString{} + : row->charging + ? tr("\nBattery %1% (charging)").arg(row->battery) + : tr("\nBattery %1%").arg(row->battery); + set_tip(tr("P%1: %2\nDrag left or right to change the player order") + .arg(i + 1) + .arg(QString::fromStdString(row->name)) + + battery_tip); + } + } + } + player_controllers[i]->SetConnectionLocked(automatic); + } + // In the automatic modes the player tabs drag to change the player order + if (tabs) { + const bool controls_page = tabs->indexOf(player_tabs[0]) >= 0; + tabs_editable = automatic && engine_active && controls_page && several; + // Under the dark themes Qt's drag would show the theme's tab: Eden drags it instead + tabs->setMovable(tabs_editable && !UISettings::IsDarkTheme()); + // Pill tabs in the players' colours (the tab bar is shared with the dialog's other + // pages: only on this page) + QTabBar* bar = tabs->tabBar(); + const bool pills = automatic && controls_page; + // (The bar is remembered: under a style sheet its style() is a wrapper, not ours) + if (pills && pill_bar != bar) { + auto* style = new PlayerTabStyle(QApplication::style()->name()); + style->setParent(bar); + bar->setStyle(style); + // (The battery icons' own shape: a square icon would shrink them) + bar->setIconSize(QSize(20, 12)); + pill_bar = bar; + } else if (!pills && pill_bar == bar) { + bar->setIconSize(QSize{}); + bar->setStyle(nullptr); + pill_bar = nullptr; + } + // The Midnight Blue themes resize a hovered tab (a 1-pixel border, less padding), so + // the page shook on hover: the hovered tab keeps its size + const bool midnight = QString::fromStdString(UISettings::values.theme) + .contains(QStringLiteral("midnight")); + const QString steady = + midnight ? QStringLiteral("QTabBar::tab:top:!selected:hover { padding-left: 7px; " + "padding-right: 7px; padding-top: 1px; " + "padding-bottom: 1px; }") + : QString{}; + if (bar->styleSheet() != steady) { + bar->setStyleSheet(steady); + } + // The dark themes' style sheet draws the tabs itself: the bubbles are painted over it + const bool painted = pills && UISettings::IsDarkTheme(); + if (painted) { + if (!tab_overlay || tab_overlay->parentWidget() != bar) { + delete tab_overlay; + tab_overlay = + new PlayerTabOverlay(bar, QString::fromStdString(UISettings::values.theme) + .contains(QStringLiteral("midnight")) + ? QColor(0x19, 0x23, 0x2D) + : QColor(0x31, 0x36, 0x3B)); + } + if (tab_overlay->geometry() != bar->rect()) { + tab_overlay->setGeometry(bar->rect()); + } + if (!tab_overlay->isVisible()) { + tab_overlay->lower(); + tab_overlay->show(); + } + tab_overlay->update(); + } else if (tab_overlay) { + tab_overlay->hide(); + } + // A player tab's colour is what makes it a bubble: none outside these modes + for (std::size_t i = 0; i < player_tabs.size(); ++i) { + if (const int index = tabs->indexOf(player_tabs[i]); index >= 0) { + const QVariant tab_data = + pills ? QVariant(QVariantList{ + ConfigurePlayerAssignment::PlayerColor(static_cast(i)), + tab_lit[i]}) + : QVariant{}; + if (bar->tabData(index) != tab_data) { + bar->setTabData(index, tab_data); + bar->update(); // (lit or not: the bubble repaints) + } + } + } + if (tabs != drag_tabs) { + drag_tabs = tabs; + tabs->tabBar()->installEventFilter(this); + connect(tabs, &QTabWidget::currentChanged, this, &ConfigureInput::UpdateTabArrows); + } + if (pending_select >= 0) { + const int index = tabs->indexOf(player_tabs[pending_select]); + if (index >= 0 && tabs->isTabVisible(index)) { + tabs->setCurrentIndex(index); + pending_select = -1; + } else if (now > pending_select_until) { + pending_select = -1; + } + } + if (!tab_dragging) { + UpdateTabArrows(); + } + } + + // Clicking a player's tab rumbles its controller, to tell which one it is + if (tabs && tabs != rumble_tabs) { + rumble_tabs = tabs; + connect(tabs->tabBar(), &QTabBar::tabBarClicked, this, [this, tabs](int index) { + const auto applet_mode = Settings::values.controller_applet_mode.GetValue(); + if (applet_mode != Settings::AppletMode::ConsoleMode && + applet_mode != Settings::AppletMode::HandheldConsoleMode) { + return; + } + const auto it = + std::find(player_tabs.begin(), player_tabs.end(), tabs->widget(index)); + if (it == player_tabs.end()) { + return; + } + const auto player = static_cast(it - player_tabs.begin()); + if (!Settings::values.vibration_enabled.GetValue() || + !Settings::values.players.GetValue()[player].vibration_enabled) { + return; + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.rumble_requests.push_back(static_cast(player)); + }); + } + + // The Controllers tab carries Console Mode in the automatic modes + ui->handheldGroup->setVisible(!automatic); + ui->connectedControllers->setVisible(!automatic); + // A player playing as a single Joy-Con: its mapping is the engine's (no Defaults/Clear) + if (auto* tab = qobject_cast(ui->consoleInputSettings->parent())) { + const bool locked = tab->JoyConLocked(); + ui->buttonClearAll->setEnabled(!locked); + ui->buttonRestoreDefaults->setEnabled(!locked); + } + + if (generation == followed_generation) { + return; + } + followed_generation = generation; + for (auto* controller : player_controllers) { + controller->RefreshFromController(); + } + // The boxes mirror the emulated controllers without acting on them + for (auto* checkbox : connected_controller_checkboxes) { + checkbox->blockSignals(true); + } + LoadPlayerControllerIndices(); + for (auto* checkbox : connected_controller_checkboxes) { + checkbox->blockSignals(false); + } + }); + follow_timer->start(50); + ui->tabWidget->insertTab(0, assignment, tr("Controllers")); + ui->tabWidget->setCurrentIndex(0); + advanced = new ConfigureInputAdvanced(hid_core, this); QHBoxLayout* advancedLayout = new QHBoxLayout(ui->tabAdvanced); @@ -201,10 +556,489 @@ void ConfigureInput::PropagatePlayerNumberChanged(size_t player_index, bool chec } } +namespace { + +/// A player tab's colour and how it is lit, from its data; nothing for other tabs +struct PlayerTabLook { + QColor accent; + int lit = 0; +}; + +std::optional TabLook(const QVariant& data) { + if (data.metaType() != QMetaType::fromType()) { + return std::nullopt; + } + const QVariantList list = data.toList(); + if (list.size() != 2) { + return std::nullopt; + } + return PlayerTabLook{list[0].value(), list[1].toInt()}; +} + +/// A player tab's label with its battery after it, the two centred together in the area +void PaintTabLabel(QPainter& painter, const QRect& area, const QString& text, const QIcon& icon, + const QSize& icon_size) { + if (icon.isNull()) { + painter.drawText(area, Qt::AlignCenter, text); + return; + } + const int text_w = QFontMetrics(painter.font()).horizontalAdvance(text); + const int left = area.center().x() - (text_w + 4 + icon_size.width()) / 2; + painter.drawText(QRect(left, area.top(), text_w + 2, area.height()), + Qt::AlignVCenter | Qt::AlignLeft, text); + icon.paint(&painter, QRect(left + text_w + 4, area.center().y() - icon_size.height() / 2, + icon_size.width(), icon_size.height())); +} + +/// Like the menu bar's chips: outlined, filled while an input is held; the selected tab the +/// other way round +struct TabPaint { + bool filled; + QColor fill; + QColor ink; +}; + +/// A chevron drawn as an icon, so it sits centred in its button; dimmed when disabled +QIcon ChevronIcon(const QColor& ink, bool left, qreal ratio) { + const auto draw = [&](int alpha) { + QPixmap pixmap(QSize(14, 14) * ratio); + pixmap.setDevicePixelRatio(ratio); + pixmap.fill(Qt::transparent); + QPainter painter{&pixmap}; + painter.setRenderHint(QPainter::Antialiasing); + QColor color = ink; + color.setAlpha(alpha); + painter.setPen(QPen(color, 2.2, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + const qreal tip = left ? 4.5 : 9.5; + const qreal back = left ? 9.0 : 5.0; + const QPointF chevron[3] = {QPointF(back, 2.5), QPointF(tip, 7), QPointF(back, 11.5)}; + painter.drawPolyline(chevron, 3); + return pixmap; + }; + QIcon icon; + icon.addPixmap(draw(255), QIcon::Normal); + icon.addPixmap(draw(80), QIcon::Disabled); + return icon; +} + +TabPaint PaintFor(const PlayerTabLook& look, bool selected) { + if (look.lit == 3) { + return {true, QColor(200, 50, 50), QColor(Qt::white)}; + } + if (look.lit == 2) { + return {true, QColor(45, 160, 80), QColor(Qt::white)}; + } + const bool filled = selected != (look.lit == 1); + return {filled, look.accent, filled ? QColor(Qt::white) : look.accent}; +} + +} // Anonymous namespace + +PlayerTabStyle::PlayerTabStyle(const QString& base) : QProxyStyle(base) {} + +void PlayerTabStyle::drawControl(ControlElement element, const QStyleOption* option, + QPainter* painter, const QWidget* widget) const { + const auto* tab = qstyleoption_cast(option); + const auto* bar = qobject_cast(widget); + if (!tab || !bar || (element != CE_TabBarTabShape && element != CE_TabBarTabLabel)) { + QProxyStyle::drawControl(element, option, painter, widget); + return; + } + // Found by its text: a dragged tab is painted into an image of its own + int index = -1; + for (int i = 0; i < bar->count() && index < 0; ++i) { + if (bar->tabText(i) == tab->text) { + index = i; + } + } + // Only a player tab is a bubble, in its player's colour; the others stay as they were + const auto look = index >= 0 ? TabLook(bar->tabData(index)) : std::nullopt; + if (!look) { + QProxyStyle::drawControl(element, option, painter, widget); + return; + } + const bool selected = tab->state & State_Selected; + const TabPaint paint = PaintFor(*look, selected); + if (element == CE_TabBarTabShape) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); + const QRectF pill = QRectF(tab->rect).adjusted(2, 3, -2, -3); + if (paint.filled) { + painter->setPen(Qt::NoPen); + painter->setBrush(paint.fill); + } else { + painter->setPen(QPen(look->accent, 1.5)); + QColor hover = look->accent; + hover.setAlpha(tab->state & State_MouseOver ? 45 : 0); + painter->setBrush(hover); + } + painter->drawRoundedRect(pill, pill.height() / 2, pill.height() / 2); + painter->restore(); + return; + } + // The label: bold, white on a filled tab, else in the player's colour, with its battery + // after it (between the tab's arrows) + painter->save(); + QFont font = painter->font(); + font.setBold(true); + painter->setFont(font); + painter->setPen(paint.ink); + const QRect area = tab->rect.adjusted(tab->leftButtonSize.width(), 0, + -tab->rightButtonSize.width(), 0); + PaintTabLabel(*painter, area, tab->text, tab->icon, tab->iconSize); + painter->restore(); +} + +QSize PlayerTabStyle::sizeFromContents(ContentsType type, const QStyleOption* option, + const QSize& size, const QWidget* widget) const { + QSize result = QProxyStyle::sizeFromContents(type, option, size, widget); + // Room for a player tab's rounded ends and bold label + const auto* tab = qstyleoption_cast(option); + if (type == CT_TabBarTab && tab && + (tab->text.startsWith(QChar(0x283F)) || !tab->icon.isNull())) { + result += QSize(14, 4); + if (widget) { + QFont bold = widget->font(); + bold.setBold(true); + result.rwidth() += (std::max)(0, QFontMetrics(bold).horizontalAdvance(tab->text) - + widget->fontMetrics().horizontalAdvance(tab->text)); + } + } + return result; +} + +PlayerTabOverlay::PlayerTabOverlay(QTabBar* bar_, QColor background_) + : QWidget(bar_), bar{bar_}, background{background_} { + setAttribute(Qt::WA_TransparentForMouseEvents); + setAttribute(Qt::WA_NoSystemBackground); + // The dark themes give a plain widget a solid background, which would cover the tab bar + setStyleSheet(QStringLiteral("background: transparent;")); +} + +void PlayerTabOverlay::SetDrag(int index, int offset) { + drag_index = index; + drag_offset = offset; + update(); +} + +void PlayerTabOverlay::paintEvent(QPaintEvent*) { + QPainter painter{this}; + painter.setRenderHint(QPainter::Antialiasing); + const int hovered = bar->underMouse() ? bar->tabAt(bar->mapFromGlobal(QCursor::pos())) : -1; + const auto bubble = [&](int i, QRect tab, bool around_arrows) { + const auto look = TabLook(bar->tabData(i)); + const TabPaint paint = PaintFor(*look, i == bar->currentIndex()); + const QRectF pill = QRectF(tab).adjusted(2, 3, -2, -3); + if (paint.filled) { + painter.setPen(Qt::NoPen); + painter.setBrush(paint.fill); + } else { + painter.setPen(QPen(look->accent, 1.5)); + QColor hover = look->accent; + hover.setAlpha(i == hovered ? 45 : 0); + painter.setBrush(hover); + } + painter.drawRoundedRect(pill, pill.height() / 2, pill.height() / 2); + // The label, between the tab's arrows + QRect text = tab; + if (around_arrows) { + if (const QWidget* left = bar->tabButton(i, QTabBar::LeftSide)) { + text.setLeft(left->geometry().right() + 1); + } + if (const QWidget* right = bar->tabButton(i, QTabBar::RightSide)) { + text.setRight(right->geometry().left() - 1); + } + } + QFont font = bar->font(); + font.setBold(true); + painter.setFont(font); + painter.setPen(paint.ink); + // The battery after the label + PaintTabLabel(painter, text, bar->tabText(i), bar->tabIcon(i), bar->iconSize()); + }; + for (int i = 0; i < bar->count(); ++i) { + const auto look = TabLook(bar->tabData(i)); + if (!look || !bar->isTabVisible(i)) { + continue; + } + const QRect tab = bar->tabRect(i); + // The theme's tab goes under the theme's background, and the bubble goes on top + painter.fillRect(tab, background); + if (i == drag_index) { + // Where the dragged tab came from: an empty slot + QColor edge = look->accent; + edge.setAlpha(90); + painter.setPen(QPen(edge, 1, Qt::DashLine)); + painter.setBrush(Qt::NoBrush); + const QRectF slot = QRectF(tab).adjusted(3, 4, -3, -4); + painter.drawRoundedRect(slot, slot.height() / 2, slot.height() / 2); + continue; + } + bubble(i, tab, true); + } + // The dragged tab, last, over the others + if (drag_index >= 0 && drag_index < bar->count()) { + bubble(drag_index, bar->tabRect(drag_index).translated(drag_offset, 0), false); + } +} + +void ConfigureInput::SwapPlayers(int a, int b) { + if (a == b || a < 0 || b < 0 || a >= 8 || b >= 8) { + return; + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.swap_requests.emplace_back(a, b); +} + +bool ConfigureInput::eventFilter(QObject* object, QEvent* event) { + if (drag_tabs && object == drag_tabs->tabBar() && tab_overlay && tab_overlay->isVisible() && + DragPlayerTab(event)) { + return true; + } + if (drag_tabs && object == drag_tabs->tabBar() && drag_tabs->isMovable()) { + if (event->type() == QEvent::MouseButtonPress && + static_cast(event)->button() == Qt::LeftButton) { + tab_order.clear(); + for (int k = 0; k < drag_tabs->count(); ++k) { + tab_order.push_back(drag_tabs->widget(k)); + } + const QPoint at = static_cast(event)->position().toPoint(); + dragged_tab = drag_tabs->widget(drag_tabs->tabBar()->tabAt(at)); + tab_dragging = true; + } else if (event->type() == QEvent::MouseButtonRelease && tab_dragging) { + tab_dragging = false; + // After the tab bar has finished its own drop + QTimer::singleShot(0, this, &ConfigureInput::FinishTabDrag); + } + } + return QWidget::eventFilter(object, event); +} + +void ConfigureInput::FinishTabDrag() { + QTabWidget* tabs = drag_tabs; + if (!tabs || tab_order.isEmpty()) { + return; + } + // The players in the order their tabs were dropped in + std::vector dropped; + for (int k = 0; k < tabs->count(); ++k) { + const auto it = std::find(player_tabs.begin(), player_tabs.end(), tabs->widget(k)); + if (it != player_tabs.end() && tabs->isTabVisible(k)) { + dropped.push_back(static_cast(it - player_tabs.begin())); + } + } + // The tabs go back in player order: the controllers move instead + for (int k = 0; k < static_cast(tab_order.size()); ++k) { + const int from = tabs->indexOf(tab_order[k]); + if (from >= 0 && from != k) { + tabs->tabBar()->moveTab(from, k); + } + } + tab_order.clear(); + const auto dragged = std::find(player_tabs.begin(), player_tabs.end(), dragged_tab); + ApplyDroppedOrder(dropped, dragged != player_tabs.end() + ? static_cast(dragged - player_tabs.begin()) + : -1); +} + +void ConfigureInput::ApplyDroppedOrder(const std::vector& dropped, int dragged_player) { + std::vector places = dropped; + std::sort(places.begin(), places.end()); + if (dropped == places) { + return; + } + + // Place k takes the controller player dropped[k] has now, one swap at a time + std::map holder; + for (const int place : places) { + holder[place] = place; + } + for (std::size_t k = 0; k < places.size(); ++k) { + int from = places[k]; + for (const auto& [place, original] : holder) { + if (original == dropped[k]) { + from = place; + } + } + if (from != places[k]) { + SwapPlayers(places[k], from); + std::swap(holder[places[k]], holder[from]); + } + } + + // The selected tab follows the controller that was dragged + const auto at = std::find(dropped.begin(), dropped.end(), dragged_player); + if (at != dropped.end()) { + pending_select = places[at - dropped.begin()]; + pending_select_until = QDateTime::currentMSecsSinceEpoch() + 2000; + } +} + +bool ConfigureInput::DragPlayerTab(QEvent* event) { + QTabBar* bar = drag_tabs->tabBar(); + const auto type = event->type(); + if (type != QEvent::MouseButtonPress && type != QEvent::MouseMove && + type != QEvent::MouseButtonRelease) { + return false; + } + const auto* mouse = static_cast(event); + const QPoint at = mouse->position().toPoint(); + const auto set_arrows = [bar](int index, bool shown) { + for (const auto side : {QTabBar::LeftSide, QTabBar::RightSide}) { + if (QWidget* button = bar->tabButton(index, side)) { + button->setVisible(shown); + } + } + }; + if (type == QEvent::MouseButtonPress) { + const int index = bar->tabAt(at); + // (Player tabs carry their look: colour and lit state) + own_drag_index = + mouse->button() == Qt::LeftButton && index >= 0 && TabLook(bar->tabData(index)) ? index + : -1; + own_drag_press_x = at.x(); + own_dragging = false; + return false; // the tab bar still selects the tab + } + if (own_drag_index < 0) { + return false; + } + const int offset = at.x() - own_drag_press_x; + if (type == QEvent::MouseMove) { + if (!own_dragging && std::abs(offset) < QApplication::startDragDistance()) { + return false; + } + if (!own_dragging) { + own_dragging = true; + set_arrows(own_drag_index, false); + } + tab_overlay->SetDrag(own_drag_index, offset); + return true; + } + // Released: the players take the order of the tabs with the dragged one where it was let go + const int index = own_drag_index; + const bool dragged = own_dragging; + own_drag_index = -1; + own_dragging = false; + if (!dragged) { + return false; + } + set_arrows(index, true); + tab_overlay->SetDrag(-1, 0); + std::vector> centres; // (x, player) + int dragged_player = -1; + for (int k = 0; k < bar->count(); ++k) { + const auto it = std::find(player_tabs.begin(), player_tabs.end(), drag_tabs->widget(k)); + if (it == player_tabs.end() || !bar->isTabVisible(k)) { + continue; + } + const int player = static_cast(it - player_tabs.begin()); + const int x = bar->tabRect(k).center().x() + (k == index ? offset : 0); + if (k == index) { + dragged_player = player; + } + centres.emplace_back(x, player); + } + std::stable_sort(centres.begin(), centres.end()); + std::vector dropped; + for (const auto& [x, player] : centres) { + dropped.push_back(player); + } + ApplyDroppedOrder(dropped, dragged_player); + return true; +} + +void ConfigureInput::UpdateTabArrows() { + QTabWidget* tabs = drag_tabs; + if (!tabs) { + return; + } + const bool movable = tabs_editable; + int last_player = -1; + for (const auto& row : last_rows) { + if (!row.leaving) { + last_player = (std::max)(last_player, row.player); + } + } + for (int player = 0; player < static_cast(player_tabs.size()); ++player) { + const int index = tabs->indexOf(player_tabs[player]); + if (index < 0) { + continue; + } + // (A tab's buttons go when the tab is removed, as on leaving the Controls page) + auto& left = tab_lefts[player]; + auto& right = tab_rights[player]; + // Only a shown tab carries arrows (a hidden tab's would float over the bar) + if (!movable || !tabs->isTabVisible(index)) { + if (left || right) { + tabs->tabBar()->setTabButton(index, QTabBar::LeftSide, nullptr); + tabs->tabBar()->setTabButton(index, QTabBar::RightSide, nullptr); + delete left; + delete right; + } + continue; + } + if (!left || !right) { + const auto make = [this, player](Qt::ArrowType arrow, int step) { + auto* button = new QToolButton; + // A chevron in the tab's ink (the dark themes' arrows are large triangles) + button->setProperty("left", arrow == Qt::LeftArrow); + button->setIconSize(QSize(14, 14)); + button->setAutoRaise(true); + button->setFixedSize(20, 20); + // The tab makes room for the size a style sheet reports (12 pixels under the dark + // themes, too small) + button->setStyleSheet(QStringLiteral("QToolButton { width: 20px; height: 20px; " + "padding: 0px; border: none; " + "background: transparent; }")); + connect(button, &QToolButton::clicked, this, [this, player, step] { + SwapPlayers(player, player + step); + pending_select = player + step; + pending_select_until = QDateTime::currentMSecsSinceEpoch() + 2000; + }); + return button; + }; + left = make(Qt::LeftArrow, -1); + right = make(Qt::RightArrow, 1); + tabs->tabBar()->setTabButton(index, QTabBar::LeftSide, left); + tabs->tabBar()->setTabButton(index, QTabBar::RightSide, right); + } + // Above the dark themes' bubble layer, which would hide them (and shown, but mid-drag) + if (!own_dragging) { + left->show(); + right->show(); + } + left->raise(); + right->raise(); + // In the tab's ink, like the chips' arrows: white on a filled tab, else its colour + if (const auto look = TabLook(tabs->tabBar()->tabData(index))) { + const TabPaint paint = PaintFor(*look, index == tabs->currentIndex()); + for (QToolButton* button : {left.data(), right.data()}) { + if (button->property("ink").value() != paint.ink) { + button->setProperty("ink", paint.ink); + button->setIcon(ChevronIcon(paint.ink, button->property("left").toBool(), + button->devicePixelRatioF())); + } + } + } + left->setEnabled(player > 0); + left->setToolTip(tr("Swap with P%1").arg(player)); + right->setEnabled(player < last_player); + right->setToolTip(tr("Swap with P%1").arg(player + 2)); + } +} + +QWidget* ConfigureInput::PlayerTab(int player) const { + return player >= 0 && player < static_cast(player_tabs.size()) ? player_tabs[player] + : nullptr; +} + QList ConfigureInput::GetSubTabs() const { + // The settings dialog shows these, not this widget's own tab bar return { - ui->tabPlayer1, ui->tabPlayer2, ui->tabPlayer3, ui->tabPlayer4, ui->tabPlayer5, - ui->tabPlayer6, ui->tabPlayer7, ui->tabPlayer8, ui->tabAdvanced, + assignment, ui->tabAdvanced, ui->tabPlayer1, ui->tabPlayer2, ui->tabPlayer3, + ui->tabPlayer4, ui->tabPlayer5, ui->tabPlayer6, ui->tabPlayer7, ui->tabPlayer8, }; } @@ -216,6 +1050,7 @@ void ConfigureInput::ApplyConfiguration() { } advanced->ApplyConfiguration(); + assignment->ApplyConfiguration(); const bool pre_docked_mode = Settings::IsDockedMode(); const bool docked_mode_selected = ui->radioDocked->isChecked(); diff --git a/src/yuzu/configuration/configure_input.h b/src/yuzu/configuration/configure_input.h index 9bdd0d68ca..6b1d46a4bf 100644 --- a/src/yuzu/configuration/configure_input.h +++ b/src/yuzu/configuration/configure_input.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2016 Citra Emulator Project @@ -7,12 +7,20 @@ #pragma once #include +#include #include +#include +#include #include #include +#include +#include +#include #include +#include "common/controller_console_state.h" + namespace Core { class System; } @@ -20,9 +28,12 @@ class System; class QCheckBox; class QString; class QTimer; +class QTabBar; +class QTabWidget; class ConfigureInputAdvanced; class ConfigureInputPlayer; +class ConfigurePlayerAssignment; class InputProfiles; @@ -36,6 +47,35 @@ class ConfigureInput; void OnDockedModeChanged(bool last_state, bool new_state, Core::System& system); +/// The settings tab bar in the automatic modes: player tabs drawn as bubbles in their colour +class PlayerTabStyle : public QProxyStyle { +public: + explicit PlayerTabStyle(const QString& base); + void drawControl(ControlElement element, const QStyleOption* option, QPainter* painter, + const QWidget* widget) const override; + QSize sizeFromContents(ContentsType type, const QStyleOption* option, const QSize& size, + const QWidget* widget) const override; +}; + +/// Under Eden's dark themes the style sheet draws the tabs: this layer paints the player tabs' +/// bubbles instead +class PlayerTabOverlay : public QWidget { +public: + PlayerTabOverlay(QTabBar* bar, QColor background); + /// A player tab being dragged (-1: none), drawn this far from its place + void SetDrag(int index, int offset); + +protected: + void paintEvent(QPaintEvent* event) override; + +private: + QTabBar* bar; + /// The theme's own background, which covers its tab under the bubble + QColor background; + int drag_index = -1; + int drag_offset = 0; +}; + class ConfigureInput : public QWidget { Q_OBJECT @@ -51,8 +91,22 @@ public: QList GetSubTabs() const; + /// A player's tab (0-based player) + QWidget* PlayerTab(int player) const; + private: void changeEvent(QEvent* event) override; + bool eventFilter(QObject* object, QEvent* event) override; + + /// Player tabs dropped in a new order: the controllers take that order, the tabs go back + void FinishTabDrag(); + /// The players' new order, as their tabs were dropped: the controllers move to match + void ApplyDroppedOrder(const std::vector& dropped, int dragged_player); + /// Eden's own drag of a player tab, under the dark themes (Qt's would be the theme's tab) + bool DragPlayerTab(QEvent* event); + /// The arrows on each player tab, swapping with the player before or after it + void UpdateTabArrows(); + void SwapPlayers(int a, int b); void RetranslateUI(); void ClearAll(); @@ -75,7 +129,39 @@ private: std::unique_ptr profiles; std::array player_controllers{}; + ConfigurePlayerAssignment* assignment = nullptr; + /// Redraws the player tabs when the engine reloads the controllers + QTimer* follow_timer = nullptr; + unsigned long long followed_generation = 0; + std::function mirror_console_mode; + /// The settings dialog's tab widget whose tab clicks rumble the players' controllers + QTabWidget* rumble_tabs = nullptr; std::array player_tabs{}; + /// Dragging the player tabs: the tab bar, and the order when the drag began + QTabWidget* drag_tabs = nullptr; + /// The tab bar drawn with PlayerTabStyle (its player tabs as bubbles) + QPointer pill_bar; + QPointer tab_overlay; + /// The players can be reordered from their tabs (the automatic modes, on this page) + bool tabs_editable = false; + /// Eden's own tab drag: the tab pressed, where, and whether it is being dragged + int own_drag_index = -1; + int own_drag_press_x = 0; + bool own_dragging = false; + QList tab_order; + QWidget* dragged_tab = nullptr; + bool tab_dragging = false; + std::array player_tab_titles{}; + std::vector last_rows; + /// How each player tab is lit (0 idle, 1 input held, 2 green blink, 3 red blink) + std::array tab_lit{}; + /// The battery each player tab shows (percent * 2 + charging, -1 none) + std::array tab_battery{-1, -1, -1, -1, -1, -1, -1, -1}; + std::array, 8> tab_lefts; + std::array, 8> tab_rights; + /// The tab to select once its player shows (the controller a drag or an arrow moved) + int pending_select = -1; + long long pending_select_until = 0; // Checkboxes representing the "Connected Controllers". std::array connected_controller_checkboxes{}; ConfigureInputAdvanced* advanced = nullptr; diff --git a/src/yuzu/configuration/configure_input_player.cpp b/src/yuzu/configuration/configure_input_player.cpp index 2750cd4a13..6178dadd53 100644 --- a/src/yuzu/configuration/configure_input_player.cpp +++ b/src/yuzu/configuration/configure_input_player.cpp @@ -6,13 +6,21 @@ #include #include +#include +#include #include +#include +#include +#include #include #include #include #include #include +#include +#include #include "common/assert.h" +#include "common/controller_console_state.h" #include "common/param_package.h" #include "hid_core/frontend/emulated_controller.h" #include "hid_core/hid_core.h" @@ -26,6 +34,7 @@ #include "yuzu/configuration/configure_input_player.h" #include "yuzu/configuration/configure_input_player_widget.h" #include "yuzu/configuration/configure_mouse_panning.h" +#include "yuzu/configuration/configure_player_assignment.h" #include "yuzu/configuration/input_profiles.h" #include "yuzu/util/limitable_input_dialog.h" @@ -289,6 +298,42 @@ QString ConfigureInputPlayer::AnalogToText(const Common::ParamPackage& param, return QObject::tr("[unknown]"); } +namespace { + +/// The SDL controller (guid, port) a player's bindings point at +std::optional> SdlDevice(const Settings::PlayerInput& player) { + for (const std::string& raw : player.buttons) { + const Common::ParamPackage param{raw}; + if (!raw.empty() && param.Get("engine", "") == "sdl") { + return std::pair{param.Get("guid", ""), param.Get("port", 0)}; + } + } + return std::nullopt; +} + +/// Points a player's SDL bindings at one controller, leaving other engines alone +void KeepSdlDevice(Settings::PlayerInput& player, const std::pair& device) { + const auto keep = [&device](auto& params) { + for (std::string& raw : params) { + Common::ParamPackage param{raw}; + if (raw.empty() || param.Get("engine", "") != "sdl") { + continue; + } + if (param.Has("guid2") && param.Get("guid2", "") == param.Get("guid", "")) { + param.Set("guid2", device.first); + } + param.Set("guid", device.first); + param.Set("port", device.second); + raw = param.Serialize(); + } + }; + keep(player.buttons); + keep(player.analogs); + keep(player.motions); +} + +} // Anonymous namespace + ConfigureInputPlayer::ConfigureInputPlayer(QWidget* parent, std::size_t player_index_, QWidget* bottom_row_, InputCommon::InputSubsystem* input_subsystem_, @@ -901,6 +946,552 @@ void ConfigureInputPlayer::ConnectPlayer(bool connected) { } } +void ConfigureInputPlayer::RefreshFromController() { + UpdateUI(); + UpdateInputDeviceCombobox(); + ApplyJoyConLock(); +} + +void ConfigureInputPlayer::ApplyJoyConLock() { + int held = 0; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + if (state.engine_active && player_index < state.joycon_held.size()) { + held = state.joycon_held[player_index]; + } + } + const bool locked = held != 0; + const bool changed = locked != joycon_locked; + joycon_locked = locked; + for (auto* button : button_map) { + if (button) { + button->setEnabled(!locked); + } + } + for (std::size_t i = 0; i < analog_map_buttons.size(); ++i) { + for (auto* button : analog_map_buttons[i]) { + if (button) { + button->setEnabled(!locked); + } + } + for (QWidget* widget : std::initializer_list{ + analog_map_modifier_button[i], analog_map_deadzone_slider[i], + analog_map_modifier_slider[i], analog_map_range_spinbox[i]}) { + if (widget) { + widget->setEnabled(!locked); + } + } + } + // (Saving would keep the Joy-Con's arrangement as the player's own mapping) + if (card_save) { + card_save->setEnabled(!locked); + card_save_new->setEnabled(!locked); + } + if (joycon_note) { + joycon_note->setVisible(locked); + if (locked) { + joycon_note->setText( + (held == 1 ? tr("P%1 plays as a sideways Joy-Con for this game") + : tr("P%1 plays as an upright Joy-Con for this game")) + .arg(player_index + 1) + + tr(": its controls are set automatically. Pick a controller type to set them " + "yourself.")); + } + } + if (card_status) { + card_status->setVisible(!locked); + } + if (changed && !locked && profile_card && profile_card->isVisible()) { + UpdateProfileCard(); + } +} + +void ConfigureInputPlayer::SetConnectionLocked(bool locked) { + const bool changed = connection_locked != locked || !style_applied; + connection_locked = locked; + style_applied = true; + // The automatic modes draw the tab like the Controllers tab, keeping the spacing (the page + // fills the Deck's screen exactly) + if (changed) { + setStyleSheet( + locked ? ConfigurePlayerAssignment::Themed(QStringLiteral( + "QLabel { background: transparent; }" + "QGroupBox { border: 1px solid palette(mid); border-radius: 8px; " + "margin-top: 1.1em; padding-bottom: 5px; " + "background: palette(alternate-base); }" + "QGroupBox::title { subcontrol-origin: margin; " + "subcontrol-position: top center; padding: 0 3px; }" + "QGroupBox QGroupBox { border: none; background: transparent; " + "padding-bottom: 0px; }" + "QGroupBox QGroupBox::title { padding: 0px; }" + // The single-button boxes carry their label inside, above the button + "QGroupBox#buttonShoulderButtonsButtonZLGroup, " + "QGroupBox#buttonShoulderButtonsButtonLGroup, " + "QGroupBox#buttonMiscButtonsMinusGroup, " + "QGroupBox#buttonMiscButtonsPlusGroup, " + "QGroupBox#buttonShoulderButtonsZRGroup, " + "QGroupBox#buttonShoulderButtonsRGroup, " + "QGroupBox#buttonMiscButtonsHomeGroup, " + "QGroupBox#buttonMiscButtonsScreenshotGroup { margin-top: 0px; " + "padding-top: 1.3em; }" + // The top row starts level with the section cards beside it + "QGroupBox#buttonShoulderButtonsButtonZLGroup, " + "QGroupBox#buttonShoulderButtonsButtonLGroup, " + "QGroupBox#buttonMiscButtonsMinusGroup, " + "QGroupBox#buttonMiscButtonsPlusGroup, " + "QGroupBox#buttonShoulderButtonsZRGroup, " + "QGroupBox#buttonShoulderButtonsRGroup { margin-top: 1.1em; }" + "QGroupBox#buttonShoulderButtonsButtonZLGroup::title, " + "QGroupBox#buttonShoulderButtonsButtonLGroup::title, " + "QGroupBox#buttonMiscButtonsMinusGroup::title, " + "QGroupBox#buttonMiscButtonsPlusGroup::title, " + "QGroupBox#buttonShoulderButtonsZRGroup::title, " + "QGroupBox#buttonShoulderButtonsRGroup::title, " + "QGroupBox#buttonMiscButtonsHomeGroup::title, " + "QGroupBox#buttonMiscButtonsScreenshotGroup::title { " + "subcontrol-origin: padding; subcontrol-position: top center; " + "top: 3px; padding: 0px; }" + "QPushButton { border: 1px solid palette(mid); border-radius: 6px; " + "padding: 2px 6px; background: palette(base); }" + "QPushButton:hover { border-color: palette(highlight); }" + "QPushButton:pressed, QPushButton:checked { " + "background: palette(highlight); color: palette(highlighted-text); }")) + + ConfigurePlayerAssignment::ModernComboStyle() + : QString{}); + if (!locked) { + // The manual modes get the tab exactly as it was + if (home_centred) { + home_centred = false; + ui->miscButtons->setStretch(0, 0); + delete ui->miscButtons->takeAt(ui->miscButtons->count() - 1); + } + if (shoulders_raised) { + shoulders_raised = false; + for (QPushButton* button : + {ui->buttonL, ui->buttonR, ui->buttonMinus, ui->buttonPlus}) { + if (auto* box = qobject_cast( + button->parentWidget() ? button->parentWidget()->layout() : nullptr)) { + delete box->takeAt(box->count() - 1); + } + } + } + if (QLayout* connect = ui->groupConnectedController->layout()) { + connect->setAlignment(ui->comboControllerType, Qt::Alignment{}); + } + // Back to the inherited fonts + for (QGroupBox* box : bolded_boxes) { + if (box) { + box->setFont(QFont()); + for (QWidget* child : box->findChildren(Qt::FindDirectChildrenOnly)) { + child->setFont(QFont()); + } + } + } + bolded_boxes.clear(); + } + if (locked) { + ConfigurePlayerAssignment::ModernizeCombos(this); + // Bold section titles (a style sheet can't bold only a title) + QFont normal = font(); + normal.setBold(false); // explicit, or the box's bold is inherited after all + QFont bold = normal; + bold.setBold(true); + for (QGroupBox* box : findChildren()) { + // No box around a section + bool inside_box = false; + for (QWidget* up = box->parentWidget(); up && up != this; up = up->parentWidget()) { + inside_box = inside_box || qobject_cast(up) != nullptr; + } + const bool section = + !inside_box && !box->objectName().startsWith(QStringLiteral("buttonMisc")) && + !box->objectName().startsWith(QStringLiteral("buttonShoulderButtons")); + if (!section) { + continue; + } + box->setFont(bold); + for (QWidget* child : box->findChildren(Qt::FindDirectChildrenOnly)) { + child->setFont(normal); + } + bolded_boxes.push_back(box); + } + // Home centred under the controller + if (!home_centred) { + home_centred = true; + ui->miscButtons->setStretch(0, 1); + ui->miscButtons->addStretch(1); + } + // Connect Controller's drop-down level with the profile card's + if (QLayout* connect = ui->groupConnectedController->layout()) { + connect->setAlignment(ui->comboControllerType, Qt::AlignTop); + } + // L, R, Minus and Plus line up with ZL and ZR: a fixed space the size of ZL's + // trigger slider under each + if (!shoulders_raised) { + shoulders_raised = true; + const int slider = ui->sliderZLThreshold->sizeHint().height(); + for (QPushButton* button : + {ui->buttonL, ui->buttonR, ui->buttonMinus, ui->buttonPlus}) { + if (auto* box = qobject_cast( + button->parentWidget() ? button->parentWidget()->layout() : nullptr)) { + box->addSpacerItem(new QSpacerItem( + 0, slider + box->spacing(), QSizePolicy::Minimum, QSizePolicy::Fixed)); + } + } + } + } + } + if (!profile_card && locked) { + BuildProfileCard(); + } + ui->profilesGroup->setVisible(!locked); + if (profile_card) { + profile_card->setVisible(locked); + if (locked) { + card_timer->start(); + UpdateProfileCard(); + } else { + card_timer->stop(); + } + } + ApplyJoyConLock(); + ui->devicesGroup->setVisible(!locked); + // The automatic modes bind motion themselves (the controller's own, or its real device's) + ui->buttonMotionLeftGroup->setVisible(!locked); + ui->buttonMotionRightGroup->setVisible(!locked); + if (ui->groupConnectedController->isCheckable() == !locked) { + return; + } + const bool blocked = ui->groupConnectedController->blockSignals(true); + ui->groupConnectedController->setCheckable(!locked); + if (!locked) { + ui->groupConnectedController->setChecked(emulated_controller->IsConnected(true)); + } + ui->groupConnectedController->blockSignals(blocked); +} + +namespace { + +/// A binding without its controller (guid, port), its fields in a fixed order +std::string MappingKey(const std::string& raw) { + if (raw.empty()) { + return {}; + } + Common::ParamPackage param{raw}; + if (param.Get("engine", "") == "sdl") { + param.Erase("guid"); + param.Erase("guid2"); + param.Erase("port"); + } + std::vector fields; + std::string field; + for (const char c : param.Serialize() + ",") { + if (c != ',') { + field += c; + continue; + } + fields.push_back(field); + field.clear(); + } + std::sort(fields.begin(), fields.end()); + std::string out; + for (const auto& f : fields) { + out += f + ';'; + } + return out; +} + +/// Asks the engine to put a profile on a player's controller ("" drops it): the engine owns the +/// mappings in the automatic modes +void RequestProfile(std::size_t player, const QString& name, bool restore = false) { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.profile_requests.push_back({static_cast(player), name.toStdString(), restore}); +} + +/// The box layout that holds a widget, searched from a top layout down +QBoxLayout* LayoutHolding(QLayout* layout, QWidget* widget) { + if (!layout) { + return nullptr; + } + if (layout->indexOf(widget) >= 0) { + return qobject_cast(layout); + } + for (int i = 0; i < layout->count(); ++i) { + if (auto* found = LayoutHolding(layout->itemAt(i)->layout(), widget)) { + return found; + } + } + return nullptr; +} + +} // Anonymous namespace + +void ConfigureInputPlayer::BuildProfileCard() { + profile_card = new QGroupBox(tr("Mapping Profile"), this); + auto* column = new QVBoxLayout(profile_card); + // Connect Controller's margins, so the two drop-downs are level + if (const QLayout* connect = ui->groupConnectedController->layout()) { + column->setContentsMargins(connect->contentsMargins()); + } + auto* row = new QHBoxLayout; + card_profiles = new QComboBox(profile_card); + card_profiles->setPlaceholderText(tr("No profile")); + ConfigurePlayerAssignment::ModernizeCombos(profile_card); + // Fills the row up to its buttons, which end at the card's edge + card_profiles->setMinimumWidth(160); + card_profiles->setToolTip(tr("Picking a profile applies it to this player at once.")); + card_save = new QPushButton(tr("Save"), profile_card); + card_save->setDefault(false); + card_save->setAutoDefault(false); + // The profile's actions, all in view beside Save (Defaults, below the drawing, resets) + const auto action = [this](const QString& text, const QString& tip) { + auto* button = new QPushButton(text, profile_card); + button->setAutoDefault(false); + button->setToolTip(tip); + return button; + }; + card_save_new = action(tr("Save as New"), tr("Keep this mapping as a new profile")); + card_rename = action(tr("Rename"), tr("Rename the selected profile")); + card_duplicate = action(tr("Duplicate"), tr("Copy the selected profile under a new name")); + card_delete = action(tr("Delete"), tr("Delete the selected profile; this player keeps its " + "current mapping")); + // (Inside the drop-down) takes the profile off: the mappings from before it come back + card_remove = ConfigurePlayerAssignment::AddClearButton(card_profiles); + card_remove->setToolTip(tr("Remove the profile from this player")); + row->addWidget(card_profiles, 1); + row->addWidget(card_save); + row->addWidget(card_save_new); + row->addWidget(card_rename); + row->addWidget(card_duplicate); + row->addWidget(card_delete); + column->addLayout(row); + card_status = new QLabel(profile_card); + card_status->setWordWrap(true); + column->addWidget(card_status); + joycon_note = new QLabel(profile_card); + joycon_note->setWordWrap(true); + joycon_note->setVisible(false); + column->addWidget(joycon_note); + // No taller than its rows: the drawing below gets the height + profile_card->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); + + connect(card_save_new, &QPushButton::clicked, this, [this] { + const int count = ui->comboProfiles->count(); + CreateProfile(); + if (ui->comboProfiles->count() > count) { + RequestProfile(player_index, ui->comboProfiles->currentText()); + } + RememberProfileMapping(); + UpdateProfileCard(); + }); + connect(card_rename, &QPushButton::clicked, this, &ConfigureInputPlayer::RenameProfile); + connect(card_duplicate, &QPushButton::clicked, this, &ConfigureInputPlayer::DuplicateProfile); + connect(card_delete, &QPushButton::clicked, this, &ConfigureInputPlayer::DeleteProfileAsked); + + // The engine owns the mappings here: picking or saving a profile goes through it + connect(card_profiles, qOverload(&QComboBox::activated), this, [this](int index) { + ui->comboProfiles->setCurrentIndex(index); + RequestProfile(player_index, ui->comboProfiles->currentText()); + RememberProfileMapping(); + UpdateProfileCard(); + }); + connect(card_remove, &QToolButton::clicked, this, [this] { + RequestProfile(player_index, {}, true); + ui->comboProfiles->setCurrentIndex(-1); + RememberProfileMapping(); + UpdateProfileCard(); + }); + connect(card_save, &QPushButton::clicked, this, [this] { + if (ui->comboProfiles->currentIndex() < 0) { + CreateProfile(); + } else { + SaveProfile(); + } + // (A new profile left unnamed leaves nothing selected) + if (ui->comboProfiles->currentIndex() >= 0) { + RequestProfile(player_index, ui->comboProfiles->currentText()); + } + RememberProfileMapping(); + UpdateProfileCard(); + }); + + // Mapping edits come from many places (polling, menus, defaults): look again now and then + card_timer = new QTimer(this); + card_timer->setInterval(500); + connect(card_timer, &QTimer::timeout, this, &ConfigureInputPlayer::UpdateProfileCard); + + if (auto* layout = LayoutHolding(this->layout(), ui->profilesGroup)) { + layout->insertWidget(layout->indexOf(ui->profilesGroup) + 1, profile_card, 1); + } + // A bold title like the other sections, with its contents in the normal font + QFont normal = font(); + normal.setBold(false); // explicit, or the card's bold is inherited after all + QFont bold = normal; + bold.setBold(true); + profile_card->setFont(bold); + for (QWidget* child : profile_card->findChildren(Qt::FindDirectChildrenOnly)) { + child->setFont(normal); + } + profile_card->setVisible(false); +} + +void ConfigureInputPlayer::UpdateProfileCard() { + if (!profile_card || !profile_card->isVisible()) { + return; + } + // Mirror the profile list the rest of the tab keeps in comboProfiles + bool same = card_profiles->count() == ui->comboProfiles->count(); + for (int i = 0; same && i < card_profiles->count(); ++i) { + same = card_profiles->itemText(i) == ui->comboProfiles->itemText(i); + } + if (!same || card_profiles->currentIndex() != ui->comboProfiles->currentIndex()) { + const bool blocked = card_profiles->blockSignals(true); + card_profiles->clear(); + for (int i = 0; i < ui->comboProfiles->count(); ++i) { + card_profiles->addItem(ui->comboProfiles->itemText(i)); + } + card_profiles->setCurrentIndex(ui->comboProfiles->currentIndex()); + card_profiles->blockSignals(blocked); + } + // The engine changes the player's profile too (a swap, the Controllers tab): follow it + const QString carried = + QString::fromStdString(Settings::values.players.GetValue()[player_index].profile_name); + if (carried != card_engine_profile) { + card_engine_profile = carried; + const bool blocked = ui->comboProfiles->blockSignals(true); + ui->comboProfiles->setCurrentIndex(ui->comboProfiles->findText(carried)); + ui->comboProfiles->blockSignals(blocked); + card_profiles->setCurrentIndex(ui->comboProfiles->currentIndex()); + } + const QString name = ui->comboProfiles->currentText(); + if (ui->comboProfiles->currentIndex() >= 0 && name != card_profile_name) { + RememberProfileMapping(); + } + + const QString follows = tr("Applies to this player's controller, whichever one it is."); + const bool selected = ui->comboProfiles->currentIndex() >= 0; + card_remove->setVisible(selected); + card_rename->setEnabled(selected); + card_duplicate->setEnabled(selected); + card_delete->setEnabled(selected); + if (ui->comboProfiles->currentIndex() < 0) { + card_save->setToolTip(tr("Keep this mapping as a new profile (asks for its name)")); + // In Modern Console a remap lasts the session; a profile is what keeps controls + card_status->setText(tr("No profile: this player uses its usual controls. Changes made " + "here last for this session; Save as New keeps them.")); + card_status->setPalette(palette()); + return; + } + card_save->setToolTip(tr("Save this mapping to \u201c%1\u201d").arg(name)); + QPalette status = palette(); + if (CurrentMappingKey() != card_profile_key) { + card_status->setText(tr("\u25cf Unsaved changes \u00b7 %1").arg(follows)); + status.setColor(QPalette::WindowText, QColor(208, 138, 30)); + } else { + card_status->setText(tr("\u2713 Saved \u00b7 %1").arg(follows)); + status.setColor(QPalette::WindowText, status.color(QPalette::PlaceholderText)); + } + card_status->setPalette(status); +} + +std::string ConfigureInputPlayer::CurrentMappingKey() const { + // Motion is the engine's while players are assigned automatically, so it is left out + std::string key; + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + key += MappingKey(emulated_controller->GetButtonParam(i).Serialize()) + '|'; + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + key += MappingKey(emulated_controller->GetStickParam(i).Serialize()) + '|'; + } + return key; +} + +void ConfigureInputPlayer::RememberProfileMapping() { + card_profile_name = ui->comboProfiles->currentText(); + // The player keeps its profile's name, so the card shows it the next time too + Settings::values.players.GetValue()[player_index].profile_name = + ui->comboProfiles->currentIndex() < 0 ? std::string{} : card_profile_name.toStdString(); + card_profile_key.clear(); + if (ui->comboProfiles->currentIndex() < 0) { + return; + } + const auto read = profiles->PeekProfile(card_profile_name.toStdString(), player_index); + if (!read) { + return; + } + for (const auto& button : read->buttons) { + card_profile_key += MappingKey(button) + '|'; + } + for (const auto& analog : read->analogs) { + card_profile_key += MappingKey(analog) + '|'; + } +} + +void ConfigureInputPlayer::SelectProfile(const QString& name) { + UpdateInputProfiles(); + ui->comboProfiles->setCurrentIndex(ui->comboProfiles->findText(name)); + emit RefreshInputProfiles(player_index); + RequestProfile(player_index, name); + RememberProfileMapping(); + UpdateProfileCard(); +} + +void ConfigureInputPlayer::RenameProfile() { + const QString old_name = ui->comboProfiles->currentText(); + if (ui->comboProfiles->currentIndex() < 0) { + return; + } + const auto new_name = + LimitableInputDialog::GetText(this, tr("Rename Profile"), tr("Enter a new name:"), 1, 30, + LimitableInputDialog::InputLimiter::Filesystem); + if (new_name.isEmpty() || new_name == old_name) { + return; + } + if (!InputProfiles::IsProfileNameValid(new_name.toStdString()) || + !profiles->RenameProfile(old_name.toStdString(), new_name.toStdString())) { + QMessageBox::critical( + this, tr("Rename Input Profile"), + tr("Could not rename \u201c%1\u201d to \u201c%2\u201d.").arg(old_name, new_name)); + return; + } + SelectProfile(new_name); +} + +void ConfigureInputPlayer::DuplicateProfile() { + const QString name = ui->comboProfiles->currentText(); + if (ui->comboProfiles->currentIndex() < 0) { + return; + } + QString copy = tr("%1 copy").arg(name); + for (int n = 2; ui->comboProfiles->findText(copy) >= 0; ++n) { + copy = tr("%1 copy %2").arg(name).arg(n); + } + if (!profiles->DuplicateProfile(name.toStdString(), copy.toStdString())) { + QMessageBox::critical(this, tr("Duplicate Input Profile"), + tr("Could not duplicate \u201c%1\u201d.").arg(name)); + return; + } + SelectProfile(copy); +} + +void ConfigureInputPlayer::DeleteProfileAsked() { + const QString name = ui->comboProfiles->currentText(); + if (ui->comboProfiles->currentIndex() < 0) { + return; + } + if (QMessageBox::question(this, tr("Delete Input Profile"), + tr("Delete the profile \u201c%1\u201d? This player keeps its " + "current mapping.") + .arg(name)) != QMessageBox::Yes) { + return; + } + DeleteProfile(); + RequestProfile(player_index, {}); + RememberProfileMapping(); + UpdateProfileCard(); +} + void ConfigureInputPlayer::UpdateInputDeviceCombobox() { // Skip input device persistence if "Input Devices" is set to "Any". if (ui->comboDevices->currentIndex() == 0) { @@ -978,10 +1569,76 @@ void ConfigureInputPlayer::UpdateInputDeviceCombobox() { } void ConfigureInputPlayer::RestoreDefaults() { + if (joycon_locked) { + return; + } + // In the automatic modes the device list is hidden (and may say "Any"): the defaults are + // those of the controller the bindings point at, or pointed at before Clear + if (connection_locked) { + if (const auto device = CurrentSdlDevice()) { + locked_device = device; + } + if (locked_device) { + const auto it = std::find_if( + input_devices.begin(), input_devices.end(), [this](const auto& param) { + return param.Get("engine", "") == "sdl" && + param.Get("guid", "") == locked_device->first && + param.Get("port", 0) == locked_device->second; + }); + if (it != input_devices.end()) { + ui->comboDevices->setCurrentIndex( + static_cast(std::distance(input_devices.begin(), it))); + } + } + } + // (Motion is the engine's in these modes, and hidden: a DSU binding stays) + const auto motions = KeptMotions(); UpdateMappingWithDefaults(); + RestoreMotions(motions); +} + +std::optional> ConfigureInputPlayer::CurrentSdlDevice() const { + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + const auto param = emulated_controller->GetButtonParam(button_id); + if (param.Get("engine", "") == "sdl") { + return std::pair{param.Get("guid", ""), param.Get("port", 0)}; + } + } + return std::nullopt; +} + +std::vector ConfigureInputPlayer::KeptMotions() const { + std::vector motions; + if (connection_locked) { + for (int motion_id = 0; motion_id < Settings::NativeMotion::NumMotions; ++motion_id) { + motions.push_back( + emulated_controller->GetMotionParam(static_cast(motion_id))); + } + } + return motions; +} + +void ConfigureInputPlayer::RestoreMotions(const std::vector& motions) { + for (std::size_t motion_id = 0; motion_id < motions.size(); ++motion_id) { + emulated_controller->SetMotionParam(motion_id, motions[motion_id]); + } } void ConfigureInputPlayer::ClearAll() { + if (joycon_locked) { + return; + } + // (The controller is remembered, for Defaults afterwards) + if (const auto device = CurrentSdlDevice()) { + locked_device = device; + } + // (Motion is the engine's in the automatic modes, and hidden: a DSU binding stays) + const auto motions = KeptMotions(); + ClearMappings(); + RestoreMotions(motions); +} + +void ConfigureInputPlayer::ClearMappings() { for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { const auto* const button = button_map[button_id]; if (button == nullptr) { @@ -1634,6 +2291,11 @@ void ConfigureInputPlayer::LoadProfile() { ApplyConfiguration(); + // In the automatic modes the profile goes onto the player's current controller (the one + // saved with it would read as a swap) + auto& player = Settings::values.players.GetValue()[player_index]; + const auto device = connection_locked ? SdlDevice(player) : std::nullopt; + if (!profiles->LoadProfile(profile_name.toStdString(), player_index)) { QMessageBox::critical(this, tr("Load Input Profile"), tr("Failed to load the input profile \"%1\"").arg(profile_name)); @@ -1641,6 +2303,9 @@ void ConfigureInputPlayer::LoadProfile() { emit RefreshInputProfiles(player_index); return; } + if (device) { + KeepSdlDevice(player, *device); + } LoadConfiguration(); } diff --git a/src/yuzu/configuration/configure_input_player.h b/src/yuzu/configuration/configure_input_player.h index fda09e9257..2c0db9bbca 100644 --- a/src/yuzu/configuration/configure_input_player.h +++ b/src/yuzu/configuration/configure_input_player.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: 2016 Citra Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -10,6 +13,7 @@ #include #include +#include #include #include "common/param_package.h" @@ -17,6 +21,8 @@ #include "ui_configure_input.h" class QCheckBox; +class QComboBox; +class QGroupBox; class QKeyEvent; class QLabel; class QPushButton; @@ -24,6 +30,7 @@ class QSlider; class QSpinBox; class QString; class QTimer; +class QToolButton; class QWidget; class InputProfiles; @@ -65,6 +72,18 @@ public: /// Update the input devices combobox. void UpdateInputDeviceCombobox(); + /// Redraws the tab from its emulated controller after the engine reloaded it + void RefreshFromController(); + + /// Use Any Controller as Joy-Cons: while this player plays as a single Joy-Con its mapping + /// is set automatically, and read-only here (with a note why) + [[nodiscard]] bool JoyConLocked() const { + return joycon_locked; + } + + /// In the automatic modes the tab hides the connection, device and motion choices + void SetConnectionLocked(bool locked); + /// Updates the list of controller profiles. void UpdateInputProfiles(); @@ -74,6 +93,15 @@ public: /// Clear all input configuration. void ClearAll(); + /// The SDL controller (guid, port) the player's bindings point at now, if any + [[nodiscard]] std::optional> CurrentSdlDevice() const; + /// Clear's work: every binding emptied + void ClearMappings(); + /// The player's motion bindings, kept across Clear and Defaults in the automatic modes + /// (motion is hidden there); none otherwise + [[nodiscard]] std::vector KeptMotions() const; + void RestoreMotions(const std::vector& motions); + signals: /// Emitted when this controller is (dis)connected by the user. void Connected(bool connected); @@ -172,6 +200,44 @@ private: std::size_t player_index; bool debug; bool is_powered_on; + /// Players are assigned automatically: the connection and controller are not this tab's + bool connection_locked{}; + /// The SDL controller (guid, port) the bindings last pointed at, for Defaults after Clear + std::optional> locked_device; + bool style_applied{}; + bool home_centred{}; + bool shoulders_raised{}; + /// The section boxes given a bold title in the automatic modes + std::vector> bolded_boxes; + + /// The profile card shown instead of the Profile group in the automatic modes (comboProfiles + /// stays the selection) + void BuildProfileCard(); + void UpdateProfileCard(); + /// The selected profile's mapping, kept to tell when the tab's mapping differs from it + void RememberProfileMapping(); + std::string CurrentMappingKey() const; + void SelectProfile(const QString& name); + void RenameProfile(); + void DuplicateProfile(); + void DeleteProfileAsked(); + QGroupBox* profile_card{}; + QComboBox* card_profiles{}; + QPushButton* card_save{}; + QToolButton* card_remove{}; + QPushButton* card_save_new{}; + QPushButton* card_rename{}; + QPushButton* card_duplicate{}; + QPushButton* card_delete{}; + QLabel* card_status{}; + QLabel* joycon_note{}; + bool joycon_locked = false; + void ApplyJoyConLock(); + QTimer* card_timer{}; + std::string card_profile_key; + QString card_profile_name; + /// The profile the engine last reported for this player + QString card_engine_profile; InputCommon::InputSubsystem* input_subsystem; diff --git a/src/yuzu/configuration/configure_input_player_widget.cpp b/src/yuzu/configuration/configure_input_player_widget.cpp index faf4e021ec..5d6584612a 100644 --- a/src/yuzu/configuration/configure_input_player_widget.cpp +++ b/src/yuzu/configuration/configure_input_player_widget.cpp @@ -230,6 +230,14 @@ void PlayerControlPreview::paintEvent(QPaintEvent* event) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); const QPointF center = rect().center(); + // The drawings need about 450x330: a smaller frame (the Steam Deck's 800-pixel screen) gets + // them scaled down to fit instead of cut off at the bottom + const qreal fit = std::min({1.0, width() / 450.0, height() / 330.0}); + if (fit < 1.0) { + p.translate(center); + p.scale(fit, fit); + p.translate(-center); + } switch (controller_type) { case Core::HID::NpadStyleIndex::Handheld: diff --git a/src/yuzu/configuration/configure_modern_per_game.cpp b/src/yuzu/configuration/configure_modern_per_game.cpp new file mode 100644 index 0000000000..97a61f2345 --- /dev/null +++ b/src/yuzu/configuration/configure_modern_per_game.cpp @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include + +#include "common/settings.h" +#include "core/core.h" +#include "yuzu/configuration/configure_modern_per_game.h" +#include "yuzu/configuration/shared_widget.h" + +ConfigureModernPerGame::ConfigureModernPerGame( + Core::System& system_, std::shared_ptr> group, + const ConfigurationShared::Builder& builder, QWidget* parent) + : Tab(group, parent), system{system_} { + auto* layout = new QVBoxLayout(this); + auto* box = new QGroupBox(tr("Modern Console"), this); + auto* box_layout = new QVBoxLayout(box); + auto* note = new QLabel(tr("For this game only. Each option follows the one in Emulation > " + "Configure > Controls > Controllers until it is set here."), + box); + note->setWordWrap(true); + box_layout->addWidget(note); + for (Settings::BasicSetting* setting : std::initializer_list{ + &Settings::values.console_joycon_auto, &Settings::values.console_button_labels, + &Settings::values.console_pause_disconnect, &Settings::values.console_player_lights, + &Settings::values.console_debug_overlay, &Settings::values.console_hints}) { + ConfigurationShared::Widget* widget = builder.BuildWidget(setting, apply_funcs); + if (widget == nullptr) { + continue; + } + if (!widget->Valid()) { + widget->deleteLater(); + continue; + } + box_layout->addWidget(widget); + } + layout->addWidget(box); + layout->addStretch(1); +} + +ConfigureModernPerGame::~ConfigureModernPerGame() = default; + +void ConfigureModernPerGame::SetConfiguration() {} + +void ConfigureModernPerGame::ApplyConfiguration() { + // (While a game runs, only the options it can change in game are applied) + const bool powered_on = system.IsPoweredOn(); + for (const auto& apply : apply_funcs) { + apply(powered_on); + } +} diff --git a/src/yuzu/configuration/configure_modern_per_game.h b/src/yuzu/configuration/configure_modern_per_game.h new file mode 100644 index 0000000000..644f247550 --- /dev/null +++ b/src/yuzu/configuration/configure_modern_per_game.h @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "yuzu/configuration/configuration_shared.h" + +namespace ConfigurationShared { +class Builder; +} + +namespace Core { +class System; +} + +/// The Modern Console options a game can have of its own (in its properties): each follows the +/// global choice (Emulation > Configure > Controls) unless set for the game +class ConfigureModernPerGame : public ConfigurationShared::Tab { +public: + explicit ConfigureModernPerGame(Core::System& system_, + std::shared_ptr> group, + const ConfigurationShared::Builder& builder, + QWidget* parent = nullptr); + ~ConfigureModernPerGame() override; + + void ApplyConfiguration() override; + void SetConfiguration() override; + +private: + Core::System& system; + std::vector> apply_funcs; +}; diff --git a/src/yuzu/configuration/configure_per_game.cpp b/src/yuzu/configuration/configure_per_game.cpp index 6f530eb0ab..692db5ab48 100644 --- a/src/yuzu/configuration/configure_per_game.cpp +++ b/src/yuzu/configuration/configure_per_game.cpp @@ -41,6 +41,7 @@ #include "yuzu/configuration/configure_graphics_advanced.h" #include "yuzu/configuration/configure_graphics_extensions.h" #include "yuzu/configuration/configure_input_per_game.h" +#include "yuzu/configuration/configure_modern_per_game.h" #include "yuzu/configuration/configure_network.h" #include "yuzu/configuration/configure_per_game.h" #include "yuzu/configuration/configure_per_game_addons.h" @@ -72,6 +73,7 @@ ConfigurePerGame::ConfigurePerGame(QWidget* parent, u64 title_id_, const std::st system_tab = std::make_unique(system_, tab_group, *builder, this); network_tab = std::make_unique(system_, this); applets_tab = std::make_unique(system_, tab_group, *builder, this); + modern_tab = std::make_unique(system_, tab_group, *builder, this); ui->setupUi(this); @@ -85,6 +87,7 @@ ConfigurePerGame::ConfigurePerGame(QWidget* parent, u64 title_id_, const std::st ui->tabWidget->addTab(input_tab.get(), tr("Input Profiles")); ui->tabWidget->addTab(network_tab.get(), tr("Network")); ui->tabWidget->addTab(applets_tab.get(), tr("Applets")); + ui->tabWidget->addTab(modern_tab.get(), tr("Modern Console")); setFocusPolicy(Qt::ClickFocus); setWindowTitle(tr("Properties")); diff --git a/src/yuzu/configuration/configure_per_game.h b/src/yuzu/configuration/configure_per_game.h index bc6561235e..b7ff975022 100644 --- a/src/yuzu/configuration/configure_per_game.h +++ b/src/yuzu/configuration/configure_per_game.h @@ -39,6 +39,7 @@ class ConfigureInputPerGame; class ConfigureSystem; class ConfigureNetwork; class ConfigureApplets; +class ConfigureModernPerGame; class QGraphicsScene; class QStandardItem; @@ -95,4 +96,5 @@ private: std::unique_ptr system_tab; std::unique_ptr network_tab; std::unique_ptr applets_tab; + std::unique_ptr modern_tab; }; diff --git a/src/yuzu/configuration/configure_player_assignment.cpp b/src/yuzu/configuration/configure_player_assignment.cpp new file mode 100644 index 0000000000..1e8711b5df --- /dev/null +++ b/src/yuzu/configuration/configure_player_assignment.cpp @@ -0,0 +1,1634 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/controller_console_state.h" +#include "common/fs/path_util.h" +#include "common/settings.h" +#include "qt_common/config/uisettings.h" +#include "yuzu/configuration/configure_player_assignment.h" +#include "yuzu/configuration/input_profiles.h" + +namespace { + +/// A dark look: one of Eden's dark themes, or the system's dark mode under the default theme +bool DarkUi() { + return UISettings::IsDarkTheme() || + QApplication::palette().color(QPalette::Window).lightness() < 128; +} + +bool IsAutomatic(Settings::AppletMode mode) { + return mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode; +} + +constexpr int MaxPlayers = 8; + +/// The players' colours, as the overlay draws their badges +constexpr std::array PlayerColors{ + QColor(79, 142, 247), QColor(239, 91, 91), QColor(47, 191, 113), QColor(245, 184, 46), + QColor(155, 108, 242), QColor(242, 140, 56), QColor(46, 196, 201), QColor(227, 107, 174), +}; + +/// An on/off switch: a rounded track with a knob, like a phone's settings +class ToggleSwitch : public QAbstractButton { +public: + explicit ToggleSwitch(QWidget* parent) : QAbstractButton(parent) { + setCheckable(true); + setCursor(Qt::PointingHandCursor); + } + + QSize sizeHint() const override { + return {42, 24}; + } + +protected: + void paintEvent(QPaintEvent*) override { + QPainter painter{this}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + const QRectF track = QRectF(rect()).adjusted(1, 1, -1, -1); + QColor fill = isChecked() ? ConfigurePlayerAssignment::Accent(this) + : (DarkUi() ? QColor(255, 255, 255, 70) + : palette().mid().color()); + if (!isEnabled()) { + fill.setAlpha(110); + } + painter.setBrush(fill); + painter.drawRoundedRect(track, track.height() / 2, track.height() / 2); + const qreal knob = track.height() - 6; + const qreal x = isChecked() ? track.right() - 3 - knob : track.left() + 3; + painter.setBrush(Qt::white); + painter.drawEllipse(QRectF(x, track.top() + 3, knob, knob)); + } +}; +constexpr char PlayerMime[] = "application/x-eden-player"; + +/// Keeps a drop-down's ✕ inside it, left of its arrow +class ClearButtonPlacer : public QObject { +public: + ClearButtonPlacer(QComboBox* combo_, QToolButton* button_) + : QObject(combo_), combo{combo_}, button{button_} {} + +protected: + bool eventFilter(QObject* watched, QEvent* event) override { + if (watched == combo && + (event->type() == QEvent::Resize || event->type() == QEvent::Show)) { + button->move(combo->width() - 22 - button->width(), + (combo->height() - button->height()) / 2); + } + return QObject::eventFilter(watched, event); + } + +private: + QComboBox* combo; + QToolButton* button; +}; + +/// The power symbol on Turn Off: white, grey while it waits +QIcon PowerIcon() { + const auto draw = [](const QColor& color) { + QPixmap pixmap(QSize(14, 14) * 2); + pixmap.setDevicePixelRatio(2); + pixmap.fill(Qt::transparent); + QPainter painter{&pixmap}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(color, 2, Qt::SolidLine, Qt::RoundCap)); + painter.drawArc(QRectF(2, 2.5, 10, 10), 125 * 16, 290 * 16); + painter.drawLine(QPointF(7, 1), QPointF(7, 7)); + painter.end(); + return pixmap; + }; + QIcon icon; + icon.addPixmap(draw(Qt::white), QIcon::Normal); + icon.addPixmap(draw(QColor(138, 143, 150)), QIcon::Disabled); + return icon; +} + +/// The edit field of a player's name: Enter and Esc don't reach the dialog +class PlayerNameEdit : public QLineEdit { +public: + using QLineEdit::QLineEdit; + + std::function on_apply; + std::function on_cancel; + +protected: + void keyPressEvent(QKeyEvent* event) override { + if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { + event->accept(); + if (on_apply) { + on_apply(); + } + return; + } + if (event->key() == Qt::Key_Escape) { + event->accept(); + if (on_cancel) { + on_cancel(); + } + return; + } + QLineEdit::keyPressEvent(event); + } +}; + +/// A setting's row: its title in bold, a line about it, and its control on the right +void AddOptionRow(QBoxLayout* to, QWidget* parent, const QString& title, const QString& note, + QWidget* control) { + auto* line = new QHBoxLayout(); + auto* text = new QVBoxLayout(); + text->setSpacing(1); + auto* title_label = new QLabel(title, parent); + QFont font = title_label->font(); + font.setBold(true); + title_label->setFont(font); + auto* note_label = new QLabel(note, parent); + note_label->setWordWrap(true); + note_label->setObjectName(QStringLiteral("note")); + text->addWidget(title_label); + text->addWidget(note_label); + line->addLayout(text, 1); + line->addWidget(control, 0, Qt::AlignVCenter); + to->addLayout(line); +} + +/// A controller's battery, as the overlay draws it +QPixmap BatteryIcon(int percent, bool charging) { + QPixmap pixmap(QSize(20, 12) * 2); + pixmap.setDevicePixelRatio(2); + pixmap.fill(Qt::transparent); + QPainter painter{&pixmap}; + painter.setRenderHint(QPainter::Antialiasing); + const QRectF cell(1, 2, 15, 8); + painter.setPen(QPen(QColor(150, 155, 162), 1.2)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(cell, 2, 2); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(150, 155, 162)); + painter.drawRect(QRectF(cell.right() + 0.6, cell.center().y() - 1.5, 1.8, 3)); + painter.setBrush(percent <= 15 && !charging ? QColor(220, 60, 60) : QColor(70, 180, 100)); + painter.drawRect(QRectF(cell.left() + 1.5, cell.top() + 1.5, + (cell.width() - 3) * (std::clamp)(percent, 0, 100) / 100.0, + cell.height() - 3)); + if (charging) { + painter.setPen(QPen(Qt::white, 1.2)); + const QPointF c = cell.center(); + const QPointF bolt[3] = {QPointF(c.x() + 1, c.y() - 3), QPointF(c.x() - 1.5, c.y() + 0.5), + QPointF(c.x() + 1.5, c.y() - 0.5)}; + painter.drawPolyline(bolt, 3); + painter.drawLine(bolt[2], QPointF(c.x() - 1, c.y() + 3)); + } + painter.end(); + return pixmap; +} + +/// The name's icons (edit, apply, remove), drawn so they look the same everywhere +QIcon NameIcon(int kind, const QColor& color) { + QPixmap pixmap(QSize(14, 14) * 2); + pixmap.setDevicePixelRatio(2); + pixmap.fill(Qt::transparent); + QPainter painter{&pixmap}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(color, 1.6, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + if (kind == 0) { // pencil + painter.drawLine(QPointF(3, 11), QPointF(10.5, 3.5)); + painter.drawLine(QPointF(9, 2), QPointF(12, 5)); + painter.drawLine(QPointF(2.5, 11.5), QPointF(2, 12)); + } else if (kind == 1) { // check + const QPointF check[3] = {QPointF(2.5, 7.5), QPointF(5.5, 10.5), QPointF(11.5, 3.5)}; + painter.drawPolyline(check, 3); + } else { // bin + painter.drawLine(QPointF(2, 3.5), QPointF(12, 3.5)); + painter.drawLine(QPointF(5.5, 3.5), QPointF(5.5, 2)); + painter.drawLine(QPointF(5.5, 2), QPointF(8.5, 2)); + painter.drawLine(QPointF(8.5, 2), QPointF(8.5, 3.5)); + painter.drawRoundedRect(QRectF(3.5, 3.5, 7, 9), 1, 1); + painter.drawLine(QPointF(6, 6), QPointF(6, 10)); + painter.drawLine(QPointF(8, 6), QPointF(8, 10)); + } + painter.end(); + return QIcon{pixmap}; +} + +/// One player in the list: a click rumbles its controller, a drag onto another player swaps them +class PlayerRow : public QFrame { +public: + PlayerRow(int player_, QWidget* parent) : QFrame(parent), player{player_} { + setObjectName(QStringLiteral("playerRow")); + setAcceptDrops(true); + setCursor(Qt::OpenHandCursor); + long_press.setSingleShot(true); + long_press.setInterval(400); + QObject::connect(&long_press, &QTimer::timeout, this, [this] { StartDrag(); }); + } + + std::function on_click; + std::function on_drop; + +protected: + void mousePressEvent(QMouseEvent* event) override { + if (event->button() == Qt::LeftButton) { + press_pos = event->position().toPoint(); + pressed = true; + long_press.start(); + } + QFrame::mousePressEvent(event); + } + + void mouseMoveEvent(QMouseEvent* event) override { + if (pressed && (event->position().toPoint() - press_pos).manhattanLength() >= + QApplication::startDragDistance()) { + StartDrag(); + } + QFrame::mouseMoveEvent(event); + } + + void mouseReleaseEvent(QMouseEvent* event) override { + if (pressed && long_press.isActive() && on_click) { + on_click(player); + } + pressed = false; + long_press.stop(); + QFrame::mouseReleaseEvent(event); + } + + void dragEnterEvent(QDragEnterEvent* event) override { + if (event->mimeData()->hasFormat(QString::fromLatin1(PlayerMime))) { + event->acceptProposedAction(); + drop_hover = true; + update(); + } + } + + void dragLeaveEvent(QDragLeaveEvent* event) override { + drop_hover = false; + update(); + QFrame::dragLeaveEvent(event); + } + + void paintEvent(QPaintEvent* event) override { + QFrame::paintEvent(event); + if (drop_hover) { + // Where the dragged player lands + QPainter painter{this}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(ConfigurePlayerAssignment::Accent(this), 2, Qt::DashLine)); + painter.drawRoundedRect(QRectF(rect()).adjusted(1, 1, -1, -1), 4, 4); + } + } + + void dropEvent(QDropEvent* event) override { + drop_hover = false; + update(); + const int from = event->mimeData()->data(QString::fromLatin1(PlayerMime)).toInt(); + if (from != player && on_drop) { + on_drop(from, player); + } + event->acceptProposedAction(); + } + +private: + void StartDrag() { + if (!pressed) { + return; + } + pressed = false; + long_press.stop(); + auto* mime = new QMimeData; + mime->setData(QString::fromLatin1(PlayerMime), QByteArray::number(player)); + auto* drag = new QDrag(this); + drag->setMimeData(mime); + drag->setPixmap(grab()); + drag->setHotSpot(press_pos); + setCursor(Qt::ClosedHandCursor); + drag->exec(Qt::MoveAction); + setCursor(Qt::OpenHandCursor); + } + + int player; + bool pressed = false; + bool drop_hover = false; + QPoint press_pos; + QTimer long_press; +}; + +} // Anonymous namespace + +QToolButton* ConfigurePlayerAssignment::AddClearButton(QComboBox* combo) { + auto* button = new QToolButton(combo); + button->setText(QStringLiteral("\u2715")); + button->setCursor(Qt::ArrowCursor); + button->setFixedSize(18, 18); + button->setStyleSheet(Themed( + QStringLiteral("QToolButton { border: none; border-radius: 9px; background: transparent; " + "color: muted-text; padding: 0px; }" + "QToolButton:hover { background: palette(highlight); " + "color: palette(highlighted-text); }"))); + // (Room for it between the text and the arrow) + combo->setStyleSheet(QStringLiteral("QComboBox { padding-right: 40px; }")); + combo->installEventFilter(new ClearButtonPlacer(combo, button)); + return button; +} + +QString ConfigurePlayerAssignment::Themed(QString style) { + if (!DarkUi()) { + return style.replace(QStringLiteral("muted-text"), QStringLiteral("#7d838b")) + .replace(QStringLiteral("popup-base"), QStringLiteral("palette(base)")); + } + // An open drop-down list is a window of its own: a solid background (a tint shows white) + style.replace( + QStringLiteral("popup-base"), + !UISettings::IsDarkTheme() + ? QApplication::palette().color(QPalette::Window).name() + : QString::fromStdString(UISettings::values.theme).contains(QStringLiteral("midnight")) + ? QStringLiteral("#19232D") + : QStringLiteral("#31363B")); + // Cards show the theme's background, fields and buttons a light tint + static const std::array, 8> dark{{ + {"palette(alternate-base)", "transparent"}, + {"palette(base)", "rgba(255, 255, 255, 14)"}, + {"palette(button)", "rgba(255, 255, 255, 22)"}, + {"palette(mid)", "rgba(255, 255, 255, 70)"}, + {"palette(highlighted-text)", "#ffffff"}, + {"palette(highlight)", "#3daee9"}, + {"palette(text)", "#eff0f1"}, + {"muted-text", "#9da3aa"}, + }}; + for (const auto& [from, to] : dark) { + style.replace(QString::fromLatin1(from), QString::fromLatin1(to)); + } + return style; +} + +QColor ConfigurePlayerAssignment::Accent(const QWidget* widget) { + return DarkUi() ? QColor(0x3d, 0xae, 0xe9) + : widget->palette().highlight().color(); +} + +QPixmap ConfigurePlayerAssignment::Battery(int percent, bool charging) { + return BatteryIcon(percent, charging); +} + +QColor ConfigurePlayerAssignment::PlayerColor(int player) { + return PlayerColors[static_cast(player) % PlayerColors.size()]; +} + +QString ConfigurePlayerAssignment::ModernComboStyle() { + return Themed(QStringLiteral( + "QComboBox { border: 1px solid palette(mid); border-radius: 6px; padding: 3px 10px; " + "background: palette(base); }" + "QComboBox:hover, QComboBox:focus { border-color: palette(highlight); }" + "QComboBox:disabled { color: palette(mid); }" + // The open list: readable rows, the hovered one in the highlight colour + "QComboBox QAbstractItemView { background: popup-base; color: palette(text); " + "border: 1px solid palette(mid); outline: 0; padding: 2px; }" + "QComboBox QAbstractItemView::item { min-height: 24px; padding: 0 8px; }" + "QComboBox QAbstractItemView::item:hover, QComboBox QAbstractItemView::item:selected { " + "background: palette(highlight); color: palette(highlighted-text); }")); +} + +void ConfigurePlayerAssignment::ModernizeCombos(QWidget* root) { + // A combo box's own list painter ignores the style sheet: the standard one follows it + for (auto* combo : root->findChildren()) { + if (!combo->property("modern_list").toBool()) { + combo->setItemDelegate(new QStyledItemDelegate(combo)); + // The list's frame takes its solid background (its padding showed white) + if (QWidget* frame = combo->view()->window(); frame && frame != combo->window()) { + frame->setStyleSheet(Themed(QStringLiteral( + "QFrame { background: popup-base; border: 1px solid palette(mid); }"))); + } + combo->setProperty("modern_list", true); + } + } +} + +QString ConfigurePlayerAssignment::OutlinedButtonStyle() { + return Themed(QStringLiteral( + "QPushButton, QToolButton { border: 1px solid palette(highlight); border-radius: 6px; " + "padding: 3px 12px; color: palette(highlight); font-weight: bold; " + "background: palette(base); }" + "QPushButton:hover, QToolButton:hover { background: palette(highlight); " + "color: palette(highlighted-text); }" + "QPushButton:disabled, QToolButton:disabled { border-color: palette(mid); " + "color: palette(mid); }")); +} + +QWidget* ConfigurePlayerAssignment::MakeSegmented(QWidget* parent, const QStringList& labels, + const std::vector& ids, + QButtonGroup* group) { + auto* bar = new QWidget(parent); + auto* bar_layout = new QHBoxLayout(bar); + bar_layout->setContentsMargins(0, 0, 0, 0); + bar_layout->setSpacing(0); + for (int k = 0; k < labels.size(); ++k) { + auto* button = new QPushButton(labels[k], bar); + button->setCheckable(true); + button->setCursor(Qt::PointingHandCursor); + QString style = + QStringLiteral("QPushButton { padding: 5px 14px; border: 1px solid palette(mid); " + "background: palette(button); }" + "QPushButton:checked { background: palette(highlight); " + "color: palette(highlighted-text); border-color: palette(highlight); }" + "QPushButton:disabled { color: palette(mid); }"); + if (k == 0) { + style += QStringLiteral("QPushButton { border-top-left-radius: 6px; " + "border-bottom-left-radius: 6px; }"); + } else { + style += QStringLiteral("QPushButton { border-left-width: 0px; }"); + } + if (k == labels.size() - 1) { + style += QStringLiteral("QPushButton { border-top-right-radius: 6px; " + "border-bottom-right-radius: 6px; }"); + } + button->setStyleSheet(Themed(style)); + group->addButton(button, ids[static_cast(k)]); + bar_layout->addWidget(button); + } + return bar; +} + +ConfigurePlayerAssignment::ConfigurePlayerAssignment(QWidget* parent) : QWidget(parent) { + // The cards keep their height and the page scrolls when they don't fit + auto* outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + auto* scroll = new QScrollArea(this); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->viewport()->setAutoFillBackground(false); + auto* content = new QWidget(scroll); + content->setAutoFillBackground(false); + scroll->setWidget(content); + outer->addWidget(scroll); + auto* layout = new QVBoxLayout(content); + content_layout = layout; + layout->setSpacing(8); + // Each section is a rounded card (kept tight: the Deck's screen is 800 pixels tall) + setStyleSheet( + Themed(QStringLiteral("QGroupBox { border: 1px solid palette(mid); border-radius: 10px; " + "margin-top: 10px; padding: 8px 8px 4px 8px; " + "background: palette(alternate-base); }" + "QGroupBox::title { subcontrol-origin: margin; left: 14px; " + "padding: 0 4px; font-weight: bold; }" + // Labels show the card behind them (a dark theme paints every widget) + "QLabel { background: transparent; }" + "QLabel#note { color: muted-text; }")) + + ModernComboStyle()); + + // The handler, as a segmented control + auto* mode_box = new QGroupBox(tr("Controller Assignment Handler"), this); + auto* mode_layout = new QVBoxLayout(mode_box); + handler_group = new QButtonGroup(this); + auto* handler_row = new QHBoxLayout(); + handler_row->addWidget(MakeSegmented(mode_box, {tr("Modern Console"), tr("Manual")}, + {HandlerModern, HandlerManual}, handler_group)); + handler_row->addStretch(1); + mode_layout->addLayout(handler_row); + + // Under Manual: the controller applet a game sees when it asks for its players + manual_panel = new QFrame(mode_box); + manual_panel->setObjectName(QStringLiteral("manualPanel")); + manual_panel->setStyleSheet( + Themed(QStringLiteral("#manualPanel { border: 1px solid palette(mid); " + "border-radius: 8px; background: palette(base); }"))); + auto* manual_layout = new QVBoxLayout(manual_panel); + manual_layout->setContentsMargins(12, 8, 12, 8); + auto* manual_title = new QLabel(tr("Controller Applet"), manual_panel); + QFont manual_font = manual_title->font(); + manual_font.setBold(true); + manual_title->setFont(manual_font); + auto* manual_note = new QLabel(tr("What a game sees when it asks for its players. These are " + "the legacy ways of handling controllers: they do not have " + "Modern Console's features, such as players assigned " + "automatically, the overlay and the hints."), + manual_panel); + manual_note->setWordWrap(true); + manual_note->setObjectName(QStringLiteral("note")); + manual_group = new QButtonGroup(this); + auto* manual_row = new QHBoxLayout(); + manual_row->addWidget(MakeSegmented( + manual_panel, {tr("Disabled"), tr("Custom Frontend"), tr("Real Hardware")}, + {static_cast(Settings::AppletMode::Disabled), + static_cast(Settings::AppletMode::HLE), static_cast(Settings::AppletMode::LLE)}, + manual_group)); + manual_row->addStretch(1); + // What Manual itself does, under the choices + auto* manual_about = new QLabel(tr("In Manual, each player's controller is set by hand in " + "its player tab (P1 to P8) and stays until you change " + "it. The same choices are in System > Applets, also per " + "game."), + manual_panel); + manual_about->setWordWrap(true); + manual_about->setObjectName(QStringLiteral("note")); + manual_layout->addWidget(manual_title); + manual_layout->addWidget(manual_note); + manual_layout->addLayout(manual_row); + manual_layout->addWidget(manual_about); + auto* manual_indent = new QHBoxLayout(); + manual_indent->setContentsMargins(16, 0, 0, 0); + manual_indent->addWidget(manual_panel); + mode_layout->addLayout(manual_indent); + + // Under Modern Console: whether this is a handheld with its own controls + handheld_panel = new QFrame(mode_box); + handheld_panel->setObjectName(QStringLiteral("handheldPanel")); + handheld_panel->setStyleSheet( + Themed(QStringLiteral("#handheldPanel { border: 1px solid palette(mid); " + "border-radius: 8px; background: palette(base); }"))); + auto* panel_layout = new QHBoxLayout(handheld_panel); + panel_layout->setContentsMargins(12, 8, 12, 8); + auto* panel_text = new QVBoxLayout(); + auto* panel_title = + new QLabel(tr("Portable Handheld Gaming Device with Built-in Controller"), handheld_panel); + QFont title_font = panel_title->font(); + title_font.setBold(true); + panel_title->setFont(title_font); + panel_title->setWordWrap(true); + auto* panel_note = new QLabel(tr("For a handheld with its own controls, like a Steam Deck, " + "Legion Go or ROG Ally: connected controllers take the " + "players first, and the built-in controls play only when " + "no other controller is connected."), + handheld_panel); + panel_note->setWordWrap(true); + panel_note->setObjectName(QStringLiteral("note")); + panel_text->addWidget(panel_title); + panel_text->addWidget(panel_note); + panel_layout->addLayout(panel_text, 1); + handheld_switch = new ToggleSwitch(handheld_panel); + handheld_switch->setToolTip(tr("On: this is a handheld with its own controls")); + panel_layout->addWidget(handheld_switch, 0, Qt::AlignVCenter); + auto* indent = new QHBoxLayout(); + indent->setContentsMargins(16, 0, 0, 0); + indent->addWidget(handheld_panel); + mode_layout->addLayout(indent); + + mode_info = new QLabel(mode_box); + mode_info->setWordWrap(true); + mode_layout->addWidget(mode_info); + layout->addWidget(mode_box); + + // Under Steam: read the controllers directly instead of through Steam Input + auto* steam_box = new QGroupBox(tr("Steam Input"), this); + steam_panel = steam_box; + auto* steam_column = new QVBoxLayout(steam_box); + auto* steam_layout = new QHBoxLayout(); + steam_column->addLayout(steam_layout); + auto* steam_text = new QVBoxLayout(); + steam_text->setSpacing(1); + auto* steam_title = new QLabel(tr("Bypass Steam Input"), steam_box); + steam_title->setFont(title_font); + auto* steam_note = new QLabel( + tr("Eden reads your controllers directly instead of through Steam's virtual " + "controllers, so Steam Input layouts don't apply in Eden. Try this if a controller " + "doesn't work under Steam, such as the Steam Controller or a newer controller. Takes " + "effect after restarting Eden; connected controllers may be assigned to different " + "players."), + steam_box); + steam_note->setWordWrap(true); + steam_note->setObjectName(QStringLiteral("note")); + steam_restart = new QLabel(tr("Restart Eden to apply this change."), steam_box); + steam_text->addWidget(steam_title); + steam_text->addWidget(steam_note); + steam_layout->addLayout(steam_text, 1); + steam_switch = new ToggleSwitch(steam_box); + steam_switch->setToolTip(tr("On: Eden reads the controllers directly")); + steam_layout->addWidget(steam_switch, 0, Qt::AlignVCenter); + original_bypass = Settings::values.console_bypass_steam.GetValue(); + steam_switch->setChecked(original_bypass); + connect(steam_switch, &QAbstractButton::toggled, this, [this](bool on) { + Settings::values.console_bypass_steam.SetValue(on); + UpdateModeUi(); + }); + + steam_column->addWidget(steam_restart); + layout->addWidget(steam_box); + + // Assign Handheld Controller: declare the handheld's own controller (that mode only) + builtin_row = new QGroupBox(tr("Assign Handheld Controller"), this); + auto* builtin_layout = new QVBoxLayout(builtin_row); + auto* picker_row = new QHBoxLayout(); + picker_row->addWidget(new QLabel(tr("Handheld's controller:"), builtin_row)); + builtin_picker = new QComboBox(builtin_row); + builtin_picker->addItem(tr("Auto-detect"), QString{}); + picker_row->addWidget(builtin_picker, 1); + builtin_identify = new QPushButton(tr("Identify"), builtin_row); + builtin_identify->setCursor(Qt::PointingHandCursor); + builtin_identify->setStyleSheet(OutlinedButtonStyle()); + picker_row->addWidget(builtin_identify); + builtin_layout->addLayout(picker_row); + // The ✕ inside the picker removes the handheld's controller (auto-detect then skips it) + builtin_remove = AddClearButton(builtin_picker); + builtin_remove->setToolTip(tr("Not the handheld's controller: remove it. Auto-detect will " + "not pick it again until you allow it.")); + builtin_remove->hide(); + // A removed controller: said plainly, with the way to allow it again + blocked_row = new QWidget(builtin_row); + auto* blocked_line = new QHBoxLayout(blocked_row); + blocked_line->setContentsMargins(0, 0, 0, 0); + blocked_label = new QLabel(blocked_row); + blocked_label->setWordWrap(true); + blocked_label->setObjectName(QStringLiteral("note")); + blocked_line->addWidget(blocked_label, 1); + blocked_allow = new QPushButton(tr("Allow Again"), blocked_row); + blocked_allow->setCursor(Qt::PointingHandCursor); + blocked_allow->setStyleSheet(OutlinedButtonStyle()); + blocked_line->addWidget(blocked_allow); + builtin_layout->addWidget(blocked_row); + builtin_status = new QLabel(builtin_row); + builtin_status->setWordWrap(true); + builtin_layout->addWidget(builtin_status); + connect(builtin_remove, &QToolButton::clicked, this, [] { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.builtin_remove_request = true; + }); + connect(blocked_allow, &QPushButton::clicked, this, + [] { Settings::values.builtin_pad_blocked.SetValue(std::string{}); }); + layout->addWidget(builtin_row); + + // The players right now, as the engine assigned them + now_box = new QGroupBox(tr("Connected Players"), this); + auto* now_layout = new QVBoxLayout(now_box); + players_box = new QWidget(now_box); + players_list = new QVBoxLayout(players_box); + players_list->setContentsMargins(0, 0, 0, 0); + players_list->setSpacing(6); + now_layout->addWidget(players_box); + players_note = new QLabel(now_box); + players_note->setWordWrap(true); + now_layout->addWidget(players_note); + layout->addWidget(now_box); + + // Gameplay: how players' controllers behave in games + auto* gameplay = new QGroupBox(tr("Gameplay"), this); + gameplay_box = gameplay; + auto* gameplay_layout = new QVBoxLayout(gameplay); + const auto add_option = [gameplay, gameplay_layout](const QString& title, const QString& note, + QWidget* control) { + AddOptionRow(gameplay_layout, gameplay, title, note, control); + }; + joycon_switch = new ToggleSwitch(gameplay); + add_option(tr("Use Any Controller as Joy-Cons"), + tr("When a game only takes Joy-Cons, such as one Joy-Con per player, your " + "controllers act as Joy-Cons automatically, with the buttons arranged to fit. A " + "controller type picked in a player's tab is left as it is."), + joycon_switch); + original_joycon = Settings::values.console_joycon_auto.GetValue(); + joycon_switch->setChecked(original_joycon); + connect(joycon_switch, &QAbstractButton::toggled, this, + [](bool on) { Settings::values.console_joycon_auto.SetValue(on); }); + + layout_group = new QButtonGroup(this); + auto* layout_choice = + MakeSegmented(gameplay, {tr("By Position"), tr("By Label")}, {0, 1}, layout_group); + add_option(tr("Button Layout"), + tr("For Xbox and PlayStation style controllers. By Position: the button where the " + "Switch has A acts as A. By Label: the button marked A acts as A. Nintendo " + "controllers already match both ways."), + layout_choice); + original_labels = Settings::values.console_button_labels.GetValue(); + layout_group->button(original_labels ? 1 : 0)->setChecked(true); + connect(layout_group, &QButtonGroup::idClicked, this, + [](int id) { Settings::values.console_button_labels.SetValue(id == 1); }); + + pause_switch = new ToggleSwitch(gameplay); + add_option(tr("Pause When a Controller Disconnects"), + tr("Pauses the game when a player's controller is turned off, or has been gone " + "for 3 seconds, and resumes when a controller takes that player again. Press + " + "on another controller to go on without that player."), + pause_switch); + original_pause = Settings::values.console_pause_disconnect.GetValue(); + pause_switch->setChecked(original_pause); + connect(pause_switch, &QAbstractButton::toggled, this, + [](bool on) { Settings::values.console_pause_disconnect.SetValue(on); }); + + lights_switch = new ToggleSwitch(gameplay); + add_option(tr("Player Lights"), + tr("Controllers' player lights and light bars show their player, in its colour. " + "Under Steam, Steam sets the lights of the controllers it handles."), + lights_switch); + original_lights = Settings::values.console_player_lights.GetValue(); + lights_switch->setChecked(original_lights); + connect(lights_switch, &QAbstractButton::toggled, this, + [](bool on) { Settings::values.console_player_lights.SetValue(on); }); + layout->addWidget(gameplay); + + // What shows over the game: a title, a line about it and a switch per item + auto* display = new QGroupBox(tr("On-Screen Display"), this); + display_box = display; + auto* display_layout = new QVBoxLayout(display); + const auto add_switch_to = [display](QVBoxLayout* to, const QString& title, + const QString& note) { + auto* toggle = new ToggleSwitch(display); + AddOptionRow(to, display, title, note, toggle); + return toggle; + }; + const auto add_switch = [&](const QString& title, const QString& note) { + return add_switch_to(display_layout, title, note); + }; + // The same switch as View > Show Controller Overlay + overlay_toggle = add_switch(tr("Controller Overlay"), + tr("The players, their controllers and their last buttons in " + "the top-left corner.")); + original_overlay = Settings::values.console_debug_overlay.GetValue(); + overlay_toggle->setChecked(original_overlay); + connect(overlay_toggle, &QAbstractButton::toggled, this, + [](bool shown) { Settings::values.console_debug_overlay.SetValue(shown); }); + + // The overlay's own options, indented under it: the button history and how it shows + history_options = new QWidget(display); + auto* history_layout = new QVBoxLayout(history_options); + history_layout->setContentsMargins(20, 0, 0, 4); + history_toggle = add_switch_to(history_layout, tr("Button History"), + tr("The last buttons each player pressed, beside their row. " + "Buttons pressed together show as one input.")); + // A slider with its value beside it + const auto add_slider = [this, display, history_layout](const QString& title, int min, int max, + int value, const QString& unit) { + auto* line = new QHBoxLayout(); + auto* label = new QLabel(title, display); + line->addWidget(label); + auto* slider = new QSlider(Qt::Horizontal, display); + slider->setRange(min, max); + slider->setValue(value); + slider->setPageStep(1); + line->addWidget(slider, 1); + auto* shown = new QLabel(display); + shown->setMinimumWidth(shown->fontMetrics().horizontalAdvance(unit.arg(max)) + 4); + shown->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + shown->setText(unit.arg(value)); + line->addWidget(shown); + connect(slider, &QSlider::valueChanged, this, + [shown, unit](int v) { shown->setText(unit.arg(v)); }); + auto* row = new QWidget(display); + row->setLayout(line); + line->setContentsMargins(0, 0, 0, 0); + history_layout->addWidget(row); + return std::pair{slider, row}; + }; + original_history = Settings::values.console_history.GetValue(); + original_history_size = Settings::values.console_history_size.GetValue(); + original_history_seconds = Settings::values.console_history_seconds.GetValue(); + original_history_gaps = Settings::values.console_history_gaps.GetValue(); + QWidget* size_row = nullptr; + QWidget* seconds_row = nullptr; + std::tie(history_size, size_row) = + add_slider(tr("Inputs kept"), 5, 20, original_history_size, tr("%1")); + std::tie(history_seconds, seconds_row) = + add_slider(tr("Expire after"), 1, 30, original_history_seconds, tr("%1 s")); + gaps_toggle = add_switch_to(history_layout, tr("Time Between Presses"), + tr("The milliseconds between inputs, never more than the expire " + "time.")); + display_layout->addWidget(history_options); + history_toggle->setChecked(original_history); + gaps_toggle->setChecked(original_history_gaps); + connect(history_toggle, &QAbstractButton::toggled, this, + [](bool shown) { Settings::values.console_history.SetValue(shown); }); + connect(history_size, &QSlider::valueChanged, this, + [](int v) { Settings::values.console_history_size.SetValue(v); }); + connect(history_seconds, &QSlider::valueChanged, this, + [](int v) { Settings::values.console_history_seconds.SetValue(v); }); + connect(gaps_toggle, &QAbstractButton::toggled, this, + [](bool shown) { Settings::values.console_history_gaps.SetValue(shown); }); + // Only what applies can be changed + const auto update_history_options = [this, size_row, seconds_row] { + const bool history_on = history_toggle->isChecked(); + history_options->setEnabled(overlay_toggle->isChecked()); + size_row->setEnabled(history_on); + seconds_row->setEnabled(history_on); + gaps_toggle->setEnabled(history_on); + }; + connect(overlay_toggle, &QAbstractButton::toggled, this, update_history_options); + connect(history_toggle, &QAbstractButton::toggled, this, update_history_options); + update_history_options(); + hints_toggle = add_switch(tr("Controller Hints"), + tr("Names a controller in the bottom-left corner for a moment " + "when it joins as a player or leaves.")); + original_hints = Settings::values.console_hints.GetValue(); + hints_toggle->setChecked(original_hints); + connect(hints_toggle, &QAbstractButton::toggled, this, + [](bool shown) { Settings::values.console_hints.SetValue(shown); }); + layout->addWidget(display); + + // Logging for bug reports + auto* debug = new QGroupBox(tr("Troubleshooting"), this); + debug_box = debug; + auto* debug_layout = new QVBoxLayout(debug); + probes_toggle = add_switch_to(debug_layout, tr("Debug Probes"), + tr("Writes detailed controller traces to the log several times " + "a second, for bug reports. Connected controllers are " + "always described in the log.")); + original_probes = Common::ControllerProbes(); + probes_toggle->setChecked(original_probes); + connect(probes_toggle, &QAbstractButton::toggled, this, + [](bool on) { Settings::values.console_debug_probes.SetValue(on); }); + layout->addWidget(debug); + + // A short word on the two automatic modes, last on the page + auto* about = new QGroupBox(tr("How It Works"), this); + about_box = about; + auto* about_layout = new QVBoxLayout(about); + const auto add_about = [about, about_layout](const QString& title, const QString& text) { + auto* title_label = new QLabel(title, about); + QFont font = title_label->font(); + font.setBold(true); + title_label->setFont(font); + auto* text_label = new QLabel(text, about); + text_label->setWordWrap(true); + text_label->setObjectName(QStringLiteral("note")); + about_layout->addWidget(title_label); + about_layout->addWidget(text_label); + }; + add_about(tr("Modern Console"), + tr("Controllers are assigned like on the console: each one that connects becomes " + "the next player, with nothing to set up. Players keep their own controls when " + "they swap, and a profile picked for a player is used again next time. Its " + "mappings are its own: each controller starts with its standard mapping, and " + "the Manual modes' players are never changed. Keyboard and mouse: use Manual.")); + add_about(tr("Handheld Console"), + tr("For a handheld PC with built-in controls. The same as Modern Console, but the " + "built-in controls always come last: connect a controller and it becomes " + "Player 1, so a docked handheld plays like a console.")); + add_about(tr("Steam Controller"), + tr("Under Steam, turn on Bypass Steam Input (Steam Input card) so Eden reads the " + "Steam Controller itself. Steam Input layouts then don't apply in Eden.")); + layout->addWidget(about); + layout->addStretch(1); + + // The mode applies at once; closing without applying puts the previous one back + original_mode = static_cast(Settings::values.controller_applet_mode.GetValue()); + connect(handler_group, &QButtonGroup::idClicked, this, + &ConfigurePlayerAssignment::OnModeChanged); + connect(manual_group, &QButtonGroup::idClicked, this, + &ConfigurePlayerAssignment::OnModeChanged); + connect(handheld_switch, &QAbstractButton::clicked, this, + &ConfigurePlayerAssignment::OnModeChanged); + connect(builtin_picker, &QComboBox::currentIndexChanged, this, + &ConfigurePlayerAssignment::OnBuiltinPicked); + connect(builtin_identify, &QPushButton::clicked, this, + &ConfigurePlayerAssignment::OnIdentifyPressed); + + timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, &ConfigurePlayerAssignment::Refresh); + // Fast enough for the highlight to follow a button press + timer->start(50); + + LoadConfiguration(); + Refresh(); +} + +ConfigurePlayerAssignment::~ConfigurePlayerAssignment() { + if (!applied) { + Settings::values.controller_applet_mode.SetValue( + static_cast(original_mode)); + Settings::values.console_debug_overlay.SetValue(original_overlay); + Settings::values.console_hints.SetValue(original_hints); + Settings::values.console_history.SetValue(original_history); + Settings::values.console_history_size.SetValue(original_history_size); + Settings::values.console_history_seconds.SetValue(original_history_seconds); + Settings::values.console_history_gaps.SetValue(original_history_gaps); + Settings::values.console_debug_probes.SetValue(original_probes); + Settings::values.console_bypass_steam.SetValue(original_bypass); + Settings::values.console_joycon_auto.SetValue(original_joycon); + Settings::values.console_button_labels.SetValue(original_labels); + Settings::values.console_pause_disconnect.SetValue(original_pause); + Settings::values.console_player_lights.SetValue(original_lights); + } +} + +void ConfigurePlayerAssignment::LoadConfiguration() { + const auto current = Settings::values.controller_applet_mode.GetValue(); + const bool modern = IsAutomatic(current); + handler_group->button(modern ? HandlerModern : HandlerManual)->setChecked(true); + // Under Manual the applet as set; the default there is Custom Frontend + QAbstractButton* applet = manual_group->button(static_cast(current)); + (applet && !modern ? applet : manual_group->button(static_cast(Settings::AppletMode::HLE))) + ->setChecked(true); + handheld_switch->setChecked(current == Settings::AppletMode::HandheldConsoleMode); + UpdateModeUi(); +} + +int ConfigurePlayerAssignment::SelectedMode() const { + switch (handler_group->checkedId()) { + case HandlerModern: + return static_cast(handheld_switch->isChecked() + ? Settings::AppletMode::HandheldConsoleMode + : Settings::AppletMode::ConsoleMode); + default: + return manual_group->checkedId(); + } +} + +void ConfigurePlayerAssignment::OnModeChanged() { + Settings::values.controller_applet_mode.SetValue( + static_cast(SelectedMode())); + UpdateModeUi(); +} + +void ConfigurePlayerAssignment::ApplyConfiguration() { + Settings::values.controller_applet_mode.SetValue( + static_cast(SelectedMode())); + applied = true; +} + +void ConfigurePlayerAssignment::UpdateModeUi() { + const auto selected = static_cast(SelectedMode()); + handheld_panel->setVisible(handler_group->checkedId() == HandlerModern); + // (Bypass Steam Input: only under Steam; applies to a Modern Console start) + steam_panel->setVisible(handler_group->checkedId() == HandlerModern && + Common::RunningUnderSteam()); + steam_restart->setVisible(steam_switch->isChecked() != + Common::GetControllerConsoleState().steam_bypassed); + manual_panel->setVisible(handler_group->checkedId() == HandlerManual); + // Only the automatic modes have live players to show + const bool automatic = IsAutomatic(selected); + switch (selected) { + case Settings::AppletMode::ConsoleMode: + mode_info->setText(tr("Controller assignment is automatic. Each controller is assigned " + "to the next available player when it connects.")); + break; + case Settings::AppletMode::HandheldConsoleMode: + mode_info->setText(tr("Controller assignment is automatic, with the handheld's own " + "controls last.")); + break; + case Settings::AppletMode::Disabled: + mode_info->setText(tr("Controllers are assigned in each player's tab. Games use the " + "configured players without showing a controller applet.")); + break; + case Settings::AppletMode::LLE: + mode_info->setText(tr("Controllers are assigned in each player's tab. When a game " + "requests controller setup, the console's own controller applet " + "is shown. This requires the system firmware.")); + break; + default: + mode_info->setText(tr("Controllers are assigned in each player's tab. When a game " + "requests controller setup, Eden's controller applet is shown.")); + break; + } + now_box->setVisible(automatic); + display_box->setVisible(automatic); + gameplay_box->setVisible(automatic); + about_box->setVisible(automatic); + debug_box->setVisible(automatic); + builtin_row->setVisible(selected == Settings::AppletMode::HandheldConsoleMode); + Refresh(); +} + +void ConfigurePlayerAssignment::Refresh() { + RefreshBuiltin(); + RefreshPlayers(); +} + +void ConfigurePlayerAssignment::RefreshBuiltin() { + auto& state = Common::GetControllerConsoleState(); + std::vector> pads; + std::size_t builtin_pads = 0; + bool armed = false; + std::chrono::steady_clock::time_point armed_at{}; + std::string detected_name; + { + std::scoped_lock lock{state.mutex}; + pads = state.pads; + builtin_pads = state.builtin_pads; + armed = state.identify_armed; + armed_at = state.identify_armed_at; + detected_name = state.handheld_name; + } + + // The entries' data: the Steam handle under Steam (the virtual pads share one GUID), else + // the pad's identity + QString signature = QString::number(builtin_pads) + QLatin1Char('|'); + for (const auto& [display, ident] : pads) { + signature += QString::fromStdString(ident) + QLatin1Char('|'); + } + if (signature != builtin_signature) { + builtin_signature = signature; + const QString current = builtin_picker->currentData().toString(); + const bool blocked = builtin_picker->blockSignals(true); + builtin_picker->clear(); + builtin_picker->addItem(tr("Auto-detect"), QString{}); + for (std::size_t i = 0; i < pads.size(); ++i) { + if (i == builtin_pads) { + // A heading (not pickable) before the standalone controllers + builtin_picker->insertSeparator(builtin_picker->count()); + builtin_picker->addItem(tr("Other controllers")); + if (auto* model = qobject_cast(builtin_picker->model())) { + model->item(builtin_picker->count() - 1)->setEnabled(false); + } + } + builtin_picker->addItem(QString::fromStdString(pads[i].first), + QString::fromStdString(pads[i].second)); + } + const int keep = builtin_picker->findData(current); + builtin_picker->setCurrentIndex(keep > 0 ? keep : 0); + builtin_picker->blockSignals(blocked); + } + + if (armed) { + const auto left = + std::chrono::duration_cast( + std::chrono::seconds{15} - (std::chrono::steady_clock::now() - armed_at)) + .count(); + builtin_status->setText( + tr("Waiting for a button press (%1 seconds)...").arg(std::max(0, left))); + // (The line below counts down) + builtin_identify->setText(tr("Press a button...")); + return; + } + builtin_identify->setText(tr("Identify")); + + // Stored per launch kind: the Steam handle under Steam, else the pad's identity + const bool steam = Common::SteamPadsInUse(); + const QString identity = + QString::fromStdString(Settings::values.builtin_pad_identity.GetValue()); + const QString handle = + QString::fromStdString(Settings::values.builtin_pad_steam_handle.GetValue()); + const QString stored = + steam ? (handle.isEmpty() ? QString{} : QStringLiteral("steamhandle:") + handle) : identity; + const bool declared = steam ? Settings::values.builtin_pad_steam_manual.GetValue() + : Settings::values.builtin_pad_manual.GetValue(); + + // Keep the picker on a declared pad, else on automatic detection + const int stored_index = stored.isEmpty() ? -1 : builtin_picker->findData(stored); + const int want_index = declared && stored_index > 0 ? stored_index : 0; + if (builtin_picker->currentIndex() != want_index) { + const bool blocked = builtin_picker->blockSignals(true); + builtin_picker->setCurrentIndex(want_index); + builtin_picker->blockSignals(blocked); + } + + bool engine_active = false; + { + std::scoped_lock lock{state.mutex}; + engine_active = state.engine_active; + } + if (engine_active && detected_name.empty() && + Settings::values.controller_applet_mode.GetValue() == + Settings::AppletMode::HandheldConsoleMode) { + builtin_status->setText(tr("Handheld controller not detected \u2014 working like Modern " + "until it is. Pick it above, or use Identify.")); + } else if (stored.isEmpty() && detected_name.empty()) { + builtin_status->setText( + tr("Not set. Pick the handheld's controller above, or use Identify.")); + } else { + const QString name = stored_index > 0 ? builtin_picker->itemText(stored_index) + : !detected_name.empty() ? QString::fromStdString(detected_name) + : tr("not set"); + builtin_status->setText( + tr("Handheld: %1 (%2). %3") + .arg(name) + .arg(want_index > 0 ? tr("declared") : tr("auto-detected")) + .arg(!detected_name.empty() + ? tr("Takes no player while external controllers are connected.") + : tr("Not currently connected."))); + } + + // The picker shows the handheld's controller, with the ✕ inside it to remove it + const bool shown = engine_active && !detected_name.empty(); + const QString first = shown && want_index == 0 + ? tr("%1 (auto-detected)").arg(QString::fromStdString(detected_name)) + : tr("Auto-detect"); + if (builtin_picker->itemText(0) != first) { + builtin_picker->setItemText(0, first); + } + builtin_remove->setVisible(shown); + builtin_status->setVisible(true); + // A removed controller, kept from auto-detect + std::string blocked_name; + { + std::scoped_lock lock{state.mutex}; + blocked_name = state.handheld_blocked_name; + } + const bool blocked = !Settings::values.builtin_pad_blocked.GetValue().empty(); + blocked_row->setVisible(blocked); + if (blocked) { + blocked_label->setText( + tr("Auto-detect is off for %1: it will not be picked as the handheld's controller.") + .arg(blocked_name.empty() ? tr("the removed controller") + : QString::fromStdString(blocked_name))); + } +} + +void ConfigurePlayerAssignment::RefreshPlayers() { + auto& state = Common::GetControllerConsoleState(); + std::vector players; + std::string handheld_name; + bool handheld_has_player = false; + bool engine_active = false; + { + std::scoped_lock lock{state.mutex}; + players = state.players; + handheld_name = state.handheld_name; + handheld_has_player = state.handheld_has_player; + engine_active = state.engine_active; + } + const auto selected = static_cast(SelectedMode()); + const bool in_effect = selected == Settings::values.controller_applet_mode.GetValue(); + + // The engine decides the flashes, as in the overlay + constexpr qint64 flash_ms = 1500; + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + int last_player = -1; + for (const auto& entry : players) { + if (!entry.leaving) { + last_player = (std::max)(last_player, entry.player); + } + } + + // The saved input profiles, by their files (cheap enough to look at every refresh) + QStringList profiles; + { + const QDir dir{QString::fromStdString(Common::FS::PathToUTF8String( + Common::FS::GetEdenPath(Common::FS::EdenPath::ConfigDir) / "input"))}; + for (const QString& file : + dir.entryList({QStringLiteral("*.ini")}, QDir::Files, QDir::Name)) { + const QString name = file.chopped(4); + if (InputProfiles::IsProfileNameValid(name.toStdString())) { + profiles.push_back(name); + } + } + } + + // Rebuild the rows when they change; the colours only restyle them + QString signature = QString::number(swap_from) + QLatin1Char(';') + + profiles.join(QLatin1Char(',')) + QLatin1Char(';'); + for (const auto& entry : players) { + signature += + QString::number(entry.player) + QLatin1Char(':') + QString::fromStdString(entry.name) + + QLatin1Char('/') + QString::fromStdString(entry.profile) + + (entry.leaving ? QStringLiteral("x") : QString{}) + QString::number(entry.turn_off) + + QLatin1Char('/') + QString::number(entry.origin) + + (entry.guessed ? QStringLiteral("?") : QString{}) + QLatin1Char('/') + + QString::number(entry.battery / 5) + + (entry.charging ? QStringLiteral("c") : QString{}) + QLatin1Char('|'); + } + if (!handheld_has_player) { + signature += QStringLiteral("h:") + QString::fromStdString(handheld_name); + } + // (Not while a name is being typed: the rows would be rebuilt under it) + if (signature != players_signature && !editing_name) { + players_signature = signature; + const bool any_turn_off = std::any_of(players.begin(), players.end(), + [](const auto& e) { return e.turn_off != 0; }); + for (QFrame* row : player_rows) { + players_list->removeWidget(row); + row->hide(); + row->deleteLater(); + } + player_rows.clear(); + row_players.clear(); + for (const auto& entry : players) { + const int player = entry.player; + auto* row = new PlayerRow(player, players_box); + row->setToolTip(tr("Click to rumble this controller. Hold, then drop on another " + "player to swap them.")); + row->on_click = [this](int p) { RequestRumble(p); }; + row->on_drop = [this](int from, int to) { RequestSwap(from, to); }; + auto* line = new QHBoxLayout(row); + line->setContentsMargins(8, 5, 6, 5); + line->setSpacing(6); + auto* grip = new QLabel(QStringLiteral("\u283F"), row); + grip->setToolTip(tr("Drag onto another player to swap")); + grip->setObjectName(QStringLiteral("note")); + line->addWidget(grip); + // The player's badge, marked with ? for a reconnect's guess, else a dot in the + // colour of the player the controller started as + auto* badge_box = new QWidget(row); + badge_box->setFixedSize(40, 24); + // (Only the badge and its mark show: the dark themes paint a plain widget) + badge_box->setAttribute(Qt::WA_TranslucentBackground); + badge_box->setStyleSheet(QStringLiteral("background: transparent;")); + auto* badge = new QLabel(QStringLiteral("P%1").arg(player + 1), badge_box); + badge->setAlignment(Qt::AlignCenter); + badge->setGeometry(0, 3, 34, 20); + badge->setStyleSheet( + QStringLiteral("QLabel { background: %1; color: white; " + "border-radius: 10px; font-weight: bold; }") + .arg(PlayerColors[static_cast(player) % 8].name())); + if (!entry.leaving && (entry.guessed || entry.origin >= 0)) { + auto* mark = new QLabel(badge_box); + if (entry.guessed) { + mark->setText(QStringLiteral("?")); + mark->setAlignment(Qt::AlignCenter); + mark->setGeometry(27, 0, 13, 13); + mark->setStyleSheet(QStringLiteral( + "QLabel { background: white; color: #0e0e12; border: 1px solid #0e0e12; " + "border-radius: 6px; font-weight: bold; font-size: 9px; }")); + badge_box->setToolTip(tr("This controller came back without a way to tell " + "which one it is. Swap it if it's in the wrong " + "place.")); + } else { + mark->setGeometry(29, 1, 10, 10); + mark->setStyleSheet( + QStringLiteral("QLabel { background: %1; border: 1px solid #0e0e12; " + "border-radius: 5px; }") + .arg(PlayerColors[static_cast(entry.origin) % 8].name())); + badge_box->setToolTip(tr("Started as P%1").arg(entry.origin + 1)); + } + } + line->addWidget(badge_box); + // The name, with a pencil to name the controller ("John" shows as "John (Xbox)") + const QString given = QString::fromStdString(entry.custom_name); + auto* name_label = new QLabel(QString::fromStdString(entry.name), row); + auto* name_edit = new PlayerNameEdit(given, row); + name_edit->setObjectName(QStringLiteral("playerName")); + name_edit->setMaxLength(16); + name_edit->setPlaceholderText(tr("Name this controller")); + name_edit->setStyleSheet(ModernComboStyle().replace(QStringLiteral("QComboBox"), + QStringLiteral("QLineEdit"))); + name_edit->hide(); + const QColor icon_color = Accent(this); + const auto name_button = [row, icon_color](int kind, const QString& tip) { + auto* button = new QToolButton(row); + button->setIcon(NameIcon(kind, icon_color)); + button->setIconSize(QSize(14, 14)); + button->setAutoRaise(true); + button->setCursor(Qt::PointingHandCursor); + button->setToolTip(tip); + // (Clicking it keeps the field focused: leaving the field cancels) + button->setFocusPolicy(Qt::NoFocus); + return button; + }; + auto* name_edit_button = name_button(0, tr("Name P%1's controller").arg(player + 1)); + auto* name_apply = name_button(1, tr("Apply the name")); + auto* name_remove = name_button(2, tr("Remove the name")); + name_apply->hide(); + // (The bin removes a given name) + name_remove->setVisible(!given.isEmpty()); + name_edit_button->setEnabled(!entry.leaving); + name_remove->setEnabled(!entry.leaving); + const auto request = [player](const QString& value) { + auto& console = Common::GetControllerConsoleState(); + std::scoped_lock lock{console.mutex}; + console.rename_requests.emplace_back(player, value.trimmed().toStdString()); + }; + const auto editing = [=, this](bool on) { + // (While a name is typed, the rows aren't rebuilt under it) + editing_name = on; + name_label->setVisible(!on); + name_edit_button->setVisible(!on); + name_edit->setVisible(on); + name_apply->setVisible(on); + name_remove->setVisible(!given.isEmpty()); + if (on) { + name_edit->setText(given); + name_edit->setFocus(); + name_edit->selectAll(); + } + }; + name_edit->on_apply = [=] { + editing(false); + if (name_edit->text().trimmed() != given) { + request(name_edit->text()); + } + }; + name_edit->on_cancel = [=] { editing(false); }; + connect(name_edit_button, &QToolButton::clicked, this, [=] { editing(true); }); + connect(name_apply, &QToolButton::clicked, this, [=] { name_edit->on_apply(); }); + connect(name_remove, &QToolButton::clicked, this, [=] { + editing(false); + request(QString{}); + }); + // The controller's battery, when it tells its charge + QLabel* battery = nullptr; + if (entry.battery >= 0 && !entry.leaving) { + battery = new QLabel(row); + battery->setPixmap(BatteryIcon(entry.battery, entry.charging)); + battery->setText(QString{}); + battery->setToolTip(entry.charging ? tr("Battery %1% (charging)").arg(entry.battery) + : tr("Battery %1%").arg(entry.battery)); + } + auto* battery_text = + battery ? new QLabel(QStringLiteral("%1%").arg(entry.battery), row) : nullptr; + if (battery_text) { + battery_text->setObjectName(QStringLiteral("note")); + battery_text->setToolTip(battery->toolTip()); + } + auto* name_line = new QHBoxLayout(); + name_line->setSpacing(2); + name_line->addWidget(name_label); + if (battery) { + name_line->addSpacing(6); + name_line->addWidget(battery_text); + name_line->addWidget(battery); + } + name_line->addWidget(name_edit, 1); + name_line->addWidget(name_edit_button); + name_line->addWidget(name_apply); + name_line->addWidget(name_remove); + name_line->addStretch(1); + line->addLayout(name_line, 1); + const bool live = !entry.leaving; + + // The controller's profile; "No profile" (or the ✕) takes it off + auto* profile = new QComboBox(row); + auto* clear = AddClearButton(profile); + profile->addItem(tr("No profile")); + profile->addItems(profiles); + // (Not found is -1: "No profile") + profile->setCurrentIndex( + static_cast(profiles.indexOf(QString::fromStdString(entry.profile)) + 1)); + profile->setToolTip(tr("Mapping profile for P%1's controller").arg(player + 1)); + // One width for every row, whatever the profile's name + profile->setFixedWidth(170); + // (A profile name with no file behind it shows as none: there is nothing to pick) + profile->setEnabled(live && !profiles.isEmpty()); + const auto take_off = [player] { + auto& console = Common::GetControllerConsoleState(); + std::scoped_lock lock{console.mutex}; + console.profile_requests.push_back({player, {}, true}); + }; + connect(profile, qOverload(&QComboBox::activated), this, + [player, profile, take_off](int index) { + if (index == 0) { + take_off(); + return; + } + auto& console = Common::GetControllerConsoleState(); + std::scoped_lock lock{console.mutex}; + console.profile_requests.push_back( + {player, profile->itemText(index).toStdString()}); + }); + clear->setToolTip(tr("Take the profile off P%1").arg(player + 1)); + // (A profile renamed or deleted elsewhere shows as none: no ✕ for it) + clear->setVisible(live && profiles.contains(QString::fromStdString(entry.profile))); + connect(clear, &QToolButton::clicked, this, take_off); + line->addWidget(profile); + + // (A disabled arrow is drawn plain, not as a dark block) + const QString plain_disabled = + QStringLiteral("QToolButton:disabled { background: transparent; border: none; }"); + auto* up = new QToolButton(row); + up->setArrowType(Qt::UpArrow); + up->setAutoRaise(true); + up->setStyleSheet(plain_disabled); + up->setToolTip(tr("Swap with P%1").arg(player)); + up->setEnabled(live && player > 0); + connect(up, &QToolButton::clicked, this, + [this, player] { RequestSwap(player, player - 1); }); + line->addWidget(up); + auto* down = new QToolButton(row); + down->setArrowType(Qt::DownArrow); + down->setAutoRaise(true); + down->setStyleSheet(plain_disabled); + down->setToolTip(tr("Swap with P%1").arg(player + 2)); + down->setEnabled(live && player < last_player); + connect(down, &QToolButton::clicked, this, + [this, player] { RequestSwap(player, player + 1); }); + line->addWidget(down); + + // Swap and Config outlined in the highlight colour (Swap filled while it waits) + const QString outlined = OutlinedButtonStyle(); + const QString filled = Themed(QStringLiteral( + "QToolButton { border: 1px solid palette(highlight); border-radius: 6px; " + "padding: 3px 12px; background: palette(highlight); " + "color: palette(highlighted-text); font-weight: bold; }")); + auto* swap = new QToolButton(row); + swap->setCursor(Qt::PointingHandCursor); + swap->setStyleSheet(swap_from >= 0 ? filled : outlined); + if (swap_from < 0) { + swap->setText(tr("Swap")); + } else if (swap_from == player) { + swap->setText(tr("Cancel")); + } else { + swap->setText(tr("Swap with P%1").arg(swap_from + 1)); + } + swap->setEnabled(live); + connect(swap, &QToolButton::clicked, this, [this, player] { OnSwapPressed(player); }); + line->addWidget(swap); + + auto* settings = new QToolButton(row); + settings->setText(tr("Config")); + settings->setCursor(Qt::PointingHandCursor); + settings->setStyleSheet(outlined); + settings->setToolTip(tr("Open P%1's tab").arg(player + 1)); + settings->setEnabled(static_cast(open_player_tab)); + connect(settings, &QToolButton::clicked, this, [this, player] { + if (open_player_tab) { + open_player_tab(player); + } + }); + line->addWidget(settings); + + // Turn Off, for a Bluetooth controller: Eden disconnects it, which turns most + // controllers off. (Room kept on the other rows so the buttons line up.) + if (any_turn_off) { + line->addSpacing(6); + auto* turn_off = new QToolButton(row); + turn_off->setText(tr("Turn Off")); + turn_off->setIcon(PowerIcon()); + turn_off->setIconSize(QSize(14, 14)); + turn_off->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + turn_off->setCursor(Qt::PointingHandCursor); + turn_off->setStyleSheet(Themed(QStringLiteral( + "QToolButton { border: 1px solid #c83c3c; border-radius: 6px; " + "padding: 3px 10px; background: #c83c3c; color: white; font-weight: bold; }" + "QToolButton:hover { background: #a82c2c; border-color: #a82c2c; }" + "QToolButton:disabled { background: transparent; border-color: palette(mid); " + "color: palette(mid); }"))); + turn_off->setToolTip( + entry.turn_off == 2 + ? tr("Turns off P%1's controller and frees the player. Turn the " + "controller on to join again.") + .arg(player + 1) + : tr("Press a button on this controller first, so Eden can tell it " + "from the others like it.")); + turn_off->setEnabled(live && entry.turn_off == 2); + QSizePolicy keep = turn_off->sizePolicy(); + keep.setRetainSizeWhenHidden(true); + turn_off->setSizePolicy(keep); + turn_off->setVisible(entry.turn_off != 0); + connect(turn_off, &QToolButton::clicked, this, [player] { + auto& console = Common::GetControllerConsoleState(); + std::scoped_lock lock{console.mutex}; + console.remove_requests.push_back(player); + }); + line->addWidget(turn_off); + } + + players_list->addWidget(row); + player_rows.push_back(row); + row_players.push_back(player); + } + if (!handheld_name.empty() && !handheld_has_player) { + auto* row = new QFrame(players_box); + auto* line = new QHBoxLayout(row); + line->setContentsMargins(4, 2, 4, 2); + line->addWidget(new QLabel(QStringLiteral("\u2014"), row)); + line->addWidget(new QLabel(tr("%1 (built-in, not a player while other controllers " + "are connected)") + .arg(QString::fromStdString(handheld_name)), + row), + 1); + players_list->addWidget(row); + player_rows.push_back(row); + row_players.push_back(-1); + } + } + + // Rows are cards; a fill marks what happens to them: red leaving, blue input held or + // rumbling, green new + const QString card = Themed(QStringLiteral("QFrame#playerRow { border: 1px solid " + "palette(mid); border-radius: 8px; " + "background: palette(base); }")); + const auto fill = [](const char* rgba) { + return Themed( + QStringLiteral("QFrame#playerRow { border: 1px solid palette(mid); border-radius: " + "8px; background-color: rgba(%1); } " + "QFrame#playerRow QLabel { color: white; }") + .arg(QString::fromLatin1(rgba))); + }; + const auto blink_on = [now](qint64 since) { + const qint64 elapsed = now - since; + return elapsed < flash_ms && (elapsed / 125) % 2 == 0; + }; + for (size_t k = 0; k < player_rows.size(); ++k) { + const int player = row_players[k]; + const auto entry = std::find_if(players.begin(), players.end(), + [&](const auto& row) { return row.player == player; }); + if (player < 0 || entry == players.end()) { + continue; + } + const auto rumbled = rumbled_at.find(player); + const bool active = + entry->active || (rumbled != rumbled_at.end() && now - rumbled->second < 500); + const bool blink = entry->flash != 0 && blink_on(entry->flash_since); + QString style = card; + if (entry->flash == 2) { + if (blink) { + style = fill("210, 60, 60, 190"); + } + } else if (active && !entry->leaving) { + style = fill("60, 120, 230, 190"); + } else if (blink) { + style = fill("60, 180, 90, 190"); + } + if (player_rows[k]->styleSheet() != style) { + player_rows[k]->setStyleSheet(style); + } + } + + if (!in_effect || (IsAutomatic(selected) && !engine_active)) { + players_note->setText(tr("Switching...")); + } else if (last_player < 0) { + players_note->setText(tr("No controller is a player right now.")); + } else { + players_note->setText(tr("Press a button on a controller to see its player. Swaps last " + "for this session and do not change your saved controls.")); + } + players_box->setVisible(in_effect && engine_active); + ModernizeCombos(this); +} + +void ConfigurePlayerAssignment::OnBuiltinPicked(int index) { + // Picking is the user's say: a removed controller is no longer kept from auto-detect + Settings::values.builtin_pad_blocked.SetValue(std::string{}); + const QString ident = builtin_picker->itemData(index).toString(); + if (ident.isEmpty()) { + // Detect automatically: forget the stored pad, so the engine detects it afresh + Settings::values.builtin_pad_identity.SetValue(std::string{}); + Settings::values.builtin_pad_steam_handle.SetValue(std::string{}); + Settings::values.builtin_pad_steam_manual.SetValue(false); + Settings::values.builtin_pad_manual.SetValue(false); + return; + } + // Under Steam the entries are Steam handles, set for Steam launches only + const QString prefix = QStringLiteral("steamhandle:"); + if (ident.startsWith(prefix)) { + Settings::values.builtin_pad_steam_handle.SetValue(ident.mid(prefix.size()).toStdString()); + Settings::values.builtin_pad_steam_manual.SetValue(true); + } else { + Settings::values.builtin_pad_identity.SetValue(ident.toStdString()); + Settings::values.builtin_pad_manual.SetValue(true); + } +} + +void ConfigurePlayerAssignment::OnIdentifyPressed() { + Settings::values.builtin_pad_blocked.SetValue(std::string{}); + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.identify_armed = true; + state.identify_armed_at = std::chrono::steady_clock::now(); +} + +void ConfigurePlayerAssignment::OnSwapPressed(int player) { + if (swap_from < 0) { + swap_from = player; + } else { + if (swap_from != player) { + RequestSwap(swap_from, player); + } + swap_from = -1; + } + RefreshPlayers(); +} + +void ConfigurePlayerAssignment::RequestSwap(int a, int b) { + if (a == b || a < 0 || b < 0 || a >= MaxPlayers || b >= MaxPlayers) { + return; + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.swap_requests.emplace_back(a, b); +} + +void ConfigurePlayerAssignment::RequestRumble(int player) { + rumbled_at[player] = QDateTime::currentMSecsSinceEpoch(); + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.rumble_requests.push_back(player); +} + +void ConfigurePlayerAssignment::AddTopWidget(QWidget* widget) { + content_layout->insertWidget(0, widget); +} + +void ConfigurePlayerAssignment::SetOpenPlayerTab(std::function open) { + open_player_tab = std::move(open); + players_signature.clear(); + RefreshPlayers(); +} diff --git a/src/yuzu/configuration/configure_player_assignment.h b/src/yuzu/configuration/configure_player_assignment.h new file mode 100644 index 0000000000..b90237b766 --- /dev/null +++ b/src/yuzu/configuration/configure_player_assignment.h @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include + +class QAbstractButton; +class QButtonGroup; +class QCheckBox; +class QComboBox; +class QFrame; +class QVBoxLayout; +class QLabel; +class QPushButton; +class QSlider; +class QTimer; +class QToolButton; +class QWidget; + +/// The Controllers tab: how players are assigned, the handheld's own controller, and the live +/// list of players. +class ConfigurePlayerAssignment : public QWidget { + Q_OBJECT + +public: + explicit ConfigurePlayerAssignment(QWidget* parent = nullptr); + ~ConfigurePlayerAssignment() override; + + void ApplyConfiguration(); + + /// How to bring a player's tab to the front (the settings dialog owns the tabs) + void SetOpenPlayerTab(std::function open); + + /// Puts a widget above everything else in the tab + void AddTopWidget(QWidget* widget); + + /// Rounded fields and outlined buttons, as the Controllers tab draws them + static QString ModernComboStyle(); + static QString OutlinedButtonStyle(); + /// A style sheet for the current theme: under a dark look the palette colours become + /// dark-friendly ones ("muted-text" is a note's colour) + static QString Themed(QString style); + /// The accent colour, as Themed gives palette(highlight) + static QColor Accent(const QWidget* widget); + /// A player's colour, as the overlay and the player badges draw it + static QColor PlayerColor(int player); + /// A controller's battery as a small cell (20x12), as the overlay draws it + static QPixmap Battery(int percent, bool charging); + /// A ✕ inside a drop-down, left of its arrow (hidden or shown by the caller) + static QToolButton* AddClearButton(QComboBox* combo); + /// Lets the open lists of a widget's drop-downs follow ModernComboStyle + static void ModernizeCombos(QWidget* root); + + /// A row of joined buttons, one checked at a time (the group gets them, with these ids) + static QWidget* MakeSegmented(QWidget* parent, const QStringList& labels, + const std::vector& ids, QButtonGroup* group); + +private: + void LoadConfiguration(); + /// The controller applet mode the controls select + int SelectedMode() const; + void OnModeChanged(); + void UpdateModeUi(); + void Refresh(); + void RefreshBuiltin(); + void RefreshPlayers(); + void OnBuiltinPicked(int index); + void OnIdentifyPressed(); + void OnSwapPressed(int player); + void RequestSwap(int a, int b); + void RequestRumble(int player); + + /// The handler (Manual, Modern Console): under Manual the controller applet, under Modern + /// Console whether this is a handheld + enum Handler { HandlerManual, HandlerModern }; + QButtonGroup* handler_group = nullptr; + QFrame* manual_panel = nullptr; + QButtonGroup* manual_group = nullptr; + QFrame* handheld_panel = nullptr; + QAbstractButton* handheld_switch = nullptr; + /// Bypass Steam Input (under Steam), a card of its own for both Modern Console modes + QWidget* steam_panel = nullptr; + QAbstractButton* steam_switch = nullptr; + QLabel* steam_restart = nullptr; + bool original_bypass = false; + /// The Gameplay card + QWidget* gameplay_box = nullptr; + QAbstractButton* joycon_switch = nullptr; + bool original_joycon = true; + QButtonGroup* layout_group = nullptr; + bool original_labels = false; + QAbstractButton* pause_switch = nullptr; + bool original_pause = false; + QAbstractButton* lights_switch = nullptr; + bool original_lights = true; + QLabel* mode_info = nullptr; + QWidget* display_box = nullptr; + QWidget* about_box = nullptr; + /// The scrolled page the cards stack in + QVBoxLayout* content_layout = nullptr; + QAbstractButton* overlay_toggle = nullptr; + bool original_overlay = false; + QAbstractButton* hints_toggle = nullptr; + bool original_hints = true; + /// The overlay's button history options (indented under Controller Overlay) + QWidget* history_options = nullptr; + QAbstractButton* history_toggle = nullptr; + QSlider* history_size = nullptr; + QSlider* history_seconds = nullptr; + QAbstractButton* gaps_toggle = nullptr; + bool original_history = true; + int original_history_size = 10; + int original_history_seconds = 5; + bool original_history_gaps = false; + /// Debug Probes, in the Troubleshooting card + QWidget* debug_box = nullptr; + QAbstractButton* probes_toggle = nullptr; + bool original_probes = false; + QWidget* now_box = nullptr; + + QWidget* builtin_row = nullptr; + QComboBox* builtin_picker = nullptr; + QPushButton* builtin_identify = nullptr; + QLabel* builtin_status = nullptr; + /// Removing the handheld's controller (the ✕), and allowing a removed one again + QToolButton* builtin_remove = nullptr; + QWidget* blocked_row = nullptr; + QLabel* blocked_label = nullptr; + QPushButton* blocked_allow = nullptr; + QString builtin_signature; + + QWidget* players_box = nullptr; + QVBoxLayout* players_list = nullptr; + QLabel* players_note = nullptr; + QString players_signature; + /// A player's name is being typed (the rows wait) + bool editing_name = false; + /// The player whose Swap was pressed first, waiting for the second (-1: none) + int swap_from = -1; + /// The rows and the player each shows, for restyling them while a controller is active + std::vector player_rows; + std::vector row_players; + /// When each player's row was clicked to rumble (blue for a moment) + std::map rumbled_at; + std::function open_player_tab; + + QTimer* timer = nullptr; + /// The mode when the dialog opened, put back unless the dialog applies + int original_mode = 0; + bool applied = false; +}; diff --git a/src/yuzu/configuration/input_profiles.cpp b/src/yuzu/configuration/input_profiles.cpp index 952b5ab7b0..15437e0233 100644 --- a/src/yuzu/configuration/input_profiles.cpp +++ b/src/yuzu/configuration/input_profiles.cpp @@ -16,9 +16,12 @@ namespace FS = Common::FS; namespace { +std::filesystem::path ProfilePath(std::string_view profile_name) { + return FS::GetEdenPath(FS::EdenPath::ConfigDir) / "input" / fmt::format("{}.ini", profile_name); +} + bool ProfileExistsInFilesystem(std::string_view profile_name) { - return FS::Exists(FS::GetEdenPath(FS::EdenPath::ConfigDir) / "input" / - fmt::format("{}.ini", profile_name)); + return FS::Exists(ProfilePath(profile_name)); } bool IsINI(const std::filesystem::path& filename) { @@ -132,6 +135,46 @@ bool InputProfiles::SaveProfile(const std::string& profile_name, std::size_t pla return true; } +bool InputProfiles::RenameProfile(const std::string& profile_name, const std::string& new_name) { + if (!ProfileExistsInMap(profile_name) || ProfileExistsInMap(new_name) || + ProfileExistsInFilesystem(new_name) || + !FS::RenameFile(ProfilePath(profile_name), ProfilePath(new_name))) { + return false; + } + map_profiles.erase(profile_name); + map_profiles.insert_or_assign( + new_name, std::make_unique(new_name, Config::ConfigType::InputProfile)); + return true; +} + +bool InputProfiles::DuplicateProfile(const std::string& profile_name, const std::string& new_name) { + if (!ProfileExistsInMap(profile_name) || ProfileExistsInMap(new_name) || + ProfileExistsInFilesystem(new_name)) { + return false; + } + std::error_code error; + std::filesystem::copy_file(ProfilePath(profile_name), ProfilePath(new_name), error); + if (error) { + return false; + } + map_profiles.insert_or_assign( + new_name, std::make_unique(new_name, Config::ConfigType::InputProfile)); + return true; +} + +std::optional InputProfiles::PeekProfile(const std::string& profile_name, + std::size_t player_index) { + if (!ProfileExistsInMap(profile_name)) { + return std::nullopt; + } + auto& player = Settings::values.players.GetValue()[player_index]; + const auto kept = player; + map_profiles[profile_name]->ReadQtControlPlayerValues(player_index); + auto read = player; + player = kept; + return read; +} + bool InputProfiles::ProfileExistsInMap(const std::string& profile_name) const { return map_profiles.find(profile_name) != map_profiles.end(); } diff --git a/src/yuzu/configuration/input_profiles.h b/src/yuzu/configuration/input_profiles.h index 4ee7a266c4..84237ff409 100644 --- a/src/yuzu/configuration/input_profiles.h +++ b/src/yuzu/configuration/input_profiles.h @@ -6,6 +6,7 @@ #pragma once +#include #include #include "common/container/unordered_map.h" @@ -31,6 +32,11 @@ public: bool DeleteProfile(const std::string& profile_name); bool LoadProfile(const std::string& profile_name, std::size_t player_index); bool SaveProfile(const std::string& profile_name, std::size_t player_index); + bool RenameProfile(const std::string& profile_name, const std::string& new_name); + bool DuplicateProfile(const std::string& profile_name, const std::string& new_name); + /// A profile's player values, read without touching the player they are read through + std::optional PeekProfile(const std::string& profile_name, + std::size_t player_index); private: bool ProfileExistsInMap(const std::string& profile_name) const; diff --git a/src/yuzu/configuration/shared_widget.cpp b/src/yuzu/configuration/shared_widget.cpp index 5e8e04be58..9410988bdb 100644 --- a/src/yuzu/configuration/shared_widget.cpp +++ b/src/yuzu/configuration/shared_widget.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -139,13 +140,34 @@ QWidget* Widget::CreateCombobox(std::function& serializer, const ComboboxTranslations* enumeration{nullptr}; if (combobox_enumerations.contains(type)) { enumeration = &combobox_enumerations.at(type); + const bool applet = type == Settings::EnumMetadata::Index(); + const bool controller = setting.Id() == Settings::values.controller_applet_mode.Id(); for (const auto& [id, name] : *enumeration) { - if (type == Settings::EnumMetadata::Index() && - id == static_cast(Settings::AppletMode::Disabled) && - setting.Id() != Settings::values.controller_applet_mode.Id()) { + // The automatic modes are the controller applet's alone + const bool automatic = + id == static_cast(Settings::AppletMode::ConsoleMode) || + id == static_cast(Settings::AppletMode::HandheldConsoleMode); + if (applet && automatic && !controller) { continue; } combobox->addItem(name, id); + // Handheld Console needs to know the handheld's own controller + const bool handheld_set = !Settings::values.builtin_pad_identity.GetValue().empty() || + !Settings::values.builtin_pad_steam_handle.GetValue().empty(); + if (applet && controller && + id == static_cast(Settings::AppletMode::HandheldConsoleMode) && + !handheld_set) { + const int row = combobox->count() - 1; + if (auto* model = qobject_cast(combobox->model())) { + model->item(row)->setEnabled(false); + } + combobox->setItemData( + row, + tr("No handheld controller is set. Pick Modern Console in Controls > " + "Controllers, turn on Portable Handheld Gaming Device, and pick or " + "identify the handheld's controller."), + Qt::ToolTipRole); + } } } else { return combobox; @@ -163,7 +185,14 @@ QWidget* Widget::CreateCombobox(std::function& serializer, const u32 setting_value = std::strtoul(setting.ToString().c_str(), nullptr, 0); combobox->setCurrentIndex(find_index(setting_value)); - serializer = [this]() { + // The controller applet's mode is also set in Controls > Controllers, which applies at once: + // unless it was changed here, this keeps what it is by then (this tab applies last) + const bool controller_mode = setting.Id() == Settings::values.controller_applet_mode.Id(); + const int loaded_index = combobox->currentIndex(); + serializer = [this, controller_mode, loaded_index]() { + if (controller_mode && combobox->currentIndex() == loaded_index) { + return setting.ToString(); + } return std::to_string(combobox->currentData().toUInt()); }; diff --git a/src/yuzu/game/game_list.cpp b/src/yuzu/game/game_list.cpp index 6b89e59253..6a70b71ae7 100644 --- a/src/yuzu/game/game_list.cpp +++ b/src/yuzu/game/game_list.cpp @@ -76,6 +76,22 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid if (system.IsPoweredOn()) { return; } + if (controller_context_menu) { + // A submenu is its own popup window (isAncestorOf stops at windows) + QWidget* popup = QApplication::activePopupWidget(); + bool ours = false; + for (QWidget* w = popup; w != nullptr && !ours; w = w->parentWidget()) { + ours = w == controller_context_menu; + } + if (ours) { + QCoreApplication::postEvent( + popup, new QKeyEvent(QEvent::KeyPress, key, Qt::NoModifier)); + } + return; + } + if (QApplication::activePopupWidget() || QApplication::activeModalWidget()) { + return; + } if (!this->isActiveWindow()) { return; } @@ -83,6 +99,28 @@ GameList::GameList(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvid QCoreApplication::postEvent(m_currentView, event); }); + connect( + controller_navigation, &ControllerNavigation::TriggerContextMenu, this, + [this] { + if (system.IsPoweredOn() || !isActiveWindow() || QApplication::activePopupWidget() || + QApplication::activeModalWidget() || controller_context_menu) { + return; + } + const QModelIndex current = m_currentView->currentIndex(); + const QModelIndex selected = current.sibling(current.row(), 0); + if (!selected.isValid() || + selected.data(GameListItem::TypeRole).value() != + GameListItemType::Game) { + return; + } + m_currentView->scrollTo(current); + const QRect rect = m_currentView->visualRect(current); + if (rect.isValid()) { + PopupContextMenu(rect.center()); + } + }, + Qt::QueuedConnection); + connect(item_model, &GameListModel::PopulatingCompleted, this, &GameList::OnPopulatingCompleted); @@ -443,7 +481,9 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { default: break; } + controller_context_menu = &context_menu; context_menu.exec(m_currentView->viewport()->mapToGlobal(menu_location)); + controller_context_menu = nullptr; } void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::string& path) { diff --git a/src/yuzu/game/game_list.h b/src/yuzu/game/game_list.h index 1540dd9cf0..1305783e5d 100644 --- a/src/yuzu/game/game_list.h +++ b/src/yuzu/game/game_list.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,7 @@ enum class StartGameType; class GameCarousel; class QAbstractItemView; +class QMenu; namespace Core { class System; } @@ -155,6 +157,7 @@ private: Settings::GameListMode game_list_mode; ControllerNavigation* controller_navigation = nullptr; + QPointer controller_context_menu; QVariantAnimation* vertical_scroll = nullptr; QVariantAnimation* horizontal_scroll = nullptr; diff --git a/src/yuzu/main.ui b/src/yuzu/main.ui index d79fcbe396..e432095be8 100644 --- a/src/yuzu/main.ui +++ b/src/yuzu/main.ui @@ -156,6 +156,8 @@ + + @@ -614,6 +616,22 @@ Show &Performance Overlay + + + true + + + Show &Controller Overlay + + + + + true + + + Show Controller &Hints + + Post-Processing &Shaders... diff --git a/src/yuzu/main_window.cpp b/src/yuzu/main_window.cpp index 69b7338a66..3bf345e62b 100644 --- a/src/yuzu/main_window.cpp +++ b/src/yuzu/main_window.cpp @@ -3,12 +3,15 @@ // Static Qt on macOS doesn't use Vulkan // Other platforms do, and thus have conflicting VulkanMemoryAllocator symbols +#include +#include #include "core/launch_params.h" #if defined(QT_STATICPLUGIN) && !defined(__APPLE__) #undef VMA_IMPLEMENTATION #endif #include +#include "common/controller_console_state.h" #include "common/fs/path_util.h" #include "common/settings.h" #include "common/settings_enums.h" @@ -42,6 +45,7 @@ #include "set_play_time_dialog.h" #include "util/util.h" #include "yuzu/game/game_list.h" +#include "yuzu/overlay_panel.h" #include "applets/qt_amiibo_settings.h" #include "applets/qt_controller.h" @@ -53,10 +57,13 @@ #include "configuration/configure_dialog.h" #include "configuration/configure_input.h" #include "configuration/configure_per_game.h" +#include "configuration/configure_player_assignment.h" #include "configuration/configure_tas.h" +#include "configuration/input_profiles.h" #include "util/clickable_label.h" #include "util/controller_navigation.h" +#include "util/gamescope.h" #include "util/overlay_dialog.h" #include "multiplayer/state.h" @@ -64,15 +71,39 @@ // Qt Stuff // #define QT_NO_OPENGL +#include +#include #include +#include +#include +#include #include #include #include #include +#include #include +#include #include #include +#include +#include +#include +#ifdef __linux__ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif #include #include #include @@ -167,6 +198,10 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include "video_core/shader_notify.h" #include +#ifdef __linux__ +#include +#include +#endif #include @@ -185,6 +220,12 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #endif +#include +#include +#include +#include +#include "common/overlay_panels.h" +#include "common/param_package.h" #include "qt_common/gamemode.h" #ifdef _WIN32 @@ -192,8 +233,8 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual #include "core/core_timing.h" #include -#include #include +#include #include #ifdef _MSC_VER #pragma comment(lib, "Dwmapi.lib") @@ -349,6 +390,387 @@ inline static bool isDarkMode() { } #endif // _WIN32 +namespace { + +/// Asks the engine to put the players in this order: place k takes the controller player +/// order[k] has now, one swap at a time +void RequestPlayerOrder(const std::vector& order) { + std::vector places = order; + std::sort(places.begin(), places.end()); + if (order == places) { + return; + } + std::map holder; + for (const int place : places) { + holder[place] = place; + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + for (std::size_t k = 0; k < places.size(); ++k) { + int from = places[k]; + for (const auto& [place, original] : holder) { + if (original == order[k]) { + from = place; + } + } + if (from != places[k]) { + state.swap_requests.emplace_back(places[k], from); + std::swap(holder[places[k]], holder[from]); + } + } +} + +/// The players as chips after the last menu: a click rumbles the controller and opens its tab; +/// a chip drags left or right to change the order +class PlayerChips : public QWidget { +public: + PlayerChips(QMenuBar* bar_, std::function open_settings_) + : QWidget(bar_), bar{bar_}, open_settings{std::move(open_settings_)} { + setAttribute(Qt::WA_NoSystemBackground); + // (A dark theme gives a plain widget a solid background.) By name, or the chips' + // tooltip takes the rule too and shows as a black box under gamescope + setObjectName(QStringLiteral("playerChips")); + setStyleSheet(QStringLiteral("#playerChips { background: transparent; }")); + setCursor(Qt::PointingHandCursor); + long_press.setSingleShot(true); + long_press.setInterval(400); + QObject::connect(&long_press, &QTimer::timeout, this, [this] { + if (pressed >= 0) { + dragging = true; + setCursor(Qt::ClosedHandCursor); + update(); + } + }); + QObject::connect(&poll, &QTimer::timeout, this, [this] { Refresh(); }); + poll.start(100); + hide(); + } + +protected: + void paintEvent(QPaintEvent*) override { + QPainter painter{this}; + painter.setRenderHint(QPainter::Antialiasing); + QFont font = bar->font(); + font.setBold(true); + painter.setFont(font); + const bool several = rows.size() > 1; + const auto chip = [&](const Common::ControllerConsoleState::PlayerRow& row, QRectF rect, + int k) { + const QColor color = ConfigurePlayerAssignment::PlayerColor(row.player); + // Filled while an input is held, else outlined; blinking green after a swap + const qint64 since = QDateTime::currentMSecsSinceEpoch() - row.flash_since; + const bool blink = row.flash == 1 && since < 1500 && (since / 125) % 2 == 0; + const bool lit = row.active || blink; + if (lit) { + painter.setPen(Qt::NoPen); + painter.setBrush(blink ? QColor(45, 160, 80) : color); + } else { + painter.setPen(QPen(color, 1.5)); + painter.setBrush(Qt::NoBrush); + } + painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2); + const QColor ink = lit ? QColor(Qt::white) : color; + // The battery's percent, then its icon, at the right end + QRectF label = rect; + if (row.battery >= 0 && batteries) { + const QRectF cell(rect.right() - (several ? 16 : 6) - battery_w, + rect.center().y() - 6, battery_w, 12); + painter.drawPixmap(cell.topLeft(), + ConfigurePlayerAssignment::Battery(row.battery, row.charging)); + const QRectF percent(cell.left() - 2 - percent_w, rect.top(), percent_w, + rect.height()); + painter.save(); + QFont percent_font = painter.font(); + percent_font.setPointSizeF(percent_font.pointSizeF() * 0.85); + painter.setFont(percent_font); + painter.setPen(ink); + painter.drawText(percent, Qt::AlignVCenter | Qt::AlignRight, + QStringLiteral("%1%").arg(row.battery)); + painter.restore(); + label.setRight(percent.left() + (several ? 16 : 6)); + } + // With other players around: the grip, and the arrows swapping with a neighbour + painter.setPen(ink); + painter.drawText(label, Qt::AlignCenter, + several ? QStringLiteral("\u283F P%1").arg(row.player + 1) + : QStringLiteral("P%1").arg(row.player + 1)); + if (several) { + const auto arrow = [&](QRectF area, bool left, bool enabled) { + QColor fill = ink; + fill.setAlpha(enabled ? 255 : 70); + painter.setPen(Qt::NoPen); + painter.setBrush(fill); + const QPointF c = area.center(); + const qreal s = 3.5; + const QPointF tri[3] = {left ? QPointF(c.x() - s, c.y()) + : QPointF(c.x() + s, c.y()), + left ? QPointF(c.x() + s * 0.7, c.y() - s) + : QPointF(c.x() - s * 0.7, c.y() - s), + left ? QPointF(c.x() + s * 0.7, c.y() + s) + : QPointF(c.x() - s * 0.7, c.y() + s)}; + painter.drawPolygon(tri, 3); + }; + arrow(LeftArrow(rect), true, k > 0); + arrow(RightArrow(rect), false, k + 1 < static_cast(rows.size())); + } + }; + for (std::size_t k = 0; k < rows.size(); ++k) { + const QRectF rect = ChipRect(static_cast(k)); + if (dragging && static_cast(k) == pressed) { + // Where the dragged chip came from + QColor edge = ConfigurePlayerAssignment::PlayerColor(rows[k].player); + edge.setAlpha(100); + painter.setPen(QPen(edge, 1, Qt::DashLine)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2); + continue; + } + chip(rows[k], rect, static_cast(k)); + } + if (dragging && pressed >= 0 && pressed < static_cast(rows.size())) { + chip(rows[static_cast(pressed)], ChipRect(pressed).translated(drag_dx, 0), + pressed); + } + } + + void mousePressEvent(QMouseEvent* event) override { + if (event->button() != Qt::LeftButton) { + return; + } + pressed = IndexAt(event->position().x()); + press_x = event->position().x(); + drag_dx = 0; + dragging = false; + // (Nothing to reorder with one player) + if (pressed >= 0 && rows.size() > 1) { + long_press.start(); + } + } + + void mouseMoveEvent(QMouseEvent* event) override { + if (dragging) { + drag_dx = event->position().x() - press_x; + update(); + } + } + + void mouseReleaseEvent(QMouseEvent* event) override { + long_press.stop(); + const int index = pressed; + pressed = -1; + setCursor(Qt::PointingHandCursor); + if (index < 0 || index >= static_cast(rows.size())) { + dragging = false; + return; + } + if (dragging) { + dragging = false; + // The players in the order of their chips, the dragged one where it was let go + std::vector> centres; + for (std::size_t k = 0; k < rows.size(); ++k) { + const double x = ChipRect(static_cast(k)).center().x() + + (static_cast(k) == index ? drag_dx : 0.0); + centres.emplace_back(x, rows[k].player); + } + std::stable_sort(centres.begin(), centres.end()); + std::vector order; + for (const auto& [x, player] : centres) { + order.push_back(player); + } + RequestPlayerOrder(order); + update(); + return; + } + if (IndexAt(event->position().x()) != index) { + return; + } + // An arrow: swap with the neighbouring chip's player + if (rows.size() > 1) { + const QRectF rect = ChipRect(index); + const QPointF at = event->position(); + const int step = LeftArrow(rect).contains(at) ? -1 + : RightArrow(rect).contains(at) ? 1 + : 0; + if (step != 0) { + const int other = index + step; + if (other >= 0 && other < static_cast(rows.size())) { + const int a = rows[static_cast(index)].player; + const int b = rows[static_cast(other)].player; + LOG_INFO(Frontend, "console-mode: menu chip swap P{} <-> P{}", a + 1, b + 1); + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.swap_requests.emplace_back(a, b); + } + return; + } + } + // A click: the controller rumbles (vibration on), and its settings open + const int player = rows[static_cast(index)].player; + if (Settings::values.vibration_enabled.GetValue() && + Settings::values.players.GetValue()[player].vibration_enabled) { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.rumble_requests.push_back(player); + } + QTimer::singleShot(0, this, [this, player] { open_settings(player); }); + } + + bool event(QEvent* event) override { + if (event->type() == QEvent::ToolTip) { + const auto* help = static_cast(event); + const int index = IndexAt(help->pos().x()); + if (index >= 0) { + const auto& row = rows[static_cast(index)]; + const QString battery = + row.battery < 0 ? QString{} + : row.charging ? QObject::tr("\nBattery %1% (charging)").arg(row.battery) + : QObject::tr("\nBattery %1%").arg(row.battery); + QToolTip::showText( + help->globalPos(), + (rows.size() > 1 + ? QObject::tr("P%1: %2\nClick: rumble and open its settings\nArrows, " + "or hold then drag: change the player order") + .arg(row.player + 1) + .arg(QString::fromStdString(row.name)) + : QObject::tr("P%1: %2\nClick: rumble and open its settings") + .arg(row.player + 1) + .arg(QString::fromStdString(row.name))) + + battery, + this); + } else { + QToolTip::hideText(); + } + return true; + } + return QWidget::event(event); + } + +private: + static constexpr int chip_w = 34; + /// With arrows and the grip, when there are several players + static constexpr int chip_w_full = 74; + static constexpr int chip_gap = 4; + /// A chip's battery, when its controller tells its charge (and the room it takes) + static constexpr int battery_w = 20; + /// Its percent after it ("100%" fits) + static constexpr int percent_w = 28; + static constexpr int battery_room = battery_w + percent_w + 4; + + /// A chip's width: wider with arrows and the grip (several players), and with a battery + static int ChipWidth(const std::vector& among, + std::size_t k, bool with_batteries) { + return (among.size() > 1 ? chip_w_full : chip_w) + + (with_batteries && among[k].battery >= 0 ? battery_room : 0); + } + + QRectF ChipRect(int k) const { + double x = 4; + for (int j = 0; j < k; ++j) { + x += ChipWidth(rows, static_cast(j), batteries) + chip_gap; + } + return QRectF(x, 3, ChipWidth(rows, static_cast(k), batteries), + height() - 6); + } + + static QRectF LeftArrow(const QRectF& chip) { + return QRectF(chip.left() + 2, chip.top(), 14, chip.height()); + } + + static QRectF RightArrow(const QRectF& chip) { + return QRectF(chip.right() - 16, chip.top(), 14, chip.height()); + } + + int IndexAt(double x) const { + for (int k = 0; k < static_cast(rows.size()); ++k) { + if (ChipRect(k) + .adjusted(-chip_gap / 2.0, 0, chip_gap / 2.0, 0) + .contains(x, height() / 2.0)) { + return k; + } + } + return -1; + } + + void Refresh() { + std::vector now; + bool engine_active = false; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + engine_active = state.engine_active; + for (const auto& row : state.players) { + if (!row.leaving) { + now.push_back(row); + } + } + } + const auto mode = Settings::values.controller_applet_mode.GetValue(); + const bool automatic = mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode; + // Right after the last menu + int x = 0; + for (QAction* action : bar->actions()) { + if (action->isVisible()) { + const QRect place = bar->actionGeometry(action); + if (place.isValid()) { + x = (std::max)(x, place.right()); + } + } + } + // (Batteries go when the chips would run past the menu bar; they stay in the tooltips) + const auto chips_width = [&](bool with_batteries) { + int w = 8; + for (std::size_t k = 0; k < now.size(); ++k) { + w += ChipWidth(now, k, with_batteries) + chip_gap; + } + return w; + }; + const bool fit = x + 10 + chips_width(true) <= bar->width(); + if (fit != batteries) { + batteries = fit; + update(); + } + const int chips_w = chips_width(batteries); + const QRect place(x + 10, 0, chips_w, bar->height()); + if (geometry() != place) { + setGeometry(place); + } + const bool shown = automatic && engine_active && !now.empty(); + if (isVisible() != shown) { + setVisible(shown); + } + // Repainted when a player changes and while one blinks (not while dragging) + const auto same = [](const auto& a, const auto& b) { + return a.player == b.player && a.active == b.active && a.name == b.name && + a.flash == b.flash && a.flash_since == b.flash_since && + a.battery == b.battery && a.charging == b.charging; + }; + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + const bool blinking = std::any_of(now.begin(), now.end(), [now_ms](const auto& r) { + return r.flash != 0 && now_ms - r.flash_since < 1600; + }); + if (!dragging && (blinking || now.size() != rows.size() || + !std::equal(now.begin(), now.end(), rows.begin(), same))) { + rows = std::move(now); + update(); + } + } + + QMenuBar* bar; + std::function open_settings; + std::vector rows; + /// The chips show their batteries (they fit in the menu bar with them) + bool batteries = true; + QTimer poll; + QTimer long_press; + int pressed = -1; + double press_x = 0; + double drag_dx = 0; + bool dragging = false; +}; + +} // Anonymous namespace + MainWindow::MainWindow(bool has_broken_vulkan) : ui{std::make_unique()}, input_subsystem{std::make_shared()}, user_data_migrator{this} { @@ -414,6 +836,8 @@ MainWindow::MainWindow(bool has_broken_vulkan) SetDefaultUIGeometry(); RestoreUIState(); + FitToScreenUnderGamescope(this); + Gamescope::FitDialogsAndMenus(this); ConnectMenuEvents(); ConnectWidgetEvents(); @@ -459,6 +883,11 @@ MainWindow::MainWindow(bool has_broken_vulkan) mouse_hide_timer.setInterval(default_mouse_hide_timeout); connect(&mouse_hide_timer, &QTimer::timeout, this, &MainWindow::HideMouseCursor); connect(ui->menubar, &QMenuBar::hovered, this, &MainWindow::ShowMouseCursor); + // The players, after the last menu, in the automatic modes + new PlayerChips(ui->menubar, [this](int player) { + open_player_tab = player; + OnConfigure(); + }); update_input_timer.setInterval(default_input_update_timeout); connect(&update_input_timer, &QTimer::timeout, this, &MainWindow::UpdateInputDrivers); @@ -538,6 +967,9 @@ MainWindow::MainWindow(bool has_broken_vulkan) } MainWindow::~MainWindow() { + // The config saves itself on destruction: it must write the user's own players + RestoreConsoleProfile(); + // will get automatically deleted otherwise if (render_window->parent() == nullptr) { delete render_window; @@ -594,7 +1026,7 @@ void MainWindow::ControllerSelectorReconfigureControllers( // Don't forget to apply settings. QtCommon::system->HIDCore().DisableAllControllerConfiguration(); QtCommon::system->ApplySettings(); - config->SaveAllValues(); + SaveAllConfigValues(); UpdateStatusButtons(); @@ -906,6 +1338,7 @@ void MainWindow::WebBrowserRequestExit() { void MainWindow::InitializeWidgets() { render_window = new GRenderWindow(this, input_subsystem); + render_window->installEventFilter(this); render_window->hide(); game_list = new GameList(QtCommon::vfs, QtCommon::provider.get(), *play_time_manager, @@ -1277,6 +1710,8 @@ void MainWindow::InitializeHotkeys() { LinkActionShortcut(ui->action_Show_Status_Bar, QStringLiteral("Toggle Status Bar")); LinkActionShortcut(ui->action_Show_Performance_Overlay, QStringLiteral("Toggle Performance Overlay")); + LinkActionShortcut(ui->action_Show_Controller_Overlay, + QStringLiteral("Toggle Controller Overlay")); LinkActionShortcut(ui->action_Fullscreen, QStringLiteral("Fullscreen")); LinkActionShortcut(ui->action_Capture_Screenshot, QStringLiteral("Capture Screenshot")); LinkActionShortcut(ui->action_TAS_Start, QStringLiteral("TAS Start/Stop"), true); @@ -1385,6 +1820,9 @@ void MainWindow::RestoreUIState() { ui->action_Show_Performance_Overlay->setChecked( UISettings::values.show_perf_overlay.GetValue()); + ui->action_Show_Controller_Overlay->setChecked( + Settings::values.console_debug_overlay.GetValue()); + ui->action_Show_Controller_Hints->setChecked(Settings::values.console_hints.GetValue()); if (perf_overlay) perf_overlay->setVisible(ui->action_Show_Performance_Overlay->isChecked()); Debugger::ToggleConsole(); @@ -1462,6 +1900,11 @@ void MainWindow::ConnectWidgetEvents() { connect(this, &MainWindow::EmulationStopping, this, &MainWindow::SoftwareKeyboardExit); connect(this, &MainWindow::EmulationStarting, this, &MainWindow::RefreshRuntimeCheats); connect(this, &MainWindow::EmulationStopping, this, &MainWindow::ClearRuntimeCheats); + connect(&console_mode_timer, &QTimer::timeout, this, &MainWindow::ApplyConsoleControllerMode); + connect(&console_activity_timer, &QTimer::timeout, this, &MainWindow::PollControllerActivity); + console_activity_timer.start(std::chrono::milliseconds{40}); + // 250ms: instant to the user, negligible cost (the input reload only runs on changes) + console_mode_timer.start(std::chrono::milliseconds{250}); connect(&status_bar_update_timer, &QTimer::timeout, this, &MainWindow::UpdateStatusBar); @@ -1518,7 +1961,5178 @@ void MainWindow::ClearRuntimeCheats() { ui->menu_Cheats->menuAction()->setVisible(false); } +namespace { + +/// The joystick API is shared with the input driver's threads (the rumble worker); +/// SDL_LockJoysticks is the official guard for calling into it elsewhere. +struct JoystickApiLock { + JoystickApiLock() { + SDL_LockJoysticks(); + } + ~JoystickApiLock() { + SDL_UnlockJoysticks(); + } +}; + +/// Calls `use` with the pad opened as a joystick for the call (under the lock); `fallback` when it +/// can't be opened +template +T WithJoystick(SDL_JoystickID id, T fallback, Use&& use) { + const JoystickApiLock lock; + SDL_Joystick* js = SDL_OpenJoystick(id); + if (!js) { + return fallback; + } + T result = use(js); + SDL_CloseJoystick(js); + return result; +} + +/// Calls `use` with the pad opened as a gamepad for the call (under the lock); `fallback` when it +/// can't be opened +template +T WithGamepad(SDL_JoystickID id, T fallback, Use&& use) { + const JoystickApiLock lock; + SDL_Gamepad* pad = SDL_OpenGamepad(id); + if (!pad) { + return fallback; + } + T result = use(pad); + SDL_CloseGamepad(pad); + return result; +} + +/// A pad's stable identity: its hardware serial when it reports one, else its model GUID (which +/// two pads can share: slots are keyed by connection, this only matches returning pads) +std::string PadIdentity(SDL_JoystickID id) { + const JoystickApiLock lock; + SDL_Joystick* js = SDL_OpenJoystick(id); + if (!js) { + return {}; + } + const char* serial = SDL_GetJoystickSerial(js); + if (serial && serial[0]) { + std::string out{"serial:"}; + out += serial; + SDL_CloseJoystick(js); + return out; + } + const SDL_GUID guid = SDL_GetJoystickGUID(js); + SDL_CloseJoystick(js); + char buf[33]{}; + for (int i = 0; i < 16; ++i) { + unsigned byte = guid.data[i]; + // Bytes 2-3 are a CRC of the name, which Steam changes on the same pad: zeroed, as + // SDLDriver::GetGUID does + if (i == 2 || i == 3) { + byte = 0; + } + std::snprintf(buf + i * 2, 3, "%02x", byte); + } + return std::string{"guid:"} + buf; +} + +/// What Steam says is in one of its virtual pads, from the file it writes for the game. SDL +/// reads it at most every 3 seconds, so a controller Steam just moved showed as the old one. +struct SteamSlot { + Uint64 handle = 0; + Uint16 vendor = 0; + Uint16 product = 0; + SDL_GamepadType type = SDL_GAMEPAD_TYPE_UNKNOWN; + QString name; +}; + +std::optional SteamSlotOf(SDL_JoystickID id); + +/// A pad's display name +QString PadName(SDL_JoystickID id) { + // (Steam names the Deck's controls with a translation key: not a name to show) + const auto slot = SteamSlotOf(id); + if (slot && !slot->name.isEmpty() && !slot->name.startsWith(QLatin1Char('#'))) { + return slot->name; + } + const Uint16 vendor = slot ? slot->vendor : SDL_GetJoystickVendorForID(id); + const Uint16 product = slot ? slot->product : SDL_GetJoystickProductForID(id); + if (vendor == 0x28DE && product == 0x1205) { + return QStringLiteral("Steam Deck"); + } + return WithJoystick(id, QStringLiteral("?"), [](SDL_Joystick* js) { + const char* name = SDL_GetJoystickName(js); + return name ? QString::fromUtf8(name) : QStringLiteral("?"); + }); +} + +/// True for Steam Input's virtual pads, whose GUID and serial aren't stable (Steam re-binds them) +bool IsSteamVirtualPad(SDL_JoystickID id) { + if (!Common::RunningUnderSteam()) { + return false; + } + // Every virtual pad's GUID carries Valve's vendor (0x28DE, bytes 4-5); the name isn't + // usable (one pad shows as "SteamDeck" and "Xbox" in a session) + // Valve makes real controllers too: the virtual gamepad product (0x11FF, bytes 8-9) marks them + return WithJoystick(id, false, [](SDL_Joystick* js) { + const SDL_GUID guid = SDL_GetJoystickGUID(js); + return guid.data[4] == 0xDE && guid.data[5] == 0x28 && guid.data[8] == 0xFF && + guid.data[9] == 0x11; + }); +} + +/// The OS device node of a pad (e.g. /dev/input/event5); empty where unsupported +QString PadPath(SDL_JoystickID id) { + return WithJoystick(id, QString{}, [](SDL_Joystick* js) { + const char* path = SDL_GetJoystickPath(js); + return path ? QString::fromUtf8(path) : QString{}; + }); +} + +/// A pad's slot as Steam says now (nothing outside Steam's pads); reread when Steam writes it +std::optional SteamSlotOf(SDL_JoystickID id) { +#ifdef __linux__ + static const QByteArray file = qgetenv("SteamVirtualGamepadInfo"); + if (file.isEmpty()) { + return std::nullopt; + } + // The pad's slot: Steam names its device "Microsoft X-Box 360 pad " (read once a pad) + static std::map slot_of_pad; + auto known = slot_of_pad.find(id); + if (known == slot_of_pad.end()) { + const QString node = QFileInfo{PadPath(id)}.fileName(); + if (node.isEmpty()) { + return std::nullopt; + } + int slot = -1; + QFile name_file{QStringLiteral("/sys/class/input/%1/device/name").arg(node)}; + if (name_file.open(QIODevice::ReadOnly)) { + const QString name = QString::fromUtf8(name_file.readAll()).trimmed(); + const QString prefix = QStringLiteral("Microsoft X-Box 360 pad "); + bool ok = false; + const int number = name.startsWith(prefix) ? name.mid(prefix.size()).toInt(&ok) : -1; + slot = ok ? number : -1; + } + known = slot_of_pad.emplace(id, slot).first; + } + if (known->second < 0) { + return std::nullopt; + } + // The file, read again when Steam writes it + static std::map said; + static timespec said_at{}; + struct ::stat st {}; + if (::stat(file.constData(), &st) != 0) { + return std::nullopt; + } + if (st.st_mtim.tv_sec != said_at.tv_sec || st.st_mtim.tv_nsec != said_at.tv_nsec) { + QFile in{QString::fromLocal8Bit(file)}; + if (!in.open(QIODevice::ReadOnly)) { + return std::nullopt; + } + said_at = st.st_mtim; + said.clear(); + SteamSlot* current = nullptr; + for (const QByteArray& raw : in.readAll().split('\n')) { + const QByteArray line = raw.trimmed(); + if (line.startsWith("[slot ") && line.endsWith(']')) { + current = &said[line.mid(6, line.size() - 7).toInt()]; + continue; + } + const auto at = line.indexOf('='); + if (!current || at < 0) { + continue; + } + const QByteArray key = line.left(at); + const QByteArray value = line.mid(at + 1); + if (key == "handle") { + current->handle = value.toULongLong(nullptr, 16); + } else if (key == "VID") { + current->vendor = static_cast(value.toUInt(nullptr, 16)); + } else if (key == "PID") { + current->product = static_cast(value.toUInt(nullptr, 16)); + } else if (key == "type") { + current->type = SDL_GetGamepadTypeFromString(value.constData()); + } else if (key == "name") { + current->name = QString::fromUtf8(value); + } + } + } + if (const auto slot = said.find(known->second); slot != said.end()) { + return slot->second; + } +#endif + return std::nullopt; +} + +/// A pad's USB vendor id (under Steam, its controller's, as Steam says now) +Uint16 PadVendor(SDL_JoystickID id) { + if (const auto slot = SteamSlotOf(id)) { + return slot->vendor; + } + return SDL_GetJoystickVendorForID(id); +} + +/// A pad's USB product id (under Steam, its controller's, as Steam says now) +Uint16 PadProduct(SDL_JoystickID id) { + if (const auto slot = SteamSlotOf(id)) { + return slot->product; + } + return SDL_GetJoystickProductForID(id); +} + +/// A pad's gamepad type (under Steam, its controller's, as Steam says now) +SDL_GamepadType PadType(SDL_JoystickID id) { + if (const auto slot = SteamSlotOf(id)) { + return slot->type; + } + return SDL_GetGamepadTypeForID(id); +} + +#ifdef __linux__ +/// The kernel's HID entry for a controller (bus, vendor, product, address), from its uevent +struct HidEntry { + unsigned bus = 0; + unsigned vendor = 0; + unsigned product = 0; + std::string address; +}; + +std::optional ReadHidEntry(const QString& dir) { + QFile file{dir + QStringLiteral("/uevent")}; + if (dir.isEmpty() || !file.open(QIODevice::ReadOnly)) { + return std::nullopt; + } + HidEntry entry; + bool has_id = false; + for (const QByteArray& line : file.readAll().split('\n')) { + if (line.startsWith("HID_ID=")) { + const QList parts = line.mid(7).split(':'); + if (parts.size() == 3) { + entry.bus = parts[0].toUInt(nullptr, 16); + entry.vendor = parts[1].toUInt(nullptr, 16); + entry.product = parts[2].toUInt(nullptr, 16); + has_id = true; + } + } else if (line.startsWith("HID_UNIQ=")) { + entry.address = line.mid(9).trimmed().toLower().toStdString(); + } + } + return has_id ? std::optional{entry} : std::nullopt; +} + +/// A controller as the kernel has it: address (or HID entry), Bluetooth or not, input nodes +struct RealController { + std::string key; + std::string address; + bool bluetooth = false; + std::vector events; +}; + +constexpr unsigned BusBluetooth = 0x5; + +/// The controllers of a model (one controller's several HID entries share its address) +std::vector ControllersOfModel(unsigned vendor, unsigned product) { + std::map found; + const QDir devices{QStringLiteral("/sys/bus/hid/devices")}; + for (const QString& name : devices.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + const QString dir = devices.filePath(name); + const auto entry = ReadHidEntry(dir); + if (!entry || entry->vendor != vendor || entry->product != product) { + continue; + } + const std::string key = entry->address.empty() ? name.toStdString() : entry->address; + auto& controller = found[key]; + controller.key = key; + controller.address = entry->address; + controller.bluetooth = entry->bus == BusBluetooth && entry->address.size() == 17; + const QDir inputs{dir + QStringLiteral("/input")}; + for (const QString& input : inputs.entryList({QStringLiteral("input*")}, QDir::Dirs)) { + const QDir nodes{inputs.filePath(input)}; + for (const QString& event : nodes.entryList({QStringLiteral("event*")}, QDir::Dirs)) { + controller.events.push_back(QStringLiteral("/dev/input/") + event); + } + } + } + std::vector out; + for (auto& [key, controller] : found) { + out.push_back(std::move(controller)); + } + return out; +} + +/// Whether a pad's controller is on Bluetooth, and its address: from the HID device behind the +/// pad, else (under Steam) the only controller of its model. Unsure with several of the model +/// until a button press tells them apart, so Turn Off never hits the wrong one. +struct PadBluetooth { + std::string address; + bool unsure = false; +}; + +PadBluetooth FindPadBluetooth(SDL_JoystickID id) { + const QString node = QFileInfo{PadPath(id)}.fileName(); + if (!node.isEmpty()) { + const QString dir = + node.startsWith(QStringLiteral("hidraw")) + ? QFileInfo{QStringLiteral("/sys/class/hidraw/%1/device").arg(node)} + .canonicalFilePath() + : QFileInfo{QStringLiteral("/sys/class/input/%1/device/device").arg(node)} + .canonicalFilePath(); + if (const auto entry = ReadHidEntry(dir)) { + const bool bluetooth = entry->bus == BusBluetooth && entry->address.size() == 17; + return {bluetooth ? entry->address : std::string{}}; + } + } + const auto controllers = + ControllersOfModel(PadVendor(id), PadProduct(id)); + const bool any_bluetooth = std::any_of(controllers.begin(), controllers.end(), + [](const auto& c) { return c.bluetooth; }); + if (controllers.size() == 1) { + return {controllers[0].bluetooth ? controllers[0].address : std::string{}}; + } + return {{}, any_bluetooth}; +} + +/// How long a press read from a controller's input node counts (checks come every 100ms) +constexpr s64 RealPressWindowUs = 300'000; + +/// True while a controller has an input held, or had one in the last 300ms (a quick tap falls +/// between checks), read from its input nodes +bool RealButtonHeld(const RealController& controller) { + static std::map open_nodes; + // (Each node's axes, read when it is opened: their ranges, and where they rest then) + static std::map, input_absinfo> ranges; + // (Each node's last press, by the kernel's timestamp) + static std::map pressed_at_us; + timespec real{}; + clock_gettime(CLOCK_REALTIME, &real); + const s64 now_us = static_cast(real.tv_sec) * 1'000'000 + real.tv_nsec / 1000; + for (const QString& event : controller.events) { + auto it = open_nodes.find(event); + if (it == open_nodes.end()) { + const int fd = + ::open(event.toLocal8Bit().constData(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); + // (A node that can't be opened yet, as it connects, is tried again next time) + if (fd < 0) { + continue; + } + it = open_nodes.emplace(event, fd).first; + for (u16 code = 0; code <= ABS_MAX; ++code) { + input_absinfo info{}; + if (ioctl(fd, EVIOCGABS(code), &info) == 0 && info.maximum > info.minimum) { + ranges[{event, code}] = info; + } + } + } + if (it->second < 0) { + continue; + } + // Its queue: the presses since the last check (and drained, so the kernel keeps nothing) + std::array events; + ssize_t got = 0; + while ((got = ::read(it->second, events.data(), sizeof(events))) > 0) { + for (std::size_t k = 0; k < static_cast(got) / sizeof(input_event); ++k) { + const input_event& e = events[k]; + bool pressed = e.type == EV_KEY && e.value == 1; + if (const auto range = ranges.find({event, e.code}); + e.type == EV_ABS && range != ranges.end()) { + // A quarter of its range away from rest + const auto& info = range->second; + const double moved = std::abs(static_cast(e.value - info.value)) / + static_cast(info.maximum - info.minimum); + pressed = moved > 0.25; + } + if (pressed) { + pressed_at_us[event] = static_cast(e.input_event_sec) * 1'000'000 + + static_cast(e.input_event_usec); + } + } + } + if (const auto pressed = pressed_at_us.find(event); + pressed != pressed_at_us.end() && now_us - pressed->second < RealPressWindowUs) { + return true; + } + std::array keys{}; + if (ioctl(it->second, EVIOCGKEY(keys.size()), keys.data()) < 0) { + ::close(it->second); + open_nodes.erase(it); + std::erase_if(ranges, [&](const auto& e) { return e.first.first == event; }); + continue; + } + if (std::any_of(keys.begin(), keys.end(), [](unsigned char b) { return b != 0; })) { + return true; + } + } + return false; +} + +/// A controller's BlueZ object paths, one per Bluetooth adapter +QStringList BluezDevicePaths(const std::string& address) { + QString device = QString::fromStdString(address).toUpper(); + device.replace(QLatin1Char(':'), QLatin1Char('_')); + QStringList paths; + const QDir adapters{QStringLiteral("/sys/class/bluetooth")}; + for (const QString& adapter : adapters.entryList({QStringLiteral("hci*")}, QDir::AllEntries)) { + if (!adapter.contains(QLatin1Char(':'))) { // (hci0:12 is a connection, not an adapter) + paths.push_back(QStringLiteral("/org/bluez/%1/dev_%2").arg(adapter, device)); + } + } + return paths; +} + +/// Asks BlueZ for a property of a controller without waiting: `answer` gets it on the GUI +/// thread (while `context` lives), or never +void AskBluezProperty(const std::string& address, const QString& interface, + const QString& property, QObject* context, + std::function answer) { + QDBusConnection bus = QDBusConnection::systemBus(); + if (!bus.isConnected()) { + return; + } + for (const QString& path : BluezDevicePaths(address)) { + QDBusMessage get = QDBusMessage::createMethodCall( + QStringLiteral("org.bluez"), path, QStringLiteral("org.freedesktop.DBus.Properties"), + QStringLiteral("Get")); + get << interface << property; + auto* watcher = new QDBusPendingCallWatcher(bus.asyncCall(get, 1000)); + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, context, + [answer](QDBusPendingCallWatcher* w) { + const QDBusPendingReply reply = *w; + if (!reply.isError()) { + answer(reply.value().variant()); + } + w->deleteLater(); + }); + } +} + +/// Asks BlueZ whether a controller is connected, without waiting (see AskBluezProperty) +void AskBluezConnected(const std::string& address, QObject* context, + std::function answer) { + AskBluezProperty(address, QStringLiteral("org.bluez.Device1"), QStringLiteral("Connected"), + context, [answer](const QVariant& value) { answer(value.toBool()); }); +} + +/// Asks BlueZ to disconnect a controller, which turns most controllers off +void DisconnectBluetooth(const std::string& address) { + QDBusConnection bus = QDBusConnection::systemBus(); + if (!bus.isConnected()) { + LOG_WARNING(Frontend, "console-mode: no system bus to disconnect {}", address); + return; + } + for (const QString& path : BluezDevicePaths(address)) { + const QDBusMessage call = QDBusMessage::createMethodCall( + QStringLiteral("org.bluez"), path, QStringLiteral("org.bluez.Device1"), + QStringLiteral("Disconnect")); + auto* watcher = new QDBusPendingCallWatcher(bus.asyncCall(call)); + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, [address, path](auto* w) { + const QDBusPendingReply<> reply = *w; + if (reply.isError()) { + if (reply.error().type() != QDBusError::UnknownObject) { + LOG_WARNING(Frontend, "console-mode: disconnect {} failed: {}", address, + reply.error().message().toStdString()); + } + } else { + LOG_INFO(Frontend, "console-mode: disconnected {} ({})", address, + path.toStdString()); + } + w->deleteLater(); + }); + } +} +#endif + +/// True while any button of a pad is held: the Identify handshake reads this. +bool PadAnyButtonPressed(SDL_JoystickID id) { + return WithJoystick(id, false, [](SDL_Joystick* js) { + const int count = SDL_GetNumJoystickButtons(js); + for (int i = 0; i < count; ++i) { + if (SDL_GetJoystickButton(js, i)) { + return true; + } + } + return false; + }); +} + +/// True while a pad's + (Start, Menu, Options) is held +bool PadStartHeld(SDL_JoystickID id) { + return WithGamepad(id, false, [](SDL_Gamepad* pad) { + return SDL_GetGamepadButton(pad, SDL_GAMEPAD_BUTTON_START); + }); +} + +/// Steam's own player index for a pad (-1 when not assigned by Steam Input). +int PadPlayerIndex(SDL_JoystickID id) { + return WithJoystick(id, -1, [](SDL_Joystick* js) { return SDL_GetJoystickPlayerIndex(js); }); +} + +/// True when SDL reports a gyro on the pad +bool PadHasGyro(SDL_JoystickID id) { + return WithGamepad(id, false, + [](SDL_Gamepad* pad) { return SDL_GamepadHasSensor(pad, SDL_SENSOR_GYRO); }); +} + +/// True while a pad's accelerometer reads nothing (gravity always registers when on) +bool PadMotionSilent(SDL_JoystickID id) { + return WithGamepad(id, false, [](SDL_Gamepad* pad) { + std::array accel{}; + return SDL_GetGamepadSensorData(pad, SDL_SENSOR_ACCEL, accel.data(), 3) && + accel[0] == 0.0f && accel[1] == 0.0f && accel[2] == 0.0f; + }); +} + +/// Asks the Steam Deck's controller to turn its motion sensors on (Steam keeps them off unless +/// its layout uses the gyro): IMU mode 0x30 = 0x18 in feature report 0x87, as SDL sends it, on +/// a thread of its own +void EnableDeckMotion(const QString& path) { +#ifdef __linux__ + static std::atomic busy{false}; + if (!path.startsWith(QStringLiteral("/dev/hidraw")) || busy.exchange(true)) { + return; + } + std::thread([node = path.toStdString()] { + const int fd = ::open(node.c_str(), O_RDWR | O_CLOEXEC); + if (fd >= 0) { + std::array report{0x00, 0x87, 0x03, 0x30, 0x18, 0x00}; + if (ioctl(fd, HIDIOCSFEATURE(report.size()), report.data()) < 0 && + Common::ControllerProbes()) { + LOG_WARNING(Frontend, "console-mode: deck motion request failed on {}", node); + } + ::close(fd); + } + busy = false; + }).detach(); +#endif +} + +/// The controller applet modes, as the log names them +const char* AppletModeName(Settings::AppletMode mode) { + switch (mode) { + case Settings::AppletMode::HLE: + return "Custom Frontend"; + case Settings::AppletMode::LLE: + return "Real Hardware"; + case Settings::AppletMode::Disabled: + return "Disabled"; + case Settings::AppletMode::ConsoleMode: + return "Modern Console"; + case Settings::AppletMode::HandheldConsoleMode: + return "Handheld Console"; + default: + return "unknown"; + } +} + +/// A controller type, as the log names it +const char* StyleName(Core::HID::NpadStyleIndex style) { + using Core::HID::NpadStyleIndex; + switch (style) { + case NpadStyleIndex::Fullkey: + return "Pro Controller"; + case NpadStyleIndex::Handheld: + return "Handheld"; + case NpadStyleIndex::JoyconDual: + return "Dual Joycons"; + case NpadStyleIndex::JoyconLeft: + return "Left Joycon"; + case NpadStyleIndex::JoyconRight: + return "Right Joycon"; + case NpadStyleIndex::GameCube: + return "GameCube"; + default: + return "other"; + } +} + +/// Whether a game's supported controller types include one (as hid_core decides it) +bool StyleSupported(Core::HID::NpadStyleIndex style, const Core::HID::NpadStyleTag& tag) { + using Core::HID::NpadStyleIndex; + switch (style) { + case NpadStyleIndex::Fullkey: + return tag.fullkey; + case NpadStyleIndex::Handheld: + return tag.handheld; + case NpadStyleIndex::JoyconDual: + return tag.joycon_dual; + case NpadStyleIndex::JoyconLeft: + return tag.joycon_left; + case NpadStyleIndex::JoyconRight: + return tag.joycon_right; + case NpadStyleIndex::GameCube: + return tag.gamecube; + case NpadStyleIndex::Pokeball: + return tag.palma; + case NpadStyleIndex::NES: + return tag.lark; + case NpadStyleIndex::SNES: + return tag.lucia; + case NpadStyleIndex::N64: + return tag.lagoon; + case NpadStyleIndex::SegaGenesis: + return tag.lager; + default: + return false; + } +} + +/// A controller's short name, for a named player ("John (Xbox)") +QString ShortControllerName(SDL_JoystickID id, const QString& full) { + const Uint16 vendor = PadVendor(id); + const Uint16 product = PadProduct(id); + if (vendor == 0x28DE && product == 0x1205) { + return QStringLiteral("Steam Deck"); + } + // (The Steam Controller and the Steam Controller (2026), wired or through a dongle) + if (vendor == 0x28DE && (product == 0x1102 || product == 0x1142 || product == 0x1302 || + product == 0x1304 || product == 0x1305)) { + return QStringLiteral("Steam Controller"); + } + switch (PadType(id)) { + case SDL_GAMEPAD_TYPE_XBOX360: + case SDL_GAMEPAD_TYPE_XBOXONE: + return QStringLiteral("Xbox"); + case SDL_GAMEPAD_TYPE_PS3: + return QStringLiteral("DualShock 3"); + case SDL_GAMEPAD_TYPE_PS4: + return QStringLiteral("DualShock 4"); + case SDL_GAMEPAD_TYPE_PS5: + return QStringLiteral("DualSense"); + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO: + return QStringLiteral("Pro Controller"); + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR: + return QStringLiteral("Joy-Con"); + case SDL_GAMEPAD_TYPE_GAMECUBE: + return QStringLiteral("GameCube"); + default: + return full; + } +} + +/// A stick binding turned for a single Joy-Con held sideways (the right one a quarter clockwise, +/// the left one the other way); only SDL bindings turn +std::string TurnStickSideways(const std::string& raw, bool right) { + Common::ParamPackage param{raw}; + if (param.Get("engine", "") != "sdl" || !param.Has("axis_x") || !param.Has("axis_y")) { + return raw; + } + const int axis_x = param.Get("axis_x", 0); + const int axis_y = param.Get("axis_y", 1); + const std::string invert_x = param.Get("invert_x", "+"); + const std::string invert_y = param.Get("invert_y", "+"); + const auto flip = [](const std::string& sign) { + return sign == "-" ? std::string{"+"} : std::string{"-"}; + }; + param.Set("axis_x", axis_y); + param.Set("axis_y", axis_x); + // Right: its x is the push's -y, its y the push's x. Left: its x is y, its y is -x. + // (An SDL stick's y is read negated, after the axes are picked: SDL's y points down. So + // the new x, SDL's y as read, is already the push's -y, and the new y needs a flip of x; + // the left one the other way round.) + param.Set("invert_x", right ? invert_y : flip(invert_y)); + param.Set("invert_y", right ? flip(invert_x) : invert_x); + // (The centre offsets follow their axes; each is minus the axis's resting value as read, + // and the swap reads each axis with the other's sign) + if (param.Has("offset_x") || param.Has("offset_y")) { + const float offset_x = param.Get("offset_x", 0.0f); + const float offset_y = param.Get("offset_y", 0.0f); + param.Set("offset_x", -offset_y); + param.Set("offset_y", -offset_x); + } + return param.Serialize(); +} + +/// A stick binding the D-pad also moves (up, down, left, right: the D-pad buttons that push +/// the stick that way); an empty D-pad leaves the stick as it is +std::string StickWithDpad(const std::string& stick, const std::string& up, + const std::string& down, const std::string& left, + const std::string& right) { + if (up.empty() && down.empty() && left.empty() && right.empty()) { + return stick; + } + Common::ParamPackage param{{"engine", "analog_from_button"}}; + param.Set("up", up); + param.Set("down", down); + param.Set("left", left); + param.Set("right", right); + if (!stick.empty()) { + param.Set("stick", stick); + } + return param.Serialize(); +} + +/// A player's bindings for a single Joy-Con held upright: the right one's stick is the left +/// stick (the D-pad moves it too), L / ZL its SL / SR; the left one's SL / SR are R / ZR +void BindUprightJoyCon(Settings::PlayerInput& set, bool right) { + using namespace Settings::NativeButton; + const auto pad = set.buttons; + auto& out = set.buttons; + if (right) { + out[RStick] = pad[LStick]; + out[SLRight] = pad[L]; + out[SRRight] = pad[ZL]; + set.analogs[Settings::NativeAnalog::RStick] = + StickWithDpad(set.analogs[Settings::NativeAnalog::LStick], pad[DUp], pad[DDown], + pad[DLeft], pad[DRight]); + } else { + out[SLLeft] = pad[R]; + out[SRLeft] = pad[ZR]; + } +} + +/// A player's bindings for a single Joy-Con held sideways: face buttons by position, the +/// shoulders to SL / SR, the left stick to its stick (the D-pad moves it too), the motion +/// turned to the Joy-Con's own axes (a game turns a sideways Joy-Con's stick and motion itself) +void BindSidewaysJoyCon(Settings::PlayerInput& set, bool right) { + for (std::string& motion : set.motions) { + Common::ParamPackage param{motion}; + if (param.Has("motion")) { + param.Set("turn", right ? 1 : -1); + motion = param.Serialize(); + } + } + using namespace Settings::NativeButton; + const auto pad = set.buttons; + auto& out = set.buttons; + const std::string bottom = pad[B], east = pad[A], west = pad[Y], top = pad[X]; + if (right) { + // Turned a quarter clockwise: A below, X to the right, B to the left, Y on top + out[A] = bottom; + out[X] = east; + out[B] = west; + out[Y] = top; + out[SLRight] = pad[L]; + out[SRRight] = pad[R]; + out[RStick] = pad[LStick]; + // (The D-pad as the player sees it: up pushes the turned stick as the stick's up does) + set.analogs[Settings::NativeAnalog::RStick] = + StickWithDpad(TurnStickSideways(set.analogs[Settings::NativeAnalog::LStick], true), + pad[DRight], pad[DLeft], pad[DUp], pad[DDown]); + } else { + // Turned a quarter the other way + out[DLeft] = bottom; + out[DDown] = east; + out[DUp] = west; + out[DRight] = top; + out[SLLeft] = pad[L]; + out[SRLeft] = pad[R]; + set.analogs[Settings::NativeAnalog::LStick] = + StickWithDpad(TurnStickSideways(set.analogs[Settings::NativeAnalog::LStick], false), + pad[DLeft], pad[DRight], pad[DDown], pad[DUp]); + } +} + +/// A battery as Linux lists it (/sys/class/power_supply): charge and whether it charges +#ifdef __linux__ +std::optional> ReadPowerSupply(const QString& dir) { + const auto read = [&](const char* name) { + QFile file{dir + QLatin1Char('/') + QLatin1String(name)}; + return file.open(QIODevice::ReadOnly) ? QString::fromLatin1(file.readAll()).trimmed() + : QString{}; + }; + int percent = -1; + bool ok = false; + if (const int capacity = read("capacity").toInt(&ok); ok) { + percent = capacity; + } else { + const QString level = read("capacity_level"); + percent = level == QStringLiteral("Full") ? 100 + : level == QStringLiteral("High") ? 75 + : level == QStringLiteral("Normal") ? 50 + : level == QStringLiteral("Low") ? 15 + : level == QStringLiteral("Critical") ? 5 + : -1; + } + if (percent < 0) { + return std::nullopt; + } + const QString status = read("status"); + return std::pair{percent, + status == QStringLiteral("Charging") || status == QStringLiteral("Full")}; +} + +/// A Bluetooth controller's battery, by its address (hid-
-battery) +std::optional> BluetoothBattery(const std::string& address) { + const QDir supplies{QStringLiteral("/sys/class/power_supply")}; + const QString wanted = QString::fromStdString(address).toLower(); + for (const QString& name : supplies.entryList(QDir::AllEntries | QDir::NoDotAndDotDot)) { + const QString dir = supplies.filePath(name); + bool match = name.toLower().contains(wanted); + if (!match) { + if (const auto entry = + ReadHidEntry(QFileInfo{dir + QStringLiteral("/device")}.canonicalFilePath())) { + match = entry->address == address; + } + } + if (match) { + return ReadPowerSupply(dir); + } + } + return std::nullopt; +} + +/// Asks BlueZ for a Bluetooth LE controller's battery (Xbox Series controllers have no power +/// supply in Linux), without waiting: `answer` gets it on the GUI thread +void AskBluezBattery(const std::string& address, QObject* context, + std::function answer) { + AskBluezProperty(address, QStringLiteral("org.bluez.Battery1"), QStringLiteral("Percentage"), + context, [answer](const QVariant& value) { + bool ok = false; + const int percent = value.toInt(&ok); + if (ok && percent >= 0 && percent <= 100) { + answer(percent); + } + }); +} + +/// The system's own battery (the handheld's own controls run on it) +std::optional> SystemBattery() { + const QDir supplies{QStringLiteral("/sys/class/power_supply")}; + for (const QString& name : supplies.entryList({QStringLiteral("BAT*")}, QDir::AllEntries)) { + if (const auto battery = ReadPowerSupply(supplies.filePath(name))) { + return battery; + } + } + return std::nullopt; +} +#endif + +/// What SDL knows of a pad's battery: percent (-1 unknown), charging, wired +std::tuple SdlBattery(SDL_JoystickID id) { + const JoystickApiLock lock; + SDL_Joystick* js = SDL_OpenJoystick(id); + if (!js) { + return {-1, false, false}; + } + int percent = -1; + const SDL_PowerState state = SDL_GetJoystickPowerInfo(js, &percent); + SDL_CloseJoystick(js); + switch (state) { + case SDL_POWERSTATE_ON_BATTERY: + return {percent, false, false}; + case SDL_POWERSTATE_CHARGING: + case SDL_POWERSTATE_CHARGED: + return {percent, true, false}; + case SDL_POWERSTATE_NO_BATTERY: + return {-1, false, true}; + default: + return {-1, false, false}; + } +} + +/// True for Nintendo's controllers and look-alikes: their buttons already match the Switch's +bool IsNintendoPad(SDL_JoystickID id) { + switch (PadType(id)) { + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR: + case SDL_GAMEPAD_TYPE_GAMECUBE: + return true; + default: + return PadVendor(id) == 0x057E; + } +} + +/// The players controllers take in the console modes: a Switch's eight (a ninth controller +/// waits). The settings' players past them are the handheld's own and one more. +constexpr int ConsolePlayers = 8; + +/// Makers of standalone controllers: never a handheld's built-in controls, even when alone +bool IsControllerMaker(Uint16 vendor) { + switch (vendor) { + case 0x045E: // Microsoft + case 0x054C: // Sony + case 0x057E: // Nintendo + case 0x2DC8: // 8BitDo + case 0x046D: // Logitech + case 0x20D6: // PowerA + case 0x0E6F: // PDP + case 0x0F0D: // Hori + case 0x1532: // Razer + case 0x0738: // Mad Catz + case 0x146B: // Nacon + case 0x3537: // GameSir + return true; + default: + return false; + } +} + +/// Makers of handhelds with built-in controls: a pad from one is the handheld's own on a +/// direct launch +bool IsHandheldMaker(Uint16 vendor) { + return vendor == 0x28DE || vendor == 0x17EF || vendor == 0x0B05 || vendor == 0x0DB0; +} + +/// Whether a pad can be a handheld's own controls (of Valve's, only the Deck's) +bool CouldBeHandheld(Uint16 vendor, Uint16 product) { + if (vendor == 0x28DE) { + return product == 0x1205; + } + return !IsControllerMaker(vendor); +} + +/// Whether a pad is plainly a standalone controller, never a handheld's controls: Valve's other +/// controllers, controller-only makers, and wireless Xbox controllers (a wired Microsoft id +/// stays: some handhelds' controls report one, such as the ROG Xbox Ally) +bool IsPlainlyStandalone(SDL_JoystickID id) { + const Uint16 vendor = PadVendor(id); + const Uint16 product = PadProduct(id); + if (vendor == 0x28DE) { + return product != 0x1205; + } + if (vendor == 0x045E) { + switch (product) { + case 0x02E0: // Xbox One S, Bluetooth + case 0x02E6: // Xbox Wireless Adapter + case 0x02FD: // Xbox One S, Bluetooth + case 0x0719: // Xbox 360 wireless receiver + case 0x0B05: // Xbox Elite Series 2, Bluetooth + case 0x0B13: // Xbox Series X|S, Bluetooth + case 0x0B20: // Xbox Series X|S, Bluetooth + case 0x0B22: // Xbox Elite Series 2, Bluetooth + return true; + default: + break; + } + } else if (IsControllerMaker(vendor)) { + return true; + } + const JoystickApiLock lock; + SDL_Gamepad* pad = SDL_OpenGamepad(id); + if (!pad) { + return false; + } + const bool wireless = SDL_GetJoystickConnectionState(SDL_GetGamepadJoystick(pad)) == + SDL_JOYSTICK_CONNECTION_WIRELESS; + SDL_CloseGamepad(pad); + return wireless; +} + +/// True for the controller types Steam reports for standalone controllers +bool IsStandaloneControllerType(SDL_GamepadType type) { + return type != SDL_GAMEPAD_TYPE_UNKNOWN && type != SDL_GAMEPAD_TYPE_STANDARD; +} + +/// Steam Input's handle for the physical controller behind a pad (0 none): Steam refills its +/// virtual pads in its own order, so only the handle tells which controller came back +Uint64 PadSteamHandle(SDL_JoystickID id) { + // (As Steam says now: SDL's copy is up to 3 seconds old) + if (const auto slot = SteamSlotOf(id)) { + return slot->handle; + } + return WithGamepad(id, Uint64{0}, + [](SDL_Gamepad* pad) { return SDL_GetGamepadSteamHandle(pad); }); +} + +/// The key a pad's Bluetooth state is kept under: its Steam handle under Steam (it follows the +/// controller between virtual pads), else the connection instance +std::string BluetoothKey(SDL_JoystickID id) { + if (const Uint64 handle = PadSteamHandle(id)) { + return fmt::format("steamhandle:{:#x}", handle); + } + return fmt::format("inst:{}", id); +} + +/// How a pad is stored as the handheld's: Steam handle under Steam, else its identity +std::string PadKey(SDL_JoystickID id, const std::string& ident) { + if (Common::SteamPadsInUse()) { + if (const Uint64 handle = PadSteamHandle(id)) { + return fmt::format("steamhandle:{:#x}", handle); + } + } + return ident; +} + +/// True while a pad has an input held: any button, or a stick or trigger past half travel. +bool PadInputHeld(SDL_JoystickID id) { + return WithGamepad(id, false, [](SDL_Gamepad* pad) { + for (int b = 0; b < SDL_GAMEPAD_BUTTON_COUNT; ++b) { + if (SDL_GetGamepadButton(pad, static_cast(b))) { + return true; + } + } + for (int a = 0; a < SDL_GAMEPAD_AXIS_COUNT; ++a) { + const Sint16 value = SDL_GetGamepadAxis(pad, static_cast(a)); + if (value > 16384 || value < -16384) { + return true; + } + } + return false; + }); +} + +/// A short rumble pulse on a pad, if it supports rumble. +void PadRumble(SDL_JoystickID id, Uint16 strength, Uint32 duration_ms) { + const JoystickApiLock lock; + if (SDL_Gamepad* pad = SDL_OpenGamepad(id)) { + SDL_RumbleGamepad(pad, strength, strength, duration_ms); + SDL_CloseGamepad(pad); + } +} + +/// An SDL motion binding for a device, as InputCommon's auto-mapping builds it +std::string SdlMotionParam(const std::string& guid, int port) { + Common::ParamPackage param; + param.Set("engine", "sdl"); + param.Set("motion", 0); + param.Set("port", port); + param.Set("guid", guid); + return param.Serialize(); +} + +/// Points every SDL binding of a player at one device (guid + port), leaving other engines +/// alone. True when it changed. +bool RouteSdlBindings(Settings::PlayerInput& player, const std::string& guid, int port) { + bool routed = false; + const auto route = [&](auto& params) { + for (std::string& raw : params) { + Common::ParamPackage param{raw}; + if (param.Get("engine", "") != "sdl") { + continue; + } + const std::string old_guid = param.Get("guid", ""); + if (old_guid == guid && param.Get("port", -1) == port) { + continue; + } + param.Set("guid", guid); + param.Set("port", port); + if (param.Has("guid2") && param.Get("guid2", "") == old_guid) { + param.Set("guid2", guid); + } + raw = param.Serialize(); + routed = true; + } + }; + route(player.buttons); + route(player.analogs); + route(player.motions); + return routed; +} + +/// The binding sets the console modes start from: the user's players without mappings or +/// profiles (each controller gets its defaults or a picked profile). Only a DSU server's motion +/// stays: the console modes don't expose motion. +std::array ModernStartSets( + const std::array& user_players) { + auto sets = user_players; + for (auto& set : sets) { + set.buttons.fill({}); + set.analogs.fill({}); + for (std::string& motion : set.motions) { + if (Common::ParamPackage{motion}.Get("engine", "") != "cemuhookudp") { + motion.clear(); + } + } + set.profile_name.clear(); + } + return sets; +} + +} // Anonymous namespace + +void MainWindow::SaveAllConfigValues() { + if (!console_profile) { + config->SaveAllValues(); + return; + } + // A console mode drives the players at runtime; the file keeps the user's own + auto& players = Settings::values.players.GetValue(true); + std::swap(players, *console_profile); + config->SaveAllValues(); + std::swap(players, *console_profile); +} + +namespace { +/// The handheld set's motion bindings, to trace what clears the DSU gyro (Debug Probes) +std::string MotionTrace(const Settings::PlayerInput& player) { + std::string out; + for (const auto& motion : player.motions) { + out += '[' + motion + ']'; + } + return out; +} +} // Anonymous namespace + +void MainWindow::RestoreConsoleProfile() { + if (!console_profile) { + return; + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: restore, profile P1 motions {}", + MotionTrace((*console_profile)[0])); + } + // A running game has its own copy of the players, which the engine drove: both copies get + // the user's players back (the game's as read at boot) + Settings::values.players.GetValue(true) = *console_profile; + if (!Settings::values.players.UsingGlobal()) { + Settings::values.players.GetValue() = game_profiles ? *game_profiles : *console_profile; + } + console_profile.reset(); + game_profiles.reset(); + session_sets.reset(); +} + +Settings::PlayerInput& MainWindow::SessionSet(int set) { + if (game_profiles && !(*game_profiles)[set].profile_name.empty()) { + return (*game_profiles)[set]; + } + if (!session_sets) { + session_sets = ModernStartSets(*console_profile); + } + return (*session_sets)[set]; +} + +std::array MainWindow::ConsoleBindingSets() const { + auto sets = session_sets ? *session_sets : ModernStartSets(*console_profile); + if (game_profiles) { + for (size_t i = 0; i < sets.size(); ++i) { + if (!(*game_profiles)[i].profile_name.empty()) { + sets[i] = (*game_profiles)[i]; + } + } + } + return sets; +} + +namespace { + +/// A binding's mapping without its device (guid, port): what a Controls tab edit changes. +/// Order- and float-format-insensitive ("0.5" and "0.500000" are the same). +std::string BindingMapping(const std::string& raw) { + if (raw.empty()) { + return {}; + } + Common::ParamPackage param{raw}; + // Only an SDL binding's guid/port is routing (a DSU server's address is the mapping) + if (param.Get("engine", "") == "sdl") { + param.Erase("guid"); + param.Erase("guid2"); + param.Erase("port"); + } + std::vector fields; + std::string field; + for (const char c : param.Serialize() + ",") { + if (c != ',') { + field += c; + continue; + } + const auto colon = field.find(':'); + if (colon != std::string::npos) { + const std::string value = field.substr(colon + 1); + char* end = nullptr; + const float number = std::strtof(value.c_str(), &end); + if (!value.empty() && end && *end == '\0') { + field = field.substr(0, colon + 1) + fmt::format("{}", number); + } + } + fields.push_back(field); + field.clear(); + } + std::sort(fields.begin(), fields.end()); + std::string out; + for (const auto& f : fields) { + out += f + ','; + } + return out; +} + +/// Puts a profile's mappings on a binding set, keeping the set's controller on SDL bindings +void ApplyProfileMappings(Settings::PlayerInput& set, const Settings::PlayerInput& profile) { + const auto apply = [](auto& to, const auto& from) { + for (size_t i = 0; i < to.size(); ++i) { + Common::ParamPackage taken{from[i]}; + const Common::ParamPackage kept{to[i]}; + if (!from[i].empty() && !to[i].empty() && taken.Get("engine", "") == "sdl" && + kept.Get("engine", "") == "sdl") { + taken.Set("guid", kept.Get("guid", "")); + taken.Set("port", kept.Get("port", 0)); + if (kept.Has("guid2")) { + taken.Set("guid2", kept.Get("guid2", "")); + } else { + taken.Erase("guid2"); + } + to[i] = taken.Serialize(); + } else { + to[i] = from[i]; + } + } + }; + apply(set.buttons, profile.buttons); + apply(set.analogs, profile.analogs); + apply(set.motions, profile.motions); +} + +/// Brings edited mappings into the profile's, keeping the profile's own device on SDL bindings. +/// True when anything was taken. +template +bool MergeMappings(Array& profile, const Array& before, const Array& after) { + bool taken = false; + for (size_t i = 0; i < after.size(); ++i) { + if (BindingMapping(after[i]) == BindingMapping(before[i])) { + continue; + } + Common::ParamPackage edited{after[i]}; + const Common::ParamPackage original{profile[i]}; + if (!after[i].empty() && !profile[i].empty() && edited.Get("engine", "") == "sdl" && + original.Get("engine", "") == "sdl") { + edited.Set("guid", original.Get("guid", "")); + edited.Set("port", original.Get("port", 0)); + if (original.Has("guid2")) { + edited.Set("guid2", original.Get("guid2", "")); + } else { + edited.Erase("guid2"); + } + profile[i] = edited.Serialize(); + } else { + profile[i] = after[i]; + } + taken = true; + } + return taken; +} + +} // Anonymous namespace + +void MainWindow::ApplySeatProfiles() { + const std::string stored = Settings::values.modern_seat_profiles.GetValue(); + std::vector seats; + std::stringstream entries{stored}; + for (std::string entry; std::getline(entries, entry, ',');) { + seats.push_back(entry); + } + std::optional input_profiles; + for (size_t i = 0; i < seats.size() && i < 8; ++i) { + if (seats[i].empty()) { + continue; + } + if (!input_profiles) { + input_profiles.emplace(); + } + const auto read = input_profiles->PeekProfile(seats[i], i); + if (!read) { + LOG_INFO(Frontend, "console-mode: P{} seat profile \"{}\" is gone", i + 1, seats[i]); + SetSeatProfile(static_cast(i), {}); + continue; + } + ApplyProfileMappings((*session_sets)[i], *read); + (*session_sets)[i].profile_name = seats[i]; + LOG_INFO(Frontend, "console-mode: P{} starts with its seat profile \"{}\"", i + 1, + seats[i]); + } +} + +void MainWindow::SetSeatProfile(int player, const std::string& name) { + if (player < 0 || player >= 8) { + return; + } + std::vector seats; + std::stringstream entries{Settings::values.modern_seat_profiles.GetValue()}; + for (std::string entry; std::getline(entries, entry, ',');) { + seats.push_back(entry); + } + seats.resize(8); + seats[static_cast(player)] = name; + std::string joined; + for (size_t i = 0; i < seats.size(); ++i) { + joined += (i ? "," : "") + seats[i]; + } + // (Nothing stored when no seat has a profile) + if (joined.find_first_not_of(',') == std::string::npos) { + joined.clear(); + } + Settings::values.modern_seat_profiles.SetValue(joined); +} + +void MainWindow::MergeConsoleProfileEdits(const std::array& before) { + if (!console_profile) { + return; + } + // The Controls tab edits the global copy, whatever copy a running game uses + const auto& after = Settings::values.players.GetValue(true); + // The handheld's own non-SDL motion (a DSU server) must survive edits on players it doesn't + // hold: kept here and put back at the end + const auto handheld_motions_kept = SessionSet(0).motions; + const auto own_slot = pad_slots.find(builtin_pad_id); + // (Only a handheld without a player can't have edited its set) + const bool handheld_has_player = builtin_pad_id && own_slot != pad_slots.end(); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: merge start, profile P1 motions {} before {} after {} " + "handheld_has_player={}", + MotionTrace((*console_profile)[0]), MotionTrace(before[0]), MotionTrace(after[0]), + handheld_has_player); + } + // The SDL device (guid, port) a player's bindings point at + const auto sdl_device = [](const Settings::PlayerInput& player) { + for (const std::string& raw : player.buttons) { + const Common::ParamPackage param{raw}; + if (!raw.empty() && param.Get("engine", "") == "sdl") { + return std::optional>{ + {param.Get("guid", ""), param.Get("port", 0)}}; + } + } + return std::optional>{}; + }; + for (size_t i = 0; i < after.size(); ++i) { + const auto& a = after[i]; + const auto& b = before[i]; + // A device change is a controller swap (below), not an edit + if (const auto device = sdl_device(a); device && device != sdl_device(b)) { + continue; + } + // A single Joy-Con's mapping is arranged from the set, not the set itself (its player + // tab is read-only meanwhile): nothing there is an edit + if (i < joycon_auto_set.size() && joycon_auto_set[i] && + (*joycon_auto_set[i] == Settings::ControllerType::RightJoycon || + *joycon_auto_set[i] == Settings::ControllerType::LeftJoycon)) { + continue; + } + // Only mapping changes count as edits (connection and routing are the engine's). The + // handheld's own pad's edits go to player one's set. + const auto own = pad_slots.find(builtin_pad_id); + size_t target = static_cast(slot_set[i]); + // For this session only: the user's own setup is the manual modes' + auto& p = SessionSet(static_cast(target)); + bool remapped = MergeMappings(p.buttons, b.buttons, a.buttons); + remapped = MergeMappings(p.analogs, b.analogs, a.analogs) || remapped; + bool edited = remapped; + // An external pad never gets the handheld's non-SDL motion: its absence on another + // player is the engine's doing, not an edit, and must not wipe it from the set + auto edited_motions = a.motions; + if (target != 0 || + !(builtin_pad_id && own != pad_slots.end() && own->second == static_cast(i))) { + const auto& handheld_motions = SessionSet(0).motions; + for (size_t k = 0; k < edited_motions.size(); ++k) { + const std::string& kept = p.motions[k]; + const bool handheld_motion = + !kept.empty() && Common::ParamPackage{kept}.Get("engine", "") != "sdl" && + std::find(handheld_motions.begin(), handheld_motions.end(), kept) != + handheld_motions.end(); + if (handheld_motion && edited_motions[k].empty()) { + edited_motions[k] = b.motions[k]; + } + } + } + edited = MergeMappings(p.motions, b.motions, edited_motions) || edited; + // Remapped and not saved: the controls are no longer the profile's + if (remapped && !p.profile_name.empty()) { + InputProfiles input_profiles; + const auto saved = input_profiles.PeekProfile(p.profile_name, i); + bool same = saved.has_value(); + for (size_t k = 0; same && k < p.buttons.size(); ++k) { + same = BindingMapping(p.buttons[k]) == BindingMapping(saved->buttons[k]); + } + for (size_t k = 0; same && k < p.analogs.size(); ++k) { + same = BindingMapping(p.analogs[k]) == BindingMapping(saved->analogs[k]); + } + if (!same) { + LOG_INFO(Frontend, "console-mode: P{} remapped: profile \"{}\" let go", i + 1, + p.profile_name); + p.profile_name.clear(); + SetSeatProfile(static_cast(i), {}); + } + } + if (!edited) { + continue; + } + set_default_of[target] = 0; // (the user's own mapping now) + LOG_INFO(Frontend, "console-mode: Controls edit on P{} kept for the session in set P{}", + i + 1, target + 1); + } + + if (!handheld_has_player) { + auto& motions = SessionSet(0).motions; + for (size_t k = 0; k < motions.size(); ++k) { + const std::string& kept = handheld_motions_kept[k]; + if (motions[k].empty() && !kept.empty() && + Common::ParamPackage{kept}.Get("engine", "") != "sdl") { + motions[k] = kept; + LOG_INFO(Frontend, "console-mode: kept the handheld's motion {} on P1", kept); + } + } + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: merge end, profile P1 motions {}", + MotionTrace((*console_profile)[0])); + } + + // A swap from the Controls tab: a player whose device was changed to another player's pad + // trades slots with it; the routing pass re-points them next tick + if (!input_subsystem) { + return; + } + // The pads and their (guid, port); listed first, as GetSDLBinding can't run under the lock + std::vector instances; + { + const JoystickApiLock lock; + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + for (int k = 0; k < count; ++k) { + instances.push_back(ids[k]); + } + SDL_free(ids); + } + std::map> bound; + for (const u32 inst : instances) { + if (const auto binding = input_subsystem->GetSDLBinding(inst)) { + bound[inst] = *binding; + } + } + // (The eight players only) + for (size_t i = 0; i < after.size() && i < static_cast(ConsolePlayers); ++i) { + const auto chosen = sdl_device(after[i]); + if (!chosen || chosen == sdl_device(before[i])) { + continue; + } + u32 pad = 0; + for (const auto& [inst, binding] : bound) { + if (binding == *chosen) { + pad = inst; + break; + } + } + if (!pad) { + continue; + } + if (pad == builtin_pad_id && Settings::values.controller_applet_mode.GetValue() == + Settings::AppletMode::HandheldConsoleMode) { + LOG_INFO(Frontend, "console-mode: Controls swap to P{} ignored: the handheld's own pad", + i + 1); + continue; + } + const auto pad_slot = pad_slots.find(pad); + if (pad_slot != pad_slots.end() && pad_slot->second == static_cast(i)) { + continue; + } + u32 holder = 0; + for (const auto& [inst, slot] : pad_slots) { + if (slot == static_cast(i) && bound.contains(inst)) { + holder = inst; + break; + } + } + const int from = pad_slot != pad_slots.end() ? pad_slot->second : -1; + if (holder) { + if (from >= 0) { + pad_slots[holder] = from; + } else { + pad_slots.erase(holder); + pad_identity.erase(holder); + } + } + pad_slots[pad] = static_cast(i); + LOG_INFO(Frontend, "console-mode: Controls swap: inst={} P{} -> P{}{}", pad, + from >= 0 ? from + 1 : 0, i + 1, + holder + ? fmt::format(", inst={} P{} -> P{}", holder, i + 1, from >= 0 ? from + 1 : 0) + : std::string{}); + } +} + +namespace { +/// The green/red flashes blink four times a second for their 1.5 seconds +bool BlinkOn(qint64 elapsed_ms) { + return elapsed_ms < 1500 && (elapsed_ms / 125) % 2 == 0; +} +} // Anonymous namespace + +void MainWindow::RenderControllerOverlay() { + // A row lights while its controller has an input held, or a moment after a tap. Never for + // motion: a resting gyro is never still. + const auto now = std::chrono::steady_clock::now(); + std::set active; + for (const auto& row : overlay_rows) { + if (row.pad == 0) { + continue; + } + const auto last = + input_subsystem ? input_subsystem->GetSDLLastActivity(row.pad) : std::nullopt; + if (PadInputHeld(row.pad) || (last && now - *last < std::chrono::milliseconds{250})) { + active.insert(row.pad); + } + } + overlay_active_pads = active; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + for (auto& player : state.players) { + const auto row = std::find_if(overlay_rows.begin(), overlay_rows.end(), + [&](const auto& r) { return r.player == player.player; }); + player.active = row != overlay_rows.end() && active.contains(row->pad); + } + } + + const auto mode = Settings::values.controller_applet_mode.GetValue(); + const bool automatic = mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode; + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + const auto publish = [](Common::OverlayPanel& panel, const QImage* image) { + GameOverlay::Publish(panel, image); + }; + + const auto player_color = [](int player) { + return ConfigurePlayerAssignment::PlayerColor(player); + }; + const QFont badge_font = GameOverlay::Style::LabelFont(); + + // Hints: a controller that took or left a player, named for a few seconds at the bottom left + constexpr qint64 hint_ms = 3000; + for (const auto& row : overlay_rows) { + const int i = row.player; + if (row.flash == 1 && row.pad != 0 && row.flash_since > hinted_join[i]) { + hinted_join[i] = row.flash_since; + controller_hints.push_back({overlay_row_name[i], i, true, row.flash_since}); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: hint \"{} joined\" P{}", + overlay_row_name[i].toStdString(), i + 1); + } + } else if (row.flash == 2 && row.flash_since > hinted_leave[i]) { + hinted_leave[i] = row.flash_since; + // (Removed on the tick it left) + const bool turned_off = std::abs(row.flash_since - turned_off_at[i]) < 1000; + // (Named after the controller that left: another may have the player already) + const QString& left_name = + overlay_left_name[i].isEmpty() ? overlay_row_name[i] : overlay_left_name[i]; + controller_hints.push_back({left_name, i, false, row.flash_since, turned_off}); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: hint \"{} {}\" P{}", left_name.toStdString(), + turned_off ? "turned off" : "left", i + 1); + } + } + } + // (A pause's notice stays while the game is paused for it, and shows even with the hints off) + const bool hints_on = Settings::values.console_hints.GetValue(); + std::erase_if(controller_hints, [&](const auto& hint) { + if (hint.pause_notice && !disconnect_paused.empty()) { + return false; + } + return now_ms - hint.at >= hint_ms || (!hints_on && !hint.pause_notice); + }); + if (!automatic || controller_hints.empty()) { + publish(Common::GetOverlayPanel(Common::OverlayPanelId::Hints), nullptr); + } else { + QFont hint_font; + hint_font.setPixelSize(15); + hint_font.setBold(true); + const QFontMetrics hint_fm{hint_font}; + constexpr int hint_h = 30; + constexpr int hint_gap = 6; + constexpr int badge_w = 36; + const auto text_of = [](const ControllerHint& hint) { + if (!hint.note.isEmpty()) { + return hint.note; + } + if (hint.joined) { + return tr("%1 joined").arg(hint.name); + } + return hint.turned_off ? tr("%1 turned off").arg(hint.name) + : tr("%1 left").arg(hint.name); + }; + int width = 0; + for (const auto& hint : controller_hints) { + width = + (std::max)(width, 14 + hint_fm.horizontalAdvance(text_of(hint)) + 10 + badge_w + 6); + } + // The widest a hint can be, so the image keeps its size while hints come and go + width = (std::max)(width, 260) + 2; + const int count = static_cast(controller_hints.size()); + QImage image{width, count * (hint_h + hint_gap), QImage::Format_RGBA8888}; + image.fill(Qt::transparent); + QPainter painter{&image}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::TextAntialiasing); + // Newest at the bottom, nearest the corner + int y = 0; + for (const auto& hint : controller_hints) { + const QString text = text_of(hint); + const int w = 14 + hint_fm.horizontalAdvance(text) + 10 + badge_w + 6; + // (A pause's notice doesn't fade while the game is paused for it) + const qint64 left = hint.pause_notice && !disconnect_paused.empty() + ? hint_ms + : hint_ms - (now_ms - hint.at); + const int alpha = left < 500 ? static_cast(255 * (std::max)(left, qint64{0}) / 500) + : 255; + painter.setPen(Qt::NoPen); + painter.setBrush(hint.joined ? QColor(18, 18, 22, 215 * alpha / 255) + : QColor(96, 22, 28, 220 * alpha / 255)); + painter.drawRoundedRect(QRectF(1, y, w, hint_h), hint_h / 2.0, hint_h / 2.0); + painter.setFont(hint_font); + painter.setPen(QColor(255, 255, 255, alpha)); + painter.drawText(QRect(15, y, w, hint_h), Qt::AlignVCenter | Qt::AlignLeft, text); + QColor badge = player_color(hint.player); + badge.setAlpha(hint.joined ? alpha : alpha / 2); + painter.setPen(Qt::NoPen); + painter.setBrush(badge); + const QRectF badge_rect(1 + w - 6 - badge_w, y + 5, badge_w, hint_h - 10); + painter.drawRoundedRect(badge_rect, badge_rect.height() / 2, badge_rect.height() / 2); + painter.setFont(badge_font); + painter.setPen(QColor(255, 255, 255, alpha)); + painter.drawText(badge_rect, Qt::AlignCenter, + QStringLiteral("P%1").arg(hint.player + 1)); + y += hint_h + hint_gap; + } + painter.end(); + publish(Common::GetOverlayPanel(Common::OverlayPanelId::Hints), &image); + } + + overlay_blink.clear(); + for (const auto& row : overlay_rows) { + overlay_blink.push_back(row.flash != 0 && BlinkOn(now_ms - row.flash_since)); + } + if (!Settings::values.console_debug_overlay.GetValue() || !automatic || overlay_rows.empty()) { + publish(Common::GetOverlayPanel(Common::OverlayPanelId::Players), nullptr); + return; + } + + // The Switch buttons as chips: a label and a colour (stick moves: the stick and an arrow) + const auto chip = [](int button) -> std::pair { + using namespace Settings::NativeButton; + if (button >= StickMoveButton) { + static constexpr std::array arrows{u"\u2190", u"\u2191", u"\u2192", + u"\u2193"}; + const int stick = (button - StickMoveButton) / 4; + const int direction = (button - StickMoveButton) % 4; + return {(stick == 0 ? QStringLiteral("LS") : QStringLiteral("RS")) + + QString::fromUtf16(arrows[static_cast(direction)]), + QColor(52, 73, 94)}; + } + switch (button) { + case A: + return {QStringLiteral("A"), QColor(231, 76, 60)}; + case B: + return {QStringLiteral("B"), QColor(230, 180, 20)}; + case X: + return {QStringLiteral("X"), QColor(52, 152, 219)}; + case Y: + return {QStringLiteral("Y"), QColor(46, 184, 104)}; + case LStick: + return {QStringLiteral("L3"), QColor(70, 90, 112)}; + case RStick: + return {QStringLiteral("R3"), QColor(70, 90, 112)}; + case L: + return {QStringLiteral("L"), QColor(120, 132, 140)}; + case R: + return {QStringLiteral("R"), QColor(120, 132, 140)}; + case ZL: + return {QStringLiteral("ZL"), QColor(98, 108, 116)}; + case ZR: + return {QStringLiteral("ZR"), QColor(98, 108, 116)}; + case Plus: + return {QStringLiteral("+"), QColor(142, 88, 182)}; + case Minus: + return {QStringLiteral("−"), QColor(142, 88, 182)}; + case DLeft: + return {QStringLiteral("←"), QColor(84, 99, 116)}; + case DUp: + return {QStringLiteral("↑"), QColor(84, 99, 116)}; + case DRight: + return {QStringLiteral("→"), QColor(84, 99, 116)}; + case DDown: + return {QStringLiteral("↓"), QColor(84, 99, 116)}; + case Home: + return {QStringLiteral("⌂"), QColor(60, 60, 66)}; + case Screenshot: + return {QStringLiteral("◉"), QColor(60, 60, 66)}; + case SLLeft: + case SLRight: + return {QStringLiteral("SL"), QColor(120, 132, 140)}; + default: + return {QStringLiteral("SR"), QColor(120, 132, 140)}; + } + }; + const QFont name_font = GameOverlay::Style::TextFont(); + const QFont chip_font = GameOverlay::Style::SmallFont(); + const QFontMetrics name_fm{name_font}; + const QFontMetrics chip_fm{chip_font}; + + constexpr int row_h = 28; + constexpr int row_gap = 4; + constexpr int badge_w = 30; + constexpr int inner = 6; + constexpr int bubble_gap = 6; + constexpr int chip_h = 18; + constexpr int chip_gap = 3; + const auto chip_width = [&](const QString& text) { + return (std::max)(chip_h, chip_fm.horizontalAdvance(text) + 10); + }; + // The widest chip there can be (a stick move, "LS→", is wider than a button) + int widest_chip = 26; + for (int k = 0; k < 8; ++k) { + widest_chip = (std::max)(widest_chip, chip_width(chip(StickMoveButton + k).first)); + } + // An entry lives as long as set, then shrinks away + const bool show_history = Settings::values.console_history.GetValue(); + const bool show_gaps = Settings::values.console_history_gaps.GetValue(); + const int history_size = Settings::values.console_history_size.GetValue(); + const qint64 press_life_ms = Settings::values.console_history_seconds.GetValue() * 1000LL; + constexpr qint64 press_shrink_ms = 400; + // The time between inputs: a tag between their chips, in ms, or seconds from one ("1.2") + QFont gap_font; + gap_font.setPixelSize(8); + gap_font.setBold(true); + const QFontMetrics gap_fm{gap_font}; + const auto gap_text = [press_life_ms](qint64 ms) { + ms = (std::min)(ms, press_life_ms); + return ms < 1000 ? QString::number(ms) + : QStringLiteral("%1s").arg(static_cast(ms) / 1000.0, 0, 'f', + ms < 10000 ? 1 : 0); + }; + constexpr int gap_pad = 3; + const int widest_gap = gap_fm.horizontalAdvance(QStringLiteral("999")) + gap_pad * 2; + const auto press_scale = [now_ms, press_life_ms](const ButtonPress& press) { + const qint64 over = now_ms - press.at - press_life_ms; + if (over <= 0) { + return 1.0; + } + return (std::max)(0.0, 1.0 - static_cast(over) / press_shrink_ms); + }; + + int name_w = 0; + for (const auto& row : overlay_rows) { + name_w = (std::max)(name_w, name_fm.horizontalAdvance(overlay_row_name[row.player])); + } + // (A name being typed has room for its 16 characters) + if (overlay_edit_player >= 0) { + name_w = (std::max)(name_w, name_fm.horizontalAdvance(QString(16, QLatin1Char('M')))); + } + // Room for a battery after the name, when any controller tells its charge + const bool batteries = std::any_of(overlay_rows.begin(), overlay_rows.end(), + [](const auto& row) { return row.battery >= 0; }); + const int battery_w = + batteries ? 8 + chip_fm.horizontalAdvance(QStringLiteral("100%")) + 5 + 20 : 0; + const int controller_w = inner + badge_w + 8 + name_w + battery_w + inner + 4; + // Room for as many of the widest chips as the history keeps, so the image keeps its size + int history_room = show_history ? inner * 2 + history_size * (widest_chip + chip_gap) + + (show_gaps ? (history_size - 1) * widest_gap : 0) + : 200; + // Short of the panels at the top right: the oldest inputs drop off sooner + if (render_window) { + const auto& layout = render_window->GetFramebufferLayout(); + const int room = static_cast(Common::OverlayRoomTopLeft( + static_cast(layout.screen.right - layout.screen.left))) - + (controller_w + bubble_gap + 2); + history_room = (std::clamp)(room, inner * 2 + widest_chip, history_room); + } + const int width = controller_w + bubble_gap + history_room + 2; + const int count = static_cast(overlay_rows.size()); + const int height = count * row_h + (count - 1) * row_gap + 2; + + QImage image{width, height, QImage::Format_RGBA8888}; + image.fill(Qt::transparent); + QPainter painter{&image}; + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::TextAntialiasing); + painter.setPen(Qt::NoPen); + // The controller bubble: its state lights it (blue held, green joined, red left) + const auto controller_bubble = [&](const OverlayRow& row, bool blink, int at_y) { + const bool gone = row.pad == 0; + QColor fill = GameOverlay::Style::Fill; + if (row.flash == 2) { + if (blink) { + fill = GameOverlay::Style::Bad; + } + } else if (!gone && active.contains(row.pad)) { + fill = GameOverlay::Style::Active; + } else if (blink) { + fill = GameOverlay::Style::Good; + } + GameOverlay::Style::Pill(painter, QRectF(1, at_y, controller_w, row_h), fill); + QColor badge = player_color(row.player); + if (gone) { + badge.setAlpha(110); + } + painter.setBrush(badge); + const QRectF badge_rect(1 + inner, at_y + 5, badge_w, row_h - 10); + painter.drawRoundedRect(badge_rect, badge_rect.height() / 2, badge_rect.height() / 2); + painter.setFont(badge_font); + painter.setPen(Qt::white); + painter.drawText(badge_rect, Qt::AlignCenter, QStringLiteral("P%1").arg(row.player + 1)); + // On the badge's corner: ? for a reconnect's guess, else the starting player's colour + if (!gone && (row.guessed || row.origin >= 0)) { + const QPointF corner(badge_rect.right() - 1, badge_rect.top() + 1); + painter.setPen(QPen(QColor(14, 14, 18), 1.5)); + if (row.guessed) { + painter.setBrush(Qt::white); + painter.drawEllipse(corner, 6, 6); + QFont mark_font = badge_font; + mark_font.setPixelSize(9); + painter.setFont(mark_font); + painter.setPen(QColor(14, 14, 18)); + painter.drawText(QRectF(corner.x() - 6, corner.y() - 6, 12, 12), Qt::AlignCenter, + QStringLiteral("?")); + } else { + painter.setBrush(player_color(row.origin)); + painter.drawEllipse(corner, 4.5, 4.5); + } + painter.setPen(Qt::NoPen); + } + painter.setFont(name_font); + if (row.player == overlay_edit_player && !gone) { + // The name being typed: a field with its caret (blinking), or what to type + const QRectF field(1 + inner + badge_w + 4, at_y + 4, name_w + 10, row_h - 8); + painter.setPen(QPen(QColor(120, 170, 255, 230), 1.2)); + painter.setBrush(QColor(255, 255, 255, 30)); + painter.drawRoundedRect(field, 5, 5); + const QRectF text_rect = field.adjusted(5, 0, -5, 0); + painter.setPen(overlay_edit_text.isEmpty() ? QColor(255, 255, 255, 110) + : QColor(245, 245, 245)); + painter.drawText(text_rect, Qt::AlignVCenter | Qt::AlignLeft, + overlay_edit_text.isEmpty() ? tr("Name") : overlay_edit_text); + if ((QDateTime::currentMSecsSinceEpoch() / 500) % 2 == 0) { + const double caret = text_rect.left() + + (overlay_edit_text.isEmpty() + ? 0 + : name_fm.horizontalAdvance(overlay_edit_text)) + + 1; + painter.setPen(QPen(Qt::white, 1.2)); + painter.drawLine(QPointF(caret, field.top() + 4), QPointF(caret, field.bottom() - 4)); + } + painter.setPen(Qt::NoPen); + return; + } + painter.setPen(gone ? GameOverlay::Style::DimText : GameOverlay::Style::Text); + painter.drawText(QRect(1 + inner + badge_w + 8, at_y, name_w + 4, row_h), + Qt::AlignVCenter | Qt::AlignLeft, overlay_row_name[row.player]); + // The battery's percent, then its icon (the same as the tabs' and the menu bar's) + if (!gone && row.battery >= 0) { + const int percent_w = chip_fm.horizontalAdvance(QStringLiteral("100%")); + const double bx = 1 + inner + badge_w + 8 + name_w + 8 + percent_w + 5; + painter.drawPixmap(QPointF(bx, at_y + row_h / 2.0 - 6), + ConfigurePlayerAssignment::Battery(row.battery, row.charging)); + painter.setFont(chip_font); + painter.setPen(QColor(220, 220, 220)); + painter.drawText(QRectF(bx - 5 - percent_w, at_y, percent_w, row_h), + Qt::AlignVCenter | Qt::AlignRight, + QStringLiteral("%1%").arg(row.battery)); + } + painter.setPen(Qt::NoPen); + }; + // A name being typed: apply, remove and cancel, where the history goes + const auto edit_buttons = [&](std::size_t r, int at_y) { + const auto& row = overlay_rows[r]; + constexpr int round_w = 24; + const bool named = !overlay_custom_name[static_cast(row.player)].empty(); + const int buttons = named ? 3 : 2; + const double x0 = 1 + controller_w + bubble_gap; + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(14, 14, 18, 200)); + painter.drawRoundedRect(QRectF(x0, at_y, 8 + buttons * (round_w + chip_gap) - chip_gap, + row_h), + row_h / 2.0, row_h / 2.0); + double x = x0 + 4; + const double h = row_h - 6; + const auto round = [&](int action, const QColor& fill) { + const QRectF rect(x, at_y + 3, round_w, h); + overlay_buttons.push_back({rect.toAlignedRect(), row.player, action, -1}); + x += round_w + chip_gap; + painter.setPen(Qt::NoPen); + painter.setBrush(fill); + painter.drawEllipse(rect.center(), h / 2.0, h / 2.0); + painter.setPen(QPen(Qt::white, 1.8, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + return rect.center(); + }; + // Apply: a check + { + const QPointF c = round(4, QColor(45, 160, 80, 230)); + const QPointF check[3] = {QPointF(c.x() - 4.5, c.y()), QPointF(c.x() - 1.5, c.y() + 3), + QPointF(c.x() + 4.5, c.y() - 3.5)}; + painter.drawPolyline(check, 3); + } + // Remove the name: a bin + if (named) { + const QPointF c = round(5, QColor(200, 50, 50, 230)); + painter.drawLine(QPointF(c.x() - 4.5, c.y() - 3.5), QPointF(c.x() + 4.5, c.y() - 3.5)); + painter.drawLine(QPointF(c.x() - 1.5, c.y() - 5), QPointF(c.x() + 1.5, c.y() - 5)); + painter.drawRoundedRect(QRectF(c.x() - 3.5, c.y() - 3.5, 7, 8.5), 1, 1); + } + // Cancel: a cross + { + const QPointF c = round(6, QColor(255, 255, 255, 40)); + painter.drawLine(QPointF(c.x() - 3.5, c.y() - 3.5), QPointF(c.x() + 3.5, c.y() + 3.5)); + painter.drawLine(QPointF(c.x() + 3.5, c.y() - 3.5), QPointF(c.x() - 3.5, c.y() + 3.5)); + } + painter.setPen(Qt::NoPen); + }; + // The row under the mouse shows its buttons where its history goes + overlay_buttons.clear(); + const auto neighbour = [this](std::size_t r, int step) { + for (int k = static_cast(r) + step; + k >= 0 && k < static_cast(overlay_rows.size()); k += step) { + if (overlay_rows[static_cast(k)].pad != 0) { + return overlay_rows[static_cast(k)].player; + } + } + return -1; + }; + const auto row_buttons = [&](std::size_t r, int at_y) { + const auto& row = overlay_rows[r]; + const auto address = pad_bluetooth.find(BluetoothKey(row.pad)); + const bool turns_off = address != pad_bluetooth.end() && !address->second.empty(); + const QString label = tr("Turn Off"); + const QString config_label = tr("Config"); + constexpr int round_w = 24; + const int remove_w = turns_off ? 22 + chip_fm.horizontalAdvance(label) + 10 : 0; + const int config_w = chip_fm.horizontalAdvance(config_label) + 20; + const int above = neighbour(r, -1); + const int below = neighbour(r, 1); + const int count_round = (above >= 0 ? 1 : 0) + (below >= 0 ? 1 : 0); + const double x0 = 1 + controller_w + bubble_gap; + const double pill_w = 4 + (count_round + 1) * (round_w + chip_gap) + config_w + + (turns_off ? chip_gap + remove_w : 0) + 4; + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(14, 14, 18, 200)); + painter.drawRoundedRect(QRectF(x0, at_y, pill_w, row_h), row_h / 2.0, row_h / 2.0); + double x = x0 + 4; + const double top = at_y + 3; + const double h = row_h - 6; + const auto add = [&](double w, int action, int target) { + const QRectF rect(x, top, w, h); + overlay_buttons.push_back({rect.toAlignedRect(), row.player, action, target}); + x += w + chip_gap; + return rect; + }; + const auto arrow = [&](int step, int target) { + const QRectF rect = add(round_w, step, target); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(255, 255, 255, 40)); + painter.drawEllipse(rect.center(), h / 2.0, h / 2.0); + const QPointF c = rect.center(); + const double d = step < 0 ? -1.0 : 1.0; + const QPointF tri[3] = {QPointF(c.x() - 5, c.y() - 2.5 * d), + QPointF(c.x() + 5, c.y() - 2.5 * d), + QPointF(c.x(), c.y() + 3.5 * d)}; + painter.setBrush(Qt::white); + painter.drawPolygon(tri, 3); + }; + if (above >= 0) { + arrow(-1, above); + } + if (below >= 0) { + arrow(1, below); + } + // Rename: a pencil, opening a small dialog for the name + { + const QRectF rect = add(round_w, 3, -1); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(255, 255, 255, 40)); + painter.drawEllipse(rect.center(), h / 2.0, h / 2.0); + const QPointF c = rect.center(); + painter.setPen(QPen(Qt::white, 1.6, Qt::SolidLine, Qt::RoundCap)); + painter.drawLine(QPointF(c.x() - 4, c.y() + 4), QPointF(c.x() + 3, c.y() - 3)); + painter.drawLine(QPointF(c.x() + 1.5, c.y() - 4.5), QPointF(c.x() + 4.5, c.y() - 1.5)); + painter.setPen(Qt::NoPen); + } + // Config: the player's tab in the controls settings + { + const QRectF config_rect = add(config_w, 2, -1); + painter.setPen(QPen(QColor(120, 170, 255, 230), 1.5)); + painter.setBrush(QColor(45, 95, 200, 120)); + painter.drawRoundedRect(config_rect.adjusted(0.75, 0.75, -0.75, -0.75), h / 2.0, + h / 2.0); + painter.setFont(chip_font); + painter.setPen(Qt::white); + painter.drawText(config_rect, Qt::AlignCenter, config_label); + painter.setPen(Qt::NoPen); + } + if (!turns_off) { + return true; + } + const QRectF rect = add(remove_w, 0, -1); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(200, 50, 50, 230)); + painter.drawRoundedRect(rect, h / 2.0, h / 2.0); + // A power symbol + const QPointF c(rect.left() + 12, rect.center().y()); + painter.setPen(QPen(Qt::white, 2, Qt::SolidLine, Qt::RoundCap)); + painter.setBrush(Qt::NoBrush); + painter.drawArc(QRectF(c.x() - 5, c.y() - 4.5, 10, 10), 125 * 16, 290 * 16); + painter.drawLine(QPointF(c.x(), c.y() - 6), QPointF(c.x(), c.y())); + painter.setFont(chip_font); + painter.setPen(Qt::white); + painter.drawText(rect.adjusted(22, 0, 0, 0), Qt::AlignVCenter | Qt::AlignLeft, label); + painter.setPen(Qt::NoPen); + return true; + }; + // Where each row is, for the mouse (a row drags onto another to swap them) + overlay_hits.clear(); + int drag_row = -1; + int drag_y = 0; + int y = 1; + for (std::size_t r = 0; r < overlay_rows.size(); ++r) { + const auto& row = overlay_rows[r]; + const bool blink = overlay_blink[r]; + overlay_hits.emplace_back(row.player, QRect(1, y, width - 2, row_h)); + if (overlay_dragging && row.player == overlay_drag_player) { + // Where the dragged row came from: an empty slot (the row follows the mouse) + QColor edge = player_color(row.player); + edge.setAlpha(150); + painter.setPen(QPen(edge, 1.5, Qt::DashLine)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(QRectF(1.5, y + 0.5, controller_w - 1, row_h - 1), row_h / 2.0, + row_h / 2.0); + painter.setPen(Qt::NoPen); + drag_row = static_cast(r); + drag_y = y; + } else { + controller_bubble(row, blink, y); + } + if (row.player == overlay_edit_player && row.pad != 0) { + edit_buttons(r, y); + y += row_h + row_gap; + continue; + } + if (row.player == overlay_hover_player && row.pad != 0 && !overlay_dragging && + row_buttons(r, y)) { + y += row_h + row_gap; + continue; + } + + // The history bubble: the last inputs, newest first, shrinking away as they expire. + // Buttons pressed together share a capsule, joined by "+". + static const std::deque none; + const auto found = press_history.find(overlay_row_history[row.player]); + const auto& presses = show_history && found != press_history.end() ? found->second : none; + struct Input { + std::size_t first; + std::size_t count; + double scale; + double width; + double gap_w; ///< the time to the newer input, drawn before this one (0: none) + }; + constexpr double plus_w = 10; + constexpr double capsule_pad = 3; + std::vector inputs; // newest first + double chips_w = 0; + for (std::size_t end = presses.size(); end > 0;) { + std::size_t first = end - 1; + while (first > 0 && presses[first - 1].at == presses[end - 1].at) { + --first; + } + const double scale = press_scale(presses[first]); + double input_w = 0; + for (std::size_t k = first; k < end; ++k) { + input_w += chip_width(chip(presses[k].button).first); + } + if (end - first > 1) { + input_w += capsule_pad * 2 + plus_w * static_cast(end - first - 1); + } + input_w *= scale; + if (scale > 0.0) { + // The time from this input to the newer one before it + double gap_w = 0; + if (show_gaps && !inputs.empty()) { + gap_w = gap_fm.horizontalAdvance( + gap_text(presses[inputs.back().first].at - presses[first].at)) + + gap_pad * 2; + } + if (chips_w + gap_w + input_w > history_room - inner * 2) { + break; + } + inputs.push_back({first, end - first, scale, input_w, gap_w}); + chips_w += gap_w + input_w + chip_gap * scale; + } + end = first; + } + if (chips_w > 0.5) { + const double bubble_x = 1 + controller_w + bubble_gap; + const double bubble_w = inner * 2 + chips_w - chip_gap; + GameOverlay::Style::Pill( + painter, QRectF(bubble_x, y, (std::max)(bubble_w, double{row_h}), row_h), + GameOverlay::Style::Fill); + double cx = bubble_x + inner; + for (std::size_t g = 0; g < inputs.size(); ++g) { + const auto& input = inputs[g]; + const double scale = input.scale; + // (Older inputs fade a step each; past twenty they're transparent) + const int alpha = + (std::clamp)(static_cast((255 - static_cast(g) * 12) * scale), 0, 255); + const double h = chip_h * scale; + const bool together = input.count > 1; + if (input.gap_w > 0) { + // A tag sitting in the gap, overlapping the space around it + const QRectF tag(cx - chip_gap * scale, y + (row_h - 12) / 2.0, input.gap_w, + 12); + painter.setBrush(QColor(255, 255, 255, 22 * alpha / 255)); + painter.drawRoundedRect(tag, 6, 6); + painter.setFont(gap_font); + painter.setPen(QColor(190, 195, 202, alpha)); + painter.drawText( + tag, Qt::AlignCenter, + gap_text(presses[inputs[g - 1].first].at - presses[input.first].at)); + painter.setPen(Qt::NoPen); + cx += input.gap_w - chip_gap * scale; + } + double x = cx; + if (together) { + // The capsule: a light band with an outline around the buttons + const double ch = (chip_h + capsule_pad * 2) * scale; + const QRectF capsule(cx, y + (row_h - ch) / 2.0, input.width, ch); + painter.setBrush(QColor(255, 255, 255, 40 * alpha / 255)); + painter.setPen(QPen(QColor(255, 255, 255, 120 * alpha / 255), 1)); + painter.drawRoundedRect(capsule, ch / 2.0, ch / 2.0); + painter.setPen(Qt::NoPen); + x += capsule_pad * scale; + } + for (std::size_t k = input.first; k < input.first + input.count; ++k) { + const auto& [label, color] = chip(presses[k].button); + const double w = chip_width(label) * scale; + QColor chip_color = color; + chip_color.setAlpha(alpha); + const QRectF rect(x, y + (row_h - h) / 2.0, w, h); + painter.setBrush(chip_color); + painter.drawRoundedRect(rect, h / 2.0, h / 2.0); + if (scale > 0.6) { + painter.setFont(chip_font); + painter.setPen(QColor(255, 255, 255, alpha)); + painter.drawText(rect, Qt::AlignCenter, label); + painter.setPen(Qt::NoPen); + } + x += w; + if (k + 1 < input.first + input.count) { + if (scale > 0.6) { + painter.setFont(chip_font); + painter.setPen(QColor(255, 255, 255, alpha)); + painter.drawText(QRectF(x, y, plus_w * scale, row_h), Qt::AlignCenter, + QStringLiteral("+")); + painter.setPen(Qt::NoPen); + } + x += plus_w * scale; + } + } + cx += input.width + chip_gap * scale; + } + } + y += row_h + row_gap; + } + // The dragged row, last, over the others + if (drag_row >= 0) { + const int at_y = std::clamp(drag_y + overlay_drag_dy, 0, height - row_h - 1); + controller_bubble(overlay_rows[static_cast(drag_row)], + overlay_blink[static_cast(drag_row)], at_y); + } + painter.end(); + publish(Common::GetOverlayPanel(Common::OverlayPanelId::Players), &image); +} + +bool MainWindow::HandleOverlayMouse(QEvent* event) { + const auto type = event->type(); + // A name being typed in the overlay takes the keys + if (overlay_edit_player >= 0) { + bool shown_now = false; + { + auto& frame = Common::GetOverlayPanel(Common::OverlayPanelId::Players); + std::scoped_lock lock{frame.mutex}; + shown_now = frame.enabled; + } + const bool still_there = + std::any_of(overlay_rows.begin(), overlay_rows.end(), [this](const auto& row) { + return row.player == overlay_edit_player && row.pad != 0; + }); + if (!shown_now || !still_there) { + overlay_edit_player = -1; + } + } + if (overlay_edit_player >= 0 && + (type == QEvent::KeyPress || type == QEvent::KeyRelease || + type == QEvent::ShortcutOverride)) { + event->accept(); + if (type != QEvent::KeyPress) { + return true; + } + const auto* key = static_cast(event); + const int player = overlay_edit_player; + if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter || + key->key() == Qt::Key_Escape) { + if (key->key() != Qt::Key_Escape) { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.rename_requests.emplace_back(player, + overlay_edit_text.trimmed().toStdString()); + } + overlay_edit_player = -1; + } else if (key->key() == Qt::Key_Backspace) { + overlay_edit_text.chop(1); + } else if (!key->text().isEmpty() && key->text().at(0).isPrint() && + overlay_edit_text.size() < 16) { + overlay_edit_text += key->text(); + } + RenderControllerOverlay(); + return true; + } + if (type == QEvent::Leave && overlay_hover_player >= 0) { + overlay_hover_player = -1; + RenderControllerOverlay(); + return false; + } + // (With no button held, the pointer's moves come as hover moves) + const bool hover_move = type == QEvent::HoverMove; + if (type != QEvent::MouseButtonPress && type != QEvent::MouseMove && + type != QEvent::MouseButtonRelease && !hover_move) { + return false; + } + bool shown = false; + { + auto& frame = Common::GetOverlayPanel(Common::OverlayPanelId::Players); + std::scoped_lock lock{frame.mutex}; + shown = frame.enabled; + } + if (!shown || overlay_hits.empty() || !render_window) { + overlay_drag_player = -1; + overlay_dragging = false; + overlay_hover_player = -1; + return false; + } + // The cursor in the overlay image + const QPoint at = + GameOverlay::CursorOn(Common::GetOverlayPanel(Common::OverlayPanelId::Players), + *render_window) + .at; + const auto row_at = [this](QPoint point) { + for (const auto& [player, rect] : overlay_hits) { + if (rect.contains(point)) { + return player; + } + } + return -1; + }; + const auto* mouse = static_cast(event); + // The row under the mouse shows its buttons (not while one is dragged) + if ((type == QEvent::MouseMove || hover_move) && !overlay_dragging) { + int hover = row_at(at); + if (hover >= 0 && + std::none_of(overlay_rows.begin(), overlay_rows.end(), + [&](const auto& row) { return row.player == hover && row.pad != 0; })) { + hover = -1; + } + if (hover != overlay_hover_player) { + overlay_hover_player = hover; + RenderControllerOverlay(); + } + } + if (hover_move) { + return false; // (the rest is the press, drag and release's) + } + // A button acts on its press; its release is the overlay's too + if (overlay_button_down && type == QEvent::MouseButtonRelease) { + overlay_button_down = false; + return true; + } + if (type == QEvent::MouseButtonPress && mouse->button() == Qt::LeftButton) { + const auto button = std::find_if(overlay_buttons.begin(), overlay_buttons.end(), + [&](const auto& b) { return b.rect.contains(at); }); + if (button != overlay_buttons.end()) { + overlay_button_down = true; + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + if (button->action == 3) { + // Name it in place: typing goes to the field (the window takes the keys) + overlay_edit_player = button->player; + overlay_edit_text = QString::fromStdString( + overlay_custom_name[static_cast(button->player)]); + render_window->setFocus(); + QTimer::singleShot(0, this, [this] { RenderControllerOverlay(); }); + } else if (button->action >= 4 && button->action <= 6) { + // Apply (4), remove the name (5), cancel (6) + if (button->action != 6) { + state.rename_requests.emplace_back( + button->player, button->action == 4 + ? overlay_edit_text.trimmed().toStdString() + : std::string{}); + } + overlay_edit_player = -1; + QTimer::singleShot(0, this, [this] { RenderControllerOverlay(); }); + } else if (button->action == 2) { + // (After the event: the dialog runs its own loop) + const int player = button->player; + QTimer::singleShot(0, this, [this, player] { + open_player_tab = player; + OnConfigure(); + }); + } else if (button->action == 0) { + LOG_INFO(Frontend, "console-mode: overlay turn off P{}", button->player + 1); + state.remove_requests.push_back(button->player); + } else { + LOG_INFO(Frontend, "console-mode: overlay swap P{} <-> P{}", button->player + 1, + button->target + 1); + state.swap_requests.emplace_back(button->player, button->target); + // The buttons follow the controller to its new row + overlay_hover_player = button->target; + } + return true; + } + } + if (type == QEvent::MouseButtonPress) { + if (mouse->button() != Qt::LeftButton) { + return false; + } + const int player = row_at(at); + if (player < 0) { + return false; // the game's + } + overlay_drag_player = player; + overlay_press = at; + overlay_drag_dy = 0; + overlay_dragging = false; + return true; + } + if (overlay_drag_player < 0) { + return false; + } + if (type == QEvent::MouseMove) { + const int dy = at.y() - overlay_press.y(); + if (!overlay_dragging && std::abs(dy) < QApplication::startDragDistance()) { + return true; + } + overlay_dragging = true; + overlay_drag_dy = dy; + RenderControllerOverlay(); + return true; + } + // Released: dropped on another row swaps the two players; a click rumbles the controller + const int player = overlay_drag_player; + const bool dragged = overlay_dragging; + overlay_drag_player = -1; + overlay_dragging = false; + overlay_drag_dy = 0; + if (dragged) { + const int target = row_at(QPoint(10, at.y())); + if (target >= 0 && target != player) { + LOG_INFO(Frontend, "console-mode: overlay swap P{} <-> P{}", player + 1, target + 1); + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.swap_requests.emplace_back(player, target); + } + RenderControllerOverlay(); + return true; + } + if (Settings::values.vibration_enabled.GetValue() && + Settings::values.players.GetValue()[player].vibration_enabled) { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.rumble_requests.push_back(player); + } + return true; +} + +bool MainWindow::OverlayAnimating() const { + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + if (!controller_hints.empty() || overlay_edit_player >= 0) { + return true; + } + const qint64 life_ms = Settings::values.console_history_seconds.GetValue() * 1000LL; + for (const auto& [ident, history] : press_history) { + if (!history.empty() && now_ms - history.front().at > life_ms - 100) { + return true; + } + } + return false; +} + +void MainWindow::LearnBluetoothPads() { +#ifdef __linux__ + // Pads with several controllers of their model: the one controller with a button held while + // just one of those pads has one is that pad's + if (pad_bt_pending.empty()) { + return; + } + // The pads now, each with the controller behind it now (Steam may have just moved one) + std::map, std::vector>> by_model; + { + const JoystickApiLock lock; + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + for (int i = 0; i < count; ++i) { + const u32 id = ids[i]; + // (Not a real device Steam hid: its virtual pad is the player) + if (Common::SteamPadsInUse() && !IsSteamVirtualPad(id) && + Common::SteamHidModel(PadVendor(id), + PadProduct(id))) { + continue; + } + std::string key = BluetoothKey(id); + if (pad_bt_pending.contains(key)) { + by_model[{PadVendor(id), PadProduct(id)}] + .emplace_back(id, std::move(key)); + } + } + SDL_free(ids); + } + // Debug Probes: controllers waiting for a press whose pads can't be found (every 2 seconds) + if (Common::ControllerProbes()) { + std::size_t found = 0; + for (const auto& [model, pads] : by_model) { + found += pads.size(); + } + static auto last_missing = std::chrono::steady_clock::time_point{}; + const auto now = std::chrono::steady_clock::now(); + if (found < pad_bt_pending.size() && now - last_missing > std::chrono::seconds{2}) { + last_missing = now; + std::string keys; + for (const auto& key : pad_bt_pending) { + keys += " " + key; + } + LOG_INFO(Frontend, "console-mode: waiting for a press:{}, {} of them on a pad", keys, + found); + } + } + std::set taken; + for (const auto& [key, address] : pad_bluetooth) { + if (!address.empty()) { + taken.insert(address); + } + } + for (const auto& [model, pads] : by_model) { + std::vector controllers = ControllersOfModel(model.first, model.second); + std::erase_if(controllers, [&](const auto& c) { return taken.contains(c.address); }); + // The last of its model left to tell apart: that one is it + if (pads.size() == 1 && controllers.size() == 1) { + const auto& [id, key] = pads[0]; + pad_bluetooth[key] = controllers[0].bluetooth ? controllers[0].address : std::string{}; + pad_bt_pending.erase(key); + if (Common::ControllerProbes()) { + LOG_INFO( + Frontend, "console-mode: inst={} ({}) is the last of its model: bluetooth={}", + id, key, controllers[0].bluetooth ? controllers[0].address : std::string{"no"}); + } + continue; + } + // A pad's button held now, or pressed in the last 200ms (a quick tap) + const auto now_steady = std::chrono::steady_clock::now(); + std::vector> pressed_pads; + for (const auto& pad : pads) { + const auto last = + input_subsystem ? input_subsystem->GetSDLLastActivity(pad.first) : std::nullopt; + if (PadAnyButtonPressed(pad.first) || + (last && now_steady - *last < std::chrono::milliseconds{200})) { + pressed_pads.push_back(pad); + } + } + // (Every check reads the controllers' queues, so a press counts when it happened) + const RealController* pressed = nullptr; + int count = 0; + for (const auto& controller : controllers) { + if (RealButtonHeld(controller)) { + pressed = &controller; + ++count; + } + } + if (pressed_pads.size() != 1) { + // Debug Probes: presses that can't tell them apart (several pads at once) + if (pressed_pads.size() > 1 && Common::ControllerProbes()) { + static auto last_many = std::chrono::steady_clock::time_point{}; + if (now_steady - last_many > std::chrono::seconds{1}) { + last_many = now_steady; + std::string which; + for (const auto& [id, key] : pressed_pads) { + which += fmt::format(" inst={}({})", id, key); + } + LOG_INFO(Frontend, + "console-mode: {} pads of model {:04x}:{:04x} pressed at once, " + "{} of {} controllers show one held:{}", + pressed_pads.size(), model.first, model.second, count, + controllers.size(), which); + } + } + continue; + } + // Debug Probes: why a press didn't tell them apart (at most once a second) + if (count != 1 && Common::ControllerProbes()) { + static auto last = std::chrono::steady_clock::time_point{}; + if (const auto now = std::chrono::steady_clock::now(); + now - last > std::chrono::seconds{1}) { + last = now; + std::string nodes; + for (const auto& controller : controllers) { + nodes += fmt::format(" {}[{}]", controller.address, + controller.events.empty() + ? std::string{"no input node"} + : controller.events.front().toStdString()); + } + LOG_INFO(Frontend, + "console-mode: {} pressed a button, {} of {} controllers of its model " + "show one held:{}", + pressed_pads.front().second, count, controllers.size(), nodes); + } + } + if (count != 1) { + continue; + } + const auto& [id, key] = pressed_pads[0]; + // (The controller behind the pad must not have changed while it was read) + if (BluetoothKey(id) != key) { + continue; + } + pad_bluetooth[key] = pressed->bluetooth ? pressed->address : std::string{}; + pad_bt_pending.erase(key); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: inst={} ({}) told apart by a button press: bluetooth={}", id, + key, pressed->bluetooth ? pressed->address : std::string{"no"}); + } + } +#endif +} + +#ifdef __linux__ +namespace { + +/// The presses since the last look: the pads that just got input, and the real controllers +/// with input in the last 150ms, read from their input nodes +struct FreshPresses { + std::set pads; + std::set controllers; +}; + +/// Reads the pads' and the real controllers' presses (a pad's is SDL's record of its input) +FreshPresses WatchControllerPresses(InputCommon::InputSubsystem* input) { + FreshPresses fresh; + // The pads with a button down, and the models behind them + std::vector pads; + std::set> models; + { + const JoystickApiLock lock; + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + for (int i = 0; i < count; ++i) { + pads.push_back(ids[i]); + models.emplace(PadVendor(ids[i]), PadProduct(ids[i])); + } + SDL_free(ids); + } + // (A pad's input newer than at the last look) + static std::map pads_seen; + for (const SDL_JoystickID id : pads) { + const auto last = input ? input->GetSDLLastActivity(id) : std::nullopt; + if (!last) { + continue; + } + auto& seen = pads_seen[id]; + if (*last != seen) { + const bool first = seen == std::chrono::steady_clock::time_point{}; + seen = *last; + if (!first) { + fresh.pads.insert(id); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: probe: pad inst={} ({}) input", id, + BluetoothKey(id)); + } + } + } + } + // The controllers' input nodes, listed again every 2 seconds + struct Node { + int fd; + std::string controller; + /// Its axes' ranges and where they rested when it was opened + std::map axes; + }; + static std::map nodes; + static std::map pressed_at; + static auto listed = std::chrono::steady_clock::time_point{}; + const auto now = std::chrono::steady_clock::now(); + if (now - listed > std::chrono::seconds{2}) { + listed = now; + for (const auto& [vendor, product] : models) { + for (const auto& controller : ControllersOfModel(vendor, product)) { + for (const QString& event : controller.events) { + if (!nodes.contains(event)) { + const int fd = ::open(event.toLocal8Bit().constData(), + O_RDONLY | O_NONBLOCK | O_CLOEXEC); + // (Not a motion sensor's node: its axes move all the time) + std::array props{}; + if (fd >= 0 && ioctl(fd, EVIOCGPROP(props.size()), props.data()) >= 0 && + (props[INPUT_PROP_ACCELEROMETER / 8] & + (1 << (INPUT_PROP_ACCELEROMETER % 8)))) { + ::close(fd); + continue; + } + std::map axes; + for (u16 code = 0; fd >= 0 && code <= ABS_MAX; ++code) { + input_absinfo info{}; + if (ioctl(fd, EVIOCGABS(code), &info) == 0 && + info.maximum > info.minimum) { + axes[code] = info; + } + } + nodes[event] = {fd, controller.key, std::move(axes)}; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: probe: watching {} of {} ({:04x}:{:04x}){}", + event.toStdString(), controller.key, vendor, product, + nodes[event].fd < 0 ? " - can't open" : ""); + } + } + } + } + } + } + for (auto it = nodes.begin(); it != nodes.end();) { + std::array events; + ssize_t got = 0; + while (it->second.fd >= 0 && + (got = ::read(it->second.fd, events.data(), sizeof(events))) > 0) { + for (std::size_t k = 0; k < static_cast(got) / sizeof(input_event); ++k) { + const input_event& e = events[k]; + bool pressed = e.type == EV_KEY && e.value == 1; + if (const auto axis = it->second.axes.find(e.code); + e.type == EV_ABS && axis != it->second.axes.end()) { + const auto& info = axis->second; + pressed = std::abs(static_cast(e.value - info.value)) / + static_cast(info.maximum - info.minimum) > + 0.25; + } + if (!pressed) { + continue; + } + pressed_at[it->second.controller] = now; + // (Logged once a burst: a stick held out sends events all the time) + static std::map logged_at; + auto& logged = logged_at[it->second.controller]; + const bool burst = now - logged < std::chrono::milliseconds{500}; + logged = now; + if (Common::ControllerProbes() && !burst) { + LOG_INFO(Frontend, "console-mode: probe: controller {} input ({} {:#x})", + it->second.controller, e.type == EV_KEY ? "button" : "axis", + e.code); + } + } + } + // (Gone: listed again if it comes back) + if (it->second.fd < 0 || (got < 0 && errno == ENODEV)) { + if (it->second.fd >= 0) { + ::close(it->second.fd); + } + it = nodes.erase(it); + } else { + ++it; + } + } + for (auto it = pressed_at.begin(); it != pressed_at.end();) { + if (now - it->second < std::chrono::milliseconds{150}) { + fresh.controllers.insert(it->first); + ++it; + } else { + it = pressed_at.erase(it); + } + } + return fresh; +} + +} // Anonymous namespace +#endif + +void MainWindow::WatchSteamPadInputs() { +#ifdef __linux__ + // (Read under Steam, and for Debug Probes) + if (!Common::SteamPadsInUse() && !Common::ControllerProbes()) { + return; + } + const FreshPresses fresh = WatchControllerPresses(input_subsystem.get()); + // A pad's input together with exactly one real controller's: when Steam then moves a + // controller to that pad, that is the one + if (Common::SteamPadsInUse() && fresh.pads.size() == 1 && fresh.controllers.size() == 1) { + last_lone_input = LoneInput{*fresh.pads.begin(), *fresh.controllers.begin(), + std::chrono::steady_clock::now()}; + } +#endif +} + +void MainWindow::PollControllerActivity() { + // (The automatic modes only) + const auto mode = Settings::values.controller_applet_mode.GetValue(); + if (mode == Settings::AppletMode::ConsoleMode || + mode == Settings::AppletMode::HandheldConsoleMode) { + WatchSteamPadInputs(); + LearnBluetoothPads(); + } + // Redrawn faster than the 250ms tick, only when who is active changes or while animating + if (overlay_rows.empty()) { + if (!controller_hints.empty()) { + RenderControllerOverlay(); + } + return; + } + const auto now = std::chrono::steady_clock::now(); + std::set active; + for (const auto& row : overlay_rows) { + if (row.pad == 0) { + continue; + } + const auto last = + input_subsystem ? input_subsystem->GetSDLLastActivity(row.pad) : std::nullopt; + if (PadInputHeld(row.pad) || (last && now - *last < std::chrono::milliseconds{250})) { + active.insert(row.pad); + } + } + // ...and when a green/red flash blinks + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + std::vector blink; + for (const auto& row : overlay_rows) { + blink.push_back(row.flash != 0 && BlinkOn(now_ms - row.flash_since)); + } + const bool pressed = PollButtonHistory(); + const auto& layout = render_window->GetFramebufferLayout(); + const float room = Common::OverlayRoomTopLeft( + static_cast(layout.screen.right - layout.screen.left)); + const bool room_changed = std::exchange(overlay_room, room) != room; + if (pressed || OverlayAnimating() || active != overlay_active_pads || blink != overlay_blink || + room_changed) { + RenderControllerOverlay(); + } +} + +bool MainWindow::PollButtonHistory() { + if (!QtCommon::system || !QtCommon::system->IsPoweredOn()) { + return false; + } + auto& hid = QtCommon::system->HIDCore(); + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + bool pressed = false; + const qint64 life_ms = Settings::values.console_history_seconds.GetValue() * 1000LL; + const std::size_t size = + static_cast(Settings::values.console_history_size.GetValue()); + // A controller that left takes its history with it (back, it starts over) + std::erase_if(press_history, [this, &pressed](const auto& entry) { + const bool kept = + std::any_of(overlay_rows.begin(), overlay_rows.end(), [&](const auto& row) { + return row.pad != 0 && overlay_row_history[row.player] == entry.first; + }); + pressed |= !kept; + return !kept; + }); + for (const auto& row : overlay_rows) { + if (row.pad == 0 || row.player < 0 || row.player >= 8 || + overlay_row_history[row.player].empty()) { + continue; + } + const int i = row.player; + const std::string& ident = overlay_row_history[i]; + const bool fresh = history_ident[i] != ident; + history_ident[i] = ident; + const auto& player = Settings::values.players.GetValue()[i]; + auto* controller = i == 0 && player.controller_type == Settings::ControllerType::Handheld + ? hid.GetEmulatedController(Core::HID::NpadIdType::Handheld) + : hid.GetEmulatedControllerByIndex(i); + if (!controller) { + continue; + } + // Entries gone through their life and shrink leave the history + auto& history = press_history[ident]; + while (!history.empty() && now_ms - history.front().at > life_ms + 400) { + history.pop_front(); + pressed = true; + } + // Presses within 50ms of an input's first (about three frames) are one input + constexpr qint64 together_ms = 50; + const auto record = [&](int button) { + qint64 at = now_ms; + if (!history.empty() && now_ms - history.back().at <= together_ms) { + const qint64 group = history.back().at; + // (A stick turning again is a new input, not a second press) + const bool same_stick = + button >= StickMoveButton && + std::any_of(history.begin(), history.end(), [&](const ButtonPress& p) { + return p.at == group && p.button >= StickMoveButton && + (p.button - StickMoveButton) / 4 == (button - StickMoveButton) / 4; + }); + if (!same_stick) { + at = group; + } + } + history.push_back({button, at}); + std::set inputs; + for (const auto& press : history) { + inputs.insert(press.at); + } + while (inputs.size() > size) { + const qint64 oldest = history.front().at; + while (!history.empty() && history.front().at == oldest) { + history.pop_front(); + } + inputs.erase(oldest); + } + pressed = true; + }; + // A stick counts when it leaves the middle, and again when it turns another way + const auto sticks = controller->GetSticksValues(); + for (int s = 0; s < 2; ++s) { + const float x = sticks[static_cast(s)].x.value; + const float y = sticks[static_cast(s)].y.value; + const float reach = std::hypot(x, y); + auto& pointing = stick_direction[i][static_cast(s)]; + if (reach > 0.6f) { + const int direction = std::abs(x) > std::abs(y) ? (x < 0 ? 0 : 2) : (y > 0 ? 1 : 3); + if (direction + 1 != pointing) { + pointing = direction + 1; + if (!fresh) { + record(StickMoveButton + s * 4 + direction); + } + } + } else if (reach < 0.3f) { + pointing = 0; + } + } + const auto values = controller->GetButtonsValues(); + auto& down = buttons_down[i]; + for (int b = 0; b < Settings::NativeButton::NumButtons; ++b) { + const bool held = values[b].value; + if (held && !down[b] && !fresh) { + record(b); + } + down[b] = held; + } + } + return pressed; +} + +void MainWindow::ApplyConsoleControllerMode() { + // (Closing: the controllers' inputs are unloaded; a reload now would outlive the input + // engines and crash at exit) + if (closing) { + return; + } + // A tick longer than 250ms means a controller or the system held Eden up: always logged + struct SlowCheckWarning { + std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + ~SlowCheckWarning() { + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + if (ms > 250) { + LOG_WARNING(Frontend, "console-mode: controller check took {} ms", ms); + } + } + } slow_check_warning; + const auto mode = Settings::values.controller_applet_mode.GetValue(); + const bool handheld = mode == Settings::AppletMode::HandheldConsoleMode; + const bool console = mode == Settings::AppletMode::ConsoleMode; + // The mode, when it changes (from the Controllers tab or System > Applets): always logged + if (mode != logged_mode) { + LOG_INFO(Frontend, "console-mode: mode {} -> {}", + logged_mode ? AppletModeName(*logged_mode) : "(start)", AppletModeName(mode)); + logged_mode = mode; + // Debug Probes: what every report needs, once per mode + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: setup steam={} bypass={} overlay={} hints={} history={} " + "inputs={} expire={}s gaps={} seat_profiles=\"{}\"", + Common::RunningUnderSteam(), + Common::GetControllerConsoleState().steam_bypassed.load(), + Settings::values.console_debug_overlay.GetValue(), + Settings::values.console_hints.GetValue(), + Settings::values.console_history.GetValue(), + Settings::values.console_history_size.GetValue(), + Settings::values.console_history_seconds.GetValue(), + Settings::values.console_history_gaps.GetValue(), + Settings::values.modern_seat_profiles.GetValue()); + } + } + // The handheld's own pad is stored per way of launching: the Steam handle under Steam, the + // raw pad's identity otherwise. A Steam virtual pad's GUID stored as the identity (older + // builds) never matches a raw pad: dropped, so auto-detection works again. + if (!Common::SteamPadsInUse()) { + const std::string identity = Settings::values.builtin_pad_identity.GetValue(); + if (identity.size() >= 25 && identity.starts_with("guid:") && + identity.compare(5 + 8, 4, "de28") == 0 && identity.compare(5 + 16, 4, "ff11") == 0) { + Settings::values.builtin_pad_identity.SetValue(std::string{}); + Settings::values.builtin_pad_manual.SetValue(false); + LOG_INFO(Frontend, + "console-mode: stored handheld was a Steam virtual pad ({}), " + "back to auto-detect for direct launches", + identity); + } + } + // A declaration with no identity (older builds) would pin "no handheld" forever: undone + if (Settings::values.builtin_pad_manual.GetValue() && + Settings::values.builtin_pad_identity.GetValue().empty()) { + Settings::values.builtin_pad_manual.SetValue(false); + LOG_INFO(Frontend, "console-mode: declared handheld had no identity, back to auto"); + } + + // The live gamepads, keyed by connection instance (always unique); pad_identity keeps the + // stable identity used to reclaim slots. + // + // Under Steam the virtual pads share one GUID and Steam moves controllers between them, so + // the Steam handle (which follows the controller) is the identity. Steam fills it in a + // moment after a pad appears: until then the pad isn't live (at most 3 seconds). + std::map live; + /// Under Steam: real devices unhidden for their motion (never players) + std::vector motion_devices; + { + static std::map first_seen; + const auto now = std::chrono::steady_clock::now(); + std::set present; + const JoystickApiLock lock; + int count = 0; + SDL_JoystickID* ids = SDL_GetGamepads(&count); + // Steam's virtual pads around (none when Steam Input is off for Eden) + Common::GetControllerConsoleState().steam_pads_present = + std::any_of(ids, ids + count, [](SDL_JoystickID id) { return IsSteamVirtualPad(id); }); + for (int i = 0; i < count; ++i) { + const u32 id = ids[i]; + present.insert(id); + first_seen.try_emplace(id, now); + // Bypassed: the real controllers play, Steam's virtual pads are left alone + if (Common::GetControllerConsoleState().steam_bypassed && IsSteamVirtualPad(id)) { + continue; + } + // A real device Steam hid, opened again for its motion: never a player + if (Common::SteamPadsInUse() && !IsSteamVirtualPad(id) && + Common::SteamHidModel(PadVendor(id), + PadProduct(id))) { + motion_devices.push_back(id); + // (Only marked once logged: Debug Probes turned on later still describes it) + static std::set probed; + if (Common::ControllerProbes() && probed.insert(id).second) { + std::string twins; + const Uint16 vendor = PadVendor(id); + const Uint16 product = PadProduct(id); + for (int k = 0; k < count; ++k) { + if (ids[k] != id && IsSteamVirtualPad(ids[k]) && + PadVendor(ids[k]) == vendor && + PadProduct(ids[k]) == product) { + twins += fmt::format("{} ", ids[k]); + } + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: real device inst={} {:04x}:{:04x} name={} " + "gyro={} path={} same model as virtual inst=[{}]", + id, vendor, product, PadName(id).toStdString(), PadHasGyro(id), + PadPath(id).toStdString(), twins); + } + } + continue; + } + std::string ident = PadIdentity(id); + if (Common::SteamPadsInUse() && IsSteamVirtualPad(id)) { + if (const Uint64 handle = PadSteamHandle(id)) { + ident = fmt::format("steamhandle:{:#x}", handle); + } else if (now - first_seen[id] < std::chrono::seconds{3}) { + continue; + } + } + // (For identifying a user's handheld from their log) + static std::set described; + if (Common::ControllerProbes() && described.insert(ident).second) { + const Uint16 vendor = PadVendor(id); + // (SDL has no name for an unknown type: null, which fmt refuses) + const char* type = SDL_GetGamepadStringForType(PadType(id)); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: device {:04x}:{:04x} name=\"{}\" type={} steam={} " + "gyro={} handheld_maker={} controller_maker={} ident={}", + vendor, PadProduct(id), PadName(id).toStdString(), + type ? type : "unknown", IsSteamVirtualPad(id), PadHasGyro(id), + IsHandheldMaker(vendor), IsControllerMaker(vendor), ident); + } + } + live[id] = std::move(ident); + } + SDL_free(ids); + std::erase_if(first_seen, [&present](const auto& e) { return !present.contains(e.first); }); + } + + // The Deck's controller only reports motion while its sensors are on, which Steam leaves off + // unless its layout uses the gyro (in Desktop Mode too): when it goes silent, ask again (at + // most every 2 seconds). Under Steam it is a motion device, else one of the pads. + { + const auto is_deck = [](u32 id) { + return PadVendor(id) == 0x28DE && PadProduct(id) == 0x1205; + }; + std::optional deck; + if (const auto it = std::find_if(motion_devices.begin(), motion_devices.end(), is_deck); + it != motion_devices.end()) { + deck = *it; + } else if (!Common::SteamPadsInUse()) { + for (const auto& [id, ident] : live) { + if (is_deck(id) && PadPath(id).startsWith(QStringLiteral("/dev/hidraw"))) { + deck = id; + break; + } + } + } + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + if (deck && now_ms - deck_motion_asked_at >= 2000 && PadMotionSilent(*deck)) { + deck_motion_asked_at = now_ms; + const QString path = PadPath(*deck); + EnableDeckMotion(path); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: deck motion silent, asked for it via {}", + path.toStdString()); + } + } + } + + // A controller Steam moved to another virtual pad takes its slot along + if (Common::SteamPadsInUse()) { + static std::map handle_inst; + std::map moves; // to <- from + for (const auto& [id, ident] : live) { + if (!ident.starts_with("steamhandle:")) { + continue; + } + const auto it = handle_inst.find(ident); + if (it != handle_inst.end() && it->second != id) { + moves[id] = it->second; + } + } + if (!moves.empty()) { + std::map old_slot; + std::set involved; + for (const auto& [to, from] : moves) { + involved.insert(to); + involved.insert(from); + } + for (const u32 inst : involved) { + if (const auto it = pad_slots.find(inst); it != pad_slots.end()) { + old_slot[inst] = it->second; + pad_slots.erase(it); + } + pad_identity.erase(inst); + } + u32 new_builtin = builtin_pad_id; + const std::set benched_before = benched_pads; + const auto address_before = inst_address; + const auto origin_before = pad_origin; + const auto guessed_before = pad_guessed; + for (const auto& [to, from] : moves) { + const auto slot = old_slot.find(from); + LOG_INFO(Frontend, "console-mode: Steam moved {} from inst={} to inst={} (P{})", + live[to], from, to, slot != old_slot.end() ? slot->second + 1 : 0); + // Steam moves a controller on its first use and says so later, so its input + // already came on the new pad: the one controller that had input with this pad + // just before is the one moved (a match made meanwhile is put right) + const std::string key = BluetoothKey(to); + if (last_lone_input && last_lone_input->pad == to && + std::chrono::steady_clock::now() - last_lone_input->at < + std::chrono::seconds{5} && + last_lone_input->controller.size() == 17 && pad_bluetooth.contains(key)) { + const std::string& address = last_lone_input->controller; + const auto had = pad_bluetooth.find(key); + if (had->second != address) { + // (The controller it was taken for is unsure again) + for (auto& [other, other_address] : pad_bluetooth) { + if (other != key && other_address == address) { + other_address.clear(); + pad_bt_pending.insert(other); + } + } + LOG_INFO(Frontend, + "console-mode: inst={} ({}) told apart by the input Steam moved " + "it for: bluetooth={}{}", + to, key, address, + had->second.empty() ? std::string{} + : " (was " + had->second + ")"); + had->second = address; + pad_bt_pending.erase(key); + } + } + if (slot != old_slot.end()) { + pad_slots[to] = slot->second; + pad_identity[to] = live[to]; + } + if (builtin_pad_id == from) { + new_builtin = to; + } + // (From the state before the moves: two pads can trade instances) + if (benched_before.contains(from)) { + benched_pads.insert(to); + } else { + benched_pads.erase(to); + } + const auto carry = [from, to](auto& now_map, const auto& before) { + if (const auto it = before.find(from); it != before.end()) { + now_map[to] = it->second; + } else { + now_map.erase(to); + } + }; + carry(inst_address, address_before); + carry(pad_origin, origin_before); + if (guessed_before.contains(from)) { + pad_guessed.insert(to); + } else { + pad_guessed.erase(to); + } + } + builtin_pad_id = new_builtin; + } + handle_inst.clear(); + for (const auto& [id, ident] : live) { + if (ident.starts_with("steamhandle:")) { + handle_inst[ident] = id; + } + } + } + + // Publish the live pads to the settings UI, and serve Identify: the next pad with a button + // held becomes the handheld's own. Whatever the mode (the tab declares it before OK). + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.pads.clear(); + // Every pad is offered, possible handheld controls first (they can pose as a + // standalone controller, such as an emulated DualSense) + std::vector> standalone; + for (const auto& [id, ident] : live) { + // Under Steam the entries carry the Steam handle (every pad shares one GUID) + const Uint64 handle = Common::SteamPadsInUse() ? PadSteamHandle(id) : 0; + auto entry = std::pair{PadName(id).toStdString(), + handle ? fmt::format("steamhandle:{:#x}", handle) : ident}; + if (IsPlainlyStandalone(id)) { + standalone.push_back(std::move(entry)); + } else { + state.pads.push_back(std::move(entry)); + } + } + state.builtin_pads = state.pads.size(); + state.pads.insert(state.pads.end(), standalone.begin(), standalone.end()); + // A tap is shorter than 250ms: poll faster while Identify waits, while twins wait for + // a press, and while paused for a disconnect (for +) + console_mode_timer.start(std::chrono::milliseconds{ + state.identify_armed || !disconnect_paused.empty() ? 50 + : pad_bt_pending.empty() ? 250 + : 100}); + if (state.identify_armed) { + const auto now = std::chrono::steady_clock::now(); + if (now - state.identify_armed_at > std::chrono::seconds{15}) { + state.identify_armed = false; + } else { + for (const auto& [id, ident] : live) { + // (Any pad: the user presses on the handheld's own on purpose) + if (PadAnyButtonPressed(id)) { + state.identify_armed = false; + builtin_pad_id = id; + builtin_identity = ident; + builtin_seen_first = true; + builtin_warned = false; + // Each way of launching keeps its own: the other one's stays + const Uint64 handle = Common::SteamPadsInUse() ? PadSteamHandle(id) : 0; + if (Common::SteamPadsInUse()) { + if (handle) { + Settings::values.builtin_pad_steam_handle.SetValue( + fmt::format("{:#x}", handle)); + Settings::values.builtin_pad_steam_manual.SetValue(true); + } + } else { + Settings::values.builtin_pad_identity.SetValue(ident); + Settings::values.builtin_pad_manual.SetValue(true); + } + LOG_INFO(Frontend, + "console-mode: identify captured inst={} ident={} " + "steam_handle={:#x}", + id, ident, handle); + break; + } + } + } + } + } + + // Under Steam the picker declares a pad by its Steam handle + { + const std::string declared = Settings::values.builtin_pad_identity.GetValue(); + constexpr std::string_view prefix = "steamhandle:"; + if (declared.starts_with(prefix)) { + const std::string handle = declared.substr(prefix.size()); + for (const auto& [id, ident] : live) { + if (fmt::format("{:#x}", PadSteamHandle(id)) == handle) { + builtin_pad_id = id; + break; + } + } + // The identity is the direct launches' own: a Steam pick only sets the handle + Settings::values.builtin_pad_steam_handle.SetValue(handle); + Settings::values.builtin_pad_steam_manual.SetValue(true); + Settings::values.builtin_pad_identity.SetValue(std::string{}); + Settings::values.builtin_pad_manual.SetValue(false); + LOG_INFO(Frontend, "console-mode: picker declared Steam handle {} inst={}", handle, + builtin_pad_id); + } + } + + // The overlay and hints only exist in the automatic modes: so do their View menu toggles + ui->action_Show_Controller_Overlay->setVisible(handheld || console); + ui->action_Show_Controller_Hints->setVisible(handheld || console); + if (!handheld && !console) { + if (!overlay_rows.empty()) { + overlay_rows.clear(); + RenderControllerOverlay(); + } + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.engine_active = false; + state.players.clear(); + state.handheld_name.clear(); + state.handheld_has_player = false; + state.swap_requests.clear(); + state.remove_requests.clear(); + } + benched_pads.clear(); + if (console_profile) { + // Leaving the console modes: the user's own players come back exactly as they were + RestoreConsoleProfile(); + pad_slots.clear(); + pad_identity.clear(); + pad_sets.clear(); + address_origin.clear(); + address_claims.clear(); + slot_set = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; + set_default_of.fill(0); + set_before_profile.clear(); + LOG_INFO(Frontend, "console-mode: left, the user's players are restored"); + if (QtCommon::system && QtCommon::system->IsPoweredOn()) { + QtCommon::system->HIDCore().ReloadInputDevices(); + } + } + if (builtin_seen_first) { + // Leaving forgets the auto-detected handheld; a declared one stays + builtin_pad_id = 0; + builtin_identity.clear(); + builtin_seen_first = false; + builtin_warned = false; + // (The Steam handle stays: it is the same controller in every session) + if (!Settings::values.builtin_pad_manual.GetValue()) { + Settings::values.builtin_pad_identity.SetValue(std::string{}); + } + } + return; + } + if (!console_profile) { + // Entering a console mode: the user's players are kept aside; the engine's changes stay + // runtime only (see SaveAllConfigValues) + console_profile = Settings::values.players.GetValue(true); + game_profiles.reset(); + session_sets = ModernStartSets(*console_profile); + ApplySeatProfiles(); + LOG_INFO(Frontend, "console-mode: entered, the user's players are kept aside"); + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: entered with profile P1 motions {}", + MotionTrace((*console_profile)[0])); + } + } + // A game with input profiles of its own: those players' sets are the profile's + if (Settings::values.players.UsingGlobal()) { + game_profiles.reset(); + } else if (!game_profiles) { + game_profiles = Settings::values.players.GetValue(); + for (size_t i = 0; i < game_profiles->size(); ++i) { + if (!(*game_profiles)[i].profile_name.empty()) { + LOG_INFO(Frontend, "console-mode: P{} uses the game's input profile \"{}\"", i + 1, + (*game_profiles)[i].profile_name); + } + } + } + if (builtin_identity.empty()) { + // Console Mode also needs to know the handheld's own pad: it carries player one's set + builtin_identity = Settings::values.builtin_pad_identity.GetValue(); + } + + // Names as the Controls tab lists them, by the device the pad is bound as + std::vector input_devices; + if (input_subsystem) { + input_devices = input_subsystem->GetInputDevices(); + } + const auto label_of = [&](u32 id, const std::string&) { + QString display; + if (input_subsystem) { + if (const auto binding = input_subsystem->GetSDLBinding(id)) { + for (const auto& device : input_devices) { + if (device.Get("engine", "") == "sdl" && + device.Get("guid", "") == binding->first && + device.Get("port", -1) == binding->second) { + display = QString::fromStdString(device.Get("display", "")); + break; + } + } + } + } + // Under Steam, SDL's names lag up to 3 seconds behind a move: until SDL has Steam's + // controller type, the name is Steam's controller's + if (const auto slot = SteamSlotOf(id); + slot && SDL_GetGamepadTypeForID(id) != slot->type) { + switch (slot->type) { + case SDL_GAMEPAD_TYPE_XBOX360: + display = QStringLiteral("Xbox 360 Controller"); + break; + case SDL_GAMEPAD_TYPE_XBOXONE: + display = QStringLiteral("Xbox One Controller"); + break; + case SDL_GAMEPAD_TYPE_PS4: + display = QStringLiteral("PS4 Controller"); + break; + case SDL_GAMEPAD_TYPE_PS5: + display = QStringLiteral("DualSense Wireless Controller"); + break; + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO: + display = QStringLiteral("Nintendo Switch Pro Controller"); + break; + default: + display = slot->name.startsWith(QLatin1Char('#')) ? PadName(id) : slot->name; + break; + } + } + // (Steam's names can be translation keys: not a name to show) + if (display.startsWith(QLatin1Char('#'))) { + display = PadName(id); + } + return std::pair{display, PadName(id)}; + }; + + // Debug Probes: the pad identities and the slot mapping, every tick + if (Common::ControllerProbes()) { + std::string trace; + for (const auto& [id, ident] : live) { + const auto it = pad_slots.find(id); + trace += fmt::format("\n pad inst={} slot={} idx={} name={} path={} ident={}", id, + it == pad_slots.end() ? -1 : static_cast(it->second), + PadPlayerIndex(id), PadName(id).toStdString(), + PadPath(id).toStdString(), ident); + } + LOG_INFO(Frontend, "console-mode: live={}{}, builtin={}{}", trace, + live.empty() ? " (none)" : "", builtin_pad_id, + builtin_seen_first ? "" : " (unlatched)"); + } + { + // Steam fills a virtual pad's handle, type and name a moment after it appears: traced + static std::map traced_steam; + for (const auto& [id, ident] : live) { + const std::string desc = fmt::format( + "steam_handle={:#x} type={} name={}", PadSteamHandle(id), + static_cast(PadType(id)), PadName(id).toStdString()); + auto& last = traced_steam[id]; + if (last != desc) { + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: pad inst={} slot={} {}", id, + pad_slots.contains(id) ? pad_slots.at(id) + 1 : 0, desc); + } + last = desc; + } + } + + static std::set traced_insts; + for (const auto& [id, ident] : live) { + if (traced_insts.insert(id).second) { + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: pad connected inst={} ident={} name={} idx={} gyro={} " + "steam_handle={:#x} type={} real_type={}", + id, ident, PadName(id).toStdString(), PadPlayerIndex(id), + PadHasGyro(id), PadSteamHandle(id), + static_cast(PadType(id)), + static_cast(SDL_GetRealGamepadTypeForID(id))); + } + } + } + for (auto it = traced_insts.begin(); it != traced_insts.end();) { + if (live.find(*it) == live.end()) { + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: pad disconnected inst={}", *it); + } + it = traced_insts.erase(it); + } else { + ++it; + } + } + } + + // Turn Off: Eden disconnects a Bluetooth controller (most turn off); it sits out until it + // reconnects, and the next controller to connect takes the player + { + // (By the controller, see BluetoothKey) + std::map keys; + std::set live_keys; + for (const auto& [id, ident] : live) { + keys[id] = BluetoothKey(id); + live_keys.insert(keys[id]); + } + std::erase_if(pad_bluetooth, [&](const auto& e) { return !live_keys.contains(e.first); }); + std::erase_if(pad_bt_pending, [&](const auto& key) { return !live_keys.contains(key); }); +#ifdef __linux__ + for (const auto& [id, ident] : live) { + if (!pad_bluetooth.contains(keys[id])) { + const PadBluetooth found = FindPadBluetooth(id); + pad_bluetooth[keys[id]] = found.address; + if (found.unsure) { + pad_bt_pending.insert(keys[id]); + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: inst={} bluetooth={}", id, + found.unsure ? std::string{"unsure until a button press"} + : found.address.empty() ? std::string{"no"} + : found.address); + } + } + } +#endif + std::vector removals; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + removals.swap(state.remove_requests); + } + for (const int player : removals) { + const auto held = std::find_if(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + return e.second == player && live.contains(e.first); + }); + if (player < 0 || player >= 10 || held == pad_slots.end()) { + continue; + } + const u32 id = held->first; + if (pad_bluetooth[keys[id]].empty()) { + continue; // (Only a Bluetooth controller is turned off) + } + pad_slots.erase(held); + pad_identity.erase(id); + benched_pads.insert(id); + turned_off_at[player] = QDateTime::currentMSecsSinceEpoch(); + const std::string address = pad_bluetooth[keys[id]]; +#ifdef __linux__ + DisconnectBluetooth(address); +#endif + LOG_INFO(Frontend, "console-mode: P{} turned off, inst={} {}", player + 1, id, address); + } + std::erase_if(benched_pads, [&](u32 id) { return !live.contains(id); }); + std::erase_if(live, [&](const auto& e) { return benched_pads.contains(e.first); }); + } + + // The handheld's controller removed with its ✕: auto-detect leaves it alone until it is + // allowed again (or picked, or identified) + { + bool remove = false; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + remove = std::exchange(state.builtin_remove_request, false); + } + if (remove && builtin_pad_id && live.contains(builtin_pad_id)) { + const std::string key = PadKey(builtin_pad_id, live[builtin_pad_id]); + Settings::values.builtin_pad_blocked.SetValue(key); + Settings::values.builtin_pad_steam_handle.SetValue(std::string{}); + Settings::values.builtin_pad_steam_manual.SetValue(false); + Settings::values.builtin_pad_identity.SetValue(std::string{}); + Settings::values.builtin_pad_manual.SetValue(false); + builtin_pad_id = 0; + builtin_identity.clear(); + LOG_INFO(Frontend, "console-mode: handheld controller {} removed: auto-detect skips it", + key); + } + } + const std::string blocked_key = Settings::values.builtin_pad_blocked.GetValue(); + const auto is_blocked = [&](u32 id) { + return !blocked_key.empty() && live.contains(id) && PadKey(id, live[id]) == blocked_key; + }; + // The handheld's rules apply only to the pad proven to be its own: the latched instance + // still alive, its Steam handle under Steam, or a unique identity match. Otherwise none. + u32 builtin_match_id = 0; + bool builtin_valid = false; + const bool steam_handle_known = + Common::SteamPadsInUse() && !Settings::values.builtin_pad_steam_handle.GetValue().empty(); + if (builtin_pad_id && live.find(builtin_pad_id) != live.end() && !steam_handle_known && + !is_blocked(builtin_pad_id)) { + builtin_match_id = builtin_pad_id; + builtin_valid = true; + } else if (Common::SteamPadsInUse()) { + // Under Steam neither the GUID nor Steam's player index tells the Deck's pad: its Steam + // handle, stable across sessions, does. With none stored yet, a lone pad is latched. + const std::string want = Settings::values.builtin_pad_steam_handle.GetValue(); + int count = 0; + u32 holder = 0; + if (!want.empty()) { + for (const auto& [id, ident] : live) { + if (fmt::format("{:#x}", PadSteamHandle(id)) != want || is_blocked(id)) { + continue; + } + if (!Settings::values.builtin_pad_steam_manual.GetValue() && + !CouldBeHandheld(PadVendor(id), PadProduct(id))) { + // A standalone controller stored as the handheld's (older builds) is dropped, + // unless the user declared it + LOG_INFO(Frontend, + "console-mode: stored handheld {} is a {:04x}:{:04x} " + "controller, not a handheld's: forgotten", + want, PadVendor(id), PadProduct(id)); + Settings::values.builtin_pad_steam_handle.SetValue(std::string{}); + continue; + } + ++count; + holder = id; + } + } + bool adopted = false; + if (count == 0) { + // No pad holds the stored handle (it can change after a reboot or update): the + // Deck's controller is the only one Steam reports with no gamepad type. Adopted when + // exactly one pad fits. + int fits = 0; + u32 fit = 0; + for (const auto& [id, ident] : live) { + if (PadType(id) == SDL_GAMEPAD_TYPE_UNKNOWN && + PadSteamHandle(id) != 0 && CouldBeHandheld(PadVendor(id), PadProduct(id)) && + !is_blocked(id)) { + ++fits; + fit = id; + } + } + if (fits == 1) { + count = 1; + holder = fit; + adopted = true; + } else if (want.empty() && live.size() == 1 && + !IsStandaloneControllerType(PadType(live.begin()->first)) && + CouldBeHandheld(PadVendor(live.begin()->first), + PadProduct(live.begin()->first)) && + !is_blocked(live.begin()->first)) { + count = 1; + holder = live.begin()->first; + } + } + if (adopted) { + const std::string handle = fmt::format("{:#x}", PadSteamHandle(holder)); + if (handle != want) { + Settings::values.builtin_pad_steam_handle.SetValue(handle); + Settings::values.builtin_pad_steam_manual.SetValue(false); + LOG_INFO(Frontend, + "console-mode: builtin adopted inst={} as the deck's controller, " + "Steam handle {} -> {}", + holder, want.empty() ? std::string{"(none)"} : want, handle); + } + } + static std::string last_resolved; + const std::string resolved = + count == 1 ? fmt::format("inst={}", holder) : fmt::format("{} matches", count); + if (resolved != last_resolved) { + last_resolved = resolved; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: builtin by Steam handle {}: {}", + want.empty() ? std::string{"(none stored)"} : want, resolved); + } + } + if (count == 1) { + builtin_match_id = holder; + builtin_valid = true; + } + } else if (!builtin_identity.empty()) { + int identity_count = 0; + u32 identity_holder = 0; + for (const auto& [id, ident] : live) { + if (ident == builtin_identity && !is_blocked(id)) { + ++identity_count; + identity_holder = id; + } + } + if (identity_count == 1) { + builtin_match_id = identity_holder; + builtin_valid = true; + } + if (!builtin_valid) { + static int last_count = -1; + if (identity_count != last_count) { + last_count = identity_count; + LOG_INFO(Frontend, + "console-mode: builtin unresolved ({} pads match identity {}), " + "built-in rules paused", + identity_count, builtin_identity); + } + } + } + if (builtin_valid) { + builtin_pad_id = builtin_match_id; + if (Common::SteamPadsInUse() && + Settings::values.builtin_pad_steam_handle.GetValue().empty()) { + // Latch the handheld's Steam handle as soon as Steam has filled it in + if (const Uint64 handle = PadSteamHandle(builtin_match_id)) { + Settings::values.builtin_pad_steam_handle.SetValue(fmt::format("{:#x}", handle)); + LOG_INFO(Frontend, "console-mode: builtin Steam handle latched {:#x} inst={}", + handle, builtin_match_id); + } + } + } + + if (handheld) { + const std::string stored{Settings::values.builtin_pad_identity.GetValue()}; + const bool manual = Settings::values.builtin_pad_manual.GetValue(); + if (manual) { + // A declared pad (picker or Identify): resolve its current instance + builtin_identity = stored; + builtin_seen_first = true; + builtin_pad_id = 0; + if (builtin_valid) { + builtin_pad_id = builtin_match_id; + } + } else if (!builtin_pad_id && !stored.empty() && !Common::SteamPadsInUse()) { + // (Under Steam the stored GUID is every pad's: the Steam handle resolves it) + for (const auto& [id, ident] : live) { + if (ident == stored) { + builtin_pad_id = id; + builtin_identity = ident; + builtin_seen_first = true; + builtin_match_id = id; + builtin_valid = true; + break; + } + } + } + // (Direct launches only: under Steam the handle resolves it above) + if (!builtin_pad_id && !manual && !Common::SteamPadsInUse()) { + auto handheld_candidate = [&]() -> u32 { + std::vector makers; + std::vector decks; + for (const auto& [id, ident] : live) { + if (IsSteamVirtualPad(id) || is_blocked(id)) { + // (A virtual pad carries Valve's vendor: no evidence) + continue; + } + const Uint16 vendor = PadVendor(id); + if (IsHandheldMaker(vendor) && CouldBeHandheld(vendor, PadProduct(id))) { + makers.push_back(id); + // Valve also makes standalone controllers: the deck's own wins + if (vendor == 0x28DE && PadProduct(id) == 0x1205) { + decks.push_back(id); + } + } + } + if (makers.size() == 1) { + return makers.front(); + } + return decks.size() == 1 ? decks.front() : 0; + }; + const u32 candidate = handheld_candidate(); + // The handheld's own from this tick on: the claims below must already skip it, or + // with externals on at launch it takes P1 and leaves it empty a tick later + if (candidate) { + LOG_INFO(Frontend, "console-mode: handheld {:04x}:{:04x} detected by its maker", + PadVendor(candidate), PadProduct(candidate)); + builtin_pad_id = candidate; + builtin_identity = live[candidate]; + builtin_seen_first = true; + Settings::values.builtin_pad_identity.SetValue(live[candidate]); + builtin_warned = false; + builtin_match_id = candidate; + builtin_valid = true; + } else if (live.size() == 1 && !IsControllerMaker(PadVendor(live.begin()->first)) && + !is_blocked(live.begin()->first)) { + LOG_INFO(Frontend, "console-mode: handheld {:04x}:{:04x} assumed: the only pad", + PadVendor(live.begin()->first), PadProduct(live.begin()->first)); + builtin_match_id = live.begin()->first; + builtin_valid = true; + builtin_pad_id = live.begin()->first; + builtin_identity = live.begin()->second; + builtin_seen_first = true; + Settings::values.builtin_pad_identity.SetValue(live.begin()->second); + // (A lone Steam virtual pad is the handheld's own: only unknown raw hardware is + // noted) + if (!builtin_warned && !IsSteamVirtualPad(live.begin()->first)) { + QMessageBox::information( + this, tr("Assign Handheld Controller"), + tr("Unknown handheld detected.\n\n" + "Careful: a less popular controller could be labeled as a handheld.")); + builtin_warned = true; + } + } + // Nothing identified: this tick works like Modern; the next looks again + } + } + + auto& players = Settings::values.players.GetValue(); + bool changed = false; + + // A disconnected pad keeps its reservation so a quick blip reclaims its player, for a grace + // period only (a different controller must not leave a player dead) + { + static std::map pad_last_seen; + const auto now = std::chrono::steady_clock::now(); + for (const auto& [id, ident] : live) { + pad_last_seen[id] = now; + } + for (auto it = pad_slots.begin(); it != pad_slots.end();) { + const bool is_live = live.find(it->first) != live.end(); + const auto seen = pad_last_seen.find(it->first); + const bool expired = !is_live && seen != pad_last_seen.end() && + now - seen->second > std::chrono::seconds{45}; + if (expired) { + LOG_INFO(Frontend, "console-mode: reservation expired inst={} (slot {} free again)", + it->first, it->second); + pad_identity.erase(it->first); + pad_last_seen.erase(it->first); + it = pad_slots.erase(it); + changed = true; + } else { + ++it; + } + } + } + + // The handheld's own pad takes no player while external controllers are around: released, + // so they fill from player one. It claims player one again when they leave. + if (handheld && builtin_valid && live.size() > 1) { + for (auto it = pad_slots.begin(); it != pad_slots.end();) { + if (it->first == builtin_match_id) { + pad_identity.erase(it->first); + it = pad_slots.erase(it); + changed = true; + } else { + ++it; + } + } + } + + // Alone, the handheld's own pad is player one: the departed externals' reservations are + // dropped (they would push it down), and returning externals fill from player one + if (handheld && builtin_valid && live.size() == 1) { + for (auto it = pad_slots.begin(); it != pad_slots.end();) { + const bool own = it->first == builtin_match_id; + if (!own || it->second != 0) { + if (!own) { + LOG_INFO(Frontend, + "console-mode: handheld alone, dropping reservation inst={} " + "(slot {})", + it->first, it->second); + } + pad_identity.erase(it->first); + it = pad_slots.erase(it); + changed = true; + } else { + ++it; + } + } + } + + // The addresses of the Bluetooth controllers holding players; promises to away controllers + // last as long as a reservation (45 seconds) + const auto now_steady = std::chrono::steady_clock::now(); + for (const auto& [id, ident] : live) { + if (const auto known = pad_bluetooth.find(BluetoothKey(id)); + known != pad_bluetooth.end() && !known->second.empty()) { + // A controller just told apart: its battery is read now + if (auto& address = inst_address[id]; address != known->second) { + address = known->second; + battery_read.erase(id); + } + if (const auto origin = pad_origin.find(id); origin != pad_origin.end()) { + address_origin.try_emplace(known->second, origin->second); + } + } + } + std::erase_if(address_claims, [&](const auto& e) { + return now_steady - e.second.at > std::chrono::seconds{45}; + }); + + // New pads claim the lowest free slot (the handheld's own skipped while others are around), + // in player index order where there is one + std::set used; + for (const auto& [id, slot] : pad_slots) { + used.insert(slot); + } + std::vector claim_order; + claim_order.reserve(live.size()); + for (const auto& [id, ident] : live) { + claim_order.push_back(id); + } + std::sort(claim_order.begin(), claim_order.end(), [](u32 a, u32 b) { + const int ia = PadPlayerIndex(a); + const int ib = PadPlayerIndex(b); + if (ia != ib) { + return ia >= 0 && (ib < 0 || ia < ib); + } + return a < b; + }); + for (const u32 id : claim_order) { + const std::string& ident = live.at(id); + // The handheld's own is excluded by identity: its instance changes when it cycles + if (handheld && builtin_valid && id == builtin_match_id && live.size() > 1) { + continue; + } + // Steam's player index only orders the claims; slots fill lowest-free + if (pad_slots.find(id) == pad_slots.end()) { + // How many controllers are away (with several, an untold return is a guess) + const auto away = std::count_if(pad_slots.begin(), pad_slots.end(), + [&](const auto& e) { return !live.contains(e.first); }); + // A Bluetooth controller comes back to its player by address (Steam's handles don't + // survive a reconnect) + const std::string bt_key = BluetoothKey(id); + const auto known = pad_bluetooth.find(bt_key); + const std::string address = + known != pad_bluetooth.end() ? known->second : std::string{}; + if (!address.empty()) { + const auto own = + std::find_if(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + if (live.contains(e.first)) { + return false; + } + const auto held = inst_address.find(e.first); + return held != inst_address.end() && held->second == address; + }); + int slot = -1; + int origin = -1; + if (own != pad_slots.end()) { + slot = own->second; + const auto from = pad_origin.find(own->first); + origin = from != pad_origin.end() ? from->second : slot; + pad_identity.erase(own->first); + pad_slots.erase(own); + } else if (const auto claim = address_claims.find(address); + claim != address_claims.end() && !used.contains(claim->second.slot)) { + // (Its player was taken by a guess, then given back) + slot = claim->second.slot; + origin = claim->second.origin; + address_claims.erase(claim); + } + if (slot >= 0) { + LOG_INFO(Frontend, "console-mode: {} back as P{} by its Bluetooth address", + address, slot + 1); + pad_slots[id] = slot; + pad_identity[id] = ident; + pad_origin[id] = origin; + used.insert(slot); + changed = true; + continue; + } + } else if (pad_bt_pending.contains(bt_key) && away > 1) { + // Its address is about to be known: wait a moment rather than guess + const auto since = claim_wait_since.try_emplace(id, now_steady).first->second; + if (now_steady - since < std::chrono::seconds{3}) { + continue; + } + } + // Reclaim a reservation with a matching identity. Identical pads share one, so an + // ambiguous pad takes the lowest of them (one the game is paused for first), or a + // lower free slot + const auto before = [&](int slot, int other) { + const bool waited = disconnect_paused.contains(slot); + if (waited != disconnect_paused.contains(other)) { + return waited; + } + return slot < other; + }; + auto match = pad_slots.end(); + int matches = 0; + for (auto it = pad_slots.begin(); it != pad_slots.end(); ++it) { + if (live.find(it->first) == live.end() && pad_identity[it->first] == ident) { + if (match == pad_slots.end() || before(it->second, match->second)) { + match = it; + } + ++matches; + } + } + if (matches == 0 && Common::SteamPadsInUse()) { + // A new Steam handle on return: no reservation is provably its own, the lowest + // is taken + for (auto it = pad_slots.begin(); it != pad_slots.end(); ++it) { + if (live.find(it->first) == live.end() && + (match == pad_slots.end() || before(it->second, match->second))) { + match = it; + } + } + if (match != pad_slots.end()) { + matches = 2; + } + } + if (matches > 1) { + int lowest_free = -1; + for (int slot = 0; slot < ConsolePlayers; ++slot) { + if (used.find(slot) == used.end()) { + lowest_free = slot; + break; + } + } + if (lowest_free < 0 || match->second < lowest_free || + disconnect_paused.contains(match->second)) { + LOG_INFO(Frontend, + "console-mode: ambiguous reclaim inst={} ident={} ({} candidates) " + "takes the lowest, slot {} from dead inst={}", + id, ident, matches, match->second, match->first); + matches = 1; + } else { + match = pad_slots.end(); + matches = 0; + } + } + if (matches == 1) { + // A guess (several away): marked (?) until its address proves it. The player + // stays promised to its controller's address + const bool guessed = away > 1; + LOG_INFO(Frontend, + "console-mode: reclaim inst={} ident={} takes slot {} from dead inst={}{}", + id, ident, match->second, match->first, guessed ? " (a guess)" : ""); + const auto from = pad_origin.find(match->first); + pad_origin[id] = from != pad_origin.end() ? from->second : match->second; + if (guessed) { + pad_guessed.insert(id); + if (const auto held = inst_address.find(match->first); + held != inst_address.end()) { + address_claims[held->second] = {match->second, pad_origin[id], now_steady}; + } + } + // Its own set comes back at once (kept by the address, learned only later) + if (const auto held = inst_address.find(match->first); + !guessed && held != inst_address.end() && !pad_sets.contains(ident)) { + if (const auto set = pad_sets.find("bt:" + held->second); + set != pad_sets.end()) { + pad_sets[ident] = set->second; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: inst={} carries its own P{} set back", + id, set->second + 1); + } + } + } + pad_slots[id] = match->second; + pad_identity[id] = ident; + pad_identity.erase(match->first); + pad_slots.erase(match); + changed = true; + continue; + } + // Claim the lowest free slot + bool assigned = false; + for (int slot = 0; slot < ConsolePlayers && !assigned; ++slot) { + if (used.find(slot) == used.end()) { + LOG_INFO(Frontend, "console-mode: claim inst={} ident={} -> slot {}", id, ident, + slot); + pad_slots[id] = slot; + pad_identity[id] = ident; + // (A known Bluetooth controller keeps the player it started as) + const auto first = address_origin.find(address); + pad_origin[id] = first != address_origin.end() ? first->second : slot; + used.insert(slot); + assigned = true; + changed = true; + } + } + if (!assigned) { + // Retire reservations and retry, never a live pad's slot + for (auto it = pad_slots.begin(); it != pad_slots.end();) { + const bool live_pad = live.find(it->first) != live.end(); + if (!live_pad) { + pad_identity.erase(it->first); + it = pad_slots.erase(it); + } else { + ++it; + } + } + used.clear(); + for (const auto& [k, slot] : pad_slots) { + used.insert(slot); + } + for (int slot = 0; slot < ConsolePlayers; ++slot) { + if (used.find(slot) == used.end()) { + LOG_INFO(Frontend, "console-mode: claim inst={} ident={} -> slot {}", id, + ident, slot); + pad_slots[id] = slot; + pad_identity[id] = ident; + pad_origin[id] = slot; + used.insert(slot); + changed = true; + break; + } + } + } + } + } + + // What is kept per connection lasts while the pad is live or its player is held for it + { + const auto kept = [&](u32 id) { return live.contains(id) || pad_slots.contains(id); }; + std::erase_if(inst_address, [&](const auto& e) { return !kept(e.first); }); + std::erase_if(pad_origin, [&](const auto& e) { return !kept(e.first); }); + std::erase_if(pad_guessed, [&](u32 id) { return !kept(id); }); + std::erase_if(claim_wait_since, [&](const auto& e) { + return !live.contains(e.first) || pad_slots.contains(e.first); + }); + } + + // A guessed controller whose address became known stays, or moves to its real player + for (auto it = pad_guessed.begin(); it != pad_guessed.end();) { + const u32 id = *it; + const auto known = pad_bluetooth.find(BluetoothKey(id)); + const auto at = pad_slots.find(id); + if (!live.contains(id) || at == pad_slots.end() || known == pad_bluetooth.end() || + known->second.empty()) { + ++it; + continue; + } + const std::string& address = known->second; + const auto own = std::find_if(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + const auto held = inst_address.find(e.first); + return !live.contains(e.first) && held != inst_address.end() && held->second == address; + }); + if (own != pad_slots.end()) { + LOG_INFO(Frontend, "console-mode: {} was guessed as P{}: moved back to P{}", address, + at->second + 1, own->second + 1); + const auto from = pad_origin.find(own->first); + pad_origin[id] = from != pad_origin.end() ? from->second : own->second; + at->second = own->second; + pad_identity.erase(own->first); + pad_slots.erase(own); + changed = true; + } else { + address_claims.erase(address); // (the guess was right) + } + it = pad_guessed.erase(it); + } + + // Swaps from the settings or the overlay: the pads trade slots, for this session + { + std::vector> requests; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + requests.swap(state.swap_requests); + } + for (const auto& [a, b] : requests) { + if (a < 0 || b < 0 || a >= ConsolePlayers || b >= ConsolePlayers || a == b) { + continue; + } + u32 pad_a = 0; + u32 pad_b = 0; + for (const auto& [id, slot] : pad_slots) { + if (live.find(id) == live.end()) { + continue; + } + if (slot == a) { + pad_a = id; + } + if (slot == b) { + pad_b = id; + } + } + if (!pad_a && !pad_b) { + continue; + } + // Everything on the two players changes places, held players too (else a returning + // controller could find its player taken) + for (auto& [id, slot] : pad_slots) { + if (slot == a) { + slot = b; + } else if (slot == b) { + slot = a; + } + } + // (A swap is the user's say: a guess is settled) + pad_guessed.erase(pad_a); + pad_guessed.erase(pad_b); + LOG_INFO(Frontend, "console-mode: swap P{} <-> P{} (inst={} inst={})", a + 1, b + 1, + pad_a, pad_b); + changed = true; + } + } + + // Profiles picked in the Controls page go on the set the player's controller carries + { + std::vector picks; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + picks.swap(state.profile_requests); + } + std::optional input_profiles; + for (const auto& [player, name, restore] : picks) { + if (!console_profile || player < 0 || player >= static_cast(players.size())) { + continue; + } + const int set = slot_set[player]; + const bool game_set = game_profiles && !(*game_profiles)[set].profile_name.empty(); + auto& target = SessionSet(set); + if (name.empty() && restore) { + if (const auto before = set_before_profile.find(set); + before != set_before_profile.end()) { + target.buttons = before->second.buttons; + target.analogs = before->second.analogs; + target.motions = before->second.motions; + set_before_profile.erase(before); + } + } + if (!name.empty()) { + set_before_profile.try_emplace(set, target); + if (!input_profiles) { + input_profiles.emplace(); + } + const auto read = input_profiles->PeekProfile(name, player); + if (!read) { + LOG_WARNING(Frontend, "console-mode: input profile \"{}\" not found", name); + continue; + } + ApplyProfileMappings(target, *read); + } + target.profile_name = name; + // The seat remembers it: the next session's P{player} starts with it + SetSeatProfile(player, name); + LOG_INFO(Frontend, "console-mode: P{} input profile \"{}\" on the P{} set{}", + player + 1, name, set + 1, game_set ? " (the game's)" : ""); + changed = true; + } + } + + // Rumble requested from the Controllers tab: a short pulse on the player's controller + { + std::vector rumbles; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + rumbles.swap(state.rumble_requests); + } + for (const int player : rumbles) { + for (const auto& [id, slot] : pad_slots) { + if (slot != player || live.find(id) == live.end()) { + continue; + } + // Sent twice: a single request through Steam's virtual pads is sometimes lost + const u32 pad = id; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: rumble P{} inst={}", player + 1, pad); + } + PadRumble(pad, 0xC000, 300); + QTimer::singleShot(150, this, [pad] { PadRumble(pad, 0xC000, 200); }); + break; + } + } + } + + // Each live pad's player gets the pad's binding set, pointed at the pad (bindings address + // pads by guid and port, which follow attach order, so the slot map alone isn't the + // routing). The handheld's own pad carries player one's set; an external pad its own set, + // without the handheld's non-SDL motion, plus its SDL gyro when the set has no motion. + slot_set = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; + // Use Any Controller as Joy-Cons: a game that doesn't take Pro Controllers gets Dual + // Joy-Cons, else a single one held sideways. Only a Pro Controller player is changed (and + // put back), never a type set by hand. + { + std::optional supported; + bool horizontal = true; + if (QtCommon::system && QtCommon::system->IsPoweredOn() && + Settings::values.console_joycon_auto.GetValue()) { + supported = QtCommon::system->HIDCore().GetSupportedStyleTag(); + horizontal = QtCommon::system->HIDCore().IsJoyHoldHorizontal(); + } + for (int i = 0; i < static_cast(players.size()) && i < 8; ++i) { + auto& player = players[i]; + auto& from = joycon_auto_from[static_cast(i)]; + auto& set = joycon_auto_set[static_cast(i)]; + if (set && player.controller_type != *set) { + from.reset(); // (changed by hand since) + set.reset(); + } + const auto base = from ? *from : player.controller_type; + if (base != Settings::ControllerType::ProController) { + continue; + } + auto want = base; + if (supported && player.connected && supported->raw != Core::HID::NpadStyleSet::None && + !supported->fullkey) { + if (supported->joycon_dual) { + want = Settings::ControllerType::DualJoyconDetached; + } else if (supported->joycon_right) { + want = Settings::ControllerType::RightJoycon; + } else if (supported->joycon_left) { + want = Settings::ControllerType::LeftJoycon; + } + } + // A single Joy-Con is held as the game holds it: sideways or upright + const bool single = want == Settings::ControllerType::RightJoycon || + want == Settings::ControllerType::LeftJoycon; + const bool sideways = single && horizontal; + auto& was_sideways = joycon_auto_sideways[static_cast(i)]; + const auto say = [&](const QString& note) { + if (player.connected) { + controller_hints.push_back( + {QString{}, i, true, QDateTime::currentMSecsSinceEpoch(), false, note}); + } + }; + if (want == player.controller_type) { + // (The game turned its Joy-Cons: the bindings follow on the routing pass) + if (set && single && was_sideways != sideways) { + was_sideways = sideways; + LOG_INFO(Frontend, "console-mode: P{} now holds its Joy-Con {}", i + 1, + sideways ? "sideways" : "upright"); + say(sideways ? tr("P%1 plays as a sideways Joy-Con").arg(i + 1) + : tr("P%1 plays as an upright Joy-Con").arg(i + 1)); + } + continue; + } + player.controller_type = want; + changed = true; + if (want == base) { + from.reset(); + set.reset(); + was_sideways = false; + LOG_INFO(Frontend, "console-mode: P{} back to a Pro Controller", i + 1); + say(tr("P%1 plays as a Pro Controller again").arg(i + 1)); + continue; + } + from = base; + set = want; + was_sideways = sideways; + LOG_INFO(Frontend, "console-mode: P{} plays as {} (the game takes no Pro Controller)", + i + 1, + !single ? "Dual Joy-Cons" + : sideways ? "a sideways Joy-Con" + : "an upright Joy-Con"); + say(!single ? tr("P%1 plays as Dual Joy-Cons").arg(i + 1) + : sideways ? tr("P%1 plays as a sideways Joy-Con").arg(i + 1) + : tr("P%1 plays as an upright Joy-Con").arg(i + 1)); + } + // (For the player tabs: a single Joy-Con's mapping is the engine's) + std::array held{}; + for (std::size_t i = 0; i < held.size(); ++i) { + const auto& set = joycon_auto_set[i]; + if (set && (*set == Settings::ControllerType::RightJoycon || + *set == Settings::ControllerType::LeftJoycon)) { + held[i] = joycon_auto_sideways[i] ? 1 : 2; + } + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.joycon_held = held; + } + + if (input_subsystem && console_profile) { + const auto profile = ConsoleBindingSets(); + const auto is_handheld_motion = [&profile](const std::string& raw) { + if (raw.empty() || Common::ParamPackage{raw}.Get("engine", "") == "sdl") { + return false; + } + return std::find(profile[0].motions.begin(), profile[0].motions.end(), raw) != + profile[0].motions.end(); + }; + for (const auto& [id, ident] : live) { + const auto it = pad_slots.find(id); + if (it == pad_slots.end() || it->second < 0 || + it->second >= static_cast(players.size())) { + continue; + } + const auto binding = input_subsystem->GetSDLBinding(id); + if (!binding) { + continue; + } + const int slot = it->second; + const bool is_builtin = builtin_valid && id == builtin_match_id; + // The set's key: a Bluetooth controller's address (so its set follows it through + // reconnects), else its identity; a set made before the address is known moves to it + const auto address = inst_address.find(id); + const std::string set_key = + address != inst_address.end() ? "bt:" + address->second : ident; + if (set_key != ident && !pad_sets.contains(set_key) && pad_sets.contains(ident)) { + pad_sets[set_key] = pad_sets[ident]; + pad_sets.erase(ident); + } else if (set_key != ident && pad_sets.contains(set_key) && + pad_sets.contains(ident) && pad_sets[ident] == pad_sets[set_key]) { + // (Lent to it on a reclaim, until its address was known) + pad_sets.erase(ident); + } + // A controller keeps the set it first got: its slot's, else the first set nobody has + if (!is_builtin && !pad_sets.contains(set_key)) { + std::set taken; + for (const auto& [other, set] : pad_sets) { + taken.insert(set); + } + int set = slot; + for (int k = 0; taken.contains(set) && k < static_cast(players.size()); ++k) { + set = k; + } + if (taken.contains(set)) { + set = slot; + } + pad_sets[set_key] = set; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: inst={} carries the P{} set", id, set + 1); + } + } + // A set with no controller mappings gives the pad its default mapping, below + const auto has_sdl = [](const Settings::PlayerInput& p) { + return std::any_of(p.buttons.begin(), p.buttons.end(), [](const std::string& b) { + return !b.empty() && Common::ParamPackage{b}.Get("engine", "") == "sdl"; + }); + }; + const int set_index = is_builtin ? 0 : pad_sets[set_key]; + slot_set[slot] = set_index; + Settings::PlayerInput want = players[slot]; + // A set holding only a gone controller's default mapping is rebuilt for this one (an + // Xbox pad's has no gyro or back buttons). Not while that controller is connected: + // identical ones without an address share a set. + const u32 defaults_of = set_default_of[static_cast(set_index)]; + const bool others_defaults = defaults_of != 0 && defaults_of != id && + !live.contains(defaults_of) && + profile[set_index].profile_name.empty(); + if (others_defaults && Common::ControllerProbes()) { + LOG_INFO(Frontend, + "console-mode: P{} set holds inst={}'s defaults: rebuilt for inst={}", + set_index + 1, defaults_of, id); + } + if (has_sdl(profile[set_index]) && !others_defaults) { + const auto& own = profile[set_index]; + want.buttons = own.buttons; + want.analogs = own.analogs; + want.motions = own.motions; + want.profile_name = own.profile_name; + RouteSdlBindings(want, binding->first, binding->second); + } else { + // No controller mappings: the controller's default mapping, for this session + Common::ParamPackage device; + device.Set("engine", "sdl"); + device.Set("guid", binding->first); + device.Set("port", binding->second); + // A DSU server's motion stays; only SDL motion is the device's to fill + const auto kept_motions = profile[set_index].motions; + const bool motion_kept = + std::any_of(kept_motions.begin(), kept_motions.end(), [](const auto& m) { + return !m.empty() && + Common::ParamPackage{m}.Get("engine", "") == "cemuhookudp"; + }); + want.buttons.fill({}); + want.analogs.fill({}); + want.motions.fill({}); + for (const auto& [index, param] : + input_subsystem->GetButtonMappingForDevice(device)) { + want.buttons[index] = param.Serialize(); + } + for (const auto& [index, param] : + input_subsystem->GetAnalogMappingForDevice(device)) { + want.analogs[index] = param.Serialize(); + } + if (motion_kept) { + want.motions = kept_motions; + } else { + for (const auto& [index, param] : + input_subsystem->GetMotionMappingForDevice(device)) { + want.motions[index] = param.Serialize(); + } + } + want.profile_name.clear(); + // Kept for the session: rebuilt every tick, it would differ once a game polls the + // sensors and reload the input four times a second + auto& own = SessionSet(set_index); + own.buttons = want.buttons; + own.analogs = want.analogs; + own.motions = want.motions; + set_default_of[static_cast(set_index)] = id; + static std::set defaulted; + if (defaulted.insert(id).second) { + LOG_INFO(Frontend, + "console-mode: P{} set has no controller mappings: inst={} " + "gets its default mapping{}", + set_index + 1, id, motion_kept ? " (its set's motion kept)" : ""); + } + } + // Under Steam the virtual pad has no motion: take the paired real device's (the Deck, + // or the only one of a model) + u32 motion_source = 0; + if (Common::SteamPadsInUse()) { + const Uint16 vendor = is_builtin ? 0x28DE : PadVendor(id); + const Uint16 product = is_builtin ? 0x1205 : PadProduct(id); + const auto same_model = [vendor, product](u32 other) { + return PadVendor(other) == vendor && + PadProduct(other) == product; + }; + const auto real = + std::count_if(motion_devices.begin(), motion_devices.end(), same_model); + const auto virtuals = std::count_if( + live.begin(), live.end(), [&](const auto& e) { return same_model(e.first); }); + if (real == 1 && (is_builtin || virtuals == 1)) { + const u32 device = + *std::find_if(motion_devices.begin(), motion_devices.end(), same_model); + if (PadHasGyro(device)) { + motion_source = device; + } + } + } + if (motion_source != overlay_motion_source[slot]) { + overlay_motion_source[slot] = motion_source; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: P{} motion from real device inst={}", + slot + 1, motion_source); + } + } + if (motion_source) { + if (const auto source = input_subsystem->GetSDLBinding(motion_source)) { + want.motions.fill(SdlMotionParam(source->first, source->second)); + } + } else { + if (!is_builtin) { + for (std::string& motion : want.motions) { + if (is_handheld_motion(motion)) { + motion.clear(); + } + } + } + // (The handheld's own pad too: its set may have been an external's) + const bool no_motion = std::all_of(want.motions.begin(), want.motions.end(), + [](const std::string& m) { return m.empty(); }); + if (no_motion && PadHasGyro(id)) { + want.motions.fill(SdlMotionParam(binding->first, binding->second)); + } + } + auto& player = players[slot]; + // A single Joy-Con (set above): the buttons and stick arranged to fit, sideways or + // upright as the game holds it + const bool single = + joycon_auto_set[static_cast(slot)] && + (player.controller_type == Settings::ControllerType::RightJoycon || + player.controller_type == Settings::ControllerType::LeftJoycon); + const bool right_joycon = + player.controller_type == Settings::ControllerType::RightJoycon; + if (single && joycon_auto_sideways[static_cast(slot)]) { + BindSidewaysJoyCon(want, right_joycon); + } else { + if (Settings::values.console_button_labels.GetValue() && !IsNintendoPad(id)) { + // Button Layout by label: the controller's A is the Switch's A + using namespace Settings::NativeButton; + std::swap(want.buttons[A], want.buttons[B]); + std::swap(want.buttons[X], want.buttons[Y]); + } + if (single) { + BindUprightJoyCon(want, right_joycon); + } + } + player.profile_name = want.profile_name; + if (player.buttons != want.buttons || player.analogs != want.analogs || + player.motions != want.motions) { + player.buttons = want.buttons; + player.analogs = want.analogs; + player.motions = want.motions; + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: P{} <- inst={} guid={} port={} set=P{}{}", + slot + 1, id, binding->first, binding->second, set_index + 1, + is_builtin ? " (handheld)" : ""); + } + changed = true; + } + } + } + + // Only the players with a live pad are connected + for (int i = 0; i < static_cast(players.size()); ++i) { + bool want = false; + for (const auto& [id, ident] : live) { + const auto it = pad_slots.find(id); + if (it != pad_slots.end() && it->second == i) { + want = true; + break; + } + } + if (players[i].connected != want) { + players[i].connected = want; + changed = true; + } + } + + // A game running with no controller is told so once (keyboard and mouse are Manual's) + { + const bool running = QtCommon::system && QtCommon::system->IsPoweredOn(); + const bool any_player = std::any_of(players.begin(), players.end(), + [](const auto& player) { return player.connected; }); + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + if (!running) { + no_controller_told = false; + no_controller_since = 0; + } else if (any_player) { + no_controller_since = 0; + } else if (!no_controller_told) { + if (no_controller_since == 0) { + no_controller_since = now_ms; + } else if (now_ms - no_controller_since >= 3000) { + no_controller_told = true; + controller_hints.push_back( + {QString{}, 0, false, now_ms, false, + tr("No controller: connect one, or use Manual for keyboard and mouse")}); + LOG_INFO(Frontend, "console-mode: a game runs with no controller"); + } + } + } + + if (changed) { + std::string out; + for (int i = 0; i < static_cast(players.size()); ++i) { + out += players[i].connected ? "1" : "0"; + } + + static std::string last_written; + if (out != last_written) { + for (size_t i = 0; i < out.size(); ++i) { + if (last_written.size() != out.size() || last_written[i] != out[i]) { + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: player {} {}", i + 1, + out[i] == '1' ? "activated" : "deactivated"); + } + } + } + last_written = out; + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: players written={}", out); + } + } + + // A live pad holds at most one slot, in range, never shared: if this logs, it's a bug + { + std::set seen_slots; + for (const auto& [id, slot] : pad_slots) { + const bool in_range = slot >= 0 && slot < ConsolePlayers; + const bool unique = in_range && seen_slots.insert(slot).second; + if (live.find(id) != live.end() && (!in_range || !unique)) { + LOG_ERROR(Frontend, + "console-mode: INVARIANT BROKEN inst={} slot={} in_range={} " + "unique={} - two pads fighting over one player or a corrupt slot", + id, slot, in_range, unique); + } + } + } + + // Pause When a Controller Disconnects: a player whose controller left and didn't come back + // within 3 seconds pauses the game; it resumes when they all have a controller again, or + // with + on another controller (those players are then let go) + { + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + for (int i = 0; i < static_cast(players.size()) && i < 10; ++i) { + const bool held = std::any_of(pad_slots.begin(), pad_slots.end(), + [&](const auto& e) { return e.second == i; }); + const bool here = std::any_of(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + return e.second == i && live.contains(e.first); + }); + auto& since = waiting_since[static_cast(i)]; + since = held && !here ? (since ? since : now_ms) : 0; + if (since == 0) { + pause_done.erase(i); // (a new wait may pause again) + } + } + // Who holds each player now (0 nobody), and whether that is an external controller + std::array holder{}; + for (const auto& [id, slot] : pad_slots) { + if (slot >= 0 && slot < 10 && live.contains(id)) { + holder[static_cast(slot)] = id; + } + } + const auto on_handheld = [&](u32 id) { + return id != 0 && builtin_valid && id == builtin_match_id; + }; + const auto was_external = held_by_external; + for (std::size_t i = 0; i < holder.size(); ++i) { + held_by_external[i] = holder[i] != 0 && !on_handheld(holder[i]); + } + const bool handheld_took_over = + !std::exchange(handheld_was_playing, + std::any_of(holder.begin(), holder.end(), on_handheld)) && + handheld_was_playing; + const bool running = + emulation_running && QtCommon::emu_thread && QtCommon::emu_thread->IsRunning(); + // (Pausing takes a moment: running again counts as a resume by hand only once seen paused) + if (!disconnect_paused.empty() && !running && !disconnect_pause_pending) { + disconnect_pause_seen = true; + } + if (!disconnect_paused.empty() && running && disconnect_pause_seen) { + disconnect_paused.clear(); // (resumed by hand) + disconnect_continue_held = false; + disconnect_pause_seen = false; + handover_paused = -1; + handover_pressed = false; + } + // The overlay is drawn into frames: the notice first, the pause a moment later + const auto pause_with_notice = [&](int player, const QString& notice) { + controller_hints.push_back({QString{}, player, false, now_ms, false, notice, true}); + RenderControllerOverlay(); + disconnect_pause_pending = true; + disconnect_pause_seen = false; + QTimer::singleShot(150, this, [this] { + disconnect_pause_pending = false; + if (!disconnect_paused.empty() && emulation_running && QtCommon::emu_thread && + QtCommon::emu_thread->IsRunning()) { + OnPauseGame(); + } + }); + }; + if (Settings::values.console_pause_disconnect.GetValue() && running && + !disconnect_pause_pending) { + // A disconnect that is surely real (Turn Off, or BlueZ says disconnected) pauses at + // once; any other waits 3 seconds, as Steam briefly drops its pads now and then + const auto surely_gone = [&](int player) { + if (now_ms - turned_off_at[static_cast(player)] < 5000) { + return true; + } +#ifdef __linux__ + // (BlueZ is asked without waiting, at most every half second; an answer counts if + // it came after the controller left) + for (const auto& [id, slot] : pad_slots) { + if (slot != player || live.contains(id)) { + continue; + } + const auto address = inst_address.find(id); + if (address == inst_address.end()) { + continue; + } + auto& known = bluez_connected[address->second]; + if (known.asked_ms == 0 || now_ms - known.asked_ms >= 500) { + known.asked_ms = now_ms; + AskBluezConnected(address->second, this, + [this, key = address->second](bool on) { + auto& entry = bluez_connected[key]; + entry.connected = on; + entry.answered_ms = + QDateTime::currentMSecsSinceEpoch(); + }); + } + if (!known.connected && known.answered_ms >= + waiting_since[static_cast(player)]) { + return true; + } + } +#endif + return false; + }; + std::set gone; + for (int i = 0; i < 10; ++i) { + // (Each wait pauses once: resumed by hand, the game keeps running) + const qint64 since = waiting_since[static_cast(i)]; + if (since && !pause_done.contains(i) && + (now_ms - since >= 3000 || surely_gone(i))) { + gone.insert(i); + } + } + // A handover: the last external controller left (whichever player it held) and the + // handheld's controls took over. Paused until someone picks the handheld up or + // reconnects. + int handover = -1; + const bool externals_left = + std::any_of(was_external.begin(), was_external.end(), [](bool b) { return b; }) && + std::none_of(held_by_external.begin(), held_by_external.end(), + [](bool b) { return b; }); + if (gone.empty() && disconnect_paused.empty() && externals_left && + handheld_took_over) { + for (std::size_t i = 0; i < holder.size() && handover < 0; ++i) { + if (on_handheld(holder[i])) { + handover = static_cast(i); + } + } + } + if (handover >= 0) { + disconnect_paused = {handover}; + handover_paused = handover; + handover_pressed = PadInputHeld(holder[static_cast(handover)]); + LOG_INFO(Frontend, "console-mode: paused, P{} handed over to the built-in controls", + handover + 1); + pause_with_notice(handover, + tr("P%1 is on the built-in controls now: press a button on " + "them, or reconnect a controller, to go on") + .arg(handover + 1)); + } else if (!gone.empty()) { + disconnect_paused = gone; + pause_done.insert(gone.begin(), gone.end()); + LOG_INFO(Frontend, "console-mode: paused, P{}'s controller disconnected", + *gone.begin() + 1); + QStringList names; + for (const int player : gone) { + names.push_back(QStringLiteral("P%1").arg(player + 1)); + } + const QString who = names.join(QStringLiteral(", ")); + // (Said once: the pause's notice takes the place of their "left" hints) + std::erase_if(controller_hints, [&](const auto& hint) { + return !hint.joined && !hint.pause_notice && gone.contains(hint.player); + }); + // (The leave is stamped a moment later, when the overlay draws it: a margin) + for (const int player : gone) { + auto& said = hinted_leave[static_cast(player)]; + said = (std::max)(said, now_ms + 2'000); + } + // (With no other controller left, reconnecting is the only way on) + pause_with_notice(*gone.begin(), + live.empty() + ? tr("%1 disconnected: reconnect to go on").arg(who) + : tr("%1 disconnected: reconnect, or press + on another " + "controller to go on without %1") + .arg(who)); + } + } else if (!disconnect_paused.empty() && !running && emulation_running && + !disconnect_pause_pending) { + // Paused for a disconnect when the last external controller left too: the handheld's + // controls took a player, and the pause becomes a handover (not a resume) + if (handover_paused < 0 && handheld_took_over && + std::none_of(held_by_external.begin(), held_by_external.end(), + [](bool b) { return b; })) { + for (std::size_t i = 0; i < holder.size(); ++i) { + if (on_handheld(holder[i])) { + handover_paused = static_cast(i); + handover_pressed = PadInputHeld(holder[i]); + disconnect_paused = {handover_paused}; + LOG_INFO(Frontend, + "console-mode: pause handed over to the built-in controls (P{})", + i + 1); + // (The disconnect's notice gives way; running a moment, so the new + // one is drawn into a frame) + std::erase_if(controller_hints, + [](const auto& hint) { return hint.pause_notice; }); + OnStartGame(); + pause_with_notice(handover_paused, + tr("P%1 is on the built-in controls now: press a button " + "on them, or reconnect a controller, to go on") + .arg(handover_paused + 1)); + break; + } + } + } + bool all_back = + std::all_of(disconnect_paused.begin(), disconnect_paused.end(), [&](int player) { + return std::any_of(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + return e.second == player && live.contains(e.first); + }); + }); + // A handover goes on when an external controller takes the player back, or at a + // press on the handheld (once let go, so the game never sees it) + if (handover_paused >= 0) { + const u32 at = holder[static_cast(handover_paused)]; + all_back = at != 0 && !on_handheld(at); + if (on_handheld(at)) { + if (PadInputHeld(at)) { + handover_pressed = true; + } else if (std::exchange(handover_pressed, false)) { + all_back = true; + } + } + } + // (+ counts once let go, so the game never sees it on resuming) + const bool plus = std::any_of(live.begin(), live.end(), + [](const auto& e) { return PadStartHeld(e.first); }); + if (all_back) { + LOG_INFO(Frontend, "console-mode: resumed, the controllers are back"); + disconnect_paused.clear(); + disconnect_continue_held = false; + handover_paused = -1; + OnStartGame(); + } else if (plus) { + disconnect_continue_held = true; + } else if (std::exchange(disconnect_continue_held, false)) { + // On without them: their players are no longer held for their controllers + for (auto it = pad_slots.begin(); it != pad_slots.end();) { + if (disconnect_paused.contains(it->second) && !live.contains(it->first)) { + pad_identity.erase(it->first); + it = pad_slots.erase(it); + } else { + ++it; + } + } + QStringList names; + for (const int player : disconnect_paused) { + names.push_back(QStringLiteral("P%1").arg(player + 1)); + } + LOG_INFO(Frontend, "console-mode: resumed without {}", + names.join(QStringLiteral(", ")).toStdString()); + controller_hints.push_back({QString{}, *disconnect_paused.begin(), false, now_ms, + false, + tr("Going on without %1") + .arg(names.join(QStringLiteral(", ")))}); + disconnect_paused.clear(); + handover_paused = -1; + handover_pressed = false; + OnStartGame(); + } + } + } + + // Player Lights: each controller shows its player in the player's colour (not under Steam, + // which sets them) + if (Settings::values.console_player_lights.GetValue() && + !(Common::RunningUnderSteam() && Common::GetControllerConsoleState().steam_pads_present)) { + std::erase_if(lights_player, [&](const auto& e) { return !live.contains(e.first); }); + for (const auto& [id, slot] : pad_slots) { + if (!live.contains(id) || IsSteamVirtualPad(id)) { + continue; + } + auto& shown = lights_player[id]; + if (shown == slot + 1) { + continue; + } + shown = slot + 1; + const JoystickApiLock lock; + if (SDL_Gamepad* pad = SDL_OpenGamepad(id)) { + SDL_SetGamepadPlayerIndex(pad, slot); + const QColor color = ConfigurePlayerAssignment::PlayerColor(slot); + SDL_SetGamepadLED(pad, static_cast(color.red()), + static_cast(color.green()), + static_cast(color.blue())); + SDL_CloseGamepad(pad); + } + if (Common::ControllerProbes()) { + LOG_INFO(Frontend, "console-mode: inst={} lights show P{}", id, slot + 1); + } + } + } + + // A player whose controller type the game doesn't support: logged once per change + if (QtCommon::system && QtCommon::system->IsPoweredOn()) { + auto& hid = QtCommon::system->HIDCore(); + const auto supported = hid.GetSupportedStyleTag(); + for (int i = 0; i < static_cast(players.size()) && i < 8; ++i) { + // (A handheld P1 is the handheld's own controller, as the game sees it) + const auto* controller = + i == 0 && players[0].controller_type == Settings::ControllerType::Handheld + ? hid.GetEmulatedController(Core::HID::NpadIdType::Handheld) + : hid.GetEmulatedControllerByIndex(static_cast(i)); + if (!controller || !players[i].connected) { + refused_logged[static_cast(i)].clear(); + continue; + } + const auto style = controller->GetNpadStyleIndex(); + const bool taken = StyleSupported(style, supported); + const std::string what = taken ? std::string{} + : fmt::format("{}/{:#x}", static_cast(style), + static_cast(supported.raw)); + auto& logged = refused_logged[static_cast(i)]; + if (what != logged) { + if (!taken) { + LOG_WARNING(Frontend, + "console-mode: P{} is a {} controller, which the game doesn't " + "support (supported styles {:#x})", + i + 1, StyleName(style), static_cast(supported.raw)); + } + logged = what; + } + } + } + + // The controller overlay is drawn into the presented frame (as an overlay panel): no Qt + // window can composite over the game under gamescope + { + // One row per player: green when its pad connects, red when it leaves, then gone. The + // name drops InputCommon's port number, which shifts as Steam moves controllers. + constexpr qint64 flash_ms = 1500; + const qint64 now_ms = QDateTime::currentMSecsSinceEpoch(); + static const QRegularExpression port_number{QStringLiteral(" \\d+(?=( \\(Steam\\))?$)")}; + static const QRegularExpression steam_suffix{QStringLiteral(" \\(Steam\\)$")}; + // A controller's name, or the one given to it with its short name ("John (Xbox)") + const auto name_key = [&](u32 pad) { + if (builtin_valid && pad == builtin_match_id) { + return std::string{"handheld"}; + } + if (const auto address = inst_address.find(pad); address != inst_address.end()) { + return "bt:" + address->second; + } + return "id:" + live[pad]; + }; + // Names given from the Controllers tab belong to the controller + { + std::vector> renames; + { + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + renames.swap(state.rename_requests); + } + for (const auto& [player, given] : renames) { + const auto held = + std::find_if(pad_slots.begin(), pad_slots.end(), [&](const auto& e) { + return e.second == player && live.contains(e.first); + }); + if (held == pad_slots.end()) { + continue; + } + const std::string key = name_key(held->first); + if (given.empty()) { + controller_names.erase(key); + } else { + controller_names[key] = given; + } + LOG_INFO(Frontend, "console-mode: P{} named \"{}\"", player + 1, given); + } + } + std::array custom_names{}; + overlay_rows.clear(); + std::erase_if(battery_read, [&](const auto& e) { return !live.contains(e.first); }); + // (A controller that leaves starts afresh when it connects again) + const auto left = [&](const std::string& key) { + return std::none_of(live.begin(), live.end(), [&](const auto& pad) { + const auto address = inst_address.find(pad.first); + return address != inst_address.end() && address->second == key; + }); + }; + std::erase_if(bluez_first, [&](const auto& e) { return left(e.first); }); + std::erase_if(bluez_battery, [&](const auto& e) { return left(e.first); }); + std::erase_if(battery_whose, [&](const auto& e) { return !live.contains(e.first); }); + std::vector tab_rows; + // Flashes follow the controller behind a player (in handheld mode P1 stays connected + // while it changes): green when one takes a player, red when one leaves + for (int i = 0; i < static_cast(players.size()); ++i) { + u32 pad = 0; + for (const auto& [id, slot] : pad_slots) { + if (slot == i && live.find(id) != live.end()) { + pad = id; + break; + } + } + auto& since = overlay_row_since[i]; + auto& gone = overlay_row_gone[i]; + auto& flash = overlay_flash[i]; + auto& flash_since = overlay_flash_since[i]; + auto& green_at = overlay_green_at[i]; + if (players[i].connected && pad) { + const auto [display, raw] = label_of(pad, std::string{}); + QString name = display.isEmpty() ? raw : display; + name.remove(port_number); + name.remove(steam_suffix); + if (name == QStringLiteral("SteamDeck")) { + name = QStringLiteral("Steam Deck"); // (Steam's name for the Deck's own) + } + // (The name of the controller that had the player, should it have just left) + const QString previous = overlay_row_name[i]; + // (A name given before the address was known moves to it, like the bindings) + if (const std::string key = name_key(pad); key.starts_with("bt:")) { + const auto earlier = controller_names.find("id:" + live[pad]); + if (earlier != controller_names.end()) { + controller_names[key] = earlier->second; + controller_names.erase(earlier); + } + } + const auto given = controller_names.find(name_key(pad)); + // Handheld Console: the controller that took P1 from the handheld's controls + // carries the handheld's name when it has none (the same person, docked) + const auto handed = controller_names.find("handheld"); + const bool inherits = given == controller_names.end() && i == 0 && handheld && + builtin_valid && pad != builtin_match_id && + !pad_slots.contains(builtin_match_id) && + handed != controller_names.end(); + if (given != controller_names.end()) { + custom_names[static_cast(i)] = given->second; + name = QStringLiteral("%1 (%2)").arg(QString::fromStdString(given->second), + ShortControllerName(pad, name)); + } else if (inherits) { + name = QStringLiteral("%1 (%2)").arg(QString::fromStdString(handed->second), + ShortControllerName(pad, name)); + } + overlay_row_name[i] = name; + if (since == 0 || gone != 0) { + since = now_ms; + gone = 0; + flash = 1; + flash_since = now_ms; + green_at = 0; + } else if (live[pad] != overlay_row_ident[i]) { + const bool old_left = + std::none_of(live.begin(), live.end(), [&](const auto& entry) { + return entry.second == overlay_row_ident[i]; + }); + if (old_left) { + flash = 2; + flash_since = now_ms; + green_at = now_ms + flash_ms / 2; + overlay_left_name[i] = previous; + } else { + flash = 1; + flash_since = now_ms; + green_at = 0; + } + } + overlay_row_ident[i] = live[pad]; + // The input history's key: the controller's own (its address, else its + // connection too when identical controllers share an identity) + std::string history_key = name_key(pad); + if (history_key.starts_with("id:") && + std::any_of(live.begin(), live.end(), [&](const auto& entry) { + return entry.first != pad && entry.second == live[pad]; + })) { + history_key += fmt::format("#{}", pad); + } + overlay_row_history[i] = std::move(history_key); + } else if (since != 0 && gone == 0) { + gone = now_ms; + flash = 2; + flash_since = now_ms; + green_at = 0; + overlay_left_name[i] = overlay_row_name[i]; + } + if (since == 0) { + continue; + } + if (gone != 0 && now_ms - gone > flash_ms) { + since = 0; + gone = 0; + flash = 0; + overlay_row_ident[i].clear(); + overlay_row_history[i].clear(); + continue; + } + if (green_at != 0 && now_ms >= green_at) { + flash = 1; + flash_since = green_at; + green_at = 0; + } + if (flash != 0 && gone == 0 && green_at == 0 && now_ms - flash_since >= flash_ms) { + flash = 0; + } + // The controller's battery: by its Bluetooth address, the system's for the handheld's + // own controls, else SDL's (never a Steam virtual pad's). Read every 10 seconds, more + // often while a new controller's value settles. + Battery battery; + if (pad) { + auto& read = battery_read[pad]; + // Whose reading it is: Steam reshuffles its pads, so another controller is read + // afresh + const bool own = builtin_valid && pad == builtin_match_id; + const std::string whose = (own ? "handheld:" : "") + BluetoothKey(pad); + if (auto& was = battery_whose[pad]; was != whose) { + was = whose; + read = {}; + } + if (read.first == 0 || now_ms - read.first > 10'000) { + Battery fresh; + const char* source = "sdl"; + if (!IsSteamVirtualPad(pad)) { + const auto [percent, charging, wired] = SdlBattery(pad); + fresh = {percent, charging, wired}; + } +#ifdef __linux__ + std::optional> found; + bool by_address = false; + bool settling = false; + if (builtin_valid && pad == builtin_match_id) { + found = SystemBattery(); + source = "system"; + } else if (const auto address = inst_address.find(pad); + address != inst_address.end()) { + by_address = true; + found = BluetoothBattery(address->second); + source = "power supply"; + if (!found) { + // (Asked without waiting: a new answer triggers a reread) + const std::string& key = address->second; + auto& bluez = bluez_battery[key]; + if (bluez.asked_ms == 0 || now_ms - bluez.asked_ms >= 500) { + bluez.asked_ms = now_ms; + AskBluezBattery(key, this, [this, key](int percent) { + auto& entry = bluez_battery[key]; + const bool moved = entry.percent != percent; + entry.percent = percent; + if (moved) { + for (const auto& [inst, at] : inst_address) { + if (at == key) { + battery_read[inst].first = 0; + } + } + } + }); + } + if (bluez.percent >= 0) { + found = std::pair{bluez.percent, false}; + } + source = "bluez"; + // BlueZ first reads 50% for a new controller (the real charge + // comes about 10 seconds later): a first 50% counts once it changes, + // or after 12 seconds + if (found) { + // (By controller: Steam reshuffling its pads is no new connection) + const auto [first, added] = + bluez_first.try_emplace(address->second, now_ms, found->first); + // (Only its placeholder: a first value of anything else is real) + if (first->second.second == 50 && + now_ms - first->second.first < 12'000 && + found->first == first->second.second) { + found.reset(); + settling = true; + } + } + } + } + if (!found && !settling && fresh.percent < 0 && !fresh.wired && + Common::ControllerProbes()) { + const auto address = inst_address.find(pad); + LOG_INFO(Frontend, "console-mode: P{} inst={} battery unknown (address {})", + i + 1, pad, + address != inst_address.end() ? address->second : "none"); + } + if (found) { + fresh = {found->first, found->second, false}; + } + // (No value yet, or BlueZ's first one settling: read again sooner) + const qint64 read_at = settling ? now_ms - 9'000 + : by_address && !found ? now_ms - 8'000 + : now_ms; +#else + const qint64 read_at = now_ms; +#endif + if (Common::ControllerProbes() && fresh.percent >= 0 && + (fresh.percent != read.second.percent || + fresh.charging != read.second.charging)) { + LOG_INFO(Frontend, "console-mode: P{} inst={} battery {}%{} ({})", i + 1, + pad, fresh.percent, fresh.charging ? " charging" : "", source); + } + read = {read_at, fresh}; + } + battery = read.second; + // A low battery, once a session per controller: named on screen + const std::string key = name_key(pad); + if (battery.percent >= 0 && battery.percent <= 15 && !battery.charging && + battery_low_told.insert(key).second) { + controller_hints.push_back({QString{}, i, false, now_ms, false, + tr("%1: battery low (%2%)") + .arg(overlay_row_name[i]) + .arg(battery.percent)}); + LOG_INFO(Frontend, "console-mode: P{} battery low ({}%)", i + 1, + battery.percent); + } + } + const auto started = pad ? pad_origin.find(pad) : pad_origin.end(); + const int origin = + started != pad_origin.end() && started->second != i ? started->second : -1; + const bool guessed = pad && pad_guessed.contains(pad); + overlay_rows.push_back({i, + QStringLiteral("P%1: %2").arg(i + 1).arg(overlay_row_name[i]), + gone != 0 ? 0u : pad, flash, flash_since, origin, guessed, + battery.percent, battery.charging}); + const std::string key = pad ? BluetoothKey(pad) : std::string{}; + const auto address = pad_bluetooth.find(key); + const int turn_off = address != pad_bluetooth.end() && !address->second.empty() ? 2 + : pad_bt_pending.contains(key) ? 1 + : 0; + overlay_custom_name[static_cast(i)] = + custom_names[static_cast(i)]; + tab_rows.push_back({i, overlay_row_name[i].toStdString(), false, gone != 0, flash, + flash_since, ConsoleBindingSets()[slot_set[i]].profile_name, + turn_off, origin, guessed, + custom_names[static_cast(i)], battery.percent, + battery.charging, battery.wired}); + } + { + std::string handheld_name; + bool handheld_has_player = false; + if (builtin_valid && live.find(builtin_match_id) != live.end()) { + const auto [display, raw] = label_of(builtin_match_id, std::string{}); + QString name = display.isEmpty() ? raw : display; + name.remove(port_number); + handheld_name = name.toStdString(); + handheld_has_player = pad_slots.contains(builtin_match_id); + } + // The removed controller, by name while it is connected + std::string blocked_name; + for (const auto& [id, ident] : live) { + if (is_blocked(id)) { + blocked_name = PadName(id).toStdString(); + } + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + state.engine_active = true; + state.players = std::move(tab_rows); + state.handheld_name = std::move(handheld_name); + state.handheld_has_player = handheld_has_player; + state.handheld_blocked_name = std::move(blocked_name); + } + RenderControllerOverlay(); + } + + if (!changed) { + return; + } + // Reloaded whether a game runs or not: the Controls tab reads the players back from the + // emulated controllers, and stale ones would be taken for the user's edits + if (QtCommon::system) { + QtCommon::system->HIDCore().ReloadInputDevices(); + } + auto& state = Common::GetControllerConsoleState(); + std::scoped_lock lock{state.mutex}; + ++state.generation; +} + bool MainWindow::eventFilter(QObject* watched, QEvent* event) { + // The controller overlay takes the mouse over its rows (drag to swap, click to rumble) + if (watched == render_window && HandleOverlayMouse(event)) { + return true; + } if (watched == ui->menu_Cheats && event->type() == QEvent::MouseButtonRelease) { const auto* mouse_event = static_cast(event); auto* action = ui->menu_Cheats->actionAt(mouse_event->pos()); @@ -1562,6 +7176,10 @@ void MainWindow::ConnectMenuEvents() { connect_menu(ui->action_Show_Filter_Bar, &MainWindow::OnToggleFilterBar); connect_menu(ui->action_Show_Status_Bar, &MainWindow::OnToggleStatusBar); connect_menu(ui->action_Show_Performance_Overlay, &MainWindow::OnTogglePerfOverlay); + connect(ui->action_Show_Controller_Overlay, &QAction::toggled, this, + [](bool shown) { Settings::values.console_debug_overlay.SetValue(shown); }); + connect(ui->action_Show_Controller_Hints, &QAction::toggled, this, + [](bool shown) { Settings::values.console_hints.SetValue(shown); }); #ifdef HAS_RESHADE connect_menu(ui->action_Post_Processing_Shaders, &MainWindow::OnPostProcessingShaders); #else @@ -1842,7 +7460,11 @@ bool MainWindow::LoadROM(const QString& filename, Service::AM::FrontendAppletPar QtCommon::system->SetFrontendAppletSet({ std::make_unique(*this), // Amiibo Settings - (Settings::values.controller_applet_mode.GetValue() == Settings::AppletMode::Disabled) + // The console modes own the controllers and suppress the applet, like Disabled + (Settings::values.controller_applet_mode.GetValue() == Settings::AppletMode::Disabled || + Settings::values.controller_applet_mode.GetValue() == Settings::AppletMode::ConsoleMode || + Settings::values.controller_applet_mode.GetValue() == + Settings::AppletMode::HandheldConsoleMode) ? nullptr : std::make_unique(*this), // Controller Selector std::make_unique(*this), // Error Display @@ -1955,7 +7577,7 @@ void MainWindow::BootGame(const QString& filename, Service::AM::FrontendAppletPa // Save configurations UpdateUISettings(); game_list->SaveInterfaceLayout(); - config->SaveAllValues(); + SaveAllConfigValues(); u64 title_id{0}; @@ -2801,7 +8423,7 @@ void MainWindow::OnLinkToRyujinx(const u64& program_id) { UISettings::values.ryujinx_link_paths.remove(program_id); } - config->SaveAllValues(); + SaveAllConfigValues(); } void MainWindow::OnMenuLoadFile() { @@ -3174,7 +8796,7 @@ void MainWindow::OnExit() { void MainWindow::OnSaveConfig() { QtCommon::system->ApplySettings(); - config->SaveAllValues(); + SaveAllConfigValues(); } void MainWindow::ErrorDisplayDisplayError(QString error_code, QString error_text) { @@ -3325,6 +8947,7 @@ void MainWindow::HideFullscreen() { statusBar()->setVisible(ui->action_Show_Status_Bar->isChecked()); ui->menubar->show(); + FitToScreenUnderGamescope(this); } else { if (UsingExclusiveFullscreen()) { render_window->showNormal(); @@ -3339,6 +8962,21 @@ void MainWindow::HideFullscreen() { } } +void MainWindow::FitToScreenUnderGamescope(QWidget* window) { + // Under gamescope a window is scaled to fit together with any tooltip reaching past it, so + // a smaller window grows and shrinks on hover: filling the screen keeps them inside + if (!Gamescope::IsRunningUnder()) { + return; + } + if (window->isFullScreen()) { + return; + } + QScreen* screen = window->screen() ? window->screen() : QGuiApplication::primaryScreen(); + if (screen) { + window->setGeometry(screen->geometry()); + } +} + void MainWindow::ToggleWindowMode() { if (ui->action_Single_Window_Mode->isChecked()) { // Render in the main window... @@ -3457,10 +9095,18 @@ void MainWindow::OnConfigure() { const auto old_language_index = Settings::values.language_index.GetValue(); const bool old_gamemode = UISettings::values.enable_gamemode.GetValue(); + // Taken before the dialog exists (its Controls tab writes the players), from the copy in + // use: during a game the global copy holds stale routing, which would invent swaps + const auto players_before = Settings::values.players.GetValue(); Settings::SetConfiguringGlobal(true); ConfigureDialog configure_dialog(this, hotkey_registry, input_subsystem.get(), vk_device_records, *QtCommon::system, !multiplayer_state->IsHostingPublicRoom()); + // Opened from a player chip: straight to that player's tab + if (open_player_tab >= 0) { + configure_dialog.OpenPlayerTab(open_player_tab); + open_player_tab = -1; + } connect(&configure_dialog, &ConfigureDialog::LanguageChanged, this, &MainWindow::OnLanguageChanged); connect(&configure_dialog, &ConfigureDialog::ExternalContentDirsChanged, this, @@ -3498,6 +9144,7 @@ void MainWindow::OnConfigure() { Settings::values.disabled_addons.clear(); + RestoreConsoleProfile(); config = std::make_unique(); UISettings::values.reset_to_defaults = false; @@ -3509,6 +9156,10 @@ void MainWindow::OnConfigure() { SetDefaultUIGeometry(); RestoreUIState(); } + MergeConsoleProfileEdits(players_before); + ui->action_Show_Controller_Overlay->setChecked( + Settings::values.console_debug_overlay.GetValue()); + ui->action_Show_Controller_Hints->setChecked(Settings::values.console_hints.GetValue()); InitializeHotkeys(); if (UISettings::values.theme != old_theme) { @@ -3534,7 +9185,7 @@ void MainWindow::OnConfigure() { UISettings::values.configuration_applied = false; - config->SaveAllValues(); + SaveAllConfigValues(); if ((UISettings::values.hide_mouse || Settings::values.mouse_panning) && emulation_running) { render_window->installEventFilter(render_window); @@ -3754,7 +9405,26 @@ void MainWindow::OpenPerGameConfiguration(u64 title_id, const std::string& file_ Settings::RestoreGlobalState(QtCommon::system->IsPoweredOn()); return; } else if (result == QDialog::Accepted) { + // A game with its own controller config keeps its own in the file, not the engine's + // runtime players (as a global save does, see SaveAllConfigValues) + std::optional> engine_players; + if (console_profile && game_profiles && QtCommon::system->IsPoweredOn() && + QtCommon::system->GetApplicationProcessProgramID() == title_id && + !Settings::values.players.UsingGlobal()) { + auto& game_players = Settings::values.players.GetValue(); + engine_players = game_players; + game_players = *game_profiles; + } dialog.ApplyConfiguration(); + if (engine_players && !Settings::values.players.UsingGlobal()) { + Settings::values.players.GetValue() = *engine_players; + } + // (The running game's own value; otherwise the View menu keeps showing the global one) + if (QtCommon::system->IsPoweredOn()) { + ui->action_Show_Controller_Overlay->setChecked( + Settings::values.console_debug_overlay.GetValue()); + ui->action_Show_Controller_Hints->setChecked(Settings::values.console_hints.GetValue()); + } } const auto reload = UISettings::values.is_game_list_reload_pending.exchange(false); @@ -3770,7 +9440,7 @@ void MainWindow::OpenPerGameConfiguration(u64 title_id, const std::string& file_ UISettings::values.configuration_applied = false; if (!is_powered_on) { - config->SaveAllValues(); + SaveAllConfigValues(); } } @@ -4526,6 +10196,21 @@ void MainWindow::closeEvent(QCloseEvent* event) { event->ignore(); return; } + // Debug Probes: who was which player when Eden closed + if (Common::ControllerProbes() && !overlay_rows.empty()) { + std::string players_now; + for (const auto& row : overlay_rows) { + players_now += fmt::format("\n P{} {} ident={} profile=\"{}\"{}", row.player + 1, + overlay_row_name[row.player].toStdString(), + overlay_row_ident[row.player], + ConsoleBindingSets()[slot_set[row.player]].profile_name, + row.pad == 0 ? " (left)" : ""); + } + LOG_INFO(Frontend, "console-mode: closing, mode {}, players:{}", + AppletModeName(Settings::values.controller_applet_mode.GetValue()), players_now); + } + closing = true; + console_mode_timer.stop(); UpdateUISettings(); game_list->SaveInterfaceLayout(); diff --git a/src/yuzu/main_window.h b/src/yuzu/main_window.h index e7ed34768b..ea02fd48f9 100644 --- a/src/yuzu/main_window.h +++ b/src/yuzu/main_window.h @@ -9,7 +9,16 @@ #include #include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include #include #include #include @@ -19,6 +28,7 @@ #include "common/common_types.h" #include "common/settings_enums.h" +#include "common/settings_input.h" #include "frontend_common/content_manager.h" #include "input_common/drivers/tas_input.h" #include "qt_common/config/qt_config.h" @@ -49,6 +59,7 @@ class LoadingScreen; class OverlayDialog; class ControllerDialog; class QLabel; +class QShortcut; class MultiplayerState; class QPushButton; class QProgressDialog; @@ -176,9 +187,6 @@ public: std::filesystem::path GetShortcutPath(QtCommon::Game::ShortcutTarget target); -protected: - bool eventFilter(QObject* watched, QEvent* event) override; - signals: /** @@ -556,6 +564,256 @@ private: QActionGroup* game_size_actions; std::vector runtime_cheat_actions; + /// The console modes' engine: assigns the connected controllers to players, on a timer + QTimer console_mode_timer; + /// Eden is closing: the engine stops (it would reload the inputs closeEvent unloaded) + bool closing = false; + void ApplyConsoleControllerMode(); + /// connection instance -> player slot; kept after a disconnect so the pad reclaims its player + std::map pad_slots; + /// Per connection: its controller's Bluetooth address, the player it started as, and + /// whether its player was a guess on a reconnect + std::map inst_address; + std::map pad_origin; + std::set pad_guessed; + /// Returning pads waiting a moment for their Bluetooth address before they claim a player + std::map claim_wait_since; + /// Players taken by a guess, kept for the Bluetooth controller that held them + struct AddressClaim { + int slot; + int origin; + std::chrono::steady_clock::time_point at; + }; + std::map address_claims; + /// Each Bluetooth controller's starting player, by address: kept through Turn Off and + /// reconnects + std::map address_origin; + /// Names given to controllers ("bt:
", "handheld" or "id:"). Never saved: + /// owners can't tell identical controllers apart in a later session + std::map controller_names; + /// The mode and each player's refused controller type as last logged + std::optional logged_mode; + std::array refused_logged{}; + /// When the deck's controller was last asked to turn its motion sensors back on + qint64 deck_motion_asked_at = 0; + /// Pads whose player was removed: they sit out until they reconnect (connection instance) + std::set benched_pads; + /// When each player's controller was last turned off (Turn Off) + std::array turned_off_at{}; + /// Each pad's Bluetooth address by BluetoothKey ("" none or unknown), and the pads not yet + /// told apart from others of their model + std::map pad_bluetooth; + std::set pad_bt_pending; + void LearnBluetoothPads(); + /// Reads the pads' and the real controllers' inputs + void WatchSteamPadInputs(); + /// The last time a pad had input together with exactly one real controller: when Steam + /// then moves a controller to that pad, that is the one + struct LoneInput { + u32 pad; + std::string controller; + std::chrono::steady_clock::time_point at; + }; + std::optional last_lone_input; + /// connection instance -> stable identity (serial, else model GUID), to match a returning pad + std::map pad_identity; + /// identity -> the binding set the controller carries, so its mappings follow it + std::map pad_sets; + /// player -> the set its controller carries, as of the last tick + std::array slot_set{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; + /// set -> the controller whose default mapping it holds (0: the user's, or empty); a set + /// holding a gone controller's defaults is rebuilt for the one taking it + std::array set_default_of{}; + /// This session's binding sets: profiles and remaps stay here, so the user's own setup + /// (the manual modes') is never touched + std::optional> session_sets; + /// The set a change goes to: the session's, or a game's own profile set + Settings::PlayerInput& SessionSet(int set); + /// The profile each seat (player number) starts a session with + void ApplySeatProfiles(); + void SetSeatProfile(int player, const std::string& name); + /// A set's mappings before this session's first profile on it, for taking the profile off + std::map set_before_profile; + /// The handheld's own pad: never a player while other pads are connected + u32 builtin_pad_id = 0; + /// The built-in's stable identity: the exclusion must survive its instance changing + std::string builtin_identity; + /// Latched once the built-in has been identified + bool builtin_seen_first = false; + /// Warn once per engagement when the built-in cannot be identified + bool builtin_warned = false; + /// Overlay rows per player: when it appeared, when its pad left, and its name + std::array overlay_row_since{}; + std::array overlay_row_gone{}; + std::array overlay_row_name{}; + /// Per player: the controller behind the row (its identity), and its flash (1 green, 2 red) + /// with when it began; a red then green keeps the green's start in overlay_green_at + std::array overlay_row_ident{}; + /// The key each player's input history is kept under (identical controllers can share an + /// identity) + std::array overlay_row_history{}; + std::array overlay_flash{}; + std::array overlay_flash_since{}; + std::array overlay_green_at{}; + /// Per player: the real device its motion comes from under Steam (0: none) + std::array overlay_motion_source{}; + /// The overlay's rows as the last assignment tick left them: flash 1 green, 2 red + struct OverlayRow { + int player; + QString text; + u32 pad; + int flash; + qint64 flash_since; + /// The player its controller started as (-1: this one), and whether it was a guess + int origin = -1; + bool guessed = false; + /// Its controller's battery (percent, -1 unknown) and whether it charges + int battery = -1; + bool charging = false; + }; + std::vector overlay_rows; + std::vector overlay_blink; + /// The room the players panel had last: redrawn when a top-right panel changes + float overlay_room = 0.0f; + std::set overlay_active_pads; + /// Polls controller input faster than the assignment tick, for the blue highlight + QTimer console_activity_timer; + /// The overlay's player rows as drawn, for the mouse + std::vector> overlay_hits; + int overlay_drag_player = -1; + int overlay_drag_dy = 0; + bool overlay_dragging = false; + QPoint overlay_press; + /// The row under the mouse (-1: none) and its buttons as drawn: swap above (-1) or below + /// (1), Turn Off (0), Config (2), rename (3); while naming apply (4), remove (5), cancel (6) + int overlay_hover_player = -1; + bool overlay_button_down = false; + struct OverlayButton { + QRect rect; + int player; + int action; + int target; + }; + std::vector overlay_buttons; + bool HandleOverlayMouse(QEvent* event); + /// Each player's given name as shown, and the name being typed (player -1: none) + std::array overlay_custom_name{}; + /// The name of the controller that last left each player, for its hint + std::array overlay_left_name{}; + /// Batteries, read at most every 10 seconds, and the controllers already warned of a low one + struct Battery { + int percent = -1; + bool charging = false; + bool wired = false; + }; + std::map> battery_read; + /// A controller's first BlueZ battery reading and when, by address (BlueZ says 50% at first) + std::map> bluez_first; + /// Whose controller each pad's battery reading is (Steam handle or identity, handheld or not) + std::map battery_whose; + std::set battery_low_told; + /// Use Any Controller as Joy-Cons: each player's type before the engine changed it, and the + /// type it set + std::array, 10> joycon_auto_from{}; + std::array, 10> joycon_auto_set{}; + /// A single Joy-Con set by it: held sideways (else upright), as the game holds it + std::array joycon_auto_sideways{}; + /// Pause When a Controller Disconnects: since when each player waits (0 none), and the + /// players a pause is for + std::array waiting_since{}; + std::set disconnect_paused; + /// A handover pause: the player (-1 none) the handheld's controls took over mid-game when + /// the last external controller left (it may be docked). Goes on at a press on the handheld + /// or when an external controller returns. Also who held each player at the last tick. + int handover_paused = -1; + bool handover_pressed = false; + std::array held_by_external{}; + /// Whether the handheld's controls held a player at the last tick (in Modern Console they + /// may have played all along: no handover) + bool handheld_was_playing = false; + /// What BlueZ last said of a controller (by address): connected, and when asked and answered + struct BluezConnection { + bool connected = true; + qint64 asked_ms = 0; + qint64 answered_ms = 0; + }; + std::map bluez_connected; + /// What BlueZ last said of a controller's battery (by address; -1 nothing yet), and when + struct BluezBattery { + int percent = -1; + qint64 asked_ms = 0; + }; + std::map bluez_battery; + /// The pause for a disconnect is about to come (its notice is drawn first) + bool disconnect_pause_pending = false; + /// The game was seen paused (until then, running isn't a resume by hand) + bool disconnect_pause_seen = false; + /// + is held on another controller during that pause: on without those players once let go + bool disconnect_continue_held = false; + std::set pause_done; + /// Player Lights: the player each controller's lights were last set to (by connection) + std::map lights_player; + int overlay_edit_player = -1; + QString overlay_edit_text; + /// The player whose tab the next settings dialog opens on (-1: as usual) + int open_player_tab = -1; + /// Under gamescope, makes a window fill the screen (see the definition) + void FitToScreenUnderGamescope(QWidget* window); + void RenderControllerOverlay(); + void PollControllerActivity(); + /// The last buttons each controller pressed, as the game sees them, newest last; by + /// controller, so the history follows a swap + struct ButtonPress { + int button; + qint64 at; + }; + std::map> press_history; + /// The controller each player's buttons were last read for (a new one records no presses) + std::array history_ident{}; + std::array, 10> buttons_down{}; + /// Stick moves in the history: StickMoveButton + stick * 4 + direction (left, up, right, + /// down), stick 0 the left one + static constexpr int StickMoveButton = 64; + /// The direction each player's sticks point, plus one (0 at rest) + std::array, 10> stick_direction{}; + /// Reads the players' buttons; true when the history changed (a press, or one expiring) + bool PollButtonHistory(); + /// A controller named on screen for a moment: it joined as a player, or left one + struct ControllerHint { + QString name; + int player; + bool joined; + qint64 at; + /// Left because Turn Off turned its controller off + bool turned_off = false; + /// Said instead of joined / left ("" none): how the player's controller changed + QString note; + /// Says the game is paused for a disconnect: kept while it is, shown with hints off too + bool pause_notice = false; + }; + std::vector controller_hints; + /// A game running with no controller: since when (0 none), and whether it was said + qint64 no_controller_since = 0; + bool no_controller_told = false; + /// The flashes already turned into hints, per player (green, red) + std::array hinted_join{}; + std::array hinted_leave{}; + /// True while the overlay animates (hints fading, history entries shrinking away) + bool OverlayAnimating() const; + /// The user's own players while a console mode drives them: what the config file sees, and + /// what leaving the mode puts back + std::optional> console_profile; + /// The running game's own players, when it has input profiles of its own + std::optional> game_profiles; + /// The binding sets the pads carry: the user's own, with a game's input profiles on top + std::array ConsoleBindingSets() const; + /// Saves the configuration, writing the user's own players while a console mode runs + void SaveAllConfigValues(); + /// Puts the user's own players back (leaving a console mode, or before the config saves) + void RestoreConsoleProfile(); + /// Folds edits made in the Controls tab during a console mode into the user's profile + void MergeConsoleProfileEdits(const std::array& before); + // Debugger panes ControllerDialog* controller_dialog = nullptr; @@ -603,6 +861,7 @@ private: std::string arguments, const bool needs_title); protected: + bool eventFilter(QObject* watched, QEvent* event) override; void dropEvent(QDropEvent* event) override; void dragEnterEvent(QDragEnterEvent* event) override; void dragMoveEvent(QDragMoveEvent* event) override; diff --git a/src/yuzu/overlay_panel.cpp b/src/yuzu/overlay_panel.cpp new file mode 100644 index 0000000000..b518377c3e --- /dev/null +++ b/src/yuzu/overlay_panel.cpp @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include +#include +#include +#include + +#include "yuzu/bootmanager.h" +#include "yuzu/overlay_panel.h" + +namespace GameOverlay { + +void Publish(Common::OverlayPanel& panel, const QImage* image) { + std::scoped_lock lock{panel.mutex}; + if (!image) { + if (panel.enabled) { + panel.enabled = false; + ++panel.version; + } + return; + } + panel.width = static_cast(image->width()); + panel.height = static_cast(image->height()); + panel.pixels.resize(static_cast(image->sizeInBytes())); + std::memcpy(panel.pixels.data(), image->constBits(), + static_cast(image->sizeInBytes())); + panel.enabled = true; + ++panel.version; +} + +Cursor CursorOn(Common::OverlayPanel& panel, const GRenderWindow& window) { + const auto& layout = window.GetFramebufferLayout(); + Common::OverlayRect place{}; + { + std::scoped_lock lock{panel.mutex}; + place = Common::PlaceOverlayPanel( + panel, static_cast(layout.screen.left), static_cast(layout.screen.top), + static_cast(layout.screen.right), static_cast(layout.screen.bottom)); + } + // The panel is drawn at its own size, in the framebuffer's pixels + const qreal ratio = window.devicePixelRatioF(); + const QPointF logical = window.mapFromGlobal(QCursor::pos()); + const QPoint at(static_cast(logical.x() * ratio) - static_cast(place.x), + static_cast(logical.y() * ratio) - static_cast(place.y)); + const bool inside = at.x() >= 0 && at.y() >= 0 && at.x() < static_cast(place.width) && + at.y() < static_cast(place.height); + return {at, inside}; +} + +namespace Style { + +QFont TextFont() { + QFont font; + font.setPixelSize(13); + return font; +} + +QFont LabelFont() { + QFont font; + font.setPixelSize(12); + font.setBold(true); + return font; +} + +QFont SmallFont() { + QFont font; + font.setPixelSize(11); + font.setBold(true); + return font; +} + +void Pill(QPainter& painter, const QRectF& rect, const QColor& fill) { + painter.setPen(Qt::NoPen); + painter.setBrush(fill); + painter.drawRoundedRect(rect, rect.height() / 2.0, rect.height() / 2.0); +} + +} // namespace Style + +} // namespace GameOverlay diff --git a/src/yuzu/overlay_panel.h b/src/yuzu/overlay_panel.h new file mode 100644 index 0000000000..8a8e05e077 --- /dev/null +++ b/src/yuzu/overlay_panel.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +#include "common/overlay_panels.h" + +class GRenderWindow; +class QImage; +class QPainter; +class QRectF; +class QString; + +/// The frontend's side of the in-game overlay panels (common/overlay_panels.h) +namespace GameOverlay { + +/// Shows an image as the panel (copied), or hides the panel (nullptr) +void Publish(Common::OverlayPanel& panel, const QImage* image); + +/// The cursor in a panel's image, and whether it is over the panel (which must be shown) +struct Cursor { + QPoint at; + bool inside = false; +}; +[[nodiscard]] Cursor CursorOn(Common::OverlayPanel& panel, const GRenderWindow& window); + +/// The overlay's look, shared by its panels +namespace Style { + +/// A panel's dark, see-through fill +inline const QColor Fill{14, 14, 18, 175}; +/// A held, hovered or active item +inline const QColor Active{45, 95, 200, 200}; +/// Something that joined or turned on +inline const QColor Good{45, 160, 80, 200}; +/// Something that left or failed +inline const QColor Bad{190, 50, 50, 200}; +inline const QColor Text{240, 240, 240}; +inline const QColor DimText{255, 255, 255, 120}; + +/// The fonts: names and lines, labels (bold), small labels (bold) +[[nodiscard]] QFont TextFont(); +[[nodiscard]] QFont LabelFont(); +[[nodiscard]] QFont SmallFont(); + +/// A pill: a rounded rectangle with fully round ends +void Pill(QPainter& painter, const QRectF& rect, const QColor& fill); + + +} // namespace Style + +} // namespace GameOverlay diff --git a/src/yuzu/util/controller_navigation.cpp b/src/yuzu/util/controller_navigation.cpp index 0dbfca243e..5d9ed7e2f2 100644 --- a/src/yuzu/util/controller_navigation.cpp +++ b/src/yuzu/util/controller_navigation.cpp @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2021 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -70,6 +73,12 @@ void ControllerNavigation::ControllerUpdateButton() { case Core::HID::NpadStyleIndex::JoyconDual: case Core::HID::NpadStyleIndex::Handheld: case Core::HID::NpadStyleIndex::GameCube: + if ((button_values[Settings::NativeButton::Minus].value && + !button_values[Settings::NativeButton::Minus].locked) || + (button_values[Settings::NativeButton::Plus].value && + !button_values[Settings::NativeButton::Plus].locked)) { + emit TriggerContextMenu(); + } TriggerButton(Settings::NativeButton::A, Qt::Key_Enter); TriggerButton(Settings::NativeButton::B, Qt::Key_Escape); TriggerButton(Settings::NativeButton::DDown, Qt::Key_Down); @@ -78,10 +87,18 @@ void ControllerNavigation::ControllerUpdateButton() { TriggerButton(Settings::NativeButton::DUp, Qt::Key_Up); break; case Core::HID::NpadStyleIndex::JoyconLeft: + if (button_values[Settings::NativeButton::Minus].value && + !button_values[Settings::NativeButton::Minus].locked) { + emit TriggerContextMenu(); + } TriggerButton(Settings::NativeButton::DDown, Qt::Key_Enter); TriggerButton(Settings::NativeButton::DLeft, Qt::Key_Escape); break; case Core::HID::NpadStyleIndex::JoyconRight: + if (button_values[Settings::NativeButton::Plus].value && + !button_values[Settings::NativeButton::Plus].locked) { + emit TriggerContextMenu(); + } TriggerButton(Settings::NativeButton::X, Qt::Key_Enter); TriggerButton(Settings::NativeButton::A, Qt::Key_Escape); break; diff --git a/src/yuzu/util/controller_navigation.h b/src/yuzu/util/controller_navigation.h index 86e210368f..d0bd8f5898 100644 --- a/src/yuzu/util/controller_navigation.h +++ b/src/yuzu/util/controller_navigation.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2021 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -29,6 +32,7 @@ public: signals: void TriggerKeyboardEvent(Qt::Key key); + void TriggerContextMenu(); private: void TriggerButton(Settings::NativeButton::Values native_button, Qt::Key key); diff --git a/src/yuzu/util/gamescope.cpp b/src/yuzu/util/gamescope.cpp new file mode 100644 index 0000000000..d79ec6863a --- /dev/null +++ b/src/yuzu/util/gamescope.cpp @@ -0,0 +1,449 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/logging.h" +#include "common/settings.h" +#include "yuzu/util/gamescope.h" + +namespace Gamescope { +namespace { + +// Under gamescope (installed on the application only there). Gamescope shows one window at a +// time, scaled to fit the screen, with no title bar, and only one popup over it: +// - A dialog takes the whole screen as it shows, before it's mapped, so it's never scaled up +// (blurry, and a tooltip past its edge rescaled it). A smaller dialog keeps its own size, +// centred. Its own show handling runs first (a message box fixes its size +// there), then its size is freed. +// - A dialog with no buttons of its own to leave it gets a Close button. +// - A closing menu can leave the mouse grabbed by the main window: taps then reach the main +// window, which the open dialog blocks, so the dialog never gets them. A modal window shown +// with no menu open lets go of any such grab. +// - A submenu's popup would hide its parent menu, which still took the mouse unseen: a +// submenu opens in its menu's place instead. +// - A smaller dialog is a card on a darker screen, like its own window on a desktop, with an +// Expand button to fill the screen (Shrink to go back). +class GamescopeDialogs final : public QObject { +public: + explicit GamescopeDialogs(QWidget* main_) : QObject(main_), main{main_} { + // The menu bar's menus got their actions before this was installed + for (QMenu* menu : main->findChildren()) { + PrepareMenu(menu); + } + } + + bool eventFilter(QObject* watched, QEvent* event) override { + if (auto* menu = qobject_cast(watched)) { + if (event->type() == QEvent::ActionAdded) { + PrepareMenu(menu); + return false; + } + return TurnPage(menu, event); + } + if (event->type() == QEvent::LayoutRequest && watched->isWidgetType() && + watched->property("gamescope_fit").isValid()) { + // A message box fixes its own size on every layout change: it lays itself out, then + // takes the whole screen again + // (Taken over here, so the layout is activated first, as Qt would before event()) + auto* widget = static_cast(watched); + if (QLayout* layout = widget->layout()) { + layout->activate(); + } + static_cast(widget)->event(event); + if (QScreen* screen = widget->screen(); screen && widget->geometry() != screen->geometry()) { + widget->setMinimumSize(0, 0); + widget->setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX); + widget->setGeometry(screen->geometry()); + } + return true; + } + if (event->type() != QEvent::Show || !watched->isWidgetType()) { + return false; + } + auto* widget = static_cast(watched); + if (widget == main || !widget->isWindow()) { + return false; + } + if (widget->windowModality() != Qt::NonModal) { + QTimer::singleShot(0, this, [this] { + if (QApplication::activePopupWidget() || !main->windowHandle()) { + return; + } + main->windowHandle()->setMouseGrabEnabled(false); + if (Settings::values.console_debug_probes.GetValue()) { + LOG_INFO(Frontend, "gamescope: dialog shown, mouse grab released"); + } + }); + } + // Eden's own overlays over the game (software keyboard, applet messages) size and place + // themselves over it + if (widget->windowType() != Qt::Dialog || widget->isFullScreen() || + (widget->windowFlags() & Qt::FramelessWindowHint)) { + return false; + } + static_cast(widget)->event(event); + FitDialog(widget); + return true; + } + +private: + static void FitDialog(QWidget* dialog) { + QScreen* screen = dialog->screen() ? dialog->screen() : QGuiApplication::primaryScreen(); + if (!screen) { + return; + } + const QRect area = screen->geometry(); + const QSize natural = dialog->size(); + const bool first = !dialog->property("gamescope_fit").isValid(); + dialog->setProperty("gamescope_fit", true); + dialog->setMinimumSize(0, 0); + dialog->setMaximumSize(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX); + dialog->setGeometry(area); + QLayout* layout = dialog->layout(); + if (!first || !layout) { + return; + } + // A smaller dialog keeps its size, a card in the middle; it can be expanded to fill the + // screen and shrunk back (each time it opens it starts as a card) + const bool centred = natural.width() < area.width() * 85 / 100 && + natural.height() < area.height() * 85 / 100; + QList corner; + if (NeedsClose(dialog)) { + auto* close = new QPushButton(tr("Close"), dialog); + connect(close, &QPushButton::clicked, dialog, &QWidget::close); + corner.append(close); + } + QPushButton* expand = nullptr; + if (centred && !IsSmallPrompt(dialog)) { + expand = new QPushButton(tr("Expand"), dialog); + expand->setToolTip(tr("Fill the screen")); + corner.prepend(expand); + } + int strip = 0; + for (QPushButton* button : corner) { + button->setCursor(Qt::PointingHandCursor); + button->adjustSize(); + strip = (std::max)(strip, button->height() + 8); + } + const QMargins own = layout->contentsMargins(); + const QRect card_area((area.width() - natural.width()) / 2, + (area.height() - natural.height()) / 2, natural.width(), + natural.height()); + QFrame* card = centred ? AddCard(dialog, card_area) : nullptr; + // Card: margins around the dialog's own size, buttons above its top right corner. + // Full: the buttons in a strip at the top right of the screen. + // (The dialog's current layout each time, and guarded pointers: a message box rebuilds + // its layout on a style change, so AddCard's style sheet already deleted the one read + // above; the kept pointer crashed in QLayout::setContentsMargins) + QList> corner_buttons; + for (QPushButton* button : corner) { + corner_buttons.append(button); + } + const auto place = [dialog_ptr = QPointer{dialog}, own, area, card_area, + card_ptr = QPointer{card}, corner_buttons, strip, + expand_ptr = QPointer{expand}](bool full) { + QWidget* const target = dialog_ptr.data(); + QLayout* const target_layout = target ? target->layout() : nullptr; + if (!target_layout) { + return; + } + QFrame* const target_card = card_ptr.data(); + QPushButton* const target_expand = expand_ptr.data(); + QList buttons; + for (const QPointer& button : corner_buttons) { + if (button) { + buttons.append(button.data()); + } + } + if (full || !target_card) { + target_layout->setContentsMargins(own + + QMargins(0, buttons.isEmpty() ? 0 : strip, 0, 0)); + } else { + target_layout->setContentsMargins(own + + QMargins(card_area.left(), card_area.top(), + area.width() - card_area.right() - 1, + area.height() - card_area.bottom() - 1)); + } + if (target_card) { + target_card->setVisible(!full); + } + if (target_expand) { + target_expand->setText(full ? tr("Shrink") : tr("Expand")); + target_expand->setToolTip(full ? tr("Back to its own size") + : tr("Fill the screen")); + target_expand->adjustSize(); + } + if (buttons.isEmpty()) { + target->update(); + return; + } + int right = full || !target_card ? area.width() - 8 : card_area.right() + 1; + const int top = + full || !target_card + ? 6 + : (std::max)(6, card_area.top() - 14 - buttons.first()->height() - 6); + for (auto it = buttons.rbegin(); it != buttons.rend(); ++it) { + (*it)->move(right - (*it)->width(), top); + (*it)->raise(); + (*it)->show(); + right -= (*it)->width() + 6; + } + target->update(); + }; + if (expand) { + connect(expand, &QPushButton::clicked, dialog, [place, expand] { + // (The connection ends with the button, so expand is alive here) + const bool full = !expand->property("gamescope_full").toBool(); + expand->setProperty("gamescope_full", full); + place(full); + }); + } + // File pickers, per-game settings and the room browser open expanded (Shrink: a card) + const bool starts_full = expand && (qobject_cast(dialog) || + dialog->inherits("ConfigurePerGame") || + dialog->inherits("Lobby")); + if (starts_full) { + expand->setProperty("gamescope_full", true); + } + place(starts_full); + } + + // A prompt or short form: a few lines and fields, nothing to gain from filling the screen + static bool IsSmallPrompt(QWidget* dialog) { + static constexpr const char* small[] = { + "QMessageBox", "QInputDialog", "AboutDialog", + "DataDialog", "DirectConnectWindow", "HostRoomWindow", + "RyujinxDialog", "LimitableInputDialog", "SetPlayTimeDialog", + "SequenceDialog", "NewUserDialog", "ConfigureProfileManagerDeleteDialog", + "MigrationDialog", "CalibrationConfigurationDialog", + }; + for (const char* name : small) { + if (dialog->inherits(name)) { + return true; + } + } + return false; + } + + // A dialog leaves through its button row; without one (the room browser, Create Room) it + // has no way out + static bool NeedsClose(QWidget* dialog) { + for (auto* box : dialog->findChildren()) { + if (!box->buttons().isEmpty()) { + return false; + } + } + return true; + } + + // The dialog's own area is a card on a darker screen, so it doesn't float edgeless + static QFrame* AddCard(QWidget* dialog, const QRect& inner) { + const QColor base = dialog->palette().color(QPalette::Window); + if (dialog->objectName().isEmpty()) { + dialog->setObjectName(QStringLiteral("gamescopeDialog")); + } + dialog->setStyleSheet(dialog->styleSheet() + + QStringLiteral("\n#%1 { background-color: %2; }") + .arg(dialog->objectName(), base.darker(160).name())); + auto* card = new QFrame(dialog); + card->setObjectName(QStringLiteral("gamescopeCard")); + card->setStyleSheet(QStringLiteral("#gamescopeCard { background-color: %1; border: 1px " + "solid %2; border-radius: 10px; }") + .arg(base.name(), base.lighter(150).name())); + card->setGeometry(inner.adjusted(-14, -14, 14, 14)); + card->lower(); + return card; + } + + // A submenu opens in its menu's place: the menu swaps its entries for the submenu's, with + // ‹ Back on top, so only one menu is ever up. Set up as a menu gets its first action, so + // it's in place before the menu first measures itself. + static void PrepareMenu(QMenu* menu) { + if (menu->property("gamescope_menu").isValid()) { + return; + } + menu->setProperty("gamescope_menu", true); + connect(menu, &QMenu::aboutToShow, menu, [menu] { + menu->setProperty("gamescope_pages", QVariantList{}); + ShowPage(menu, nullptr); + }); + } + + // A tap (or Enter / Right) on a submenu's entry or on Back turns the page, the menu kept open + static bool TurnPage(QMenu* menu, QEvent* event) { + if (event->type() == QEvent::KeyPress) { + const int key = static_cast(event)->key(); + if (key != Qt::Key_Return && key != Qt::Key_Enter && key != Qt::Key_Right && + key != Qt::Key_Left) { + return false; + } + } else if (event->type() != QEvent::MouseButtonRelease) { + return false; + } + QAction* active = menu->activeAction(); + if (!active || !active->isEnabled()) { + return false; + } + QVariantList pages = menu->property("gamescope_pages").toList(); + const bool is_left = event->type() == QEvent::KeyPress && + static_cast(event)->key() == Qt::Key_Left; + if (active->property("gamescope_back").isValid() || (is_left && !pages.isEmpty())) { + pages.removeLast(); + } else if (auto* sub = qobject_cast(active->property("gamescope_sub").value()); + sub && !is_left) { + pages.append(QVariant::fromValue(sub)); + } else { + return false; + } + menu->setProperty("gamescope_pages", pages); + auto* page = pages.isEmpty() ? nullptr : qobject_cast(pages.last().value()); + ShowPage(menu, page); + return true; + } + + // Shows the menu's own entries (page null) or a submenu's, submenus as entries ending in › + static void ShowPage(QMenu* menu, QMenu* page) { + for (QAction* action : menu->actions()) { + if (action->property("gamescope_added").isValid()) { + menu->removeAction(action); + action->deleteLater(); + } else if (action->property("gamescope_hidden").isValid()) { + action->setVisible(true); + action->setProperty("gamescope_hidden", QVariant{}); + } + } + const QList own = menu->actions(); + QAction* first = own.isEmpty() ? nullptr : own.first(); + const auto add = [menu](QAction* added, QAction* before) { + added->setProperty("gamescope_added", true); + menu->insertAction(before, added); + }; + if (page) { + for (QAction* action : own) { + if (action->isVisible()) { + action->setVisible(false); + action->setProperty("gamescope_hidden", true); + } + } + QMetaObject::invokeMethod(page, "aboutToShow"); + auto* back = new QAction(QStringLiteral("‹ ") + tr("Back"), menu); + back->setProperty("gamescope_back", true); + add(back, first); + auto* separator = new QAction(menu); + separator->setSeparator(true); + add(separator, first); + for (QAction* original : page->actions()) { + if (!original->isVisible()) { + continue; + } + QAction* copy = nullptr; + if (original->isSeparator()) { + copy = new QAction(menu); + copy->setSeparator(true); + } else if (QMenu* sub = original->menu()) { + copy = new QAction(original->icon(), original->text() + QStringLiteral(" ›"), menu); + copy->setEnabled(original->isEnabled()); + copy->setProperty("gamescope_sub", QVariant::fromValue(sub)); + } else { + copy = new QAction(original->icon(), original->text(), menu); + copy->setEnabled(original->isEnabled()); + copy->setCheckable(original->isCheckable()); + copy->setChecked(original->isChecked()); + connect(copy, &QAction::triggered, original, [original] { original->trigger(); }); + } + add(copy, first); + } + } else { + for (QAction* action : own) { + QMenu* sub = action->menu(); + if (!sub || !action->isVisible()) { + continue; + } + // (A hidden action reads as disabled: its state is taken first) + const bool enabled = action->isEnabled(); + action->setVisible(false); + action->setProperty("gamescope_hidden", true); + auto* entry = new QAction(action->icon(), action->text() + QStringLiteral(" ›"), menu); + entry->setEnabled(enabled); + entry->setProperty("gamescope_sub", QVariant::fromValue(sub)); + add(entry, action); + } + } + if (!menu->isVisible()) { + return; + } + // An open menu turning its page: sized again and kept on the screen + menu->adjustSize(); + if (QScreen* screen = menu->screen()) { + const QRect area = screen->availableGeometry(); + QPoint at = menu->pos(); + at.setX((std::max)(area.left(), (std::min)(at.x(), area.right() - menu->width()))); + at.setY((std::max)(area.top(), (std::min)(at.y(), area.bottom() - menu->height()))); + menu->move(at); + } + } + + QWidget* main; +}; +} // namespace + +bool IsRunningUnder() { + return qEnvironmentVariableIsSet("GAMESCOPE_WAYLAND_DISPLAY") || + qgetenv("XDG_CURRENT_DESKTOP") == "gamescope"; +} + +// Opening a folder starts the desktop's file manager, which can't show under gamescope (in Game +// Mode it doesn't even reach a display): folders and files open in Eden's own file browser +class FolderBrowser final : public QObject { + Q_OBJECT + +public: + explicit FolderBrowser(QWidget* main_) : QObject(main_), main{main_} {} + +public slots: + void Open(const QUrl& url) { + const QFileInfo info(url.toLocalFile()); + const QString folder = info.isDir() ? info.absoluteFilePath() : info.absolutePath(); + QWidget* parent = QApplication::activeWindow() ? QApplication::activeWindow() : main; + QFileDialog browser(parent, folder); + browser.setOption(QFileDialog::DontUseNativeDialog); + browser.setFileMode(QFileDialog::ExistingFiles); + browser.setLabelText(QFileDialog::Reject, tr("Close")); + browser.setDirectory(folder); + if (!info.isDir()) { + browser.selectFile(info.fileName()); + } + browser.exec(); + } + +private: + QWidget* main; +}; + +void FitDialogsAndMenus(QWidget* main) { + if (IsRunningUnder()) { + qApp->installEventFilter(new GamescopeDialogs(main)); + QDesktopServices::setUrlHandler(QStringLiteral("file"), new FolderBrowser(main), "Open"); + } +} + +} // namespace Gamescope + +#include "gamescope.moc" diff --git a/src/yuzu/util/gamescope.h b/src/yuzu/util/gamescope.h new file mode 100644 index 0000000000..fd6e9c95cd --- /dev/null +++ b/src/yuzu/util/gamescope.h @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +class QWidget; + +// Steam's Game Mode runs Eden under gamescope, which shows one window at a time, scaled to fit +// the screen, with no title bars, and only one popup over it +namespace Gamescope { + +/// Whether Eden runs under gamescope +bool IsRunningUnder(); + +/// Under gamescope only: fits Eden's dialogs and menus to how gamescope shows windows +/// (see gamescope.cpp). `main` is the main window. +void FitDialogsAndMenus(QWidget* main); + +} // namespace Gamescope diff --git a/src/yuzu/util/overlay_dialog.cpp b/src/yuzu/util/overlay_dialog.cpp index 2d50f22acb..da74fc3772 100644 --- a/src/yuzu/util/overlay_dialog.cpp +++ b/src/yuzu/util/overlay_dialog.cpp @@ -12,6 +12,7 @@ #include "hid_core/hid_types.h" #include "ui_overlay_dialog.h" #include "yuzu/util/overlay_dialog.h" +#include "yuzu/util/gamescope.h" namespace { @@ -29,8 +30,12 @@ OverlayDialog::OverlayDialog(QWidget* parent, Core::System& system, const QStrin : QDialog(parent), ui{std::make_unique()}, use_rich_text{use_rich_text_} { ui->setupUi(this); + // Under gamescope only one normal window shows at a time: a window bypassing the window + // manager is drawn over the game instead of in its place + const bool gamescope = Gamescope::IsRunningUnder(); setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | Qt::WindowTitleHint | - Qt::WindowSystemMenuHint | Qt::CustomizeWindowHint); + Qt::WindowSystemMenuHint | Qt::CustomizeWindowHint | + (gamescope ? Qt::X11BypassWindowManagerHint : Qt::WindowFlags{})); setWindowModality(Qt::WindowModal); setAttribute(Qt::WA_TranslucentBackground);