mirror of
https://github.com/ddeverill/SteamlessController.git
synced 2026-10-21 22:22:16 -05:00
251 lines
9.3 KiB
C++
251 lines
9.3 KiB
C++
#include "SteamWatcher.h"
|
|
#include "EventLog.h"
|
|
#include <Windows.h>
|
|
#include <TlHelp32.h>
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <string>
|
|
|
|
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
|
|
|
|
struct SteamProcess {
|
|
bool running = false;
|
|
FILETIME started{};
|
|
};
|
|
|
|
static SteamProcess FindSteamProcess() {
|
|
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
|
if (snap == INVALID_HANDLE_VALUE) return {};
|
|
|
|
PROCESSENTRY32W pe{};
|
|
pe.dwSize = sizeof(pe);
|
|
DWORD pid = 0;
|
|
if (Process32FirstW(snap, &pe)) {
|
|
do {
|
|
if (_wcsicmp(pe.szExeFile, L"steam.exe") == 0) {
|
|
pid = pe.th32ProcessID;
|
|
break;
|
|
}
|
|
} while (Process32NextW(snap, &pe));
|
|
}
|
|
CloseHandle(snap);
|
|
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<ULONGLONG>(size.QuadPart);
|
|
const DWORD bytes = static_cast<DWORD>(std::min<ULONGLONG>(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<char>(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
|
|
// exit. Covers Steam games and non-Steam shortcuts launched through Steam;
|
|
// games launched entirely outside Steam are invisible to it (acceptable — we
|
|
// keep the controller, and the manual toggle remains the escape hatch).
|
|
static bool IsGameRunning() {
|
|
DWORD appId = 0;
|
|
DWORD size = sizeof(appId);
|
|
if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\Valve\\Steam", L"RunningAppID",
|
|
RRF_RT_REG_DWORD, nullptr, &appId, &size) != ERROR_SUCCESS)
|
|
return false;
|
|
return appId != 0;
|
|
}
|
|
|
|
SteamState SteamWatcher::Detect() {
|
|
const SteamProcess steam = FindSteamProcess();
|
|
if (!steam.running) return SteamState::NoSteam;
|
|
if (IsControllerHiddenFromSteam(steam)) return SteamState::ControllerHidden;
|
|
return IsGameRunning() ? SteamState::InGame : SteamState::SteamIdle;
|
|
}
|
|
|
|
void SteamWatcher::Start(SteamStateFn onChange) {
|
|
Stop();
|
|
m_onChange = std::move(onChange);
|
|
m_running = true;
|
|
m_thread = std::thread(&SteamWatcher::PollLoop, this);
|
|
}
|
|
|
|
void SteamWatcher::Stop() {
|
|
if (m_running.exchange(false) && m_thread.joinable())
|
|
m_thread.join();
|
|
}
|
|
|
|
void SteamWatcher::PollLoop() {
|
|
SteamState reported = Detect();
|
|
m_state = reported;
|
|
if (m_onChange) m_onChange(reported);
|
|
|
|
SteamState pending = reported;
|
|
int pendingPolls = 0;
|
|
|
|
while (m_running.load()) {
|
|
for (int waited = 0; waited < POLL_INTERVAL_MS && m_running.load();
|
|
waited += POLL_SLICE_MS)
|
|
Sleep(POLL_SLICE_MS);
|
|
if (!m_running.load()) break;
|
|
|
|
const SteamState now = Detect();
|
|
if (now == reported) {
|
|
pendingPolls = 0;
|
|
continue;
|
|
}
|
|
|
|
if (static_cast<int>(now) > static_cast<int>(reported)) {
|
|
// More Steam activity (Steam appeared / game launched) — report
|
|
// immediately so the controller is yielded before Steam needs it.
|
|
reported = now;
|
|
pendingPolls = 0;
|
|
m_state = reported;
|
|
if (m_onChange) m_onChange(reported);
|
|
} else {
|
|
// Less Steam activity (game quit / Steam gone) — must hold for
|
|
// several consecutive polls so a Steam self-restart or game
|
|
// relaunch doesn't cause ownership flapping.
|
|
if (now != pending) {
|
|
pending = now;
|
|
pendingPolls = 1;
|
|
} else if (++pendingPolls >= LESS_STEAM_POLL_COUNT) {
|
|
reported = now;
|
|
pendingPolls = 0;
|
|
m_state = reported;
|
|
if (m_onChange) m_onChange(reported);
|
|
}
|
|
}
|
|
}
|
|
}
|