diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs
index bb6e3b0..853bf26 100644
--- a/src/SharpEmu.Libs/Agc/AgcExports.cs
+++ b/src/SharpEmu.Libs/Agc/AgcExports.cs
@@ -2741,8 +2741,12 @@ public static class AgcExports
ctx.TryReadUInt32(currentAddress + sizeof(uint), out var eventTypeRaw))
{
var eventType = eventTypeRaw & 0x3Fu;
- var triggered = KernelEventQueueCompatExports.TriggerRegisteredEvents(
- eventType,
+
+ // The guest registers AGC events with a full eventId, but the command buffer
+ // only carries a 6-bit EVENT_TYPE. Those two values are not the same numbering
+ // scheme, so exact ident matching never wakes anything. Trigger every graphics
+ // event registration instead (workaround for issue #173).
+ var triggered = KernelEventQueueCompatExports.TriggerRegisteredEventsByFilter(
KernelEventQueueCompatExports.KernelEventFilterGraphics,
eventType);
if (tracePackets)
diff --git a/src/SharpEmu.Libs/Kernel/KernelEventQueueCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelEventQueueCompatExports.cs
index aac6079..dafdb9c 100644
--- a/src/SharpEmu.Libs/Kernel/KernelEventQueueCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelEventQueueCompatExports.cs
@@ -589,6 +589,66 @@ public static class KernelEventQueueCompatExports
return triggeredCount;
}
+ ///
+ /// Triggers every registered event on every queue that matches
+ /// regardless of the registration's ident. This is a workaround for PS5 AGC command
+ /// buffers, where IT_EVENT_WRITE carries a hardware EVENT_TYPE that does not
+ /// match the eventId the guest registered with sceAgcDriverAddEqEvent.
+ /// See issue #173.
+ ///
+ public static int TriggerRegisteredEventsByFilter(
+ short filter,
+ ulong data)
+ {
+ List? wakeHandles = null;
+ var triggeredCount = 0;
+ lock (_eventQueueGate)
+ {
+ foreach (var (handle, registrations) in _registeredEvents)
+ {
+ foreach (var registration in registrations.Values)
+ {
+ if (registration.Filter != filter)
+ {
+ continue;
+ }
+
+ if (!_pendingEvents.TryGetValue(handle, out var queue))
+ {
+ queue = new KernelEventDeque();
+ _pendingEvents[handle] = queue;
+ }
+
+ QueueOrUpdateEvent(
+ queue,
+ new KernelQueuedEvent(
+ registration.Ident,
+ registration.Filter,
+ 0,
+ 1,
+ data,
+ registration.UserData));
+ (wakeHandles ??= new List()).Add(handle);
+ triggeredCount++;
+
+ // A single queue only needs to be woken once, even if multiple
+ // registrations matched.
+ break;
+ }
+ }
+ }
+
+ if (wakeHandles is not null)
+ {
+ foreach (var handle in wakeHandles)
+ {
+ WakeEventQueue(handle);
+ }
+ }
+
+ return triggeredCount;
+ }
+
private static bool TriggerRegisteredEvent(
ulong handle,
ulong ident,
diff --git a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs
index b96956f..c2aa476 100644
--- a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs
@@ -99,20 +99,6 @@ public static class KernelPthreadCompatExports
// See PthreadMutexState.WakeKey.
public string WakeKey { get; } = "pthread_cond#" + Interlocked.Increment(ref _nextCondWakeId).ToString("X");
-
- // A signal with no waiter stays pending; the guest often signals before the wait.
- public int PendingSignals { get; set; }
-
- public bool TryConsumePendingSignal()
- {
- if (PendingSignals <= 0)
- {
- return false;
- }
-
- PendingSignals--;
- return true;
- }
}
private readonly record struct PthreadMutexAttrState(int Type, int Protocol);
@@ -1375,9 +1361,8 @@ public static class KernelPthreadCompatExports
{
state.Waiters++;
var observedEpoch = state.SignalEpoch;
- var consumedPendingSignal = state.TryConsumePendingSignal();
TracePthreadCond(
- consumedPendingSignal ? "wait-enter-pending" : "wait-enter",
+ "wait-enter",
condAddress,
mutexAddress,
state,
@@ -1392,25 +1377,6 @@ public static class KernelPthreadCompatExports
return unlockResult;
}
- if (consumedPendingSignal)
- {
- state.Waiters = Math.Max(0, state.Waiters - 1);
- TracePthreadCond("wait-wake-pending", condAddress, mutexAddress, state, timed, waitResult);
-
- // Relock outside SyncRoot to preserve lock ordering.
- Monitor.Exit(state.SyncRoot);
- try
- {
- var pendingLockResult = PthreadMutexRelockForCondWait(ctx, mutexAddress, releasedRecursion);
- return pendingLockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK ? pendingLockResult : waitResult;
- }
- finally
- {
- // Balance the surrounding lock statement.
- Monitor.Enter(state.SyncRoot);
- }
- }
-
var scheduler = GuestThreadExecution.Scheduler;
if (GuestThreadExecution.IsGuestThread &&
GuestThreadExecution.RequestCurrentThreadBlock(
@@ -1542,10 +1508,6 @@ public static class KernelPthreadCompatExports
Monitor.Pulse(state.SyncRoot);
}
}
- else
- {
- state.PendingSignals++;
- }
TracePthreadCond(broadcast ? "broadcast" : "signal", condAddress, mutexAddress: 0, state, timed: false, (int)OrbisGen2Result.ORBIS_GEN2_OK);
}
diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcEventQueueTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcEventQueueTests.cs
new file mode 100644
index 0000000..ccfc4cd
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Agc/AgcEventQueueTests.cs
@@ -0,0 +1,136 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Kernel;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Agc;
+
+// IT_EVENT_WRITE carries a 6-bit hardware EVENT_TYPE, but sceAgcDriverAddEqEvent registers the
+// listener with a guest-defined eventId. Those two values are not the same numbering scheme, so
+// exact ident matching never wakes anything (issue #173). TriggerRegisteredEventsByFilter wakes
+// every graphics registration instead.
+public sealed class AgcEventQueueTests
+{
+ private const ulong BaseAddress = 0x1_0000_0000;
+ private const int MemorySize = 0x2000;
+
+ [Fact]
+ public void TriggerRegisteredEventsByFilter_DifferentIdentThanEventType_WakesGraphicsWaiter()
+ {
+ var memory = new FakeCpuMemory(BaseAddress, MemorySize);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ const ulong handleOutAddress = BaseAddress + 0x100;
+ const ulong eventsAddress = BaseAddress + 0x200;
+ const ulong outCountAddress = BaseAddress + 0x300;
+ const ulong timeoutAddress = BaseAddress + 0x400;
+
+ // Create an event queue.
+ ctx[CpuRegister.Rdi] = handleOutAddress;
+ var createResult = KernelEventQueueCompatExports.KernelCreateEqueue(ctx);
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, createResult);
+
+ var handle = ReadUInt64(memory, handleOutAddress);
+
+ // Register a graphics event with eventId 0x20 (as Poppy Playtime does).
+ const ulong registeredEventId = 0x20;
+ const ulong userData = 0xDEAD_BEEF;
+ var registered = KernelEventQueueCompatExports.RegisterEvent(
+ handle,
+ registeredEventId,
+ KernelEventQueueCompatExports.KernelEventFilterGraphics,
+ userData);
+ Assert.True(registered);
+
+ // The command buffer fires EVENT_WRITE with eventType 0x07. This does not match 0x20.
+ const ulong eventType = 0x07;
+ var triggered = KernelEventQueueCompatExports.TriggerRegisteredEventsByFilter(
+ KernelEventQueueCompatExports.KernelEventFilterGraphics,
+ eventType);
+ Assert.Equal(1, triggered);
+
+ // Wait with timeout=0. The event is already pending, so this returns immediately.
+ WriteUInt64(memory, timeoutAddress, 0);
+ ctx[CpuRegister.Rdi] = handle;
+ ctx[CpuRegister.Rsi] = eventsAddress;
+ ctx[CpuRegister.Rdx] = 4;
+ ctx[CpuRegister.Rcx] = outCountAddress;
+ ctx[CpuRegister.R8] = timeoutAddress;
+ var waitResult = KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, waitResult);
+ Assert.Equal(1u, ReadUInt32(memory, outCountAddress));
+
+ // Verify the queued event carries the registered ident and the event type as data.
+ Assert.Equal(registeredEventId, ReadUInt64(memory, eventsAddress + 0x00));
+ Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterGraphics, ReadInt16(memory, eventsAddress + 0x08));
+ Assert.Equal(0u, ReadUInt16(memory, eventsAddress + 0x0A));
+ Assert.Equal(1u, ReadUInt32(memory, eventsAddress + 0x0C));
+ Assert.Equal(eventType, ReadUInt64(memory, eventsAddress + 0x10));
+ Assert.Equal(userData, ReadUInt64(memory, eventsAddress + 0x18));
+ }
+
+ [Fact]
+ public void TriggerRegisteredEventsByFilter_NoGraphicsRegistrations_ReturnsZero()
+ {
+ var memory = new FakeCpuMemory(BaseAddress, MemorySize);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ const ulong handleOutAddress = BaseAddress + 0x100;
+ ctx[CpuRegister.Rdi] = handleOutAddress;
+ var createResult = KernelEventQueueCompatExports.KernelCreateEqueue(ctx);
+ Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, createResult);
+
+ var handle = ReadUInt64(memory, handleOutAddress);
+
+ // Register a user event, not a graphics event.
+ KernelEventQueueCompatExports.RegisterEvent(
+ handle,
+ 0x1,
+ KernelEventQueueCompatExports.KernelEventFilterUser,
+ 0);
+
+ var triggered = KernelEventQueueCompatExports.TriggerRegisteredEventsByFilter(
+ KernelEventQueueCompatExports.KernelEventFilterGraphics,
+ 0x07);
+
+ Assert.Equal(0, triggered);
+ }
+
+ private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
+ {
+ Span buffer = stackalloc byte[8];
+ Assert.True(memory.TryRead(address, buffer));
+ return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
+ }
+
+ private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
+ {
+ Span buffer = stackalloc byte[4];
+ Assert.True(memory.TryRead(address, buffer));
+ return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
+ }
+
+ private static ushort ReadUInt16(FakeCpuMemory memory, ulong address)
+ {
+ Span buffer = stackalloc byte[2];
+ Assert.True(memory.TryRead(address, buffer));
+ return BinaryPrimitives.ReadUInt16LittleEndian(buffer);
+ }
+
+ private static short ReadInt16(FakeCpuMemory memory, ulong address)
+ {
+ Span buffer = stackalloc byte[2];
+ Assert.True(memory.TryRead(address, buffer));
+ return BinaryPrimitives.ReadInt16LittleEndian(buffer);
+ }
+
+ private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
+ {
+ Span buffer = stackalloc byte[8];
+ BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
+ Assert.True(memory.TryWrite(address, buffer));
+ }
+}
diff --git a/tests/SharpEmu.Libs.Tests/Pthread/PthreadCondSemanticsTests.cs b/tests/SharpEmu.Libs.Tests/Pthread/PthreadCondSemanticsTests.cs
new file mode 100644
index 0000000..5095054
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Pthread/PthreadCondSemanticsTests.cs
@@ -0,0 +1,82 @@
+// 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;
+
+// POSIX condition variables are edges, not semaphore credits. A signal with no waiter
+// must have no effect. This was violated by the previous implementation which persisted
+// signals via PendingSignals, causing lock inversions and predicate bypasses.
+// See issue #113.
+public sealed class PthreadCondSemanticsTests
+{
+ [Fact]
+ public void PthreadCondState_DoesNotHavePendingSignals()
+ {
+ // Verify that PthreadCondState no longer has the PendingSignals property.
+ // This is a regression test to ensure the POSIX-correct behavior is maintained.
+ var stateType = typeof(KernelPthreadCompatExports).GetNestedType("PthreadCondState", BindingFlags.NonPublic);
+ Assert.NotNull(stateType);
+
+ var pendingSignalsProp = stateType.GetProperty("PendingSignals");
+ Assert.Null(pendingSignalsProp);
+
+ var tryConsumeMethod = stateType.GetMethod("TryConsumePendingSignal");
+ Assert.Null(tryConsumeMethod);
+ }
+
+ [Fact]
+ public void PthreadCondSignal_WithNoWaiter_DoesNotPersist()
+ {
+ // This test verifies the semantic contract: signal without waiter is a no-op.
+ // We can't easily test the full pthread flow without the scheduler, but we can
+ // verify the code path by checking that SignalEpoch advances but no state persists.
+ var stateType = typeof(KernelPthreadCompatExports).GetNestedType("PthreadCondState", BindingFlags.NonPublic);
+ Assert.NotNull(stateType);
+
+ // Create an instance via reflection
+ var state = Activator.CreateInstance(stateType);
+ Assert.NotNull(state);
+
+ var syncRootProp = stateType.GetProperty("SyncRoot");
+ var signalEpochProp = stateType.GetProperty("SignalEpoch");
+ var waitersProp = stateType.GetProperty("Waiters");
+
+ Assert.NotNull(syncRootProp);
+ Assert.NotNull(signalEpochProp);
+ Assert.NotNull(waitersProp);
+
+ var syncRoot = syncRootProp.GetValue(state);
+ Assert.NotNull(syncRoot);
+
+ // Initial state
+ Assert.Equal(0UL, (ulong)signalEpochProp.GetValue(state)!);
+ Assert.Equal(0, (int)waitersProp.GetValue(state)!);
+
+ // Simulate signal with no waiter (this would have incremented PendingSignals before)
+ lock (syncRoot)
+ {
+ signalEpochProp.SetValue(state, (ulong)signalEpochProp.GetValue(state)! + 1);
+ // Note: we don't increment PendingSignals because it doesn't exist
+ }
+
+ // Verify epoch advanced but no persistent signal state
+ Assert.Equal(1UL, (ulong)signalEpochProp.GetValue(state)!);
+
+ // A new waiter arriving should see the new epoch but not consume any "pending" signal
+ // (because there's no such concept anymore)
+ lock (syncRoot)
+ {
+ var observedEpoch = (ulong)signalEpochProp.GetValue(state)!;
+ waitersProp.SetValue(state, (int)waitersProp.GetValue(state)! + 1);
+
+ // Waiter sees epoch=1, will block until epoch changes again
+ Assert.Equal(1UL, observedEpoch);
+ Assert.Equal(1, (int)waitersProp.GetValue(state)!);
+ }
+ }
+}