Compare commits

...

5 Commits

Author SHA1 Message Date
ParantezTech 5f26576d6b [Diagnostics] Summarize synchronized table reads and trace several writer addresses
SHARPEMU_TRACE_GPU_READS=1 now also reports, every five seconds, the guest
words resource planning read through a GPU synchronization: read count, how
often the value changed, and the first and last value. SHARPEMU_TRACE_WRITER_ADDRESS
accepts a comma-separated list.
2026-10-04 16:32:25 +03:00
ParantezTech ca102a67c7 [GPU] Run bounded modulo copies of plain words on the host
Demon's Souls copies its per-frame constant and descriptor tables with a
compute kernel (dst[i] = src[i % N] for i < count). Run on the GPU, the
destinations stay GPU-dirty, and every draw whose resource tables read them
waits for a readback: about 19% of the render thread in the title scene,
for values that never changed.

The kernel is now recognized by its instruction sequence. When both
descriptors are plain 32-bit words of the same format and neither range is
GPU-dirty or GPU image memory, the copy is written to guest memory through
the host DMA path and the dispatch is skipped; anything else still runs on
the GPU. SHARPEMU_HOST_BOUNDED_COPY=0 turns it off.

Title scene with profiling: 7.4-8.6 -> 11.8-15.9 fps.
2026-10-04 16:32:25 +03:00
ParantezTech a7cc266702 [AudioOut2] Block a blocking push only until a queue slot frees
A blocking sceAudioOut2ContextPush waited only when the host stream hit its
128 KiB cap, so a title rendering faster than real time kept about 680 ms
queued and every sound played that far behind (GTA V: queue=2 context,
queued_ms avg=678). The push now waits until the device holds the cushion
plus QueueDepth-1 grains, the level GetQueueLevel already reports as full;
the host cap stays as the limit for non-blocking bursts. GTA V now holds
about 41 ms with no underruns.
2026-10-04 07:59:11 +03:00
ParantezTech 767bca9e26 [ShaderPrewarm] Hold the game until the prewarm finishes and resume interrupted runs
The prewarm compiled thousands of compute pipelines on background threads
while the now faster boot ran beside it, so logos, Bink movies and the first
scenes raced the compile and dropped frames.

- Hold the guest at its first splash hide, GPU submission or CPU flip until
  the prewarm is decided and done; the render thread keeps presenting the
  splash. SHARPEMU_SHADER_PREWARM_WAIT=0 opts out.
- Stamp only the shader translators, the compiler type and the driver, so a
  Libs-only build no longer triggers a full prewarm.
- Record finished pipelines in shader-prewarm.progress so an interrupted
  prewarm resumes with the remaining ones.
2026-10-04 07:47:51 +03:00
ParantezTech ac1f739353 [DeS] Fixes for page faults and stack bounds issues in the emulator and audio fixes 2026-10-04 07:47:51 +03:00
32 changed files with 1409 additions and 51 deletions

View File

