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

+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";