diff --git a/README.md b/README.md index 895bf53..9e7267b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,18 @@ cmake --build build/release --config Release --target SteamlessController The Steam Controller exposes a vendor HID collection (usage page `0xFF00`) that carries all game input in a 54-byte report (ID `0x42`) at ~60 Hz. By default the firmware runs in **lizard mode**, emulating a keyboard and mouse so the controller works without drivers. +### Keeping Steam running + +Steam normally opens the physical Steam Controller even while the client is idle. That can produce duplicate keyboard/mouse and virtual-gamepad input if SteamlessController uses the controller at the same time. + +Steam's per-device controller blacklist is the narrow workaround: it leaves the Steam client and Steam Input for other controller types running, while reserving Steam Controllers for SteamlessController. Exit Steam, back up and open `config/config.vdf` under the Steam install directory, then add this property inside the outer `InstallConfigStore` object and restart Steam: + +```vdf +"controller_blacklist" "28de/1302,28de/1303,28de/1304" +``` + +SteamlessController verifies both the setting and Steam's current `logs/controller.txt` session before treating this as safe. If Steam has not loaded the blacklist, it still yields rather than risk duplicate input. Steam games will receive SteamlessController's virtual Xbox controller instead of the physical Steam Controller, so Steam Controller-specific Steam Input layouts, gyro, and touch menus are unavailable until the entry is removed and Steam is restarted. + SteamlessController sends HID feature reports to disable lizard mode, then reads the raw input reports and translates them into a virtual Xbox 360 controller via ViGEmBus. A background heartbeat re-sends the disable command every 800 ms to keep lizard mode off. The full input report layout is documented in [`src/steam/SteamController.h`](src/steam/SteamController.h). diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index 85abc8a..e227253 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -243,11 +243,11 @@ void ControllerManager::ReleaseDevices() { NotifyStateChanged(); } -void ControllerManager::SetSteamPresent(bool present) { - if (m_steamPresent == present) return; - m_steamPresent = present; +void ControllerManager::SetSteamMayOwnController(bool mayOwn) { + if (m_steamMayOwnController == mayOwn) return; + m_steamMayOwnController = mayOwn; - if (!present) { + if (!mayOwn) { m_steamConflictAlertShown = false; return; } @@ -415,8 +415,8 @@ void ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut) { // whichever mapper starts second can produce duplicate input or starve // the other reader. The Windows-only holder is benign, so shared access // remains the compatibility path while Steam is absent. - if (m_steamPresent) { - EventLog::Write("GAMEMODE: shared access blocked while Steam is running %ls", + if (m_steamMayOwnController) { + EventLog::Write("GAMEMODE: shared access blocked because Steam may own %ls", slot.path.c_str()); NotifySteamConflict(); return; @@ -482,7 +482,8 @@ void ControllerManager::NotifySteamConflict() { m_alertFn(L"Steam is using the controller", L"Windows only granted shared controller access while Steam is running. " L"Steamless Mode was kept off to prevent duplicate or missing input. " - L"Exit Steam completely, then enable Steamless Mode again."); + L"Exit Steam, or add the Steam Controller to Steam's controller_blacklist " + L"and restart Steam."); } } diff --git a/src/app/ControllerManager.h b/src/app/ControllerManager.h index 090bec4..5fc0cf5 100644 --- a/src/app/ControllerManager.h +++ b/src/app/ControllerManager.h @@ -38,9 +38,9 @@ public: // fallback if this fails or the process dies harder than a C++ exception. void EmergencyRestoreAll() noexcept; - // Shared HID access is only safe while Steam is absent. If Steam appears - // during a shared session, yield before both mappers can emit input. - void SetSteamPresent(bool present); + // Shared HID access is only safe while Steam cannot see the physical + // controller. If that changes, yield before both mappers can emit input. + void SetSteamMayOwnController(bool mayOwn); void SetTrackpadMouseEnabled(bool enabled); void SetUseLeftTrackpad(bool enabled); @@ -80,7 +80,7 @@ private: bool m_trackpadMouseEnabled = false; bool m_useLeftTrackpad = false; bool m_backButtonsEnabled = false; - bool m_steamPresent = false; + bool m_steamMayOwnController = false; bool m_steamConflictAlertShown = false; ControllerPlatform m_controllerPlatform = ControllerPlatform::Xbox; BackButtonConfig m_backConfig; diff --git a/src/app/SteamWatcher.cpp b/src/app/SteamWatcher.cpp index 92a8bad..da8f588 100644 --- a/src/app/SteamWatcher.cpp +++ b/src/app/SteamWatcher.cpp @@ -1,28 +1,176 @@ #include "SteamWatcher.h" +#include "EventLog.h" #include #include +#include +#include +#include static constexpr int POLL_INTERVAL_MS = 2000; static constexpr int POLL_SLICE_MS = 100; // wake often for fast Stop() static constexpr int LESS_STEAM_POLL_COUNT = 3; // ~6s stable before we take over -static bool IsSteamProcessRunning() { +struct SteamProcess { + bool running = false; + FILETIME started{}; +}; + +static SteamProcess FindSteamProcess() { HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (snap == INVALID_HANDLE_VALUE) return false; + if (snap == INVALID_HANDLE_VALUE) return {}; PROCESSENTRY32W pe{}; pe.dwSize = sizeof(pe); - bool found = false; + DWORD pid = 0; if (Process32FirstW(snap, &pe)) { do { if (_wcsicmp(pe.szExeFile, L"steam.exe") == 0) { - found = true; + pid = pe.th32ProcessID; break; } } while (Process32NextW(snap, &pe)); } CloseHandle(snap); - return found; + if (pid == 0) return {}; + + SteamProcess result{}; + result.running = true; + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!process) return result; + + FILETIME exited{}, kernel{}, user{}; + GetProcessTimes(process, &result.started, &exited, &kernel, &user); + CloseHandle(process); + return result; +} + +static bool ReadRegistryString(HKEY root, const wchar_t* subkey, const wchar_t* name, + std::wstring& value) { + DWORD bytes = 0; + if (RegGetValueW(root, subkey, name, RRF_RT_REG_SZ, nullptr, nullptr, &bytes) + != ERROR_SUCCESS || bytes < sizeof(wchar_t)) + return false; + + value.resize(bytes / sizeof(wchar_t)); + if (RegGetValueW(root, subkey, name, RRF_RT_REG_SZ, nullptr, value.data(), &bytes) + != ERROR_SUCCESS) + return false; + while (!value.empty() && value.back() == L'\0') value.pop_back(); + return !value.empty(); +} + +static bool ReadFileTail(const std::wstring& path, size_t maxBytes, std::string& text, + FILETIME* lastWrite = nullptr) { + HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) return false; + + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file, &size) || size.QuadPart < 0) { + CloseHandle(file); + return false; + } + if (lastWrite && !GetFileTime(file, nullptr, nullptr, lastWrite)) { + CloseHandle(file); + return false; + } + + const ULONGLONG fileBytes = static_cast(size.QuadPart); + const DWORD bytes = static_cast(std::min(fileBytes, maxBytes)); + LARGE_INTEGER offset{}; + offset.QuadPart = size.QuadPart - bytes; + if (!SetFilePointerEx(file, offset, nullptr, FILE_BEGIN)) { + CloseHandle(file); + return false; + } + + text.resize(bytes); + DWORD read = 0; + const bool ok = bytes == 0 || ReadFile(file, text.data(), bytes, &read, nullptr); + CloseHandle(file); + if (!ok) return false; + text.resize(read); + return true; +} + +static void LowerAscii(std::string& text) { + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); +} + +static bool ReportControllerHidden(bool hidden, const char* reason) { + static std::string previous; + const std::string current = std::string(hidden ? "hidden: " : "not hidden: ") + reason; + if (current != previous) { + EventLog::Write("STEAM: physical controller %s", current.c_str()); + previous = current; + } + return hidden; +} + +static std::string ControllerBlacklistValue(const std::string& config) { + constexpr char key[] = "\"controller_blacklist\""; + const size_t keyPos = config.rfind(key); + if (keyPos == std::string::npos) return {}; + const size_t open = config.find('"', keyPos + sizeof(key) - 1); + if (open == std::string::npos) return {}; + const size_t close = config.find('"', open + 1); + if (close == std::string::npos) return {}; + return config.substr(open + 1, close - open - 1); +} + +// Steam writes an explicit line for every device suppressed by +// controller_blacklist. Requiring that line from the current client session is +// important: editing config.vdf while Steam is already running does not revoke +// the handles Steam has open. Any missing or ambiguous evidence fails closed. +static bool IsControllerHiddenFromSteam(const SteamProcess& steam) { + if (!steam.running || (steam.started.dwLowDateTime == 0 && steam.started.dwHighDateTime == 0)) + return ReportControllerHidden(false, "process start time unavailable"); + + std::wstring steamPath; + if (!ReadRegistryString(HKEY_CURRENT_USER, L"Software\\Valve\\Steam", L"SteamPath", + steamPath)) + return ReportControllerHidden(false, "SteamPath unavailable"); + std::replace(steamPath.begin(), steamPath.end(), L'/', L'\\'); + + std::string config; + const std::wstring configPath = steamPath + L"\\config\\config.vdf"; + if (!ReadFileTail(configPath, 1024 * 1024, config)) + return ReportControllerHidden(false, "config.vdf unavailable"); + LowerAscii(config); + const std::string blacklist = ControllerBlacklistValue(config); + if (blacklist.empty()) + return ReportControllerHidden(false, "controller_blacklist missing"); + + FILETIME logWrite{}; + std::string log; + if (!ReadFileTail(steamPath + L"\\logs\\controller.txt", 4 * 1024 * 1024, log, + &logWrite) + || CompareFileTime(&logWrite, &steam.started) < 0) + return ReportControllerHidden(false, "current controller log unavailable"); + LowerAscii(log); + + const size_t sessionPos = log.rfind("client version:"); + if (sessionPos == std::string::npos) + return ReportControllerHidden(false, "current controller session unavailable"); + const std::string session = log.substr(sessionPos); + + constexpr const char* ids[] = {"28de/1302", "28de/1303", "28de/1304"}; + bool hidden = false; + for (const char* id : ids) { + std::string type(id); + std::replace(type.begin(), type.end(), '/', ' '); + if (session.find("type: " + type) != std::string::npos) + return ReportControllerHidden(false, "Steam opened a physical controller"); + if (blacklist.find(id) != std::string::npos + && session.find(std::string("hiding blacklisted device ") + id) + != std::string::npos) + hidden = true; + } + return ReportControllerHidden(hidden, hidden ? "confirmed by current Steam session" + : "no matching hide event"); } // Steam publishes the app id of the running game here and resets it to 0 on @@ -39,7 +187,9 @@ static bool IsGameRunning() { } SteamState SteamWatcher::Detect() { - if (!IsSteamProcessRunning()) return SteamState::NoSteam; + const SteamProcess steam = FindSteamProcess(); + if (!steam.running) return SteamState::NoSteam; + if (IsControllerHiddenFromSteam(steam)) return SteamState::ControllerHidden; return IsGameRunning() ? SteamState::InGame : SteamState::SteamIdle; } diff --git a/src/app/SteamWatcher.h b/src/app/SteamWatcher.h index 29496b1..8853d2b 100644 --- a/src/app/SteamWatcher.h +++ b/src/app/SteamWatcher.h @@ -8,9 +8,10 @@ // reported immediately (yield the controller fast), transitions to a LOWER // value are debounced (don't grab it during a Steam restart or game relaunch). enum class SteamState { - NoSteam = 0, // steam.exe not running - SteamIdle = 1, // Steam running, no game active (RunningAppID == 0) - InGame = 2, // Steam running a game + NoSteam = 0, // steam.exe not running + ControllerHidden = 1, // Steam is running but its active session ignored the controller + SteamIdle = 2, // Steam running, no game active (RunningAppID == 0) + InGame = 3, // Steam running a game }; // Watches the Steam process and its RunningAppID registry value, reporting diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 67ec5b4..2d8a1b5 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -232,9 +232,10 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { case WM_STEAMSTATE: { const auto steamState = static_cast(wp); // Outside ApplySteamState, which returns early in Manual mode. A shared - // manual session must still yield if Steam starts, and a manual enable - // must not settle for shared access while Steam is already present. - m_controller->SetSteamPresent(steamState != SteamState::NoSteam); + // manual session must still yield if Steam can see the controller, and + // a manual enable must not settle for unsafe shared access. + m_controller->SetSteamMayOwnController(steamState == SteamState::SteamIdle + || steamState == SteamState::InGame); ApplySteamState(steamState); return 0; } @@ -293,8 +294,10 @@ void TrayApp::SetAutoMode(AutoMode mode) { bool TrayApp::WantControl(SteamState state) const { switch (m_autoMode) { - case AutoMode::OffWhileSteam: return state == SteamState::NoSteam; - case AutoMode::OffOnlyInGame: return state != SteamState::InGame; + case AutoMode::OffWhileSteam: + return state == SteamState::NoSteam || state == SteamState::ControllerHidden; + case AutoMode::OffOnlyInGame: + return state != SteamState::InGame; default: return false; // Manual: tray toggle decides } } @@ -302,7 +305,7 @@ bool TrayApp::WantControl(SteamState state) const { void TrayApp::ApplySteamState(SteamState state) { if (m_autoMode == AutoMode::Manual) return; - EventLog::Write("AUTO: steam state %d (0=none 1=idle 2=inGame) -> %s", + EventLog::Write("AUTO: steam state %d (0=none 1=controllerHidden 2=idle 3=inGame) -> %s", static_cast(state), WantControl(state) ? "take control" : "yield"); if (WantControl(state)) { @@ -318,7 +321,8 @@ void TrayApp::ApplySteamState(SteamState state) { // Steam only (re)opens controllers on device-arrival events. If it is // already running and we held the device, it never saw one — cycle // the device so Steam adopts it immediately. - if (hadControl && state != SteamState::NoSteam) + if (hadControl && state != SteamState::NoSteam + && state != SteamState::ControllerHidden) RestartControllerDevices(); } }