@ -9,10 +9,23 @@ public sealed partial class DirectExecutionBackend
{
private const int HostStackStateBytes = 3 * sizeof(ulong);
private static unsafe bool TryGetGuestStackBounds(ulong stackPointer, out ulong bottom, out ulong top)
internal static unsafe bool TryGetGuestStackBounds(ulong stackPointer, out ulong bottom, out ulong top)
{
if (OperatingSystem.IsWindows() &&
QueryVirtualMemoryInformation(-1, (void*)stackPointer, 0, out var allocation,
(nuint)sizeof(StackMemoryRegionInformation), null) != 0 &&
((allocation.Flags & 1) == 0 || allocation.CommitSize == allocation.RegionSize) &&
allocation.RegionSize != 0 && allocation.RegionSize <= ulong.MaxValue - allocation.AllocationBase &&
stackPointer >= allocation.AllocationBase && stackPointer - allocation.AllocationBase < allocation.RegionSize)
{
bottom = allocation.AllocationBase;
top = bottom + allocation.RegionSize;
return true;
}
bottom = top = 0;
if (VirtualQuery((void*)stackPointer, out var region, (nuint)sizeof(MEMORY_BASIC_INFORMATION64)) == 0 ||
var queried = VirtualQuery((void*)stackPointer, out var region, (nuint)sizeof(MEMORY_BASIC_INFORMATION64));
if (queried == 0 ||
region.State != 0x1000 || region.RegionSize == 0 || region.RegionSize > ulong.MaxValue - region.BaseAddress)
{
return false;
@ -176,4 +189,18 @@ public sealed partial class DirectExecutionBackend
[DllImport("kernel32.dll", ExactSpelling = true)]
private static extern void GetCurrentThreadStackLimits(out ulong lowLimit, out ulong highLimit);
[StructLayout(LayoutKind.Sequential)]
private struct StackMemoryRegionInformation
{
public ulong AllocationBase;
public uint AllocationProtect;
public uint Flags;
public ulong RegionSize;
public ulong CommitSize;
}
[DllImport("api-ms-win-core-memory-l1-1-4.dll", ExactSpelling = true)]
private static extern unsafe int QueryVirtualMemoryInformation(nint process, void* address, int informationClass,
out StackMemoryRegionInformation information, nuint informationSize, nuint* returnSize);
}

View File

@ -30,6 +30,9 @@ public sealed class GuestGpuMemory : IDisposable
private readonly PageGuard _pages;
private readonly ReaderWriterLockSlim _spansLock = new();
private readonly SpanSet _spans = new();
private long _spanVersion;
public long SpanVersion => Interlocked.Read(ref _spanVersion);
private sealed record GpuAttachment(IGpuQueueRelay? Gpu, IGpuTickScheduler? Scheduler);
private readonly object _attachGate = new();
@ -204,6 +207,7 @@ public sealed class GuestGpuMemory : IDisposable
try
{
_spans.Add(address, size);
Interlocked.Increment(ref _spanVersion);
}
finally
{
@ -276,6 +280,7 @@ public sealed class GuestGpuMemory : IDisposable
try
{
_spans.Remove(address, size);
Interlocked.Increment(ref _spanVersion);
}
finally
{

View File

@ -590,7 +590,16 @@ public static class AjmExports
return ctx.SetReturn(OrbisAjmErrorJobCreation);
}
var status = TryGetInstance(instanceId, out _) ? 0 : Atrac9DecodeState.ResultInvalidParameter;
var status = TryGetInstance(instanceId, out var instance) ? 0 : Atrac9DecodeState.ResultInvalidParameter;
Span<byte> gapless = stackalloc byte[AjmSidebandGaplessDecodeBytes];
if (status == 0 && gaplessAddress != 0 && ctx.Memory.TryRead(gaplessAddress, gapless))
{
instance!.Atrac9?.SetGapless(
BinaryPrimitives.ReadUInt32LittleEndian(gapless),
BinaryPrimitives.ReadUInt16LittleEndian(gapless[4..]),
reset != 0);
}
WriteBasicResult(ctx, resultAddress, status);
Trace(
$"batch_job_set_gapless_decode instance=0x{instanceId:X8} " +
@ -1163,6 +1172,10 @@ public static class AjmExports
if ((flags & AjmJobSidebandFlagGaplessDecode) != 0 && (ulong)(offset + AjmSidebandGaplessDecodeBytes) <= size)
{
var gapless = instance?.Atrac9?.Gapless ?? default;
BinaryPrimitives.WriteUInt32LittleEndian(sideband[offset..], gapless.TotalSamples);
BinaryPrimitives.WriteUInt16LittleEndian(sideband[(offset + 4)..], gapless.SkipSamples);
BinaryPrimitives.WriteUInt16LittleEndian(sideband[(offset + 6)..], gapless.SkippedSamples);
offset += AjmSidebandGaplessDecodeBytes;
}

View File

@ -20,6 +20,8 @@ internal readonly record struct Atrac9DecodeResult(
ulong TotalDecodedSamples,
uint Frames);
internal readonly record struct Atrac9Gapless(uint TotalSamples, ushort SkipSamples, ushort SkippedSamples);
internal sealed class Atrac9DecodeState
{
internal const int ResultNotInitialized = 0x00000001;
@ -47,6 +49,33 @@ internal sealed class Atrac9DecodeState
private int _containerHeaderLength;
private int _compressedLength;
private ulong _totalDecodedSamples;
private Atrac9Gapless _gaplessInit;
private Atrac9Gapless _gapless;
public Atrac9Gapless Gapless
{
get
{
lock (_gate)
{
return _gapless;
}
}
}
public void SetGapless(uint totalSamples, ushort skipSamples, bool reset)
{
lock (_gate)
{
_gaplessInit = new Atrac9Gapless(totalSamples, skipSamples, 0);
if (reset || _gapless == default)
{
_gapless = _gaplessInit;
}
}
}
private bool HasSampleLimit => _gaplessInit.TotalSamples is not 0 and not uint.MaxValue;
public Atrac9Config? Config
{
@ -82,6 +111,7 @@ internal sealed class Atrac9DecodeState
_compressedLength = 0;
_totalDecodedSamples = 0;
_containerHeaderLength = 0;
_gapless = _gaplessInit;
Trace(
$"initialized config={Convert.ToHexString(normalizedConfig)} channels={config.ChannelCount} " +
$"rate={config.SampleRate} frame_samples={config.FrameSamples} " +
@ -114,6 +144,7 @@ internal sealed class Atrac9DecodeState
_compressedLength = 0;
_totalDecodedSamples = 0;
_containerHeaderLength = 0;
_gapless = _gaplessInit;
if (_compressed is not null)
{
Array.Clear(_compressed);
@ -153,7 +184,6 @@ internal sealed class Atrac9DecodeState
}
var bytesPerSample = GetBytesPerSample(encoding);
var outputBytesPerSuperframe = checked(config.SuperframeSamples * channels * bytesPerSample);
var consumed = 0;
var written = 0;
uint frames = 0;
@ -162,6 +192,11 @@ internal sealed class Atrac9DecodeState
while (_compressedLength == config.SuperframeBytes ||
consumed < input.Length)
{
if (HasSampleLimit && _gapless.TotalSamples == 0)
{
break;
}
// Titles that stream whole .at9 files hand AJM the RIFF/WAVE
// container rather than a pointer into its `data` chunk, so the
// stream has to be advanced past the header before the first
@ -202,7 +237,15 @@ internal sealed class Atrac9DecodeState
break;
}
if (output.Length - written < outputBytesPerSuperframe)
var skip = Math.Min(config.SuperframeSamples, (int)_gapless.SkipSamples);
var samples = config.SuperframeSamples - skip;
if (HasSampleLimit)
{
samples = (int)Math.Min((uint)samples, _gapless.TotalSamples);
}
var outputBytes = checked(samples * channels * bytesPerSample);
if (output.Length - written < outputBytes)
{
status |= ResultNotEnoughRoom;
break;
@ -231,14 +274,19 @@ internal sealed class Atrac9DecodeState
WriteInterleaved(
_planarPcm,
output.Slice(written, outputBytesPerSuperframe),
config.SuperframeSamples,
output.Slice(written, outputBytes),
skip,
samples,
channels,
encoding);
written += outputBytesPerSuperframe;
_gapless = new Atrac9Gapless(
HasSampleLimit ? _gapless.TotalSamples - (uint)samples : _gapless.TotalSamples,
(ushort)(_gapless.SkipSamples - skip),
(ushort)Math.Min(ushort.MaxValue, _gapless.SkippedSamples + config.SuperframeSamples - samples));
written += outputBytes;
_compressedLength = 0;
_totalDecodedSamples += unchecked((uint)config.SuperframeSamples);
_totalDecodedSamples += unchecked((uint)samples);
frames += unchecked((uint)config.FramesPerSuperframe);
if (!multipleFrames)
@ -353,12 +401,13 @@ internal sealed class Atrac9DecodeState
private static void WriteInterleaved(
short[][] source,
Span<byte> destination,
int firstSample,
int samples,
int channels,
Atrac9PcmEncoding encoding)
{
var offset = 0;
for (var sample = 0; sample < samples; sample++)
for (var sample = firstSample; sample < firstSample + samples; sample++)
{
for (var channel = 0; channel < channels; channel++)
{

View File

@ -60,6 +60,14 @@ public static class AudioOut2Exports
// SceAudioOut2Attribute: u32 attributeId, 4 pad, const void* value, size_t valueSize.
private const int AttributeEntrySize = 0x18;
private const uint PortAttributeIdPcm = 0;
private const uint PortAttributeIdGain = 1;
private const uint PortAttributeIdPassthrough = 5;
private const uint PortAttributeIdAmbisonics = 8;
internal const uint AmbisonicsNone = uint.MaxValue;
internal const uint AmbisonicsAcnBase = 64;
internal const uint PassthroughLeft = 1;
internal const uint PassthroughRight = 2;
private const float HalfPower = 0.70710677f;
private const ushort PortStateOutputConnectedPrimary = 0x01;
// A host device that already holds this much audio is pacing the context; anything beyond the
@ -131,6 +139,9 @@ public static class AudioOut2Exports
return ahead <= 0 ? 0 : (uint)Math.Min((ahead + GrainTicks - 1) / GrainTicks, QueueDepth);
}
public double DeviceSlotFreeMilliseconds =>
DeviceSlotFreeThreshold(GrainSamples, Frequency, QueueDepth);
// Grains the device holds beyond the cushion, at most QueueDepth.
public uint DeviceQueuedGrains(int queuedMilliseconds)
{
@ -197,6 +208,9 @@ public static class AudioOut2Exports
public uint SamplingFrequency { get; }
public uint GrainSamples { get; }
public ulong PcmAddress;
public float Gain = 1f;
public uint Ambisonics = AmbisonicsNone;
public uint Passthrough;
public int PcmPending;
@ -419,6 +433,14 @@ public static class AudioOut2Exports
{
context.DevicePaced = true;
}
if (submitted || advancedGrain == 1)
{
if (blocking != 0)
{
WaitForDeviceSlot(context);
}
}
else
{
// Push is the blocking point of a grain: it waits for the grain an Advance claimed, or
@ -545,6 +567,7 @@ public static class AudioOut2Exports
Span<byte> entry = stackalloc byte[AttributeEntrySize];
Span<byte> pcm = stackalloc byte[8];
Span<byte> word = stackalloc byte[sizeof(uint)];
for (uint i = 0; i < attributeCount; i++)
{
if (!ctx.Memory.TryRead(attributesAddress + (i * AttributeEntrySize), entry))
@ -555,7 +578,41 @@ public static class AudioOut2Exports
var attributeId = BinaryPrimitives.ReadUInt32LittleEndian(entry);
var valueAddress = BinaryPrimitives.ReadUInt64LittleEndian(entry[0x08..]);
var valueSize = BinaryPrimitives.ReadUInt64LittleEndian(entry[0x10..]);
if (attributeId != PortAttributeIdPcm || valueAddress == 0 || valueSize < 8)
if (valueAddress == 0)
{
continue;
}
if (attributeId is PortAttributeIdGain or PortAttributeIdPassthrough or PortAttributeIdAmbisonics)
{
if (valueSize < sizeof(uint) || !ctx.Memory.TryRead(valueAddress, word))
{
continue;
}
var value = BinaryPrimitives.ReadUInt32LittleEndian(word);
switch (attributeId)
{
case PortAttributeIdGain:
var gain = BitConverter.UInt32BitsToSingle(value);
if (float.IsFinite(gain))
{
port.Gain = gain;
}
break;
case PortAttributeIdPassthrough:
port.Passthrough = value;
break;
default:
port.Ambisonics = value;
break;
}
continue;
}
if (attributeId != PortAttributeIdPcm || valueSize < 8)
{
continue;
}
@ -1050,6 +1107,28 @@ public static class AudioOut2Exports
}
}
internal static double DeviceSlotFreeThreshold(uint grainSamples, uint frequency, uint queueDepth) =>
DeviceCushionMilliseconds + (grainSamples * 1000.0 / frequency * Math.Max((long)queueDepth - 1, 0));
private static void WaitForDeviceSlot(ContextState context)
{
var backend = ResolveContextBackend(context, out _);
if (backend is null)
{
return;
}
var limit = context.DeviceSlotFreeMilliseconds;
var deadline = Stopwatch.GetTimestamp() + (Stopwatch.Frequency / 4);
int queued;
while ((queued = backend.QueuedMilliseconds) > limit &&
Stopwatch.GetTimestamp() < deadline &&
!HostSessionControl.IsShutdownRequested)
{
Thread.Sleep((int)Math.Clamp(Math.Ceiling(queued - limit), 1, 50));
}
}
private static uint QueuedGrains(ContextState context)
{
if (context.DevicePaced)
@ -1087,7 +1166,9 @@ public static class AudioOut2Exports
if (port.ContextHandle != context.Handle ||
port.PcmAddress == 0 ||
Interlocked.Exchange(ref port.PcmPending, 0) == 0 ||
!TryDecodeDataFormat(port.DataFormat, out var ch, out var bps, out var isFloat))
!TryDecodeDataFormat(port.DataFormat, out var ch, out var bps, out var isFloat) ||
!TryGetStereoGains(port.PortType, ch, port.Gain, port.Ambisonics, port.Passthrough,
out var leftGain, out var rightGain))
{
continue;
}
@ -1111,7 +1192,8 @@ public static class AudioOut2Exports
ch,
bps,
isFloat,
additive: mixedPorts > 0);
leftGain,
rightGain);
mixedPorts++;
}
@ -1162,20 +1244,69 @@ public static class AudioOut2Exports
}
}
private static bool IsMainOrBgmPort(ushort portType)
internal static bool RoutesToSpeakers(ushort portType) => (portType & 0xFF) is 0 or 1 or 2;
internal static (float Gain, uint Ambisonics, uint Passthrough)? PortMixAttributes(ulong portHandle) =>
Ports.TryGetValue(portHandle, out var port) ? (port.Gain, port.Ambisonics, port.Passthrough) : null;
internal static bool TryGetStereoGains(
ushort portType,
int channels,
float gain,
uint ambisonics,
uint passthrough,
out float left,
out float right)
{
var kind = portType & 0xFF;
return kind is 0 or 1;
left = 0f;
right = 0f;
if (!RoutesToSpeakers(portType) || gain == 0f)
{
return false;
}
if (!IsObjectPort(portType) || channels != 1)
{
left = gain;
right = gain;
return true;
}
if (ambisonics == AmbisonicsNone)
{
left = passthrough == PassthroughRight ? 0f : passthrough == PassthroughLeft ? gain : gain * HalfPower;
right = passthrough == PassthroughLeft ? 0f : passthrough == PassthroughRight ? gain : gain * HalfPower;
return true;
}
var acn = ambisonics >= AmbisonicsAcnBase;
var component = acn ? ambisonics - AmbisonicsAcnBase : ambisonics;
if (component == 0)
{
left = acn ? gain * HalfPower : gain;
right = left;
return true;
}
if (component == (acn ? 1u : 2u))
{
left = gain * HalfPower;
right = -left;
return true;
}
return false;
}
private static void MixPortIntoStereo(
internal static void MixPortIntoStereo(
ReadOnlySpan<byte> source,
Span<float> mix,
int frames,
int channels,
int bytesPerSample,
bool isFloat,
bool additive)
float leftGain,
float rightGain)
{
var frameSize = channels * bytesPerSample;
for (var frame = 0; frame < frames; frame++)
@ -1204,16 +1335,8 @@ public static class AudioOut2Exports
: ReadNormalizedSample(frameBytes, 1, bytesPerSample, isFloat);
}
if (additive)
{
mix[frame * 2] += left;
mix[(frame * 2) + 1] += right;
}
else
{
mix[frame * 2] = left;
mix[(frame * 2) + 1] = right;
}
mix[frame * 2] += left * leftGain;
mix[(frame * 2) + 1] += right * rightGain;
}
}

View File

@ -9,15 +9,19 @@ namespace SharpEmu.Libs.Diagnostics;
internal static class DccWriterTrace
{
private const int MaxReports = 64;
private const int MaxReports = 256;
private const int MaxFrames = 12;
private static readonly bool DccEnabled =
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_DCC_WRITERS") == "1";
private static readonly ulong TargetAddress = ParseAddress(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_WRITER_ADDRESS"));
private static readonly ulong[] TargetAddresses = (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_WRITER_ADDRESS") ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(ParseAddress)
.Where(address => address != 0)
.ToArray();
public static readonly bool Enabled = DccEnabled || TargetAddress != 0;
public static readonly bool Enabled = DccEnabled || TargetAddresses.Length != 0;
[ThreadStatic]
public static ulong CurrentProgram;
@ -43,7 +47,7 @@ internal static class DccWriterTrace
public static void Record(IGuestImageCache images, ulong address, ulong size)
{
var targeted = TargetAddress != 0 && TargetAddress >= address && TargetAddress - address < size;
var targeted = TargetAddresses.Any(target => target >= address && target - address < size);
if (_reported.Count >= MaxReports || (!targeted && !(DccEnabled && images.OverlapsDccMetadata(address, size))) ||
!_reported.Add((address, size, CurrentProgram)))
{

View File

@ -17,6 +17,30 @@ internal static class GpuReadTrace
public static string? CurrentStage;
private static readonly HashSet<(ulong Shader, ulong Page, bool Table)> _reported = new();
private static readonly Dictionary<ulong, (long Reads, long Changes, uint Last, uint First, ulong Shader)> _values = new();
private static long _nextValueReport = System.Diagnostics.Stopwatch.GetTimestamp();
public static void RecordValue(ulong address, uint word)
{
lock (_values)
{
_values[address] = _values.TryGetValue(address, out var entry)
? (entry.Reads + 1, entry.Changes + (entry.Last != word ? 1 : 0), word, entry.First, entry.Shader)
: (1, 0, word, word, CurrentShader);
var now = System.Diagnostics.Stopwatch.GetTimestamp();
if (now < _nextValueReport)
{
return;
}
_nextValueReport = now + System.Diagnostics.Stopwatch.Frequency * 5;
foreach (var (key, value) in _values.OrderByDescending(pair => pair.Value.Reads).Take(16))
{
Console.Error.WriteLine(
$"[GPU][SYNC_READ_VALUE] address=0x{key:X16} reads={value.Reads} changes={value.Changes} first=0x{value.First:X8} last=0x{value.Last:X8} shader=0x{value.Shader:X16}");
}
}
}
public static void Record(ulong address, bool table)
{

View File

@ -460,6 +460,72 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
return true;
}
public const ulong MaxHostCopyWords = 64 * 1024;
public bool TryCopyWordsOnHost(ulong destination, ulong source, ulong sourceWords, ulong words)
{
if (destination == 0 || source == 0 || sourceWords == 0 || words == 0 || ((destination | source) & 3) != 0 ||
words > MaxHostCopyWords || sourceWords > MaxHostCopyWords)
{
return false;
}
var size = words * sizeof(uint);
var sourceSize = Math.Min(sourceWords, words) * sizeof(uint);
if (size > ulong.MaxValue - destination || sourceSize > ulong.MaxValue - source ||
(source < destination + size && destination < source + sourceSize))
{
return false;
}
var images = RequireImageCache();
var sourceRegion = images.QueryRegion(source, sourceSize);
var destinationRegion = images.QueryRegion(destination, size);
if (HasGpuDirtyBytes(source, sourceSize) || HasGpuDirtyBytes(destination, size) ||
sourceRegion.GpuImageBytes || destinationRegion.GpuImageBytes)
{
return false;
}
var pattern = System.Buffers.ArrayPool<byte>.Shared.Rent((int)sourceSize);
var chunk = System.Buffers.ArrayPool<byte>.Shared.Rent((int)Math.Min(size, 64UL * 1024));
try
{
if (!_backing.TryReadBacking(source, pattern.AsSpan(0, (int)sourceSize)))
{
return false;
}
if (destinationRegion.ImageBytes)
{
images.InvalidateMemory(destination, size);
}
var chunkLength = (ulong)(chunk.Length - chunk.Length % sizeof(uint));
for (ulong offset = 0; offset < size;)
{
var length = (int)Math.Min(size - offset, chunkLength);
for (var filled = 0; filled < length;)
{
var patternOffset = (int)((offset + (ulong)filled) % sourceSize);
var copied = Math.Min(length - filled, (int)sourceSize - patternOffset);
pattern.AsSpan(patternOffset, copied).CopyTo(chunk.AsSpan(filled, copied));
filled += copied;
}
WriteHostMemory(destination + offset, chunk.AsSpan(0, length));
offset += (ulong)length;
}
return true;
}
finally
{
System.Buffers.ArrayPool<byte>.Shared.Return(pattern);
System.Buffers.ArrayPool<byte>.Shared.Return(chunk);
}
}
public void CopyBuffer(ulong dstVaddr, ulong srcVaddr, ulong size, bool dstGds, bool srcGds)
{
var dstMemory = !dstGds;
@ -557,7 +623,21 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
private ulong _bdaTouchTick = ulong.MaxValue;
private ulong _bdaTouchMapping;
public static ulong MappingKey(IReadOnlyCollection<GuestSpan> spans)
{
var mapping = (ulong)spans.Count;
foreach (var span in spans)
mapping = (mapping ^ span.Address ^ (span.Size << 17)) * 0x100000001B3UL;
return mapping;
}
public void PrepareBda(IEnumerable<GuestSpan> mapped)
{
var spans = mapped as IReadOnlyCollection<GuestSpan> ?? mapped.ToList();
PrepareBda(spans, MappingKey(spans));
}
public void PrepareBda(IReadOnlyCollection<GuestSpan> mapped, ulong mapping)
{
var traceAddress = GuestGpuMemoryHook.TraceAddress;
var traceCovered = false;
@ -572,10 +652,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
}
else
{
var spans = mapped as IReadOnlyCollection<GuestSpan> ?? mapped.ToList();
var mapping = (ulong)spans.Count;
foreach (var span in spans)
mapping = (mapping ^ span.Address ^ (span.Size << 17)) * 0x100000001B3UL;
var spans = mapped;
if (_retirementPolicy.CurrentTick != _bdaTouchTick || mapping != _bdaTouchMapping)
{
foreach (var span in spans)

View File

@ -1196,4 +1196,10 @@ internal sealed partial class MetalCommandStreamHost : IRenderHost, IShaderPipel
_ = (address, size, fillValue);
return false;
}
bool IRenderHost.TryCopyWordsOnHost(ulong destination, ulong source, ulong sourceWords, ulong words)
{
_ = (destination, source, sourceWords, words);
return false;
}
}

View File

@ -26,8 +26,104 @@ public sealed record BoundedFill(
FillWord[]? Pattern = null,
FillWord? PatternLength = null);
public sealed record BoundedCopy(
uint GroupScalarRegister,
FillWord Count,
FillWord Modulus,
FillWord[] Source,
FillWord[] Destination);
public static class BoundedFillDetector
{
private static readonly string[] ModuloCopyOpcodes =
[
"VLshlAddU32", "SBufferLoadDword", "VCmpxGtU32", "SCbranchExecz", "SBufferLoadDword",
"VCvtF32U32", "SCmpLgU32", "SCselectB64", "VRcpIflagF32", "VMulF32", "VCvtU32F32", "VMadU64U32", "VCmpNeU32", "VSubI32",
"VCndmaskB32", "VMulHiU32", "VSubI32", "VAddI32", "VCndmaskB32", "VMulHiU32", "VMulLoU32", "VSubI32", "VCmpGeU32",
"VCmpLeU32", "SAndB64", "VAddcU32", "VAddCoCiU32", "VCndmaskB32", "VMulLoU32", "VSubI32",
"BufferLoadFormatX", "BufferStoreFormatX", "SEndpgm",
];
public static BoundedCopy? DetectCopy(Gen5ShaderProgram program)
{
var instructions = program.Instructions
.Where(instruction => instruction.Opcode is not ("SWaitcnt" or "STtraceData" or "SInstPrefetch") &&
!(instruction.Opcode == "SMovB32" && instruction.Destinations is [{ Kind: Gen5OperandKind.ScalarRegister, Value: MarkerRegister }]))
.ToArray();
if (!instructions.Select(instruction => instruction.Opcode).SequenceEqual(ModuloCopyOpcodes))
{
return null;
}
var countLoad = instructions[1];
var modulusLoad = instructions[4];
if (instructions[0].Destinations is not [{ Kind: Gen5OperandKind.VectorRegister } index] ||
instructions[0].Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } group, { Kind: Gen5OperandKind.EncodedConstant, Value: Shift64 },
{ Kind: Gen5OperandKind.VectorRegister, Value: ThreadIndexRegister }] ||
countLoad.Destinations is not [{ Kind: Gen5OperandKind.ScalarRegister } count] ||
modulusLoad.Destinations is not [{ Kind: Gen5OperandKind.ScalarRegister } modulus] ||
countLoad.Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } constants, _] ||
modulusLoad.Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } modulusConstants, _] ||
modulusConstants.Value != constants.Value ||
instructions[2].Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } limit, { Kind: Gen5OperandKind.VectorRegister } compared] ||
limit.Value != count.Value || compared.Value != index.Value ||
instructions[5].Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } converted] || converted.Value != modulus.Value ||
instructions[6].Sources is not [{ Kind: Gen5OperandKind.EncodedConstant, Value: ZeroConstant }, { Kind: Gen5OperandKind.ScalarRegister } tested] ||
tested.Value != modulus.Value)
{
return null;
}
foreach (var at in new[] { 11, 20, 23, 28 })
{
if (instructions[at].Sources is not [{ Kind: Gen5OperandKind.ScalarRegister } multiplier, ..] || multiplier.Value != modulus.Value)
{
return null;
}
}
var load = instructions[30];
var store = instructions[31];
if (instructions[29].Destinations is not [{ Kind: Gen5OperandKind.VectorRegister } remainder] ||
instructions[29].Sources is not [{ Kind: Gen5OperandKind.VectorRegister } dividend, { Kind: Gen5OperandKind.VectorRegister }] ||
dividend.Value != index.Value ||
load.Control is not Gen5BufferMemoryControl { DwordCount: 1, OffsetBytes: 0, IndexEnabled: true, OffsetEnabled: false, Typed: false } loadControl ||
store.Control is not Gen5BufferMemoryControl { DwordCount: 1, OffsetBytes: 0, IndexEnabled: true, OffsetEnabled: false, Typed: false } storeControl ||
load.Sources is not [_, _, { Kind: Gen5OperandKind.EncodedConstant, Value: ZeroConstant }] ||
store.Sources is not [_, _, { Kind: Gen5OperandKind.EncodedConstant, Value: ZeroConstant }] ||
loadControl.VectorAddress != remainder.Value || storeControl.VectorAddress != index.Value ||
storeControl.VectorData != loadControl.VectorData)
{
return null;
}
var branch = instructions[3];
if (branch.Pc + sizeof(uint) + (uint)((short)(branch.Words[0] & 0xFFFF) * sizeof(uint)) != instructions[^1].Pc)
{
return null;
}
var resourceRegisters = Enumerable.Range(0, 4)
.SelectMany(word => new[] { loadControl.ScalarResource + (uint)word, storeControl.ScalarResource + (uint)word });
var constantRegisters = Enumerable.Range(0, 4).Select(word => constants.Value + (uint)word);
if (resourceRegisters.Any(WrittenScalars(instructions, instructions.Length).Contains) ||
constantRegisters.Any(WrittenScalars(instructions, 4).Contains) ||
WrittenScalars(instructions[5..29], 24).Contains(modulus.Value) ||
countLoad.Control is not Gen5ScalarMemoryControl { DynamicOffsetRegister: null, ImmediateOffsetBytes: >= 0 } countControl ||
modulusLoad.Control is not Gen5ScalarMemoryControl { DynamicOffsetRegister: null, ImmediateOffsetBytes: >= 0 } modulusControl)
{
return null;
}
FillWord User(uint register) => new(FillWordSource.UserData, register, 0, 0);
return new BoundedCopy(
group.Value,
new FillWord(FillWordSource.BufferResource, constants.Value, 0, countControl.ImmediateOffsetBytes),
new FillWord(FillWordSource.BufferResource, constants.Value, 0, modulusControl.ImmediateOffsetBytes),
[.. Enumerable.Range(0, 4).Select(word => User(loadControl.ScalarResource + (uint)word))],
[.. Enumerable.Range(0, 4).Select(word => User(storeControl.ScalarResource + (uint)word))]);
}
private const uint Shift64 = 134;
private const uint ZeroConstant = 128;
private const uint NullScalar = 125;

View File

@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Globalization;
using System.IO.Hashing;
using System.Text;
using SharpEmu.HLE;
@ -141,6 +142,7 @@ internal sealed class ShaderPrewarmList : IDisposable
{
public const string FileName = "shader-prewarm.bin";
public const string StampFileName = "shader-prewarm.stamp";
public const string ProgressFileName = "shader-prewarm.progress";
private const uint Magic = 0x57504553;
private const uint FormatVersion = 1;
@ -151,16 +153,19 @@ internal sealed class ShaderPrewarmList : IDisposable
private readonly object _gate = new();
private readonly FileStream _file;
private readonly string _stampPath;
private readonly string _progressPath;
private readonly object _progressGate = new();
private readonly HashSet<(ulong Hash, uint CodeSize, ulong Address)> _codes = [];
private readonly HashSet<ulong> _computes = [];
private readonly Dictionary<(ulong Hash, uint CodeSize, ulong Address), ShaderCodeCapture> _loadedCodes = new();
private readonly List<ComputePrewarmRecord> _loadedComputes = [];
private bool _failed;
private ShaderPrewarmList(FileStream file, string stampPath)
private ShaderPrewarmList(FileStream file, string stampPath, string progressPath)
{
_file = file;
_stampPath = stampPath;
_progressPath = progressPath;
}
public int LoadedComputeCount => _loadedComputes.Count;
@ -172,7 +177,7 @@ internal sealed class ShaderPrewarmList : IDisposable
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, FileName);
var file = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read);
var list = new ShaderPrewarmList(file, Path.Combine(directory, StampFileName));
var list = new ShaderPrewarmList(file, Path.Combine(directory, StampFileName), Path.Combine(directory, ProgressFileName));
try
{
list.Load();
@ -230,6 +235,93 @@ internal sealed class ShaderPrewarmList : IDisposable
}
}
public static ulong Identity(ComputePrewarmRecord record) => XxHash3.HashToUInt64(SerializeCompute(record));
public HashSet<ulong> ReadProgress(string stamp)
{
var completed = new HashSet<ulong>();
lock (_progressGate)
{
try
{
if (!File.Exists(_progressPath))
{
return completed;
}
var lines = File.ReadAllLines(_progressPath);
if (lines.Length == 0 || lines[0] != ProgressKey(stamp))
{
File.Delete(_progressPath);
return completed;
}
for (var index = 1; index < lines.Length; index++)
{
if (ulong.TryParse(lines[index], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var identity))
{
completed.Add(identity);
}
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
completed.Clear();
}
}
return completed;
}
public void AppendProgress(string stamp, IReadOnlyCollection<ulong> identities)
{
if (identities.Count == 0)
{
return;
}
lock (_progressGate)
{
try
{
var lines = new List<string>(identities.Count + 1);
if (!File.Exists(_progressPath))
{
lines.Add(ProgressKey(stamp));
}
foreach (var identity in identities)
{
lines.Add(identity.ToString("X16", CultureInfo.InvariantCulture));
}
File.AppendAllLines(_progressPath, lines);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
Console.Error.WriteLine($"[LOADER][WARN] Shader prewarm progress write failed: {exception.Message}");
}
}
}
public void ClearProgress()
{
lock (_progressGate)
{
try
{
File.Delete(_progressPath);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
Console.Error.WriteLine($"[LOADER][WARN] Shader prewarm progress clear failed: {exception.Message}");
}
}
}
private static string ProgressKey(string stamp) =>
XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(stamp)).ToString("X16", CultureInfo.InvariantCulture);
public void RecordCompute(ShaderCodeCapture code, ComputePrewarmRecord record)
{
var payload = SerializeCompute(record);

View File

@ -87,6 +87,7 @@ internal sealed class ProgramSourceEntry
public required bool HasBitwiseExclusiveOr { get; init; }
public ConstantFill? ConstantFill { get; init; }
public BoundedFill? BoundedFill { get; init; }
public BoundedCopy? BoundedCopy { get; init; }
public EmbeddedVertexFetchPlan? EmbeddedFetch { get; init; }
public ShaderVertexInput[] VertexInputs { get; init; } = [];
public List<ProgramPermutation> Permutations { get; } = new(8);
@ -383,6 +384,7 @@ internal sealed class ShaderProgramCache
HasBitwiseExclusiveOr = exclusiveOr,
ConstantFill = source.Stage == ShaderStage.Compute ? ConstantFillDetector.Detect(program) : null,
BoundedFill = source.Stage == ShaderStage.Compute ? BoundedFillDetector.Detect(program) : null,
BoundedCopy = source.Stage == ShaderStage.Compute ? BoundedFillDetector.DetectCopy(program) : null,
EmbeddedFetch = fetch,
VertexInputs = vertexInputs,
};
@ -837,6 +839,7 @@ internal sealed class ShaderProgramCache
HasBitwiseExclusiveOr = entry.HasBitwiseExclusiveOr,
ConstantFill = entry.ConstantFill,
BoundedFill = entry.BoundedFill,
BoundedCopy = entry.BoundedCopy,
Buffers = buffers,
Images = images,
SamplerCount = info.Samplers.Count,

