diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index 86301913..25ad782e 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -330,6 +330,11 @@ public static partial class AgcExports private static long _labelProducerSequence; private static readonly object _labelProducerGate = new(); private static readonly List _labelProducers = []; + private const int LabelProducerSoftBound = 4096; + // Raised when a compaction pass frees nothing because every record is still + // active, so registration does not rescan the whole list on every add while + // a queue is suspended. Reset once compaction can make progress again. + private static int _labelProducerCompactionBound = LabelProducerSoftBound; private static readonly HashSet<(object Memory, ulong Address)> _tracedProducerlessWaits = new(); private static long _shaderTranslationMissTraceCount; @@ -4486,9 +4491,21 @@ public static partial class AgcExports }; lock (_labelProducerGate) { - if (_labelProducers.Count >= 4096) + if (_labelProducers.Count >= _labelProducerCompactionBound) { - _labelProducers.RemoveRange(0, 1024); + // Active producer records are synchronization state, not a + // diagnostic cache. Removing one can hide an earlier + // same-submission label write and make a valid in-stream fence + // suspend forever. Compact only completed history; if all + // records are active, correctness takes precedence over the + // soft diagnostic bound. + var removed = CompactCompletedEntries( + _labelProducers, + static candidate => candidate.Completed, + targetCount: LabelProducerSoftBound * 3 / 4); + _labelProducerCompactionBound = removed == 0 + ? _labelProducers.Count * 2 + : LabelProducerSoftBound; } _labelProducers.Add(producer); @@ -4509,6 +4526,36 @@ public static partial class AgcExports return producer; } + internal static int CompactCompletedEntries( + List entries, + Func isCompleted, + int targetCount) + { + ArgumentNullException.ThrowIfNull(entries); + ArgumentNullException.ThrowIfNull(isCompleted); + targetCount = Math.Max(0, targetCount); + + // Single order-preserving pass. Removing one-by-one would shift the + // tail on every eviction, which is quadratic on a list this size and + // runs while the label gate is held. + var removable = entries.Count - targetCount; + var removed = 0; + var write = 0; + for (var read = 0; read < entries.Count; read++) + { + if (removed < removable && isCompleted(entries[read])) + { + removed++; + continue; + } + + entries[write++] = entries[read]; + } + + entries.RemoveRange(write, entries.Count - write); + return removed; + } + private static void CompleteLabelProducer(LabelProducerTrace? producer) { if (producer is null) @@ -6169,6 +6216,13 @@ public static partial class AgcExports GpuWaitRegistry.RecordProduced( ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data); } + else if (!wroteData && dataSelection is 1 or 2) + { + // See ApplySubmittedReleaseMem: a dropped label write strands + // every waiter on this label permanently. + ReportLabelWriteFailure( + "release_mem_standard", destinationAddress, data, dataSelection); + } if (tracePacket) { @@ -6184,6 +6238,33 @@ public static partial class AgcExports writesGuestMemory ? writeLength : 0); } + private static long _labelWriteFailureCount; + + /// + /// Reports a GPU release-label write that could not reach guest memory. + /// Rate-limited (first 16, then powers of two) because a wedged queue can + /// retry, but never silenced: this is the difference between a diagnosable + /// fault and a permanently suspended graphics queue with no explanation. + /// + private static void ReportLabelWriteFailure( + string packet, + ulong destinationAddress, + ulong data, + uint dataSelection) + { + var count = Interlocked.Increment(ref _labelWriteFailureCount); + if (count > 16 && (count & (count - 1)) != 0) + { + return; + } + + Console.Error.WriteLine( + $"[LOADER][ERROR] agc.label_write_failed packet={packet} " + + $"dst=0x{destinationAddress:X16} data=0x{data:X16} " + + $"data_sel={dataSelection} count={count} — a suspended WAIT_REG_MEM " + + $"on this label can no longer be satisfied or deadlock-broken."); + } + private static (uint Destination, uint DataSelection) DecodeStandardReleaseMemControl(uint control) => ( @@ -6244,6 +6325,15 @@ public static partial class AgcExports GpuWaitRegistry.RecordProduced( ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data); } + else if (!wroteData && dataSelection is 1 or 2) + { + // A label write that fails is not a benign miss: this packet + // is the producer a suspended WAIT_REG_MEM is waiting for, and + // RecordProduced above is skipped, so the deadlock breaker has + // no value to replay either. The queue then never resumes. + // Never let that happen quietly. + ReportLabelWriteFailure("release_mem", destinationAddress, data, dataSelection); + } if (tracePacket) { diff --git a/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs index a85c6c96..24e6eccd 100644 --- a/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs +++ b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs @@ -442,6 +442,50 @@ internal static class GpuWaitRegistry return collected; } + /// + /// Drops produced-label values that no registered waiter is watching. Called + /// under when the table reaches its soft bound. A value + /// still watched by a waiter is the only thing that can release that waiter + /// once the guest recycles its label, so those are always retained even if + /// the table has to grow past the bound. + /// + private static void PruneUnwatchedProducedLocked() + { + List<(object Memory, ulong Address)>? unwatched = null; + foreach (var (key, _) in _lastProduced) + { + if (_waiters.TryGetValue(key.Item2, out var list)) + { + var watched = false; + foreach (var waiter in list) + { + if (ReferenceEquals(waiter.Memory, key.Item1)) + { + watched = true; + break; + } + } + + if (watched) + { + continue; + } + } + + (unwatched ??= []).Add(key); + } + + if (unwatched is null) + { + return; + } + + foreach (var key in unwatched) + { + _lastProduced.Remove(key); + } + } + /// Records the value a label producer wrote, for the deadlock /// breaker. Also latches any already-waiting waiter it satisfies. public static bool RecordProduced(object memory, ulong address, ulong value) @@ -450,7 +494,14 @@ internal static class GpuWaitRegistry { if (_lastProduced.Count >= 8192) { - _lastProduced.Clear(); + // These entries are release state, not a cache. CollectDeadlockBroken + // can only free a waiter whose label the guest has since recycled by + // replaying the value a real producer wrote to it, so clearing the + // table wholesale strands every such waiter forever — the suspended + // queue then never resumes and the title wedges with its render + // thread parked. Drop only values no live waiter is watching, and + // let the table exceed the bound when they all are. + PruneUnwatchedProducedLocked(); } _lastProduced[(memory, address)] = value; diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcLabelProducerRetentionTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcLabelProducerRetentionTests.cs new file mode 100644 index 00000000..ffc2f295 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcLabelProducerRetentionTests.cs @@ -0,0 +1,49 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Generic; +using System.Linq; +using SharpEmu.Libs.Agc; +using Xunit; + +namespace SharpEmu.Libs.Tests.Agc; + +public sealed class AgcLabelProducerRetentionTests +{ + [Fact] + public void ProducerHistoryCompactionNeverEvictsActiveRecords() + { + var entries = new List<(string Name, bool Completed)> + { + ("active-a", false), + ("complete-a", true), + ("complete-b", true), + ("active-b", false), + ("complete-c", true), + }; + + var removed = AgcExports.CompactCompletedEntries( + entries, + static entry => entry.Completed, + targetCount: 2); + + Assert.Equal(3, removed); + Assert.Equal( + ["active-a", "active-b"], + entries.Select(static entry => entry.Name)); + } + + [Fact] + public void ProducerHistoryMayExceedSoftBoundWhileAllRecordsAreActive() + { + var entries = new List { false, false, false }; + + var removed = AgcExports.CompactCompletedEntries( + entries, + static completed => completed, + targetCount: 1); + + Assert.Equal(0, removed); + Assert.Equal(3, entries.Count); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Agc/GpuWaitRegistryProducedRetentionTests.cs b/tests/SharpEmu.Libs.Tests/Agc/GpuWaitRegistryProducedRetentionTests.cs new file mode 100644 index 00000000..60f1547a --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/GpuWaitRegistryProducedRetentionTests.cs @@ -0,0 +1,71 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Agc; +using Xunit; + +namespace SharpEmu.Libs.Tests.Agc; + +public sealed class GpuWaitRegistryProducedRetentionTests +{ + private const ulong WatchedLabel = 0x7020_0000_1000UL; + + // A suspended DCB whose label the guest has recycled can only be released by + // replaying the value a real producer wrote to that label. Recording enough + // unrelated producers to cross the table's soft bound must not discard that + // value, or the waiter is stranded and the graphics queue never resumes. + [Fact] + public void ProducedValueSurvivesBoundCrossingWhileAWaiterWatchesIt() + { + GpuWaitRegistry.Clear(); + var memory = new object(); + + GpuWaitRegistry.Register(WatchedLabel, NewWaiter(memory, WatchedLabel)); + Assert.True(GpuWaitRegistry.RecordProduced(memory, WatchedLabel, 1)); + + // Cross the soft bound with labels nobody is waiting on. + for (var i = 0; i < 9000; i++) + { + GpuWaitRegistry.RecordProduced(memory, 0x7030_0000_0000UL + ((ulong)i * 8), 1); + } + + // The guest has since recycled the label, so its memory no longer holds + // the produced value — the registry's record is the only way back. + var broken = GpuWaitRegistry.CollectDeadlockBroken(memory, nowTicks: 1_000_000, minAgeTicks: 1); + + Assert.NotNull(broken); + Assert.Contains(broken!, waiter => waiter.WaitAddress == WatchedLabel); + GpuWaitRegistry.Clear(); + } + + [Fact] + public void UnwatchedProducedValuesArePrunedAtTheBound() + { + GpuWaitRegistry.Clear(); + var memory = new object(); + + for (var i = 0; i < 9000; i++) + { + GpuWaitRegistry.RecordProduced(memory, 0x7030_0000_0000UL + ((ulong)i * 8), 1); + } + + // Nothing was watching any of them, so a waiter registered afterwards on + // a pruned label has no produced value to replay and stays suspended. + GpuWaitRegistry.Register(WatchedLabel, NewWaiter(memory, WatchedLabel)); + var broken = GpuWaitRegistry.CollectDeadlockBroken(memory, nowTicks: 1_000_000, minAgeTicks: 1); + + Assert.Null(broken); + GpuWaitRegistry.Clear(); + } + + private static GpuWaitRegistry.WaitingDcb NewWaiter(object memory, ulong address) => new() + { + WaitAddress = address, + ReferenceValue = 1, + Mask = 0xFFFF_FFFFUL, + CompareFunction = 3, // equal + Memory = memory, + QueueName = "dcb.graphics", + RegisteredTicks = 0, + }; +}