diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md
index 0a44b2e..1728ea6 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 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 13d4367..764f889 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 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 4995db3..e89683e 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -105,6 +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 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 411ef35..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();
@@ -265,14 +270,25 @@ private static void ReadGamepads(bool[] held, StringBuilder? diag)
}
}
- private static void ReadRaw(bool[] held, int xinputPads, StringBuilder? diag)
+ private static readonly Dictionary NoisyStreaks = [];
+ private static readonly HashSet DistrustedPads = [];
+
+ private static HashSet ReadRaw(bool[] held, int xinputPads, StringBuilder? diag)
{
+ var invalidGamepads = new HashSet();
foreach (var raw in RawGameController.RawGameControllers)
{
var name = SafeName(raw);
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;
@@ -282,6 +298,24 @@ 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");
+ if (gamepad is not null) invalidGamepads.Add(gamepad);
+ continue;
+ }
+ if (pressedCount > ControllerMapping.MaxSimultaneousHidButtons)
+ {
+ if (gamepad is not null) invalidGamepads.Add(gamepad);
+ continue;
+ }
+
var (confirmIndex, backIndex, optionIndex, altIndex, menuIndex) = ControllerMapping.HidIndices(raw.HardwareVendorId);
Set(held, ControllerAction.Confirm, buttons, confirmIndex);
Set(held, ControllerAction.Back, buttons, backIndex);
@@ -321,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)
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));
+ }
+}