View File

@ -188,5 +188,7 @@ public interface IRenderHost
bool TryFillDccMetadata(ulong address, ulong size, uint fillValue);
bool TryCopyWordsOnHost(ulong destination, ulong source, ulong sourceWords, ulong words);
Exception Fatal(string message);
}

View File

@ -101,6 +101,12 @@ public sealed partial class RenderExecutor
return;
}
if (indirectArgumentsAddress == 0 && TryConsumeBoundedCopy(input, groupsX, groupsY, groupsZ, dispatchInitiator))
{
_host.ResetBindings();
return;
}
if (indirectArgumentsAddress == 0 && TryConsumeImageClear(input, groupsX, groupsY, groupsZ, dispatchInitiator))
{
_host.ResetBindings();
@ -552,6 +558,71 @@ public sealed partial class RenderExecutor
return consumed;
}
private bool TryConsumeBoundedCopy(ComputeInputInfo input, uint groupsX, uint groupsY, uint groupsZ, uint dispatchInitiator)
{
var program = input.Stage.Program!;
if (program.BoundedCopy is not { } copy || !BoundedCopyEnabled)
{
return false;
}
if (program.UserDataBase != 0 || copy.GroupScalarRegister != (uint)input.WorkgroupRegister || !input.GroupIdX || input.GroupIdY || input.GroupIdZ ||
input.ThreadIdCount < 1 || input.ThreadsX != ImageClearWaveSize || input.ThreadsY != 1 || input.ThreadsZ != 1 ||
groupsX == 0 || groupsY != 1 || groupsZ != 1)
{
return RefuseBoundedFill(program, $"copy shape dispatch={groupsX}x{groupsY}x{groupsZ} local={input.ThreadsX}x{input.ThreadsY}x{input.ThreadsZ}");
}
var userData = input.Stage.Resources.UserData;
Span<uint> sourceWords = stackalloc uint[4];
Span<uint> destinationWords = stackalloc uint[4];
for (var word = 0; word < 4; word++)
{
if (!TryResolveFillWord(copy.Source[word], userData, out sourceWords[word]) ||
!TryResolveFillWord(copy.Destination[word], userData, out destinationWords[word]))
{
return RefuseBoundedFill(program, $"copy descriptor word {word} unreadable");
}
}
if (!TryResolveFillWord(copy.Count, userData, out var count) || !TryResolveFillWord(copy.Modulus, userData, out var modulus))
{
return RefuseBoundedFill(program, "copy range unreadable");
}
var source = BufferDescriptorWords.From(sourceWords);
var destination = BufferDescriptorWords.From(destinationWords);
if (!IsPlainWordBuffer(source) || !IsPlainWordBuffer(destination) || source.Format != destination.Format ||
modulus == 0 || modulus > source.RecordCount)
{
return RefuseBoundedFill(program,
$"copy src=0x{source.Address:X}/{source.Format}/{source.Stride}/{source.OutOfBounds} dst=0x{destination.Address:X}/{destination.Format}/{destination.Stride}/{destination.OutOfBounds} " +
$"modulus={modulus} records={source.RecordCount}");
}
var threads = (dispatchInitiator & DispatchInitiatorUseThreadDimensions) != 0 ? groupsX : (ulong)groupsX * input.ThreadsX;
var words = Math.Min(Math.Min((ulong)count, threads), destination.RecordCount);
if (words == 0)
{
return false;
}
var consumed = _host.TryCopyWordsOnHost(destination.Address, source.Address, modulus, words);
if (RenderTrace.Enabled)
{
RenderTrace.Write(
$"Bounded copy: shader=0x{program.Hash:X16} src=0x{source.Address:X16} dst=0x{destination.Address:X16} words={words} modulus={modulus} consumed={consumed}");
}
return consumed || RefuseBoundedFill(program, $"copy refused by the host dst=0x{destination.Address:X} words={words} modulus={modulus}");
}
private static readonly bool BoundedCopyEnabled = Environment.GetEnvironmentVariable("SHARPEMU_HOST_BOUNDED_COPY") != "0";
private static bool IsPlainWordBuffer(BufferDescriptorWords descriptor) =>
descriptor.Stride == sizeof(uint) && !descriptor.SwizzleEnabled && !descriptor.AddThreadId && descriptor.OutOfBounds == 0 &&
descriptor.Type == 0 && (descriptor.Address & 3) == 0 && descriptor.Format is Format32UInt or Format32SInt or Format32Float;
private static bool RefuseBoundedFill(ShaderProgramInfo program, string reason)
{
Diagnostics.DccWriterTrace.Refuse(program.Hash, reason);

View File

@ -58,6 +58,7 @@ public class ShaderProgramInfo
public bool HasBitwiseExclusiveOr { get; init; }
public Pipelines.ConstantFill? ConstantFill { get; init; }
public Pipelines.BoundedFill? BoundedFill { get; init; }
public Pipelines.BoundedCopy? BoundedCopy { get; init; }
public BufferResourceInfo[] Buffers { get; init; } = [];
public ImageResourceInfo[] Images { get; init; } = [];
public int SamplerCount { get; init; }

View File

@ -40,6 +40,7 @@ internal static unsafe partial class VulkanVideoPresenter
}
EnsureStarted(1280, 720);
WaitForShaderPrewarm("first GPU submission");
// Queue publication precedes device initialization; only the render thread consumes it.
lock (_gate)
{
@ -117,6 +118,7 @@ internal static unsafe partial class VulkanVideoPresenter
_ = width;
_ = height;
_ = pitchInPixel;
WaitForShaderPrewarm("first flip");
if (!IsKnownDisplayBuffer(address) || !TryGetActivePresenter(out var presenter))
{
return false;

View File

@ -402,8 +402,7 @@ internal static unsafe partial class VulkanVideoPresenter
}
using var profileScope = BufferUploadProfile.BeginSweep(vertexProgramHash, pixelProgramHash, computeProgramHash);
var memory = GuestGpuMemoryHook.Current ?? throw SubmissionScheduler.Fatal("A device-address program needs the guest GPU memory registry.");
var spans = new List<GuestSpan>();
memory.ForEachSpan((address, size) => spans.Add(new GuestSpan(address, size)));
var spans = DeviceAddressSpans(memory);
var traceAddress = GuestGpuMemoryHook.TraceAddress;
if (traceAddress != 0)
{
@ -420,7 +419,29 @@ internal static unsafe partial class VulkanVideoPresenter
if (traceAddress != 0 && !memory.Covers(traceAddress, 1))
GuestGpuMemoryHook.Trace(traceAddress, 1,
$"device-address-mapping-check readable={_guestMemory.CanRead(traceAddress, 1)} backed={_guestBacking.IsBackedView(traceAddress)}");
_bufferCache.PrepareBda(spans);
_bufferCache.PrepareBda(spans, _bdaSpanMapping);
}
private List<GuestSpan>? _bdaSpans;
private GuestGpuMemory? _bdaSpanMemory;
private long _bdaSpanVersion = -1;
private ulong _bdaSpanMapping;
private List<GuestSpan> DeviceAddressSpans(GuestGpuMemory memory)
{
var version = memory.SpanVersion;
if (_bdaSpans is { } cached && ReferenceEquals(_bdaSpanMemory, memory) && _bdaSpanVersion == version)
{
return cached;
}
var spans = new List<GuestSpan>();
memory.ForEachSpan((address, size) => spans.Add(new GuestSpan(address, size)));
_bdaSpans = spans;
_bdaSpanMemory = memory;
_bdaSpanVersion = version;
_bdaSpanMapping = GuestBufferCache.MappingKey(spans);
return spans;
}
public void BindResources(IPreparedBindings prepared)

View File

@ -100,6 +100,7 @@ internal static unsafe partial class VulkanVideoPresenter
{
using var profile = ResourceMaterializationProfile.Measure(ResourceMaterializationProfile.Phase.GuestRead);
word = 0;
var synchronized = false;
if (IsCleanReadPage(address, sizeof(uint)))
{
if (TryGetAliasPointer(address, sizeof(uint), out var alias))
@ -132,6 +133,8 @@ internal static unsafe partial class VulkanVideoPresenter
{
return false;
}
synchronized = true;
}
else
{
@ -146,6 +149,11 @@ internal static unsafe partial class VulkanVideoPresenter
}
word = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(bytes);
if (Diagnostics.GpuReadTrace.Enabled && synchronized)
{
Diagnostics.GpuReadTrace.RecordValue(address, word);
}
return true;
}

View File

@ -157,8 +157,73 @@ internal static unsafe partial class VulkanVideoPresenter
_guestFlipVersionSequence = 0;
}
private static readonly bool _shaderPrewarmWaitDisabled =
Environment.GetEnvironmentVariable("SHARPEMU_SHADER_PREWARM_WAIT") == "0";
private static bool _shaderPrewarmDecided;
private static bool _shaderPrewarmRunning;
private static bool _shaderPrewarmReleased;
private static int _shaderPrewarmHoldReported;
private static int _shaderPrewarmProgress;
private static int _shaderPrewarmTotal;
private static void SetShaderPrewarmState(bool running)
{
lock (_gate)
{
_shaderPrewarmDecided = true;
_shaderPrewarmRunning = running;
Volatile.Write(ref _shaderPrewarmReleased, !running);
System.Threading.Monitor.PulseAll(_gate);
}
}
internal static void WaitForShaderPrewarm(string holder)
{
if (_shaderPrewarmWaitDisabled || Volatile.Read(ref _shaderPrewarmReleased))
{
return;
}
var started = System.Diagnostics.Stopwatch.GetTimestamp();
var reported = started;
var reporter = false;
lock (_gate)
{
while (_thread is not null && (!_shaderPrewarmDecided || _shaderPrewarmRunning) &&
!_closed && _presenterStartupFailure is null &&
!HostSessionControl.IsShutdownRequested && !Volatile.Read(ref _presenterCloseRequested))
{
if (!reporter && _shaderPrewarmRunning &&
Interlocked.Exchange(ref _shaderPrewarmHoldReported, 1) == 0)
{
reporter = true;
Console.Error.WriteLine(
$"[LOADER][INFO] Shader prewarm: holding the game at its {holder} until it finishes.");
}
System.Threading.Monitor.Wait(_gate, 1000);
if (reporter && System.Diagnostics.Stopwatch.GetElapsedTime(reported) >= TimeSpan.FromSeconds(5))
{
reported = System.Diagnostics.Stopwatch.GetTimestamp();
var total = Volatile.Read(ref _shaderPrewarmTotal);
var done = Volatile.Read(ref _shaderPrewarmProgress);
Console.Error.WriteLine(
$"[LOADER][INFO] Shader prewarm: {done}/{total} ({(total == 0 ? 0 : done * 100 / total)}%)");
}
}
}
if (reporter)
{
Console.Error.WriteLine(string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"[LOADER][INFO] Shader prewarm: game released after {System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalSeconds:F1}s."));
}
}
public static void HideSplashScreen()
{
WaitForShaderPrewarm("splash screen");
lock (_gate)
{
_splashHidden = true;

View File

@ -1200,5 +1200,8 @@ internal static unsafe partial class VulkanVideoPresenter
_bufferCache.FillDccMetadata(address, size, fillValue);
return true;
}
public bool TryCopyWordsOnHost(ulong destination, ulong source, ulong sourceWords, ulong words) =>
_bufferCache.TryCopyWordsOnHost(destination, source, sourceWords, words);
}
}

