Add per-game control profiles

Everything about how the controller behaves — paddle bindings, both
trackpads, and which kind of pad the game sees — is now configured in one
window and saved per game, rather than spread across tray checkboxes that
applied everywhere at once.

Installed games come from the Start Menu and the packaged-app list. Steam
does not shortcut its library with .lnk files, so its games are read from
the .url shortcuts it does write; Xbox and Store titles register no shortcut
file at all and come from the shell's app list instead. None of these
sources can tell a game from any other program, so the picker searches
rather than scrolls — a saved profile is pinned, everything else is reached
by typing.

Trackpads are configured per pad. The previous pair of toggles could only
ever describe one pad doing one thing, because a single TrackpadMouse
instance was switched between pads by a bool; there are two now. A pad can
move the pointer, scroll, feed the DS4 touchpad, or do nothing, and its
click is an ordinary binding dispatched down the same path as the paddles —
it used to be hardwired to a left click that only fired while that pad drove
the mouse.

Key bindings can carry Ctrl, Alt, Shift and Win. The modifiers ride in the
free top byte of the packed binding, so values written by earlier builds
decode unchanged. Windows is chosen from a toggle rather than pressed: the
Start menu opens before the page is told anything, which is what made that
key unbindable before. Choosing modifiers rather than typing them also keeps
combinations like Alt+Tab from being claimed by the system mid-capture.
Ctrl+Alt+Delete is refused outright, being the Secure Attention Sequence
that no ordinary process can send.

Profiles follow the foreground application, and nothing polls to do it. An
unelevated process cannot subscribe to process-start events — the WMI class
that reports them requires administrator rights, as does an ETW kernel
session — but SetWinEventHook reports foreground changes for the cost of one
hook and no thread. Focus is also the better question than "is it running":
a launcher that starts a game and stays resident answers yes long after the
game has exited. Steam profiles are keyed by app id, which a foreground
window cannot supply, so Steam's manifests are read to map that id to an
install directory; a game's launcher, its anti-cheat service and the game
itself all live under it and all resolve to the same profile.

Auto Mode still decides whether the controller is ours at all, so a profile
only loads while we hold the pad. A balloon names the profile when one
loads, under the notification setting, because a profile that quietly fails
to match is otherwise invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dylan DeverillandClaude Opus 5 committed 2026-08-13 16:53:55 -07:00
1 parent 838cd7ae68
commit a087c236b2
27 files changed
+2780 -290

No files matched your search

