Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 40 additions & 5 deletions src/ConsoleMode/Services/ControllerInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>SonyHidReader bits use the XInput layout, so they map like an Xbox pad.</summary>
Expand Down Expand Up @@ -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<Gamepad> 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();
Expand All @@ -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<string, int> NoisyStreaks = [];
private static readonly HashSet<string> DistrustedPads = [];

private static HashSet<Gamepad> ReadRaw(bool[] held, int xinputPads, StringBuilder? diag)
{
var invalidGamepads = new HashSet<Gamepad>();
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;
Expand All @@ -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);
Comment thread
lippdev marked this conversation as resolved.
continue;
}

var (confirmIndex, backIndex, optionIndex, altIndex, menuIndex) = ControllerMapping.HidIndices(raw.HardwareVendorId);
Set(held, ControllerAction.Confirm, buttons, confirmIndex);
Set(held, ControllerAction.Back, buttons, backIndex);
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions src/ConsoleMode/Services/ControllerMapping.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,27 @@ public static ushort SonyButtons(ReadOnlySpan<byte> report, bool dualSense)
/// <summary>Whether to read Windows.Gaming.Input.Gamepad objects: only when XInput saw nothing.</summary>
public static bool ShouldReadGamepads(int xinputPads) => xinputPads == 0;

/// <summary>
/// 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.
/// </summary>
public const int MaxSimultaneousHidButtons = 5;

/// <summary>Consecutive polls over the limit before the pad is ignored for good.</summary>
public const int DistrustAfterPolls = 3;

/// <summary>
/// Tracks one HID pad's readings: true once they were impossible for
/// <see cref="DistrustAfterPolls"/> polls in a row. A single reading over the limit is
/// skipped by the caller without judging the pad.
/// </summary>
public static bool UpdateHidTrust(int pressedButtons, ref int noisyStreak)
{
noisyStreak = pressedButtons > MaxSimultaneousHidButtons ? noisyStreak + 1 : 0;
return noisyStreak >= DistrustAfterPolls;
}

/// <summary>Stick axis from 0..1 (0.5 centred) to a direction; ±0.25 dead zone.</summary>
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);
Expand Down
45 changes: 45 additions & 0 deletions tests/ConsoleMode.Tests/HidTrustTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading