diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs index 9e30df80..2b93c1dd 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml.cs +++ b/src/SharpEmu.GUI/MainWindow.axaml.cs @@ -12,7 +12,8 @@ using Avalonia.Platform; using Avalonia.Platform.Storage; using Avalonia.Threading; using Avalonia.VisualTree; -using SharpEmu.Libs.Pad; +using SharpEmu.HLE.Host; +using SharpEmu.HLE.Host.Windows; using SharpEmu.Logging; using System.Collections.Concurrent; using System.Collections.ObjectModel; @@ -62,7 +63,7 @@ public partial class MainWindow : Window // Controller navigation state. private readonly DispatcherTimer _gamepadTimer; - private uint _previousPadButtons; + private HostGamepadButtons _previousPadButtons; private long _navLeftNextAt; private long _navRightNextAt; private long _navUpNextAt; @@ -151,8 +152,8 @@ public partial class MainWindow : Window Opened += async (_, _) => await OnOpenedAsync(); Closing += (_, _) => OnWindowClosing(); - DualSenseReader.EnsureStarted(); - XInputReader.EnsureStarted(); + WindowsDualSenseReader.EnsureStarted(); + WindowsXInputReader.EnsureStarted(); _gamepadTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50), @@ -224,9 +225,9 @@ public partial class MainWindow : Window private void PollGamepad() { // DualSense wins when both are connected; XInput covers Xbox pads. - if (!DualSenseReader.TryGetState(out var pad) && !XInputReader.TryGetState(out pad)) + if (!WindowsDualSenseReader.TryGetState(out var pad) && !WindowsXInputReader.TryGetState(out pad)) { - _previousPadButtons = 0; + _previousPadButtons = HostGamepadButtons.None; return; } @@ -239,12 +240,12 @@ public partial class MainWindow : Window } var shoulderPressed = pad.Buttons & ~_previousPadButtons; - if ((shoulderPressed & OrbisPadButton.L1) != 0) + if ((shoulderPressed & HostGamepadButtons.L1) != 0) { SetActivePage(0); } - if ((shoulderPressed & OrbisPadButton.R1) != 0) + if ((shoulderPressed & HostGamepadButtons.R1) != 0) { SetActivePage(1); } @@ -256,10 +257,10 @@ public partial class MainWindow : Window } var now = Environment.TickCount64; - var left = (pad.Buttons & 0x0080) != 0 || pad.LeftX < 64; - var right = (pad.Buttons & 0x0020) != 0 || pad.LeftX > 192; - var up = (pad.Buttons & 0x0010) != 0 || pad.LeftY < 64; - var down = (pad.Buttons & 0x0040) != 0 || pad.LeftY > 192; + var left = (pad.Buttons & HostGamepadButtons.Left) != 0 || pad.LeftX < 64; + var right = (pad.Buttons & HostGamepadButtons.Right) != 0 || pad.LeftX > 192; + var up = (pad.Buttons & HostGamepadButtons.Up) != 0 || pad.LeftY < 64; + var down = (pad.Buttons & HostGamepadButtons.Down) != 0 || pad.LeftY > 192; if (ShouldNavigate(left, ref _navLeftNextAt, now)) { @@ -282,12 +283,12 @@ public partial class MainWindow : Window } var pressed = pad.Buttons & ~_previousPadButtons; - if ((pressed & 0x4000) != 0) // Cross + if ((pressed & HostGamepadButtons.Cross) != 0) { LaunchSelected(); } - if ((pressed & 0x2000) != 0) // Circle + if ((pressed & HostGamepadButtons.Circle) != 0) { StopEmulator(); } diff --git a/src/SharpEmu.GUI/SharpEmu.GUI.csproj b/src/SharpEmu.GUI/SharpEmu.GUI.csproj index 9eff14d6..86415a5a 100644 --- a/src/SharpEmu.GUI/SharpEmu.GUI.csproj +++ b/src/SharpEmu.GUI/SharpEmu.GUI.csproj @@ -10,6 +10,9 @@ SPDX-License-Identifier: GPL-2.0-or-later false 0.0.1 + + true + with the emulator's host input backend. They are dependency-free, so + they are compiled in directly rather than pulling a reference to all + of SharpEmu.HLE into the launcher. --> - - - - + + + + diff --git a/src/SharpEmu.HLE/Host/HostGamepadState.cs b/src/SharpEmu.HLE/Host/HostGamepadState.cs new file mode 100644 index 00000000..79760dca --- /dev/null +++ b/src/SharpEmu.HLE/Host/HostGamepadState.cs @@ -0,0 +1,46 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host; + +/// +/// Host-neutral gamepad button flags. Named after the PlayStation layout the guest API +/// exposes, but the numeric values are the seam's own — the HLE pad exports translate +/// them to SCE_PAD_BUTTON bits, so guest ABI values never leak into host backends. +/// +[Flags] +public enum HostGamepadButtons : uint +{ + None = 0, + Up = 1 << 0, + Down = 1 << 1, + Left = 1 << 2, + Right = 1 << 3, + Cross = 1 << 4, + Circle = 1 << 5, + Square = 1 << 6, + Triangle = 1 << 7, + L1 = 1 << 8, + R1 = 1 << 9, + L2 = 1 << 10, + R2 = 1 << 11, + L3 = 1 << 12, + R3 = 1 << 13, + Options = 1 << 14, + TouchPad = 1 << 15, +} + +/// +/// Snapshot of one host gamepad: sticks are 0..255 with 128 centered and Y growing +/// downward; triggers 0..255. Unmanaged on purpose so per-frame polls can stackalloc +/// snapshot buffers. +/// +public readonly record struct HostGamepadState( + bool Connected, + HostGamepadButtons Buttons, + byte LeftX, + byte LeftY, + byte RightX, + byte RightY, + byte LeftTrigger, + byte RightTrigger); diff --git a/src/SharpEmu.HLE/Host/IHostAudioOutput.cs b/src/SharpEmu.HLE/Host/IHostAudioOutput.cs new file mode 100644 index 00000000..2f84fe57 --- /dev/null +++ b/src/SharpEmu.HLE/Host/IHostAudioOutput.cs @@ -0,0 +1,23 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host; + +/// +/// Host audio-output device access. The HLE audio exports convert guest submissions to +/// interleaved stereo 16-bit PCM (the format every backend accepts) and feed them through +/// streams opened here; everything device-specific — queueing, backpressure, native +/// buffer lifetime — lives behind . +/// +public interface IHostAudioOutput +{ + /// Backend identifier for diagnostics (e.g. "winmm"). + string BackendName { get; } + + /// + /// Opens an interleaved stereo 16-bit PCM output stream at the given sample rate. + /// Throws when the host has no usable output device; callers degrade to a silent + /// port and pace the guest instead. + /// + IHostAudioStream OpenStereoPcm16Stream(uint sampleRate); +} diff --git a/src/SharpEmu.HLE/Host/IHostAudioStream.cs b/src/SharpEmu.HLE/Host/IHostAudioStream.cs new file mode 100644 index 00000000..2db99abb --- /dev/null +++ b/src/SharpEmu.HLE/Host/IHostAudioStream.cs @@ -0,0 +1,18 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host; + +/// +/// One open host audio output stream. Submissions are interleaved stereo 16-bit PCM at +/// the sample rate the stream was opened with. +/// +public interface IHostAudioStream : IDisposable +{ + /// + /// Submits one buffer. May block briefly while the device drains its queue (this is + /// what paces the guest's audio loop); returns false when the stream cannot accept + /// audio, in which case the caller paces the guest itself. + /// + bool Submit(ReadOnlySpan stereoPcm16); +} diff --git a/src/SharpEmu.HLE/Host/IHostInput.cs b/src/SharpEmu.HLE/Host/IHostInput.cs new file mode 100644 index 00000000..52779bcf --- /dev/null +++ b/src/SharpEmu.HLE/Host/IHostInput.cs @@ -0,0 +1,44 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host; + +/// +/// Host input devices: gamepad state snapshots, force-feedback/lightbar sinks, and the +/// keyboard-fallback queries. Which physical readers exist (DualSense over raw HID, +/// XInput, evdev, ...) is a backend detail; merge policy between devices and the +/// keyboard lives in the HLE pad exports. +/// +public interface IHostInput +{ + /// Starts the background device readers once; safe to call repeatedly. + void EnsureStarted(); + + /// + /// Fills with snapshots of currently connected + /// gamepads and returns how many were written (0 when none are connected). + /// + int GetGamepadStates(Span destination); + + /// Human-readable name of the first connected gamepad, or null. + string? DescribeConnectedGamepad(); + + /// Sets rumble on all connected gamepads; large = strong/left motor. + void SetRumble(byte largeMotor, byte smallMotor); + + /// + /// Approximates per-trigger vibration on gamepads without independent trigger + /// actuators; null leaves that trigger's current value unchanged. + /// + void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger); + + void SetLightbar(byte red, byte green, byte blue); + + void ResetLightbar(); + + /// True when a window of this process has keyboard focus. + bool IsHostWindowFocused(); + + /// Windows virtual-key code semantics; other backends translate. + bool IsKeyDown(int virtualKey); +} diff --git a/src/SharpEmu.HLE/Host/IHostPlatform.cs b/src/SharpEmu.HLE/Host/IHostPlatform.cs index 3dfeac60..05575c96 100644 --- a/src/SharpEmu.HLE/Host/IHostPlatform.cs +++ b/src/SharpEmu.HLE/Host/IHostPlatform.cs @@ -16,4 +16,8 @@ public interface IHostPlatform IHostThreading Threading { get; } IHostSymbolResolver Symbols { get; } + + IHostAudioOutput Audio { get; } + + IHostInput Input { get; } } diff --git a/src/SharpEmu.HLE/Host/IHostThreading.cs b/src/SharpEmu.HLE/Host/IHostThreading.cs index 85cc0c9e..57a4b8e1 100644 --- a/src/SharpEmu.HLE/Host/IHostThreading.cs +++ b/src/SharpEmu.HLE/Host/IHostThreading.cs @@ -24,6 +24,12 @@ public interface IHostThreading bool TrySetCurrentThreadAffinity(nuint affinityMask); + /// + /// Asks the OS for ~1 ms timed-wait granularity for the life of the process + /// (idempotent; best-effort). No-op on platforms whose default is already fine. + /// + void RequestTimerResolution(); + /// /// Creates a raw OS thread executing native code at with /// of reserved (not committed) stack. diff --git a/src/SharpEmu.Libs/Pad/DualSenseReader.cs b/src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs similarity index 78% rename from src/SharpEmu.Libs/Pad/DualSenseReader.cs rename to src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs index 7094cdcc..5b49d545 100644 --- a/src/SharpEmu.Libs/Pad/DualSenseReader.cs +++ b/src/SharpEmu.HLE/Host/Windows/WindowsDualSenseReader.cs @@ -3,21 +3,21 @@ using Microsoft.Win32.SafeHandles; -namespace SharpEmu.Libs.Pad; +namespace SharpEmu.HLE.Host.Windows; /// /// Reads a DualSense controller over raw HID on a background thread. /// Supports USB (input report 0x01) and Bluetooth (extended report 0x31, /// activated by requesting feature report 0x05), with hot-plug retry. /// -internal static class DualSenseReader +internal static class WindowsDualSenseReader { private const ushort SonyVendorId = 0x054C; private const ushort DualSenseProductId = 0x0CE6; private const ushort DualSenseEdgeProductId = 0x0DF2; private static readonly object Gate = new(); - private static PadState _state; + private static HostGamepadState _state; private static bool _started; // Output (rumble/lightbar) state, all guarded by Gate. @@ -37,6 +37,8 @@ internal static class DualSenseReader /// Starts the background reader once; safe to call repeatedly. internal static void EnsureStarted() { + // The GUI source-links this reader and calls it directly, without the + // host-platform resolution that otherwise guarantees Windows. if (!OperatingSystem.IsWindows()) { return; @@ -59,7 +61,7 @@ internal static class DualSenseReader } } - internal static bool TryGetState(out PadState state) + internal static bool TryGetState(out HostGamepadState state) { lock (Gate) { @@ -69,7 +71,7 @@ internal static class DualSenseReader return state.Connected; } - private static void SetState(in PadState state) + private static void SetState(in HostGamepadState state) { lock (Gate) { @@ -148,11 +150,11 @@ internal static class DualSenseReader { if (_outputStream is null) { - var handle = HidNative.CreateFile( + var handle = WindowsHidNative.CreateFile( _devicePath, - HidNative.GenericRead | HidNative.GenericWrite, - HidNative.FileShareRead | HidNative.FileShareWrite, - 0, HidNative.OpenExisting, 0, 0); + WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite, + WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, + 0, WindowsHidNative.OpenExisting, 0, 0); if (handle.IsInvalid) { handle.Dispose(); @@ -262,7 +264,7 @@ internal static class DualSenseReader // to the full 0x31 input report. Harmless over USB. var feature = new byte[41]; feature[0] = 0x05; - _ = HidNative.HidD_GetFeature(handle, feature, feature.Length); + _ = WindowsHidNative.HidD_GetFeature(handle, feature, feature.Length); if (!announcedConnect) { @@ -320,18 +322,18 @@ internal static class DualSenseReader private static SafeFileHandle? OpenDualSense(out string? devicePath) { devicePath = null; - foreach (var path in HidNative.EnumerateHidDevicePaths()) + foreach (var path in WindowsHidNative.EnumerateHidDevicePaths()) { // Open without access rights just to query VID/PID. - using var probe = HidNative.CreateFile( - path, 0, HidNative.FileShareRead | HidNative.FileShareWrite, 0, HidNative.OpenExisting, 0, 0); + using var probe = WindowsHidNative.CreateFile( + path, 0, WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, 0, WindowsHidNative.OpenExisting, 0, 0); if (probe.IsInvalid) { continue; } - var attributes = new HidNative.HiddAttributes { Size = 12 }; - if (!HidNative.HidD_GetAttributes(probe, ref attributes) || + var attributes = new WindowsHidNative.HiddAttributes { Size = 12 }; + if (!WindowsHidNative.HidD_GetAttributes(probe, ref attributes) || attributes.VendorId != SonyVendorId || (attributes.ProductId != DualSenseProductId && attributes.ProductId != DualSenseEdgeProductId)) { @@ -339,19 +341,19 @@ internal static class DualSenseReader } // Read+write so feature reports work; fall back to read-only. - var handle = HidNative.CreateFile( + var handle = WindowsHidNative.CreateFile( path, - HidNative.GenericRead | HidNative.GenericWrite, - HidNative.FileShareRead | HidNative.FileShareWrite, - 0, HidNative.OpenExisting, 0, 0); + WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite, + WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, + 0, WindowsHidNative.OpenExisting, 0, 0); if (handle.IsInvalid) { handle.Dispose(); - handle = HidNative.CreateFile( + handle = WindowsHidNative.CreateFile( path, - HidNative.GenericRead, - HidNative.FileShareRead | HidNative.FileShareWrite, - 0, HidNative.OpenExisting, 0, 0); + WindowsHidNative.GenericRead, + WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, + 0, WindowsHidNative.OpenExisting, 0, 0); } if (!handle.IsInvalid) @@ -366,7 +368,7 @@ internal static class DualSenseReader return null; } - private static bool TryParseReport(ReadOnlySpan report, out PadState state) + private static bool TryParseReport(ReadOnlySpan report, out HostGamepadState state) { // USB: report id 0x01, payload starts at [1]. // Bluetooth extended: report id 0x31, sequence byte at [1], payload at [2]. @@ -395,43 +397,43 @@ internal static class DualSenseReader var buttons1 = report[offset + 8]; var buttons2 = report[offset + 9]; - uint buttons = 0; - buttons |= (buttons0 & 0x10) != 0 ? OrbisPadButton.Square : 0; - buttons |= (buttons0 & 0x20) != 0 ? OrbisPadButton.Cross : 0; - buttons |= (buttons0 & 0x40) != 0 ? OrbisPadButton.Circle : 0; - buttons |= (buttons0 & 0x80) != 0 ? OrbisPadButton.Triangle : 0; + var buttons = HostGamepadButtons.None; + buttons |= (buttons0 & 0x10) != 0 ? HostGamepadButtons.Square : 0; + buttons |= (buttons0 & 0x20) != 0 ? HostGamepadButtons.Cross : 0; + buttons |= (buttons0 & 0x40) != 0 ? HostGamepadButtons.Circle : 0; + buttons |= (buttons0 & 0x80) != 0 ? HostGamepadButtons.Triangle : 0; buttons |= HatToButtons(buttons0 & 0x0F); - buttons |= (buttons1 & 0x01) != 0 ? OrbisPadButton.L1 : 0; - buttons |= (buttons1 & 0x02) != 0 ? OrbisPadButton.R1 : 0; - buttons |= (buttons1 & 0x04) != 0 ? OrbisPadButton.L2 : 0; - buttons |= (buttons1 & 0x08) != 0 ? OrbisPadButton.R2 : 0; - buttons |= (buttons1 & 0x20) != 0 ? OrbisPadButton.Options : 0; - buttons |= (buttons1 & 0x40) != 0 ? OrbisPadButton.L3 : 0; - buttons |= (buttons1 & 0x80) != 0 ? OrbisPadButton.R3 : 0; - buttons |= (buttons2 & 0x02) != 0 ? OrbisPadButton.TouchPad : 0; + buttons |= (buttons1 & 0x01) != 0 ? HostGamepadButtons.L1 : 0; + buttons |= (buttons1 & 0x02) != 0 ? HostGamepadButtons.R1 : 0; + buttons |= (buttons1 & 0x04) != 0 ? HostGamepadButtons.L2 : 0; + buttons |= (buttons1 & 0x08) != 0 ? HostGamepadButtons.R2 : 0; + buttons |= (buttons1 & 0x20) != 0 ? HostGamepadButtons.Options : 0; + buttons |= (buttons1 & 0x40) != 0 ? HostGamepadButtons.L3 : 0; + buttons |= (buttons1 & 0x80) != 0 ? HostGamepadButtons.R3 : 0; + buttons |= (buttons2 & 0x02) != 0 ? HostGamepadButtons.TouchPad : 0; - state = new PadState( + state = new HostGamepadState( Connected: true, Buttons: buttons, LeftX: leftX, LeftY: leftY, RightX: rightX, RightY: rightY, - L2: l2, - R2: r2); + LeftTrigger: l2, + RightTrigger: r2); return true; } - private static uint HatToButtons(int hat) => hat switch + private static HostGamepadButtons HatToButtons(int hat) => hat switch { - 0 => OrbisPadButton.Up, - 1 => OrbisPadButton.Up | OrbisPadButton.Right, - 2 => OrbisPadButton.Right, - 3 => OrbisPadButton.Right | OrbisPadButton.Down, - 4 => OrbisPadButton.Down, - 5 => OrbisPadButton.Down | OrbisPadButton.Left, - 6 => OrbisPadButton.Left, - 7 => OrbisPadButton.Left | OrbisPadButton.Up, + 0 => HostGamepadButtons.Up, + 1 => HostGamepadButtons.Up | HostGamepadButtons.Right, + 2 => HostGamepadButtons.Right, + 3 => HostGamepadButtons.Right | HostGamepadButtons.Down, + 4 => HostGamepadButtons.Down, + 5 => HostGamepadButtons.Down | HostGamepadButtons.Left, + 6 => HostGamepadButtons.Left, + 7 => HostGamepadButtons.Left | HostGamepadButtons.Up, _ => 0, }; } diff --git a/src/SharpEmu.Libs/Pad/HidNative.cs b/src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs similarity index 71% rename from src/SharpEmu.Libs/Pad/HidNative.cs rename to src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs index b52a5897..22e8afbb 100644 --- a/src/SharpEmu.Libs/Pad/HidNative.cs +++ b/src/SharpEmu.HLE/Host/Windows/WindowsHidNative.cs @@ -4,13 +4,13 @@ using System.Runtime.InteropServices; using Microsoft.Win32.SafeHandles; -namespace SharpEmu.Libs.Pad; +namespace SharpEmu.HLE.Host.Windows; /// /// Minimal Win32 HID interop used to talk to a DualSense controller /// directly, without any external input library. /// -internal static partial class HidNative +internal static partial class WindowsHidNative { internal const int DigcfPresent = 0x02; internal const int DigcfDeviceInterface = 0x10; @@ -38,28 +38,32 @@ internal static partial class HidNative public ushort VersionNumber; } - [DllImport("hid.dll")] - internal static extern void HidD_GetHidGuid(out Guid hidGuid); + [LibraryImport("hid.dll")] + internal static partial void HidD_GetHidGuid(out Guid hidGuid); - [DllImport("hid.dll")] - internal static extern bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, ref HiddAttributes attributes); + [LibraryImport("hid.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, ref HiddAttributes attributes); - [DllImport("hid.dll")] - internal static extern bool HidD_GetFeature(SafeFileHandle hidDeviceObject, byte[] reportBuffer, int reportBufferLength); + [LibraryImport("hid.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool HidD_GetFeature(SafeFileHandle hidDeviceObject, [In, Out] byte[] reportBuffer, int reportBufferLength); - [DllImport("setupapi.dll", CharSet = CharSet.Unicode)] - internal static extern nint SetupDiGetClassDevs(ref Guid classGuid, nint enumerator, nint hwndParent, int flags); + [LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetClassDevsW")] + internal static partial nint SetupDiGetClassDevs(ref Guid classGuid, nint enumerator, nint hwndParent, int flags); - [DllImport("setupapi.dll")] - internal static extern bool SetupDiEnumDeviceInterfaces( + [LibraryImport("setupapi.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetupDiEnumDeviceInterfaces( nint deviceInfoSet, nint deviceInfoData, ref Guid interfaceClassGuid, int memberIndex, ref SpDeviceInterfaceData deviceInterfaceData); - [DllImport("setupapi.dll", CharSet = CharSet.Unicode)] - internal static extern bool SetupDiGetDeviceInterfaceDetail( + [LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetDeviceInterfaceDetailW")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetupDiGetDeviceInterfaceDetail( nint deviceInfoSet, ref SpDeviceInterfaceData deviceInterfaceData, nint deviceInterfaceDetailData, @@ -67,11 +71,12 @@ internal static partial class HidNative out int requiredSize, nint deviceInfoData); - [DllImport("setupapi.dll")] - internal static extern bool SetupDiDestroyDeviceInfoList(nint deviceInfoSet); + [LibraryImport("setupapi.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetupDiDestroyDeviceInfoList(nint deviceInfoSet); - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - internal static extern SafeFileHandle CreateFile( + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + internal static partial SafeFileHandle CreateFile( string fileName, uint desiredAccess, uint shareMode, diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs b/src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs new file mode 100644 index 00000000..c2abe1f4 --- /dev/null +++ b/src/SharpEmu.HLE/Host/Windows/WindowsHostInput.cs @@ -0,0 +1,84 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; + +namespace SharpEmu.HLE.Host.Windows; + +/// +/// Windows input backend: DualSense over raw HID plus XInput controllers for gamepads, +/// user32 for the keyboard-fallback queries. Rumble fans out to every reader; lightbar +/// only exists on the DualSense. +/// +internal sealed partial class WindowsHostInput : IHostInput +{ + public void EnsureStarted() + { + WindowsDualSenseReader.EnsureStarted(); + WindowsXInputReader.EnsureStarted(); + } + + public int GetGamepadStates(Span destination) + { + var count = 0; + if (count < destination.Length && WindowsDualSenseReader.TryGetState(out var dualSense)) + { + destination[count++] = dualSense; + } + + if (count < destination.Length && WindowsXInputReader.TryGetState(out var xinput)) + { + destination[count++] = xinput; + } + + return count; + } + + public string? DescribeConnectedGamepad() + { + if (WindowsDualSenseReader.TryGetState(out _)) + { + return "DualSense"; + } + + return WindowsXInputReader.TryGetState(out _) ? "Xbox controller" : null; + } + + public void SetRumble(byte largeMotor, byte smallMotor) + { + WindowsDualSenseReader.SetRumble(largeMotor, smallMotor); + WindowsXInputReader.SetRumble(largeMotor, smallMotor); + } + + public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) => + WindowsXInputReader.SetTriggerRumble(leftTrigger, rightTrigger); + + public void SetLightbar(byte red, byte green, byte blue) => + WindowsDualSenseReader.SetLightbar(red, green, blue); + + public void ResetLightbar() => WindowsDualSenseReader.ResetLightbar(); + + public bool IsHostWindowFocused() + { + var foregroundWindow = GetForegroundWindow(); + if (foregroundWindow == 0) + { + return false; + } + + GetWindowThreadProcessId(foregroundWindow, out var processId); + return processId == (uint)Environment.ProcessId; + } + + public bool IsKeyDown(int virtualKey) => + (GetAsyncKeyState(virtualKey) & 0x8000) != 0; + + [LibraryImport("user32.dll")] + private static partial short GetAsyncKeyState(int vKey); + + [LibraryImport("user32.dll")] + private static partial nint GetForegroundWindow(); + + [LibraryImport("user32.dll")] + private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId); +} diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs b/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs index d8fdf28d..0e5ab7e2 100644 --- a/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs +++ b/src/SharpEmu.HLE/Host/Windows/WindowsHostPlatform.cs @@ -10,4 +10,8 @@ internal sealed class WindowsHostPlatform : IHostPlatform public IHostThreading Threading { get; } = new WindowsHostThreading(); public IHostSymbolResolver Symbols { get; } = new WindowsHostSymbolResolver(); + + public IHostAudioOutput Audio { get; } = new WindowsWaveOutAudio(); + + public IHostInput Input { get; } = new WindowsHostInput(); } diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsHostThreading.cs b/src/SharpEmu.HLE/Host/Windows/WindowsHostThreading.cs index 44b36059..8f87b198 100644 --- a/src/SharpEmu.HLE/Host/Windows/WindowsHostThreading.cs +++ b/src/SharpEmu.HLE/Host/Windows/WindowsHostThreading.cs @@ -23,6 +23,31 @@ internal sealed unsafe partial class WindowsHostThreading : IHostThreading private const int CtxRbp = 160; private const int CtxRip = 248; + private static int _timerResolutionRequested; + + public void RequestTimerResolution() + { + if (Interlocked.Exchange(ref _timerResolutionRequested, 1) != 0) + { + return; + } + + try + { + if (TimeBeginPeriod(1) != 0) + { + Console.Error.WriteLine( + "[LOADER][WARN] Host timer resolution request rejected; " + + "timed waits keep the default ~15.6 ms granularity."); + } + } + catch (DllNotFoundException exception) + { + Console.Error.WriteLine( + $"[LOADER][WARN] Host timer resolution unavailable: {exception.Message}"); + } + } + public uint AllocateTlsSlot() => TlsAlloc(); public bool FreeTlsSlot(uint slot) => TlsFree(slot); @@ -162,4 +187,7 @@ internal sealed unsafe partial class WindowsHostThreading : IHostThreading [LibraryImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static partial bool CloseHandle(nint hObject); + + [LibraryImport("winmm.dll", EntryPoint = "timeBeginPeriod")] + private static partial uint TimeBeginPeriod(uint uPeriod); } diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsWaveOutAudio.cs b/src/SharpEmu.HLE/Host/Windows/WindowsWaveOutAudio.cs new file mode 100644 index 00000000..30ce38c3 --- /dev/null +++ b/src/SharpEmu.HLE/Host/Windows/WindowsWaveOutAudio.cs @@ -0,0 +1,243 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; + +namespace SharpEmu.HLE.Host.Windows; + +internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput +{ + public string BackendName => "winmm"; + + public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate) => new WaveOutStream(sampleRate); + + private sealed partial class WaveOutStream : IHostAudioStream + { + private const uint WaveMapper = uint.MaxValue; + private const uint CallbackEvent = 0x0005_0000; + private const ushort WaveFormatPcm = 1; + private const uint WaveHeaderDone = 0x0000_0001; + private const int MaximumQueuedPcmBytes = 32 * 1024; + + private readonly object _gate = new(); + private readonly AutoResetEvent _completion = new(false); + private readonly Queue _buffers = new(); + private IntPtr _device; + private int _queuedPcmBytes; + private bool _disposed; + + public WaveOutStream(uint sampleRate) + { + var format = new WaveFormat + { + FormatTag = WaveFormatPcm, + Channels = 2, + SamplesPerSecond = sampleRate, + AverageBytesPerSecond = checked(sampleRate * 4), + BlockAlign = 4, + BitsPerSample = 16, + ExtraSize = 0, + }; + var result = WaveOutOpen( + out _device, + WaveMapper, + ref format, + _completion.SafeWaitHandle.DangerousGetHandle(), + IntPtr.Zero, + CallbackEvent); + if (result != 0) + { + throw new InvalidOperationException($"waveOutOpen failed with MMRESULT {result}."); + } + } + + public bool Submit(ReadOnlySpan stereoPcm16) + { + lock (_gate) + { + if (_disposed) + { + return false; + } + + ReapCompletedBuffers(); + while (_queuedPcmBytes != 0 && + _queuedPcmBytes + stereoPcm16.Length > MaximumQueuedPcmBytes) + { + if (!_completion.WaitOne(TimeSpan.FromSeconds(1))) + { + return false; + } + + ReapCompletedBuffers(); + } + + return QueueBuffer(stereoPcm16); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_device != IntPtr.Zero) + { + WaveOutReset(_device); + while (_buffers.TryDequeue(out var buffer)) + { + ReleaseBuffer(buffer); + } + + WaveOutClose(_device); + _device = IntPtr.Zero; + } + + _completion.Dispose(); + } + } + + private bool QueueBuffer(ReadOnlySpan data) + { + var dataAddress = Marshal.AllocHGlobal(data.Length); + var headerAddress = IntPtr.Zero; + try + { + unsafe + { + data.CopyTo(new Span((void*)dataAddress, data.Length)); + } + + var header = new WaveHeader + { + Data = dataAddress, + BufferLength = checked((uint)data.Length), + }; + headerAddress = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(header, headerAddress, false); + + var result = WaveOutPrepareHeader( + _device, + headerAddress, + checked((uint)Marshal.SizeOf())); + if (result != 0) + { + return false; + } + + result = WaveOutWrite( + _device, + headerAddress, + checked((uint)Marshal.SizeOf())); + if (result != 0) + { + WaveOutUnprepareHeader( + _device, + headerAddress, + checked((uint)Marshal.SizeOf())); + return false; + } + + _buffers.Enqueue(new NativeBuffer(dataAddress, headerAddress, data.Length)); + _queuedPcmBytes += data.Length; + dataAddress = IntPtr.Zero; + headerAddress = IntPtr.Zero; + return true; + } + finally + { + if (headerAddress != IntPtr.Zero) + { + Marshal.FreeHGlobal(headerAddress); + } + + if (dataAddress != IntPtr.Zero) + { + Marshal.FreeHGlobal(dataAddress); + } + } + } + + private void ReapCompletedBuffers() + { + while (_buffers.TryPeek(out var buffer)) + { + var header = Marshal.PtrToStructure(buffer.Header); + if ((header.Flags & WaveHeaderDone) == 0) + { + return; + } + + _buffers.Dequeue(); + ReleaseBuffer(buffer); + } + } + + private void ReleaseBuffer(NativeBuffer buffer) + { + WaveOutUnprepareHeader( + _device, + buffer.Header, + checked((uint)Marshal.SizeOf())); + _queuedPcmBytes -= buffer.Length; + Marshal.FreeHGlobal(buffer.Header); + Marshal.FreeHGlobal(buffer.Data); + } + + private readonly record struct NativeBuffer(IntPtr Data, IntPtr Header, int Length); + + [StructLayout(LayoutKind.Sequential, Pack = 2)] + private struct WaveFormat + { + public ushort FormatTag; + public ushort Channels; + public uint SamplesPerSecond; + public uint AverageBytesPerSecond; + public ushort BlockAlign; + public ushort BitsPerSample; + public ushort ExtraSize; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WaveHeader + { + public IntPtr Data; + public uint BufferLength; + public uint BytesRecorded; + public nuint User; + public uint Flags; + public uint Loops; + public IntPtr Next; + public nuint Reserved; + } + + [LibraryImport("winmm.dll", EntryPoint = "waveOutOpen")] + private static partial uint WaveOutOpen( + out IntPtr device, + uint deviceId, + ref WaveFormat format, + IntPtr callback, + IntPtr instance, + uint flags); + + [LibraryImport("winmm.dll", EntryPoint = "waveOutPrepareHeader")] + private static partial uint WaveOutPrepareHeader(IntPtr device, IntPtr header, uint headerSize); + + [LibraryImport("winmm.dll", EntryPoint = "waveOutWrite")] + private static partial uint WaveOutWrite(IntPtr device, IntPtr header, uint headerSize); + + [LibraryImport("winmm.dll", EntryPoint = "waveOutUnprepareHeader")] + private static partial uint WaveOutUnprepareHeader(IntPtr device, IntPtr header, uint headerSize); + + [LibraryImport("winmm.dll", EntryPoint = "waveOutReset")] + private static partial uint WaveOutReset(IntPtr device); + + [LibraryImport("winmm.dll", EntryPoint = "waveOutClose")] + private static partial uint WaveOutClose(IntPtr device); + } +} diff --git a/src/SharpEmu.Libs/Pad/XInputReader.cs b/src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs similarity index 72% rename from src/SharpEmu.Libs/Pad/XInputReader.cs rename to src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs index 1cbc3779..6e1bc2b7 100644 --- a/src/SharpEmu.Libs/Pad/XInputReader.cs +++ b/src/SharpEmu.HLE/Host/Windows/WindowsXInputReader.cs @@ -3,15 +3,15 @@ using System.Runtime.InteropServices; -namespace SharpEmu.Libs.Pad; +namespace SharpEmu.HLE.Host.Windows; /// /// Reads Xbox 360 / Xbox One (and other XInput-compatible) controllers via -/// the Windows XInput API on a background thread, translated to the same -/// ORBIS pad conventions as . Supports rumble -/// and hot-plug retry; the first connected slot (of four) is used. +/// the Windows XInput API on a background thread, translated to +/// conventions. Supports rumble and hot-plug +/// retry; the first connected slot (of four) is used. /// -internal static class XInputReader +internal static partial class WindowsXInputReader { private const uint ErrorSuccess = 0; private const int SlotCount = 4; @@ -34,7 +34,7 @@ internal static class XInputReader private const ushort XinputY = 0x8000; private static readonly object Gate = new(); - private static PadState _state; + private static HostGamepadState _state; private static bool _started; private static int _slot = -1; // connected XInput user index, -1 when none private static byte _motorLeft; @@ -45,6 +45,8 @@ internal static class XInputReader /// Starts the background reader once; safe to call repeatedly. internal static void EnsureStarted() { + // The GUI source-links this reader and calls it directly, without the + // host-platform resolution that otherwise guarantees Windows. if (!OperatingSystem.IsWindows()) { return; @@ -67,7 +69,7 @@ internal static class XInputReader } } - internal static bool TryGetState(out PadState state) + internal static bool TryGetState(out HostGamepadState state) { lock (Gate) { @@ -77,7 +79,7 @@ internal static class XInputReader return state.Connected; } - private static void SetState(in PadState state) + private static void SetState(in HostGamepadState state) { lock (Gate) { @@ -204,40 +206,40 @@ internal static class XInputReader return -1; } - private static PadState Translate(in XInputGamepad pad) + private static HostGamepadState Translate(in XInputGamepad pad) { - uint buttons = 0; - buttons |= (pad.Buttons & XinputDpadUp) != 0 ? OrbisPadButton.Up : 0; - buttons |= (pad.Buttons & XinputDpadDown) != 0 ? OrbisPadButton.Down : 0; - buttons |= (pad.Buttons & XinputDpadLeft) != 0 ? OrbisPadButton.Left : 0; - buttons |= (pad.Buttons & XinputDpadRight) != 0 ? OrbisPadButton.Right : 0; - buttons |= (pad.Buttons & XinputStart) != 0 ? OrbisPadButton.Options : 0; - buttons |= (pad.Buttons & XinputBack) != 0 ? OrbisPadButton.TouchPad : 0; - buttons |= (pad.Buttons & XinputLeftThumb) != 0 ? OrbisPadButton.L3 : 0; - buttons |= (pad.Buttons & XinputRightThumb) != 0 ? OrbisPadButton.R3 : 0; - buttons |= (pad.Buttons & XinputLeftShoulder) != 0 ? OrbisPadButton.L1 : 0; - buttons |= (pad.Buttons & XinputRightShoulder) != 0 ? OrbisPadButton.R1 : 0; - buttons |= (pad.Buttons & XinputA) != 0 ? OrbisPadButton.Cross : 0; - buttons |= (pad.Buttons & XinputB) != 0 ? OrbisPadButton.Circle : 0; - buttons |= (pad.Buttons & XinputX) != 0 ? OrbisPadButton.Square : 0; - buttons |= (pad.Buttons & XinputY) != 0 ? OrbisPadButton.Triangle : 0; - buttons |= pad.LeftTrigger > TriggerThreshold ? OrbisPadButton.L2 : 0; - buttons |= pad.RightTrigger > TriggerThreshold ? OrbisPadButton.R2 : 0; + var buttons = HostGamepadButtons.None; + buttons |= (pad.Buttons & XinputDpadUp) != 0 ? HostGamepadButtons.Up : 0; + buttons |= (pad.Buttons & XinputDpadDown) != 0 ? HostGamepadButtons.Down : 0; + buttons |= (pad.Buttons & XinputDpadLeft) != 0 ? HostGamepadButtons.Left : 0; + buttons |= (pad.Buttons & XinputDpadRight) != 0 ? HostGamepadButtons.Right : 0; + buttons |= (pad.Buttons & XinputStart) != 0 ? HostGamepadButtons.Options : 0; + buttons |= (pad.Buttons & XinputBack) != 0 ? HostGamepadButtons.TouchPad : 0; + buttons |= (pad.Buttons & XinputLeftThumb) != 0 ? HostGamepadButtons.L3 : 0; + buttons |= (pad.Buttons & XinputRightThumb) != 0 ? HostGamepadButtons.R3 : 0; + buttons |= (pad.Buttons & XinputLeftShoulder) != 0 ? HostGamepadButtons.L1 : 0; + buttons |= (pad.Buttons & XinputRightShoulder) != 0 ? HostGamepadButtons.R1 : 0; + buttons |= (pad.Buttons & XinputA) != 0 ? HostGamepadButtons.Cross : 0; + buttons |= (pad.Buttons & XinputB) != 0 ? HostGamepadButtons.Circle : 0; + buttons |= (pad.Buttons & XinputX) != 0 ? HostGamepadButtons.Square : 0; + buttons |= (pad.Buttons & XinputY) != 0 ? HostGamepadButtons.Triangle : 0; + buttons |= pad.LeftTrigger > TriggerThreshold ? HostGamepadButtons.L2 : 0; + buttons |= pad.RightTrigger > TriggerThreshold ? HostGamepadButtons.R2 : 0; - return new PadState( + return new HostGamepadState( Connected: true, Buttons: buttons, LeftX: AxisToByte(pad.ThumbLX), LeftY: AxisToByteInverted(pad.ThumbLY), RightX: AxisToByte(pad.ThumbRX), RightY: AxisToByteInverted(pad.ThumbRY), - L2: pad.LeftTrigger, - R2: pad.RightTrigger); + LeftTrigger: pad.LeftTrigger, + RightTrigger: pad.RightTrigger); } private static byte AxisToByte(short value) => (byte)((value + 32768) >> 8); - // XInput Y grows upward, ORBIS pads report Y growing downward. + // XInput Y grows upward, host pad conventions report Y growing downward. private static byte AxisToByteInverted(short value) => (byte)(255 - ((value + 32768) >> 8)); [StructLayout(LayoutKind.Sequential)] @@ -267,9 +269,9 @@ internal static class XInputReader } // xinput1_4.dll ships with Windows 8 and later. - [DllImport("xinput1_4.dll")] - private static extern uint XInputGetState(uint userIndex, out XInputState state); + [LibraryImport("xinput1_4.dll")] + private static partial uint XInputGetState(uint userIndex, out XInputState state); - [DllImport("xinput1_4.dll")] - private static extern uint XInputSetState(uint userIndex, ref XInputVibration vibration); + [LibraryImport("xinput1_4.dll")] + private static partial uint XInputSetState(uint userIndex, ref XInputVibration vibration); } diff --git a/src/SharpEmu.Libs/Audio/AudioOutExports.cs b/src/SharpEmu.Libs/Audio/AudioOutExports.cs index b2b3f307..a044e584 100644 --- a/src/SharpEmu.Libs/Audio/AudioOutExports.cs +++ b/src/SharpEmu.Libs/Audio/AudioOutExports.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.HLE; +using SharpEmu.HLE.Host; using System.Buffers; using System.Collections.Concurrent; using System.Diagnostics; @@ -27,7 +28,7 @@ public static class AudioOutExports int channels, int bytesPerSample, bool isFloat, - WinMmAudioPort? backend) + IHostAudioStream? backend) { UserId = userId; Type = type; @@ -48,7 +49,7 @@ public static class AudioOutExports public int Channels { get; } public int BytesPerSample { get; } public bool IsFloat { get; } - public WinMmAudioPort? Backend { get; } + public IHostAudioStream? Backend { get; } public int BufferByteLength => checked((int)BufferLength * Channels * BytesPerSample); @@ -103,12 +104,13 @@ public static class AudioOutExports return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); } - WinMmAudioPort? backend = null; + IHostAudioStream? backend = null; string backendName; try { - backend = new WinMmAudioPort(frequency); - backendName = "winmm"; + var audio = HostPlatform.Current.Audio; + backend = audio.OpenStereoPcm16Stream(frequency); + backendName = audio.BackendName; } catch (Exception exception) { @@ -180,15 +182,31 @@ public static class AudioOutExports return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } - if (port.Backend is null || - !port.Backend.Submit( - source, - port.BufferLength, - port.Channels, - port.BytesPerSample, - port.IsFloat)) + if (port.Backend is null) { port.PaceSilence(); + return ctx.SetReturn(0); + } + + var outputLength = checked((int)port.BufferLength * AudioPcmConversion.OutputFrameSize); + var output = ArrayPool.Shared.Rent(outputLength); + try + { + AudioPcmConversion.ConvertToStereoPcm16( + source, + output.AsSpan(0, outputLength), + checked((int)port.BufferLength), + port.Channels, + port.BytesPerSample, + port.IsFloat); + if (!port.Backend.Submit(output.AsSpan(0, outputLength))) + { + port.PaceSilence(); + } + } + finally + { + ArrayPool.Shared.Return(output); } return ctx.SetReturn(0); diff --git a/src/SharpEmu.Libs/Audio/AudioPcmConversion.cs b/src/SharpEmu.Libs/Audio/AudioPcmConversion.cs new file mode 100644 index 00000000..a15ce19c --- /dev/null +++ b/src/SharpEmu.Libs/Audio/AudioPcmConversion.cs @@ -0,0 +1,55 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; + +namespace SharpEmu.Libs.Audio; + +/// +/// Converts guest AudioOut submissions (mono/stereo/7.1, s16 or float32) into the +/// interleaved stereo 16-bit PCM that host audio streams accept. Platform-neutral — +/// device specifics live behind IHostAudioStream. +/// +internal static class AudioPcmConversion +{ + /// Bytes per output frame: two 16-bit channels. + public const int OutputFrameSize = 4; + + public static void ConvertToStereoPcm16( + ReadOnlySpan source, + Span destination, + int frames, + int channels, + int bytesPerSample, + bool isFloat) + { + var sourceFrameSize = checked(channels * bytesPerSample); + for (var frame = 0; frame < frames; frame++) + { + var sourceFrame = source.Slice(frame * sourceFrameSize, sourceFrameSize); + var left = ReadSample(sourceFrame, 0, bytesPerSample, isFloat); + var right = channels == 1 + ? left + : ReadSample(sourceFrame, 1, bytesPerSample, isFloat); + BinaryPrimitives.WriteInt16LittleEndian(destination[(frame * OutputFrameSize)..], left); + BinaryPrimitives.WriteInt16LittleEndian(destination[((frame * OutputFrameSize) + 2)..], right); + } + } + + private static short ReadSample( + ReadOnlySpan frame, + int channel, + int bytesPerSample, + bool isFloat) + { + var sample = frame.Slice(channel * bytesPerSample, bytesPerSample); + if (!isFloat) + { + return BinaryPrimitives.ReadInt16LittleEndian(sample); + } + + var bits = BinaryPrimitives.ReadInt32LittleEndian(sample); + var value = Math.Clamp(BitConverter.Int32BitsToSingle(bits), -1.0f, 1.0f); + return checked((short)MathF.Round(value * short.MaxValue)); + } +} diff --git a/src/SharpEmu.Libs/Audio/WinMmAudioPort.cs b/src/SharpEmu.Libs/Audio/WinMmAudioPort.cs deleted file mode 100644 index 7322774a..00000000 --- a/src/SharpEmu.Libs/Audio/WinMmAudioPort.cs +++ /dev/null @@ -1,302 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using System.Buffers; -using System.Buffers.Binary; -using System.Runtime.InteropServices; - -namespace SharpEmu.Libs.Audio; - -internal sealed class WinMmAudioPort : IDisposable -{ - private const uint WaveMapper = uint.MaxValue; - private const uint CallbackEvent = 0x0005_0000; - private const ushort WaveFormatPcm = 1; - private const uint WaveHeaderDone = 0x0000_0001; - private const int MaximumQueuedPcmBytes = 32 * 1024; - - private readonly object _gate = new(); - private readonly AutoResetEvent _completion = new(false); - private readonly Queue _buffers = new(); - private IntPtr _device; - private int _queuedPcmBytes; - private bool _disposed; - - public WinMmAudioPort(uint sampleRate) - { - if (!OperatingSystem.IsWindows()) - { - throw new PlatformNotSupportedException("WinMM audio is only available on Windows."); - } - - var format = new WaveFormat - { - FormatTag = WaveFormatPcm, - Channels = 2, - SamplesPerSecond = sampleRate, - AverageBytesPerSecond = checked(sampleRate * 4), - BlockAlign = 4, - BitsPerSample = 16, - ExtraSize = 0, - }; - var result = WaveOutOpen( - out _device, - WaveMapper, - ref format, - _completion.SafeWaitHandle.DangerousGetHandle(), - IntPtr.Zero, - CallbackEvent); - if (result != 0) - { - throw new InvalidOperationException($"waveOutOpen failed with MMRESULT {result}."); - } - } - - public bool Submit( - ReadOnlySpan source, - uint frames, - int channels, - int bytesPerSample, - bool isFloat) - { - lock (_gate) - { - if (_disposed) - { - return false; - } - - var outputLength = checked((int)frames * 4); - ReapCompletedBuffers(); - while (_queuedPcmBytes != 0 && - _queuedPcmBytes + outputLength > MaximumQueuedPcmBytes) - { - if (!_completion.WaitOne(TimeSpan.FromSeconds(1))) - { - return false; - } - - ReapCompletedBuffers(); - } - - var output = ArrayPool.Shared.Rent(outputLength); - try - { - ConvertToStereoPcm16( - source, - output.AsSpan(0, outputLength), - checked((int)frames), - channels, - bytesPerSample, - isFloat); - return QueueBuffer(output.AsSpan(0, outputLength)); - } - finally - { - ArrayPool.Shared.Return(output); - } - } - } - - public void Dispose() - { - lock (_gate) - { - if (_disposed) - { - return; - } - - _disposed = true; - if (_device != IntPtr.Zero) - { - WaveOutReset(_device); - while (_buffers.TryDequeue(out var buffer)) - { - ReleaseBuffer(buffer); - } - - WaveOutClose(_device); - _device = IntPtr.Zero; - } - - _completion.Dispose(); - } - } - - private bool QueueBuffer(ReadOnlySpan data) - { - var dataAddress = Marshal.AllocHGlobal(data.Length); - var headerAddress = IntPtr.Zero; - try - { - unsafe - { - data.CopyTo(new Span((void*)dataAddress, data.Length)); - } - - var header = new WaveHeader - { - Data = dataAddress, - BufferLength = checked((uint)data.Length), - }; - headerAddress = Marshal.AllocHGlobal(Marshal.SizeOf()); - Marshal.StructureToPtr(header, headerAddress, false); - - var result = WaveOutPrepareHeader( - _device, - headerAddress, - checked((uint)Marshal.SizeOf())); - if (result != 0) - { - return false; - } - - result = WaveOutWrite( - _device, - headerAddress, - checked((uint)Marshal.SizeOf())); - if (result != 0) - { - WaveOutUnprepareHeader( - _device, - headerAddress, - checked((uint)Marshal.SizeOf())); - return false; - } - - _buffers.Enqueue(new NativeBuffer(dataAddress, headerAddress, data.Length)); - _queuedPcmBytes += data.Length; - dataAddress = IntPtr.Zero; - headerAddress = IntPtr.Zero; - return true; - } - finally - { - if (headerAddress != IntPtr.Zero) - { - Marshal.FreeHGlobal(headerAddress); - } - - if (dataAddress != IntPtr.Zero) - { - Marshal.FreeHGlobal(dataAddress); - } - } - } - - private void ReapCompletedBuffers() - { - while (_buffers.TryPeek(out var buffer)) - { - var header = Marshal.PtrToStructure(buffer.Header); - if ((header.Flags & WaveHeaderDone) == 0) - { - return; - } - - _buffers.Dequeue(); - ReleaseBuffer(buffer); - } - } - - private void ReleaseBuffer(NativeBuffer buffer) - { - WaveOutUnprepareHeader( - _device, - buffer.Header, - checked((uint)Marshal.SizeOf())); - _queuedPcmBytes -= buffer.Length; - Marshal.FreeHGlobal(buffer.Header); - Marshal.FreeHGlobal(buffer.Data); - } - - private static void ConvertToStereoPcm16( - ReadOnlySpan source, - Span destination, - int frames, - int channels, - int bytesPerSample, - bool isFloat) - { - var sourceFrameSize = checked(channels * bytesPerSample); - for (var frame = 0; frame < frames; frame++) - { - var sourceFrame = source.Slice(frame * sourceFrameSize, sourceFrameSize); - var left = ReadSample(sourceFrame, 0, bytesPerSample, isFloat); - var right = channels == 1 - ? left - : ReadSample(sourceFrame, 1, bytesPerSample, isFloat); - BinaryPrimitives.WriteInt16LittleEndian(destination[(frame * 4)..], left); - BinaryPrimitives.WriteInt16LittleEndian(destination[((frame * 4) + 2)..], right); - } - } - - private static short ReadSample( - ReadOnlySpan frame, - int channel, - int bytesPerSample, - bool isFloat) - { - var sample = frame.Slice(channel * bytesPerSample, bytesPerSample); - if (!isFloat) - { - return BinaryPrimitives.ReadInt16LittleEndian(sample); - } - - var bits = BinaryPrimitives.ReadInt32LittleEndian(sample); - var value = Math.Clamp(BitConverter.Int32BitsToSingle(bits), -1.0f, 1.0f); - return checked((short)MathF.Round(value * short.MaxValue)); - } - - private readonly record struct NativeBuffer(IntPtr Data, IntPtr Header, int Length); - - [StructLayout(LayoutKind.Sequential, Pack = 2)] - private struct WaveFormat - { - public ushort FormatTag; - public ushort Channels; - public uint SamplesPerSecond; - public uint AverageBytesPerSecond; - public ushort BlockAlign; - public ushort BitsPerSample; - public ushort ExtraSize; - } - - [StructLayout(LayoutKind.Sequential)] - private struct WaveHeader - { - public IntPtr Data; - public uint BufferLength; - public uint BytesRecorded; - public nuint User; - public uint Flags; - public uint Loops; - public IntPtr Next; - public nuint Reserved; - } - - [DllImport("winmm.dll", EntryPoint = "waveOutOpen")] - private static extern uint WaveOutOpen( - out IntPtr device, - uint deviceId, - ref WaveFormat format, - IntPtr callback, - IntPtr instance, - uint flags); - - [DllImport("winmm.dll", EntryPoint = "waveOutPrepareHeader")] - private static extern uint WaveOutPrepareHeader(IntPtr device, IntPtr header, uint headerSize); - - [DllImport("winmm.dll", EntryPoint = "waveOutWrite")] - private static extern uint WaveOutWrite(IntPtr device, IntPtr header, uint headerSize); - - [DllImport("winmm.dll", EntryPoint = "waveOutUnprepareHeader")] - private static extern uint WaveOutUnprepareHeader(IntPtr device, IntPtr header, uint headerSize); - - [DllImport("winmm.dll", EntryPoint = "waveOutReset")] - private static extern uint WaveOutReset(IntPtr device); - - [DllImport("winmm.dll", EntryPoint = "waveOutClose")] - private static extern uint WaveOutClose(IntPtr device); -} diff --git a/src/SharpEmu.Libs/HostTimerResolution.cs b/src/SharpEmu.Libs/HostTimerResolution.cs deleted file mode 100644 index cb2651a5..00000000 --- a/src/SharpEmu.Libs/HostTimerResolution.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using System.Runtime.InteropServices; -using System.Runtime.Versioning; - -namespace SharpEmu.Libs; - -public static class HostTimerResolution -{ - private const uint TargetPeriodMilliseconds = 1; - - private static int _requested; - - public static void Request() - { - if (Interlocked.Exchange(ref _requested, 1) != 0) - { - return; - } - - if (!OperatingSystem.IsWindows()) - { - return; - } - - try - { - if (TimeBeginPeriod(TargetPeriodMilliseconds) != 0) - { - Console.Error.WriteLine( - "[LOADER][WARN] Host timer resolution request rejected; " + - "timed waits keep the default ~15.6 ms granularity."); - } - } - catch (DllNotFoundException exception) - { - Console.Error.WriteLine( - $"[LOADER][WARN] Host timer resolution unavailable: {exception.Message}"); - } - } - - [SupportedOSPlatform("windows")] - [DllImport("winmm.dll", EntryPoint = "timeBeginPeriod", ExactSpelling = true)] - private static extern uint TimeBeginPeriod(uint uPeriod); -} diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs index 83a9a140..72205c5f 100644 --- a/src/SharpEmu.Libs/Pad/PadExports.cs +++ b/src/SharpEmu.Libs/Pad/PadExports.cs @@ -2,9 +2,9 @@ // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.HLE; +using SharpEmu.HLE.Host; using System.Buffers.Binary; using System.Diagnostics; -using System.Runtime.InteropServices; namespace SharpEmu.Libs.Pad; @@ -38,8 +38,7 @@ public static class PadExports public static int PadInit(CpuContext ctx) { _initialized = true; - DualSenseReader.EnsureStarted(); - XInputReader.EnsureStarted(); + HostPlatform.Current.Input.EnsureStarted(); return ctx.SetReturn(0); } @@ -81,15 +80,13 @@ public static class PadExports return ctx.SetReturn(OrbisPadErrorDeviceNotConnected); } - DualSenseReader.EnsureStarted(); - XInputReader.EnsureStarted(); + var input = HostPlatform.Current.Input; + input.EnsureStarted(); if (Interlocked.Exchange(ref _controlsAnnouncementLogged, 1) == 0) { - Console.Error.WriteLine(DualSenseReader.TryGetState(out _) - ? "[LOADER][INFO] Controls: DualSense connected (keyboard fallback also active)." - : XInputReader.TryGetState(out _) - ? "[LOADER][INFO] Controls: Xbox controller connected (keyboard fallback also active)." - : "[LOADER][INFO] Keyboard controls: Arrow keys = D-pad, WASD = left stick, IJKL = right stick, Z/Enter = Cross, X/Esc = Circle, C = Square, V = Triangle, Q = L1, E = R1, R = L2, F = R2, Tab/Backspace = Options. A DualSense or Xbox controller will be used automatically when plugged in."); + Console.Error.WriteLine(input.DescribeConnectedGamepad() is { } gamepadName + ? $"[LOADER][INFO] Controls: {gamepadName} connected (keyboard fallback also active)." + : "[LOADER][INFO] Keyboard controls: Arrow keys = D-pad, WASD = left stick, IJKL = right stick, Z/Enter = Cross, X/Esc = Circle, C = Square, V = Triangle, Q = L1, E = R1, R = L2, F = R2, Tab/Backspace = Options. A DualSense or Xbox controller will be used automatically when plugged in."); } return ctx.SetReturn(PrimaryPadHandle); @@ -282,7 +279,7 @@ public static class PadExports } var triggerMask = parameter[0]; - XInputReader.SetTriggerRumble( + HostPlatform.Current.Input.SetTriggerRumble( (triggerMask & 0x01) != 0 ? DecodeTriggerVibration(parameter[8..64]) : null, (triggerMask & 0x02) != 0 ? DecodeTriggerVibration(parameter[64..120]) : null); return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK); @@ -326,8 +323,7 @@ public static class PadExports return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } - DualSenseReader.SetRumble(parameter[0], parameter[1]); - XInputReader.SetRumble(parameter[0], parameter[1]); + HostPlatform.Current.Input.SetRumble(parameter[0], parameter[1]); return ctx.SetReturn(0); } @@ -357,7 +353,7 @@ public static class PadExports return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } - DualSenseReader.SetLightbar(color[0], color[1], color[2]); + HostPlatform.Current.Input.SetLightbar(color[0], color[1], color[2]); return ctx.SetReturn(0); } @@ -374,7 +370,7 @@ public static class PadExports return ctx.SetReturn(OrbisPadErrorInvalidHandle); } - DualSenseReader.ResetLightbar(); + HostPlatform.Current.Input.ResetLightbar(); return ctx.SetReturn(0); } @@ -420,37 +416,30 @@ public static class PadExports return _cachedInputState; } - var acceptsKeyboardInput = IsEmulatorWindowFocused(); - var buttons = acceptsKeyboardInput ? ReadKeyboardButtons() : 0; - var leftX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x41), IsKeyDown(0x44)) : (byte)128; - var leftY = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x57), IsKeyDown(0x53)) : (byte)128; - var rightX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x4A), IsKeyDown(0x4C)) : (byte)128; - var rightY = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x49), IsKeyDown(0x4B)) : (byte)128; - var l2 = acceptsKeyboardInput && IsKeyDown(0x52) ? (byte)255 : (byte)0; - var r2 = acceptsKeyboardInput && IsKeyDown(0x46) ? (byte)255 : (byte)0; + var input = HostPlatform.Current.Input; + var acceptsKeyboardInput = input.IsHostWindowFocused(); + var buttons = acceptsKeyboardInput ? ReadKeyboardButtons(input) : 0; + var leftX = acceptsKeyboardInput ? ReadAnalogStick(input.IsKeyDown(0x41), input.IsKeyDown(0x44)) : (byte)128; + var leftY = acceptsKeyboardInput ? ReadAnalogStick(input.IsKeyDown(0x57), input.IsKeyDown(0x53)) : (byte)128; + var rightX = acceptsKeyboardInput ? ReadAnalogStick(input.IsKeyDown(0x4A), input.IsKeyDown(0x4C)) : (byte)128; + var rightY = acceptsKeyboardInput ? ReadAnalogStick(input.IsKeyDown(0x49), input.IsKeyDown(0x4B)) : (byte)128; + var l2 = acceptsKeyboardInput && input.IsKeyDown(0x52) ? (byte)255 : (byte)0; + var r2 = acceptsKeyboardInput && input.IsKeyDown(0x46) ? (byte)255 : (byte)0; - if (DualSenseReader.TryGetState(out var pad)) + Span gamepads = stackalloc HostGamepadState[2]; + var gamepadCount = input.GetGamepadStates(gamepads); + for (var index = 0; index < gamepadCount; index++) { - buttons |= pad.Buttons; + var pad = gamepads[index]; + buttons |= ToOrbisButtons(pad.Buttons); // The controller stick wins whenever it is deflected past a // small deadzone; otherwise any keyboard value stays. leftX = MergeAxis(pad.LeftX, leftX); leftY = MergeAxis(pad.LeftY, leftY); rightX = MergeAxis(pad.RightX, rightX); rightY = MergeAxis(pad.RightY, rightY); - l2 = Math.Max(l2, pad.L2); - r2 = Math.Max(r2, pad.R2); - } - - if (XInputReader.TryGetState(out var xpad)) - { - buttons |= xpad.Buttons; - leftX = MergeAxis(xpad.LeftX, leftX); - leftY = MergeAxis(xpad.LeftY, leftY); - rightX = MergeAxis(xpad.RightX, rightX); - rightY = MergeAxis(xpad.RightY, rightY); - l2 = Math.Max(l2, xpad.L2); - r2 = Math.Max(r2, xpad.R2); + l2 = Math.Max(l2, pad.LeftTrigger); + r2 = Math.Max(r2, pad.RightTrigger); } _cachedInputState = new PadState( @@ -466,50 +455,49 @@ public static class PadExports return _cachedInputState; } - [DllImport("user32.dll")] - private static extern short GetAsyncKeyState(int vKey); - - [DllImport("user32.dll")] - private static extern nint GetForegroundWindow(); - - [DllImport("user32.dll")] - private static extern uint GetWindowThreadProcessId(nint hWnd, out uint processId); - - private static bool IsKeyDown(int vk) => - (GetAsyncKeyState(vk) & 0x8000) != 0; - - private static bool IsEmulatorWindowFocused() + /// Maps the host seam's neutral button flags onto SCE_PAD_BUTTON bits. + private static uint ToOrbisButtons(HostGamepadButtons buttons) { - var foregroundWindow = GetForegroundWindow(); - if (foregroundWindow == 0) - { - return false; - } - - GetWindowThreadProcessId(foregroundWindow, out var processId); - return processId == (uint)Environment.ProcessId; + uint result = 0; + if ((buttons & HostGamepadButtons.Up) != 0) result |= OrbisPadButton.Up; + if ((buttons & HostGamepadButtons.Down) != 0) result |= OrbisPadButton.Down; + if ((buttons & HostGamepadButtons.Left) != 0) result |= OrbisPadButton.Left; + if ((buttons & HostGamepadButtons.Right) != 0) result |= OrbisPadButton.Right; + if ((buttons & HostGamepadButtons.Cross) != 0) result |= OrbisPadButton.Cross; + if ((buttons & HostGamepadButtons.Circle) != 0) result |= OrbisPadButton.Circle; + if ((buttons & HostGamepadButtons.Square) != 0) result |= OrbisPadButton.Square; + if ((buttons & HostGamepadButtons.Triangle) != 0) result |= OrbisPadButton.Triangle; + if ((buttons & HostGamepadButtons.L1) != 0) result |= OrbisPadButton.L1; + if ((buttons & HostGamepadButtons.R1) != 0) result |= OrbisPadButton.R1; + if ((buttons & HostGamepadButtons.L2) != 0) result |= OrbisPadButton.L2; + if ((buttons & HostGamepadButtons.R2) != 0) result |= OrbisPadButton.R2; + if ((buttons & HostGamepadButtons.L3) != 0) result |= OrbisPadButton.L3; + if ((buttons & HostGamepadButtons.R3) != 0) result |= OrbisPadButton.R3; + if ((buttons & HostGamepadButtons.Options) != 0) result |= OrbisPadButton.Options; + if ((buttons & HostGamepadButtons.TouchPad) != 0) result |= OrbisPadButton.TouchPad; + return result; } - private static uint ReadKeyboardButtons() + private static uint ReadKeyboardButtons(IHostInput input) { uint buttons = 0; // D-pad - if (IsKeyDown(0x25)) buttons |= 0x0080; // Left - if (IsKeyDown(0x27)) buttons |= 0x0020; // Right - if (IsKeyDown(0x26)) buttons |= 0x0010; // Up - if (IsKeyDown(0x28)) buttons |= 0x0040; // Down + if (input.IsKeyDown(0x25)) buttons |= OrbisPadButton.Left; + if (input.IsKeyDown(0x27)) buttons |= OrbisPadButton.Right; + if (input.IsKeyDown(0x26)) buttons |= OrbisPadButton.Up; + if (input.IsKeyDown(0x28)) buttons |= OrbisPadButton.Down; // Face buttons - if (IsKeyDown(0x5A) || IsKeyDown(0x0D)) buttons |= 0x4000; // Z / Enter = Cross - if (IsKeyDown(0x58) || IsKeyDown(0x1B)) buttons |= 0x2000; // X / Escape = Circle - if (IsKeyDown(0x43)) buttons |= 0x8000; // C = Square - if (IsKeyDown(0x56)) buttons |= 0x1000; // V = Triangle + if (input.IsKeyDown(0x5A) || input.IsKeyDown(0x0D)) buttons |= OrbisPadButton.Cross; // Z / Enter + if (input.IsKeyDown(0x58) || input.IsKeyDown(0x1B)) buttons |= OrbisPadButton.Circle; // X / Escape + if (input.IsKeyDown(0x43)) buttons |= OrbisPadButton.Square; // C + if (input.IsKeyDown(0x56)) buttons |= OrbisPadButton.Triangle; // V // Shoulder buttons - if (IsKeyDown(0x51)) buttons |= 0x0400; // Q = L1 - if (IsKeyDown(0x45)) buttons |= 0x0800; // E = R1 - if (IsKeyDown(0x52)) buttons |= 0x0100; // R = L2 (digital) - if (IsKeyDown(0x46)) buttons |= 0x0200; // F = R2 (digital) + if (input.IsKeyDown(0x51)) buttons |= OrbisPadButton.L1; // Q + if (input.IsKeyDown(0x45)) buttons |= OrbisPadButton.R1; // E + if (input.IsKeyDown(0x52)) buttons |= OrbisPadButton.L2; // R (digital) + if (input.IsKeyDown(0x46)) buttons |= OrbisPadButton.R2; // F (digital) // Options (Start) - if (IsKeyDown(0x09) || IsKeyDown(0x08)) buttons |= 0x0008; // Tab / Backspace = Options + if (input.IsKeyDown(0x09) || input.IsKeyDown(0x08)) buttons |= OrbisPadButton.Options; // Tab / Backspace return buttons; } diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs index 91f7e150..dca2dd88 100644 --- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs +++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.HLE; +using SharpEmu.HLE.Host; using SharpEmu.Libs.Audio; using SharpEmu.Libs.Kernel; using SharpEmu.Logging; @@ -68,7 +69,7 @@ public static class VideoOutExports return; } - HostTimerResolution.Request(); + HostPlatform.Current.Threading.RequestTimerResolution(); _vblankPumpThread = new Thread(VblankPumpLoop) {