+53
View File
@@ -48,6 +48,53 @@ if (MSVC)
target_compile_options(RawControllerProbe PRIVATE /W4 /utf-8)
endif()
# Game-library diagnostic probe — dumps what the Start Menu walk finds
add_executable(GameLibraryProbe
src/probe/GameLibraryProbe.cpp
src/app/GameLibrary.cpp
)
target_include_directories(GameLibraryProbe PRIVATE src)
target_link_libraries(GameLibraryProbe PRIVATE ole32 shell32 shlwapi windowsapp)
if (MSVC)
target_compile_options(GameLibraryProbe PRIVATE /W4 /WX /utf-8)
set_target_properties(GameLibraryProbe PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE")
endif()
# Foreground-watcher diagnostic probe — prints what the per-game profile
# matcher would see as the user switches applications
add_executable(ForegroundProbe
src/probe/ForegroundProbe.cpp
src/app/ForegroundWatcher.cpp
)
target_include_directories(ForegroundProbe PRIVATE src)
if (MSVC)
target_compile_options(ForegroundProbe PRIVATE /W4 /WX /utf-8)
set_target_properties(ForegroundProbe PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE")
endif()
# Steam manifest diagnostic probe — dumps the app id to install directory map
# that per-game profiles for Steam games are matched through
add_executable(SteamAppProbe
src/probe/SteamAppProbe.cpp
src/app/SteamAppLocator.cpp
)
target_include_directories(SteamAppProbe PRIVATE src)
if (MSVC)
target_compile_options(SteamAppProbe PRIVATE /W4 /WX /utf-8)
set_target_properties(SteamAppProbe PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE")
endif()
# Binding encoding checks — the packed and wire forms both have to keep
# decoding values written by earlier builds
add_executable(BindingCodecProbe
src/probe/BindingCodecProbe.cpp
)
target_include_directories(BindingCodecProbe PRIVATE src)
if (MSVC)
target_compile_options(BindingCodecProbe PRIVATE /W4 /WX /utf-8)
set_target_properties(BindingCodecProbe PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE")
endif()
# Elevated on-demand helper — restarts the controller device node so the tray
# app (which never runs elevated) can take the device from a running Steam.
add_executable(SteamlessDeviceCycle WIN32
@@ -96,8 +143,12 @@ add_executable(SteamlessController WIN32
src/app/SteamWatcher.cpp
src/app/DeviceRestart.cpp
src/app/EventLog.cpp
src/app/ForegroundWatcher.cpp
src/app/GameLibrary.cpp
src/app/GameProfiles.cpp
src/app/HandleFinder.cpp
src/app/RemapWindow.cpp
src/app/SteamAppLocator.cpp
resources/app.rc
)
target_include_directories(SteamlessController PRIVATE
@@ -110,8 +161,10 @@ target_link_libraries(SteamlessController PRIVATE
${webview2_SOURCE_DIR}/build/native/${WV2_ARCH}/WebView2LoaderStatic.lib
version
ole32
shell32
shlwapi
cfgmgr32
windowsapp
)
if (MSVC)
target_compile_options(SteamlessController PRIVATE /W4 /WX /utf-8 /wd4828)
+44 -14
View File
@@ -59,10 +59,11 @@ inline BackButtonAction BackButtonActionFromId(const std::string& id) {
// from a space too large to enumerate (a keyboard virtual-key code, an extended
// mouse button).
//
// Persisted as a single DWORD, (kind << 16) | code. Every value written by
// builds that stored a bare BackButtonAction is 0-18, so its kind bits are
// already zero and it decodes as Kind::Action — old settings load unchanged
// with no migration step.
// Persisted as a single DWORD, (mods << 24) | (kind << 16) | code. Every value
// written by builds that stored a bare BackButtonAction is 0-18, so its kind
// bits are already zero and it decodes as Kind::Action; every value written
// before modifiers existed has zero in the top byte and decodes as a plain
// key — old settings load unchanged with no migration step.
struct BackButtonBinding {
enum class Kind : uint8_t {
Action = 0, // code is a BackButtonAction
@@ -74,19 +75,38 @@ struct BackButtonBinding {
// where they were first persisted.
enum class MouseButtonCode : uint16_t { Middle = 0, X1 = 1, X2 = 2, COUNT };
// Held alongside a Kind::Key binding, so a paddle can send Ctrl+Alt+K
// rather than one key. Meaningless on the other kinds and ignored there.
//
// Windows is in this set even though it cannot be captured by pressing it:
// the Start menu opens on the way, before any page sees the key. It is
// set from its toggle in the picker instead, which is the whole reason
// modifiers are a separate field rather than more virtual-key codes.
enum Modifier : uint8_t {
ModNone = 0,
ModCtrl = 1 << 0,
ModAlt = 1 << 1,
ModShift = 1 << 2,
ModWin = 1 << 3,
ModAll = ModCtrl | ModAlt | ModShift | ModWin,
};
Kind kind = Kind::Action;
uint16_t code = static_cast<uint16_t>(BackButtonAction::None);
uint8_t mods = ModNone;
static BackButtonBinding FromAction(BackButtonAction a) {
return { Kind::Action, static_cast<uint16_t>(a) };
}
static BackButtonBinding FromKey(uint16_t vk) {
return { Kind::Key, vk };
static BackButtonBinding FromKey(uint16_t vk, uint8_t modifiers = ModNone) {
return { Kind::Key, vk, static_cast<uint8_t>(modifiers & ModAll) };
}
static BackButtonBinding FromMouseButton(MouseButtonCode b) {
return { Kind::MouseButton, static_cast<uint16_t>(b) };
}
bool HasModifiers() const { return kind == Kind::Key && mods != ModNone; }
bool IsAction() const { return kind == Kind::Action; }
// The built-in action this binding names, or None when it is a key or an
@@ -101,12 +121,14 @@ struct BackButtonBinding {
}
bool operator==(const BackButtonBinding& o) const {
return kind == o.kind && code == o.code;
return kind == o.kind && code == o.code && mods == o.mods;
}
bool operator!=(const BackButtonBinding& o) const { return !(*this == o); }
uint32_t Pack() const {
return (static_cast<uint32_t>(kind) << 16) | code;
return (static_cast<uint32_t>(mods) << 24)
| (static_cast<uint32_t>(kind) << 16)
| code;
}
// Anything unrecognised — a corrupt value, or one written by a future build
@@ -114,13 +136,16 @@ struct BackButtonBinding {
static BackButtonBinding Unpack(uint32_t packed) {
const auto kind = static_cast<Kind>((packed >> 16) & 0xFF);
const auto code = static_cast<uint16_t>(packed & 0xFFFF);
// Unknown modifier bits are dropped rather than rejecting the whole
// binding: the key it names is still exactly what the user chose.
const auto mods = static_cast<uint8_t>((packed >> 24) & ModAll);
switch (kind) {
case Kind::Action:
return code < static_cast<uint16_t>(BackButtonAction::COUNT)
? BackButtonBinding{ Kind::Action, code }
: BackButtonBinding{};
case Kind::Key:
return code != 0 ? BackButtonBinding{ Kind::Key, code }
return code != 0 ? BackButtonBinding{ Kind::Key, code, mods }
: BackButtonBinding{};
case Kind::MouseButton:
return code < static_cast<uint16_t>(MouseButtonCode::COUNT)
@@ -136,7 +161,10 @@ struct BackButtonBinding {
std::string Id() const {
switch (kind) {
case Kind::Key:
return "key:" + std::to_string(code);
// Modifiers ride in the same number they occupy in the packed
// form, so an unmodified key produces exactly the string earlier
// builds did and profiles written by them still read back.
return "key:" + std::to_string((static_cast<uint32_t>(mods) << 16) | code);
case Kind::MouseButton:
switch (static_cast<MouseButtonCode>(code)) {
case MouseButtonCode::Middle: return "mouse:middle";
@@ -152,13 +180,15 @@ struct BackButtonBinding {
static BackButtonBinding FromId(const std::string& id) {
if (id.rfind("key:", 0) == 0) {
uint32_t vk = 0;
uint32_t value = 0;
for (size_t i = 4; i < id.size(); ++i) {
if (id[i] < '0' || id[i] > '9') return {};
vk = vk * 10 + static_cast<uint32_t>(id[i] - '0');
if (vk > 0xFFFF) return {};
value = value * 10 + static_cast<uint32_t>(id[i] - '0');
if (value > 0xFFFFFF) return {}; // room for the modifier byte
}
return vk != 0 ? FromKey(static_cast<uint16_t>(vk)) : BackButtonBinding{};
const auto vk = static_cast<uint16_t>(value & 0xFFFF);
const auto mods = static_cast<uint8_t>((value >> 16) & ModAll);
return vk != 0 ? FromKey(vk, mods) : BackButtonBinding{};
}
if (id == "mouse:middle") return FromMouseButton(MouseButtonCode::Middle);
if (id == "mouse:x1") return FromMouseButton(MouseButtonCode::X1);
+93 -53
View File
@@ -25,7 +25,9 @@ struct ControllerManager::Slot {
SteamController::Transport transport = SteamController::Transport::Unknown;
std::unique_ptr<SteamController> sc;
std::unique_ptr<VirtualController> vc;
TrackpadMouse trackpad;
// One per physical pad — they are configured independently.
TrackpadMouse leftPad;
TrackpadMouse rightPad;
std::thread readThread;
std::atomic<bool> readRunning{false};
bool gameModeActive = false;
@@ -37,17 +39,21 @@ struct ControllerManager::Slot {
// thread for that timeout on every one of those attempts.
std::chrono::steady_clock::time_point lastSilentAt{};
// What each paddle (L4, L5, R4, R5) is currently holding down, so leaving
// game mode mid-press releases it instead of stranding the input down.
// Records what the press actually sent rather than reading the binding
// again on release, so rebinding a paddle mid-hold still releases cleanly.
BackButtonBinding paddleHeld[4];
// What each bindable button is currently holding down, so leaving game
// mode mid-press releases it instead of stranding the input down. Records
// what the press actually sent rather than reading the binding again on
// release, so rebinding mid-hold still releases cleanly.
//
// Indices 0-3 are the paddles (L4, L5, R4, R5); 4-5 are the left and right
// pad clicks, which are ordinary bindings dispatched down this same path.
static constexpr size_t kBindableCount = 6;
BackButtonBinding paddleHeld[kBindableCount];
// Auto-repeat for held key bindings. When the next repeat is due, and the
// gap to use after that — both captured at press time from the user's
// keyboard settings, so the rate cannot shift mid-hold.
std::chrono::steady_clock::time_point paddleRepeatAt[4]{};
std::chrono::milliseconds paddleRepeatGap[4]{};
std::chrono::steady_clock::time_point paddleRepeatAt[kBindableCount]{};
std::chrono::milliseconds paddleRepeatGap[kBindableCount]{};
// Haptic edge-detection state for touch (movement ticks).
bool hapticWasRightTouching = false;
@@ -213,7 +219,8 @@ ControllerManager::~ControllerManager() {
// but without the gameModeActive guard — we always want to restore lizard mode).
if (slot->gameModeActive) {
StopReadLoop(*slot);
slot->trackpad.Reset();
slot->leftPad.Reset();
slot->rightPad.Reset();
ReleaseHeldPaddleInputs(*slot);
slot->vc.reset();
slot->gameModeActive = false;
@@ -272,26 +279,38 @@ void ControllerManager::ReleaseDevices() {
NotifyStateChanged();
}
void ControllerManager::SetTrackpadMouseEnabled(bool enabled) {
m_trackpadMouseEnabled = enabled;
for (auto& slot : m_slots) {
slot->trackpad.SetTrackpadEnabled(enabled);
if (slot->vc) slot->vc->SetTrackpadMouseClaim(enabled, m_useLeftTrackpad);
}
void ControllerManager::ApplyPadSettings(Slot& slot) {
slot.leftPad.SetPad(true);
slot.rightPad.SetPad(false);
slot.leftPad.SetMode(m_profile.leftPad.mode);
slot.rightPad.SetMode(m_profile.rightPad.mode);
slot.leftPad.SetScrollDirection(m_profile.leftPad.scrollDir);
slot.rightPad.SetScrollDirection(m_profile.rightPad.scrollDir);
// The virtual controller reads pad modes straight off the profile it is
// handed every frame, so there is nothing to push to it here.
}
void ControllerManager::SetUseLeftTrackpad(bool enabled) {
m_useLeftTrackpad = enabled;
for (auto& slot : m_slots) {
slot->trackpad.SetUseLeftTrackpad(enabled);
if (slot->vc) slot->vc->SetTrackpadMouseClaim(m_trackpadMouseEnabled, enabled);
}
}
void ControllerManager::SetProfile(const ControllerProfile& profile) {
const bool platformChanged = profile.platform != m_profile.platform;
m_profile = profile;
void ControllerManager::SetBackButtonConfig(const BackButtonConfig& cfg) {
m_backConfig = cfg;
// Live update: the ReadLoop reads m_backConfig on every frame, so the
// change takes effect on the very next report.
// The virtual pad is created as an X360 target or a DS4 one and cannot
// become the other, so a platform change is the one setting here that
// needs the controller rebuilt rather than merely re-read. Cheap to get
// wrong in the other direction, too: rebuilding on every apply would
// disconnect and reconnect the pad under the running game each time
// somebody edited a paddle binding.
if (platformChanged && IsGameModeActive()) {
DisableGameMode();
EnableGameMode(); // re-applies pad settings to each slot as it comes up
return;
}
// Live update: the ReadLoop reads m_profile on every frame, so paddle and
// pad-click changes take effect on the very next report. Movement modes
// live inside the per-slot TrackpadMouse objects, so those are pushed.
for (auto& slot : m_slots)
ApplyPadSettings(*slot);
}
// Sends the key or mouse event a binding stands for. Gamepad actions reach the
@@ -306,7 +325,16 @@ static bool SendPaddleInput(const BackButtonBinding& binding, bool down) {
switch (binding.kind) {
case BackButtonBinding::Kind::Key:
SendKeyInput(binding.code, down);
// Modifiers wrap the key: down before it so the application sees them
// already held when the key arrives, up after it so the shortcut is
// never briefly the unmodified key on the way out.
if (down) {
SendModifiers(binding.mods, true);
SendKeyInput(binding.code, true);
} else {
SendKeyInput(binding.code, false);
SendModifiers(binding.mods, false);
}
return true;
case BackButtonBinding::Kind::MouseButton: {
@@ -353,15 +381,6 @@ void ControllerManager::ReleaseHeldPaddleInputs(Slot& slot) {
}
}
void ControllerManager::SetControllerPlatform(ControllerPlatform platform) {
if (m_controllerPlatform == platform) return;
m_controllerPlatform = platform;
if (IsGameModeActive()) {
DisableGameMode();
EnableGameMode();
}
}
void ControllerManager::StartButtonCapture(std::function<void(const BackButtonBinding&)> callback) {
std::lock_guard<std::mutex> lk(m_captureMutex);
m_captureCallback = std::move(callback);
@@ -496,7 +515,7 @@ ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut,
}
slot.vc = std::make_unique<VirtualController>(
m_controllerPlatform,
m_profile.platform,
[sc = slot.sc.get()](uint8_t largeMotor, uint8_t smallMotor) {
if (sc->IsOpen()) sc->SetRumble(largeMotor, smallMotor);
});
@@ -510,21 +529,20 @@ ControllerManager::EnableGameModeSlot(Slot& slot, bool& vigemMissingOut,
return GameModeOutcome::Blocked;
}
if (m_controllerPlatform == ControllerPlatform::PlayStation)
if (m_profile.platform == ControllerPlatform::PlayStation)
slot.sc->SetImuEnabled(true);
slot.vc->SetTrackpadMouseClaim(m_trackpadMouseEnabled, m_useLeftTrackpad);
EventLog::Write("GAMEMODE: enabled %ls", slot.path.c_str());
// Takeover is the moment users report the trackpad arriving dead, and what
// decides whether injection can land is whatever window happens to be in
// front right then — so record it here rather than reconstructing it later.
if (m_trackpadMouseEnabled)
if (m_profile.leftPad.ClaimedForDesktop() || m_profile.rightPad.ClaimedForDesktop())
InputInjection::LogEnvironment("game mode taken");
slot.gameModeActive = true;
slot.trackpad.Reset();
slot.leftPad.Reset();
slot.rightPad.Reset();
ReleaseHeldPaddleInputs(slot);
slot.trackpad.SetTrackpadEnabled(m_trackpadMouseEnabled);
slot.trackpad.SetUseLeftTrackpad(m_useLeftTrackpad);
ApplyPadSettings(slot);
StartReadLoop(slot);
return GameModeOutcome::Enabled;
}
@@ -535,7 +553,8 @@ void ControllerManager::DisableGameModeSlot(Slot& slot) {
StopReadLoop(slot);
if (slot.sc->IsOpen())
slot.sc->SetRumble(0, 0);
slot.trackpad.Reset();
slot.leftPad.Reset();
slot.rightPad.Reset();
ReleaseHeldPaddleInputs(slot);
slot.vc.reset();
slot.sc->EnableLizardMode();
@@ -660,8 +679,9 @@ void ControllerManager::ReadLoop(Slot* slot) {
"dropped before it reaches SendInput", n);
}
if (slot->vc) slot->vc->Update(buf, n, m_backConfig);
slot->trackpad.Update(buf, n);
if (slot->vc) slot->vc->Update(buf, n, m_profile);
slot->leftPad.Update(buf, n);
slot->rightPad.Update(buf, n);
// Trackpad haptics.
{
@@ -859,17 +879,34 @@ void ControllerManager::ReadLoop(Slot* slot) {
slot->hapticWasLeftTouching = lt;
}
// Deliver back paddles bound to a key or a mouse button. These go out
// through SendInput rather than the virtual pad. Edge-detected so each
// press sends exactly one down and each release exactly one up.
// Deliver back paddles and pad clicks bound to a key or a mouse button.
// These go out through SendInput rather than the virtual pad.
// Edge-detected so each press sends exactly one down and each release
// exactly one up.
//
// Pad clicks ride this same path rather than living in TrackpadMouse,
// which is what makes a pad's click independent of its movement mode —
// the click used to be hardcoded to a left button and only fired while
// that pad was driving the mouse.
if (hasPrev) {
// A pad set to feed the DS4 touchpad has no click binding to
// dispatch — its press is the touchpad press. EffectiveClick
// returns nothing in that case, so a binding left behind by an
// earlier mode cannot fire from behind the hidden UI.
const BackButtonBinding leftPadClick = m_profile.leftPad.EffectiveClick();
const BackButtonBinding rightPadClick = m_profile.rightPad.EffectiveClick();
struct PaddleEdge { bool cur; bool prev; const BackButtonBinding& binding; };
const PaddleEdge edges[] = {
{ n>4 && (buf[4]&SteamController::BTN_L4)!=0, (prevBuf[4]&SteamController::BTN_L4)!=0, m_backConfig.l4 },
{ n>4 && (buf[4]&SteamController::BTN_L5)!=0, (prevBuf[4]&SteamController::BTN_L5)!=0, m_backConfig.l5 },
{ n>2 && (buf[2]&SteamController::BTN_R4)!=0, (prevBuf[2]&SteamController::BTN_R4)!=0, m_backConfig.r4 },
{ n>3 && (buf[3]&SteamController::BTN_R5)!=0, (prevBuf[3]&SteamController::BTN_R5)!=0, m_backConfig.r5 },
{ n>4 && (buf[4]&SteamController::BTN_L4)!=0, (prevBuf[4]&SteamController::BTN_L4)!=0, m_profile.back.l4 },
{ n>4 && (buf[4]&SteamController::BTN_L5)!=0, (prevBuf[4]&SteamController::BTN_L5)!=0, m_profile.back.l5 },
{ n>2 && (buf[2]&SteamController::BTN_R4)!=0, (prevBuf[2]&SteamController::BTN_R4)!=0, m_profile.back.r4 },
{ n>3 && (buf[3]&SteamController::BTN_R5)!=0, (prevBuf[3]&SteamController::BTN_R5)!=0, m_profile.back.r5 },
{ n>5 && (buf[5]&SteamController::BTN_TP_LT_CLICK)!=0, (prevBuf[5]&SteamController::BTN_TP_LT_CLICK)!=0, leftPadClick },
{ n>4 && (buf[4]&SteamController::BTN_TP_RT_CLICK)!=0, (prevBuf[4]&SteamController::BTN_TP_RT_CLICK)!=0, rightPadClick },
};
static_assert(std::size(edges) == Slot::kBindableCount,
"paddleHeld must have one entry per edge-dispatched binding");
for (size_t i = 0; i < std::size(edges); ++i) {
const auto& e = edges[i];
if (e.cur == e.prev) continue;
@@ -901,6 +938,9 @@ void ControllerManager::ReadLoop(Slot* slot) {
const auto now = std::chrono::steady_clock::now();
if (now < slot->paddleRepeatAt[i]) continue;
// The base key only. Modifiers stay held across the repeat, which
// is exactly what a real keyboard does — holding Ctrl+K repeats
// the K, it does not re-press Ctrl.
SendKeyInput(held.code, true);
slot->paddleRepeatAt[i] = now + slot->paddleRepeatGap[i];
}
+12 -12
View File
@@ -1,6 +1,7 @@
#pragma once
#include "BackButtonConfig.h"
#include "ControllerPlatform.h"
#include "TrackpadConfig.h"
#include <atomic>
#include <functional>
#include <memory>
@@ -54,17 +55,15 @@ public:
// than refusing — refusing is what escalates to a device cycle.
void SetSteamPresent(bool present) { m_steamPresent = present; }
void SetTrackpadMouseEnabled(bool enabled);
void SetUseLeftTrackpad(bool enabled);
void SetBackButtonConfig(const BackButtonConfig& cfg);
void SetControllerPlatform(ControllerPlatform platform);
// The whole active profile — platform, paddle bindings and both pads.
// Mostly applied live: the read loop reads the profile every frame, so
// bindings land on the next report. A changed platform is the exception —
// it rebuilds the virtual controller, see the definition.
void SetProfile(const ControllerProfile& profile);
bool IsConnected() const { return !m_slots.empty(); }
bool IsGameModeActive() const;
bool IsTrackpadMouseEnabled() const { return m_trackpadMouseEnabled; }
bool IsUseLeftTrackpad() const { return m_useLeftTrackpad; }
ControllerPlatform GetControllerPlatform() const { return m_controllerPlatform; }
const BackButtonConfig& GetBackButtonConfig() const { return m_backConfig; }
const ControllerProfile& GetProfile() const { return m_profile; }
// Called by RemapWindow when a row enters/exits listening state.
// Callback fires on the read thread — use PostMessage to marshal to the UI thread.
@@ -85,14 +84,15 @@ private:
void ReadLoop(Slot* slot);
void NotifyStateChanged(bool vigemMissing = false);
// Pushes the current profile's pad settings into one slot's trackpads and
// its virtual controller. Shared by SetProfile and slot creation.
void ApplyPadSettings(Slot& slot);
StateChangedFn m_onStateChanged;
AlertFn m_alertFn;
std::vector<std::unique_ptr<Slot>> m_slots;
bool m_trackpadMouseEnabled = false;
bool m_useLeftTrackpad = false;
bool m_steamPresent = false;
ControllerPlatform m_controllerPlatform = ControllerPlatform::Xbox;
BackButtonConfig m_backConfig;
ControllerProfile m_profile;
std::atomic<bool> m_capturing{false};
std::mutex m_captureMutex;
+188
View File
@@ -0,0 +1,188 @@
#include "ForegroundWatcher.h"
#include <appmodel.h>
#include <vector>
ForegroundWatcher* ForegroundWatcher::s_instance = nullptr;
namespace {
// Packaged apps do not own their own top-level window. Windows hosts it in
// ApplicationFrameHost.exe, so the foreground window resolves to the host and
// every Store app would look like the same application. The app's real window
// is a child of the frame, owned by a different process — that one carries
// the identity worth having.
constexpr wchar_t kFrameHostExe[] = L"ApplicationFrameHost.exe";
bool IsFrameHost(const std::wstring& exePath) {
const size_t slash = exePath.find_last_of(L'\\');
const wchar_t* leaf = slash == std::wstring::npos ? exePath.c_str()
: exePath.c_str() + slash + 1;
return _wcsicmp(leaf, kFrameHostExe) == 0;
}
struct FrameChildSearch {
DWORD hostPid = 0;
HWND found = nullptr;
};
BOOL CALLBACK FindFrameChild(HWND child, LPARAM param) {
auto* search = reinterpret_cast<FrameChildSearch*>(param);
DWORD pid = 0;
GetWindowThreadProcessId(child, &pid);
if (pid != 0 && pid != search->hostPid) {
search->found = child;
return FALSE; // stop at the first child that is not the host itself
}
return TRUE;
}
std::wstring ImagePathOf(HANDLE process) {
// Longer than MAX_PATH on purpose: a packaged app's path under
// WindowsApps carries a versioned package name and overruns it easily.
wchar_t buf[1024];
DWORD len = ARRAYSIZE(buf);
if (!QueryFullProcessImageNameW(process, 0, buf, &len)) return {};
return std::wstring(buf, len);
}
std::wstring AumidOf(HANDLE process) {
UINT32 len = 0;
// Asking with no buffer is how the length comes back; anything other than
// "too small" means this process has no package identity, which is the
// ordinary case for a desktop application.
if (GetApplicationUserModelId(process, &len, nullptr) != ERROR_INSUFFICIENT_BUFFER
|| len == 0)
return {};
std::vector<wchar_t> buf(len);
if (GetApplicationUserModelId(process, &len, buf.data()) != ERROR_SUCCESS)
return {};
return std::wstring(buf.data()); // len counts the terminator; trust the string
}
ForegroundIdentity IdentifyProcess(DWORD pid) {
ForegroundIdentity id;
if (pid == 0) return id;
// Limited information is enough for both queries and, unlike the fuller
// rights, is granted to an unelevated process for most things the user
// is likely to be looking at.
HANDLE proc = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (!proc) return id; // protected or elevated — reported as unknown
id.exePath = ImagePathOf(proc);
id.aumid = AumidOf(proc);
CloseHandle(proc);
return id;
}
} // namespace
ForegroundIdentity ForegroundWatcher::Current() {
HWND hwnd = GetForegroundWindow();
if (!hwnd) return {};
DWORD pid = 0;
GetWindowThreadProcessId(hwnd, &pid);
ForegroundIdentity id = IdentifyProcess(pid);
if (!id.exePath.empty() && IsFrameHost(id.exePath)) {
FrameChildSearch search{ pid, nullptr };
EnumChildWindows(hwnd, FindFrameChild, reinterpret_cast<LPARAM>(&search));
if (search.found) {
DWORD childPid = 0;
GetWindowThreadProcessId(search.found, &childPid);
ForegroundIdentity hosted = IdentifyProcess(childPid);
// Only take the hosted identity if it resolved; a frame with no
// reachable child is still better described by the frame itself
// than by nothing at all.
if (!hosted.Empty()) id = hosted;
}
}
return id;
}
void CALLBACK ForegroundWatcher::HookProc(HWINEVENTHOOK, DWORD event, HWND hwnd,
LONG idObject, LONG, DWORD, DWORD) {
if (!s_instance || !s_instance->m_hwnd) return;
if (event != EVENT_SYSTEM_FOREGROUND || idObject != OBJID_WINDOW || !hwnd) return;
// Deliberately does not resolve anything here. The window that raised the
// event is often not the one focus settles on, so the timer re-reads the
// foreground when it expires and a burst collapses into one answer.
SetTimer(s_instance->m_hwnd, IDT_DEBOUNCE, DEBOUNCE_MS, nullptr);
}
LRESULT CALLBACK ForegroundWatcher::WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (msg == WM_TIMER && wp == IDT_DEBOUNCE && s_instance) {
KillTimer(hwnd, IDT_DEBOUNCE);
s_instance->OnDebounceElapsed();
return 0;
}
return DefWindowProcW(hwnd, msg, wp, lp);
}
void ForegroundWatcher::OnDebounceElapsed() {
const ForegroundIdentity now = Current();
// An unidentifiable foreground is not a change worth reporting: it says
// nothing about what the user switched to, and treating it as "no game"
// would drop a profile every time focus passed through something we
// cannot open.
if (now.Empty() || now == m_last) return;
m_last = now;
if (m_onChange) m_onChange(now);
}
bool ForegroundWatcher::Start(ChangedFn onChange) {
Stop();
m_onChange = std::move(onChange);
s_instance = this;
WNDCLASSEXW wc{};
wc.cbSize = sizeof(wc);
wc.lpfnWndProc = WndProc;
wc.hInstance = GetModuleHandleW(nullptr);
wc.lpszClassName = CLASS_NAME;
RegisterClassExW(&wc); // OK if already registered
// Message-only: it exists purely to own the debounce timer, and must
// never appear anywhere a user could see it.
m_hwnd = CreateWindowExW(0, CLASS_NAME, L"", 0, 0, 0, 0, 0,
HWND_MESSAGE, nullptr, wc.hInstance, nullptr);
if (!m_hwnd) {
s_instance = nullptr;
return false;
}
// SKIPOWNPROCESS: our own tray menu and settings window taking focus is
// not the user switching applications, and would otherwise clear whatever
// profile is running the moment they opened the menu.
m_hook = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND,
nullptr, HookProc, 0, 0,
WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);
if (!m_hook) {
DestroyWindow(m_hwnd);
m_hwnd = nullptr;
s_instance = nullptr;
return false;
}
m_last = Current();
return true;
}
void ForegroundWatcher::Stop() {
if (m_hook) {
UnhookWinEvent(m_hook);
m_hook = nullptr;
}
if (m_hwnd) {
KillTimer(m_hwnd, IDT_DEBOUNCE);
DestroyWindow(m_hwnd);
m_hwnd = nullptr;
}
if (s_instance == this) s_instance = nullptr;
m_onChange = nullptr;
m_last = {};
}
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include <Windows.h>
#include <functional>
#include <string>
// Who owns the window the user is currently working in.
//
// Both fields are filled where they apply: every process has an image path,
// and only a packaged (MSIX/UWP) one has an AUMID. A foreground window we
// could not identify at all — a protected process, or one that closed while
// being asked — reports empty, which callers should read as "unknown", never
// as "the desktop".
struct ForegroundIdentity {
std::wstring exePath;
std::wstring aumid;
bool Empty() const { return exePath.empty() && aumid.empty(); }
bool operator==(const ForegroundIdentity& o) const {
return _wcsicmp(exePath.c_str(), o.exePath.c_str()) == 0
&& _wcsicmp(aumid.c_str(), o.aumid.c_str()) == 0;
}
bool operator!=(const ForegroundIdentity& o) const { return !(*this == o); }
};
// Reports which application the user has switched to, without polling.
//
// Built on SetWinEventHook(EVENT_SYSTEM_FOREGROUND), which is the only
// push-based answer to "what is the user doing now" available to a process
// that never runs elevated: the event-driven alternatives for watching
// processes start (Win32_ProcessStartTrace, an ETW kernel session) all
// require administrator rights, and the unelevated WMI route polls
// internally. Registering costs one hook and no thread; the callback only
// runs when focus actually changes, which is a human-scale rate.
//
// Focus is also the better question than "did a game launch". A launcher
// that starts a game and stays resident — Ubisoft Connect, the EA app —
// keeps its process alive long after the game exits, so "is it running"
// answers yes for far too long. What is in front does not have that problem,
// and it means alt-tabbing to a browser hands the desktop its own settings
// back.
class ForegroundWatcher {
public:
using ChangedFn = std::function<void(const ForegroundIdentity&)>;
~ForegroundWatcher() { Stop(); }
// Begins reporting. The callback runs on the calling thread, which must
// be the one pumping messages — out-of-context hooks are delivered
// through the message queue, and the debounce below is a window timer.
bool Start(ChangedFn onChange);
void Stop();
// Resolve the foreground window right now, for callers that need to
// catch up rather than wait for the next switch (taking the controller
// back, say, when the user has been sitting in a game the whole time).
static ForegroundIdentity Current();
private:
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp);
static void CALLBACK HookProc(HWINEVENTHOOK hook, DWORD event, HWND hwnd,
LONG idObject, LONG idChild,
DWORD thread, DWORD time);
void OnDebounceElapsed();
HWINEVENTHOOK m_hook = nullptr;
HWND m_hwnd = nullptr; // message-only, owns the debounce timer
ChangedFn m_onChange;
ForegroundIdentity m_last;
static ForegroundWatcher* s_instance;
static constexpr wchar_t CLASS_NAME[] = L"SteamlessForegroundWatcher";
static constexpr UINT_PTR IDT_DEBOUNCE = 1;
// Focus churns while an application starts — splash screens, launcher
// windows, the game's own window replacing its loader — and every one of
// those is a foreground change. Settling first keeps a burst from being
// read as several different applications, which matters because a
// profile switch can rebuild the virtual controller.
static constexpr UINT DEBOUNCE_MS = 400;
};
+286
View File
@@ -0,0 +1,286 @@
#define NOMINMAX // avoid Windows.h's min/max macros colliding with C++/WinRT templates
#include "GameLibrary.h"
#include <Windows.h>
#include <shlobj.h>
#include <shlwapi.h>
#include <wrl/client.h>
#include <winrt/Windows.ApplicationModel.h>
#include <winrt/Windows.ApplicationModel.Core.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Management.Deployment.h>
#include <algorithm>
#include <cstdio>
#include <cwctype>
#include <unordered_set>
#pragma comment(lib, "shlwapi.lib")
namespace {
// Targets under the Windows directory are system utilities (Notepad, Control
// Panel applets, accessories) that Start Menu shortcuts surface right
// alongside real applications — filtered out so the picker isn't dominated
// by them.
bool IsUnderWindowsDir(const std::wstring& path) {
wchar_t winDir[MAX_PATH];
if (!GetWindowsDirectoryW(winDir, MAX_PATH)) return false;
const size_t len = wcslen(winDir);
return path.size() > len
&& _wcsnicmp(path.c_str(), winDir, len) == 0
&& (path[len] == L'\\' || path[len] == L'\0');
}
// Shortcut names that are never the game/app itself, just noise that rides
// along with it in the same Start Menu folder — "Uninstall X", "X Readme".
// Only applied to .lnk-sourced entries: a real name can contain one of these
// words as part of a longer word ("Truck-kun is Supporting Me..." contains
// "support"), so matching is whole-word, not substring.
bool LooksLikeNoise(const std::wstring& name) {
static const wchar_t* kNoisePhrases[] = { L"read me" }; // has a space; safe as a substring
static const wchar_t* kNoiseWords[] = {
L"uninstall", L"readme", L"license", L"changelog",
L"help", L"documentation", L"website", L"support", L"faq",
};
std::wstring lower = name;
for (wchar_t& c : lower) c = static_cast<wchar_t>(towlower(c));
for (const wchar_t* phrase : kNoisePhrases)
if (lower.find(phrase) != std::wstring::npos) return true;
size_t start = 0;
while (start < lower.size()) {
size_t end = start;
while (end < lower.size() && iswalnum(lower[end])) ++end;
if (end > start) {
const std::wstring word = lower.substr(start, end - start);
for (const wchar_t* noise : kNoiseWords)
if (word == noise) return true;
}
start = end + 1;
}
return false;
}
// Case-insensitive identity for dedup. Only a bare filesystem path benefits
// from PathCanonicalizeW (collapsing "." / ".." segments); running it over a
// "steam://" URI or an "aumid:" id risks mangling them for no reason, since
// neither one follows filesystem path rules.
std::wstring DedupKey(const std::wstring& id) {
std::wstring key;
if (id.rfind(L"steam://", 0) == 0 || id.rfind(L"aumid:", 0) == 0) {
key = id;
} else {
wchar_t buf[MAX_PATH];
key = PathCanonicalizeW(buf, id.c_str()) ? buf : id;
}
for (wchar_t& c : key) c = static_cast<wchar_t>(towlower(c));
return key;
}
// A file extension worth resolving further — anything else in the Start
// Menu (icons, config, whatever an installer left behind) is not a shortcut.
bool HasExtension(const std::wstring& path, const wchar_t* ext) {
const size_t dot = path.find_last_of(L'.');
return dot != std::wstring::npos && _wcsicmp(path.c_str() + dot, ext) == 0;
}
void WalkDirectory(const std::wstring& dir, std::vector<std::wstring>& shortcutPaths) {
WIN32_FIND_DATAW fd{};
HANDLE find = FindFirstFileW((dir + L"\\*").c_str(), &fd);
if (find == INVALID_HANDLE_VALUE) return;
do {
if (wcscmp(fd.cFileName, L".") == 0 || wcscmp(fd.cFileName, L"..") == 0)
continue;
const std::wstring full = dir + L"\\" + fd.cFileName;
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
WalkDirectory(full, shortcutPaths);
} else if (HasExtension(full, L".lnk") || HasExtension(full, L".url")) {
shortcutPaths.push_back(full);
}
} while (FindNextFileW(find, &fd));
FindClose(find);
}
// Empty when the shortcut has no literal file target — the case for a
// packaged/UWP app, which resolves through an AUMID instead of a path.
std::wstring ResolveLnkTarget(IShellLinkW* link) {
wchar_t target[MAX_PATH] = {};
if (FAILED(link->GetPath(target, MAX_PATH, nullptr, 0)) || target[0] == L'\0')
return {};
return target;
}
// Every Steam-installed game gets one of these instead of a .lnk — an
// Internet Shortcut whose URL is "steam://rungameid/<appid>". Anything else
// under a "URL=" line (Steam also drops a couple of plain website links in
// the same folder, e.g. its support center) is not a game and is skipped.
// .url files are plain INI-style text, always ASCII in the one line this
// cares about, so a byte-for-byte narrow read is safe.
std::wstring ResolveUrlSteamId(const std::wstring& path) {
FILE* f = nullptr;
if (_wfopen_s(&f, path.c_str(), L"r") != 0 || !f) return {};
static constexpr char kLinePrefix[] = "URL=";
static constexpr size_t kLinePrefixLen = sizeof(kLinePrefix) - 1;
static constexpr char kUriPrefix[] = "steam://rungameid/";
static constexpr size_t kUriPrefixLen = sizeof(kUriPrefix) - 1;
char line[512];
std::string uri;
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, kLinePrefix, kLinePrefixLen) != 0) continue;
const char* value = line + kLinePrefixLen;
if (strncmp(value, kUriPrefix, kUriPrefixLen) != 0) continue;
uri = value; // keep the steam:// URI itself, "URL=" already dropped
break;
}
fclose(f);
while (!uri.empty() && (uri.back() == '\n' || uri.back() == '\r'))
uri.pop_back();
if (uri.size() <= kUriPrefixLen) return {};
// The appid must be purely numeric — anything else means this line was
// not the simple form assumed above, and guessing further isn't worth it.
for (size_t i = kUriPrefixLen; i < uri.size(); ++i)
if (uri[i] < '0' || uri[i] > '9') return {};
return std::wstring(uri.begin(), uri.end());
}
std::wstring NameFromShortcutPath(const std::wstring& shortcutPath) {
std::wstring name = shortcutPath;
const size_t slash = name.find_last_of(L'\\');
if (slash != std::wstring::npos) name.erase(0, slash + 1);
const size_t dot = name.find_last_of(L'.');
if (dot != std::wstring::npos) name.erase(dot);
return name;
}
// Packaged (MSIX/UWP) apps — the Xbox app's Game Pass titles among them —
// register no shortcut file the Start Menu walk above can see; Windows
// builds their Start Menu presence straight from the package manifest.
// Resolved through the real WinRT PackageManager API rather than the
// simpler shell:AppsFolder namespace specifically because only PackageManager
// exposes each package's signing info — Playnite's own Xbox library extension
// filters on exactly this (see its Programs2.cs::GetUWPApps()) to drop
// vendor/OEM-signed utilities (GPU control panels, audio consoles) that
// register as packaged apps but were never distributed through the Store,
// and would otherwise flood this list alongside actual Store/Game Pass
// titles. shell:AppsFolder's IShellItem properties have no equivalent.
//
// This still can't tell a game apart from any other Store app — Calculator
// and Paint are Store-signed too. Nothing local can make that call; Playnite
// only gets it by cross-referencing each package against Microsoft's Xbox
// Live catalog over an authenticated account, which is out of scope here.
// That filtering is left to the user picking from the list, same as
// everywhere else in this file.
std::vector<InstalledGame> EnumeratePackagedApps() {
std::vector<InstalledGame> games;
try {
// Matches the STA the classic-COM shortcut resolution above already
// set up via CoInitializeEx — WinRT's activation layer needs its own
// compatible init call on top of that, not a competing one.
winrt::init_apartment(winrt::apartment_type::single_threaded);
winrt::Windows::Management::Deployment::PackageManager manager;
for (const auto& package : manager.FindPackagesForUser(L"")) {
if (package.IsFramework() || package.IsResourcePackage()) continue;
if (package.SignatureKind() != winrt::Windows::ApplicationModel::PackageSignatureKind::Store)
continue;
for (const auto& entry : package.GetAppListEntries()) {
const std::wstring name(entry.DisplayInfo().DisplayName());
const std::wstring aumid(entry.AppUserModelId());
if (name.empty() || aumid.empty()) continue;
games.push_back({ name, L"aumid:" + aumid });
}
}
} catch (const winrt::hresult_error&) {
// Best-effort: an empty result here just means the picker falls back
// to whatever the Start Menu walk found on its own.
}
return games;
}
} // namespace
namespace GameLibrary {
std::vector<InstalledGame> EnumerateInstalled() {
std::vector<InstalledGame> games;
// COM may already be initialized on this thread (WebView2 runs on the
// same UI thread once opened) — only tear down what we set up ourselves.
const HRESULT coInit = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
const bool comOwnedByUs = coInit == S_OK || coInit == S_FALSE;
std::vector<std::wstring> shortcutPaths;
for (REFKNOWNFOLDERID folder : { FOLDERID_CommonPrograms, FOLDERID_Programs }) {
PWSTR path = nullptr;
if (SUCCEEDED(SHGetKnownFolderPath(folder, 0, nullptr, &path)) && path)
WalkDirectory(path, shortcutPaths);
if (path) CoTaskMemFree(path);
}
std::unordered_set<std::wstring> seen;
for (const auto& shortcutPath : shortcutPaths) {
std::wstring id;
bool isSteamLink = false;
if (HasExtension(shortcutPath, L".url")) {
id = ResolveUrlSteamId(shortcutPath);
if (id.empty()) continue; // not a "play this game" link
isSteamLink = true;
} else {
Microsoft::WRL::ComPtr<IShellLinkW> link;
if (FAILED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(&link))))
continue;
Microsoft::WRL::ComPtr<IPersistFile> file;
if (FAILED(link.As(&file)) || FAILED(file->Load(shortcutPath.c_str(), STGM_READ)))
continue;
id = ResolveLnkTarget(link.Get());
if (id.empty()) continue; // packaged app — not this pass
if (!HasExtension(id, L".exe")) continue;
if (GetFileAttributesW(id.c_str()) == INVALID_FILE_ATTRIBUTES)
continue; // shortcut is stale — target no longer exists
if (IsUnderWindowsDir(id)) continue;
}
// The shortcut's own filename is the friendly name a user or
// installer chose ("Rainbow Six Siege"); the exe's own name rarely
// is ("RainbowSix.exe") — so the display name comes from the link,
// not the target.
std::wstring name = NameFromShortcutPath(shortcutPath);
// A steam://rungameid/ link is already proof this is a real,
// launchable game — Steam's own non-game links (support center, EULA)
// don't take that form, so the noise-word filter only has false
// positives left to contribute here (a game title that happens to
// contain one of those words, as this app's own test machine has).
if (!isSteamLink && LooksLikeNoise(name)) continue;
if (!seen.insert(DedupKey(id)).second)
continue; // already have this game from another shortcut
games.push_back({ std::move(name), std::move(id) });
}
for (auto& game : EnumeratePackagedApps()) {
if (!seen.insert(DedupKey(game.id)).second)
continue;
games.push_back(std::move(game));
}
if (comOwnedByUs) CoUninitialize();
std::sort(games.begin(), games.end(), [](const InstalledGame& a, const InstalledGame& b) {
return _wcsicmp(a.name.c_str(), b.name.c_str()) < 0;
});
return games;
}
} // namespace GameLibrary
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <string>
#include <vector>
// One installed application resolved from a Start Menu shortcut. Called a
// "game" for the feature it serves, but nothing here can actually tell a
// game from any other program — that filtering is left to the user picking
// from the list.
struct InstalledGame {
std::wstring name; // the shortcut's own name, e.g. "Rainbow Six Siege"
// Opaque stable identity for this game — what per-game profiles are keyed
// on. Takes one of three shapes depending on where the entry came from:
// - a resolved exe path, for an ordinary Start Menu .lnk;
// - "steam://rungameid/N", for a Steam game — Steam shortcuts its
// library with .url Internet Shortcuts rather than .lnk files, and
// that URI identifies the game precisely (matches the RunningAppID
// SteamWatcher already reads) without needing to locate its install
// directory;
// - "aumid:PackageFamilyName!AppId", for a packaged/MSIX app (Xbox app
// and Microsoft Store titles among them) resolved through
// shell:AppsFolder rather than any shortcut file, since those apps
// register no shortcut file for the Start Menu walk to find.
std::wstring id;
};
namespace GameLibrary {
// Combines two sources into one list, sorted by name, one entry per distinct
// game:
// - both Start Menu Programs folders (current user and all users), walked
// for .lnk shortcuts (resolved to a target executable) and .url
// shortcuts (resolved to a Steam game's steam://rungameid/N); and
// - shell:AppsFolder, for packaged/MSIX apps — Xbox app and Microsoft
// Store titles among them — which register no shortcut file in the
// Start Menu folders for the walk above to find.
//
// Broad coverage from one code path, at the cost of occasionally surfacing a
// launcher stub instead of the real game (a shortcut that punches out to
// another launcher, for example) and of not being able to tell an actual
// game apart from any other installed program — that filtering is left to
// the user picking from the list.
std::vector<InstalledGame> EnumerateInstalled();
}
+133
View File
@@ -0,0 +1,133 @@
#include "GameProfiles.h"
#include <Windows.h>
#include <cstdio>
namespace {
constexpr wchar_t kProfilesKey[] = L"Software\\SteamlessController\\GameProfiles";
DWORD ReadDw(HKEY key, const wchar_t* name, DWORD def) {
DWORD val = 0, size = sizeof(val);
if (RegQueryValueExW(key, name, nullptr, nullptr,
reinterpret_cast<LPBYTE>(&val), &size) == ERROR_SUCCESS)
return val;
return def;
}
std::wstring ReadSz(HKEY key, const wchar_t* name) {
wchar_t buf[MAX_PATH] = {};
DWORD size = sizeof(buf);
if (RegQueryValueExW(key, name, nullptr, nullptr,
reinterpret_cast<LPBYTE>(buf), &size) == ERROR_SUCCESS)
return buf;
return {};
}
void WriteDw(HKEY key, const wchar_t* name, DWORD val) {
RegSetValueExW(key, name, 0, REG_DWORD,
reinterpret_cast<const BYTE*>(&val), sizeof(val));
}
void WriteSz(HKEY key, const wchar_t* name, const std::wstring& val) {
RegSetValueExW(key, name, 0, REG_SZ,
reinterpret_cast<const BYTE*>(val.c_str()),
static_cast<DWORD>((val.size() + 1) * sizeof(wchar_t)));
}
} // namespace
namespace GameProfiles {
std::map<std::wstring, ControllerProfile> Load() {
std::map<std::wstring, ControllerProfile> profiles;
HKEY parent;
if (RegOpenKeyExW(HKEY_CURRENT_USER, kProfilesKey, 0, KEY_READ, &parent) != ERROR_SUCCESS)
return profiles;
const DWORD unbound = BackButtonBinding::FromAction(BackButtonAction::None).Pack();
// Numbered subkeys ("0", "1", ...) rather than the game id itself — an
// exe-path id is not a legal single registry key name, since its
// backslashes would be read as key-hierarchy separators.
for (DWORD i = 0;; ++i) {
wchar_t sub[16];
swprintf_s(sub, L"%lu", i);
HKEY child;
if (RegOpenKeyExW(parent, sub, 0, KEY_READ, &child) != ERROR_SUCCESS)
break;
const std::wstring id = ReadSz(child, L"Id");
if (!id.empty()) {
ControllerProfile p;
p.displayName = ReadSz(child, L"Name");
p.platform = ReadDw(child, L"Platform", 0) != 0 ? ControllerPlatform::PlayStation
: ControllerPlatform::Xbox;
p.back.l4 = BackButtonBinding::Unpack(ReadDw(child, L"L4", unbound));
p.back.l5 = BackButtonBinding::Unpack(ReadDw(child, L"L5", unbound));
p.back.r4 = BackButtonBinding::Unpack(ReadDw(child, L"R4", unbound));
p.back.r5 = BackButtonBinding::Unpack(ReadDw(child, L"R5", unbound));
// Profiles written before per-pad settings existed have none of
// these values; the defaults leave both pads unclaimed, which is
// exactly how those profiles behaved.
p.leftPad.mode = TrackpadModeFromDword(ReadDw(child, L"LeftPadMode", 0));
p.leftPad.click = BackButtonBinding::Unpack(ReadDw(child, L"LeftPadClick", unbound));
p.leftPad.scrollDir = ScrollDirectionFromDword(ReadDw(child, L"LeftPadScrollDir", 0));
p.rightPad.mode = TrackpadModeFromDword(ReadDw(child, L"RightPadMode", 0));
p.rightPad.click = BackButtonBinding::Unpack(ReadDw(child, L"RightPadClick", unbound));
p.rightPad.scrollDir = ScrollDirectionFromDword(ReadDw(child, L"RightPadScrollDir", 0));
profiles[id] = p;
}
RegCloseKey(child);
}
RegCloseKey(parent);
return profiles;
}
void Save(const std::map<std::wstring, ControllerProfile>& profiles) {
HKEY parent;
if (RegCreateKeyExW(HKEY_CURRENT_USER, kProfilesKey, 0, nullptr,
REG_OPTION_NON_VOLATILE, KEY_ALL_ACCESS, nullptr,
&parent, nullptr) != ERROR_SUCCESS)
return;
DWORD i = 0;
for (const auto& [id, p] : profiles) {
wchar_t sub[16];
swprintf_s(sub, L"%lu", i);
HKEY child;
if (RegCreateKeyExW(parent, sub, 0, nullptr, REG_OPTION_NON_VOLATILE,
KEY_WRITE, nullptr, &child, nullptr) == ERROR_SUCCESS) {
WriteSz(child, L"Id", id);
WriteSz(child, L"Name", p.displayName);
WriteDw(child, L"Platform",
p.platform == ControllerPlatform::PlayStation ? 1 : 0);
WriteDw(child, L"L4", p.back.l4.Pack());
WriteDw(child, L"L5", p.back.l5.Pack());
WriteDw(child, L"R4", p.back.r4.Pack());
WriteDw(child, L"R5", p.back.r5.Pack());
WriteDw(child, L"LeftPadMode", static_cast<DWORD>(p.leftPad.mode));
WriteDw(child, L"LeftPadClick", p.leftPad.click.Pack());
WriteDw(child, L"LeftPadScrollDir", static_cast<DWORD>(p.leftPad.scrollDir));
WriteDw(child, L"RightPadMode", static_cast<DWORD>(p.rightPad.mode));
WriteDw(child, L"RightPadClick", p.rightPad.click.Pack());
WriteDw(child, L"RightPadScrollDir", static_cast<DWORD>(p.rightPad.scrollDir));
RegCloseKey(child);
}
++i;
}
// Numbered subkeys have no children of their own, so dropping whatever
// range existed past the current count is a plain per-key delete — no
// recursive-delete dependency needed when the map has shrunk.
for (;; ++i) {
wchar_t sub[16];
swprintf_s(sub, L"%lu", i);
if (RegDeleteKeyW(parent, sub) != ERROR_SUCCESS) break;
}
RegCloseKey(parent);
}
} // namespace GameProfiles
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <map>
#include <string>
#include "TrackpadConfig.h"
// Per-game overrides, keyed by the game's id (the same opaque identity
// GameLibrary::InstalledGame::id produces — an exe path for most shortcuts, a
// "steam://rungameid/N" URI for Steam games, an "aumid:..." for packaged
// apps). A game with no entry here uses the global default profile instead —
// most users never create any of these.
namespace GameProfiles {
std::map<std::wstring, ControllerProfile> Load();
void Save(const std::map<std::wstring, ControllerProfile>& profiles);
}
+87
View File
@@ -44,6 +44,10 @@ const JsCodeToVk kCodeMap[] = {
{"ShiftLeft", VK_LSHIFT}, {"ShiftRight", VK_RSHIFT},
{"ControlLeft", VK_LCONTROL}, {"ControlRight", VK_RCONTROL},
{"AltLeft", VK_LMENU}, {"AltRight", VK_RMENU},
// Present for completeness, not because they can be captured: Windows
// opens the Start menu on the way down, so the page is never told. A
// binding that wants Windows carries it as a modifier flag instead.
{"MetaLeft", VK_LWIN}, {"MetaRight", VK_RWIN},
// Function row
{"F1",VK_F1},{"F2",VK_F2},{"F3",VK_F3},{"F4",VK_F4},{"F5",VK_F5},{"F6",VK_F6},
@@ -121,6 +125,89 @@ uint16_t VkFromJsCode(const std::string& jsCode) {
return 0;
}
namespace {
// The physical key each modifier flag sends. Left-hand keys throughout:
// applications test the merged state, and a left modifier is what a user
// pressing the shortcut themselves would almost always produce.
struct ModifierKey { BackButtonBinding::Modifier flag; uint16_t vk; const wchar_t* name; };
// Order matters twice over: it is the order modifiers are pressed in, and the
// order they read in a label. Ctrl, Alt, Shift, Win is the convention Windows
// itself uses when it spells out a shortcut.
const ModifierKey kModifierKeys[] = {
{ BackButtonBinding::ModCtrl, VK_LCONTROL, L"Ctrl" },
{ BackButtonBinding::ModAlt, VK_LMENU, L"Alt" },
{ BackButtonBinding::ModShift, VK_LSHIFT, L"Shift" },
{ BackButtonBinding::ModWin, VK_LWIN, L"Win" },
};
// How many live bindings are currently holding each modifier down, indexed
// alongside kModifierKeys. Only ever touched from the read thread that
// dispatches bindings, which is the same thread for every slot.
int g_modifierHolds[std::size(kModifierKeys)] = {};
// Whether this app is the one holding the key down, as opposed to having
// found it already held. Recorded rather than re-tested on release, because
// the test cannot tell the two apart: an injected key reads as pressed to
// GetAsyncKeyState exactly like a real one, so asking again on the way out
// would see our own press and decline to undo it.
bool g_modifierSentByUs[std::size(kModifierKeys)] = {};
// Whether the key is down right now, by anyone's hand. Checked only before a
// press, where the answer means "the user is holding it" — nothing of ours is
// down at that point, or the hold count would have caught it first.
bool PhysicallyHeld(uint16_t vk) {
// The merged virtual key, not the left/right one: the user may be holding
// the right-hand key while this sends the left, and either satisfies the
// shortcut.
uint16_t merged = vk;
switch (vk) {
case VK_LCONTROL: merged = VK_CONTROL; break;
case VK_LMENU: merged = VK_MENU; break;
case VK_LSHIFT: merged = VK_SHIFT; break;
default: break;
}
return (GetAsyncKeyState(merged) & 0x8000) != 0;
}
} // namespace
void SendModifiers(uint8_t mods, bool down) {
for (size_t i = 0; i < std::size(kModifierKeys); ++i) {
const auto& m = kModifierKeys[i];
if (!(mods & m.flag)) continue;
if (down) {
// Only the first binding to want it does anything; the rest just
// join the count so the key outlives their individual releases.
if (g_modifierHolds[i]++ > 0) continue;
// Already down under the user's own finger — leave it theirs, and
// remember not to release what we never pressed.
g_modifierSentByUs[i] = !PhysicallyHeld(m.vk);
if (g_modifierSentByUs[i]) SendKeyInput(m.vk, true);
} else {
if (g_modifierHolds[i] == 0) continue; // never pressed, or already undone
if (--g_modifierHolds[i] > 0) continue; // another binding still wants it
if (!g_modifierSentByUs[i]) continue;
g_modifierSentByUs[i] = false;
SendKeyInput(m.vk, false);
}
}
}
std::wstring KeyComboDisplayName(const BackButtonBinding& binding) {
if (binding.kind != BackButtonBinding::Kind::Key) return L"Key";
std::wstring name;
for (const auto& m : kModifierKeys) {
if (!(binding.mods & m.flag)) continue;
name += m.name;
name += L" + ";
}
return name + KeyDisplayName(binding.code);
}
std::wstring KeyDisplayName(uint16_t vk) {
if (vk == 0) return L"Key";
+21
View File
@@ -2,6 +2,7 @@
#include <chrono>
#include <cstdint>
#include <string>
#include "BackButtonConfig.h"
// Keyboard support for paddle bindings.
//
@@ -20,6 +21,11 @@ uint16_t VkFromJsCode(const std::string& jsCode);
// has no name for it.
std::wstring KeyDisplayName(uint16_t vk);
// Name for a whole key binding, modifiers included: "Ctrl + Alt + K". The
// modifier names are fixed rather than drawn from the layout, because that is
// how every application spells them in its own shortcut lists.
std::wstring KeyComboDisplayName(const BackButtonBinding& binding);
// Presses or releases a key. Sends a scan code rather than a virtual key, and
// flags the extended keys, because software that reads scan codes directly
// (games, in particular) sees nothing useful otherwise.
@@ -30,6 +36,21 @@ std::wstring KeyDisplayName(uint16_t vk);
// to repeat has to re-send the press itself, paced by the two values below.
void SendKeyInput(uint16_t vk, bool down);
// Presses or releases the modifiers of a binding around its base key.
//
// Two things make this more than a loop over SendKeyInput.
//
// A modifier the user is physically holding must be left alone. Windows
// tracks one global state per key, so releasing Ctrl on a binding's behalf
// while the real Ctrl is still down tells every application the key came up,
// and the keyboard behaves oddly until the user taps it again.
//
// And two bindings can want the same modifier at once — paddles bound to
// Ctrl+C and Ctrl+V, pressed overlapping. Releasing the first must not drop
// the Ctrl the second is still relying on, so presses are counted and the key
// is released on the last one out.
void SendModifiers(uint8_t mods, bool down);
// The user's Windows keyboard settings: how long a key must be held before it
// starts repeating, and the gap between repeats after that. Read fresh rather
// than cached at startup so a change in Control Panel is picked up.
+690 -45
View File
@@ -71,6 +71,23 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back
.btn-cancel:hover{border-color:#c0392b;color:#fff;background:rgba(192,57,43,.08);}
.btn-row-reset{padding:6px 12px;border-radius:5px;border:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.03);color:#8f98a0;font-size:12px;font-weight:600;transition:border-color .15s,color .15s,background .15s;}
.btn-row-reset:hover{border-color:#66c0f4;color:#66c0f4;background:rgba(102,192,244,.06);}
.combo-wrap{position:relative;}
.combo-toggle{width:100%;background:#101925;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#fff;font-size:13.5px;font-family:'Barlow',system-ui,sans-serif;padding:9px 12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:8px;}
.combo-toggle:hover{border-color:#66c0f4;}
.combo-toggle .chev{color:#5c6b78;font-size:11px;flex:none;}
.combo-panel{position:absolute;top:calc(100% + 6px);left:0;right:0;background:#101925;border:1px solid rgba(102,192,244,.3);border-radius:8px;box-shadow:0 12px 28px rgba(0,0,0,.45);padding:10px;z-index:20;}
.combo-search{width:100%;box-sizing:border-box;background:#0b131c;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#fff;font-size:13px;font-family:'Barlow',system-ui,sans-serif;padding:8px 10px;}
.combo-search:focus{outline:none;border-color:#66c0f4;}
.combo-section-label{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:1px;color:#5c6b78;font-weight:600;margin:10px 2px 6px;}
.combo-list{max-height:220px;overflow-y:auto;display:flex;flex-direction:column;gap:2px;}
.combo-item{padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;color:#c7d5e0;}
.combo-item:hover{background:rgba(102,192,244,.12);color:#fff;}
.combo-item.active{background:rgba(102,192,244,.18);color:#fff;}
.combo-empty{padding:10px;font-size:12px;color:#5c6b78;text-align:center;}
.mode-row{padding-top:14px;padding-bottom:14px;}
.mode-select{background:#101925;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#fff;font-size:13px;font-family:'Barlow',system-ui,sans-serif;padding:7px 10px;font-weight:600;cursor:pointer;flex:none;min-width:215px;}
.mode-select:hover{border-color:#66c0f4;}
.mode-select:focus{outline:none;border-color:#66c0f4;}
.picker{margin-top:12px;background:#101925;border:1px solid rgba(102,192,244,.2);border-radius:8px;padding:12px 13px;}
.picker-label{font-family:'JetBrains Mono',monospace;font-size:10.5px;letter-spacing:1px;color:#5c6b78;font-weight:600;}
.picker-rows{display:flex;flex-direction:column;gap:7px;margin-top:10px;}
@@ -79,6 +96,11 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back
.picker-pill:hover{border-color:#66c0f4;background:rgba(102,192,244,.1);}
.chip-sm{width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:11px;flex:none;}
.picker-name{font-size:12px;color:#c7d5e0;font-weight:600;}
.mod-pill{padding:6px 14px;border-radius:7px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.03);color:#aeb9c2;font-size:12px;font-weight:700;letter-spacing:.3px;transition:border-color .15s,color .15s,background .15s;}
.mod-pill:hover{border-color:#66c0f4;color:#66c0f4;}
.mod-pill.on{border-color:#66c0f4;background:rgba(102,192,244,.18);color:#fff;}
.mod-note{font-size:11.5px;color:#5c6b78;line-height:1.45;margin-top:9px;}
.mod-note b{color:#8f98a0;font-weight:700;}
#footer{height:62px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 20px;background:#15202c;border-top:1px solid rgba(0,0,0,.45);}
.footer-right{display:flex;gap:10px;align-items:center;}
.btn-reset{padding:9px 16px;border-radius:3px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);color:#aeb9c2;font-weight:600;font-size:13.5px;transition:color .15s;}
@@ -86,6 +108,17 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back
.btn-apply{padding:9px 22px;border-radius:3px;border:1px solid #4c7a1d;background:linear-gradient(to bottom,#94c63d,#5d8a1f);color:#13260a;font-weight:700;font-size:13.5px;box-shadow:0 1px 0 rgba(255,255,255,.25) inset;min-width:96px;text-align:center;transition:opacity .15s;}
.btn-apply:hover{opacity:.9;}
.btn-apply:disabled{opacity:.7;cursor:default;}
#modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:50;}
.modal{background:#1b2838;border:1px solid rgba(102,192,244,.3);border-radius:10px;box-shadow:0 20px 50px rgba(0,0,0,.6);padding:22px 24px;max-width:420px;}
.modal h3{margin:0 0 8px;font-size:16px;font-weight:800;color:#fff;}
.modal p{margin:0 0 18px;font-size:13.5px;color:#aeb9c2;line-height:1.5;}
.modal-btns{display:flex;gap:9px;justify-content:flex-end;}
.btn-discard{padding:8px 15px;border-radius:4px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);color:#aeb9c2;font-weight:600;font-size:13px;}
.btn-discard:hover{border-color:#c0392b;color:#fff;}
.btn-modal-cancel{padding:8px 15px;border-radius:4px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);color:#aeb9c2;font-weight:600;font-size:13px;}
.btn-modal-cancel:hover{color:#fff;}
.btn-save{padding:8px 18px;border-radius:4px;border:1px solid #4c7a1d;background:linear-gradient(to bottom,#94c63d,#5d8a1f);color:#13260a;font-weight:700;font-size:13px;}
.btn-save:hover{opacity:.9;}
</style>
</head>
<body>
@@ -94,7 +127,7 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back
<div class="left">
<div class="glyph"><div class="glyph-dot"></div></div>
<span class="tb-title">SteamlessController</span>
<span class="tb-sub">&#8212; Back Button Mapping</span>
<span class="tb-sub">&#8212; Customize Controls</span>
</div>
<div class="winctls">
<button class="winctl" id="btn-min" title="Minimize">&#8211;</button>
@@ -104,8 +137,67 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back
<div id="body">
<div>
<h2>Back Button Mapping</h2>
<p class="instr">Click <b>Rebind</b> on a button, then press any gamepad button on your controller &#8212; or any key on your keyboard, or your mouse's middle or thumb buttons. The new binding shows up here instantly. Pick <b>Off</b> to stop a paddle doing anything at all.</p>
<h2>Customize Controls</h2>
<p class="instr">Click <b>Rebind</b> on any button, then press any gamepad button on your controller &#8212; or any key on your keyboard, or your mouse's middle or thumb buttons. The new binding shows up here instantly. Pick <b>Off</b> to stop a button doing anything at all.</p>
</div>
<div class="group">
<div class="group-label">APPLY TO</div>
<div class="combo-wrap">
<button class="combo-toggle" id="combo-toggle">
<span id="combo-current">Default (all other games)</span>
<span class="chev">&#9660;</span>
</button>
<div class="combo-panel" id="combo-panel" style="display:none;">
<input class="combo-search" id="combo-search" type="text" placeholder="Search for a game..." autocomplete="off">
<div id="combo-results"></div>
</div>
</div>
</div>
<div class="group">
<div class="group-label">CONTROLLER PLATFORM</div>
<div class="row mode-row">
<div class="row-top">
<span class="pos-label">Appear to games as</span>
<div class="connector"></div>
<select id="platform" class="mode-select"></select>
</div>
</div>
</div>
<div class="group">
<div class="group-label">LEFT TRACKPAD</div>
<div class="row mode-row">
<div class="row-top">
<span class="pos-label">Movement Mode</span>
<div class="connector"></div>
<select id="mode-LPAD" class="mode-select"></select>
</div>
</div>
<div class="row mode-row" id="dir-row-LPAD">
<div class="row-top">
<span class="pos-label">Scroll Direction</span>
<div class="connector"></div>
<select id="dir-LPAD" class="mode-select"></select>
</div>
</div>
<div id="row-LPAD" class="row"></div>
</div>
<div class="group">
<div class="group-label">RIGHT TRACKPAD</div>
<div class="row mode-row">
<div class="row-top">
<span class="pos-label">Movement Mode</span>
<div class="connector"></div>
<select id="mode-RPAD" class="mode-select"></select>
</div>
</div>
<div class="row mode-row" id="dir-row-RPAD">
<div class="row-top">
<span class="pos-label">Scroll Direction</span>
<div class="connector"></div>
<select id="dir-RPAD" class="mode-select"></select>
</div>
</div>
<div id="row-RPAD" class="row"></div>
</div>
<div class="group">
<div class="group-label">LEFT GRIP</div>
@@ -121,10 +213,22 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back
<div id="footer">
<div class="footer-right">
<button class="btn-reset" id="btn-reset">Reset all to default</button>
<button class="btn-reset" id="btn-reset">Reset this profile to default</button>
<button class="btn-apply" id="btn-apply">Apply</button>
</div>
</div>
<div id="modal-backdrop" style="display:none;">
<div class="modal">
<h3>Unsaved changes</h3>
<p id="modal-text">You have unsaved changes to this profile.</p>
<div class="modal-btns">
<button class="btn-modal-cancel" id="btn-modal-cancel">Cancel</button>
<button class="btn-discard" id="btn-modal-discard">Discard</button>
<button class="btn-save" id="btn-modal-save">Save</button>
</div>
</div>
</div>
)HTML"
// Split here only to stay under MSVC's ~16KB limit on a single string literal
// (C2026); the two halves are concatenated back into one document. Markup and
@@ -133,19 +237,111 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back
R"HTML(
<script>
'use strict';
// ---- Defaults (upper paddles left-click, lower paddles unbound) ----
// Keep in sync with BackButtonConfig in BackButtonConfig.h.
var DEFAULTS = {L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none'};
// ---- Defaults ----
// Upper paddles left-click, lower paddles unbound; the right pad points and
// clicks while the left pad scrolls. Keep in sync with ControllerProfile in
// TrackpadConfig.h — these two are what "default" means, and a fresh install
// and this window's reset button both have to land on the same thing.
var DEFAULTS = {L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none',LPAD:'none',RPAD:'leftMouse'};
var DEFAULT_MODES = {LPAD:'scroll',RPAD:'pointer'};
var DEFAULT_DIRS = {LPAD:'natural',RPAD:'natural'};
var DEFAULT_PLATFORM = 'xbox';
// ---- State ----
var bindings = {L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none'};
var bindings = {L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none',LPAD:'none',RPAD:'none'};
// Trackpad movement modes, keyed by the same row ids their click rows use.
var modes = {LPAD:'none',RPAD:'none'};
// Scroll direction per pad. Only meaningful in scroll mode, but kept for
// every pad so switching modes back and forth does not lose the choice.
var dirs = {LPAD:'natural',RPAD:'natural'};
var platform = 'xbox';
// The DS4 touchpad only exists on a virtual PlayStation pad — an X360 report
// has nothing to carry it. The option stays selectable either way (a profile
// can legitimately be edited before its platform is), but it says so, and
// re-labels the moment the platform dropdown above it changes.
function modeOptions(){
return [
{id:'none', label:'None'},
{id:'pointer', label:'Mouse Pointer'},
{id:'scroll', label:'Scroll Wheel'},
{id:'ds4', label:platform==='ps'?'DS4 Touchpad'
:'DS4 Touchpad (PlayStation only)'},
];
}
var DIR_OPTIONS = [
{id:'natural', label:'Natural'},
{id:'reversed', label:'Reversed'},
];
var PLATFORM_OPTIONS = [
{id:'xbox', label:'Xbox Controller'},
{id:'ps', label:'PlayStation Controller'},
];
var listening = null;
// Modifier bits, matching BackButtonBinding::Modifier.
var MOD = {CTRL:1, ALT:2, SHIFT:4, WIN:8};
var MODIFIER_LIST = [
{bit:MOD.CTRL, label:'Ctrl'},
{bit:MOD.ALT, label:'Alt'},
{bit:MOD.SHIFT, label:'Shift'},
// Cannot be captured by pressing it — Windows opens the Start menu and
// takes focus before this page is told anything. The toggle is the only
// way in, which is why modifiers are chosen rather than typed.
{bit:MOD.WIN, label:'Win'},
];
var MODIFIER_CODES = {
ControlLeft:MOD.CTRL, ControlRight:MOD.CTRL,
AltLeft:MOD.ALT, AltRight:MOD.ALT,
ShiftLeft:MOD.SHIFT, ShiftRight:MOD.SHIFT,
MetaLeft:MOD.WIN, MetaRight:MOD.WIN,
};
// Modifiers armed for the binding currently being captured, from ticking a
// toggle or from pressing the key itself. Cleared whenever listening starts.
var pendingMods = 0;
// Which of those arrived by pressing the physical key rather than ticking a
// toggle — only those can be released, and releasing is what binds them alone.
var modsPressed = 0;
// A capture is in flight. The binding comes back from C++ asynchronously, and
// the user's fingers leave the keys well before it lands; without this the
// keyup of a modifier they were holding would send a second, different one.
var captureSent = false;
var sasWarning = false;
function sendCapture(code, mods){
captureSent=true;
postMsg({type:'keyCaptured',code:code,mods:String(mods)});
}
// What the keyboard reported alongside this keypress. A fallback, not the
// advertised flow: pressing a whole shortcut at once often never reaches this
// page at all, because Windows claims combinations like Alt+Tab first. It
// still earns its place for keys that need a modifier to type — Shift and the
// punctuation key that produces "?" arrive together whether or not the toggle
// was ticked.
function liveMods(e){
return (e.ctrlKey?MOD.CTRL:0)|(e.altKey?MOD.ALT:0)
|(e.shiftKey?MOD.SHIFT:0)|(e.metaKey?MOD.WIN:0);
}
// Display names for key bindings, keyed by binding id. Supplied by C++ from the
// active keyboard layout — the catalog below can't cover them.
var keyLabels = {};
var flash = null;
var flashTimer = null;
var applyTimer = null;
// Per-game picker. Game ids are small indices ("0","1",...) into GAMES, not
// the raw exe path — keeps the hand-rolled JSON channel back to C++ free of
// Unicode and backslash escaping. "" is the always-present default profile.
var GAMES = [];
// Each profile is a flat object: one entry per bindable row id, plus
// "<pad>mode" for each trackpad's movement mode. Flat because the JSON
// reader on the C++ side matches "key":"value" pairs without nesting.
var PROFILES = {'':{platform:'xbox',L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none',LPAD:'none',RPAD:'leftMouse',LPADmode:'scroll',RPADmode:'pointer',LPADdir:'natural',RPADdir:'natural'}};
var currentGame = '';
var comboOpen = false;
var comboQuery = '';
// What the current selection was when it was last loaded or saved — the
// baseline "unsaved changes" is measured against.
var savedProfile = null;
// What to run once the unsaved-changes prompt resolves. Set when the prompt
// opens, because the action that triggered it has to wait for the answer.
var pendingAction = null;
// ---- Input catalog (Steam Controller nomenclature) ----
var INPUTS = [
@@ -185,12 +381,18 @@ var PICKER_ROWS = [
['none'],
];
// Every rebindable row, trackpad clicks included — they are the same kind of
// thing as a paddle and go through the identical rebind/capture flow.
var ROWS = [
{id:'L4',posTag:'UPPER',posLabel:'Upper grip'},
{id:'L5',posTag:'LOWER',posLabel:'Lower grip'},
{id:'R4',posTag:'UPPER',posLabel:'Upper grip'},
{id:'R5',posTag:'LOWER',posLabel:'Lower grip'},
{id:'LPAD',badge:'L',posTag:'PAD',posLabel:'Trackpad Click'},
{id:'RPAD',badge:'R',posTag:'PAD',posLabel:'Trackpad Click'},
];
// The pads, and the dropdown id each one's Movement Mode lives in.
var PADS = ['LPAD','RPAD'];
// ---- WebView2 bridge ----
function postMsg(obj){
@@ -201,9 +403,18 @@ if(window.chrome&&window.chrome.webview){
window.chrome.webview.addEventListener('message',function(e){
var msg=JSON.parse(e.data);
if(msg.type==='init'){
bindings=msg.bindings;
keyLabels=msg.labels||{};
GAMES=msg.games||[];
PROFILES=msg.profiles||PROFILES;
currentGame='';
loadProfileInto(PROFILES['']||{});
closeCombo();
renderComboLabel();
renderModeSelects();
renderAll();
} else if(msg.type==='closeRequested'){
// C++ asks before hiding the window so an unsaved profile isn't lost.
guard(function(){postMsg({type:'close'});});
} else if(msg.type==='buttonCaptured'){
if(!listening) return;
if(msg.label) keyLabels[msg.button]=msg.label;
@@ -216,9 +427,18 @@ if(window.chrome&&window.chrome.webview){
function startListening(rowId){
if(listening&&listening!==rowId) postMsg({type:'stopListening'});
listening=rowId;
pendingMods=0;
modsPressed=0;
captureSent=false;
sasWarning=false;
renderAll();
postMsg({type:'startListening',row:rowId});
}
function toggleMod(bit){
pendingMods^=bit;
sasWarning=false;
renderAll();
}
function cancelListening(){
if(!listening) return;
listening=null;
@@ -240,11 +460,27 @@ function doBind(rowId,inputId){
function resetRow(rowId){
doBind(rowId, DEFAULTS[rowId]);
}
function setMode(padId,modeId){
modes[padId]=modeId;
// The rows below the dropdown depend on the mode: Scroll Direction only
// applies to scrolling, and a DS4 touchpad's click is the touchpad press
// rather than anything rebindable.
if(listening===padId&&modeId==='ds4') cancelListening();
renderPadRows();
}
function setDir(padId,dirId){
dirs[padId]=dirId;
}
function resetDefaults(){
cancelListening();
bindings={L4:DEFAULTS.L4,L5:DEFAULTS.L5,R4:DEFAULTS.R4,R5:DEFAULTS.R5};
platform=DEFAULT_PLATFORM;
bindings={};
ROWS.forEach(function(r){bindings[r.id]=DEFAULTS[r.id];});
modes={}; dirs={};
PADS.forEach(function(p){modes[p]=DEFAULT_MODES[p];dirs[p]=DEFAULT_DIRS[p];});
flash=null;
clearTimeout(flashTimer);
renderModeSelects();
renderAll();
}
function applyBindings(){
@@ -253,7 +489,208 @@ function applyBindings(){
btn.textContent='Applied \u2713';
btn.disabled=true;
applyTimer=setTimeout(function(){btn.textContent='Apply';btn.disabled=false;},1500);
postMsg({type:'apply',bindings:bindings});
saveCurrent();
}
// Flatten the live editor state into the stored/wire profile shape.
function currentProfile(){
var p={platform:platform};
ROWS.forEach(function(r){p[r.id]=bindings[r.id];});
PADS.forEach(function(x){p[x+'mode']=modes[x];p[x+'dir']=dirs[x];});
return p;
}
// Inverse of currentProfile: adopt a stored profile as the live state, filling
// in defaults for anything it does not carry. A profile saved before trackpad
// settings existed has no pad entries, and reads as the unclaimed default.
function loadProfileInto(p){
platform=p.hasOwnProperty('platform')?p.platform:DEFAULT_PLATFORM;
bindings={};
ROWS.forEach(function(r){
bindings[r.id]=p.hasOwnProperty(r.id)?p[r.id]:DEFAULTS[r.id];
});
modes={}; dirs={};
PADS.forEach(function(x){
modes[x]=p.hasOwnProperty(x+'mode')?p[x+'mode']:DEFAULT_MODES[x];
dirs[x] =p.hasOwnProperty(x+'dir') ?p[x+'dir'] :DEFAULT_DIRS[x];
});
savedProfile=currentProfile();
}
// Commit the current state to the selected profile. Split out from the
// Apply button because the unsaved-changes prompt saves without the button's
// transient "Applied" feedback.
function saveCurrent(){
var snapshot=currentProfile();
var p={}; for(var k in PROFILES) p[k]=PROFILES[k];
p[currentGame]=snapshot;
PROFILES=p;
savedProfile=snapshot;
var msg=currentProfile();
msg.type='apply';
msg.game=currentGame;
postMsg(msg);
renderCombo(); // a newly saved game becomes a pinned entry
}
// ---- Unsaved-changes guard ----
function isDirty(){
if(!savedProfile) return false;
var cur=currentProfile();
for(var k in cur) if(cur[k]!==savedProfile[k]) return true;
return false;
}
// Run `action`, but if the current profile has unsaved edits, ask first.
// Every path that would abandon those edits \u2014 switching selection, closing
// the window \u2014 goes through here rather than duplicating the prompt.
function guard(action){
if(!isDirty()){action();return;}
pendingAction=action;
var name=currentGame===''?'the default profile':(gameName(currentGame)||'this game');
document.getElementById('modal-text').textContent=
'You have unsaved changes to '+name+'. Save them before continuing?';
document.getElementById('modal-backdrop').style.display='flex';
}
function resolveModal(choice){
document.getElementById('modal-backdrop').style.display='none';
var action=pendingAction;
pendingAction=null;
if(choice==='cancel') return;
if(choice==='save') saveCurrent();
if(action) action();
}
// ---- Per-game picker (searchable combo box) ----
function gameName(gameId){
for(var i=0;i<GAMES.length;i++) if(GAMES[i].id===gameId) return GAMES[i].name;
return '';
}
function currentLabel(){
return currentGame===''?'Default (all other games)':(gameName(currentGame)||'Unknown game');
}
function renderComboLabel(){
document.getElementById('combo-current').textContent=currentLabel();
}
// Games with a saved profile, plus Default, are pinned: they show with no
// search text, because they are the ones the user has already committed to
// and will come back to. Everything else is reachable only by searching \u2014
// the full list runs to hundreds of entries and scrolling it is not a real
// way to find anything.
function pinnedGames(){
return GAMES.filter(function(g){return PROFILES.hasOwnProperty(g.id);});
}
function renderCombo(){
renderComboLabel();
var results=document.getElementById('combo-results');
if(!results) return;
var q=comboQuery.trim().toLowerCase();
var html='';
var pinned=pinnedGames();
if(q) pinned=pinned.filter(function(g){return g.name.toLowerCase().indexOf(q)>=0;});
var showDefault=!q||'default (all other games)'.indexOf(q)>=0;
if(showDefault||pinned.length){
html+='<div class="combo-section-label">PROFILES</div><div class="combo-list">';
if(showDefault)
html+=itemHTML('','Default (all other games)');
pinned.forEach(function(g){html+=itemHTML(g.id,g.name);});
html+='</div>';
}
if(q){
var pinnedIds={};
pinned.forEach(function(g){pinnedIds[g.id]=true;});
var matches=GAMES.filter(function(g){
return !pinnedIds[g.id]&&g.name.toLowerCase().indexOf(q)>=0;
}).slice(0,50); // a two-letter query can match hundreds; cap the DOM work
if(matches.length){
html+='<div class="combo-section-label">INSTALLED GAMES</div><div class="combo-list">';
matches.forEach(function(g){html+=itemHTML(g.id,g.name);});
html+='</div>';
} else if(!pinned.length&&!showDefault){
html+='<div class="combo-empty">No games match "'+escapeHTML(comboQuery)+'"</div>';
}
}
results.innerHTML=html;
}
function itemHTML(id,name){
var cls='combo-item'+(id===currentGame?' active':'');
return '<div class="'+cls+'" onclick="pickGame(\''+id+'\')">'+escapeHTML(name)+'</div>';
}
function escapeHTML(s){
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function openCombo(){
comboOpen=true;
comboQuery='';
document.getElementById('combo-search').value='';
document.getElementById('combo-panel').style.display='block';
renderCombo();
document.getElementById('combo-search').focus();
}
function closeCombo(){
comboOpen=false;
var panel=document.getElementById('combo-panel');
if(panel) panel.style.display='none';
}
function toggleCombo(){
if(comboOpen) closeCombo(); else openCombo();
}
function pickGame(gameId){
closeCombo();
if(gameId===currentGame) return;
guard(function(){selectGame(gameId);});
}
function selectGame(gameId){
currentGame=gameId;
// A game with no profile of its own starts from the default's settings \u2014
// loadProfileInto copies, so editing here cannot mutate the cached profile.
loadProfileInto(PROFILES[currentGame]||PROFILES['']||{});
cancelListening();
renderComboLabel();
renderModeSelects();
renderAll();
}
// ---- Movement Mode / Scroll Direction dropdowns ----
function fillSelect(id,options,value){
var sel=document.getElementById(id);
if(!sel) return;
sel.innerHTML='';
options.forEach(function(o){
var opt=document.createElement('option');
opt.value=o.id;
opt.textContent=o.label;
sel.appendChild(opt);
});
sel.value=value;
}
function setPlatform(value){
platform=value;
// The DS4 Touchpad option's label depends on this, so the pad dropdowns
// have to be rebuilt rather than left as they are.
renderModeSelects();
}
function renderModeSelects(){
fillSelect('platform',PLATFORM_OPTIONS,platform);
var opts=modeOptions();
PADS.forEach(function(padId){
fillSelect('mode-'+padId,opts,modes[padId]);
fillSelect('dir-'+padId,DIR_OPTIONS,dirs[padId]);
});
renderPadRows();
}
// Show only the rows the current mode actually has settings for.
function renderPadRows(){
PADS.forEach(function(padId){
var dirRow=document.getElementById('dir-row-'+padId);
if(dirRow) dirRow.style.display=(modes[padId]==='scroll')?'':'none';
// A pad feeding the DS4 touchpad has no rebindable click: the press is
// the touchpad press, and offering to remap it would promise behaviour
// the pad cannot deliver.
var clickRow=document.getElementById('row-'+padId);
if(clickRow) clickRow.style.display=(modes[padId]==='ds4')?'none':'';
});
}
// ---- Render helpers ----
// Resolves a binding id to something renderable. Keys aren't in the static
@@ -301,13 +738,24 @@ function renderRow(def){
}).join('');
return '<div class="picker-row">'+pills+'</div>';
}).join('');
var modPills=MODIFIER_LIST.map(function(m){
var on=(pendingMods&m.bit)!==0;
return '<button class="mod-pill'+(on?' on':'')+'" '+
'onclick="toggleMod('+m.bit+')">'+m.label+'</button>';
}).join('');
pickerHTML='<div class="picker">'+
'<div class="picker-label">OR PICK MANUALLY</div>'+
'<div class="picker-label">HOLD WITH THE KEY(S)</div>'+
'<div class="picker-row" style="margin-top:9px;">'+modPills+'</div>'+
(sasWarning
? '<div class="mod-note">Windows reserves Ctrl + Alt + Delete for itself &#8212; '
+ 'no application can send it, so it cannot be bound here.</div>'
: '<div class="mod-note">Pick modifiers, then press the key.</div>')+
'<div class="picker-label" style="margin-top:14px;">OR PICK MANUALLY</div>'+
'<div class="picker-rows">'+pickerRows+'</div></div>';
}
el.innerHTML='<div class="row-top">'+
'<div class="badge">'+
'<span class="badge-id">'+def.id+'</span>'+
'<span class="badge-id">'+(def.badge||def.id)+'</span>'+
'<span class="badge-pos">'+def.posTag+'</span>'+
'</div>'+
'<span class="pos-label">'+def.posLabel+'</span>'+
@@ -321,10 +769,47 @@ function renderAll(){
// ---- Wire static controls ----
document.getElementById('btn-min').onclick=function(){postMsg({type:'minimize'});};
document.getElementById('btn-close').onclick=function(){postMsg({type:'close'});};
document.getElementById('btn-close').onclick=function(){guard(function(){postMsg({type:'close'});});};
document.getElementById('btn-reset').onclick=resetDefaults;
document.getElementById('btn-apply').onclick=applyBindings;
document.getElementById('platform').addEventListener('change',function(e){
setPlatform(e.target.value);
});
PADS.forEach(function(padId){
var modeSel=document.getElementById('mode-'+padId);
if(modeSel) modeSel.addEventListener('change',function(e){setMode(padId,e.target.value);});
var dirSel=document.getElementById('dir-'+padId);
if(dirSel) dirSel.addEventListener('change',function(e){setDir(padId,e.target.value);});
});
// mousedown as well as click: the document-level handler below closes the
// panel on mousedown, which lands before this button's click — without this
// the toggle would close and immediately reopen, never appearing to shut.
document.getElementById('combo-toggle').addEventListener('mousedown',function(e){
e.stopPropagation();
});
document.getElementById('combo-toggle').onclick=function(e){
e.stopPropagation();
toggleCombo();
};
document.getElementById('combo-search').addEventListener('input',function(e){
comboQuery=e.target.value;
renderCombo();
});
// Clicks inside the panel must not reach the document handler below, which
// closes it — that would fire before an item's own onclick could run.
document.getElementById('combo-panel').addEventListener('mousedown',function(e){
e.stopPropagation();
});
document.addEventListener('mousedown',function(){
if(comboOpen) closeCombo();
});
document.getElementById('btn-modal-cancel').onclick=function(){resolveModal('cancel');};
document.getElementById('btn-modal-discard').onclick=function(){resolveModal('discard');};
document.getElementById('btn-modal-save').onclick=function(){resolveModal('save');};
// Title bar drag: mousedown on titlebar (but not on buttons) tells C++ to start a window move.
document.getElementById('titlebar').addEventListener('mousedown',function(e){
if(e.target.closest('.winctls')) return;
@@ -338,7 +823,7 @@ document.getElementById('titlebar').addEventListener('mousedown',function(e){
// available from the picker below instead.
var MOUSE_CAPTURE = {1:'mouse:middle', 3:'mouse:x1', 4:'mouse:x2'};
document.addEventListener('mousedown',function(e){
if(!listening) return;
if(!listening||comboOpen) return;
var id=MOUSE_CAPTURE[e.button];
if(!id) return;
// Suppress the browser defaults these carry: autoscroll on middle, and
@@ -353,13 +838,61 @@ document.addEventListener('auxclick',function(e){
document.addEventListener('keydown',function(e){
// Escape stays the cancel affordance, so it is deliberately not bindable.
if(e.key==='Escape'){cancelListening();return;}
// Innermost dismissable thing first: the modal is the only one that can sit
// on top of the others.
if(e.key==='Escape'){
if(pendingAction!==null||document.getElementById('modal-backdrop').style.display==='flex'){
resolveModal('cancel');
} else if(comboOpen){
closeCombo();
} else {
cancelListening();
}
return;
}
// The search box is a real text field — typing in it must not be swallowed
// by the binding-capture handler below.
if(comboOpen) return;
if(!listening||e.repeat) return;
// Swallow the key so it can't also drive the page (Tab moving focus, Space
// clicking the focused button) while a row is listening.
e.preventDefault();
e.stopPropagation();
postMsg({type:'keyCaptured',code:e.code});
// A modifier on its own is the start of a combination far more often than
// it is the whole binding, so pressing one arms its toggle and waits to see
// what follows. Releasing it without pressing anything else binds the bare
// modifier — see the keyup handler below, which is what keeps a paddle
// bound to plain Ctrl possible.
if(MODIFIER_CODES[e.code]){
pendingMods|=MODIFIER_CODES[e.code];
modsPressed|=MODIFIER_CODES[e.code];
renderAll();
return;
}
// Ctrl+Alt+Delete is the Secure Attention Sequence. Windows will not let any
// ordinary process synthesize it, so binding it would produce a paddle that
// silently does nothing.
if(e.code==='Delete'&&(pendingMods&MOD.CTRL)&&(pendingMods&MOD.ALT)){
sasWarning=true;
renderAll();
return;
}
sendCapture(e.code, pendingMods|liveMods(e));
});
// A modifier released without anything pressed while it was down is the whole
// binding, not the start of one. Any other modifiers still armed ride along,
// so holding Ctrl and tapping Shift binds Ctrl+Shift.
document.addEventListener('keyup',function(e){
if(!listening||captureSent) return;
var bit=MODIFIER_CODES[e.code];
if(!bit||!(modsPressed&bit)) return;
e.preventDefault();
e.stopPropagation();
sendCapture(e.code, pendingMods&~bit);
});
renderAll();
@@ -476,7 +1009,22 @@ LRESULT RemapWindow::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
return 0;
case WM_CLOSE:
// Ask the page first: it owns the in-progress bindings and knows
// whether they differ from what was saved. It answers with a "close"
// message once the user has resolved that, which arrives here as
// WM_CLOSE_CONFIRMED. Without a live webview there is nothing holding
// unsaved state, so hide immediately.
if (m_webview) {
PostToWebView(L"{\"type\":\"closeRequested\"}");
return 0;
}
ShowWindow(hwnd, SW_HIDE);
if (m_onClose) m_onClose();
return 0;
case WM_CLOSE_CONFIRMED:
ShowWindow(hwnd, SW_HIDE);
if (m_onClose) m_onClose();
return 0;
case WM_DESTROY:
@@ -500,14 +1048,18 @@ RemapWindow::~RemapWindow() {
}
void RemapWindow::Open(HINSTANCE hInst, ControllerManager* mgr,
const BackButtonConfig& cfg,
std::function<void(const BackButtonConfig&)> applyCallback)
const ControllerProfile& cfg,
std::vector<InstalledGame> games,
std::map<std::wstring, ControllerProfile> gameProfiles,
std::function<void(const std::wstring&, const ControllerProfile&)> applyCallback)
{
// If already open, just bring it to front.
if (m_hwnd) {
if (IsWindowVisible(m_hwnd)) { BringToFront(); return; }
// Was hidden. Refresh config and show.
m_config = cfg;
m_games = std::move(games);
m_gameProfiles = std::move(gameProfiles);
m_applyCallback = std::move(applyCallback);
ShowWindow(m_hwnd, SW_SHOW);
BringToFront();
@@ -529,6 +1081,8 @@ void RemapWindow::Open(HINSTANCE hInst, ControllerManager* mgr,
m_hInst = hInst;
m_mgr = mgr;
m_config = cfg;
m_games = std::move(games);
m_gameProfiles = std::move(gameProfiles);
m_applyCallback = std::move(applyCallback);
s_instance = this;
@@ -560,7 +1114,7 @@ void RemapWindow::Open(HINSTANCE hInst, ControllerManager* mgr,
GetMonitorInfoW(MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST), &mi);
m_hwnd = CreateWindowExW(
0, CLASS_NAME, L"Back Button Mapping",
0, CLASS_NAME, L"Customize Controls",
WS_POPUP | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX,
mi.rcWork.left, mi.rcWork.top, WINDOW_W, WINDOW_H,
nullptr, nullptr, hInst, nullptr);
@@ -699,7 +1253,7 @@ static std::wstring JsonEscape(const std::wstring& s) {
// has a name for it. Only keys need one — their names come from the layout.
static std::wstring BindingLabel(const BackButtonBinding& binding) {
return binding.kind == BackButtonBinding::Kind::Key
? KeyDisplayName(binding.code)
? KeyComboDisplayName(binding)
: std::wstring();
}
@@ -729,7 +1283,10 @@ void RemapWindow::OnWebMessage(const std::wstring& raw) {
ShowWindow(m_hwnd, SW_MINIMIZE);
} else if (type == "close") {
PostMessageW(m_hwnd, WM_CLOSE, 0, 0);
// The page has resolved any unsaved-changes prompt — this is the
// confirmed close, not a fresh request, so it must not route back
// through WM_CLOSE and ask again.
PostMessageW(m_hwnd, WM_CLOSE_CONFIRMED, 0, 0);
} else if (type == "startListening") {
std::string row = JsonStr(msg, "row");
@@ -755,25 +1312,63 @@ void RemapWindow::OnWebMessage(const std::wstring& raw) {
// binding to nothing.
const uint16_t vk = VkFromJsCode(JsonStr(msg, "code"));
if (vk != 0) {
// The page sends the modifier set it observed, plus whatever the
// user ticked — including Windows, which cannot arrive any other
// way because pressing it opens the Start menu before the page
// ever sees the key.
uint8_t mods = 0;
const std::string modStr = JsonStr(msg, "mods");
for (char c : modStr) {
if (c < '0' || c > '9') { mods = 0; break; }
mods = static_cast<uint8_t>(mods * 10 + (c - '0'));
}
if (m_mgr) m_mgr->StopButtonCapture();
PostCapturedBinding(BackButtonBinding::FromKey(vk));
PostCapturedBinding(BackButtonBinding::FromKey(vk, mods));
}
} else if (type == "stopListening") {
if (m_mgr) m_mgr->StopButtonCapture();
} else if (type == "apply") {
// Extract the four bindings from the nested "bindings" object.
size_t bStart = msg.find("\"bindings\":");
if (bStart != std::string::npos) {
std::string sub = msg.substr(bStart);
BackButtonConfig cfg;
cfg.l4 = BackButtonBinding::FromId(JsonStr(sub, "L4"));
cfg.l5 = BackButtonBinding::FromId(JsonStr(sub, "L5"));
cfg.r4 = BackButtonBinding::FromId(JsonStr(sub, "R4"));
cfg.r5 = BackButtonBinding::FromId(JsonStr(sub, "R5"));
// The page sends one flat object: a binding per row id, plus a mode
// per pad.
ControllerProfile cfg;
cfg.platform = JsonStr(msg, "platform") == "ps" ? ControllerPlatform::PlayStation
: ControllerPlatform::Xbox;
cfg.back.l4 = BackButtonBinding::FromId(JsonStr(msg, "L4"));
cfg.back.l5 = BackButtonBinding::FromId(JsonStr(msg, "L5"));
cfg.back.r4 = BackButtonBinding::FromId(JsonStr(msg, "R4"));
cfg.back.r5 = BackButtonBinding::FromId(JsonStr(msg, "R5"));
cfg.leftPad.click = BackButtonBinding::FromId(JsonStr(msg, "LPAD"));
cfg.rightPad.click = BackButtonBinding::FromId(JsonStr(msg, "RPAD"));
cfg.leftPad.mode = TrackpadModeFromId(JsonStr(msg, "LPADmode"));
cfg.rightPad.mode = TrackpadModeFromId(JsonStr(msg, "RPADmode"));
cfg.leftPad.scrollDir = ScrollDirectionFromId(JsonStr(msg, "LPADdir"));
cfg.rightPad.scrollDir = ScrollDirectionFromId(JsonStr(msg, "RPADdir"));
// "game" is an index into m_games (ASCII decimal), never the raw
// game id — that can contain non-ASCII characters this hand-rolled
// channel would mangle, and backslashes this parser does not unescape.
const std::string gameIdx = JsonStr(msg, "game");
if (gameIdx.empty()) {
m_config = cfg;
if (m_applyCallback) m_applyCallback(cfg);
if (m_applyCallback) m_applyCallback(L"", cfg);
} else {
size_t idx = 0;
bool valid = true;
for (char c : gameIdx) {
if (c < '0' || c > '9') { valid = false; break; }
idx = idx * 10 + static_cast<size_t>(c - '0');
}
if (valid && idx < m_games.size()) {
const std::wstring& gameId = m_games[idx].id;
// Captured here because this is the only place both halves
// are in hand: the picker knows the friendly name, and
// nothing downstream re-enumerates the installed list.
cfg.displayName = m_games[idx].name;
m_gameProfiles[gameId] = cfg;
if (m_applyCallback) m_applyCallback(gameId, cfg);
}
}
}
}
@@ -798,26 +1393,76 @@ void RemapWindow::SendInitState() {
const std::string s = b.Id();
return std::wstring(s.begin(), s.end());
};
auto narrow = [](const char* s) {
const std::string v = s;
return std::wstring(v.begin(), v.end());
};
// One flat object per profile — the page reads it back the same way, and
// the narrow JSON reader on this side cannot descend into nesting.
auto profileJson = [&](const ControllerProfile& p) {
return L"{\"platform\":\""
+ std::wstring(p.platform == ControllerPlatform::PlayStation ? L"ps" : L"xbox")
+ L"\","
L"\"L4\":\"" + wid(p.back.l4) + L"\","
L"\"L5\":\"" + wid(p.back.l5) + L"\","
L"\"R4\":\"" + wid(p.back.r4) + L"\","
L"\"R5\":\"" + wid(p.back.r5) + L"\","
L"\"LPAD\":\"" + wid(p.leftPad.click) + L"\","
L"\"RPAD\":\"" + wid(p.rightPad.click) + L"\","
L"\"LPADmode\":\"" + narrow(TrackpadModeId(p.leftPad.mode)) + L"\","
L"\"RPADmode\":\"" + narrow(TrackpadModeId(p.rightPad.mode)) + L"\","
L"\"LPADdir\":\"" + narrow(ScrollDirectionId(p.leftPad.scrollDir)) + L"\","
L"\"RPADdir\":\"" + narrow(ScrollDirectionId(p.rightPad.scrollDir)) + L"\"}";
};
// Key bindings need a label the page cannot derive on its own — their names
// come from the keyboard layout. Sent as an id→name map so a paddle pair
// bound to the same key contributes one entry.
// bound to the same key contributes one entry. Covers every profile, not
// just the default's, so a key bound only in a per-game override still
// gets a name when that game is selected.
std::wstring labels;
for (const auto* b : { &m_config.l4, &m_config.l5, &m_config.r4, &m_config.r5 }) {
const std::wstring label = BindingLabel(*b);
if (label.empty()) continue;
const std::wstring entry = L"\"" + wid(*b) + L"\":\"" + JsonEscape(label) + L"\"";
if (labels.find(entry) != std::wstring::npos) continue;
auto addLabel = [&](const BackButtonBinding& b) {
const std::wstring label = BindingLabel(b);
if (label.empty()) return;
const std::wstring entry = L"\"" + wid(b) + L"\":\"" + JsonEscape(label) + L"\"";
if (labels.find(entry) != std::wstring::npos) return;
if (!labels.empty()) labels += L",";
labels += entry;
};
auto addProfileLabels = [&](const ControllerProfile& p) {
for (const auto* b : { &p.back.l4, &p.back.l5, &p.back.r4, &p.back.r5,
&p.leftPad.click, &p.rightPad.click })
addLabel(*b);
};
addProfileLabels(m_config);
for (const auto& [id, cfg] : m_gameProfiles)
addProfileLabels(cfg);
// Games list: index-based picker ids so the raw game id — possibly
// non-ASCII, sometimes backslash-laden — never has to cross the
// hand-rolled JSON channel; see OnWebMessage's "apply" handling for the
// other direction.
std::wstring gamesJson;
for (size_t i = 0; i < m_games.size(); ++i) {
if (!gamesJson.empty()) gamesJson += L",";
gamesJson += L"{\"id\":\"" + std::to_wstring(i) + L"\",\"name\":\""
+ JsonEscape(m_games[i].name) + L"\"}";
}
// Profiles: the default plus every game that already has a saved
// override. A listed game with no entry here simply has none yet — the
// page falls back to the default profile's bindings when first selected.
std::wstring profilesJson = L"\"\":" + profileJson(m_config);
for (size_t i = 0; i < m_games.size(); ++i) {
auto it = m_gameProfiles.find(m_games[i].id);
if (it == m_gameProfiles.end()) continue;
profilesJson += L",\"" + std::to_wstring(i) + L"\":" + profileJson(it->second);
}
std::wstring json =
L"{\"type\":\"init\",\"bindings\":{"
L"\"L4\":\"" + wid(m_config.l4) + L"\","
L"\"L5\":\"" + wid(m_config.l5) + L"\","
L"\"R4\":\"" + wid(m_config.r4) + L"\","
L"\"R5\":\"" + wid(m_config.r5) + L"\""
L"},\"labels\":{" + labels + L"}}";
L"{\"type\":\"init\""
L",\"labels\":{" + labels + L"}"
L",\"games\":[" + gamesJson + L"]"
L",\"profiles\":{" + profilesJson + L"}}";
PostToWebView(json);
}
+31 -6
View File
@@ -3,7 +3,12 @@
#include <wrl.h>
#include <WebView2.h>
#include <functional>
#include <map>
#include <vector>
#include "BackButtonConfig.h"
#include "ControllerPlatform.h"
#include "GameLibrary.h"
#include "TrackpadConfig.h"
class ControllerManager;
@@ -17,12 +22,25 @@ public:
// Opens (or re-focuses) the remap window.
// Shows a "not supported" message box and returns without opening if WebView2
// is unavailable (Windows 7/8 or no runtime installed).
// applyCallback fires on the UI thread when the user clicks Apply.
void Open(HINSTANCE hInst, ControllerManager* mgr, const BackButtonConfig& cfg,
std::function<void(const BackButtonConfig&)> applyCallback);
// games populates the per-game picker; gameProfiles supplies whatever
// overrides already exist for entries in it. applyCallback fires on the
// UI thread when the user clicks Apply — the game id identifies whichever
// game was selected, or is empty for the default profile.
void Open(HINSTANCE hInst, ControllerManager* mgr, const ControllerProfile& profile,
std::vector<InstalledGame> games,
std::map<std::wstring, ControllerProfile> gameProfiles,
std::function<void(const std::wstring&, const ControllerProfile&)> applyCallback);
void BringToFront() const;
bool IsOpen() const { return m_hwnd != nullptr; }
// Open in the sense that matters to callers: visible and being edited.
// A window that has been closed is only hidden, not destroyed, so the
// handle alone does not answer this.
bool IsOpen() const { return m_hwnd != nullptr && IsWindowVisible(m_hwnd); }
// Fires when the window is dismissed. Set once and kept for the object's
// lifetime — profile switching is suppressed while the window is up, so
// somebody has to re-evaluate once it comes down.
void SetOnClose(std::function<void()> fn) { m_onClose = std::move(fn); }
private:
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp);
@@ -41,12 +59,19 @@ private:
// Called from the read thread via PostMessage — marshals a captured button
// back to the UI thread so we can call PostWebMessageAsString safely.
static constexpr UINT WM_BUTTON_CAPTURED = WM_APP + 1;
// The page has resolved its unsaved-changes prompt and the window may now
// be hidden. Distinct from WM_CLOSE so the confirmed close does not loop
// back into asking the page again.
static constexpr UINT WM_CLOSE_CONFIRMED = WM_APP + 2;
HWND m_hwnd = nullptr;
HINSTANCE m_hInst = nullptr;
ControllerManager* m_mgr = nullptr;
BackButtonConfig m_config;
std::function<void(const BackButtonConfig&)> m_applyCallback;
ControllerProfile m_config;
std::vector<InstalledGame> m_games;
std::map<std::wstring, ControllerProfile> m_gameProfiles;
std::function<void(const std::wstring&, const ControllerProfile&)> m_applyCallback;
std::function<void()> m_onClose;
Microsoft::WRL::ComPtr<ICoreWebView2Environment> m_env;
Microsoft::WRL::ComPtr<ICoreWebView2Controller> m_controller;
+217
View File
@@ -0,0 +1,217 @@
#include "SteamAppLocator.h"
#include <Windows.h>
#include <cwctype>
namespace {
// Steam's manifests are all the same shape: a quoted key followed by either a
// quoted value or a brace-delimited block. That is little enough grammar to
// read directly, and the alternative — a general VDF library — would be far
// more machinery than two files' worth of key lookups justifies.
//
// Only the tokens matter here, not the tree: every value this needs is
// identified by its key plus how deeply nested it is, so the scanner tracks a
// depth counter instead of building nodes.
class VdfScanner {
public:
explicit VdfScanner(const std::wstring& text) : m_text(text) {}
struct Token {
enum class Kind { String, OpenBrace, CloseBrace, End } kind = Kind::End;
std::wstring value;
};
Token Next() {
while (m_pos < m_text.size()) {
const wchar_t c = m_text[m_pos];
if (c == L'{') { ++m_pos; return { Token::Kind::OpenBrace, {} }; }
if (c == L'}') { ++m_pos; return { Token::Kind::CloseBrace, {} }; }
if (c == L'"') { ++m_pos; return { Token::Kind::String, ReadQuoted() }; }
// Whitespace between tokens, and the odd comment line Steam
// writes into these files.
if (c == L'/' && m_pos + 1 < m_text.size() && m_text[m_pos + 1] == L'/') {
while (m_pos < m_text.size() && m_text[m_pos] != L'\n') ++m_pos;
continue;
}
++m_pos;
}
return {};
}
private:
std::wstring ReadQuoted() {
std::wstring out;
while (m_pos < m_text.size() && m_text[m_pos] != L'"') {
// Paths are written with their separators escaped, so an
// unprocessed value would be full of doubled backslashes.
if (m_text[m_pos] == L'\\' && m_pos + 1 < m_text.size()) {
++m_pos;
out += m_text[m_pos++];
continue;
}
out += m_text[m_pos++];
}
if (m_pos < m_text.size()) ++m_pos; // closing quote
return out;
}
const std::wstring& m_text;
size_t m_pos = 0;
};
std::wstring ReadUtf8File(const std::wstring& path) {
HANDLE f = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (f == INVALID_HANDLE_VALUE) return {};
LARGE_INTEGER size{};
if (!GetFileSizeEx(f, &size) || size.QuadPart <= 0 || size.QuadPart > (8 << 20)) {
CloseHandle(f);
return {};
}
std::string bytes(static_cast<size_t>(size.QuadPart), '\0');
DWORD read = 0;
const bool ok = ReadFile(f, bytes.data(), static_cast<DWORD>(bytes.size()),
&read, nullptr) != FALSE;
CloseHandle(f);
if (!ok) return {};
bytes.resize(read);
const int n = MultiByteToWideChar(CP_UTF8, 0, bytes.data(),
static_cast<int>(bytes.size()), nullptr, 0);
if (n <= 0) return {};
std::wstring text(static_cast<size_t>(n), L'\0');
MultiByteToWideChar(CP_UTF8, 0, bytes.data(), static_cast<int>(bytes.size()),
text.data(), n);
return text;
}
std::wstring SteamInstallPath() {
wchar_t buf[MAX_PATH] = {};
DWORD size = sizeof(buf);
// Written by the client for the current user; the machine-wide key exists
// too but points at the installer's idea of the path, not the user's.
if (RegGetValueW(HKEY_CURRENT_USER, L"Software\\Valve\\Steam", L"SteamPath",
RRF_RT_REG_SZ, nullptr, buf, &size) != ERROR_SUCCESS)
return {};
std::wstring path(buf);
// Steam stores this with forward slashes and in lower case.
for (wchar_t& c : path) if (c == L'/') c = L'\\';
while (!path.empty() && path.back() == L'\\') path.pop_back();
return path;
}
std::wstring NormalizeDir(std::wstring dir) {
for (wchar_t& c : dir) c = static_cast<wchar_t>(towlower(c));
if (!dir.empty() && dir.back() != L'\\') dir += L'\\';
return dir;
}
// Every library root Steam knows about, the default install included.
std::vector<std::wstring> LibraryRoots(const std::wstring& steamPath) {
std::vector<std::wstring> roots{ steamPath };
const std::wstring text = ReadUtf8File(steamPath + L"\\steamapps\\libraryfolders.vdf");
if (text.empty()) return roots;
VdfScanner scanner(text);
int depth = 0;
std::wstring pendingKey;
for (;;) {
const auto tok = scanner.Next();
if (tok.kind == VdfScanner::Token::Kind::End) break;
if (tok.kind == VdfScanner::Token::Kind::OpenBrace) { ++depth; pendingKey.clear(); continue; }
if (tok.kind == VdfScanner::Token::Kind::CloseBrace) { --depth; pendingKey.clear(); continue; }
if (pendingKey.empty()) { pendingKey = tok.value; continue; }
// "libraryfolders" { "0" { "path" "..." } } — depth 2 is where a
// library's own fields live. Anchoring on the depth keeps a "path"
// appearing anywhere else in the file from being mistaken for one.
if (depth == 2 && _wcsicmp(pendingKey.c_str(), L"path") == 0 && !tok.value.empty())
roots.push_back(tok.value);
pendingKey.clear();
}
return roots;
}
// StateFlags is a bitfield; this is the only bit that means the game is
// actually on disk and playable. Without the check a half-downloaded or
// part-uninstalled app claims an install directory that may not exist.
constexpr int kStateFullyInstalled = 4;
} // namespace
void SteamAppLocator::Refresh() {
m_installDirs.clear();
const std::wstring steamPath = SteamInstallPath();
if (steamPath.empty()) return;
for (const auto& root : LibraryRoots(steamPath)) {
const std::wstring appsDir = root + L"\\steamapps";
WIN32_FIND_DATAW fd{};
HANDLE find = FindFirstFileW((appsDir + L"\\appmanifest_*.acf").c_str(), &fd);
if (find == INVALID_HANDLE_VALUE) continue;
do {
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue;
const std::wstring text = ReadUtf8File(appsDir + L"\\" + fd.cFileName);
if (text.empty()) continue;
std::wstring appId, installDir;
int stateFlags = 0;
VdfScanner scanner(text);
int depth = 0;
std::wstring pendingKey;
for (;;) {
const auto tok = scanner.Next();
if (tok.kind == VdfScanner::Token::Kind::End) break;
if (tok.kind == VdfScanner::Token::Kind::OpenBrace) { ++depth; pendingKey.clear(); continue; }
if (tok.kind == VdfScanner::Token::Kind::CloseBrace) { --depth; pendingKey.clear(); continue; }
if (pendingKey.empty()) { pendingKey = tok.value; continue; }
// "AppState" { "appid" "..." "installdir" "..." } — depth 1.
if (depth == 1) {
if (_wcsicmp(pendingKey.c_str(), L"appid") == 0) appId = tok.value;
else if (_wcsicmp(pendingKey.c_str(), L"installdir") == 0) installDir = tok.value;
else if (_wcsicmp(pendingKey.c_str(), L"StateFlags") == 0)
stateFlags = _wtoi(tok.value.c_str());
}
pendingKey.clear();
}
if (appId.empty() || installDir.empty()) continue;
if (!(stateFlags & kStateFullyInstalled)) continue;
const std::wstring full = appsDir + L"\\common\\" + installDir;
if (GetFileAttributesW(full.c_str()) == INVALID_FILE_ATTRIBUTES) continue;
// First library wins. A game present in two libraries is Steam's
// problem to reconcile, and either answer names the same game.
m_installDirs.emplace(appId, NormalizeDir(full));
} while (FindNextFileW(find, &fd));
FindClose(find);
}
}
std::wstring SteamAppLocator::AppIdForPath(const std::wstring& exePath) const {
if (exePath.empty() || m_installDirs.empty()) return {};
std::wstring lower = exePath;
for (wchar_t& c : lower) c = static_cast<wchar_t>(towlower(c));
std::wstring bestId;
size_t bestLen = 0;
for (const auto& [appId, dir] : m_installDirs) {
if (dir.size() <= bestLen) continue; // cannot beat what we have
if (lower.compare(0, dir.size(), dir) != 0) continue;
bestId = appId;
bestLen = dir.size();
}
return bestId;
}
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <map>
#include <string>
#include <vector>
// Maps Steam app ids to where their games are installed, so a running process
// can be traced back to the Steam game it belongs to.
//
// This exists because the two ends do not speak the same language. A Steam
// profile is keyed by app id — that is all a "steam://rungameid/N" shortcut
// carries — while the only thing a foreground window yields is an executable
// path. Steam publishes the missing half on disk: libraryfolders.vdf lists
// every library root, and each steamapps/appmanifest_<id>.acf names the
// folder that app installed into.
//
// Matching on the directory rather than a specific executable is deliberate.
// A game's folder holds its launcher, its anti-cheat service and the game
// itself, and which of those ends up in the foreground varies by title and by
// moment; all of them are equally "that game".
class SteamAppLocator {
public:
// Re-reads Steam's manifests. Cheap — a handful of small text files — but
// it is disk I/O, so callers drive it at meaningful moments rather than
// on every lookup. Safe to call when Steam is not installed at all.
void Refresh();
// The app id whose install directory contains this executable, or empty.
// Longest match wins, so a library that happens to live inside another
// game's folder still resolves to the more specific one.
std::wstring AppIdForPath(const std::wstring& exePath) const;
bool Empty() const { return m_installDirs.empty(); }
size_t Count() const { return m_installDirs.size(); }
private:
// appid -> absolute install directory, lowercased with a trailing
// separator so a prefix test cannot match a sibling folder whose name
// merely starts the same way ("Portal" against "Portal 2").
std::map<std::wstring, std::wstring> m_installDirs;
};
+145
View File
@@ -0,0 +1,145 @@
#pragma once
#include <cstdint>
#include <string>
#include "BackButtonConfig.h"
#include "ControllerPlatform.h"
// What a trackpad's finger movement drives.
//
// Values are stable — persisted as DWORDs. None is 0 so a missing or zeroed
// value reads as "this pad does nothing", which is both the safe default and
// what an unconfigured pad did on Xbox, the default platform.
enum class TrackpadMode : uint8_t {
None = 0, // pad is not used for anything
MousePointer = 1,
ScrollWheel = 2,
// Pad feeds the virtual DS4's touchpad, which only exists in PlayStation
// mode — the X360 report has nothing to carry it. Its click is the
// touchpad press by definition and is therefore not rebindable; letting
// it be both would report one physical press two different ways.
DS4Touchpad = 3,
COUNT
};
inline const char* TrackpadModeId(TrackpadMode m) {
switch (m) {
case TrackpadMode::MousePointer: return "pointer";
case TrackpadMode::ScrollWheel: return "scroll";
case TrackpadMode::DS4Touchpad: return "ds4";
default: return "none";
}
}
inline TrackpadMode TrackpadModeFromId(const std::string& id) {
if (id == "pointer") return TrackpadMode::MousePointer;
if (id == "scroll") return TrackpadMode::ScrollWheel;
if (id == "ds4") return TrackpadMode::DS4Touchpad;
return TrackpadMode::None;
}
// Anything a future build might write that this one does not understand
// degrades to "does nothing" rather than being applied.
inline TrackpadMode TrackpadModeFromDword(uint32_t v) {
return v < static_cast<uint32_t>(TrackpadMode::COUNT)
? static_cast<TrackpadMode>(v)
: TrackpadMode::None;
}
// Which way the content moves when a finger drags across a pad in scroll
// mode. Natural is the touchscreen convention — the content follows the
// finger — and is the default.
enum class ScrollDirection : uint8_t { Natural = 0, Reversed = 1, COUNT };
inline const char* ScrollDirectionId(ScrollDirection d) {
return d == ScrollDirection::Reversed ? "reversed" : "natural";
}
inline ScrollDirection ScrollDirectionFromId(const std::string& id) {
return id == "reversed" ? ScrollDirection::Reversed : ScrollDirection::Natural;
}
inline ScrollDirection ScrollDirectionFromDword(uint32_t v) {
return v < static_cast<uint32_t>(ScrollDirection::COUNT)
? static_cast<ScrollDirection>(v)
: ScrollDirection::Natural;
}
// One physical trackpad's configuration. The click is a BackButtonBinding
// rather than a type of its own: a pad click and a back paddle are the same
// question ("what should this button do"), answered by the same catalog and
// dispatched down the same path.
struct TrackpadSettings {
TrackpadMode mode = TrackpadMode::None;
ScrollDirection scrollDir = ScrollDirection::Natural;
BackButtonBinding click = BackButtonBinding::FromAction(BackButtonAction::None);
bool operator==(const TrackpadSettings& o) const {
return mode == o.mode && scrollDir == o.scrollDir && click == o.click;
}
bool operator!=(const TrackpadSettings& o) const { return !(*this == o); }
// True when this pad's movement is driving the desktop.
bool ClaimedForDesktop() const {
return mode == TrackpadMode::MousePointer || mode == TrackpadMode::ScrollWheel;
}
// True when this pad reaches the game through the virtual DS4's touchpad,
// which also makes its click the touchpad press rather than a binding.
bool FeedsDs4Touchpad() const { return mode == TrackpadMode::DS4Touchpad; }
// The binding this pad's click should dispatch, which is nothing at all
// while the click belongs to the DS4 touchpad. Reading it through here
// rather than touching `click` directly is what stops a binding left over
// from an earlier mode firing behind the hidden UI.
BackButtonBinding EffectiveClick() const {
return FeedsDs4Touchpad() ? BackButtonBinding{} : click;
}
};
// Everything one profile controls — the global default profile and each
// per-game override are both this shape.
//
// The two pads deliberately start out different, which is why these defaults
// live here rather than on TrackpadSettings (one struct, one default, no way
// to say "left scrolls, right points"). Out of the box the controller is a
// usable desktop pointing device: right pad moves the cursor and clicks, left
// pad scrolls. "Reset this profile to default" in the UI restores exactly
// this — keep the two in step.
struct ControllerProfile {
// The game's name as the picker showed it, carried so anything that has
// to talk about this profile later can name it. Profile ids are exe
// paths, steam:// URIs and package identifiers — none of them fit in a
// sentence — and the friendly name is only available while the installed
// list is in hand, which is when the profile is created. Empty for the
// default profile, which is not a game.
//
// Deliberately absent from operator== below: it labels the profile, it is
// not one of the settings the profile applies.
std::wstring displayName;
// Which kind of virtual pad the game sees. Unlike everything else here
// this cannot be changed in place — the ViGEm target is created as one
// type or the other — so applying a profile that changes it tears the
// virtual controller down and builds it again, which a running game sees
// as a controller unplug and replug.
ControllerPlatform platform = ControllerPlatform::Xbox;
BackButtonConfig back;
TrackpadSettings leftPad{
.mode = TrackpadMode::ScrollWheel,
.scrollDir = ScrollDirection::Natural,
.click = BackButtonBinding::FromAction(BackButtonAction::None),
};
TrackpadSettings rightPad{
.mode = TrackpadMode::MousePointer,
.scrollDir = ScrollDirection::Natural,
.click = BackButtonBinding::FromAction(BackButtonAction::LeftMouseButton),
};
bool operator==(const ControllerProfile& o) const {
return platform == o.platform
&& back.l4 == o.back.l4 && back.l5 == o.back.l5
&& back.r4 == o.back.r4 && back.r5 == o.back.r5
&& leftPad == o.leftPad && rightPad == o.rightPad;
}
bool operator!=(const ControllerProfile& o) const { return !(*this == o); }
};
+91 -54
View File
@@ -6,21 +6,20 @@
#include <cstring>
#include <cstdint>
static void SendMouseButton(DWORD flags) {
INPUT input{};
input.type = INPUT_MOUSE;
input.mi.dwFlags = flags;
InputInjection::Send(input, "trackpad-click");
void TrackpadMouse::SetMode(TrackpadMode mode) {
if (mode == m_mode) return;
m_mode = mode;
Reset(); // carried remainders and touch state mean nothing to the new mode
}
void TrackpadMouse::Reset() {
if (m_prevClick) SendMouseButton(MOUSEEVENTF_LEFTUP);
m_touching = false;
m_prevClick = false;
m_prevX = 0;
m_prevY = 0;
m_remX = 0.0f;
m_remY = 0.0f;
m_touching = false;
m_prevX = 0;
m_prevY = 0;
m_remX = 0.0f;
m_remY = 0.0f;
m_scrollRemX = 0.0f;
m_scrollRemY = 0.0f;
m_haveRef = false;
m_travelSent = 0;
}
@@ -52,23 +51,84 @@ void TrackpadMouse::NoteMovementSent(long px, bool haveCursor,
}
}
void TrackpadMouse::UpdatePointer(int dx, int dy) {
// Carry sub-pixel remainders between frames — per-frame deltas scaled by
// sensitivity are often below one pixel, and truncating them each frame
// would discard slow movement entirely.
const float fx = static_cast<float>(dx) * SENSITIVITY + m_remX;
const float fy = static_cast<float>(dy) * SENSITIVITY + m_remY;
const LONG ix = static_cast<LONG>(fx);
const LONG iy = static_cast<LONG>(fy);
m_remX = fx - static_cast<float>(ix);
m_remY = fy - static_cast<float>(iy);
if (ix == 0 && iy == 0) return;
INPUT input{};
input.type = INPUT_MOUSE;
input.mi.dwFlags = MOUSEEVENTF_MOVE;
input.mi.dx = ix;
input.mi.dy = iy;
// Read the cursor before sending: SendInput queues the event rather than
// applying it, so a position read straight after would still be the old one.
POINT before{};
const bool haveBefore = GetCursorPos(&before) != FALSE;
if (InputInjection::Send(input, "trackpad-move")) {
NoteMovementSent(std::labs(ix) + std::labs(iy),
haveBefore, before.x, before.y);
}
}
// Wheel events are quantised to WHEEL_DELTA notches, which is much coarser
// than a pad frame's movement — so the same remainder-carry the pointer path
// uses is what makes slow scrolling work at all here.
//
// Takes raw pad deltas (Y growing upward). Natural scrolling means the
// content follows the finger, which is the opposite sense to the wheel's own
// convention that positive is "away from the user" — hence the inversion
// here rather than at the call site.
void TrackpadMouse::UpdateScroll(int dx, int dy) {
if (m_scrollDir == ScrollDirection::Natural) { dx = -dx; dy = -dy; }
const float fy = static_cast<float>(dy) * SCROLL_SENSITIVITY + m_scrollRemY;
const float fx = static_cast<float>(dx) * SCROLL_SENSITIVITY + m_scrollRemX;
const LONG iy = static_cast<LONG>(fy);
const LONG ix = static_cast<LONG>(fx);
m_scrollRemY = fy - static_cast<float>(iy);
m_scrollRemX = fx - static_cast<float>(ix);
if (iy != 0) {
INPUT input{};
input.type = INPUT_MOUSE;
input.mi.dwFlags = MOUSEEVENTF_WHEEL;
input.mi.mouseData = static_cast<DWORD>(iy);
InputInjection::Send(input, "trackpad-scroll");
}
if (ix != 0) {
INPUT input{};
input.type = INPUT_MOUSE;
input.mi.dwFlags = MOUSEEVENTF_HWHEEL;
input.mi.mouseData = static_cast<DWORD>(ix);
InputInjection::Send(input, "trackpad-hscroll");
}
}
void TrackpadMouse::Update(const uint8_t* buf, size_t n) {
// Back paddles mapped to a mouse button are handled by ControllerManager,
// alongside every other paddle binding.
if (n < 30 || !m_trackpadEnabled) return;
// Pad clicks are handled by ControllerManager, alongside every other
// binding — this is movement only. Only the two desktop-driving modes
// produce anything here; None and DS4Touchpad are both "not the desktop's".
if (n < 30
|| (m_mode != TrackpadMode::MousePointer && m_mode != TrackpadMode::ScrollWheel))
return;
const uint8_t b2 = buf[4];
const uint8_t b3 = buf[5];
const bool touching = m_useLeftTrackpad
? (b3 & SteamController::BTN_TP_LT) != 0
: (b2 & SteamController::BTN_TP_RT) != 0;
const bool clicking = m_useLeftTrackpad
? (b3 & SteamController::BTN_TP_LT_CLICK) != 0
: (b2 & SteamController::BTN_TP_RT_CLICK) != 0;
const bool touching = m_isLeftPad
? (b3 & SteamController::BTN_TP_LT) != 0
: (b2 & SteamController::BTN_TP_RT) != 0;
int16_t x = 0, y = 0;
if (m_useLeftTrackpad) {
if (m_isLeftPad) {
memcpy(&x, buf + 18, 2);
memcpy(&y, buf + 20, 2);
} else {
@@ -77,42 +137,19 @@ void TrackpadMouse::Update(const uint8_t* buf, size_t n) {
}
if (touching && m_touching) {
const int dx = static_cast<int>(x - m_prevX);
const int dy = -static_cast<int>(y - m_prevY);
if (dx != 0 || dy != 0) {
// Carry sub-pixel remainders between frames — per-frame deltas
// scaled by sensitivity are often below one pixel, and truncating
// them each frame would discard slow movement entirely.
const float fx = static_cast<float>(dx) * SENSITIVITY + m_remX;
const float fy = static_cast<float>(dy) * SENSITIVITY + m_remY;
const LONG ix = static_cast<LONG>(fx);
const LONG iy = static_cast<LONG>(fy);
m_remX = fx - static_cast<float>(ix);
m_remY = fy - static_cast<float>(iy);
if (ix != 0 || iy != 0) {
INPUT input{};
input.type = INPUT_MOUSE;
input.mi.dwFlags = MOUSEEVENTF_MOVE;
input.mi.dx = ix;
input.mi.dy = iy;
// Read the cursor before sending: SendInput queues the event
// rather than applying it, so a position read straight after
// would still be the old one.
POINT before{};
const bool haveBefore = GetCursorPos(&before) != FALSE;
if (InputInjection::Send(input, "trackpad-move")) {
NoteMovementSent(std::labs(ix) + std::labs(iy),
haveBefore, before.x, before.y);
}
const int dxRaw = static_cast<int>(x - m_prevX);
const int dyRaw = static_cast<int>(y - m_prevY);
if (dxRaw != 0 || dyRaw != 0) {
if (m_mode == TrackpadMode::MousePointer) {
// Pad Y grows upward, screen Y grows downward.
UpdatePointer(dxRaw, -dyRaw);
} else {
// Raw deltas — UpdateScroll owns the direction convention.
UpdateScroll(dxRaw, dyRaw);
}
}
}
if (touching) { m_prevX = x; m_prevY = y; }
m_touching = touching;
if (clicking != m_prevClick) {
SendMouseButton(clicking ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP);
m_prevClick = clicking;
}
}
+26 -7
View File
@@ -1,25 +1,42 @@
#pragma once
#include <cstdint>
#include <cstddef>
#include "TrackpadConfig.h"
// Desktop input driven by ONE physical trackpad. One instance per pad, so the
// two pads can be configured independently — this used to be a single instance
// switched between pads by a bool, which structurally could not represent
// "both pads doing something".
//
// Only movement lives here. The pad's click is a binding like any other and is
// dispatched by ControllerManager alongside the back paddles, so it works the
// same whatever this pad's movement mode is.
class TrackpadMouse {
public:
void SetTrackpadEnabled(bool enabled) { m_trackpadEnabled = enabled; }
void SetUseLeftTrackpad(bool enabled) { m_useLeftTrackpad = enabled; }
// Which physical pad to read. Set once when the slot is created.
void SetPad(bool isLeftPad) { m_isLeftPad = isLeftPad; }
void SetMode(TrackpadMode mode);
void SetScrollDirection(ScrollDirection dir) { m_scrollDir = dir; }
void Update(const uint8_t* buf, size_t n);
void Reset();
private:
bool m_trackpadEnabled = false;
bool m_useLeftTrackpad = false;
void UpdatePointer(int dx, int dy);
void UpdateScroll(int dx, int dy);
void NoteMovementSent(long px, bool haveCursor, long cursorX, long cursorY);
bool m_isLeftPad = false;
TrackpadMode m_mode = TrackpadMode::None;
ScrollDirection m_scrollDir = ScrollDirection::Natural;
bool m_touching = false;
bool m_prevClick = false;
int16_t m_prevX = 0;
int16_t m_prevY = 0;
float m_remX = 0.0f; // sub-pixel movement carry
float m_remY = 0.0f;
float m_scrollRemX = 0.0f; // sub-detent scroll carry
float m_scrollRemY = 0.0f;
// Movement Windows accepted, checked against the cursor actually moving.
// A refused SendInput reports itself; motion that is accepted and then
@@ -37,6 +54,8 @@ private:
static constexpr long STUCK_TRAVEL_PX = 120;
static constexpr float SENSITIVITY = 0.01125f;
void NoteMovementSent(long px, bool haveCursor, long cursorX, long cursorY);
// Chosen so a full swipe across the pad is a few notches rather than a
// page-length fling: the pad's axes span roughly +/-32000, and one wheel
// detent is WHEEL_DELTA (120).
static constexpr float SCROLL_SENSITIVITY = 0.02f;
};
+253 -72
View File
@@ -3,6 +3,8 @@
#include "ControllerPlatform.h"
#include "DeviceRestart.h"
#include "EventLog.h"
#include "GameLibrary.h"
#include "GameProfiles.h"
#include "InputInjection.h"
#include "steam/SteamController.h"
#include "resource.h"
@@ -55,9 +57,10 @@ TrayApp::TrayApp() {
}
TrayApp::~TrayApp() {
// Stop the watcher before anything else so its callback can't post to a
// window (or reference a controller) that is being torn down.
// Stop the watchers before anything else so their callbacks can't post to
// a window (or reference a controller) that is being torn down.
m_steamWatcher.Stop();
m_foregroundWatcher.Stop();
RemoveTrayIcon();
g_app = nullptr;
}
@@ -152,6 +155,25 @@ bool TrayApp::Init(HINSTANCE hInstance) {
m_steamWatcher.Start([this](SteamState state) {
PostMessageW(m_hwnd, WM_STEAMSTATE, static_cast<WPARAM>(state), 0);
});
// Nothing switches profiles while the window is up, so a profile created
// for the game the user is already in has to be picked up when it closes.
m_remapWindow.SetOnClose([this] { RefreshActiveProfile(); });
// What Steam has installed, for matching a running game back to the app
// id its profile is keyed by.
RefreshSteamApps();
// Per-game profiles. Started unconditionally for the same reason as the
// Steam watcher: the callback decides whether anything applies, and a
// watcher that is already warm makes the first switch instant. Costs one
// hook and no thread — it only runs when the user changes applications.
if (!m_foregroundWatcher.Start([this](const ForegroundIdentity& id) {
OnForegroundChanged(id);
}))
EventLog::Write("PROFILE: foreground watcher could not start — "
"per-game profiles will not switch by themselves");
return true;
}
@@ -182,9 +204,10 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (LOWORD(lp) == NIN_BALLOONUSERCLICK) {
if (m_balloonAction == BalloonAction::EnableDisabledDevice)
EnableDisabledControllerDevice();
else
else if (m_balloonAction == BalloonAction::ViGEmDownload)
ShellExecuteW(nullptr, L"open", L"https://github.com/nefarius/ViGEmBus/releases/latest",
nullptr, nullptr, SW_SHOWNORMAL);
// BalloonAction::None: purely informational, nothing to open.
}
else if (LOWORD(lp) == WM_LBUTTONDBLCLK)
OpenRemapWindow();
@@ -212,25 +235,9 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
TryAcquireController();
}
break;
case IDM_TRACKPAD:
m_controller->SetTrackpadMouseEnabled(!m_controller->IsTrackpadMouseEnabled());
SaveSettings();
break;
case IDM_REMAP_BACK:
OpenRemapWindow();
break;
case IDM_LEFT_TRACKPAD:
m_controller->SetUseLeftTrackpad(!m_controller->IsUseLeftTrackpad());
SaveSettings();
break;
case IDM_PLATFORM_XBOX:
m_controller->SetControllerPlatform(ControllerPlatform::Xbox);
SaveSettings();
break;
case IDM_PLATFORM_PS:
m_controller->SetControllerPlatform(ControllerPlatform::PlayStation);
SaveSettings();
break;
case IDM_MODE_MANUAL:
SetAutoMode(AutoMode::Manual);
break;
@@ -253,7 +260,7 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
break;
case IDM_NOTIFICATIONS:
m_notificationsEnabled = !m_notificationsEnabled;
EventLog::Write("USER: disconnect notifications %s",
EventLog::Write("USER: notifications %s",
m_notificationsEnabled ? "enabled" : "disabled");
SaveSettings();
break;
@@ -354,6 +361,110 @@ void TrayApp::SetAutoMode(AutoMode mode) {
ApplySteamState(m_steamWatcher.GetState());
}
// ---------------------------------------------------------------------------
// Per-game profiles
// ---------------------------------------------------------------------------
// Case-insensitive whole-path comparison. Both sides are already full Win32
// paths — one from the shortcut the profile was created from, the other from
// QueryFullProcessImageNameW — so no canonicalisation is attempted here; a
// game reached through a substituted drive or a symlink is a known gap.
static bool SamePath(const std::wstring& a, const std::wstring& b) {
return !a.empty() && _wcsicmp(a.c_str(), b.c_str()) == 0;
}
std::wstring TrayApp::MatchProfile(const ForegroundIdentity& id) const {
static constexpr wchar_t kSteamPrefix[] = L"steam://rungameid/";
static constexpr size_t kSteamPrefixLen = ARRAYSIZE(kSteamPrefix) - 1;
// Resolved once rather than per profile: the lookup walks every known
// Steam install, and the answer cannot differ between profiles.
const std::wstring steamAppId = m_steamApps.AppIdForPath(id.exePath);
for (const auto& [profileId, profile] : m_gameProfiles) {
if (profileId.rfind(L"aumid:", 0) == 0) {
if (!id.aumid.empty()
&& _wcsicmp(profileId.c_str() + 6, id.aumid.c_str()) == 0)
return profileId;
} else if (profileId.rfind(kSteamPrefix, 0) == 0) {
if (!steamAppId.empty() && profileId.compare(kSteamPrefixLen,
std::wstring::npos,
steamAppId) == 0)
return profileId;
} else if (SamePath(profileId, id.exePath)) {
return profileId;
}
}
return {};
}
void TrayApp::RefreshSteamApps() {
m_steamApps.Refresh();
EventLog::Write("PROFILE: %zu installed Steam app(s) located for profile matching",
m_steamApps.Count());
}
void TrayApp::ActivateProfile(const std::wstring& gameId) {
if (gameId == m_activeGameId) return;
const ControllerProfile* profile = &m_defaultProfile;
if (!gameId.empty()) {
auto it = m_gameProfiles.find(gameId);
// A profile deleted between resolving and applying leaves nothing to
// switch to; the default is the right answer, not a stale pointer.
if (it == m_gameProfiles.end()) return ActivateProfile({});
profile = &it->second;
}
m_activeGameId = gameId;
EventLog::Write("PROFILE: switched to %ls",
gameId.empty() ? L"the default profile" : gameId.c_str());
m_controller->SetProfile(*profile);
if (gameId.empty()) return; // returning to the default is not news
// Told once per game rather than on every activation. Alt-tabbing out of
// a game and back in reloads its profile, and a balloon on each round
// trip would be noise — the point is to confirm the profile was found at
// all, which the first one does.
if (gameId == m_toastedGameId) return;
m_toastedGameId = gameId;
if (!m_notificationsEnabled) return;
// Falls back to the id when the profile predates display names being
// stored; an exe path is poor prose but it still identifies the game.
const std::wstring& name = profile->displayName.empty() ? gameId
: profile->displayName;
ShowAlertBalloon(L"Custom controls loaded",
L"Custom controller profile for " + name + L" loaded.",
BalloonAction::None, NIIF_INFO);
}
void TrayApp::OnForegroundChanged(const ForegroundIdentity& id) {
// Auto Mode owns whether the controller is ours at all. When it has been
// yielded there is no input to rebind — Steam is driving the pad — so
// there is nothing here to decide.
if (!m_controller->IsGameModeActive()) return;
// The customization window edits one profile while another could be
// running underneath it. Switching out from under the user mid-edit
// would apply their changes to the wrong game.
if (m_remapWindow.IsOpen()) return;
ActivateProfile(MatchProfile(id));
}
void TrayApp::RefreshActiveProfile() {
if (!m_controller->IsGameModeActive()) {
// Nothing of ours is running; drop back so re-acquiring starts from a
// known state rather than from whatever was live when control went.
ActivateProfile({});
return;
}
if (m_remapWindow.IsOpen()) return;
ActivateProfile(MatchProfile(ForegroundWatcher::Current()));
}
bool TrayApp::WantControl(SteamState state) const {
switch (m_autoMode) {
case AutoMode::OffWhileSteam: return state == SteamState::NoSteam;
@@ -388,6 +499,10 @@ void TrayApp::ReleaseControl() {
m_acquireRetries = 0;
m_lastCycleTick = 0;
m_cycleInFlight = false; // cleared with the tick it is measured against
// Whatever game profile was live belongs to a controller we no longer
// have. Dropping it now means re-acquiring starts from the default and
// re-resolves, rather than resuming a profile for a game long since quit.
ActivateProfile({});
const bool hadControl = m_controller->IsGameModeActive();
// Good citizen: restore lizard mode and close our HID handles so
// Steam can claim the controller without contention.
@@ -449,6 +564,10 @@ void TrayApp::TryAcquireController(uint32_t stateWaitMs) {
KillTimer(m_hwnd, IDT_WAKE_POLL);
m_acquireRetries = 0;
m_lastCycleTick = 0;
// The controller is ours again, and the user may have been sitting in
// a game the whole time it was not — no foreground change is coming
// to tell us, so ask.
RefreshActiveProfile();
return;
}
if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger
@@ -819,13 +938,33 @@ void TrayApp::ShowElevationBalloon() {
}
void TrayApp::OpenRemapWindow() {
// The moment a user is most likely to have installed something since the
// app started, and the moment a stale list would matter most — profiles
// created here are matched against it.
RefreshSteamApps();
// Synchronous on the UI thread — the Start Menu walk plus the
// shell:AppsFolder enumeration together are still fast enough in
// practice (local disk and COM calls, no network) to not need a
// background thread for an action the user just explicitly asked to
// open a window for.
m_remapWindow.Open(
m_hInstance,
m_controller.get(),
m_controller->GetBackButtonConfig(),
[this](const BackButtonConfig& cfg) {
m_controller->SetBackButtonConfig(cfg);
SaveSettings();
m_defaultProfile,
GameLibrary::EnumerateInstalled(),
m_gameProfiles,
[this](const std::wstring& gameId, const ControllerProfile& profile) {
if (gameId.empty()) {
m_defaultProfile = profile;
SaveSettings();
} else {
m_gameProfiles[gameId] = profile;
GameProfiles::Save(m_gameProfiles);
}
// Editing whichever profile happens to be live should be visible
// straight away; editing any other one must not disturb it.
if (gameId == m_activeGameId) m_controller->SetProfile(profile);
});
}
@@ -873,7 +1012,7 @@ void TrayApp::UpdateTrayIcon(bool connected, bool gameModeActive, bool vigemMiss
}
void TrayApp::ShowAlertBalloon(const std::wstring& title, const std::wstring& text,
BalloonAction action) {
BalloonAction action, DWORD infoFlags) {
m_balloonAction = action;
NOTIFYICONDATAW nid{};
@@ -881,7 +1020,7 @@ void TrayApp::ShowAlertBalloon(const std::wstring& title, const std::wstring& te
nid.hWnd = m_hwnd;
nid.uID = TRAY_UID;
nid.uFlags = NIF_INFO;
nid.dwInfoFlags = NIIF_WARNING;
nid.dwInfoFlags = infoFlags;
wcsncpy_s(nid.szInfoTitle, title.c_str(), _TRUNCATE);
wcsncpy_s(nid.szInfo, text.c_str(), _TRUNCATE);
Shell_NotifyIconW(NIM_MODIFY, &nid);
@@ -1091,21 +1230,19 @@ void TrayApp::LoadSettings() {
m_autoMode = mode == 2 ? AutoMode::OffOnlyInGame
: mode == 1 ? AutoMode::OffWhileSteam
: AutoMode::Manual;
m_controller->SetTrackpadMouseEnabled(readDw(L"TrackpadMouse", 0) != 0);
m_controller->SetUseLeftTrackpad (readDw(L"UseLeftTrackpad", 0) != 0);
m_controller->SetControllerPlatform (readDw(L"ControllerPlatform", 0) != 0
? ControllerPlatform::PlayStation
: ControllerPlatform::Xbox);
const bool isPlayStation = readDw(L"ControllerPlatform", 0) != 0;
// Existing installs keep exactly the bindings they had — the new
// click-by-default only applies to fresh installs, which never reach here
// and so take the BackButtonConfig defaults.
const DWORD unbound = BackButtonBinding::FromAction(BackButtonAction::None).Pack();
BackButtonConfig cfg;
cfg.l4 = BackButtonBinding::Unpack(readDw(L"BackBtnL4", unbound));
cfg.l5 = BackButtonBinding::Unpack(readDw(L"BackBtnL5", unbound));
cfg.r4 = BackButtonBinding::Unpack(readDw(L"BackBtnR4", unbound));
cfg.r5 = BackButtonBinding::Unpack(readDw(L"BackBtnR5", unbound));
ControllerProfile profile;
profile.platform = isPlayStation ? ControllerPlatform::PlayStation
: ControllerPlatform::Xbox;
profile.back.l4 = BackButtonBinding::Unpack(readDw(L"BackBtnL4", unbound));
profile.back.l5 = BackButtonBinding::Unpack(readDw(L"BackBtnL5", unbound));
profile.back.r4 = BackButtonBinding::Unpack(readDw(L"BackBtnR4", unbound));
profile.back.r5 = BackButtonBinding::Unpack(readDw(L"BackBtnR5", unbound));
// Migrate the retired "Back Buttons as Mouse Click" toggle into real
// bindings. The toggle used to hijack L4/R4 for a left click and silently
@@ -1115,10 +1252,50 @@ void TrayApp::LoadSettings() {
const bool migrateBackMouse = readDw(L"BackButtons", 0) != 0;
if (migrateBackMouse) {
const auto leftClick = BackButtonBinding::FromAction(BackButtonAction::LeftMouseButton);
if (cfg.l4.IsAction(BackButtonAction::None)) cfg.l4 = leftClick;
if (cfg.r4.IsAction(BackButtonAction::None)) cfg.r4 = leftClick;
if (profile.back.l4.IsAction(BackButtonAction::None)) profile.back.l4 = leftClick;
if (profile.back.r4.IsAction(BackButtonAction::None)) profile.back.r4 = leftClick;
}
m_controller->SetBackButtonConfig(cfg);
// Per-pad settings. Installs that predate them carry the two retired
// toggles instead — one pad, mouse or nothing — which is expressible in
// the new shape exactly: the chosen pad becomes a pointer whose click is
// the left button it was always hardwired to, and the other pad stays
// unclaimed. "PadsMigrated" marks the conversion done, so a later choice
// of "no pad at all" is not overwritten on the next launch by the stale
// toggles this reads.
const bool migratePads = readDw(L"PadsMigrated", 0) == 0;
if (migratePads) {
// A pad the old build was not using for the mouse still reached the
// game as a DS4 touchpad on PlayStation, and did nothing at all on
// Xbox. Both are now explicit modes, so the migration has to name the
// right one rather than let them share a default.
//
// Assigned whole rather than field by field: ControllerProfile's
// defaults describe a fresh install, and an upgrade is not one. Left
// partially overwritten, the pad that was not driving the mouse would
// pick up the fresh-install click binding it never had.
const TrackpadMode idle = isPlayStation ? TrackpadMode::DS4Touchpad
: TrackpadMode::None;
profile.leftPad = TrackpadSettings{ .mode = idle };
profile.rightPad = TrackpadSettings{ .mode = idle };
if (readDw(L"TrackpadMouse", 0) != 0) {
auto& pad = readDw(L"UseLeftTrackpad", 0) != 0 ? profile.leftPad
: profile.rightPad;
pad.mode = TrackpadMode::MousePointer;
pad.click = BackButtonBinding::FromAction(BackButtonAction::LeftMouseButton);
}
} else {
profile.leftPad.mode = TrackpadModeFromDword(readDw(L"LeftPadMode", 0));
profile.leftPad.click = BackButtonBinding::Unpack(readDw(L"LeftPadClick", unbound));
profile.leftPad.scrollDir = ScrollDirectionFromDword(readDw(L"LeftPadScrollDir", 0));
profile.rightPad.mode = TrackpadModeFromDword(readDw(L"RightPadMode", 0));
profile.rightPad.click = BackButtonBinding::Unpack(readDw(L"RightPadClick", unbound));
profile.rightPad.scrollDir = ScrollDirectionFromDword(readDw(L"RightPadScrollDir", 0));
}
m_defaultProfile = profile;
m_activeGameId.clear();
m_controller->SetProfile(m_defaultProfile);
m_notificationsEnabled = readDw(L"ShowNotifications", 1) != 0;
@@ -1130,13 +1307,20 @@ void TrayApp::LoadSettings() {
RegCloseKey(key);
// Persist the migrated bindings and drop the retired value, so the fill-in
// above runs exactly once and a later choice of "Off" is not resurrected.
if (migrateBackMouse) {
m_gameProfiles = GameProfiles::Load();
// Persist whatever was migrated and drop the retired values, so each
// fill-in above runs exactly once and a later choice of "Off" is not
// resurrected on the next launch.
if (migrateBackMouse || migratePads) {
SaveSettings();
HKEY writeKey;
if (RegOpenKeyExW(HKEY_CURRENT_USER, REG_KEY, 0, KEY_SET_VALUE, &writeKey) == ERROR_SUCCESS) {
RegDeleteValueW(writeKey, L"BackButtons");
if (migrateBackMouse) RegDeleteValueW(writeKey, L"BackButtons");
if (migratePads) {
RegDeleteValueW(writeKey, L"TrackpadMouse");
RegDeleteValueW(writeKey, L"UseLeftTrackpad");
}
RegCloseKey(writeKey);
}
}
@@ -1157,15 +1341,27 @@ void TrayApp::SaveSettings() {
writeDw(L"AutoSteamMode", static_cast<DWORD>(m_autoMode));
writeDw(L"StartupMechanism", static_cast<DWORD>(m_startupMechanism));
writeDw(L"ShowNotifications", m_notificationsEnabled ? 1 : 0);
writeDw(L"TrackpadMouse", m_controller->IsTrackpadMouseEnabled() ? 1 : 0);
writeDw(L"UseLeftTrackpad", m_controller->IsUseLeftTrackpad() ? 1 : 0);
writeDw(L"ControllerPlatform", m_controller->GetControllerPlatform() == ControllerPlatform::PlayStation ? 1 : 0);
const auto& cfg = m_controller->GetBackButtonConfig();
writeDw(L"BackBtnL4", cfg.l4.Pack());
writeDw(L"BackBtnL5", cfg.l5.Pack());
writeDw(L"BackBtnR4", cfg.r4.Pack());
writeDw(L"BackBtnR5", cfg.r5.Pack());
// Always the default profile, never ControllerManager's live one: a game
// profile can be active here, and persisting that would overwrite the
// user's own settings with a per-game override.
const auto& profile = m_defaultProfile;
writeDw(L"ControllerPlatform",
profile.platform == ControllerPlatform::PlayStation ? 1 : 0);
writeDw(L"BackBtnL4", profile.back.l4.Pack());
writeDw(L"BackBtnL5", profile.back.l5.Pack());
writeDw(L"BackBtnR4", profile.back.r4.Pack());
writeDw(L"BackBtnR5", profile.back.r5.Pack());
writeDw(L"LeftPadMode", static_cast<DWORD>(profile.leftPad.mode));
writeDw(L"LeftPadClick", profile.leftPad.click.Pack());
writeDw(L"LeftPadScrollDir", static_cast<DWORD>(profile.leftPad.scrollDir));
writeDw(L"RightPadMode", static_cast<DWORD>(profile.rightPad.mode));
writeDw(L"RightPadClick", profile.rightPad.click.Pack());
writeDw(L"RightPadScrollDir", static_cast<DWORD>(profile.rightPad.scrollDir));
// Written last and unconditionally: its presence is what tells the next
// launch the per-pad values above are authoritative.
writeDw(L"PadsMigrated", 1);
RegCloseKey(key);
}
@@ -1173,8 +1369,6 @@ void TrayApp::SaveSettings() {
void TrayApp::ShowContextMenu() {
bool connected = m_controller->IsConnected();
bool gameModeOn = m_controller->IsGameModeActive();
bool trackpadOn = m_controller->IsTrackpadMouseEnabled();
bool leftPad = m_controller->IsUseLeftTrackpad();
bool startupOn = m_startupEnabled;
HMENU menu = CreatePopupMenu();
@@ -1219,24 +1413,11 @@ void TrayApp::ShowContextMenu() {
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(menu, MF_STRING | (trackpadOn ? MF_CHECKED : MF_UNCHECKED),
IDM_TRACKPAD, L"Enable Trackpad Mouse");
AppendMenuW(menu, MF_STRING | (leftPad ? MF_CHECKED : MF_UNCHECKED),
IDM_LEFT_TRACKPAD, L"Use Left Trackpad Instead");
AppendMenuW(menu, MF_STRING, IDM_REMAP_BACK, L"Remap Back Buttons...");
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
bool isPS = (m_controller->GetControllerPlatform() == ControllerPlatform::PlayStation);
HMENU platformMenu = CreatePopupMenu();
AppendMenuW(platformMenu, MF_STRING | (!isPS ? MF_CHECKED : MF_UNCHECKED),
IDM_PLATFORM_XBOX, L"Xbox");
AppendMenuW(platformMenu, MF_STRING | (isPS ? MF_CHECKED : MF_UNCHECKED),
IDM_PLATFORM_PS, L"PlayStation");
AppendMenuW(menu, MF_STRING | MF_POPUP,
reinterpret_cast<UINT_PTR>(platformMenu), L"Controller Platform");
// Everything about how the controller behaves — trackpads, paddles, and
// which kind of pad the game sees — is configured in one window now, per
// profile. This menu used to carry three separate entries for pieces of
// that, none of which could be set per game.
AppendMenuW(menu, MF_STRING, IDM_REMAP_BACK, L"Customize Controls...");
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
@@ -1246,7 +1427,7 @@ void TrayApp::ShowContextMenu() {
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(menu, MF_STRING, IDM_OPENLOG, L"Open Event Log");
AppendMenuW(menu, MF_STRING | (m_notificationsEnabled ? MF_CHECKED : MF_UNCHECKED),
IDM_NOTIFICATIONS, L"Disconnect Notifications");
IDM_NOTIFICATIONS, L"Show Notifications");
AppendMenuW(menu, MF_STRING, IDM_EXIT, L"Exit");
// SetForegroundWindow is required for the menu to dismiss on click-away.
+58 -7
View File
@@ -4,7 +4,10 @@
#include <mutex>
#include <string>
#include "DeviceRestart.h"
#include "ForegroundWatcher.h"
#include "GameProfiles.h"
#include "RemapWindow.h"
#include "SteamAppLocator.h"
#include "SteamWatcher.h"
class ControllerManager;
@@ -37,9 +40,13 @@ private:
// What clicking the balloon should do. Balloons are transient and the click
// arrives long after the call that raised one, so the action travels with
// it rather than being inferred at click time.
enum class BalloonAction { ViGEmDownload, EnableDisabledDevice };
enum class BalloonAction { ViGEmDownload, EnableDisabledDevice, None };
// infoFlags picks the icon Windows draws: a warning by default, because
// most of these report something the user has to act on. Purely
// informational notices pass NIIF_INFO and BalloonAction::None.
void ShowAlertBalloon(const std::wstring& title, const std::wstring& text,
BalloonAction action = BalloonAction::ViGEmDownload);
BalloonAction action = BalloonAction::ViGEmDownload,
DWORD infoFlags = NIIF_WARNING);
void OpenEventLog();
void ShowContextMenu();
void LoadSettings();
@@ -59,6 +66,25 @@ private:
void DeleteLegacyStartupTask();
void UpdateStartupRegistration();
// Per-game profile switching. The foreground application decides which
// profile is live; see ForegroundWatcher for why focus rather than
// "is it running" is the question being asked.
void OnForegroundChanged(const ForegroundIdentity& id);
// The profile id matching an application, or empty when none does.
std::wstring MatchProfile(const ForegroundIdentity& id) const;
// Make `gameId`'s profile the live one (empty selects the default).
// Cheap and idempotent when it is already live.
void ActivateProfile(const std::wstring& gameId);
// Re-resolve the foreground now, for the moments where the answer can
// have changed while nobody was listening — taking the controller back,
// or closing the window that suppresses switching.
void RefreshActiveProfile();
// Re-read Steam's install manifests. Driven by events rather than a
// timer: startup, and whenever the user opens the window where profiles
// are created. A game installed after that is invisible until one of
// those happens again, which is the price of not polling the disk.
void RefreshSteamApps();
// Control plumbing, shared by every mode.
void SetAutoMode(AutoMode mode);
bool WantControl(SteamState state) const;
@@ -99,7 +125,10 @@ private:
bool m_vigemBalloonShown = false;
bool m_startupEnabled = false;
int m_startupMechanism = 0; // 0 none, 1 Run key, 2 elevated task
bool m_notificationsEnabled = true; // disconnect/stall balloons
// Every balloon this app raises, not just the disconnect and stall ones
// it started out covering — a per-game profile loading is announced
// through it too.
bool m_notificationsEnabled = true;
BalloonAction m_balloonAction = BalloonAction::ViGEmDownload;
// Unbiased interrupt time at the last heartbeat, and the tick this instance
// started. Unbiased so that time the machine spent asleep does not read as
@@ -120,16 +149,38 @@ private:
std::wstring m_alertText; // consumed on WM_ALERT
std::unique_ptr<ControllerManager> m_controller;
SteamWatcher m_steamWatcher;
ForegroundWatcher m_foregroundWatcher;
// Bridges a Steam profile's app id to the running executable. Refreshed
// at the few moments the answer can have changed — see RefreshSteamApps.
SteamAppLocator m_steamApps;
RemapWindow m_remapWindow;
// Per-game overrides, keyed by GameLibrary's opaque game id.
// Most users never populate this; empty by default.
std::map<std::wstring, ControllerProfile> m_gameProfiles;
// The user's own settings, and the only profile that is ever persisted to
// the app's registry key. Deliberately not the same thing as whatever
// ControllerManager is running: while a game profile is active that is a
// per-game override, and writing it back would quietly replace the user's
// defaults with some game's bindings.
ControllerProfile m_defaultProfile;
// Which game's profile is live, or empty for the default. The key into
// m_gameProfiles, so it is also the answer to "did the active profile
// actually change" without comparing whole profiles.
std::wstring m_activeGameId;
// The game the "profile loaded" balloon last named, so alt-tabbing in and
// out of one game does not raise it over and over.
std::wstring m_toastedGameId;
static constexpr UINT IDM_TOGGLE = 1001;
static constexpr UINT IDM_EXIT = 1002;
static constexpr UINT IDM_TRACKPAD = 1003;
// 1003 and 1005 were the retired "Enable Trackpad Mouse" and "Use Left
// Trackpad Instead" items — left unused rather than reassigned, so a
// stale menu message from an old build can't land on a new command.
static constexpr UINT IDM_REMAP_BACK = 1004;
static constexpr UINT IDM_LEFT_TRACKPAD = 1005;
static constexpr UINT IDM_STARTUP = 1006;
static constexpr UINT IDM_PLATFORM_XBOX = 1008;
static constexpr UINT IDM_PLATFORM_PS = 1009;
// 1008 and 1009 were the retired "Controller Platform" submenu items,
// now a dropdown in the customization window. Left unused rather than
// reassigned, like 1003/1005 above.
static constexpr UINT IDM_MODE_MANUAL = 1010;
static constexpr UINT IDM_MODE_STEAM = 1011;
static constexpr UINT IDM_MODE_GAME = 1012;
+28 -12
View File
@@ -274,19 +274,18 @@ void VirtualController::SetBatteryState(uint8_t levelPercent, uint8_t chargeStat
m_ds4BatterySpecial = static_cast<uint8_t>(0x10 | level);
}
void VirtualController::SetTrackpadMouseClaim(bool enabled, bool useLeftTrackpad) {
m_trackpadMouseEnabled = enabled;
m_useLeftTrackpadForMouse = useLeftTrackpad;
}
void VirtualController::Update(const uint8_t* buf, size_t n,
const BackButtonConfig& backCfg) {
const ControllerProfile& profile) {
if (!m_valid) return;
const BackButtonConfig& backCfg = profile.back;
if (m_platform == ControllerPlatform::PlayStation) {
// Suppress a pad from the DS4 touchpad report when it is claimed for mouse use.
const bool suppressRight = m_trackpadMouseEnabled && !m_useLeftTrackpadForMouse;
const bool suppressLeft = m_trackpadMouseEnabled && m_useLeftTrackpadForMouse;
// A pad reaches the DS4 touchpad only when that is what it was set
// to. Decided per pad, so one pad can drive the desktop while the
// other still reaches the game — and a pad set to None reaches
// neither.
const bool leftToTouchpad = profile.leftPad.FeedsDs4Touchpad();
const bool rightToTouchpad = profile.rightPad.FeedsDs4Touchpad();
DS4_REPORT_EX ex{};
auto& r = ex.Report;
@@ -367,6 +366,13 @@ void VirtualController::Update(const uint8_t* buf, size_t n,
if (n > 3) {
if (buf[3] & SteamController::BTN_R5) applyBack(backCfg.r5);
}
// Pad clicks bound to a gamepad action reach the virtual pad the
// same way the paddles do. EffectiveClick yields nothing for a
// pad feeding the touchpad, whose press is reported below instead.
if (n > 5 && (buf[5] & SteamController::BTN_TP_LT_CLICK))
applyBack(profile.leftPad.EffectiveClick());
if (n > 4 && (buf[4] & SteamController::BTN_TP_RT_CLICK))
applyBack(profile.rightPad.EffectiveClick());
DS4_DPAD_DIRECTIONS hat = DS4_BUTTON_DPAD_NONE;
if (dUp && dRt) hat = DS4_BUTTON_DPAD_NORTHEAST;
@@ -385,10 +391,13 @@ void VirtualController::Update(const uint8_t* buf, size_t n,
const bool rawRightClick = (b2 & SteamController::BTN_TP_RT_CLICK) != 0;
const bool rawLeftClick = (b3 & SteamController::BTN_TP_LT_CLICK) != 0;
const bool rightTouching = rawRightTouching && !suppressRight;
const bool leftTouching = rawLeftTouching && !suppressLeft;
const bool rightTouching = rawRightTouching && rightToTouchpad;
const bool leftTouching = rawLeftTouching && leftToTouchpad;
if ((rawRightClick && !suppressRight) || (rawLeftClick && !suppressLeft))
// The click travels with the pad: a pad feeding the touchpad
// reports its press as the touchpad press, and a pad doing
// anything else has already dispatched that press as a binding.
if ((rawRightClick && rightToTouchpad) || (rawLeftClick && leftToTouchpad))
r.bSpecial |= DS4_SPECIAL_BUTTON_TOUCHPAD;
if (rightTouching && !m_wasRightTouching)
@@ -476,6 +485,13 @@ void VirtualController::Update(const uint8_t* buf, size_t n,
if (n > 3) {
if (buf[3] & SteamController::BTN_R5) ApplyBackActionX360(backCfg.r5, report);
}
// Pad clicks bound to a gamepad action. EffectiveClick still applies:
// a pad set to DS4 Touchpad has no binding to deliver, and on this
// platform no touchpad to deliver it to either.
if (n > 5 && (buf[5] & SteamController::BTN_TP_LT_CLICK))
ApplyBackActionX360(profile.leftPad.EffectiveClick(), report);
if (n > 4 && (buf[4] & SteamController::BTN_TP_RT_CLICK))
ApplyBackActionX360(profile.rightPad.EffectiveClick(), report);
vigem_target_x360_update(static_cast<PVIGEM_CLIENT>(m_client),
static_cast<PVIGEM_TARGET>(m_target),
+4 -8
View File
@@ -6,6 +6,7 @@
#include <thread>
#include "BackButtonConfig.h"
#include "ControllerPlatform.h"
#include "TrackpadConfig.h"
class VirtualController {
public:
@@ -25,16 +26,12 @@ public:
const char* FailStage() const { return m_failStage; }
uint32_t LastError() const { return m_lastError; }
void Update(const uint8_t* buf, size_t n, const BackButtonConfig& backCfg);
void Update(const uint8_t* buf, size_t n, const ControllerProfile& profile);
void OnRumble(uint8_t largeMotor, uint8_t smallMotor);
// DS4-only: update battery level shown in the DS4 report.
void SetBatteryState(uint8_t levelPercent, uint8_t chargeState);
// Tell VirtualController which (if any) trackpad is claimed for mouse use,
// so that pad's touch data is excluded from the DS4 touchpad report.
void SetTrackpadMouseClaim(bool enabled, bool useLeftTrackpad);
private:
void StartDs4OutputThread();
void StopDs4OutputThread();
@@ -49,9 +46,8 @@ private:
const char* m_failStage = "none";
uint32_t m_lastError = 0;
// DS4 touchpad tracking
bool m_trackpadMouseEnabled = false;
bool m_useLeftTrackpadForMouse = false;
// DS4 touchpad tracking. Which pads feed it is read from the profile
// handed to Update every frame, so none of that is duplicated here.
uint8_t m_touchPacketCounter = 0;
uint8_t m_rightTracking = 0;
uint8_t m_leftTracking = 0;
+64
View File
@@ -0,0 +1,64 @@
// Console diagnostic: round-trips bindings through both encodings and checks
// that values written before modifiers existed still decode to exactly what
// they used to mean. Those old values live in real users' registries, so
// "it compiles" is not evidence that their paddles still work.
#include "app/BackButtonConfig.h"
#include <cstdio>
namespace {
int g_failures = 0;
void Check(bool ok, const char* what) {
if (!ok) ++g_failures;
printf(" [%s] %s\n", ok ? "ok" : "FAIL", what);
}
void RoundTrip(const char* what, BackButtonBinding b) {
const BackButtonBinding viaPack = BackButtonBinding::Unpack(b.Pack());
const BackButtonBinding viaId = BackButtonBinding::FromId(b.Id());
printf(" %-26s pack=0x%08X id=%-14s\n", what, b.Pack(), b.Id().c_str());
Check(viaPack == b, "survives Pack/Unpack");
Check(viaId == b, "survives Id/FromId");
}
} // namespace
int main() {
using B = BackButtonBinding;
printf("Round-trips\n");
RoundTrip("plain key K", B::FromKey('K'));
RoundTrip("Ctrl+K", B::FromKey('K', B::ModCtrl));
RoundTrip("Ctrl+Alt+Shift+Win", B::FromKey('K', B::ModCtrl | B::ModAlt | B::ModShift | B::ModWin));
RoundTrip("Win+G", B::FromKey('G', B::ModWin));
RoundTrip("action A", B::FromAction(BackButtonAction::A));
RoundTrip("middle mouse", B::FromMouseButton(B::MouseButtonCode::Middle));
printf("\nValues written before modifiers existed\n");
// Exactly what older builds persisted: (kind << 16) | code, top byte zero.
const uint32_t oldPlainKey = (1u << 16) | 'K';
const B decodedKey = B::Unpack(oldPlainKey);
printf(" 0x%08X -> kind=%d code=%u mods=%u\n", oldPlainKey,
static_cast<int>(decodedKey.kind), decodedKey.code, decodedKey.mods);
Check(decodedKey == B::FromKey('K'), "old key value still decodes unmodified");
const uint32_t oldAction = static_cast<uint32_t>(BackButtonAction::LeftMouseButton);
Check(B::Unpack(oldAction) == B::FromAction(BackButtonAction::LeftMouseButton),
"bare-action value from the oldest builds still decodes");
// The wire id an older page would have sent for a plain key.
Check(B::FromId("key:75") == B::FromKey('K'), "old \"key:75\" id still decodes");
Check(B::FromKey('K').Id() == "key:75", "unmodified key still emits the old id");
printf("\nRejections\n");
Check(B::FromId("key:99999999") == B{}, "absurdly large id rejected");
Check(B::FromId("key:abc") == B{}, "non-numeric id rejected");
Check(B::Unpack((1u << 16) | 0) == B{}, "key with no virtual key rejected");
// A future build inventing a fifth modifier must not poison the binding.
const B futureMods = B::Unpack((0xF0u << 24) | (1u << 16) | 'K');
Check(futureMods == B::FromKey('K'), "unknown modifier bits dropped, key kept");
printf("\n%s (%d failure(s))\n", g_failures ? "FAILED" : "All checks passed", g_failures);
return g_failures ? 1 : 0;
}
+42
View File
@@ -0,0 +1,42 @@
// Console diagnostic: prints the foreground application as the user switches
// between windows, which is the signal per-game profiles are resolved from.
// Exists because the interesting failures here are silent — a packaged app
// resolving to ApplicationFrameHost.exe instead of itself looks like nothing
// happening at all.
#include "app/ForegroundWatcher.h"
#include <Windows.h>
#include <cstdio>
static void Print(const wchar_t* tag, const ForegroundIdentity& id) {
wprintf(L"%-8ls exe=[%ls]\n aumid=[%ls]\n", tag,
id.exePath.empty() ? L"(none)" : id.exePath.c_str(),
id.aumid.empty() ? L"(none)" : id.aumid.c_str());
fflush(stdout);
}
int main(int argc, char** argv) {
const int seconds = argc > 1 ? atoi(argv[1]) : 25;
Print(L"INITIAL", ForegroundWatcher::Current());
wprintf(L"Watching for %d seconds — switch windows now.\n", seconds);
fflush(stdout);
ForegroundWatcher watcher;
if (!watcher.Start([](const ForegroundIdentity& id) { Print(L"CHANGED", id); })) {
wprintf(L"Failed to install the foreground hook.\n");
return 1;
}
// The hook is out-of-context, so its callback arrives through this
// thread's message queue — without a pump nothing is ever delivered.
const ULONGLONG end = GetTickCount64() + static_cast<ULONGLONG>(seconds) * 1000;
MSG msg;
while (GetTickCount64() < end) {
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
Sleep(20);
}
return 0;
}
+14
View File
@@ -0,0 +1,14 @@
// Console diagnostic: dumps what GameLibrary::EnumerateInstalled() finds by
// walking the Start Menu, so the path-matching approach can be checked
// against a real machine's installed games without going through the
// remap window's UI.
#include "app/GameLibrary.h"
#include <cstdio>
int main() {
const auto games = GameLibrary::EnumerateInstalled();
wprintf(L"%zu game(s) found:\n", games.size());
for (const auto& g : games)
wprintf(L" %-40ls %ls\n", g.name.c_str(), g.id.c_str());
return 0;
}
+20
View File
@@ -0,0 +1,20 @@
// Console diagnostic: dumps what SteamAppLocator reads out of Steam's own
// manifests, and resolves any paths given on the command line back to an app
// id. The parsing has no visible effect until a profile fails to match, which
// is far too late to find out it read the wrong key.
#include "app/SteamAppLocator.h"
#include <cstdio>
int wmain(int argc, wchar_t** argv) {
SteamAppLocator locator;
locator.Refresh();
wprintf(L"%zu installed Steam app(s)\n", locator.Count());
for (int i = 1; i < argc; ++i) {
const std::wstring appId = locator.AppIdForPath(argv[i]);
wprintf(L" %ls\n -> %ls\n", argv[i],
appId.empty() ? L"(no Steam app owns this path)" : appId.c_str());
}
return 0;
}