From 21b57f08a4b8aa2ff24e439f4f1c8e79988971af Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:32:22 +0200 Subject: [PATCH 1/2] Unknown controller support --- aurora-main/lib/input.cpp | 33 ++ aurora-main/lib/input.hpp | 1 + aurora-main/lib/window.cpp | 9 + runtime/include/controller_mapping_wizard.h | 21 + runtime/src/controller_mapping_wizard.cpp | 467 ++++++++++++++++++++ runtime/src/settings_overlay.cpp | 8 + 6 files changed, 539 insertions(+) create mode 100644 runtime/include/controller_mapping_wizard.h create mode 100644 runtime/src/controller_mapping_wizard.cpp diff --git a/aurora-main/lib/input.cpp b/aurora-main/lib/input.cpp index e5a2f56..bf7489b 100644 --- a/aurora-main/lib/input.cpp +++ b/aurora-main/lib/input.cpp @@ -316,6 +316,25 @@ void apply_port_preferences() noexcept { } } } + +// SDL only hands out a player index when the device already had a gamepad mapping +// at connect time, so anything mapped later (the setup wizard) stays at -1. +void ensure_player_index(GameController& controller) noexcept { + if (SDL_GetGamepadPlayerIndex(controller.m_controller) >= 0) { + return; + } + for (int32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) { + const bool taken = std::any_of(g_GameControllers.begin(), g_GameControllers.end(), [&](const auto& entry) { + return entry.second.m_controller != controller.m_controller && + SDL_GetGamepadPlayerIndex(entry.second.m_controller) == port; + }); + if (!taken) { + SDL_SetGamepadPlayerIndex(controller.m_controller, port); + controller.m_playerIndex = port; + return; + } + } +} } // namespace GameController* get_controller_for_player(uint32_t player) noexcept { @@ -364,6 +383,7 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept { controller.m_hasRgbLed = SDL_GetBooleanProperty(props, SDL_PROP_GAMEPAD_CAP_RGB_LED_BOOLEAN, false); SDL_JoystickID instance = SDL_GetJoystickID(SDL_GetGamepadJoystick(ctrl)); g_GameControllers[instance] = controller; + ensure_player_index(g_GameControllers[instance]); apply_port_preferences(); return instance; } @@ -371,6 +391,19 @@ SDL_JoystickID add_controller(SDL_JoystickID which) noexcept { return -1; } +bool refresh_controller(SDL_JoystickID instance) noexcept { + const auto it = g_GameControllers.find(instance); + if (it == g_GameControllers.end()) { + return false; + } + // The SDL mapping changed underneath us; drop the cached PAD bindings so they + // are rebuilt from the new one. + it->second.m_mappingLoaded = false; + ensure_player_index(it->second); + apply_port_preferences(); + return true; +} + void remove_controller(Uint32 instance) noexcept { if (auto it = g_GameControllers.find(instance); it != g_GameControllers.end()) { SDL_CloseGamepad(it->second.m_controller); diff --git a/aurora-main/lib/input.hpp b/aurora-main/lib/input.hpp index b839865..1c6f24e 100644 --- a/aurora-main/lib/input.hpp +++ b/aurora-main/lib/input.hpp @@ -51,6 +51,7 @@ struct GameController { GameController* get_controller_for_player(uint32_t player) noexcept; Sint32 get_instance_for_player(uint32_t player) noexcept; SDL_JoystickID add_controller(SDL_JoystickID which) noexcept; +bool refresh_controller(SDL_JoystickID instance) noexcept; void remove_controller(Uint32 instance) noexcept; Sint32 player_index(Uint32 instance) noexcept; void set_player_index(Uint32 instance, Sint32 index) noexcept; diff --git a/aurora-main/lib/window.cpp b/aurora-main/lib/window.cpp index 37d64be..86649c9 100644 --- a/aurora-main/lib/window.cpp +++ b/aurora-main/lib/window.cpp @@ -294,6 +294,15 @@ void process_event(SDL_Event& event) { }); break; } + case SDL_EVENT_GAMEPAD_REMAPPED: { + if (input::refresh_controller(event.gdevice.which)) { + g_events.push_back(AuroraEvent{ + .type = AURORA_CONTROLLER_ADDED, + .controller = event.gdevice.which, + }); + } + break; + } case SDL_EVENT_GAMEPAD_REMOVED: { input::remove_controller(event.gdevice.which); g_events.push_back(AuroraEvent{ diff --git a/runtime/include/controller_mapping_wizard.h b/runtime/include/controller_mapping_wizard.h new file mode 100644 index 0000000..69f70ea --- /dev/null +++ b/runtime/include/controller_mapping_wizard.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +// Press-to-bind setup for joysticks SDL either doesn't recognize as gamepads or +// recognizes with a mapping that lacks the analog stick (e.g. raphnet adapters). +// The wizard produces a standard SDL gamepad mapping, applies it live, and +// persists it to gamecontrollerdb.txt in the user data directory. +namespace controller_mapping_wizard { + +void LoadPersistedMappings(); +void HandleSdlEvent(const SDL_Event& event); + +// Lists devices that need setup inside the controller settings menu. +void DrawSetupList(); +// Draws the wizard window when active; call once per overlay frame. +void Draw(); + +bool IsActive(); + +} // namespace controller_mapping_wizard diff --git a/runtime/src/controller_mapping_wizard.cpp b/runtime/src/controller_mapping_wizard.cpp new file mode 100644 index 0000000..6d84e23 --- /dev/null +++ b/runtime/src/controller_mapping_wizard.cpp @@ -0,0 +1,467 @@ +#include "controller_mapping_wizard.h" +#include "runtime_config.h" +#include "runtime_log.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace controller_mapping_wizard { +namespace { + +using Clock = std::chrono::steady_clock; + +constexpr int16_t kStickThreshold = 16000; +constexpr int16_t kTriggerThreshold = 10000; +constexpr auto kCaptureDebounce = std::chrono::milliseconds(350); + +enum class StepKind { + Button, // button or single-direction hat press + Trigger, // button press or axis pull + Stick, // axis motion in the prompted direction +}; + +struct Step { + const char* mappingKey; + const char* prompt; + StepKind kind; +}; + +// Prompts describe what the control does in-game; the SDL fields land on the +// right GC controls through pad.cpp's "standard" defaults (Z lives on +// rightshoulder, L/R on the trigger axes). +constexpr std::array kSteps = {{ + {"a", "Press the button for A (accelerate / select)", StepKind::Button}, + {"b", "Press the button for B (brake / back)", StepKind::Button}, + {"x", "Press the button for X", StepKind::Button}, + {"y", "Press the button for Y", StepKind::Button}, + {"start", "Press the button for pause (Start)", StepKind::Button}, + {"rightshoulder", "Press the button for rear view (Z)", StepKind::Button}, + {"lefttrigger", "Press or pull the control for using items (L)", StepKind::Trigger}, + {"righttrigger", "Press or pull the control for hop / drift (R)", StepKind::Trigger}, + {"dpup", "Press D-pad Up", StepKind::Button}, + {"dpdown", "Press D-pad Down", StepKind::Button}, + {"dpleft", "Press D-pad Left", StepKind::Button}, + {"dpright", "Press D-pad Right", StepKind::Button}, + {"leftx", "Move the Control Stick LEFT", StepKind::Stick}, + {"lefty", "Move the Control Stick UP", StepKind::Stick}, + {"rightx", "Move the C-Stick LEFT (or Skip)", StepKind::Stick}, + {"righty", "Move the C-Stick UP (or Skip)", StepKind::Stick}, +}}; + +struct WizardState { + bool active = false; + SDL_JoystickID instance = 0; + SDL_Joystick* joystick = nullptr; + bool ownsJoystick = false; + std::string deviceName; + size_t stepIndex = 0; + std::array, kSteps.size()> bindings{}; + std::vector axisBaseline; + Clock::time_point acceptAfter{}; + std::string status; +}; + +WizardState g_wizard; + +std::filesystem::path MappingDbPath() { + return RuntimeConfigFile::ApplicationDataDirectory() / "gamecontrollerdb.txt"; +} + +std::string GuidString(SDL_JoystickID instance) { + char buf[33] = {}; + SDL_GUIDToString(SDL_GetJoystickGUIDForID(instance), buf, sizeof(buf)); + return buf; +} + +bool BindingUsed(const std::string& value) { + return std::any_of(g_wizard.bindings.begin(), g_wizard.bindings.end(), + [&](const std::optional& b) { return b && *b == value; }); +} + +void SnapshotAxes() { + g_wizard.axisBaseline.clear(); + const int axes = SDL_GetNumJoystickAxes(g_wizard.joystick); + for (int i = 0; i < axes; ++i) { + g_wizard.axisBaseline.push_back(SDL_GetJoystickAxis(g_wizard.joystick, i)); + } +} + +void AdvanceStep(std::optional value) { + g_wizard.bindings[g_wizard.stepIndex] = std::move(value); + ++g_wizard.stepIndex; + g_wizard.acceptAfter = Clock::now() + kCaptureDebounce; + SnapshotAxes(); +} + +void StopWizard() { + if (g_wizard.ownsJoystick && g_wizard.joystick != nullptr) { + SDL_CloseJoystick(g_wizard.joystick); + } + g_wizard = WizardState{}; +} + +void StartWizard(SDL_JoystickID instance) { + StopWizard(); + SDL_Joystick* joystick = nullptr; + bool owns = false; + if (SDL_Gamepad* gamepad = SDL_GetGamepadFromID(instance)) { + joystick = SDL_GetGamepadJoystick(gamepad); + } else { + joystick = SDL_OpenJoystick(instance); + owns = true; + } + if (joystick == nullptr) { + RT_LOG(RT_TAG_CONFIG) << "controller wizard: failed to open joystick " << instance << ": " + << SDL_GetError() << std::endl; + return; + } + g_wizard.active = true; + g_wizard.instance = instance; + g_wizard.joystick = joystick; + g_wizard.ownsJoystick = owns; + const char* name = SDL_GetJoystickNameForID(instance); + g_wizard.deviceName = name != nullptr ? name : "Controller"; + g_wizard.acceptAfter = Clock::now() + kCaptureDebounce; + SnapshotAxes(); +} + +std::string BuildMappingString() { + std::string name = g_wizard.deviceName; + std::replace(name.begin(), name.end(), ',', ' '); + std::string mapping = GuidString(g_wizard.instance) + "," + name + ","; + for (size_t i = 0; i < kSteps.size(); ++i) { + if (g_wizard.bindings[i]) { + mapping += std::string(kSteps[i].mappingKey) + ":" + *g_wizard.bindings[i] + ","; + } + } + mapping += "platform:Windows,"; + return mapping; +} + +void PersistMapping(const std::string& guid, const std::string& mapping) { + const std::filesystem::path path = MappingDbPath(); + std::vector lines; + { + std::ifstream in(path); + std::string line; + while (std::getline(in, line)) { + if (line.rfind(guid + ",", 0) != 0) { + lines.push_back(line); + } + } + } + lines.push_back(mapping); + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + std::ofstream out(path, std::ios::trunc); + for (const auto& line : lines) { + out << line << '\n'; + } +} + +void FinishWizard() { + const std::string guid = GuidString(g_wizard.instance); + const std::string mapping = BuildMappingString(); + if (SDL_AddGamepadMapping(mapping.c_str()) < 0) { + g_wizard.status = std::string("Failed to apply mapping: ") + SDL_GetError(); + RT_LOG(RT_TAG_CONFIG) << "controller wizard: " << g_wizard.status << " (" << mapping << ")" + << std::endl; + return; + } + PersistMapping(guid, mapping); + RT_LOG(RT_TAG_CONFIG) << "controller wizard: applied mapping " << mapping << std::endl; + StopWizard(); +} + +struct SetupCandidate { + SDL_JoystickID id; + std::string name; + bool incompleteMapping; +}; + +// A device needs setup when SDL has no gamepad mapping for it at all, or when +// the mapping it matched has no analog stick even though the hardware reports +// axes (SDL's built-in raphnet WUSBMote entry is button-only). +std::vector CollectCandidates() { + std::vector candidates; + int count = 0; + SDL_JoystickID* ids = SDL_GetJoysticks(&count); + if (ids == nullptr) { + return candidates; + } + for (int i = 0; i < count; ++i) { + const SDL_JoystickID id = ids[i]; + const char* rawName = SDL_GetJoystickNameForID(id); + const std::string name = rawName != nullptr ? rawName : "Unknown controller"; + if (!SDL_IsGamepad(id)) { + candidates.push_back({id, name, false}); + continue; + } + SDL_Gamepad* gamepad = SDL_GetGamepadFromID(id); + if (gamepad == nullptr) { + continue; + } + char* mapping = SDL_GetGamepadMappingForID(id); + if (mapping == nullptr) { + continue; + } + const std::string mappingStr = mapping; + SDL_free(mapping); + const bool hasStick = mappingStr.find("leftx:") != std::string::npos && + mappingStr.find("lefty:") != std::string::npos; + SDL_Joystick* joystick = SDL_GetGamepadJoystick(gamepad); + if (!hasStick && joystick != nullptr && SDL_GetNumJoystickAxes(joystick) >= 2) { + candidates.push_back({id, name, true}); + } + } + SDL_free(ids); + return candidates; +} + +void HandleButtonDown(const SDL_JoyButtonEvent& event) { + const Step& step = kSteps[g_wizard.stepIndex]; + if (step.kind == StepKind::Stick) { + return; + } + const std::string value = "b" + std::to_string(event.button); + if (BindingUsed(value)) { + g_wizard.status = "That button is already bound"; + return; + } + g_wizard.status.clear(); + AdvanceStep(value); +} + +void HandleHatMotion(const SDL_JoyHatEvent& event) { + const Step& step = kSteps[g_wizard.stepIndex]; + if (step.kind == StepKind::Stick) { + return; + } + // Only single-direction presses bind cleanly; diagonals are ignored. + if (event.value != SDL_HAT_UP && event.value != SDL_HAT_RIGHT && event.value != SDL_HAT_DOWN && + event.value != SDL_HAT_LEFT) { + return; + } + const std::string value = + "h" + std::to_string(event.hat) + "." + std::to_string(static_cast(event.value)); + if (BindingUsed(value)) { + g_wizard.status = "That direction is already bound"; + return; + } + g_wizard.status.clear(); + AdvanceStep(value); +} + +void HandleAxisMotion(const SDL_JoyAxisEvent& event) { + const Step& step = kSteps[g_wizard.stepIndex]; + if (step.kind == StepKind::Button) { + return; + } + if (event.axis >= g_wizard.axisBaseline.size()) { + return; + } + const int32_t delta = + static_cast(event.value) - static_cast(g_wizard.axisBaseline[event.axis]); + const int16_t threshold = step.kind == StepKind::Stick ? kStickThreshold : kTriggerThreshold; + if (std::abs(delta) < threshold) { + return; + } + // Stick prompts ask for LEFT/UP, which SDL expects to be negative; triggers + // are expected to increase when pulled. A wrong-way delta means the raw + // axis is inverted, which the mapping expresses with a '~' suffix. + const bool expectNegative = step.kind == StepKind::Stick; + const bool inverted = expectNegative ? delta > 0 : delta < 0; + std::string value = "a" + std::to_string(event.axis); + // Reject reusing an axis already bound (with or without inversion). + if (BindingUsed(value) || BindingUsed(value + "~")) { + g_wizard.status = "That axis is already bound"; + return; + } + if (inverted) { + value += "~"; + } + g_wizard.status.clear(); + AdvanceStep(value); +} + +} // namespace + +void LoadPersistedMappings() { + std::ifstream in(MappingDbPath()); + if (!in) { + return; + } + std::string line; + int added = 0; + while (std::getline(in, line)) { + const std::string trimmed = RuntimeConfigFile::Trim(line); + if (trimmed.empty() || trimmed[0] == '#') { + continue; + } + if (SDL_AddGamepadMapping(trimmed.c_str()) >= 0) { + ++added; + } else { + RT_LOG(RT_TAG_CONFIG) << "gamecontrollerdb.txt: rejected mapping: " << trimmed + << " (" << SDL_GetError() << ")" << std::endl; + } + } + if (added > 0) { + RT_LOG(RT_TAG_CONFIG) << "gamecontrollerdb.txt: applied " << added << " custom mapping" + << (added == 1 ? "" : "s") << std::endl; + } +} + +void HandleSdlEvent(const SDL_Event& event) { + if (!g_wizard.active) { + return; + } + if (event.type == SDL_EVENT_JOYSTICK_REMOVED && event.jdevice.which == g_wizard.instance) { + StopWizard(); + return; + } + if (event.type == SDL_EVENT_KEY_DOWN && event.key.scancode == SDL_SCANCODE_ESCAPE) { + StopWizard(); + return; + } + if (g_wizard.stepIndex >= kSteps.size() || Clock::now() < g_wizard.acceptAfter) { + return; + } + switch (event.type) { + case SDL_EVENT_JOYSTICK_BUTTON_DOWN: + if (event.jbutton.which == g_wizard.instance) { + HandleButtonDown(event.jbutton); + } + break; + case SDL_EVENT_JOYSTICK_HAT_MOTION: + if (event.jhat.which == g_wizard.instance) { + HandleHatMotion(event.jhat); + } + break; + case SDL_EVENT_JOYSTICK_AXIS_MOTION: + if (event.jaxis.which == g_wizard.instance) { + HandleAxisMotion(event.jaxis); + } + break; + default: + break; + } +} + +void DrawSetupList() { + const std::vector candidates = CollectCandidates(); + if (candidates.empty()) { + return; + } + ImGui::SeparatorText("Unrecognized controllers"); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); + ImGui::TextDisabled( + "These devices have no usable gamepad mapping. Set one up by pressing " + "each control when asked."); + ImGui::PopTextWrapPos(); + for (const auto& candidate : candidates) { + ImGui::PushID(static_cast(candidate.id)); + ImGui::TextUnformatted(candidate.name.c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton(candidate.incompleteMapping ? "Fix mapping" : "Set up")) { + StartWizard(candidate.id); + } + if (candidate.incompleteMapping && ImGui::IsItemHovered()) { + ImGui::SetTooltip("SDL matched a mapping without an analog stick for this device"); + } + ImGui::PopID(); + } +} + +void Draw() { + if (!g_wizard.active) { + return; + } + const ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + viewport->Size.x * 0.5f, + viewport->Pos.y + viewport->Size.y * 0.5f), + ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(420.0f, 0.0f), ImGuiCond_Appearing); + bool open = true; + if (ImGui::Begin("Controller setup", &open, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings)) { + ImGui::TextUnformatted(g_wizard.deviceName.c_str()); + ImGui::Separator(); + if (g_wizard.stepIndex < kSteps.size()) { + ImGui::Text("Step %d of %d", static_cast(g_wizard.stepIndex + 1), + static_cast(kSteps.size())); + ImGui::Spacing(); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 400.0f); + ImGui::TextUnformatted(kSteps[g_wizard.stepIndex].prompt); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + if (ImGui::Button("Skip")) { + g_wizard.status.clear(); + AdvanceStep(std::nullopt); + } + ImGui::SameLine(); + ImGui::BeginDisabled(g_wizard.stepIndex == 0); + if (ImGui::Button("Back")) { + --g_wizard.stepIndex; + g_wizard.bindings[g_wizard.stepIndex].reset(); + g_wizard.status.clear(); + g_wizard.acceptAfter = Clock::now() + kCaptureDebounce; + SnapshotAxes(); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + open = false; + } + } else { + const size_t boundCount = + std::count_if(g_wizard.bindings.begin(), g_wizard.bindings.end(), + [](const std::optional& b) { return b.has_value(); }); + ImGui::Text("Captured %d of %d controls.", static_cast(boundCount), + static_cast(kSteps.size())); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 400.0f); + ImGui::TextDisabled("Save applies the mapping now and remembers it for future launches."); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + ImGui::BeginDisabled(boundCount == 0); + if (ImGui::Button("Save")) { + FinishWizard(); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Back")) { + --g_wizard.stepIndex; + g_wizard.bindings[g_wizard.stepIndex].reset(); + g_wizard.acceptAfter = Clock::now() + kCaptureDebounce; + SnapshotAxes(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + open = false; + } + } + if (!g_wizard.status.empty()) { + ImGui::Spacing(); + ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.3f, 1.0f), "%s", g_wizard.status.c_str()); + } + } + ImGui::End(); + if (!open && g_wizard.active) { + StopWizard(); + } +} + +bool IsActive() { return g_wizard.active; } + +} // namespace controller_mapping_wizard diff --git a/runtime/src/settings_overlay.cpp b/runtime/src/settings_overlay.cpp index b269604..8986aac 100644 --- a/runtime/src/settings_overlay.cpp +++ b/runtime/src/settings_overlay.cpp @@ -1,5 +1,6 @@ #include "settings_overlay.h" #include "audio_backend.h" +#include "controller_mapping_wizard.h" #include "game_graphics_options.h" #include "music_attenuation.h" #include "runtime_config.h" @@ -320,6 +321,7 @@ void DrawControllerSettings() { } ImGui::Separator(); + controller_mapping_wizard::DrawSetupList(); const uint32_t controllerCount = PADCount(); if (controllerCount == 0) { ImGui::TextDisabled("No controller connected"); @@ -840,6 +842,7 @@ void PersistDisplayModeIfChanged() { } // namespace void InitializeRuntimeSettings() noexcept { + controller_mapping_wizard::LoadPersistedMappings(); ApplyConfiguredMappings(); AudioBackend::Instance().SetMasterVolume(static_cast(g_audioVolumePercent) / 100.0f); AudioBackend::Instance().SetMuted(g_audioMuted); @@ -872,6 +875,7 @@ void HandleEvents(const AuroraEvent* events) noexcept { if (ev->type != AURORA_SDL_EVENT) { continue; } + controller_mapping_wizard::HandleSdlEvent(ev->sdl); if (IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) { SetTopBarVisible(!g_topBarVisible); } @@ -893,6 +897,10 @@ void Draw() noexcept { } DrawFpsOverlay(); DrawTopBar(); + controller_mapping_wizard::Draw(); + // The wizard captures raw presses; keep them out of the game even when the + // top bar is hidden mid-setup. + PADBlockInput(g_topBarVisible || controller_mapping_wizard::IsActive()); DrawStartupScreen(); } From dbf6d05625709ec12ff6afd5550d5772e0747cea Mon Sep 17 00:00:00 2001 From: patchzyy <64382339+patchzyy@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:50:20 +0200 Subject: [PATCH 2/2] SDL cache + output stream --- aurora-main/lib/input.cpp | 55 +++++++++++++++++------ runtime/src/controller_mapping_wizard.cpp | 13 +++++- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/aurora-main/lib/input.cpp b/aurora-main/lib/input.cpp index bf7489b..8884f1a 100644 --- a/aurora-main/lib/input.cpp +++ b/aurora-main/lib/input.cpp @@ -255,6 +255,18 @@ IdentityMatch identity_match(const ControllerIdentity& saved, const ControllerId : IdentityMatch::None; } +void assign_player_index(GameController& controller, int32_t port) { + SDL_SetGamepadPlayerIndex(controller.m_controller, port); + controller.m_playerIndex = port; +} + +// SDL forgets the index for devices mapped after connect, so player_index() falls +// back to the cached copy; both have to move together or a port looks doubly taken. +int32_t effective_player_index(const GameController& controller) { + const int32_t player = SDL_GetGamepadPlayerIndex(controller.m_controller); + return player >= 0 ? player : controller.m_playerIndex; +} + bool is_instance_claimed(const std::array& claimedControllers, size_t claimedCount, Uint32 instance) { return std::find(claimedControllers.begin(), claimedControllers.begin() + claimedCount, instance) != @@ -269,10 +281,10 @@ void apply_port_preferences() noexcept { } for (auto& [instance, controller] : g_GameControllers) { - const int32_t player = SDL_GetGamepadPlayerIndex(controller.m_controller); + const int32_t player = effective_player_index(controller); if (player >= 0 && player < PAD_MAX_CONTROLLERS && g_portPreferences[player].state != PortPreferenceState::Unset) { // Keep SDL's default player assignment from taking explicitly configured ports - SDL_SetGamepadPlayerIndex(controller.m_controller, -1); + assign_player_index(controller, -1); } } @@ -293,7 +305,7 @@ void apply_port_preferences() noexcept { switch (identity_match(preference.identity, controller_identity(controller))) { case IdentityMatch::Exact: - SDL_SetGamepadPlayerIndex(controller.m_controller, static_cast(port)); + assign_player_index(controller, static_cast(port)); claimedControllers[claimedCount++] = instance; fallbackController = nullptr; break; @@ -311,7 +323,7 @@ void apply_port_preferences() noexcept { } if (fallbackController != nullptr) { - SDL_SetGamepadPlayerIndex(fallbackController->m_controller, static_cast(port)); + assign_player_index(*fallbackController, static_cast(port)); claimedControllers[claimedCount++] = fallbackInstance; } } @@ -320,19 +332,34 @@ void apply_port_preferences() noexcept { // SDL only hands out a player index when the device already had a gamepad mapping // at connect time, so anything mapped later (the setup wizard) stays at -1. void ensure_player_index(GameController& controller) noexcept { - if (SDL_GetGamepadPlayerIndex(controller.m_controller) >= 0) { + const int32_t player = SDL_GetGamepadPlayerIndex(controller.m_controller); + if (player >= 0) { + controller.m_playerIndex = player; return; } - for (int32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) { - const bool taken = std::any_of(g_GameControllers.begin(), g_GameControllers.end(), [&](const auto& entry) { - return entry.second.m_controller != controller.m_controller && - SDL_GetGamepadPlayerIndex(entry.second.m_controller) == port; - }); - if (!taken) { - SDL_SetGamepadPlayerIndex(controller.m_controller, port); - controller.m_playerIndex = port; - return; + if (controller.m_playerIndex >= 0) { + return; + } + ensure_port_preferences_loaded(); + const auto claim = [&](bool skipConfiguredPorts) { + for (int32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) { + if (skipConfiguredPorts && g_portPreferences[port].state != PortPreferenceState::Unset) { + continue; + } + const bool taken = std::any_of(g_GameControllers.begin(), g_GameControllers.end(), [&](const auto& entry) { + return entry.second.m_controller != controller.m_controller && effective_player_index(entry.second) == port; + }); + if (!taken) { + assign_player_index(controller, port); + return true; + } } + return false; + }; + // Explicitly configured ports are only used as a last resort so a hot-plugged + // controller cannot steal the port its preferred device will claim. + if (!claim(true)) { + claim(false); } } } // namespace diff --git a/runtime/src/controller_mapping_wizard.cpp b/runtime/src/controller_mapping_wizard.cpp index 6d84e23..d88a7ec 100644 --- a/runtime/src/controller_mapping_wizard.cpp +++ b/runtime/src/controller_mapping_wizard.cpp @@ -150,7 +150,7 @@ std::string BuildMappingString() { return mapping; } -void PersistMapping(const std::string& guid, const std::string& mapping) { +bool PersistMapping(const std::string& guid, const std::string& mapping) { const std::filesystem::path path = MappingDbPath(); std::vector lines; { @@ -166,9 +166,14 @@ void PersistMapping(const std::string& guid, const std::string& mapping) { std::error_code ec; std::filesystem::create_directories(path.parent_path(), ec); std::ofstream out(path, std::ios::trunc); + if (!out) { + return false; + } for (const auto& line : lines) { out << line << '\n'; } + out.close(); + return static_cast(out); } void FinishWizard() { @@ -180,7 +185,11 @@ void FinishWizard() { << std::endl; return; } - PersistMapping(guid, mapping); + if (!PersistMapping(guid, mapping)) { + g_wizard.status = "Failed to save mapping to " + MappingDbPath().string(); + RT_LOG(RT_TAG_CONFIG) << "controller wizard: " << g_wizard.status << std::endl; + return; + } RT_LOG(RT_TAG_CONFIG) << "controller wizard: applied mapping " << mapping << std::endl; StopWizard(); }