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