From e898b8203123b3a901d4c50996d11ee07d088cd0 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Thu, 24 Sep 2026 15:11:02 -0300 Subject: [PATCH] feat: session menu works with PlayStation pads, horizontal tiles, and a start-of-session toast - Controller dead in the menu: its navigation read PlayStation pads only through Windows.Gaming.Input, which needs our window in the foreground, and the game usually keeps it. The menu now reads Sony pads over HID (full pad: D-pad, stick, face buttons) and no longer requires the foreground. - Layout: horizontal tiles (back to game, volume, resolution, audio, FPS, HDR, record) with the two ways out side by side. On the volume tile A enters adjust mode (Left/Right), X/Square mutes, A or B leaves. - Toast: 5 s into a session, a corner toast on the game screen says which combo opens the menu (Select + Y, or Create + Triangle on PlayStation). It never takes the focus. Co-Authored-By: Claude Opus 5.5 --- src/ConsoleMode/Native/WindowPlacement.cs | 11 ++ src/ConsoleMode/Resources/Strings.en-US.json | 2 + src/ConsoleMode/Resources/Strings.es-ES.json | 2 + src/ConsoleMode/Resources/Strings.pt-BR.json | 2 + src/ConsoleMode/Services/ControllerInput.cs | 32 ++++- src/ConsoleMode/Services/ControllerMapping.cs | 40 ++++-- src/ConsoleMode/Services/GamepadNavigator.cs | 5 +- src/ConsoleMode/Services/SonyHidReader.cs | 4 +- src/ConsoleMode/SessionHintWindow.cs | 77 ++++++++++ src/ConsoleMode/SessionMenuWindow.xaml | 136 ++++++++++-------- src/ConsoleMode/SessionMenuWindow.xaml.cs | 40 ++++-- .../ViewModels/MainViewModel.SessionMenu.cs | 25 ++++ src/ConsoleMode/ViewModels/MainViewModel.cs | 1 + .../ControllerMappingTests.cs | 84 +++++++++-- 14 files changed, 365 insertions(+), 96 deletions(-) create mode 100644 src/ConsoleMode/SessionHintWindow.cs diff --git a/src/ConsoleMode/Native/WindowPlacement.cs b/src/ConsoleMode/Native/WindowPlacement.cs index da3b2e0..7f6c936 100644 --- a/src/ConsoleMode/Native/WindowPlacement.cs +++ b/src/ConsoleMode/Native/WindowPlacement.cs @@ -25,6 +25,17 @@ public static void CenterOn(AppWindow appWindow, ScreenRect? target, double widt appWindow.MoveAndResize(new Windows.Graphics.RectInt32(cx - w / 2, cy - h / 2, w, h)); } + /// Top-right corner of the screen, in from the edges. + public static void TopRightOn(AppWindow appWindow, ScreenRect? target, double widthDip, double heightDip, double marginDip) + { + if (target is null || target.Width <= 0 || target.Height <= 0) { CenterOn(appWindow, target, widthDip, heightDip); return; } + var scale = DpiAt(target.X + target.Width / 2, target.Y + target.Height / 2); + var w = (int)(widthDip * scale); + var h = (int)(heightDip * scale); + var m = (int)(marginDip * scale); + appWindow.MoveAndResize(new Windows.Graphics.RectInt32(target.X + target.Width - w - m, target.Y + m, w, h)); + } + [StructLayout(LayoutKind.Sequential)] private struct POINT { public int X, Y; } diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index e7f9d1a..ee5520a 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -57,6 +57,8 @@ "VolumeRow": "Volume", "Muted": "Muted", "SessionMenuHint": "Select + Y opens this menu at any time.", + "SessionHintTitle": "Console Mode menu", + "SessionHintBody": "Hold {0} to open the panel over the game.", "RecordLast30": "Record the last 30 s", "ComingSoonSuffix": " · coming soon", "ExitConsoleMode": "Exit Console Mode", diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json index 108be10..304f12b 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -57,6 +57,8 @@ "VolumeRow": "Volumen", "Muted": "Silencio", "SessionMenuHint": "Select + Y abre este menú en cualquier momento.", + "SessionHintTitle": "Menú de Console Mode", + "SessionHintBody": "Mantén {0} para abrir el panel sobre el juego.", "RecordLast30": "Grabar los últimos 30 s", "ComingSoonSuffix": " · próximamente", "ExitConsoleMode": "Salir de Console Mode", diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json index cc1d631..96a08c0 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -57,6 +57,8 @@ "VolumeRow": "Volume", "Muted": "Mudo", "SessionMenuHint": "Select + Y abre este menu a qualquer momento.", + "SessionHintTitle": "Menu do Console Mode", + "SessionHintBody": "Segure {0} para abrir o painel sobre o jogo.", "RecordLast30": "Gravar os últimos 30 s", "ComingSoonSuffix": " · em breve", "ExitConsoleMode": "Sair do Console Mode", diff --git a/src/ConsoleMode/Services/ControllerInput.cs b/src/ConsoleMode/Services/ControllerInput.cs index 722ecbf..411ef35 100644 --- a/src/ConsoleMode/Services/ControllerInput.cs +++ b/src/ConsoleMode/Services/ControllerInput.cs @@ -118,17 +118,30 @@ public static ControllerFamily DetectFamily() return ControllerFamily.None; } + /// + /// Also read PlayStation pads straight from HID (SonyHidReader). Windows.Gaming.Input only + /// reaches the foreground window, so a menu shown over a game that keeps the focus needs this. + /// + public bool ReadSonyHid { get; init; } + public bool IsRunning => _timer.IsRunning; public void Start() { + if (_timer.IsRunning) return; _primed = false; + if (ReadSonyHid) SonyHidReader.Acquire(); _timer.Start(); } - public void Stop() => _timer.Stop(); + public void Stop() + { + if (!_timer.IsRunning) return; + _timer.Stop(); + if (ReadSonyHid) SonyHidReader.Release(); + } - public void Dispose() => _timer.Stop(); + public void Dispose() => Stop(); private void Poll() { @@ -142,6 +155,7 @@ private void Poll() try { ReadAll(held, null); + if (ReadSonyHid) ReadSony(held, SonyHidReader.Held); } catch (Exception ex) { @@ -188,6 +202,20 @@ private static void ReadAll(bool[] held, StringBuilder? diag) ReadRaw(held, xinputPads, diag); } + /// SonyHidReader bits use the XInput layout, so they map like an Xbox pad. + private static void ReadSony(bool[] held, ushort b) + { + held[(int)ControllerAction.Confirm] |= (b & XInputA) != 0; + held[(int)ControllerAction.Back] |= (b & XInputB) != 0; + held[(int)ControllerAction.Option] |= (b & XInputX) != 0; + held[(int)ControllerAction.Alt] |= (b & XInputY) != 0; + held[(int)ControllerAction.Menu] |= (b & XInputStart) != 0; + held[(int)ControllerAction.Up] |= (b & XInputDpadUp) != 0; + held[(int)ControllerAction.Down] |= (b & XInputDpadDown) != 0; + held[(int)ControllerAction.Left] |= (b & XInputDpadLeft) != 0; + held[(int)ControllerAction.Right] |= (b & XInputDpadRight) != 0; + } + private static int ReadXInput(bool[] held, StringBuilder? diag) { var count = 0; diff --git a/src/ConsoleMode/Services/ControllerMapping.cs b/src/ConsoleMode/Services/ControllerMapping.cs index ab464e8..eba94d4 100644 --- a/src/ConsoleMode/Services/ControllerMapping.cs +++ b/src/ConsoleMode/Services/ControllerMapping.cs @@ -10,27 +10,45 @@ public static class ControllerMapping public static bool IsDualSense(ushort productId) => productId is 0x0CE6 or 0x0DF2; /// - /// XInput-style bits (Guide, Start, Back, Y) from a raw DS4/DualSense input report, first - /// byte = report ID. Create/Share maps to Back, Options to Start, Triangle to Y, PS to Guide. - /// Unknown reports give 0. + /// XInput-style button bits from a raw DS4/DualSense input report (first byte = report ID): + /// Cross = A, Circle = B, Square = X, Triangle = Y, Options = Start, Create/Share = Back, + /// PS = Guide; the hat and the left stick set the D-pad bits. Unknown reports give 0. /// - public static ushort SonyChordButtons(ReadOnlySpan report, bool dualSense) + public static ushort SonyButtons(ReadOnlySpan report, bool dualSense) { if (report.Length == 0) return 0; - var start = report[0] switch + // Offset of the buttons byte (hat + face) and of the left stick X, per report layout. + var (start, stick) = report[0] switch { - 0x31 => 9, // DualSense, Bluetooth - 0x11 => 7, // DS4, Bluetooth - 0x01 when dualSense && report.Length >= 64 => 8, // DualSense, USB - 0x01 => 5, // DS4 USB, or either pad's basic Bluetooth report - _ => -1 + 0x31 => (9, 2), // DualSense, Bluetooth + 0x11 => (7, 3), // DS4, Bluetooth + 0x01 when dualSense && report.Length >= 64 => (8, 1), // DualSense, USB + 0x01 => (5, 1), // DS4 USB, or either pad's basic Bluetooth report + _ => (-1, -1) }; if (start < 0 || report.Length < start + 3) return 0; + var face = report[start]; + var hat = face & 0x0F; ushort bits = 0; - if ((report[start] & 0x80) != 0) bits |= 0x8000; // Triangle -> Y + if ((face & 0x10) != 0) bits |= 0x4000; // Square -> X + if ((face & 0x20) != 0) bits |= 0x1000; // Cross -> A + if ((face & 0x40) != 0) bits |= 0x2000; // Circle -> B + if ((face & 0x80) != 0) bits |= 0x8000; // Triangle -> Y if ((report[start + 1] & 0x10) != 0) bits |= 0x0020; // Create/Share -> Back if ((report[start + 1] & 0x20) != 0) bits |= 0x0010; // Options -> Start if ((report[start + 2] & 0x01) != 0) bits |= 0x0400; // PS -> Guide + // Hat: 0 = up, clockwise to 7 = up-left, 8 = released. + if (hat is 7 or 0 or 1) bits |= 0x0001; + if (hat is 1 or 2 or 3) bits |= 0x0008; + if (hat is 3 or 4 or 5) bits |= 0x0002; + if (hat is 5 or 6 or 7) bits |= 0x0004; + // Left stick: 0..255, 128 = center, Y grows downward. + var x = report[stick]; + var y = report[stick + 1]; + if (y < 64) bits |= 0x0001; + if (y > 192) bits |= 0x0002; + if (x < 64) bits |= 0x0004; + if (x > 192) bits |= 0x0008; return bits; } diff --git a/src/ConsoleMode/Services/GamepadNavigator.cs b/src/ConsoleMode/Services/GamepadNavigator.cs index 00ffdd4..caabe5c 100644 --- a/src/ConsoleMode/Services/GamepadNavigator.cs +++ b/src/ConsoleMode/Services/GamepadNavigator.cs @@ -41,10 +41,11 @@ public sealed class GamepadNavigator : IDisposable public Func? Intercept { get; set; } /// When given, presses only count while this window is in the foreground. - public GamepadNavigator(DispatcherQueue dispatcher, DependencyObject root, nint hwnd = 0) + /// Also read PlayStation pads over HID, for windows that may not get the foreground. + public GamepadNavigator(DispatcherQueue dispatcher, DependencyObject root, nint hwnd = 0, bool readSonyHid = false) { _root = root; - _input = new ControllerInput(dispatcher); + _input = new ControllerInput(dispatcher) { ReadSonyHid = readSonyHid }; if (hwnd != 0) _input.IsActive = () => NativeWindows.GetForegroundWindow() == hwnd; _input.Pressed += OnPressed; } diff --git a/src/ConsoleMode/Services/SonyHidReader.cs b/src/ConsoleMode/Services/SonyHidReader.cs index 8b5fb04..a946d96 100644 --- a/src/ConsoleMode/Services/SonyHidReader.cs +++ b/src/ConsoleMode/Services/SonyHidReader.cs @@ -16,7 +16,7 @@ public static class SonyHidReader private static CancellationTokenSource? _cts; private static int _users; - /// XInput-style bits held on any Sony pad (see ControllerMapping.SonyChordButtons). + /// XInput-style bits held on any Sony pad (see ControllerMapping.SonyButtons). public static ushort Held { get @@ -94,7 +94,7 @@ private static async Task ReadLoopAsync(string path, Pad pad, CancellationToken { var read = await stream.ReadAsync(buffer.AsMemory(0, pad.ReportLength), ct); if (read <= 0) break; - var bits = ControllerMapping.SonyChordButtons(buffer.AsSpan(0, read), pad.DualSense); + var bits = ControllerMapping.SonyButtons(buffer.AsSpan(0, read), pad.DualSense); lock (Gate) { Buttons[path] = bits; } } } diff --git a/src/ConsoleMode/SessionHintWindow.cs b/src/ConsoleMode/SessionHintWindow.cs new file mode 100644 index 0000000..9a52742 --- /dev/null +++ b/src/ConsoleMode/SessionHintWindow.cs @@ -0,0 +1,77 @@ +using System.Runtime.InteropServices; +using ConsoleMode.Models; +using ConsoleMode.Native; +using Microsoft.UI; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using WinRT.Interop; + +namespace ConsoleMode; + +/// +/// A toast in the top-right corner of the game screen, shown once when the session starts: +/// "hold Select + Y to open the panel". It never takes the focus from the game (shown without +/// activation, WS_EX_NOACTIVATE) and closes by itself. +/// +public sealed class SessionHintWindow : Window +{ + private const double WidthDip = 470; + private const double HeightDip = 96; + private const double MarginDip = 28; + + public SessionHintWindow(string title, string body, ScreenRect? target, TimeSpan duration) + { + var icon = new FontIcon { Glyph = "", FontSize = 30, VerticalAlignment = VerticalAlignment.Center }; + var text = new StackPanel { Spacing = 2, VerticalAlignment = VerticalAlignment.Center }; + text.Children.Add(new TextBlock { Text = title, FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }); + text.Children.Add(new TextBlock { Text = body, FontSize = 15, TextWrapping = TextWrapping.Wrap, Opacity = 0.85 }); + var row = new Grid { ColumnSpacing = 16 }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + Grid.SetColumn(text, 1); + row.Children.Add(icon); + row.Children.Add(text); + Content = new Border + { + Background = new SolidColorBrush(ColorHelper.FromArgb(0xF2, 0x0F, 0x14, 0x18)), + BorderBrush = new SolidColorBrush(ColorHelper.FromArgb(0x40, 0xFF, 0xFF, 0xFF)), + BorderThickness = new Thickness(1), + Padding = new Thickness(20, 12, 20, 12), + RequestedTheme = ElementTheme.Dark, + Child = row + }; + + var hwnd = WindowNative.GetWindowHandle(this); + // Tool window (no taskbar button) that never activates. + SetWindowLongPtr(hwnd, GwlExStyle, GetWindowLongPtr(hwnd, GwlExStyle) | WsExNoActivate | WsExToolWindow); + var appWindow = AppWindow.GetFromWindowId(Win32Interop.GetWindowIdFromWindow(hwnd)); + if (appWindow.Presenter is OverlappedPresenter presenter) + { + presenter.IsAlwaysOnTop = true; + presenter.IsResizable = false; + presenter.IsMaximizable = false; + presenter.IsMinimizable = false; + presenter.SetBorderAndTitleBar(false, false); + } + WindowPlacement.TopRightOn(appWindow, target, WidthDip, HeightDip, MarginDip); + appWindow.Show(activateWindow: false); + + var timer = DispatcherQueue.CreateTimer(); + timer.Interval = duration; + timer.IsRepeating = false; + timer.Tick += (_, _) => Close(); + timer.Start(); + } + + private const int GwlExStyle = -20; + private const nint WsExNoActivate = 0x08000000; + private const nint WsExToolWindow = 0x00000080; + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + private static extern nint GetWindowLongPtr(nint hwnd, int index); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static extern nint SetWindowLongPtr(nint hwnd, int index, nint value); +} diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml index 14c58d9..032615c 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -5,9 +5,10 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ConsoleMode.ViewModels" + xmlns:controls="using:ConsoleMode.Controls" Title="Console Mode"> - + @@ -25,6 +26,29 @@ + + + @@ -45,88 +69,88 @@ - - - - - - - - - - + - - - - + diff --git a/src/ConsoleMode/SessionMenuWindow.xaml.cs b/src/ConsoleMode/SessionMenuWindow.xaml.cs index 2b639fa..2bf6c80 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml.cs +++ b/src/ConsoleMode/SessionMenuWindow.xaml.cs @@ -18,11 +18,12 @@ namespace ConsoleMode; /// public sealed partial class SessionMenuWindow : Window { - private const double WidthDip = 760; - private const double HeightDip = 680; + private const double WidthDip = 1100; + private const double HeightDip = 620; private readonly nint _hwnd; private readonly GamepadNavigator _navigator; + private bool _editingVolume; public MainViewModel ViewModel { get; } @@ -43,18 +44,25 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) } WindowPlacement.CenterOn(appWindow, target, WidthDip, HeightDip); - _navigator = new GamepadNavigator(DispatcherQueue, Root, _hwnd) + // No foreground gate and HID for PlayStation pads: the game often keeps the focus even + // with the menu on top, and then the menu never heard the controller. + _navigator = new GamepadNavigator(DispatcherQueue, Root, readSonyHid: true) { - // Left/Right on the volume row adjust it instead of moving focus. + // Adjust mode on the volume tile: Left/Right change it, Up/Down are swallowed. BeforeMove = direction => { - if (Root.XamlRoot is not { } root || !ReferenceEquals(FocusManager.GetFocusedElement(root), VolumeRow)) return false; - if (direction == FocusNavigationDirection.Left) { ViewModel.ChangeVolume(-1); return true; } - if (direction == FocusNavigationDirection.Right) { ViewModel.ChangeVolume(1); return true; } - return false; + if (!_editingVolume) return false; + if (direction == FocusNavigationDirection.Left) ViewModel.ChangeVolume(-1); + else if (direction == FocusNavigationDirection.Right) ViewModel.ChangeVolume(1); + return true; } }; - _navigator.BackRequested += ViewModel.SessionMenuBack; + _navigator.OptionRequested += () => { if (IsVolumeFocused()) ViewModel.ToggleMuteCommand.Execute(null); }; + _navigator.BackRequested += () => + { + if (_editingVolume) SetEditingVolume(false); + else ViewModel.SessionMenuBack(); + }; _navigator.Start(); // PreviewKeyDown (tunneling): the ScrollViewer would otherwise handle the arrows as scrolling @@ -79,9 +87,11 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) } if (e.Key is not (Windows.System.VirtualKey.Escape or Windows.System.VirtualKey.GamepadB)) return; e.Handled = true; + if (_editingVolume) { SetEditingVolume(false); return; } ViewModel.SessionMenuBack(); }; ViewModel.PropertyChanged += OnViewModelChanged; + VolumeRow.LostFocus += (_, _) => SetEditingVolume(false); // BringToFront runs before the tree exists; the real first focus happens here. Root.Loaded += (_, _) => FirstRow.Focus(FocusState.Keyboard); Activated += (_, args) => @@ -98,6 +108,18 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) }; } + private bool IsVolumeFocused() => + Root.XamlRoot is { } root && ReferenceEquals(FocusManager.GetFocusedElement(root), VolumeRow); + + /// A on the volume tile toggles adjust mode; the arrows show while it is on. + private void OnVolumeClick(object sender, RoutedEventArgs e) => SetEditingVolume(!_editingVolume); + + private void SetEditingVolume(bool on) + { + _editingVolume = on; + VolumeLeft.Visibility = VolumeRight.Visibility = on ? Visibility.Visible : Visibility.Collapsed; + } + /// Windows won't hand a background process the foreground; this forces it (PlayStation pads need it). public void BringToFront() { diff --git a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs index 3802eae..60a9abd 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs @@ -58,6 +58,31 @@ public void ToggleSessionMenu() AppLog.Write("Menu da sessão: aberto"); } + /// + /// Once per session, a few seconds in (the game UI is up by then): a corner toast telling + /// how to open this menu, with the combo written for the pad in use. + /// + private async Task ShowSessionHintAsync() + { + await Task.Delay(TimeSpan.FromSeconds(5)); + _dispatcher.TryEnqueue(() => + { + if (!IsConsoleActive || IsSessionMenuOpen) return; + try + { + var combo = IsPlayStationHints ? "Create + △" : "Select + Y"; + _ = new SessionHintWindow(LocalizationService.Get("SessionHintTitle"), + LocalizationService.Get("SessionHintBody", combo), + Engine.State.FocusMonitorRect, TimeSpan.FromSeconds(7)); + AppLog.Write("Menu da sessão: aviso exibido"); + } + catch (Exception ex) + { + AppLog.Write($"Menu da sessão: aviso: {ex.Message}"); + } + }); + } + [RelayCommand] private void CloseSessionMenu() { diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs index 3606d32..f27ed4e 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.cs @@ -96,6 +96,7 @@ partial void OnIsConsoleActiveChanged(bool value) { OnPropertyChanged(nameof(IsIdle)); NotifyStartState(); + if (value) _ = ShowSessionHintAsync(); } partial void OnFocusRowChanged(MonitorRowViewModel? value) { diff --git a/tests/ConsoleMode.Tests/ControllerMappingTests.cs b/tests/ConsoleMode.Tests/ControllerMappingTests.cs index b07fb1f..61bd7ee 100644 --- a/tests/ConsoleMode.Tests/ControllerMappingTests.cs +++ b/tests/ConsoleMode.Tests/ControllerMappingTests.cs @@ -43,30 +43,86 @@ public void Gamepads_are_read_only_without_xinput() public void Stick_has_a_quarter_dead_zone(double x, double y, bool left, bool right, bool up, bool down) => Assert.Equal((left, right, up, down), ControllerMapping.StickDirections(x, y)); - // Select + Y on a Sony pad = Create/Share + Triangle, at each report's button offset. - [Theory] - [InlineData(0x01, 64, true, 8)] // DualSense, USB - [InlineData(0x31, 78, true, 9)] // DualSense, Bluetooth - [InlineData(0x01, 64, false, 5)] // DS4, USB - [InlineData(0x11, 78, false, 7)] // DS4, Bluetooth - [InlineData(0x01, 10, true, 5)] // basic Bluetooth report, before the enhanced mode - public void Sony_reports_map_create_and_triangle_to_select_and_y(int id, int length, bool dualSense, int start) + // Report IDs and offsets: (id, length, dualSense, buttons byte, left stick X byte). + public static TheoryData SonyLayouts => new() + { + { 0x01, 64, true, 8, 1 }, // DualSense, USB + { 0x31, 78, true, 9, 2 }, // DualSense, Bluetooth + { 0x01, 64, false, 5, 1 }, // DS4, USB + { 0x11, 78, false, 7, 3 }, // DS4, Bluetooth + { 0x01, 10, true, 5, 1 }, // basic Bluetooth report, before the enhanced mode + }; + + /// Nothing pressed: hat released (8), sticks centered. + private static byte[] NeutralReport(int id, int length, int start, int stick) { var report = new byte[length]; report[0] = (byte)id; - report[start] = 0x88; // Triangle, D-pad neutral + report[start] = 0x08; + report[stick] = report[stick + 1] = 128; + return report; + } + + [Theory] + [MemberData(nameof(SonyLayouts))] + public void Sony_neutral_report_gives_nothing(int id, int length, bool dualSense, int start, int stick) => + Assert.Equal(0, ControllerMapping.SonyButtons(NeutralReport(id, length, start, stick), dualSense)); + + // Select + Y on a Sony pad = Create/Share + Triangle. + [Theory] + [MemberData(nameof(SonyLayouts))] + public void Sony_reports_map_create_and_triangle_to_select_and_y(int id, int length, bool dualSense, int start, int stick) + { + var report = NeutralReport(id, length, start, stick); + report[start] |= 0x80; // Triangle report[start + 1] = 0x10; // Create/Share - Assert.Equal(0x0020 | 0x8000, ControllerMapping.SonyChordButtons(report, dualSense)); + Assert.Equal(0x0020 | 0x8000, ControllerMapping.SonyButtons(report, dualSense)); + } + + [Theory] + [MemberData(nameof(SonyLayouts))] + public void Sony_face_buttons_map_to_xbox_positions(int id, int length, bool dualSense, int start, int stick) + { + var report = NeutralReport(id, length, start, stick); + report[start] |= 0x10 | 0x20 | 0x40; // Square, Cross, Circle + Assert.Equal(0x4000 | 0x1000 | 0x2000, ControllerMapping.SonyButtons(report, dualSense)); + } + + [Theory] + [InlineData(0, 0x0001)] // up + [InlineData(1, 0x0001 | 0x0008)] // up-right + [InlineData(2, 0x0008)] // right + [InlineData(4, 0x0002)] // down + [InlineData(6, 0x0004)] // left + [InlineData(7, 0x0001 | 0x0004)] // up-left + public void Sony_hat_maps_to_dpad(int hat, int expected) + { + var report = NeutralReport(0x01, 64, 8, 1); + report[8] = (byte)hat; + Assert.Equal(expected, ControllerMapping.SonyButtons(report, dualSense: true)); + } + + [Theory] + [InlineData(128, 0, 0x0001)] // stick up + [InlineData(128, 255, 0x0002)] // down + [InlineData(0, 128, 0x0004)] // left + [InlineData(255, 128, 0x0008)] // right + [InlineData(150, 100, 0)] // inside the dead zone + public void Sony_left_stick_maps_to_dpad(int x, int y, int expected) + { + var report = NeutralReport(0x31, 78, 9, 2); + report[2] = (byte)x; + report[3] = (byte)y; + Assert.Equal(expected, ControllerMapping.SonyButtons(report, dualSense: true)); } [Fact] public void Sony_options_and_ps_map_to_start_and_guide() { - var report = new byte[64]; - report[0] = 0x01; + var report = NeutralReport(0x01, 64, 8, 1); report[9] = 0x20; // Options report[10] = 0x01; // PS - Assert.Equal(0x0010 | 0x0400, ControllerMapping.SonyChordButtons(report, dualSense: true)); + Assert.Equal(0x0010 | 0x0400, ControllerMapping.SonyButtons(report, dualSense: true)); } [Theory] @@ -74,5 +130,5 @@ public void Sony_options_and_ps_map_to_start_and_guide() [InlineData(new byte[] { 0x05, 0xFF, 0xFF })] // unknown report ID [InlineData(new byte[] { 0x31, 0, 0 })] // too short public void Unknown_or_short_sony_reports_give_nothing(byte[] report) => - Assert.Equal(0, ControllerMapping.SonyChordButtons(report, dualSense: true)); + Assert.Equal(0, ControllerMapping.SonyButtons(report, dualSense: true)); }