View File

@ -17,6 +17,7 @@ internal static unsafe partial class VulkanVideoPresenter
private sealed partial class Presenter
{
private const int MaxLoggedPrewarmFailures = 8;
private const int PrewarmProgressBatch = 256;
private ShaderPrewarmList? _shaderPrewarmList;
private ConcurrentDictionary<string, byte>? _prewarmedShaderIdentities;
@ -27,16 +28,25 @@ internal static unsafe partial class VulkanVideoPresenter
private int _shaderPrewarmCompiled;
private int _shaderPrewarmFailed;
private int _shaderPrewarmRuntimeHits;
private readonly ConcurrentQueue<ulong> _shaderPrewarmCompleted = new();
ShaderPrewarmList? IShaderPipelineHost.ShaderPrewarm => Volatile.Read(ref _shaderPrewarmList);
private void StartShaderPrewarm()
{
if (!StartShaderPrewarmCore())
{
SetShaderPrewarmState(false);
}
}
private bool StartShaderPrewarmCore()
{
if (_shaderPrewarmList is not null ||
_pipelineCacheShardDirectory is not null ||
Environment.GetEnvironmentVariable("SHARPEMU_SHADER_PREWARM") == "0")
{
return;
return false;
}
var directory = Path.GetDirectoryName(VulkanPipelineCacheStorage.ResolvePath(
@ -44,7 +54,7 @@ internal static unsafe partial class VulkanVideoPresenter
Environment.GetEnvironmentVariable("SHARPEMU_VK_PIPELINE_CACHE_PATH")));
if (string.IsNullOrEmpty(directory) || ShaderPrewarmList.Open(directory) is not { } list)
{
return;
return false;
}
_prewarmedShaderIdentities = new ConcurrentDictionary<string, byte>(StringComparer.Ordinal);
@ -57,7 +67,7 @@ internal static unsafe partial class VulkanVideoPresenter
{
list.WriteStamp(stamp);
_shaderPrewarmFinished = true;
return;
return false;
}
if (list.IsStampCurrent(stamp))
@ -65,18 +75,31 @@ internal static unsafe partial class VulkanVideoPresenter
_shaderPrewarmFinished = true;
Console.Error.WriteLine(
$"[LOADER][INFO] Shader prewarm skipped: compute={list.LoadedComputeCount} reason=same-build-and-driver");
return;
return false;
}
var work = new ConcurrentQueue<(ComputePrewarmRecord Record, ShaderCodeCapture Code)>(list.LoadedComputes());
var completed = list.ReadProgress(stamp);
var pending = list.LoadedComputes()
.Where(item => !completed.Contains(ShaderPrewarmList.Identity(item.Record)))
.ToList();
var started = Stopwatch.GetTimestamp();
if (pending.Count == 0)
{
FinishShaderPrewarm(list, stamp, started);
return false;
}
var work = new ConcurrentQueue<(ComputePrewarmRecord Record, ShaderCodeCapture Code)>(pending);
var workerCount = Math.Clamp(Environment.ProcessorCount / 4, 1, 4);
var remaining = workerCount;
var started = Stopwatch.GetTimestamp();
var host = (IShaderPipelineHost)this;
var sharedInt64Atomics = host.SharedInt64AtomicsEnabled;
var execGuardElision = host.ExecGuardElisionEnabled;
Volatile.Write(ref _shaderPrewarmTotal, pending.Count);
Volatile.Write(ref _shaderPrewarmProgress, 0);
Console.Error.WriteLine(
$"[LOADER][INFO] Shader prewarm started: compute={work.Count} workers={workerCount}");
$"[LOADER][INFO] Shader prewarm started: compute={pending.Count} resumed={completed.Count} workers={workerCount}");
SetShaderPrewarmState(true);
_shaderPrewarmThreads = new Thread[workerCount];
for (var index = 0; index < workerCount; index++)
{
@ -85,8 +108,14 @@ internal static unsafe partial class VulkanVideoPresenter
while (!_shaderPrewarmStopping && work.TryDequeue(out var item))
{
PrewarmComputePipeline(item.Record, item.Code, compiler, sharedInt64Atomics, execGuardElision);
_shaderPrewarmCompleted.Enqueue(ShaderPrewarmList.Identity(item.Record));
if (Interlocked.Increment(ref _shaderPrewarmProgress) % PrewarmProgressBatch == 0)
{
FlushShaderPrewarmProgress(list, stamp);
}
}
FlushShaderPrewarmProgress(list, stamp);
if (Interlocked.Decrement(ref remaining) == 0)
{
FinishShaderPrewarm(list, stamp, started);
@ -99,6 +128,19 @@ internal static unsafe partial class VulkanVideoPresenter
};
_shaderPrewarmThreads[index].Start();
}
return true;
}
private void FlushShaderPrewarmProgress(ShaderPrewarmList list, string stamp)
{
var batch = new List<ulong>();
while (_shaderPrewarmCompleted.TryDequeue(out var identity))
{
batch.Add(identity);
}
list.AppendProgress(stamp, batch);
}
private static string ShaderPrewarmStampPart(Type type) =>
@ -109,8 +151,7 @@ internal static unsafe partial class VulkanVideoPresenter
'|',
ShaderPrewarmStampPart(typeof(Gen5ShaderTranslator)),
ShaderPrewarmStampPart(typeof(Gen5SpirvTranslator)),
ShaderPrewarmStampPart(compiler.GetType()),
ShaderPrewarmStampPart(typeof(ShaderProgramCache)),
compiler.GetType().FullName,
DriverCacheSignature().TrimEnd('\n'));
private void PrewarmComputePipeline(
@ -182,11 +223,14 @@ internal static unsafe partial class VulkanVideoPresenter
var matched = runtime.Keys.Count(prewarmed.ContainsKey);
if (_shaderPrewarmStopping)
{
SetShaderPrewarmState(false);
return;
}
list.WriteStamp(stamp);
list.ClearProgress();
_shaderPrewarmFinished = true;
SetShaderPrewarmState(false);
_pipelineCacheDirty = true;
Console.Error.WriteLine(
$"[LOADER][INFO] Shader prewarm done: compiled={Volatile.Read(ref _shaderPrewarmCompiled)} " +
@ -218,6 +262,7 @@ internal static unsafe partial class VulkanVideoPresenter
private void StopShaderPrewarm()
{
_shaderPrewarmStopping = true;
SetShaderPrewarmState(false);
foreach (var thread in _shaderPrewarmThreads)
{
thread.Join(TimeSpan.FromSeconds(10));

View File

@ -473,6 +473,19 @@ public static class ResourceMaterializer
return value.ToString();
}
private static readonly HashSet<uint> NulledSampledFormats = new();
private static void ReportNulledSampledFormat(uint format)
{
lock (NulledSampledFormats)
{
if (NulledSampledFormats.Add(format))
{
Console.Error.WriteLine($"[GPU][WARN] A sampled image descriptor uses unsupported format {format}; it is bound as a null texture.");
}
}
}
private static bool NullImageDescriptor(ReadOnlySpan<uint> descriptor) =>
descriptor[0] == 0 && (descriptor[1] & 0xFF) == 0;
@ -1054,7 +1067,19 @@ public static class ResourceMaterializer
numericClass = ImageNumericClass.Uint;
}
}
else if (numericClass == ImageNumericClass.Unsupported || (baseImage.DepthCompare && numericClass != ImageNumericClass.Float))
else if (numericClass == ImageNumericClass.Unsupported)
{
ReportNulledSampledFormat(format);
Array.Clear(descriptor);
images[index] = image with
{
NumericClass = ImageNumericClass.Float,
Dimension = ImageDimension.Dim2D,
Cube = false,
};
continue;
}
else if (baseImage.DepthCompare && numericClass != ImageNumericClass.Float)
{
return Fail($"sampled image descriptor {index} uses unsupported format {format}");
}

View File

@ -0,0 +1,113 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Audio;
using Xunit;
namespace SharpEmu.Libs.Tests.Audio;
public sealed class Atrac9GaplessTests
{
private static readonly byte[] MonoConfig = [0xFE, 0x70, 0x17, 0xE0];
private const int SuperframeBytes = 192;
private const int SuperframeSamples = 256;
private static readonly byte[] SilentFrame = [0x00, 0x84, 0x00, 0x40];
private static Atrac9DecodeState CreateDecoder()
{
var decoder = new Atrac9DecodeState();
Assert.True(decoder.TryInitialize(MonoConfig));
return decoder;
}
private static Atrac9DecodeResult DecodeSuperframes(Atrac9DecodeState decoder, int superframes, int outputSamples)
{
var input = new byte[SuperframeBytes * superframes];
for (var superframe = 0; superframe < superframes; superframe++)
{
SilentFrame.CopyTo(input, superframe * SuperframeBytes);
}
var output = new byte[outputSamples * sizeof(short)];
return decoder.Decode(input, output, Atrac9PcmEncoding.Signed16, requestedChannels: 0, multipleFrames: true);
}
[Fact]
public void WithoutAWindowEverySuperframeSampleIsWritten()
{
var decoder = CreateDecoder();
var result = DecodeSuperframes(decoder, 2, SuperframeSamples * 2);
Assert.Equal(SuperframeSamples * 2 * sizeof(short), result.OutputWritten);
Assert.Equal((ulong)(SuperframeSamples * 2), result.TotalDecodedSamples);
Assert.Equal(default, decoder.Gapless);
}
[Fact]
public void TheWindowSkipsTheLeadInAndStopsAtTheTotal()
{
var decoder = CreateDecoder();
decoder.SetGapless(totalSamples: 500, skipSamples: 100, reset: false);
var first = DecodeSuperframes(decoder, 1, SuperframeSamples);
Assert.Equal(156 * sizeof(short), first.OutputWritten);
Assert.Equal(new Atrac9Gapless(344, 0, 100), decoder.Gapless);
var rest = DecodeSuperframes(decoder, 3, SuperframeSamples * 3);
Assert.Equal((256 + 88) * sizeof(short), rest.OutputWritten);
Assert.Equal(SuperframeBytes * 2, rest.InputConsumed);
Assert.Equal(new Atrac9Gapless(0, 0, 100 + 256 - 88), decoder.Gapless);
Assert.Equal(500ul, rest.TotalDecodedSamples);
var ended = DecodeSuperframes(decoder, 1, SuperframeSamples);
Assert.Equal(0, ended.OutputWritten);
Assert.Equal(0, ended.InputConsumed);
}
[Fact]
public void ALeadInLongerThanASuperframeWritesNothingForIt()
{
var decoder = CreateDecoder();
decoder.SetGapless(totalSamples: 0, skipSamples: 300, reset: false);
var first = DecodeSuperframes(decoder, 1, 0);
Assert.Equal(0, first.OutputWritten);
Assert.Equal(SuperframeBytes, first.InputConsumed);
Assert.Equal(new Atrac9Gapless(0, 44, 256), decoder.Gapless);
var second = DecodeSuperframes(decoder, 1, SuperframeSamples);
Assert.Equal((256 - 44) * sizeof(short), second.OutputWritten);
Assert.Equal(new Atrac9Gapless(0, 0, 300), decoder.Gapless);
}
[Fact]
public void TheRoomCheckUsesTheTrimmedSize()
{
var decoder = CreateDecoder();
decoder.SetGapless(totalSamples: 100, skipSamples: 0, reset: false);
var result = DecodeSuperframes(decoder, 1, 100);
Assert.Equal(100 * sizeof(short), result.OutputWritten);
Assert.Equal(0, result.Status & Atrac9DecodeState.ResultNotEnoughRoom);
}
[Fact]
public void ResetRestoresTheWindowAndASetWithoutResetKeepsProgress()
{
var decoder = CreateDecoder();
decoder.SetGapless(totalSamples: 500, skipSamples: 100, reset: false);
DecodeSuperframes(decoder, 1, SuperframeSamples);
decoder.SetGapless(totalSamples: 500, skipSamples: 100, reset: false);
Assert.Equal(new Atrac9Gapless(344, 0, 100), decoder.Gapless);
decoder.Reset();
Assert.Equal(new Atrac9Gapless(500, 100, 0), decoder.Gapless);
DecodeSuperframes(decoder, 1, SuperframeSamples);
decoder.SetGapless(totalSamples: 2000, skipSamples: 10, reset: true);
Assert.Equal(new Atrac9Gapless(2000, 10, 0), decoder.Gapless);
}
}

View File

@ -0,0 +1,158 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Audio;
using Xunit;
namespace SharpEmu.Libs.Tests.Audio;
public sealed class AudioOut2MixTests
{
private const ulong MemoryBase = 0x1_0000_0000;
private const ushort ObjectMain = 0x100;
private const float HalfPower = 0.70710677f;
[Theory]
[InlineData((ushort)0, true)]
[InlineData((ushort)1, true)]
[InlineData((ushort)2, true)]
[InlineData(ObjectMain, true)]
[InlineData((ushort)3, false)]
[InlineData((ushort)4, false)]
[InlineData((ushort)5, false)]
[InlineData((ushort)6, false)]
public void OnlyMainBgmAndVoicePortsReachTheSpeakers(ushort portType, bool expected)
{
Assert.Equal(expected, AudioOut2Exports.RoutesToSpeakers(portType));
Assert.Equal(expected, AudioOut2Exports.TryGetStereoGains(portType, 2, 1f, AudioOut2Exports.AmbisonicsNone, 0, out _, out _));
}
[Theory]
[InlineData(64u, HalfPower, HalfPower)]
[InlineData(65u, HalfPower, -HalfPower)]
[InlineData(0u, 1f, 1f)]
[InlineData(2u, HalfPower, -HalfPower)]
public void FirstOrderAmbisonicsDecodeToStereoCardioids(uint ambisonics, float left, float right)
{
Assert.True(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 0.5f, ambisonics, 0, out var leftGain, out var rightGain));
Assert.Equal(left * 0.5f, leftGain, 5);
Assert.Equal(right * 0.5f, rightGain, 5);
}
[Theory]
[InlineData(66u)]
[InlineData(67u)]
[InlineData(99u)]
[InlineData(1u)]
[InlineData(3u)]
[InlineData(15u)]
public void HigherAmbisonicComponentsDoNotFeedStereo(uint ambisonics)
{
Assert.False(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 1f, ambisonics, 0, out _, out _));
}
[Fact]
public void PlainObjectsPlayCentredOrPassThroughToOneSide()
{
Assert.True(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 1f, AudioOut2Exports.AmbisonicsNone, 0, out var left, out var right));
Assert.Equal(HalfPower, left, 5);
Assert.Equal(HalfPower, right, 5);
Assert.True(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 1f, AudioOut2Exports.AmbisonicsNone, AudioOut2Exports.PassthroughLeft, out left, out right));
Assert.Equal((1f, 0f), (left, right));
Assert.True(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 1f, AudioOut2Exports.AmbisonicsNone, AudioOut2Exports.PassthroughRight, out left, out right));
Assert.Equal((0f, 1f), (left, right));
Assert.False(AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 0f, AudioOut2Exports.AmbisonicsNone, 0, out _, out _));
}
[Fact]
public void FifthOrderSceneDecodesASourceToItsSide()
{
const int frames = 4;
var leftSource = DecodeScene(frames, azimuthSine: 1f);
var centreSource = DecodeScene(frames, azimuthSine: 0f);
Assert.Equal(2f * HalfPower, leftSource[0], 4);
Assert.Equal(0f, leftSource[1], 4);
Assert.Equal(HalfPower, centreSource[0], 4);
Assert.Equal(HalfPower, centreSource[1], 4);
}
[Fact]
public void SetAttributesStoresGainAmbisonicsAndPassthrough()
{
var memory = new FakeCpuMemory(MemoryBase, 0x1000);
var ctx = new CpuContext(memory, Generation.Gen5);
var param = new byte[0x40];
BinaryPrimitives.WriteUInt16LittleEndian(param, ObjectMain);
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(4), 0x100);
Assert.True(memory.TryWrite(MemoryBase, param));
ctx[CpuRegister.Rdi] = 0;
ctx[CpuRegister.Rsi] = MemoryBase;
ctx[CpuRegister.Rdx] = MemoryBase + 0x80;
Assert.Equal(0, AudioOut2Exports.AudioOut2PortCreate(ctx));
var handle = BinaryPrimitives.ReadUInt64LittleEndian(Read(memory, MemoryBase + 0x80, 8));
var values = new byte[12];
BinaryPrimitives.WriteSingleLittleEndian(values, 0.25f);
BinaryPrimitives.WriteUInt32LittleEndian(values.AsSpan(4), 65);
BinaryPrimitives.WriteUInt32LittleEndian(values.AsSpan(8), AudioOut2Exports.PassthroughLeft);
Assert.True(memory.TryWrite(MemoryBase + 0x100, values));
var attributes = new byte[0x18 * 3];
WriteAttribute(attributes, 0, 1, MemoryBase + 0x100);
WriteAttribute(attributes, 1, 8, MemoryBase + 0x104);
WriteAttribute(attributes, 2, 5, MemoryBase + 0x108);
Assert.True(memory.TryWrite(MemoryBase + 0x200, attributes));
ctx[CpuRegister.Rdi] = handle;
ctx[CpuRegister.Rsi] = MemoryBase + 0x200;
ctx[CpuRegister.Rdx] = 3;
Assert.Equal(0, AudioOut2Exports.AudioOut2PortSetAttributes(ctx));
Assert.Equal((0.25f, 65u, AudioOut2Exports.PassthroughLeft), AudioOut2Exports.PortMixAttributes(handle));
ctx[CpuRegister.Rdi] = handle;
AudioOut2Exports.AudioOut2PortDestroy(ctx);
}
private static float[] DecodeScene(int frames, float azimuthSine)
{
var mix = new float[frames * 2];
var source = new byte[frames * sizeof(float)];
for (var acn = 0u; acn < 36; acn++)
{
var component = acn switch
{
0 => 1f,
1 => azimuthSine,
_ => 0.6f,
};
for (var frame = 0; frame < frames; frame++)
{
BinaryPrimitives.WriteSingleLittleEndian(source.AsSpan(frame * sizeof(float)), component);
}
if (AudioOut2Exports.TryGetStereoGains(ObjectMain, 1, 1f, AudioOut2Exports.AmbisonicsAcnBase + acn, 0, out var left, out var right))
{
AudioOut2Exports.MixPortIntoStereo(source, mix, frames, 1, sizeof(float), true, left, right);
}
}
return mix;
}
private static void WriteAttribute(byte[] attributes, int index, uint id, ulong value)
{
var entry = attributes.AsSpan(index * 0x18, 0x18);
BinaryPrimitives.WriteUInt32LittleEndian(entry, id);
BinaryPrimitives.WriteUInt64LittleEndian(entry[0x08..], value);
BinaryPrimitives.WriteUInt64LittleEndian(entry[0x10..], sizeof(uint));
}
private static byte[] Read(FakeCpuMemory memory, ulong address, int length)
{
var bytes = new byte[length];
Assert.True(memory.TryRead(address, bytes));
return bytes;
}
}

