Sdl backend (#670)

* [audio] added sdl audio backend and in-tree atrac9 decoder

* [input] replaced per-platform pad readers with sdl gamepad input

* [video] added sdl window and host display plumbing

* [gui] added host display options and per-game render settings

* [bink] synced host movie playback to the guest audio clock

* [cpu] hooked windows write faults into guest image tracking

* [perf] added guest and render profiling, reserved host cpu lanes

* [kernel] fixed stale pthread mutex handle alias

* [host] wired the sdl session, save-data paths and project references

* [audio] hoisted ajm trace stackalloc out of its loop

* [video] Add guest image sync setting

* [build] Strip native symbols

* reuse
This commit is contained in:
Berk
2026-07-28 03:33:26 +03:00
committed by GitHub
parent b4cc5f88ca
commit 2b6bd5a532
111 changed files with 9846 additions and 4479 deletions
+9
View File
@@ -20,6 +20,15 @@ public sealed class CpuContext(ICpuMemory memory, Generation generation)
public ulong Rip { get; set; }
/// <summary>
/// Index of the import this context is currently executing, or -1 when it is
/// running guest code. Only maintained while guest profiling is enabled;
/// <see cref="Rip"/> alone cannot answer "what is this thread inside right
/// now" because it keeps pointing at the last import stub after the call
/// returns.
/// </summary>
public int ActiveImportIndex { get; set; } = -1;
public ulong Rflags { get; set; }
public ulong FsBase { get; set; }
+7 -10
View File
@@ -87,17 +87,14 @@ public static unsafe class GuestImageWriteTracker
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
// Windows defaults off: VirtualProtect fault sync still regresses titles
// like Dead Cells / Demon's Souls. Opt in with SHARPEMU_GUEST_IMAGE_CPU_SYNC=1
// when a title needs CPU-written guest planes (e.g. GTA intro). Linux/macOS
// keep the historical opt-out (=0 disables).
// CPU-written guest image synchronization is the compatible default. A few
// titles (currently GTA V) require the lower-overhead watch-only path and
// opt out explicitly with SHARPEMU_GUEST_IMAGE_CPU_SYNC=0.
private static readonly bool _enabled =
OperatingSystem.IsWindows()
? string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
"1",
StringComparison.Ordinal)
: Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
!string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
"0",
StringComparison.Ordinal);
private static readonly (bool Wildcard, ulong[] Addresses) _lifetimeTraceFilter =
ParseAddressList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
private static readonly (bool Wildcard, string[] Sources) _lifetimeSourceTraceFilter =
+69
View File
@@ -0,0 +1,69 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.HLE.Host;
/// <summary>
/// How much guest audio the host device has actually played, in seconds.
///
/// This is the only clock in the emulator that advances at the rate the player
/// hears. Wall clock runs ahead of it whenever the guest cannot feed the device
/// (the stream underruns and the missing time is never played), so anything
/// that has to stay in step with the guest's audio — host-decoded video being
/// the case that matters — has to follow this rather than <see cref="Stopwatch"/>.
///
/// Reported per stream and kept as the furthest-along value: the guest's ports
/// all carry one mix, and the leading port is the one whose position the
/// listener perceives.
/// </summary>
public static class GuestAudioClock
{
private static long _playedMicroseconds;
private static long _lastAdvanceTimestamp;
/// <summary>Seconds of guest audio the device has played. Monotonic.</summary>
public static double PlayedSeconds =>
Interlocked.Read(ref _playedMicroseconds) / 1_000_000.0;
/// <summary>
/// True while a stream has reported progress recently. False means no guest
/// audio is playing, and callers must fall back to wall clock rather than
/// stalling on a clock that will never advance.
/// </summary>
public static bool IsRunning
{
get
{
var last = Interlocked.Read(ref _lastAdvanceTimestamp);
return last != 0 &&
Stopwatch.GetElapsedTime(last) < TimeSpan.FromMilliseconds(250);
}
}
public static void Report(double playedSeconds)
{
if (double.IsNaN(playedSeconds) || playedSeconds < 0)
{
return;
}
var microseconds = (long)(playedSeconds * 1_000_000.0);
var current = Interlocked.Read(ref _playedMicroseconds);
while (microseconds > current)
{
var seen = Interlocked.CompareExchange(
ref _playedMicroseconds,
microseconds,
current);
if (seen == current)
{
Interlocked.Exchange(ref _lastAdvanceTimestamp, Stopwatch.GetTimestamp());
return;
}
current = seen;
}
}
}
+90 -1
View File
@@ -28,8 +28,44 @@ public enum HostGamepadButtons : uint
R3 = 1 << 13,
Options = 1 << 14,
TouchPad = 1 << 15,
Create = 1 << 16,
Ps = 1 << 17,
Mic = 1 << 18,
}
public enum HostGamepadType : byte
{
Generic,
DualShock4,
DualSense,
}
public enum HostGamepadConnection : byte
{
Unknown,
Wired,
Wireless,
}
public readonly record struct HostMotionState(
bool Available,
float AccelerationX,
float AccelerationY,
float AccelerationZ,
float AngularVelocityX,
float AngularVelocityY,
float AngularVelocityZ);
public readonly record struct HostTouchPoint(
bool Active,
byte Id,
float X,
float Y);
public readonly record struct HostTouchState(
HostTouchPoint First,
HostTouchPoint Second);
/// <summary>
/// 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
@@ -43,4 +79,57 @@ public readonly record struct HostGamepadState(
byte RightX,
byte RightY,
byte LeftTrigger,
byte RightTrigger);
byte RightTrigger,
HostGamepadType Type = HostGamepadType.Generic,
HostGamepadConnection Connection = HostGamepadConnection.Unknown,
HostMotionState Motion = default,
HostTouchState Touch = default,
byte BatteryPercent = 0);
/// <summary>A complete 11-byte DualSense adaptive-trigger command.</summary>
public readonly record struct HostAdaptiveTriggerEffect(
byte B0,
byte B1,
byte B2,
byte B3,
byte B4,
byte B5,
byte B6,
byte B7,
byte B8,
byte B9,
byte B10,
byte FallbackStrength = 0)
{
public static HostAdaptiveTriggerEffect FromBytes(ReadOnlySpan<byte> source, byte fallbackStrength = 0)
{
if (source.Length < 11)
{
throw new ArgumentException("Adaptive-trigger source is too small.", nameof(source));
}
return new HostAdaptiveTriggerEffect(
source[0], source[1], source[2], source[3], source[4], source[5],
source[6], source[7], source[8], source[9], source[10], fallbackStrength);
}
public void CopyTo(Span<byte> destination)
{
if (destination.Length < 11)
{
throw new ArgumentException("Adaptive-trigger destination is too small.", nameof(destination));
}
destination[0] = B0;
destination[1] = B1;
destination[2] = B2;
destination[3] = B3;
destination[4] = B4;
destination[5] = B5;
destination[6] = B6;
destination[7] = B7;
destination[8] = B8;
destination[9] = B9;
destination[10] = B10;
}
}
+13
View File
@@ -15,4 +15,17 @@ public interface IHostAudioStream : IDisposable
/// audio, in which case the caller paces the guest itself.
/// </summary>
bool Submit(ReadOnlySpan<byte> stereoPcm16);
/// <summary>
/// Audio already handed to the device and not yet played, in milliseconds —
/// the cushion protecting playback from a late submission. Zero means the
/// device has run dry and is emitting silence.
///
/// Callers that pace the guest against an emulated hardware queue need this:
/// pacing purely on wall clock releases exactly one buffer per buffer-period
/// and so keeps the cushion at zero, which turns any scheduling jitter into
/// an audible dropout. Returns -1 when the backend cannot report a depth, in
/// which case callers must fall back to their own pacing.
/// </summary>
int QueuedMilliseconds => -1;
}
+5
View File
@@ -32,6 +32,11 @@ public interface IHostInput
/// </summary>
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
/// <summary>Applies native DualSense trigger effects when supported.</summary>
void SetAdaptiveTriggerEffect(
HostAdaptiveTriggerEffect? leftTrigger,
HostAdaptiveTriggerEffect? rightTrigger);
void SetLightbar(byte red, byte green, byte blue);
void ResetLightbar();
@@ -0,0 +1,19 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host;
/// <summary>
/// Optional host-audio extension for backends that can accept the guest's
/// interleaved PCM layout directly and perform device conversion themselves.
/// </summary>
public interface IHostPcmAudioOutput : IHostAudioOutput
{
IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format);
}
public enum HostPcmFormat
{
Signed16,
Float32,
}
@@ -0,0 +1,42 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host;
/// <summary>Input snapshots produced by the active host window.</summary>
public interface IHostWindowInputSource
{
bool HasKeyboardFocus { get; }
bool IsKeyDown(int virtualKey);
int GetGamepadStates(Span<HostGamepadState> destination);
string? DescribeConnectedGamepad();
void SetRumble(byte largeMotor, byte smallMotor);
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
void SetAdaptiveTriggerEffect(
HostAdaptiveTriggerEffect? leftTrigger,
HostAdaptiveTriggerEffect? rightTrigger);
void SetLightbar(byte red, byte green, byte blue);
void ResetLightbar();
}
/// <summary>Process-wide bridge between the window layer and host input.</summary>
public static class HostWindowInputSource
{
private static IHostWindowInputSource? _current;
public static IHostWindowInputSource? Current => Volatile.Read(ref _current);
public static void Set(IHostWindowInputSource source) =>
Volatile.Write(ref _current, source);
public static void Clear(IHostWindowInputSource source) =>
Interlocked.CompareExchange(ref _current, null, source);
}
@@ -1,186 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host.Posix;
/// <summary>
/// Bridges a window-provided input source into the host input seam. POSIX
/// hosts have no user32/XInput/raw-HID readers; keyboard and gamepad state
/// come from the presenter's GLFW window instead, which registers itself via
/// <see cref="SetSource"/> once the window exists. Until then (and with no
/// window at all, e.g. headless runs) every query reports neutral input.
/// Rumble and lightbar are unsupported by the GLFW input layer and no-op.
/// </summary>
public interface IPosixWindowInputSource
{
/// <summary>True while the window's keyboard is delivering events.</summary>
bool HasKeyboardFocus { get; }
/// <summary>Windows virtual-key semantics; the source translates.</summary>
bool IsKeyDown(int virtualKey);
/// <summary>Same contract as <see cref="IHostInput.GetGamepadStates"/>.</summary>
int GetGamepadStates(Span<HostGamepadState> destination);
string? DescribeConnectedGamepad();
}
// Public so the presenter's window layer (SharpEmu.Libs) can register its
// input source; the platform still constructs the singleton itself.
public sealed class PosixHostInput : IHostInput
{
private static volatile IPosixWindowInputSource? _source;
/// <summary>Called by the presenter's window layer when input is ready.</summary>
public static void SetSource(IPosixWindowInputSource source)
{
_source = source;
}
public void EnsureStarted()
{
// Device readers are event-driven off the window thread; nothing to start.
}
public int GetGamepadStates(Span<HostGamepadState> destination)
{
return _source?.GetGamepadStates(destination) ?? 0;
}
public string? DescribeConnectedGamepad() => _source?.DescribeConnectedGamepad();
public void SetRumble(byte largeMotor, byte smallMotor)
{
}
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
{
}
public void SetLightbar(byte red, byte green, byte blue)
{
}
public void ResetLightbar()
{
}
public bool IsHostWindowFocused()
{
// GLFW only delivers key events to the focused window, so a
// delivering keyboard implies focus.
return _source?.HasKeyboardFocus ?? IsEmbeddedX11WindowFocused();
}
public bool IsKeyDown(int virtualKey)
{
var source = _source;
if (source is not null)
{
return source.IsKeyDown(virtualKey);
}
return IsEmbeddedX11WindowFocused() && IsEmbeddedX11KeyDown(virtualKey);
}
private static bool IsEmbeddedX11WindowFocused()
{
if (!OperatingSystem.IsLinux())
{
return false;
}
var display = HostSessionControl.EmbeddedHostDisplay;
var window = HostSessionControl.EmbeddedHostWindow;
if (display == 0 || window == 0 || XGetInputFocus(display, out var focusedWindow, out _) == 0 || focusedWindow == 0)
{
return false;
}
return GetTopLevelWindow(display, focusedWindow) == GetTopLevelWindow(display, window);
}
private static bool IsEmbeddedX11KeyDown(int virtualKey)
{
var display = HostSessionControl.EmbeddedHostDisplay;
var keysym = ToX11Keysym(virtualKey);
if (display == 0 || keysym == 0)
{
return false;
}
var keycode = XKeysymToKeycode(display, keysym);
if (keycode == 0)
{
return false;
}
var keymap = new byte[32];
XQueryKeymap(display, keymap);
return (keymap[keycode >> 3] & (1 << (keycode & 7))) != 0;
}
private static nint GetTopLevelWindow(nint display, nint window)
{
var current = window;
for (var depth = 0; depth < 16; depth++)
{
if (XQueryTree(display, current, out var root, out var parent, out var children, out _) == 0)
{
return 0;
}
if (children != 0)
{
XFree(children);
}
if (parent == 0 || parent == root)
{
return current;
}
current = parent;
}
return 0;
}
private static nuint ToX11Keysym(int virtualKey)
{
return virtualKey switch
{
0x08 => 0xFF08, // Backspace
0x09 => 0xFF09, // Tab
0x0D => 0xFF0D, // Return
0x1B => 0xFF1B, // Escape
0x25 => 0xFF51, // Left
0x26 => 0xFF52, // Up
0x27 => 0xFF53, // Right
0x28 => 0xFF54, // Down
>= 0x41 and <= 0x5A => (nuint)virtualKey,
_ => 0,
};
}
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XGetInputFocus(nint display, out nint focus, out int revertTo);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XQueryKeymap(nint display, [System.Runtime.InteropServices.Out] byte[] keysReturn);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern byte XKeysymToKeycode(nint display, nuint keysym);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XQueryTree(
nint display,
nint window,
out nint root,
out nint parent,
out nint children,
out uint childCount);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XFree(nint data);
}
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host.Sdl;
namespace SharpEmu.HLE.Host.Posix;
internal sealed class PosixHostPlatform : IHostPlatform
@@ -11,7 +13,7 @@ internal sealed class PosixHostPlatform : IHostPlatform
public IHostSymbolResolver Symbols { get; } = new PosixHostSymbolResolver();
public IHostAudioOutput Audio { get; } = new PosixHostAudio();
public IHostAudioOutput Audio { get; } = new SdlHostAudio();
public IHostInput Input { get; } = new PosixHostInput();
public IHostInput Input { get; } = new WindowHostInput();
}
+325
View File
@@ -0,0 +1,325 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using System.Runtime.InteropServices;
using SDL;
using static SDL.SDL3;
namespace SharpEmu.HLE.Host.Sdl;
internal sealed unsafe class SdlHostAudio : IHostPcmAudioOutput
{
/// <summary>
/// Cap for streams this class paces itself (AudioOut). Blocking the guest
/// here is that path's only pacing, so the device settles at this depth —
/// it is the playback latency, and the floor under it is how much jitter the
/// stream can absorb before it runs dry.
/// </summary>
private static readonly int TargetQueuedMilliseconds =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_AUDIO_LATENCY_MS"),
out var latencyMs) && latencyMs > 0
? latencyMs
: 60;
private const int MaximumWaitMilliseconds = 250;
private static readonly object InitGate = new();
private static bool _initialized;
public string BackendName => "sdl3";
/// <summary>
/// Stereo PCM16 stream with a caller-chosen backpressure cap. Callers that
/// pace the guest themselves pass a deeper cap so this class's backpressure
/// does not fight their pacing.
/// </summary>
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
=> OpenStream(
sampleRate,
channels: 2,
HostPcmFormat.Signed16,
maxQueuedPcmBytes > 0 ? maxQueuedPcmBytes : 32 * 1024);
/// <summary>
/// Guest-format stream for AudioOut, which has no queue model of its own:
/// blocking here is that path's only pacing, so the device settles at
/// TargetQueuedMilliseconds and that depth is the playback latency.
/// </summary>
public IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format)
{
var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
var cap = checked((int)((long)sampleRate * channels * bytesPerSample *
TargetQueuedMilliseconds / 1_000));
return OpenStream(sampleRate, channels, format, cap);
}
private static IHostAudioStream OpenStream(
uint sampleRate,
int channels,
HostPcmFormat format,
int maximumQueuedBytes)
{
if (sampleRate is < 8_000 or > 384_000 || channels is < 1 or > 8)
{
throw new ArgumentOutOfRangeException(
sampleRate is < 8_000 or > 384_000 ? nameof(sampleRate) : nameof(channels));
}
EnsureInitialized();
return new AudioStream(sampleRate, channels, format, maximumQueuedBytes);
}
private static void EnsureInitialized()
{
lock (InitGate)
{
if (_initialized)
{
return;
}
if ((SDL_WasInit(SDL_InitFlags.SDL_INIT_AUDIO) & SDL_InitFlags.SDL_INIT_AUDIO) == 0 &&
!SDL_InitSubSystem(SDL_InitFlags.SDL_INIT_AUDIO))
{
throw new InvalidOperationException($"SDL audio initialization failed: {GetError()}");
}
_initialized = true;
}
}
private static string GetError()
{
var error = Unsafe_SDL_GetError();
return error is null ? "unknown SDL error" : Marshal.PtrToStringUTF8((nint)error) ?? "unknown SDL error";
}
private static readonly bool _traceQueue = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_QUEUE"),
"1",
StringComparison.Ordinal);
private static int _nextStreamId;
private sealed class AudioStream : IHostAudioStream
{
private readonly object _gate = new();
private readonly int _maximumQueuedBytes;
private readonly int _bytesPerFrame;
private readonly uint _sampleRate;
private readonly int _streamId = Interlocked.Increment(ref _nextStreamId);
private SDL_AudioStream* _stream;
private bool _disposed;
private long _totalSubmittedBytes;
// Queue diagnostics for the current report window.
private long _windowStart = Stopwatch.GetTimestamp();
private long _submissions;
private long _submittedBytes;
private long _blockedTicks;
private long _drops;
private long _emptyObservations;
private int _minQueuedBytes = int.MaxValue;
private int _maxQueuedBytes;
private long _queuedByteSum;
public AudioStream(
uint sampleRate,
int channels,
HostPcmFormat format,
int maximumQueuedBytes)
{
var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
_bytesPerFrame = channels * bytesPerSample;
_sampleRate = sampleRate;
var spec = new SDL_AudioSpec
{
format = format == HostPcmFormat.Float32
? SDL_AudioFormat.SDL_AUDIO_F32LE
: SDL_AudioFormat.SDL_AUDIO_S16LE,
channels = checked((byte)channels),
freq = checked((int)sampleRate),
};
_stream = SDL_OpenAudioDeviceStream(
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
&spec,
null,
IntPtr.Zero);
if (_stream is null)
{
throw new InvalidOperationException($"SDL audio stream creation failed: {GetError()}");
}
if (!SDL_ResumeAudioStreamDevice(_stream))
{
SDL_DestroyAudioStream(_stream);
_stream = null;
throw new InvalidOperationException($"SDL audio stream start failed: {GetError()}");
}
_maximumQueuedBytes = maximumQueuedBytes;
}
public int QueuedMilliseconds
{
get
{
lock (_gate)
{
if (_disposed || _stream is null)
{
return -1;
}
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
return bytesPerSecond <= 0
? -1
: (int)(SDL_GetAudioStreamQueued(_stream) / bytesPerSecond * 1000.0);
}
}
}
public bool Submit(ReadOnlySpan<byte> pcm)
{
if (pcm.IsEmpty)
{
return true;
}
lock (_gate)
{
if (_disposed || _stream is null)
{
return false;
}
var blockStart = Stopwatch.GetTimestamp();
var deadline = blockStart +
(Stopwatch.Frequency * MaximumWaitMilliseconds / 1_000);
int queued;
var overrun = false;
while ((queued = SDL_GetAudioStreamQueued(_stream)) > _maximumQueuedBytes)
{
if (Stopwatch.GetTimestamp() >= deadline)
{
// Enqueue anyway rather than discarding the buffer. A gap in
// the stream is an audible click; the extra latency of one
// over-deep submission is not, and the queue recovers as soon
// as the device drains back under the cap.
overrun = true;
break;
}
Thread.Sleep(1);
}
RecordSubmission(queued, blockStart, dropped: overrun, bytes: pcm.Length);
bool submitted;
fixed (byte* data = pcm)
{
submitted = SDL_PutAudioStreamData(_stream, (nint)data, pcm.Length);
}
if (submitted)
{
// Everything handed over minus what the device still holds is
// what the player has actually heard.
_totalSubmittedBytes += pcm.Length;
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
if (bytesPerSecond > 0)
{
GuestAudioClock.Report(
Math.Max(0, _totalSubmittedBytes - queued - pcm.Length) / bytesPerSecond);
}
}
return submitted;
}
}
/// <summary>
/// Samples the queue depth at the moment the guest was allowed to write.
/// That depth is the playback latency the guest's audio is subject to, so
/// it is the number to look at when the sound is late; an observed depth
/// of zero is a genuine underrun, which is what a crackle sounds like.
/// Caller holds <see cref="_gate"/>.
/// </summary>
private void RecordSubmission(int queuedBytes, long blockStart, bool dropped, int bytes)
{
if (!_traceQueue)
{
return;
}
var now = Stopwatch.GetTimestamp();
_submissions++;
_submittedBytes += bytes;
_blockedTicks += now - blockStart;
_queuedByteSum += queuedBytes;
_minQueuedBytes = Math.Min(_minQueuedBytes, queuedBytes);
_maxQueuedBytes = Math.Max(_maxQueuedBytes, queuedBytes);
if (dropped)
{
_drops++;
}
if (queuedBytes == 0)
{
_emptyObservations++;
}
var elapsedTicks = now - _windowStart;
if (elapsedTicks < Stopwatch.Frequency)
{
return;
}
_windowStart = now;
var seconds = elapsedTicks / (double)Stopwatch.Frequency;
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
Console.Error.WriteLine(
$"[PERF][AUDIO] stream#{_streamId} {seconds:F1}s " +
$"queued_ms min={ToMilliseconds(_minQueuedBytes, bytesPerSecond):F0} " +
$"avg={ToMilliseconds((int)(_queuedByteSum / Math.Max(1, _submissions)), bytesPerSecond):F0} " +
$"max={ToMilliseconds(_maxQueuedBytes, bytesPerSecond):F0} " +
$"cap={ToMilliseconds(_maximumQueuedBytes, bytesPerSecond):F0} " +
$"submits/s={_submissions / seconds:F0} " +
$"fill={_submittedBytes / seconds / bytesPerSecond * 100.0:F0}% " +
$"blocked={_blockedTicks * 100.0 / elapsedTicks:F0}% " +
$"empty={_emptyObservations} drops={_drops}");
_submissions = 0;
_submittedBytes = 0;
_blockedTicks = 0;
_drops = 0;
_emptyObservations = 0;
_minQueuedBytes = int.MaxValue;
_maxQueuedBytes = 0;
_queuedByteSum = 0;
}
private static double ToMilliseconds(int bytes, double bytesPerSecond) =>
bytesPerSecond <= 0 ? 0 : bytes / bytesPerSecond * 1000.0;
public void Dispose()
{
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
if (_stream is not null)
{
SDL_ClearAudioStream(_stream);
SDL_DestroyAudioStream(_stream);
_stream = null;
}
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host;
/// <summary>
/// Routes emulated input through the active cross-platform host window.
/// </summary>
internal sealed class WindowHostInput : IHostInput
{
public void EnsureStarted()
{
// SDL owns device discovery and pumps it on the window thread.
}
public int GetGamepadStates(Span<HostGamepadState> destination) =>
HostWindowInputSource.Current?.GetGamepadStates(destination) ?? 0;
public string? DescribeConnectedGamepad() =>
HostWindowInputSource.Current?.DescribeConnectedGamepad();
public void SetRumble(byte largeMotor, byte smallMotor) =>
HostWindowInputSource.Current?.SetRumble(largeMotor, smallMotor);
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) =>
HostWindowInputSource.Current?.SetTriggerRumble(leftTrigger, rightTrigger);
public void SetAdaptiveTriggerEffect(
HostAdaptiveTriggerEffect? leftTrigger,
HostAdaptiveTriggerEffect? rightTrigger) =>
HostWindowInputSource.Current?.SetAdaptiveTriggerEffect(leftTrigger, rightTrigger);
public void SetLightbar(byte red, byte green, byte blue) =>
HostWindowInputSource.Current?.SetLightbar(red, green, blue);
public void ResetLightbar() => HostWindowInputSource.Current?.ResetLightbar();
public bool IsHostWindowFocused() =>
HostWindowInputSource.Current?.HasKeyboardFocus ?? false;
public bool IsKeyDown(int virtualKey) =>
HostWindowInputSource.Current?.IsKeyDown(virtualKey) ?? false;
}
@@ -1,439 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Microsoft.Win32.SafeHandles;
namespace SharpEmu.HLE.Host.Windows;
/// <summary>
/// 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.
/// </summary>
public 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 HostGamepadState _state;
private static bool _started;
// Output (rumble/lightbar) state, all guarded by Gate.
private static string? _devicePath;
private static bool _bluetooth;
private static bool _outputReady;
private static bool _lightbarSetupPending;
private static byte _outputSequence;
private static FileStream? _outputStream;
private static byte _motorLeft;
private static byte _motorRight;
private static byte _lightbarRed;
private static byte _lightbarGreen;
private static byte _lightbarBlue = 64; // PS-style blue default
private static byte _playerLeds = 0x04; // center LED = player 1
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
public 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;
}
lock (Gate)
{
if (_started)
{
return;
}
_started = true;
var thread = new Thread(ReadLoop)
{
IsBackground = true,
Name = "DualSenseReader",
};
thread.Start();
}
}
public static bool TryGetState(out HostGamepadState state)
{
lock (Gate)
{
state = _state;
}
return state.Connected;
}
private static void SetState(in HostGamepadState state)
{
lock (Gate)
{
_state = state;
}
}
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
internal static void SetRumble(byte largeMotor, byte smallMotor)
{
lock (Gate)
{
if (_motorLeft == largeMotor && _motorRight == smallMotor)
{
return;
}
_motorLeft = largeMotor;
_motorRight = smallMotor;
SendOutputLocked();
}
}
internal static void SetLightbar(byte red, byte green, byte blue)
{
lock (Gate)
{
if (_lightbarRed == red && _lightbarGreen == green && _lightbarBlue == blue)
{
return;
}
_lightbarRed = red;
_lightbarGreen = green;
_lightbarBlue = blue;
SendOutputLocked();
}
}
internal static void ResetLightbar() => SetLightbar(0, 0, 64);
private static void OnDeviceIdentified(string path, bool bluetooth)
{
lock (Gate)
{
_devicePath = path;
_bluetooth = bluetooth;
_outputReady = true;
_lightbarSetupPending = true;
// Announce ourselves on the hardware: default lightbar + player 1 LED.
SendOutputLocked();
}
}
private static void OnDeviceLost()
{
lock (Gate)
{
_devicePath = null;
_outputReady = false;
_motorLeft = 0;
_motorRight = 0;
_outputStream?.Dispose();
_outputStream = null;
}
}
private static void SendOutputLocked()
{
if (!_outputReady || _devicePath is null)
{
return; // flushed by OnDeviceIdentified once connected
}
try
{
if (_outputStream is null)
{
var handle = WindowsHidNative.CreateFile(
_devicePath,
WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite,
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
0, WindowsHidNative.OpenExisting, 0, 0);
if (handle.IsInvalid)
{
handle.Dispose();
return; // read-only device access: outputs unavailable
}
_outputStream = new FileStream(handle, FileAccess.Write, bufferSize: 1);
}
var report = BuildOutputReportLocked();
_outputStream.Write(report, 0, report.Length);
_outputStream.Flush();
}
catch (Exception)
{
_outputStream?.Dispose();
_outputStream = null;
}
}
private static byte[] BuildOutputReportLocked()
{
// Common 47-byte output payload (offsets per the DualSense output
// report layout, same as Linux hid-playstation).
Span<byte> common = stackalloc byte[47];
common[0] = 0x03; // valid_flag0: compatible vibration + haptics select
common[1] = 0x04 | 0x10; // valid_flag1: lightbar + player indicator
common[2] = _motorRight; // right (weak) motor
common[3] = _motorLeft; // left (strong) motor
if (_lightbarSetupPending)
{
common[38] |= 0x02; // valid_flag2: lightbar setup control enable
common[41] = 0x01; // lightbar_setup: light on
_lightbarSetupPending = false;
}
common[43] = _playerLeds;
common[44] = _lightbarRed;
common[45] = _lightbarGreen;
common[46] = _lightbarBlue;
if (!_bluetooth)
{
var usbReport = new byte[48];
usbReport[0] = 0x02;
common.CopyTo(usbReport.AsSpan(1));
return usbReport;
}
// Bluetooth: 0x31 wrapper with sequence tag and CRC32 over a 0xA2
// seed byte plus the first 74 report bytes.
var btReport = new byte[78];
btReport[0] = 0x31;
btReport[1] = (byte)((_outputSequence & 0x0F) << 4);
_outputSequence = (byte)((_outputSequence + 1) & 0x0F);
btReport[2] = 0x10;
common.CopyTo(btReport.AsSpan(3));
var crc = Crc32(0xA2, btReport.AsSpan(0, 74));
btReport[74] = (byte)crc;
btReport[75] = (byte)(crc >> 8);
btReport[76] = (byte)(crc >> 16);
btReport[77] = (byte)(crc >> 24);
return btReport;
}
private static uint Crc32(byte seed, ReadOnlySpan<byte> data)
{
var crc = Crc32Update(0xFFFFFFFFu, seed);
foreach (var value in data)
{
crc = Crc32Update(crc, value);
}
return ~crc;
}
private static uint Crc32Update(uint crc, byte value)
{
crc ^= value;
for (var bit = 0; bit < 8; bit++)
{
crc = (crc >> 1) ^ (0xEDB88320u & (uint)-(int)(crc & 1));
}
return crc;
}
private static void ReadLoop()
{
var announcedConnect = false;
while (true)
{
SafeFileHandle? handle = null;
try
{
handle = OpenDualSense(out var devicePath);
if (handle is null || devicePath is null)
{
SetState(default);
announcedConnect = false;
Thread.Sleep(1000);
continue;
}
// Bluetooth quirk: the DualSense sends a simplified report
// until feature report 0x05 is requested, which switches it
// to the full 0x31 input report. Harmless over USB.
var feature = new byte[41];
feature[0] = 0x05;
_ = WindowsHidNative.HidD_GetFeature(handle, feature, feature.Length);
if (!announcedConnect)
{
Console.Error.WriteLine("[LOADER][INFO] DualSense controller connected.");
announcedConnect = true;
}
using var stream = new FileStream(handle, FileAccess.Read, bufferSize: 1);
handle = null; // stream owns it now
var buffer = new byte[256];
var transportKnown = false;
while (true)
{
var read = stream.Read(buffer, 0, buffer.Length);
if (read <= 0)
{
break;
}
if (TryParseReport(buffer.AsSpan(0, read), out var state))
{
if (!transportKnown)
{
// The first parsed report tells us the transport,
// which the output (rumble/lightbar) path needs.
transportKnown = true;
OnDeviceIdentified(devicePath, bluetooth: buffer[0] == 0x31);
}
SetState(state);
}
}
}
catch (Exception)
{
// Unplugged or read error: fall through and retry.
}
finally
{
handle?.Dispose();
}
if (announcedConnect)
{
Console.Error.WriteLine("[LOADER][INFO] DualSense controller disconnected.");
announcedConnect = false;
}
OnDeviceLost();
SetState(default);
Thread.Sleep(1000);
}
}
private static SafeFileHandle? OpenDualSense(out string? devicePath)
{
devicePath = null;
foreach (var path in WindowsHidNative.EnumerateHidDevicePaths())
{
// Open without access rights just to query VID/PID.
using var probe = WindowsHidNative.CreateFile(
path, 0, WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, 0, WindowsHidNative.OpenExisting, 0, 0);
if (probe.IsInvalid)
{
continue;
}
var attributes = new WindowsHidNative.HiddAttributes { Size = 12 };
if (!WindowsHidNative.HidD_GetAttributes(probe, ref attributes) ||
attributes.VendorId != SonyVendorId ||
(attributes.ProductId != DualSenseProductId && attributes.ProductId != DualSenseEdgeProductId))
{
continue;
}
// Read+write so feature reports work; fall back to read-only.
var handle = WindowsHidNative.CreateFile(
path,
WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite,
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
0, WindowsHidNative.OpenExisting, 0, 0);
if (handle.IsInvalid)
{
handle.Dispose();
handle = WindowsHidNative.CreateFile(
path,
WindowsHidNative.GenericRead,
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
0, WindowsHidNative.OpenExisting, 0, 0);
}
if (!handle.IsInvalid)
{
devicePath = path;
return handle;
}
handle.Dispose();
}
return null;
}
private static bool TryParseReport(ReadOnlySpan<byte> report, out HostGamepadState state)
{
// USB: report id 0x01, payload starts at [1].
// Bluetooth extended: report id 0x31, sequence byte at [1], payload at [2].
int offset;
if (report.Length >= 11 && report[0] == 0x01)
{
offset = 1;
}
else if (report.Length >= 12 && report[0] == 0x31)
{
offset = 2;
}
else
{
state = default;
return false;
}
var leftX = report[offset + 0];
var leftY = report[offset + 1];
var rightX = report[offset + 2];
var rightY = report[offset + 3];
var l2 = report[offset + 4];
var r2 = report[offset + 5];
var buttons0 = report[offset + 7];
var buttons1 = report[offset + 8];
var buttons2 = report[offset + 9];
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 ? 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 HostGamepadState(
Connected: true,
Buttons: buttons,
LeftX: leftX,
LeftY: leftY,
RightX: rightX,
RightY: rightY,
LeftTrigger: l2,
RightTrigger: r2);
return true;
}
private static HostGamepadButtons HatToButtons(int hat) => hat switch
{
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,
};
}
@@ -1,141 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace SharpEmu.HLE.Host.Windows;
/// <summary>
/// Minimal Win32 HID interop used to talk to a DualSense controller
/// directly, without any external input library.
/// </summary>
internal static partial class WindowsHidNative
{
internal const int DigcfPresent = 0x02;
internal const int DigcfDeviceInterface = 0x10;
internal const uint GenericRead = 0x80000000;
internal const uint GenericWrite = 0x40000000;
internal const uint FileShareRead = 0x1;
internal const uint FileShareWrite = 0x2;
internal const uint OpenExisting = 3;
[StructLayout(LayoutKind.Sequential)]
internal struct SpDeviceInterfaceData
{
public int CbSize;
public Guid InterfaceClassGuid;
public int Flags;
public nint Reserved;
}
[StructLayout(LayoutKind.Sequential)]
internal struct HiddAttributes
{
public int Size;
public ushort VendorId;
public ushort ProductId;
public ushort VersionNumber;
}
[LibraryImport("hid.dll")]
internal static partial void HidD_GetHidGuid(out Guid hidGuid);
[LibraryImport("hid.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, ref HiddAttributes attributes);
[LibraryImport("hid.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool HidD_GetFeature(SafeFileHandle hidDeviceObject, [In, Out] byte[] reportBuffer, int reportBufferLength);
[LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetClassDevsW")]
internal static partial nint SetupDiGetClassDevs(ref Guid classGuid, nint enumerator, nint hwndParent, int flags);
[LibraryImport("setupapi.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetupDiEnumDeviceInterfaces(
nint deviceInfoSet,
nint deviceInfoData,
ref Guid interfaceClassGuid,
int memberIndex,
ref SpDeviceInterfaceData deviceInterfaceData);
[LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetDeviceInterfaceDetailW")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetupDiGetDeviceInterfaceDetail(
nint deviceInfoSet,
ref SpDeviceInterfaceData deviceInterfaceData,
nint deviceInterfaceDetailData,
int deviceInterfaceDetailDataSize,
out int requiredSize,
nint deviceInfoData);
[LibraryImport("setupapi.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetupDiDestroyDeviceInfoList(nint deviceInfoSet);
[LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
internal static partial SafeFileHandle CreateFile(
string fileName,
uint desiredAccess,
uint shareMode,
nint securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
nint templateFile);
/// <summary>
/// Enumerates the device paths of all present HID interfaces.
/// </summary>
internal static List<string> EnumerateHidDevicePaths()
{
var paths = new List<string>();
HidD_GetHidGuid(out var hidGuid);
var deviceInfoSet = SetupDiGetClassDevs(ref hidGuid, 0, 0, DigcfPresent | DigcfDeviceInterface);
if (deviceInfoSet == -1 || deviceInfoSet == 0)
{
return paths;
}
try
{
var interfaceData = new SpDeviceInterfaceData
{
CbSize = Marshal.SizeOf<SpDeviceInterfaceData>(),
};
for (var index = 0; SetupDiEnumDeviceInterfaces(deviceInfoSet, 0, ref hidGuid, index, ref interfaceData); index++)
{
SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, 0, 0, out var requiredSize, 0);
if (requiredSize <= 0)
{
continue;
}
var detailBuffer = Marshal.AllocHGlobal(requiredSize);
try
{
// SP_DEVICE_INTERFACE_DETAIL_DATA_W.cbSize is 8 on x64
// (DWORD + aligned WCHAR[1]); the path string follows it.
Marshal.WriteInt32(detailBuffer, 8);
if (SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, detailBuffer, requiredSize, out _, 0) &&
Marshal.PtrToStringUni(detailBuffer + 4) is { Length: > 0 } path)
{
paths.Add(path);
}
}
finally
{
Marshal.FreeHGlobal(detailBuffer);
}
}
}
finally
{
SetupDiDestroyDeviceInfoList(deviceInfoSet);
}
return paths;
}
}
@@ -1,101 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.HLE.Host.Windows;
/// <summary>
/// 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.
/// </summary>
internal sealed partial class WindowsHostInput : IHostInput
{
public void EnsureStarted()
{
WindowsDualSenseReader.EnsureStarted();
WindowsXInputReader.EnsureStarted();
}
public int GetGamepadStates(Span<HostGamepadState> 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);
if (processId == (uint)Environment.ProcessId)
{
return true;
}
// The GUI runs the emulator in an isolated child process. Its native
// Vulkan surface is a child of the GUI window, so the foreground
// window belongs to the launcher process rather than this one.
var embeddedHostWindow = HostSessionControl.EmbeddedHostWindow;
var hostTopLevelWindow = embeddedHostWindow == 0
? 0
: GetAncestor(embeddedHostWindow, GetAncestorRoot);
return hostTopLevelWindow != 0 && foregroundWindow == hostTopLevelWindow;
}
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);
[LibraryImport("user32.dll")]
private static partial nint GetAncestor(nint hWnd, uint gaFlags);
private const uint GetAncestorRoot = 2;
}
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host.Sdl;
namespace SharpEmu.HLE.Host.Windows;
internal sealed class WindowsHostPlatform : IHostPlatform
@@ -11,7 +13,7 @@ internal sealed class WindowsHostPlatform : IHostPlatform
public IHostSymbolResolver Symbols { get; } = new WindowsHostSymbolResolver();
public IHostAudioOutput Audio { get; } = new WindowsWaveOutAudio();
public IHostAudioOutput Audio { get; } = new SdlHostAudio();
public IHostInput Input { get; } = new WindowsHostInput();
public IHostInput Input { get; } = new WindowHostInput();
}
@@ -1,277 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.HLE.Host.Windows;
/// <summary>
/// Reads Xbox 360 / Xbox One (and other XInput-compatible) controllers via
/// the Windows XInput API on a background thread, translated to
/// <see cref="HostGamepadState"/> conventions. Supports rumble and hot-plug
/// retry; the first connected slot (of four) is used.
/// </summary>
public static partial class WindowsXInputReader
{
private const uint ErrorSuccess = 0;
private const int SlotCount = 4;
private const byte TriggerThreshold = 30; // XINPUT_GAMEPAD_TRIGGER_THRESHOLD
// XINPUT_GAMEPAD wButtons bit values.
private const ushort XinputDpadUp = 0x0001;
private const ushort XinputDpadDown = 0x0002;
private const ushort XinputDpadLeft = 0x0004;
private const ushort XinputDpadRight = 0x0008;
private const ushort XinputStart = 0x0010;
private const ushort XinputBack = 0x0020;
private const ushort XinputLeftThumb = 0x0040;
private const ushort XinputRightThumb = 0x0080;
private const ushort XinputLeftShoulder = 0x0100;
private const ushort XinputRightShoulder = 0x0200;
private const ushort XinputA = 0x1000;
private const ushort XinputB = 0x2000;
private const ushort XinputX = 0x4000;
private const ushort XinputY = 0x8000;
private static readonly object Gate = new();
private static HostGamepadState _state;
private static bool _started;
private static int _slot = -1; // connected XInput user index, -1 when none
private static byte _motorLeft;
private static byte _motorRight;
private static byte _triggerLeft;
private static byte _triggerRight;
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
public 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;
}
lock (Gate)
{
if (_started)
{
return;
}
_started = true;
var thread = new Thread(ReadLoop)
{
IsBackground = true,
Name = "XInputReader",
};
thread.Start();
}
}
public static bool TryGetState(out HostGamepadState state)
{
lock (Gate)
{
state = _state;
}
return state.Connected;
}
private static void SetState(in HostGamepadState state)
{
lock (Gate)
{
_state = state;
}
}
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
internal static void SetRumble(byte largeMotor, byte smallMotor)
{
lock (Gate)
{
if (_motorLeft == largeMotor && _motorRight == smallMotor)
{
return;
}
_motorLeft = largeMotor;
_motorRight = smallMotor;
SendRumbleLocked();
}
}
/// <summary>Approximates per-trigger vibration on the two XInput body motors.</summary>
internal static void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
{
lock (Gate)
{
var changed = false;
if (leftTrigger is { } left)
{
changed |= _triggerLeft != left;
_triggerLeft = left;
}
if (rightTrigger is { } right)
{
changed |= _triggerRight != right;
_triggerRight = right;
}
if (changed)
{
SendRumbleLocked();
}
}
}
private static void SendRumbleLocked()
{
if (_slot < 0)
{
return; // resent on connect
}
var vibration = new XInputVibration
{
LeftMotorSpeed = (ushort)(Math.Max(_motorLeft, _triggerLeft) * 257),
RightMotorSpeed = (ushort)(Math.Max(_motorRight, _triggerRight) * 257),
};
_ = XInputSetState((uint)_slot, ref vibration);
}
private static void ReadLoop()
{
try
{
while (true)
{
var slot = FindConnectedSlot();
if (slot < 0)
{
SetState(default);
Thread.Sleep(1000);
continue;
}
lock (Gate)
{
_slot = slot;
SendRumbleLocked();
}
Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller connected.");
while (XInputGetState((uint)slot, out var state) == ErrorSuccess)
{
SetState(Translate(state.Gamepad));
Thread.Sleep(8);
}
Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller disconnected.");
lock (Gate)
{
_slot = -1;
_motorLeft = 0;
_motorRight = 0;
_triggerLeft = 0;
_triggerRight = 0;
_state = default;
}
Thread.Sleep(1000);
}
}
catch (DllNotFoundException)
{
// XInput unavailable on this system; leave the reader disconnected.
}
catch (EntryPointNotFoundException)
{
}
}
private static int FindConnectedSlot()
{
for (var index = 0; index < SlotCount; index++)
{
if (XInputGetState((uint)index, out _) == ErrorSuccess)
{
return index;
}
}
return -1;
}
private static HostGamepadState Translate(in XInputGamepad pad)
{
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 HostGamepadState(
Connected: true,
Buttons: buttons,
LeftX: AxisToByte(pad.ThumbLX),
LeftY: AxisToByteInverted(pad.ThumbLY),
RightX: AxisToByte(pad.ThumbRX),
RightY: AxisToByteInverted(pad.ThumbRY),
LeftTrigger: pad.LeftTrigger,
RightTrigger: pad.RightTrigger);
}
private static byte AxisToByte(short value) => (byte)((value + 32768) >> 8);
// 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)]
private struct XInputGamepad
{
public ushort Buttons;
public byte LeftTrigger;
public byte RightTrigger;
public short ThumbLX;
public short ThumbLY;
public short ThumbRX;
public short ThumbRY;
}
[StructLayout(LayoutKind.Sequential)]
private struct XInputState
{
public uint PacketNumber;
public XInputGamepad Gamepad;
}
[StructLayout(LayoutKind.Sequential)]
private struct XInputVibration
{
public ushort LeftMotorSpeed;
public ushort RightMotorSpeed;
}
// xinput1_4.dll ships with Windows 8 and later.
[LibraryImport("xinput1_4.dll")]
private static partial uint XInputGetState(uint userIndex, out XInputState state);
[LibraryImport("xinput1_4.dll")]
private static partial uint XInputSetState(uint userIndex, ref XInputVibration vibration);
}
+2 -2
View File
@@ -6,8 +6,8 @@ using System.Collections.Concurrent;
namespace SharpEmu.HLE;
/// <summary>
/// Runs work on the real process main thread. macOS only allows AppKit (and
/// therefore GLFW windowing) on that thread, so the CLI moves emulation onto
/// Runs work on the real process main thread. macOS requires its windowing
/// event loop on that thread, so the CLI moves emulation onto
/// a worker thread, parks the main thread in <see cref="Pump"/>, and the
/// video presenter posts its window loop here. On other platforms
/// <see cref="IsAvailable"/> stays false and nothing changes.
-17
View File
@@ -12,8 +12,6 @@ public static class HostSessionControl
private static Action<string>? _shutdownHandler;
private static string? _pendingShutdownReason;
private static int _shutdownRequested;
private static long _embeddedHostWindow;
private static long _embeddedHostDisplay;
/// <summary>
/// Indicates that the active host session is being stopped. Runtime code
@@ -22,21 +20,6 @@ public static class HostSessionControl
/// </summary>
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
/// <summary>
/// Native GUI surface used by an isolated emulator child. Input backends
/// use it to treat the launcher window as the active game window.
/// </summary>
public static nint EmbeddedHostWindow => unchecked((nint)Interlocked.Read(ref _embeddedHostWindow));
/// <summary>X11 Display* paired with <see cref="EmbeddedHostWindow"/> when available.</summary>
public static nint EmbeddedHostDisplay => unchecked((nint)Interlocked.Read(ref _embeddedHostDisplay));
public static void SetEmbeddedHostSurface(nint window, nint display = 0)
{
Interlocked.Exchange(ref _embeddedHostDisplay, unchecked((long)display));
Interlocked.Exchange(ref _embeddedHostWindow, unchecked((long)window));
}
/// <summary>
/// Starts a fresh session after the previous guest has fully left its
/// execution backend.
+4
View File
@@ -21,6 +21,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.SDL3-CS" />
</ItemGroup>
<ItemGroup>
<!-- Forces build ordering for the aerolib task below; loaded as a build component,
never a runtime dependency. -->