From 904be68c53a693850077594d9502838a102ca2f7 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 30 Sep 2026 15:33:24 -0300 Subject: [PATCH 1/2] fix: ignore HID controllers with impossible readings A Switch Pro Controller read through Windows' generic HID driver reports 6 to 10 buttons down at once and a D-pad stuck on Up with nobody touching it. The console interface trusted those readings, so it kept moving the focus and pressed "Play now" by itself, starting a session and switching the screens. ControllerMapping.UpdateHidTrust tracks each HID pad: a reading with more than 5 buttons down is skipped, and after 3 such readings in a row the pad is ignored until the app is reopened, with a line in the log. XInput pads do not go through this path and are not affected. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 1 + CHANGELOG.md | 1 + docs/TESTING.md | 1 + src/ConsoleMode/Services/ControllerInput.cs | 17 +++++++ src/ConsoleMode/Services/ControllerMapping.cs | 21 +++++++++ tests/ConsoleMode.Tests/HidTrustTests.cs | 45 +++++++++++++++++++ 6 files changed, 86 insertions(+) create mode 100644 tests/ConsoleMode.Tests/HidTrustTests.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index aa31b4d..c627fbe 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -8,6 +8,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ### Fixes - Session menu: the notice that shows the shortcut when a session starts no longer gets stuck on screen; it goes away by itself after a few seconds. (#114) +- Controllers: a controller that sends impossible readings through the generic HID driver (many buttons down at once, a stuck D-pad) is now ignored instead of moving around and pressing buttons in the app by itself. The log says so, and reopening the app tries again. (#115) - Displays: a monitor turned to portrait (rotated) keeps its rotation in the layout backup, so restoring the desk puts it back in portrait. Before, the rotation was never saved or restored. (#108) - Playnite: a slow start no longer sends you back to the PC after a few seconds. When Playnite swaps its loading window for the main one, Console Mode now follows the new window and only restores the desk once Playnite actually closes. (#83) - Audio: if switching the output fails, the session goes on instead of being rolled back after the screens were already switched. (#109) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a9bed..be9bccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ### Correções - Menu da sessão: o aviso que mostra o atalho no início da sessão não fica mais preso na tela; ele some sozinho depois de alguns segundos. (#114) +- Controles: um controle que manda leituras impossíveis pelo driver HID genérico (muitos botões apertados ao mesmo tempo, direcional preso) agora é ignorado em vez de navegar e apertar botões sozinho no app. O log avisa, e reabrir o app tenta de novo. (#115) - Telas: um monitor girado (em retrato) mantém a rotação no backup do layout, então restaurar a mesa devolve o monitor em retrato. Antes a rotação nunca era salva nem restaurada. (#108) - Playnite: quando ele demora para abrir, o app não volta mais para o PC depois de alguns segundos. Se o Playnite troca a janela de carregamento pela principal, o Console Mode passa a acompanhar a nova e só restaura a mesa quando o Playnite fecha de verdade. (#83) - Áudio: se a troca da saída falhar, a sessão continua em vez de ser desfeita depois de as telas já terem sido trocadas. (#109) diff --git a/docs/TESTING.md b/docs/TESTING.md index 0b7885d..02a2f77 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -105,6 +105,7 @@ Pré-condições: Ajustes → Interface = **Automático** (padrão). - [ ] Com o **Steam aberto** e suporte a PlayStation ligado: se a leitura fica vazia, fechar o Steam faz voltar (é o Steam Input capturando o controle). Resultado: ______ - [ ] "Copiar diagnóstico" cola dispositivos + amostra; o `consolemode.log` tem a linha `Controles: …` de abertura com o DualSense listado. Resultado: ______ - [ ] Desconectar e reconectar durante o teste: a lista atualiza e a leitura continua. Resultado: ______ +- [ ] Um controle HID que manda leitura impossível (ex.: Switch Pro pelo driver genérico, com 6 ou mais botões "apertados" sozinhos) é ignorado: o app não navega nem inicia a sessão sozinho, e o log mostra `Controles: … ignorado: leitura inválida`. Reabrir o app tenta de novo. Resultado: ______ ## 4. Regressões diff --git a/src/ConsoleMode/Services/ControllerInput.cs b/src/ConsoleMode/Services/ControllerInput.cs index 411ef35..4382d8e 100644 --- a/src/ConsoleMode/Services/ControllerInput.cs +++ b/src/ConsoleMode/Services/ControllerInput.cs @@ -265,11 +265,15 @@ private static void ReadGamepads(bool[] held, StringBuilder? diag) } } + private static readonly Dictionary NoisyStreaks = []; + private static readonly HashSet DistrustedPads = []; + private static void ReadRaw(bool[] held, int xinputPads, StringBuilder? diag) { foreach (var raw in RawGameController.RawGameControllers) { var name = SafeName(raw); + if (DistrustedPads.Contains(name)) { diag?.AppendLine($"HID {name}: ignorado (leitura inválida)"); continue; } try { var isGamepad = Gamepad.FromGameController(raw) is not null; @@ -282,6 +286,19 @@ private static void ReadRaw(bool[] held, int xinputPads, StringBuilder? diag) var axes = new double[raw.AxisCount]; raw.GetCurrentReading(buttons, switches, axes); + // A pad with impossible readings is ignored (see ControllerMapping.MaxSimultaneousHidButtons). + var pressedCount = buttons.Count(on => on); + NoisyStreaks.TryGetValue(name, out var streak); + var distrust = ControllerMapping.UpdateHidTrust(pressedCount, ref streak); + NoisyStreaks[name] = streak; + if (distrust) + { + DistrustedPads.Add(name); + AppLog.Write($"Controles: {name} ignorado: leitura inválida ({pressedCount} botões ao mesmo tempo); reabra o app para tentar de novo"); + continue; + } + if (pressedCount > ControllerMapping.MaxSimultaneousHidButtons) continue; + var (confirmIndex, backIndex, optionIndex, altIndex, menuIndex) = ControllerMapping.HidIndices(raw.HardwareVendorId); Set(held, ControllerAction.Confirm, buttons, confirmIndex); Set(held, ControllerAction.Back, buttons, backIndex); diff --git a/src/ConsoleMode/Services/ControllerMapping.cs b/src/ConsoleMode/Services/ControllerMapping.cs index eba94d4..6fa983f 100644 --- a/src/ConsoleMode/Services/ControllerMapping.cs +++ b/src/ConsoleMode/Services/ControllerMapping.cs @@ -75,6 +75,27 @@ public static ushort SonyButtons(ReadOnlySpan report, bool dualSense) /// Whether to read Windows.Gaming.Input.Gamepad objects: only when XInput saw nothing. public static bool ShouldReadGamepads(int xinputPads) => xinputPads == 0; + /// + /// Most buttons a person holds on a HID pad at once. Some pads read wrong through Windows' + /// generic HID driver (a Switch Pro Controller reports 6 to 10 buttons down and a stuck D-pad + /// with nobody touching it); acting on that moves the focus and presses buttons by itself. + /// + public const int MaxSimultaneousHidButtons = 5; + + /// Consecutive polls over the limit before the pad is ignored for good. + public const int DistrustAfterPolls = 3; + + /// + /// Tracks one HID pad's readings: true once they were impossible for + /// polls in a row. A single reading over the limit is + /// skipped by the caller without judging the pad. + /// + public static bool UpdateHidTrust(int pressedButtons, ref int noisyStreak) + { + noisyStreak = pressedButtons > MaxSimultaneousHidButtons ? noisyStreak + 1 : 0; + return noisyStreak >= DistrustAfterPolls; + } + /// Stick axis from 0..1 (0.5 centred) to a direction; ±0.25 dead zone. public static (bool Left, bool Right, bool Up, bool Down) StickDirections(double x, double y) => (x < 0.25, x > 0.75, y < 0.25, y > 0.75); diff --git a/tests/ConsoleMode.Tests/HidTrustTests.cs b/tests/ConsoleMode.Tests/HidTrustTests.cs new file mode 100644 index 0000000..f23300a --- /dev/null +++ b/tests/ConsoleMode.Tests/HidTrustTests.cs @@ -0,0 +1,45 @@ +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public class HidTrustTests +{ + [Fact] + public void A_pad_with_impossible_readings_is_distrusted_after_a_few_polls() + { + // A Switch Pro Controller read as generic HID: 6 to 10 buttons down, nobody touching it. + var streak = 0; + Assert.False(ControllerMapping.UpdateHidTrust(6, ref streak)); + Assert.False(ControllerMapping.UpdateHidTrust(8, ref streak)); + Assert.True(ControllerMapping.UpdateHidTrust(10, ref streak)); + } + + [Fact] + public void A_normal_pad_is_never_distrusted() + { + var streak = 0; + for (var i = 0; i < 100; i++) + Assert.False(ControllerMapping.UpdateHidTrust(i % 4, ref streak)); + Assert.Equal(0, streak); + } + + [Fact] + public void A_short_burst_over_the_limit_does_not_condemn_the_pad() + { + var streak = 0; + Assert.False(ControllerMapping.UpdateHidTrust(7, ref streak)); + Assert.False(ControllerMapping.UpdateHidTrust(7, ref streak)); + Assert.False(ControllerMapping.UpdateHidTrust(2, ref streak)); // back to normal: the count restarts + Assert.Equal(0, streak); + Assert.False(ControllerMapping.UpdateHidTrust(7, ref streak)); + Assert.False(ControllerMapping.UpdateHidTrust(7, ref streak)); + } + + [Fact] + public void The_limit_itself_is_allowed() + { + var streak = 0; + for (var i = 0; i < 10; i++) + Assert.False(ControllerMapping.UpdateHidTrust(ControllerMapping.MaxSimultaneousHidButtons, ref streak)); + } +} From 7d529a8aff4ae8fe844f18e67347be455e641659 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 30 Sep 2026 18:28:49 -0300 Subject: [PATCH 2/2] fix: filter invalid controllers from WGI input --- CHANGELOG.en-US.md | 2 +- CHANGELOG.md | 2 +- docs/TESTING.md | 3 +- src/ConsoleMode/Services/ControllerInput.cs | 32 ++++++++++++++++----- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 7d58609..1728ea6 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -8,7 +8,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ### Fixes - Session menu: the notice that shows the shortcut when a session starts no longer gets stuck on screen; it goes away by itself after a few seconds. (#114) -- Controllers: a controller that sends impossible readings through the generic HID driver (many buttons down at once, a stuck D-pad) is now ignored instead of moving around and pressing buttons in the app by itself. The log says so, and reopening the app tries again. (#115) +- Controllers: a controller that sends impossible readings through the generic HID driver (many buttons down at once, a stuck D-pad) is now ignored even when Windows also exposes it through Windows.Gaming.Input. The log says so, and reopening the app tries again. (#115) - Displays: a monitor turned to portrait (rotated) keeps its rotation in the layout backup, so restoring the desk puts it back in portrait. Before, the rotation was never saved or restored. (#108) - Playnite: a slow start no longer sends you back to the PC after a few seconds. When Playnite swaps its loading window for the main one, Console Mode now follows the new window and only restores the desk once Playnite actually closes. (#83) - Audio: if switching the output fails, the session goes on instead of being rolled back after the screens were already switched. (#109) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9295a6c..764f889 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ### Correções - Menu da sessão: o aviso que mostra o atalho no início da sessão não fica mais preso na tela; ele some sozinho depois de alguns segundos. (#114) -- Controles: um controle que manda leituras impossíveis pelo driver HID genérico (muitos botões apertados ao mesmo tempo, direcional preso) agora é ignorado em vez de navegar e apertar botões sozinho no app. O log avisa, e reabrir o app tenta de novo. (#115) +- Controles: um controle que manda leituras impossíveis pelo driver HID genérico (muitos botões apertados ao mesmo tempo, direcional preso) agora é ignorado também quando o Windows o expõe por Windows.Gaming.Input. O log avisa, e reabrir o app tenta de novo. (#115) - Telas: um monitor girado (em retrato) mantém a rotação no backup do layout, então restaurar a mesa devolve o monitor em retrato. Antes a rotação nunca era salva nem restaurada. (#108) - Playnite: quando ele demora para abrir, o app não volta mais para o PC depois de alguns segundos. Se o Playnite troca a janela de carregamento pela principal, o Console Mode passa a acompanhar a nova e só restaura a mesa quando o Playnite fecha de verdade. (#83) - Áudio: se a troca da saída falhar, a sessão continua em vez de ser desfeita depois de as telas já terem sido trocadas. (#109) diff --git a/docs/TESTING.md b/docs/TESTING.md index 3828091..e89683e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -105,7 +105,8 @@ Pré-condições: Ajustes → Interface = **Automático** (padrão). - [ ] Com o **Steam aberto** e suporte a PlayStation ligado: se a leitura fica vazia, fechar o Steam faz voltar (é o Steam Input capturando o controle). Resultado: ______ - [ ] "Copiar diagnóstico" cola dispositivos + amostra; o `consolemode.log` tem a linha `Controles: …` de abertura com o DualSense listado. Resultado: ______ - [ ] Desconectar e reconectar durante o teste: a lista atualiza e a leitura continua. Resultado: ______ -- [ ] Um controle HID que manda leitura impossível (ex.: Switch Pro pelo driver genérico, com 6 ou mais botões "apertados" sozinhos) é ignorado: o app não navega nem inicia a sessão sozinho, e o log mostra `Controles: … ignorado: leitura inválida`. Reabrir o app tenta de novo. Resultado: ______ +- [ ] Um controle HID que manda leitura impossível (ex.: Switch Pro pelo driver genérico, com 6 ou mais botões "apertados" sozinhos) é ignorado mesmo quando também aparece em Windows.Gaming.Input: o app não navega nem inicia a sessão sozinho, e o log mostra `Controles: … ignorado: leitura inválida`. Reabrir o app tenta de novo. Resultado: ______ +- [ ] Com um controle HID defeituoso e outro controle normal conectados, o defeituoso não gera ações pelo HID nem pela projeção Windows.Gaming.Input, e o controle normal continua navegando. Resultado: ______ ## 4. Regressões diff --git a/src/ConsoleMode/Services/ControllerInput.cs b/src/ConsoleMode/Services/ControllerInput.cs index 4382d8e..769eb03 100644 --- a/src/ConsoleMode/Services/ControllerInput.cs +++ b/src/ConsoleMode/Services/ControllerInput.cs @@ -198,8 +198,8 @@ private static bool IsDirection(ControllerAction action) => private static void ReadAll(bool[] held, StringBuilder? diag) { var xinputPads = ReadXInput(held, diag); - if (ControllerMapping.ShouldReadGamepads(xinputPads)) ReadGamepads(held, diag); - ReadRaw(held, xinputPads, diag); + var invalidGamepads = ReadRaw(held, xinputPads, diag); + if (ControllerMapping.ShouldReadGamepads(xinputPads)) ReadGamepads(held, diag, invalidGamepads); } /// SonyHidReader bits use the XInput layout, so they map like an Xbox pad. @@ -238,10 +238,15 @@ private static int ReadXInput(bool[] held, StringBuilder? diag) return count; } - private static void ReadGamepads(bool[] held, StringBuilder? diag) + private static void ReadGamepads(bool[] held, StringBuilder? diag, HashSet invalidGamepads) { foreach (var pad in Gamepad.Gamepads) { + if (invalidGamepads.Contains(pad)) + { + diag?.AppendLine("Gamepad: ignorado (leitura HID inválida)"); + continue; + } try { var r = pad.GetCurrentReading(); @@ -268,15 +273,22 @@ private static void ReadGamepads(bool[] held, StringBuilder? diag) private static readonly Dictionary NoisyStreaks = []; private static readonly HashSet DistrustedPads = []; - private static void ReadRaw(bool[] held, int xinputPads, StringBuilder? diag) + private static HashSet ReadRaw(bool[] held, int xinputPads, StringBuilder? diag) { + var invalidGamepads = new HashSet(); foreach (var raw in RawGameController.RawGameControllers) { var name = SafeName(raw); - if (DistrustedPads.Contains(name)) { diag?.AppendLine($"HID {name}: ignorado (leitura inválida)"); continue; } try { - var isGamepad = Gamepad.FromGameController(raw) is not null; + var gamepad = Gamepad.FromGameController(raw); + var isGamepad = gamepad is not null; + if (DistrustedPads.Contains(name)) + { + if (gamepad is not null) invalidGamepads.Add(gamepad); + diag?.AppendLine($"HID {name}: ignorado (leitura inválida)"); + continue; + } // Xbox pads also show up here; XInput already covers them, unless XInput saw nothing. if (!ControllerMapping.ShouldReadHid(isGamepad, xinputPads)) { diag?.AppendLine($"HID {name}: coberto pelo XInput"); continue; } if (raw.ButtonCount < 2) continue; @@ -295,9 +307,14 @@ private static void ReadRaw(bool[] held, int xinputPads, StringBuilder? diag) { DistrustedPads.Add(name); AppLog.Write($"Controles: {name} ignorado: leitura inválida ({pressedCount} botões ao mesmo tempo); reabra o app para tentar de novo"); + if (gamepad is not null) invalidGamepads.Add(gamepad); + continue; + } + if (pressedCount > ControllerMapping.MaxSimultaneousHidButtons) + { + if (gamepad is not null) invalidGamepads.Add(gamepad); continue; } - if (pressedCount > ControllerMapping.MaxSimultaneousHidButtons) continue; var (confirmIndex, backIndex, optionIndex, altIndex, menuIndex) = ControllerMapping.HidIndices(raw.HardwareVendorId); Set(held, ControllerAction.Confirm, buttons, confirmIndex); @@ -338,6 +355,7 @@ private static void ReadRaw(bool[] held, int xinputPads, StringBuilder? diag) diag?.AppendLine($"HID {name}: erro {ex.Message}"); } } + return invalidGamepads; } private static void Set(bool[] held, ControllerAction action, bool[] buttons, int index)