View File

@ -0,0 +1,28 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Audio;
using Xunit;
namespace SharpEmu.Libs.Tests.Audio;
public sealed class AudioOut2PacingTests
{
[Theory]
[InlineData(256u, 48000u, 1u, 40.0)]
[InlineData(256u, 48000u, 2u, 45.333)]
[InlineData(256u, 48000u, 4u, 56.0)]
[InlineData(1024u, 48000u, 4u, 104.0)]
public void ABlockingPushWaitsUntilTheDeviceHoldsTheCushionAndOneSlotLessThanTheQueue(
uint grainSamples, uint frequency, uint queueDepth, double expected)
{
Assert.Equal(expected, AudioOut2Exports.DeviceSlotFreeThreshold(grainSamples, frequency, queueDepth), 3);
}
[Fact]
public void TheThresholdStaysFarBelowTheHostQueueCap()
{
const double hostCapMilliseconds = 128 * 1024 * 1000.0 / (48000 * 4);
Assert.True(AudioOut2Exports.DeviceSlotFreeThreshold(256, 48000, 2) < hostCapMilliseconds / 10);
}
}

View File

@ -0,0 +1,44 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Native;
using SharpEmu.HLE;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
public sealed class GuestStackBoundsTests
{
[Fact]
public unsafe void AllocationQueryHandlesProtectionSplitsAndFallsBackForPartialCommit()
{
if (!OperatingSystem.IsWindows()) return;
var pageSize = (nuint)Environment.SystemPageSize;
var memory = (byte*)HostMemory.Alloc(null, pageSize * 3,
HostMemory.MEM_RESERVE | HostMemory.MEM_COMMIT, HostMemory.PAGE_READWRITE);
Assert.True(memory != null);
try
{
Assert.True(HostMemory.Protect(memory + pageSize, pageSize, HostMemory.PAGE_READONLY, out _));
// VirtualQuery at the last page would return only that page, not the allocation.
Assert.True(DirectExecutionBackend.TryGetGuestStackBounds((ulong)(memory + pageSize * 2 + 16),
out var bottom, out var top));
Assert.Equal((ulong)memory, bottom);
Assert.Equal((ulong)(memory + pageSize * 3), top);
Assert.True(HostMemory.Free(memory, pageSize, 0x4000)); // MEM_DECOMMIT
Assert.True(DirectExecutionBackend.TryGetGuestStackBounds((ulong)(memory + pageSize * 2 + 16),
out bottom, out top));
Assert.Equal((ulong)(memory + pageSize * 2), bottom);
Assert.Equal((ulong)(memory + pageSize * 3), top);
Assert.False(DirectExecutionBackend.TryGetGuestStackBounds((ulong)memory, out _, out _));
}
finally
{
Assert.True(HostMemory.Free(memory, 0, HostMemory.MEM_RELEASE));
}
Assert.False(DirectExecutionBackend.TryGetGuestStackBounds(0, out _, out _));
}
}

