diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index 5bc3cf33..86301913 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -604,6 +604,11 @@ public static partial class AgcExports public uint DrawIndexOffset { get; set; } public bool PredicateSkip { get; set; } public string QueueName { get; set; } = "graphics"; + // Ident this queue's end-of-pipe completion interrupt is published under. + // The graphics queue keeps 0; a compute queue takes the owner handle it + // was submitted with, which is the same value the guest registers through + // sceAgcDriverAddEqEvent. + public ulong CompletionEventId { get; set; } public ulong ActiveSubmissionId { get; set; } public Queue PendingSubmissions { get; } = new(); public bool HasActiveSubmission { get; set; } @@ -3333,6 +3338,7 @@ public static partial class AgcExports } queueState.QueueName = $"acb.compute[{ownerHandle}]"; + queueState.CompletionEventId = ownerHandle; EnqueueSubmittedDcb( ctx, gpuState, @@ -3523,33 +3529,47 @@ public static partial class AgcExports SubmittedDcbState state, ulong submissionId) { - if (!ReferenceEquals(state, gpuState.Graphics) || - state.CompletionEventNotifiedSubmissionId == submissionId) + if (state.CompletionEventNotifiedSubmissionId == submissionId) { return; } state.CompletionEventNotifiedSubmissionId = submissionId; + // Hardware raises an end-of-pipe interrupt for every submission on every + // queue, so this is unconditional. It stays safe for titles that do not + // want it because delivery is registration-gated: TriggerRegisteredEvents + // only queues onto equeues that registered this exact ident through + // sceAgcDriverAddEqEvent. Graphics keeps ident 0; a compute queue uses the + // owner handle it was submitted under. + var completionEventId = state.CompletionEventId; + var isGraphics = ReferenceEquals(state, gpuState.Graphics); + var queueName = state.QueueName; void TriggerCompletionEvents() { var triggered = KernelEventQueueCompatExports.TriggerRegisteredEvents( - ident: 0, + completionEventId, KernelEventQueueCompatExports.KernelEventFilterGraphics, - data: 0); - if (_compatibilitySubmitCompletionEvent) + completionEventId); + // The broad fan-out wakes graphics registrations whose ident never + // matches anything the driver publishes. That is a compatibility + // guess rather than hardware behavior, so it stays opt-in and stays + // on the graphics queue where it was measured. + if (isGraphics && _compatibilitySubmitCompletionEvent) { triggered += KernelEventQueueCompatExports.TriggerRegisteredEventsDistinct( KernelEventQueueCompatExports.KernelEventFilterGraphics); } TraceAgc( - $"agc.driver_submit_dcb completion submission={submissionId} " + - $"queues={triggered}"); + $"agc.completion_event queue={queueName} submission={submissionId} " + + $"event=0x{completionEventId:X} queues={triggered}"); } - // A DCB is complete only after its translated Vulkan work and ordered - // guest-memory writes have finished. Put the notification on that same - // logical graphics queue instead of approximating completion with a - // timer, which can wake Unity while its upload data is still stale. + // A submission is complete only after its translated Vulkan work and + // ordered guest-memory writes have finished. Put the notification on that + // same logical queue instead of approximating completion with a timer or a + // ThreadPool hop, either of which can only make the interrupt late and + // reorder it against registration changes (and can wake Unity while its + // upload data is still stale). if (GuestGpu.Current.SubmitOrderedGuestAction( TriggerCompletionEvents, $"agc submit completion {submissionId}") == 0) diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcEventQueueTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcEventQueueTests.cs index ccfc4cde..9a13da48 100644 --- a/tests/SharpEmu.Libs.Tests/Agc/AgcEventQueueTests.cs +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcEventQueueTests.cs @@ -8,10 +8,20 @@ using Xunit; namespace SharpEmu.Libs.Tests.Agc; +// The kernel event-queue registry is process-wide static state, and these tests assert over +// every graphics registration in it, so they cannot run beside another suite that registers +// graphics events. +[CollectionDefinition(GraphicsEventQueueStateCollection.Name, DisableParallelization = true)] +public sealed class GraphicsEventQueueStateCollection +{ + public const string Name = "GraphicsEventQueueState"; +} + // 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. +[Collection(GraphicsEventQueueStateCollection.Name)] public sealed class AgcEventQueueTests { private const ulong BaseAddress = 0x1_0000_0000; @@ -70,6 +80,14 @@ public sealed class AgcEventQueueTests Assert.Equal(1u, ReadUInt32(memory, eventsAddress + 0x0C)); Assert.Equal(eventType, ReadUInt64(memory, eventsAddress + 0x10)); Assert.Equal(userData, ReadUInt64(memory, eventsAddress + 0x18)); + + // Registrations live in process-wide static state, and a sibling test asserts that + // no graphics registration exists at all. Drop this one instead of relying on + // execution order. + ctx[CpuRegister.Rdi] = handle; + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_OK, + KernelEventQueueCompatExports.KernelDeleteEqueue(ctx)); } [Fact] diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcSubmitCompletionEventTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcSubmitCompletionEventTests.cs new file mode 100644 index 00000000..9d478e93 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcSubmitCompletionEventTests.cs @@ -0,0 +1,220 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.Libs.Agc; +using SharpEmu.Libs.Kernel; +using Xunit; + +namespace SharpEmu.Libs.Tests.Agc; + +// sceAgcDriverSubmitAcb takes the compute queue's owner handle in rdi. That handle is the +// eventId the guest registers with sceAgcDriverAddEqEvent, so an ACB reaching its queue fence +// must deliver a graphics-filter completion event under that exact ident. UE 4.27's dynamic +// resolution heuristic parks the game thread on that interrupt; without it the render side +// never publishes GPU timings and the title deadlocks. +[Collection(GraphicsEventQueueStateCollection.Name)] +public sealed class AgcSubmitCompletionEventTests +{ + private const ulong BaseAddress = 0x1_0000_0000; + private const int MemorySize = 0x2000; + + private const ulong HandleOutAddress = BaseAddress + 0x100; + private const ulong EventsAddress = BaseAddress + 0x200; + private const ulong OutCountAddress = BaseAddress + 0x300; + private const ulong TimeoutAddress = BaseAddress + 0x400; + private const ulong PacketAddress = BaseAddress + 0x500; + + [Fact] + public void DriverSubmitAcb_DeliversCompletionEventUnderOwnerHandleIdent() + { + // Deliberately unusual so a graphics registration from a sibling test cannot alias it: + // the kernel event registry is process-wide static state. + const ulong ownerHandle = 0x5EA1; + const ulong userData = 0xC0FF_EE00_1234_5678; + + var memory = new FakeCpuMemory(BaseAddress, MemorySize); + var ctx = new CpuContext(memory, Generation.Gen5); + var equeue = CreateEqueue(ctx, memory); + + try + { + Assert.True(KernelEventQueueCompatExports.RegisterEvent( + equeue, + ownerHandle, + KernelEventQueueCompatExports.KernelEventFilterGraphics, + userData)); + + SubmitEmptyAcb(ctx, memory, ownerHandle); + + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_OK, + WaitEqueue(ctx, memory, equeue)); + Assert.Equal(1u, ReadUInt32(memory, OutCountAddress)); + Assert.Equal(ownerHandle, ReadUInt64(memory, EventsAddress + 0x00)); + Assert.Equal( + KernelEventQueueCompatExports.KernelEventFilterGraphics, + ReadInt16(memory, EventsAddress + 0x08)); + Assert.Equal(ownerHandle, ReadUInt64(memory, EventsAddress + 0x10)); + Assert.Equal(userData, ReadUInt64(memory, EventsAddress + 0x18)); + } + finally + { + DeleteEqueue(ctx, equeue); + } + } + + // Delivery is registration-gated: a title that never registered the ACB owner handle as a + // graphics eventId must not observe a spurious completion interrupt. + [Fact] + public void DriverSubmitAcb_WithoutMatchingRegistration_DeliversNothing() + { + const ulong ownerHandle = 0x5EA2; + const ulong unrelatedEventId = 0x5EA3; + + var memory = new FakeCpuMemory(BaseAddress, MemorySize); + var ctx = new CpuContext(memory, Generation.Gen5); + var equeue = CreateEqueue(ctx, memory); + + try + { + Assert.True(KernelEventQueueCompatExports.RegisterEvent( + equeue, + unrelatedEventId, + KernelEventQueueCompatExports.KernelEventFilterGraphics, + userData: 0)); + + SubmitEmptyAcb(ctx, memory, ownerHandle); + + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT, + WaitEqueue(ctx, memory, equeue)); + Assert.Equal(0u, ReadUInt32(memory, OutCountAddress)); + } + finally + { + DeleteEqueue(ctx, equeue); + } + } + + // The graphics queue keeps its own completion ident (0); an ACB owner handle must not be + // able to wake a graphics-queue completion registration or vice versa. + [Fact] + public void DriverSubmitDcb_DeliversCompletionEventUnderGraphicsIdent() + { + const ulong graphicsCompletionIdent = 0; + const ulong userData = 0xABCD_0000_0000_1111; + + var memory = new FakeCpuMemory(BaseAddress, MemorySize); + var ctx = new CpuContext(memory, Generation.Gen5); + var equeue = CreateEqueue(ctx, memory); + + try + { + Assert.True(KernelEventQueueCompatExports.RegisterEvent( + equeue, + graphicsCompletionIdent, + KernelEventQueueCompatExports.KernelEventFilterGraphics, + userData)); + + WriteEmptySubmitPacket(memory); + ctx[CpuRegister.Rdi] = PacketAddress; + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_OK, + AgcExports.DriverSubmitDcb(ctx)); + + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_OK, + WaitEqueue(ctx, memory, equeue)); + Assert.Equal(1u, ReadUInt32(memory, OutCountAddress)); + Assert.Equal(graphicsCompletionIdent, ReadUInt64(memory, EventsAddress + 0x00)); + Assert.Equal(userData, ReadUInt64(memory, EventsAddress + 0x18)); + } + finally + { + DeleteEqueue(ctx, equeue); + } + } + + private static void SubmitEmptyAcb(CpuContext ctx, FakeCpuMemory memory, ulong ownerHandle) + { + WriteEmptySubmitPacket(memory); + ctx[CpuRegister.Rdi] = ownerHandle; + ctx[CpuRegister.Rsi] = PacketAddress; + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_OK, + AgcExports.DriverSubmitAcb(ctx)); + } + + // A zero-dword submission parses as immediately complete, so the queue reaches its fence + // without needing a live GPU backend. + private static void WriteEmptySubmitPacket(FakeCpuMemory memory) + { + WriteUInt64(memory, PacketAddress, 0); + WriteUInt32(memory, PacketAddress + 8, 0); + } + + private static ulong CreateEqueue(CpuContext ctx, FakeCpuMemory memory) + { + ctx[CpuRegister.Rdi] = HandleOutAddress; + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_OK, + KernelEventQueueCompatExports.KernelCreateEqueue(ctx)); + return ReadUInt64(memory, HandleOutAddress); + } + + // The kernel event registry is process-wide static state and deleting the queue drops its + // registrations, so sibling suites that assert over every graphics registration stay clean. + private static void DeleteEqueue(CpuContext ctx, ulong equeue) + { + ctx[CpuRegister.Rdi] = equeue; + _ = KernelEventQueueCompatExports.KernelDeleteEqueue(ctx); + } + + private static int WaitEqueue(CpuContext ctx, FakeCpuMemory memory, ulong equeue) + { + WriteUInt64(memory, TimeoutAddress, 0); + ctx[CpuRegister.Rdi] = equeue; + ctx[CpuRegister.Rsi] = EventsAddress; + ctx[CpuRegister.Rdx] = 4; + ctx[CpuRegister.Rcx] = OutCountAddress; + ctx[CpuRegister.R8] = TimeoutAddress; + return KernelEventQueueCompatExports.KernelWaitEqueue(ctx); + } + + 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 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)); + } + + private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value) + { + Span buffer = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(buffer, value); + Assert.True(memory.TryWrite(address, buffer)); + } +}