diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
index 71e8669..3c36b57 100644
--- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
+++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs
@@ -3978,6 +3978,33 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
public bool SupportsGuestContextTransfer => true;
+ public bool TryGetGuestThreadStackBounds(
+ ulong threadHandle,
+ out ulong stackBase,
+ out ulong stackSize)
+ {
+ stackBase = 0;
+ stackSize = 0;
+ if (threadHandle == 0)
+ {
+ return false;
+ }
+
+ lock (_guestThreadGate)
+ {
+ if (!_guestThreads.TryGetValue(threadHandle, out var thread) ||
+ thread.StackBase == 0 ||
+ thread.StackSize == 0)
+ {
+ return false;
+ }
+
+ stackBase = thread.StackBase;
+ stackSize = thread.StackSize;
+ return true;
+ }
+ }
+
public void RegisterGuestThreadContext(ulong threadHandle, CpuContext context)
{
if (threadHandle == 0)
diff --git a/src/SharpEmu.HLE/GuestThreadExecution.cs b/src/SharpEmu.HLE/GuestThreadExecution.cs
index 4879358..2a1f013 100644
--- a/src/SharpEmu.HLE/GuestThreadExecution.cs
+++ b/src/SharpEmu.HLE/GuestThreadExecution.cs
@@ -50,6 +50,21 @@ public interface IGuestThreadScheduler
///
void RegisterGuestThreadContext(ulong threadHandle, CpuContext context);
+ ///
+ /// Returns the exact mapped guest stack owned by a scheduler thread. The
+ /// default keeps alternate/test schedulers source-compatible when they do
+ /// not own guest stack mappings.
+ ///
+ bool TryGetGuestThreadStackBounds(
+ ulong threadHandle,
+ out ulong stackBase,
+ out ulong stackSize)
+ {
+ stackBase = 0;
+ stackSize = 0;
+ return false;
+ }
+
bool TryStartThread(CpuContext creatorContext, GuestThreadStartRequest request, out string? error);
bool TryJoinThread(
diff --git a/src/SharpEmu.Libs/Gpu/GpuCommands/CommandStreamQueue.cs b/src/SharpEmu.Libs/Gpu/GpuCommands/CommandStreamQueue.cs
index d0a0de6..1db6ec3 100644
--- a/src/SharpEmu.Libs/Gpu/GpuCommands/CommandStreamQueue.cs
+++ b/src/SharpEmu.Libs/Gpu/GpuCommands/CommandStreamQueue.cs
@@ -26,6 +26,8 @@ public enum SliceResult
// Submissions of every queue; one thread runs the slices, other threads enqueue and wait.
public sealed class CommandStreamQueue
{
+ private sealed record ControlBarrier(ulong CutoffOrdinal, Action Work, Action Cancel);
+
public const int ComputeQueueCount = 56;
public const int QueueCount = 1 + ComputeQueueCount;
public const int AllBlockedRetryMilliseconds = 100;
@@ -37,8 +39,10 @@ public sealed class CommandStreamQueue
private readonly object _gate = new();
private readonly LinkedList[] _queues = new LinkedList[QueueCount];
private readonly GpuCommandInterpreter?[] _interpreters = new GpuCommandInterpreter?[QueueCount];
+ private readonly Queue _controlBarriers = new();
private int _nextQueue;
private int _submissionCount;
+ private ulong _admissionOrdinal;
private bool _accepting = true;
private bool _stopping;
private bool _processing;
@@ -91,7 +95,7 @@ public sealed class CommandStreamQueue
{
lock (_gate)
{
- return _submissionCount != 0;
+ return _submissionCount != 0 || _controlBarriers.Count != 0;
}
}
}
@@ -113,7 +117,7 @@ public sealed class CommandStreamQueue
{
lock (_gate)
{
- return SelectQueueLocked() >= 0;
+ return HasRunnableWorkLocked();
}
}
}
@@ -200,6 +204,7 @@ public sealed class CommandStreamQueue
throw _host.Fatal($"The command stream no longer accepts submissions: queue={submission.QueueId} address=0x{submission.Address:X16}.");
}
+ submission.AdmissionOrdinal = ++_admissionOrdinal;
_queues[submission.QueueId].AddLast(submission);
_submissionCount++;
if (submission.Kind is not (CommandSubmissionKind.FlipPreparation or CommandSubmissionKind.FrameBoundary))
@@ -208,6 +213,23 @@ public sealed class CommandStreamQueue
Monitor.PulseAll(_gate);
}
+ public bool TryEnqueueControlBarrier(Action work, Action cancel)
+ {
+ ArgumentNullException.ThrowIfNull(work);
+ ArgumentNullException.ThrowIfNull(cancel);
+ lock (_gate)
+ {
+ if (!_accepting)
+ {
+ return false;
+ }
+
+ _controlBarriers.Enqueue(new ControlBarrier(_admissionOrdinal, work, cancel));
+ Monitor.PulseAll(_gate);
+ return true;
+ }
+ }
+
public IdleOutcome Done()
{
if (_processingThread == Thread.CurrentThread)
@@ -257,7 +279,8 @@ public sealed class CommandStreamQueue
{
lock (_gate)
{
- while (_outcome == IdleOutcome.Completed && (_processing || _submissionCount != 0))
+ while (_outcome == IdleOutcome.Completed &&
+ (_processing || _submissionCount != 0 || _controlBarriers.Count != 0))
{
Monitor.Wait(_gate);
}
@@ -279,13 +302,13 @@ public sealed class CommandStreamQueue
{
lock (_gate)
{
- if (SelectQueueLocked() >= 0 || _stopping)
+ if (HasRunnableWorkLocked() || _stopping)
{
- return SelectQueueLocked() >= 0;
+ return HasRunnableWorkLocked();
}
Monitor.Wait(_gate, timeoutMilliseconds);
- return SelectQueueLocked() >= 0;
+ return HasRunnableWorkLocked();
}
}
@@ -295,7 +318,7 @@ public sealed class CommandStreamQueue
lock (_gate)
{
var deadline = Environment.TickCount64 + intervalMilliseconds;
- while (SelectQueueLocked() < 0)
+ while (!HasRunnableWorkLocked())
{
var remaining = deadline - Environment.TickCount64;
if (remaining <= 0)
@@ -313,10 +336,15 @@ public sealed class CommandStreamQueue
// Publishes a failure: nothing runs any more and every waiter is released with Failed.
public void Fail()
{
+ Action[] cancellations;
lock (_gate)
{
FailLocked();
+ cancellations = CancelControlBarriersLocked();
}
+
+ foreach (var cancel in cancellations)
+ cancel();
}
private void FailLocked()
@@ -364,7 +392,8 @@ public sealed class CommandStreamQueue
// Runs one slice of the next unblocked submission on the calling thread.
public SliceResult ProcessOne()
{
- CommandSubmission submission;
+ CommandSubmission? submission = null;
+ ControlBarrier? control = null;
lock (_gate)
{
if (_processing)
@@ -372,35 +401,73 @@ public sealed class CommandStreamQueue
throw _host.Fatal("The command stream queue is already processing a slice.");
}
- var selected = SelectQueueLocked();
- if (selected < 0)
+ if (IsControlBarrierReadyLocked())
{
- return _submissionCount != 0 ? SliceResult.AllBlocked : SliceResult.NoWork;
+ control = _controlBarriers.Dequeue();
+ }
+ else
+ {
+ var selected = SelectQueueLocked();
+ if (selected < 0)
+ {
+ return _submissionCount != 0 ? SliceResult.AllBlocked : SliceResult.NoWork;
+ }
+
+ var queue = _queues[selected];
+ submission = queue.First!.Value;
+ queue.RemoveFirst();
+ _submissionCount--;
+ _nextQueue = (selected + 1) % QueueCount;
}
- var queue = _queues[selected];
- submission = queue.First!.Value;
- queue.RemoveFirst();
- _submissionCount--;
- _nextQueue = (selected + 1) % QueueCount;
_processing = true;
_processingThread = Thread.CurrentThread;
- if (!submission.Started && submission.Kind is not (CommandSubmissionKind.FlipPreparation or CommandSubmissionKind.FrameBoundary))
+ if (submission is { Started: false } &&
+ submission.Kind is not (CommandSubmissionKind.FlipPreparation or CommandSubmissionKind.FrameBoundary))
SubmissionFlowProfile.Record(SubmissionFlowProfile.EventKind.ProcessingStarted, submission.QueueId,
submission.SubmissionId, submission.Address, submission.DwordCount, _submissionCount);
}
+ if (control is not null)
+ {
+ try
+ {
+ control.Work();
+ }
+ catch (Exception exception)
+ {
+ try
+ {
+ Console.Error.WriteLine($"[GPU][ERROR] Command stream control barrier failed. {exception}");
+ }
+ catch (Exception)
+ {
+ }
+
+ Fail();
+ throw;
+ }
+
+ lock (_gate)
+ {
+ _processing = false;
+ Monitor.PulseAll(_gate);
+ }
+
+ return SliceResult.Completed;
+ }
+
bool complete;
try
{
- complete = RunSlice(submission);
+ complete = RunSlice(submission!);
}
catch (Exception exception)
{
try
{
// Report the cause before another submitting thread observes the failed queue.
- Console.Error.WriteLine($"[GPU][ERROR] Command stream slice failed: queue={submission.QueueId} address=0x{submission.Address:X16}. {exception}");
+ Console.Error.WriteLine($"[GPU][ERROR] Command stream slice failed: queue={submission!.QueueId} address=0x{submission.Address:X16}. {exception}");
}
catch (Exception)
{
@@ -416,7 +483,7 @@ public sealed class CommandStreamQueue
SliceResult result;
if (!complete)
{
- submission.Blocked = true;
+ submission!.Blocked = true;
submission.BlockedSinceTicks = System.Diagnostics.Stopwatch.GetTimestamp();
submission.BlockedPacketAddress = submission.Commands.CurrentPacketAddress;
_queues[submission.QueueId].AddFirst(submission);
@@ -597,12 +664,17 @@ public sealed class CommandStreamQueue
// Drops every submission without stopping admission; the presenter uses it after device loss.
public void DiscardAll()
{
+ Action[] cancellations;
lock (_gate)
{
_outcome = IdleOutcome.Failed;
DropAllLocked();
+ cancellations = CancelControlBarriersLocked();
Monitor.PulseAll(_gate);
}
+
+ foreach (var cancel in cancellations)
+ cancel();
}
// A diagnostic view of the blocked heads: their count, the oldest age and one sample.
@@ -669,6 +741,20 @@ public sealed class CommandStreamQueue
private int SelectQueueLocked()
{
+ if (_controlBarriers.TryPeek(out var control))
+ {
+ for (var offset = 0; offset < QueueCount; offset++)
+ {
+ var queueId = (_nextQueue + offset) % QueueCount;
+ if (_queues[queueId].First is { } head &&
+ head.Value.AdmissionOrdinal <= control.CutoffOrdinal &&
+ !head.Value.Blocked)
+ {
+ return queueId;
+ }
+ }
+ }
+
for (var offset = 0; offset < QueueCount; offset++)
{
var queueId = (_nextQueue + offset) % QueueCount;
@@ -681,6 +767,42 @@ public sealed class CommandStreamQueue
return -1;
}
+ private bool HasRunnableWorkLocked() => IsControlBarrierReadyLocked() || SelectQueueLocked() >= 0;
+
+ private bool IsControlBarrierReadyLocked()
+ {
+ if (!_controlBarriers.TryPeek(out var control))
+ {
+ return false;
+ }
+
+ foreach (var queue in _queues)
+ {
+ if (queue.First is { } head && head.Value.AdmissionOrdinal <= control.CutoffOrdinal)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private Action[] CancelControlBarriersLocked()
+ {
+ if (_controlBarriers.Count == 0)
+ {
+ return [];
+ }
+
+ var cancellations = new Action[_controlBarriers.Count];
+ for (var index = 0; index < cancellations.Length; index++)
+ {
+ cancellations[index] = _controlBarriers.Dequeue().Cancel;
+ }
+
+ return cancellations;
+ }
+
private void DropAllLocked()
{
foreach (var queue in _queues)
diff --git a/src/SharpEmu.Libs/Gpu/GpuCommands/CommandSubmission.cs b/src/SharpEmu.Libs/Gpu/GpuCommands/CommandSubmission.cs
index 701022d..5ec2b47 100644
--- a/src/SharpEmu.Libs/Gpu/GpuCommands/CommandSubmission.cs
+++ b/src/SharpEmu.Libs/Gpu/GpuCommands/CommandSubmission.cs
@@ -101,6 +101,8 @@ public sealed class CommandSubmission
// The submitter's own sequence number, kept for diagnostics and the translation state.
public ulong SubmissionId { get; }
+ internal ulong AdmissionOrdinal { get; set; }
+
// Snapshots captured at submit time; the processor never reads them.
public object? GeometrySnapshots { get; }
diff --git a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
index 96120cf..c8b3160 100644
--- a/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/IGuestGpuBackend.cs
@@ -57,6 +57,11 @@ internal interface IGuestGpuBackend
// Marks the frame boundary; off the worker it first waits for the accepted submissions.
IdleOutcome SubmitDone(ICpuMemory memory);
+ void RunAfterPendingCommandStreams(Action work)
+ {
+ work();
+ }
+
/// Registers a display buffer with its guest texture format tag.
void RegisterKnownDisplayBuffer(ulong address, uint guestFormat);
diff --git a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.cs b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.cs
index 94728e9..8d50e0a 100644
--- a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.cs
+++ b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.cs
@@ -126,6 +126,17 @@ public sealed unsafe partial class GuestImageCache : IGuestImageCache, IGuestIma
var found = LookUpImage(ref request, exactFormat);
if (request.Role is ImageRole.Texture or ImageRole.StorageImage)
{
+ if (request.Description.Metadata.Kind == MetadataKind.Dcc)
+ {
+ using var held = _lock.Hold();
+ var image = _slots[found];
+ if (image.Description.Metadata.Kind == MetadataKind.None)
+ {
+ image.Description.Metadata.Kind = MetadataKind.Dcc;
+ image.Description.Metadata.Range = request.Description.Metadata.Range;
+ }
+ }
+
ref readonly var description = ref _slots[found].Description;
if (description.DccSliceSize is var sliceSize and not 0)
{
diff --git a/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.DepthTarget.cs b/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.DepthTarget.cs
index c0b7dc3..8a0c357 100644
--- a/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.DepthTarget.cs
+++ b/src/SharpEmu.Libs/Gpu/Images/ImageRequestBuilders.DepthTarget.cs
@@ -58,8 +58,6 @@ public static partial class ImageRequestBuilders
return Format.Undefined;
}
- private static bool HtileStencilCompatible(bool hasStencil, bool hasHtile, bool htileStencilDisabled) => !hasStencil || !hasHtile || htileStencilDisabled;
-
// Builds the request for the bound depth target. Null when no depth or stencil state is active.
public static DepthTargetResolution? DepthTarget(in DepthTargetWords depthWords, IImageFormatSupport device) =>
DepthTargetCore(in depthWords, device, copyMode: false, writeBuffer: false);
@@ -127,7 +125,7 @@ public static partial class ImageRequestBuilders
if (hasStencil)
{
- if (depthWords.StencilFormat != GuestStencilFormat.Stencil8UInt || !HtileStencilCompatible(hasStencil, hasHtile, depthWords.HtileStencilDisabled) ||
+ if (depthWords.StencilFormat != GuestStencilFormat.Stencil8UInt ||
stencilAddress == 0 || (!copyMode && !depthWords.StencilWriteDisabled && depthWords.StencilWriteBase != depthWords.StencilReadBase) || (stencilAddress & 0xFFFF) != 0)
{
throw SubmissionScheduler.Fatal(
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
index 0ba5ab0..9c67fdb 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalGuestGpuBackend.cs
@@ -124,6 +124,57 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend, IGuestImageSnapsh
public IdleOutcome SubmitDone(ICpuMemory memory) => EnsureCommandStream(memory).Done();
+ public void RunAfterPendingCommandStreams(Action work)
+ {
+ ArgumentNullException.ThrowIfNull(work);
+
+ CommandStreamQueue? commandStream;
+ CommandStreamWorker? worker;
+ lock (_commandStreamGate)
+ {
+ commandStream = _commandStream;
+ worker = _commandStreamWorker;
+ }
+
+ if (commandStream is null || worker?.IsWorkerThread == true)
+ {
+ work();
+ return;
+ }
+
+ using var done = new SemaphoreSlim(0);
+ System.Runtime.ExceptionServices.ExceptionDispatchInfo? failure = null;
+ if (!commandStream.TryEnqueueControlBarrier(
+ () =>
+ {
+ try
+ {
+ work();
+ }
+ catch (Exception exception)
+ {
+ failure = System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception);
+ }
+ finally
+ {
+ done.Release();
+ }
+ },
+ () =>
+ {
+ failure = System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(
+ new OperationCanceledException("The command stream stopped before the video-out state change could run."));
+ done.Release();
+ }))
+ {
+ work();
+ return;
+ }
+
+ done.Wait();
+ failure?.Throw();
+ }
+
public void RegisterKnownDisplayBuffer(ulong address, uint guestFormat) =>
MetalVideoPresenter.RegisterKnownDisplayBuffer(address, guestFormat);
diff --git a/src/SharpEmu.Libs/Gpu/Scheduling/GpuWorkerRelay.cs b/src/SharpEmu.Libs/Gpu/Scheduling/GpuWorkerRelay.cs
index 19e67f0..028e46b 100644
--- a/src/SharpEmu.Libs/Gpu/Scheduling/GpuWorkerRelay.cs
+++ b/src/SharpEmu.Libs/Gpu/Scheduling/GpuWorkerRelay.cs
@@ -14,16 +14,60 @@ public sealed class GpuWorkerRelay : IGpuQueueRelay
private readonly object _gate = new();
private readonly Queue _commands = new();
private readonly Action _wake;
+ private readonly Func? _tryEnqueueAfterPendingWork;
private int _pendingCount;
private bool _accepting = true;
- public GpuWorkerRelay(Action wake)
+ public GpuWorkerRelay(Action wake, Func? tryEnqueueAfterPendingWork = null)
{
_wake = wake;
+ _tryEnqueueAfterPendingWork = tryEnqueueAfterPendingWork;
}
public bool TryRunAfterPendingWork(Action work) => TryRunOnGpuQueue(work);
+ public bool TryRunAfterAcceptedCommandStreams(Action work)
+ {
+ ArgumentNullException.ThrowIfNull(work);
+ if (IsGpuQueueThread || _tryEnqueueAfterPendingWork is null)
+ {
+ return TryRunOnGpuQueue(work);
+ }
+
+ using var done = new SemaphoreSlim(0);
+ System.Runtime.ExceptionServices.ExceptionDispatchInfo? failure = null;
+ if (!_tryEnqueueAfterPendingWork(
+ () =>
+ {
+ try
+ {
+ work();
+ }
+ catch (Exception exception)
+ {
+ failure = System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception);
+ }
+ finally
+ {
+ done.Release();
+ }
+ },
+ () =>
+ {
+ failure = System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(
+ new OperationCanceledException(
+ "The command stream stopped before the ordered GPU worker action could run."));
+ done.Release();
+ }))
+ {
+ return false;
+ }
+
+ done.Wait();
+ failure?.Throw();
+ return true;
+ }
+
public bool IsGpuQueueThread => _boundWorker == this;
public bool HasPendingCommands => Volatile.Read(ref _pendingCount) != 0;
diff --git a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
index 2d8eec6..5ca67cc 100644
--- a/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
+++ b/src/SharpEmu.Libs/Gpu/Vulkan/VulkanGuestGpuBackend.cs
@@ -66,6 +66,9 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
public IdleOutcome SubmitDone(ICpuMemory memory) =>
VulkanVideoPresenter.SubmitDone(memory);
+ public void RunAfterPendingCommandStreams(Action work) =>
+ VulkanVideoPresenter.RunAfterPendingCommandStreams(work);
+
public void RegisterKnownDisplayBuffer(ulong address, uint guestFormat) =>
VulkanVideoPresenter.RegisterKnownDisplayBuffer(address, guestFormat);
diff --git a/src/SharpEmu.Libs/Kernel/KernelEventQueueCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelEventQueueCompatExports.cs
index 0b5d967..061afd8 100644
--- a/src/SharpEmu.Libs/Kernel/KernelEventQueueCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelEventQueueCompatExports.cs
@@ -161,20 +161,43 @@ public static class KernelEventQueueCompatExports
}
public bool Remove(ulong ident, short filter)
+ => RemoveAll(ident, filter) != 0;
+
+ public int RemoveAll(ulong ident, short filter)
{
- var index = FindIndex(ident, filter);
- if (index < 0)
+ var originalCount = Count;
+ var writeIndex = 0;
+ for (var readIndex = 0; readIndex < originalCount; readIndex++)
{
- return false;
+ var candidate = this[readIndex];
+ if (candidate.Ident == ident && candidate.Filter == filter)
+ {
+ continue;
+ }
+
+ this[writeIndex++] = candidate;
}
- for (var i = index; i + 1 < Count; i++)
+ Count = writeIndex;
+ return originalCount - writeIndex;
+ }
+
+ public int UpdateUserData(ulong ident, short filter, ulong userData)
+ {
+ var updatedCount = 0;
+ for (var index = 0; index < Count; index++)
{
- this[i] = this[i + 1];
+ var candidate = this[index];
+ if (candidate.Ident != ident || candidate.Filter != filter)
+ {
+ continue;
+ }
+
+ this[index] = candidate with { UserData = userData };
+ updatedCount++;
}
- Count--;
- return true;
+ return updatedCount;
}
}
@@ -824,52 +847,55 @@ public static class KernelEventQueueCompatExports
}
var waiterId = Interlocked.Increment(ref _nextEventQueueWaiterId);
- if (timeoutAddress == 0)
- {
- var requestedBlock = GuestThreadExecution.RequestCurrentThreadBlock(
- ctx,
- "sceKernelWaitEqueue",
- state.WakeKey,
- new EqueueWaiter
- {
- Ctx = ctx,
- State = state,
- EventsAddress = eventsAddress,
- EventCapacity = eventCapacity,
- OutCountAddress = outCountAddress,
- WaiterId = waiterId,
- });
- if (requestedBlock)
+ var blockDeadlineTimestamp = timeoutAddress == 0
+ ? 0
+ : GuestThreadExecution.ComputeDeadlineTimestamp(
+ TimeSpan.FromMicroseconds(timeoutUsec));
+ var requestedBlock = GuestThreadExecution.RequestCurrentThreadBlock(
+ ctx,
+ "sceKernelWaitEqueue",
+ state.WakeKey,
+ new EqueueWaiter
{
- var wakeAfterRegistration = false;
- lock (_eventQueueGate)
- {
- wakeAfterRegistration =
- !IsLiveEventQueueLocked(state) ||
- HasPendingEventsLocked(state.Handle);
- }
-
- if (wakeAfterRegistration)
- {
- WakeEventQueue(
- state,
- _logEqueue
- ? "source=post-registration-state-check"
- : null);
- }
-
- if (_logEqueue)
- {
- TraceEventQueue(
- ctx,
- "wait-block",
- handle,
- $"generation={state.Generation} waiter={waiterId} " +
- $"capacity={eventCapacity} timeout=infinite " +
- $"events=0x{eventsAddress:X16} out_count=0x{outCountAddress:X16}");
- }
- return (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ Ctx = ctx,
+ State = state,
+ EventsAddress = eventsAddress,
+ EventCapacity = eventCapacity,
+ OutCountAddress = outCountAddress,
+ WaiterId = waiterId,
+ },
+ blockDeadlineTimestamp);
+ if (requestedBlock)
+ {
+ var wakeAfterRegistration = false;
+ lock (_eventQueueGate)
+ {
+ wakeAfterRegistration =
+ !IsLiveEventQueueLocked(state) ||
+ HasPendingEventsLocked(state.Handle);
}
+
+ if (wakeAfterRegistration)
+ {
+ WakeEventQueue(
+ state,
+ _logEqueue
+ ? "source=post-registration-state-check"
+ : null);
+ }
+
+ if (_logEqueue)
+ {
+ TraceEventQueue(
+ ctx,
+ "wait-block",
+ handle,
+ $"generation={state.Generation} waiter={waiterId} " +
+ $"capacity={eventCapacity} timeout=" +
+ (timeoutAddress == 0 ? "infinite " : $"{timeoutUsec}_usec ") +
+ $"events=0x{eventsAddress:X16} out_count=0x{outCountAddress:X16}");
+ }
+ return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (timeoutAddress != 0)
@@ -1141,9 +1167,10 @@ public static class KernelEventQueueCompatExports
}
var key = (ident, filter);
+ var isExistingRegistration = events.ContainsKey(key);
var isNewReadRegistration =
filter == KernelEventFilterRead &&
- !events.ContainsKey(key);
+ !isExistingRegistration;
events[(ident, filter)] = new KernelEventRegistration(
ident,
filter,
@@ -1152,6 +1179,11 @@ public static class KernelEventQueueCompatExports
lowWater,
unchecked((ulong)Interlocked.Increment(
ref _nextEventRegistrationGeneration)));
+ if (isExistingRegistration &&
+ _pendingEvents.TryGetValue(handle, out var pending))
+ {
+ _ = pending.UpdateUserData(ident, filter, userData);
+ }
if (isNewReadRegistration)
{
_readEventRegistrationCount++;
@@ -1296,7 +1328,7 @@ public static class KernelEventQueueCompatExports
if (_pendingEvents.TryGetValue(handle, out var pending))
{
- _ = pending.Remove(ident, filter);
+ _ = pending.RemoveAll(ident, filter);
}
return true;
@@ -1387,22 +1419,13 @@ public static class KernelEventQueueCompatExports
_pendingEvents[handle] = queue;
}
- // Keep each interrupt as a separate event. Limit growth when the queue is not read.
- var queuedEvent = new KernelQueuedEvent(
+ queue.AddLast(new KernelQueuedEvent(
registration.Ident,
registration.Filter,
registration.Flags,
1,
data,
- registration.UserData);
- if (CountPendingEvents(queue, registration.Ident, registration.Filter) < 256)
- {
- queue.AddLast(queuedEvent);
- }
- else
- {
- QueueOrUpdateEvent(queue, queuedEvent);
- }
+ registration.UserData));
(wakeQueues ??= []).Add(state);
triggeredCount++;
@@ -1630,24 +1653,6 @@ public static class KernelEventQueueCompatExports
}
}
- private static int CountPendingEvents(
- KernelEventDeque queue,
- ulong ident,
- short filter)
- {
- var count = 0;
- for (var i = 0; i < queue.Count; i++)
- {
- var pending = queue[i];
- if (pending.Ident == ident && pending.Filter == filter)
- {
- count++;
- }
- }
-
- return count;
- }
-
private static void PollReadEvents(object? state)
{
_ = state;
diff --git a/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs
index 16b86ca..e423acd 100644
--- a/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelPthreadExtendedCompatExports.cs
@@ -548,23 +548,38 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
+ ulong stackAddress = 0;
+ ulong stackSize = 0;
+ var hasStackBounds = false;
+ var scheduler = GuestThreadExecution.Scheduler;
+ if (scheduler is not null &&
+ scheduler.TryGetGuestThreadStackBounds(thread, out stackAddress, out stackSize) &&
+ IsValidStackBounds(stackAddress, stackSize))
+ {
+ hasStackBounds = true;
+ }
+ else
+ {
+ stackAddress = 0;
+ stackSize = 0;
+ if (thread == KernelPthreadState.GetCurrentThreadHandle() &&
+ TryInferNativeGuestStack(ctx[CpuRegister.Rsp], out stackAddress))
+ {
+ stackSize = NativeGuestStackSize;
+ hasStackBounds = true;
+ }
+ }
+
lock (_stateGate)
{
var threadState = GetOrCreateThreadStateLocked(thread);
- // The native executor maps guest pthread stacks itself, after the
- // kernel-facing thread object has been created. Report that live
- // mapping when a thread asks for its own attributes. IL2CPP's
- // conservative collector uses these two fields to register the stack;
- // returning the default null address lets it recycle objects that are
- // still reachable only from guest registers/stack frames.
- if (thread == KernelPthreadState.GetCurrentThreadHandle() &&
- TryInferNativeGuestStack(ctx[CpuRegister.Rsp], out var stackAddress))
+ if (hasStackBounds)
{
threadState.Attributes = threadState.Attributes with
{
StackAddress = stackAddress,
- StackSize = NativeGuestStackSize,
+ StackSize = stackSize,
};
}
_attrStates[outAttrAddress] = threadState.Attributes;
@@ -574,6 +589,11 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
+ private static bool IsValidStackBounds(ulong stackAddress, ulong stackSize) =>
+ stackAddress != 0 &&
+ stackSize != 0 &&
+ stackAddress <= ulong.MaxValue - stackSize;
+
private static bool TryInferNativeGuestStack(ulong stackPointer, out ulong stackAddress)
{
stackAddress = 0;
diff --git a/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs
index 5dfe103..c17f8c5 100644
--- a/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs
@@ -73,7 +73,7 @@ public static class KernelSemaphoreCompatExports
Count = initialCount,
};
- if (!TryWriteUInt32(ctx, semaphoreAddress, handle))
+ if (!ctx.TryWriteUInt64(semaphoreAddress, handle))
{
_semaphores.TryRemove(handle, out _);
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
@@ -113,6 +113,27 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
+ return WaitSemaphoreCore(
+ ctx,
+ semaphore,
+ handle,
+ needCount,
+ hasTimeout: timeoutAddress != 0,
+ timeoutAddress,
+ timeoutUsec,
+ "sceKernelWaitSema");
+ }
+
+ private static int WaitSemaphoreCore(
+ CpuContext ctx,
+ KernelSemaphoreState semaphore,
+ uint handle,
+ int needCount,
+ bool hasTimeout,
+ ulong timeoutAddress,
+ uint timeoutUsec,
+ string operationName)
+ {
lock (semaphore.Gate)
{
if (semaphore.Count >= needCount)
@@ -125,7 +146,7 @@ public static class KernelSemaphoreCompatExports
if (_traceSema)
{
- TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
+ TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(hasTimeout ? timeoutUsec : "infinite")}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -137,7 +158,7 @@ public static class KernelSemaphoreCompatExports
// tokens (so a wake commits the acquisition), while the resume
// handler distinguishes a real acquisition from a deadline expiry.
var acquired = false;
- var deadline = timeoutAddress != 0
+ var deadline = hasTimeout
? GuestThreadExecution.ComputeDeadlineTimestamp(TimeSpan.FromMicroseconds(timeoutUsec))
: 0;
@@ -187,7 +208,7 @@ public static class KernelSemaphoreCompatExports
if (GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
- "sceKernelWaitSema",
+ operationName,
GetSemaphoreWakeKey(handle),
ResumeWait,
WakePredicate,
@@ -216,14 +237,21 @@ public static class KernelSemaphoreCompatExports
if (_traceSema)
{
- TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
+ TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(hasTimeout ? timeoutUsec : "infinite")} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
// Not a guest thread (or no scheduler): fall back to a host-thread
// wait so the semantics still hold on non-cooperative callers.
- return WaitSemaphoreOnHostThread(ctx, semaphore, handle, needCount, timeoutAddress, timeoutUsec);
+ return WaitSemaphoreOnHostThread(
+ ctx,
+ semaphore,
+ handle,
+ needCount,
+ hasTimeout,
+ timeoutAddress,
+ timeoutUsec);
}
private static int WaitSemaphoreOnHostThread(
@@ -231,46 +259,62 @@ public static class KernelSemaphoreCompatExports
KernelSemaphoreState semaphore,
uint handle,
int needCount,
+ bool hasTimeout,
ulong timeoutAddress,
uint timeoutUsec)
{
- var deadlineMs = timeoutAddress != 0
+ var deadlineMs = hasTimeout
? Environment.TickCount64 + Math.Max(1L, timeoutUsec / 1000L)
: long.MaxValue;
- lock (semaphore.Gate)
+ if (_traceSema)
{
- if (_traceSema)
+ lock (semaphore.Gate)
{
TraceSemaphore(
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
- $"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
+ $"count={semaphore.Count} timeout={(hasTimeout ? timeoutUsec : "infinite")} {FormatCallSite(ctx)}");
}
- while (semaphore.Count < needCount)
+ }
+
+ while (true)
+ {
+ lock (semaphore.Gate)
{
+ if (semaphore.Count >= needCount)
+ {
+ semaphore.Count -= needCount;
+ semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
+ if (_traceSema)
+ {
+ TraceSemaphore(
+ $"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
+ }
+ if (timeoutAddress != 0)
+ {
+ _ = TryWriteUInt32(ctx, timeoutAddress, 0);
+ }
+
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
+ }
+
var remaining = deadlineMs - Environment.TickCount64;
- if (timeoutAddress != 0 && remaining <= 0)
+ if (hasTimeout && remaining <= 0)
{
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
- _ = TryWriteUInt32(ctx, timeoutAddress, 0);
+ if (timeoutAddress != 0)
+ {
+ _ = TryWriteUInt32(ctx, timeoutAddress, 0);
+ }
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
}
- Monitor.Wait(semaphore.Gate, (int)Math.Min(remaining, 100));
+ var waitMilliseconds = hasTimeout
+ ? (int)Math.Max(1L, Math.Min(remaining, 10L))
+ : 10;
+ Monitor.Wait(semaphore.Gate, waitMilliseconds);
}
- semaphore.Count -= needCount;
- semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
- if (_traceSema)
- {
- TraceSemaphore(
- $"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
- }
- if (timeoutAddress != 0)
- {
- _ = TryWriteUInt32(ctx, timeoutAddress, 0);
- }
-
- return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
+ GuestThreadExecution.Scheduler?.DeliverPendingGuestExceptionIfReady(ctx);
}
}
@@ -328,7 +372,7 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
}
- if (signalCount <= 0)
+ if (signalCount < 0)
{
signalTrace.Record(SemaphoreSignalProfile.Stage.Rejected,
result: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
@@ -532,6 +576,35 @@ public static class KernelSemaphoreCompatExports
: result;
}
+ [SysAbiExport(
+ Nid = "fjN6NQHhK8k",
+ ExportName = "scePthreadSemTimedwait",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libKernel")]
+ public static int PthreadSemTimedWait(CpuContext ctx)
+ {
+ var timeoutUsec = unchecked((uint)ctx[CpuRegister.Rsi]);
+ if (!TryGetPosixSemaphoreHandle(ctx, ctx[CpuRegister.Rdi], out var handle))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ if (!_semaphores.TryGetValue(handle, out var semaphore))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
+ }
+
+ return WaitSemaphoreCore(
+ ctx,
+ semaphore,
+ handle,
+ needCount: 1,
+ hasTimeout: true,
+ timeoutAddress: 0,
+ timeoutUsec,
+ "scePthreadSemTimedwait");
+ }
+
[SysAbiExport(
Nid = "w5IHyvahg-o",
ExportName = "sem_timedwait",
diff --git a/src/SharpEmu.Libs/Kernel/KernelSyncOnAddressCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelSyncOnAddressCompatExports.cs
index 1ce1b3b..17d81da 100644
--- a/src/SharpEmu.Libs/Kernel/KernelSyncOnAddressCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelSyncOnAddressCompatExports.cs
@@ -10,23 +10,44 @@ namespace SharpEmu.Libs.Kernel;
// Wake selects a fixed number of registered waiters at the same address.
public static class KernelSyncOnAddressCompatExports
{
+ private const int UmtxOperationWait = 2;
+ private const int UmtxOperationWake = 3;
+ private const int PosixEperm = 1;
+ private const int PosixEacces = 13;
+ private const int PosixEfault = 14;
+ private const int PosixEinval = 22;
+ private const int PosixEagain = 35;
+ private const int PosixEtimedout = 60;
+ private const int PosixEcanceled = 85;
+ private const int PosixEother = 1062;
+
private sealed class SyncWaiter : IGuestThreadBlockWaiter
{
private readonly CpuContext _context;
private readonly ulong _expected;
private readonly bool _is64Bit;
+ private readonly bool _usePosixResult;
+ private readonly long _deadlineTimestamp;
private readonly object _hostGate = new();
private readonly long _registeredTicks;
private readonly int _registeredManagedThread;
private int _wakeRequested;
private int _resumeRecorded;
- public SyncWaiter(CpuContext context, ulong address, ulong expected, bool is64Bit)
+ public SyncWaiter(
+ CpuContext context,
+ ulong address,
+ ulong expected,
+ bool is64Bit,
+ bool usePosixResult,
+ long deadlineTimestamp)
{
_context = context;
Address = address;
_expected = expected;
_is64Bit = is64Bit;
+ _usePosixResult = usePosixResult;
+ _deadlineTimestamp = deadlineTimestamp;
_registeredTicks = KernelSyncOnAddressProfile.Enabled
? Stopwatch.GetTimestamp()
: 0L;
@@ -50,13 +71,13 @@ public static class KernelSyncOnAddressCompatExports
if (Volatile.Read(ref _wakeRequested) != 0)
{
RecordResume(explicitWake: true, valueChanged: false, timedOut: false, faulted: false);
- return (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ return Complete((int)OrbisGen2Result.ORBIS_GEN2_OK);
}
if (!TryReadValue(_context, Address, _is64Bit, out var value))
{
RecordResume(explicitWake: false, valueChanged: false, timedOut: false, faulted: true);
- return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
+ return Complete((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
var valueChanged = value != _expected;
@@ -65,15 +86,21 @@ public static class KernelSyncOnAddressCompatExports
valueChanged,
timedOut: !valueChanged,
faulted: false);
- return valueChanged
+ var result = valueChanged
? (int)OrbisGen2Result.ORBIS_GEN2_OK
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
+ return Complete(result);
}
public bool TryWake() => Volatile.Read(ref _wakeRequested) != 0;
public bool RequestWake()
{
+ if (_deadlineTimestamp != 0 && Stopwatch.GetTimestamp() >= _deadlineTimestamp)
+ {
+ return false;
+ }
+
if (Interlocked.Exchange(ref _wakeRequested, 1) != 0)
{
return false;
@@ -87,42 +114,50 @@ public static class KernelSyncOnAddressCompatExports
return true;
}
- public int WaitOnHost(TimeSpan? timeout)
+ public int WaitOnHost()
{
- if (Volatile.Read(ref _wakeRequested) == 0)
+ while (Volatile.Read(ref _wakeRequested) == 0)
{
- lock (_hostGate)
+ if (!TryReadValue(_context, Address, _is64Bit, out var current) ||
+ current != _expected)
{
- if (timeout is null)
- {
- while (Volatile.Read(ref _wakeRequested) == 0)
- {
- Monitor.Wait(_hostGate);
- }
- }
- else
- {
- var deadline = Stopwatch.GetTimestamp() +
- (long)Math.Ceiling(timeout.Value.TotalSeconds * Stopwatch.Frequency);
- while (Volatile.Read(ref _wakeRequested) == 0)
- {
- var remainingTicks = deadline - Stopwatch.GetTimestamp();
- if (remainingTicks <= 0)
- {
- break;
- }
+ break;
+ }
- var remaining = TimeSpan.FromSeconds(
- remainingTicks / (double)Stopwatch.Frequency);
- Monitor.Wait(_hostGate, remaining);
- }
+ var waitSlice = TimeSpan.FromMilliseconds(10);
+ if (_deadlineTimestamp != 0)
+ {
+ var remainingTicks = _deadlineTimestamp - Stopwatch.GetTimestamp();
+ if (remainingTicks <= 0)
+ {
+ break;
+ }
+
+ var remainingSeconds = remainingTicks / (double)Stopwatch.Frequency;
+ if (remainingSeconds < waitSlice.TotalSeconds)
+ {
+ waitSlice = TimeSpan.FromSeconds(remainingSeconds);
}
}
+
+ lock (_hostGate)
+ {
+ if (Volatile.Read(ref _wakeRequested) == 0)
+ {
+ Monitor.Wait(_hostGate, waitSlice);
+ }
+ }
+
+ GuestThreadExecution.Scheduler?.DeliverPendingGuestExceptionIfReady(_context);
}
return Resume();
}
+ private int Complete(int result) => _usePosixResult
+ ? SetPosixReturn(_context, result)
+ : result;
+
private void RecordResume(
bool explicitWake,
bool valueChanged,
@@ -171,6 +206,80 @@ public static class KernelSyncOnAddressCompatExports
LibraryName = "libKernel")]
public static int SyncOnAddressWait64(CpuContext ctx) => Wait(ctx, is64Bit: true);
+ [SysAbiExport(
+ Nid = "04AjkP0jO9U",
+ ExportName = "_umtx_op",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libKernel")]
+ public static int UmtxOp(CpuContext ctx)
+ {
+ var address = ctx[CpuRegister.Rdi];
+ var operation = unchecked((int)ctx[CpuRegister.Rsi]);
+ var value = ctx[CpuRegister.Rdx];
+ var secondaryAddress = ctx[CpuRegister.Rcx];
+ var timeoutAddress = ctx[CpuRegister.R8];
+
+ if (secondaryAddress != 0)
+ {
+ return SetPosixReturn(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ switch (operation)
+ {
+ case UmtxOperationWait:
+ {
+ TimeSpan? timeout = null;
+ long deadline = 0;
+ if (timeoutAddress != 0)
+ {
+ if (!ctx.TryReadUInt64(timeoutAddress, out var secondsBits) ||
+ !ctx.TryReadUInt64(timeoutAddress + sizeof(long), out var nanosecondsBits))
+ {
+ return SetPosixReturn(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ var seconds = unchecked((long)secondsBits);
+ var nanoseconds = unchecked((long)nanosecondsBits);
+ if (seconds < 0 || nanoseconds < 0 || nanoseconds >= 1_000_000_000L)
+ {
+ return SetPosixReturn(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ timeout = CreateTimespecDuration(seconds, nanoseconds);
+ deadline = GuestThreadExecution.ComputeDeadlineTimestamp(timeout.Value);
+ }
+
+ return WaitCore(
+ ctx,
+ is64Bit: true,
+ address,
+ value,
+ timeout,
+ deadline,
+ usePosixResult: true,
+ reason: "_umtx_op.wait");
+ }
+
+ case UmtxOperationWake:
+ return Wake(
+ ctx,
+ address,
+ unchecked((int)value),
+ usePosixResult: true);
+
+ default:
+ return SetPosixReturn(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+ }
+
[SysAbiExport(
Nid = "q2y-wDIVWZA",
ExportName = "sceKernelSyncOnAddressWake",
@@ -180,14 +289,29 @@ public static class KernelSyncOnAddressCompatExports
{
var address = ctx[CpuRegister.Rdi];
var requested = unchecked((int)ctx[CpuRegister.Rsi]);
+ return Wake(ctx, address, requested, usePosixResult: false);
+ }
+
+ private static int Wake(
+ CpuContext ctx,
+ ulong address,
+ int requested,
+ bool usePosixResult)
+ {
if (address == 0 || (address & 3) != 0 || requested < 0)
{
- return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ return CompleteResult(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
+ usePosixResult);
}
if (requested == 0)
{
- return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
+ return CompleteResult(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_OK,
+ usePosixResult);
}
List? selected = null;
@@ -259,7 +383,10 @@ public static class KernelSyncOnAddressCompatExports
GuestThreadExecution.CurrentGuestThreadHandle,
Environment.CurrentManagedThreadId);
- return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
+ return CompleteResult(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_OK,
+ usePosixResult);
}
private static void CaptureCallStack(
@@ -304,15 +431,15 @@ public static class KernelSyncOnAddressCompatExports
private static int Wait(CpuContext ctx, bool is64Bit)
{
var address = ctx[CpuRegister.Rdi];
+ var expected = is64Bit
+ ? ctx[CpuRegister.Rsi]
+ : unchecked((uint)ctx[CpuRegister.Rsi]);
var alignmentMask = is64Bit ? 7UL : 3UL;
if (address == 0 || (address & alignmentMask) != 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
- var expected = is64Bit
- ? ctx[CpuRegister.Rsi]
- : unchecked((uint)ctx[CpuRegister.Rsi]);
if (!TryReadValue(ctx, address, is64Bit, out var current))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
@@ -334,28 +461,92 @@ public static class KernelSyncOnAddressCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
- if (timeoutMicroseconds == 0)
- {
- return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
- }
-
timeout = TimeSpan.FromTicks((long)timeoutMicroseconds * 10L);
deadline = GuestThreadExecution.ComputeDeadlineTimestamp(timeout.Value);
}
- var waiter = new SyncWaiter(ctx, address, expected, is64Bit);
+ return WaitCore(
+ ctx,
+ is64Bit,
+ address,
+ expected,
+ timeout,
+ deadline,
+ usePosixResult: false,
+ reason: is64Bit
+ ? "sceKernelSyncOnAddressWait64"
+ : "sceKernelSyncOnAddressWait32");
+ }
+
+ private static int WaitCore(
+ CpuContext ctx,
+ bool is64Bit,
+ ulong address,
+ ulong expected,
+ TimeSpan? timeout,
+ long deadline,
+ bool usePosixResult,
+ string reason)
+ {
+ var alignmentMask = is64Bit ? 7UL : 3UL;
+ if (address == 0 || (address & alignmentMask) != 0)
+ {
+ return CompleteResult(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
+ usePosixResult);
+ }
+
+ if (!TryReadValue(ctx, address, is64Bit, out var current))
+ {
+ return CompleteResult(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
+ usePosixResult);
+ }
+
+ if (current != expected)
+ {
+ KernelSyncOnAddressProfile.RecordImmediate(address);
+ return CompleteResult(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_OK,
+ usePosixResult);
+ }
+
+ if (timeout == TimeSpan.Zero)
+ {
+ return CompleteResult(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT,
+ usePosixResult);
+ }
+
+ var waiter = new SyncWaiter(
+ ctx,
+ address,
+ expected,
+ is64Bit,
+ usePosixResult,
+ deadline);
Register(address, waiter);
if (!TryReadValue(ctx, address, is64Bit, out current))
{
Unregister(waiter);
- return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ return CompleteResult(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
+ usePosixResult);
}
if (current != expected || waiter.TryWake())
{
Unregister(waiter);
KernelSyncOnAddressProfile.RecordImmediate(address);
- return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
+ return CompleteResult(
+ ctx,
+ (int)OrbisGen2Result.ORBIS_GEN2_OK,
+ usePosixResult);
}
var returnRip = 0UL;
@@ -383,7 +574,7 @@ public static class KernelSyncOnAddressCompatExports
if (GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
- is64Bit ? "sceKernelSyncOnAddressWait64" : "sceKernelSyncOnAddressWait32",
+ reason,
waiter.WakeKey,
waiter,
deadline))
@@ -393,7 +584,24 @@ public static class KernelSyncOnAddressCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
- return SetReturn(ctx, waiter.WaitOnHost(timeout));
+ return SetReturn(ctx, waiter.WaitOnHost());
+ }
+
+ private static TimeSpan CreateTimespecDuration(long seconds, long nanoseconds)
+ {
+ var subsecondTicks = (nanoseconds + 99L) / 100L;
+ if (seconds > TimeSpan.MaxValue.Ticks / TimeSpan.TicksPerSecond)
+ {
+ return TimeSpan.MaxValue;
+ }
+
+ var secondTicks = seconds * TimeSpan.TicksPerSecond;
+ if (secondTicks > TimeSpan.MaxValue.Ticks - subsecondTicks)
+ {
+ return TimeSpan.MaxValue;
+ }
+
+ return TimeSpan.FromTicks(secondTicks + subsecondTicks);
}
private static void Register(ulong address, SyncWaiter waiter)
@@ -458,4 +666,31 @@ public static class KernelSyncOnAddressCompatExports
ctx[CpuRegister.Rax] = unchecked((ulong)(long)result);
return result;
}
+
+ private static int CompleteResult(CpuContext ctx, int result, bool usePosixResult) =>
+ usePosixResult
+ ? SetPosixReturn(ctx, result)
+ : SetReturn(ctx, result);
+
+ private static int SetPosixReturn(CpuContext ctx, int result)
+ {
+ if (result == (int)OrbisGen2Result.ORBIS_GEN2_OK)
+ {
+ return SetReturn(ctx, 0);
+ }
+
+ var errno = result switch
+ {
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED => PosixEperm,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DELETED => PosixEacces,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT => PosixEfault,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT => PosixEinval,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN => PosixEagain,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT => PosixEtimedout,
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_CANCELED => PosixEcanceled,
+ _ => PosixEother,
+ };
+ _ = KernelRuntimeCompatExports.TrySetErrno(ctx, errno);
+ return SetReturn(ctx, -1);
+ }
}
diff --git a/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs b/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs
index e886b5e..04a354a 100644
--- a/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs
+++ b/src/SharpEmu.Libs/Np/NpWebApi2Exports.cs
@@ -12,9 +12,27 @@ public static class NpWebApi2Exports
private static int _initialized;
private static int _nextLibraryContextHandle;
private static int _nextPushEventHandle;
+ private static int _nextPushEventCallbackHandle;
private static int _nextUserContextHandle = 1000;
private static readonly object _contextGate = new();
private static readonly HashSet _libraryContexts = [];
+ private static readonly HashSet _userContexts = [];
+ private static readonly HashSet _pushEventFilters = [];
+
+ public static void ResetRuntimeState()
+ {
+ Interlocked.Exchange(ref _initialized, 0);
+ Interlocked.Exchange(ref _nextLibraryContextHandle, 0);
+ Interlocked.Exchange(ref _nextPushEventHandle, 0);
+ Interlocked.Exchange(ref _nextPushEventCallbackHandle, 0);
+ Interlocked.Exchange(ref _nextUserContextHandle, 1000);
+ lock (_contextGate)
+ {
+ _libraryContexts.Clear();
+ _userContexts.Clear();
+ _pushEventFilters.Clear();
+ }
+ }
[SysAbiExport(
Nid = "+o9816YQhqQ",
@@ -51,6 +69,11 @@ public static class NpWebApi2Exports
}
var filterHandle = Interlocked.Increment(ref _nextPushEventHandle);
+ lock (_contextGate)
+ {
+ _pushEventFilters.Add(filterHandle);
+ }
+
TraceNpWebApi2("push-event-create-filter", libraryContextId, (ulong)filterHandle);
return ctx.SetReturn(filterHandle);
}
@@ -74,6 +97,20 @@ public static class NpWebApi2Exports
return ctx.SetReturn(handle);
}
+ [SysAbiExport(
+ Nid = "fIATVMo4Y1w",
+ ExportName = "sceNpWebApi2PushEventDeleteHandle",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceNpWebApi2")]
+ public static int NpWebApi2PushEventDeleteHandle(CpuContext ctx)
+ {
+ TraceNpWebApi2(
+ "push-event-delete-handle",
+ unchecked((int)ctx[CpuRegister.Rdi]),
+ unchecked((uint)ctx[CpuRegister.Rsi]));
+ return ctx.SetReturn(0);
+ }
+
[SysAbiExport(
Nid = "sk54bi6FtYM",
ExportName = "sceNpWebApi2CreateUserContext",
@@ -97,9 +134,34 @@ public static class NpWebApi2Exports
}
var userContextId = Interlocked.Increment(ref _nextUserContextHandle);
+ lock (_contextGate)
+ {
+ _userContexts.Add(userContextId);
+ }
+
return ctx.SetReturn(userContextId);
}
+ [SysAbiExport(
+ Nid = "fY3QqeNkF8k",
+ ExportName = "sceNpWebApi2PushEventRegisterCallback",
+ Target = Generation.Gen4 | Generation.Gen5,
+ LibraryName = "libSceNpWebApi2")]
+ public static int NpWebApi2PushEventRegisterCallback(CpuContext ctx)
+ {
+ var userContextId = unchecked((int)ctx[CpuRegister.Rdi]);
+ var filterId = unchecked((int)ctx[CpuRegister.Rsi]);
+ var callback = ctx[CpuRegister.Rdx];
+ if (callback == 0 || !IsRegisteredPushEventPair(userContextId, filterId))
+ {
+ return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
+ }
+
+ var callbackId = Interlocked.Increment(ref _nextPushEventCallbackHandle);
+ TraceNpWebApi2("push-event-register-callback", userContextId, (ulong)callbackId);
+ return ctx.SetReturn(callbackId);
+ }
+
[SysAbiExport(
Nid = "bEvXpcEk200",
ExportName = "sceNpWebApi2Terminate",
@@ -171,10 +233,25 @@ public static class NpWebApi2Exports
if (_libraryContexts.Count == 0)
{
Interlocked.Exchange(ref _initialized, 0);
+ _userContexts.Clear();
+ _pushEventFilters.Clear();
}
}
}
+ private static bool IsRegisteredPushEventPair(int userContextId, int filterId)
+ {
+ if (Volatile.Read(ref _initialized) == 0 || userContextId <= 0 || filterId <= 0)
+ {
+ return false;
+ }
+
+ lock (_contextGate)
+ {
+ return _userContexts.Contains(userContextId) && _pushEventFilters.Contains(filterId);
+ }
+ }
+
private static void TraceNpWebApi2(string operation, int id, ulong arg0)
{
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP_WEB_API2"), "1", StringComparison.Ordinal))
diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
index 9656d52..2688f09 100644
--- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
+++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs
@@ -398,15 +398,18 @@ public static partial class VideoOutExports
public static int VideoOutClose(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
- lock (_stateGate)
+ GuestGpu.Current.RunAfterPendingCommandStreams(() =>
{
- foreach (var request in _flipRequests.Values.Where(request => request.Handle == handle).ToArray())
+ lock (_stateGate)
{
- CancelFlipLocked(request);
+ foreach (var request in _flipRequests.Values.Where(request => request.Handle == handle).ToArray())
+ {
+ CancelFlipLocked(request);
+ }
+ _ports.Remove(handle);
+ Monitor.PulseAll(_stateGate);
}
- _ports.Remove(handle);
- Monitor.PulseAll(_stateGate);
- }
+ });
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -1246,36 +1249,42 @@ public static partial class VideoOutExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var attributeIndex = unchecked((int)ctx[CpuRegister.Rsi]);
- if (!TryGetPort(handle, out var port))
- {
- return OrbisVideoOutErrorInvalidHandle;
- }
-
if (attributeIndex < 0)
{
return OrbisVideoOutErrorInvalidValue;
}
- lock (_stateGate)
+ var result = (int)OrbisGen2Result.ORBIS_GEN2_OK;
+ GuestGpu.Current.RunAfterPendingCommandStreams(() =>
{
- if (attributeIndex >= port.Groups.Length || port.Groups[attributeIndex] is null)
+ lock (_stateGate)
{
- return OrbisVideoOutErrorInvalidValue;
- }
-
- port.Groups[attributeIndex] = null;
- foreach (var slot in port.BufferSlots)
- {
- if (slot.GroupIndex == attributeIndex)
+ if (!_ports.TryGetValue(handle, out var port))
{
- slot.GroupIndex = -1;
- slot.AddressLeft = 0;
- slot.AddressRight = 0;
+ result = OrbisVideoOutErrorInvalidHandle;
+ return;
+ }
+
+ if (attributeIndex >= port.Groups.Length || port.Groups[attributeIndex] is null)
+ {
+ result = OrbisVideoOutErrorInvalidValue;
+ return;
+ }
+
+ port.Groups[attributeIndex] = null;
+ foreach (var slot in port.BufferSlots)
+ {
+ if (slot.GroupIndex == attributeIndex)
+ {
+ slot.GroupIndex = -1;
+ slot.AddressLeft = 0;
+ slot.AddressRight = 0;
+ }
}
}
+ });
- return (int)OrbisGen2Result.ORBIS_GEN2_OK;
- }
+ return result;
}
[SysAbiExport(
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs
index 194b952..36e741d 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs
@@ -72,6 +72,26 @@ internal static unsafe partial class VulkanVideoPresenter
? testStream.Done()
: IdleOutcome.Completed;
+ public static void RunAfterPendingCommandStreams(Action work)
+ {
+ ArgumentNullException.ThrowIfNull(work);
+ if (TryGetActivePresenter(out var presenter))
+ {
+ if (presenter.Relay.TryRunAfterAcceptedCommandStreams(work))
+ {
+ return;
+ }
+
+ if (!HostSessionControl.IsShutdownRequested && !Volatile.Read(ref _closed) &&
+ !Volatile.Read(ref _presenterCloseRequested))
+ {
+ throw SubmissionScheduler.Fatal("The GPU worker rejected an ordered video-out state change.");
+ }
+ }
+
+ work();
+ }
+
// The blocked heads of the stream this memory submits to; null when no stream exists for it.
internal static BlockedSnapshot? SnapshotBlockedCommandStream(ICpuMemory? memory)
{
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Scheduler.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Scheduler.cs
index 1f09f5c..dbdff71 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Scheduler.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Scheduler.cs
@@ -22,7 +22,7 @@ internal static unsafe partial class VulkanVideoPresenter
// This partial owns the submission scheduler and the GPU worker relay.
private readonly object _queueGate = new();
- private readonly GpuWorkerRelay _relay = new(WakeRenderThread);
+ private readonly GpuWorkerRelay _relay;
internal GpuWorkerRelay Relay => _relay;
private readonly SubmissionContext _submissionContext = new();
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
index 8fd8978..c9a8d52 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
@@ -9,6 +9,7 @@ using SharpEmu.Libs.AvPlayer;
using SharpEmu.Libs.Media;
using SharpEmu.Libs.Gpu;
using SharpEmu.Libs.Gpu.GpuCommands;
+using SharpEmu.Libs.Gpu.Scheduling;
using SharpEmu.Libs.Gpu.Images;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Vulkan;
@@ -255,6 +256,7 @@ internal static unsafe partial class VulkanVideoPresenter
public Presenter(uint width, uint height)
{
_commandStream = new CommandStreamQueue(this);
+ _relay = new GpuWorkerRelay(WakeRenderThread, _commandStream.TryEnqueueControlBarrier);
_hostBufferPool = new VulkanHostBufferPool(
MaximumCachedHostBufferBytes,
DestroyHostBufferAllocation);
diff --git a/src/SharpEmu.ShaderCompiler/Ir/Gen5ExecFullAnalysis.cs b/src/SharpEmu.ShaderCompiler/Ir/Gen5ExecFullAnalysis.cs
index 4328d1b..c37863e 100644
--- a/src/SharpEmu.ShaderCompiler/Ir/Gen5ExecFullAnalysis.cs
+++ b/src/SharpEmu.ShaderCompiler/Ir/Gen5ExecFullAnalysis.cs
@@ -158,12 +158,14 @@ public static class Gen5ExecFullAnalysis
written |= Bit(sdst) | Bit(sdst + 1);
}
- if (opcode.StartsWith("VCmp", StringComparison.Ordinal) && scalarDestination is null)
+ if (opcode.StartsWith("VCmpx", StringComparison.Ordinal))
{
- // VOPC compares write VCC, V_CMPX writes EXEC.
- written |= opcode.StartsWith("VCmpx", StringComparison.Ordinal)
- ? Bit(ExecLow) | Bit(ExecHigh)
- : Bit(106) | Bit(107);
+ written |= Bit(ExecLow) | Bit(ExecHigh);
+ }
+ else if (opcode.StartsWith("VCmp", StringComparison.Ordinal) && scalarDestination is null)
+ {
+ // VOPC compares write VCC.
+ written |= Bit(106) | Bit(107);
}
if (opcode.StartsWith('V') && opcode.Contains("Co", StringComparison.Ordinal) && scalarDestination is null)
diff --git a/tests/SharpEmu.Libs.Tests/Cpu/GuestThreadStackBoundsTests.cs b/tests/SharpEmu.Libs.Tests/Cpu/GuestThreadStackBoundsTests.cs
new file mode 100644
index 0000000..8fe77a6
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Cpu/GuestThreadStackBoundsTests.cs
@@ -0,0 +1,51 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Collections;
+using System.Reflection;
+using SharpEmu.Core.Cpu.Native;
+using SharpEmu.HLE;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Cpu;
+
+public sealed class GuestThreadStackBoundsTests
+{
+ [NativeX64Fact]
+ public void SchedulerReturnsAuthoritativeBoundsAcrossTheFullSlotWindow()
+ {
+ const BindingFlags privateInstance = BindingFlags.NonPublic | BindingFlags.Instance;
+ const ulong stackSize = 0x20_0000;
+ const ulong stackStride = 0x100_0000;
+ var highestStack = OperatingSystem.IsWindows()
+ ? 0x0000_7FFF_E000_0000UL
+ : 0x0000_6FFF_E000_0000UL;
+ var backendType = typeof(DirectExecutionBackend);
+ var threadType = backendType.GetNestedType("GuestThreadState", BindingFlags.NonPublic)!;
+ var modules = new ModuleManager();
+ modules.Freeze();
+ using var backend = new DirectExecutionBackend(modules);
+ var gate = backendType.GetField("_guestThreadGate", privateInstance)!.GetValue(backend)!;
+ var threads = (IDictionary)backendType.GetField("_guestThreads", privateInstance)!.GetValue(backend)!;
+
+ foreach (var slot in new[] { 0, 48, 56, 1023 })
+ {
+ var handle = 0x1000UL + (ulong)slot;
+ var expectedBase = highestStack - ((ulong)slot * stackStride);
+ var thread = Activator.CreateInstance(threadType)!;
+ threadType.GetProperty("ThreadHandle")!.SetValue(thread, handle);
+ threadType.GetProperty("StackBase")!.SetValue(thread, expectedBase);
+ threadType.GetProperty("StackSize")!.SetValue(thread, stackSize);
+ lock (gate)
+ threads.Add(handle, thread);
+
+ Assert.True(backend.TryGetGuestThreadStackBounds(handle, out var actualBase, out var actualSize));
+ Assert.Equal(expectedBase, actualBase);
+ Assert.Equal(stackSize, actualSize);
+ }
+
+ Assert.False(backend.TryGetGuestThreadStackBounds(0xDEAD, out var missingBase, out var missingSize));
+ Assert.Equal(0UL, missingBase);
+ Assert.Equal(0UL, missingSize);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/CommandStreamQueueTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/CommandStreamQueueTests.cs
index 5e461d1..b942cbf 100644
--- a/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/CommandStreamQueueTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/CommandStreamQueueTests.cs
@@ -154,6 +154,83 @@ public sealed class CommandStreamQueueTests
Assert.Equal(new[] { "begin 0 1", "begin 1 3", "begin 0 2" }, host.Calls.Where(call => call.StartsWith("begin", StringComparison.Ordinal)));
}
+ [Fact]
+ public void ControlBarrier_RunsBetweenTheAcceptedSliceAndLaterAdmissions()
+ {
+ var (host, queue) = NewQueue();
+ Enqueue(host, queue, Graphics, 1, CreateInstanceCountPacket(1));
+ var cancelled = false;
+ host.PendingCommands.Enqueue(() =>
+ {
+ Assert.True(queue.TryEnqueueControlBarrier(
+ () => host.Calls.Add("mapping_change"),
+ () => cancelled = true));
+ Enqueue(host, queue, Graphics + 0x100, 2, CreateInstanceCountPacket(2));
+ });
+
+ Assert.Equal(SliceResult.Completed, queue.ProcessOne());
+ Assert.DoesNotContain("mapping_change", host.Calls);
+ Assert.Equal(SliceResult.Completed, queue.ProcessOne());
+ Assert.Equal(SliceResult.Completed, queue.ProcessOne());
+
+ var first = host.Calls.IndexOf("begin 0 1");
+ var barrier = host.Calls.IndexOf("mapping_change");
+ var second = host.Calls.IndexOf("begin 0 2");
+ Assert.True(first >= 0 && barrier > first && second > barrier, string.Join(", ", host.Calls));
+ Assert.False(cancelled);
+ }
+
+ [Fact]
+ public void ControlBarrier_PreservesCrossQueueProgressWhileAnOlderHeadIsBlocked()
+ {
+ var (host, queue) = NewQueue();
+ host.WriteDword(Label, 0);
+ Enqueue(host, queue, Graphics, 1, WaitEqual(Label, 1));
+ Assert.Equal(SliceResult.BlockedWithoutProgress, queue.ProcessOne());
+ Assert.True(queue.TryEnqueueControlBarrier(() => host.Calls.Add("mapping_change"), () => { }));
+ EnqueueCompute(host, queue, 0x20, Compute, 2, CreateInstanceCountPacket(2));
+
+ Assert.Equal(SliceResult.Completed, queue.ProcessOne());
+ Assert.DoesNotContain("mapping_change", host.Calls);
+ host.WriteDword(Label, 1);
+ queue.RetryBlocked();
+ Assert.Equal(SliceResult.Completed, queue.ProcessOne());
+ Assert.Equal(SliceResult.Completed, queue.ProcessOne());
+
+ var compute = host.Calls.IndexOf("begin 1 2");
+ var retriedGraphics = host.Calls.FindLastIndex(call => call == "begin 0 1");
+ var barrier = host.Calls.IndexOf("mapping_change");
+ Assert.True(compute >= 0 && retriedGraphics > compute && barrier > retriedGraphics, string.Join(", ", host.Calls));
+ }
+
+ [Fact]
+ public void ControlBarrier_IsCancelledWhenTheQueueIsDiscarded()
+ {
+ var (_, queue) = NewQueue();
+ var ran = false;
+ var cancelled = false;
+ Assert.True(queue.TryEnqueueControlBarrier(() => ran = true, () => cancelled = true));
+
+ queue.DiscardAll();
+
+ Assert.False(ran);
+ Assert.True(cancelled);
+ Assert.False(queue.HasPending);
+ }
+
+ [Fact]
+ public void ShutdownDrain_RunsAnAcceptedControlBarrier()
+ {
+ var (_, queue) = NewQueue();
+ var ran = false;
+ Assert.True(queue.TryEnqueueControlBarrier(() => ran = true, () => { }));
+ queue.StopAccepting();
+
+ Assert.Equal(IdleOutcome.Completed, queue.DrainForShutdown(cancelBlockedOnNoProgress: true));
+ Assert.True(ran);
+ Assert.False(queue.HasPending);
+ }
+
[Fact]
public void BlockedHead_IsRequeuedAndRetriedWithoutStoppingOtherQueues()
{
diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestImageCacheTests.Metadata.cs b/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestImageCacheTests.Metadata.cs
index 6f8a042..b0438dc 100644
--- a/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestImageCacheTests.Metadata.cs
+++ b/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestImageCacheTests.Metadata.cs
@@ -196,6 +196,41 @@ public sealed partial class GuestImageCacheTests
harness.Shutdown();
}
+ [Fact]
+ public void ATextureRequestWithDcc_GivesAnImageFoundWithoutMetadataItsDccMetadata()
+ {
+ if (!GatePrerequisites.Ready(_vulkan)) return;
+ using var harness = new CacheHarness(_vulkan);
+ const ulong SliceSize = 0x1000;
+ var address = harness.MapBacked(0x20000, ReadWrite);
+ var metadata = address + 0x10000;
+ var plain = LinearRequest(address + 0xc000, 4, Format.R8G8B8A8Unorm, GuestPixelFormat.Bits8_8_8_8UNorm, GuestImageType.Color2D, new Extent3D(1, 1, 1), 1, 4, 1);
+ var imageId = harness.Find(ref plain);
+ var image = harness.Image(imageId);
+ image.Description.TileMode = GuestTileMode.RenderTarget;
+ Assert.Equal(MetadataKind.None, image.Description.Metadata.Kind);
+ harness.Write(metadata, Enumerable.Repeat((byte)0x55, (int)SliceSize).ToArray());
+ harness.Worker.Run(() =>
+ {
+ Assert.NotNull(harness.Cache.ObtainBuffer(metadata, SliceSize, isWritten: true).Buffer);
+ harness.Cache.FillBuffer(metadata, SliceSize, 0, isGds: false);
+ });
+ Assert.True(harness.Cache.HasGpuDirtyBytes(metadata, SliceSize));
+
+ var compressed = plain;
+ compressed.Description.TileMode = GuestTileMode.RenderTarget;
+ compressed.Description.Metadata.Kind = MetadataKind.Dcc;
+ compressed.Description.Metadata.Range = new GuestSpan(metadata, 0);
+ Assert.Equal(imageId, harness.Find(ref compressed));
+ Assert.Equal(MetadataKind.Dcc, image.Description.Metadata.Kind);
+ Assert.Equal(metadata, image.Description.Metadata.Range.Address);
+ Assert.Equal(SliceSize, image.Description.DccSliceSize);
+ Assert.False(harness.Cache.HasGpuDirtyBytes(metadata, SliceSize));
+ Assert.True(harness.Images.TryReadGuestDccClear(metadata, SliceSize, 0, out _, out var code));
+ Assert.Equal(0x00, code);
+ harness.Shutdown();
+ }
+
[Fact]
public void GuestDccClear_ReadsOnlyUniformClearSlices()
{
diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs
index c7511b3..cfd8351 100644
--- a/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageRequestBuildersTests.cs
@@ -361,6 +361,38 @@ public sealed class ImageRequestBuildersTests : IClassFixture? tryEnqueueAfterPendingWork = null)
{
- Relay = new GpuWorkerRelay(Wake);
+ Relay = new GpuWorkerRelay(Wake, tryEnqueueAfterPendingWork);
_thread = new Thread(Run) { IsBackground = true };
}
@@ -160,6 +160,116 @@ public sealed class GpuWorkerRelayTests : IDisposable
device.Complete(1);
}
+ [Fact]
+ public void AfterPendingWork_BypassesTheCommandStreamBarrier()
+ {
+ var barrierCalled = false;
+ using var worker = new TestWorker(
+ (_, _) =>
+ {
+ barrierCalled = true;
+ return true;
+ });
+ worker.Start();
+ var workRan = false;
+ var ranOnWorker = false;
+
+ Assert.True(worker.Relay.TryRunAfterPendingWork(() =>
+ {
+ workRan = true;
+ ranOnWorker = worker.Relay.IsGpuQueueThread;
+ }));
+ Assert.True(workRan);
+ Assert.True(ranOnWorker);
+ Assert.False(barrierCalled);
+ }
+
+ [Fact]
+ public void AfterPendingWork_ReturnsFalseWhenTheWorkerIsClosed()
+ {
+ var barrierCalled = false;
+ var relay = new GpuWorkerRelay(
+ () => { },
+ (_, _) =>
+ {
+ barrierCalled = true;
+ return true;
+ });
+ relay.StopAcceptingWork();
+
+ Assert.False(relay.TryRunAfterPendingWork(() => { }));
+ Assert.False(barrierCalled);
+ }
+
+ [Fact]
+ public async Task AfterAcceptedCommandStreams_WaitsForTheControlBarrier()
+ {
+ Action? run = null;
+ Action? cancel = null;
+ var relay = new GpuWorkerRelay(
+ () => { },
+ (work, cancellation) =>
+ {
+ run = work;
+ cancel = cancellation;
+ return true;
+ });
+ var workRan = false;
+
+ var completion = Task.Run(() =>
+ relay.TryRunAfterAcceptedCommandStreams(() => workRan = true));
+ Assert.True(WaitUntil(() => Volatile.Read(ref run) is not null));
+ Assert.False(await CompletesWithin(completion, 50));
+
+ run!();
+
+ Assert.True(await completion.WaitAsync(TimeSpan.FromSeconds(2)));
+ Assert.True(workRan);
+ Assert.NotNull(cancel);
+ }
+
+ [Fact]
+ public async Task AfterAcceptedCommandStreams_CancellationReleasesTheCaller()
+ {
+ Action? cancel = null;
+ var relay = new GpuWorkerRelay(
+ () => { },
+ (_, cancellation) =>
+ {
+ cancel = cancellation;
+ return true;
+ });
+
+ var completion = Task.Run(() => Record.Exception(() =>
+ relay.TryRunAfterAcceptedCommandStreams(() => { })));
+ Assert.True(WaitUntil(() => Volatile.Read(ref cancel) is not null));
+ cancel!();
+
+ Assert.IsType(
+ await completion.WaitAsync(TimeSpan.FromSeconds(2)));
+ }
+
+ [Fact]
+ public async Task AfterAcceptedCommandStreams_RethrowsTheWorkException()
+ {
+ Action? run = null;
+ var relay = new GpuWorkerRelay(
+ () => { },
+ (work, _) =>
+ {
+ run = work;
+ return true;
+ });
+ var expected = new InvalidOperationException("ordered state change failed");
+
+ var completion = Task.Run(() => Record.Exception(() =>
+ relay.TryRunAfterAcceptedCommandStreams(() => throw expected)));
+ Assert.True(WaitUntil(() => Volatile.Read(ref run) is not null));
+ run!();
+
+ Assert.Same(expected, await completion.WaitAsync(TimeSpan.FromSeconds(2)));
+ }
+
[Fact]
public void CommandsRunBeforeGuestWork()
{
diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelHostWaitExceptionTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelHostWaitExceptionTests.cs
new file mode 100644
index 0000000..da5218a
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelHostWaitExceptionTests.cs
@@ -0,0 +1,135 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using System.Reflection;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Kernel;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Kernel;
+
+[Collection("GuestThreadFlowProfile")]
+public sealed class KernelHostWaitExceptionTests
+{
+ private const ulong MemoryBase = 0x1_3000_0000;
+ private const ulong SemaphoreAddress = MemoryBase + 0x100;
+ private const ulong NameAddress = MemoryBase + 0x200;
+ private const ulong ValueAddress = MemoryBase + 0x300;
+ private const ulong TimeoutAddress = MemoryBase + 0x400;
+
+ [Fact]
+ public void SemaphoreHostWaitDeliversPendingExceptionBeforeReturning()
+ {
+ var memory = new FakeCpuMemory(MemoryBase, 0x1000);
+ var createContext = new CpuContext(memory, Generation.Gen5);
+ Assert.True(memory.TryWrite(NameAddress, "exception-sema\0"u8));
+ createContext[CpuRegister.Rdi] = SemaphoreAddress;
+ createContext[CpuRegister.Rsi] = NameAddress;
+ createContext[CpuRegister.Rdx] = 1;
+ createContext[CpuRegister.Rcx] = 0;
+ createContext[CpuRegister.R8] = 1;
+ createContext[CpuRegister.R9] = 0;
+ Assert.Equal(0, KernelSemaphoreCompatExports.KernelCreateSema(createContext));
+ Assert.True(createContext.TryReadUInt64(SemaphoreAddress, out var handle));
+
+ WriteUInt32(memory, TimeoutAddress, 1_000_000);
+ var waitContext = new CpuContext(memory, Generation.Gen5);
+ waitContext[CpuRegister.Rdi] = handle;
+ waitContext[CpuRegister.Rsi] = 1;
+ waitContext[CpuRegister.Rdx] = TimeoutAddress;
+ var signalContext = new CpuContext(memory, Generation.Gen5);
+ var scheduler = DispatchProxy.Create();
+ var proxy = (PendingExceptionScheduler)(object)scheduler;
+ proxy.DeliverException = context =>
+ {
+ Assert.Same(waitContext, context);
+ Assert.Equal(
+ 0,
+ KernelSemaphoreCompatExports.KernelSignalSema(
+ signalContext,
+ unchecked((uint)handle),
+ 1));
+ };
+
+ var previousScheduler = GuestThreadExecution.Scheduler;
+ try
+ {
+ GuestThreadExecution.Scheduler = scheduler;
+ Assert.Equal(0, KernelSemaphoreCompatExports.KernelWaitSema(waitContext));
+ Assert.Equal(1, proxy.DeliverCalls);
+ }
+ finally
+ {
+ GuestThreadExecution.Scheduler = previousScheduler;
+ createContext[CpuRegister.Rdi] = handle;
+ Assert.Equal(0, KernelSemaphoreCompatExports.KernelDeleteSema(createContext));
+ }
+ }
+
+ [Fact]
+ public void SyncOnAddressHostWaitDeliversPendingExceptionBeforeReturning()
+ {
+ var memory = new FakeCpuMemory(MemoryBase, 0x1000);
+ WriteUInt32(memory, ValueAddress, 7);
+ WriteUInt32(memory, TimeoutAddress, 1_000_000);
+ var context = new CpuContext(memory, Generation.Gen5);
+ context[CpuRegister.Rdi] = ValueAddress;
+ context[CpuRegister.Rsi] = 7;
+ context[CpuRegister.Rdx] = TimeoutAddress;
+ var scheduler = DispatchProxy.Create();
+ var proxy = (PendingExceptionScheduler)(object)scheduler;
+ proxy.DeliverException = deliveredContext =>
+ {
+ Assert.Same(context, deliveredContext);
+ WriteUInt32(memory, ValueAddress, 8);
+ };
+
+ var previousScheduler = GuestThreadExecution.Scheduler;
+ try
+ {
+ GuestThreadExecution.Scheduler = scheduler;
+ Assert.Equal(
+ (int)OrbisGen2Result.ORBIS_GEN2_OK,
+ KernelSyncOnAddressCompatExports.SyncOnAddressWait32(context));
+ Assert.Equal(1, proxy.DeliverCalls);
+ }
+ finally
+ {
+ GuestThreadExecution.Scheduler = previousScheduler;
+ }
+ }
+
+ private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
+ {
+ Span bytes = stackalloc byte[sizeof(uint)];
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, value);
+ Assert.True(memory.TryWrite(address, bytes));
+ }
+
+ public class PendingExceptionScheduler : DispatchProxy
+ {
+ private int _deliverCalls;
+
+ public Action? DeliverException { get; set; }
+
+ public int DeliverCalls => Volatile.Read(ref _deliverCalls);
+
+ protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
+ {
+ if (targetMethod?.Name == nameof(IGuestThreadScheduler.DeliverPendingGuestExceptionIfReady))
+ {
+ Interlocked.Increment(ref _deliverCalls);
+ DeliverException!((CpuContext)args![0]!);
+ return null;
+ }
+
+ if (targetMethod?.Name == nameof(IGuestThreadScheduler.WakeBlockedThreads))
+ {
+ return 0;
+ }
+
+ throw new NotSupportedException(targetMethod?.Name);
+ }
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelSemaphoreCompatExportsTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelSemaphoreCompatExportsTests.cs
new file mode 100644
index 0000000..6377e0d
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelSemaphoreCompatExportsTests.cs
@@ -0,0 +1,38 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE;
+using SharpEmu.Libs.Kernel;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Kernel;
+
+public sealed class KernelSemaphoreCompatExportsTests
+{
+ private const ulong MemoryBase = 0x1_0000_0000;
+ private const ulong SemaphoreAddress = MemoryBase + 0x100;
+ private const ulong NameAddress = MemoryBase + 0x200;
+
+ [Fact]
+ public void KernelCreateSemaWritesPointerSizedHandleAndClearsUpperBits()
+ {
+ var context = new CpuContext(new FakeCpuMemory(MemoryBase, 0x1000), Generation.Gen5);
+ Assert.True(context.TryWriteUInt64(SemaphoreAddress, 0xC0DE_C0DE_CAFE_BA00));
+ Assert.True(context.Memory.TryWrite(NameAddress, "unity-worker\0"u8));
+
+ context[CpuRegister.Rdi] = SemaphoreAddress;
+ context[CpuRegister.Rsi] = NameAddress;
+ context[CpuRegister.Rdx] = 1;
+ context[CpuRegister.Rcx] = 0;
+ context[CpuRegister.R8] = 1;
+ context[CpuRegister.R9] = 0;
+
+ Assert.Equal(0, KernelSemaphoreCompatExports.KernelCreateSema(context));
+ Assert.True(context.TryReadUInt64(SemaphoreAddress, out var handle));
+ Assert.NotEqual(0UL, handle);
+ Assert.Equal(0UL, handle >> 32);
+
+ context[CpuRegister.Rdi] = handle;
+ Assert.Equal(0, KernelSemaphoreCompatExports.KernelDeleteSema(context));
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelSyncOnAddressCompatExportsTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelSyncOnAddressCompatExportsTests.cs
index ec3224d..a1ba3a3 100644
--- a/tests/SharpEmu.Libs.Tests/Kernel/KernelSyncOnAddressCompatExportsTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelSyncOnAddressCompatExportsTests.cs
@@ -145,6 +145,166 @@ public sealed class KernelSyncOnAddressCompatExportsTests
waiter.Resume());
}
+ [Fact]
+ public void UmtxWaitReturnsImmediatelyWhenValueDoesNotMatch()
+ {
+ var (memory, ctx) = CreateContext();
+ WriteUInt64(memory, ValueAddress, 7);
+ ctx[CpuRegister.Rdi] = ValueAddress;
+ ctx[CpuRegister.Rsi] = 2; // UMTX_OP_WAIT
+ ctx[CpuRegister.Rdx] = 9;
+ ctx[CpuRegister.Rcx] = 0;
+ ctx[CpuRegister.R8] = 0;
+
+ RunAsGuest(0x809, () => Assert.Equal(
+ 0,
+ KernelSyncOnAddressCompatExports.UmtxOp(ctx)));
+
+ Assert.False(GuestThreadExecution.TryConsumeCurrentThreadBlock(out _));
+ Assert.Equal(0UL, ctx[CpuRegister.Rax]);
+ }
+
+ [Fact]
+ public void UmtxWaitAndWakeShareTheAddressWaitRegistry()
+ {
+ var (memory, waitContext) = CreateContext();
+ var wakeContext = new CpuContext(memory, Generation.Gen5);
+ WriteUInt64(memory, ValueAddress, 0);
+
+ IGuestThreadBlockWaiter? stagedWaiter = null;
+ waitContext[CpuRegister.Rdi] = ValueAddress;
+ waitContext[CpuRegister.Rsi] = 2; // UMTX_OP_WAIT
+ waitContext[CpuRegister.Rdx] = 0;
+ waitContext[CpuRegister.Rcx] = 0;
+ waitContext[CpuRegister.R8] = 0;
+ RunAsGuest(0x80A, () =>
+ {
+ Assert.Equal(0, KernelSyncOnAddressCompatExports.UmtxOp(waitContext));
+ Assert.True(GuestThreadExecution.TryConsumeCurrentThreadBlock(
+ out var reason,
+ out _,
+ out var hasContinuation,
+ out _,
+ out var waiter,
+ out var deadline));
+ Assert.Equal("_umtx_op.wait", reason);
+ Assert.True(hasContinuation);
+ Assert.Equal(0, deadline);
+ stagedWaiter = Assert.IsAssignableFrom(waiter);
+ });
+
+ wakeContext[CpuRegister.Rdi] = ValueAddress;
+ wakeContext[CpuRegister.Rsi] = 3; // UMTX_OP_WAKE
+ wakeContext[CpuRegister.Rdx] = 1;
+ wakeContext[CpuRegister.Rcx] = 0;
+ wakeContext[CpuRegister.R8] = 0;
+ Assert.Equal(0, KernelSyncOnAddressCompatExports.UmtxOp(wakeContext));
+
+ var resumed = Assert.IsAssignableFrom(stagedWaiter);
+ Assert.True(resumed.TryWake());
+ Assert.Equal(0, resumed.Resume());
+ }
+
+ [Fact]
+ public void UmtxZeroTimespecReturnsMinusOneAndTimedOutErrno()
+ {
+ var (memory, ctx) = CreateContext();
+ WriteUInt64(memory, ValueAddress, 12);
+ WriteUInt64(memory, TimeoutAddress, 0);
+ WriteUInt64(memory, TimeoutAddress + sizeof(long), 0);
+ SetErrnoStorage(ctx);
+ ctx[CpuRegister.Rdi] = ValueAddress;
+ ctx[CpuRegister.Rsi] = 2; // UMTX_OP_WAIT
+ ctx[CpuRegister.Rdx] = 12;
+ ctx[CpuRegister.Rcx] = 0;
+ ctx[CpuRegister.R8] = TimeoutAddress;
+
+ Assert.Equal(-1, KernelSyncOnAddressCompatExports.UmtxOp(ctx));
+ Assert.Equal(ulong.MaxValue, ctx[CpuRegister.Rax]);
+ Assert.Equal(60, ReadInt32(memory, ctx.FsBase + 0x40)); // ETIMEDOUT
+ Assert.False(GuestThreadExecution.TryConsumeCurrentThreadBlock(out _));
+ }
+
+ [Fact]
+ public void UmtxRejectsInvalidTimespecWithEinval()
+ {
+ var (memory, ctx) = CreateContext();
+ WriteUInt64(memory, ValueAddress, 12);
+ WriteUInt64(memory, TimeoutAddress, 0);
+ WriteUInt64(memory, TimeoutAddress + sizeof(long), 1_000_000_000);
+ SetErrnoStorage(ctx);
+ ctx[CpuRegister.Rdi] = ValueAddress;
+ ctx[CpuRegister.Rsi] = 2; // UMTX_OP_WAIT
+ ctx[CpuRegister.Rdx] = 12;
+ ctx[CpuRegister.Rcx] = 0;
+ ctx[CpuRegister.R8] = TimeoutAddress;
+
+ Assert.Equal(-1, KernelSyncOnAddressCompatExports.UmtxOp(ctx));
+ Assert.Equal(22, ReadInt32(memory, ctx.FsBase + 0x40)); // EINVAL
+ }
+
+ [Fact]
+ public void UmtxLargeTimespecSaturatesWithoutOverflow()
+ {
+ var (memory, ctx) = CreateContext();
+ WriteUInt64(memory, ValueAddress, 1);
+ WriteUInt64(memory, TimeoutAddress, unchecked((ulong)long.MaxValue));
+ WriteUInt64(memory, TimeoutAddress + sizeof(long), 999_999_999);
+ ctx[CpuRegister.Rdi] = ValueAddress;
+ ctx[CpuRegister.Rsi] = 2; // UMTX_OP_WAIT
+ ctx[CpuRegister.Rdx] = 2; // Already changed, so no host wait.
+ ctx[CpuRegister.Rcx] = 0;
+ ctx[CpuRegister.R8] = TimeoutAddress;
+
+ Assert.Equal(0, KernelSyncOnAddressCompatExports.UmtxOp(ctx));
+ }
+
+ [Fact]
+ public void UmtxTimedContinuationReturnsPosixTimeout()
+ {
+ var (memory, ctx) = CreateContext();
+ WriteUInt64(memory, ValueAddress, 21);
+ WriteUInt64(memory, TimeoutAddress, 0);
+ WriteUInt64(memory, TimeoutAddress + sizeof(long), 1_000_000); // 1 ms
+ SetErrnoStorage(ctx);
+ ctx[CpuRegister.Rdi] = ValueAddress;
+ ctx[CpuRegister.Rsi] = 2; // UMTX_OP_WAIT
+ ctx[CpuRegister.Rdx] = 21;
+ ctx[CpuRegister.Rcx] = 0;
+ ctx[CpuRegister.R8] = TimeoutAddress;
+
+ IGuestThreadBlockWaiter? stagedWaiter = null;
+ RunAsGuest(0x80B, () =>
+ {
+ Assert.Equal(0, KernelSyncOnAddressCompatExports.UmtxOp(ctx));
+ Assert.True(GuestThreadExecution.TryConsumeCurrentThreadBlock(
+ out _,
+ out _,
+ out _,
+ out _,
+ out var waiter,
+ out var deadline));
+ Assert.True(deadline > 0);
+ stagedWaiter = Assert.IsAssignableFrom(waiter);
+ });
+
+ Thread.Sleep(10);
+ var resumed = Assert.IsAssignableFrom(stagedWaiter);
+ Assert.Equal(-1, resumed.Resume());
+ Assert.Equal(60, ReadInt32(memory, ctx.FsBase + 0x40)); // ETIMEDOUT
+ }
+
+ [Fact]
+ public void UmtxExportIsRegisteredForGen5()
+ {
+ var manager = new ModuleManager();
+ manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5));
+
+ Assert.True(manager.TryGetExport("04AjkP0jO9U", out var export));
+ Assert.Equal("_umtx_op", export.Name);
+ Assert.Equal("libKernel", export.LibraryName);
+ }
+
private static (FakeCpuMemory Memory, CpuContext Context) CreateContext()
{
var memory = new FakeCpuMemory(BaseAddress, 0x1000);
@@ -213,4 +373,16 @@ public sealed class KernelSyncOnAddressCompatExportsTests
BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
Assert.True(memory.TryWrite(address, bytes));
}
+
+ private static void SetErrnoStorage(CpuContext ctx)
+ {
+ ctx.FsBase = BaseAddress + 0x700;
+ }
+
+ private static int ReadInt32(FakeCpuMemory memory, ulong address)
+ {
+ Span bytes = stackalloc byte[sizeof(int)];
+ Assert.True(memory.TryRead(address, bytes));
+ return BinaryPrimitives.ReadInt32LittleEndian(bytes);
+ }
}
diff --git a/tests/SharpEmu.Libs.Tests/Kernel/SemaphoreSignalProfileTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/SemaphoreSignalProfileTests.cs
index c92fada..b183830 100644
--- a/tests/SharpEmu.Libs.Tests/Kernel/SemaphoreSignalProfileTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Kernel/SemaphoreSignalProfileTests.cs
@@ -41,7 +41,8 @@ public sealed class SemaphoreSignalProfileTests
[Theory]
[InlineData(0, 2, true)]
- [InlineData(0, 0, false)]
+ [InlineData(0, 0, true)]
+ [InlineData(1, -1, false)]
[InlineData(1, int.MaxValue, false)]
public void SignalKeepsTokenSemanticsAndRecordsStagesWhenEnabled(uint initial, int count, bool succeeds)
{
diff --git a/tests/SharpEmu.Libs.Tests/Np/NpWebApi2ExportsTests.cs b/tests/SharpEmu.Libs.Tests/Np/NpWebApi2ExportsTests.cs
new file mode 100644
index 0000000..e69ea9e
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Np/NpWebApi2ExportsTests.cs
@@ -0,0 +1,78 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE;
+using SharpEmu.Libs.Np;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Np;
+
+public sealed class NpWebApi2ExportsTests
+{
+ private const ulong MemoryBase = 0x1_0000_0000;
+ private const int InvalidArgument = unchecked((int)0x80553402);
+
+ [Fact]
+ public void PushEventRegisterCallback_ValidatesStateAndReturnsCallbackId()
+ {
+ var context = new CpuContext(new FakeCpuMemory(MemoryBase, 0x1000), Generation.Gen5);
+ NpWebApi2Exports.ResetRuntimeState();
+ try
+ {
+ context[CpuRegister.Rdi] = 1;
+ context[CpuRegister.Rsi] = 0x1000;
+ var libraryContextId = NpWebApi2Exports.NpWebApi2Initialize(context);
+ Assert.True(libraryContextId > 0);
+
+ context[CpuRegister.Rdi] = unchecked((ulong)libraryContextId);
+ context[CpuRegister.Rsi] = 0x1000_0000;
+ var userContextId = NpWebApi2Exports.NpWebApi2CreateUserContext(context);
+ Assert.True(userContextId > 0);
+
+ context[CpuRegister.Rdi] = unchecked((ulong)libraryContextId);
+ var filterId = NpWebApi2Exports.NpWebApi2PushEventCreateFilter(context);
+ Assert.True(filterId > 0);
+
+ context[CpuRegister.Rdi] = unchecked((ulong)userContextId);
+ context[CpuRegister.Rsi] = unchecked((ulong)filterId);
+ context[CpuRegister.Rdx] = 0x8000_1000;
+ context[CpuRegister.Rcx] = 0x1234;
+ Assert.True(NpWebApi2Exports.NpWebApi2PushEventRegisterCallback(context) > 0);
+
+ context[CpuRegister.Rdx] = 0;
+ Assert.Equal(
+ InvalidArgument,
+ NpWebApi2Exports.NpWebApi2PushEventRegisterCallback(context));
+ }
+ finally
+ {
+ NpWebApi2Exports.ResetRuntimeState();
+ }
+ }
+
+ [Fact]
+ public void PushEventRegisterCallback_RegistersForGen5()
+ {
+ var manager = new ModuleManager();
+ manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5));
+
+ Assert.True(manager.TryGetExport("fY3QqeNkF8k", out var export));
+ Assert.Equal("sceNpWebApi2PushEventRegisterCallback", export.Name);
+ Assert.Equal("libSceNpWebApi2", export.LibraryName);
+ }
+
+ [Fact]
+ public void PushEventDeleteHandle_IsIdempotentAndRegisteredForGen5()
+ {
+ var context = new CpuContext(new FakeCpuMemory(MemoryBase, 0x1000), Generation.Gen5);
+ context[CpuRegister.Rdi] = 1;
+ context[CpuRegister.Rsi] = 1;
+ Assert.Equal(0, NpWebApi2Exports.NpWebApi2PushEventDeleteHandle(context));
+
+ var manager = new ModuleManager();
+ manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5));
+ Assert.True(manager.TryGetExport("fIATVMo4Y1w", out var export));
+ Assert.Equal("sceNpWebApi2PushEventDeleteHandle", export.Name);
+ Assert.Equal("libSceNpWebApi2", export.LibraryName);
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Pthread/PthreadSemaphoreSemanticsTests.cs b/tests/SharpEmu.Libs.Tests/Pthread/PthreadSemaphoreSemanticsTests.cs
index 5dfab21..c3bf2e0 100644
--- a/tests/SharpEmu.Libs.Tests/Pthread/PthreadSemaphoreSemanticsTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Pthread/PthreadSemaphoreSemanticsTests.cs
@@ -17,6 +17,7 @@ public sealed class PthreadSemaphoreSemanticsTests
[InlineData("C36iRE0F5sE", "scePthreadSemWait")]
[InlineData("aishVAiFaYM", "scePthreadSemPost")]
[InlineData("H2a+IN9TP0E", "scePthreadSemTrywait")]
+ [InlineData("fjN6NQHhK8k", "scePthreadSemTimedwait")]
[InlineData("GEnUkDZoUwY", "scePthreadSemInit")]
[InlineData("Vwc+L05e6oE", "scePthreadSemDestroy")]
public void RegistryResolvesPthreadSemaphoreExports(string nid, string exportName)
@@ -68,6 +69,41 @@ public sealed class PthreadSemaphoreSemanticsTests
DestroySemaphore(context);
}
+ [Fact]
+ public void PthreadSemTimedWaitTreatsSecondArgumentAsRelativeMicroseconds()
+ {
+ var context = CreateContext();
+ InitializeSemaphore(context, initialCount: 1);
+
+ context[CpuRegister.Rdi] = SemaphoreAddress;
+ context[CpuRegister.Rsi] = 250_000;
+
+ Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemTimedWait(context));
+ Assert.Equal(0UL, context[CpuRegister.Rax]);
+ Assert.Equal(0, ReadSemaphoreValue(context));
+
+ DestroySemaphore(context);
+ }
+
+ [Fact]
+ public void PthreadSemTimedWaitWithZeroTimeoutReturnsTimedOutWhenEmpty()
+ {
+ var context = CreateContext();
+ InitializeSemaphore(context, initialCount: 0);
+
+ context[CpuRegister.Rdi] = SemaphoreAddress;
+ context[CpuRegister.Rsi] = 0;
+
+ Assert.Equal(
+ (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT,
+ KernelSemaphoreCompatExports.PthreadSemTimedWait(context));
+ Assert.Equal(
+ unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT),
+ context[CpuRegister.Rax]);
+
+ DestroySemaphore(context);
+ }
+
[Fact]
public void PthreadSemInitRejectsNonPrivateFlag()
{
diff --git a/tests/SharpEmu.Libs.Tests/Pthread/PthreadStackBoundsTests.cs b/tests/SharpEmu.Libs.Tests/Pthread/PthreadStackBoundsTests.cs
new file mode 100644
index 0000000..2399637
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Pthread/PthreadStackBoundsTests.cs
@@ -0,0 +1,138 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Reflection;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Kernel;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Pthread;
+
+[CollectionDefinition(Name, DisableParallelization = true)]
+public sealed class PthreadStackBoundsCollection
+{
+ public const string Name = "PthreadStackBounds";
+}
+
+[Collection(PthreadStackBoundsCollection.Name)]
+public sealed class PthreadStackBoundsTests : IDisposable
+{
+ private const ulong MemoryBase = 0x6_2000_0000;
+ private const ulong ThreadHandle = 0x1234_5000;
+ private const ulong AttrAddress = MemoryBase + 0x100;
+ private const ulong OutStackAddress = MemoryBase + 0x200;
+ private const ulong OutStackSizeAddress = MemoryBase + 0x208;
+ private readonly IGuestThreadScheduler? _previousScheduler = GuestThreadExecution.Scheduler;
+
+ [Fact]
+ public void PosixGetAttrUsesSchedulerBoundsForRemoteHighSlotThread()
+ {
+ const ulong stackBase = 0x0000_7FFF_A800_0000;
+ const ulong stackSize = 0x20_0000;
+ var scheduler = CreateScheduler(ThreadHandle, stackBase, stackSize);
+ GuestThreadExecution.Scheduler = scheduler;
+
+ var context = CreateContext();
+ context[CpuRegister.Rsp] = stackBase + stackSize - 0xEA0;
+ context[CpuRegister.Rdi] = ThreadHandle;
+ context[CpuRegister.Rsi] = AttrAddress;
+
+ Assert.Equal(0, KernelPthreadExtendedCompatExports.PthreadAttrGetNpPOSIX(context));
+ AssertStackBounds(context, stackBase, stackSize);
+ }
+
+ [Fact]
+ public void CurrentPrimaryThreadRetainsBoundedStackPointerFallback()
+ {
+ const ulong primaryThreadHandle = ThreadHandle + 1;
+ var stackBase = OperatingSystem.IsWindows()
+ ? 0x0000_7FFF_F000_0000UL
+ : 0x0000_6FFF_F000_0000UL;
+ GuestThreadExecution.Scheduler = CreateScheduler();
+ var previousThread = GuestThreadExecution.EnterGuestThread(primaryThreadHandle);
+ try
+ {
+ var context = CreateContext();
+ context[CpuRegister.Rsp] = stackBase + 0x1F_F160;
+ context[CpuRegister.Rdi] = primaryThreadHandle;
+ context[CpuRegister.Rsi] = AttrAddress + 0x20;
+
+ Assert.Equal(0, KernelPthreadExtendedCompatExports.PthreadAttrGet(context));
+ AssertStackBounds(context, stackBase, 0x20_0000, AttrAddress + 0x20);
+ }
+ finally
+ {
+ GuestThreadExecution.RestoreGuestThread(previousThread);
+ }
+ }
+
+ [Fact]
+ public void InvalidSchedulerBoundsDoNotReplaceDefaultAttributes()
+ {
+ const ulong invalidThreadHandle = ThreadHandle + 2;
+ GuestThreadExecution.Scheduler = CreateScheduler(invalidThreadHandle, 0, 0x20_0000);
+ var context = CreateContext();
+ context[CpuRegister.Rdi] = invalidThreadHandle;
+ context[CpuRegister.Rsi] = AttrAddress + 0x40;
+
+ Assert.Equal(0, KernelPthreadExtendedCompatExports.PthreadAttrGet(context));
+ AssertStackBounds(context, 0, 0x10_0000, AttrAddress + 0x40);
+ }
+
+ public void Dispose()
+ {
+ GuestThreadExecution.Scheduler = _previousScheduler;
+ }
+
+ private static CpuContext CreateContext() =>
+ new(new FakeCpuMemory(MemoryBase, 0x4000), Generation.Gen5);
+
+ private static IGuestThreadScheduler CreateScheduler(
+ ulong threadHandle = 0,
+ ulong stackBase = 0,
+ ulong stackSize = 0)
+ {
+ var scheduler = DispatchProxy.Create();
+ var proxy = (StackBoundsSchedulerProxy)(object)scheduler;
+ if (threadHandle != 0)
+ {
+ proxy.Bounds[threadHandle] = (stackBase, stackSize);
+ }
+
+ return scheduler;
+ }
+
+ private static void AssertStackBounds(
+ CpuContext context,
+ ulong expectedBase,
+ ulong expectedSize,
+ ulong attrAddress = AttrAddress)
+ {
+ context[CpuRegister.Rdi] = attrAddress;
+ context[CpuRegister.Rsi] = OutStackAddress;
+ context[CpuRegister.Rdx] = OutStackSizeAddress;
+ Assert.Equal(0, KernelPthreadExtendedCompatExports.PthreadAttrGetstackPOSIX(context));
+ Assert.True(context.TryReadUInt64(OutStackAddress, out var actualBase));
+ Assert.True(context.TryReadUInt64(OutStackSizeAddress, out var actualSize));
+ Assert.Equal(expectedBase, actualBase);
+ Assert.Equal(expectedSize, actualSize);
+ }
+
+ public class StackBoundsSchedulerProxy : DispatchProxy
+ {
+ public Dictionary Bounds { get; } = [];
+
+ protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
+ {
+ if (targetMethod?.Name == nameof(IGuestThreadScheduler.TryGetGuestThreadStackBounds))
+ {
+ var found = Bounds.TryGetValue((ulong)args![0]!, out var bounds);
+ args[1] = found ? bounds.Base : 0UL;
+ args[2] = found ? bounds.Size : 0UL;
+ return found;
+ }
+
+ throw new NotSupportedException(targetMethod?.Name);
+ }
+ }
+}
diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5ExecFullAnalysisTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ExecFullAnalysisTests.cs
index a276960..caffd90 100644
--- a/tests/SharpEmu.ShaderCompiler.Tests/Gen5ExecFullAnalysisTests.cs
+++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ExecFullAnalysisTests.cs
@@ -66,6 +66,31 @@ public sealed class Gen5ExecFullAnalysisTests
Assert.DoesNotContain(after, full);
}
+ [Fact]
+ public void AfterAnSdwaVectorCompareIntoExec_ExecIsUnknown()
+ {
+ var before = VectorAdd();
+ var pc = _pc;
+ _program.Add(new Gen5ShaderInstruction(
+ pc,
+ Gen5ShaderEncoding.Vopc,
+ "VCmpxNltF32",
+ [0u],
+ [Gen5Operand.Vector(0), Gen5Operand.Vector(1)],
+ [],
+ new Gen5SdwaControl(0, 0, 6, 6, false, false, 0, 0, 0, false, 106)));
+ _pc += 4;
+ var save = Scalar("SMovB64", 22, Gen5Operand.Scalar(Exec));
+ VectorAdd();
+ Scalar("SMovB64", Exec, Gen5Operand.Scalar(22));
+ var after = VectorAdd();
+
+ var full = Analyze();
+ Assert.Contains(before, full);
+ Assert.DoesNotContain(save, full);
+ Assert.DoesNotContain(after, full);
+ }
+
[Fact]
public void RestoringASavedFullExec_MakesItFullAgain()
{