View File

@ -104,6 +104,43 @@ public sealed class GuestBufferCacheTests : IClassFixture<HeadlessVulkanFixture>
Assert.Equal(new byte[] { 0x78, 0x56, 0x34, 0x12 }, harness.Read(address, 4));
}
[Fact]
public void AHostWordCopyRepeatsTheSourceAndReachesARegisteredBuffer()
{
if (_vulkan is null) return;
using var harness = new CacheHarness(_vulkan);
var address = harness.MapBacked(Page, ReadWrite);
var destination = address + 0x100;
harness.Write(address, Bytes(1u, 2u, 3u));
var (before, beforeOffset) = harness.Worker.Run(() => harness.Cache.ObtainBuffer(destination, 28, isWritten: false));
Assert.Equal(new byte[28], harness.ReadBack(before, beforeOffset, 28));
Assert.True(harness.Worker.Run(() => harness.Cache.TryCopyWordsOnHost(destination, address, 3, 7)));
var expected = Bytes(1u, 2u, 3u, 1u, 2u, 3u, 1u);
Assert.Equal(expected, harness.Read(destination, 28));
Assert.False(harness.Cache.HasGpuDirtyBytes(destination, 28));
var (buffer, offset) = harness.Worker.Run(() => harness.Cache.ObtainBuffer(destination, 28, isWritten: false));
Assert.Equal(expected, harness.ReadBack(buffer, offset, 28));
harness.Shutdown();
}
[Fact]
public void AHostWordCopyIsRefusedForGpuWrittenOrOverlappingRanges()
{
if (_vulkan is null) return;
using var harness = new CacheHarness(_vulkan);
var address = harness.MapBacked(Page, ReadWrite);
harness.Write(address, Bytes(1u, 2u, 3u, 4u));
Assert.False(harness.Worker.Run(() => harness.Cache.TryCopyWordsOnHost(address + 8, address, 4, 4)));
harness.Worker.Run(() => harness.Cache.ObtainBuffer(address + 0x200, 4, isWritten: true));
Assert.False(harness.Worker.Run(() => harness.Cache.TryCopyWordsOnHost(address + 0x100, address + 0x200, 1, 1)));
Assert.False(harness.Worker.Run(() => harness.Cache.TryCopyWordsOnHost(address + 0x200, address, 1, 1)));
Assert.True(harness.Worker.Run(() => harness.Cache.TryCopyWordsOnHost(address + 0x100, address, 4, 4)));
harness.Shutdown();
}
[Theory]
[InlineData(false)]
[InlineData(true)]

