From 3ff6606fcb6c1cae3aa46ef619f14b28efd3705d Mon Sep 17 00:00:00 2001 From: Felipe <54341095+Pipe530@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:04:37 -0300 Subject: [PATCH] Input mapping tabs UI, emulated touchpad, and rumble for non-PS5 controllers (#1541) * Revamp input mapping dialog with tabs, gamepad remapping and gyro emulation toggle Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add emulated touchpad (mouse/gamepad) with particle skins; widen input dialog Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Disable mouse touchpad mode when the cursor leaves the window Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add touchpad hand-cursor toggle; share touchpad options across both tabs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Hide touchpad trail and particles when the visual toggle is off Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Rename input mapping tabs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Rumble non-DualSense gamepads from haptic audio Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix input mapping, touchpad transitions, and rumble fallback --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: nmzik --- .github/workflows/build.yml | 12 +- CMakeLists.txt | 14 + assets/cursors/drawing.png | Bin 0 -> 25250 bytes assets/cursors/hand.png | Bin 0 -> 22571 bytes src/graphics/presentation/systemOverlay.cpp | 377 ++++++++++- .../presentation/window/hostInput.cpp | 570 ++++++++++++++++- src/graphics/presentation/window/hostInput.h | 23 + src/graphics/presentation/window/window.cpp | 64 +- src/launcher/CMakeLists.txt | 1 + src/launcher/include/controllerPreview.h | 2 + src/launcher/include/inputMappingDialog.h | 27 +- src/launcher/src/controllerPreview.cpp | 47 +- src/launcher/src/inputMappingDialog.cpp | 598 +++++++++++++----- src/libs/controller.cpp | 102 ++- src/libs/controller.h | 1 + src/libs/dualSenseHaptics.cpp | 27 + tests/HostInputTests.cpp | 205 ++++++ tests/PadHapticsTests.cpp | 49 +- 18 files changed, 1909 insertions(+), 210 deletions(-) create mode 100644 assets/cursors/drawing.png create mode 100644 assets/cursors/hand.png create mode 100644 tests/HostInputTests.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61b221222..c820aed70 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -113,7 +113,7 @@ jobs: shell: cmd run: | cmake --build _Build/windows --target launcher ^ - audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^ + audio_out2_port_tests pad_haptics_tests controller_settings_tests host_input_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests ^ shader_recompiler_compute_tests ^ resource_tracking_tests scalar_provenance_tests ^ resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests --parallel @@ -121,7 +121,7 @@ jobs: - name: Test shell: cmd run: | - ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$" + ctest --test-dir _Build/windows --output-on-failure -R "^(audio_out2_port|pad_haptics|controller_settings|host_input|ime_dialog|lru_cache|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$" - name: Install shell: cmd @@ -202,7 +202,7 @@ jobs: shell: bash run: | cmake --build _Build/macos \ - --target launcher audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \ + --target launcher audio_out2_port_tests pad_haptics_tests controller_settings_tests host_input_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \ memory_tracker_tests page_manager_tests shader_recompiler_compute_tests \ resource_tracking_tests scalar_provenance_tests \ resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests \ @@ -212,7 +212,7 @@ jobs: shell: bash run: | ctest --test-dir _Build/macos --output-on-failure \ - -R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$' + -R '^(audio_out2_port|pad_haptics|controller_settings|host_input|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$' - name: Install shell: bash @@ -388,7 +388,7 @@ jobs: run: | cmake --build _Build/linux \ --target launcher page_manager_tests memory_tracker_tests \ - audio_out2_port_tests pad_haptics_tests controller_settings_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \ + audio_out2_port_tests pad_haptics_tests controller_settings_tests host_input_tests ime_dialog_tests lru_cache_tests virtual_memory_allocation_tests \ resource_tracking_tests scalar_provenance_tests \ resource_materialization_tests shader_vertex_metadata_tests archive_file_tests avplayer_file_tests \ --parallel @@ -397,7 +397,7 @@ jobs: shell: bash run: | ctest --test-dir _Build/linux --output-on-failure \ - -R '^(audio_out2_port|pad_haptics|controller_settings|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$' + -R '^(audio_out2_port|pad_haptics|controller_settings|host_input|ime_dialog|lru_cache|memory_tracker|page_manager|resource_tracking|scalar_provenance|resource_materialization|shader_vertex_metadata|archive_file|avplayer_file|virtual_memory_allocation)$' - name: Install shell: bash diff --git a/CMakeLists.txt b/CMakeLists.txt index ba2d71a7c..21a5e35bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -526,6 +526,10 @@ add_executable(controller_settings_tests EXCLUDE_FROM_ALL target_link_libraries(controller_settings_tests common fmt::fmt SDL3::SDL3-static) target_include_directories(controller_settings_tests PRIVATE ${inc_headers}) +add_executable(host_input_tests EXCLUDE_FROM_ALL "${KYTY_TESTS_DIR}/HostInputTests.cpp") +target_link_libraries(host_input_tests common fmt::fmt SDL3::SDL3-static) +target_include_directories(host_input_tests PRIVATE ${inc_headers}) + add_executable(save_data_memory_tests EXCLUDE_FROM_ALL "${KYTY_TESTS_DIR}/SaveDataMemoryTests.cpp" ) @@ -644,6 +648,7 @@ if(BUILD_TESTING) add_test(NAME ngs2_sampler COMMAND $) add_test(NAME pad_haptics COMMAND $) add_test(NAME controller_settings COMMAND $) + add_test(NAME host_input COMMAND $) add_test(NAME save_data_memory COMMAND $) add_test(NAME png_enc COMMAND $) add_test(NAME ces COMMAND $) @@ -723,6 +728,7 @@ if(BUILD_TESTING) ngs2_sampler_tests pad_haptics_tests controller_settings_tests + host_input_tests save_data_memory_tests png_enc_tests ces_tests @@ -760,6 +766,14 @@ add_custom_target(kyty_trophy_sound add_dependencies(kyty_emulator kyty_trophy_sound) install(FILES "${KYTY_TROPHY_UNLOCK_SOUND}" DESTINATION assets/sounds) +add_custom_target(kyty_touch_cursors + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/assets/cursors" + "$/assets/cursors" + VERBATIM) +add_dependencies(kyty_emulator kyty_touch_cursors) +install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/assets/cursors" DESTINATION assets) + target_link_libraries(kyty_emulator ${kyty_emulator_link_libraries}) if (WIN32) target_link_libraries(kyty_emulator opengl32) diff --git a/assets/cursors/drawing.png b/assets/cursors/drawing.png new file mode 100644 index 0000000000000000000000000000000000000000..31bc6e9dde07a07200f45a44ce35296efce8e8ae GIT binary patch literal 25250 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 Jv&lL9KLLULhpqqs literal 0 HcmV?d00001 diff --git a/assets/cursors/hand.png b/assets/cursors/hand.png new file mode 100644 index 0000000000000000000000000000000000000000..08f84010d1de0f93507594da0b25f153f7a64ce0 GIT binary patch literal 22571 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 DP8#{; literal 0 HcmV?d00001 diff --git a/src/graphics/presentation/systemOverlay.cpp b/src/graphics/presentation/systemOverlay.cpp index 9197644d1..058f64b06 100644 --- a/src/graphics/presentation/systemOverlay.cpp +++ b/src/graphics/presentation/systemOverlay.cpp @@ -10,6 +10,7 @@ #include "gpu_blit_shaders/gpu_imgui_hdr_spv.h" #include "graphics/host_gpu/graphicContext.h" #include "graphics/presentation/ddsImage.h" +#include "graphics/presentation/window/hostInput.h" #include "imgui.h" #include "imgui_internal.h" #include "imgui_impl_vulkan.h" @@ -149,6 +150,47 @@ bool GetOverlaySnapshot(OverlaySnapshot* snapshot) { } constexpr size_t INPUT_QUEUE_CAPACITY = 128; +constexpr int MAX_TOUCH_CURSOR_IMAGE_DIMENSION = 2048; +constexpr float TOUCH_CURSOR_IMAGE_SIZE = 48.0f; + +enum class TouchParticleSkin : uint8_t { Classic, BlueNeon, Monochrome, Crimson, RedBlue }; + +TouchParticleSkin GetTouchParticleSkin() { + static const auto skin = [] { + for (const auto& value: Config::GetKeymap()) { + constexpr std::string_view key = "TouchParticleSkin="; + if (std::string_view(value).starts_with(key)) { + const auto name = std::string_view(value).substr(key.size()); + if (name == "BlueNeon") { + return TouchParticleSkin::BlueNeon; + } + if (name == "Monochrome") { + return TouchParticleSkin::Monochrome; + } + if (name == "Crimson") { + return TouchParticleSkin::Crimson; + } + if (name == "RedBlue") { + return TouchParticleSkin::RedBlue; + } + } + } + return TouchParticleSkin::Classic; + }(); + return skin; +} + +bool TouchCursorImageEnabled() { + static const bool enabled = [] { + for (const auto& value: Config::GetKeymap()) { + if (std::string_view(value) == "TouchCursorImage=Off") { + return false; + } + } + return true; + }(); + return enabled; +} enum class InputKind : uint8_t { Button, @@ -552,9 +594,11 @@ SystemOverlayVisualState GetSystemOverlayVisualState() noexcept { const auto system = SystemDialog::GetVisualState(); const bool trophy_active = g_trophy_notification_active.load(std::memory_order_acquire); const bool splash = g_splash_active.load(std::memory_order_acquire); - return {core.active || dialog.active || system.active || trophy_active || splash, + const auto cursor = GetHostInputTouchPadVisualState(); + return {core.active || dialog.active || system.active || trophy_active || splash || + (cursor.enabled && cursor.cursor_visible), core.revision + dialog.revision + system.revision + (trophy_active ? 1u : 0u) + - (splash ? 2u : 0u)}; + (splash ? 2u : 0u) + static_cast(cursor.revision)}; } bool ProcessSystemOverlayInput(const SDL_Event& event) { @@ -694,6 +738,20 @@ void NotifyTrophyUnlocked(std::string_view name, int32_t grade, } struct SystemOverlay::Impl { + enum class TouchParticleIcon : uint8_t { Square, Triangle, Cross, Circle }; + + struct TouchParticle { + ImVec2 position; + Clock::time_point born; + TouchParticleIcon icon; + }; + + struct TouchTrailSegment { + ImVec2 from; + ImVec2 to; + Clock::time_point born; + }; + explicit Impl(GraphicContext& context): graphics(context) {} ~Impl() { @@ -705,6 +763,12 @@ struct SystemOverlay::Impl { ReleaseVulkan(); if (imgui_context != nullptr) { ImGui::SetCurrentContext(imgui_context); + if (hand_cursor_image) { + ImGui::UnregisterUserTexture(hand_cursor_image.get()); + } + if (drawing_cursor_image) { + ImGui::UnregisterUserTexture(drawing_cursor_image.get()); + } if (trophy_notification_image) { ImGui::UnregisterUserTexture(trophy_notification_image.get()); } @@ -735,6 +799,8 @@ struct SystemOverlay::Impl { style.WindowRounding = 10.0f; style.FrameRounding = 6.0f; style.ItemSpacing = {8.0f, 8.0f}; + hand_cursor_image = LoadTouchCursorImage("hand.png"); + drawing_cursor_image = LoadTouchCursorImage("drawing.png"); } void PlayTrophySound() { @@ -775,6 +841,55 @@ struct SystemOverlay::Impl { } } + std::unique_ptr LoadTouchCursorImage(std::string_view filename) { + const char* base_path = SDL_GetBasePath(); + if (base_path == nullptr) { + LOGF("Could not locate the emulator folder for touchpad cursor images: %s\n", + SDL_GetError()); + return nullptr; + } + const std::filesystem::path path = + std::filesystem::path(base_path) / "assets" / "cursors" / std::string(filename); + + Common::File file; + if (!file.Open(path, Common::File::Mode::Read)) { + LOGF("Touchpad cursor image not found: %s\n", Common::PathToString(path).c_str()); + return nullptr; + } + const auto encoded = file.ReadWholeBuffer(); + if (encoded.empty() || + encoded.size() > static_cast(std::numeric_limits::max())) { + LOGF("Touchpad cursor image is empty or too large: %s\n", + Common::PathToString(path).c_str()); + return nullptr; + } + + int width = 0; + int height = 0; + auto* pixels = stbi_load_from_memory( + reinterpret_cast(encoded.data()), static_cast(encoded.size()), + &width, &height, nullptr, 4); + if (pixels == nullptr || width <= 0 || height <= 0 || + width > MAX_TOUCH_CURSOR_IMAGE_DIMENSION || + height > MAX_TOUCH_CURSOR_IMAGE_DIMENSION) { + LOGF("Could not load touchpad cursor image %s: %s\n", + Common::PathToString(path).c_str(), + pixels == nullptr ? stbi_failure_reason() : "image dimensions exceed 2048x2048"); + stbi_image_free(pixels); + return nullptr; + } + + auto texture = std::make_unique(); + texture->Create(ImTextureFormat_RGBA32, width, height); + std::memcpy(texture->Pixels, pixels, + static_cast(width) * static_cast(height) * 4); + texture->UseColors = true; + stbi_image_free(pixels); + ImGui::RegisterUserTexture(texture.get()); + LOGF("Loaded touchpad cursor image: %s\n", Common::PathToString(path).c_str()); + return texture; + } + std::unique_ptr LoadTrophyIcon(std::span encoded) { if (encoded.empty() || encoded.size() > static_cast(std::numeric_limits::max())) { @@ -1191,6 +1306,248 @@ struct SystemOverlay::Impl { } } + void EmitTouchParticles(ImVec2 from, ImVec2 to, Clock::time_point now) { + const float dx = to.x - from.x; + const float dy = to.y - from.y; + const float distance = std::hypot(dx, dy); + if (distance < 0.01f) { + return; + } + if (distance >= 1.0f) { + trail_segments.push_back({from, to, now}); + if (trail_segments.size() > MAX_TOUCH_TRAIL_SEGMENTS) { + trail_segments.erase(trail_segments.begin()); + } + } + + constexpr float PARTICLE_SPACING = 24.0f; + float traveled = 0.0f; + while (particle_distance + distance - traveled >= PARTICLE_SPACING) { + traveled += PARTICLE_SPACING - particle_distance; + const float fraction = std::clamp(traveled / distance, 0.0f, 1.0f); + particles.push_back( + {{from.x + dx * fraction, from.y + dy * fraction}, now, next_particle_icon}); + next_particle_icon = static_cast( + (static_cast(next_particle_icon) + 1) % 4); + if (particles.size() > MAX_TOUCH_PARTICLES) { + particles.erase(particles.begin()); + } + particle_distance = 0.0f; + } + particle_distance += distance - traveled; + } + + void DrawTouchParticles(ImDrawList* draw, Clock::time_point now) { + const auto skin = GetTouchParticleSkin(); + constexpr float PARTICLE_LIFETIME = 1.0f; + constexpr float TRAIL_LIFETIME = 0.35f; + std::erase_if(trail_segments, [now](const auto& segment) { + return std::chrono::duration(now - segment.born).count() >= TRAIL_LIFETIME; + }); + std::erase_if(particles, [now](const auto& particle) { + return std::chrono::duration(now - particle.born).count() >= + PARTICLE_LIFETIME; + }); + for (const auto& segment: trail_segments) { + const float age = + std::chrono::duration(now - segment.born).count() / TRAIL_LIFETIME; + const float fade = 1.0f - age; + if (skin == TouchParticleSkin::RedBlue) { + draw->AddLine(segment.from, segment.to, + IM_COL32(20, 75, 220, static_cast(105.0f * fade)), + 9.0f * (1.0f - age * 0.5f)); + draw->AddLine(segment.from, segment.to, + IM_COL32(255, 45, 65, static_cast(230.0f * fade)), + 4.0f * (1.0f - age * 0.5f)); + } else if (skin == TouchParticleSkin::Crimson) { + draw->AddLine(segment.from, segment.to, + IM_COL32(110, 0, 8, static_cast(130.0f * fade)), + 9.0f * (1.0f - age * 0.5f)); + draw->AddLine(segment.from, segment.to, + IM_COL32(255, 35, 20, static_cast(235.0f * fade)), + 4.0f * (1.0f - age * 0.5f)); + } else { + draw->AddLine(segment.from, segment.to, + IM_COL32(35, 145, 255, static_cast(180.0f * fade)), + 5.0f * (1.0f - age * 0.5f)); + } + } + + for (const auto& particle: particles) { + const float age = + std::chrono::duration(now - particle.born).count() / PARTICLE_LIFETIME; + const float alpha = 1.0f - age; + const float size = 9.0f * (1.0f - age * 0.35f); + const float drift = (static_cast(particle.icon) % 2 == 0 ? -1.0f : 1.0f) * + 8.0f * age; + const ImVec2 center {particle.position.x + drift, particle.position.y - 18.0f * age}; + const auto faded = [alpha, skin, icon = particle.icon](int r, int g, int b) { + if (skin == TouchParticleSkin::BlueNeon) { + r = 75; + g = 220; + b = 255; + } else if (skin == TouchParticleSkin::RedBlue) { + const bool red = icon == TouchParticleIcon::Square || + icon == TouchParticleIcon::Cross; + r = red ? 255 : 75; + g = red ? 65 : 175; + b = red ? 85 : 255; + } else if (skin == TouchParticleSkin::Monochrome) { + r = 235; + g = 245; + b = 255; + } else if (skin == TouchParticleSkin::Crimson) { + const bool ember = icon == TouchParticleIcon::Triangle || + icon == TouchParticleIcon::Circle; + r = 255; + g = ember ? 205 : 115; + b = ember ? 70 : 15; + } + return IM_COL32(r, g, b, static_cast(255.0f * alpha)); + }; + + if (skin != TouchParticleSkin::Classic) { + const ImU32 glow = + skin == TouchParticleSkin::BlueNeon + ? IM_COL32(0, 115, 255, static_cast(38.0f * alpha)) + : (skin == TouchParticleSkin::RedBlue + ? IM_COL32(75, 100, 255, static_cast(55.0f * alpha)) + : (skin == TouchParticleSkin::Crimson + ? IM_COL32(255, 70, 0, static_cast(70.0f * alpha)) + : IM_COL32(115, 165, 255, static_cast(30.0f * alpha)))); + draw->AddCircleFilled(center, size + 6.0f, glow); + } + const ImU32 particle_glow = + skin == TouchParticleSkin::Crimson + ? (particle.icon == TouchParticleIcon::Triangle || + particle.icon == TouchParticleIcon::Circle + ? IM_COL32(255, 195, 30, static_cast(135.0f * alpha)) + : IM_COL32(255, 95, 0, static_cast(135.0f * alpha))) + : (skin == TouchParticleSkin::RedBlue + ? (particle.icon == TouchParticleIcon::Square || + particle.icon == TouchParticleIcon::Cross + ? IM_COL32(255, 40, 65, static_cast(105.0f * alpha)) + : IM_COL32(35, 125, 255, static_cast(105.0f * alpha))) + : IM_COL32(35, 145, 255, + static_cast((skin == TouchParticleSkin::BlueNeon + ? 100.0f + : 65.0f) * + alpha))); + draw->AddCircleFilled(center, size + 2.0f, particle_glow); + switch (particle.icon) { + case TouchParticleIcon::Square: + draw->AddRect({center.x - size * 0.65f, center.y - size * 0.65f}, + {center.x + size * 0.65f, center.y + size * 0.65f}, + faded(255, 105, 180), 2.0f, 0, 2.2f); + break; + case TouchParticleIcon::Triangle: + draw->AddLine({center.x, center.y - size}, {center.x - size, center.y + size}, + faded(80, 230, 130), 2.2f); + draw->AddLine({center.x - size, center.y + size}, + {center.x + size, center.y + size}, faded(80, 230, 130), 2.2f); + draw->AddLine({center.x + size, center.y + size}, + {center.x, center.y - size}, faded(80, 230, 130), 2.2f); + break; + case TouchParticleIcon::Cross: + draw->AddLine({center.x - size * 0.75f, center.y - size * 0.75f}, + {center.x + size * 0.75f, center.y + size * 0.75f}, + faded(90, 170, 255), 2.5f); + draw->AddLine({center.x + size * 0.75f, center.y - size * 0.75f}, + {center.x - size * 0.75f, center.y + size * 0.75f}, + faded(90, 170, 255), 2.5f); + break; + case TouchParticleIcon::Circle: + draw->AddCircle(center, size * 0.8f, faded(255, 105, 105), 0, 2.2f); + break; + } + } + } + + void DrawTouchPadCursor(const HostInputTouchPadVisualState& state) { + if (!TouchCursorImageEnabled()) { + particles.clear(); + trail_segments.clear(); + return; + } + const auto now = Clock::now(); + if (state.enabled && state.position_valid) { + const ImVec2 target {state.x * extent.width, state.y * extent.height}; + if (!cursor_initialized || state.gesture != cursor_gesture) { + cursor_position = target; + cursor_initialized = true; + cursor_gesture = state.gesture; + particle_distance = 0.0f; + } else { + const float dt = last_cursor_update == Clock::time_point {} + ? 1.0f / 60.0f + : std::clamp(std::chrono::duration(now - + last_cursor_update) + .count(), + 1.0f / 1000.0f, 0.05f); + const ImVec2 previous = cursor_position; + const float follow = 1.0f - std::exp(-18.0f * dt); + cursor_position.x += (target.x - cursor_position.x) * follow; + cursor_position.y += (target.y - cursor_position.y) * follow; + if (state.drawing) { + EmitTouchParticles(previous, cursor_position, now); + } + } + last_cursor_update = now; + } + auto* draw = ImGui::GetForegroundDrawList(); + DrawTouchParticles(draw, now); + if (!state.enabled || !state.cursor_visible || + !state.position_valid || !cursor_initialized) { + return; + } + + const ImVec2 position = cursor_position; + const auto& cursor_image = + state.drawing ? drawing_cursor_image : hand_cursor_image; + if (cursor_image != nullptr) { + const float scale = TOUCH_CURSOR_IMAGE_SIZE / + static_cast(std::max(cursor_image->Height, 1)); + const ImVec2 size {static_cast(cursor_image->Width) * scale, + static_cast(cursor_image->Height) * scale}; + draw->AddImage(cursor_image->GetTexRef(), position, + {position.x + size.x, position.y + size.y}); + return; + } + + const ImU32 blue = IM_COL32(35, 145, 255, 220); + const ImVec2 hand[] = { + {position.x, position.y}, {position.x, position.y + 20.0f}, + {position.x + 4.0f, position.y + 16.0f}, + {position.x + 9.0f, position.y + 27.0f}, + {position.x + 13.0f, position.y + 25.0f}, + {position.x + 10.0f, position.y + 17.0f}, + {position.x + 18.0f, position.y + 18.0f}, + {position.x + 20.0f, position.y + 15.0f}, + {position.x + 19.0f, position.y + 12.0f}, + {position.x + 12.0f, position.y + 10.0f}, + {position.x + 12.0f, position.y + 4.0f}, + {position.x + 10.0f, position.y + 2.0f}, + {position.x + 8.0f, position.y + 3.0f}, + {position.x + 8.0f, position.y + 10.0f}, + {position.x + 6.0f, position.y + 2.0f}, + {position.x + 4.0f, position.y + 2.0f}, + {position.x + 3.0f, position.y + 10.0f}, + {position.x + 2.0f, position.y + 1.0f}, + }; + draw->AddConvexPolyFilled(hand, IM_ARRAYSIZE(hand), IM_COL32(250, 250, 250, 255)); + for (std::size_t i = 0; i < IM_ARRAYSIZE(hand); i++) { + draw->AddLine(hand[i], hand[(i + 1) % IM_ARRAYSIZE(hand)], + IM_COL32(20, 25, 35, 255), 1.5f); + } + if (state.drawing) { + const ImVec2 pen_start {position.x + 12.0f, position.y + 8.0f}; + const ImVec2 pen_end {position.x + 23.0f, position.y - 3.0f}; + draw->AddLine(pen_start, pen_end, IM_COL32(10, 45, 95, 255), 5.0f); + draw->AddLine(pen_start, pen_end, blue, 2.5f); + } + } + + void DrawTrophyNotification(vk::Extent2D frame_extent, Clock::time_point now) { std::erase_if(retired_trophy_images, [](const auto& texture) { if (texture->Status != ImTextureStatus_Destroyed) { @@ -1316,7 +1673,8 @@ struct SystemOverlay::Impl { const bool has_overlay = GetOverlaySnapshot(&snapshot); const auto now = Clock::now(); if (!has_overlay && !g_trophy_notification_active.load(std::memory_order_acquire) && - !g_splash_active.load(std::memory_order_acquire)) { + !g_splash_active.load(std::memory_order_acquire) && + !GetHostInputTouchPadVisualState().enabled) { return false; } const auto prepared_session = snapshot.session; @@ -1356,6 +1714,7 @@ struct SystemOverlay::Impl { } } DrawTrophyNotification(frame_extent, now); + DrawTouchPadCursor(GetHostInputTouchPadVisualState()); ImGui::Render(); extent = frame_extent; return true; @@ -1410,6 +1769,18 @@ struct SystemOverlay::Impl { ImVec2 right_stick {}; OverlaySession session; std::unique_ptr trophy_notification_image; + std::unique_ptr hand_cursor_image; + std::unique_ptr drawing_cursor_image; + bool cursor_initialized = false; + ImVec2 cursor_position {}; + float particle_distance = 0.0f; + TouchParticleIcon next_particle_icon = TouchParticleIcon::Square; + Clock::time_point last_cursor_update; + uint64_t cursor_gesture = 0; + std::vector particles; + std::vector trail_segments; + static constexpr std::size_t MAX_TOUCH_PARTICLES = 64; + static constexpr std::size_t MAX_TOUCH_TRAIL_SEGMENTS = 128; std::optional trophy_notification; Clock::time_point trophy_notification_born; std::vector> retired_trophy_images; diff --git a/src/graphics/presentation/window/hostInput.cpp b/src/graphics/presentation/window/hostInput.cpp index 980673a2d..fdda4afca 100644 --- a/src/graphics/presentation/window/hostInput.cpp +++ b/src/graphics/presentation/window/hostInput.cpp @@ -12,8 +12,10 @@ #include #include #include +#include #include #include +#include namespace Libs::Graphics { @@ -27,6 +29,8 @@ struct ControlInfo { float touch_x = 0.0f; Controller::Setting setting {}; + bool gyro = false; + bool touchpad_mode = false; }; static constexpr std::array CONTROL_INFO = { @@ -61,10 +65,66 @@ static constexpr std::array CONTROL_INFO = { ControlInfo {.name = "VibrationIntensity", .setting = Controller::Setting::VibrationIntensity}, ControlInfo {.name = "TriggerEffectIntensity", .setting = Controller::Setting::TriggerEffectIntensity}, + ControlInfo {.name = "GyroToggle", .gyro = true}, + ControlInfo {.name = "TouchPadMode", .touchpad_mode = true}, }; constexpr std::size_t INVALID_CONTROL = CONTROL_INFO.size(); +struct PadSource { + std::string_view name; + int button; // SDL_GamepadButton, or -1 for an analog trigger +}; + +constexpr std::array PAD_SOURCES = { + PadSource {"South", SDL_GAMEPAD_BUTTON_SOUTH}, + PadSource {"East", SDL_GAMEPAD_BUTTON_EAST}, + PadSource {"West", SDL_GAMEPAD_BUTTON_WEST}, + PadSource {"North", SDL_GAMEPAD_BUTTON_NORTH}, + PadSource {"Back", SDL_GAMEPAD_BUTTON_BACK}, + PadSource {"Guide", SDL_GAMEPAD_BUTTON_GUIDE}, + PadSource {"Start", SDL_GAMEPAD_BUTTON_START}, + PadSource {"LeftStick", SDL_GAMEPAD_BUTTON_LEFT_STICK}, + PadSource {"RightStick", SDL_GAMEPAD_BUTTON_RIGHT_STICK}, + PadSource {"LeftShoulder", SDL_GAMEPAD_BUTTON_LEFT_SHOULDER}, + PadSource {"RightShoulder", SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER}, + PadSource {"DpadUp", SDL_GAMEPAD_BUTTON_DPAD_UP}, + PadSource {"DpadDown", SDL_GAMEPAD_BUTTON_DPAD_DOWN}, + PadSource {"DpadLeft", SDL_GAMEPAD_BUTTON_DPAD_LEFT}, + PadSource {"DpadRight", SDL_GAMEPAD_BUTTON_DPAD_RIGHT}, + PadSource {"Misc1", SDL_GAMEPAD_BUTTON_MISC1}, + PadSource {"RightPaddle1", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1}, + PadSource {"LeftPaddle1", SDL_GAMEPAD_BUTTON_LEFT_PADDLE1}, + PadSource {"RightPaddle2", SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2}, + PadSource {"LeftPaddle2", SDL_GAMEPAD_BUTTON_LEFT_PADDLE2}, + PadSource {"Touchpad", SDL_GAMEPAD_BUTTON_TOUCHPAD}, + PadSource {"LeftTrigger", -1}, + PadSource {"RightTrigger", -1}, +}; + +constexpr std::size_t LEFT_TRIGGER_SOURCE = PAD_SOURCES.size() - 2; +constexpr std::size_t RIGHT_TRIGGER_SOURCE = PAD_SOURCES.size() - 1; +constexpr std::size_t INVALID_SOURCE = PAD_SOURCES.size(); + +struct PadDefault { + std::string_view control; + std::string_view source; +}; + +constexpr std::array PAD_DEFAULTS = { + PadDefault {"Cross", "South"}, PadDefault {"Circle", "East"}, + PadDefault {"Square", "West"}, PadDefault {"Triangle", "North"}, + PadDefault {"Options", "Start"}, PadDefault {"L3", "LeftStick"}, + PadDefault {"R3", "RightStick"}, PadDefault {"L1", "LeftShoulder"}, + PadDefault {"R1", "RightShoulder"}, PadDefault {"L2", "LeftTrigger"}, + PadDefault {"R2", "RightTrigger"}, PadDefault {"Up", "DpadUp"}, + PadDefault {"Down", "DpadDown"}, PadDefault {"Left", "DpadLeft"}, + PadDefault {"Right", "DpadRight"}, PadDefault {"TouchPad", "Touchpad"}, + PadDefault {"GyroToggle", "Back"}, +}; + +constexpr std::string_view PAD_PREFIX = "Pad:"; + struct Binding { SDL_Keycode key = SDLK_UNKNOWN; uint8_t mouse_button = 0; @@ -74,6 +134,7 @@ struct Binding { constexpr int MOUSE_POLL_INTERVAL_MS = 33; constexpr uint64_t CURSOR_IDLE_HIDE_MS = 2000; constexpr std::string_view MOUSE_SENSITIVITY = "MouseSensitivity="; +constexpr std::string_view TOUCH_PARTICLE_SKIN = "TouchParticleSkin="; struct MouseJoystickState { bool enabled = false; @@ -85,6 +146,38 @@ MouseJoystickState g_mouse; SDL_Window* g_mouse_window = nullptr; uint64_t g_cursor_hide_at = 0; +struct MouseTouchPadState { + bool enabled = false; + bool cursor_visible = true; + bool drawing = false; + bool position_valid = false; + float x = 0.5f; + float y = 0.5f; + uint64_t revision = 0; + uint64_t gesture = 0; +}; + +struct GamepadTouchPadState { + bool enabled = false; + bool drawing = false; + int id = -1; + int x_axis = 128; + int y_axis = 128; + float x = 0.5f; + float y = 0.5f; + uint64_t next_poll = 0; + uint64_t last_poll = 0; +}; + +constexpr int GAMEPAD_TOUCHPAD_POLL_MS = 8; +constexpr int GAMEPAD_TOUCHPAD_DEADZONE = 18; +constexpr float GAMEPAD_TOUCHPAD_SPEED = 0.8f; + +MouseTouchPadState g_mouse_touchpad; +GamepadTouchPadState g_gamepad_touchpad; +std::mutex g_touchpad_mutex; +std::unordered_map> g_pad_trigger_pressed; + std::size_t ControlFromName(std::string_view name) { const auto info = std::find_if(CONTROL_INFO.begin(), CONTROL_INFO.end(), [name](const auto& item) { return item.name == name; }); @@ -126,6 +219,14 @@ public: InputMap() { for (const auto& value: Config::GetKeymap()) { const std::string_view entry = value; + if (entry == "Keyboard=Disabled") { + m_custom = true; + continue; + } + if (entry == "Pad=Disabled") { + m_custom_pad = true; + continue; + } if (entry.starts_with(MOUSE_SENSITIVITY)) { const float sensitivity = std::strtof(value.c_str() + MOUSE_SENSITIVITY.size(), nullptr); @@ -133,6 +234,13 @@ public: std::clamp(std::isfinite(sensitivity) ? sensitivity : 1.0f, 0.1f, 5.0f); continue; } + if (entry.starts_with(TOUCH_PARTICLE_SKIN) || entry.starts_with("TouchCursorImage=")) { + continue; + } + if (entry.starts_with(PAD_PREFIX)) { + AddPadEntry(entry.substr(PAD_PREFIX.size()), value); + continue; + } const auto split = entry.find('='); Binding binding; @@ -153,10 +261,25 @@ public: } Add(binding); } + if (!m_custom_pad) { + for (const auto& item: PAD_DEFAULTS) { + AssignPad(ControlFromName(item.control), SourceFromName(item.source)); + } + } } - [[nodiscard]] bool Custom() const { return m_size != 0; } + [[nodiscard]] bool Custom() const { return m_custom; } [[nodiscard]] float MouseSensitivity() const { return m_mouse_sensitivity; } + [[nodiscard]] std::size_t PadControl(std::size_t source) const { + return source < m_pad_controls.size() ? m_pad_controls[source] : INVALID_CONTROL; + } + + static std::size_t SourceFromButton(int button) { + const auto item = std::find_if(PAD_SOURCES.begin(), PAD_SOURCES.end(), + [button](const auto& s) { return s.button == button; }); + return static_cast(std::distance(PAD_SOURCES.begin(), item)); + } + [[nodiscard]] std::size_t FindKey(int key_code) const { key_code = NormalizeKey(static_cast(key_code)); const auto binding = @@ -173,7 +296,39 @@ public: } private: + static std::size_t SourceFromName(std::string_view name) { + const auto item = std::find_if(PAD_SOURCES.begin(), PAD_SOURCES.end(), + [name](const auto& s) { return s.name == name; }); + return static_cast(std::distance(PAD_SOURCES.begin(), item)); + } + + void AssignPad(std::size_t control, std::size_t source) { + for (auto& existing: m_pad_controls) { + if (existing == control) { + existing = INVALID_CONTROL; + } + } + m_pad_controls[source] = control; + } + + void AddPadEntry(std::string_view entry, const std::string& original) { + const auto split = entry.find('='); + const auto control = split != std::string_view::npos ? ControlFromName(entry.substr(0, split)) + : INVALID_CONTROL; + const auto source = split != std::string_view::npos ? SourceFromName(entry.substr(split + 1)) + : INVALID_SOURCE; + if (control == INVALID_CONTROL || source == INVALID_SOURCE) { + EXIT("Invalid input mapping: %s\n", original.c_str()); + } + const auto& info = CONTROL_INFO[control]; + if (info.button == 0 && !info.gyro && info.axis != Controller::Axis::AxisMax) { + EXIT("Invalid input mapping: %s\n", original.c_str()); + } + m_custom_pad = true; + AssignPad(control, source); + } void Add(const Binding& binding) { + m_custom = true; for (std::size_t index = 0; index < m_size;) { if (Conflicts(m_bindings[index], binding)) { m_bindings[index] = m_bindings[--m_size]; @@ -186,6 +341,13 @@ private: } std::array m_bindings {}; + std::array m_pad_controls = [] { + std::array a {}; + a.fill(INVALID_CONTROL); + return a; + }(); + bool m_custom = false; + bool m_custom_pad = false; std::size_t m_size = 0; float m_mouse_sensitivity = 1.0f; }; @@ -211,6 +373,39 @@ void SetTouchPad(float x, bool down) { Controller::SetTouchPad(Controller::HOST_INPUT_CONTROLLER_ID, 0, down, x, 0.5f); } +void SetSwipeTouchPad(bool down, float x, float y) { + Controller::SetTouchPad(Controller::HOST_INPUT_CONTROLLER_ID, 1, down, x, y); +} + +void RecordMouseTouchPadPosition(float x, float y) { + int width = 0; + int height = 0; + if (g_mouse_window != nullptr) { + SDL_GetWindowSize(g_mouse_window, &width, &height); + } + if (width > 0 && height > 0) { + x = std::clamp(x / static_cast(width), 0.0f, 1.0f); + y = std::clamp(y / static_cast(height), 0.0f, 1.0f); + } else { + x = 0.5f; + y = 0.5f; + } + + std::scoped_lock lock(g_touchpad_mutex); + if (!g_mouse_touchpad.enabled) { + return; + } + g_mouse_touchpad.x = x; + g_mouse_touchpad.y = y; + g_mouse_touchpad.position_valid = true; + g_mouse_touchpad.revision++; +} + +bool TouchPadModeActive() { + std::scoped_lock lock(g_touchpad_mutex); + return g_mouse_touchpad.enabled || g_gamepad_touchpad.enabled; +} + uint32_t DefaultKeyboardButton(int key_code) { switch (NormalizeKey(static_cast(key_code))) { case SDLK_UP: return Controller::PAD_BUTTON_UP; @@ -270,6 +465,18 @@ void SetControl(std::size_t control, bool down) { SetButton(info.button, down); return; } + if (info.gyro) { + if (down) { + Controller::ToggleGyroEmulation(Controller::HOST_INPUT_CONTROLLER_ID); + } + return; + } + if (info.touchpad_mode) { + if (down) { + HostInputToggleTouchPadMode(); + } + return; + } if (info.axis == Controller::Axis::AxisMax) { CycleSettingOnPress(info.setting, down); return; @@ -297,6 +504,11 @@ void DefaultKeyboardInput(int key_code, bool down) { switch (NormalizeKey(static_cast(key_code))) { case SDLK_BACKSPACE: SetTouchPad(0.25f, down); return; + case SDLK_F6: + if (down) { + HostInputToggleTouchPadMode(); + } + return; case SDLK_TAB: SetTouchPad(0.75f, down); return; case SDLK_1: CycleSettingOnPress(Controller::Setting::SpeakerVolume, down); return; case SDLK_2: CycleSettingOnPress(Controller::Setting::VibrationIntensity, down); return; @@ -399,8 +611,134 @@ bool IsCursorActivity(const SDL_Event& event) { return which != SDL_TOUCH_MOUSEID && which != SDL_PEN_MOUSEID; } +std::array GamepadLeftStick(int id) { + auto* pad = SDL_GetGamepadFromID(id); + if (pad == nullptr) { + return {128, 128}; + } + const auto value = [pad](SDL_GamepadAxis axis) { + return Controller::controller_get_axis(SDL_JOYSTICK_AXIS_MIN, SDL_JOYSTICK_AXIS_MAX, + SDL_GetGamepadAxis(pad, axis)); + }; + return {value(SDL_GAMEPAD_AXIS_LEFTX), value(SDL_GAMEPAD_AXIS_LEFTY)}; +} + +void ApplyPadControl(int id, std::size_t control, std::size_t source, bool down) { + if (control == INVALID_CONTROL) { + return; + } + const auto& info = CONTROL_INFO[control]; + if (info.gyro) { + if (down) { + Controller::ToggleGyroEmulation(id); + } + } else if (info.touchpad_mode) { + if (down) { + HostInputToggleGamepadTouchPad(id); + } + } else if (info.button == Controller::PAD_BUTTON_CROSS && HostInputGamepadDrawButton(id, down)) { + return; + } else if (info.button == Controller::PAD_BUTTON_TOUCH_PAD) { + // Real touchpads report their own finger position; other inputs emulate a click. + if (source != PAD_SOURCES.size() && PAD_SOURCES[source].button != SDL_GAMEPAD_BUTTON_TOUCHPAD) { + Controller::SetTouchPad(id, 0, down, info.touch_x != 0.0f ? info.touch_x : 0.5f, 0.5f); + } + Controller::SetButton(id, Controller::PAD_BUTTON_TOUCH_PAD, down); + } else if (info.button == Controller::PAD_BUTTON_L2) { + Controller::SetAxis(id, Controller::Axis::TriggerLeft, down ? 255 : 0); + } else if (info.button == Controller::PAD_BUTTON_R2) { + Controller::SetAxis(id, Controller::Axis::TriggerRight, down ? 255 : 0); + } else if (info.button != 0) { + Controller::SetButton(id, info.button, down); + } else if (info.axis == Controller::Axis::AxisMax) { + CycleSettingOnPress(info.setting, down); + } +} + +int PollGamepadTouchPad(uint64_t now_ms) { + float x = 0; + float y = 0; + int id = -1; + bool drawing = false; + { + std::scoped_lock lock(g_touchpad_mutex); + if (!g_gamepad_touchpad.enabled) { + return -1; + } + if (now_ms < g_gamepad_touchpad.next_poll) { + return static_cast(g_gamepad_touchpad.next_poll - now_ms); + } + + const float dt = g_gamepad_touchpad.last_poll == 0 || now_ms <= g_gamepad_touchpad.last_poll + ? 0.008f + : std::min(static_cast(now_ms - g_gamepad_touchpad.last_poll) * + 0.001f, + 0.05f); + g_gamepad_touchpad.last_poll = now_ms; + g_gamepad_touchpad.next_poll = now_ms + GAMEPAD_TOUCHPAD_POLL_MS; + const auto velocity = [](int axis) { + const int offset = axis - 128; + if (std::abs(offset) <= GAMEPAD_TOUCHPAD_DEADZONE) { + return 0.0f; + } + const int adjusted = + offset > 0 ? offset - GAMEPAD_TOUCHPAD_DEADZONE : offset + GAMEPAD_TOUCHPAD_DEADZONE; + return static_cast(adjusted) / static_cast(127 - GAMEPAD_TOUCHPAD_DEADZONE); + }; + const float old_x = g_gamepad_touchpad.x; + const float old_y = g_gamepad_touchpad.y; + g_gamepad_touchpad.x = std::clamp( + old_x + velocity(g_gamepad_touchpad.x_axis) * GAMEPAD_TOUCHPAD_SPEED * dt, 0.0f, 1.0f); + g_gamepad_touchpad.y = std::clamp( + old_y + velocity(g_gamepad_touchpad.y_axis) * GAMEPAD_TOUCHPAD_SPEED * dt, 0.0f, 1.0f); + if (old_x != g_gamepad_touchpad.x || old_y != g_gamepad_touchpad.y) { + g_mouse_touchpad.revision++; + } + id = g_gamepad_touchpad.id; + x = g_gamepad_touchpad.x; + y = g_gamepad_touchpad.y; + drawing = g_gamepad_touchpad.drawing; + } + if (drawing) { + Controller::SetTouchPad(id, 1, true, x, y); + } + return GAMEPAD_TOUCHPAD_POLL_MS; +} + } // namespace +void HostInputPadButton(int id, int button, bool down) { + const auto source = InputMap::SourceFromButton(button); + const auto& map = GetInputMap(); + ApplyPadControl(id, map.PadControl(source), source, down); +} + +void HostInputPadTrigger(int id, bool left, int value) { + const auto source = left ? LEFT_TRIGGER_SOURCE : RIGHT_TRIGGER_SOURCE; + const auto control = GetInputMap().PadControl(source); + if (control == INVALID_CONTROL) { + return; + } + const auto& info = CONTROL_INFO[control]; + if (info.button == Controller::PAD_BUTTON_L2) { + Controller::SetAxis(id, Controller::Axis::TriggerLeft, value); + } else if (info.button == Controller::PAD_BUTTON_R2) { + Controller::SetAxis(id, Controller::Axis::TriggerRight, value); + } else { + auto& state = g_pad_trigger_pressed[id][left ? 0 : 1]; + const bool down = state ? value > 32 : value > 96; + if (down != state) { + state = down; + ApplyPadControl(id, control, source, down); + } + } +} + +void HostInputDisconnectGamepad(int id) { + g_pad_trigger_pressed.erase(id); + HostInputDisconnectGamepadTouchPad(id); +} + void HostInputInit(SDL_Window* window) { GetInputMap(); g_mouse_window = window; @@ -410,6 +748,16 @@ void HostInputInit(SDL_Window* window) { } void HostInputShutdown() { + g_pad_trigger_pressed.clear(); + HostInputSetTouchPadMode(false); + int gamepad_touch_id = -1; + { + std::scoped_lock lock(g_touchpad_mutex); + gamepad_touch_id = g_gamepad_touchpad.enabled ? g_gamepad_touchpad.id : -1; + } + if (gamepad_touch_id >= 0) { + HostInputDisconnectGamepadTouchPad(gamepad_touch_id); + } if (g_mouse.enabled) { SetRelativeMouseMode(false); CenterMouseStick(); @@ -438,6 +786,219 @@ void HostInputMouseButton(uint8_t mouse_button, bool down) { } } +void HostInputMouseTouchPad(bool down, float x, float y) { + RecordMouseTouchPadPosition(x, y); + { + std::scoped_lock lock(g_touchpad_mutex); + if (!g_mouse_touchpad.enabled) { + return; + } + if (down && !g_mouse_touchpad.drawing) { + g_mouse_touchpad.gesture++; + } + g_mouse_touchpad.drawing = down; + g_mouse_touchpad.revision++; + x = g_mouse_touchpad.x; + y = g_mouse_touchpad.y; + } + SetSwipeTouchPad(down, x, y); +} + +void HostInputMouseMotion(float x, float y) { + RecordMouseTouchPadPosition(x, y); + std::scoped_lock lock(g_touchpad_mutex); + if (g_mouse_touchpad.enabled && g_mouse_touchpad.drawing) { + SetSwipeTouchPad(true, g_mouse_touchpad.x, g_mouse_touchpad.y); + } +} + +void HostInputSetTouchPadMode(bool enabled) { + if (enabled) { + int gamepad_id = -1; + { + std::scoped_lock lock(g_touchpad_mutex); + if (g_gamepad_touchpad.enabled) { + gamepad_id = g_gamepad_touchpad.id; + } + } + if (gamepad_id >= 0) { + HostInputDisconnectGamepadTouchPad(gamepad_id); + } + } + { + std::scoped_lock lock(g_touchpad_mutex); + if (g_mouse_touchpad.enabled == enabled) { + return; + } + } + if (!enabled) { + float x = 0; + float y = 0; + (void) SDL_GetMouseState(&x, &y); + HostInputMouseTouchPad(false, x, y); + } + bool show_system_cursor = false; + { + std::scoped_lock lock(g_touchpad_mutex); + g_mouse_touchpad.enabled = enabled; + g_mouse_touchpad.cursor_visible = true; + g_mouse_touchpad.drawing = false; + g_mouse_touchpad.position_valid = false; + g_mouse_touchpad.gesture++; + g_mouse_touchpad.revision++; + show_system_cursor = !enabled && !g_gamepad_touchpad.enabled; + } + if (enabled) { + if (!SDL_HideCursor()) { + LOGF("Failed to hide mouse cursor for touchpad mode: %s\n", SDL_GetError()); + } + } else if (show_system_cursor && !Config::HideCursorEnabled() && !SDL_ShowCursor()) { + LOGF("Failed to restore mouse cursor after touchpad mode: %s\n", SDL_GetError()); + } + LOGF("Mouse touchpad mode: %s\n", enabled ? "enabled" : "disabled"); +} + +void HostInputToggleTouchPadMode() { + bool enabled = false; + { + std::scoped_lock lock(g_touchpad_mutex); + enabled = !g_mouse_touchpad.enabled; + } + HostInputSetTouchPadMode(enabled); +} + +void HostInputToggleGamepadTouchPad(int id) { + bool mouse_mode = false; + { + std::scoped_lock lock(g_touchpad_mutex); + mouse_mode = g_mouse_touchpad.enabled; + } + if (mouse_mode) { + HostInputSetTouchPadMode(false); + } + + const auto stick = GamepadLeftStick(id); + bool enabled = false; + float x = 0.5f; + float y = 0.5f; + { + std::scoped_lock lock(g_touchpad_mutex); + if (g_gamepad_touchpad.enabled && g_gamepad_touchpad.id != id) { + return; + } + enabled = !g_gamepad_touchpad.enabled; + if (enabled) { + g_gamepad_touchpad = {}; + g_gamepad_touchpad.enabled = true; + g_gamepad_touchpad.id = id; + g_gamepad_touchpad.x_axis = stick[0]; + g_gamepad_touchpad.y_axis = stick[1]; + g_gamepad_touchpad.last_poll = SDL_GetTicks(); + g_gamepad_touchpad.next_poll = g_gamepad_touchpad.last_poll + GAMEPAD_TOUCHPAD_POLL_MS; + g_mouse_touchpad.gesture++; + } else { + x = g_gamepad_touchpad.x; + y = g_gamepad_touchpad.y; + g_gamepad_touchpad = {}; + } + g_mouse_touchpad.revision++; + } + Controller::SetAxis(id, Controller::Axis::LeftX, enabled ? 128 : stick[0]); + Controller::SetAxis(id, Controller::Axis::LeftY, enabled ? 128 : stick[1]); + if (enabled) { + if (!SDL_HideCursor()) { + LOGF("Failed to hide mouse cursor for gamepad touchpad mode: %s\n", SDL_GetError()); + } + } else { + Controller::SetTouchPad(id, 1, false, x, y); + if (!Config::HideCursorEnabled() && !SDL_ShowCursor()) { + LOGF("Failed to restore mouse cursor after gamepad touchpad mode: %s\n", + SDL_GetError()); + } + } + LOGF("Gamepad touchpad mode: %s\n", enabled ? "enabled" : "disabled"); +} + +bool HostInputGamepadDrawButton(int id, bool down) { + float x = 0; + float y = 0; + { + std::scoped_lock lock(g_touchpad_mutex); + if (!g_gamepad_touchpad.enabled || g_gamepad_touchpad.id != id) { + return false; + } + if (!down && !g_gamepad_touchpad.drawing) { + return false; + } + if (down && !g_gamepad_touchpad.drawing) { + g_mouse_touchpad.gesture++; + } + g_gamepad_touchpad.drawing = down; + x = g_gamepad_touchpad.x; + y = g_gamepad_touchpad.y; + g_mouse_touchpad.revision++; + } + Controller::SetTouchPad(id, 1, down, x, y); + return true; +} + +bool HostInputGamepadAxis(int id, bool horizontal, int value) { + std::scoped_lock lock(g_touchpad_mutex); + if (!g_gamepad_touchpad.enabled || g_gamepad_touchpad.id != id) { + return false; + } + (horizontal ? g_gamepad_touchpad.x_axis : g_gamepad_touchpad.y_axis) = std::clamp(value, 0, 255); + g_gamepad_touchpad.next_poll = SDL_GetTicks(); + return true; +} + +void HostInputDisconnectGamepadTouchPad(int id) { + float x = 0; + float y = 0; + { + std::scoped_lock lock(g_touchpad_mutex); + if (!g_gamepad_touchpad.enabled || g_gamepad_touchpad.id != id) { + return; + } + x = g_gamepad_touchpad.x; + y = g_gamepad_touchpad.y; + g_gamepad_touchpad = {}; + g_mouse_touchpad.revision++; + } + Controller::SetTouchPad(id, 1, false, x, y); + const auto stick = GamepadLeftStick(id); + Controller::SetAxis(id, Controller::Axis::LeftX, stick[0]); + Controller::SetAxis(id, Controller::Axis::LeftY, stick[1]); + if (!TouchPadModeActive() && !Config::HideCursorEnabled() && !SDL_ShowCursor()) { + LOGF("Failed to restore mouse cursor after gamepad disconnect: %s\n", SDL_GetError()); + } +} + +HostInputTouchPadVisualState GetHostInputTouchPadVisualState() { + std::scoped_lock lock(g_touchpad_mutex); + HostInputTouchPadVisualState state {}; + if (g_gamepad_touchpad.enabled) { + state.enabled = true; + state.cursor_visible = true; + state.drawing = g_gamepad_touchpad.drawing; + state.x = g_gamepad_touchpad.x; + state.y = g_gamepad_touchpad.y; + state.revision = g_mouse_touchpad.revision; + state.gesture = g_mouse_touchpad.gesture; + state.position_valid = true; + return state; + } + state.enabled = g_mouse_touchpad.enabled; + state.cursor_visible = g_mouse_touchpad.cursor_visible; + state.drawing = g_mouse_touchpad.drawing; + state.x = g_mouse_touchpad.x; + state.y = g_mouse_touchpad.y; + state.revision = g_mouse_touchpad.revision; + state.gesture = g_mouse_touchpad.gesture; + state.position_valid = g_mouse_touchpad.position_valid; + return state; +} + void HostInputToggleMouseToJoystick() { if (g_mouse.enabled) { SetRelativeMouseMode(false); @@ -467,16 +1028,19 @@ bool HostInputWaitEvent(SDL_Event* event, int max_wait_ms) { timeout = PollMouse(SDL_GetTicks()); } - if (g_cursor_hide_at != 0) { + if (g_cursor_hide_at != 0 && !TouchPadModeActive()) { const auto now_ms = SDL_GetTicks(); const int cursor_timeout = now_ms < g_cursor_hide_at ? static_cast(g_cursor_hide_at - now_ms) : 0; timeout = timeout < 0 ? cursor_timeout : std::min(timeout, cursor_timeout); } + if (const int touch_timeout = PollGamepadTouchPad(SDL_GetTicks()); touch_timeout >= 0) { + timeout = timeout < 0 ? touch_timeout : std::min(timeout, touch_timeout); + } timeout = timeout < 0 ? max_wait_ms : std::min(timeout, max_wait_ms); const bool has_event = SDL_WaitEventTimeout(event, timeout); - if (Config::HideCursorEnabled()) { + if (Config::HideCursorEnabled() && !TouchPadModeActive()) { const auto now_ms = SDL_GetTicks(); if (has_event && !g_mouse.enabled && IsCursorActivity(*event) && SDL_GetWindowFromEvent(event) == g_mouse_window) { diff --git a/src/graphics/presentation/window/hostInput.h b/src/graphics/presentation/window/hostInput.h index 824484650..53e94640b 100644 --- a/src/graphics/presentation/window/hostInput.h +++ b/src/graphics/presentation/window/hostInput.h @@ -8,10 +8,33 @@ union SDL_Event; namespace Libs::Graphics { +struct HostInputTouchPadVisualState { + bool enabled = false; + bool cursor_visible = true; + bool drawing = false; + bool position_valid = false; + float x = 0.5f; + float y = 0.5f; + uint64_t revision = 0; + uint64_t gesture = 0; +}; + void HostInputInit(SDL_Window* window); void HostInputShutdown(); void HostInputKey(int key_code, bool down); void HostInputMouseButton(uint8_t mouse_button, bool down); +void HostInputMouseTouchPad(bool down, float x, float y); +void HostInputMouseMotion(float x, float y); +void HostInputPadButton(int id, int sdl_button, bool down); +void HostInputPadTrigger(int id, bool left, int value); +void HostInputDisconnectGamepad(int id); +void HostInputSetTouchPadMode(bool enabled); +void HostInputToggleTouchPadMode(); +void HostInputToggleGamepadTouchPad(int id); +[[nodiscard]] bool HostInputGamepadDrawButton(int id, bool down); +[[nodiscard]] bool HostInputGamepadAxis(int id, bool horizontal, int value); +void HostInputDisconnectGamepadTouchPad(int id); +[[nodiscard]] HostInputTouchPadVisualState GetHostInputTouchPadVisualState(); void HostInputToggleMouseToJoystick(); [[nodiscard]] bool HostInputWaitEvent(SDL_Event* event, int max_wait_ms); diff --git a/src/graphics/presentation/window/window.cpp b/src/graphics/presentation/window/window.cpp index a68e22342..8b636720e 100644 --- a/src/graphics/presentation/window/window.cpp +++ b/src/graphics/presentation/window/window.cpp @@ -1,4 +1,4 @@ -#include "graphics/presentation/window.h" +#include "graphics/presentation/window.h" #include @@ -54,25 +54,6 @@ struct EventKeyboard { double timestamp_seconds; }; -static uint32_t ControllerButtonToPadButton(int button) { - switch (button) { - case SDL_GAMEPAD_BUTTON_SOUTH: return Controller::PAD_BUTTON_CROSS; - case SDL_GAMEPAD_BUTTON_EAST: return Controller::PAD_BUTTON_CIRCLE; - case SDL_GAMEPAD_BUTTON_WEST: return Controller::PAD_BUTTON_SQUARE; - case SDL_GAMEPAD_BUTTON_NORTH: return Controller::PAD_BUTTON_TRIANGLE; - case SDL_GAMEPAD_BUTTON_START: return Controller::PAD_BUTTON_OPTIONS; - case SDL_GAMEPAD_BUTTON_LEFT_STICK: return Controller::PAD_BUTTON_L3; - case SDL_GAMEPAD_BUTTON_RIGHT_STICK: return Controller::PAD_BUTTON_R3; - case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: return Controller::PAD_BUTTON_L1; - case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: return Controller::PAD_BUTTON_R1; - case SDL_GAMEPAD_BUTTON_DPAD_UP: return Controller::PAD_BUTTON_UP; - case SDL_GAMEPAD_BUTTON_DPAD_DOWN: return Controller::PAD_BUTTON_DOWN; - case SDL_GAMEPAD_BUTTON_DPAD_LEFT: return Controller::PAD_BUTTON_LEFT; - case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: return Controller::PAD_BUTTON_RIGHT; - case SDL_GAMEPAD_BUTTON_TOUCHPAD: return Controller::PAD_BUTTON_TOUCH_PAD; - default: return 0; - } -} static Controller::Axis ControllerAxisFromSdl(int axis_id) { switch (axis_id) { @@ -268,6 +249,15 @@ static void GameEventMouse([[maybe_unused]] const EventMouse& mb) { } HostInputMouseButton(mouse_button, mb.down); + if (!mb.touch && mb.left) { + HostInputMouseTouchPad(mb.down, static_cast(mb.x), static_cast(mb.y)); + } + } else if (mb.motion && !mb.touch) { + if (mb.left) { + HostInputMouseTouchPad(true, static_cast(mb.x), static_cast(mb.y)); + } else { + HostInputMouseMotion(static_cast(mb.x), static_cast(mb.y)); + } } } @@ -316,6 +306,7 @@ static void GameEventController([[maybe_unused]] const EventController& f) { } if (f.removed) { + HostInputDisconnectGamepad(f.id); if (auto* pad = SDL_GetGamepadFromID(f.id); pad != nullptr) { Controller::Disconnect(f.id); SDL_CloseGamepad(pad); @@ -323,30 +314,22 @@ static void GameEventController([[maybe_unused]] const EventController& f) { } if (f.down || f.up) { - const auto button = ControllerButtonToPadButton(f.button); - if (button != 0) { - Controller::SetButton(f.id, button, f.down); - } else if (f.button == SDL_GAMEPAD_BUTTON_BACK) { - // Pads without a touchpad (Switch, Xbox, ...) have no way to click it, which - // blocks games that require it. Use their Back/Select button as a centered click. - auto* pad = SDL_GetGamepadFromID(f.id); - if (pad != nullptr && SDL_GetNumGamepadTouchpads(pad) == 0) { - if (f.down) { - Controller::SetTouchPad(f.id, 0, true, 0.5f, 0.5f); - } - Controller::SetButton(f.id, Controller::PAD_BUTTON_TOUCH_PAD, f.down); - if (f.up) { - Controller::SetTouchPad(f.id, 0, false, 0.5f, 0.5f); - } - } - } + HostInputPadButton(f.id, f.button, f.down); } if (f.axis) { - const auto axis = ControllerAxisFromSdl(f.axis_id); - if (axis != Controller::Axis::AxisMax) { - Controller::SetAxis(f.id, axis, + if (f.axis_id == SDL_GAMEPAD_AXIS_LEFT_TRIGGER || + f.axis_id == SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) { + HostInputPadTrigger(f.id, f.axis_id == SDL_GAMEPAD_AXIS_LEFT_TRIGGER, ControllerAxisValueFromSdl(f.axis_id, f.axis_value)); + } else if (const auto axis = ControllerAxisFromSdl(f.axis_id); + axis != Controller::Axis::AxisMax) { + const int value = ControllerAxisValueFromSdl(f.axis_id, f.axis_value); + if ((f.axis_id == SDL_GAMEPAD_AXIS_LEFTX || f.axis_id == SDL_GAMEPAD_AXIS_LEFTY) && + HostInputGamepadAxis(f.id, f.axis_id == SDL_GAMEPAD_AXIS_LEFTX, value)) { + return; + } + Controller::SetAxis(f.id, axis, value); } } } @@ -431,6 +414,7 @@ void WindowContext::ProcessWindowEvent(const SDL_WindowEvent& event) { break; case SDL_EVENT_WINDOW_MOUSE_LEAVE: LOGF("Mouse left window %" PRIu32 "\n", window_event.windowID); + HostInputSetTouchPadMode(false); break; case SDL_EVENT_WINDOW_FOCUS_GAINED: LOGF("Window %" PRIu32 " gained keyboard focus\n", window_event.windowID); diff --git a/src/launcher/CMakeLists.txt b/src/launcher/CMakeLists.txt index 988194aa4..78b482b21 100644 --- a/src/launcher/CMakeLists.txt +++ b/src/launcher/CMakeLists.txt @@ -105,6 +105,7 @@ if(APPLE) install(TARGETS kyty_emulator DESTINATION "KytyPS5.app/Contents/MacOS") install(TARGETS kyty_updater DESTINATION "KytyPS5.app/Contents/MacOS") install(FILES "${KYTY_TROPHY_UNLOCK_SOUND}" DESTINATION "KytyPS5.app/Contents/Resources/assets/sounds") + install(DIRECTORY "${CMAKE_SOURCE_DIR}/assets/cursors" DESTINATION "KytyPS5.app/Contents/Resources/assets") # Deploy and ad-hoc sign the Qt frameworks and plugins in the bundle. find_program(KYTY_MACDEPLOYQT NAMES macdeployqt PATHS "${Qt6_DIR}/../../../bin" NO_DEFAULT_PATH) if(NOT KYTY_MACDEPLOYQT) diff --git a/src/launcher/include/controllerPreview.h b/src/launcher/include/controllerPreview.h index cf9917fe8..a6a890ee5 100644 --- a/src/launcher/include/controllerPreview.h +++ b/src/launcher/include/controllerPreview.h @@ -24,6 +24,7 @@ public: void SetSuspended(bool suspended); [[nodiscard]] const QSet& Pressed() const { return m_pressed; } + [[nodiscard]] const QSet& RawPressed() const { return m_raw_pressed; } [[nodiscard]] QPointF LeftStick() const { return m_left_stick; } [[nodiscard]] QPointF RightStick() const { return m_right_stick; } [[nodiscard]] const QString& DeviceName() const { return m_device_name; } @@ -44,6 +45,7 @@ private: bool m_preview_enabled = false; bool m_suspended = false; QSet m_pressed; + QSet m_raw_pressed; QPointF m_left_stick; QPointF m_right_stick; QString m_device_name; diff --git a/src/launcher/include/inputMappingDialog.h b/src/launcher/include/inputMappingDialog.h index 7b3b22c16..a7728363c 100644 --- a/src/launcher/include/inputMappingDialog.h +++ b/src/launcher/include/inputMappingDialog.h @@ -1,15 +1,20 @@ -#ifndef LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_ +#ifndef LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_ #define LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_ #include #include +#include #include class ControllerPreview; class DualSenseWidget; class QLabel; class QPushButton; +class QCheckBox; +class QComboBox; class QDoubleSpinBox; +class QTabWidget; +class QWidget; class InputMappingDialog final: public QDialog { public: @@ -20,20 +25,30 @@ public: [[nodiscard]] QStringList Mapping() const; private: - void ChangeBinding(const QString& id); - void RestoreDefaults(); - void UpdateButtons(); - void UpdatePreview(); - void UpdateHighlights(); + QWidget* BuildTab(bool pad); + void ChangeKeyboardBinding(const QString& id); + void ChangePadBinding(const QString& id); + void ClearBinding(bool pad, const QString& id); + void RestoreDefaults(); + void UpdateButtons(); + void UpdatePreview(); + void UpdateHighlights(); ControllerPreview* m_preview; DualSenseWidget* m_controller = nullptr; + QTabWidget* m_tabs = nullptr; QHash m_bindings; + QHash m_pad_bindings; QHash m_buttons; + QHash m_pad_buttons; + QSet m_pad_pressed; QString m_selected; QLabel* m_status = nullptr; QDoubleSpinBox* m_sensitivity = nullptr; + QComboBox* m_particle_skin = nullptr; + QCheckBox* m_touch_cursor = nullptr; bool m_custom_bindings = false; + bool m_custom_pad = false; }; #endif /* LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_ */ diff --git a/src/launcher/src/controllerPreview.cpp b/src/launcher/src/controllerPreview.cpp index cabe2894b..1dc3aea66 100644 --- a/src/launcher/src/controllerPreview.cpp +++ b/src/launcher/src/controllerPreview.cpp @@ -20,6 +20,34 @@ constexpr struct { {SDL_GAMEPAD_BUTTON_START, "Options"}, }; +// Host-side names; they must match PAD_SOURCES in hostInput.cpp. +constexpr struct { + SDL_GamepadButton button; + const char* name; +} RAW_BUTTONS[] = { + {SDL_GAMEPAD_BUTTON_SOUTH, "South"}, + {SDL_GAMEPAD_BUTTON_EAST, "East"}, + {SDL_GAMEPAD_BUTTON_WEST, "West"}, + {SDL_GAMEPAD_BUTTON_NORTH, "North"}, + {SDL_GAMEPAD_BUTTON_BACK, "Back"}, + {SDL_GAMEPAD_BUTTON_GUIDE, "Guide"}, + {SDL_GAMEPAD_BUTTON_START, "Start"}, + {SDL_GAMEPAD_BUTTON_LEFT_STICK, "LeftStick"}, + {SDL_GAMEPAD_BUTTON_RIGHT_STICK, "RightStick"}, + {SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, "LeftShoulder"}, + {SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, "RightShoulder"}, + {SDL_GAMEPAD_BUTTON_DPAD_UP, "DpadUp"}, + {SDL_GAMEPAD_BUTTON_DPAD_DOWN, "DpadDown"}, + {SDL_GAMEPAD_BUTTON_DPAD_LEFT, "DpadLeft"}, + {SDL_GAMEPAD_BUTTON_DPAD_RIGHT, "DpadRight"}, + {SDL_GAMEPAD_BUTTON_MISC1, "Misc1"}, + {SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1, "RightPaddle1"}, + {SDL_GAMEPAD_BUTTON_LEFT_PADDLE1, "LeftPaddle1"}, + {SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2, "RightPaddle2"}, + {SDL_GAMEPAD_BUTTON_LEFT_PADDLE2, "LeftPaddle2"}, + {SDL_GAMEPAD_BUTTON_TOUCHPAD, "Touchpad"}, +}; + double Axis(SDL_Gamepad* gamepad, SDL_GamepadAxis axis) { const int value = SDL_GetGamepadAxis(gamepad, axis); return value / (value < 0 ? 32768.0 : 32767.0); @@ -100,6 +128,7 @@ void ControllerPreview::UpdateRunning() { SDL_QuitSubSystem(SDL_INIT_GAMEPAD); } m_pressed.clear(); + m_raw_pressed.clear(); m_left_stick = {}; m_right_stick = {}; m_device_name.clear(); @@ -131,12 +160,25 @@ void ControllerPreview::Refresh() { } QSet pressed; + QSet raw; QPointF left; QPointF right; QString name; if (!m_gamepads.isEmpty()) { auto* gamepad = m_gamepads.first(); name = QString::fromUtf8(SDL_GetGamepadName(gamepad)); + for (const auto& binding: RAW_BUTTONS) { + if (SDL_GamepadHasButton(gamepad, binding.button) && + SDL_GetGamepadButton(gamepad, binding.button)) { + raw.insert(QString::fromLatin1(binding.name)); + } + } + if (Axis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) > 0.5) { + raw.insert(QStringLiteral("LeftTrigger")); + } + if (Axis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) > 0.5) { + raw.insert(QStringLiteral("RightTrigger")); + } for (const auto& binding: BUTTONS) { if (SDL_GetGamepadButton(gamepad, binding.button)) { pressed.insert(QString::fromLatin1(binding.control)); @@ -180,9 +222,10 @@ void ControllerPreview::Refresh() { pressed.insert(QStringLiteral("TouchPadRight")); } } - if (pressed != m_pressed || left != m_left_stick || right != m_right_stick || - name != m_device_name) { + if (pressed != m_pressed || raw != m_raw_pressed || left != m_left_stick || + right != m_right_stick || name != m_device_name) { m_pressed = pressed; + m_raw_pressed = raw; m_left_stick = left; m_right_stick = right; m_device_name = name; diff --git a/src/launcher/src/inputMappingDialog.cpp b/src/launcher/src/inputMappingDialog.cpp index 4e2a7acc5..d880349cf 100644 --- a/src/launcher/src/inputMappingDialog.cpp +++ b/src/launcher/src/inputMappingDialog.cpp @@ -1,8 +1,10 @@ -#include "inputMappingDialog.h" +#include "inputMappingDialog.h" #include "controllerPreview.h" #include "dualsenseWidget.h" +#include +#include #include #include #include @@ -16,56 +18,113 @@ #include #include #include +#include #include +#include #include namespace { constexpr auto DEFAULT_MOUSE_SENSITIVITY = 1.0; constexpr char MOUSE_SENSITIVITY[] = "MouseSensitivity="; +constexpr char TOUCH_PARTICLE_SKIN[] = "TouchParticleSkin="; +constexpr char TOUCH_CURSOR_OFF[] = "TouchCursorImage=Off"; +constexpr char KEYBOARD_DISABLED[] = "Keyboard=Disabled"; +constexpr char PAD_DISABLED[] = "Pad=Disabled"; -enum Group { Dpad, LeftStick, RightStick, Face, Shoulders, Center, Shortcuts, GroupCount }; +constexpr char PAD_PREFIX[] = "Pad:"; +enum Section { Face, Dpad, Shoulders, Sticks, System, LeftStick, RightStick, Shortcuts, SectionCount }; + +const char* const SECTION_TITLES[SectionCount] = { + QT_TR_NOOP("Face buttons"), QT_TR_NOOP("D-pad"), + QT_TR_NOOP("Shoulders && triggers"), QT_TR_NOOP("Stick clicks"), + QT_TR_NOOP("System"), QT_TR_NOOP("Left stick"), + QT_TR_NOOP("Right stick"), QT_TR_NOOP("In-game shortcuts (cycle)"), +}; + +// pad_default is null for controls that have no gamepad equivalent. struct PadControl { const char* id; const char* label; - const char* default_binding; - Group group; - int row; - int column; + const char* keyboard_default; + const char* pad_default; + Section section; }; constexpr PadControl PAD_CONTROLS[] = { - {"Up", "Up", "Up", Dpad, 0, 1}, - {"Down", "Down", "Down", Dpad, 2, 1}, - {"Left", "Left", "Left", Dpad, 1, 0}, - {"Right", "Right", "Right", Dpad, 1, 2}, - {"LeftStickUp", "Up", "W", LeftStick, 0, 1}, - {"LeftStickDown", "Down", "S", LeftStick, 2, 1}, - {"LeftStickLeft", "Left", "A", LeftStick, 1, 0}, - {"LeftStickRight", "Right", "D", LeftStick, 1, 2}, - {"RightStickUp", "Up", "T", RightStick, 0, 1}, - {"RightStickDown", "Down", "G", RightStick, 2, 1}, - {"RightStickLeft", "Left", "F", RightStick, 1, 0}, - {"RightStickRight", "Right", "H", RightStick, 1, 2}, - {"Triangle", "Triangle", "I", Face, 0, 1}, - {"Circle", "Circle", "L", Face, 1, 2}, - {"Cross", "Cross", "J", Face, 2, 1}, - {"Square", "Square", "K", Face, 1, 0}, - {"L1", "L1", "Q", Shoulders, 0, 1}, - {"R1", "R1", "E", Shoulders, 0, 2}, - {"L2", "L2", "Z", Shoulders, 0, 0}, - {"R2", "R2", "C", Shoulders, 0, 3}, - {"L3", "Press / L3", "Left Shift", LeftStick, 1, 1}, - {"R3", "Press / R3", "Left Ctrl", RightStick, 1, 1}, - {"Options", "Options", "Return", Center, 0, 1}, - {"TouchPad", "Touch pad left", "Backspace", Center, 0, 0}, - {"TouchPadRight", "Touch pad right", "Tab", Center, 0, 2}, - {"SpeakerVolume", "Speaker volume", "1", Shortcuts, 0, 0}, - {"VibrationIntensity", "Vibration intensity", "2", Shortcuts, 0, 1}, - {"TriggerEffectIntensity", "Trigger intensity", "3", Shortcuts, 0, 2}, + {"Cross", "Cross", "J", "South", Face}, + {"Circle", "Circle", "L", "East", Face}, + {"Square", "Square", "K", "West", Face}, + {"Triangle", "Triangle", "I", "North", Face}, + {"Up", "Up", "Up", "DpadUp", Dpad}, + {"Down", "Down", "Down", "DpadDown", Dpad}, + {"Left", "Left", "Left", "DpadLeft", Dpad}, + {"Right", "Right", "Right", "DpadRight", Dpad}, + {"L1", "L1", "Q", "LeftShoulder", Shoulders}, + {"R1", "R1", "E", "RightShoulder", Shoulders}, + {"L2", "L2", "Z", "LeftTrigger", Shoulders}, + {"R2", "R2", "C", "RightTrigger", Shoulders}, + {"L3", "L3 (left stick click)", "Left Shift", "LeftStick", Sticks}, + {"R3", "R3 (right stick click)", "Left Ctrl", "RightStick", Sticks}, + {"Options", "Options", "Return", "Start", System}, + {"TouchPad", "Touch pad click", "Backspace", "Touchpad", System}, + {"TouchPadRight", "Touch pad right", "Tab", nullptr, System}, + {"GyroToggle", "Gyro emulation toggle", "", "Back", System}, + {"TouchPadMode", "Emulated touch pad mode", "F6", "", System}, + {"LeftStickUp", "Up", "W", nullptr, LeftStick}, + {"LeftStickDown", "Down", "S", nullptr, LeftStick}, + {"LeftStickLeft", "Left", "A", nullptr, LeftStick}, + {"LeftStickRight", "Right", "D", nullptr, LeftStick}, + {"RightStickUp", "Up", "T", nullptr, RightStick}, + {"RightStickDown", "Down", "G", nullptr, RightStick}, + {"RightStickLeft", "Left", "F", nullptr, RightStick}, + {"RightStickRight", "Right", "H", nullptr, RightStick}, + {"SpeakerVolume", "Speaker volume", "1", nullptr, Shortcuts}, + {"VibrationIntensity", "Vibration intensity", "2", nullptr, Shortcuts}, + {"TriggerEffectIntensity", "Trigger intensity", "3", nullptr, Shortcuts}, }; +struct PadSourceName { + const char* id; + const char* label; +}; + +constexpr PadSourceName PAD_SOURCES[] = { + {"South", "A / Cross"}, + {"East", "B / Circle"}, + {"West", "X / Square"}, + {"North", "Y / Triangle"}, + {"Back", "View / Back / Share"}, + {"Guide", "Guide / Home"}, + {"Start", "Menu / Start"}, + {"LeftStick", "Left stick click"}, + {"RightStick", "Right stick click"}, + {"LeftShoulder", "LB / L1"}, + {"RightShoulder", "RB / R1"}, + {"DpadUp", "D-pad Up"}, + {"DpadDown", "D-pad Down"}, + {"DpadLeft", "D-pad Left"}, + {"DpadRight", "D-pad Right"}, + {"Misc1", "Share / Misc"}, + {"RightPaddle1", "Right paddle 1"}, + {"LeftPaddle1", "Left paddle 1"}, + {"RightPaddle2", "Right paddle 2"}, + {"LeftPaddle2", "Left paddle 2"}, + {"Touchpad", "Touch pad click"}, + {"LeftTrigger", "LT / L2"}, + {"RightTrigger", "RT / R2"}, +}; + +QString PadSourceLabel(const QString& id) { + for (const auto& source: PAD_SOURCES) { + if (id == QLatin1String(source.id)) { + return QObject::tr(source.label); + } + } + return id; +} + QString KeypadName(int key) { if (key >= Qt::Key_0 && key <= Qt::Key_9) { return QStringLiteral("Keypad %1").arg(key - Qt::Key_0); @@ -191,6 +250,54 @@ private: QString m_binding; }; +class PadCaptureDialog final: public QDialog { +public: + PadCaptureDialog(ControllerPreview* preview, QWidget* parent): QDialog(parent) { + setWindowTitle(tr("Set Binding")); + setModal(true); + setMinimumWidth(360); + + auto* layout = new QVBoxLayout(this); + m_label = new QLabel(this); + m_label->setAlignment(Qt::AlignCenter); + m_label->setWordWrap(true); + layout->addWidget(m_label); + + if (preview == nullptr) { + return; + } + // Inputs already held when the dialog opens must be released before they count. + m_held = preview->RawPressed(); + connect(preview, &ControllerPreview::StateChanged, this, [this, preview] { + const auto& pressed = preview->RawPressed(); + m_held.intersect(pressed); + for (const auto& name: pressed) { + if (!m_held.contains(name)) { + m_binding = name; + accept(); + return; + } + } + UpdateText(preview); + }); + UpdateText(preview); + } + + [[nodiscard]] const QString& Binding() const { return m_binding; } + +private: + void UpdateText(const ControllerPreview* preview) { + m_label->setText(preview->DeviceName().isEmpty() + ? tr("No controller connected.\nConnect one, or press Esc to cancel.") + : tr("Press a button or trigger on %1.\nEsc cancels.") + .arg(preview->DeviceName())); + } + + QLabel* m_label = nullptr; + QString m_binding; + QSet m_held; +}; + void AssignBinding(QHash& bindings, const QString& id, const QString& binding) { for (auto item = bindings.begin(); item != bindings.end();) { if (item.value().compare(binding, Qt::CaseInsensitive) == 0) { @@ -205,7 +312,11 @@ void AssignBinding(QHash& bindings, const QString& id, const Q QHash ParseMapping(const QStringList& mapping) { QHash result; for (const auto& entry: mapping) { - if (entry.startsWith(QLatin1String(MOUSE_SENSITIVITY))) { + if (entry == QLatin1String(KEYBOARD_DISABLED) || entry == QLatin1String(PAD_DISABLED) || + entry.startsWith(QLatin1String(MOUSE_SENSITIVITY)) || + entry.startsWith(QLatin1String(TOUCH_PARTICLE_SKIN)) || + entry.startsWith(QLatin1String("TouchCursorImage=")) || + entry.startsWith(QLatin1String(PAD_PREFIX))) { continue; } const auto separator = entry.indexOf(QLatin1Char('=')); @@ -216,6 +327,15 @@ QHash ParseMapping(const QStringList& mapping) { return result; } +QString ParseTouchParticleSkin(const QStringList& mapping) { + for (const auto& entry: mapping) { + if (entry.startsWith(QLatin1String(TOUCH_PARTICLE_SKIN))) { + return entry.mid(sizeof(TOUCH_PARTICLE_SKIN) - 1); + } + } + return QStringLiteral("Classic"); +} + double ParseMouseSensitivity(const QStringList& mapping) { for (const auto& entry: mapping) { if (entry.startsWith(QLatin1String(MOUSE_SENSITIVITY))) { @@ -231,106 +351,67 @@ InputMappingDialog::InputMappingDialog(const QStringList& mapping, ControllerPre QWidget* parent) : QDialog(parent), m_preview(preview) { setWindowTitle(tr("Controls")); - resize(QSize(1100, 820).boundedTo(screen()->availableGeometry().size())); + resize(QSize(1360, 780).boundedTo(screen()->availableGeometry().size())); + + m_bindings = ParseMapping(mapping); + m_custom_bindings = !m_bindings.isEmpty() || mapping.contains(QLatin1String(KEYBOARD_DISABLED)); + m_custom_pad = mapping.contains(QLatin1String(PAD_DISABLED)); + for (const auto& entry: mapping) { + if (!entry.startsWith(QLatin1String(PAD_PREFIX))) { + continue; + } + const auto body = entry.mid(static_cast(sizeof(PAD_PREFIX)) - 1); + const auto separator = body.indexOf(QLatin1Char('=')); + if (separator > 0 && separator + 1 < body.size()) { + AssignBinding(m_pad_bindings, body.left(separator), body.mid(separator + 1)); + m_custom_pad = true; + } + } + for (const auto& control: PAD_CONTROLS) { + const auto id = QString::fromLatin1(control.id); + if (!m_custom_bindings && control.keyboard_default[0] != '\0') { + m_bindings.insert(id, QString::fromLatin1(control.keyboard_default)); + } + if (!m_custom_pad && control.pad_default != nullptr) { + m_pad_bindings.insert(id, QString::fromLatin1(control.pad_default)); + } + } auto* layout = new QVBoxLayout(this); layout->setSpacing(10); - auto* title = new QLabel(tr("Controller setup"), this); - auto font = title->font(); - font.setPointSize(font.pointSize() + 7); - font.setBold(true); - title->setFont(font); - layout->addWidget(title); - auto* hint = new QLabel( - tr("Select a button to remap. Press controller buttons to test."), this); - hint->setWordWrap(true); - layout->addWidget(hint); - m_status = new QLabel(this); - m_status->setObjectName(QStringLiteral("controllerStatus")); - m_status->setWordWrap(true); - layout->addWidget(m_status); - auto* scroll = new QScrollArea(this); - scroll->setWidgetResizable(true); - scroll->setFrameShape(QFrame::NoFrame); - auto* content = new QWidget(scroll); - auto* grid = new QGridLayout(content); - grid->setContentsMargins(0, 0, 0, 0); - grid->setSpacing(12); - QGroupBox* groups[GroupCount]; - QGridLayout* cells[GroupCount]; - const char* titles[] = {"D-Pad", - "Left stick", - "Right stick", - "Face buttons", - "Shoulders and triggers", - "Touch pad and options", - "In-game shortcuts (cycle)"}; - for (int index = 0; index < GroupCount; ++index) { - groups[index] = new QGroupBox(tr(titles[index]), content); - cells[index] = new QGridLayout(groups[index]); - cells[index]->setSpacing(5); - } - - m_bindings = ParseMapping(mapping); - m_custom_bindings = !m_bindings.isEmpty(); - for (const auto& control: PAD_CONTROLS) { - const auto id = QString::fromLatin1(control.id); - auto* cell = new QGroupBox(tr(control.label), groups[control.group]); - auto* cell_layout = new QVBoxLayout(cell); - cell_layout->setContentsMargins(3, 5, 3, 3); - auto* button = new QPushButton(cell); - button->setObjectName(QStringLiteral("binding_") + id); - button->setAutoDefault(false); - button->setMinimumWidth(64); - button->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - button->setAccessibleName(tr(titles[control.group]) + QStringLiteral(" / ") + - tr(control.label)); - cell_layout->addWidget(button); - cells[control.group]->addWidget(cell, control.row, control.column); - m_buttons.insert(id, button); - if (!m_custom_bindings) { - m_bindings.insert(id, QString::fromLatin1(control.default_binding)); - } - connect(button, &QPushButton::clicked, this, [this, id] { ChangeBinding(id); }); - } - - auto* left = new QVBoxLayout; - left->addWidget(groups[Dpad]); - left->addWidget(groups[LeftStick]); - left->addStretch(); - grid->addLayout(left, 0, 0); - auto* center = new QVBoxLayout; - center->addWidget(groups[Shoulders]); - m_controller = new DualSenseWidget(content); - m_controller->setObjectName(QStringLiteral("controllerDiagram")); - center->addWidget(m_controller, 1); - center->addWidget(groups[Center]); - grid->addLayout(center, 0, 1); - auto* right = new QVBoxLayout; - right->addWidget(groups[Face]); - right->addWidget(groups[RightStick]); - right->addStretch(); - grid->addLayout(right, 0, 2); - grid->setColumnStretch(1, 1); - grid->addWidget(groups[Shortcuts], 1, 0, 1, 3); - scroll->setWidget(content); - layout->addWidget(scroll, 1); - - auto* sensitivity_layout = new QHBoxLayout; - sensitivity_layout->addWidget(new QLabel(tr("Mouse sensitivity"), this)); - m_sensitivity = new QDoubleSpinBox(this); - m_sensitivity->setObjectName(QStringLiteral("mouseSensitivity")); - m_sensitivity->setRange(0.1, 5.0); - m_sensitivity->setSingleStep(0.1); - m_sensitivity->setDecimals(1); - m_sensitivity->setSuffix(QStringLiteral("×")); + m_tabs = new QTabWidget(this); + m_tabs->setObjectName(QStringLiteral("inputTabs")); + m_tabs->setDocumentMode(true); + m_tabs->addTab(BuildTab(true), tr("Gamepad")); + m_tabs->addTab(BuildTab(false), tr("Keyboard && Mouse")); + layout->addWidget(m_tabs, 1); m_sensitivity->setValue(ParseMouseSensitivity(mapping)); - sensitivity_layout->addWidget(m_sensitivity); - sensitivity_layout->addWidget( - new QLabel(tr("F7 in-game toggles mouse movement on the right stick."), this)); - sensitivity_layout->addStretch(); - layout->addLayout(sensitivity_layout); + + auto* touch_group = new QGroupBox(tr("Emulated touch pad (shared by both tabs)"), this); + auto* touch_layout = new QHBoxLayout(touch_group); + touch_layout->addWidget(new QLabel(tr("Particle skin"), touch_group)); + m_particle_skin = new QComboBox(touch_group); + m_particle_skin->setObjectName(QStringLiteral("touchParticleSkin")); + m_particle_skin->addItem(tr("Classic"), QStringLiteral("Classic")); + m_particle_skin->addItem(tr("Blue neon"), QStringLiteral("BlueNeon")); + m_particle_skin->addItem(tr("Monochrome"), QStringLiteral("Monochrome")); + m_particle_skin->addItem(tr("Crimson"), QStringLiteral("Crimson")); + m_particle_skin->addItem(tr("Red/Blue"), QStringLiteral("RedBlue")); + touch_layout->addWidget(m_particle_skin); + m_touch_cursor = new QCheckBox(tr("Show hand cursor and trail"), touch_group); + m_touch_cursor->setObjectName(QStringLiteral("touchCursorImage")); + m_touch_cursor->setToolTip(tr("When off, the emulated touch pad still works but the hand " + "image and drawing trail are not shown.")); + m_touch_cursor->setChecked(!mapping.contains(QLatin1String(TOUCH_CURSOR_OFF))); + touch_layout->addWidget(m_touch_cursor); + touch_layout->addStretch(); + touch_layout->addWidget(new QLabel( + tr("Toggle with the touch pad mode key (F6 by default); draw with left mouse / Cross."), + touch_group)); + layout->addWidget(touch_group); + const auto skin_index = m_particle_skin->findData(ParseTouchParticleSkin(mapping)); + m_particle_skin->setCurrentIndex(skin_index >= 0 ? skin_index : 0); auto* buttons = new QDialogButtonBox(QDialogButtonBox::RestoreDefaults | QDialogButtonBox::Save | QDialogButtonBox::Cancel, @@ -338,9 +419,14 @@ InputMappingDialog::InputMappingDialog(const QStringList& mapping, ControllerPre auto* defaults = buttons->button(QDialogButtonBox::RestoreDefaults); defaults->setObjectName(QStringLiteral("defaults")); defaults->setAutoDefault(false); - layout->addWidget(buttons); - connect(m_controller, &DualSenseWidget::ControlClicked, this, - &InputMappingDialog::ChangeBinding); + defaults->setToolTip(tr("Restore the defaults of the current tab.")); + auto* tip = new QLabel(tr("Tip: right-click a binding to unmap it."), this); + auto* footer = new QHBoxLayout; + footer->addWidget(tip); + footer->addStretch(); + footer->addWidget(buttons); + layout->addLayout(footer); + connect(defaults, &QPushButton::clicked, this, &InputMappingDialog::RestoreDefaults); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); @@ -359,6 +445,128 @@ InputMappingDialog::~InputMappingDialog() { } } +QWidget* InputMappingDialog::BuildTab(bool pad) { + auto* page = new QWidget(this); + auto* layout = new QVBoxLayout(page); + layout->setSpacing(10); + + auto* hint = new QLabel( + pad ? tr("Choose which button on your gamepad triggers each PS5 control. Click a " + "binding, then press the button or trigger you want to use.") + : tr("Choose which keyboard key or mouse button triggers each PS5 control. Click a " + "binding, then press the key or mouse button."), + page); + hint->setWordWrap(true); + layout->addWidget(hint); + if (pad) { + m_status = new QLabel(page); + m_status->setObjectName(QStringLiteral("controllerStatus")); + m_status->setWordWrap(true); + layout->addWidget(m_status); + } + + auto* body = new QHBoxLayout; + body->setSpacing(14); + layout->addLayout(body, 1); + + auto* scroll = new QScrollArea(page); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + auto* content = new QWidget(scroll); + auto* columns = new QGridLayout(content); + columns->setContentsMargins(0, 0, 8, 0); + columns->setSpacing(12); + columns->setAlignment(Qt::AlignTop); + + // Section order and the column each one lives in. + static constexpr Section ORDER[] = {Face, Dpad, Shoulders, Sticks, System, LeftStick, + RightStick, Shortcuts}; + int column_rows[2] = {0, 0}; + for (const auto section: ORDER) { + QGroupBox* group = nullptr; + QGridLayout* rows = nullptr; + int row = 0; + for (const auto& control: PAD_CONTROLS) { + if (control.section != section || (pad && control.pad_default == nullptr)) { + continue; + } + if (group == nullptr) { + group = new QGroupBox(tr(SECTION_TITLES[section]), content); + rows = new QGridLayout(group); + rows->setHorizontalSpacing(12); + rows->setVerticalSpacing(6); + rows->setColumnStretch(0, 1); + rows->setColumnMinimumWidth(1, 190); + } + const auto id = QString::fromLatin1(control.id); + const auto label = !pad && id == QLatin1String("TouchPad") ? tr("Touch pad left") + : tr(control.label); + auto* name = new QLabel(label, group); + auto* button = new QPushButton(group); + button->setObjectName((pad ? QStringLiteral("padBinding_") : QStringLiteral("binding_")) + + id); + button->setAutoDefault(false); + button->setMinimumWidth(190); + button->setContextMenuPolicy(Qt::CustomContextMenu); + button->setAccessibleName(tr(SECTION_TITLES[section]) + QStringLiteral(" / ") + label); + rows->addWidget(name, row, 0); + rows->addWidget(button, row, 1); + ++row; + (pad ? m_pad_buttons : m_buttons).insert(id, button); + connect(button, &QPushButton::clicked, this, [this, pad, id] { + pad ? ChangePadBinding(id) : ChangeKeyboardBinding(id); + }); + connect(button, &QPushButton::customContextMenuRequested, this, + [this, pad, id] { ClearBinding(pad, id); }); + } + if (group != nullptr) { + const int target = column_rows[0] <= column_rows[1] ? 0 : 1; + columns->addWidget(group, column_rows[target]++, target); + } + } + columns->setColumnStretch(0, 1); + columns->setColumnStretch(1, 1); + scroll->setWidget(content); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setMinimumWidth(pad ? 640 : 700); + body->addWidget(scroll, 3); + + if (pad) { + m_controller = new DualSenseWidget(page); + m_controller->setObjectName(QStringLiteral("controllerDiagram")); + m_controller->setMinimumWidth(320); + body->addWidget(m_controller, 2); + connect(m_controller, &DualSenseWidget::ControlClicked, this, + [this](const QString& id) { + ChangePadBinding(id == QLatin1String("TouchPadRight") ? QStringLiteral("TouchPad") + : id); + }); + auto* note = new QLabel(tr("The gyro emulation toggle activates tilt mode on gamepads " + "without a gyroscope: the right stick then controls pitch and " + "roll. It defaults to the View/Back button."), + page); + note->setWordWrap(true); + layout->addWidget(note); + } else { + auto* sensitivity_layout = new QHBoxLayout; + sensitivity_layout->addWidget(new QLabel(tr("Mouse sensitivity"), page)); + m_sensitivity = new QDoubleSpinBox(page); + m_sensitivity->setObjectName(QStringLiteral("mouseSensitivity")); + m_sensitivity->setRange(0.1, 5.0); + m_sensitivity->setSingleStep(0.1); + m_sensitivity->setDecimals(1); + m_sensitivity->setSuffix(QStringLiteral("×")); + m_sensitivity->setValue(DEFAULT_MOUSE_SENSITIVITY); + sensitivity_layout->addWidget(m_sensitivity); + sensitivity_layout->addWidget( + new QLabel(tr("F7 in-game toggles mouse movement on the right stick."), page)); + sensitivity_layout->addStretch(); + layout->addLayout(sensitivity_layout); + + } + return page; +} + QStringList InputMappingDialog::Mapping() const { QStringList result; if (m_custom_bindings) { @@ -369,15 +577,38 @@ QStringList InputMappingDialog::Mapping() const { result.append(id + QLatin1Char('=') + binding); } } + if (result.isEmpty()) { + result.append(QLatin1String(KEYBOARD_DISABLED)); + } + } + if (m_custom_pad) { + const auto start = result.size(); + for (const auto& control: PAD_CONTROLS) { + const auto id = QString::fromLatin1(control.id); + const auto source = m_pad_bindings.value(id); + if (!source.isEmpty()) { + result.append(QLatin1String(PAD_PREFIX) + id + QLatin1Char('=') + source); + } + } + if (result.size() == start) { + result.append(QLatin1String(PAD_DISABLED)); + } } if (m_sensitivity->value() != DEFAULT_MOUSE_SENSITIVITY) { result.append(QLatin1String(MOUSE_SENSITIVITY) + QString::number(m_sensitivity->value(), 'f', 1)); } + const auto skin = m_particle_skin->currentData().toString(); + if (skin != QLatin1String("Classic")) { + result.append(QLatin1String(TOUCH_PARTICLE_SKIN) + skin); + } + if (!m_touch_cursor->isChecked()) { + result.append(QLatin1String(TOUCH_CURSOR_OFF)); + } return result; } -void InputMappingDialog::ChangeBinding(const QString& id) { +void InputMappingDialog::ChangeKeyboardBinding(const QString& id) { if (!m_buttons.contains(id)) { return; } @@ -393,14 +624,54 @@ void InputMappingDialog::ChangeBinding(const QString& id) { UpdateButtons(); } -void InputMappingDialog::RestoreDefaults() { - m_bindings.clear(); - for (const auto& control: PAD_CONTROLS) { - m_bindings.insert(QString::fromLatin1(control.id), - QString::fromLatin1(control.default_binding)); +void InputMappingDialog::ChangePadBinding(const QString& id) { + if (!m_pad_buttons.contains(id)) { + return; + } + m_selected = id; + UpdateHighlights(); + PadCaptureDialog dialog(m_preview, this); + dialog.setWindowTitle(tr("Set binding — %1").arg(m_pad_buttons.value(id)->accessibleName())); + if (dialog.exec() == QDialog::Accepted) { + AssignBinding(m_pad_bindings, id, dialog.Binding()); + m_custom_pad = true; + } + m_selected.clear(); + UpdateButtons(); +} + +void InputMappingDialog::ClearBinding(bool pad, const QString& id) { + if (pad) { + m_pad_bindings.remove(id); + m_custom_pad = true; + } else { + m_bindings.remove(id); + m_custom_bindings = true; + } + UpdateButtons(); +} + +void InputMappingDialog::RestoreDefaults() { + if (m_tabs->currentIndex() == 0) { + m_pad_bindings.clear(); + for (const auto& control: PAD_CONTROLS) { + if (control.pad_default != nullptr) { + m_pad_bindings.insert(QString::fromLatin1(control.id), + QString::fromLatin1(control.pad_default)); + } + } + m_custom_pad = false; + } else { + m_bindings.clear(); + for (const auto& control: PAD_CONTROLS) { + if (control.keyboard_default[0] != '\0') { + m_bindings.insert(QString::fromLatin1(control.id), + QString::fromLatin1(control.keyboard_default)); + } + } + m_sensitivity->setValue(DEFAULT_MOUSE_SENSITIVITY); + m_custom_bindings = false; } - m_sensitivity->setValue(DEFAULT_MOUSE_SENSITIVITY); - m_custom_bindings = false; UpdateButtons(); } @@ -410,14 +681,42 @@ void InputMappingDialog::UpdateButtons() { const auto binding = m_bindings.value(item.key()); const auto label = binding.isEmpty() ? tr("None") : binding; button->setText(QString(label).replace(QLatin1Char('&'), QLatin1String("&&"))); - button->setToolTip(tr("%1: %2. Click to change.").arg(button->accessibleName(), label)); + button->setToolTip( + tr("%1: %2. Click to change, right-click to clear.").arg(button->accessibleName(), label)); + } + for (auto item = m_pad_buttons.begin(); item != m_pad_buttons.end(); ++item) { + auto* button = item.value(); + const auto source = m_pad_bindings.value(item.key()); + const auto label = source.isEmpty() ? tr("None") : PadSourceLabel(source); + button->setText(QString(label).replace(QLatin1Char('&'), QLatin1String("&&"))); + button->setToolTip( + tr("%1: %2. Click to change, right-click to clear.").arg(button->accessibleName(), label)); } UpdateHighlights(); } void InputMappingDialog::UpdatePreview() { - const auto pressed = m_preview != nullptr ? m_preview->Pressed() : QSet(); - m_controller->SetPressed(pressed); + m_pad_pressed.clear(); + if (m_preview != nullptr) { + const auto& raw = m_preview->RawPressed(); + for (auto item = m_pad_bindings.begin(); item != m_pad_bindings.end(); ++item) { + if (raw.contains(item.value())) { + m_pad_pressed.insert(item.key()); + } + } + if (m_pad_pressed.contains(QStringLiteral("TouchPad"))) { + m_pad_pressed.insert(QStringLiteral("TouchPadRight")); + } + for (const auto& name: m_preview->Pressed()) { + // Stick directions and touch pad fingers are shown as the pad reports them. + if (name.startsWith(QLatin1String("LeftStick")) || + name.startsWith(QLatin1String("RightStick")) || + name.startsWith(QLatin1String("TouchPad"))) { + m_pad_pressed.insert(name); + } + } + } + m_controller->SetPressed(m_pad_pressed); m_controller->SetSticks(m_preview != nullptr ? m_preview->LeftStick() : QPointF(), m_preview != nullptr ? m_preview->RightStick() : QPointF()); UpdateHighlights(); @@ -432,8 +731,8 @@ void InputMappingDialog::UpdatePreview() { void InputMappingDialog::UpdateHighlights() { m_controller->SetSelected(m_selected); - for (auto item = m_buttons.begin(); item != m_buttons.end(); ++item) { - const bool down = m_preview != nullptr && m_preview->Pressed().contains(item.key()); + for (auto item = m_pad_buttons.begin(); item != m_pad_buttons.end(); ++item) { + const bool down = m_pad_pressed.contains(item.key()); auto colors = palette(); if (down) { colors.setColor(QPalette::Button, colors.color(QPalette::Highlight)); @@ -446,4 +745,11 @@ void InputMappingDialog::UpdateHighlights() { item.value()->setPalette(colors); item.value()->setFont(label_font); } + for (auto item = m_buttons.begin(); item != m_buttons.end(); ++item) { + auto colors = palette(); + if (item.key() == m_selected) { + colors.setColor(QPalette::ButtonText, colors.color(QPalette::Highlight)); + } + item.value()->setPalette(colors); + } } diff --git a/src/libs/controller.cpp b/src/libs/controller.cpp index 4c34601a0..5c6c853ab 100644 --- a/src/libs/controller.cpp +++ b/src/libs/controller.cpp @@ -126,6 +126,7 @@ public: void Motion(int id, Sensor sensor, const float* data, uint64_t time_us); void SetMotionSensorState(bool enable); void ResetOrientation(); + void ToggleGyroEmulation(int id); void ResetInputState(); void ReleaseHostPads(); void GetConnectionInfo(bool* flag, int* count); @@ -147,6 +148,8 @@ private: void CheckActive(); void AddState(); + // Emulated tilt: the right stick drives pitch and roll instead of the stick axes. + void UpdateEmulatedTilt(); // Apply cached output without changing its game-requested lifetime; caller holds m_mutex. void ApplyVibration(); bool SendTriggerEffect(const PadTriggerEffectParam& param); @@ -160,6 +163,9 @@ private: int m_connected_count = 0; bool m_motion_enabled = true; uint64_t m_gyro_time = 0; + bool m_gyro_emulation = false; + uint64_t m_emulated_time = 0; + std::array m_emulated_stick {128, 128}; // Accelerometer gravity direction in the reset frame. std::array m_up_reference {0.0f, 1.0f, 0.0f}; bool m_up_reference_valid = false; @@ -479,6 +485,9 @@ void GameController::CheckActive() { m_connected = new_connected; m_state = {}; m_gyro_time = 0; + m_gyro_emulation = false; + m_emulated_time = 0; + m_emulated_stick = {128, 128}; m_up_reference_valid = false; m_states_num = 0; m_first_state = 0; @@ -524,6 +533,12 @@ void GameController::Axis(int id, Controller::Axis axis, int value) { EXIT_IF(axis_id < 0 || axis_id >= static_cast(Controller::Axis::AxisMax)); + if (m_gyro_emulation && (axis == Controller::Axis::RightX || axis == Controller::Axis::RightY)) { + m_emulated_stick[axis == Controller::Axis::RightY ? 1 : 0] = value; + UpdateEmulatedTilt(); + return; + } + m_state.axes[axis_id] = value; uint32_t trigger = 0; @@ -545,7 +560,12 @@ void GameController::RightStick(int id, int x, int y) { Common::LockGuard lock(m_mutex); if (m_active_id == id || id == HOST_INPUT_CONTROLLER_ID) { - m_state.time = LibKernel::KernelGetProcessTime(); + m_state.time = LibKernel::KernelGetProcessTime(); + if (m_gyro_emulation) { + m_emulated_stick = {x, y}; + UpdateEmulatedTilt(); + return; + } m_state.axes[static_cast(Axis::RightX)] = x; m_state.axes[static_cast(Axis::RightY)] = y; AddState(); @@ -677,6 +697,82 @@ void GameController::SetMotionSensorState(bool enable) { } } +static Quat QuatMultiply(const Quat& a, const Quat& b) { + return {a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]}; +} + +void GameController::UpdateEmulatedTilt() { + constexpr float max_angle = 0.7853982f; // 45 degrees at full stick deflection + const auto angle = [](int value) { + return std::clamp((value - 128) / 127.0f, -1.0f, 1.0f) * max_angle; + }; + const float roll = angle(m_emulated_stick[0]); + const float pitch = angle(m_emulated_stick[1]); + + const Quat q_pitch {std::sin(pitch * 0.5f), 0.0f, 0.0f, std::cos(pitch * 0.5f)}; + const Quat q_roll {0.0f, 0.0f, std::sin(-roll * 0.5f), std::cos(-roll * 0.5f)}; + Quat q = QuatMultiply(q_roll, q_pitch); + + const uint64_t now = LibKernel::KernelGetProcessTime(); + Vec3 rate {}; + if (m_emulated_time != 0 && now > m_emulated_time) { + const float dt = std::clamp(static_cast(now - m_emulated_time) * 0.000001f, + 0.001f, 0.1f); + auto delta = QuatMultiply(QuatConjugate(m_state.orientation), q); + if (delta[3] < 0.0f) { + for (auto& v: delta) { + v = -v; + } + } + rate = {2.0f * delta[0] / dt, 2.0f * delta[1] / dt, 2.0f * delta[2] / dt}; + } + m_emulated_time = now; + + m_state.time = now; + m_state.orientation = q; + m_state.accel = QuatRotate(QuatConjugate(q), {0.0f, 1.0f, 0.0f}); + m_state.gyro = rate; + AddState(); + // Angular velocity is only nonzero while the tilt changes. + m_state.gyro = {}; +} + +void GameController::ToggleGyroEmulation(int id) { + Common::LockGuard lock(m_mutex); + if (id != HOST_INPUT_CONTROLLER_ID && id != m_active_id) { + return; + } + // Pads with a real gyroscope report it directly. + if (m_active_id != HOST_INPUT_CONTROLLER_ID) { + auto* pad = SDL_GetGamepadFromID(static_cast(m_active_id)); + if (pad != nullptr && SDL_GamepadHasSensor(pad, SDL_SENSOR_GYRO)) { + return; + } + } + + m_gyro_emulation = !m_gyro_emulation; + m_emulated_time = 0; + m_state.time = LibKernel::KernelGetProcessTime(); + if (m_gyro_emulation) { + m_emulated_stick = {m_state.axes[static_cast(Axis::RightX)], + m_state.axes[static_cast(Axis::RightY)]}; + m_state.axes[static_cast(Axis::RightX)] = 128; + m_state.axes[static_cast(Axis::RightY)] = 128; + UpdateEmulatedTilt(); + } else { + m_state.orientation = {0.0f, 0.0f, 0.0f, 1.0f}; + m_state.accel = {0.0f, 1.0f, 0.0f}; + m_state.gyro = {}; + m_state.axes[static_cast(Axis::RightX)] = m_emulated_stick[0]; + m_state.axes[static_cast(Axis::RightY)] = m_emulated_stick[1]; + AddState(); + } + LOGF("Emulated gyro tilt: %s\n", m_gyro_emulation ? "enabled" : "disabled"); +} + void GameController::ResetOrientation() { Common::LockGuard lock(m_mutex); m_state.orientation = {0.0f, 0.0f, 0.0f, 1.0f}; @@ -931,6 +1027,10 @@ void SetSensor(int id, Sensor sensor, const float* data, uint64_t time_us) { g_controller->Motion(id, sensor, data, time_us); } +void ToggleGyroEmulation(int id) { + g_controller->ToggleGyroEmulation(id); +} + void ResetInputState() { g_controller->ResetInputState(); } diff --git a/src/libs/controller.h b/src/libs/controller.h index b84246a18..c53641261 100644 --- a/src/libs/controller.h +++ b/src/libs/controller.h @@ -73,6 +73,7 @@ void SetAxis(int id, Axis axis, int value); void SetRightStick(int id, int x, int y); void SetTouchPad(int id, int finger, bool down, float x, float y); void SetSensor(int id, Sensor sensor, const float* data, uint64_t time_us); +void ToggleGyroEmulation(int id); void ResetInputState(); int GetActiveControllerId(); diff --git a/src/libs/dualSenseHaptics.cpp b/src/libs/dualSenseHaptics.cpp index 9fe4af61c..b884f528e 100644 --- a/src/libs/dualSenseHaptics.cpp +++ b/src/libs/dualSenseHaptics.cpp @@ -326,6 +326,33 @@ uint64_t Queue(Stream* stream, int controller, const void* data, uint32_t frames if (frames == 0) { return 0; } + if (!stream->speaker && + SDL_GetGamepadTypeForID(static_cast(controller)) != SDL_GAMEPAD_TYPE_PS5) { + CloseDevice(stream); + stream->next_check = 0; + // Non-DualSense pads have no haptic audio path: approximate it with the rumble motors. + float peak[2] = {0.0f, 0.0f}; + for (uint32_t frame = 0; frame < frames; frame++) { + for (uint32_t ch = 0; ch < 2; ch++) { + const auto src_ch = channels == 1 ? 0 : ch; + const auto index = (static_cast(first_frame) + frame) * channels + src_ch; + float value = is_float ? static_cast(data)[index] + : static_cast(data)[index] / 32768.0f; + value *= volume[src_ch] / 32768.0f * gain * (gains != nullptr ? gains[src_ch] : 1.0f); + peak[ch] = std::max(peak[ch], std::fabs(value)); + } + } + const auto to_motor = [](float v) { + return static_cast(std::clamp(v * 2.0f, 0.0f, 1.0f) * 65535.0f); + }; + SDL_LockJoysticks(); + if (auto* pad = SDL_GetGamepadFromID(static_cast(controller)); + pad != nullptr) { + (void) SDL_RumbleGamepad(pad, to_motor(peak[0]), to_motor(peak[1]), 100); + } + SDL_UnlockJoysticks(); + return static_cast(frames) * 1000000 / stream->freq; + } const bool wireless = IsWireless(controller); if (!wireless && !CanUseDevice(controller)) { CloseDevice(stream); diff --git a/tests/HostInputTests.cpp b/tests/HostInputTests.cpp new file mode 100644 index 000000000..44b3c7b27 --- /dev/null +++ b/tests/HostInputTests.cpp @@ -0,0 +1,205 @@ +// Exercise production host input with deterministic gamepads and guest output. +#include +#include +#include +#include + +namespace { +void Check(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "HostInputTests: %s\n", message); + std::abort(); + } +} + +std::array physical_stick {}; +std::array guest_stick {128, 128}; +uint32_t guest_buttons = 0; +bool guest_touch = false; +} // namespace + +namespace Fake { +SDL_Gamepad* GetGamepadFromID(SDL_JoystickID id) { + return id == 1 || id == 2 ? reinterpret_cast(static_cast(id)) + : nullptr; +} +Sint16 GetGamepadAxis(SDL_Gamepad*, SDL_GamepadAxis axis) { + return physical_stick[axis == SDL_GAMEPAD_AXIS_LEFTY ? 1 : 0]; +} +Uint64 GetTicks() { + return 1000; +} +bool HideCursor() { + return true; +} +bool ShowCursor() { + return true; +} +SDL_MouseButtonFlags GetMouseState(float* x, float* y) { + *x = *y = 0.0f; + return 0; +} +} // namespace Fake + +#define SDL_GetGamepadFromID Fake::GetGamepadFromID +#define SDL_GetGamepadAxis Fake::GetGamepadAxis +#define SDL_GetTicks Fake::GetTicks +#define SDL_HideCursor Fake::HideCursor +#define SDL_ShowCursor Fake::ShowCursor +#define SDL_GetMouseState Fake::GetMouseState +#include "graphics/presentation/window/hostInput.cpp" +#undef SDL_GetGamepadFromID +#undef SDL_GetGamepadAxis +#undef SDL_GetTicks +#undef SDL_HideCursor +#undef SDL_ShowCursor +#undef SDL_GetMouseState + +namespace Libs::Controller { +void SetButton(int id, uint32_t button, bool down) { + if (id == 1) { + guest_buttons = down ? guest_buttons | button : guest_buttons & ~button; + } +} +void SetAxis(int id, Axis axis, int value) { + if (id == 1 && (axis == Axis::LeftX || axis == Axis::LeftY)) { + guest_stick[axis == Axis::LeftY ? 1 : 0] = value; + } +} +void SetTouchPad(int id, int finger, bool down, float, float) { + if (id == 1 && finger == 1) { + guest_touch = down; + } +} +void SetRightStick(int, int, int) {} +void ToggleGyroEmulation(int) {} +void CycleSetting(Setting) {} +} // namespace Libs::Controller + +namespace { +using namespace Libs::Graphics; +using namespace Libs::Controller; + +struct Inputs { + Inputs() { + physical_stick = {}; + guest_stick = {128, 128}; + guest_buttons = 0; + guest_touch = false; + HostInputInit(nullptr); + } + ~Inputs() { HostInputShutdown(); } +}; + +void TestTouchPadStickHandoff() { + Inputs inputs; + physical_stick = {SDL_JOYSTICK_AXIS_MAX, SDL_JOYSTICK_AXIS_MIN}; + guest_stick = {255, 0}; + HostInputToggleGamepadTouchPad(1); + Check(guest_stick == std::array {128, 128}, "entering touchpad mode retained guest movement"); + PollGamepadTouchPad(1008); + const auto cursor = GetHostInputTouchPadVisualState(); + Check(cursor.x > 0.5f && cursor.y < 0.5f, + "entering touchpad mode did not sample an already-held stick"); + Check(HostInputGamepadAxis(1, true, 127) && HostInputGamepadAxis(1, false, 127), + "touchpad mode did not capture stick releases"); + Check(guest_stick == std::array {128, 128}, "touchpad movement leaked to guest axes"); + // Sample current physical axes on exit even if an event was consumed elsewhere. + physical_stick = {SDL_JOYSTICK_AXIS_MIN, SDL_JOYSTICK_AXIS_MAX}; + HostInputToggleGamepadTouchPad(1); + Check(guest_stick == std::array {0, 255}, "leaving touchpad mode did not restore current axes"); + Check(!HostInputGamepadAxis(1, true, 127), "leaving touchpad mode retained stick capture"); +} + +void TestKeyboardSwitchesFromGamepadTouchPad() { + Inputs inputs; + physical_stick = {SDL_JOYSTICK_AXIS_MIN, SDL_JOYSTICK_AXIS_MAX}; + HostInputToggleGamepadTouchPad(1); + Check(HostInputGamepadDrawButton(1, true) && guest_touch, "gamepad drawing did not start"); + HostInputKey(SDLK_F6, true); + Check(GetHostInputTouchPadVisualState().enabled && !HostInputGamepadAxis(1, true, 127), + "F6 failed to switch from gamepad to mouse touchpad mode"); + Check(!guest_touch && guest_stick == std::array {0, 255}, + "switching to mouse touchpad mode retained gamepad input capture"); + HostInputKey(SDLK_F6, false); + HostInputKey(SDLK_F6, true); + Check(!GetHostInputTouchPadVisualState().enabled, "F6 failed to disable mouse touchpad mode"); +} + +void TestIndependentTriggerEdges() { + Inputs inputs; + HostInputPadTrigger(2, true, 255); + HostInputPadTrigger(1, true, 255); + Check(guest_buttons == PAD_BUTTON_CROSS, "an inactive pad suppressed the active trigger press"); + HostInputPadTrigger(2, true, 0); + HostInputPadTrigger(1, true, 0); + Check(guest_buttons == 0, "an inactive pad suppressed the active trigger release"); + HostInputPadTrigger(1, false, 255); + HostInputPadTrigger(1, true, 255); + HostInputPadTrigger(1, true, 0); + Check(guest_buttons == PAD_BUTTON_CIRCLE, "left and right trigger edges were mixed"); + HostInputPadTrigger(1, false, 0); + Check(guest_buttons == 0, "right trigger release was lost"); +} + +void TestTriggerLifecycleReset() { + Inputs inputs; + HostInputPadTrigger(1, true, 255); + HostInputDisconnectGamepad(1); + guest_buttons = 0; + HostInputPadTrigger(1, true, 255); + Check(guest_buttons == PAD_BUTTON_CROSS, "disconnect retained a previous trigger press"); + HostInputShutdown(); + guest_buttons = 0; + HostInputInit(nullptr); + HostInputPadTrigger(1, true, 255); + Check(guest_buttons == PAD_BUTTON_CROSS, "shutdown retained a previous trigger press"); +} + +void TestDisabledMappingTabs() { + Config::ConfigOptions options; + const auto south = InputMap::SourceFromButton(SDL_GAMEPAD_BUTTON_SOUTH); + Config::Load(options); + const InputMap defaults; + Check(!defaults.Custom() && defaults.PadControl(south) == ControlFromName("Cross"), + "empty configuration lost its default mappings"); + options.keymap = {"Keyboard=Disabled"}; + Config::Load(options); + const InputMap keyboard_disabled; + Check(keyboard_disabled.Custom() && keyboard_disabled.FindKey(SDLK_J) == INVALID_CONTROL && + keyboard_disabled.PadControl(south) == ControlFromName("Cross"), + "disabling keyboard mappings changed gamepad defaults or restored keyboard input"); + for (const auto& keymap: {Config::Keymap {"Pad=Disabled"}, + Config::Keymap {"Keyboard=Disabled", "Pad=Disabled"}}) { + options.keymap = keymap; + Config::Load(options); + const InputMap disabled; + Check(disabled.Custom() == (keymap.size() == 2), + "disabling gamepad mappings changed keyboard defaults"); + for (std::size_t source = 0; source < PAD_SOURCES.size(); source++) { + Check(disabled.PadControl(source) == INVALID_CONTROL, + "a disabled gamepad mapping reverted to its default"); + } + } + options.keymap = {"Cross=J", "Pad:Circle=South"}; + Config::Load(options); + const InputMap mapped; + Check(mapped.Custom() && mapped.FindKey(SDLK_J) == ControlFromName("Cross") && + mapped.PadControl(south) == ControlFromName("Circle"), + "explicit mappings no longer override defaults"); +} +} // namespace + +int main() { + Config::Initialize(); + Config::ConfigOptions options; + options.keymap = {"Pad:Cross=LeftTrigger", "Pad:Circle=RightTrigger"}; + Config::Load(options); + TestTouchPadStickHandoff(); + TestKeyboardSwitchesFromGamepadTouchPad(); + TestIndependentTriggerEdges(); + TestTriggerLifecycleReset(); + TestDisabledMappingTabs(); + Config::Shutdown(); + return 0; +} diff --git a/tests/PadHapticsTests.cpp b/tests/PadHapticsTests.cpp index 47234959c..dfa381802 100644 --- a/tests/PadHapticsTests.cpp +++ b/tests/PadHapticsTests.cpp @@ -143,13 +143,13 @@ void DestroyAudioStream(SDL_AudioStream* stream) { SDL_AudioDeviceID GetAudioStreamDevice(SDL_AudioStream*) { return 999; } -// Pads 1 and 3 are DualSenses. +// Pads 1 and 3 are DualSenses; pad 2 only supports rumble. SDL_GamepadType GetGamepadTypeForID(SDL_JoystickID id) { return id == 1 || id == 3 ? SDL_GAMEPAD_TYPE_PS5 : SDL_GAMEPAD_TYPE_UNKNOWN; } SDL_Gamepad* GetGamepadFromID(SDL_JoystickID id) { - return id == 1 || id == 3 ? reinterpret_cast(static_cast(id)) - : nullptr; + return id >= 1 && id <= 3 ? reinterpret_cast(static_cast(id)) + : nullptr; } SDL_GamepadType GetGamepadType(SDL_Gamepad* pad) { return GetGamepadTypeForID(static_cast(reinterpret_cast(pad))); @@ -458,6 +458,47 @@ void TestDiscoveryAndHotplug() { Check(streams.empty(), "disconnected endpoint retained its stream"); } +void TestRumbleFallbackGains() { + Fixture f; + auto port = Open(); + const auto queue = [&](std::array gains) { + Check(Haptics::Queue(port.get(), 2, pcm.data(), 2, 2, true, unity.data(), 1.0f, + gains.data()) == 2 * 1000000 / 48000, + "rumble fallback playback time is wrong"); + }; + queue({0.0f, 0.25f}); + Check(pad_rumble[2].large == 0 && pad_rumble[2].small == 16383, + "rumble fallback ignored per-channel gains"); + queue({0.5f, 0.0f}); + Check(pad_rumble[2].large == 32767 && pad_rumble[2].small == 0, + "rumble fallback swapped per-channel gains"); + queue({0.0f, 0.0f}); + Check(pad_rumble[2].large == 0 && pad_rumble[2].small == 0, + "muted fallback channels still rumbled"); + Check(streams.empty() && hid_opens == 0, "rumble fallback opened an audio device"); +} + +void TestRumbleFallbackSwitch() { + for (bool bluetooth: {false, true}) { + Fixture f; + wireless = hid_available = bluetooth; + auto port = Open(); + Queue(port, pcm.data()); + Check(bluetooth ? Haptics::UsesBluetooth(port.get()) : streams.size() == 1, + "DualSense transport did not open before fallback"); + Haptics::Queue(port.get(), 2, pcm.data(), 2, 2, true, unity.data()); + Check(streams.empty() && !Haptics::UsesBluetooth(port.get()) && + (!bluetooth || hid_closes == 1), + "switching to rumble retained the DualSense transport"); + Check(pad_rumble[2].large == 65535 && pad_rumble[2].small == 65535, + "switching from DualSense did not start rumble fallback"); + Queue(port, pcm.data()); + Check(bluetooth ? Haptics::UsesBluetooth(port.get()) && hid_opens == 2 + : streams.size() == 1 && opens == 2, + "switching back delayed reopening the DualSense transport"); + } +} + void TestSpeaker() { Fixture f; devices = {{10, "DualSense Wireless Controller", 2}}; @@ -1279,6 +1320,8 @@ int main() { TestAudioDefaultResumeFailure(); TestFormatsAndVolume(); TestDiscoveryAndHotplug(); + TestRumbleFallbackGains(); + TestRumbleFallbackSwitch(); TestSpeaker(); TestSpeakerUnplug(); TestSpeakerSwitchAndFailures();