diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index ba27ac17..5bc3cf33 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -608,6 +608,11 @@ public static partial class AgcExports public Queue PendingSubmissions { get; } = new(); public bool HasActiveSubmission { get; set; } public bool IsSuspended { get; set; } + + // Set when parsing stops on an INDIRECT_BUFFER packet so the caller can + // continue into the buffer it links to. + public ulong PendingChainAddress { get; set; } + public uint PendingChainDwords { get; set; } public ulong CompletionEventNotifiedSubmissionId { get; set; } public Dictionary<(uint Op, uint Register), uint> FramePacketCounts { get; } = new(); public uint FramePacketCount { get; set; } @@ -3250,6 +3255,7 @@ public static partial class AgcExports !TryReadUInt64(ctx, packetAddress, out var commandAddress) || !TryReadUInt32(ctx, packetAddress + 8, out var dwordCount)) { + TraceAgc($"agc.driver_submit_dcb_rejected packet=0x{packetAddress:X16}"); return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); } @@ -3262,10 +3268,9 @@ public static partial class AgcExports } } - if (tracePackets) - { - TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}"); - } + TraceAgc( + $"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} " + + $"dwords={dwordCount} end=0x{commandAddress + ((ulong)dwordCount * sizeof(uint)):X16}"); GuestGpu.Current.AttachGuestMemory(ctx.Memory); var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); @@ -3312,12 +3317,10 @@ public static partial class AgcExports } } - if (tracePackets) - { - TraceAgc( - $"agc.driver_submit_acb owner={ownerHandle} packet=0x{packetAddress:X16} " + - $"addr=0x{commandAddress:X16} dwords={dwordCount}"); - } + TraceAgc( + $"agc.driver_submit_acb owner={ownerHandle} packet=0x{packetAddress:X16} " + + $"addr=0x{commandAddress:X16} dwords={dwordCount} " + + $"end=0x{commandAddress + ((ulong)dwordCount * sizeof(uint)):X16}"); GuestGpu.Current.AttachGuestMemory(ctx.Memory); var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); @@ -3574,33 +3577,72 @@ public static partial class AgcExports using var guestQueueScope = GuestGpu.Current.EnterGuestQueue( state.QueueName, state.ActiveSubmissionId); - var windowByteCount = checked((int)(dwordCount * sizeof(uint))); - var rented = GuestDataPool.Shared.Rent(windowByteCount); - try + // A submission is one link of a chain, not necessarily the whole stream: + // when a title's command arena fills mid-frame it continues in a fresh + // buffer and links the two with an INDIRECT_BUFFER packet, then submits + // only the first link. Stopping at the end of the submitted window drops + // every packet past the switch -- including the flip and the end-of-frame + // completion labels the guest is waiting on. + for (var chainDepth = 0; ; chainDepth++) { - if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount))) + if (chainDepth > MaxSubmittedChainDepth) { - _dcbWindowBuffer = rented; - _dcbWindowStart = commandAddress; - _dcbWindowByteLength = windowByteCount; + TraceAgc( + $"agc.dcb_chain_depth_exceeded queue={state.QueueName} " + + $"submission={state.ActiveSubmissionId} addr=0x{commandAddress:X16}"); + return false; } - return ParseSubmittedDcbCore( - ctx, - gpuState, - state, - commandAddress, - dwordCount, - tracePackets); - } - finally - { - _dcbWindowBuffer = null; - _dcbWindowByteLength = 0; - GuestDataPool.Shared.Return(rented); + state.PendingChainAddress = 0; + state.PendingChainDwords = 0; + var windowByteCount = checked((int)(dwordCount * sizeof(uint))); + var rented = GuestDataPool.Shared.Rent(windowByteCount); + bool suspended; + try + { + if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount))) + { + _dcbWindowBuffer = rented; + _dcbWindowStart = commandAddress; + _dcbWindowByteLength = windowByteCount; + } + + suspended = ParseSubmittedDcbCore( + ctx, + gpuState, + state, + commandAddress, + dwordCount, + tracePackets); + } + finally + { + _dcbWindowBuffer = null; + _dcbWindowByteLength = 0; + GuestDataPool.Shared.Return(rented); + } + + if (suspended) + { + return true; + } + + var chainAddress = state.PendingChainAddress; + var chainDwords = state.PendingChainDwords; + if (chainAddress == 0 || chainDwords == 0 || chainDwords > 1_000_000) + { + return false; + } + + commandAddress = chainAddress; + dwordCount = chainDwords; } } + // Deep enough for a title that links one continuation buffer per frame, + // shallow enough that a self-referencing chain cannot spin forever. + private const int MaxSubmittedChainDepth = 64; + private static bool ParseSubmittedDcbCore( CpuContext ctx, SubmittedGpuState gpuState, @@ -3727,6 +3769,32 @@ public static partial class AgcExports continue; } + if (op == ItIndirectBuffer && + length >= 4 && + TryReadUInt32(ctx, currentAddress + 4, out var chainLow) && + TryReadUInt32(ctx, currentAddress + 8, out var chainHigh) && + TryReadUInt32(ctx, currentAddress + 12, out var chainDwords)) + { + var chainAddress = ((ulong)(chainHigh & 0xFFFFu) << 32) | chainLow; + var chainLength = chainDwords & 0xFFFFFu; + // Titles emit a zeroed INDIRECT_BUFFER as padding for a branch they + // decided not to take. Only a populated one redirects the stream. + if (chainAddress != 0 && chainLength != 0) + { + state.PendingChainAddress = chainAddress; + state.PendingChainDwords = chainLength; + TraceAgc( + $"agc.dcb_chain queue={state.QueueName} " + + $"submission={state.ActiveSubmissionId} " + + $"packet=0x{currentAddress:X16} " + + $"target=0x{chainAddress:X16} dwords={chainLength}"); + + // The link is a jump, not a call: whatever follows it in this + // buffer is unreachable padding. + return false; + } + } + if (op == ItNop && register is RDrawReset or RAcbReset && length >= 2) @@ -13582,6 +13650,58 @@ public static partial class AgcExports return ReturnPointer(ctx, cmd); } + // Matches the 4-dword INDIRECT_BUFFER packet CbBranch writes below. + [SysAbiExport( + Nid = "uZW-mqsxkrM", + ExportName = "sceAgcCbBranchGetSize", + Target = Generation.Gen5, + LibraryName = "libSceAgc")] + public static int CbBranchGetSize(CpuContext ctx) + { + ctx[CpuRegister.Rax] = 4u * sizeof(uint); + return (int)ctx[CpuRegister.Rax]; + } + + // How a title continues a frame whose command arena filled: it branches from + // the tail of the exhausted buffer into a fresh one and submits only the first + // buffer, leaving the driver to follow the link. Dropping this packet strands + // everything written after the switch -- for UE 4.27 that is the rest of the + // frame, including its flip and the end-of-frame labels the guest's AGC + // interrupt thread needs before it will trigger the backbuffer event. + // + // The branch target and its length arrive on the stack, past six register + // arguments (verified against a live call: the values matched the continuation + // buffer the title had already written into). + [SysAbiExport( + Nid = "w1KFAHVqpaU", + ExportName = "sceAgcCbBranch", + Target = Generation.Gen5, + LibraryName = "libSceAgc")] + public static int CbBranch(CpuContext ctx) + { + var commandBufferAddress = ctx[CpuRegister.Rdi]; + if (commandBufferAddress == 0 || + !TryReadUInt64(ctx, ctx[CpuRegister.Rsp] + (2 * sizeof(ulong)), out var target) || + !TryReadUInt64(ctx, ctx[CpuRegister.Rsp] + (3 * sizeof(ulong)), out var targetDwords)) + { + return ReturnPointer(ctx, 0); + } + + if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 4, out var commandAddress) || + !TryWriteUInt32(ctx, commandAddress, Pm4(4, ItIndirectBuffer, RZero)) || + !TryWriteUInt32(ctx, commandAddress + 4, (uint)(target & 0xFFFF_FFFFUL)) || + !TryWriteUInt32(ctx, commandAddress + 8, (uint)((target >> 32) & 0xFFFFUL)) || + !TryWriteUInt32(ctx, commandAddress + 12, (uint)targetDwords & 0xFFFFFu)) + { + return ReturnPointer(ctx, 0); + } + + TraceAgc( + $"agc.cb_branch buf=0x{commandBufferAddress:X16} cmd=0x{commandAddress:X16} " + + $"target=0x{target:X16} dwords={targetDwords}"); + return ReturnPointer(ctx, commandAddress); + } + [SysAbiExport( Nid = "b-oySn+G2tE", ExportName = "sceAgcAcbJumpGetSize", diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcCommandBufferChainTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcCommandBufferChainTests.cs new file mode 100644 index 00000000..c527e3ef --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcCommandBufferChainTests.cs @@ -0,0 +1,243 @@ +// 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; + +// The kernel event-queue registry is process-wide static state; serialize against +// other suites that register graphics events. +[CollectionDefinition(AgcCommandBufferChainCollection.Name, DisableParallelization = true)] +public sealed class AgcCommandBufferChainCollection +{ + public const string Name = "AgcCommandBufferChainState"; +} + +// A submission is one link of a chain, not always the whole command stream. When a +// title's command arena fills mid-frame it continues in a fresh buffer, links the two +// with an INDIRECT_BUFFER packet and submits only the first link, so a parser that +// stops at the end of the submitted window silently drops the rest of that frame -- +// including its flip and the end-of-frame labels the guest waits on. +[Collection(AgcCommandBufferChainCollection.Name)] +public sealed class AgcCommandBufferChainTests +{ + private const ulong BaseAddress = 0x1_0000_0000; + private const int MemorySize = 0x4000; + + 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 SubmitPacketAddress = BaseAddress + 0x500; + private const ulong StackAddress = BaseAddress + 0x600; + + private const ulong CommandBufferAddress = BaseAddress + 0x800; + private const ulong FirstLinkAddress = BaseAddress + 0x1000; + private const ulong SecondLinkAddress = BaseAddress + 0x2000; + private const ulong WaitLabelAddress = BaseAddress + 0x3000; + + // Graphics-queue completions land on ident 0, so its absence is how a suspended + // queue is observed without standing up a GPU backend. + private const ulong GraphicsCompletionIdent = 0; + + [Fact] + public void SubmittedDcb_FollowsIndirectBufferIntoTheChainedBuffer() + { + var memory = new FakeCpuMemory(BaseAddress, MemorySize); + var ctx = new CpuContext(memory, Generation.Gen5); + var equeue = CreateEqueue(ctx, memory); + + try + { + RegisterGraphicsCompletion(equeue); + + // The chained buffer parks on a label that never reaches its reference, + // so reaching it at all suspends the queue. + var secondLinkDwords = WriteUnsatisfiedWait(ctx, memory, SecondLinkAddress); + var firstLinkDwords = WriteChain( + ctx, + memory, + FirstLinkAddress, + SecondLinkAddress, + secondLinkDwords); + + SubmitDcb(ctx, memory, FirstLinkAddress, firstLinkDwords); + + Assert.NotEqual( + (int)OrbisGen2Result.ORBIS_GEN2_OK, + WaitEqueue(ctx, memory, equeue)); + } + finally + { + DeleteEqueue(ctx, equeue); + } + } + + // Titles emit a zeroed INDIRECT_BUFFER as padding for a branch they decided not to + // take. Treating that as a redirect truncates the frame it appears in. + [Fact] + public void SubmittedDcb_KeepsParsingPastAnEmptyIndirectBuffer() + { + var memory = new FakeCpuMemory(BaseAddress, MemorySize); + var ctx = new CpuContext(memory, Generation.Gen5); + var equeue = CreateEqueue(ctx, memory); + + try + { + RegisterGraphicsCompletion(equeue); + + var paddingDwords = WriteChain(ctx, memory, FirstLinkAddress, target: 0, targetDwords: 0); + var waitDwords = WriteUnsatisfiedWait( + ctx, + memory, + FirstLinkAddress + (paddingDwords * sizeof(uint))); + + SubmitDcb(ctx, memory, FirstLinkAddress, paddingDwords + waitDwords); + + Assert.NotEqual( + (int)OrbisGen2Result.ORBIS_GEN2_OK, + WaitEqueue(ctx, memory, equeue)); + } + finally + { + DeleteEqueue(ctx, equeue); + } + } + + // A chain whose target is never satisfied must not be mistaken for a completed + // submission: without the redirect the empty first link completes immediately. + [Fact] + public void SubmittedDcb_WithoutAChain_CompletesImmediately() + { + var memory = new FakeCpuMemory(BaseAddress, MemorySize); + var ctx = new CpuContext(memory, Generation.Gen5); + var equeue = CreateEqueue(ctx, memory); + + try + { + RegisterGraphicsCompletion(equeue); + + var paddingDwords = WriteChain(ctx, memory, FirstLinkAddress, target: 0, targetDwords: 0); + SubmitDcb(ctx, memory, FirstLinkAddress, paddingDwords); + + Assert.Equal( + (int)OrbisGen2Result.ORBIS_GEN2_OK, + WaitEqueue(ctx, memory, equeue)); + Assert.Equal(GraphicsCompletionIdent, ReadUInt64(memory, EventsAddress + 0x00)); + } + finally + { + DeleteEqueue(ctx, equeue); + } + } + + private static void RegisterGraphicsCompletion(ulong equeue) => + Assert.True(KernelEventQueueCompatExports.RegisterEvent( + equeue, + GraphicsCompletionIdent, + KernelEventQueueCompatExports.KernelEventFilterGraphics, + userData: 0)); + + private static uint WriteChain( + CpuContext ctx, + FakeCpuMemory memory, + ulong linkAddress, + ulong target, + uint targetDwords) + { + PointCommandBufferAt(memory, linkAddress); + ctx[CpuRegister.Rdi] = CommandBufferAddress; + ctx[CpuRegister.Rsi] = target; + ctx[CpuRegister.Rdx] = targetDwords; + Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbJump(ctx)); + Assert.Equal(linkAddress, ctx[CpuRegister.Rax]); + return 4; + } + + private static uint WriteUnsatisfiedWait(CpuContext ctx, FakeCpuMemory memory, ulong linkAddress) + { + PointCommandBufferAt(memory, linkAddress); + WriteUInt32(memory, WaitLabelAddress, 0); + ctx[CpuRegister.Rsp] = StackAddress; + ctx[CpuRegister.Rdi] = CommandBufferAddress; + ctx[CpuRegister.Rsi] = 0; // 32-bit compare + ctx[CpuRegister.Rdx] = 3; // equal + ctx[CpuRegister.Rcx] = 4; // memory space + ctx[CpuRegister.R8] = 2; + ctx[CpuRegister.R9] = WaitLabelAddress; + WriteUInt64(memory, StackAddress + 8, 1); // reference the label never reaches + WriteUInt64(memory, StackAddress + 16, 0xFFFF_FFFF); // mask + WriteUInt32(memory, StackAddress + 24, 0x10); // poll interval + Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx)); + return 7; + } + + private static void PointCommandBufferAt(FakeCpuMemory memory, ulong linkAddress) + { + WriteUInt64(memory, CommandBufferAddress + 0x10, linkAddress); + WriteUInt64(memory, CommandBufferAddress + 0x18, linkAddress + 0x400); + } + + private static void SubmitDcb( + CpuContext ctx, + FakeCpuMemory memory, + ulong commandAddress, + uint dwordCount) + { + WriteUInt64(memory, SubmitPacketAddress, commandAddress); + WriteUInt32(memory, SubmitPacketAddress + 8, dwordCount); + ctx[CpuRegister.Rdi] = SubmitPacketAddress; + Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverSubmitDcb(ctx)); + } + + 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); + } + + 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 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)); + } +}