View File

@ -127,6 +127,48 @@ public sealed class BoundedFillDetectorTests
Assert.True(fill.Formatted);
}
private static readonly uint[] DemonsSoulsModuloCopy =
[
0xBEFC03FF, 0xA65CD91E, 0xBF960000, 0xBFA00003, 0xD7460002, 0x04010C0C, 0xF4201A84, 0xFA000000,
0xBF8CC07F, 0x7DA8046A, 0xBF88002A, 0xF4200304, 0xFA000004, 0xBF8CC07F, 0x7E000C0C, 0xBF070C80,
0x8588807E, 0x7E005700, 0x100000FF, 0x4F800000, 0x7E060F00, 0xD5766A00, 0x0202060C, 0x7D8A0280,
0x4C020080, 0x02000101, 0xD56A0001, 0x00020700, 0x4C000303, 0x4A020303, 0x02000101, 0xD56A0000,
0x00020500, 0xD5690001, 0x0002000C, 0x4C060302, 0x7D8C02F9, 0x06068A02, 0x7D86060C, 0x87EA6A0A,
0x50000080, 0xD5286A00, 0x002A00C1, 0xD5010000, 0x002200C1, 0xD5690000, 0x0002000C, 0x4C000102,
0xE0002000, 0x80000000, 0xBF8C3F70, 0xE0102000, 0x80010002, 0xBF810000,
];
[Fact]
public void TheDemonsSoulsModuloCopyIsRecognized()
{
var program = Decode(DemonsSoulsModuloCopy);
var copy = BoundedFillDetector.DetectCopy(program);
Assert.NotNull(copy);
static FillWord User(uint register) => new(FillWordSource.UserData, register, 0, 0);
Assert.Equal(12u, copy.GroupScalarRegister);
Assert.Equal(new FillWord(FillWordSource.BufferResource, 8, 0, 0), copy.Count);
Assert.Equal(new FillWord(FillWordSource.BufferResource, 8, 0, 4), copy.Modulus);
Assert.Equal([User(0), User(1), User(2), User(3)], copy.Source);
Assert.Equal([User(4), User(5), User(6), User(7)], copy.Destination);
Assert.Null(BoundedFillDetector.Detect(program));
}
[Fact]
public void AModuloCopyStoringAtTheRemainderIsRejected()
{
var words = DemonsSoulsModuloCopy.ToArray();
words[Array.IndexOf(words, 0x80010002u)] = 0x80010000u;
Assert.Null(BoundedFillDetector.DetectCopy(Decode(words)));
}
[Fact]
public void FillsAreNotCopies()
{
Assert.Null(BoundedFillDetector.DetectCopy(Decode(PatternFromUserData)));
Assert.Null(BoundedFillDetector.DetectCopy(Decode(ZeroRangeFromConstantBuffer)));
}
[Fact]
public void APatternFillWithAnotherBranchTargetIsRejected()
{

View File

@ -119,6 +119,52 @@ public sealed class ShaderPrewarmListTests : IDisposable
Assert.False(list.IsStampCurrent("build-b"));
}
[Fact]
public void ProgressResumesUnderTheSameStampAndClears()
{
using (var list = Open())
{
Assert.Empty(list.ReadProgress("build-a"));
list.AppendProgress("build-a", [1UL, 2UL]);
list.AppendProgress("build-a", [0xFEDC_BA98_7654_3210UL]);
}
using (var reopened = Open())
{
Assert.Equal(new HashSet<ulong> { 1UL, 2UL, 0xFEDC_BA98_7654_3210UL }, reopened.ReadProgress("build-a"));
reopened.ClearProgress();
Assert.Empty(reopened.ReadProgress("build-a"));
}
}
[Fact]
public void ProgressOfAnotherStampIsDiscarded()
{
using var list = Open();
list.AppendProgress("build-a", [7UL]);
Assert.Empty(list.ReadProgress("build-b"));
Assert.Empty(list.ReadProgress("build-a"));
list.AppendProgress("build-b", [8UL]);
Assert.Equal(new HashSet<ulong> { 8UL }, list.ReadProgress("build-b"));
}
[Fact]
public void ARecordIdentityDependsOnItsSpecialization()
{
using (var list = Open())
{
_ = CompileAtRuntime(list, BufferDescriptorWords.Format32UInt);
_ = CompileAtRuntime(list, BufferDescriptorWords.Format32x4UInt);
}
using var reloaded = Open();
var identities = reloaded.LoadedComputes().Select(item => ShaderPrewarmList.Identity(item.Record)).ToArray();
Assert.Equal(2, identities.Length);
Assert.NotEqual(identities[0], identities[1]);
Assert.Equal(identities[0], ShaderPrewarmList.Identity(reloaded.LoadedComputes()[0].Record));
}
[Fact]
public void RecordedReadsReplayAsMergedRanges()
{

View File

@ -416,6 +416,83 @@ public sealed class RenderExecutorComputeTests : IDisposable
AssertNotDispatched();
}
private const ulong CopySource = RecordingRenderHost.MemoryBase + 0x71_0000;
private void ConfigureBoundedCopy(uint count, uint modulus, uint sourceRecords, uint destinationRecords, uint sourceFormat = Format32UInt)
{
static SharpEmu.Libs.Gpu.Pipelines.FillWord User(uint register) =>
new(SharpEmu.Libs.Gpu.Pipelines.FillWordSource.UserData, register, 0, 0);
_host.WriteGuest(FillParameters, [.. BitConverter.GetBytes(count), .. BitConverter.GetBytes(modulus)]);
var copy = new SharpEmu.Libs.Gpu.Pipelines.BoundedCopy(
12,
new SharpEmu.Libs.Gpu.Pipelines.FillWord(SharpEmu.Libs.Gpu.Pipelines.FillWordSource.BufferResource, 8, 0, 0),
new SharpEmu.Libs.Gpu.Pipelines.FillWord(SharpEmu.Libs.Gpu.Pipelines.FillWordSource.BufferResource, 8, 0, 4),
[User(0), User(1), User(2), User(3)],
[User(4), User(5), User(6), User(7)]);
var program = new ShaderProgramInfo { Stage = ShaderStageKind.Compute, Hash = 0xF333, BoundedCopy = copy };
var source = BufferDescriptor(CopySource, 4, sourceRecords, format: sourceFormat);
var destination = BufferDescriptor(MetadataAddress, 4, destinationRecords, format: Format32UInt);
var parameters = BufferDescriptor(FillParameters, 16, 1, format: 0);
var input = ComputeProgram(Stage(program, userData: [.. source, .. destination, .. parameters])).Input;
_pipelines.Compute = new ComputeProgram
{
Program = new ShaderProgram(0x33),
Input = new ComputeInputInfo
{
ThreadsX = 64, ThreadsY = 1, ThreadsZ = 1, GroupIdX = true, ThreadIdCount = 1, WaveSize = 64,
WorkgroupRegister = 12, Stage = input.Stage,
},
};
}
[Fact]
public void BoundedCopy_BecomesAHostCopyAndRecordsNothing()
{
ConfigureBoundedCopy(count: 0x98, modulus: 0x98, sourceRecords: 0x98, destinationRecords: 0x98);
_executor.Dispatch(1, Banks(), 3, 1, 1, 0x41);
Assert.Contains($"host_copy {MetadataAddress:X} {CopySource:X} 152 152", _host.Calls);
AssertNotDispatched();
Assert.Contains("reset_bindings", _host.Calls);
}
[Fact]
public void BoundedCopy_IsClampedToTheThreadsAndTheDestinationRecords()
{
ConfigureBoundedCopy(count: 0x1000, modulus: 4, sourceRecords: 4, destinationRecords: 0x400);
_executor.Dispatch(1, Banks(), 4, 1, 1, 0x41);
Assert.Contains($"host_copy {MetadataAddress:X} {CopySource:X} 4 256", _host.Calls);
ConfigureBoundedCopy(count: 0x1000, modulus: 4, sourceRecords: 4, destinationRecords: 0x20);
_executor.Dispatch(1, Banks(), 4, 1, 1, 0x41);
Assert.Contains($"host_copy {MetadataAddress:X} {CopySource:X} 4 32", _host.Calls);
AssertNotDispatched();
}
[Theory]
[InlineData(0u, 4u, Format32UInt)]
[InlineData(8u, 4u, Format32UInt)]
[InlineData(4u, 4u, 22u)]
public void BoundedCopyThatIsNotAPlainWordCopy_IsDispatched(uint modulus, uint sourceRecords, uint sourceFormat)
{
ConfigureBoundedCopy(count: 0x40, modulus: modulus, sourceRecords: sourceRecords, destinationRecords: 0x40, sourceFormat: sourceFormat);
_executor.Dispatch(1, Banks(), 1, 1, 1, 0x41);
Assert.DoesNotContain(_host.Calls, call => call.StartsWith("host_copy", StringComparison.Ordinal));
AssertDispatched(1, 1, 1);
}
[Fact]
public void BoundedCopyTheHostRefuses_IsDispatched()
{
_host.HostCopyAccepted = false;
ConfigureBoundedCopy(count: 0x40, modulus: 0x40, sourceRecords: 0x40, destinationRecords: 0x40);
_executor.Dispatch(1, Banks(), 1, 1, 1, 0x41);
Assert.Contains($"host_copy {MetadataAddress:X} {CopySource:X} 64 64", _host.Calls);
AssertDispatched(1, 1, 1);
}
private void ConfigurePatternFill(uint count, uint length, uint[] pattern)
{
static SharpEmu.Libs.Gpu.Pipelines.FillWord User(uint register) =>

View File

@ -394,6 +394,14 @@ internal sealed class RecordingRenderHost : IRenderHost
return RegisteredDcc.Contains(address);
}
public bool HostCopyAccepted { get; set; } = true;
public bool TryCopyWordsOnHost(ulong destination, ulong source, ulong sourceWords, ulong words)
{
Calls.Add($"host_copy {destination:X} {source:X} {sourceWords} {words}");
return HostCopyAccepted;
}
public Exception Fatal(string message) => new RenderExecutorFatalException(message);
}

