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
11 changes: 11 additions & 0 deletions src/ConsoleMode/Native/WindowPlacement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/// <summary>Top-right corner of the screen, <paramref name="marginDip"/> in from the edges.</summary>
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; }

Expand Down
2 changes: 2 additions & 0 deletions src/ConsoleMode/Resources/Strings.en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/ConsoleMode/Resources/Strings.es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/ConsoleMode/Resources/Strings.pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 30 additions & 2 deletions src/ConsoleMode/Services/ControllerInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,17 +118,30 @@ public static ControllerFamily DetectFamily()
return ControllerFamily.None;
}

/// <summary>
/// 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.
/// </summary>
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()
{
Expand All @@ -142,6 +155,7 @@ private void Poll()
try
{
ReadAll(held, null);
if (ReadSonyHid) ReadSony(held, SonyHidReader.Held);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -188,6 +202,20 @@ private static void ReadAll(bool[] held, StringBuilder? diag)
ReadRaw(held, xinputPads, diag);
}

/// <summary>SonyHidReader bits use the XInput layout, so they map like an Xbox pad.</summary>
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;
Expand Down
40 changes: 29 additions & 11 deletions src/ConsoleMode/Services/ControllerMapping.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,45 @@ public static class ControllerMapping
public static bool IsDualSense(ushort productId) => productId is 0x0CE6 or 0x0DF2;

/// <summary>
/// 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.
/// </summary>
public static ushort SonyChordButtons(ReadOnlySpan<byte> report, bool dualSense)
public static ushort SonyButtons(ReadOnlySpan<byte> 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;
}

Expand Down
5 changes: 3 additions & 2 deletions src/ConsoleMode/Services/GamepadNavigator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ public sealed class GamepadNavigator : IDisposable
public Func<ControllerAction, bool>? Intercept { get; set; }

/// <param name="hwnd">When given, presses only count while this window is in the foreground.</param>
public GamepadNavigator(DispatcherQueue dispatcher, DependencyObject root, nint hwnd = 0)
/// <param name="readSonyHid">Also read PlayStation pads over HID, for windows that may not get the foreground.</param>
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;
}
Expand Down
4 changes: 2 additions & 2 deletions src/ConsoleMode/Services/SonyHidReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public static class SonyHidReader
private static CancellationTokenSource? _cts;
private static int _users;

/// <summary>XInput-style bits held on any Sony pad (see ControllerMapping.SonyChordButtons).</summary>
/// <summary>XInput-style bits held on any Sony pad (see ControllerMapping.SonyButtons).</summary>
public static ushort Held
{
get
Expand Down Expand Up @@ -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; }
}
}
Expand Down
77 changes: 77 additions & 0 deletions src/ConsoleMode/SessionHintWindow.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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);
}
Loading