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
+205 -7
View File
@@ -2,13 +2,17 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Collections.Concurrent;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Acm;
public static class AcmExports
{
private const int AcmBatchErrorBytes = 32;
private static readonly ConcurrentDictionary<uint, byte> Contexts = new();
private static int _nextContextHandle;
private static int _nextBatchHandle;
[SysAbiExport(
Nid = "ZIXln2K3XMk",
@@ -23,12 +27,17 @@ public static class AcmExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle);
return ctx.Memory.TryWrite(outContextAddress, handleBytes)
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
var handle = unchecked((uint)Interlocked.Increment(ref _nextContextHandle));
Span<byte> handleBytes = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(handleBytes, handle);
if (!ctx.Memory.TryWrite(outContextAddress, handleBytes))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
Contexts[handle] = 0;
Trace($"context_create context={handle}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
@@ -38,7 +47,196 @@ public static class AcmExports
LibraryName = "libSceAcm")]
public static int AcmContextDestroy(CpuContext ctx)
{
_ = ctx;
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
Contexts.TryRemove(context, out _);
Trace($"context_destroy context={context}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "tW9W+CAG4FE",
ExportName = "sceAcmBatchStartBuffer",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchStartBuffer(CpuContext ctx)
{
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
var commandsAddress = ctx[CpuRegister.Rsi];
var commandsSize = ctx[CpuRegister.Rdx];
var errorAddress = ctx[CpuRegister.Rcx];
var batchAddress = ctx[CpuRegister.R8];
if (!Contexts.ContainsKey(context) ||
batchAddress == 0 ||
(commandsSize != 0 && commandsAddress == 0))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return CompleteBatchStart(ctx, context, 1, errorAddress, batchAddress);
}
[SysAbiExport(
Nid = "8fe55ktlNVo",
ExportName = "sceAcmBatchStartBuffers",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchStartBuffers(CpuContext ctx)
{
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
var infoCount = unchecked((uint)ctx[CpuRegister.Rsi]);
var infoArrayAddress = ctx[CpuRegister.Rdx];
var errorAddress = ctx[CpuRegister.Rcx];
var batchAddress = ctx[CpuRegister.R8];
if (!Contexts.ContainsKey(context) ||
batchAddress == 0 ||
(infoCount != 0 && infoArrayAddress == 0))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return CompleteBatchStart(ctx, context, infoCount, errorAddress, batchAddress);
}
[SysAbiExport(
Nid = "RLN3gRlXJLE",
ExportName = "sceAcmBatchWait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchWait(CpuContext ctx)
{
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
return ctx.SetReturn(
Contexts.ContainsKey(context)
? OrbisGen2Result.ORBIS_GEN2_OK
: OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
[SysAbiExport(
Nid = "r7z5YQFZo+U",
ExportName = "sceAcmBatchJobNotification",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchJobNotification(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 32);
[SysAbiExport(
Nid = "u70oWo92SYQ",
ExportName = "sceAcm_ConvReverb_SharedInput",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmConvReverbSharedInput(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 1024);
[SysAbiExport(
Nid = "9nLbWmRDpa8",
ExportName = "sceAcm_ConvReverb_SharedIr",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmConvReverbSharedIr(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 1024);
[SysAbiExport(
Nid = "KovqaFbmtsM",
ExportName = "sceAcm_FFT",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmFft(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 256);
[SysAbiExport(
Nid = "DR-ZCmvVR9Q",
ExportName = "sceAcm_IFFT",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmIfft(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 256);
[SysAbiExport(
Nid = "LA4RCNKnFjg",
ExportName = "sceAcm_Panner",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmPanner(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 512);
internal static void ResetForTests()
{
Contexts.Clear();
Interlocked.Exchange(ref _nextContextHandle, 0);
Interlocked.Exchange(ref _nextBatchHandle, 0);
}
private static int CompleteBatchStart(
CpuContext ctx,
uint context,
uint infoCount,
ulong errorAddress,
ulong batchAddress)
{
if (errorAddress != 0)
{
Span<byte> error = stackalloc byte[AcmBatchErrorBytes];
error.Clear();
if (!ctx.Memory.TryWrite(errorAddress, error))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
var batch = unchecked((uint)Interlocked.Increment(ref _nextBatchHandle));
Span<byte> batchBytes = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(batchBytes, batch);
if (!ctx.Memory.TryWrite(batchAddress, batchBytes))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
Trace($"batch_start context={context} count={infoCount} batch={batch}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static int AdvanceBatchInfo(CpuContext ctx, ulong byteCount)
{
var infoAddress = ctx[CpuRegister.Rdi];
if (infoAddress == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
Span<byte> info = stackalloc byte[24];
if (!ctx.Memory.TryRead(infoAddress, info))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
var buffer = BinaryPrimitives.ReadUInt64LittleEndian(info);
var offset = BinaryPrimitives.ReadUInt64LittleEndian(info[8..]);
var size = BinaryPrimitives.ReadUInt64LittleEndian(info[16..]);
if (buffer != 0 && size != 0)
{
var nextOffset = offset > ulong.MaxValue - byteCount
? ulong.MaxValue
: offset + byteCount;
BinaryPrimitives.WriteUInt64LittleEndian(info[8..], Math.Min(size, nextOffset));
if (!ctx.Memory.TryWrite(infoAddress, info))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static void Trace(string message)
{
if (string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_ACM"),
"1",
StringComparison.Ordinal))
{
Console.Error.WriteLine($"[LOADER][TRACE] acm.{message}");
}
}
}
+11
View File
@@ -4383,6 +4383,14 @@ public static partial class AgcExports
_tracedProducerlessWaits.Clear();
}
if (!stale)
{
// Count before the deduplication below: the warning fires once
// per label, so on its own it cannot say how often a queue
// actually suspends.
GpuWaitProfile.RecordSuspend(producer is not null);
}
if (!stale && producer is null &&
!_tracedProducerlessWaits.Add(
(memory, waiter.WaitAddress)))
@@ -5648,6 +5656,8 @@ public static partial class AgcExports
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot(
ctx.Memory);
GpuWaitProfile.RecordMonitorPoll(resumed != 0);
GpuWaitProfile.ReportIfDue(remaining);
if (remaining == 0)
{
gpuState.WaitMonitorRunning = false;
@@ -5841,6 +5851,7 @@ public static partial class AgcExports
$"submission={waiter.SubmissionId} label=0x{waiter.WaitAddress:X16} " +
$"resume=0x{waiter.ResumeAddress:X16} remaining_dwords={remainingDwords} " +
$"waited_ms={waitedMilliseconds:F3}");
GpuWaitProfile.RecordResume(waiter.WaitAddress, waitedMilliseconds);
if (remainingDwords == 0)
{
state.IsSuspended = false;
+160
View File
@@ -0,0 +1,160 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.Libs.Agc;
/// <summary>
/// Aggregate accounting for suspended WAIT_REG_MEM packets, enabled with
/// SHARPEMU_PROFILE_GPU_WAIT=1.
///
/// The existing <c>agc.wait_suspended</c> warning is deduplicated per label, so
/// a label that suspends every frame is reported once and then goes silent —
/// which makes the log useless for judging whether GPU waits cost frame time.
/// This counts every suspension and every resume, and reports how long the
/// queues actually sat blocked.
/// </summary>
internal static class GpuWaitProfile
{
public static readonly bool Enabled = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT"),
"1",
StringComparison.Ordinal);
private static readonly double _reportSeconds =
double.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT_REPORT_S"),
System.Globalization.CultureInfo.InvariantCulture,
out var seconds) && seconds > 0
? seconds
: 5.0;
private static readonly object _gate = new();
private static readonly Dictionary<ulong, (long Count, double Milliseconds)> _byLabel = new();
private static long _suspensions;
private static long _resumes;
private static long _producerless;
private static long _monitorPolls;
private static long _monitorEmptyPolls;
private static double _totalWaitMilliseconds;
private static double _maxWaitMilliseconds;
private static long _windowStart = Stopwatch.GetTimestamp();
public static void RecordSuspend(bool hasProducer)
{
if (!Enabled)
{
return;
}
lock (_gate)
{
_suspensions++;
if (!hasProducer)
{
_producerless++;
}
}
}
public static void RecordResume(ulong label, double waitedMilliseconds)
{
if (!Enabled)
{
return;
}
lock (_gate)
{
_resumes++;
_totalWaitMilliseconds += waitedMilliseconds;
if (waitedMilliseconds > _maxWaitMilliseconds)
{
_maxWaitMilliseconds = waitedMilliseconds;
}
if (_byLabel.Count < 4096)
{
var existing = _byLabel.TryGetValue(label, out var entry) ? entry : default;
_byLabel[label] = (existing.Count + 1, existing.Milliseconds + waitedMilliseconds);
}
}
}
/// <summary>
/// Called once per wake of the wait monitor. An empty poll means the monitor
/// burned a wakeup without resuming anything, which is the cost of the
/// backoff loop rather than of the wait itself.
/// </summary>
public static void RecordMonitorPoll(bool resumedAny)
{
if (!Enabled)
{
return;
}
lock (_gate)
{
_monitorPolls++;
if (!resumedAny)
{
_monitorEmptyPolls++;
}
}
}
public static void ReportIfDue(int remainingWaiters)
{
if (!Enabled)
{
return;
}
string line;
lock (_gate)
{
var now = Stopwatch.GetTimestamp();
var elapsedTicks = now - _windowStart;
if (elapsedTicks < _reportSeconds * Stopwatch.Frequency)
{
return;
}
_windowStart = now;
var seconds = elapsedTicks / (double)Stopwatch.Frequency;
// Total blocked time across all queues. Above 1000ms/s the queues are
// overlapping their stalls, so compare it against the frame budget,
// not against wall time.
var top = _byLabel
.OrderByDescending(entry => entry.Value.Milliseconds)
.Take(5)
.Select(entry =>
$"0x{entry.Key:X}={entry.Value.Milliseconds / seconds:F0}ms/s" +
$"/n{entry.Value.Count}")
.ToArray();
line =
$"[PERF][GPUWAIT] {seconds:F1}s suspend/s={_suspensions / seconds:F0} " +
$"resume/s={_resumes / seconds:F0} producerless/s={_producerless / seconds:F0} " +
$"blocked_ms/s={_totalWaitMilliseconds / seconds:F0} " +
$"avg_ms={(_resumes > 0 ? _totalWaitMilliseconds / _resumes : 0):F2} " +
$"max_ms={_maxWaitMilliseconds:F1} " +
$"monitor_polls/s={_monitorPolls / seconds:F0} " +
$"empty={(_monitorPolls > 0 ? _monitorEmptyPolls * 100.0 / _monitorPolls : 0):F0}% " +
$"outstanding={remainingWaiters} top: {string.Join(" | ", top)}";
_suspensions = 0;
_resumes = 0;
_producerless = 0;
_monitorPolls = 0;
_monitorEmptyPolls = 0;
_totalWaitMilliseconds = 0;
_maxWaitMilliseconds = 0;
_byLabel.Clear();
}
Console.Error.WriteLine(line);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,409 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using LibAtrac9;
namespace SharpEmu.Libs.Audio;
internal enum Atrac9PcmEncoding
{
Signed16,
Signed32,
Float,
}
internal readonly record struct Atrac9DecodeResult(
int Status,
int InputConsumed,
int OutputWritten,
ulong TotalDecodedSamples,
uint Frames);
internal sealed class Atrac9DecodeState
{
internal const int ResultNotInitialized = 0x00000001;
internal const int ResultInvalidData = 0x00000002;
internal const int ResultInvalidParameter = 0x00000004;
internal const int ResultPartialInput = 0x00000008;
internal const int ResultNotEnoughRoom = 0x00000010;
internal const int ResultCodecError = 0x40000000;
private const int MaxContainerHeaderBytes = 8 * 1024;
private enum ContainerScan
{
NotContainer,
NeedMoreData,
Found,
}
private readonly object _gate = new();
private Atrac9Decoder? _decoder;
private byte[]? _configData;
private byte[]? _compressed;
private short[][]? _planarPcm;
private byte[]? _containerHeader;
private int _containerHeaderLength;
private int _compressedLength;
private ulong _totalDecodedSamples;
public Atrac9Config? Config
{
get
{
lock (_gate)
{
return _decoder?.Config;
}
}
}
public bool TryInitialize(ReadOnlySpan<byte> configData)
{
if (configData.Length < 4)
{
return false;
}
lock (_gate)
{
try
{
var normalizedConfig = configData[..4].ToArray();
var decoder = new Atrac9Decoder();
decoder.Initialize(normalizedConfig);
var config = decoder.Config;
_decoder = decoder;
_configData = normalizedConfig;
_compressed = new byte[config.SuperframeBytes];
_planarPcm = CreatePcmBuffer(config.ChannelCount, config.SuperframeSamples);
_compressedLength = 0;
_totalDecodedSamples = 0;
_containerHeaderLength = 0;
Trace(
$"initialized config={Convert.ToHexString(normalizedConfig)} channels={config.ChannelCount} " +
$"rate={config.SampleRate} frame_samples={config.FrameSamples} " +
$"superframe_samples={config.SuperframeSamples} superframe_bytes={config.SuperframeBytes} " +
$"frames_per_superframe={config.FramesPerSuperframe}");
return true;
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException)
{
Clear();
return false;
}
}
}
public void Reset()
{
lock (_gate)
{
if (_configData is null)
{
Clear();
return;
}
var decoder = new Atrac9Decoder();
decoder.Initialize((byte[])_configData.Clone());
_decoder = decoder;
_compressedLength = 0;
_totalDecodedSamples = 0;
_containerHeaderLength = 0;
if (_compressed is not null)
{
Array.Clear(_compressed);
}
}
}
public Atrac9DecodeResult Decode(
ReadOnlySpan<byte> input,
Span<byte> output,
Atrac9PcmEncoding encoding,
int requestedChannels,
bool multipleFrames)
{
lock (_gate)
{
if (_decoder is null || _compressed is null || _planarPcm is null)
{
return new Atrac9DecodeResult(
ResultNotInitialized,
0,
0,
_totalDecodedSamples,
0);
}
var config = _decoder.Config;
var channels = requestedChannels > 0 ? requestedChannels : config.ChannelCount;
if (channels is < 1 or > 16)
{
return new Atrac9DecodeResult(
ResultInvalidParameter,
0,
0,
_totalDecodedSamples,
0);
}
var bytesPerSample = GetBytesPerSample(encoding);
var outputBytesPerSuperframe = checked(config.SuperframeSamples * channels * bytesPerSample);
var consumed = 0;
var written = 0;
uint frames = 0;
var status = 0;
while (_compressedLength == config.SuperframeBytes ||
consumed < input.Length)
{
// Titles that stream whole .at9 files hand AJM the RIFF/WAVE
// container rather than a pointer into its `data` chunk, so the
// stream has to be advanced past the header before the first
// superframe — otherwise every job fails with invalid data and
// the title drops the voice. This is checked at every superframe
// boundary, not just after initialize, because a looping voice
// rewinds to the file header without reinitialising.
if (_compressedLength == 0 && (_containerHeaderLength != 0 || consumed < input.Length))
{
var scan = ScanContainerHeader(input[consumed..], out var headerBytes);
if (scan == ContainerScan.NeedMoreData)
{
consumed = input.Length;
status |= ResultPartialInput;
break;
}
if (scan == ContainerScan.Found)
{
_containerHeader = null;
_containerHeaderLength = 0;
consumed += headerBytes;
continue;
}
}
if (_compressedLength < config.SuperframeBytes)
{
var copied = Math.Min(config.SuperframeBytes - _compressedLength, input.Length - consumed);
input.Slice(consumed, copied).CopyTo(_compressed.AsSpan(_compressedLength));
_compressedLength += copied;
consumed += copied;
}
if (_compressedLength < config.SuperframeBytes)
{
status |= ResultPartialInput;
break;
}
if (output.Length - written < outputBytesPerSuperframe)
{
status |= ResultNotEnoughRoom;
break;
}
try
{
_decoder.Decode(_compressed, _planarPcm);
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
{
Trace(
$"decode_failed superframe_bytes={config.SuperframeBytes} " +
$"config={Convert.ToHexString(_configData ?? [])} " +
$"head={Convert.ToHexString(_compressed.AsSpan(0, Math.Min(16, _compressed.Length)))} " +
$"error={exception.GetType().Name}: {exception.Message}");
_compressedLength = 0;
return new Atrac9DecodeResult(
status | ResultInvalidData | ResultCodecError,
consumed,
written,
_totalDecodedSamples,
frames);
}
WriteInterleaved(
_planarPcm,
output.Slice(written, outputBytesPerSuperframe),
config.SuperframeSamples,
channels,
encoding);
written += outputBytesPerSuperframe;
_compressedLength = 0;
_totalDecodedSamples += unchecked((uint)config.SuperframeSamples);
frames += unchecked((uint)config.FramesPerSuperframe);
if (!multipleFrames)
{
break;
}
}
return new Atrac9DecodeResult(
status,
consumed,
written,
_totalDecodedSamples,
frames);
}
}
private ContainerScan ScanContainerHeader(ReadOnlySpan<byte> input, out int inputHeaderBytes)
{
inputHeaderBytes = 0;
if (_containerHeaderLength == 0 &&
(input.Length < 4 || !input[..4].SequenceEqual("RIFF"u8)))
{
return ContainerScan.NotContainer;
}
_containerHeader ??= new byte[MaxContainerHeaderBytes];
var previousLength = _containerHeaderLength;
var copied = Math.Min(input.Length, MaxContainerHeaderBytes - previousLength);
input[..copied].CopyTo(_containerHeader.AsSpan(previousLength));
var totalLength = previousLength + copied;
if (!TryFindRiffDataOffset(_containerHeader.AsSpan(0, totalLength), out var dataOffset))
{
if (totalLength >= MaxContainerHeaderBytes)
{
// Not a shape we understand — fall back to treating the stream
// as raw superframes rather than swallowing it forever. The
// scratch is dropped without advancing the caller's cursor, so
// the bytes are still decoded normally.
Trace($"container_scan_gave_up bytes={totalLength}");
_containerHeader = null;
_containerHeaderLength = 0;
return ContainerScan.NotContainer;
}
_containerHeaderLength = totalLength;
return ContainerScan.NeedMoreData;
}
inputHeaderBytes = dataOffset - previousLength;
Trace($"container_header_skipped bytes={dataOffset} from_this_input={inputHeaderBytes}");
return ContainerScan.Found;
}
private static bool TryFindRiffDataOffset(ReadOnlySpan<byte> header, out int dataOffset)
{
dataOffset = 0;
if (header.Length < 12 ||
!header[..4].SequenceEqual("RIFF"u8) ||
!header.Slice(8, 4).SequenceEqual("WAVE"u8))
{
return false;
}
var offset = 12;
while (offset + 8 <= header.Length)
{
var chunkId = header.Slice(offset, 4);
var chunkSize = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(offset + 4, 4));
offset += 8;
if (chunkId.SequenceEqual("data"u8))
{
dataOffset = offset;
return true;
}
// RIFF chunks are word aligned.
var advance = chunkSize + (chunkSize & 1);
if (advance > (ulong)(int.MaxValue - offset))
{
return false;
}
offset += (int)advance;
}
return false;
}
private static short[][] CreatePcmBuffer(int channels, int samples)
{
var result = new short[channels][];
for (var channel = 0; channel < channels; channel++)
{
result[channel] = new short[samples];
}
return result;
}
private static int GetBytesPerSample(Atrac9PcmEncoding encoding) =>
encoding switch
{
Atrac9PcmEncoding.Signed16 => sizeof(short),
Atrac9PcmEncoding.Signed32 => sizeof(int),
Atrac9PcmEncoding.Float => sizeof(float),
_ => throw new ArgumentOutOfRangeException(nameof(encoding)),
};
private static void WriteInterleaved(
short[][] source,
Span<byte> destination,
int samples,
int channels,
Atrac9PcmEncoding encoding)
{
var offset = 0;
for (var sample = 0; sample < samples; sample++)
{
for (var channel = 0; channel < channels; channel++)
{
var sourceChannel = Math.Min(channel, source.Length - 1);
var value = source[sourceChannel][sample];
switch (encoding)
{
case Atrac9PcmEncoding.Signed16:
BinaryPrimitives.WriteInt16LittleEndian(destination[offset..], value);
offset += sizeof(short);
break;
case Atrac9PcmEncoding.Signed32:
BinaryPrimitives.WriteInt32LittleEndian(destination[offset..], value << 16);
offset += sizeof(int);
break;
case Atrac9PcmEncoding.Float:
BinaryPrimitives.WriteInt32LittleEndian(
destination[offset..],
BitConverter.SingleToInt32Bits(value / 32768.0f));
offset += sizeof(float);
break;
default:
throw new ArgumentOutOfRangeException(nameof(encoding));
}
}
}
}
private static void Trace(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AJM"), "1", StringComparison.Ordinal))
{
Console.Error.WriteLine($"[LOADER][TRACE] ajm.at9.{message}");
}
}
private void Clear()
{
_decoder = null;
_configData = null;
_compressed = null;
_planarPcm = null;
_containerHeader = null;
_containerHeaderLength = 0;
_compressedLength = 0;
_totalDecodedSamples = 0;
}
}
+43 -20
View File
@@ -44,6 +44,7 @@ public static class AudioOutExports
int channels,
int bytesPerSample,
bool isFloat,
bool preservesGuestFormat,
IHostAudioStream? backend)
{
UserId = userId;
@@ -54,6 +55,7 @@ public static class AudioOutExports
Channels = channels;
BytesPerSample = bytesPerSample;
IsFloat = isFloat;
PreservesGuestFormat = preservesGuestFormat;
Backend = backend;
}
@@ -65,6 +67,7 @@ public static class AudioOutExports
public int Channels { get; }
public int BytesPerSample { get; }
public bool IsFloat { get; }
public bool PreservesGuestFormat { get; }
public IHostAudioStream? Backend { get; }
public object SubmissionGate { get; } = new();
public volatile float Volume = 1.0f;
@@ -140,6 +143,7 @@ public static class AudioOutExports
}
IHostAudioStream? backend = null;
var preservesGuestFormat = false;
string backendName;
try
{
@@ -152,7 +156,18 @@ public static class AudioOutExports
else
{
var audio = HostPlatform.Current.Audio;
backend = audio.OpenStereoPcm16Stream(frequency);
if (audio is IHostPcmAudioOutput pcmAudio)
{
backend = pcmAudio.OpenPcmStream(
frequency,
channels,
isFloat ? HostPcmFormat.Float32 : HostPcmFormat.Signed16);
preservesGuestFormat = true;
}
else
{
backend = audio.OpenStereoPcm16Stream(frequency);
}
backendName = audio.BackendName;
}
}
@@ -173,6 +188,7 @@ public static class AudioOutExports
channels,
bytesPerSample,
isFloat,
preservesGuestFormat,
backend);
Console.Error.WriteLine(
$"[LOADER][INFO] AudioOut port {handle}: {frequency} Hz, " +
@@ -321,18 +337,13 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
var outputLength = checked((int)port.BufferLength * AudioPcmConversion.OutputFrameSize);
var outputLength = port.PreservesGuestFormat
? port.BufferByteLength
: checked((int)port.BufferLength * AudioPcmConversion.OutputFrameSize);
var output = ArrayPool<byte>.Shared.Rent(outputLength);
try
{
AudioPcmConversion.ConvertToStereoPcm16(
source,
output.AsSpan(0, outputLength),
checked((int)port.BufferLength),
port.Channels,
port.BytesPerSample,
port.IsFloat,
port.Volume);
ConvertForHost(port, source, output.AsSpan(0, outputLength));
if (!port.Backend.Submit(output.AsSpan(0, outputLength)))
{
port.PaceSilence();
@@ -449,17 +460,11 @@ public static class AudioOutExports
TraceOutput(output.Handle, output.Port, source);
output.HostBufferLength = checked(
(int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize);
output.HostBufferLength = output.Port.PreservesGuestFormat
? output.Port.BufferByteLength
: checked((int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize);
output.HostBuffer = ArrayPool<byte>.Shared.Rent(output.HostBufferLength);
AudioPcmConversion.ConvertToStereoPcm16(
source,
output.HostBuffer.AsSpan(0, output.HostBufferLength),
checked((int)output.Port.BufferLength),
output.Port.Channels,
output.Port.BytesPerSample,
output.Port.IsFloat,
output.Port.Volume);
ConvertForHost(output.Port, source, output.HostBuffer.AsSpan(0, output.HostBufferLength));
}
finally
{
@@ -513,6 +518,24 @@ public static class AudioOutExports
(ulong)candidate.BufferLength * current.Frequency >
(ulong)current.BufferLength * candidate.Frequency;
private static void ConvertForHost(PortState port, ReadOnlySpan<byte> source, Span<byte> destination)
{
if (port.PreservesGuestFormat)
{
AudioPcmConversion.CopyWithVolume(source, destination, port.IsFloat, port.Volume);
return;
}
AudioPcmConversion.ConvertToStereoPcm16(
source,
destination,
checked((int)port.BufferLength),
port.Channels,
port.BytesPerSample,
port.IsFloat,
port.Volume);
}
private static void TraceOutput(int handle, PortState port, ReadOnlySpan<byte> source)
{
if (!_traceOutput)
@@ -43,6 +43,46 @@ internal static class AudioPcmConversion
}
}
/// <summary>
/// Copies interleaved PCM without changing its channel layout. SDL can convert
/// this directly to the physical device, which preserves surround mixes that
/// would otherwise be truncated to the first two guest channels.
/// </summary>
public static void CopyWithVolume(
ReadOnlySpan<byte> source,
Span<byte> destination,
bool isFloat,
float volume)
{
var clampedVolume = Math.Clamp(volume, 0.0f, 1.0f);
if (clampedVolume >= 1.0f)
{
source.CopyTo(destination);
return;
}
if (isFloat)
{
for (var offset = 0; offset < source.Length; offset += sizeof(float))
{
var sample = BinaryPrimitives.ReadSingleLittleEndian(source.Slice(offset, sizeof(float)));
BinaryPrimitives.WriteSingleLittleEndian(
destination.Slice(offset, sizeof(float)),
sample * clampedVolume);
}
return;
}
for (var offset = 0; offset < source.Length; offset += sizeof(short))
{
var sample = BinaryPrimitives.ReadInt16LittleEndian(source.Slice(offset, sizeof(short)));
BinaryPrimitives.WriteInt16LittleEndian(
destination.Slice(offset, sizeof(short)),
ApplyVolume(sample, clampedVolume));
}
}
private static short ReadSample(
ReadOnlySpan<byte> frame,
int channel,
+3 -1
View File
@@ -133,10 +133,12 @@ internal static class Bink2MovieBridge
if (_playback.IsFinished)
{
var completedPath = _activePath;
var progress = _playback.PlaybackProgress;
CloseActiveLocked();
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge completed: " +
Path.GetFileName(completedPath));
$"{Path.GetFileName(completedPath)} after " +
$"{progress.Seconds:F2}s at frame {progress.FrameIndex}");
AttachNextQueuedMovieLocked();
}
return false;
+129 -7
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink;
@@ -36,6 +37,8 @@ internal sealed class BinkFramePlayback : IDisposable
private long _currentFrameIndex = -1;
private long _nextDecodedFrameIndex;
private long _playbackStartTimestamp;
private double _audioStartSeconds;
private long _lastSkewTraceTimestamp;
private bool _playbackClockStarted;
private bool _decoderCompleted;
private bool _stopRequested;
@@ -83,6 +86,26 @@ internal sealed class BinkFramePlayback : IDisposable
}
}
/// <summary>
/// Wall-clock seconds since the first frame was presented, and the index of
/// the last frame shown. Playback is on its own time base when these agree
/// with the movie's frame rate.
/// </summary>
internal (double Seconds, long FrameIndex) PlaybackProgress
{
get
{
lock (_gate)
{
return (
_playbackClockStarted
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
: 0,
_currentFrameIndex);
}
}
}
internal bool TryGetFrame(
bool advanceClock,
out byte[] pixels,
@@ -118,14 +141,13 @@ internal sealed class BinkFramePlayback : IDisposable
if (advanceClock && !_playbackClockStarted)
{
_playbackStartTimestamp = Stopwatch.GetTimestamp();
_audioStartSeconds = GuestAudioClock.PlayedSeconds;
_playbackClockStarted = true;
}
var elapsedSeconds = _playbackClockStarted
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
: 0;
var targetFrameIndex = (long)Math.Floor(
elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
var elapsedSeconds = CurrentPlaybackSecondsLocked();
TraceClockSkewLocked();
var targetFrameIndex = CurrentTargetFrameIndexLocked();
DecodedFrame? replacement = null;
while (_decodedFrames.Count > 0 &&
_decodedFrames.Peek().Index <= targetFrameIndex)
@@ -166,6 +188,86 @@ internal sealed class BinkFramePlayback : IDisposable
}
}
/// <summary>
/// Time base for playback. A host-decoded movie runs on whatever clock it is
/// given, but the audio that belongs to it comes from the guest, which does
/// not advance at wall-clock rate on a slow frame. Following the audio keeps
/// the two together; SHARPEMU_MOVIE_CLOCK=wall restores the old behaviour.
/// </summary>
private static readonly bool _followGuestAudioClock = !string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_MOVIE_CLOCK"),
"wall",
StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Seconds of playback elapsed on the movie's time base. Falls back to wall
/// clock whenever guest audio is not flowing: a movie whose audio never
/// starts — or stops early — must still finish rather than hang on a clock
/// that will never advance again.
/// </summary>
private double CurrentPlaybackSecondsLocked()
{
if (!_playbackClockStarted)
{
return 0;
}
var wallSeconds = Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds;
if (!_followGuestAudioClock || !GuestAudioClock.IsRunning)
{
return wallSeconds;
}
return Math.Clamp(GuestAudioClock.PlayedSeconds - _audioStartSeconds, 0, wallSeconds);
}
private static readonly bool _traceClockSkew = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_MOVIE_SYNC"),
"1",
StringComparison.Ordinal);
/// <summary>
/// Logs how far the movie's wall clock has drifted from the guest audio the
/// movie is supposed to be in step with. A skew that is flat across playback
/// is a late audio start; one that grows is a rate mismatch, and the two need
/// different fixes. Caller holds <see cref="_gate"/>.
/// </summary>
private void TraceClockSkewLocked()
{
if (!_traceClockSkew || !_playbackClockStarted)
{
return;
}
var now = Stopwatch.GetTimestamp();
if (_lastSkewTraceTimestamp != 0 &&
Stopwatch.GetElapsedTime(_lastSkewTraceTimestamp) < TimeSpan.FromSeconds(1))
{
return;
}
_lastSkewTraceTimestamp = now;
var wallSeconds = Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds;
var audioSeconds = GuestAudioClock.PlayedSeconds - _audioStartSeconds;
Console.Error.WriteLine(
$"[PERF][MOVIE] wall_s={wallSeconds:F2} audio_s={audioSeconds:F2} " +
$"playback_s={CurrentPlaybackSecondsLocked():F2} " +
$"skew_s={wallSeconds - audioSeconds:F2} frame={_currentFrameIndex} " +
$"audio_running={GuestAudioClock.IsRunning}");
}
/// <summary>
/// The frame the movie's own time base says should be on screen right now.
/// Returns -1 until the first frame is presented, so the queue prefills
/// instead of instantly declaring everything late.
/// </summary>
private long CurrentTargetFrameIndexLocked() =>
_playbackClockStarted
? (long)Math.Floor(
CurrentPlaybackSecondsLocked() *
FramesPerSecondNumerator / FramesPerSecondDenominator)
: -1;
private void DecodeLoop()
{
try
@@ -199,8 +301,28 @@ internal sealed class BinkFramePlayback : IDisposable
lock (_gate)
{
_decodedFrames.Enqueue(new DecodedFrame(
_nextDecodedFrameIndex++, destination));
var frameIndex = _nextDecodedFrameIndex++;
// Frames are pulled once per guest flip, so a title running
// well under the movie's frame rate cannot drain a queue
// this shallow fast enough and the movie stretches past its
// real duration — audio finishes while the last picture sits
// on screen and the next movie starts late. Once the clock
// has passed a queued frame it can never be shown, so retire
// it in favour of this newer one. Only superseded frames are
// dropped, never the newest, so a decoder that cannot keep
// up still advances the picture instead of freezing it.
var targetFrameIndex = CurrentTargetFrameIndexLocked();
if (frameIndex <= targetFrameIndex)
{
while (_decodedFrames.Count > 0 &&
_decodedFrames.Peek().Index <= targetFrameIndex)
{
_freeBuffers.Enqueue(_decodedFrames.Dequeue().Pixels);
}
}
_decodedFrames.Enqueue(new DecodedFrame(frameIndex, destination));
Monitor.PulseAll(_gate);
}
}
@@ -1,7 +1,9 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using FFmpeg.AutoGen;
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Bink;
@@ -13,13 +15,29 @@ namespace SharpEmu.Libs.Bink;
/// </summary>
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
{
private const int OutputAudioChannels = 2;
private const int OutputAudioBytesPerSample = sizeof(short);
private readonly object _decodeGate = new();
private AVFormatContext* _formatContext;
private AVCodecContext* _codecContext;
private AVCodecContext* _audioCodecContext;
private SwsContext* _swsContext;
private SwrContext* _swrContext;
private AVFrame* _frame;
private AVFrame* _audioFrame;
private AVPacket* _packet;
private IHostAudioStream? _audioStream;
private readonly int _videoStreamIndex;
private readonly int _audioStreamIndex;
private readonly int _audioOutputSampleRate;
private AVChannelLayout _swrInputLayout;
private AVSampleFormat _swrInputFormat = AVSampleFormat.AV_SAMPLE_FMT_NONE;
private int _swrInputSampleRate;
private bool _swrInputLayoutValid;
private bool _draining;
private bool _audioDraining;
private bool _audioFailed;
private int _disposed;
public uint Width { get; }
@@ -34,6 +52,10 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
AVFormatContext* formatContext,
AVCodecContext* codecContext,
int videoStreamIndex,
AVCodecContext* audioCodecContext,
int audioStreamIndex,
IHostAudioStream? audioStream,
int audioOutputSampleRate,
uint width,
uint height,
uint framesPerSecondNumerator,
@@ -42,11 +64,16 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
_formatContext = formatContext;
_codecContext = codecContext;
_videoStreamIndex = videoStreamIndex;
_audioCodecContext = audioCodecContext;
_audioStreamIndex = audioStreamIndex;
_audioStream = audioStream;
_audioOutputSampleRate = audioOutputSampleRate;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
_frame = ffmpeg.av_frame_alloc();
_audioFrame = audioCodecContext is null ? null : ffmpeg.av_frame_alloc();
_packet = ffmpeg.av_packet_alloc();
}
@@ -93,6 +120,8 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
AVFormatContext* formatContext = null;
AVCodecContext* codecContext = null;
AVCodecContext* audioCodecContext = null;
IHostAudioStream? audioStream = null;
try
{
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
@@ -151,6 +180,28 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
frameRate = new AVRational { num = 30, den = 1 };
}
var audioStreamIndex = TryOpenAudioDecoder(
formatContext,
out audioCodecContext,
out var audioOutputSampleRate);
if (audioStreamIndex >= 0 && audioCodecContext is not null)
{
try
{
audioStream = HostPlatform.Current.Audio.OpenStereoPcm16Stream(
checked((uint)audioOutputSampleRate));
}
catch (Exception exception) when (exception is InvalidOperationException or
ArgumentOutOfRangeException)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink audio output unavailable: {exception.Message}");
ffmpeg.avcodec_free_context(&audioCodecContext);
audioStreamIndex = -1;
audioOutputSampleRate = 0;
}
}
var outputWidth = (uint)codecContext->width;
var outputHeight = (uint)codecContext->height;
if (maximumWidth > 0 && maximumHeight > 0 &&
@@ -175,12 +226,18 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
formatContext,
codecContext,
videoStreamIndex,
audioCodecContext,
audioStreamIndex,
audioStream,
audioOutputSampleRate,
outputWidth,
outputHeight,
(uint)frameRate.num,
(uint)frameRate.den);
formatContext = null;
codecContext = null;
audioCodecContext = null;
audioStream = null;
return true;
}
catch (DllNotFoundException)
@@ -194,6 +251,13 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
ffmpeg.avcodec_free_context(&codecContext);
}
if (audioCodecContext is not null)
{
ffmpeg.avcodec_free_context(&audioCodecContext);
}
audioStream?.Dispose();
if (formatContext is not null)
{
ffmpeg.avformat_close_input(&formatContext);
@@ -201,52 +265,102 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
}
}
private static int TryOpenAudioDecoder(
AVFormatContext* formatContext,
out AVCodecContext* codecContext,
out int outputSampleRate)
{
codecContext = null;
outputSampleRate = 0;
AVCodec* decoder = null;
var streamIndex = ffmpeg.av_find_best_stream(
formatContext, AVMediaType.AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0);
if (streamIndex < 0 || decoder is null)
{
return -1;
}
var candidate = ffmpeg.avcodec_alloc_context3(decoder);
if (candidate is null)
{
return -1;
}
var stream = formatContext->streams[streamIndex];
if (ffmpeg.avcodec_parameters_to_context(candidate, stream->codecpar) < 0)
{
ffmpeg.avcodec_free_context(&candidate);
return -1;
}
candidate->thread_count = 0;
candidate->thread_type = ffmpeg.FF_THREAD_FRAME | ffmpeg.FF_THREAD_SLICE;
if (ffmpeg.avcodec_open2(candidate, decoder, null) < 0)
{
ffmpeg.avcodec_free_context(&candidate);
return -1;
}
outputSampleRate = candidate->sample_rate > 0 ? candidate->sample_rate : 48_000;
codecContext = candidate;
return streamIndex;
}
public bool TryDecodeNextFrame(Span<byte> destination)
{
var stride = checked((int)(Width * 4));
var required = (long)stride * Height;
if (destination.Length < required)
lock (_decodeGate)
{
return false;
}
if (Volatile.Read(ref _disposed) != 0)
{
return false;
}
if (!TryReceiveFrame())
{
return false;
}
var stride = checked((int)(Width * 4));
var required = (long)stride * Height;
if (destination.Length < required)
{
return false;
}
_swsContext = ffmpeg.sws_getCachedContext(
_swsContext,
_frame->width,
_frame->height,
(AVPixelFormat)_frame->format,
(int)Width,
(int)Height,
AVPixelFormat.AV_PIX_FMT_BGRA,
ffmpeg.SWS_FAST_BILINEAR,
null,
null,
null);
if (_swsContext is null)
{
ffmpeg.av_frame_unref(_frame);
return false;
}
if (!TryReceiveFrame())
{
return false;
}
fixed (byte* destinationPointer = destination)
{
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
var destinationStrides = new int[4] { stride, 0, 0, 0 };
var convertedRows = ffmpeg.sws_scale(
_swsContext = ffmpeg.sws_getCachedContext(
_swsContext,
_frame->data,
_frame->linesize,
0,
_frame->width,
_frame->height,
destinationPlanes,
destinationStrides);
ffmpeg.av_frame_unref(_frame);
return convertedRows == (int)Height;
(AVPixelFormat)_frame->format,
(int)Width,
(int)Height,
AVPixelFormat.AV_PIX_FMT_BGRA,
ffmpeg.SWS_FAST_BILINEAR,
null,
null,
null);
if (_swsContext is null)
{
ffmpeg.av_frame_unref(_frame);
return false;
}
fixed (byte* destinationPointer = destination)
{
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
var destinationStrides = new int[4] { stride, 0, 0, 0 };
var convertedRows = ffmpeg.sws_scale(
_swsContext,
_frame->data,
_frame->linesize,
0,
_frame->height,
destinationPlanes,
destinationStrides);
ffmpeg.av_frame_unref(_frame);
return convertedRows == (int)Height;
}
}
}
@@ -291,9 +405,17 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
{
_draining = true;
ffmpeg.avcodec_send_packet(_codecContext, null);
DrainAudioDecoder();
return true;
}
if (_packet->stream_index == _audioStreamIndex)
{
DecodeAudioPacket(_packet);
ffmpeg.av_packet_unref(_packet);
continue;
}
if (_packet->stream_index != _videoStreamIndex)
{
ffmpeg.av_packet_unref(_packet);
@@ -311,6 +433,248 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
}
}
private void DecodeAudioPacket(AVPacket* packet)
{
if (_audioCodecContext is null || _audioFrame is null || _audioFailed)
{
return;
}
var sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, packet);
if (sendResult == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
DrainAvailableAudioFrames();
sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, packet);
}
if (sendResult < 0)
{
DisableAudio("packet decode failed");
return;
}
DrainAvailableAudioFrames();
}
private void DrainAudioDecoder()
{
if (_audioCodecContext is null || _audioFrame is null ||
_audioDraining || _audioFailed)
{
return;
}
_audioDraining = true;
var sendResult = ffmpeg.avcodec_send_packet(_audioCodecContext, null);
if (sendResult >= 0 || sendResult == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
DrainAvailableAudioFrames();
}
}
private void DrainAvailableAudioFrames()
{
while (_audioCodecContext is not null && _audioFrame is not null)
{
var receiveResult = ffmpeg.avcodec_receive_frame(_audioCodecContext, _audioFrame);
if (receiveResult == ffmpeg.AVERROR(ffmpeg.EAGAIN) ||
receiveResult == ffmpeg.AVERROR_EOF)
{
return;
}
if (receiveResult < 0)
{
DisableAudio("frame decode failed");
return;
}
if (!SubmitAudioFrame())
{
ffmpeg.av_frame_unref(_audioFrame);
DisableAudio("host submission failed");
return;
}
ffmpeg.av_frame_unref(_audioFrame);
}
}
private bool SubmitAudioFrame()
{
if (_audioStream is null || _audioFrame is null ||
_audioFrame->nb_samples <= 0 || _audioFrame->extended_data is null)
{
return true;
}
var sampleRate = _audioFrame->sample_rate > 0
? _audioFrame->sample_rate
: _audioCodecContext->sample_rate;
if (sampleRate <= 0)
{
return false;
}
var inputLayout = _audioFrame->ch_layout;
var ownsInputLayout = false;
if (ffmpeg.av_channel_layout_check(&inputLayout) == 0)
{
inputLayout = _audioCodecContext->ch_layout;
}
if (ffmpeg.av_channel_layout_check(&inputLayout) == 0)
{
ffmpeg.av_channel_layout_default(
&inputLayout,
Math.Max(1, _audioFrame->ch_layout.nb_channels));
ownsInputLayout = true;
}
try
{
if (!EnsureAudioResampler(
&inputLayout,
(AVSampleFormat)_audioFrame->format,
sampleRate))
{
return false;
}
var maximumSamples = ffmpeg.swr_get_out_samples(
_swrContext, _audioFrame->nb_samples);
if (maximumSamples <= 0)
{
return true;
}
var outputBytes = checked(
maximumSamples * OutputAudioChannels * OutputAudioBytesPerSample);
var buffer = ArrayPool<byte>.Shared.Rent(outputBytes);
try
{
fixed (byte* output = buffer)
{
var outputPlanes = stackalloc byte*[1];
outputPlanes[0] = output;
var convertedSamples = ffmpeg.swr_convert(
_swrContext,
outputPlanes,
maximumSamples,
_audioFrame->extended_data,
_audioFrame->nb_samples);
if (convertedSamples < 0)
{
return false;
}
var convertedBytes = checked(
convertedSamples * OutputAudioChannels * OutputAudioBytesPerSample);
return _audioStream.Submit(buffer.AsSpan(0, convertedBytes));
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
finally
{
if (ownsInputLayout)
{
ffmpeg.av_channel_layout_uninit(&inputLayout);
}
}
}
private bool EnsureAudioResampler(
AVChannelLayout* inputLayout,
AVSampleFormat inputFormat,
int inputSampleRate)
{
var storedInputLayout = _swrInputLayout;
if (_swrContext is not null &&
_swrInputFormat == inputFormat &&
_swrInputSampleRate == inputSampleRate &&
ffmpeg.av_channel_layout_compare(&storedInputLayout, inputLayout) == 0)
{
return true;
}
FreeAudioResampler();
AVChannelLayout copiedInputLayout = default;
if (ffmpeg.av_channel_layout_copy(&copiedInputLayout, inputLayout) < 0)
{
return false;
}
AVChannelLayout outputLayout = default;
ffmpeg.av_channel_layout_default(&outputLayout, OutputAudioChannels);
SwrContext* context = null;
var allocateResult = ffmpeg.swr_alloc_set_opts2(
&context,
&outputLayout,
AVSampleFormat.AV_SAMPLE_FMT_S16,
_audioOutputSampleRate,
&copiedInputLayout,
inputFormat,
inputSampleRate,
0,
null);
ffmpeg.av_channel_layout_uninit(&outputLayout);
if (allocateResult < 0 || context is null || ffmpeg.swr_init(context) < 0)
{
if (context is not null)
{
ffmpeg.swr_free(&context);
}
ffmpeg.av_channel_layout_uninit(&copiedInputLayout);
return false;
}
_swrContext = context;
_swrInputLayout = copiedInputLayout;
_swrInputLayoutValid = true;
_swrInputFormat = inputFormat;
_swrInputSampleRate = inputSampleRate;
return true;
}
private void DisableAudio(string reason)
{
if (_audioFailed)
{
return;
}
_audioFailed = true;
Console.Error.WriteLine($"[LOADER][WARN] Bink audio disabled: {reason}.");
FreeAudioResampler();
_audioStream?.Dispose();
_audioStream = null;
}
private void FreeAudioResampler()
{
if (_swrContext is not null)
{
var context = _swrContext;
ffmpeg.swr_free(&context);
_swrContext = null;
}
if (_swrInputLayoutValid)
{
var inputLayout = _swrInputLayout;
ffmpeg.av_channel_layout_uninit(&inputLayout);
_swrInputLayout = default;
_swrInputLayoutValid = false;
}
_swrInputFormat = AVSampleFormat.AV_SAMPLE_FMT_NONE;
_swrInputSampleRate = 0;
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
@@ -318,38 +682,59 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
return;
}
if (_swsContext is not null)
lock (_decodeGate)
{
ffmpeg.sws_freeContext(_swsContext);
_swsContext = null;
}
FreeAudioResampler();
_audioStream?.Dispose();
_audioStream = null;
if (_packet is not null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_swsContext is not null)
{
ffmpeg.sws_freeContext(_swsContext);
_swsContext = null;
}
if (_frame is not null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_packet is not null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_codecContext is not null)
{
var codecContext = _codecContext;
ffmpeg.avcodec_free_context(&codecContext);
_codecContext = null;
}
if (_frame is not null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_formatContext is not null)
{
var formatContext = _formatContext;
ffmpeg.avformat_close_input(&formatContext);
_formatContext = null;
if (_audioFrame is not null)
{
var frame = _audioFrame;
ffmpeg.av_frame_free(&frame);
_audioFrame = null;
}
if (_codecContext is not null)
{
var codecContext = _codecContext;
ffmpeg.avcodec_free_context(&codecContext);
_codecContext = null;
}
if (_audioCodecContext is not null)
{
var codecContext = _audioCodecContext;
ffmpeg.avcodec_free_context(&codecContext);
_audioCodecContext = null;
}
if (_formatContext is not null)
{
var formatContext = _formatContext;
ffmpeg.avformat_close_input(&formatContext);
_formatContext = null;
}
}
}
}
+609 -15
View File
@@ -2,28 +2,84 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Audio;
using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Threading;
namespace SharpEmu.Libs.Codec;
/// <summary>
/// libSceVideodec / libSceAudiodec handle management. Actual H.264/HEVC and
/// AAC/AT9 decoding requires an external codec, which is out of scope; these
/// exports keep the decoder lifecycle resolvable (create/decode/flush/delete)
/// and report "no output produced" so guests advance instead of failing on
/// unresolved imports.
/// libSceVideodec / libSceAudiodec compatibility exports.
/// </summary>
public static class CodecExports
{
private const int Ok = 0;
private const int VideodecErrorInvalidArg = unchecked((int)0x80620801);
private const int AudiodecErrorInvalidType = unchecked((int)0x807F0001);
private const int AudiodecErrorInvalidArg = unchecked((int)0x807F0002);
private const int AudiodecErrorInvalidParamSize = unchecked((int)0x807F0004);
private const int AudiodecErrorInvalidBsiInfoSize = unchecked((int)0x807F0005);
private const int AudiodecErrorInvalidAuInfoSize = unchecked((int)0x807F0006);
private const int AudiodecErrorInvalidPcmItemSize = unchecked((int)0x807F0007);
private const int AudiodecErrorInvalidCtrlPointer = unchecked((int)0x807F0008);
private const int AudiodecErrorInvalidParamPointer = unchecked((int)0x807F0009);
private const int AudiodecErrorInvalidBsiInfoPointer = unchecked((int)0x807F000A);
private const int AudiodecErrorInvalidAuInfoPointer = unchecked((int)0x807F000B);
private const int AudiodecErrorInvalidPcmItemPointer = unchecked((int)0x807F000C);
private const int AudiodecErrorInvalidAuPointer = unchecked((int)0x807F000D);
private const int AudiodecErrorInvalidPcmPointer = unchecked((int)0x807F000E);
private const int AudiodecErrorInvalidHandle = unchecked((int)0x807F000F);
private const int AudiodecErrorInvalidWordLength = unchecked((int)0x807F0010);
private const int AudiodecErrorInvalidAuSize = unchecked((int)0x807F0011);
private const int AudiodecErrorInvalidPcmSize = unchecked((int)0x807F0012);
private const uint AudiodecTypeAt9 = 1;
private const uint AudiodecTypeMp3 = 2;
private const uint AudiodecTypeAac = 3;
private const int MaxAudioDecoders = 64;
private const int MaxDecodeBufferBytes = 64 * 1024 * 1024;
private static readonly ConcurrentDictionary<ulong, byte> VideoDecoders = new();
private static readonly ConcurrentDictionary<ulong, byte> AudioDecoders = new();
private static readonly ConcurrentDictionary<int, AudioDecoderState> AudioDecoders = new();
private static readonly ConcurrentDictionary<uint, int> AudioCodecInitCounts = new();
private static readonly object AudioDecoderGate = new();
private static long _nextHandle = 1;
private static int _nextAudioHandle;
private sealed class AudioDecoderState
{
public required uint CodecType { get; init; }
public required int WordSize { get; init; }
public required int Channels { get; init; }
public required int SampleRate { get; init; }
public required int FrameBytes { get; init; }
public required int FramesPerSuperframe { get; init; }
public required int FrameSamples { get; init; }
public Atrac9DecodeState? Atrac9 { get; init; }
}
private readonly record struct AudioControl(
ulong ParamAddress,
ulong BsiInfoAddress,
ulong AuInfoAddress,
ulong PcmItemAddress,
ulong AuAddress,
uint AuSize,
ulong PcmAddress,
uint PcmSize,
int WordSize,
byte[]? Atrac9Config,
uint AacMaxChannels,
uint AacSampleRateIndex);
// ---- Video decoder ----
@@ -65,34 +121,572 @@ public static class CodecExports
// ---- Audio decoder ----
[SysAbiExport(Nid = "VjhsmxpcezI", ExportName = "sceAudiodecInitLibrary",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecInitLibrary(CpuContext ctx)
{
var codecType = unchecked((uint)ctx[CpuRegister.Rdi]);
if (!IsValidAudioCodecType(codecType))
{
return SetReturn(ctx, AudiodecErrorInvalidType);
}
AudioCodecInitCounts.AddOrUpdate(codecType, 1, static (_, count) => count + 1);
return SetReturn(ctx, Ok);
}
[SysAbiExport(Nid = "h5jSB2QIDV0", ExportName = "sceAudiodecTermLibrary",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecTermLibrary(CpuContext ctx)
{
var codecType = unchecked((uint)ctx[CpuRegister.Rdi]);
if (!IsValidAudioCodecType(codecType))
{
return SetReturn(ctx, AudiodecErrorInvalidType);
}
AudioCodecInitCounts.AddOrUpdate(codecType, 0, static (_, count) => Math.Max(0, count - 1));
return SetReturn(ctx, Ok);
}
[SysAbiExport(Nid = "O3f1sLMWRvs", ExportName = "sceAudiodecCreateDecoder",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecCreateDecoder(CpuContext ctx)
{
var handle = (ulong)Interlocked.Increment(ref _nextHandle);
AudioDecoders[handle] = 1;
// sceAudiodec returns the handle directly (>= 0) or a negative error.
ctx[CpuRegister.Rax] = handle;
return unchecked((int)handle);
var controlAddress = ctx[CpuRegister.Rdi];
var codecType = unchecked((uint)ctx[CpuRegister.Rsi]);
if (!IsValidAudioCodecType(codecType))
{
return SetReturn(ctx, AudiodecErrorInvalidType);
}
var validation = TryReadAudioControl(ctx, controlAddress, codecType, decode: false, out var control);
if (validation != Ok)
{
return SetReturn(ctx, validation);
}
if (!AudioCodecInitCounts.TryGetValue(codecType, out var initCount) || initCount == 0)
{
return SetReturn(ctx, AudiodecErrorInvalidArg);
}
var decoder = CreateAudioDecoder(codecType, control);
if (decoder is null)
{
return SetReturn(ctx, AudiodecErrorInvalidArg);
}
int handle;
lock (AudioDecoderGate)
{
if (AudioDecoders.Count >= MaxAudioDecoders)
{
return SetReturn(ctx, AudiodecErrorInvalidArg);
}
do
{
_nextAudioHandle = _nextAudioHandle % MaxAudioDecoders + 1;
handle = _nextAudioHandle;
}
while (AudioDecoders.ContainsKey(handle));
AudioDecoders[handle] = decoder;
}
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, Ok);
return SetReturn(ctx, handle);
}
[SysAbiExport(Nid = "KHXHMDLkILw", ExportName = "sceAudiodecDecode",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecDecode(CpuContext ctx)
{
// No decoder present: report success with zero output samples so the
// caller treats the frame as silent rather than erroring.
return SetReturn(ctx, AudioDecoders.ContainsKey(ctx[CpuRegister.Rdi]) ? Ok : AudiodecErrorInvalidArg);
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
if (!AudioDecoders.TryGetValue(handle, out var decoder))
{
return SetReturn(ctx, AudiodecErrorInvalidHandle);
}
var validation = TryReadAudioControl(
ctx,
ctx[CpuRegister.Rsi],
decoder.CodecType,
decode: true,
out var control);
if (validation != Ok)
{
return SetReturn(ctx, validation);
}
if (control.AuSize > MaxDecodeBufferBytes)
{
return SetReturn(ctx, AudiodecErrorInvalidAuSize);
}
if (control.PcmSize > MaxDecodeBufferBytes)
{
return SetReturn(ctx, AudiodecErrorInvalidPcmSize);
}
if (decoder.CodecType == AudiodecTypeAt9 && decoder.Atrac9 is not null)
{
DecodeAtrac9(ctx, decoder, control);
}
else
{
DecodeAudioSilence(ctx, decoder, control);
}
return SetReturn(ctx, Ok);
}
[SysAbiExport(Nid = "Tp+ZEy69mLk", ExportName = "sceAudiodecDeleteDecoder",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecDeleteDecoder(CpuContext ctx)
{
AudioDecoders.TryRemove(ctx[CpuRegister.Rdi], out _);
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
return SetReturn(
ctx,
AudioDecoders.TryRemove(handle, out _)
? Ok
: AudiodecErrorInvalidHandle);
}
[SysAbiExport(Nid = "6Vf9WTLDoss", ExportName = "sceAudiodecClearContext",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecClearContext(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
if (!AudioDecoders.TryGetValue(handle, out var decoder))
{
return SetReturn(ctx, AudiodecErrorInvalidHandle);
}
decoder.Atrac9?.Reset();
return SetReturn(ctx, Ok);
}
private static bool IsValidAudioCodecType(uint codecType) =>
codecType is AudiodecTypeAt9 or AudiodecTypeMp3 or AudiodecTypeAac;
private static int TryReadAudioControl(
CpuContext ctx,
ulong controlAddress,
uint codecType,
bool decode,
out AudioControl control)
{
control = default;
if (controlAddress == 0)
{
return AudiodecErrorInvalidCtrlPointer;
}
Span<byte> controlData = stackalloc byte[32];
if (!ctx.Memory.TryRead(controlAddress, controlData))
{
return AudiodecErrorInvalidCtrlPointer;
}
var paramAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData);
var bsiInfoAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[8..]);
var auInfoAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[16..]);
var pcmItemAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[24..]);
if (paramAddress == 0)
{
return AudiodecErrorInvalidParamPointer;
}
if (bsiInfoAddress == 0)
{
return AudiodecErrorInvalidBsiInfoPointer;
}
if (auInfoAddress == 0)
{
return AudiodecErrorInvalidAuInfoPointer;
}
if (pcmItemAddress == 0)
{
return AudiodecErrorInvalidPcmItemPointer;
}
Span<byte> auInfo = stackalloc byte[24];
if (!ctx.Memory.TryRead(auInfoAddress, auInfo))
{
return AudiodecErrorInvalidAuInfoPointer;
}
if (BinaryPrimitives.ReadUInt32LittleEndian(auInfo) != auInfo.Length)
{
return AudiodecErrorInvalidAuInfoSize;
}
Span<byte> pcmItem = stackalloc byte[24];
if (!ctx.Memory.TryRead(pcmItemAddress, pcmItem))
{
return AudiodecErrorInvalidPcmItemPointer;
}
if (BinaryPrimitives.ReadUInt32LittleEndian(pcmItem) != pcmItem.Length)
{
return AudiodecErrorInvalidPcmItemSize;
}
var auAddress = BinaryPrimitives.ReadUInt64LittleEndian(auInfo[8..]);
var auSize = BinaryPrimitives.ReadUInt32LittleEndian(auInfo[16..]);
var pcmAddress = BinaryPrimitives.ReadUInt64LittleEndian(pcmItem[8..]);
var pcmSize = BinaryPrimitives.ReadUInt32LittleEndian(pcmItem[16..]);
if (decode && auAddress == 0)
{
return AudiodecErrorInvalidAuPointer;
}
if (decode && pcmAddress == 0)
{
return AudiodecErrorInvalidPcmPointer;
}
var parameterResult = TryReadAudioParameters(
ctx,
codecType,
paramAddress,
bsiInfoAddress,
out var wordSize,
out var atrac9Config,
out var aacMaxChannels,
out var aacSampleRateIndex);
if (parameterResult != Ok)
{
return parameterResult;
}
if (decode && auSize == 0)
{
return AudiodecErrorInvalidAuSize;
}
if (decode && pcmSize == 0)
{
return AudiodecErrorInvalidPcmSize;
}
control = new AudioControl(
paramAddress,
bsiInfoAddress,
auInfoAddress,
pcmItemAddress,
auAddress,
auSize,
pcmAddress,
pcmSize,
wordSize,
atrac9Config,
aacMaxChannels,
aacSampleRateIndex);
return Ok;
}
private static int TryReadAudioParameters(
CpuContext ctx,
uint codecType,
ulong paramAddress,
ulong bsiInfoAddress,
out int wordSize,
out byte[]? atrac9Config,
out uint aacMaxChannels,
out uint aacSampleRateIndex)
{
wordSize = 0;
atrac9Config = null;
aacMaxChannels = 0;
aacSampleRateIndex = 0;
var paramSize = codecType switch
{
AudiodecTypeAt9 => 12,
AudiodecTypeMp3 => 8,
AudiodecTypeAac => 24,
_ => 0,
};
var bsiSize = codecType switch
{
AudiodecTypeAt9 => 36,
AudiodecTypeMp3 => 24,
AudiodecTypeAac => 20,
_ => 0,
};
if (paramSize == 0 || bsiSize == 0)
{
return AudiodecErrorInvalidType;
}
Span<byte> param = stackalloc byte[paramSize];
if (!ctx.Memory.TryRead(paramAddress, param))
{
return AudiodecErrorInvalidParamPointer;
}
var suppliedParamSize = BinaryPrimitives.ReadUInt32LittleEndian(param);
if (codecType == AudiodecTypeAac
? suppliedParamSize < paramSize
: suppliedParamSize != paramSize)
{
return AudiodecErrorInvalidParamSize;
}
Span<byte> bsi = stackalloc byte[bsiSize];
if (!ctx.Memory.TryRead(bsiInfoAddress, bsi))
{
return AudiodecErrorInvalidBsiInfoPointer;
}
if (BinaryPrimitives.ReadUInt32LittleEndian(bsi) != bsiSize)
{
return AudiodecErrorInvalidBsiInfoSize;
}
wordSize = BinaryPrimitives.ReadInt32LittleEndian(param[4..]);
if (wordSize is < 0 or > 2)
{
return AudiodecErrorInvalidWordLength;
}
if (codecType == AudiodecTypeAt9)
{
atrac9Config = param[8..12].ToArray();
}
else if (codecType == AudiodecTypeAac)
{
aacSampleRateIndex = BinaryPrimitives.ReadUInt32LittleEndian(param[12..]);
aacMaxChannels = BinaryPrimitives.ReadUInt32LittleEndian(param[16..]);
}
return Ok;
}
private static AudioDecoderState? CreateAudioDecoder(uint codecType, AudioControl control)
{
if (codecType == AudiodecTypeAt9)
{
var atrac9 = new Atrac9DecodeState();
if (control.Atrac9Config is null || !atrac9.TryInitialize(control.Atrac9Config))
{
return null;
}
var config = atrac9.Config!;
return new AudioDecoderState
{
CodecType = codecType,
WordSize = control.WordSize,
Channels = config.ChannelCount,
SampleRate = config.SampleRate,
FrameBytes = config.SuperframeBytes,
FramesPerSuperframe = config.FramesPerSuperframe,
FrameSamples = config.FrameSamples,
Atrac9 = atrac9,
};
}
if (codecType == AudiodecTypeMp3)
{
return new AudioDecoderState
{
CodecType = codecType,
WordSize = control.WordSize,
Channels = 2,
SampleRate = 48_000,
FrameBytes = 1_441,
FramesPerSuperframe = 1,
FrameSamples = 1_152,
};
}
var channels = control.AacMaxChannels == 0
? 2
: unchecked((int)Math.Min(control.AacMaxChannels, 8));
return new AudioDecoderState
{
CodecType = codecType,
WordSize = control.WordSize,
Channels = channels,
SampleRate = GetAacSampleRate(control.AacSampleRateIndex),
FrameBytes = 4_608,
FramesPerSuperframe = 1,
FrameSamples = 2_048,
};
}
private static void DecodeAtrac9(CpuContext ctx, AudioDecoderState decoder, AudioControl control)
{
var input = ArrayPool<byte>.Shared.Rent(checked((int)control.AuSize));
var output = ArrayPool<byte>.Shared.Rent(checked((int)control.PcmSize));
try
{
if (!ctx.Memory.TryRead(control.AuAddress, input.AsSpan(0, checked((int)control.AuSize))))
{
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, AudiodecErrorInvalidAuPointer);
WriteAudioBufferSizes(ctx, control, 0, 0);
return;
}
var result = decoder.Atrac9!.Decode(
input.AsSpan(0, checked((int)control.AuSize)),
output.AsSpan(0, checked((int)control.PcmSize)),
GetAtrac9Encoding(decoder.WordSize),
decoder.Channels,
multipleFrames: false);
var outputWritten = result.OutputWritten;
if (outputWritten != 0 &&
!ctx.Memory.TryWrite(control.PcmAddress, output.AsSpan(0, outputWritten)))
{
outputWritten = 0;
}
WriteAudioBufferSizes(
ctx,
control,
unchecked((uint)result.InputConsumed),
unchecked((uint)outputWritten));
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, result.Status);
}
finally
{
ArrayPool<byte>.Shared.Return(input);
ArrayPool<byte>.Shared.Return(output);
}
}
private static void DecodeAudioSilence(CpuContext ctx, AudioDecoderState decoder, AudioControl control)
{
var wantedPcm = checked(
decoder.FrameSamples *
decoder.Channels *
GetAudioBytesPerSample(decoder.WordSize));
var outputSize = Math.Min(control.PcmSize, unchecked((uint)wantedPcm));
ClearGuestMemory(ctx, control.PcmAddress, outputSize);
WriteAudioBufferSizes(
ctx,
control,
Math.Min(control.AuSize, unchecked((uint)decoder.FrameBytes)),
outputSize);
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, Ok);
}
private static void WriteAudioBufferSizes(
CpuContext ctx,
AudioControl control,
uint inputConsumed,
uint outputWritten)
{
Span<byte> value = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(value, inputConsumed);
_ = ctx.Memory.TryWrite(control.AuInfoAddress + 16, value);
BinaryPrimitives.WriteUInt32LittleEndian(value, outputWritten);
_ = ctx.Memory.TryWrite(control.PcmItemAddress + 16, value);
}
private static void WriteAudioDecoderInfo(
CpuContext ctx,
ulong infoAddress,
AudioDecoderState decoder,
int result)
{
if (decoder.CodecType == AudiodecTypeAt9)
{
Span<byte> info = stackalloc byte[36];
info.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(info, unchecked((uint)info.Length));
BinaryPrimitives.WriteUInt32LittleEndian(info[4..], unchecked((uint)decoder.Channels));
var superframeSamples = checked(decoder.FrameSamples * decoder.FramesPerSuperframe);
var bitrate = superframeSamples == 0
? 0u
: unchecked((uint)((ulong)decoder.FrameBytes * 8 * (uint)decoder.SampleRate /
(uint)superframeSamples));
BinaryPrimitives.WriteUInt32LittleEndian(info[8..], bitrate);
BinaryPrimitives.WriteUInt32LittleEndian(info[12..], unchecked((uint)decoder.SampleRate));
BinaryPrimitives.WriteUInt32LittleEndian(info[16..], unchecked((uint)decoder.FrameBytes));
BinaryPrimitives.WriteUInt32LittleEndian(info[20..], unchecked((uint)decoder.FramesPerSuperframe));
BinaryPrimitives.WriteUInt32LittleEndian(
info[24..],
unchecked((uint)(decoder.FrameBytes / Math.Max(decoder.FramesPerSuperframe, 1))));
BinaryPrimitives.WriteUInt32LittleEndian(info[28..], unchecked((uint)decoder.FrameSamples));
BinaryPrimitives.WriteInt32LittleEndian(info[32..], result);
_ = ctx.Memory.TryWrite(infoAddress, info);
return;
}
if (decoder.CodecType == AudiodecTypeMp3)
{
Span<byte> info = stackalloc byte[24];
info.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(info, unchecked((uint)info.Length));
BinaryPrimitives.WriteInt32LittleEndian(info[20..], result);
_ = ctx.Memory.TryWrite(infoAddress, info);
return;
}
Span<byte> aacInfo = stackalloc byte[20];
aacInfo.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(aacInfo, unchecked((uint)aacInfo.Length));
BinaryPrimitives.WriteUInt32LittleEndian(aacInfo[4..], unchecked((uint)decoder.SampleRate));
BinaryPrimitives.WriteUInt32LittleEndian(aacInfo[8..], unchecked((uint)decoder.Channels));
BinaryPrimitives.WriteInt32LittleEndian(aacInfo[16..], result);
_ = ctx.Memory.TryWrite(infoAddress, aacInfo);
}
private static Atrac9PcmEncoding GetAtrac9Encoding(int wordSize) =>
wordSize switch
{
0 => Atrac9PcmEncoding.Signed32,
1 => Atrac9PcmEncoding.Signed16,
2 => Atrac9PcmEncoding.Float,
_ => throw new ArgumentOutOfRangeException(nameof(wordSize)),
};
private static int GetAudioBytesPerSample(int wordSize) =>
wordSize == 1 ? sizeof(short) : sizeof(int);
private static int GetAacSampleRate(uint index)
{
ReadOnlySpan<int> rates =
[
96_000, 88_200, 64_000, 48_000, 44_100, 32_000,
24_000, 22_050, 16_000, 12_000, 11_025, 8_000,
];
return index < rates.Length ? rates[unchecked((int)index)] : 48_000;
}
private static void ClearGuestMemory(CpuContext ctx, ulong address, uint byteCount)
{
Span<byte> zero = stackalloc byte[256];
var cursor = address;
var remaining = byteCount;
while (remaining != 0)
{
var length = unchecked((int)Math.Min(remaining, (uint)zero.Length));
if (!ctx.Memory.TryWrite(cursor, zero[..length]))
{
return;
}
cursor += unchecked((uint)length);
remaining -= unchecked((uint)length);
}
}
internal static void ResetAudioDecodersForTests()
{
AudioDecoders.Clear();
AudioCodecInitCounts.Clear();
Interlocked.Exchange(ref _nextAudioHandle, 0);
}
private static bool TryWriteHandle(CpuContext ctx, ulong address, ulong handle) =>
ctx.TryWriteUInt64(address, handle);
+15 -1
View File
@@ -237,7 +237,21 @@ internal interface IGuestGpuBackend
void SubmitGuestImageFill(ulong address, uint fillValue);
void SubmitGuestImageWrite(ulong address, byte[] pixels);
/// <summary>
/// Uploads guest-authored pixels into a live guest image. <paramref name="rowOffset"/>
/// is the first image row the buffer covers, so a caller that knows only part
/// of the surface changed can send that band instead of the whole thing; the
/// untouched rows on the host already hold the same bytes.
/// </summary>
void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0);
/// <summary>
/// Whether a non-zero <c>rowOffset</c> is honoured. Backends that cannot
/// upload a sub-range must report false so callers keep sending the whole
/// surface: a dropped band would leave the host copy stale, which is worse
/// than an oversized upload.
/// </summary>
bool SupportsPartialImageWrite => false;
/// <summary>
/// Asks the presenter to refresh CPU-dirty guest images on its render/present
@@ -398,7 +398,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
MetalVideoPresenter.SubmitGuestImageFill(address, fillValue);
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
@@ -1,182 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Posix;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// Keyboard state sampled from the Metal presenter's window, feeding the POSIX
/// host input seam so pad emulation works like the Vulkan presenter's
/// HostWindowInput. Key events arrive on the AppKit main thread as macOS
/// virtual key codes; pad reads happen on guest threads, so state is guarded.
/// Window gamepads are not surfaced by AppKit — controller support would go
/// through GameController.framework and is out of scope here.
/// </summary>
internal static class MetalHostInput
{
private static readonly object Gate = new();
private static readonly HashSet<ushort> Pressed = new();
private static volatile bool _connected;
/// <summary>Registers this window's keyboard as the host input source.</summary>
public static void Attach()
{
_connected = true;
PosixHostInput.SetSource(new MetalWindowInputSource());
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
}
// Debug automation: SHARPEMU_METAL_AUTOKEY="12:0x24,15:0x24" presses the
// macOS key code at each elapsed-seconds mark for a few frames, letting
// headless test runs navigate menus without a human at the keyboard.
private static readonly List<(double At, ushort Key, bool[] State)> _autoKeys = ParseAutoKeys();
private static readonly System.Diagnostics.Stopwatch _autoKeyClock =
System.Diagnostics.Stopwatch.StartNew();
private static List<(double, ushort, bool[])> ParseAutoKeys()
{
var keys = new List<(double, ushort, bool[])>();
var spec = Environment.GetEnvironmentVariable("SHARPEMU_METAL_AUTOKEY");
if (string.IsNullOrWhiteSpace(spec))
{
return keys;
}
foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var parts = entry.Split(':');
if (parts.Length == 2 &&
double.TryParse(parts[0], out var at) &&
TryParseKeyCode(parts[1], out var key))
{
keys.Add((at, key, new bool[2]));
}
}
return keys;
}
private static bool TryParseKeyCode(string text, out ushort key)
{
return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? ushort.TryParse(text[2..], System.Globalization.NumberStyles.HexNumber, null, out key)
: ushort.TryParse(text, out key);
}
/// <summary>Called once per render frame; fires and releases scripted keys.</summary>
public static void PumpAutoKeys()
{
if (_autoKeys.Count == 0)
{
return;
}
var elapsed = _autoKeyClock.Elapsed.TotalSeconds;
foreach (var (at, key, state) in _autoKeys)
{
if (!state[0] && elapsed >= at)
{
state[0] = true;
KeyDown(key, isRepeat: false);
Console.Error.WriteLine($"[LOADER][INFO] Metal autokey press 0x{key:X} at {elapsed:F1}s");
}
else if (state[0] && !state[1] && elapsed >= at + 0.2)
{
state[1] = true;
KeyUp(key);
}
}
}
public static void KeyDown(ushort keyCode, bool isRepeat)
{
// kVK_F1: parity with the Vulkan window's perf-overlay toggle.
if (keyCode == 0x7A && !isRepeat)
{
VideoOut.PerfOverlay.Toggle();
}
lock (Gate)
{
Pressed.Add(keyCode);
}
}
public static void KeyUp(ushort keyCode)
{
lock (Gate)
{
Pressed.Remove(keyCode);
}
}
private static bool IsKeyCodeDown(ushort keyCode)
{
lock (Gate)
{
return Pressed.Contains(keyCode);
}
}
private sealed class MetalWindowInputSource : IPosixWindowInputSource
{
public bool HasKeyboardFocus => _connected;
public bool IsKeyDown(int virtualKey) =>
TryMapVirtualKey(virtualKey, out var keyCode) && IsKeyCodeDown(keyCode);
public int GetGamepadStates(Span<HostGamepadState> destination) => 0;
public string? DescribeConnectedGamepad() => null;
}
/// <summary>Windows virtual-key semantics (the seam's contract) to macOS
/// kVK virtual key codes, covering the keys pad emulation polls.</summary>
private static bool TryMapVirtualKey(int vk, out ushort keyCode)
{
keyCode = vk switch
{
0x08 => 0x33, // Backspace -> kVK_Delete
0x09 => 0x30, // Tab
0x0D => 0x24, // Enter -> kVK_Return
0x1B => 0x35, // Escape
0x20 => 0x31, // Space
0x25 => 0x7B, // Left
0x26 => 0x7E, // Up
0x27 => 0x7C, // Right
0x28 => 0x7D, // Down
// Letters: macOS ANSI key codes are layout-position based and
// non-contiguous, so map each polled letter explicitly.
0x41 => 0x00, // A
0x42 => 0x0B, // B
0x43 => 0x08, // C
0x44 => 0x02, // D
0x45 => 0x0E, // E
0x46 => 0x03, // F
0x47 => 0x05, // G
0x48 => 0x04, // H
0x49 => 0x22, // I
0x4A => 0x26, // J
0x4B => 0x28, // K
0x4C => 0x25, // L
0x4D => 0x2E, // M
0x4E => 0x2D, // N
0x4F => 0x1F, // O
0x50 => 0x23, // P
0x51 => 0x0C, // Q
0x52 => 0x0F, // R
0x53 => 0x01, // S
0x54 => 0x11, // T
0x55 => 0x20, // U
0x56 => 0x09, // V
0x57 => 0x0D, // W
0x58 => 0x07, // X
0x59 => 0x10, // Y
0x5A => 0x06, // Z
_ => ushort.MaxValue,
};
return keyCode != ushort.MaxValue;
}
}
+2 -89
View File
@@ -5,19 +5,6 @@ using System.Runtime.InteropServices;
namespace SharpEmu.Libs.Gpu.Metal;
// Core Graphics / Metal ABI structs passed by value through objc_msgSend. Struct
// *returns* are deliberately never used: on x86-64 (this process runs under Rosetta
// on Apple silicon) large struct returns switch to objc_msgSend_stret, and avoiding
// them entirely keeps one calling convention everywhere.
[StructLayout(LayoutKind.Sequential)]
internal struct CGRect
{
public double X;
public double Y;
public double Width;
public double Height;
}
[StructLayout(LayoutKind.Sequential)]
internal struct CGSize
{
@@ -93,31 +80,21 @@ internal struct MtlViewport
}
/// <summary>
/// Objective-C runtime access for the Metal presenter: AppKit, QuartzCore, and Metal
/// Objective-C runtime access for the Metal presenter: QuartzCore and Metal
/// through objc_msgSend, with one LibraryImport overload per distinct native
/// signature. Dependency-free by design — this plus the OS frameworks is the entire
/// Metal path, which is what keeps it NativeAOT-clean.
/// </summary>
internal static partial class MetalNative
{
private const string CoreFoundation =
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
[LibraryImport(CoreFoundation)]
public static partial nint CFRunLoopGetMain();
[LibraryImport(CoreFoundation)]
public static partial void CFRunLoopStop(nint runLoop);
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
private const string AppKitFramework = "/System/Library/Frameworks/AppKit.framework/AppKit";
private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
private static bool _frameworksLoaded;
/// <summary>
/// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is
/// Makes QuartzCore classes visible to objc_getClass; Metal is
/// pulled in by its own LibraryImport. Call once before any Class() lookup.
/// </summary>
public static void EnsureFrameworksLoaded()
@@ -127,7 +104,6 @@ internal static partial class MetalNative
return;
}
NativeLibrary.Load(AppKitFramework);
NativeLibrary.Load(QuartzCoreFramework);
_frameworksLoaded = true;
}
@@ -141,16 +117,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint sel_registerName(string name);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
public static partial nint objc_allocateClassPair(nint superclass, string name, nuint extraBytes);
[LibraryImport(ObjCLibrary)]
public static partial void objc_registerClassPair(nint cls);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
[return: MarshalAs(UnmanagedType.I1)]
public static partial bool class_addMethod(nint cls, nint name, nint imp, string types);
[LibraryImport(ObjCLibrary)]
public static partial nint objc_autoreleasePoolPush();
@@ -169,14 +135,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument);
/// <summary>objc_msgSend for a CGRect-returning selector (e.g. -bounds).
/// A 32-byte struct is returned via the x86-64 stret ABI — a hidden
/// pointer to caller storage passed ahead of self/_cmd — so this must not
/// be folded into the plain objc_msgSend overloads.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")]
public static partial void SendStretRect(out CGRect result, nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
@@ -209,17 +167,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
/// <summary>performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
/// to perform is itself an argument, followed by the object and the wait
/// flag.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidPerformSelector(
nint receiver,
nint selector,
nint performedSelector,
nint argument,
[MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
/// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
@@ -237,9 +184,6 @@ internal static partial class MetalNative
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidRect(nint receiver, nint selector, CGRect rect);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
@@ -328,37 +272,6 @@ internal static partial class MetalNative
nuint indexBufferOffset,
nuint instanceCount);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTimer(
nint receiver,
nint selector,
double interval,
nint target,
nint timerSelector,
nint userInfo,
[MarshalAs(UnmanagedType.I1)] bool repeats);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendInitFrame(nint receiver, nint selector, CGRect frame);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendInitWindow(
nint receiver,
nint selector,
CGRect contentRect,
nuint styleMask,
nuint backing,
[MarshalAs(UnmanagedType.I1)] bool defer);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendNextEvent(
nint receiver,
nint selector,
ulong eventMask,
nint untilDate,
nint inMode,
[MarshalAs(UnmanagedType.I1)] bool dequeue);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTextureDescriptor(
nint receiver,
@@ -1,8 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut;
using SharpEmu.ShaderCompiler;
@@ -11,28 +9,12 @@ using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// The Metal presenter: an AppKit window hosting a CAMetalLayer. A CADisplayLink
/// requested from the content view drives <see cref="RenderFrame"/> in sync with the
/// display refresh, on the main run loop — the loop whose Core Animation observer
/// commits presented drawables to the window server, so it must be a real running
/// run loop (a hand-pumped event drain never fires that observer and the window
/// stays black). Everything AppKit runs on the process main thread via
/// <see cref="HostMainThread"/> (AppKit traps off-main), which the CLI parks for us.
/// The Metal presenter uses SDL for its host window, events and input, then renders
/// into the CAMetalLayer owned by SDL's Metal view. Windowing and input therefore
/// share the Vulkan path while all Metal command encoding remains native.
/// </summary>
internal static partial class MetalVideoPresenter
{
private const uint DefaultWindowWidth = 1280;
private const uint DefaultWindowHeight = 720;
// NSWindow style: Titled | Closable | Miniaturizable | Resizable. Resizable
// both lets the user drag the window edges and turns the green zoom button
// into the full-screen toggle (paired with the collection behavior below).
private const nuint WindowStyleMask = 1 | 2 | 4 | 8;
// NSWindowCollectionBehaviorFullScreenPrimary: opt this window into native
// full-screen, so the green button enters full-screen rather than zooming.
private const nuint FullScreenPrimaryBehavior = 1 << 7;
private const nuint BackingStoreBuffered = 2;
private const nuint PixelFormatBgra8Unorm = (nuint)MtlPixelFormat.Bgra8Unorm;
private const nuint LoadActionLoad = 1;
private const nuint LoadActionClear = 2;
@@ -69,17 +51,14 @@ internal static partial class MetalVideoPresenter
private static readonly byte[] _overlayPixels =
new byte[PerfOverlay.PanelWidth * PerfOverlay.PanelHeight * 4];
// Presenter objects and per-frame present state, shared between window setup
// and the display-link RenderFrame callback (both on the main thread).
// Presenter objects and per-frame present state, all used on the SDL host thread.
private static nint _device;
private static nint _commandQueue;
private static nint _metalLayer;
private static nint _presentPipeline;
private static nint _presentSampler;
private static nint _window;
private static nint _application;
private static nint _renderTimer;
private static nint _renderTimerTarget;
private static SdlHostWindow? _hostWindow;
private static HostVideoOptions _videoOptions = HostVideoOptions.Default;
private static double _drawableWidth;
private static double _drawableHeight;
private static nint _frameTexture;
@@ -91,10 +70,23 @@ internal static partial class MetalVideoPresenter
private static nint _ownedVersionTexture;
private static ulong _presentGuestAddress;
private static long _presentedSequence = -1;
private static bool _userClosed;
private static uint _windowWidth;
private static uint _windowHeight;
public static bool TryConfigureVideo(HostVideoOptions options)
{
lock (_gate)
{
if (_thread is not null)
{
return false;
}
_videoOptions = options.Normalize();
return true;
}
}
public static void EnsureStarted(uint width, uint height)
{
if (width == 0 || height == 0)
@@ -183,6 +175,7 @@ internal static partial class MetalVideoPresenter
public static void RequestClose()
{
Volatile.Write(ref _closeRequested, true);
Volatile.Read(ref _hostWindow)?.Close();
}
private static void StartPresenterLocked()
@@ -247,33 +240,23 @@ internal static partial class MetalVideoPresenter
VideoOutExports.SetSelectedGpuName(deviceName);
}
// Fixed window like the Vulkan presenter: guest frames letterbox into
// it. Sizing the window from the guest's display mode (4K) exceeds the
// screen — macOS clamps the window while the layer keeps the requested
// geometry, leaving the visible region showing nothing but clear.
const uint width = DefaultWindowWidth;
const uint height = DefaultWindowHeight;
HostVideoOptions videoOptions;
lock (_gate)
{
videoOptions = _videoOptions;
}
using var hostWindow = new SdlHostWindow(
VideoOutExports.GetWindowTitle(),
videoOptions,
SdlGraphicsApi.Metal,
ToggleMetalPerformanceHud);
Volatile.Write(ref _hostWindow, hostWindow);
var setupPool = MetalNative.objc_autoreleasePoolPush();
try
{
_application = MetalNative.Send(
MetalNative.Class("NSApplication"), MetalNative.Selector("sharedApplication"));
// NSApplicationActivationPolicyRegular: dock icon + key window like any app.
MetalNative.Send(_application, MetalNative.Selector("setActivationPolicy:"), 0);
MetalNative.SendVoid(_application, MetalNative.Selector("finishLaunching"));
_window = CreateWindow(width, height);
// Swap in the key-capturing view before the metal layer attaches so
// the layer lands on the input-aware content view.
var keyView = MetalNative.SendInitFrame(
MetalNative.Send(CreateKeyViewClass(), MetalNative.Selector("alloc")),
MetalNative.Selector("initWithFrame:"),
new CGRect { X = 0, Y = 0, Width = width, Height = height });
MetalNative.SendVoid(_window, MetalNative.Selector("setContentView:"), keyView);
_metalLayer = CreateLayer(_device, _window, out _drawableWidth, out _drawableHeight);
_metalLayer = hostWindow.CreateMetalLayer();
ConfigureMetalLayer(_device, _metalLayer);
_commandQueue = MetalNative.Send(_device, MetalNative.Selector("newCommandQueue"));
if (!TryCreatePresentPipeline(_device, out _presentPipeline, out var pipelineError))
{
@@ -282,31 +265,7 @@ internal static partial class MetalVideoPresenter
}
_presentSampler = CreateLinearSampler(_device);
MetalNative.SendVoid(_window, MetalNative.Selector("makeKeyAndOrderFront:"), 0);
MetalNative.SendVoidBool(
_application, MetalNative.Selector("activateIgnoringOtherApps:"), true);
MetalNative.SendVoid(_window, MetalNative.Selector("makeFirstResponder:"), keyView);
MetalHostInput.Attach();
// A repeating NSTimer on this (main) run loop fires onFrame: at the
// display rate. CADisplayLink (NSView.displayLinkWithTarget:selector:)
// is the natural choice but its callback never fires under the x86-64
// Rosetta process this emulator runs as — proven in isolation against
// a bare AppKit harness, where a timer fires and composites and the
// display link does not. nextDrawable still throttles presentation to
// the display, so the timer only needs to keep up, not pace precisely.
_renderTimerTarget = CreateRenderTimerTarget();
_renderTimer = MetalNative.Send(
MetalNative.SendTimer(
MetalNative.Class("NSTimer"),
MetalNative.Selector("scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:"),
1.0 / 60.0,
_renderTimerTarget,
MetalNative.Selector("onFrame:"),
0,
repeats: true),
MetalNative.Selector("retain"));
SyncDrawableSizeToLayer();
}
finally
{
@@ -314,25 +273,20 @@ internal static partial class MetalVideoPresenter
}
Console.Error.WriteLine("[LOADER][INFO] Metal VideoOut presenter started.");
// [NSApp run] runs the main run loop (its Core Animation observer commits
// presented drawables to the window server) AND fully activates the app,
// which a bare CFRunLoopRun does not — the CADisplayLink is only serviced
// once the app is running, and NSApp dispatches window events itself.
// Returns once RenderFrame stops it.
MetalNative.SendVoid(_application, MetalNative.Selector("run"));
var closePool = MetalNative.objc_autoreleasePoolPush();
try
{
MetalNative.SendVoid(_window, MetalNative.Selector("close"));
hostWindow.Run(
static () => { },
static _ => RenderFrameSafely(),
static () => { });
}
finally
{
MetalNative.objc_autoreleasePoolPop(closePool);
Volatile.Write(ref _hostWindow, null);
_metalLayer = 0;
}
if (_userClosed)
if (hostWindow.ClosedByUser)
{
Console.Error.WriteLine(
"[LOADER][WARN] Metal VideoOut window closed; requesting emulator shutdown.");
@@ -340,115 +294,8 @@ internal static partial class MetalVideoPresenter
}
}
/// <summary>
/// An NSView subclass that records key events for pad emulation. Overriding
/// keyDown:/keyUp: (instead of an event monitor) needs no ObjC blocks, and
/// swallowing the events also silences the system alert beep AppKit plays
/// for unhandled keys. Registered once per process.
/// </summary>
private static unsafe nint CreateKeyViewClass()
{
var cls = MetalNative.objc_allocateClassPair(
MetalNative.Class("NSView"), "SharpEmuMetalView", 0);
if (cls == 0)
{
return MetalNative.Class("SharpEmuMetalView");
}
var keyDown = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, void>)&OnKeyDown;
MetalNative.class_addMethod(cls, MetalNative.Selector("keyDown:"), keyDown, "v@:@");
var keyUp = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, void>)&OnKeyUp;
MetalNative.class_addMethod(cls, MetalNative.Selector("keyUp:"), keyUp, "v@:@");
// Command-modified keys never reach keyDown: — AppKit routes them through
// performKeyEquivalent:, so Cmd+F1 (Metal Performance HUD) hooks in here.
var keyEquivalent = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, byte>)&OnPerformKeyEquivalent;
MetalNative.class_addMethod(
cls, MetalNative.Selector("performKeyEquivalent:"), keyEquivalent, "c@:@");
// First responder status is what routes key events to this view.
var accepts = (nint)(delegate* unmanaged[Cdecl]<nint, nint, byte>)&AcceptsFirstResponder;
MetalNative.class_addMethod(
cls, MetalNative.Selector("acceptsFirstResponder"), accepts, "c@:");
MetalNative.objc_registerClassPair(cls);
return cls;
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void OnKeyDown(nint self, nint cmd, nint nsEvent)
{
try
{
var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
var isRepeat = MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat"));
// Function keys can arrive here even with Command held (AppKit only
// reroutes some chords through the key-equivalent path), so catch
// Cmd+F1 in both places — and keep it away from MetalHostInput so it
// never toggles the plain-F1 perf overlay.
var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags"));
if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0)
{
if (!isRepeat)
{
ToggleMetalPerformanceHud();
}
return;
}
MetalHostInput.KeyDown(keyCode, isRepeat);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Metal key-down handler failed: {exception.Message}");
}
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void OnKeyUp(nint self, nint cmd, nint nsEvent)
{
try
{
var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
MetalHostInput.KeyUp(keyCode);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Metal key-up handler failed: {exception.Message}");
}
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static byte AcceptsFirstResponder(nint self, nint cmd) => 1;
private const ushort KeyCodeF1 = 0x7A;
private const ulong NsEventModifierFlagCommand = 1UL << 20;
private static bool _metalHudVisible;
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static byte OnPerformKeyEquivalent(nint self, nint cmd, nint nsEvent)
{
try
{
var keyCode = (ushort)(MetalNative.Send(nsEvent, MetalNative.Selector("keyCode")) & 0xFFFF);
var modifiers = (ulong)MetalNative.Send(nsEvent, MetalNative.Selector("modifierFlags"));
if (keyCode == KeyCodeF1 && (modifiers & NsEventModifierFlagCommand) != 0)
{
if (!MetalNative.SendBool(nsEvent, MetalNative.Selector("isARepeat")))
{
ToggleMetalPerformanceHud();
}
return 1; // handled: no system beep, no further routing
}
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Metal key-equivalent handler failed: {exception.Message}");
}
return 0;
}
/// <summary>
/// Cmd+F1: Apple's Metal Performance HUD on the CAMetalLayer (plain F1 keeps
/// the built-in CPU-rasterized perf overlay). Configured per Apple's
@@ -456,7 +303,7 @@ internal static partial class MetalVideoPresenter
/// mode=default|disabled and logging=default, plus any MTL_HUD_* environment
/// keys directly in the dictionary — all three HUD flags (enabled, per-frame
/// logging, shader-compile logging) ride in one property set. Runs on the
/// AppKit main thread (the key-equivalent path), same thread as the render loop.
/// SDL host thread, the same thread as the render loop.
/// </summary>
private static void ToggleMetalPerformanceHud()
{
@@ -508,33 +355,13 @@ internal static partial class MetalVideoPresenter
$"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1).");
}
private static unsafe nint CreateRenderTimerTarget()
/// <summary>The SDL host loop drains guest work continuously. The method
/// remains as the enqueue-side hook used by guest image synchronization.</summary>
internal static void ScheduleGuestWorkDrain()
{
// A minimal NSObject subclass whose onFrame: is our unmanaged callback —
// the dependency-free way to hand a target/selector to NSTimer without a
// binding library. Registered once per process.
var cls = MetalNative.objc_allocateClassPair(
MetalNative.Class("NSObject"), "SharpEmuRenderTimerTarget", 0);
if (cls != 0)
{
var imp = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, void>)&OnRenderTimer;
// "v@:@": void return, self, _cmd, one object argument (the timer).
MetalNative.class_addMethod(cls, MetalNative.Selector("onFrame:"), imp, "v@:@");
var wakeImp = (nint)(delegate* unmanaged[Cdecl]<nint, nint, nint, void>)&OnGuestWorkWake;
MetalNative.class_addMethod(cls, MetalNative.Selector("onGuestWork:"), wakeImp, "v@:@");
MetalNative.objc_registerClassPair(cls);
}
else
{
cls = MetalNative.Class("SharpEmuRenderTimerTarget");
}
return MetalNative.Send(
MetalNative.Send(cls, MetalNative.Selector("alloc")), MetalNative.Selector("init"));
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void OnRenderTimer(nint self, nint cmd, nint timer)
private static void RenderFrameSafely()
{
try
{
@@ -546,78 +373,13 @@ internal static partial class MetalVideoPresenter
}
}
/// <summary>Set while an onGuestWork: wake is scheduled on the main run
/// loop; coalesces enqueue-side wake requests to one in-flight message.</summary>
private static int _guestWorkWakeScheduled;
/// <summary>Wakes the main run loop to drain guest work now instead of at
/// the next render tick. Guest submit→wait round-trips (release-mem labels,
/// CPU-visible write-backs) otherwise cost a full frame interval each —
/// games that chain several per frame crawl at a fraction of the display
/// rate. Safe from any thread; no-op until the presenter starts.</summary>
internal static void ScheduleGuestWorkDrain()
{
if (Interlocked.CompareExchange(ref _guestWorkWakeScheduled, 1, 0) != 0)
{
return;
}
var target = _renderTimerTarget;
if (target == 0)
{
Volatile.Write(ref _guestWorkWakeScheduled, 0);
return;
}
MetalNative.SendVoidPerformSelector(
target,
MetalNative.Selector("performSelectorOnMainThread:withObject:waitUntilDone:"),
MetalNative.Selector("onGuestWork:"),
0,
waitUntilDone: false);
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void OnGuestWorkWake(nint self, nint cmd, nint argument)
{
Volatile.Write(ref _guestWorkWakeScheduled, 0);
if (_device == 0 || _commandQueue == 0 || Volatile.Read(ref _closeRequested))
{
return;
}
var pool = MetalNative.objc_autoreleasePoolPush();
try
{
DrainGuestWork(_device, _commandQueue);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][ERROR] Metal guest work wake failed: {exception}");
}
finally
{
MetalNative.objc_autoreleasePoolPop(pool);
}
}
private static void RenderFrame()
{
MetalHostInput.PumpAutoKeys();
var pool = MetalNative.objc_autoreleasePoolPush();
try
{
// NSApp.run dispatches window events itself, so there is no manual
// event drain here.
var visible = MetalNative.SendBool(_window, MetalNative.Selector("isVisible"));
if (Volatile.Read(ref _closeRequested) || !visible)
if (Volatile.Read(ref _closeRequested))
{
_userClosed = !visible && !Volatile.Read(ref _closeRequested);
MetalNative.SendVoid(_renderTimer, MetalNative.Selector("invalidate"));
// Stop both the AppKit loop and the underlying CFRunLoop so
// [NSApp run] returns.
MetalNative.SendVoid(_application, MetalNative.Selector("stop:"), 0);
MetalNative.CFRunLoopStop(MetalNative.CFRunLoopGetMain());
return;
}
@@ -715,8 +477,7 @@ internal static partial class MetalVideoPresenter
if (!string.Equals(title, _lastWindowTitle, StringComparison.Ordinal))
{
_lastWindowTitle = title;
MetalNative.SendVoid(
_window, MetalNative.Selector("setTitle:"), MetalNative.NsString(title));
Volatile.Read(ref _hostWindow)?.SetTitle(title);
}
}
@@ -725,8 +486,20 @@ internal static partial class MetalVideoPresenter
// for a drawable, or nextDrawable keeps handing back the original
// resolution and Core Animation stretches it (blurry, mis-scaled
// overlay). No-op when the size is unchanged, i.e. almost every tick.
var hostWindow = Volatile.Read(ref _hostWindow);
if (hostWindow?.ConsumeSurfaceRestore() == true)
{
_drawableWidth = 0;
_drawableHeight = 0;
}
SyncDrawableSizeToLayer();
if (hostWindow?.IsMinimized == true)
{
return;
}
var drawable = MetalNative.Send(_metalLayer, MetalNative.Selector("nextDrawable"));
if (drawable == 0)
{
@@ -846,43 +619,33 @@ internal static partial class MetalVideoPresenter
3);
}
private static nint CreateWindow(uint width, uint height)
private static void ConfigureMetalLayer(nint device, nint layer)
{
var window = MetalNative.SendInitWindow(
MetalNative.Send(MetalNative.Class("NSWindow"), MetalNative.Selector("alloc")),
MetalNative.Selector("initWithContentRect:styleMask:backing:defer:"),
new CGRect { X = 0, Y = 0, Width = width, Height = height },
WindowStyleMask,
BackingStoreBuffered,
defer: false);
// The presenter owns the handle; AppKit must not free it on user close.
MetalNative.SendVoidBool(window, MetalNative.Selector("setReleasedWhenClosed:"), false);
MetalNative.Send(
window, MetalNative.Selector("setCollectionBehavior:"), (nint)FullScreenPrimaryBehavior);
MetalNative.SendVoid(
window,
MetalNative.Selector("setTitle:"),
MetalNative.NsString(VideoOutExports.GetWindowTitle()));
MetalNative.SendVoid(window, MetalNative.Selector("center"));
// makeKeyAndOrderFront happens after the metal layer is attached.
return window;
MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
var setDisplaySync = MetalNative.Selector("setDisplaySyncEnabled:");
if (MetalNative.SendBool(layer, MetalNative.Selector("respondsToSelector:"), setDisplaySync))
{
MetalNative.SendVoidBool(layer, setDisplaySync, _videoOptions.VSync);
}
}
/// <summary>Keeps the CAMetalLayer's drawable size (pixels) matched to its
/// current bounds (points) × scale as the window resizes or moves between
/// displays. CAMetalLayer never updates drawableSize on its own, even as a
/// view's backing layer, so the render loop drives it.</summary>
/// <summary>Keeps the SDL-owned CAMetalLayer drawable in host pixels as the
/// window resizes or moves between displays with different scale factors.</summary>
private static void SyncDrawableSizeToLayer()
{
MetalNative.SendStretRect(out var bounds, _metalLayer, MetalNative.Selector("bounds"));
var scale = MetalNative.SendDouble(_metalLayer, MetalNative.Selector("contentsScale"));
if (scale <= 0)
var hostWindow = Volatile.Read(ref _hostWindow);
if (hostWindow is null || _metalLayer == 0)
{
scale = 1;
return;
}
var width = Math.Max(1, Math.Round(bounds.Width * scale));
var height = Math.Max(1, Math.Round(bounds.Height * scale));
var pixelSize = hostWindow.PixelSize;
var width = (double)pixelSize.Width;
var height = (double)pixelSize.Height;
if (width == _drawableWidth && height == _drawableHeight)
{
return;
@@ -896,57 +659,6 @@ internal static partial class MetalVideoPresenter
new CGSize { Width = width, Height = height });
}
private static nint CreateLayer(nint device, nint window, out double drawableWidth, out double drawableHeight)
{
var contentView = MetalNative.Send(window, MetalNative.Selector("contentView"));
var scale = MetalNative.SendDouble(window, MetalNative.Selector("backingScaleFactor"));
if (scale <= 0)
{
scale = 1;
}
const uint width = DefaultWindowWidth;
const uint height = DefaultWindowHeight;
drawableWidth = width * scale;
drawableHeight = height * scale;
var layer = MetalNative.Send(
MetalNative.Send(MetalNative.Class("CAMetalLayer"), MetalNative.Selector("alloc")),
MetalNative.Selector("init"));
MetalNative.SendVoid(layer, MetalNative.Selector("setDevice:"), device);
MetalNative.Send(layer, MetalNative.Selector("setPixelFormat:"), (nint)PixelFormatBgra8Unorm);
// A Core Animation layer composites with its alpha channel by default, so
// a presented frame whose guest alpha is zero would show through as the
// window background (black). The presenter output is a finished opaque
// frame; mark the layer opaque so alpha never reaches the compositor.
MetalNative.SendVoidBool(layer, MetalNative.Selector("setOpaque:"), true);
MetalNative.SendVoidBool(layer, MetalNative.Selector("setFramebufferOnly:"), true);
MetalNative.SendVoidDouble(layer, MetalNative.Selector("setContentsScale:"), scale);
MetalNative.SendVoidSize(
layer,
MetalNative.Selector("setDrawableSize:"),
new CGSize { Width = drawableWidth, Height = drawableHeight });
// A manually created layer defaults to a zero-size frame, and a hosted
// layer's geometry is the caller's job: without this the presenter
// happily presents every drawable into a layer with no on-screen
// extent — a permanently black window.
MetalNative.SendVoidRect(
layer,
MetalNative.Selector("setFrame:"),
new CGRect { X = 0, Y = 0, Width = width, Height = height });
// wantsLayer FIRST, then the layer: that makes the metal layer the
// view's AppKit-managed BACKING layer (geometry and window-server
// commits handled by AppKit) — the SDL/GLFW pattern. The reverse order
// creates a layer-hosting view whose tree the app must commit itself,
// which never composites under a manually pumped run loop.
MetalNative.SendVoidBool(contentView, MetalNative.Selector("setWantsLayer:"), true);
MetalNative.SendVoid(contentView, MetalNative.Selector("setLayer:"), layer);
MetalNative.SendVoid(MetalNative.Class("CATransaction"), MetalNative.Selector("flush"));
return layer;
}
private static bool TryCreatePresentPipeline(nint device, out nint pipeline, out string error)
{
pipeline = 0;
@@ -1105,10 +817,24 @@ internal static partial class MetalVideoPresenter
{
MetalNative.SendVoid(encoder, MetalNative.Selector("setRenderPipelineState:"), pipeline);
// Aspect-fit letterbox: scale the frame into the drawable via the viewport.
var scale = Math.Min(drawableWidth / frameWidth, drawableHeight / frameHeight);
var viewportWidth = frameWidth * scale;
var viewportHeight = frameHeight * scale;
var scaleX = drawableWidth / frameWidth;
var scaleY = drawableHeight / frameHeight;
var viewportWidth = drawableWidth;
var viewportHeight = drawableHeight;
if (_videoOptions.ScalingMode != HostScalingMode.Stretch)
{
var scale = _videoOptions.ScalingMode == HostScalingMode.Cover
? Math.Max(scaleX, scaleY)
: Math.Min(scaleX, scaleY);
if (_videoOptions.ScalingMode == HostScalingMode.Integer && scale >= 1)
{
scale = Math.Floor(scale);
}
viewportWidth = frameWidth * scale;
viewportHeight = frameHeight * scale;
}
MetalNative.SendVoidViewport(
encoder,
MetalNative.Selector("setViewport:"),
@@ -378,8 +378,10 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
public void SubmitGuestImageWrite(ulong address, byte[] pixels, uint rowOffset = 0) =>
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels, rowOffset);
public bool SupportsPartialImageWrite => true;
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
@@ -129,6 +129,24 @@ public static partial class KernelMemoryCompatExports
private static readonly HashSet<string> _negativeStatCache = new(HostFsPathComparer);
private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(HostFsPathComparer);
private static long _nextFileDescriptor = 2;
private static string _applicationTitleId = "UNKNOWN";
public static void ConfigureApplicationInfo(string? titleId)
{
var value = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId.Trim();
Span<char> sanitized = value.Length <= 128
? stackalloc char[value.Length]
: new char[value.Length];
for (var index = 0; index < value.Length; index++)
{
var character = value[index];
sanitized[index] = char.IsAsciiLetterOrDigit(character) || character is '-' or '_'
? char.ToUpperInvariant(character)
: '_';
}
Volatile.Write(ref _applicationTitleId, new string(sanitized));
}
internal static int AllocateGuestFileDescriptor()
{
@@ -5350,7 +5368,7 @@ public static partial class KernelMemoryCompatExports
}
else
{
root = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs", "devlog", "app"));
root = Path.Combine(ResolveGameLogRoot(), "devlog", "app");
}
Directory.CreateDirectory(root);
@@ -5419,14 +5437,20 @@ public static partial class KernelMemoryCompatExports
}
else
{
root = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "logs", "hostapp"));
Environment.SetEnvironmentVariable(hostappVariableName, root);
root = Path.Combine(ResolveGameLogRoot(), "hostapp");
}
Directory.CreateDirectory(root);
return root;
}
private static string ResolveGameLogRoot() =>
Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"user",
"game_logs",
Volatile.Read(ref _applicationTitleId)));
private static string GetPerAppWritableRoot()
{
var app0Root = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
@@ -35,9 +35,13 @@ public static class KernelPthreadCompatExports
private static readonly bool _tracePthreadConds =
_tracePthreads ||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_CONDS"), "1", StringComparison.Ordinal);
private static readonly bool _tracePthreadFastPath =
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_FASTPATH"), "1", StringComparison.Ordinal);
private static readonly HashSet<ulong>? _tracePthreadMutexFilter = ParseTraceAddressFilter(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_MUTEX_FILTER"));
private static long _nextSynchronizationWaiterId;
private static int _pthreadFastPathTraceWritten;
private static readonly ConcurrentDictionary<ulong, byte> _pthreadFastPathBusyTraced = new();
private sealed class PthreadMutexState
{
@@ -809,6 +813,7 @@ public static class KernelPthreadCompatExports
if (!TryResolveMutexState(ctx, mutexAddress, createIfZero: true, out var resolvedAddress, out var state))
{
TracePthreadFastPathBusy(tryOnly ? "trylock_missing" : "lock_missing", mutexAddress, resolvedAddress, null, KernelPthreadState.GetCurrentThreadHandle(), (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, null, KernelPthreadState.GetCurrentThreadHandle(), (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
@@ -861,6 +866,7 @@ public static class KernelPthreadCompatExports
var ownedResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
TracePthreadFastPathBusy(tryOnly ? "trylock_self" : "lock_self", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
return ownedResult;
}
@@ -949,6 +955,7 @@ public static class KernelPthreadCompatExports
if (tryOnly)
{
TracePthreadFastPathBusy("trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
@@ -1021,6 +1028,7 @@ public static class KernelPthreadCompatExports
{
if (state.RecursionCount <= 0)
{
TracePthreadFastPathUnlock(ctx, mutexAddress, resolvedAddress, state, currentThreadId);
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
@@ -1187,17 +1195,23 @@ public static class KernelPthreadCompatExports
return 0;
}
if (_mutexStates.ContainsKey(mutexAddress))
var hasPointedHandle =
KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress, out var pointedHandle) &&
pointedHandle != 0 &&
pointedHandle != mutexAddress;
if (_mutexStates.TryGetValue(mutexAddress, out var cachedState))
{
return mutexAddress;
return hasPointedHandle &&
_mutexStates.TryGetValue(pointedHandle, out var pointedState) &&
!ReferenceEquals(pointedState, cachedState)
? pointedHandle
: mutexAddress;
}
if (KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress, out var pointedHandle) && pointedHandle != 0)
if (hasPointedHandle && _mutexStates.ContainsKey(pointedHandle))
{
if (_mutexStates.ContainsKey(pointedHandle))
{
return pointedHandle;
}
return pointedHandle;
}
return mutexAddress;
@@ -1212,13 +1226,35 @@ public static class KernelPthreadCompatExports
return false;
}
var hasPointedHandle = KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress, out var pointedHandle);
if (_mutexStates.TryGetValue(mutexAddress, out state))
{
// `mutexAddress` is often the address of the guest's ScePthreadMutex
// variable rather than the handle itself, and that storage is
// reusable — a stack frame recycles the slot, or the guest assigns a
// different mutex to it. The slot therefore outranks anything cached
// under its address: keeping the stale entry would resolve a release
// onto the wrong mutex, leave the real one owned forever and wedge
// every waiter on it (Demon's Souls' Scream audio engine did exactly
// this and spun on scePthreadMutexTrylock).
if (hasPointedHandle &&
pointedHandle != 0 &&
pointedHandle != mutexAddress &&
_mutexStates.TryGetValue(pointedHandle, out var pointedState) &&
!ReferenceEquals(pointedState, state))
{
_mutexStates[mutexAddress] = pointedState;
resolvedAddress = pointedHandle;
state = pointedState;
return true;
}
resolvedAddress = mutexAddress;
return true;
}
if (!KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress, out var pointedHandle))
if (!hasPointedHandle)
{
return false;
}
@@ -2163,6 +2199,60 @@ public static class KernelPthreadCompatExports
$"recursion={(state?.RecursionCount ?? 0)} type={(state?.Type ?? 0)} result=0x{unchecked((uint)result):X8}");
}
private static void TracePthreadFastPathUnlock(
CpuContext ctx,
ulong mutexAddress,
ulong resolvedAddress,
PthreadMutexState state,
ulong currentThreadId)
{
if (!_tracePthreadFastPath || Interlocked.Increment(ref _pthreadFastPathTraceWritten) > 16)
{
return;
}
Span<byte> objectBytes = stackalloc byte[0x50];
if (!ctx.Memory.TryRead(resolvedAddress, objectBytes))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] pthread_fastpath_unlock: mutex=0x{mutexAddress:X16} resolved=0x{resolvedAddress:X16} read=failed");
return;
}
Span<ulong> words = stackalloc ulong[10];
for (var index = 0; index < words.Length; index++)
{
words[index] = BinaryPrimitives.ReadUInt64LittleEndian(objectBytes.Slice(index * sizeof(ulong), sizeof(ulong)));
}
Console.Error.WriteLine(
$"[LOADER][TRACE] pthread_fastpath_unlock: mutex=0x{mutexAddress:X16} resolved=0x{resolvedAddress:X16} " +
$"current=0x{currentThreadId:X16} owner=0x{state.OwnerThreadId:X16} recursion={state.RecursionCount} " +
$"q00=0x{words[0]:X16} q08=0x{words[1]:X16} q10=0x{words[2]:X16} q18=0x{words[3]:X16} " +
$"q20=0x{words[4]:X16} q28=0x{words[5]:X16} q30=0x{words[6]:X16} q38=0x{words[7]:X16} " +
$"q40=0x{words[8]:X16} q48=0x{words[9]:X16}");
}
private static void TracePthreadFastPathBusy(
string operation,
ulong mutexAddress,
ulong resolvedAddress,
PthreadMutexState? state,
ulong currentThreadId,
int result)
{
if (!_tracePthreadFastPath || !_pthreadFastPathBusyTraced.TryAdd(mutexAddress, 0))
{
return;
}
Console.Error.WriteLine(
$"[LOADER][TRACE] pthread_fastpath_{operation}: mutex=0x{mutexAddress:X16} resolved=0x{resolvedAddress:X16} " +
$"current=0x{currentThreadId:X16} owner=0x{(state?.OwnerThreadId ?? 0):X16} " +
$"recursion={(state?.RecursionCount ?? 0)} type={(state?.Type ?? 0)} " +
$"waiters={(state?.QueuedWaiterCount ?? 0)} result=0x{unchecked((uint)result):X8}");
}
private static void TracePthreadCond(string operation, ulong condAddress, ulong mutexAddress, PthreadCondState? state, bool timed, int result)
{
if (!_tracePthreadConds)
+45 -10
View File
@@ -30,7 +30,7 @@ public static class Ngs2Exports
// The grain length defaults to 256 frames (matching the 8192-byte AudioOut
// buffers games copy it into) until the title overrides it.
private const int DefaultGrainSamples = 256;
private const double OutputSampleRate = 48000.0;
private const int DefaultSampleRate = 48000;
private sealed class SystemState
{
@@ -38,6 +38,7 @@ public static class Ngs2Exports
public uint Uid { get; }
public int GrainSamples { get; set; } = DefaultGrainSamples;
public int SampleRate { get; set; } = DefaultSampleRate;
}
private sealed record RackState(ulong SystemHandle, uint RackId);
@@ -496,6 +497,7 @@ public static class Ngs2Exports
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
Span<byte> renderBufferInfo = stackalloc byte[RenderBufferInfoSize];
for (uint i = 0; i < bufferInfoCount; i++)
{
var entryAddress = bufferInfoAddress + (i * RenderBufferInfoSize);
@@ -527,10 +529,9 @@ public static class Ngs2Exports
if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4)
{
Span<byte> rbi = stackalloc byte[RenderBufferInfoSize];
ctx.Memory.TryRead(entryAddress, rbi);
ctx.Memory.TryRead(entryAddress, renderBufferInfo);
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(rbi)}");
$"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(renderBufferInfo)}");
}
}
}
@@ -552,6 +553,7 @@ public static class Ngs2Exports
CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels)
{
int grain;
int sampleRate;
lock (StateGate)
{
if (!Systems.TryGetValue(systemHandle, out var system))
@@ -560,6 +562,7 @@ public static class Ngs2Exports
}
grain = system.GrainSamples;
sampleRate = system.SampleRate;
}
var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float)));
@@ -590,7 +593,7 @@ public static class Ngs2Exports
continue;
}
MixOneVoice(accum, capacityFrames, channels, voice);
MixOneVoice(accum, capacityFrames, channels, sampleRate, voice);
mixedAnything = true;
}
}
@@ -606,15 +609,20 @@ public static class Ngs2Exports
}
}
// Resample one voice from its source rate to 48 kHz (nearest-sample) and add
// Resample one voice to the system rate and add
// it to the front stereo pair. Advances the voice cursor and handles loop /
// one-shot end. Must be called under StateGate.
private static void MixOneVoice(float[] accum, int frames, int channels, VoiceState voice)
private static void MixOneVoice(
float[] accum,
int frames,
int channels,
int outputSampleRate,
VoiceState voice)
{
var pcm = voice.Pcm!;
var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length;
var loopStart = voice.LoopStart;
var step = voice.SourceRate / OutputSampleRate;
var step = voice.SourceRate / (double)outputSampleRate;
var gain = voice.Gain / 32768f;
var pos = voice.Position;
for (var f = 0; f < frames; f++)
@@ -640,7 +648,14 @@ public static class Ngs2Exports
break;
}
var sample = pcm[idx] * gain;
var next = idx + 1;
if (next >= loopEnd)
{
next = loopStart >= 0 && loopStart < loopEnd ? loopStart : idx;
}
var fraction = pos - idx;
var sample = (float)((pcm[idx] + ((pcm[next] - pcm[idx]) * fraction)) * gain);
var baseIndex = f * channels;
accum[baseIndex] += sample;
if (channels > 1)
@@ -736,7 +751,27 @@ public static class Ngs2Exports
ExportName = "sceNgs2SystemSetSampleRate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemSetSampleRate(CpuContext ctx) => ValidateSystem(ctx);
public static int Ngs2SystemSetSampleRate(CpuContext ctx)
{
var systemHandle = ctx[CpuRegister.Rdi];
var sampleRate = unchecked((int)ctx[CpuRegister.Rsi]);
lock (StateGate)
{
if (!Systems.TryGetValue(systemHandle, out var system))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
}
if (sampleRate is < 8000 or > 192000)
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
system.SampleRate = sampleRate;
}
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "gThZqM5PYlQ",
+152 -214
View File
@@ -2,138 +2,135 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Posix;
using Silk.NET.Input;
namespace SharpEmu.Libs.Pad;
/// <summary>
/// Keyboard and gamepad state sampled from the presenter's window, feeding
/// the POSIX host input seam (macOS/Linux have no user32/XInput/raw-HID
/// readers). The presenter attaches the window's input context once the
/// window exists; input events arrive on the window thread and pad reads
/// happen on guest threads, so all state is guarded.
/// </summary>
/// <summary>Cross-platform input state supplied by the SDL game window.</summary>
public static class HostWindowInput
{
private static readonly object Gate = new();
private static readonly HashSet<Key> Pressed = new();
private static volatile bool _connected;
// Latest window-gamepad snapshot in the host seam's conventions.
private static readonly HashSet<int> PressedKeys = new();
private static bool _focused;
private static bool _gamepadConnected;
private static string? _gamepadName;
private static HostGamepadButtons _gamepadButtons;
private static byte _gamepadLeftX = 128;
private static byte _gamepadLeftY = 128;
private static byte _gamepadRightX = 128;
private static byte _gamepadRightY = 128;
private static byte _gamepadL2;
private static byte _gamepadR2;
private static HostGamepadState _gamepadState;
private static IHostGamepadOutput? _gamepadOutput;
private static readonly WindowInputSource Source = new();
/// <summary>True once a window keyboard is delivering events.</summary>
public static bool IsConnected => _connected;
public static void Attach(IInputContext input)
{
foreach (var keyboard in input.Keyboards)
{
keyboard.KeyDown += (_, key, _) =>
{
if (key == Key.F1)
{
VideoOut.PerfOverlay.Toggle();
}
lock (Gate)
{
Pressed.Add(key);
}
};
keyboard.KeyUp += (_, key, _) =>
{
lock (Gate)
{
Pressed.Remove(key);
}
};
}
if (input.Keyboards.Count > 0)
{
_connected = true;
}
foreach (var gamepad in input.Gamepads)
{
AttachGamepad(gamepad);
}
input.ConnectionChanged += (device, connected) =>
{
if (device is not IGamepad gamepad)
{
return;
}
if (connected)
{
AttachGamepad(gamepad);
return;
}
lock (Gate)
{
_gamepadConnected = false;
_gamepadName = null;
_gamepadButtons = HostGamepadButtons.None;
_gamepadLeftX = 128;
_gamepadLeftY = 128;
_gamepadRightX = 128;
_gamepadRightY = 128;
_gamepadL2 = 0;
_gamepadR2 = 0;
}
};
PosixHostInput.SetSource(new WindowInputSource());
}
public static bool IsKeyDown(Key key)
public static void Connect(IHostGamepadOutput? gamepadOutput = null)
{
lock (Gate)
{
return Pressed.Contains(key);
_focused = true;
_gamepadOutput = gamepadOutput;
PressedKeys.Clear();
}
HostWindowInputSource.Set(Source);
}
public static void Disconnect()
{
lock (Gate)
{
_focused = false;
_gamepadConnected = false;
_gamepadName = null;
_gamepadState = default;
_gamepadOutput = null;
PressedKeys.Clear();
}
HostWindowInputSource.Clear(Source);
}
public static void SetFocused(bool focused)
{
lock (Gate)
{
_focused = focused;
if (!focused)
{
PressedKeys.Clear();
}
}
}
private sealed class WindowInputSource : IPosixWindowInputSource
public static void SetKey(int virtualKey, bool down)
{
public bool HasKeyboardFocus => _connected;
lock (Gate)
{
if (down)
{
PressedKeys.Add(virtualKey);
}
else
{
PressedKeys.Remove(virtualKey);
}
}
}
public static void SetGamepad(string? name, HostGamepadState state)
{
lock (Gate)
{
_gamepadConnected = state.Connected;
_gamepadName = name;
_gamepadState = state;
}
}
public static void ClearGamepad()
{
lock (Gate)
{
_gamepadConnected = false;
_gamepadName = null;
_gamepadState = default;
}
}
internal static byte ToStickByte(short value)
{
var normalized = value + 32768;
return (byte)Math.Clamp((normalized * 255 + 32767) / 65535, 0, 255);
}
internal static byte ToTriggerByte(short value) =>
(byte)Math.Clamp(value * 255 / 32767, 0, 255);
private sealed class WindowInputSource : IHostWindowInputSource
{
public bool HasKeyboardFocus
{
get
{
lock (Gate)
{
return _focused;
}
}
}
public bool IsKeyDown(int virtualKey)
{
return TryMapVirtualKey(virtualKey, out var key) && HostWindowInput.IsKeyDown(key);
lock (Gate)
{
return PressedKeys.Contains(virtualKey);
}
}
public int GetGamepadStates(Span<HostGamepadState> destination)
{
lock (Gate)
{
if (!_gamepadConnected || destination.Length == 0)
if (!_gamepadConnected || destination.IsEmpty)
{
return 0;
}
destination[0] = new HostGamepadState(
Connected: true,
Buttons: _gamepadButtons,
LeftX: _gamepadLeftX,
LeftY: _gamepadLeftY,
RightX: _gamepadRightX,
RightY: _gamepadRightY,
LeftTrigger: _gamepadL2,
RightTrigger: _gamepadR2);
destination[0] = _gamepadState;
return 1;
}
}
@@ -142,139 +139,80 @@ public static class HostWindowInput
{
lock (Gate)
{
return _gamepadConnected ? _gamepadName ?? "GLFW gamepad" : null;
return _gamepadConnected ? _gamepadName ?? "SDL gamepad" : null;
}
}
}
private static bool TryMapVirtualKey(int vk, out Key key)
{
key = vk switch
public void SetRumble(byte largeMotor, byte smallMotor)
{
0x08 => Key.Backspace,
0x09 => Key.Tab,
0x0D => Key.Enter,
0x1B => Key.Escape,
0x25 => Key.Left,
0x26 => Key.Up,
0x27 => Key.Right,
0x28 => Key.Down,
>= 0x41 and <= 0x5A => Key.A + (vk - 0x41),
_ => Key.Unknown,
};
return key != Key.Unknown;
}
IHostGamepadOutput? output;
lock (Gate)
{
output = _gamepadOutput;
}
private static void AttachGamepad(IGamepad gamepad)
{
lock (Gate)
{
_gamepadConnected = true;
_gamepadName = gamepad.Name;
output?.SetRumble(largeMotor, smallMotor);
}
gamepad.ButtonDown += (_, button) =>
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
{
var bit = MapButton(button.Name);
if (bit == HostGamepadButtons.None)
IHostGamepadOutput? output;
lock (Gate)
{
return;
output = _gamepadOutput;
}
output?.SetTriggerRumble(leftTrigger, rightTrigger);
}
public void SetAdaptiveTriggerEffect(
HostAdaptiveTriggerEffect? leftTrigger,
HostAdaptiveTriggerEffect? rightTrigger)
{
IHostGamepadOutput? output;
lock (Gate)
{
_gamepadButtons |= bit;
}
};
gamepad.ButtonUp += (_, button) =>
{
var bit = MapButton(button.Name);
if (bit == HostGamepadButtons.None)
{
return;
output = _gamepadOutput;
}
lock (Gate)
{
_gamepadButtons &= ~bit;
}
};
gamepad.ThumbstickMoved += (_, thumbstick) =>
output?.SetAdaptiveTriggerEffect(leftTrigger, rightTrigger);
}
public void SetLightbar(byte red, byte green, byte blue)
{
// Silk's GLFW backend reports sticks -1..1 with +Y pointing down,
// matching the seam's 0..255 down-growing convention after biasing.
var x = ToStickByte(thumbstick.X);
var y = ToStickByte(thumbstick.Y);
IHostGamepadOutput? output;
lock (Gate)
{
if (thumbstick.Index == 0)
{
_gamepadLeftX = x;
_gamepadLeftY = y;
}
else
{
_gamepadRightX = x;
_gamepadRightY = y;
}
output = _gamepadOutput;
}
};
gamepad.TriggerMoved += (_, trigger) =>
output?.SetLightbar(red, green, blue);
}
public void ResetLightbar()
{
// GLFW gamepad triggers rest at -1 and saturate at +1.
var value = (byte)Math.Clamp((int)((trigger.Position + 1.0f) * 0.5f * 255.0f), 0, 255);
IHostGamepadOutput? output;
lock (Gate)
{
if (trigger.Index == 0)
{
_gamepadL2 = value;
if (value > 64)
{
_gamepadButtons |= HostGamepadButtons.L2;
}
else
{
_gamepadButtons &= ~HostGamepadButtons.L2;
}
}
else
{
_gamepadR2 = value;
if (value > 64)
{
_gamepadButtons |= HostGamepadButtons.R2;
}
else
{
_gamepadButtons &= ~HostGamepadButtons.R2;
}
}
output = _gamepadOutput;
}
};
output?.ResetLightbar();
}
}
internal static byte ToStickByte(float value)
{
return (byte)Math.Clamp((int)MathF.Round((value + 1.0f) * 127.5f), 0, 255);
}
private static HostGamepadButtons MapButton(ButtonName name) => name switch
{
// GLFW reports the Xbox layout: A=Cross, B=Circle, X=Square, Y=Triangle.
ButtonName.A => HostGamepadButtons.Cross,
ButtonName.B => HostGamepadButtons.Circle,
ButtonName.X => HostGamepadButtons.Square,
ButtonName.Y => HostGamepadButtons.Triangle,
ButtonName.LeftBumper => HostGamepadButtons.L1,
ButtonName.RightBumper => HostGamepadButtons.R1,
ButtonName.Back => HostGamepadButtons.TouchPad,
ButtonName.Start => HostGamepadButtons.Options,
ButtonName.LeftStick => HostGamepadButtons.L3,
ButtonName.RightStick => HostGamepadButtons.R3,
ButtonName.DPadUp => HostGamepadButtons.Up,
ButtonName.DPadRight => HostGamepadButtons.Right,
ButtonName.DPadDown => HostGamepadButtons.Down,
ButtonName.DPadLeft => HostGamepadButtons.Left,
_ => HostGamepadButtons.None,
};
}
public interface IHostGamepadOutput
{
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();
}
+220 -14
View File
@@ -37,6 +37,7 @@ public static class PadExports
private static PadState _cachedInputState;
private static bool _initialized;
private static int _motionSensorEnabled;
private static int _controlsAnnouncementLogged;
[SysAbiExport(
@@ -151,9 +152,13 @@ public static class PadExports
public static int PadSetMotionSensorState(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
return IsPrimaryPadHandle(handle)
? ctx.SetReturn(0)
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
Volatile.Write(ref _motionSensorEnabled, ctx[CpuRegister.Rsi] != 0 ? 1 : 0);
return ctx.SetReturn(0);
}
[SysAbiExport(
@@ -362,24 +367,170 @@ public static class PadExports
}
var triggerMask = parameter[0];
HostPlatform.Current.Input.SetTriggerRumble(
(triggerMask & 0x01) != 0 ? DecodeTriggerVibration(parameter[8..64]) : null,
(triggerMask & 0x02) != 0 ? DecodeTriggerVibration(parameter[64..120]) : null);
HostPlatform.Current.Input.SetAdaptiveTriggerEffect(
(triggerMask & 0x01) != 0 ? DecodeTriggerEffect(parameter[8..64]) : null,
(triggerMask & 0x02) != 0 ? DecodeTriggerEffect(parameter[64..120]) : null);
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
}
private static byte DecodeTriggerVibration(ReadOnlySpan<byte> command)
private static HostAdaptiveTriggerEffect DecodeTriggerEffect(ReadOnlySpan<byte> command)
{
var mode = BinaryPrimitives.ReadUInt32LittleEndian(command);
var amplitude = mode switch
var parameters = command[8..];
Span<byte> native = stackalloc byte[11];
native.Clear();
byte fallbackStrength = 0;
switch (mode)
{
3 when command[10] != 0 => command[9],
6 when command[8] != 0 => command[9..19].ToArray().Max(),
_ => (byte)0,
};
return (byte)(Math.Min(amplitude, (byte)8) * 255 / 8);
case 1:
EncodeFeedback(native, parameters[0], parameters[1]);
fallbackStrength = ScaleTriggerStrength(parameters[1]);
break;
case 2:
EncodeWeapon(native, parameters[0], parameters[1], parameters[2]);
fallbackStrength = ScaleTriggerStrength(parameters[2]);
break;
case 3:
EncodeZonedEffect(native, 0x26, parameters[0], parameters[1], parameters[2]);
fallbackStrength = ScaleTriggerStrength(parameters[1]);
break;
case 4:
EncodeZonedStrengths(native, 0x21, parameters[..10], 0);
fallbackStrength = ScaleTriggerStrength(Max(parameters[..10]));
break;
case 5:
EncodeSlope(native, parameters[0], parameters[1], parameters[2], parameters[3]);
fallbackStrength = ScaleTriggerStrength(Math.Max(parameters[2], parameters[3]));
break;
case 6:
EncodeZonedStrengths(native, 0x26, parameters[1..11], parameters[0]);
fallbackStrength = parameters[0] == 0 ? (byte)0 : ScaleTriggerStrength(Max(parameters[1..11]));
break;
default:
native[0] = 0x05;
break;
}
return HostAdaptiveTriggerEffect.FromBytes(native, fallbackStrength);
}
private static void EncodeFeedback(Span<byte> destination, byte position, byte strength)
{
if (position > 9 || strength is 0 or > 8)
{
destination[0] = 0x05;
return;
}
Span<byte> strengths = stackalloc byte[10];
strengths[position..].Fill(strength);
EncodeZonedStrengths(destination, 0x21, strengths, 0);
}
private static void EncodeWeapon(Span<byte> destination, byte start, byte end, byte strength)
{
if (start is < 2 or > 7 || end <= start || end > 8 || strength is 0 or > 8)
{
destination[0] = 0x05;
return;
}
var zones = (ushort)((1 << start) | (1 << end));
destination[0] = 0x25;
BinaryPrimitives.WriteUInt16LittleEndian(destination[1..], zones);
destination[3] = (byte)(strength - 1);
}
private static void EncodeZonedEffect(
Span<byte> destination,
byte nativeMode,
byte position,
byte strength,
byte frequency)
{
if (position > 9 || strength is 0 or > 8 || frequency == 0)
{
destination[0] = 0x05;
return;
}
Span<byte> strengths = stackalloc byte[10];
strengths[position..].Fill(strength);
EncodeZonedStrengths(destination, nativeMode, strengths, frequency);
}
private static void EncodeSlope(
Span<byte> destination,
byte startPosition,
byte endPosition,
byte startStrength,
byte endStrength)
{
if (startPosition > 8 || endPosition <= startPosition || endPosition > 9 ||
startStrength is 0 or > 8 || endStrength is 0 or > 8)
{
destination[0] = 0x05;
return;
}
Span<byte> strengths = stackalloc byte[10];
var distance = endPosition - startPosition;
for (var index = startPosition; index < strengths.Length; index++)
{
strengths[index] = index <= endPosition
? (byte)Math.Round(startStrength + ((endStrength - startStrength) * (index - startPosition) / (double)distance))
: endStrength;
}
EncodeZonedStrengths(destination, 0x21, strengths, 0);
}
private static void EncodeZonedStrengths(
Span<byte> destination,
byte nativeMode,
ReadOnlySpan<byte> strengths,
byte frequency)
{
ushort activeZones = 0;
uint packedStrengths = 0;
for (var index = 0; index < Math.Min(strengths.Length, 10); index++)
{
var strength = strengths[index];
if (strength is 0 or > 8)
{
continue;
}
activeZones |= (ushort)(1 << index);
packedStrengths |= (uint)(strength - 1) << (index * 3);
}
if (activeZones == 0 || (nativeMode == 0x26 && frequency == 0))
{
destination[0] = 0x05;
return;
}
destination[0] = nativeMode;
BinaryPrimitives.WriteUInt16LittleEndian(destination[1..], activeZones);
BinaryPrimitives.WriteUInt32LittleEndian(destination[3..], packedStrengths);
destination[9] = frequency;
}
private static byte Max(ReadOnlySpan<byte> values)
{
byte result = 0;
foreach (var value in values)
{
result = Math.Max(result, value);
}
return result;
}
private static byte ScaleTriggerStrength(byte strength) =>
(byte)(Math.Min(strength, (byte)8) * 255 / 8);
[SysAbiExport(
Nid = "yFVnOdGxvZY",
ExportName = "scePadSetVibration",
@@ -478,6 +629,17 @@ public static class PadExports
data[0x08] = l2;
data[0x09] = r2;
BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f);
if (Volatile.Read(ref _motionSensorEnabled) != 0 && input.Motion.Available)
{
BinaryPrimitives.WriteSingleLittleEndian(data[0x1C..], input.Motion.AccelerationX);
BinaryPrimitives.WriteSingleLittleEndian(data[0x20..], input.Motion.AccelerationY);
BinaryPrimitives.WriteSingleLittleEndian(data[0x24..], input.Motion.AccelerationZ);
BinaryPrimitives.WriteSingleLittleEndian(data[0x28..], input.Motion.AngularVelocityX);
BinaryPrimitives.WriteSingleLittleEndian(data[0x2C..], input.Motion.AngularVelocityY);
BinaryPrimitives.WriteSingleLittleEndian(data[0x30..], input.Motion.AngularVelocityZ);
}
WriteTouchData(data, input.Touch);
data[0x4C] = 1;
var timestampTicks = Stopwatch.GetTimestamp();
var timestampMicroseconds =
@@ -508,6 +670,10 @@ public static class PadExports
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;
var gamepadType = HostGamepadType.Generic;
var connection = HostGamepadConnection.Unknown;
var motion = default(HostMotionState);
var touch = default(HostTouchState);
Span<HostGamepadState> gamepads = stackalloc HostGamepadState[2];
var gamepadCount = input.GetGamepadStates(gamepads);
@@ -523,6 +689,13 @@ public static class PadExports
rightY = MergeAxis(pad.RightY, rightY);
l2 = Math.Max(l2, pad.LeftTrigger);
r2 = Math.Max(r2, pad.RightTrigger);
if (index == 0)
{
gamepadType = pad.Type;
connection = pad.Connection;
motion = pad.Motion;
touch = pad.Touch;
}
}
if (IsAutoCrossActive())
@@ -538,7 +711,11 @@ public static class PadExports
RightX: rightX,
RightY: rightY,
L2: l2,
R2: r2);
R2: r2,
Type: gamepadType,
Connection: connection,
Motion: motion,
Touch: touch);
_lastInputSampleTicks = now;
return _cachedInputState;
}
@@ -592,6 +769,7 @@ public static class PadExports
private static uint ToOrbisButtons(HostGamepadButtons buttons)
{
uint result = 0;
if ((buttons & HostGamepadButtons.Create) != 0) result |= OrbisPadButton.Share;
if ((buttons & HostGamepadButtons.Up) != 0) result |= OrbisPadButton.Up;
if ((buttons & HostGamepadButtons.Down) != 0) result |= OrbisPadButton.Down;
if ((buttons & HostGamepadButtons.Left) != 0) result |= OrbisPadButton.Left;
@@ -611,6 +789,34 @@ public static class PadExports
return result;
}
private static void WriteTouchData(Span<byte> data, HostTouchState touch)
{
Span<HostTouchPoint> active = stackalloc HostTouchPoint[2];
var count = 0;
if (touch.First.Active)
{
active[count++] = touch.First;
}
if (touch.Second.Active)
{
active[count++] = touch.Second;
}
data[0x34] = (byte)count;
for (var index = 0; index < count; index++)
{
var offset = 0x3C + (index * 8);
var point = active[index];
BinaryPrimitives.WriteUInt16LittleEndian(
data[offset..],
(ushort)Math.Round(Math.Clamp(point.X, 0, 1) * 1919));
BinaryPrimitives.WriteUInt16LittleEndian(
data[(offset + 2)..],
(ushort)Math.Round(Math.Clamp(point.Y, 0, 1) * 942));
data[offset + 4] = point.Id;
}
}
private static uint ReadKeyboardButtons(IHostInput input)
{
uint buttons = 0;
+8 -1
View File
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Pad;
/// <summary>
@@ -16,11 +18,16 @@ internal readonly record struct PadState(
byte RightX,
byte RightY,
byte L2,
byte R2);
byte R2,
HostGamepadType Type = HostGamepadType.Generic,
HostGamepadConnection Connection = HostGamepadConnection.Unknown,
HostMotionState Motion = default,
HostTouchState Touch = default);
/// <summary>SCE_PAD_BUTTON bit values.</summary>
internal static class OrbisPadButton
{
internal const uint Share = 0x0001;
internal const uint L3 = 0x0002;
internal const uint R3 = 0x0004;
internal const uint Options = 0x0008;
@@ -0,0 +1,121 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host;
using SDL;
using static SDL.SDL3;
namespace SharpEmu.Libs.Pad;
internal static unsafe class SdlGamepadStateReader
{
public static void EnableSonyHidApi()
{
fixed (byte* enabled = "1"u8)
fixed (byte* ps4 = SDL_HINT_JOYSTICK_HIDAPI_PS4)
fixed (byte* ps5 = SDL_HINT_JOYSTICK_HIDAPI_PS5)
{
SDL_SetHint(ps4, enabled);
SDL_SetHint(ps5, enabled);
}
}
public static SDL_Gamepad* OpenPreferredGamepad()
{
using var gamepads = SDL_GetGamepads();
if (gamepads is null || gamepads.Count == 0)
{
return null;
}
var selected = gamepads[0];
for (var index = 0; index < gamepads.Count; index++)
{
var type = SDL_GetRealGamepadTypeForID(gamepads[index]);
if (type is SDL_GamepadType.SDL_GAMEPAD_TYPE_PS5 or SDL_GamepadType.SDL_GAMEPAD_TYPE_PS4)
{
selected = gamepads[index];
break;
}
}
return SDL_OpenGamepad(selected);
}
public static HostGamepadState Read(SDL_Gamepad* gamepad)
{
var buttons = HostGamepadButtons.None;
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_SOUTH, HostGamepadButtons.Cross, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_EAST, HostGamepadButtons.Circle, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_WEST, HostGamepadButtons.Square, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_NORTH, HostGamepadButtons.Triangle, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, HostGamepadButtons.L1, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, HostGamepadButtons.R1, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_LEFT_STICK, HostGamepadButtons.L3, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_RIGHT_STICK, HostGamepadButtons.R3, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_BACK, HostGamepadButtons.Create, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_GUIDE, HostGamepadButtons.Ps, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_START, HostGamepadButtons.Options, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_MISC1, HostGamepadButtons.Mic, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_TOUCHPAD, HostGamepadButtons.TouchPad, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_UP, HostGamepadButtons.Up, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_RIGHT, HostGamepadButtons.Right, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_DOWN, HostGamepadButtons.Down, ref buttons);
AddButton(gamepad, SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_LEFT, HostGamepadButtons.Left, ref buttons);
var leftTrigger = HostWindowInput.ToTriggerByte(
SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_LEFT_TRIGGER));
var rightTrigger = HostWindowInput.ToTriggerByte(
SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_RIGHT_TRIGGER));
if (leftTrigger > 64)
{
buttons |= HostGamepadButtons.L2;
}
if (rightTrigger > 64)
{
buttons |= HostGamepadButtons.R2;
}
var battery = 0;
SDL_GetGamepadPowerInfo(gamepad, &battery);
return new HostGamepadState(
Connected: true,
Buttons: buttons,
LeftX: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_LEFTX)),
LeftY: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_LEFTY)),
RightX: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_RIGHTX)),
RightY: HostWindowInput.ToStickByte(SDL_GetGamepadAxis(gamepad, SDL_GamepadAxis.SDL_GAMEPAD_AXIS_RIGHTY)),
LeftTrigger: leftTrigger,
RightTrigger: rightTrigger,
Type: MapGamepadType(SDL_GetRealGamepadType(gamepad)),
Connection: GetConnection(gamepad),
BatteryPercent: (byte)Math.Clamp(battery, 0, 100));
}
public static HostGamepadType MapGamepadType(SDL_GamepadType type) => type switch
{
SDL_GamepadType.SDL_GAMEPAD_TYPE_PS4 => HostGamepadType.DualShock4,
SDL_GamepadType.SDL_GAMEPAD_TYPE_PS5 => HostGamepadType.DualSense,
_ => HostGamepadType.Generic,
};
public static HostGamepadConnection GetConnection(SDL_Gamepad* gamepad) =>
SDL_GetGamepadConnectionState(gamepad) switch
{
SDL_JoystickConnectionState.SDL_JOYSTICK_CONNECTION_WIRED => HostGamepadConnection.Wired,
SDL_JoystickConnectionState.SDL_JOYSTICK_CONNECTION_WIRELESS => HostGamepadConnection.Wireless,
_ => HostGamepadConnection.Unknown,
};
private static void AddButton(
SDL_Gamepad* gamepad,
SDL_GamepadButton source,
HostGamepadButtons target,
ref HostGamepadButtons buttons)
{
if (SDL_GetGamepadButton(gamepad, source))
{
buttons |= target;
}
}
}
@@ -0,0 +1,85 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host;
using SDL;
using static SDL.SDL3;
namespace SharpEmu.Libs.Pad;
/// <summary>Cross-platform SDL gamepad polling for launcher navigation.</summary>
public static unsafe class SdlLauncherGamepad
{
private const SDL_InitFlags InitFlags = SDL_InitFlags.SDL_INIT_GAMEPAD;
private static SDL_Gamepad* _gamepad;
private static bool _initialized;
public static void EnsureStarted()
{
if (_initialized)
{
return;
}
SdlGamepadStateReader.EnableSonyHidApi();
if (!SDL_InitSubSystem(InitFlags))
{
Console.Error.WriteLine("[GUI][WARN] SDL gamepad initialization failed.");
return;
}
_initialized = true;
OpenFirstGamepad();
}
public static bool TryGetState(out HostGamepadState state)
{
state = default;
if (!_initialized)
{
return false;
}
SDL_UpdateGamepads();
if (_gamepad is not null && !SDL_GamepadConnected(_gamepad))
{
SDL_CloseGamepad(_gamepad);
_gamepad = null;
}
if (_gamepad is null)
{
OpenFirstGamepad();
}
if (_gamepad is null)
{
return false;
}
state = SdlGamepadStateReader.Read(_gamepad);
return true;
}
public static void Shutdown()
{
if (!_initialized)
{
return;
}
if (_gamepad is not null)
{
SDL_CloseGamepad(_gamepad);
_gamepad = null;
}
SDL_QuitSubSystem(InitFlags);
_initialized = false;
}
private static void OpenFirstGamepad()
{
_gamepad = SdlGamepadStateReader.OpenPreferredGamepad();
}
}
+20 -2
View File
@@ -38,6 +38,7 @@ public static class SaveDataExports
private static readonly object _memoryGate = new();
private static readonly HashSet<int> _preparedTransactionResources = [];
private static string? _titleId;
private static int _legacySaveMigrationChecked;
public static void ConfigureApplicationInfo(string? titleId)
{
@@ -1115,7 +1116,7 @@ public static class SaveDataExports
}
// Saves are keyed by title id only (single-user emulation) under
// ~/SharpEmu/Saves/<titleId>/; userId is accepted for API fidelity but not
// user/savedata/<titleId>/; userId is accepted for API fidelity but not
// part of the host path.
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId);
@@ -1133,7 +1134,24 @@ public static class SaveDataExports
ctx.TryReadUInt64(address + 0x10, out offset);
}
private static string ResolveSaveDataRoot() => SaveDataStorage.Root();
private static string ResolveSaveDataRoot()
{
var root = SaveDataStorage.Root();
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR")) &&
Interlocked.Exchange(ref _legacySaveMigrationChecked, 1) == 0)
{
try
{
SaveDataStorage.MigrateLegacyLayout(root);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
TraceSaveData($"migration_failed root='{root}' error='{exception.Message}'");
}
}
return root;
}
private static string ResolveConfiguredTitleId()
{
+61 -6
View File
@@ -8,7 +8,7 @@ namespace SharpEmu.Libs.SaveData;
/// <summary>
/// Host-side layout and metadata for PS5 save data. Saves live under
/// <c>~/SharpEmu/Saves/&lt;titleId&gt;/&lt;dirName&gt;/</c> (overridable via
/// <c>user/savedata/&lt;titleId&gt;/&lt;dirName&gt;/</c> next to the executable (overridable via
/// <c>SHARPEMU_SAVEDATA_DIR</c>); the game's files are written directly inside a
/// slot through the mounted <c>/savedata0</c> filesystem, and the PS5 UI
/// metadata (title/subtitle/detail/userParam) plus icon live under
@@ -17,19 +17,74 @@ namespace SharpEmu.Libs.SaveData;
/// </summary>
public static class SaveDataStorage
{
/// <summary>Root of all saves: the env override, else <c>~/SharpEmu/Saves</c>.</summary>
/// <summary>Root of all saves: the env override, else the portable <c>user/savedata</c> directory.</summary>
public static string Root(string? overrideDir = null)
{
var configured = overrideDir ?? Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
var root = string.IsNullOrWhiteSpace(configured)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"SharpEmu",
"Saves")
? Path.Combine(AppContext.BaseDirectory, "user", "savedata")
: configured;
return Path.GetFullPath(root);
}
/// <summary>
/// Imports saves written by the short-lived profile layout and by the old
/// numeric-user layout. Newer destination files are never overwritten.
/// </summary>
public static void MigrateLegacyLayout(string destinationRoot, string? profileRoot = null)
{
destinationRoot = Path.GetFullPath(destinationRoot);
profileRoot ??= Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"SharpEmu",
"Saves");
if (Directory.Exists(profileRoot) &&
!string.Equals(Path.GetFullPath(profileRoot), destinationRoot, StringComparison.OrdinalIgnoreCase))
{
MergeDirectory(profileRoot, destinationRoot);
}
if (!Directory.Exists(destinationRoot))
{
return;
}
foreach (var userRoot in Directory.EnumerateDirectories(destinationRoot).ToArray())
{
if (!uint.TryParse(Path.GetFileName(userRoot), out _))
{
continue;
}
foreach (var titleRoot in Directory.EnumerateDirectories(userRoot))
{
MergeDirectory(titleRoot, Path.Combine(destinationRoot, Path.GetFileName(titleRoot)));
}
}
}
private static void MergeDirectory(string sourceRoot, string destinationRoot)
{
Directory.CreateDirectory(destinationRoot);
foreach (var sourceFile in Directory.EnumerateFiles(sourceRoot))
{
var destinationFile = Path.Combine(destinationRoot, Path.GetFileName(sourceFile));
if (!File.Exists(destinationFile) ||
File.GetLastWriteTimeUtc(sourceFile) > File.GetLastWriteTimeUtc(destinationFile))
{
File.Copy(sourceFile, destinationFile, overwrite: true);
}
}
foreach (var sourceDirectory in Directory.EnumerateDirectories(sourceRoot))
{
MergeDirectory(
sourceDirectory,
Path.Combine(destinationRoot, Path.GetFileName(sourceDirectory)));
}
}
/// <summary>Per-title directory: <c>&lt;root&gt;/&lt;titleId&gt;</c>.</summary>
public static string TitleRoot(string root, string titleId) =>
Path.Combine(root, Sanitize(titleId));
+3 -2
View File
@@ -5,7 +5,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\SharpEmu.LibAtrac9\SharpEmu.LibAtrac9.csproj" />
<ProjectReference Include="..\SharpEmu.HLE\SharpEmu.HLE.csproj" />
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Metal\SharpEmu.ShaderCompiler.Metal.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Vulkan\SharpEmu.ShaderCompiler.Vulkan.csproj" />
@@ -27,11 +29,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ItemGroup>
<PackageReference Include="FFmpeg.AutoGen" />
<PackageReference Include="NLayer" />
<PackageReference Include="Silk.NET.Input" />
<PackageReference Include="ppy.SDL3-CS" />
<PackageReference Include="Silk.NET.Vulkan" />
<PackageReference Include="Silk.NET.Vulkan.Extensions.EXT" />
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" />
<PackageReference Include="Silk.NET.Windowing" />
</ItemGroup>
<PropertyGroup>
@@ -0,0 +1,129 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SDL;
using static SDL.SDL3;
namespace SharpEmu.Libs.VideoOut;
public sealed record HostDisplayMode(int Width, int Height, int RefreshRate);
public sealed record HostDisplayInfo(
int Index,
string Name,
IReadOnlyList<HostDisplayMode> Modes);
public static unsafe class HostDisplayCatalog
{
private const SDL_InitFlags VideoFlag = SDL_InitFlags.SDL_INIT_VIDEO;
private static int _queryFailureLogged;
public static IReadOnlyList<HostDisplayInfo> Query()
{
var initializedHere = false;
var videoReady = false;
try
{
initializedHere = (SDL_WasInit(VideoFlag) & VideoFlag) == 0;
if (initializedHere && !SDL_InitSubSystem(VideoFlag))
{
LogQueryFailure(SDL_GetError() ?? "unknown SDL error");
return CreateFallback();
}
videoReady = true;
using var displays = SDL_GetDisplays();
if (displays is null || displays.Count == 0)
{
LogQueryFailure("SDL reported no displays");
return CreateFallback();
}
var result = new List<HostDisplayInfo>(displays.Count);
for (var index = 0; index < displays.Count; index++)
{
var display = displays[index];
var name = SDL_GetDisplayName(display);
var modes = ReadModes(display);
result.Add(new HostDisplayInfo(
index,
string.IsNullOrWhiteSpace(name) ? $"Display {index + 1}" : name,
modes));
}
return result;
}
catch (Exception exception)
{
LogQueryFailure(exception.Message);
return CreateFallback();
}
finally
{
if (initializedHere && videoReady)
{
SDL_QuitSubSystem(VideoFlag);
}
}
}
private static IReadOnlyList<HostDisplayMode> ReadModes(SDL_DisplayID display)
{
var modes = new HashSet<HostDisplayMode>();
using (var fullscreenModes = SDL_GetFullscreenDisplayModes(display))
{
if (fullscreenModes is not null)
{
for (var index = 0; index < fullscreenModes.Count; index++)
{
var mode = fullscreenModes[index];
AddMode(modes, &mode);
}
}
}
AddMode(modes, SDL_GetDesktopDisplayMode(display));
if (modes.Count == 0)
{
return CreateFallbackModes();
}
return modes
.OrderByDescending(mode => (long)mode.Width * mode.Height)
.ThenByDescending(mode => mode.Width)
.ThenByDescending(mode => mode.RefreshRate)
.ToArray();
}
private static void AddMode(HashSet<HostDisplayMode> modes, SDL_DisplayMode* mode)
{
if (mode is null || mode->w <= 0 || mode->h <= 0)
{
return;
}
var refreshRate = mode->refresh_rate > 0
? Math.Max(1, (int)Math.Round(mode->refresh_rate, MidpointRounding.AwayFromZero))
: 0;
modes.Add(new HostDisplayMode(mode->w, mode->h, refreshRate));
}
private static IReadOnlyList<HostDisplayInfo> CreateFallback() =>
[new HostDisplayInfo(0, "Display 1", CreateFallbackModes())];
private static IReadOnlyList<HostDisplayMode> CreateFallbackModes() =>
[
new HostDisplayMode(3840, 2160, 60),
new HostDisplayMode(2560, 1440, 60),
new HostDisplayMode(1920, 1080, 60),
new HostDisplayMode(1280, 720, 60),
];
private static void LogQueryFailure(string message)
{
if (Interlocked.Exchange(ref _queryFailureLogged, 1) == 0)
{
Console.Error.WriteLine($"[GUI][WARN] SDL display query failed: {message}");
}
}
}
@@ -0,0 +1,68 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
using SharpEmu.Libs.Gpu.Metal;
public enum HostWindowMode
{
Windowed,
Borderless,
ExclusiveFullscreen,
}
public enum HostScalingMode
{
Fit,
Cover,
Stretch,
Integer,
}
public enum HostHdrMode
{
Auto,
On,
Off,
}
public sealed record HostVideoOptions
{
public static HostVideoOptions Default { get; } = new();
public HostWindowMode WindowMode { get; init; } = HostWindowMode.Windowed;
public HostScalingMode ScalingMode { get; init; } = HostScalingMode.Fit;
public int Width { get; init; } = 1920;
public int Height { get; init; } = 1080;
public int DisplayIndex { get; init; }
public int RefreshRate { get; init; }
public bool VSync { get; init; } = true;
public HostHdrMode HdrMode { get; init; } = HostHdrMode.Auto;
public HostVideoOptions Normalize() => this with
{
Width = Math.Clamp(Width, 640, 16384),
Height = Math.Clamp(Height, 360, 16384),
DisplayIndex = Math.Max(0, DisplayIndex),
RefreshRate = Math.Clamp(RefreshRate, 0, 1000),
HdrMode = Enum.IsDefined(HdrMode) ? HdrMode : HostHdrMode.Auto,
};
}
public static class HostVideoHost
{
public static bool TryConfigureVideo(HostVideoOptions options)
{
var normalized = options.Normalize();
return VulkanVideoPresenter.TryConfigureVideo(normalized) &
MetalVideoPresenter.TryConfigureVideo(normalized);
}
}
@@ -0,0 +1,252 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.Libs.VideoOut;
/// <summary>
/// Self-time accounting for the render thread, enabled with
/// SHARPEMU_PROFILE_RENDER=1. The existing videoout counters report how much
/// work was done (draws, pipelines, SPIR-V) but not where the render thread's
/// second went, which is the number that decides whether a low frame rate is
/// the emulator recording commands, the GPU executing them, or neither.
///
/// Scopes nest: entering a phase suspends the enclosing one and resumes it on
/// dispose, so a <see cref="Phase.QueueSubmit"/> inside
/// <see cref="Phase.Flush"/> is never counted twice.
/// </summary>
internal static class RenderPhaseProfile
{
internal enum Phase
{
/// <summary>Outside any measured phase — loop overhead.</summary>
Unattributed = 0,
/// <summary>Parked because no guest work and no newer flip exist.</summary>
Idle,
/// <summary>Blocked on the frame slot's fence: the GPU is behind.</summary>
FrameSlotWait,
/// <summary>Reaping completed guest submissions (fence polls).</summary>
Collect,
Evict,
/// <summary>Dequeuing the next guest work item.</summary>
TakeWork,
/// <summary>Building the diagnostic label for a work item.</summary>
Describe,
/// <summary>Publishing a work item's completion to its waiters.</summary>
CompleteWork,
/// <summary>Selecting the presentation to show this iteration.</summary>
TakePresentation,
Draw,
Compute,
ColorClear,
ImageWrite,
OrderedAction,
Flip,
/// <summary>Closing and submitting the batched guest command buffer.</summary>
Flush,
/// <summary>vkQueueSubmit itself.</summary>
QueueSubmit,
/// <summary>vkAcquireNextImageKHR.</summary>
Acquire,
/// <summary>Recording + submitting the presentation command buffer.</summary>
Present,
/// <summary>vkQueuePresentKHR.</summary>
QueuePresent,
Count,
}
public static readonly bool Enabled = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_RENDER"),
"1",
StringComparison.Ordinal);
/// <summary>
/// Breaks down the CPU-visible actions which are deliberately serialized
/// behind guest GPU work. This stays opt-in because it is diagnostic data,
/// not a normal render-thread cost.
/// </summary>
public static readonly bool OrderedActionDetailsEnabled = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_ORDERED_ACTION"),
"1",
StringComparison.Ordinal);
private static readonly double _reportSeconds =
double.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_RENDER_REPORT_S"),
System.Globalization.CultureInfo.InvariantCulture,
out var seconds) && seconds > 0
? seconds
: 5.0;
private static readonly long[] _ticks = new long[(int)Phase.Count];
private static readonly long[] _entries = new long[(int)Phase.Count];
private static long _frames;
private static long _windowStart = Stopwatch.GetTimestamp();
private static readonly Dictionary<string, OrderedActionStats> _orderedActions =
new(StringComparer.Ordinal);
// The render loop is single-threaded, so plain fields are enough and keep
// the per-scope cost to two timestamp reads.
[ThreadStatic] private static Phase _current;
[ThreadStatic] private static long _lastTimestamp;
internal readonly ref struct Scope
{
private readonly Phase _previous;
private readonly bool _active;
internal Scope(Phase previous)
{
_previous = previous;
_active = true;
}
public void Dispose()
{
if (!_active)
{
return;
}
Charge(_previous);
}
}
public static Scope Measure(Phase phase)
{
if (!Enabled)
{
return default;
}
var previous = Charge(phase);
_entries[(int)phase]++;
return new Scope(previous);
}
public static void RecordOrderedAction(string debugName, bool completed)
{
if (!OrderedActionDetailsEnabled)
{
return;
}
var category = GetOrderedActionCategory(debugName);
ref var stats = ref System.Runtime.InteropServices.CollectionsMarshal.GetValueRefOrAddDefault(
_orderedActions,
category,
out _);
stats.Executed += completed ? 1 : 0;
stats.Deferred += completed ? 0 : 1;
}
/// <summary>
/// Closes out the running phase and switches to <paramref name="next"/>,
/// returning the phase that was running.
/// </summary>
private static Phase Charge(Phase next)
{
var now = Stopwatch.GetTimestamp();
var previous = _current;
if (_lastTimestamp != 0)
{
_ticks[(int)previous] += now - _lastTimestamp;
}
_lastTimestamp = now;
_current = next;
return previous;
}
/// <summary>Called once per presented frame; also drives the report.</summary>
public static void RecordFrame()
{
if (!Enabled)
{
return;
}
_frames++;
var now = Stopwatch.GetTimestamp();
var elapsedTicks = now - _windowStart;
if (elapsedTicks < _reportSeconds * Stopwatch.Frequency)
{
return;
}
_windowStart = now;
var seconds = elapsedTicks / (double)Stopwatch.Frequency;
var frames = _frames;
_frames = 0;
var parts = new List<(Phase Phase, double Percent, long Entries)>((int)Phase.Count);
var accounted = 0L;
for (var index = 0; index < (int)Phase.Count; index++)
{
var phaseTicks = _ticks[index];
_ticks[index] = 0;
var entries = _entries[index];
_entries[index] = 0;
accounted += phaseTicks;
if (phaseTicks <= 0)
{
continue;
}
parts.Add(((Phase)index, phaseTicks * 100.0 / elapsedTicks, entries));
}
parts.Sort(static (left, right) => right.Percent.CompareTo(left.Percent));
Console.Error.WriteLine(
$"[PERF][RENDER] {seconds:F1}s fps={frames / seconds:F1} " +
$"covered={accounted * 100.0 / elapsedTicks:F0}% " +
string.Join(
" ",
parts.Select(part =>
$"{part.Phase}={part.Percent:F1}%" +
(part.Entries > 0 ? $"/n{part.Entries}" : string.Empty))));
if (OrderedActionDetailsEnabled && _orderedActions.Count != 0)
{
var ordered = _orderedActions
.OrderByDescending(static pair => pair.Value.Executed + pair.Value.Deferred)
.Take(12)
.Select(static pair =>
$"{pair.Key}=ok{pair.Value.Executed}/defer{pair.Value.Deferred}");
Console.Error.WriteLine($"[PERF][ORDERED] {string.Join(" ", ordered)}");
_orderedActions.Clear();
}
}
private static string GetOrderedActionCategory(string debugName)
{
if (debugName.EndsWith(" completion", StringComparison.Ordinal))
{
return "completion";
}
var firstSpace = debugName.IndexOf(' ');
if (firstSpace < 0)
{
return debugName;
}
// AGC labels conventionally begin with "agc <packet>". Keeping the
// packet token separates DMA, submit and register traffic without
// retaining guest addresses in the diagnostic key.
if (debugName.StartsWith("agc ", StringComparison.Ordinal))
{
var secondSpace = debugName.IndexOf(' ', firstSpace + 1);
return secondSpace < 0 ? debugName : debugName[..secondSpace];
}
return debugName[..firstSpace];
}
private struct OrderedActionStats
{
public long Executed;
public long Deferred;
}
}
+832
View File
@@ -0,0 +1,832 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using System.Runtime.InteropServices;
using SharpEmu.HLE.Host;
using SharpEmu.Libs.Pad;
using SDL;
using Silk.NET.Vulkan;
using static SDL.SDL3;
namespace SharpEmu.Libs.VideoOut;
internal enum SdlGraphicsApi
{
Vulkan,
Metal,
}
internal readonly record struct SdlHdrState(
bool Enabled,
float SdrWhiteLevel,
float Headroom);
internal sealed unsafe class SdlHostWindow : IDisposable, IHostGamepadOutput
{
private const SDL_InitFlags InitFlags = SDL_InitFlags.SDL_INIT_VIDEO | SDL_InitFlags.SDL_INIT_GAMEPAD;
private static readonly long CursorHideDelayTicks = 2 * Stopwatch.Frequency;
private const uint OutputDurationMs = 5_000;
private readonly HostVideoOptions _options;
private readonly SdlGraphicsApi _graphicsApi;
private readonly Action? _toggleBackendHud;
private readonly object _gamepadGate = new();
private SDL_Window* _window;
private nint _metalView;
private SDL_Gamepad* _gamepad;
private HostGamepadType _gamepadType;
private byte _leftTriggerRumble;
private byte _rightTriggerRumble;
private int _closeRequested;
private bool _closedByUser;
private bool _fullscreen;
private bool _focused = true;
private bool _cursorVisible = true;
private long _cursorHideDeadline;
private bool _surfaceRestorePending;
private bool _hdrStateChangePending;
private bool _disposed;
public SdlHostWindow(
string title,
HostVideoOptions options,
SdlGraphicsApi graphicsApi,
Action? toggleBackendHud = null)
{
_options = options.Normalize();
_graphicsApi = graphicsApi;
_toggleBackendHud = toggleBackendHud;
SdlGamepadStateReader.EnableSonyHidApi();
if (!SDL_InitSubSystem(InitFlags))
{
throw new InvalidOperationException($"SDL video initialization failed: {GetError()}");
}
var graphicsFlag = graphicsApi == SdlGraphicsApi.Vulkan
? SDL_WindowFlags.SDL_WINDOW_VULKAN
: SDL_WindowFlags.SDL_WINDOW_METAL;
var flags = graphicsFlag |
SDL_WindowFlags.SDL_WINDOW_RESIZABLE |
SDL_WindowFlags.SDL_WINDOW_HIGH_PIXEL_DENSITY |
SDL_WindowFlags.SDL_WINDOW_HIDDEN;
_window = CreateWindow(title, _options.Width, _options.Height, flags);
if (_window is null)
{
SDL_QuitSubSystem(InitFlags);
throw new InvalidOperationException($"SDL window creation failed: {GetError()}");
}
MoveToConfiguredDisplay();
ApplyConfiguredMode(_options.WindowMode);
SetIcon();
SDL_ShowWindow(_window);
SDL_RaiseWindow(_window);
if (_fullscreen && !SDL_SyncWindow(_window))
{
Console.Error.WriteLine($"[LOADER][WARN] SDL initial fullscreen sync failed: {GetError()}");
}
HostWindowInput.Connect(this);
OpenFirstGamepad();
Console.Error.WriteLine(
$"[LOADER][INFO] SDL3 window ready: mode={_options.WindowMode} " +
$"size={_options.Width}x{_options.Height} display={_options.DisplayIndex} " +
$"refresh={(_options.RefreshRate == 0 ? "auto" : _options.RefreshRate)}");
var hdr = HdrState;
Console.Error.WriteLine(
$"[LOADER][INFO] SDL3 display HDR: enabled={hdr.Enabled} " +
$"sdr_white={hdr.SdrWhiteLevel:F3} headroom={hdr.Headroom:F3}");
}
public (int Width, int Height) PixelSize
{
get
{
var width = 0;
var height = 0;
if (_window is not null)
{
SDL_GetWindowSizeInPixels(_window, &width, &height);
}
return (Math.Max(width, 1), Math.Max(height, 1));
}
}
public bool IsMinimized =>
_window is not null &&
(SDL_GetWindowFlags(_window) & SDL_WindowFlags.SDL_WINDOW_MINIMIZED) != 0;
public bool ClosedByUser => _closedByUser;
public SdlHdrState HdrState
{
get
{
if (_window is null)
{
return default;
}
var properties = SDL_GetWindowProperties(_window);
fixed (byte* hdrEnabledName = SDL_PROP_WINDOW_HDR_ENABLED_BOOLEAN)
fixed (byte* sdrWhiteName = SDL_PROP_WINDOW_SDR_WHITE_LEVEL_FLOAT)
fixed (byte* headroomName = SDL_PROP_WINDOW_HDR_HEADROOM_FLOAT)
{
return new SdlHdrState(
(bool)SDL_GetBooleanProperty(properties, hdrEnabledName, false),
SDL_GetFloatProperty(properties, sdrWhiteName, 1f),
SDL_GetFloatProperty(properties, headroomName, 1f));
}
}
}
public bool ConsumeSurfaceRestore()
{
var pending = _surfaceRestorePending;
_surfaceRestorePending = false;
return pending;
}
public bool ConsumeHdrStateChange()
{
var pending = _hdrStateChangePending;
_hdrStateChangePending = false;
return pending;
}
public byte** GetRequiredVulkanInstanceExtensions(out uint count)
{
EnsureGraphicsApi(SdlGraphicsApi.Vulkan);
uint extensionCount = 0;
var extensions = SDL_Vulkan_GetInstanceExtensions(&extensionCount);
count = extensionCount;
if (extensions is null || count == 0)
{
throw new InvalidOperationException($"SDL did not provide Vulkan instance extensions: {GetError()}");
}
return extensions;
}
public SurfaceKHR CreateVulkanSurface(Instance instance)
{
EnsureGraphicsApi(SdlGraphicsApi.Vulkan);
VkSurfaceKHR_T* surface = null;
if (!SDL_Vulkan_CreateSurface(
_window,
(VkInstance_T*)instance.Handle,
null,
&surface) || surface is null)
{
throw new InvalidOperationException($"SDL Vulkan surface creation failed: {GetError()}");
}
return new SurfaceKHR(unchecked((ulong)surface));
}
public nint CreateMetalLayer()
{
EnsureGraphicsApi(SdlGraphicsApi.Metal);
if (_metalView == 0)
{
_metalView = SDL_Metal_CreateView(_window);
if (_metalView == 0)
{
throw new InvalidOperationException($"SDL Metal view creation failed: {GetError()}");
}
}
var layer = SDL_Metal_GetLayer(_metalView);
if (layer == 0)
{
throw new InvalidOperationException($"SDL Metal layer lookup failed: {GetError()}");
}
return layer;
}
public void SetTitle(string title)
{
if (_window is null)
{
return;
}
var utf8 = Marshal.StringToCoTaskMemUTF8(title);
try
{
SDL_SetWindowTitle(_window, (byte*)utf8);
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
}
public void Close() => Volatile.Write(ref _closeRequested, 1);
public void SetRumble(byte largeMotor, byte smallMotor)
{
lock (_gamepadGate)
{
if (IsGamepadConnected())
{
SDL_RumbleGamepad(
_gamepad,
ExpandByte(largeMotor),
ExpandByte(smallMotor),
OutputDurationMs);
}
}
}
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
{
lock (_gamepadGate)
{
if (IsGamepadConnected())
{
if (leftTrigger is { } left)
{
_leftTriggerRumble = left;
}
if (rightTrigger is { } right)
{
_rightTriggerRumble = right;
}
SDL_RumbleGamepadTriggers(
_gamepad,
ExpandByte(_leftTriggerRumble),
ExpandByte(_rightTriggerRumble),
OutputDurationMs);
}
}
}
public void SetAdaptiveTriggerEffect(
HostAdaptiveTriggerEffect? leftTrigger,
HostAdaptiveTriggerEffect? rightTrigger)
{
lock (_gamepadGate)
{
if (!IsGamepadConnected())
{
return;
}
if (_gamepadType != HostGamepadType.DualSense)
{
if (leftTrigger is { } fallbackLeft)
{
_leftTriggerRumble = fallbackLeft.FallbackStrength;
}
if (rightTrigger is { } fallbackRight)
{
_rightTriggerRumble = fallbackRight.FallbackStrength;
}
SDL_RumbleGamepadTriggers(
_gamepad,
ExpandByte(_leftTriggerRumble),
ExpandByte(_rightTriggerRumble),
OutputDurationMs);
return;
}
Span<byte> state = stackalloc byte[47];
state.Clear();
if (rightTrigger is { } nativeRight)
{
state[0] |= 0x04;
nativeRight.CopyTo(state[10..21]);
}
if (leftTrigger is { } nativeLeft)
{
state[0] |= 0x08;
nativeLeft.CopyTo(state[21..32]);
}
if (state[0] == 0)
{
return;
}
fixed (byte* data = state)
{
SDL_SendGamepadEffect(_gamepad, (nint)data, state.Length);
}
}
}
public void SetLightbar(byte red, byte green, byte blue)
{
lock (_gamepadGate)
{
if (IsGamepadConnected())
{
SDL_SetGamepadLED(_gamepad, red, green, blue);
}
}
}
public void ResetLightbar() => SetLightbar(0, 0, 64);
public void Run(
Action initialize,
Action<double> render,
Action closing,
Action? idle = null)
{
initialize();
var timer = Stopwatch.StartNew();
var last = timer.Elapsed.TotalSeconds;
try
{
while (Volatile.Read(ref _closeRequested) == 0)
{
PumpEvents();
if (Volatile.Read(ref _closeRequested) != 0)
{
break;
}
UpdateCursorAutoHide();
SampleGamepad();
var now = timer.Elapsed.TotalSeconds;
render(now - last);
last = now;
if (IsMinimized)
{
SDL_Delay(10);
}
else
{
idle?.Invoke();
}
}
}
finally
{
closing();
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
SDL_ShowCursor();
HostWindowInput.Disconnect();
CloseGamepad();
if (_metalView != 0)
{
SDL_Metal_DestroyView(_metalView);
_metalView = 0;
}
if (_window is not null)
{
SDL_DestroyWindow(_window);
_window = null;
}
SDL_QuitSubSystem(InitFlags);
}
private void PumpEvents()
{
SDL_Event windowEvent;
while (SDL_PollEvent(&windowEvent))
{
switch (windowEvent.Type)
{
case SDL_EventType.SDL_EVENT_QUIT:
case SDL_EventType.SDL_EVENT_WINDOW_CLOSE_REQUESTED:
_closedByUser = true;
Volatile.Write(ref _closeRequested, 1);
break;
case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_GAINED:
_focused = true;
UpdateCursorVisibility();
HostWindowInput.SetFocused(true);
break;
case SDL_EventType.SDL_EVENT_WINDOW_FOCUS_LOST:
_focused = false;
UpdateCursorVisibility();
HostWindowInput.SetFocused(false);
break;
case SDL_EventType.SDL_EVENT_WINDOW_MINIMIZED:
_focused = false;
UpdateCursorVisibility();
HostWindowInput.SetFocused(false);
break;
case SDL_EventType.SDL_EVENT_WINDOW_RESTORED:
case SDL_EventType.SDL_EVENT_WINDOW_RESIZED:
case SDL_EventType.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:
case SDL_EventType.SDL_EVENT_WINDOW_MAXIMIZED:
case SDL_EventType.SDL_EVENT_WINDOW_ENTER_FULLSCREEN:
case SDL_EventType.SDL_EVENT_WINDOW_LEAVE_FULLSCREEN:
case SDL_EventType.SDL_EVENT_WINDOW_EXPOSED:
case SDL_EventType.SDL_EVENT_WINDOW_DISPLAY_CHANGED:
case SDL_EventType.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED:
_surfaceRestorePending = true;
break;
case SDL_EventType.SDL_EVENT_WINDOW_HDR_STATE_CHANGED:
_hdrStateChangePending = true;
break;
case SDL_EventType.SDL_EVENT_KEY_DOWN:
case SDL_EventType.SDL_EVENT_KEY_UP:
HandleKey(windowEvent.key);
break;
case SDL_EventType.SDL_EVENT_MOUSE_MOTION:
ShowCursorTemporarily();
break;
case SDL_EventType.SDL_EVENT_MOUSE_BUTTON_DOWN:
ShowCursorTemporarily();
if (windowEvent.button.button == 1 && windowEvent.button.clicks == 2)
{
ToggleFullscreen();
}
break;
case SDL_EventType.SDL_EVENT_GAMEPAD_ADDED:
if (_gamepad is null)
{
OpenFirstGamepad();
}
break;
case SDL_EventType.SDL_EVENT_GAMEPAD_REMOVED:
if (_gamepad is not null && !SDL_GamepadConnected(_gamepad))
{
CloseGamepad();
OpenFirstGamepad();
}
break;
}
}
}
private void HandleKey(SDL_KeyboardEvent keyEvent)
{
var down = keyEvent.type == SDL_EventType.SDL_EVENT_KEY_DOWN;
if (down && !keyEvent.repeat)
{
if (keyEvent.key == SDL_Keycode.SDLK_F1 &&
(keyEvent.mod & SDL_Keymod.SDL_KMOD_GUI) != 0 &&
_toggleBackendHud is not null)
{
_toggleBackendHud();
}
else if (keyEvent.key == SDL_Keycode.SDLK_F1)
{
PerfOverlay.Toggle();
}
else if (keyEvent.key == SDL_Keycode.SDLK_F11)
{
ToggleFullscreen();
}
}
if (TryMapVirtualKey(keyEvent.key, out var virtualKey))
{
HostWindowInput.SetKey(virtualKey, down);
}
}
private void ToggleFullscreen()
{
if (_fullscreen)
{
SDL_SetWindowFullscreen(_window, false);
SDL_SetWindowFullscreenMode(_window, null);
SDL_SetWindowResizable(_window, true);
SDL_SetWindowSize(_window, _options.Width, _options.Height);
MoveToConfiguredDisplay();
_fullscreen = false;
UpdateCursorVisibility();
return;
}
ApplyConfiguredMode(
_options.WindowMode == HostWindowMode.ExclusiveFullscreen
? HostWindowMode.ExclusiveFullscreen
: HostWindowMode.Borderless);
}
private void ApplyConfiguredMode(HostWindowMode mode)
{
if (mode == HostWindowMode.Windowed)
{
_fullscreen = false;
UpdateCursorVisibility();
return;
}
if (mode == HostWindowMode.ExclusiveFullscreen)
{
var display = GetConfiguredDisplay();
SDL_DisplayMode closest;
if (SDL_GetClosestFullscreenDisplayMode(
display,
_options.Width,
_options.Height,
_options.RefreshRate,
true,
&closest))
{
SDL_SetWindowFullscreenMode(_window, &closest);
}
else
{
Console.Error.WriteLine(
$"[LOADER][WARN] SDL exclusive mode unavailable; using borderless: {GetError()}");
SDL_SetWindowFullscreenMode(_window, null);
}
}
else
{
SDL_SetWindowFullscreenMode(_window, null);
}
SDL_SetWindowFullscreen(_window, true);
_fullscreen = true;
UpdateCursorVisibility();
}
private void UpdateCursorVisibility()
{
_cursorHideDeadline = 0;
SetCursorVisible(!_fullscreen || !_focused);
}
private void ShowCursorTemporarily()
{
if (!_fullscreen || !_focused)
{
return;
}
SetCursorVisible(true);
_cursorHideDeadline = Stopwatch.GetTimestamp() + CursorHideDelayTicks;
}
private void UpdateCursorAutoHide()
{
if (!_fullscreen || !_focused || !_cursorVisible || _cursorHideDeadline == 0 ||
Stopwatch.GetTimestamp() < _cursorHideDeadline)
{
return;
}
_cursorHideDeadline = 0;
SetCursorVisible(false);
}
private void SetCursorVisible(bool visible)
{
if (_cursorVisible == visible)
{
return;
}
if (visible)
{
SDL_ShowCursor();
}
else
{
SDL_HideCursor();
}
_cursorVisible = visible;
}
private void MoveToConfiguredDisplay()
{
var display = GetConfiguredDisplay();
SDL_Rect bounds;
if (SDL_GetDisplayBounds(display, &bounds))
{
var x = bounds.x + Math.Max(0, (bounds.w - _options.Width) / 2);
var y = bounds.y + Math.Max(0, (bounds.h - _options.Height) / 2);
SDL_SetWindowPosition(_window, x, y);
}
}
private SDL_DisplayID GetConfiguredDisplay()
{
using var displays = SDL_GetDisplays();
if (displays is null || displays.Count == 0)
{
return SDL_GetPrimaryDisplay();
}
return displays[Math.Clamp(_options.DisplayIndex, 0, displays.Count - 1)];
}
private void OpenFirstGamepad()
{
lock (_gamepadGate)
{
_gamepad = SdlGamepadStateReader.OpenPreferredGamepad();
if (_gamepad is null)
{
return;
}
_gamepadType = SdlGamepadStateReader.MapGamepadType(SDL_GetRealGamepadType(_gamepad));
EnableSensor(SDL_SensorType.SDL_SENSOR_ACCEL);
EnableSensor(SDL_SensorType.SDL_SENSOR_GYRO);
}
Console.Error.WriteLine(
$"[LOADER][INFO] SDL gamepad connected: {DescribeGamepad()} " +
$"type={_gamepadType} connection={SdlGamepadStateReader.GetConnection(_gamepad)}");
SampleGamepad();
}
private void CloseGamepad()
{
lock (_gamepadGate)
{
if (_gamepad is not null)
{
SDL_CloseGamepad(_gamepad);
_gamepad = null;
}
_gamepadType = HostGamepadType.Generic;
_leftTriggerRumble = 0;
_rightTriggerRumble = 0;
}
HostWindowInput.ClearGamepad();
}
private void SampleGamepad()
{
lock (_gamepadGate)
{
if (!IsGamepadConnected())
{
HostWindowInput.ClearGamepad();
return;
}
SDL_UpdateGamepads();
var state = SdlGamepadStateReader.Read(_gamepad) with
{
Motion = ReadMotion(),
Touch = ReadTouch(),
};
HostWindowInput.SetGamepad(
DescribeGamepad(),
state);
}
}
private void EnableSensor(SDL_SensorType sensor)
{
if (SDL_GamepadHasSensor(_gamepad, sensor))
{
SDL_SetGamepadSensorEnabled(_gamepad, sensor, true);
}
}
private HostMotionState ReadMotion()
{
Span<float> acceleration = stackalloc float[3];
Span<float> angularVelocity = stackalloc float[3];
acceleration.Clear();
angularVelocity.Clear();
var hasAcceleration = false;
var hasAngularVelocity = false;
fixed (float* data = acceleration)
{
hasAcceleration = SDL_GamepadHasSensor(_gamepad, SDL_SensorType.SDL_SENSOR_ACCEL) &&
SDL_GetGamepadSensorData(_gamepad, SDL_SensorType.SDL_SENSOR_ACCEL, data, acceleration.Length);
}
fixed (float* data = angularVelocity)
{
hasAngularVelocity = SDL_GamepadHasSensor(_gamepad, SDL_SensorType.SDL_SENSOR_GYRO) &&
SDL_GetGamepadSensorData(_gamepad, SDL_SensorType.SDL_SENSOR_GYRO, data, angularVelocity.Length);
}
return new HostMotionState(
hasAcceleration || hasAngularVelocity,
acceleration[0],
acceleration[1],
acceleration[2],
angularVelocity[0],
angularVelocity[1],
angularVelocity[2]);
}
private HostTouchState ReadTouch()
{
if (SDL_GetNumGamepadTouchpads(_gamepad) <= 0)
{
return default;
}
return new HostTouchState(ReadTouchPoint(0), ReadTouchPoint(1));
}
private HostTouchPoint ReadTouchPoint(int finger)
{
if (SDL_GetNumGamepadTouchpadFingers(_gamepad, 0) <= finger)
{
return default;
}
SDLBool down = false;
float x = 0;
float y = 0;
float pressure = 0;
if (!SDL_GetGamepadTouchpadFinger(_gamepad, 0, finger, &down, &x, &y, &pressure))
{
return default;
}
return new HostTouchPoint(down, (byte)finger, Math.Clamp(x, 0, 1), Math.Clamp(y, 0, 1));
}
private bool IsGamepadConnected() => _gamepad is not null && SDL_GamepadConnected(_gamepad);
private string DescribeGamepad() =>
Marshal.PtrToStringUTF8((nint)Unsafe_SDL_GetGamepadName(_gamepad)) ?? "SDL gamepad";
private static ushort ExpandByte(byte value) => (ushort)(value * 257);
private static bool TryMapVirtualKey(SDL_Keycode key, out int virtualKey)
{
virtualKey = key switch
{
SDL_Keycode.SDLK_BACKSPACE => 0x08,
SDL_Keycode.SDLK_TAB => 0x09,
SDL_Keycode.SDLK_RETURN => 0x0D,
SDL_Keycode.SDLK_ESCAPE => 0x1B,
SDL_Keycode.SDLK_LEFT => 0x25,
SDL_Keycode.SDLK_UP => 0x26,
SDL_Keycode.SDLK_RIGHT => 0x27,
SDL_Keycode.SDLK_DOWN => 0x28,
>= SDL_Keycode.SDLK_A and <= SDL_Keycode.SDLK_Z => 0x41 + (int)(key - SDL_Keycode.SDLK_A),
_ => 0,
};
return virtualKey != 0;
}
private void SetIcon()
{
if (!PngSplashLoader.TryLoadIcon(out var pixels, out var width, out var height))
{
return;
}
fixed (byte* data = pixels)
{
var surface = SDL_CreateSurfaceFrom(
(int)width,
(int)height,
SDL_PixelFormat.SDL_PIXELFORMAT_ABGR8888,
(nint)data,
checked((int)width * 4));
if (surface is not null)
{
SDL_SetWindowIcon(_window, surface);
SDL_DestroySurface(surface);
}
}
}
private static SDL_Window* CreateWindow(string title, int width, int height, SDL_WindowFlags flags)
{
var utf8 = Marshal.StringToCoTaskMemUTF8(title);
try
{
return SDL_CreateWindow((byte*)utf8, width, height, flags);
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
}
private static string GetError() =>
Marshal.PtrToStringUTF8((nint)Unsafe_SDL_GetError()) ?? "unknown SDL error";
private void EnsureGraphicsApi(SdlGraphicsApi expected)
{
if (_graphicsApi != expected)
{
throw new InvalidOperationException(
$"SDL host window uses {_graphicsApi}, not {expected}.");
}
}
}
+48 -30
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Logging;
using SharpEmu.HLE.Host;
using SharpEmu.Libs.Diagnostics;
using SharpEmu.Libs.Gpu;
@@ -69,11 +70,14 @@ public static class VideoOutExports
private static readonly Dictionary<int, VideoOutPortState> _ports = new();
private static int _presentationWindowCloseNotified;
private static int _vblankStopRequested;
private static int _hdrOutputRequested;
private static readonly Dictionary<(int Handle, int BufferIndex, ulong Address), ulong> _lastFrameFingerprints = new();
private static int _nextHandle = 1;
private static int _frameDumpCount;
private static long _nextFrameDumpIndex;
private static string _windowTitle = "SharpEmu VideoOut";
private static string _applicationWindowTitle = "VideoOut";
private static string _selectedGpuName = string.Empty;
private static string _applicationTitleId = "UNKNOWN";
private static readonly bool _logFrameRate = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"),
"1",
@@ -113,7 +117,18 @@ public static class VideoOutExports
var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}";
lock (_stateGate)
{
_windowTitle = $"SharpEmu - {application}{versionSuffix}";
_applicationTitleId = string.IsNullOrWhiteSpace(titleId)
? "UNKNOWN"
: titleId.Trim();
_applicationWindowTitle = $"{application}{versionSuffix}";
}
}
internal static string GetApplicationTitleId()
{
lock (_stateGate)
{
return _applicationTitleId;
}
}
@@ -121,7 +136,10 @@ public static class VideoOutExports
{
lock (_stateGate)
{
return _windowTitle;
var gpuSuffix = string.IsNullOrWhiteSpace(_selectedGpuName)
? string.Empty
: $" · {_selectedGpuName}";
return $"SharpEmu · {BuildInfo.CommitSha ?? "dev"} - {_applicationWindowTitle}{gpuSuffix}";
}
}
@@ -139,7 +157,7 @@ public static class VideoOutExports
: string.Empty;
lock (_stateGate)
{
_windowTitle = $"{_windowTitle} · {gpuName.Trim()}{backendSuffix}";
_selectedGpuName = $"{gpuName.Trim()}{backendSuffix}";
}
}
@@ -166,30 +184,17 @@ public static class VideoOutExports
private static void RequestHostShutdown(string reason)
{
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
var embedded = VulkanVideoHost.IsEmbedded;
AudioOutExports.ShutdownAllPorts();
Interlocked.Exchange(ref _vblankStopRequested, 1);
HostSessionControl.RequestShutdown(reason);
GuestGpu.Current.RequestClose();
// A hosted game can still be issuing AGC work after it requests its
// own shutdown. Keep the presenter's resources alive until the GUI
// session reaches its guest-safe exit path and disposes the host
// surface.
if (!embedded)
// Give guest and GPU threads a bounded window to leave cooperatively.
ThreadPool.QueueUserWorkItem(static _ =>
{
GuestGpu.Current.RequestClose();
}
// The embedded GUI owns the process lifetime. A guest shutdown should
// end only that session rather than terminating the launcher itself.
if (!embedded)
{
ThreadPool.QueueUserWorkItem(static _ =>
{
Thread.Sleep(2000);
Environment.Exit(0);
});
}
Thread.Sleep(2000);
Environment.Exit(0);
});
}
private sealed class VideoOutPortState
@@ -871,6 +876,8 @@ public static class VideoOutExports
}
}
internal static bool IsHdrOutputRequested => Volatile.Read(ref _hdrOutputRequested) != 0;
[SysAbiExport(
Nid = "MTxxrOCeSig",
ExportName = "sceVideoOutSetWindowModeMargins",
@@ -1175,16 +1182,21 @@ public static class VideoOutExports
var guestImageSubmitted = false;
ulong guestImageAddress = 0;
if (submitGpuImage &&
bufferIndex >= 0 &&
if (bufferIndex >= 0 &&
TryGetDisplayBufferInfo(handle, bufferIndex, out var displayBuffer))
{
Interlocked.Exchange(
ref _hdrOutputRequested,
IsHdrPixelFormat(displayBuffer.PixelFormat) ? 1 : 0);
guestImageAddress = displayBuffer.Address;
guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
displayBuffer.Address,
displayBuffer.Width,
displayBuffer.Height,
displayBuffer.PitchInPixel);
if (submitGpuImage)
{
guestImageSubmitted = GuestGpu.Current.TrySubmitGuestImage(
displayBuffer.Address,
displayBuffer.Width,
displayBuffer.Height,
displayBuffer.PitchInPixel);
}
}
if (_dumpVideoOut)
@@ -1711,6 +1723,12 @@ public static class VideoOutExports
internal static bool IsPacked10BitPixelFormat(ulong pixelFormat) =>
IsPacked10BitPixelFormatNormalized(NormalizePixelFormat(pixelFormat));
internal static bool IsHdrPixelFormat(ulong pixelFormat) =>
NormalizePixelFormat(pixelFormat) is
SceVideoOutPixelFormatA2R10G10B10Bt2020Pq or
SceVideoOutPixelFormat2R10G10B10A2Bt2100Pq or
SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq;
private static bool IsPacked10BitPixelFormatNormalized(ulong pixelFormat) =>
pixelFormat is
SceVideoOutPixelFormatA2R10G10B10 or
@@ -1,270 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
/// <summary>
/// A native child surface owned by a host UI. The presenter consumes this
/// directly, avoiding a second top-level GLFW window when the GUI is active.
/// </summary>
public enum VulkanHostSurfaceKind
{
Win32,
Xlib,
Metal,
}
/// <summary>
/// Platform-native handles required to create a Vulkan presentation surface.
/// The GUI owns their lifetime and updates the physical pixel size on resize.
/// </summary>
public sealed class VulkanHostSurface : IDisposable
{
private int _pixelWidth;
private int _pixelHeight;
private int _resizeGeneration;
private readonly bool _ownsDisplay;
private readonly bool _pollNativeSize;
private long _nextNativeSizePoll;
public VulkanHostSurface(
VulkanHostSurfaceKind kind,
nint windowHandle,
nint displayHandle = 0,
nint metalLayerHandle = 0,
bool ownsDisplay = false,
bool pollNativeSize = false)
{
Kind = kind;
WindowHandle = windowHandle;
DisplayHandle = displayHandle;
MetalLayerHandle = metalLayerHandle;
_ownsDisplay = ownsDisplay;
_pollNativeSize = pollNativeSize;
}
public VulkanHostSurfaceKind Kind { get; }
public nint WindowHandle { get; }
/// <summary>X11 Display* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Xlib"/>.</summary>
public nint DisplayHandle { get; }
/// <summary>CAMetalLayer* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Metal"/>.</summary>
public nint MetalLayerHandle { get; }
public int PixelWidth => Volatile.Read(ref _pixelWidth);
public int PixelHeight => Volatile.Read(ref _pixelHeight);
internal int ResizeGeneration => Volatile.Read(ref _resizeGeneration);
public void UpdatePixelSize(int width, int height)
{
width = Math.Max(width, 1);
height = Math.Max(height, 1);
if (Volatile.Read(ref _pixelWidth) == width && Volatile.Read(ref _pixelHeight) == height)
{
return;
}
Volatile.Write(ref _pixelWidth, width);
Volatile.Write(ref _pixelHeight, height);
Interlocked.Increment(ref _resizeGeneration);
}
/// <summary>
/// The child emulator cannot receive Avalonia resize notifications. Poll
/// the native host at a bounded rate so embedded child swapchains still
/// follow normal resize and F11 transitions.
/// </summary>
internal void RefreshChildProcessPixelSize()
{
if (!_pollNativeSize)
{
return;
}
var now = System.Diagnostics.Stopwatch.GetTimestamp();
var due = Volatile.Read(ref _nextNativeSizePoll);
if (now < due || Interlocked.CompareExchange(
ref _nextNativeSizePoll,
now + (System.Diagnostics.Stopwatch.Frequency / 8),
due) != due)
{
return;
}
if (Kind == VulkanHostSurfaceKind.Win32 && GetClientRect(WindowHandle, out var rect))
{
UpdatePixelSize(rect.Right - rect.Left, rect.Bottom - rect.Top);
return;
}
if (Kind == VulkanHostSurfaceKind.Xlib && DisplayHandle != 0 &&
XGetGeometry(
DisplayHandle,
WindowHandle,
out _,
out _,
out _,
out var width,
out var height,
out _,
out _) != 0)
{
UpdatePixelSize(unchecked((int)width), unchecked((int)height));
}
}
/// <summary>
/// Serializes a native child handle for a separately hosted emulator
/// process. Metal object pointers are process-local, so macOS falls back
/// to a standalone child window until an IPC Metal host is implemented.
/// </summary>
public bool TryGetChildProcessDescriptor(out string descriptor)
{
descriptor = string.Empty;
if (Kind == VulkanHostSurfaceKind.Metal || WindowHandle == 0)
{
return false;
}
var kind = Kind == VulkanHostSurfaceKind.Win32 ? "win32" : "xlib";
descriptor = string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"{kind}:{unchecked((ulong)WindowHandle):X}:{Math.Max(PixelWidth, 1)}:{Math.Max(PixelHeight, 1)}:{unchecked((ulong)DisplayHandle):X}");
return true;
}
/// <summary>
/// Reconstructs a surface in the isolated emulator process. X11 clients
/// must open their own Display connection; Display* values cannot cross a
/// process boundary.
/// </summary>
public static bool TryCreateChildProcessSurface(
string descriptor,
out VulkanHostSurface? surface,
out string? error)
{
surface = null;
error = null;
var parts = descriptor.Split(':');
if (parts.Length != 5 ||
!ulong.TryParse(parts[1], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var window) ||
!int.TryParse(parts[2], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var width) ||
!int.TryParse(parts[3], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var height) ||
!ulong.TryParse(parts[4], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var nativeDisplay))
{
error = "invalid host-surface descriptor";
return false;
}
if (window == 0 || width <= 0 || height <= 0)
{
error = "host-surface descriptor has an invalid size or handle";
return false;
}
if (string.Equals(parts[0], "win32", StringComparison.OrdinalIgnoreCase))
{
surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Win32,
unchecked((nint)window),
unchecked((nint)nativeDisplay),
pollNativeSize: true);
}
else if (string.Equals(parts[0], "xlib", StringComparison.OrdinalIgnoreCase))
{
var display = XOpenDisplay(0);
if (display == 0)
{
error = "could not open an X11 display for the host surface";
return false;
}
surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Xlib,
unchecked((nint)window),
display,
ownsDisplay: true,
pollNativeSize: true);
}
else
{
error = $"unsupported host-surface kind '{parts[0]}'";
return false;
}
surface.UpdatePixelSize(width, height);
return true;
}
public void Dispose()
{
if (_ownsDisplay && DisplayHandle != 0 && OperatingSystem.IsLinux())
{
_ = XCloseDisplay(DisplayHandle);
}
}
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
private static extern nint XOpenDisplay(nint displayName);
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
private static extern int XCloseDisplay(nint display);
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetClientRect", SetLastError = true)]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool GetClientRect(nint window, out Rect rect);
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XGetGeometry")]
private static extern int XGetGeometry(
nint display,
nint drawable,
out nint root,
out int x,
out int y,
out uint width,
out uint height,
out uint borderWidth,
out uint depth);
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
}
/// <summary>
/// Small public bridge between a desktop UI and the internal Vulkan
/// presenter. Launchers can host a surface without depending on renderer
/// submission internals.
/// </summary>
public static class VulkanVideoHost
{
/// <summary>
/// Raised after the first successful Vulkan present to an embedded host
/// surface. UI hosts use this to retire their launch affordance only once
/// a real frame can be seen.
/// </summary>
public static event Action<VulkanHostSurface>? FirstFramePresented
{
add => VulkanVideoPresenter.FirstHostFramePresented += value;
remove => VulkanVideoPresenter.FirstHostFramePresented -= value;
}
public static bool TryAttachSurface(VulkanHostSurface surface) =>
VulkanVideoPresenter.TryAttachHostSurface(surface);
public static void DetachSurface(VulkanHostSurface surface) =>
VulkanVideoPresenter.DetachHostSurface(surface);
public static void RequestClose() => VulkanVideoPresenter.RequestClose();
public static bool IsEmbedded => VulkanVideoPresenter.UsesHostSurface;
}
@@ -0,0 +1,96 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
internal static class VulkanPipelineCacheStorage
{
private const string CacheFileName = "vulkan-pipeline-cache.bin";
internal static string ResolvePath(string? titleId, string? configuredPath)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
return Path.GetFullPath(
Environment.ExpandEnvironmentVariables(configuredPath));
}
return Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory,
"user",
"pipeline_cache",
SanitizeTitleId(titleId),
CacheFileName));
}
internal static string GetLegacyPath()
{
var root = OperatingSystem.IsMacOS()
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Library",
"Caches")
: Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(root, "SharpEmu", CacheFileName);
}
internal static bool ImportLegacyCache(string legacyPath, string destinationPath)
{
if (File.Exists(destinationPath) || !File.Exists(legacyPath))
{
return false;
}
var directory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
try
{
File.Copy(legacyPath, destinationPath, overwrite: false);
try
{
File.Delete(legacyPath);
}
catch (IOException)
{
// The destination is valid; a locked legacy cache can remain
// as an unused artifact without affecting future writes.
}
catch (UnauthorizedAccessException)
{
// See above. Cache migration must not block game startup.
}
return true;
}
catch (IOException) when (File.Exists(destinationPath))
{
return false;
}
}
private static string SanitizeTitleId(string? titleId)
{
if (string.IsNullOrWhiteSpace(titleId))
{
return "UNKNOWN";
}
var source = titleId.Trim();
Span<char> sanitized = source.Length <= 128
? stackalloc char[source.Length]
: new char[source.Length];
for (var index = 0; index < source.Length; index++)
{
var value = source[index];
sanitized[index] = char.IsAsciiLetterOrDigit(value) || value is '-' or '_'
? char.ToUpperInvariant(value)
: '_';
}
return new string(sanitized);
}
}
File diff suppressed because it is too large Load Diff