View File

@ -30,6 +30,10 @@ public sealed class PresenterStartupTests
Set("_presenterStartupFailure", null);
Set("_activePresenter", null);
Set("_thread", Thread.CurrentThread);
Set("_shaderPrewarmDecided", true);
Set("_shaderPrewarmRunning", false);
Set("_shaderPrewarmReleased", false);
Set("_shaderPrewarmHoldReported", 0);
}
private static FieldInfo Field(string name) => PresenterType.GetField(name, BindingFlags.Static | BindingFlags.NonPublic)!;
@ -106,6 +110,45 @@ public sealed class PresenterStartupTests
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ASubmissionWaitsUntilTheShaderPrewarmIsDecidedAndDone(bool undecided)
{
var runner = new StreamRunner();
var queue = new CommandStreamQueue(runner.Host);
Assert.True(runner.Host.Memory.TryWrite(StreamRunner.CommandAddress, new byte[] { 0, 16, 0, 192, 0, 0, 0, 0 }));
using var state = new StartupState();
state.Set(undecided ? "_shaderPrewarmDecided" : "_shaderPrewarmRunning", !undecided);
state.Publish(queue);
Exception? failure = null;
var thread = new Thread(() =>
{
try { VulkanVideoPresenter.SubmitCommandStream(runner.Host.Memory, 0, StreamRunner.CommandAddress, 2, 1, null); }
catch (Exception exception) { failure = exception; }
});
thread.Start();
try
{
Assert.True(SpinWait.SpinUntil(() => (thread.ThreadState & ThreadState.WaitSleepJoin) != 0, 5000));
Assert.False(thread.Join(200));
Assert.Equal(0, queue.PendingSubmissionCount);
state.Set("_shaderPrewarmDecided", true);
state.Set("_shaderPrewarmRunning", false);
Assert.True(thread.Join(5000));
Assert.Null(failure);
Assert.Equal(1, queue.PendingSubmissionCount);
}
finally
{
if (thread.IsAlive)
{
VulkanVideoPresenter.RequestClose();
Assert.True(thread.Join(5000));
}
}
}
[Fact]
public void StartupFailureRejectsTheSubmission()
{