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)); }