mirror of
https://github.com/par274/sharpemu.git
synced 2026-08-03 16:39:51 +08:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b4d93cb71 | |||
| a7dae8dd7d | |||
| dc3f719fa1 | |||
| f3d9439952 | |||
| 8df4039ca4 | |||
| f36ce4084a | |||
| 4b5ea6a793 | |||
| cf3bd0b4f2 | |||
| 5ee7cd1dfa | |||
| ea9be7484f | |||
| a8fa9c96dc | |||
| c387b969e1 | |||
| 7c9740fee8 | |||
| 544f588cfd | |||
| ecd657006a | |||
| a7ec3d5a77 | |||
| 97bd8c422e | |||
| c4ae4a2059 | |||
| 93c9f14081 | |||
| 532251c0c3 | |||
| 816ec4ad27 | |||
| 82c2c7f48c | |||
| 531e35b6d5 | |||
| 3f9bd2b92b | |||
| eb0653eded | |||
| e1695cf87f | |||
| 0dd543354d | |||
| fc5b6baaa7 | |||
| e5e02c0908 | |||
| 539baa66e7 | |||
| b572738547 | |||
| b75e4e01a0 | |||
| 79aa764d03 | |||
| ec65419c0a | |||
| c990b7799f | |||
| e7149bf41f | |||
| 444af50f4b | |||
| 5864328e35 | |||
| 753ddf93be |
@@ -48,6 +48,8 @@ internal static partial class Program
|
||||
{
|
||||
ConfigureManagedPluginResolution();
|
||||
|
||||
SharpEmu.Libs.VideoOut.RenderDocCapture.Initialize();
|
||||
|
||||
try
|
||||
{
|
||||
return Run(args);
|
||||
|
||||
@@ -111,15 +111,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
name is a fixed constant, not derived from the RID/architecture: each
|
||||
publish output only ever holds one architecture's binaries anyway, so
|
||||
varying the name added a class of bugs (RID resolution timing, host-OS
|
||||
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
|
||||
FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
|
||||
name. -->
|
||||
vs. target-RID mixups) for no benefit. Runtime code (FfmpegRuntime)
|
||||
uses the same literal "plugins" folder name. -->
|
||||
<PropertyGroup>
|
||||
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
|
||||
<FfmpegRuntimeTag>3b502d4</FfmpegRuntimeTag>
|
||||
<FfmpegRuntimeDir>
|
||||
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
|
||||
|
||||
@@ -214,6 +214,12 @@ public sealed partial class DirectExecutionBackend
|
||||
Console.Error.WriteLine($"[LOADER][TRACE] Raw sentinel recoveries: {num2} (last import index={importIndex})");
|
||||
_lastReportedRawSentinelRecoveries = num2;
|
||||
}
|
||||
if (importStubEntry.IsLeaf &&
|
||||
TryDispatchHotMemoryLeaf(cpuContext, importStubEntry, argPackPtr, out var hotMemoryResult))
|
||||
{
|
||||
return hotMemoryResult;
|
||||
}
|
||||
|
||||
if (importStubEntry.IsLeaf &&
|
||||
TryDispatchLeafImport(cpuContext, importStubEntry, argPackPtr, num, out var leafResult))
|
||||
{
|
||||
@@ -381,7 +387,8 @@ public sealed partial class DirectExecutionBackend
|
||||
bool flag4 = !string.IsNullOrWhiteSpace(_importFilter);
|
||||
bool flag5 = false;
|
||||
ExportedFunction? matchedExport = importStubEntry.Export;
|
||||
bool periodicTrace = num <= 128 ||
|
||||
bool periodicTrace = _logImportPeriodic &&
|
||||
(num <= 128 ||
|
||||
(num >= 240 && num <= 400) ||
|
||||
(num >= 900 && num <= 1300) ||
|
||||
num % 100000 == 0L ||
|
||||
@@ -389,7 +396,7 @@ public sealed partial class DirectExecutionBackend
|
||||
(importStubEntry.Nid == "rTXw65xmLIA" && (num <= 256 || num % 128 == 0)) ||
|
||||
flag ||
|
||||
flag2 ||
|
||||
flag3;
|
||||
flag3);
|
||||
if (matchedExport is not null)
|
||||
{
|
||||
if (flag4)
|
||||
@@ -1274,6 +1281,41 @@ public sealed partial class DirectExecutionBackend
|
||||
Mxcsr: context.Mxcsr,
|
||||
RestoreFullFpuState: false);
|
||||
|
||||
/// <summary>
|
||||
/// Ultra-thin path for hot memcpy/memmove leaf imports: skip
|
||||
/// CpuContext register marshalling, import-call frames, and vector return
|
||||
/// stores when guest memory can satisfy the copy directly.
|
||||
/// </summary>
|
||||
private unsafe bool TryDispatchHotMemoryLeaf(
|
||||
CpuContext cpuContext,
|
||||
ImportStubEntry importStubEntry,
|
||||
nint argPackPtr,
|
||||
out ulong result)
|
||||
{
|
||||
result = 0;
|
||||
var nid = importStubEntry.Nid;
|
||||
if (nid is not ("Q3VBxCXhUHs" or "+P6FRGH4LfA"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var destination = *(ulong*)argPackPtr;
|
||||
var source = *(ulong*)(argPackPtr + 8);
|
||||
var count = *(ulong*)(argPackPtr + 16);
|
||||
if (count > (ulong)int.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (count != 0 && !cpuContext.Memory.TryCopy(destination, source, count))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = destination;
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryDispatchLeafImport(
|
||||
CpuContext cpuContext,
|
||||
ImportStubEntry importStubEntry,
|
||||
@@ -1337,7 +1379,7 @@ public sealed partial class DirectExecutionBackend
|
||||
Volatile.Write(ref activeGuestThreadState.LastReturnRip, returnRip);
|
||||
Volatile.Write(ref activeGuestThreadState.LastImportNid, importStubEntry.Nid);
|
||||
}
|
||||
if (dispatchIndex % 100000 == 0)
|
||||
if (_logImportPeriodic && dispatchIndex % 100000 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] Import#{dispatchIndex}: {export.LibraryName}:{export.Name} ({importStubEntry.Nid}) " +
|
||||
@@ -1471,7 +1513,12 @@ public sealed partial class DirectExecutionBackend
|
||||
"xk0AcarP3V4" or // scePadOpen
|
||||
"yH17Q6NWtVg" or // sceUserServiceGetEvent
|
||||
"D-CzAxQL0XI" or // sceUserServiceGetPlatformPrivacySetting
|
||||
"K-jXhbt2gn4"; // scePthreadMutexTrylock
|
||||
"K-jXhbt2gn4" or // scePthreadMutexTrylock
|
||||
// Hot memory leaves: skip non-NoBlock call-frame bookkeeping on top of TryCopy.
|
||||
"Q3VBxCXhUHs" or // memcpy
|
||||
"+P6FRGH4LfA" or // memmove
|
||||
"DfivPArhucg" or // memcmp
|
||||
"8zTFvBIAIN8"; // memset
|
||||
|
||||
private bool ShouldLogImportResult(string nid, OrbisGen2Result result)
|
||||
{
|
||||
|
||||
@@ -344,6 +344,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private bool _logAllImports;
|
||||
|
||||
private bool _logImportPeriodic;
|
||||
|
||||
private bool _logImportFrames;
|
||||
|
||||
private bool _logImportRecent;
|
||||
@@ -1161,6 +1163,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
_logFiber = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_FIBER"), "1", StringComparison.Ordinal);
|
||||
_logBootstrap = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_BOOTSTRAP"), "1", StringComparison.Ordinal);
|
||||
_logAllImports = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_ALL_IMPORTS"), "1", StringComparison.Ordinal);
|
||||
// Periodic Import# spam (every 100k, early bands, NID samples) is on
|
||||
// only when explicitly requested — default stderr traffic was a measurable tax.
|
||||
_logImportPeriodic = string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_PERIODIC"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
_logImportFrames = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_FRAMES"), "1", StringComparison.Ordinal);
|
||||
_logImportRecent = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_RECENT"), "1", StringComparison.Ordinal);
|
||||
_logStackCheck = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STACK_CHK"), "1", StringComparison.Ordinal);
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
private long _mappingGeneration;
|
||||
private const ulong PageSize = 0x1000;
|
||||
private const ulong HostAllocationGranularity = 0x10000;
|
||||
private const ulong GuestAllocationArenaAddress = 0x00006000_0000_0000;
|
||||
private const ulong GuestAllocationArenaSize = 0x0100_0000;
|
||||
private const ulong GuestAllocationArenaStartOffset = PageSize;
|
||||
@@ -117,6 +118,9 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private const uint PAGE_READONLY = 0x02;
|
||||
|
||||
private readonly IHostMemory _hostMemory;
|
||||
|
||||
private readonly object _fixedAllocationGate = new();
|
||||
private readonly HashSet<ulong> _fixedGranuleReservationBases = new();
|
||||
private ulong _guestAllocationArenaBase;
|
||||
private readonly SortedDictionary<ulong, ulong> _guestAllocationFreeRanges = new();
|
||||
private readonly Dictionary<ulong, (ulong Offset, ulong Size)> _guestAllocations = new();
|
||||
@@ -247,7 +251,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
// reserve-only + lazy commit only when a huge non-exec commit fails —
|
||||
// that is the Poppy / large-reservation path #608 was aiming for.
|
||||
var reservedOnly = false;
|
||||
var result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
var result = TryAllocateFixedThroughGranules(desiredAddress, alignedSize, hostProtection, traceReject: false);
|
||||
if (result == 0)
|
||||
{
|
||||
result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
}
|
||||
|
||||
if (result == 0 && allowLazyReserve)
|
||||
{
|
||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||
@@ -329,7 +338,16 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
// Prefer a full commit. Only fall back to reserve-only when a large
|
||||
// non-executable commit cannot be satisfied (see TryAllocateAtExact).
|
||||
ulong result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
ulong result = 0;
|
||||
if (desiredAddress != 0)
|
||||
{
|
||||
result = TryAllocateFixedThroughGranules(desiredAddress, alignedSize, hostProtection, traceReject: false);
|
||||
}
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
}
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
@@ -436,6 +454,183 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return $"fail:{primeBytes:X}";
|
||||
}
|
||||
|
||||
private ulong TryAllocateFixedThroughGranules(
|
||||
ulong desiredAddress,
|
||||
ulong alignedSize,
|
||||
HostPageProtection hostProtection,
|
||||
bool traceReject = true)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || desiredAddress == 0 || alignedSize == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var requestStart = AlignDown(desiredAddress, PageSize);
|
||||
ulong requestEnd;
|
||||
ulong granuleEnd;
|
||||
try
|
||||
{
|
||||
requestEnd = AlignUp(desiredAddress + alignedSize, PageSize);
|
||||
granuleEnd = AlignUp(requestEnd, HostAllocationGranularity);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var granuleStart = AlignDown(requestStart, HostAllocationGranularity);
|
||||
|
||||
lock (_fixedAllocationGate)
|
||||
{
|
||||
var newReservations = new List<ulong>();
|
||||
|
||||
void Reject(ulong segmentAddress, string reason)
|
||||
{
|
||||
if (traceReject)
|
||||
{
|
||||
Log.Warn(
|
||||
$"fixed-alloc reject: want=0x{desiredAddress:X16}+0x{alignedSize:X} segment=0x{segmentAddress:X16} {reason}");
|
||||
}
|
||||
foreach (var reservationBase in newReservations)
|
||||
{
|
||||
_hostMemory.Free(reservationBase);
|
||||
_fixedGranuleReservationBases.Remove(reservationBase);
|
||||
}
|
||||
}
|
||||
|
||||
var cursor = granuleStart;
|
||||
while (cursor < granuleEnd)
|
||||
{
|
||||
if (!_hostMemory.Query(cursor, out var info))
|
||||
{
|
||||
Reject(cursor, "query-failed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var segmentEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
|
||||
? ulong.MaxValue
|
||||
: info.BaseAddress + info.RegionSize;
|
||||
segmentEnd = Math.Min(segmentEnd, granuleEnd);
|
||||
if (segmentEnd <= cursor)
|
||||
{
|
||||
Reject(cursor, "query-no-progress");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (info.State == HostRegionState.Free)
|
||||
{
|
||||
var alignedReserveBase = AlignUp(cursor, HostAllocationGranularity);
|
||||
var unreservableEnd = Math.Min(segmentEnd, alignedReserveBase);
|
||||
if (unreservableEnd > cursor && cursor < requestEnd && unreservableEnd > requestStart)
|
||||
{
|
||||
Reject(cursor, $"free-but-unreservable head (granule base 0x{AlignDown(cursor, HostAllocationGranularity):X16} owned elsewhere)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (alignedReserveBase < segmentEnd)
|
||||
{
|
||||
var reserved = _hostMemory.Reserve(alignedReserveBase, segmentEnd - alignedReserveBase, HostPageProtection.ReadWrite);
|
||||
if (reserved != alignedReserveBase)
|
||||
{
|
||||
if (reserved != 0)
|
||||
{
|
||||
_hostMemory.Free(reserved);
|
||||
}
|
||||
|
||||
Reject(alignedReserveBase, "reserve-failed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
_fixedGranuleReservationBases.Add(alignedReserveBase);
|
||||
newReservations.Add(alignedReserveBase);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var trusted = _fixedGranuleReservationBases.Contains(info.AllocationBase) ||
|
||||
IsTrackedRegionBase(info.AllocationBase);
|
||||
if (!trusted && cursor < requestEnd && segmentEnd > requestStart)
|
||||
{
|
||||
Reject(cursor, $"foreign {info.State} allocBase=0x{info.AllocationBase:X16} prot=0x{info.RawProtection:X}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
cursor = segmentEnd;
|
||||
}
|
||||
|
||||
var commitCursor = requestStart;
|
||||
while (commitCursor < requestEnd)
|
||||
{
|
||||
if (!_hostMemory.Query(commitCursor, out var info))
|
||||
{
|
||||
Reject(commitCursor, "commit-query-failed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var segmentEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
|
||||
? ulong.MaxValue
|
||||
: info.BaseAddress + info.RegionSize;
|
||||
segmentEnd = Math.Min(segmentEnd, requestEnd);
|
||||
if (segmentEnd <= commitCursor)
|
||||
{
|
||||
Reject(commitCursor, "commit-no-progress");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (info.State != HostRegionState.Committed &&
|
||||
!_hostMemory.Commit(commitCursor, segmentEnd - commitCursor, hostProtection))
|
||||
{
|
||||
Reject(commitCursor, "commit-failed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
commitCursor = segmentEnd;
|
||||
}
|
||||
|
||||
if (newReservations.Count == 0)
|
||||
{
|
||||
TraceVmem($"Fixed alloc committed into existing granule reservations: 0x{desiredAddress:X16}+0x{alignedSize:X}");
|
||||
}
|
||||
|
||||
return desiredAddress;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsTrackedRegionBase(ulong allocationBase)
|
||||
{
|
||||
_gate.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var low = 0;
|
||||
var high = _regions.Count - 1;
|
||||
while (low <= high)
|
||||
{
|
||||
var middle = low + ((high - low) >> 1);
|
||||
var address = _regions[middle].VirtualAddress;
|
||||
if (address == allocationBase)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (address < allocationBase)
|
||||
{
|
||||
low = middle + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryBackFixedRange(ulong address, ulong size, bool executable)
|
||||
{
|
||||
if (size == 0)
|
||||
@@ -463,7 +658,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
// MemoryRegions are inserted only once every gap in the range has been
|
||||
// backed. If any gap fails to back, every earlier host allocation is freed
|
||||
// and no region is inserted, so the address space is left untouched.
|
||||
var stagedAllocations = new List<(ulong Address, ulong Size)>();
|
||||
var stagedAllocations = new List<(ulong Address, ulong Size, bool GranuleTracked)>();
|
||||
|
||||
var cursor = start;
|
||||
while (cursor < end)
|
||||
@@ -482,7 +677,21 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
if (info.State == HostRegionState.Free)
|
||||
var needsGranuleAwareBacking = OperatingSystem.IsWindows() &&
|
||||
(info.State == HostRegionState.Free || info.State == HostRegionState.Reserved);
|
||||
|
||||
if (needsGranuleAwareBacking)
|
||||
{
|
||||
var runSize = runEnd - cursor;
|
||||
if (TryAllocateFixedThroughGranules(cursor, runSize, hostProtection, traceReject: false) != cursor)
|
||||
{
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
stagedAllocations.Add((cursor, runSize, true));
|
||||
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
|
||||
}
|
||||
else if (info.State == HostRegionState.Free)
|
||||
{
|
||||
var runSize = runEnd - cursor;
|
||||
var allocated = _hostMemory.Allocate(cursor, runSize, hostProtection);
|
||||
@@ -496,10 +705,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
stagedAllocations.Add((cursor, runSize));
|
||||
stagedAllocations.Add((cursor, runSize, false));
|
||||
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
|
||||
}
|
||||
|
||||
|
||||
cursor = runEnd;
|
||||
}
|
||||
|
||||
@@ -513,7 +723,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var (gapAddress, gapSize) in stagedAllocations)
|
||||
foreach (var (gapAddress, gapSize, _) in stagedAllocations)
|
||||
{
|
||||
InsertRegionSorted(new MemoryRegion
|
||||
{
|
||||
@@ -533,9 +743,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return true;
|
||||
|
||||
Rollback:
|
||||
foreach (var (gapAddress, _) in stagedAllocations)
|
||||
foreach (var (gapAddress, _, granuleTracked) in stagedAllocations)
|
||||
{
|
||||
_hostMemory.Free(gapAddress);
|
||||
if (!granuleTracked)
|
||||
{
|
||||
_hostMemory.Free(gapAddress);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -791,24 +1004,41 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
{
|
||||
lock (_guestAllocationGate)
|
||||
{
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
lock (_fixedAllocationGate)
|
||||
{
|
||||
foreach (var region in _regions)
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_hostMemory.Free(region.VirtualAddress);
|
||||
var freedBases = new HashSet<ulong>();
|
||||
foreach (var region in _regions)
|
||||
{
|
||||
if (freedBases.Add(region.VirtualAddress))
|
||||
{
|
||||
_hostMemory.Free(region.VirtualAddress);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var reservationBase in _fixedGranuleReservationBases)
|
||||
{
|
||||
if (freedBases.Add(reservationBase))
|
||||
{
|
||||
_hostMemory.Free(reservationBase);
|
||||
}
|
||||
}
|
||||
|
||||
_fixedGranuleReservationBases.Clear();
|
||||
_regions.Clear();
|
||||
_pageProtections.Clear();
|
||||
lock (_allocationSearchHintGate)
|
||||
{
|
||||
_allocationSearchHints.Clear();
|
||||
}
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
}
|
||||
_regions.Clear();
|
||||
_pageProtections.Clear();
|
||||
lock (_allocationSearchHintGate)
|
||||
finally
|
||||
{
|
||||
_allocationSearchHints.Clear();
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
_guestAllocationArenaBase = 0;
|
||||
@@ -1402,6 +1632,42 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows() && !region.IsReservedOnly)
|
||||
{
|
||||
var previous = low > 0 ? _regions[low - 1] : null;
|
||||
var next = low < _regions.Count ? _regions[low] : null;
|
||||
var mergePrevious = previous is not null &&
|
||||
!previous.IsReservedOnly &&
|
||||
previous.IsExecutable == region.IsExecutable &&
|
||||
previous.Protection == region.Protection &&
|
||||
previous.VirtualAddress + previous.Size == region.VirtualAddress;
|
||||
var mergeNext = next is not null &&
|
||||
!next.IsReservedOnly &&
|
||||
next.IsExecutable == region.IsExecutable &&
|
||||
next.Protection == region.Protection &&
|
||||
region.VirtualAddress + region.Size == next.VirtualAddress;
|
||||
|
||||
if (mergePrevious && mergeNext)
|
||||
{
|
||||
previous!.Size += region.Size + next!.Size;
|
||||
_regions.RemoveAt(low);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mergePrevious)
|
||||
{
|
||||
previous!.Size += region.Size;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mergeNext)
|
||||
{
|
||||
next!.VirtualAddress = region.VirtualAddress;
|
||||
next.Size += region.Size;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_regions.Insert(low, region);
|
||||
}
|
||||
|
||||
|
||||
@@ -401,6 +401,9 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(app0VariableName, app0Root);
|
||||
// Overlap the cooked-id APR walk with module load so the first ReadFile
|
||||
// miss mid-boot does not stall on a cold USB index.
|
||||
SharpEmu.Libs.Ampr.AmprFileRegistry.BeginApp0IndexPreload(app0Root);
|
||||
return new App0BindingScope(app0VariableName);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ cascade order so individual launcher views do not redefine global visuals.
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Inputs.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Console.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Library.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/OptionsNav.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Options.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/GameOptions.axaml" />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
|
||||
@@ -18,6 +18,7 @@ public partial class App : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnMainWindowClose;
|
||||
desktop.MainWindow = new MainWindow();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>
|
||||
/// Remembers the last completed library scan so a cold start can paint the
|
||||
/// grid immediately instead of waiting on a recursive walk of every game
|
||||
/// folder. The cache is a display seed, never an authority: startup still
|
||||
/// runs the normal scan and reconciles over it, so a stale file can only
|
||||
/// ever cost one frame of wrong content, not a wrong library.
|
||||
/// </summary>
|
||||
internal static class GameLibraryCache
|
||||
{
|
||||
private const int CurrentVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
internal static string CachePath =>
|
||||
Path.Combine(AppContext.BaseDirectory, "user", "library_cache.json");
|
||||
|
||||
internal sealed class CachedGame
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? TitleId { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
public string? CoverPath { get; set; }
|
||||
public string? BackgroundPath { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class CacheDocument
|
||||
{
|
||||
public int Version { get; set; }
|
||||
public List<string> Folders { get; set; } = [];
|
||||
public List<CachedGame> Games { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached games when the file matches the configured folder
|
||||
/// set and the executables still exist. Entries whose executable is gone
|
||||
/// are dropped so a removed game never flashes on screen.
|
||||
/// </summary>
|
||||
internal static List<GameEntry> Load(IReadOnlyList<string> folders)
|
||||
{
|
||||
var games = new List<GameEntry>();
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CachePath))
|
||||
{
|
||||
return games;
|
||||
}
|
||||
|
||||
var document = JsonSerializer.Deserialize<CacheDocument>(
|
||||
File.ReadAllText(CachePath),
|
||||
SerializerOptions);
|
||||
if (document is null || document.Version != CurrentVersion)
|
||||
{
|
||||
return games;
|
||||
}
|
||||
|
||||
if (!SameFolders(document.Folders, folders))
|
||||
{
|
||||
return games;
|
||||
}
|
||||
|
||||
foreach (var cached in document.Games)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cached.Path) || !File.Exists(cached.Path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
games.Add(new GameEntry(
|
||||
cached.Name,
|
||||
cached.TitleId,
|
||||
cached.Version,
|
||||
cached.Path,
|
||||
cached.SizeBytes,
|
||||
Existing(cached.CoverPath),
|
||||
Existing(cached.BackgroundPath)));
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Could not read the library cache: {exception.Message}");
|
||||
games.Clear();
|
||||
}
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
internal static void Save(IReadOnlyList<string> folders, IReadOnlyList<GameEntry> games)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(CachePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var document = new CacheDocument
|
||||
{
|
||||
Version = CurrentVersion,
|
||||
Folders = [.. folders],
|
||||
};
|
||||
|
||||
foreach (var game in games)
|
||||
{
|
||||
document.Games.Add(new CachedGame
|
||||
{
|
||||
Name = game.Name,
|
||||
TitleId = game.TitleId,
|
||||
Version = game.Version,
|
||||
Path = game.Path,
|
||||
SizeBytes = game.SizeBytes,
|
||||
CoverPath = game.CoverPath,
|
||||
BackgroundPath = game.BackgroundPath,
|
||||
});
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
CachePath,
|
||||
JsonSerializer.Serialize(document, SerializerOptions));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Could not write the library cache: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Existing(string? path) =>
|
||||
!string.IsNullOrWhiteSpace(path) && File.Exists(path) ? path : null;
|
||||
|
||||
private static bool SameFolders(
|
||||
IReadOnlyList<string> cached,
|
||||
IReadOnlyList<string> configured)
|
||||
{
|
||||
if (cached.Count != configured.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var known = new HashSet<string>(cached, GameLibraryPath.Comparer);
|
||||
foreach (var folder in configured)
|
||||
{
|
||||
if (!known.Contains(folder))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ public sealed class GuiSettings
|
||||
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
|
||||
public bool PlayTitleMusic { get; set; } = true;
|
||||
|
||||
public string LibraryLayout { get; set; } = "Carousel";
|
||||
|
||||
public string? EmulatorPath { get; set; }
|
||||
|
||||
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
|
||||
@@ -132,6 +134,7 @@ public sealed class GuiSettings
|
||||
{
|
||||
settings.RenderResolutionScale = 1.0;
|
||||
}
|
||||
settings.LibraryLayout = NormalizeChoice(settings.LibraryLayout, "Carousel", "Grid");
|
||||
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
||||
settings.Resolution = NormalizeResolution(settings.Resolution);
|
||||
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ إضافة مجلد ألعاب",
|
||||
|
||||
"Library.Loading": "جارٍ تحميل المكتبة...",
|
||||
"Library.Stat.Version": "الإصدار",
|
||||
"Library.Stat.Installed": "المثبت",
|
||||
"Library.Stat.TitleId": "معرّف اللعبة",
|
||||
"Common.Back": "رجوع",
|
||||
|
||||
"Options.General": "عام",
|
||||
"Options.Logging": "التسجيل",
|
||||
"Options.Section.Emulation": "المحاكاة",
|
||||
"Options.Section.Logging": "التسجيل",
|
||||
"Options.Section.Launcher": "المُشغِّل",
|
||||
@@ -140,13 +145,12 @@
|
||||
"Options.Env.LogDirectMemory.Desc": "تسجيل تخصيصات الذاكرة المباشرة وإخفاقاتها في وحدة التحكم.\nاستخدمه عندما تنهار لعبة أو تُغلق أثناء الإقلاع.",
|
||||
"Options.Env.LogIo.Desc": "تسجيل فتح الملفات وقراءتها وحلّ المسارات في وحدة التحكم.\nاستخدمه عندما لا تجد لعبة ملفات بياناتها أثناء الإقلاع.",
|
||||
"Options.Env.LogNp.Desc": "تسجيل نداءات مكتبة NP (شبكة PlayStation) في وحدة التحكم.",
|
||||
"Options.Env.Group.Debug": "تصحيح الأخطاء",
|
||||
"Options.Env.Group.General": "عام",
|
||||
"Options.Env.RenderDoc.Desc": "يحمّل واجهة RenderDoc داخل التطبيق حتى يمكن التقاط الإطارات من داخل المحاكي.\nاضغط F10 أثناء تشغيل اللعبة لالتقاط إطار واحد؛ تُحفظ اللقطات في user/logs/capture_logs/<TITLE_ID>.\nيتطلب تثبيت RenderDoc. يبطئ وحدة معالجة الرسوميات ويسبب تعليق بعض الألعاب، لذا اتركه معطلاً ما لم تكن تصحح الأخطاء.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "إعادة رفع أسطح الضيف التي تعيد كتابتها شيفرة المعالج الخاصة باللعبة.\nاتركه مغلقًا عادة. شغّله للألعاب التي لا تصل أسطحها المرسومة بالمعالج إلى الشاشة.\nيكلّف أداءً ويسبب مشاكل في بعض الألعاب مثل GTA V.",
|
||||
"Common.Save": "حفظ",
|
||||
"Common.Cancel": "إلغاء",
|
||||
"PerGame.Title": "إعدادات خاصة باللعبة — {0} ({1})",
|
||||
"PerGame.InheritNote": "الصفوف غير المحددة ترث الإعدادات الافتراضية العامة.",
|
||||
"PerGame.EnvToggles.Label": "مفاتيح البيئة",
|
||||
"PerGame.EnvToggles.Desc": "تجاوز المجموعة العامة من مفاتيح SHARPEMU_* لهذه اللعبة.",
|
||||
"Options.About": "حول",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "الكود المصدري والمشكلات وتطوير المشروع.",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Adicionar pasta do jogo",
|
||||
|
||||
"Library.Loading": "Carregando biblioteca…",
|
||||
"Library.Stat.Version": "Versão",
|
||||
"Library.Stat.Installed": "Instalado",
|
||||
"Library.Stat.TitleId": "ID do título",
|
||||
"Common.Back": "Voltar",
|
||||
|
||||
"Options.General": "Opções Gerais",
|
||||
"Options.Logging": "Logs",
|
||||
"Options.Env.Tab": "Ambiente",
|
||||
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
|
||||
"Options.Env.Desc": "Variáveis de ambiente passadas ao emulador durante a inicialização.",
|
||||
@@ -34,6 +39,9 @@
|
||||
"Options.Env.DumpSpirv.Desc": "Extrai os shaders AGC e suas traduções SPIR-V para a pasta shader-dumps.\nUse ao reportar bugs de shader ou renderização.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Registra alocações de memória direta e falhas no console.\nUse quando um jogo aborta ou fecha durante a inicialização (boot).",
|
||||
"Options.Env.LogNp.Desc": "Registra chamadas da biblioteca NP (PlayStation Network) no console.",
|
||||
"Options.Env.Group.Debug": "Depuração",
|
||||
"Options.Env.Group.General": "Geral",
|
||||
"Options.Env.RenderDoc.Desc": "Carrega a API in-application do RenderDoc para capturar frames de dentro do emulador.\nPressione F10 enquanto o jogo roda para capturar um frame; as capturas vão para user/logs/capture_logs/<TITLE_ID>.\nExige o RenderDoc instalado. Deixa a GPU mais lenta e trava alguns títulos, então mantenha desativado a menos que esteja depurando.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Recarrega as superfícies do convidado que o próprio código de CPU do jogo reescreve.\nDeixe desativado normalmente. Ative para títulos cujas superfícies desenhadas pela CPU nunca chegam à tela.\nCusta desempenho e causa regressões em alguns títulos, como GTA V.",
|
||||
"Options.Section.Emulation": "EMULAÇÃO",
|
||||
"Options.Section.Logging": "LOGS",
|
||||
@@ -151,10 +159,6 @@
|
||||
"Options.Env.LogIo.Desc": "Registra no console a abertura e leitura de arquivos e a resolução de caminhos.\nUse quando um jogo não encontrar seus arquivos de dados durante a inicialização.",
|
||||
"Common.Save": "Salvar",
|
||||
"Common.Cancel": "Cancelar",
|
||||
"PerGame.Title": "Configurações por jogo — {0} ({1})",
|
||||
"PerGame.InheritNote": "As linhas desmarcadas herdam os padrões globais.",
|
||||
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
|
||||
"PerGame.EnvToggles.Desc": "Substitui o conjunto global de opções SHARPEMU_* para este jogo.",
|
||||
"About.Github.LatestCommitLabel": "Último commit",
|
||||
"About.Github.LatestCommitDescription": "Último commit na branch main",
|
||||
"Updater.Auto.Label": "Verificar atualizações ao iniciar",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Spielordner hinzufügen",
|
||||
|
||||
"Library.Loading": "Bibliothek wird geladen…",
|
||||
"Library.Stat.Version": "Version",
|
||||
"Library.Stat.Installed": "Installiert",
|
||||
"Library.Stat.TitleId": "Titel-ID",
|
||||
"Common.Back": "Zurück",
|
||||
|
||||
"Options.General": "Allgemein",
|
||||
"Options.Logging": "Protokollierung",
|
||||
"Options.Section.Emulation": "EMULATION",
|
||||
"Options.Section.Logging": "PROTOKOLLIERUNG",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
@@ -140,13 +145,12 @@
|
||||
"Options.Env.LogDirectMemory.Desc": "Direkte Speicherzuweisungen und Fehler in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start abbricht oder sich beendet.",
|
||||
"Options.Env.LogIo.Desc": "Datei-Öffnen, -Lesen und Pfadauflösung in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start seine Datendateien nicht findet.",
|
||||
"Options.Env.LogNp.Desc": "NP-Bibliotheksaufrufe (PlayStation Network) in der Konsole protokollieren.",
|
||||
"Options.Env.Group.Debug": "Debug",
|
||||
"Options.Env.Group.General": "Allgemein",
|
||||
"Options.Env.RenderDoc.Desc": "Lädt die RenderDoc-In-Application-API, damit Frames aus dem Emulator heraus aufgezeichnet werden können.\nDrücke F10 während das Spiel läuft, um ein Frame aufzuzeichnen; Aufzeichnungen landen in user/logs/capture_logs/<TITLE_ID>.\nErfordert eine RenderDoc-Installation. Verlangsamt die GPU und lässt manche Titel hängen, lass es also aus, wenn du nicht debuggst.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Gast-Oberflächen neu hochladen, die der eigene CPU-Code des Spiels überschreibt.\nNormalerweise aus lassen. Für Titel aktivieren, deren CPU-gezeichnete Oberflächen nie auf dem Bildschirm erscheinen.\nKostet Leistung und verursacht bei einigen Titeln wie GTA V Regressionen.",
|
||||
"Common.Save": "Speichern",
|
||||
"Common.Cancel": "Abbrechen",
|
||||
"PerGame.Title": "Spielspezifische Einstellungen — {0} ({1})",
|
||||
"PerGame.InheritNote": "Nicht angehakte Zeilen übernehmen die globalen Standardwerte.",
|
||||
"PerGame.EnvToggles.Label": "Umgebungsschalter",
|
||||
"PerGame.EnvToggles.Desc": "Die globalen SHARPEMU_*-Schalter für dieses Spiel überschreiben.",
|
||||
"Options.About": "Über",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Quellcode, Issues und Projektentwicklung.",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Tilføj spilmappe",
|
||||
|
||||
"Library.Loading": "Indlæser bibliotek…",
|
||||
"Library.Stat.Version": "Version",
|
||||
"Library.Stat.Installed": "Installeret",
|
||||
"Library.Stat.TitleId": "Titel-id",
|
||||
"Common.Back": "Tilbage",
|
||||
|
||||
"Options.General": "Generelt",
|
||||
"Options.Logging": "Logging",
|
||||
"Options.Section.Emulation": "EMULERING",
|
||||
"Options.Section.Logging": "LOGGING",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
@@ -140,13 +145,12 @@
|
||||
"Options.Env.LogDirectMemory.Desc": "Log direkte hukommelsestildelinger og fejl til konsollen.\nBrug dette, når et spil afbryder eller lukker under opstart.",
|
||||
"Options.Env.LogIo.Desc": "Log åbning og læsning af filer samt stiopslag til konsollen.\nBrug dette, når et spil ikke kan finde sine datafiler under opstart.",
|
||||
"Options.Env.LogNp.Desc": "Log NP-bibliotekskald (PlayStation Network) til konsollen.",
|
||||
"Options.Env.Group.Debug": "Fejlfinding",
|
||||
"Options.Env.Group.General": "Generelt",
|
||||
"Options.Env.RenderDoc.Desc": "Indlæser RenderDocs in-application-API, så frames kan optages inde fra emulatoren.\nTryk på F10 mens spillet kører for at optage ét frame; optagelser havner i user/logs/capture_logs/<TITLE_ID>.\nKræver at RenderDoc er installeret. Gør GPU'en langsommere og får nogle titler til at hænge, så lad den være slået fra, medmindre du fejlfinder.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Genindlæs gæsteoverflader, som spillets egen CPU-kode omskriver.\nLad den være slået fra normalt. Slå til for titler, hvis CPU-tegnede overflader aldrig når skærmen.\nKoster ydeevne og giver regressioner i nogle titler, såsom GTA V.",
|
||||
"Common.Save": "Gem",
|
||||
"Common.Cancel": "Annuller",
|
||||
"PerGame.Title": "Indstillinger pr. spil — {0} ({1})",
|
||||
"PerGame.InheritNote": "Umarkerede rækker arver de globale standardværdier.",
|
||||
"PerGame.EnvToggles.Label": "Miljøkontakter",
|
||||
"PerGame.EnvToggles.Desc": "Tilsidesæt det globale sæt SHARPEMU_*-kontakter for dette spil.",
|
||||
"Options.About": "Om",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Kildekode, issues og projektudvikling.",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"Library.SearchWatermark": "Search library…",
|
||||
"Library.AddFolder": "Add folder",
|
||||
"Library.OpenFile": "Open file…",
|
||||
"Library.View.Grid": "Stacked view",
|
||||
"Library.View.Carousel": "Row view",
|
||||
|
||||
"Library.Context.Launch": "Launch",
|
||||
"Library.Context.OpenFolder": "Open game folder",
|
||||
@@ -24,8 +26,13 @@
|
||||
"Library.Empty.AddFolder": "+ Add game folder",
|
||||
|
||||
"Library.Loading": "Loading library…",
|
||||
"Library.Stat.Version": "Version",
|
||||
"Library.Stat.Installed": "Installed",
|
||||
"Library.Stat.TitleId": "Title ID",
|
||||
"Common.Back": "Back",
|
||||
|
||||
"Options.General": "General",
|
||||
"Options.Logging": "Logging",
|
||||
"Options.Env.Tab": "Environment",
|
||||
"Options.Section.Environment": "ENVIRONMENT VARIABLES",
|
||||
"Options.Env.Desc": "Switches passed to the emulator as environment variables at launch.",
|
||||
@@ -37,6 +44,9 @@
|
||||
"Options.Env.LogDirectMemory.Desc": "Log direct memory allocations and failures to the console.\nUse when a game aborts or exits during boot.",
|
||||
"Options.Env.LogIo.Desc": "Log file open, read, and path-resolve activity to the console.\nUse when a game cannot find its data files during boot.",
|
||||
"Options.Env.LogNp.Desc": "Log NP (PlayStation Network) library calls to the console.",
|
||||
"Options.Env.Group.Debug": "Debug",
|
||||
"Options.Env.Group.General": "General",
|
||||
"Options.Env.RenderDoc.Desc": "Load the RenderDoc in-application API so frames can be captured from inside the emulator.\nPress F10 while the game runs to capture one frame; captures land in user/logs/capture_logs/<TITLE_ID>.\nRequires RenderDoc to be installed. Slows the GPU down and hangs some titles, so leave it off unless you are debugging.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Re-upload guest surfaces the game's own CPU code rewrites.\nLeave off normally. Turn on for titles whose CPU-drawn surfaces never reach the screen.\nCosts performance and regresses some titles, such as GTA V.",
|
||||
"Options.DefaultProfile.Label": "Default profile name",
|
||||
"Options.DefaultProfile.Desc": "Name used when a game asks for text input. Defaults to Sharp.",
|
||||
@@ -117,12 +127,6 @@
|
||||
"Common.Save": "Save",
|
||||
"Common.Cancel": "Cancel",
|
||||
|
||||
"PerGame.Title": "Per-game settings — {0} ({1})",
|
||||
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
|
||||
"PerGame.Tab.General": "General",
|
||||
"PerGame.Tab.Graphics": "Graphics",
|
||||
"PerGame.EnvToggles.Label": "Environment toggles",
|
||||
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
|
||||
|
||||
"Console.Title": "CONSOLE",
|
||||
"Console.SearchWatermark": "Search...",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Añadir carpeta de juegos",
|
||||
|
||||
"Library.Loading": "Cargando biblioteca…",
|
||||
"Library.Stat.Version": "Versión",
|
||||
"Library.Stat.Installed": "Instalado",
|
||||
"Library.Stat.TitleId": "ID del título",
|
||||
"Common.Back": "Volver",
|
||||
|
||||
"Options.General": "General",
|
||||
"Options.Logging": "Logs",
|
||||
"Options.Section.Emulation": "EMULACIÓN",
|
||||
"Options.Section.Logging": "LOGS",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
@@ -150,13 +155,12 @@
|
||||
"Options.Env.LogDirectMemory.Desc": "Registrar en la consola las asignaciones de memoria directa y sus fallos.\nÚsalo cuando un juego se aborte o se cierre durante el arranque.",
|
||||
"Options.Env.LogIo.Desc": "Registrar en la consola la apertura y lectura de archivos y la resolución de rutas.\nÚsalo cuando un juego no encuentre sus archivos de datos durante el arranque.",
|
||||
"Options.Env.LogNp.Desc": "Registrar en la consola las llamadas a la biblioteca NP (PlayStation Network).",
|
||||
"Options.Env.Group.Debug": "Depuración",
|
||||
"Options.Env.Group.General": "General",
|
||||
"Options.Env.RenderDoc.Desc": "Carga la API in-application de RenderDoc para poder capturar fotogramas desde el emulador.\nPulsa F10 mientras el juego se ejecuta para capturar un fotograma; las capturas se guardan en user/logs/capture_logs/<TITLE_ID>.\nRequiere tener RenderDoc instalado. Ralentiza la GPU y bloquea algunos títulos, así que déjalo desactivado salvo que estés depurando.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Volver a subir las superficies del invitado que reescribe el propio código de CPU del juego.\nDejar desactivado normalmente. Activar en títulos cuyas superficies dibujadas por CPU nunca llegan a la pantalla.\nCuesta rendimiento y causa regresiones en algunos títulos, como GTA V.",
|
||||
"Common.Save": "Guardar",
|
||||
"Common.Cancel": "Cancelar",
|
||||
"PerGame.Title": "Ajustes por juego — {0} ({1})",
|
||||
"PerGame.InheritNote": "Las filas sin marcar heredan los valores globales.",
|
||||
"PerGame.EnvToggles.Label": "Variables de entorno",
|
||||
"PerGame.EnvToggles.Desc": "Sustituir el conjunto global de opciones SHARPEMU_* para este juego.",
|
||||
"Updater.Auto.Label": "Buscar actualizaciones al iniciar",
|
||||
"Updater.Auto.Desc": "Consulta GitHub sin retrasar el arranque.",
|
||||
"Updater.Label": "Actualizaciones",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Ajouter un dossier de jeux",
|
||||
|
||||
"Library.Loading": "Chargement de la bibliothèque…",
|
||||
"Library.Stat.Version": "Version",
|
||||
"Library.Stat.Installed": "Installé",
|
||||
"Library.Stat.TitleId": "ID du titre",
|
||||
"Common.Back": "Retour",
|
||||
|
||||
"Options.General": "Général",
|
||||
"Options.Logging": "Journalisation",
|
||||
"Options.Env.Tab": "Environnement",
|
||||
"Options.Section.Environment": "VARIABLES D’ENVIRONNEMENT",
|
||||
"Options.Env.Desc": "Paramètres passés à l’émulateur comme variables d’environnement au lancement.",
|
||||
@@ -34,6 +39,9 @@
|
||||
"Options.Env.DumpSpirv.Desc": "Exporter les shaders AGC et leurs traductions SPIR-V dans le dossier shader-dumps.\nÀ utiliser pour signaler des bugs de shader ou de rendu.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Journaliser les allocations de mémoire directe et les échecs dans la console.\nÀ utiliser quand un jeu plante ou se ferme pendant le démarrage.",
|
||||
"Options.Env.LogNp.Desc": "Journaliser les appels de la bibliothèque NP (PlayStation Network) dans la console.",
|
||||
"Options.Env.Group.Debug": "Débogage",
|
||||
"Options.Env.Group.General": "Général",
|
||||
"Options.Env.RenderDoc.Desc": "Charge l'API in-application de RenderDoc afin de capturer des images depuis l'émulateur.\nAppuyez sur F10 pendant le jeu pour capturer une image ; les captures sont écrites dans user/logs/capture_logs/<TITLE_ID>.\nNécessite l'installation de RenderDoc. Ralentit le GPU et bloque certains jeux : laissez cette option désactivée sauf en cas de débogage.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Recharger les surfaces invité que le code CPU du jeu réécrit lui-même.\nLaisser désactivé normalement. Activer pour les titres dont les surfaces dessinées par le CPU n'atteignent jamais l'écran.\nCoûte des performances et provoque des régressions sur certains titres, comme GTA V.",
|
||||
"Options.Section.Emulation": "ÉMULATION",
|
||||
"Options.Section.Logging": "JOURNALISATION",
|
||||
@@ -151,10 +159,6 @@
|
||||
"Options.Env.LogIo.Desc": "Journaliser l’ouverture et la lecture des fichiers ainsi que la résolution des chemins dans la console.\nÀ utiliser quand un jeu ne trouve pas ses fichiers de données au démarrage.",
|
||||
"Common.Save": "Enregistrer",
|
||||
"Common.Cancel": "Annuler",
|
||||
"PerGame.Title": "Paramètres par jeu — {0} ({1})",
|
||||
"PerGame.InheritNote": "Les lignes non cochées héritent des valeurs globales par défaut.",
|
||||
"PerGame.EnvToggles.Label": "Variables d’environnement",
|
||||
"PerGame.EnvToggles.Desc": "Remplacer l’ensemble global des options SHARPEMU_* pour ce jeu.",
|
||||
"About.Github.LatestCommitLabel": "Dernier commit",
|
||||
"About.Github.LatestCommitDescription": "Dernier commit sur la branche main",
|
||||
"Updater.Auto.Label": "Vérifier les mises à jour au démarrage",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Játékmappa hozzáadása",
|
||||
|
||||
"Library.Loading": "Könyvtár betöltése",
|
||||
"Library.Stat.Version": "Verzió",
|
||||
"Library.Stat.Installed": "Telepítve",
|
||||
"Library.Stat.TitleId": "Címazonosító",
|
||||
"Common.Back": "Vissza",
|
||||
|
||||
"Options.General": "Általános",
|
||||
"Options.Logging": "Logolás",
|
||||
"Options.Env.Tab": "Környezet",
|
||||
"Options.Section.Environment": "KÖRNYEZETI VÁLTOZÓK",
|
||||
"Options.Env.Desc": "Indításkor környezeti változóként az emulátorhoz átadott kapcsolók.",
|
||||
@@ -34,6 +39,9 @@
|
||||
"Options.Env.DumpSpirv.Desc": "Az AGC-shaderek és azok SPIR-V-fordításainak mentése a shader-dumps mappába.\nHasználd shader- vagy renderelési hibák jelentésekor.",
|
||||
"Options.Env.LogDirectMemory.Desc": "A közvetlen memóriaallokációk és hibák naplózása a konzolra.\nHasználd, ha egy játék a rendszerindítás során megszakad vagy kilép.",
|
||||
"Options.Env.LogNp.Desc": "Az NP (PlayStation Network) könyvtárhívásokat naplózza a konzolra.",
|
||||
"Options.Env.Group.Debug": "Hibakeresés",
|
||||
"Options.Env.Group.General": "Általános",
|
||||
"Options.Env.RenderDoc.Desc": "Betölti a RenderDoc alkalmazáson belüli API-ját, így képkockák rögzíthetők az emulátorból.\nNyomd meg az F10-et futó játék közben egy képkocka rögzítéséhez; a felvételek a user/logs/capture_logs/<TITLE_ID> mappába kerülnek.\nTelepített RenderDocot igényel. Lassítja a GPU-t és egyes játékokat lefagyaszt, ezért hagyd kikapcsolva, hacsak nem hibakeresel.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Újratölti azokat a vendégfelületeket, amelyeket a játék saját CPU-kódja ír felül.\nNormál esetben hagyd kikapcsolva. Kapcsold be azoknál a címeknél, amelyek CPU-val rajzolt felületei sosem jutnak ki a képernyőre.\nTeljesítménybe kerül, és egyes címeknél, például a GTA V-nél regressziót okoz.",
|
||||
"Options.Section.Emulation": "EMULÁCIÓ",
|
||||
"Options.Section.Logging": "LOGOLÁS",
|
||||
@@ -151,10 +159,6 @@
|
||||
"Options.Env.LogIo.Desc": "A fájlmegnyitások, olvasások és útvonal-feloldások naplózása a konzolra.\nAkkor használd, ha egy játék indításkor nem találja az adatfájljait.",
|
||||
"Common.Save": "Mentés",
|
||||
"Common.Cancel": "Mégse",
|
||||
"PerGame.Title": "Játékonkénti beállítások — {0} ({1})",
|
||||
"PerGame.InheritNote": "A be nem jelölt sorok a globális alapértelmezéseket öröklik.",
|
||||
"PerGame.EnvToggles.Label": "Környezeti kapcsolók",
|
||||
"PerGame.EnvToggles.Desc": "A globális SHARPEMU_* kapcsolókészlet felülírása ennél a játéknál.",
|
||||
"About.Github.LatestCommitLabel": "Legutóbbi commit",
|
||||
"About.Github.LatestCommitDescription": "A main ág legutóbbi commitja",
|
||||
"Updater.Auto.Label": "Frissítések keresése indításkor",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Aggiungi cartella giochi",
|
||||
|
||||
"Library.Loading": "Caricamento libreria…",
|
||||
"Library.Stat.Version": "Versione",
|
||||
"Library.Stat.Installed": "Installato",
|
||||
"Library.Stat.TitleId": "ID titolo",
|
||||
"Common.Back": "Indietro",
|
||||
|
||||
"Options.General": "Generale",
|
||||
"Options.Logging": "Log",
|
||||
"Options.Section.Emulation": "EMULAZIONE",
|
||||
"Options.Section.Logging": "LOG",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
@@ -145,13 +150,12 @@
|
||||
"Options.Env.LogDirectMemory.Desc": "Registra in console le allocazioni di memoria diretta e i relativi errori.\nUsalo quando un gioco si interrompe o si chiude durante l'avvio.",
|
||||
"Options.Env.LogIo.Desc": "Registra in console l'apertura e la lettura dei file e la risoluzione dei percorsi.\nUsalo quando un gioco non trova i propri file di dati durante l'avvio.",
|
||||
"Options.Env.LogNp.Desc": "Registra in console le chiamate alla libreria NP (PlayStation Network).",
|
||||
"Options.Env.Group.Debug": "Debug",
|
||||
"Options.Env.Group.General": "Generale",
|
||||
"Options.Env.RenderDoc.Desc": "Carica l'API in-application di RenderDoc per catturare i frame dall'interno dell'emulatore.\nPremi F10 mentre il gioco è in esecuzione per catturare un frame; le catture finiscono in user/logs/capture_logs/<TITLE_ID>.\nRichiede RenderDoc installato. Rallenta la GPU e blocca alcuni titoli, quindi lascialo disattivato se non stai facendo debug.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Ricarica le superfici guest riscritte dal codice CPU del gioco.\nLasciare disattivato normalmente. Attivare per i titoli le cui superfici disegnate dalla CPU non raggiungono mai lo schermo.\nCosta prestazioni e causa regressioni in alcuni titoli, come GTA V.",
|
||||
"Common.Save": "Salva",
|
||||
"Common.Cancel": "Annulla",
|
||||
"PerGame.Title": "Impostazioni per gioco — {0} ({1})",
|
||||
"PerGame.InheritNote": "Le righe non selezionate ereditano i valori globali.",
|
||||
"PerGame.EnvToggles.Label": "Variabili d'ambiente",
|
||||
"PerGame.EnvToggles.Desc": "Sovrascrivi l'insieme globale delle opzioni SHARPEMU_* per questo gioco.",
|
||||
"Options.About": "Informazioni",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Codice sorgente, issue e sviluppo del progetto.",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ ゲームフォルダーを追加",
|
||||
|
||||
"Library.Loading": "ライブラリを読み込み中…",
|
||||
"Library.Stat.Version": "バージョン",
|
||||
"Library.Stat.Installed": "インストール済み",
|
||||
"Library.Stat.TitleId": "タイトルID",
|
||||
"Common.Back": "戻る",
|
||||
|
||||
"Options.General": "一般",
|
||||
"Options.Logging": "ロギング",
|
||||
"Options.Section.Emulation": "エミュレーション",
|
||||
"Options.Section.Logging": "ロギング",
|
||||
"Options.Section.Launcher": "ランチャー",
|
||||
@@ -140,13 +145,12 @@
|
||||
"Options.Env.LogDirectMemory.Desc": "ダイレクトメモリの割り当てと失敗をコンソールに記録します。\nゲームが起動中に中断・終了する場合に使用してください。",
|
||||
"Options.Env.LogIo.Desc": "ファイルのオープン・読み込み・パス解決の動作をコンソールに記録します。\nゲームが起動中にデータファイルを見つけられない場合に使用してください。",
|
||||
"Options.Env.LogNp.Desc": "NP(PlayStation Network)ライブラリの呼び出しをコンソールに記録します。",
|
||||
"Options.Env.Group.Debug": "デバッグ",
|
||||
"Options.Env.Group.General": "一般",
|
||||
"Options.Env.RenderDoc.Desc": "RenderDoc のアプリ内 API を読み込み、エミュレーター内からフレームをキャプチャできるようにします。\nゲーム実行中に F10 を押すと 1 フレームをキャプチャします。保存先は user/logs/capture_logs/<TITLE_ID> です。\nRenderDoc のインストールが必要です。GPU が遅くなり一部のタイトルはハングするため、デバッグ時以外はオフのままにしてください。",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "ゲーム自身の CPU コードが書き換えるゲスト表面を再アップロードします。\n通常はオフのままにしてください。CPU で描画した表面が画面に反映されないタイトルで有効にします。\n性能を犠牲にし、GTA V など一部のタイトルでは不具合が生じます。",
|
||||
"Common.Save": "保存",
|
||||
"Common.Cancel": "キャンセル",
|
||||
"PerGame.Title": "ゲームごとの設定 — {0} ({1})",
|
||||
"PerGame.InheritNote": "チェックされていない行はグローバルの既定値を継承します。",
|
||||
"PerGame.EnvToggles.Label": "環境スイッチ",
|
||||
"PerGame.EnvToggles.Desc": "このゲームに対してグローバルのSHARPEMU_*スイッチを上書きします。",
|
||||
"Options.About": "情報",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "ソースコード、Issue、プロジェクトの開発。",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ 게임 폴더 추가",
|
||||
|
||||
"Library.Loading": "라이브러리 불러오는 중…",
|
||||
"Library.Stat.Version": "버전",
|
||||
"Library.Stat.Installed": "설치됨",
|
||||
"Library.Stat.TitleId": "타이틀 ID",
|
||||
"Common.Back": "뒤로",
|
||||
|
||||
"Options.General": "일반",
|
||||
"Options.Logging": "로깅",
|
||||
"Options.Section.Emulation": "에뮬레이션",
|
||||
"Options.Section.Logging": "로깅",
|
||||
"Options.Section.Launcher": "런처",
|
||||
@@ -140,13 +145,12 @@
|
||||
"Options.Env.LogDirectMemory.Desc": "다이렉트 메모리 할당과 실패를 콘솔에 기록합니다.\n게임이 부팅 중 중단되거나 종료될 때 사용하세요.",
|
||||
"Options.Env.LogIo.Desc": "파일 열기, 읽기, 경로 확인 동작을 콘솔에 기록합니다.\n게임이 부팅 중 데이터 파일을 찾지 못할 때 사용하세요.",
|
||||
"Options.Env.LogNp.Desc": "NP(PlayStation Network) 라이브러리 호출을 콘솔에 기록합니다.",
|
||||
"Options.Env.Group.Debug": "디버그",
|
||||
"Options.Env.Group.General": "일반",
|
||||
"Options.Env.RenderDoc.Desc": "RenderDoc의 인앱 API를 로드하여 에뮬레이터 내부에서 프레임을 캡처할 수 있게 합니다.\n게임 실행 중 F10을 누르면 한 프레임을 캡처하며, 캡처 파일은 user/logs/capture_logs/<TITLE_ID>에 저장됩니다.\nRenderDoc이 설치되어 있어야 합니다. GPU 속도가 느려지고 일부 타이틀은 멈추므로 디버깅할 때가 아니면 꺼 두세요.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "게임의 자체 CPU 코드가 다시 쓰는 게스트 표면을 다시 업로드합니다.\n평소에는 꺼 두세요. CPU로 그린 표면이 화면에 나타나지 않는 타이틀에서 켜세요.\n성능을 소모하며 GTA V 등 일부 타이틀에서는 문제가 생깁니다.",
|
||||
"Common.Save": "저장",
|
||||
"Common.Cancel": "취소",
|
||||
"PerGame.Title": "게임별 설정 — {0} ({1})",
|
||||
"PerGame.InheritNote": "선택하지 않은 항목은 전역 기본값을 따릅니다.",
|
||||
"PerGame.EnvToggles.Label": "환경 스위치",
|
||||
"PerGame.EnvToggles.Desc": "이 게임에 대해 전역 SHARPEMU_* 스위치 설정을 재정의합니다.",
|
||||
"Options.About": "정보",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "소스 코드, 이슈, 프로젝트 개발.",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Gamemap toevoegen",
|
||||
|
||||
"Library.Loading": "Bibliotheek laden…",
|
||||
"Library.Stat.Version": "Versie",
|
||||
"Library.Stat.Installed": "Geïnstalleerd",
|
||||
"Library.Stat.TitleId": "Titel-ID",
|
||||
"Common.Back": "Terug",
|
||||
|
||||
"Options.General": "Algemeen",
|
||||
"Options.Logging": "Logging",
|
||||
"Options.Section.Emulation": "EMULATIE",
|
||||
"Options.Section.Logging": "LOGGING",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
@@ -140,13 +145,12 @@
|
||||
"Options.Env.LogDirectMemory.Desc": "Log directe geheugentoewijzingen en fouten naar de console.\nGebruik dit wanneer een game tijdens het opstarten afbreekt of afsluit.",
|
||||
"Options.Env.LogIo.Desc": "Log het openen en lezen van bestanden en het oplossen van paden naar de console.\nGebruik dit wanneer een game zijn databestanden niet kan vinden tijdens het opstarten.",
|
||||
"Options.Env.LogNp.Desc": "Log NP-bibliotheekaanroepen (PlayStation Network) naar de console.",
|
||||
"Options.Env.Group.Debug": "Debuggen",
|
||||
"Options.Env.Group.General": "Algemeen",
|
||||
"Options.Env.RenderDoc.Desc": "Laadt de RenderDoc in-application-API zodat frames vanuit de emulator kunnen worden vastgelegd.\nDruk op F10 terwijl het spel draait om één frame vast te leggen; opnamen komen in user/logs/capture_logs/<TITLE_ID>.\nVereist een geïnstalleerde RenderDoc. Vertraagt de GPU en laat sommige games vastlopen, dus laat dit uit tenzij je aan het debuggen bent.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Gastoppervlakken opnieuw uploaden die de eigen CPU-code van de game herschrijft.\nNormaal uit laten. Inschakelen voor titels waarvan de door de CPU getekende oppervlakken nooit het scherm bereiken.\nKost prestaties en veroorzaakt regressies in sommige titels, zoals GTA V.",
|
||||
"Common.Save": "Opslaan",
|
||||
"Common.Cancel": "Annuleren",
|
||||
"PerGame.Title": "Instellingen per game — {0} ({1})",
|
||||
"PerGame.InheritNote": "Niet-aangevinkte rijen erven de globale standaardwaarden.",
|
||||
"PerGame.EnvToggles.Label": "Omgevingsschakelaars",
|
||||
"PerGame.EnvToggles.Desc": "Overschrijf de globale set SHARPEMU_*-schakelaars voor deze game.",
|
||||
"Options.About": "Over",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Broncode, issues en projectontwikkeling.",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Adicionar pasta de jogos",
|
||||
|
||||
"Library.Loading": "A carregar biblioteca…",
|
||||
"Library.Stat.Version": "Versão",
|
||||
"Library.Stat.Installed": "Instalado",
|
||||
"Library.Stat.TitleId": "ID do título",
|
||||
"Common.Back": "Voltar",
|
||||
|
||||
"Options.General": "Geral",
|
||||
"Options.Logging": "Registos",
|
||||
"Options.Env.Tab": "Ambiente",
|
||||
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
|
||||
"Options.Env.Desc": "Switches passados ao emulador como variáveis de ambiente no arranque.",
|
||||
@@ -34,6 +39,9 @@
|
||||
"Options.Env.DumpSpirv.Desc": "Extrai os shaders AGC e as respetivas traduções SPIR-V para a pasta shader-dumps.\nUtilize ao reportar problemas de shaders ou renderização.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Regista alocações de memória direta e falhas na consola.\nUtilize quando um jogo aborta ou fecha durante o arranque.",
|
||||
"Options.Env.LogNp.Desc": "Regista chamadas da biblioteca NP (PlayStation Network) na consola.",
|
||||
"Options.Env.Group.Debug": "Depuração",
|
||||
"Options.Env.Group.General": "Geral",
|
||||
"Options.Env.RenderDoc.Desc": "Carrega a API in-application do RenderDoc para capturar frames a partir do emulador.\nPrime F10 enquanto o jogo corre para capturar um frame; as capturas vão para user/logs/capture_logs/<TITLE_ID>.\nRequer o RenderDoc instalado. Torna a GPU mais lenta e bloqueia alguns títulos, por isso deixa desativado a menos que estejas a depurar.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Recarrega as superfícies do convidado que o próprio código de CPU do jogo reescreve.\nDeixar desativado normalmente. Ativar para títulos cujas superfícies desenhadas pela CPU nunca chegam ao ecrã.\nCusta desempenho e causa regressões em alguns títulos, como GTA V.",
|
||||
"Options.Section.Emulation": "EMULAÇÃO",
|
||||
"Options.Section.Logging": "REGISTOS",
|
||||
@@ -151,10 +159,6 @@
|
||||
"Options.Env.LogIo.Desc": "Registar na consola a abertura e leitura de ficheiros e a resolução de caminhos.\nUtilize quando um jogo não encontrar os seus ficheiros de dados durante o arranque.",
|
||||
"Common.Save": "Guardar",
|
||||
"Common.Cancel": "Cancelar",
|
||||
"PerGame.Title": "Definições por jogo — {0} ({1})",
|
||||
"PerGame.InheritNote": "As linhas não assinaladas herdam as predefinições globais.",
|
||||
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
|
||||
"PerGame.EnvToggles.Desc": "Substituir o conjunto global de opções SHARPEMU_* para este jogo.",
|
||||
"About.Github.LatestCommitLabel": "Último commit",
|
||||
"About.Github.LatestCommitDescription": "Último commit no ramo main",
|
||||
"Updater.Auto.Label": "Procurar atualizações no arranque",
|
||||
|
||||
@@ -24,8 +24,13 @@
|
||||
"Library.Empty.AddFolder": "+ Добавить папку с играми",
|
||||
|
||||
"Library.Loading": "Загрузка библиотеки…",
|
||||
"Library.Stat.Version": "Версия",
|
||||
"Library.Stat.Installed": "Установлено",
|
||||
"Library.Stat.TitleId": "ID игры",
|
||||
"Common.Back": "Назад",
|
||||
|
||||
"Options.General": "Основные",
|
||||
"Options.Logging": "Логгирование",
|
||||
"Options.Env.Tab": "Окружение",
|
||||
"Options.Section.Environment": "ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ",
|
||||
"Options.Env.Desc": "Параметры, передаваемые эмулятору как переменные окружения при запуске.",
|
||||
@@ -37,6 +42,9 @@
|
||||
"Options.Env.LogDirectMemory.Desc": "Выводить в консоль выделения прямой памяти и ошибки выделения.\nИспользуйте, если игра аварийно завершает работу или закрывается при запуске.",
|
||||
"Options.Env.LogIo.Desc": "Выводить в консоль операции открытия и чтения файлов, а также разрешение путей.\nИспользуйте, если игра не может найти файлы данных при запуске.",
|
||||
"Options.Env.LogNp.Desc": "Выводить в консоль вызовы библиотеки NP (PlayStation Network).",
|
||||
"Options.Env.Group.Debug": "Отладка",
|
||||
"Options.Env.Group.General": "Общие",
|
||||
"Options.Env.RenderDoc.Desc": "Загружает внутренний API RenderDoc, чтобы кадры можно было захватывать из эмулятора.\nНажмите F10 во время игры, чтобы захватить один кадр; захваты сохраняются в user/logs/capture_logs/<TITLE_ID>.\nТребует установленного RenderDoc. Замедляет GPU и подвешивает некоторые игры, поэтому оставьте выключенным, если не занимаетесь отладкой.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Повторно загружать гостевые поверхности, которые переписывает собственный код ЦП игры.\nОбычно оставляйте выключенным. Включайте для игр, чьи отрисованные ЦП поверхности не попадают на экран.\nСнижает производительность и вызывает регрессии в некоторых играх, например в GTA V.",
|
||||
"Options.Section.Emulation": "ЭМУЛЯЦИЯ",
|
||||
"Options.Section.Logging": "ЛОГГИРОВАНИЕ",
|
||||
@@ -117,12 +125,6 @@
|
||||
"Common.Save": "Сохранить",
|
||||
"Common.Cancel": "Отмена",
|
||||
|
||||
"PerGame.Title": "Настройки игры — {0} ({1})",
|
||||
"PerGame.InheritNote": "Неотмеченные строки наследуют глобальные настройки.",
|
||||
"PerGame.Tab.General": "Основные",
|
||||
"PerGame.Tab.Graphics": "Графика",
|
||||
"PerGame.EnvToggles.Label": "Переключатели окружения",
|
||||
"PerGame.EnvToggles.Desc": "Переопределить глобальный набор переключателей SHARPEMU_* для этой игры.",
|
||||
|
||||
"Console.Title": "КОНСОЛЬ",
|
||||
"Console.SearchWatermark": "Поиск...",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"Library.SearchWatermark": "Kütüphanede ara…",
|
||||
"Library.AddFolder": "Klasör ekle",
|
||||
"Library.OpenFile": "Dosya aç…",
|
||||
"Library.View.Grid": "Alt alta görünüm",
|
||||
"Library.View.Carousel": "Tek sıra görünüm",
|
||||
|
||||
"Library.Context.Launch": "Başlat",
|
||||
"Library.Context.OpenFolder": "Oyun klasörünü aç",
|
||||
@@ -23,8 +25,13 @@
|
||||
"Library.Empty.AddFolder": "+ Oyun klasörü ekle",
|
||||
|
||||
"Library.Loading": "Kütüphane yükleniyor…",
|
||||
"Library.Stat.Version": "Sürüm",
|
||||
"Library.Stat.Installed": "Yüklü",
|
||||
"Library.Stat.TitleId": "Başlık kimliği",
|
||||
"Common.Back": "Geri",
|
||||
|
||||
"Options.General": "Genel",
|
||||
"Options.Logging": "Günlükleme",
|
||||
"Options.Section.Emulation": "EMÜLASYON",
|
||||
"Options.Section.Logging": "GÜNLÜKLEME",
|
||||
"Options.Section.Launcher": "BAŞLATICI",
|
||||
@@ -172,17 +179,14 @@
|
||||
"Options.Env.LogDirectMemory.Desc": "Doğrudan bellek tahsislerini ve hatalarını konsola günlükle.\nBir oyun açılış sırasında çöküyor veya kapanıyorsa kullanın.",
|
||||
"Options.Env.LogIo.Desc": "Dosya açma, okuma ve yol çözümleme etkinliğini konsola günlükle.\nBir oyun açılışta veri dosyalarını bulamıyorsa kullanın.",
|
||||
"Options.Env.LogNp.Desc": "NP (PlayStation Network) kütüphane çağrılarını konsola günlükle.",
|
||||
"Options.Env.Group.Debug": "Hata Ayıklama",
|
||||
"Options.Env.Group.General": "Genel",
|
||||
"Options.Env.RenderDoc.Desc": "RenderDoc uygulama içi API'sini yükler, böylece kareler emülatörün içinden yakalanabilir.\nOyun çalışırken F10'a basınca bir kare yakalanır; kayıtlar user/logs/capture_logs/<TITLE_ID> altına düşer.\nRenderDoc'un kurulu olmasını gerektirir. GPU'yu yavaşlatır ve bazı oyunları kilitler, hata ayıklamıyorsanız kapalı bırakın.",
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Oyunun kendi CPU kodunun yeniden yazdığı misafir yüzeyleri tekrar yükler.\nNormalde kapalı bırakın. CPU ile çizilen yüzeyleri ekrana ulaşmayan oyunlarda açın.\nPerformansa mal olur ve GTA V gibi bazı oyunlarda soruna yol açar.",
|
||||
"Options.DefaultProfile.Label": "Varsayilan profil adi",
|
||||
"Options.DefaultProfile.Desc": "Oyun metin girisi istediginde kullanilacak ad. Varsayilan deger Sharp'tir.",
|
||||
"Common.Save": "Kaydet",
|
||||
"Common.Cancel": "İptal",
|
||||
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
|
||||
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
|
||||
"PerGame.Tab.General": "Genel",
|
||||
"PerGame.Tab.Graphics": "Grafik",
|
||||
"PerGame.EnvToggles.Label": "Ortam anahtarları",
|
||||
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
|
||||
"Options.About": "Hakkında",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Kaynak kodu, hata kayıtları ve proje geliştirme.",
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.VisualTree;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>Inline per-game settings navigation, loading and persistence.</summary>
|
||||
public partial class MainWindow
|
||||
{
|
||||
private static readonly string[] GameEnvironmentToggleNames =
|
||||
[
|
||||
"SHARPEMU_BTHID_UNAVAILABLE",
|
||||
"SHARPEMU_DISABLE_IMPORT_LOOP_GUARD",
|
||||
"SHARPEMU_WRITABLE_APP0",
|
||||
"SHARPEMU_VK_VALIDATION",
|
||||
"SHARPEMU_DUMP_SPIRV",
|
||||
"SHARPEMU_LOG_DIRECT_MEMORY",
|
||||
"SHARPEMU_LOG_IO",
|
||||
"SHARPEMU_LOG_NP",
|
||||
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
|
||||
"SHARPEMU_RENDERDOC",
|
||||
];
|
||||
|
||||
private readonly List<string> _gameEnvironmentPassthrough = new();
|
||||
private IReadOnlyList<HostDisplayOption> _gameHostDisplays = [];
|
||||
private bool _isGameSettingsOpen;
|
||||
private bool _isLoadingGameSettings;
|
||||
private bool _updatingGameHostDisplayOptions;
|
||||
private int _gameOptionsIndicatorIndex;
|
||||
private int _gameOptionsSectionIndex;
|
||||
private string? _gameSettingsTitleId;
|
||||
|
||||
private void WireGameOptions()
|
||||
{
|
||||
GameSettingsButton.Click += (_, _) => OpenSelectedGameSettings();
|
||||
|
||||
GameLogLevelBox.ItemsSource = _logLevelChoices;
|
||||
GameWindowModeBox.ItemsSource = _windowModeChoices;
|
||||
GameScalingModeBox.ItemsSource = _scalingModeChoices;
|
||||
GameHdrModeBox.ItemsSource = _hdrModeChoices;
|
||||
|
||||
var navigationButtons = GameOptionsNavigationButtons();
|
||||
for (var index = 0; index < navigationButtons.Length; index++)
|
||||
{
|
||||
var section = index;
|
||||
navigationButtons[index].Click += (_, _) =>
|
||||
{
|
||||
if (section < GameOptionsSectionPanels().Length)
|
||||
{
|
||||
SetGameOptionsSection(section);
|
||||
}
|
||||
else
|
||||
{
|
||||
CloseGameSettings();
|
||||
}
|
||||
};
|
||||
navigationButtons[index].PointerEntered += (_, _) =>
|
||||
{
|
||||
if (_isGameSettingsOpen)
|
||||
{
|
||||
SetGameOptionsNavigationIndicator(section);
|
||||
}
|
||||
};
|
||||
navigationButtons[index].GotFocus += (_, _) =>
|
||||
{
|
||||
if (_isGameSettingsOpen)
|
||||
{
|
||||
SetGameOptionsNavigationIndicator(section);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
GameOptionsNavHost.PointerExited += (_, _) =>
|
||||
{
|
||||
if (_isGameSettingsOpen)
|
||||
{
|
||||
SetGameOptionsNavigationIndicator(_gameOptionsSectionIndex);
|
||||
}
|
||||
};
|
||||
|
||||
GameOptionsLaunchButton.Click += (_, _) =>
|
||||
{
|
||||
CloseGameSettings();
|
||||
LaunchSelected();
|
||||
};
|
||||
GameOptionsCloseButton.Click += (_, _) => CloseGameSettings();
|
||||
GameOptionsOpenFolderButton.Click += (_, _) => OpenSelectedGameFolder();
|
||||
GameOptionsCopyPathButton.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
|
||||
GameOptionsCopyTitleIdButton.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
|
||||
GameOptionsRemoveButton.Click += (_, _) =>
|
||||
{
|
||||
CloseGameSettings();
|
||||
RemoveSelectedFromLibrary();
|
||||
};
|
||||
|
||||
GameStrictToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameLogLevelBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameTraceImportsBox.ValueChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameLogToFileToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameWindowModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameDisplayBox.SelectionChanged += (_, _) => OnGameHostDisplayChanged();
|
||||
GameResolutionBox.SelectionChanged += (_, _) => OnGameHostResolutionChanged();
|
||||
GameRefreshRateBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameScalingModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameVSyncToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameHdrModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
foreach (var (_, toggle) in GameEnvironmentToggles())
|
||||
{
|
||||
toggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||
}
|
||||
|
||||
SetGameOptionsSection(0, animateIndicator: false);
|
||||
}
|
||||
|
||||
private void OpenSelectedGameSettings()
|
||||
{
|
||||
if (GameList.SelectedItem is not GameEntry game)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(game.TitleId))
|
||||
{
|
||||
AppendConsoleLine(
|
||||
"[GUI][WARN] Per-game settings require a title ID, which this game does not have.",
|
||||
WarningLineBrush);
|
||||
return;
|
||||
}
|
||||
|
||||
_gameSettingsTitleId = game.TitleId;
|
||||
GameOptionsOverlay.DataContext = game;
|
||||
LoadGameSettings(game.TitleId);
|
||||
SetGameOptionsSection(0, animateIndicator: false);
|
||||
GameOptionsLaunchButton.IsEnabled = !_isRunning;
|
||||
GameOptionsCopyTitleIdButton.IsEnabled =
|
||||
!string.IsNullOrWhiteSpace(game.TitleId);
|
||||
|
||||
_isGameSettingsOpen = true;
|
||||
SetGameOptionsPagesSpan(coversConsoleRow: true);
|
||||
SetGameOptionsOpenClass(BackdropLayer, active: true);
|
||||
SetGameOptionsOpenClass(CarouselHost, active: true);
|
||||
SetGameOptionsOpenClass(LibrarySelectedDetails, active: true);
|
||||
SetGameOptionsOpenClass(GameOptionsOverlay, active: true);
|
||||
GameList.IsHitTestVisible = false;
|
||||
LibraryToolbar.IsHitTestVisible = false;
|
||||
GameOptionsOverlay.IsHitTestVisible = true;
|
||||
GameOptionsGeneralNav.Focus();
|
||||
}
|
||||
|
||||
private void CloseGameSettings(bool restoreLibrary = true)
|
||||
{
|
||||
if (!_isGameSettingsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isGameSettingsOpen = false;
|
||||
SetGameOptionsPagesSpan(coversConsoleRow: false);
|
||||
SetGameOptionsNavigationIndicator(_gameOptionsIndicatorIndex, animate: false);
|
||||
_gameSettingsTitleId = null;
|
||||
_gameEnvironmentPassthrough.Clear();
|
||||
SetGameOptionsOpenClass(BackdropLayer, active: false);
|
||||
SetGameOptionsOpenClass(CarouselHost, active: false);
|
||||
SetGameOptionsOpenClass(LibrarySelectedDetails, active: false);
|
||||
SetGameOptionsOpenClass(GameOptionsOverlay, active: false);
|
||||
GameOptionsOverlay.IsHitTestVisible = false;
|
||||
GameOptionsOverlay.DataContext = null;
|
||||
GameList.IsHitTestVisible = true;
|
||||
LibraryToolbar.IsHitTestVisible = true;
|
||||
|
||||
if (restoreLibrary && _activePageIndex == 0)
|
||||
{
|
||||
GameList.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadGameSettings(string titleId)
|
||||
{
|
||||
var effective = EffectiveLaunchSettings.Resolve(
|
||||
_settings,
|
||||
PerGameSettings.Load(titleId));
|
||||
|
||||
_isLoadingGameSettings = true;
|
||||
_updatingGameHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
GameStrictToggle.IsChecked = effective.StrictDynlibResolution;
|
||||
GameLogLevelBox.SelectedItem = FindChoice(
|
||||
_logLevelChoices,
|
||||
effective.LogLevel,
|
||||
"Info");
|
||||
GameTraceImportsBox.Value = Math.Clamp(effective.ImportTraceLimit, 0, 4096);
|
||||
GameLogToFileToggle.IsChecked = effective.LogToFile;
|
||||
GameWindowModeBox.SelectedItem = FindChoice(
|
||||
_windowModeChoices,
|
||||
effective.WindowMode,
|
||||
"Windowed");
|
||||
GameScalingModeBox.SelectedItem = FindChoice(
|
||||
_scalingModeChoices,
|
||||
effective.ScalingMode,
|
||||
"Fit");
|
||||
GameVSyncToggle.IsChecked = effective.VSync;
|
||||
GameHdrModeBox.SelectedItem = FindChoice(
|
||||
_hdrModeChoices,
|
||||
effective.HdrMode,
|
||||
"Auto");
|
||||
|
||||
_gameHostDisplays = HostDisplayOptions.BuildDisplays(
|
||||
HostDisplayCatalog.Query(),
|
||||
effective.DisplayIndex);
|
||||
GameDisplayBox.ItemsSource = _gameHostDisplays;
|
||||
var display = HostDisplayOptions.SelectDisplay(
|
||||
_gameHostDisplays,
|
||||
effective.DisplayIndex);
|
||||
GameDisplayBox.SelectedItem = display;
|
||||
PopulateGameHostModes(
|
||||
display,
|
||||
effective.Resolution,
|
||||
effective.RefreshRate);
|
||||
|
||||
_gameEnvironmentPassthrough.Clear();
|
||||
foreach (var entry in effective.EnvironmentToggles)
|
||||
{
|
||||
if (!IsKnownGameEnvironmentEntry(entry))
|
||||
{
|
||||
_gameEnvironmentPassthrough.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (name, toggle) in GameEnvironmentToggles())
|
||||
{
|
||||
toggle.IsChecked = IsEnvironmentEnabled(
|
||||
effective.EnvironmentToggles,
|
||||
name);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingGameHostDisplayOptions = false;
|
||||
_isLoadingGameSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistOpenGameSettings()
|
||||
{
|
||||
if (!_isGameSettingsOpen ||
|
||||
_isLoadingGameSettings ||
|
||||
_updatingGameHostDisplayOptions ||
|
||||
string.IsNullOrWhiteSpace(_gameSettingsTitleId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = new PerGameSettings
|
||||
{
|
||||
LogLevel = SelectedComboText(GameLogLevelBox, "Info"),
|
||||
ImportTraceLimit = (int)(GameTraceImportsBox.Value ?? 0),
|
||||
StrictDynlibResolution = GameStrictToggle.IsChecked == true,
|
||||
LogToFile = GameLogToFileToggle.IsChecked == true,
|
||||
WindowMode = SelectedComboText(GameWindowModeBox, "Windowed"),
|
||||
Resolution = SelectedComboText(GameResolutionBox, "1920x1080"),
|
||||
DisplayIndex = GameDisplayBox.SelectedItem is HostDisplayOption display
|
||||
? display.Index
|
||||
: 0,
|
||||
RefreshRate = SelectedGameRefreshRate(),
|
||||
ScalingMode = SelectedComboText(GameScalingModeBox, "Fit"),
|
||||
VSync = GameVSyncToggle.IsChecked == true,
|
||||
HdrMode = SelectedComboText(GameHdrModeBox, "Auto"),
|
||||
EnvironmentToggles = BuildGameEnvironmentEntries(),
|
||||
};
|
||||
settings.RemoveInheritedValues(_settings);
|
||||
settings.Save(_gameSettingsTitleId);
|
||||
}
|
||||
|
||||
private void OnGameHostDisplayChanged()
|
||||
{
|
||||
if (_isLoadingGameSettings ||
|
||||
_updatingGameHostDisplayOptions ||
|
||||
GameDisplayBox.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_updatingGameHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
PopulateGameHostModes(
|
||||
display,
|
||||
GameResolutionBox.SelectedItem as string ?? "1920x1080",
|
||||
SelectedGameRefreshRate());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingGameHostDisplayOptions = false;
|
||||
}
|
||||
|
||||
PersistOpenGameSettings();
|
||||
}
|
||||
|
||||
private void OnGameHostResolutionChanged()
|
||||
{
|
||||
if (_isLoadingGameSettings ||
|
||||
_updatingGameHostDisplayOptions ||
|
||||
GameDisplayBox.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_updatingGameHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
PopulateGameRefreshRates(
|
||||
display,
|
||||
GameResolutionBox.SelectedItem as string,
|
||||
SelectedGameRefreshRate());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingGameHostDisplayOptions = false;
|
||||
}
|
||||
|
||||
PersistOpenGameSettings();
|
||||
}
|
||||
|
||||
private void PopulateGameHostModes(
|
||||
HostDisplayOption display,
|
||||
string selectedResolution,
|
||||
int selectedRefreshRate)
|
||||
{
|
||||
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
|
||||
GameResolutionBox.ItemsSource = resolutions;
|
||||
GameResolutionBox.SelectedItem = resolutions.FirstOrDefault(resolution =>
|
||||
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase))
|
||||
?? resolutions[0];
|
||||
PopulateGameRefreshRates(
|
||||
display,
|
||||
GameResolutionBox.SelectedItem as string,
|
||||
selectedRefreshRate);
|
||||
}
|
||||
|
||||
private void PopulateGameRefreshRates(
|
||||
HostDisplayOption display,
|
||||
string? resolution,
|
||||
int selectedRefreshRate)
|
||||
{
|
||||
var refreshRates = HostDisplayOptions.BuildRefreshRates(
|
||||
display,
|
||||
resolution,
|
||||
selectedRefreshRate,
|
||||
Localization.Instance.Get("Options.RefreshRate.Automatic"));
|
||||
GameRefreshRateBox.ItemsSource = refreshRates;
|
||||
GameRefreshRateBox.SelectedItem = refreshRates.FirstOrDefault(
|
||||
refreshRate => refreshRate.Value == selectedRefreshRate) ?? refreshRates[0];
|
||||
}
|
||||
|
||||
private int SelectedGameRefreshRate() =>
|
||||
GameRefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate
|
||||
? refreshRate.Value
|
||||
: 0;
|
||||
|
||||
private List<string> BuildGameEnvironmentEntries()
|
||||
{
|
||||
var entries = new List<string>(_gameEnvironmentPassthrough);
|
||||
foreach (var (name, toggle) in GameEnvironmentToggles())
|
||||
{
|
||||
if (toggle.IsChecked == true)
|
||||
{
|
||||
entries.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static LocalizedChoice FindChoice(
|
||||
IEnumerable<LocalizedChoice> choices,
|
||||
string value,
|
||||
string fallback) =>
|
||||
choices.FirstOrDefault(choice =>
|
||||
string.Equals(choice.Value, value, StringComparison.OrdinalIgnoreCase))
|
||||
?? choices.First(choice =>
|
||||
string.Equals(choice.Value, fallback, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsEnvironmentEnabled(
|
||||
IEnumerable<string> entries,
|
||||
string name)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var parts = entry.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||
if (!string.Equals(parts[0], name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return parts.Length == 1 || parts[1] != "0";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsKnownGameEnvironmentEntry(string entry)
|
||||
{
|
||||
var name = entry.Split('=', 2, StringSplitOptions.TrimEntries)[0];
|
||||
return GameEnvironmentToggleNames.Contains(name, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void SetGameOptionsSection(int section, bool animateIndicator = true)
|
||||
{
|
||||
var buttons = GameOptionsSectionButtons();
|
||||
var panels = GameOptionsSectionPanels();
|
||||
section = Math.Clamp(section, 0, buttons.Length - 1);
|
||||
_gameOptionsSectionIndex = section;
|
||||
SetGameOptionsNavigationIndicator(section, animateIndicator);
|
||||
|
||||
for (var index = 0; index < buttons.Length; index++)
|
||||
{
|
||||
var active = index == section;
|
||||
SetActiveClass(buttons[index], active);
|
||||
SetOptionsPanelInteraction(panels[index], active);
|
||||
}
|
||||
|
||||
SetActiveClass(GameOptionsBackNav, active: false);
|
||||
}
|
||||
|
||||
private void SetGameOptionsNavigationIndicator(int section, bool animate = true)
|
||||
{
|
||||
var buttons = GameOptionsNavigationButtons();
|
||||
_gameOptionsIndicatorIndex = Math.Clamp(section, 0, buttons.Length - 1);
|
||||
var button = buttons[_gameOptionsIndicatorIndex];
|
||||
MoveNavigationIndicator(
|
||||
GameOptionsNavIndicator,
|
||||
GameOptionsNavHost,
|
||||
button,
|
||||
_gameOptionsIndicatorIndex,
|
||||
animate);
|
||||
}
|
||||
|
||||
private void SetGameOptionsPagesSpan(bool coversConsoleRow)
|
||||
{
|
||||
Grid.SetRowSpan(PagesHost, coversConsoleRow ? 2 : 1);
|
||||
}
|
||||
|
||||
private Button[] GameOptionsNavigationButtons() =>
|
||||
[
|
||||
GameOptionsGeneralNav,
|
||||
GameOptionsLoggingNav,
|
||||
GameOptionsRenderingNav,
|
||||
GameOptionsEnvironmentNav,
|
||||
GameOptionsBackNav,
|
||||
];
|
||||
|
||||
private Button[] GameOptionsSectionButtons() =>
|
||||
[
|
||||
GameOptionsGeneralNav,
|
||||
GameOptionsLoggingNav,
|
||||
GameOptionsRenderingNav,
|
||||
GameOptionsEnvironmentNav,
|
||||
];
|
||||
|
||||
private Control[] GameOptionsSectionPanels() =>
|
||||
[
|
||||
GameOptionsGeneralPanel,
|
||||
GameOptionsLoggingPanel,
|
||||
GameOptionsRenderingPanel,
|
||||
GameOptionsEnvironmentPanel,
|
||||
];
|
||||
|
||||
private (string Name, ToggleSwitch Toggle)[] GameEnvironmentToggles() =>
|
||||
[
|
||||
("SHARPEMU_BTHID_UNAVAILABLE", GameEnvBthidToggle),
|
||||
("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD", GameEnvLoopGuardToggle),
|
||||
("SHARPEMU_WRITABLE_APP0", GameEnvWritableApp0Toggle),
|
||||
("SHARPEMU_VK_VALIDATION", GameEnvVkValidationToggle),
|
||||
("SHARPEMU_DUMP_SPIRV", GameEnvDumpSpirvToggle),
|
||||
("SHARPEMU_LOG_DIRECT_MEMORY", GameEnvLogDirectMemoryToggle),
|
||||
("SHARPEMU_LOG_IO", GameEnvLogIoToggle),
|
||||
("SHARPEMU_LOG_NP", GameEnvLogNpToggle),
|
||||
("SHARPEMU_GUEST_IMAGE_CPU_SYNC", GameEnvGuestImageCpuSyncToggle),
|
||||
("SHARPEMU_RENDERDOC", GameEnvRenderDocToggle),
|
||||
];
|
||||
|
||||
private static void SetGameOptionsOpenClass(Control control, bool active) =>
|
||||
SetClass(control, "gameOptionsOpen", active);
|
||||
}
|
||||
+780
-111
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Automation;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
@@ -12,6 +13,7 @@ using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Rendering.Composition;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using SharpEmu.Core.Cpu;
|
||||
@@ -33,6 +35,8 @@ public partial class MainWindow : Window
|
||||
{
|
||||
private const int MaxConsoleLines = 4000;
|
||||
private const int MaxConsoleLinesPerFlush = 500;
|
||||
private static readonly TimeSpan NavigationIndicatorAnimationDuration =
|
||||
TimeSpan.FromMilliseconds(180);
|
||||
|
||||
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
|
||||
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
|
||||
@@ -119,6 +123,7 @@ public partial class MainWindow : Window
|
||||
private bool _isClosing;
|
||||
private bool _restoringGameSelection;
|
||||
private bool _addFolderInProgress;
|
||||
private bool _isLibraryGridLayout;
|
||||
private GameEntry? _lastSelectedGame;
|
||||
|
||||
// Bundled key art shown whenever no game-specific backdrop applies; the
|
||||
@@ -130,6 +135,8 @@ public partial class MainWindow : Window
|
||||
private HostGamepadButtons _previousPadButtons;
|
||||
private long _navLeftNextAt;
|
||||
private long _navRightNextAt;
|
||||
private long _navUpNextAt;
|
||||
private long _navDownNextAt;
|
||||
|
||||
//Github http client for latest commit
|
||||
private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient();
|
||||
@@ -165,8 +172,6 @@ public partial class MainWindow : Window
|
||||
ConsoleList.ItemsSource = _consoleLines;
|
||||
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
|
||||
_pendingLines.Enqueue((line, isError)));
|
||||
Closed += (_, _) => _emulator?.Stop();
|
||||
|
||||
_consoleFlushTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(80),
|
||||
@@ -217,8 +222,12 @@ public partial class MainWindow : Window
|
||||
CloseConsoleButton.Click += (_, _) => ConsoleToggle.IsChecked = false;
|
||||
LibraryTabButton.Click += (_, _) => SetActivePage(0);
|
||||
OptionsTabButton.Click += (_, _) => SetActivePage(1);
|
||||
LibraryLayoutButton.Click += (_, _) => ToggleLibraryLayout();
|
||||
LibraryPage.SizeChanged += (_, _) => UpdateLibraryGridHeight();
|
||||
LibrarySelectedDetails.SizeChanged += (_, _) => UpdateLibraryGridHeight();
|
||||
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
WireOptionsNavigation();
|
||||
WireGameOptions();
|
||||
|
||||
// The settings page edits _settings live, so a launch started while
|
||||
// it is open already uses the new values.
|
||||
@@ -290,14 +299,44 @@ public partial class MainWindow : Window
|
||||
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
||||
CtxCopyPath.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Clipboard.Path");
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
|
||||
CtxCopyTitleId.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Clipboard.TitleId");
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
|
||||
CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings();
|
||||
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
|
||||
|
||||
Opened += async (_, _) => await OnOpenedAsync();
|
||||
Closing += (_, _) => OnWindowClosing();
|
||||
Closing += (_, _) => BeginWindowClosing();
|
||||
Closed += (_, _) => CompleteWindowClosing();
|
||||
|
||||
SdlLauncherGamepad.EnsureStarted();
|
||||
_gamepadTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(50),
|
||||
};
|
||||
EnvRenderDocToggle.IsCheckedChanged += (_, _) =>
|
||||
|
||||
SetEnvironmentToggle(
|
||||
"SHARPEMU_RENDERDOC",
|
||||
EnvRenderDocToggle.IsChecked == true);
|
||||
DefaultProfileBox.TextChanged += (_, _) =>
|
||||
_settings.DefaultProfile = GuiSettings.NormalizeDefaultProfile(DefaultProfileBox.Text);
|
||||
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
|
||||
|
||||
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
|
||||
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
||||
CtxCopyPath.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
|
||||
CtxCopyTitleId.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
|
||||
CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings();
|
||||
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
|
||||
|
||||
Opened += async (_, _) => await OnOpenedAsync();
|
||||
Closing += (_, _) => BeginWindowClosing();
|
||||
Closed += (_, _) => CompleteWindowClosing();
|
||||
|
||||
SdlLauncherGamepad.EnsureStarted();
|
||||
_gamepadTimer = new DispatcherTimer
|
||||
@@ -341,6 +380,11 @@ public partial class MainWindow : Window
|
||||
{
|
||||
if (index == _activePageIndex)
|
||||
{
|
||||
if (index == 0 && _isGameSettingsOpen)
|
||||
{
|
||||
CloseGameSettings();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,26 +393,94 @@ public partial class MainWindow : Window
|
||||
_settings.Save(); // leaving the Options page
|
||||
}
|
||||
|
||||
if (_isGameSettingsOpen)
|
||||
{
|
||||
CloseGameSettings(restoreLibrary: false);
|
||||
}
|
||||
|
||||
_activePageIndex = index;
|
||||
SetActiveClass(LibraryTabButton, index == 0);
|
||||
SetActiveClass(OptionsTabButton, index == 1);
|
||||
LibraryPage.IsVisible = index == 0;
|
||||
LibraryToolbar.IsVisible = index == 0;
|
||||
OptionsPageSurface.IsVisible = index == 1;
|
||||
OptionsPage.IsVisible = index == 1;
|
||||
|
||||
if (index == 1)
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() => SetOptionsNavigationIndicator(_optionsSectionIndex, animate: false),
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetActiveClass(Button button, bool active)
|
||||
private void SetLibraryLayout(bool grid)
|
||||
{
|
||||
_isLibraryGridLayout = grid;
|
||||
SetClass(GameList, "gridLayout", grid);
|
||||
SetClass(LibrarySelectedDetails, "gridLayout", grid);
|
||||
LibraryPage.RowDefinitions[0].Height = grid
|
||||
? GridLength.Auto
|
||||
: new GridLength(188);
|
||||
LibraryPage.Margin = grid
|
||||
? new Thickness(0, 6, 0, 0)
|
||||
: new Thickness(0, 46, 0, 0);
|
||||
UpdateLibraryGridHeight();
|
||||
UpdateLibraryLayoutButton();
|
||||
|
||||
if (GameList.SelectedItem is { } selected)
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() => GameList.ScrollIntoView(selected),
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLibraryGridHeight()
|
||||
{
|
||||
var pageHeight = LibraryPage.Bounds.Height;
|
||||
if (!_isLibraryGridLayout || pageHeight <= 0)
|
||||
{
|
||||
GameList.MaxHeight = double.PositiveInfinity;
|
||||
return;
|
||||
}
|
||||
|
||||
GameList.MaxHeight = Math.Max(
|
||||
0,
|
||||
pageHeight - LibrarySelectedDetails.DesiredSize.Height);
|
||||
}
|
||||
|
||||
private void ToggleLibraryLayout()
|
||||
{
|
||||
SetLibraryLayout(!_isLibraryGridLayout);
|
||||
_settings.LibraryLayout = _isLibraryGridLayout ? "Grid" : "Carousel";
|
||||
_settings.Save();
|
||||
}
|
||||
|
||||
private void UpdateLibraryLayoutButton()
|
||||
{
|
||||
LibraryLayoutGlyph.Text = _isLibraryGridLayout ? "view_carousel" : "grid_view";
|
||||
var label = Localization.Instance.Get(
|
||||
_isLibraryGridLayout ? "Library.View.Carousel" : "Library.View.Grid");
|
||||
ToolTip.SetTip(LibraryLayoutButton, label);
|
||||
AutomationProperties.SetName(LibraryLayoutButton, label);
|
||||
}
|
||||
|
||||
private static void SetActiveClass(Button button, bool active) =>
|
||||
SetClass(button, "active", active);
|
||||
|
||||
private static void SetClass(Control control, string className, bool active)
|
||||
{
|
||||
if (active)
|
||||
{
|
||||
if (!button.Classes.Contains("active"))
|
||||
if (!control.Classes.Contains(className))
|
||||
{
|
||||
button.Classes.Add("active");
|
||||
control.Classes.Add(className);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
button.Classes.Remove("active");
|
||||
control.Classes.Remove(className);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,17 +559,69 @@ public partial class MainWindow : Window
|
||||
active ? KeyboardNavigationMode.Continue : KeyboardNavigationMode.None);
|
||||
}
|
||||
|
||||
private void SetOptionsNavigationIndicator(int section)
|
||||
private void SetOptionsNavigationIndicator(int section, bool animate = true)
|
||||
{
|
||||
if (OptionsNavIndicator.RenderTransform is not TranslateTransform transform)
|
||||
var buttons = OptionsNavigationButtons();
|
||||
var button = buttons[Math.Clamp(section, 0, buttons.Length - 1)];
|
||||
MoveNavigationIndicator(
|
||||
OptionsNavIndicator,
|
||||
OptionsNavHost,
|
||||
button,
|
||||
section,
|
||||
animate);
|
||||
}
|
||||
|
||||
private static void ConfigureNavigationIndicatorAnimation(Border indicator)
|
||||
{
|
||||
if (ElementComposition.GetElementVisual(indicator) is not { } visual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var buttons = OptionsNavigationButtons();
|
||||
var button = buttons[Math.Clamp(section, 0, buttons.Length - 1)];
|
||||
transform.Y = button.TranslatePoint(default, OptionsNavHost)?.Y
|
||||
var translationAnimation = visual.Compositor.CreateVector3KeyFrameAnimation();
|
||||
translationAnimation.Duration = NavigationIndicatorAnimationDuration;
|
||||
translationAnimation.Target = nameof(CompositionVisual.Translation);
|
||||
translationAnimation.InsertExpressionKeyFrame(
|
||||
1f,
|
||||
"this.FinalValue",
|
||||
new CubicEaseOut());
|
||||
|
||||
var animations = visual.Compositor.CreateImplicitAnimationCollection();
|
||||
animations[nameof(CompositionVisual.Translation)] = translationAnimation;
|
||||
visual.ImplicitAnimations = animations;
|
||||
}
|
||||
|
||||
private static void MoveNavigationIndicator(
|
||||
Border indicator,
|
||||
Control host,
|
||||
Button button,
|
||||
int section,
|
||||
bool animate = true)
|
||||
{
|
||||
if (ElementComposition.GetElementVisual(indicator) is not { } visual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetY = button.TranslatePoint(default, host)?.Y
|
||||
?? section * button.Bounds.Height;
|
||||
|
||||
if (!animate)
|
||||
{
|
||||
visual.ImplicitAnimations = null;
|
||||
visual.StopAnimation(nameof(CompositionVisual.Translation));
|
||||
}
|
||||
else if (visual.ImplicitAnimations is null)
|
||||
{
|
||||
ConfigureNavigationIndicatorAnimation(indicator);
|
||||
}
|
||||
|
||||
visual.Translation = new Vector3D(0, targetY, 0);
|
||||
|
||||
if (!animate)
|
||||
{
|
||||
ConfigureNavigationIndicatorAnimation(indicator);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Github http client config ----
|
||||
@@ -582,7 +746,7 @@ public partial class MainWindow : Window
|
||||
SetActivePage(1);
|
||||
}
|
||||
|
||||
if (_activePageIndex != 0)
|
||||
if (_activePageIndex != 0 || _isGameSettingsOpen)
|
||||
{
|
||||
_previousPadButtons = pad.Buttons;
|
||||
return;
|
||||
@@ -602,6 +766,23 @@ public partial class MainWindow : Window
|
||||
MoveSelection(1);
|
||||
}
|
||||
|
||||
if (_isLibraryGridLayout)
|
||||
{
|
||||
var up = (pad.Buttons & HostGamepadButtons.Up) != 0 || pad.LeftY < 64;
|
||||
var down = (pad.Buttons & HostGamepadButtons.Down) != 0 || pad.LeftY > 192;
|
||||
var rowStep = LibraryRowStep();
|
||||
|
||||
if (ShouldNavigate(up, ref _navUpNextAt, now))
|
||||
{
|
||||
MoveSelection(-rowStep);
|
||||
}
|
||||
|
||||
if (ShouldNavigate(down, ref _navDownNextAt, now))
|
||||
{
|
||||
MoveSelection(rowStep);
|
||||
}
|
||||
}
|
||||
|
||||
var pressed = pad.Buttons & ~_previousPadButtons;
|
||||
if ((pressed & HostGamepadButtons.Cross) != 0)
|
||||
{
|
||||
@@ -638,6 +819,29 @@ public partial class MainWindow : Window
|
||||
return false;
|
||||
}
|
||||
|
||||
private int LibraryRowStep()
|
||||
{
|
||||
if (GameList.ContainerFromIndex(0) is not { } first)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
var top = first.Bounds.Top;
|
||||
var columns = 1;
|
||||
for (var index = 1; index < _libraryTiles.Count; index++)
|
||||
{
|
||||
if (GameList.ContainerFromIndex(index) is not { } container ||
|
||||
Math.Abs(container.Bounds.Top - top) > 0.5)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
columns++;
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
private void MoveSelection(int delta)
|
||||
{
|
||||
var index = GameList.SelectedIndex < 0
|
||||
@@ -673,6 +877,8 @@ public partial class MainWindow : Window
|
||||
{
|
||||
_ = CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
SeedLibraryFromCache();
|
||||
await RescanLibraryAsync();
|
||||
}
|
||||
|
||||
@@ -709,6 +915,7 @@ public partial class MainWindow : Window
|
||||
RefreshHostRefreshRates(_settings.RefreshRate);
|
||||
RefreshUpdateText();
|
||||
UpdateEmptyStateTexts();
|
||||
UpdateLibraryLayoutButton();
|
||||
UpdateRunButtons();
|
||||
}
|
||||
|
||||
@@ -799,6 +1006,13 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnKeyDown(object sender, KeyEventArgs args)
|
||||
{
|
||||
if (args.Key == Key.Escape && _isGameSettingsOpen)
|
||||
{
|
||||
CloseGameSettings();
|
||||
args.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Key == Key.F11 && !_isRunning)
|
||||
{
|
||||
WindowState = WindowState == WindowState.FullScreen
|
||||
@@ -816,22 +1030,44 @@ public partial class MainWindow : Window
|
||||
// still needs a preview hook for its own shortcuts.
|
||||
}
|
||||
|
||||
private void OnWindowClosing()
|
||||
private void BeginWindowClosing()
|
||||
{
|
||||
if (_isClosing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isClosing = true;
|
||||
Interlocked.Increment(ref _libraryScanGeneration);
|
||||
Interlocked.Increment(ref _detailLoadGeneration);
|
||||
_libraryWatcher.Dispose();
|
||||
_settings.Save();
|
||||
_consoleFlushTimer.Stop();
|
||||
_gamepadTimer.Stop();
|
||||
SdlLauncherGamepad.Shutdown();
|
||||
_sndPreview.Stop();
|
||||
_discord?.Dispose();
|
||||
_consoleWindow?.Close();
|
||||
_emulator?.Dispose();
|
||||
_consoleMirror?.Dispose();
|
||||
DropFileLog();
|
||||
}
|
||||
|
||||
private void CompleteWindowClosing()
|
||||
{
|
||||
RunShutdownStep("library watcher", _libraryWatcher.Dispose);
|
||||
RunShutdownStep("settings", _settings.Save);
|
||||
RunShutdownStep("SDL gamepad", SdlLauncherGamepad.Shutdown);
|
||||
RunShutdownStep("title music", _sndPreview.Stop);
|
||||
RunShutdownStep("Discord Rich Presence", () => _discord?.Dispose());
|
||||
RunShutdownStep("console window", () => _consoleWindow?.Close());
|
||||
RunShutdownStep("emulator process", () => _emulator?.Dispose());
|
||||
RunShutdownStep("console mirror", () => _consoleMirror?.Dispose());
|
||||
RunShutdownStep("file log", DropFileLog);
|
||||
}
|
||||
|
||||
private static void RunShutdownStep(string component, Action action)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Failed to clean up {component}: {exception}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
@@ -868,14 +1104,17 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
var isMaximized = WindowState == WindowState.Maximized;
|
||||
glyph.Text = isMaximized ? "❐" : "□";
|
||||
ToolTip.SetTip(button, isMaximized ? "Restore" : "Maximize");
|
||||
AutomationProperties.SetName(
|
||||
button,
|
||||
isMaximized ? "Restore window" : "Maximize window");
|
||||
var state = GetMaximizeButtonState(WindowState);
|
||||
glyph.Text = state.Glyph;
|
||||
ToolTip.SetTip(button, state.ToolTip);
|
||||
AutomationProperties.SetName(button, state.AutomationName);
|
||||
}
|
||||
|
||||
internal static WindowMaximizeButtonState GetMaximizeButtonState(WindowState windowState) =>
|
||||
windowState == WindowState.Maximized
|
||||
? new("filter_none", "Restore", "Restore window")
|
||||
: new("crop_square", "Maximize", "Maximize window");
|
||||
|
||||
private void UpdateWindowChromeState()
|
||||
{
|
||||
UpdateMaximizeButton();
|
||||
@@ -886,11 +1125,6 @@ public partial class MainWindow : Window
|
||||
titleBar.IsVisible = !isFullscreen;
|
||||
}
|
||||
|
||||
if (StatusBar is { } statusBar)
|
||||
{
|
||||
statusBar.IsVisible = !isFullscreen;
|
||||
}
|
||||
|
||||
if (ResizeHandles is { } handles)
|
||||
{
|
||||
handles.IsVisible = CanResize && WindowState == WindowState.Normal;
|
||||
@@ -966,6 +1200,7 @@ public partial class MainWindow : Window
|
||||
LogToFileToggle.IsChecked = _settings.LogToFile;
|
||||
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
|
||||
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
|
||||
SetLibraryLayout(string.Equals(_settings.LibraryLayout, "Grid", StringComparison.OrdinalIgnoreCase));
|
||||
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
|
||||
AutoUpdateToggle.IsChecked = _settings.CheckForUpdatesOnStartup;
|
||||
EnvBthidToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_BTHID_UNAVAILABLE");
|
||||
@@ -978,6 +1213,8 @@ public partial class MainWindow : Window
|
||||
EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP");
|
||||
EnvGuestImageCpuSyncToggle.IsChecked =
|
||||
_settings.EnvironmentToggles.Contains("SHARPEMU_GUEST_IMAGE_CPU_SYNC");
|
||||
EnvRenderDocToggle.IsChecked =
|
||||
_settings.EnvironmentToggles.Contains("SHARPEMU_RENDERDOC");
|
||||
DefaultProfileBox.Text = _settings.DefaultProfile;
|
||||
WindowModeBox.SelectedIndex = ChoiceIndex(_settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
||||
LoadHostDisplayOptions();
|
||||
@@ -1278,9 +1515,6 @@ public partial class MainWindow : Window
|
||||
? Path.GetFullPath(found)
|
||||
: null;
|
||||
|
||||
EmulatorPathText.Text = _emulatorExePath is not null
|
||||
? Localization.Instance.Format("Status.EmulatorPath", _emulatorExePath)
|
||||
: Localization.Instance.Get("Status.EmulatorNotFound");
|
||||
}
|
||||
|
||||
// ---- Game library ----
|
||||
@@ -1346,6 +1580,31 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paints the previous scan's result before the real scan starts. The
|
||||
/// scan that follows reconciles over this, so the cache only ever
|
||||
/// shortens the blank period; it never decides what the library holds.
|
||||
/// </summary>
|
||||
private void SeedLibraryFromCache()
|
||||
{
|
||||
Dispatcher.UIThread.VerifyAccess();
|
||||
|
||||
if (_allGames.Count != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cached = GameLibraryCache.Load(_settings.GameFolders.ToArray());
|
||||
if (cached.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_allGames.AddRange(cached);
|
||||
RefreshVisibleGames(new HashSet<GameEntry>(cached));
|
||||
LoadGameDetailsInBackground(cached, cached);
|
||||
}
|
||||
|
||||
private async Task RescanLibraryAsync(bool showProgress = true)
|
||||
{
|
||||
Dispatcher.UIThread.VerifyAccess();
|
||||
@@ -1355,11 +1614,6 @@ public partial class MainWindow : Window
|
||||
var excluded = new HashSet<string>(_settings.ExcludedGames, GameLibraryPath.Comparer);
|
||||
_libraryWatcher.Watch(folders);
|
||||
var showLoadingState = showProgress && _allGames.Count == 0;
|
||||
if (showProgress)
|
||||
{
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
|
||||
}
|
||||
|
||||
if (showLoadingState)
|
||||
{
|
||||
EmptyState.IsVisible = false;
|
||||
@@ -1380,12 +1634,7 @@ public partial class MainWindow : Window
|
||||
LoadingState.IsVisible = false;
|
||||
LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games);
|
||||
UpdateDiscordPresence();
|
||||
if (showProgress)
|
||||
{
|
||||
StatusBarRight.Text = folders.Length == 0
|
||||
? Localization.Instance.Get("Status.AddFolderPrompt")
|
||||
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
|
||||
}
|
||||
GameLibraryCache.Save(folders, reconciliation.Games);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1782,24 +2031,6 @@ public partial class MainWindow : Window
|
||||
CtxGameSettings.IsEnabled = !string.IsNullOrWhiteSpace(game.TitleId);
|
||||
}
|
||||
|
||||
private void OpenSelectedGameSettings()
|
||||
{
|
||||
if (GameList.SelectedItem is not GameEntry game)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(game.TitleId))
|
||||
{
|
||||
AppendConsoleLine(
|
||||
"[GUI][WARN] Per-game settings require a title ID, which this game does not have.",
|
||||
WarningLineBrush);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = new PerGameSettingsDialog(game.TitleId, game.Name, _settings).ShowDialog(this);
|
||||
}
|
||||
|
||||
private void OpenSelectedGameFolder()
|
||||
{
|
||||
if (GameList.SelectedItem is not GameEntry game)
|
||||
@@ -1830,12 +2061,13 @@ public partial class MainWindow : Window
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.CouldNotOpenFolder", ex.Message);
|
||||
AppendConsoleLine(
|
||||
Localization.Instance.Format("Status.CouldNotOpenFolder", ex.Message),
|
||||
WarningLineBrush);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Copies <paramref name="text"/> and reports it via <paramref name="whatKey"/>, e.g. "Clipboard.Path".</summary>
|
||||
private async Task CopyToClipboardAsync(string? text, string whatKey)
|
||||
private async Task CopyToClipboardAsync(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || Clipboard is null)
|
||||
{
|
||||
@@ -1843,7 +2075,6 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
await Clipboard.SetTextAsync(text);
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.CopiedToClipboard", Localization.Instance.Get(whatKey));
|
||||
}
|
||||
|
||||
private void RemoveSelectedFromLibrary()
|
||||
@@ -1863,7 +2094,6 @@ public partial class MainWindow : Window
|
||||
string.Equals(g.Path, game.Path, GameLibraryPath.Comparison));
|
||||
GameList.SelectedItem = null;
|
||||
RefreshVisibleGames();
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name);
|
||||
}
|
||||
|
||||
private void RefreshVisibleGames(IReadOnlySet<GameEntry>? backgroundsChanged = null)
|
||||
@@ -2173,7 +2403,6 @@ public partial class MainWindow : Window
|
||||
_runningGameName = displayName;
|
||||
_runningGameTitleId = resolvedTitleId;
|
||||
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
|
||||
UpdateRunButtons();
|
||||
UpdateDiscordPresence();
|
||||
|
||||
@@ -2226,7 +2455,6 @@ public partial class MainWindow : Window
|
||||
_emulator.Stop();
|
||||
_runningGameName = null;
|
||||
_runningGameTitleId = null;
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
|
||||
UpdateDiscordPresence();
|
||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
|
||||
@@ -2293,7 +2521,6 @@ public partial class MainWindow : Window
|
||||
brush);
|
||||
CloseFileLogSoon();
|
||||
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
|
||||
_runningGameName = null;
|
||||
_runningGameTitleId = null;
|
||||
UpdateRunButtons();
|
||||
@@ -2446,6 +2673,9 @@ public partial class MainWindow : Window
|
||||
LaunchButton.IsEnabled = GameList.SelectedItem is GameEntry;
|
||||
}
|
||||
|
||||
GameSettingsButton.IsEnabled =
|
||||
GameList.SelectedItem is GameEntry game &&
|
||||
!string.IsNullOrWhiteSpace(game.TitleId);
|
||||
OpenFileButton.IsEnabled = !_isRunning;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,74 @@ public sealed class PerGameSettings
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes values that already match the global configuration so the
|
||||
/// remaining object contains only effective per-game overrides.
|
||||
/// </summary>
|
||||
internal void RemoveInheritedValues(GuiSettings global)
|
||||
{
|
||||
if (string.Equals(LogLevel, global.LogLevel, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LogLevel = null;
|
||||
}
|
||||
|
||||
if (ImportTraceLimit == global.ImportTraceLimit)
|
||||
{
|
||||
ImportTraceLimit = null;
|
||||
}
|
||||
|
||||
if (StrictDynlibResolution == global.StrictDynlibResolution)
|
||||
{
|
||||
StrictDynlibResolution = null;
|
||||
}
|
||||
|
||||
if (LogToFile == global.LogToFile)
|
||||
{
|
||||
LogToFile = null;
|
||||
}
|
||||
|
||||
if (string.Equals(WindowMode, global.WindowMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WindowMode = null;
|
||||
}
|
||||
|
||||
if (string.Equals(Resolution, global.Resolution, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Resolution = null;
|
||||
}
|
||||
|
||||
if (DisplayIndex == global.DisplayIndex)
|
||||
{
|
||||
DisplayIndex = null;
|
||||
}
|
||||
|
||||
if (RefreshRate == global.RefreshRate)
|
||||
{
|
||||
RefreshRate = null;
|
||||
}
|
||||
|
||||
if (string.Equals(ScalingMode, global.ScalingMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ScalingMode = null;
|
||||
}
|
||||
|
||||
if (VSync == global.VSync)
|
||||
{
|
||||
VSync = null;
|
||||
}
|
||||
|
||||
if (string.Equals(HdrMode, global.HdrMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HdrMode = null;
|
||||
}
|
||||
|
||||
if (EnvironmentToggles is { } environmentToggles &&
|
||||
EnvironmentEntriesEqual(environmentToggles, global.EnvironmentToggles))
|
||||
{
|
||||
EnvironmentToggles = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(string titleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(titleId))
|
||||
@@ -130,6 +198,35 @@ public sealed class PerGameSettings
|
||||
|
||||
return trimmed.Length == 0 ? "UNKNOWN" : trimmed;
|
||||
}
|
||||
|
||||
private static bool EnvironmentEntriesEqual(
|
||||
IEnumerable<string> left,
|
||||
IEnumerable<string> right) =>
|
||||
NormalizeEnvironmentEntries(left).SetEquals(NormalizeEnvironmentEntries(right));
|
||||
|
||||
private static HashSet<string> NormalizeEnvironmentEntries(IEnumerable<string> entries)
|
||||
{
|
||||
var normalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var parts = entry.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0 || string.IsNullOrWhiteSpace(parts[0]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parts.Length == 2 && parts[1] == "0")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(parts.Length == 2 && parts[1] != "1"
|
||||
? $"{parts[0]}={parts[1]}"
|
||||
: parts[0]);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record EffectiveLaunchSettings(
|
||||
|
||||
@@ -1,426 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
public sealed class PerGameSettingsDialog : Window
|
||||
{
|
||||
private static readonly string[] LogLevels =
|
||||
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
|
||||
private static readonly string[] WindowModes = { "Windowed", "Borderless", "Exclusive" };
|
||||
private static readonly string[] ScalingModes = { "Fit", "Cover", "Stretch", "Integer" };
|
||||
private static readonly string[] HdrModes = { "Auto", "On", "Off" };
|
||||
|
||||
private static readonly string[] EnvToggles =
|
||||
{
|
||||
"SHARPEMU_BTHID_UNAVAILABLE",
|
||||
"SHARPEMU_DISABLE_IMPORT_LOOP_GUARD",
|
||||
"SHARPEMU_WRITABLE_APP0",
|
||||
"SHARPEMU_VK_VALIDATION",
|
||||
"SHARPEMU_DUMP_SPIRV",
|
||||
"SHARPEMU_LOG_DIRECT_MEMORY",
|
||||
"SHARPEMU_LOG_IO",
|
||||
"SHARPEMU_LOG_NP",
|
||||
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
|
||||
};
|
||||
|
||||
private readonly string _titleId;
|
||||
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
|
||||
private bool _updatingHostDisplayOptions;
|
||||
|
||||
private readonly SettingRow _logLevelRow;
|
||||
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
|
||||
|
||||
private readonly SettingRow _traceRow;
|
||||
private readonly NumericUpDown _trace = new()
|
||||
{
|
||||
Minimum = 0, Maximum = 4096, Increment = 16, Width = 160, FormatString = "0",
|
||||
};
|
||||
|
||||
private readonly SettingRow _strictRow;
|
||||
private readonly ToggleSwitch _strict = new();
|
||||
|
||||
private readonly SettingRow _logToFileRow;
|
||||
private readonly ToggleSwitch _logToFile = new();
|
||||
|
||||
private readonly SettingRow _windowModeRow;
|
||||
private readonly ComboBox _windowMode = new() { ItemsSource = WindowModes, Width = 160 };
|
||||
|
||||
private readonly SettingRow _resolutionRow;
|
||||
private readonly ComboBox _resolution = new() { Width = 160 };
|
||||
|
||||
private readonly SettingRow _displayIndexRow;
|
||||
private readonly ComboBox _displayIndex = new() { Width = 240 };
|
||||
|
||||
private readonly SettingRow _refreshRateRow;
|
||||
private readonly ComboBox _refreshRate = new() { Width = 160 };
|
||||
|
||||
private readonly SettingRow _scalingModeRow;
|
||||
private readonly ComboBox _scalingMode = new() { ItemsSource = ScalingModes, Width = 160 };
|
||||
|
||||
private readonly SettingRow _vsyncRow;
|
||||
private readonly ToggleSwitch _vsync = new();
|
||||
|
||||
private readonly SettingRow _hdrModeRow;
|
||||
private readonly ComboBox _hdrMode = new() { ItemsSource = HdrModes, Width = 160 };
|
||||
|
||||
private readonly SettingRow _envRow;
|
||||
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
|
||||
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
|
||||
|
||||
public PerGameSettingsDialog(string titleId, string displayName, GuiSettings global)
|
||||
{
|
||||
_titleId = titleId;
|
||||
var loc = Localization.Instance;
|
||||
|
||||
Title = loc.Format("PerGame.Title", displayName, titleId);
|
||||
Width = 520;
|
||||
MaxHeight = 720;
|
||||
SizeToContent = SizeToContent.Height;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
CanResize = false;
|
||||
|
||||
Background = new SolidColorBrush(Color.Parse("#0D1017"));
|
||||
|
||||
_strict.OnContent = _logToFile.OnContent = _vsync.OnContent = loc.Get("Common.On");
|
||||
_strict.OffContent = _logToFile.OffContent = _vsync.OffContent = loc.Get("Common.Off");
|
||||
|
||||
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
|
||||
_traceRow = Row(loc.Get("Options.TraceImports.Label"), loc.Get("Options.TraceImports.Desc"), _trace);
|
||||
_strictRow = Row(loc.Get("Options.Strict.Label"), loc.Get("Options.Strict.Desc"), _strict);
|
||||
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
|
||||
_windowModeRow = Row(loc.Get("Options.WindowMode.Label"), loc.Get("Options.WindowMode.Desc"), _windowMode);
|
||||
_resolutionRow = Row(loc.Get("Options.Resolution.Label"), loc.Get("Options.Resolution.Desc"), _resolution);
|
||||
_displayIndexRow = Row(loc.Get("Options.Display.Label"), loc.Get("Options.Display.Desc"), _displayIndex);
|
||||
_refreshRateRow = Row(loc.Get("Options.RefreshRate.Label"), loc.Get("Options.RefreshRate.Desc"), _refreshRate);
|
||||
_scalingModeRow = Row(loc.Get("Options.Scaling.Label"), loc.Get("Options.Scaling.Desc"), _scalingMode);
|
||||
_vsyncRow = Row(loc.Get("Options.VSync.Label"), loc.Get("Options.VSync.Desc"), _vsync);
|
||||
_hdrModeRow = Row(loc.Get("Options.Hdr.Label"), loc.Get("Options.Hdr.Desc"), _hdrMode);
|
||||
_envRow = new SettingRow
|
||||
{
|
||||
Label = loc.Get("PerGame.EnvToggles.Label"),
|
||||
Description = loc.Get("PerGame.EnvToggles.Desc"),
|
||||
ShowOverride = true,
|
||||
};
|
||||
|
||||
foreach (var name in EnvToggles)
|
||||
{
|
||||
var box = new ToggleSwitch { OnContent = name, OffContent = name };
|
||||
_envBoxes.Add((name, box));
|
||||
_envList.Children.Add(box);
|
||||
}
|
||||
|
||||
var general = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
|
||||
general.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
|
||||
general.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
|
||||
general.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
|
||||
|
||||
var graphics = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
|
||||
graphics.Children.Add(Card(
|
||||
loc.Get("Options.Section.Display"),
|
||||
_windowModeRow,
|
||||
_resolutionRow,
|
||||
_displayIndexRow,
|
||||
_refreshRateRow,
|
||||
_scalingModeRow,
|
||||
_vsyncRow,
|
||||
_hdrModeRow));
|
||||
|
||||
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = loc.Get("PerGame.InheritNote"),
|
||||
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
|
||||
FontSize = 12,
|
||||
});
|
||||
content.Children.Add(new TabControl
|
||||
{
|
||||
ItemsSource = new[]
|
||||
{
|
||||
new TabItem { Header = loc.Get("PerGame.Tab.General"), Content = general },
|
||||
new TabItem { Header = loc.Get("PerGame.Tab.Graphics"), Content = graphics },
|
||||
},
|
||||
});
|
||||
|
||||
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
|
||||
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
|
||||
save.Click += (_, _) => { Persist(); Close(); };
|
||||
cancel.Click += (_, _) => Close();
|
||||
|
||||
var buttonBar = new Border
|
||||
{
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#8B94A7")) { Opacity = 0.25 },
|
||||
BorderThickness = new Thickness(0, 1, 0, 0),
|
||||
Padding = new(16),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Children = { cancel, save },
|
||||
},
|
||||
};
|
||||
|
||||
var root = new Grid { RowDefinitions = new RowDefinitions("*,Auto") };
|
||||
var scroller = new ScrollViewer { Content = content };
|
||||
Grid.SetRow(scroller, 0);
|
||||
Grid.SetRow(buttonBar, 1);
|
||||
root.Children.Add(scroller);
|
||||
root.Children.Add(buttonBar);
|
||||
Content = root;
|
||||
|
||||
_displayIndex.SelectionChanged += (_, _) => OnHostDisplayChanged();
|
||||
_resolution.SelectionChanged += (_, _) => OnHostResolutionChanged();
|
||||
LoadValues(global);
|
||||
_envRow.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == SettingRow.IsOverriddenProperty)
|
||||
{
|
||||
_envList.IsEnabled = _envRow.IsOverridden;
|
||||
}
|
||||
};
|
||||
_envList.IsEnabled = _envRow.IsOverridden;
|
||||
}
|
||||
|
||||
private static SettingRow Row(string label, string description, Control value) => new()
|
||||
{
|
||||
Label = label,
|
||||
Description = description,
|
||||
ShowOverride = true,
|
||||
Content = value,
|
||||
};
|
||||
|
||||
private static Border Card(string title, params Control[] rows)
|
||||
{
|
||||
var stack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 14 };
|
||||
stack.Children.Add(new TextBlock { Text = title, Classes = { "sectionTitle" } });
|
||||
foreach (var row in rows)
|
||||
{
|
||||
stack.Children.Add(row);
|
||||
}
|
||||
|
||||
var card = new Border { Child = stack };
|
||||
card.Classes.Add("card");
|
||||
return card;
|
||||
}
|
||||
|
||||
private void LoadValues(GuiSettings global)
|
||||
{
|
||||
var existing = PerGameSettings.Load(_titleId);
|
||||
var displayIndex = Math.Max(0, existing?.DisplayIndex ?? global.DisplayIndex);
|
||||
var resolution = existing?.Resolution ?? global.Resolution;
|
||||
var refreshRate = Math.Clamp(existing?.RefreshRate ?? global.RefreshRate, 0, 1000);
|
||||
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
_hostDisplays = HostDisplayOptions.BuildDisplays(HostDisplayCatalog.Query(), displayIndex);
|
||||
_displayIndex.ItemsSource = _hostDisplays;
|
||||
var display = HostDisplayOptions.SelectDisplay(_hostDisplays, displayIndex);
|
||||
_displayIndex.SelectedItem = display;
|
||||
PopulateHostModes(display, resolution, refreshRate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = false;
|
||||
}
|
||||
|
||||
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
|
||||
_trace.Value = global.ImportTraceLimit;
|
||||
_strict.IsChecked = global.StrictDynlibResolution;
|
||||
_logToFile.IsChecked = global.LogToFile;
|
||||
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, global.WindowMode, "Windowed");
|
||||
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, global.ScalingMode, "Fit");
|
||||
_vsync.IsChecked = global.VSync;
|
||||
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, global.HdrMode, "Auto");
|
||||
foreach (var (name, box) in _envBoxes)
|
||||
{
|
||||
box.IsChecked = IsEnvironmentEnabled(global.EnvironmentToggles, name, defaultValue: false);
|
||||
}
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.LogLevel is { } level && Array.IndexOf(LogLevels, level) >= 0)
|
||||
{
|
||||
_logLevelRow.IsOverridden = true;
|
||||
_logLevel.SelectedItem = level;
|
||||
}
|
||||
|
||||
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
|
||||
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
|
||||
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
|
||||
if (existing.WindowMode is { } windowMode && WindowModes.Contains(windowMode, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_windowModeRow.IsOverridden = true;
|
||||
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, windowMode, "Windowed");
|
||||
}
|
||||
if (existing.Resolution is not null)
|
||||
{
|
||||
_resolutionRow.IsOverridden = true;
|
||||
}
|
||||
if (existing.DisplayIndex is not null)
|
||||
{
|
||||
_displayIndexRow.IsOverridden = true;
|
||||
}
|
||||
if (existing.RefreshRate is not null)
|
||||
{
|
||||
_refreshRateRow.IsOverridden = true;
|
||||
}
|
||||
if (existing.ScalingMode is { } scalingMode && ScalingModes.Contains(scalingMode, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_scalingModeRow.IsOverridden = true;
|
||||
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, scalingMode, "Fit");
|
||||
}
|
||||
if (existing.VSync is { } vsync)
|
||||
{
|
||||
_vsyncRow.IsOverridden = true;
|
||||
_vsync.IsChecked = vsync;
|
||||
}
|
||||
if (existing.HdrMode is { } hdrMode && HdrModes.Contains(hdrMode, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_hdrModeRow.IsOverridden = true;
|
||||
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, hdrMode, "Auto");
|
||||
}
|
||||
if (existing.EnvironmentToggles is { } env)
|
||||
{
|
||||
_envRow.IsOverridden = true;
|
||||
foreach (var (name, box) in _envBoxes)
|
||||
{
|
||||
box.IsChecked = IsEnvironmentEnabled(env, name, defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ChoiceOrDefault(string[] choices, string? value, string fallback) =>
|
||||
choices.FirstOrDefault(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
|
||||
|
||||
private void OnHostDisplayChanged()
|
||||
{
|
||||
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
PopulateHostModes(
|
||||
display,
|
||||
_resolution.SelectedItem as string ?? "1920x1080",
|
||||
SelectedRefreshRate());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHostResolutionChanged()
|
||||
{
|
||||
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedRefreshRate = SelectedRefreshRate();
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateHostModes(
|
||||
HostDisplayOption display,
|
||||
string selectedResolution,
|
||||
int selectedRefreshRate)
|
||||
{
|
||||
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
|
||||
_resolution.ItemsSource = resolutions;
|
||||
_resolution.SelectedItem = resolutions.FirstOrDefault(resolution =>
|
||||
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
|
||||
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||
}
|
||||
|
||||
private void PopulateRefreshRates(
|
||||
HostDisplayOption display,
|
||||
string? resolution,
|
||||
int selectedRefreshRate)
|
||||
{
|
||||
var rates = HostDisplayOptions.BuildRefreshRates(
|
||||
display,
|
||||
resolution,
|
||||
selectedRefreshRate,
|
||||
Localization.Instance.Get("Options.RefreshRate.Automatic"));
|
||||
_refreshRate.ItemsSource = rates;
|
||||
_refreshRate.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
|
||||
}
|
||||
|
||||
private int SelectedRefreshRate() =>
|
||||
_refreshRate.SelectedItem is HostRefreshRateOption refreshRate ? refreshRate.Value : 0;
|
||||
|
||||
private void Persist()
|
||||
{
|
||||
var settings = new PerGameSettings
|
||||
{
|
||||
LogLevel = _logLevelRow.IsOverridden ? _logLevel.SelectedItem as string : null,
|
||||
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
|
||||
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
|
||||
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
|
||||
WindowMode = _windowModeRow.IsOverridden ? _windowMode.SelectedItem as string : null,
|
||||
Resolution = _resolutionRow.IsOverridden ? _resolution.SelectedItem as string : null,
|
||||
DisplayIndex = _displayIndexRow.IsOverridden && _displayIndex.SelectedItem is HostDisplayOption display
|
||||
? display.Index
|
||||
: null,
|
||||
RefreshRate = _refreshRateRow.IsOverridden ? SelectedRefreshRate() : null,
|
||||
ScalingMode = _scalingModeRow.IsOverridden ? _scalingMode.SelectedItem as string : null,
|
||||
VSync = _vsyncRow.IsOverridden ? _vsync.IsChecked == true : null,
|
||||
HdrMode = _hdrModeRow.IsOverridden ? _hdrMode.SelectedItem as string : null,
|
||||
EnvironmentToggles = _envRow.IsOverridden ? BuildEnvironmentEntries() : null,
|
||||
};
|
||||
settings.Save(_titleId);
|
||||
}
|
||||
|
||||
private List<string> BuildEnvironmentEntries()
|
||||
{
|
||||
return _envBoxes
|
||||
.Where(entry => entry.Box.IsChecked == true)
|
||||
.Select(entry => entry.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool IsEnvironmentEnabled(
|
||||
IEnumerable<string> entries,
|
||||
string name,
|
||||
bool defaultValue)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(entry, name + "=1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Presenters;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
@@ -18,17 +16,9 @@ public sealed class SettingRow : ContentControl
|
||||
public static readonly StyledProperty<string?> DescriptionProperty =
|
||||
AvaloniaProperty.Register<SettingRow, string?>(nameof(Description));
|
||||
|
||||
public static readonly StyledProperty<bool> ShowOverrideProperty =
|
||||
AvaloniaProperty.Register<SettingRow, bool>(nameof(ShowOverride));
|
||||
|
||||
public static readonly StyledProperty<bool> IsOverriddenProperty =
|
||||
AvaloniaProperty.Register<SettingRow, bool>(
|
||||
nameof(IsOverridden), defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public static readonly StyledProperty<FontFamily?> LabelFontFamilyProperty =
|
||||
AvaloniaProperty.Register<SettingRow, FontFamily?>(nameof(LabelFontFamily));
|
||||
|
||||
private ContentPresenter? _slot;
|
||||
private TextBlock? _label;
|
||||
|
||||
public string? Label
|
||||
@@ -43,18 +33,6 @@ public sealed class SettingRow : ContentControl
|
||||
set => SetValue(DescriptionProperty, value);
|
||||
}
|
||||
|
||||
public bool ShowOverride
|
||||
{
|
||||
get => GetValue(ShowOverrideProperty);
|
||||
set => SetValue(ShowOverrideProperty, value);
|
||||
}
|
||||
|
||||
public bool IsOverridden
|
||||
{
|
||||
get => GetValue(IsOverriddenProperty);
|
||||
set => SetValue(IsOverriddenProperty, value);
|
||||
}
|
||||
|
||||
public FontFamily? LabelFontFamily
|
||||
{
|
||||
get => GetValue(LabelFontFamilyProperty);
|
||||
@@ -64,20 +42,14 @@ public sealed class SettingRow : ContentControl
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
_slot = e.NameScope.Find<ContentPresenter>("PART_Slot");
|
||||
_label = e.NameScope.Find<TextBlock>("PART_Label");
|
||||
UpdateSlotEnabled();
|
||||
UpdateLabelFont();
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == ShowOverrideProperty || change.Property == IsOverriddenProperty)
|
||||
{
|
||||
UpdateSlotEnabled();
|
||||
}
|
||||
else if (change.Property == LabelFontFamilyProperty)
|
||||
if (change.Property == LabelFontFamilyProperty)
|
||||
{
|
||||
UpdateLabelFont();
|
||||
}
|
||||
@@ -90,12 +62,4 @@ public sealed class SettingRow : ContentControl
|
||||
_label.FontFamily = family;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSlotEnabled()
|
||||
{
|
||||
if (_slot is not null)
|
||||
{
|
||||
_slot.IsEnabled = !ShowOverride || IsOverridden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,23 @@ Shared launcher button variants and page switcher styles.
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.iconGhost">
|
||||
<Setter Property="Width" Value="32" />
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.iconGhost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ToggleButton.ghost">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
|
||||
@@ -19,6 +19,12 @@ Window chrome button sizing and interaction states.
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.windowChromeGlyph">
|
||||
<Setter Property="IsHitTestVisible" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.windowCloseGlyph">
|
||||
<Setter Property="Margin" Value="0,-1,0,1" />
|
||||
</Style>
|
||||
<Style Selector="Button.windowChrome:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Contextual per-game options reveal, actions, and selected-game motion.
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style Selector="TextBlock.gameStatLabel">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="Opacity" Value="0.75" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.gameStatValue">
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Panel.backdropLayer">
|
||||
<Setter Property="RenderTransform" Value="translateY(0px) scale(1)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Panel.carouselHost">
|
||||
<Setter Property="RenderTransform" Value="translateY(0px) scale(1)" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost">
|
||||
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Grid.gameOptionsOverlay">
|
||||
<Setter Property="RenderTransform" Value="translateY(96px)" />
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Panel.backdropLayer.gameOptionsOpen">
|
||||
<Setter Property="RenderTransform" Value="translateY(-92px) scale(1.04)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Panel.carouselHost.gameOptionsOpen">
|
||||
<Setter Property="RenderTransform" Value="translateY(-18px) scale(1)" />
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost.gameOptionsOpen">
|
||||
<Setter Property="RenderTransform" Value="translateY(-28px)" />
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Grid.gameOptionsOverlay.gameOptionsOpen">
|
||||
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.playButton">
|
||||
<Setter Property="MinHeight" Value="54" />
|
||||
<Setter Property="MinWidth" Value="200" />
|
||||
<Setter Property="Padding" Value="34,13" />
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="Foreground" Value="#111111" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.playButton:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.playButton.danger">
|
||||
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.playButton.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource DangerHoverBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsCircle, ToggleButton.optionsCircle">
|
||||
<Setter Property="Width" Value="54" />
|
||||
<Setter Property="Height" Value="54" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="27" />
|
||||
<Setter Property="Background" Value="#12FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsCircle:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||
ToggleButton.optionsCircle:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||
ToggleButton.optionsCircle:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="#24FFFFFF" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsCircle.compact, ToggleButton.optionsCircle.compact">
|
||||
<Setter Property="Width" Value="40" />
|
||||
<Setter Property="Height" Value="40" />
|
||||
<Setter Property="CornerRadius" Value="20" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost">
|
||||
<Setter Property="Spacing" Value="18" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.selectedGameTitle">
|
||||
<Setter Property="FontSize" Value="42" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost.gridLayout">
|
||||
<Setter Property="Spacing" Value="12" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost.gridLayout TextBlock.selectedGameTitle">
|
||||
<Setter Property="FontSize" Value="26" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.selectedDetailsDivider">
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="MinWidth" Value="440" />
|
||||
<Setter Property="Margin" Value="0,0,0,18" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="Background">
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,0%">
|
||||
<GradientStop Offset="0" Color="#00FFFFFF" />
|
||||
<GradientStop Offset="0.06" Color="#2EFFFFFF" />
|
||||
<GradientStop Offset="0.55" Color="#14FFFFFF" />
|
||||
<GradientStop Offset="1" Color="#00FFFFFF" />
|
||||
</LinearGradientBrush>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.playButton">
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
<Setter Property="MinWidth" Value="168" />
|
||||
<Setter Property="Padding" Value="28,10" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.optionsCircle,
|
||||
StackPanel.selectedDetailsHost.gridLayout ToggleButton.optionsCircle">
|
||||
<Setter Property="Width" Value="44" />
|
||||
<Setter Property="Height" Value="44" />
|
||||
<Setter Property="CornerRadius" Value="22" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameOptionsLaunch">
|
||||
<Setter Property="Width" Value="220" />
|
||||
<Setter Property="Height" Value="46" />
|
||||
<Setter Property="Padding" Value="20,0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="#111111" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameOptionsLaunch /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="BoxShadow" Value="0 0 0 0 Transparent" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameOptionsLaunch:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="#EAF3FF" />
|
||||
<Setter Property="Foreground" Value="#111111" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameAction">
|
||||
<Setter Property="Width" Value="250" />
|
||||
<Setter Property="Height" Value="48" />
|
||||
<Setter Property="Padding" Value="16,0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#0FFFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#14FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameAction:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="#1AFFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#28FFFFFF" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameAction.dangerAction">
|
||||
<Setter Property="Foreground" Value="{StaticResource DangerBrush}" />
|
||||
<Setter Property="BorderBrush" Value="#48FF646E" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameAction.dangerAction:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="#1FFF646E" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource DangerBrush}" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -9,8 +9,25 @@ Cover-art library item states and motion.
|
||||
<Style Selector="ListBox.tileGrid">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="4,0,28,0" />
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Hidden" />
|
||||
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
|
||||
<Setter Property="ItemsPanel">
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.tileGrid.gridLayout">
|
||||
<Setter Property="Padding" Value="4,0,14,0" />
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
|
||||
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
|
||||
<Setter Property="ItemsPanel">
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem">
|
||||
<Setter Property="Width" Value="160" />
|
||||
@@ -35,6 +52,25 @@ Cover-art library item states and motion.
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<!-- The title rides along in the item template so both layouts share one
|
||||
template. The rail hides it because the selected game already names
|
||||
itself in the details below the covers. -->
|
||||
<Style Selector="TextBlock.libraryTileName">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LineHeight" Value="16" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid.gridLayout TextBlock.libraryTileName">
|
||||
<Setter Property="IsVisible" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid.gridLayout ListBoxItem">
|
||||
<Setter Property="Height" Value="200" />
|
||||
<Setter Property="Margin" Value="0,6,12,10" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Top" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover,
|
||||
ListBox.tileGrid ListBoxItem:selected">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
|
||||
@@ -1,108 +1,12 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Options page navigation, section transitions and content layout
|
||||
Options page section transitions and content layout
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:SharpEmu.GUI"
|
||||
xmlns:settingsControls="using:SharpEmu.GUI.Controls.Settings">
|
||||
<Style Selector="Border.optionsNavSurface">
|
||||
<Setter Property="Width" Value="244" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="VerticalAlignment" Value="Top" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.optionsNavIndicator">
|
||||
<Setter Property="Height" Value="54" />
|
||||
<Setter Property="Margin" Value="0,3,0,0" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Background" Value="#13FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E6FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="2" />
|
||||
<Setter Property="BoxShadow" Value="0 8 24 0 #24000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav">
|
||||
<Setter Property="Height" Value="61" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="Padding" Value="16,0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="FocusAdorner" Value="{x:Null}" />
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||
Button.optionsNav:focus-visible /template/ ContentPresenter#PART_ContentPresenter,
|
||||
Button.optionsNav.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pressed">
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.optionsNavIcon">
|
||||
<Setter Property="Width" Value="20" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
<Setter Property="Padding" Value="0,0,0,5" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Foreground"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.optionsNavLabel">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="LineHeight" Value="20" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Padding" Value="0,0,0,4" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Foreground"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pointerover TextBlock.optionsNavIcon,
|
||||
Button.optionsNav:focus-visible TextBlock.optionsNavIcon,
|
||||
Button.optionsNav.active TextBlock.optionsNavIcon">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pointerover TextBlock.optionsNavLabel,
|
||||
Button.optionsNav:focus-visible TextBlock.optionsNavLabel,
|
||||
Button.optionsNav.active TextBlock.optionsNavLabel">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.optionsContentSurface">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
@@ -138,6 +42,27 @@ Options page navigation, section transitions and content layout
|
||||
<Setter Property="Padding" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.optionsGroupHeader">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="#8CFFFFFF" />
|
||||
<Setter Property="Margin" Value="2,0,0,10" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.optionsGroupDivider">
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="Margin" Value="0,18,0,18" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="Background">
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,0%">
|
||||
<GradientStop Offset="0" Color="#00FFFFFF" />
|
||||
<GradientStop Offset="0.06" Color="#2EFFFFFF" />
|
||||
<GradientStop Offset="0.55" Color="#14FFFFFF" />
|
||||
<GradientStop Offset="1" Color="#00FFFFFF" />
|
||||
</LinearGradientBrush>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.optionsInfoRow">
|
||||
<Setter Property="MinHeight" Value="70" />
|
||||
<Setter Property="Padding" Value="18,12" />
|
||||
@@ -147,11 +72,6 @@ Options page navigation, section transitions and content layout
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="local|SettingRow.optionRow:focus-within /template/ Border#PART_RowSurface">
|
||||
<Setter Property="Background" Value="#0DFFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#22FFFFFF" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ComboBox.optionValue, settingsControls|SplitNumericUpDown.optionValue, TextBox.optionValue">
|
||||
<Setter Property="Width" Value="190" />
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Shared navigation for global and per-game options
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style Selector="Border.optionsNavSurface">
|
||||
<Setter Property="Width" Value="244" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="VerticalAlignment" Value="Top" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.optionsNavIndicator">
|
||||
<Setter Property="Height" Value="54" />
|
||||
<Setter Property="Margin" Value="0,3,0,0" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Background" Value="#13FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E6FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="2" />
|
||||
<Setter Property="BoxShadow" Value="0 8 24 0 #24000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav">
|
||||
<Setter Property="Height" Value="61" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="Padding" Value="16,0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="FocusAdorner" Value="{x:Null}" />
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||
Button.optionsNav:focus-visible /template/ ContentPresenter#PART_ContentPresenter,
|
||||
Button.optionsNav.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pressed">
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.optionsNavIcon">
|
||||
<Setter Property="Width" Value="20" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
<Setter Property="Padding" Value="0,0,0,5" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Foreground"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.optionsNavLabel">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="LineHeight" Value="20" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Padding" Value="0,0,0,4" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Foreground"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pointerover TextBlock.optionsNavIcon,
|
||||
Button.optionsNav:focus-visible TextBlock.optionsNavIcon,
|
||||
Button.optionsNav.active TextBlock.optionsNavIcon,
|
||||
Button.optionsNav:pointerover TextBlock.optionsNavLabel,
|
||||
Button.optionsNav:focus-visible TextBlock.optionsNavLabel,
|
||||
Button.optionsNav.active TextBlock.optionsNavLabel">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -12,11 +12,22 @@ Control theme for the shared launcher settings row.
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border x:Name="PART_RowSurface"
|
||||
Classes="settingRowSurface"
|
||||
Background="#06FFFFFF"
|
||||
BorderBrush="#0AFFFFFF"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14"
|
||||
Padding="18,12">
|
||||
<Border.Transitions>
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
<BrushTransition Property="BorderBrush"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Border.Transitions>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="3" Margin="0,0,18,0">
|
||||
<TextBlock x:Name="PART_Label"
|
||||
@@ -28,7 +39,7 @@ Control theme for the shared launcher settings row.
|
||||
<TextBlock Text="{TemplateBinding Description}"
|
||||
FontSize="11"
|
||||
FontWeight="Normal"
|
||||
Foreground="{StaticResource MutedBrush}"
|
||||
Foreground="{StaticResource SettingsDescriptionBrush}"
|
||||
LineHeight="16"
|
||||
MaxWidth="690"
|
||||
HorizontalAlignment="Left"
|
||||
@@ -37,20 +48,9 @@ Control theme for the shared launcher settings row.
|
||||
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
|
||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="12"
|
||||
VerticalAlignment="Center">
|
||||
<ToggleSwitch OnContent="Override"
|
||||
OffContent="Override"
|
||||
MinWidth="0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{TemplateBinding ShowOverride}"
|
||||
IsChecked="{Binding IsOverridden, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
|
||||
<ContentPresenter x:Name="PART_Slot"
|
||||
Content="{TemplateBinding Content}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<ContentPresenter Grid.Column="1"
|
||||
Content="{TemplateBinding Content}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
|
||||
@@ -10,25 +10,23 @@ Shared colors and brushes used throughout the launcher.
|
||||
|
||||
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
|
||||
|
||||
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0" Color="#12151F" />
|
||||
<GradientStop Offset="0.55" Color="#0D1017" />
|
||||
<GradientStop Offset="1" Color="#0B0D14" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
|
||||
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
|
||||
<SolidColorBrush x:Key="ElevatedBrush" Color="#1B2230" />
|
||||
<SolidColorBrush x:Key="TextBrush" Color="#E8ECF4" />
|
||||
<SolidColorBrush x:Key="MutedBrush" Color="#8B94A7" />
|
||||
<SolidColorBrush x:Key="FaintBrush" Color="#5A6478" />
|
||||
<SolidColorBrush x:Key="BgBrush" Color="#0C0C0C" />
|
||||
<SolidColorBrush x:Key="ChromeBrush" Color="#070707" />
|
||||
<SolidColorBrush x:Key="CardBrush" Color="#161616" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#12FFFFFF" />
|
||||
<SolidColorBrush x:Key="ElevatedBrush" Color="#1E1E1E" />
|
||||
<SolidColorBrush x:Key="GameOptionsMenuBrush" Color="#1B1B1B" />
|
||||
<SolidColorBrush x:Key="TextBrush" Color="#F7F8FB" />
|
||||
<SolidColorBrush x:Key="SecondaryTextBrush" Color="#C0C5CF" />
|
||||
<SolidColorBrush x:Key="MutedBrush" Color="#777F8E" />
|
||||
<SolidColorBrush x:Key="SettingsDescriptionBrush" Color="#8A8A8A" />
|
||||
<SolidColorBrush x:Key="FaintBrush" Color="#4D5461" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#7C5CFC" />
|
||||
<SolidColorBrush x:Key="AccentHoverBrush" Color="#8F73FF" />
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
||||
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
|
||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
||||
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#FF646E" />
|
||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#FF858D" />
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#66F2A3" />
|
||||
<SolidColorBrush x:Key="InfoBrush" Color="#64A7FF" />
|
||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#0BFFFFFF" />
|
||||
<SolidColorBrush x:Key="TileSelectedBrush" Color="#12FFFFFF" />
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
internal readonly record struct WindowMaximizeButtonState(
|
||||
string Glyph,
|
||||
string ToolTip,
|
||||
string AutomationName);
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Bink;
|
||||
using SharpEmu.Libs.Media;
|
||||
using SharpEmu.Libs.Gpu;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
@@ -95,8 +95,11 @@ public static partial class AgcExports
|
||||
private const uint SpiShaderPgmRsrc1Hs = 0x10A;
|
||||
private const uint SpiShaderPgmLoLs = 0x148;
|
||||
private const uint SpiShaderPgmHiLs = 0x149;
|
||||
private const uint SpiShaderPgmLoGs = 0x8A;
|
||||
private const uint SpiShaderPgmHiGs = 0x8B;
|
||||
// Not 0x8A/0x8B - those are SPI_SHADER_PGM_RSRC1/RSRC2_GS, and reading them
|
||||
// as an address yields a 58-bit value (observed live: 0x30004622C008300).
|
||||
private const uint SpiShaderPgmLoGs = 0x88;
|
||||
private const uint SpiShaderPgmHiGs = 0x89;
|
||||
private const uint SpiShaderPgmRsrc1Gs = 0x8A;
|
||||
private const uint SpiShaderPgmChksumGs = 0x80;
|
||||
private const uint SpiPsInputEna = 0x1B3;
|
||||
private const uint SpiPsInputAddr = 0x1B4;
|
||||
@@ -139,9 +142,15 @@ public static partial class AgcExports
|
||||
private const uint CbColor0Base = 0x318;
|
||||
private const uint CbColorRegisterStride = 15;
|
||||
private const uint CbColor0Info = 0x31C;
|
||||
private const uint CbColor0ClearWord0 = 0x323;
|
||||
private const uint CbColor0ClearWord1 = 0x324;
|
||||
private const uint CbColor0BaseExt = 0x390;
|
||||
private const uint CbColor0Attrib2 = 0x3B0;
|
||||
private const uint CbColor0Attrib3 = 0x3B8;
|
||||
// CB_COLORn_INFO.DCC_ENABLE (gc_10_1_0_sh_mask.h). On GFX10 the legacy
|
||||
// FAST_CLEAR and COMPRESSION bits stay clear because DCC, not CMASK,
|
||||
// carries the compression.
|
||||
private const uint CbColorInfoDccEnableMask = 1u << 28;
|
||||
private const uint CbBlend0Control = 0x1E0;
|
||||
private const uint PaScModeCntl0 = 0x292;
|
||||
// GFX10 DB context registers (register byte address minus 0x28000, / 4).
|
||||
@@ -330,6 +339,11 @@ public static partial class AgcExports
|
||||
private static long _labelProducerSequence;
|
||||
private static readonly object _labelProducerGate = new();
|
||||
private static readonly List<LabelProducerTrace> _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;
|
||||
@@ -496,7 +510,8 @@ public static partial class AgcExports
|
||||
float ClearRed = 0f,
|
||||
float ClearGreen = 0f,
|
||||
float ClearBlue = 0f,
|
||||
float ClearAlpha = 1f);
|
||||
float ClearAlpha = 1f,
|
||||
bool IsDccFastClear = false);
|
||||
|
||||
private sealed record TranslatedImageBinding(
|
||||
TextureDescriptor Descriptor,
|
||||
@@ -604,10 +619,20 @@ public static partial class AgcExports
|
||||
public uint DrawIndexOffset { get; set; }
|
||||
public bool PredicateSkip { get; set; }
|
||||
public string QueueName { get; set; } = "graphics";
|
||||
// Ident this queue's end-of-pipe completion interrupt is published under.
|
||||
// The graphics queue keeps 0; a compute queue takes the owner handle it
|
||||
// was submitted with, which is the same value the guest registers through
|
||||
// sceAgcDriverAddEqEvent.
|
||||
public ulong CompletionEventId { get; set; }
|
||||
public ulong ActiveSubmissionId { get; set; }
|
||||
public Queue<PendingSubmission> 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; }
|
||||
@@ -2159,6 +2184,18 @@ public static partial class AgcExports
|
||||
return (int)ctx[CpuRegister.Rax];
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "r98I08t+LOg",
|
||||
ExportName = "sceAgcDcbDrawIndexIndirectMultiGetSize",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int DcbDrawIndexIndirectMultiGetSize(CpuContext ctx)
|
||||
{
|
||||
// Eight, matching the packet DcbDrawIndexIndirectMulti emits.
|
||||
ctx[CpuRegister.Rax] = 8u * sizeof(uint);
|
||||
return (int)ctx[CpuRegister.Rax];
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "rUuVjyR+Rd4",
|
||||
ExportName = "sceAgcDcbGetLodStatsGetSize",
|
||||
@@ -3250,6 +3287,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 +3300,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 +3349,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());
|
||||
@@ -3330,6 +3365,7 @@ public static partial class AgcExports
|
||||
}
|
||||
|
||||
queueState.QueueName = $"acb.compute[{ownerHandle}]";
|
||||
queueState.CompletionEventId = ownerHandle;
|
||||
EnqueueSubmittedDcb(
|
||||
ctx,
|
||||
gpuState,
|
||||
@@ -3520,33 +3556,47 @@ public static partial class AgcExports
|
||||
SubmittedDcbState state,
|
||||
ulong submissionId)
|
||||
{
|
||||
if (!ReferenceEquals(state, gpuState.Graphics) ||
|
||||
state.CompletionEventNotifiedSubmissionId == submissionId)
|
||||
if (state.CompletionEventNotifiedSubmissionId == submissionId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
state.CompletionEventNotifiedSubmissionId = submissionId;
|
||||
// Hardware raises an end-of-pipe interrupt for every submission on every
|
||||
// queue, so this is unconditional. It stays safe for titles that do not
|
||||
// want it because delivery is registration-gated: TriggerRegisteredEvents
|
||||
// only queues onto equeues that registered this exact ident through
|
||||
// sceAgcDriverAddEqEvent. Graphics keeps ident 0; a compute queue uses the
|
||||
// owner handle it was submitted under.
|
||||
var completionEventId = state.CompletionEventId;
|
||||
var isGraphics = ReferenceEquals(state, gpuState.Graphics);
|
||||
var queueName = state.QueueName;
|
||||
void TriggerCompletionEvents()
|
||||
{
|
||||
var triggered = KernelEventQueueCompatExports.TriggerRegisteredEvents(
|
||||
ident: 0,
|
||||
completionEventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
data: 0);
|
||||
if (_compatibilitySubmitCompletionEvent)
|
||||
completionEventId);
|
||||
// The broad fan-out wakes graphics registrations whose ident never
|
||||
// matches anything the driver publishes. That is a compatibility
|
||||
// guess rather than hardware behavior, so it stays opt-in and stays
|
||||
// on the graphics queue where it was measured.
|
||||
if (isGraphics && _compatibilitySubmitCompletionEvent)
|
||||
{
|
||||
triggered += KernelEventQueueCompatExports.TriggerRegisteredEventsDistinct(
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
}
|
||||
TraceAgc(
|
||||
$"agc.driver_submit_dcb completion submission={submissionId} " +
|
||||
$"queues={triggered}");
|
||||
$"agc.completion_event queue={queueName} submission={submissionId} " +
|
||||
$"event=0x{completionEventId:X} queues={triggered}");
|
||||
}
|
||||
|
||||
// A DCB is complete only after its translated Vulkan work and ordered
|
||||
// guest-memory writes have finished. Put the notification on that same
|
||||
// logical graphics queue instead of approximating completion with a
|
||||
// timer, which can wake Unity while its upload data is still stale.
|
||||
// A submission is complete only after its translated Vulkan work and
|
||||
// ordered guest-memory writes have finished. Put the notification on that
|
||||
// same logical queue instead of approximating completion with a timer or a
|
||||
// ThreadPool hop, either of which can only make the interrupt late and
|
||||
// reorder it against registration changes (and can wake Unity while its
|
||||
// upload data is still stale).
|
||||
if (GuestGpu.Current.SubmitOrderedGuestAction(
|
||||
TriggerCompletionEvents,
|
||||
$"agc submit completion {submissionId}") == 0)
|
||||
@@ -3574,33 +3624,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 +3816,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)
|
||||
@@ -4398,9 +4513,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);
|
||||
@@ -4421,6 +4548,36 @@ public static partial class AgcExports
|
||||
return producer;
|
||||
}
|
||||
|
||||
internal static int CompactCompletedEntries<T>(
|
||||
List<T> entries,
|
||||
Func<T, bool> 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)
|
||||
@@ -6081,6 +6238,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)
|
||||
{
|
||||
@@ -6096,6 +6260,33 @@ public static partial class AgcExports
|
||||
writesGuestMemory ? writeLength : 0);
|
||||
}
|
||||
|
||||
private static long _labelWriteFailureCount;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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) =>
|
||||
(
|
||||
@@ -6156,6 +6347,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)
|
||||
{
|
||||
@@ -6248,6 +6448,48 @@ public static partial class AgcExports
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only view of a parsed graphics context register. False when the
|
||||
/// register was never written.
|
||||
/// </summary>
|
||||
internal static bool TryGetGraphicsContextRegisterForTests(
|
||||
CpuContext ctx,
|
||||
uint registerOffset,
|
||||
out uint value)
|
||||
{
|
||||
value = 0;
|
||||
if (!_submittedGpuStates.TryGetValue(ctx.Memory, out var gpuState))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (gpuState.Gate)
|
||||
{
|
||||
return gpuState.Graphics.CxRegisters.TryGetValue(registerOffset, out value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SH-register counterpart of <see cref="TryGetGraphicsContextRegisterForTests"/>;
|
||||
/// the shader stage addresses live here.
|
||||
/// </summary>
|
||||
internal static bool TryGetGraphicsShRegisterForTests(
|
||||
CpuContext ctx,
|
||||
uint registerOffset,
|
||||
out uint value)
|
||||
{
|
||||
value = 0;
|
||||
if (!_submittedGpuStates.TryGetValue(ctx.Memory, out var gpuState))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (gpuState.Gate)
|
||||
{
|
||||
return gpuState.Graphics.ShRegisters.TryGetValue(registerOffset, out value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GraphicsDcbSetIndexSize writes VGT_INDEX_TYPE via SET_UCONFIG_REG.
|
||||
/// Mirror that into <see cref="SubmittedDcbState.IndexSize"/>.
|
||||
@@ -6630,6 +6872,29 @@ public static partial class AgcExports
|
||||
$"dst=0x{resolveDestination.Address:X16}");
|
||||
}
|
||||
|
||||
// A DCC fast clear writes metadata only; the colour block discards
|
||||
// the quad's shaded output. Reset the attachment and drop the draw,
|
||||
// which reproduces the observable effect of a clear to zero without
|
||||
// modelling DCC block state.
|
||||
if (translatedDraw.IsDccFastClear)
|
||||
{
|
||||
foreach (var target in translatedDraw.GuestTargets)
|
||||
{
|
||||
if (target.Address != 0)
|
||||
{
|
||||
VulkanVideoPresenter.RequestGuestColorClear(target.Address);
|
||||
}
|
||||
}
|
||||
|
||||
ReturnPooledDrawArrays(
|
||||
translatedDraw,
|
||||
globals: true,
|
||||
vertex: true,
|
||||
index: true);
|
||||
state.TranslatedDraw = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var firstTarget = translatedDraw.RenderTargets.FirstOrDefault();
|
||||
if (firstTarget.Address != 0)
|
||||
{
|
||||
@@ -7266,22 +7531,6 @@ public static partial class AgcExports
|
||||
}
|
||||
}
|
||||
|
||||
state.UcRegisters.TryGetValue(VgtPrimitiveType, out var earlyPrimitiveType);
|
||||
if (IsRectListPrimitive(earlyPrimitiveType) &&
|
||||
(exportEvaluation.VertexInputs is null || exportEvaluation.VertexInputs.Count == 0) &&
|
||||
!VertexProgramExportsParameters(exportState.Program) &&
|
||||
GetInterpolatedAttributeCount(pixelState) != 0)
|
||||
{
|
||||
ReturnPooledEvaluationArrays(exportEvaluation);
|
||||
ReturnPooledEvaluationArrays(pixelEvaluation);
|
||||
error =
|
||||
$"rect-list-no-param-exports ps_inputs={GetInterpolatedAttributeCount(pixelState)}";
|
||||
TraceAgcShader(
|
||||
$"agc.rect_list_skip es=0x{exportShaderAddress:X16} " +
|
||||
$"ps=0x{pixelShaderAddress:X16} {error}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Every bound color target the shader exports to. Deferred renderers
|
||||
// draw a multi-render-target G-buffer (up to eight slots) in one pass.
|
||||
// Fall back to slot 0 if we cannot match any export to a bound target.
|
||||
@@ -7562,6 +7811,12 @@ public static partial class AgcExports
|
||||
pixelUserData[index] = pixelEvaluation.InitialScalarRegisters[index];
|
||||
}
|
||||
|
||||
var renderState = ApplyTransparentPremultipliedFillClear(
|
||||
CreateRenderState(state.CxRegisters, renderTargets, pixelColorExportMasks),
|
||||
textures,
|
||||
vertexInputs,
|
||||
pixelEvaluation.InitialScalarRegisters);
|
||||
|
||||
draw = new TranslatedGuestDraw(
|
||||
exportShaderAddress,
|
||||
pixelShaderAddress,
|
||||
@@ -7579,11 +7834,7 @@ public static partial class AgcExports
|
||||
renderTargets,
|
||||
DecodeDepthTarget(state.CxRegisters),
|
||||
guestTargets,
|
||||
ApplyTransparentPremultipliedFillClear(
|
||||
CreateRenderState(state.CxRegisters, renderTargets, pixelColorExportMasks),
|
||||
textures,
|
||||
vertexInputs,
|
||||
pixelEvaluation.InitialScalarRegisters),
|
||||
renderState,
|
||||
pixelUserData,
|
||||
state.CxRegisters.TryGetValue(CbBlend0Control, out var rawBlend) ? rawBlend : 0,
|
||||
state.CxRegisters.TryGetValue(
|
||||
@@ -7597,7 +7848,15 @@ public static partial class AgcExports
|
||||
fullscreenClearColor.Red,
|
||||
fullscreenClearColor.Green,
|
||||
fullscreenClearColor.Blue,
|
||||
fullscreenClearColor.Alpha);
|
||||
fullscreenClearColor.Alpha,
|
||||
IsDccFastClearDraw(
|
||||
state.CxRegisters,
|
||||
renderTargets,
|
||||
textures,
|
||||
vertexInputs,
|
||||
renderState,
|
||||
primitiveType,
|
||||
vertexCount));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7874,6 +8133,113 @@ public static partial class AgcExports
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recognises the covering quad a GFX10 driver issues to clear a
|
||||
/// DCC-compressed colour target. There is no clear packet: the driver
|
||||
/// programs CB_COLORn_CLEAR_WORD0/1 and draws a quad that the colour block
|
||||
/// turns into DCC clear codes, discarding whatever the pixel shader
|
||||
/// exported. Executing it as an ordinary draw writes the shaded output
|
||||
/// instead, and because the blend it uses computes
|
||||
/// <c>a <- a_src + a_dst * (1 - a_src)</c> - fixed point 1 - the target's
|
||||
/// alpha then climbs every frame and saturates.
|
||||
///
|
||||
/// Restricted to clear-to-zero. The reset performed for a match clears the
|
||||
/// attachment to zero, so a nonzero CLEAR_WORD would be cleared to the
|
||||
/// wrong colour; those fall through and are drawn. Zero is zero under every
|
||||
/// encoding the register can carry, so the pair needs no format handling.
|
||||
///
|
||||
/// The clip-space test is load-bearing rather than belt-and-braces: fills
|
||||
/// sharing the vertex count, topology and blend outnumber the clears by two
|
||||
/// orders of magnitude and sit at coordinates well outside the frame.
|
||||
/// </summary>
|
||||
private const uint TriangleStripPrimitive = 6;
|
||||
|
||||
// A float32x3 vertex position stream (BUF_DATA_FORMAT_32_32_32 / FLOAT).
|
||||
private const uint PositionDataFormat = 13;
|
||||
private const uint PositionNumberFormat = 7;
|
||||
|
||||
private static bool IsDccFastClearDraw(
|
||||
IReadOnlyDictionary<uint, uint> registers,
|
||||
IReadOnlyList<RenderTargetDescriptor> renderTargets,
|
||||
IReadOnlyList<TranslatedImageBinding> textures,
|
||||
IReadOnlyList<Gen5VertexInputBinding> vertexInputs,
|
||||
GuestRenderState renderState,
|
||||
uint primitiveType,
|
||||
uint vertexCount)
|
||||
{
|
||||
if (textures.Count != 0 ||
|
||||
vertexCount != 4 ||
|
||||
primitiveType != TriangleStripPrimitive ||
|
||||
renderTargets.Count == 0 ||
|
||||
renderState.Blends.Count == 0 ||
|
||||
!renderState.Blends.All(IsTransparentPremultipliedFillBlend))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var slotStride = renderTargets[0].Slot * CbColorRegisterStride;
|
||||
return registers.TryGetValue(CbColor0Info + slotStride, out var info) &&
|
||||
(info & CbColorInfoDccEnableMask) != 0 &&
|
||||
registers.TryGetValue(CbColor0ClearWord0 + slotStride, out var clearWord0) &&
|
||||
registers.TryGetValue(CbColor0ClearWord1 + slotStride, out var clearWord1) &&
|
||||
clearWord0 == 0 &&
|
||||
clearWord1 == 0 &&
|
||||
CoversClipSpace(vertexInputs, vertexCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the draw's float32x3 position stream spans the full clip
|
||||
/// rectangle, i.e. x and y both reach -1 and +1.
|
||||
/// </summary>
|
||||
private static bool CoversClipSpace(
|
||||
IReadOnlyList<Gen5VertexInputBinding> vertexInputs,
|
||||
uint vertexCount)
|
||||
{
|
||||
const float Tolerance = 0.001f;
|
||||
foreach (var input in vertexInputs)
|
||||
{
|
||||
if (input.DataFormat != PositionDataFormat ||
|
||||
input.NumberFormat != PositionNumberFormat)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var stride = input.Stride == 0 ? 12u : input.Stride;
|
||||
var available = Math.Min(input.DataLength, input.Data.Length);
|
||||
float minX = float.MaxValue, maxX = float.MinValue;
|
||||
float minY = float.MaxValue, maxY = float.MinValue;
|
||||
var seen = 0;
|
||||
for (var vertex = 0u; vertex < vertexCount; vertex++)
|
||||
{
|
||||
var at = (int)(input.OffsetBytes + (vertex * stride));
|
||||
if (at + 12 > available)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var position = input.Data.AsSpan(at);
|
||||
var x = BitConverter.ToSingle(position);
|
||||
var y = BitConverter.ToSingle(position[4..]);
|
||||
if (!float.IsFinite(x) || !float.IsFinite(y))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
minX = Math.Min(minX, x);
|
||||
maxX = Math.Max(maxX, x);
|
||||
minY = Math.Min(minY, y);
|
||||
maxY = Math.Max(maxY, y);
|
||||
seen++;
|
||||
}
|
||||
|
||||
return seen >= 3 &&
|
||||
minX <= -1f + Tolerance && maxX >= 1f - Tolerance &&
|
||||
minY <= -1f + Tolerance && maxY >= 1f - Tolerance;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsTransparentPremultipliedFillBlend(GuestBlendState blend) =>
|
||||
blend is
|
||||
{
|
||||
@@ -8058,20 +8424,6 @@ public static partial class AgcExports
|
||||
? (packedMasks >> (int)(target * 4)) & 0xFu
|
||||
: 0;
|
||||
|
||||
private static bool VertexProgramExportsParameters(Gen5ShaderProgram program)
|
||||
{
|
||||
foreach (var instruction in program.Instructions)
|
||||
{
|
||||
if (instruction.Control is Gen5ExportControl export &&
|
||||
export.Target is >= 32 and < 64)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static uint GetInterpolatedAttributeCount(Gen5ShaderState state)
|
||||
{
|
||||
var maxAttribute = -1;
|
||||
@@ -12334,13 +12686,16 @@ public static partial class AgcExports
|
||||
// GTA V Enhanced HS headers start at RSRC1/RSRC2 (0x10A/0x10B) and
|
||||
// omit PGM_LO/HI from the default table. Still succeed: the code VA
|
||||
// lives at ShaderCodeOffset and later binder paths republish it.
|
||||
if (shaderType == HsFrontShaderType && firstLo is SpiShaderPgmRsrc1Hs or SpiShaderPgmLoHs)
|
||||
// GS front headers can likewise start at RSRC1_GS (0x8A) instead of
|
||||
// PGM_LO_GS (0x88) - same deal, skip the patch here.
|
||||
if ((shaderType == HsFrontShaderType && firstLo is SpiShaderPgmRsrc1Hs or SpiShaderPgmLoHs) ||
|
||||
(shaderType == GsFrontShaderType && firstLo is SpiShaderPgmRsrc1Gs or SpiShaderPgmLoGs))
|
||||
{
|
||||
TraceCreateShader(
|
||||
0,
|
||||
headerAddress,
|
||||
codeAddress,
|
||||
$"skip-pgm-patch type={HsFrontShaderType} first_lo=0x{firstLo:X8}");
|
||||
$"skip-pgm-patch type={shaderType} first_lo=0x{firstLo:X8}");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -12495,9 +12850,6 @@ public static partial class AgcExports
|
||||
private static bool IsEsGeometryShaderType(byte shaderType) =>
|
||||
shaderType is GsShaderType or GsBackShaderType;
|
||||
|
||||
private static bool IsRectListPrimitive(uint primitiveType) =>
|
||||
AgcPrimitiveHelpers.IsRectListPrimitive(primitiveType);
|
||||
|
||||
private static int SetIndirectPatchAddress(CpuContext ctx, string registerSpace)
|
||||
{
|
||||
var commandAddress = ctx[CpuRegister.Rdi];
|
||||
@@ -13582,6 +13934,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",
|
||||
|
||||
@@ -442,6 +442,50 @@ internal static class GpuWaitRegistry
|
||||
return collected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops produced-label values that no registered waiter is watching. Called
|
||||
/// under <see cref="_gate"/> 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records the value a label producer wrote, for the deadlock
|
||||
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
|
||||
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;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
@@ -25,7 +26,6 @@ public static class AmprExports
|
||||
private const uint KernelEventQueueRecordType = 2;
|
||||
private const uint WriteAddressRecordType = 3;
|
||||
private static readonly ConcurrentDictionary<ulong, CommandBufferState> _commandBuffers = new();
|
||||
private static readonly ConcurrentDictionary<string, Lazy<CachedHostFile>> _hostFileCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly bool _traceAmpr =
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AMPR"), "1", StringComparison.Ordinal);
|
||||
private static readonly bool _traceAmprReads =
|
||||
@@ -40,7 +40,7 @@ public static class AmprExports
|
||||
public ulong CommandCount;
|
||||
}
|
||||
|
||||
private sealed class CachedHostFile
|
||||
private sealed class CachedHostFile : IDisposable
|
||||
{
|
||||
public CachedHostFile(string path)
|
||||
{
|
||||
@@ -55,8 +55,26 @@ public static class AmprExports
|
||||
|
||||
public SafeFileHandle Handle { get; }
|
||||
public long Length { get; }
|
||||
|
||||
public void Dispose() => Handle.Dispose();
|
||||
}
|
||||
|
||||
private sealed class CachedHostFileEntry
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required CachedHostFile File { get; init; }
|
||||
}
|
||||
|
||||
// Keep a bounded LRU of open host files. An unbounded cache exhausts the
|
||||
// process FD limit (~10k on macOS) during large asset storms, after
|
||||
// which every new open throws IOException and surfaces as NOT_FOUND — the
|
||||
// guest then reports InvalidFileFourCC on empty buffers.
|
||||
private const int MaxCachedHostFiles = 1536;
|
||||
private static readonly object _hostFileCacheGate = new();
|
||||
private static readonly Dictionary<string, LinkedListNode<CachedHostFileEntry>> _hostFileByPath =
|
||||
new(HostFsPath.Comparer);
|
||||
private static readonly LinkedList<CachedHostFileEntry> _hostFileLru = new();
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "8aI7R7WaOlc",
|
||||
ExportName = "sceAmprCommandBufferConstructor",
|
||||
@@ -80,6 +98,7 @@ public static class AmprExports
|
||||
}
|
||||
|
||||
TraceAmpr(ctx, "ctor", commandBuffer, buffer, size);
|
||||
TryPreindexApp0();
|
||||
ctx[CpuRegister.Rax] = commandBuffer;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -107,6 +126,7 @@ public static class AmprExports
|
||||
}
|
||||
|
||||
TraceAmpr(ctx, "apr_ctor", commandBuffer, aux0, aux1);
|
||||
TryPreindexApp0();
|
||||
ctx[CpuRegister.Rax] = commandBuffer;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -271,8 +291,19 @@ public static class AmprExports
|
||||
|
||||
if (!AmprFileRegistry.TryGetHostPath(fileId, out var hostPath))
|
||||
{
|
||||
TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
// Cooked Insomniac content ids are FNV("/app0/...") and may never
|
||||
// pass through APR resolve. Index app0 once, then retry the lookup.
|
||||
var app0Root = KernelMemoryCompatExports.ResolveGuestPath("$/");
|
||||
if (!string.IsNullOrEmpty(app0Root))
|
||||
{
|
||||
AmprFileRegistry.EnsureApp0Indexed(app0Root);
|
||||
}
|
||||
|
||||
if (!AmprFileRegistry.TryGetHostPath(fileId, out hostPath))
|
||||
{
|
||||
TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
}
|
||||
|
||||
// Offset -1 means "continue after the previous read of this file id".
|
||||
@@ -779,7 +810,9 @@ public static class AmprExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
const int ChunkSize = 1024 * 1024;
|
||||
// 4 MiB chunks cut syscall/Rosetta round-trips on DeS' large sequential
|
||||
// APR reads without blowing the ArrayPool for small probes.
|
||||
const int ChunkSize = 4 * 1024 * 1024;
|
||||
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Min((ulong)ChunkSize, size));
|
||||
|
||||
try
|
||||
@@ -857,27 +890,100 @@ public static class AmprExports
|
||||
cachePath = hostPath;
|
||||
}
|
||||
|
||||
var lazy = _hostFileCache.GetOrAdd(
|
||||
cachePath,
|
||||
static path => new Lazy<CachedHostFile>(() => new CachedHostFile(path), isThreadSafe: true));
|
||||
lock (_hostFileCacheGate)
|
||||
{
|
||||
if (_hostFileByPath.TryGetValue(cachePath, out var existing))
|
||||
{
|
||||
_hostFileLru.Remove(existing);
|
||||
_hostFileLru.AddFirst(existing);
|
||||
file = existing.Value.File;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
CachedHostFile opened;
|
||||
try
|
||||
{
|
||||
file = lazy.Value;
|
||||
return true;
|
||||
opened = new CachedHostFile(cachePath);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
_hostFileCache.TryRemove(cachePath, out _);
|
||||
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
|
||||
return false;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
_hostFileCache.TryRemove(cachePath, out _);
|
||||
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
return false;
|
||||
// Likely EMFILE from a prior unbounded cache, or a transient miss.
|
||||
// Evict everything we hold and retry once so a full FD table can
|
||||
// recover without restarting the process.
|
||||
EvictAllCachedHostFiles();
|
||||
try
|
||||
{
|
||||
opened = new CachedHostFile(cachePath);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
|
||||
return false;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
lock (_hostFileCacheGate)
|
||||
{
|
||||
if (_hostFileByPath.TryGetValue(cachePath, out var raced))
|
||||
{
|
||||
opened.Dispose();
|
||||
_hostFileLru.Remove(raced);
|
||||
_hostFileLru.AddFirst(raced);
|
||||
file = raced.Value.File;
|
||||
return true;
|
||||
}
|
||||
|
||||
while (_hostFileByPath.Count >= MaxCachedHostFiles)
|
||||
{
|
||||
EvictLeastRecentlyUsedHostFileLocked();
|
||||
}
|
||||
|
||||
var entry = new CachedHostFileEntry { Path = cachePath, File = opened };
|
||||
var node = _hostFileLru.AddFirst(entry);
|
||||
_hostFileByPath[cachePath] = node;
|
||||
file = opened;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EvictAllCachedHostFiles()
|
||||
{
|
||||
List<CachedHostFile> doomed;
|
||||
lock (_hostFileCacheGate)
|
||||
{
|
||||
doomed = _hostFileLru.Select(entry => entry.File).ToList();
|
||||
_hostFileLru.Clear();
|
||||
_hostFileByPath.Clear();
|
||||
}
|
||||
|
||||
foreach (var cached in doomed)
|
||||
{
|
||||
cached.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EvictLeastRecentlyUsedHostFileLocked()
|
||||
{
|
||||
var last = _hostFileLru.Last;
|
||||
if (last is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hostFileLru.RemoveLast();
|
||||
_hostFileByPath.Remove(last.Value.Path);
|
||||
last.Value.File.Dispose();
|
||||
}
|
||||
|
||||
private static bool AppendReadFileRecord(
|
||||
@@ -1004,6 +1110,19 @@ public static class AmprExports
|
||||
return false;
|
||||
}
|
||||
|
||||
// GPU WAIT_REG_MEM often watches these APR completion labels
|
||||
// (e.g. 0x20505xxx DEADBEEF/counter fences). Without
|
||||
// RecordProduced the wait sits producerless after the guest recycles
|
||||
// the dword, and CollectDeadlockBroken cannot replay the wake.
|
||||
_ = GpuWaitRegistry.RecordProduced(ctx.Memory, address, value);
|
||||
if ((address & 4ul) == 0)
|
||||
{
|
||||
// 32-bit GPU waits use the low dword; also latch that view when the
|
||||
// address is dword-aligned so a u32 compare against ref sees it.
|
||||
_ = GpuWaitRegistry.RecordProduced(
|
||||
ctx.Memory, address, unchecked((uint)value));
|
||||
}
|
||||
|
||||
TraceAmpr(ctx, "complete_write_address", address, value, 0);
|
||||
return true;
|
||||
}
|
||||
@@ -1021,6 +1140,15 @@ public static class AmprExports
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void TryPreindexApp0()
|
||||
{
|
||||
var app0Root = KernelMemoryCompatExports.ResolveGuestPath("$/");
|
||||
if (!string.IsNullOrEmpty(app0Root))
|
||||
{
|
||||
AmprFileRegistry.EnsureApp0Indexed(app0Root);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraceAmpr(CpuContext ctx, string operation, ulong commandBuffer, ulong arg0, ulong arg1)
|
||||
{
|
||||
if (!_traceAmpr)
|
||||
|
||||
@@ -2,15 +2,35 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpEmu.Libs.Ampr;
|
||||
|
||||
internal static class AmprFileRegistry
|
||||
{
|
||||
private static readonly ConcurrentDictionary<uint, string> _hostPathsById = new();
|
||||
private const uint CacheMagicV2 = 0x32495041u; // 'API2'
|
||||
private const uint CacheVersionV2 = 2;
|
||||
private const uint CacheMagicV3 = 0x33495041u; // 'API3'
|
||||
private const uint CacheVersionV3 = 3;
|
||||
|
||||
private static readonly ConcurrentDictionary<uint, string> _hostPathsById = new(
|
||||
concurrencyLevel: Math.Max(4, Environment.ProcessorCount),
|
||||
capacity: 1_048_576);
|
||||
private static readonly object _indexGate = new();
|
||||
private static string? _indexedApp0Root;
|
||||
private static string? _indexingApp0Root;
|
||||
private static int _preloadStarted;
|
||||
|
||||
public static uint Register(string guestPath, string hostPath)
|
||||
{
|
||||
if (TryGetApp0Relative(guestPath, out var relative) && relative.Length != 0)
|
||||
{
|
||||
RegisterApp0Relative(relative, hostPath);
|
||||
return ComputeFileId("$/" + relative);
|
||||
}
|
||||
|
||||
var id = ComputeFileId(guestPath);
|
||||
_hostPathsById[id] = hostPath;
|
||||
return id;
|
||||
@@ -21,18 +41,576 @@ internal static class AmprFileRegistry
|
||||
return _hostPathsById.TryGetValue(id, out hostPath!);
|
||||
}
|
||||
|
||||
/// <summary>Test hook: wipe registry state between cases.</summary>
|
||||
internal static void ClearForTests()
|
||||
{
|
||||
lock (_indexGate)
|
||||
{
|
||||
_hostPathsById.Clear();
|
||||
_indexedApp0Root = null;
|
||||
_indexingApp0Root = null;
|
||||
_preloadStarted = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test hook for the allocation-free alias publisher.</summary>
|
||||
internal static void RegisterApp0RelativeForTests(string relative, string hostPath) =>
|
||||
RegisterApp0Relative(relative, hostPath);
|
||||
|
||||
/// <summary>
|
||||
/// Kick off <see cref="EnsureApp0Indexed"/> on a background thread as soon as
|
||||
/// the host knows app0. otherwise pays the full tree walk on the
|
||||
/// first cooked-id APR miss mid-boot (~8s under Rosetta for DeS).
|
||||
/// </summary>
|
||||
public static void BeginApp0IndexPreload(string? app0Root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(app0Root) || !Directory.Exists(app0Root))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.Exchange(ref _preloadStarted, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var root = app0Root;
|
||||
ThreadPool.UnsafeQueueUserWorkItem(
|
||||
static state =>
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureApp0Indexed((string)state!);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] ampr.app0_index_preload_failed: {exception.Message}");
|
||||
}
|
||||
},
|
||||
root);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indexes every file under app0 under both <c>$/</c> and <c>/app0/</c> FNV
|
||||
/// ids. Cooked asset tables ship precomputed ids; without this walk, a title
|
||||
/// that never resolves those paths through APR leaves ReadFile permanently
|
||||
/// NOT_FOUND.
|
||||
/// </summary>
|
||||
public static void EnsureApp0Indexed(string app0Root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(app0Root) || !Directory.Exists(app0Root))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedRoot = Path.GetFullPath(app0Root);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
lock (_indexGate)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (string.Equals(_indexedApp0Root, normalizedRoot, HostFsPath.Comparison))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_indexingApp0Root is not null)
|
||||
{
|
||||
// Another thread (preload) owns the walk — wait instead of
|
||||
// stacking a second 8s index on the guest APR miss path.
|
||||
if (string.Equals(
|
||||
_indexingApp0Root,
|
||||
normalizedRoot,
|
||||
HostFsPath.Comparison))
|
||||
{
|
||||
Monitor.Wait(_indexGate);
|
||||
continue;
|
||||
}
|
||||
|
||||
Monitor.Wait(_indexGate, 50);
|
||||
continue;
|
||||
}
|
||||
|
||||
_indexingApp0Root = normalizedRoot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cachePathV3 = GetIndexCachePath(normalizedRoot, version: 3);
|
||||
var cachePathV2 = GetIndexCachePath(normalizedRoot, version: 2);
|
||||
if (TryLoadIndexCache(normalizedRoot, cachePathV3, preferV3: true, out var cachedFiles))
|
||||
{
|
||||
lock (_indexGate)
|
||||
{
|
||||
_indexedApp0Root = normalizedRoot;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] ampr.app0_index_cache_hit root={normalizedRoot} " +
|
||||
$"files={cachedFiles} ids={_hostPathsById.Count} " +
|
||||
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryLoadIndexCache(normalizedRoot, cachePathV2, preferV3: false, out cachedFiles))
|
||||
{
|
||||
lock (_indexGate)
|
||||
{
|
||||
_indexedApp0Root = normalizedRoot;
|
||||
}
|
||||
|
||||
// Promote v2 (rehash-on-load) to v3 (precomputed ids) so the
|
||||
// next boot skips the Rosetta FNV storm.
|
||||
TrySaveIndexCache(normalizedRoot, cachePathV3, cachedFiles);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] ampr.app0_index_cache_hit root={normalizedRoot} " +
|
||||
$"files={cachedFiles} ids={_hostPathsById.Count} " +
|
||||
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1} upgraded=v3");
|
||||
return;
|
||||
}
|
||||
|
||||
var relatives = new List<string>(256 * 1024);
|
||||
try
|
||||
{
|
||||
foreach (var hostPath in Directory.EnumerateFiles(
|
||||
normalizedRoot,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||
.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(relative) ||
|
||||
relative.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
relatives.Add(relative);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// The walk is an opportunistic warm-up reached synchronously from
|
||||
// sceAmprCommandBufferConstructor; a dump that moves or a mount
|
||||
// that hiccups must not fault the guest export. The background
|
||||
// preload already swallows this. Leave the root unindexed so a
|
||||
// later call retries.
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] ampr.app0_index_walk_failed root={normalizedRoot}: {exception.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash + dictionary fill dominates under Rosetta once the walk is
|
||||
// done; parallelize across cores without re-walking the tree.
|
||||
Parallel.ForEach(
|
||||
relatives,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
|
||||
},
|
||||
relative =>
|
||||
{
|
||||
var hostPath = Path.Combine(normalizedRoot, relative.Replace('/', Path.DirectorySeparatorChar));
|
||||
RegisterApp0Relative(relative, hostPath);
|
||||
});
|
||||
|
||||
lock (_indexGate)
|
||||
{
|
||||
_indexedApp0Root = normalizedRoot;
|
||||
}
|
||||
|
||||
TrySaveIndexCache(normalizedRoot, cachePathV3, relatives.Count);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] ampr.app0_indexed root={normalizedRoot} " +
|
||||
$"files={relatives.Count} ids={_hostPathsById.Count} " +
|
||||
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_indexGate)
|
||||
{
|
||||
_indexingApp0Root = null;
|
||||
Monitor.PulseAll(_indexGate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the four Insomniac path aliases for one app0-relative file
|
||||
/// without allocating intermediate guest-path strings.
|
||||
/// </summary>
|
||||
private static void RegisterApp0Relative(string relative, string hostPath)
|
||||
{
|
||||
// "$/" + relative
|
||||
Publish(FnvContinueAscii(FnvContinueAscii(OffsetBasis, (byte)'$'), (byte)'/'), relative, hostPath);
|
||||
// "/app0/" + relative
|
||||
Publish(FnvContinueAsciiPrefix(OffsetBasis, "/app0/"u8), relative, hostPath);
|
||||
// "app0/" + relative
|
||||
Publish(FnvContinueAsciiPrefix(OffsetBasis, "app0/"u8), relative, hostPath);
|
||||
// bare relative
|
||||
Publish(OffsetBasis, relative, hostPath);
|
||||
}
|
||||
|
||||
private static void Publish(uint hash, string relative, string hostPath)
|
||||
{
|
||||
_hostPathsById[FnvContinueUtf8(hash, relative)] = hostPath;
|
||||
}
|
||||
|
||||
internal static uint ComputeFileId(string guestPath)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(guestPath);
|
||||
return FnvContinueUtf8(OffsetBasis, guestPath);
|
||||
}
|
||||
|
||||
const uint offsetBasis = 2166136261;
|
||||
const uint prime = 16777619;
|
||||
|
||||
var hash = offsetBasis;
|
||||
foreach (var b in bytes)
|
||||
internal static IEnumerable<string> EnumerateApp0PathAliases(string guestPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(guestPath))
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= prime;
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!TryGetApp0Relative(guestPath, out var relative) ||
|
||||
string.IsNullOrEmpty(relative))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return "$/" + relative;
|
||||
yield return "/app0/" + relative;
|
||||
yield return "app0/" + relative;
|
||||
yield return relative;
|
||||
}
|
||||
|
||||
private static bool TryGetApp0Relative(string guestPath, out string relative)
|
||||
{
|
||||
relative = string.Empty;
|
||||
var normalized = guestPath.Replace('\\', '/');
|
||||
|
||||
if (normalized.StartsWith("$/", StringComparison.Ordinal))
|
||||
{
|
||||
relative = normalized[2..].TrimStart('/');
|
||||
return relative.Length != 0;
|
||||
}
|
||||
|
||||
if (normalized.StartsWith("/app0/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relative = normalized["/app0/".Length..].TrimStart('/');
|
||||
return relative.Length != 0;
|
||||
}
|
||||
|
||||
if (normalized.StartsWith("app0/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relative = normalized["app0/".Length..].TrimStart('/');
|
||||
return relative.Length != 0;
|
||||
}
|
||||
|
||||
// Bare relative paths are treated as app0-relative by ResolveGuestPath.
|
||||
if (!normalized.StartsWith('/') &&
|
||||
!Path.IsPathFullyQualified(guestPath))
|
||||
{
|
||||
relative = normalized.TrimStart('/');
|
||||
return relative.Length != 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetIndexCachePath(string normalizedRoot, int version)
|
||||
{
|
||||
var overrideDir = Environment.GetEnvironmentVariable("SHARPEMU_AMPR_INDEX_CACHE");
|
||||
var cacheDir = !string.IsNullOrWhiteSpace(overrideDir)
|
||||
? overrideDir
|
||||
: Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"SharpEmu",
|
||||
"ampr-index");
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
// Distinct roots must not share a cache file. Folding case is only
|
||||
// correct where the host filesystem folds it too.
|
||||
var rootKey = OperatingSystem.IsWindows() ? normalizedRoot.ToLowerInvariant() : normalizedRoot;
|
||||
var rootHash = ComputeFileId(rootKey);
|
||||
return Path.Combine(cacheDir, $"app0-{rootHash:x8}.v{version}.idx");
|
||||
}
|
||||
|
||||
private static bool TryLoadIndexCache(
|
||||
string normalizedRoot,
|
||||
string cachePath,
|
||||
bool preferV3,
|
||||
out int fileCount)
|
||||
{
|
||||
fileCount = 0;
|
||||
try
|
||||
{
|
||||
if (!File.Exists(cachePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_AMPR_REINDEX"),
|
||||
"1",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(cachePath);
|
||||
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false);
|
||||
var magic = reader.ReadUInt32();
|
||||
var version = reader.ReadUInt32();
|
||||
var isV3 = magic == CacheMagicV3 && version == CacheVersionV3;
|
||||
var isV2 = magic == CacheMagicV2 && version == CacheVersionV2;
|
||||
if (preferV3)
|
||||
{
|
||||
if (!isV3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (!isV2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var root = reader.ReadString();
|
||||
if (!string.Equals(root, normalizedRoot, HostFsPath.Comparison))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedFiles = reader.ReadInt32();
|
||||
var expectedParamTicks = reader.ReadInt64();
|
||||
var actualParamTicks = GetParamJsonWriteTicks(normalizedRoot);
|
||||
if (actualParamTicks == 0 || actualParamTicks != expectedParamTicks)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedFiles < 0 || expectedFiles > 8_000_000)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isV3)
|
||||
{
|
||||
// Precomputed FNV ids — no Rosetta hash storm on every boot.
|
||||
var entries = new (string Relative, uint Id0, uint Id1, uint Id2, uint Id3)[expectedFiles];
|
||||
for (var i = 0; i < expectedFiles; i++)
|
||||
{
|
||||
entries[i] = (
|
||||
reader.ReadString(),
|
||||
reader.ReadUInt32(),
|
||||
reader.ReadUInt32(),
|
||||
reader.ReadUInt32(),
|
||||
reader.ReadUInt32());
|
||||
}
|
||||
|
||||
Parallel.ForEach(
|
||||
entries,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
|
||||
},
|
||||
entry =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.Relative) ||
|
||||
entry.Relative.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hostPath = normalizedRoot.EndsWith(Path.DirectorySeparatorChar)
|
||||
? normalizedRoot + entry.Relative.Replace('/', Path.DirectorySeparatorChar)
|
||||
: normalizedRoot + Path.DirectorySeparatorChar +
|
||||
entry.Relative.Replace('/', Path.DirectorySeparatorChar);
|
||||
_hostPathsById[entry.Id0] = hostPath;
|
||||
_hostPathsById[entry.Id1] = hostPath;
|
||||
_hostPathsById[entry.Id2] = hostPath;
|
||||
_hostPathsById[entry.Id3] = hostPath;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var relatives = new string[expectedFiles];
|
||||
for (var i = 0; i < expectedFiles; i++)
|
||||
{
|
||||
relatives[i] = reader.ReadString();
|
||||
}
|
||||
|
||||
Parallel.ForEach(
|
||||
relatives,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
|
||||
},
|
||||
relative =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(relative) ||
|
||||
relative.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hostPath = Path.Combine(
|
||||
normalizedRoot,
|
||||
relative.Replace('/', Path.DirectorySeparatorChar));
|
||||
RegisterApp0Relative(relative, hostPath);
|
||||
});
|
||||
}
|
||||
|
||||
fileCount = expectedFiles;
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_hostPathsById.Clear();
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] ampr.app0_index_cache_load_failed: {exception.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TrySaveIndexCache(
|
||||
string normalizedRoot,
|
||||
string cachePath,
|
||||
int fileCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
var paramTicks = GetParamJsonWriteTicks(normalizedRoot);
|
||||
if (paramTicks == 0 || fileCount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var relatives = new HashSet<string>(HostFsPath.Comparer);
|
||||
foreach (var hostPath in _hostPathsById.Values)
|
||||
{
|
||||
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||
.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(relative) ||
|
||||
relative.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
relatives.Add(relative);
|
||||
}
|
||||
|
||||
var tempPath = cachePath + ".tmp";
|
||||
using (var stream = File.Create(tempPath))
|
||||
using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: false))
|
||||
{
|
||||
writer.Write(CacheMagicV3);
|
||||
writer.Write(CacheVersionV3);
|
||||
writer.Write(normalizedRoot);
|
||||
writer.Write(relatives.Count);
|
||||
writer.Write(paramTicks);
|
||||
foreach (var relative in relatives)
|
||||
{
|
||||
writer.Write(relative);
|
||||
// Mirror RegisterApp0Relative id order: $/ /app0/ app0/ bare.
|
||||
writer.Write(ComputeApp0AliasIds(relative, out var id1, out var id2, out var id3));
|
||||
writer.Write(id1);
|
||||
writer.Write(id2);
|
||||
writer.Write(id3);
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(tempPath, cachePath, overwrite: true);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] ampr.app0_index_cache_saved path={cachePath} " +
|
||||
$"files={relatives.Count}");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] ampr.app0_index_cache_save_failed: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ComputeApp0AliasIds(
|
||||
string relative,
|
||||
out uint app0Slash,
|
||||
out uint app0,
|
||||
out uint bare)
|
||||
{
|
||||
var dollar = FnvContinueUtf8(
|
||||
FnvContinueAscii(FnvContinueAscii(OffsetBasis, (byte)'$'), (byte)'/'),
|
||||
relative);
|
||||
app0Slash = FnvContinueUtf8(FnvContinueAsciiPrefix(OffsetBasis, "/app0/"u8), relative);
|
||||
app0 = FnvContinueUtf8(FnvContinueAsciiPrefix(OffsetBasis, "app0/"u8), relative);
|
||||
bare = FnvContinueUtf8(OffsetBasis, relative);
|
||||
return dollar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cheap dump fingerprint. Full-tree walks are too expensive for cache
|
||||
/// validation; param.json changes with title updates. Force a rebuild with
|
||||
/// SHARPEMU_AMPR_REINDEX=1 after manual dump edits.
|
||||
/// </summary>
|
||||
private static long GetParamJsonWriteTicks(string normalizedRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var paramPath = Path.Combine(normalizedRoot, "sce_sys", "param.json");
|
||||
return File.Exists(paramPath)
|
||||
? File.GetLastWriteTimeUtc(paramPath).Ticks
|
||||
: 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private const uint OffsetBasis = 2166136261;
|
||||
private const uint FnvPrime = 16777619;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static uint FnvContinueAscii(uint hash, byte value)
|
||||
{
|
||||
hash ^= value;
|
||||
return hash * FnvPrime;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static uint FnvContinueAsciiPrefix(uint hash, ReadOnlySpan<byte> ascii)
|
||||
{
|
||||
foreach (var value in ascii)
|
||||
{
|
||||
hash ^= value;
|
||||
hash *= FnvPrime;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static uint FnvContinueUtf8(uint hash, string text)
|
||||
{
|
||||
// Game asset paths are overwhelmingly ASCII; avoid Encoding.GetBytes
|
||||
// allocations on the 223k-file DeS index hot path.
|
||||
Span<byte> utf8Scratch = stackalloc byte[4];
|
||||
for (var i = 0; i < text.Length; i++)
|
||||
{
|
||||
var ch = text[i];
|
||||
if (ch < 0x80)
|
||||
{
|
||||
hash ^= (byte)ch;
|
||||
hash *= FnvPrime;
|
||||
continue;
|
||||
}
|
||||
|
||||
var written = Encoding.UTF8.GetBytes(text.AsSpan(i, 1), utf8Scratch);
|
||||
for (var b = 0; b < written; b++)
|
||||
{
|
||||
hash ^= utf8Scratch[b];
|
||||
hash *= FnvPrime;
|
||||
}
|
||||
}
|
||||
|
||||
return hash;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using SharpEmu.Libs.Media;
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
@@ -15,6 +16,10 @@ public static class AvPlayerExports
|
||||
private const int InvalidParameters = unchecked((int)0x806A0001);
|
||||
private const int OperationFailed = unchecked((int)0x806A0002);
|
||||
private const int FrameBufferCount = 3;
|
||||
private const int MaxCatchUpFrames = 2;
|
||||
private const ulong TextureAllocationAlignment = 0x100;
|
||||
private const int FramePitchAlignment = 64;
|
||||
private const int FrameHeightAlignment = 16;
|
||||
private const int FrameInfoSize = 40;
|
||||
private const int FrameInfoExSize = 104;
|
||||
// This structure is 32 bytes. A larger write can damage the guest stack.
|
||||
@@ -22,6 +27,7 @@ public static class AvPlayerExports
|
||||
private const int StreamInfoExSize = 32;
|
||||
private const int MaxGuestPathLength = 4096;
|
||||
private static readonly object StateGate = new();
|
||||
private static readonly HashSet<string> TracedOnce = new();
|
||||
private static readonly Dictionary<ulong, PlayerState> Players = new();
|
||||
private static int _traceCount;
|
||||
|
||||
@@ -31,6 +37,7 @@ public static class AvPlayerExports
|
||||
public bool AutoStart { get; init; }
|
||||
public ulong AllocatorObject { get; init; }
|
||||
public ulong AllocateTextureCallback { get; init; }
|
||||
public ulong AllocateCallback { get; init; }
|
||||
public ulong EventObject { get; init; }
|
||||
public ulong EventCallback { get; init; }
|
||||
public string? SourcePath { get; set; }
|
||||
@@ -42,11 +49,10 @@ public static class AvPlayerExports
|
||||
public bool Paused { get; set; }
|
||||
public bool Looping { get; set; }
|
||||
public bool EndOfStream { get; set; }
|
||||
public Process? Decoder { get; set; }
|
||||
public Stream? DecoderOutput { get; set; }
|
||||
public Process? AudioDecoder { get; set; }
|
||||
public Stream? AudioDecoderOutput { get; set; }
|
||||
public Stopwatch PlaybackClock { get; } = new();
|
||||
public long SkippedFrameDebt { get; set; }
|
||||
public byte[]? RawFrame { get; set; }
|
||||
public byte[]? RawAudioFrame { get; set; }
|
||||
public byte[]? PaddedFrame { get; set; }
|
||||
@@ -66,42 +72,6 @@ public static class AvPlayerExports
|
||||
DecoderOutput = null;
|
||||
AudioDecoderOutput?.Dispose();
|
||||
AudioDecoderOutput = null;
|
||||
if (Decoder is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Decoder.HasExited)
|
||||
{
|
||||
Decoder.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
Decoder.Dispose();
|
||||
Decoder = null;
|
||||
}
|
||||
}
|
||||
if (AudioDecoder is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!AudioDecoder.HasExited)
|
||||
{
|
||||
AudioDecoder.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
AudioDecoder.Dispose();
|
||||
AudioDecoder = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetPlayback()
|
||||
@@ -110,6 +80,7 @@ public static class AvPlayerExports
|
||||
PlaybackClock.Reset();
|
||||
NextFrameIndex = 0;
|
||||
NextAudioFrameIndex = 0;
|
||||
SkippedFrameDebt = 0;
|
||||
EndOfStream = false;
|
||||
}
|
||||
}
|
||||
@@ -137,6 +108,7 @@ public static class AvPlayerExports
|
||||
AutoStart = TryReadByte(ctx, initDataAddress + 108, out var autoStart) && autoStart != 0,
|
||||
AllocatorObject = TryReadUInt64(ctx, initDataAddress, out var allocatorObject) ? allocatorObject : 0,
|
||||
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 24, out var allocateTexture) ? allocateTexture : 0,
|
||||
AllocateCallback = TryReadUInt64(ctx, initDataAddress + 8, out var allocate) ? allocate : 0,
|
||||
EventObject = TryReadUInt64(ctx, initDataAddress + 80, out var eventObject) ? eventObject : 0,
|
||||
EventCallback = TryReadUInt64(ctx, initDataAddress + 88, out var eventCallback) ? eventCallback : 0,
|
||||
});
|
||||
@@ -191,6 +163,7 @@ public static class AvPlayerExports
|
||||
AutoStart = TryReadByte(ctx, initDataAddress + 164, out var autoStart) && autoStart != 0,
|
||||
AllocatorObject = TryReadUInt64(ctx, initDataAddress + 8, out var allocatorObject) ? allocatorObject : 0,
|
||||
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 32, out var allocateTexture) ? allocateTexture : 0,
|
||||
AllocateCallback = TryReadUInt64(ctx, initDataAddress + 16, out var allocate) ? allocate : 0,
|
||||
EventObject = TryReadUInt64(ctx, initDataAddress + 88, out var eventObject) ? eventObject : 0,
|
||||
EventCallback = TryReadUInt64(ctx, initDataAddress + 96, out var eventCallback) ? eventCallback : 0,
|
||||
});
|
||||
@@ -356,7 +329,7 @@ public static class AvPlayerExports
|
||||
}
|
||||
|
||||
player.Paused = false;
|
||||
if (player.Decoder is not null)
|
||||
if (player.DecoderOutput is not null)
|
||||
{
|
||||
player.PlaybackClock.Start();
|
||||
}
|
||||
@@ -388,7 +361,13 @@ public static class AvPlayerExports
|
||||
ExportName = "sceAvPlayerEnableStream",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAvPlayer")]
|
||||
public static int AvPlayerEnableStream(CpuContext ctx) => ValidatePlayer(ctx);
|
||||
public static int AvPlayerEnableStream(CpuContext ctx)
|
||||
{
|
||||
TraceOnce(
|
||||
$"enable_stream_{ctx[CpuRegister.Rsi]}",
|
||||
$"enable_stream index={ctx[CpuRegister.Rsi]}");
|
||||
return ValidatePlayer(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "k-q+xOxdc3E",
|
||||
@@ -445,10 +424,13 @@ public static class AvPlayerExports
|
||||
{
|
||||
lock (StateGate)
|
||||
{
|
||||
return SetReturn(
|
||||
ctx,
|
||||
Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) &&
|
||||
player.Started && !player.EndOfStream ? 1 : 0);
|
||||
var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
|
||||
var active = found && player!.Started && !player.EndOfStream;
|
||||
TraceOnce(
|
||||
"is_active",
|
||||
$"is_active found={found} started={(found && player!.Started)} " +
|
||||
$"eos={(found && player!.EndOfStream)} returned={(active ? 1 : 0)}");
|
||||
return SetReturn(ctx, active ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,13 +458,21 @@ public static class AvPlayerExports
|
||||
var infoAddress = ctx[CpuRegister.Rsi];
|
||||
lock (StateGate)
|
||||
{
|
||||
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) ||
|
||||
infoAddress == 0 || !player.Started || player.Paused || player.EndOfStream ||
|
||||
player.SourcePath is null || !EnsureAudioDecoder(player))
|
||||
var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
|
||||
if (!found || infoAddress == 0 || !player!.Started || player.Paused ||
|
||||
player.EndOfStream || player.SourcePath is null || !EnsureAudioDecoder(player))
|
||||
{
|
||||
TraceOnce(
|
||||
"audio_data_refused",
|
||||
$"audio_data refused found={found} info=0x{infoAddress:X16} " +
|
||||
$"started={(found && player!.Started)} paused={(found && player!.Paused)} " +
|
||||
$"eos={(found && player!.EndOfStream)} " +
|
||||
$"decoder={(found && player!.SourcePath is not null && EnsureAudioDecoder(player))}");
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
TraceOnce("audio_data_ok", "audio_data first delivery");
|
||||
|
||||
const int samplesPerFrame = 1024;
|
||||
const int channelCount = 2;
|
||||
const int sampleRate = 48_000;
|
||||
@@ -560,7 +550,9 @@ public static class AvPlayerExports
|
||||
{
|
||||
lock (StateGate)
|
||||
{
|
||||
return SetReturn(ctx, Players.ContainsKey(ctx[CpuRegister.Rdi]) ? 2 : InvalidParameters);
|
||||
var known = Players.ContainsKey(ctx[CpuRegister.Rdi]);
|
||||
TraceOnce("stream_count", $"stream_count known={known} returned={(known ? 2 : -1)}");
|
||||
return SetReturn(ctx, known ? 2 : InvalidParameters);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +629,10 @@ public static class AvPlayerExports
|
||||
return SetReturn(ctx, InvalidParameters);
|
||||
}
|
||||
|
||||
TraceOnce(
|
||||
$"stream_info_{streamIndex}_{infoSize}",
|
||||
$"stream_info index={streamIndex} size={infoSize} " +
|
||||
$"type={(streamIndex == 0 ? "video" : "audio")} duration_ms={player.DurationMilliseconds}");
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
}
|
||||
@@ -672,6 +668,8 @@ public static class AvPlayerExports
|
||||
}
|
||||
|
||||
|
||||
EnsureGuestVideoBuffers(ctx, player);
|
||||
|
||||
NotifyEvent(ctx, player, 2); // StateReady
|
||||
if (autoStart)
|
||||
{
|
||||
@@ -699,7 +697,20 @@ public static class AvPlayerExports
|
||||
}
|
||||
|
||||
var fps = Math.Max(1.0, player.FramesPerSecond);
|
||||
var expectedFrame = (long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps);
|
||||
var expectedFrame =
|
||||
(long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps) -
|
||||
player.SkippedFrameDebt;
|
||||
var behind = expectedFrame - player.NextFrameIndex;
|
||||
if (behind > MaxCatchUpFrames)
|
||||
{
|
||||
player.SkippedFrameDebt += behind - MaxCatchUpFrames;
|
||||
expectedFrame = player.NextFrameIndex + MaxCatchUpFrames;
|
||||
TraceOnce(
|
||||
"catch_up_capped",
|
||||
$"catch_up capped behind={behind} max={MaxCatchUpFrames} " +
|
||||
$"fps={fps:F3} {player.Width}x{player.Height}");
|
||||
}
|
||||
|
||||
while (player.NextFrameIndex < expectedFrame)
|
||||
{
|
||||
if (!ReadFrame(player))
|
||||
@@ -748,62 +759,28 @@ public static class AvPlayerExports
|
||||
return true;
|
||||
}
|
||||
|
||||
var ffmpeg = FindFfmpeg();
|
||||
if (ffmpeg is null || player.SourcePath is null)
|
||||
if (player.SourcePath is null)
|
||||
{
|
||||
Console.Error.WriteLine("[AVPLAYER][ERROR] FFmpeg was not found. Set SHARPEMU_FFMPEG_PATH.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo(ffmpeg)
|
||||
if (!FfmpegMediaStream.TryOpenVideo(
|
||||
player.SourcePath,
|
||||
checked((int)player.Width),
|
||||
checked((int)player.Height),
|
||||
out var videoStream) ||
|
||||
videoStream is null)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-nostdin");
|
||||
startInfo.ArgumentList.Add("-hide_banner");
|
||||
startInfo.ArgumentList.Add("-loglevel");
|
||||
startInfo.ArgumentList.Add("error");
|
||||
startInfo.ArgumentList.Add("-i");
|
||||
startInfo.ArgumentList.Add(player.SourcePath);
|
||||
startInfo.ArgumentList.Add("-map");
|
||||
startInfo.ArgumentList.Add("0:v:0");
|
||||
startInfo.ArgumentList.Add("-an");
|
||||
startInfo.ArgumentList.Add("-pix_fmt");
|
||||
startInfo.ArgumentList.Add("nv12");
|
||||
startInfo.ArgumentList.Add("-f");
|
||||
startInfo.ArgumentList.Add("rawvideo");
|
||||
startInfo.ArgumentList.Add("pipe:1");
|
||||
|
||||
try
|
||||
{
|
||||
player.Decoder = Process.Start(startInfo);
|
||||
if (player.Decoder is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
player.Decoder.ErrorDataReceived += (_, eventArgs) =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(eventArgs.Data))
|
||||
{
|
||||
Console.Error.WriteLine($"[AVPLAYER][FFMPEG] {eventArgs.Data}");
|
||||
}
|
||||
};
|
||||
player.Decoder.BeginErrorReadLine();
|
||||
player.DecoderOutput = player.Decoder.StandardOutput.BaseStream;
|
||||
player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)];
|
||||
player.PlaybackClock.Start();
|
||||
Trace($"decoder_started pid={player.Decoder.Id} source='{player.SourcePath}'");
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg: {exception.Message}");
|
||||
player.Dispose();
|
||||
Console.Error.WriteLine(
|
||||
$"[AVPLAYER][ERROR] Could not open a video stream in '{player.SourcePath}'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
player.DecoderOutput = videoStream;
|
||||
player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)];
|
||||
player.PlaybackClock.Start();
|
||||
Trace($"decoder_started source='{player.SourcePath}' {player.Width}x{player.Height} nv12");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool EnsureAudioDecoder(PlayerState player)
|
||||
@@ -813,65 +790,21 @@ public static class AvPlayerExports
|
||||
return true;
|
||||
}
|
||||
|
||||
var ffmpeg = FindFfmpeg();
|
||||
if (ffmpeg is null || player.SourcePath is null)
|
||||
if (player.SourcePath is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo(ffmpeg)
|
||||
if (!FfmpegMediaStream.TryOpenAudio(player.SourcePath, out var audioStream) ||
|
||||
audioStream is null)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-nostdin");
|
||||
startInfo.ArgumentList.Add("-hide_banner");
|
||||
startInfo.ArgumentList.Add("-loglevel");
|
||||
startInfo.ArgumentList.Add("error");
|
||||
startInfo.ArgumentList.Add("-i");
|
||||
startInfo.ArgumentList.Add(player.SourcePath);
|
||||
startInfo.ArgumentList.Add("-map");
|
||||
startInfo.ArgumentList.Add("0:a:0");
|
||||
startInfo.ArgumentList.Add("-vn");
|
||||
startInfo.ArgumentList.Add("-ac");
|
||||
startInfo.ArgumentList.Add("2");
|
||||
startInfo.ArgumentList.Add("-ar");
|
||||
startInfo.ArgumentList.Add("48000");
|
||||
startInfo.ArgumentList.Add("-f");
|
||||
startInfo.ArgumentList.Add("s16le");
|
||||
startInfo.ArgumentList.Add("pipe:1");
|
||||
|
||||
try
|
||||
{
|
||||
player.AudioDecoder = Process.Start(startInfo);
|
||||
if (player.AudioDecoder is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
player.AudioDecoder.ErrorDataReceived += (_, eventArgs) =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(eventArgs.Data))
|
||||
{
|
||||
Console.Error.WriteLine($"[AVPLAYER][FFMPEG-AUDIO] {eventArgs.Data}");
|
||||
}
|
||||
};
|
||||
player.AudioDecoder.BeginErrorReadLine();
|
||||
player.AudioDecoderOutput = player.AudioDecoder.StandardOutput.BaseStream;
|
||||
player.RawAudioFrame = new byte[1024 * 2 * sizeof(short)];
|
||||
Trace($"audio_decoder_started pid={player.AudioDecoder.Id} source='{player.SourcePath}'");
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg audio decoder: {exception.Message}");
|
||||
player.AudioDecoderOutput?.Dispose();
|
||||
player.AudioDecoderOutput = null;
|
||||
player.AudioDecoder?.Dispose();
|
||||
player.AudioDecoder = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
player.AudioDecoderOutput = audioStream;
|
||||
player.RawAudioFrame = new byte[1024 * FfmpegMediaStream.AudioChannels * sizeof(short)];
|
||||
Trace($"audio_decoder_started source='{player.SourcePath}' s16 stereo 48000");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ReadFrame(PlayerState player)
|
||||
@@ -923,9 +856,9 @@ public static class AvPlayerExports
|
||||
return false;
|
||||
}
|
||||
|
||||
var alignedWidth = AlignUp(player.Width, 16);
|
||||
var alignedHeight = AlignUp(player.Height, 16);
|
||||
var bufferStride = checked(alignedWidth * alignedHeight * 3 / 2);
|
||||
var alignedWidth = AlignUp(player.Width, FramePitchAlignment);
|
||||
var alignedHeight = AlignUp(player.Height, FrameHeightAlignment);
|
||||
var bufferStride = GetVideoBufferSize(player);
|
||||
if (player.GuestBuffers[0] == 0)
|
||||
{
|
||||
if (!AllocateGuestVideoBuffers(ctx, player, bufferStride))
|
||||
@@ -981,39 +914,78 @@ public static class AvPlayerExports
|
||||
return ctx.Memory.TryWrite(infoAddress, info);
|
||||
}
|
||||
|
||||
private static int GetVideoBufferSize(PlayerState player) =>
|
||||
checked(
|
||||
AlignUp(player.Width, FramePitchAlignment) *
|
||||
AlignUp(player.Height, FrameHeightAlignment) * 3 / 2);
|
||||
|
||||
private static void EnsureGuestVideoBuffers(CpuContext ctx, PlayerState player)
|
||||
{
|
||||
lock (StateGate)
|
||||
{
|
||||
if (player.GuestBuffers[0] != 0 || player.Width <= 0 || player.Height <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bufferSize = GetVideoBufferSize(player);
|
||||
if (AllocateGuestVideoBuffers(ctx, player, bufferSize))
|
||||
{
|
||||
player.GuestBufferStride = bufferSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AllocateGuestVideoBuffers(CpuContext ctx, PlayerState player, int bufferSize)
|
||||
{
|
||||
var scheduler = GuestThreadExecution.Scheduler;
|
||||
if (!player.TextureAllocatorFailed && player.AllocateTextureCallback != 0 && scheduler is not null)
|
||||
if (!player.TextureAllocatorFailed && scheduler is not null)
|
||||
{
|
||||
for (var index = 0; index < player.GuestBuffers.Length; index++)
|
||||
foreach (var (callback, kind) in new[]
|
||||
{
|
||||
(player.AllocateTextureCallback, "texture"),
|
||||
(player.AllocateCallback, "generic"),
|
||||
})
|
||||
{
|
||||
if (!scheduler.TryCallGuestFunction(
|
||||
ctx,
|
||||
player.AllocateTextureCallback,
|
||||
player.AllocatorObject,
|
||||
0x100,
|
||||
checked((ulong)bufferSize),
|
||||
0,
|
||||
0,
|
||||
"avplayer_allocate_texture",
|
||||
out var buffer,
|
||||
out var error) || buffer == 0)
|
||||
if (callback == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[AVPLAYER][ERROR] Guest texture allocation failed index={index} " +
|
||||
$"callback=0x{player.AllocateTextureCallback:X16}: {error ?? "returned null"}");
|
||||
player.TextureAllocatorFailed = true;
|
||||
Array.Clear(player.GuestBuffers);
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
var allocated = true;
|
||||
for (var index = 0; index < player.GuestBuffers.Length; index++)
|
||||
{
|
||||
if (!scheduler.TryCallGuestFunction(
|
||||
ctx,
|
||||
callback,
|
||||
player.AllocatorObject,
|
||||
TextureAllocationAlignment,
|
||||
checked((ulong)bufferSize),
|
||||
0,
|
||||
0,
|
||||
"avplayer_allocate_" + kind,
|
||||
out var buffer,
|
||||
out var error) || buffer == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[AVPLAYER][WARN] Guest {kind} allocation failed index={index} " +
|
||||
$"callback=0x{callback:X16} size={bufferSize} " +
|
||||
$"align=0x{TextureAllocationAlignment:X}: {error ?? "returned null"}");
|
||||
allocated = false;
|
||||
Array.Clear(player.GuestBuffers);
|
||||
break;
|
||||
}
|
||||
player.GuestBuffers[index] = buffer;
|
||||
Trace($"{kind}_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
|
||||
}
|
||||
|
||||
if (allocated)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
player.GuestBuffers[index] = buffer;
|
||||
Trace($"texture_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
|
||||
}
|
||||
if (!player.TextureAllocatorFailed)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
player.TextureAllocatorFailed = true;
|
||||
}
|
||||
|
||||
if (!KernelMemoryCompatExports.TryAllocateHleData(
|
||||
@@ -1043,158 +1015,25 @@ public static class AvPlayerExports
|
||||
height = 0;
|
||||
framesPerSecond = 30.0;
|
||||
durationMilliseconds = 0;
|
||||
var ffmpeg = FindFfmpeg();
|
||||
if (ffmpeg is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
|
||||
if (!File.Exists(ffprobe))
|
||||
|
||||
if (!FfmpegMediaStream.TryProbe(path, out width, out height, out var rate, out var duration))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo(ffprobe)
|
||||
if (rate > 0)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-v");
|
||||
startInfo.ArgumentList.Add("error");
|
||||
startInfo.ArgumentList.Add("-select_streams");
|
||||
startInfo.ArgumentList.Add("v:0");
|
||||
startInfo.ArgumentList.Add("-show_entries");
|
||||
startInfo.ArgumentList.Add("stream=width,height,avg_frame_rate,duration");
|
||||
startInfo.ArgumentList.Add("-of");
|
||||
startInfo.ArgumentList.Add("default=noprint_wrappers=1");
|
||||
startInfo.ArgumentList.Add(path);
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
Console.Error.WriteLine($"[AVPLAYER][FFPROBE] {error.Trim()}");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var separator = line.IndexOf('=');
|
||||
if (separator < 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var key = line[..separator];
|
||||
var value = line[(separator + 1)..];
|
||||
switch (key)
|
||||
{
|
||||
case "width":
|
||||
_ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out width);
|
||||
break;
|
||||
case "height":
|
||||
_ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out height);
|
||||
break;
|
||||
case "avg_frame_rate":
|
||||
var parts = value.Split('/');
|
||||
if (parts.Length == 2 &&
|
||||
double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var numerator) &&
|
||||
double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator) &&
|
||||
denominator != 0)
|
||||
{
|
||||
framesPerSecond = numerator / denominator;
|
||||
}
|
||||
break;
|
||||
case "duration":
|
||||
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var duration))
|
||||
{
|
||||
durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return width > 0 && height > 0 && framesPerSecond > 0;
|
||||
framesPerSecond = rate;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
|
||||
|
||||
if (duration > 0)
|
||||
{
|
||||
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to probe video: {exception.Message}");
|
||||
return false;
|
||||
durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0)));
|
||||
}
|
||||
|
||||
return width > 0 && height > 0 && framesPerSecond > 0;
|
||||
}
|
||||
|
||||
internal static string? FindFfmpeg() =>
|
||||
FindFfmpeg(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
|
||||
Environment.GetEnvironmentVariable("PATH"),
|
||||
OperatingSystem.IsWindows(),
|
||||
AppContext.BaseDirectory);
|
||||
|
||||
internal static string? FindFfmpeg(
|
||||
string? configured,
|
||||
string? searchPath,
|
||||
bool isWindows,
|
||||
string? baseDirectory = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
|
||||
if (!string.IsNullOrWhiteSpace(baseDirectory))
|
||||
{
|
||||
foreach (var candidate in new[]
|
||||
{
|
||||
Path.Combine(baseDirectory, executable),
|
||||
Path.Combine(baseDirectory, "ffmpeg", executable),
|
||||
})
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var directory in (searchPath ?? string.Empty)
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var candidate = Path.Combine(RemovePathQuotes(directory), executable);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string GetFfprobePath(string ffmpeg, bool isWindows) =>
|
||||
Path.Combine(
|
||||
Path.GetDirectoryName(ffmpeg) ?? string.Empty,
|
||||
isWindows ? "ffprobe.exe" : "ffprobe");
|
||||
|
||||
private static string RemovePathQuotes(string directory) =>
|
||||
directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"'
|
||||
? directory[1..^1]
|
||||
: directory;
|
||||
|
||||
internal static string? ResolveGuestPath(string guestPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(guestPath))
|
||||
@@ -1606,4 +1445,17 @@ public static class AvPlayerExports
|
||||
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraceOnce(string key, string message)
|
||||
{
|
||||
lock (TracedOnce)
|
||||
{
|
||||
if (!TracedOnce.Add(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
using SharpEmu.Libs.AvPlayer;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
|
||||
internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
|
||||
{
|
||||
private readonly Process _process;
|
||||
private readonly Stream _output;
|
||||
private int _errorLines;
|
||||
private int _disposed;
|
||||
|
||||
private FfmpegBinkFrameSource(
|
||||
Process process,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator)
|
||||
{
|
||||
_process = process;
|
||||
_output = process.StandardOutput.BaseStream;
|
||||
Width = width;
|
||||
Height = height;
|
||||
FramesPerSecondNumerator = framesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = framesPerSecondDenominator;
|
||||
}
|
||||
|
||||
public uint Width { get; }
|
||||
|
||||
public uint Height { get; }
|
||||
|
||||
public uint FramesPerSecondNumerator { get; }
|
||||
|
||||
public uint FramesPerSecondDenominator { get; }
|
||||
|
||||
internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
|
||||
|
||||
internal static bool TryOpen(
|
||||
string path,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator,
|
||||
out FfmpegBinkFrameSource? source)
|
||||
{
|
||||
source = null;
|
||||
var ffmpeg = AvPlayerExports.FindFfmpeg();
|
||||
if (ffmpeg is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo(ffmpeg)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-nostdin");
|
||||
startInfo.ArgumentList.Add("-hide_banner");
|
||||
startInfo.ArgumentList.Add("-loglevel");
|
||||
startInfo.ArgumentList.Add("error");
|
||||
startInfo.ArgumentList.Add("-i");
|
||||
startInfo.ArgumentList.Add(path);
|
||||
startInfo.ArgumentList.Add("-map");
|
||||
startInfo.ArgumentList.Add("0:v:0");
|
||||
startInfo.ArgumentList.Add("-an");
|
||||
startInfo.ArgumentList.Add("-pix_fmt");
|
||||
startInfo.ArgumentList.Add("bgra");
|
||||
startInfo.ArgumentList.Add("-f");
|
||||
startInfo.ArgumentList.Add("rawvideo");
|
||||
startInfo.ArgumentList.Add("pipe:1");
|
||||
|
||||
try
|
||||
{
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
source = new FfmpegBinkFrameSource(
|
||||
process,
|
||||
width,
|
||||
height,
|
||||
framesPerSecondNumerator,
|
||||
framesPerSecondDenominator);
|
||||
process.ErrorDataReceived += source.OnErrorData;
|
||||
process.BeginErrorReadLine();
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or
|
||||
InvalidOperationException or
|
||||
System.ComponentModel.Win32Exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
try
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < destination.Length)
|
||||
{
|
||||
var read = _output.Read(destination[offset..]);
|
||||
if (read == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or ObjectDisposedException)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
|
||||
Interlocked.Increment(ref _errorLines) > 20)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_output.Dispose();
|
||||
try
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
_process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,13 @@ namespace SharpEmu.Libs.Font;
|
||||
|
||||
public static class FontExports
|
||||
{
|
||||
private const ushort GlyphMagic = 0x0F03;
|
||||
private const int GlyphSize = 0x100;
|
||||
private const int GlyphMetricsSize = 8 * sizeof(float);
|
||||
private const int RenderOutputSize = 0x40;
|
||||
|
||||
private static readonly object AllocationGate = new();
|
||||
private static readonly Stack<ulong> FreeGlyphs = new();
|
||||
private static ulong _librarySelectionAddress;
|
||||
private static ulong _rendererSelectionAddress;
|
||||
|
||||
@@ -321,6 +327,151 @@ public static class FontExports
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "C-4Qw5Srlyw",
|
||||
ExportName = "sceFontGenerateCharGlyph",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int GenerateCharGlyph(CpuContext ctx)
|
||||
{
|
||||
var outputAddress = ctx[CpuRegister.Rcx];
|
||||
if (outputAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
if (!TryRentGlyph(ctx, out var glyph) ||
|
||||
!ctx.TryWriteUInt64(outputAddress, glyph))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "8-zmgsxkBek",
|
||||
ExportName = "sceFontGlyphDefineAttribute",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int GlyphDefineAttribute(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "LHDoRWVFGqk",
|
||||
ExportName = "sceFontDeleteGlyph",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int DeleteGlyph(CpuContext ctx)
|
||||
{
|
||||
var glyphPointerAddress = ctx[CpuRegister.Rsi];
|
||||
if (glyphPointerAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
if (!ctx.TryReadUInt64(glyphPointerAddress, out var glyph))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (glyph != 0)
|
||||
{
|
||||
lock (AllocationGate)
|
||||
{
|
||||
FreeGlyphs.Push(glyph);
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.TryWriteUInt64(glyphPointerAddress, 0)
|
||||
? SetSuccess(ctx)
|
||||
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "kAenWy1Zw5o",
|
||||
ExportName = "sceFontRenderCharGlyphImageHorizontal",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int RenderCharGlyphImageHorizontal(CpuContext ctx)
|
||||
{
|
||||
var metricsAddress = ctx[CpuRegister.Rcx];
|
||||
var resultAddress = ctx[CpuRegister.R8];
|
||||
|
||||
if (metricsAddress != 0)
|
||||
{
|
||||
var values = new[] { 8.0f, 16.0f, 0.0f, 12.0f, 8.0f, 0.0f, 0.0f, 16.0f };
|
||||
for (var index = 0; index < values.Length; index++)
|
||||
{
|
||||
if (!TryWriteUInt32(
|
||||
ctx,
|
||||
metricsAddress + (ulong)(index * sizeof(float)),
|
||||
BitConverter.SingleToUInt32Bits(values[index])))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resultAddress != 0)
|
||||
{
|
||||
Span<byte> cleared = stackalloc byte[RenderOutputSize];
|
||||
cleared.Clear();
|
||||
if (!ctx.Memory.TryWrite(resultAddress, cleared))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "vzHs3C8lWJk",
|
||||
ExportName = "sceFontCloseFont",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int CloseFont(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "1QjhKxrsOB8",
|
||||
ExportName = "sceFontUnbindRenderer",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int UnbindRenderer(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "exAxkyVLt0s",
|
||||
ExportName = "sceFontDestroyRenderer",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int DestroyRenderer(CpuContext ctx)
|
||||
{
|
||||
var rendererPointerAddress = ctx[CpuRegister.Rdi];
|
||||
if (rendererPointerAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
return ctx.TryWriteUInt64(rendererPointerAddress, 0)
|
||||
? SetSuccess(ctx)
|
||||
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
private static bool TryRentGlyph(CpuContext ctx, out ulong glyph)
|
||||
{
|
||||
lock (AllocationGate)
|
||||
{
|
||||
if (FreeGlyphs.Count > 0)
|
||||
{
|
||||
glyph = FreeGlyphs.Pop();
|
||||
return TryWriteUInt16(ctx, glyph, GlyphMagic);
|
||||
}
|
||||
}
|
||||
|
||||
return TryAllocateOpaque(ctx, GlyphSize, out glyph) &&
|
||||
TryWriteUInt16(ctx, glyph, GlyphMagic);
|
||||
}
|
||||
|
||||
private static int ReturnSelection(CpuContext ctx, ref ulong selectionAddress, uint objectSize)
|
||||
{
|
||||
if (ctx[CpuRegister.Rdi] != 0)
|
||||
|
||||
@@ -253,7 +253,7 @@ internal static partial class MetalVideoPresenter
|
||||
if (writeBackBuffers.Count > 0)
|
||||
{
|
||||
var committed = FlushBatchedGuestCommands();
|
||||
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
|
||||
WaitForCommittedCommandBuffer(committed);
|
||||
WriteBuffersBackToGuest(writeBackBuffers);
|
||||
}
|
||||
|
||||
|
||||
@@ -580,7 +580,7 @@ internal static partial class MetalVideoPresenter
|
||||
if (writeBackBuffers.Count > 0)
|
||||
{
|
||||
var committed = FlushBatchedGuestCommands();
|
||||
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
|
||||
WaitForCommittedCommandBuffer(committed);
|
||||
WriteBuffersBackToGuest(writeBackBuffers);
|
||||
}
|
||||
|
||||
@@ -668,7 +668,7 @@ internal static partial class MetalVideoPresenter
|
||||
TagSnapshotResources(commandBuffer);
|
||||
if (writeBackBuffers.Count > 0)
|
||||
{
|
||||
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("waitUntilCompleted"));
|
||||
WaitForCommittedCommandBuffer(commandBuffer);
|
||||
WriteBuffersBackToGuest(writeBackBuffers);
|
||||
}
|
||||
|
||||
@@ -2082,6 +2082,30 @@ internal static partial class MetalVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until a committed command buffer finishes. Skips the ObjC wait
|
||||
/// when status is already Completed (common for tiny label/writeback
|
||||
/// batches), avoiding redundant waitUntilCompleted round-trips on the
|
||||
/// ordered queue.
|
||||
/// </summary>
|
||||
private static void WaitForCommittedCommandBuffer(nint commandBuffer)
|
||||
{
|
||||
if (commandBuffer == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// MTLCommandBufferStatusCompleted = 4.
|
||||
const nint completed = 4;
|
||||
if (MetalNative.Send(commandBuffer, MetalNative.Selector("status")) >= completed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("waitUntilCompleted"));
|
||||
}
|
||||
|
||||
private static void ReturnPooledGuestData(TranslatedGuestDraw draw)
|
||||
{
|
||||
foreach (var buffer in draw.GlobalMemoryBuffers)
|
||||
|
||||
@@ -646,6 +646,30 @@ internal static partial class MetalVideoPresenter
|
||||
var pixelSize = hostWindow.PixelSize;
|
||||
var width = (double)pixelSize.Width;
|
||||
var height = (double)pixelSize.Height;
|
||||
|
||||
// Retina hosts often present at 3840x2160 into a 1920x1080 window.
|
||||
// Cap is opt-in: defaulting it on silently drops present resolution for
|
||||
// every Metal title. SHARPEMU_METAL_CAP_DRAWABLE=1 enables the long-edge
|
||||
// cap; SHARPEMU_METAL_FULL_DRAWABLE=1 remains a no-op when the cap is off.
|
||||
if (string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_METAL_CAP_DRAWABLE"),
|
||||
"1",
|
||||
StringComparison.Ordinal) &&
|
||||
!string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_METAL_FULL_DRAWABLE"),
|
||||
"1",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
const double maxLongEdge = 1920.0;
|
||||
var longEdge = Math.Max(width, height);
|
||||
if (longEdge > maxLongEdge)
|
||||
{
|
||||
var scale = maxLongEdge / longEdge;
|
||||
width = Math.Round(width * scale);
|
||||
height = Math.Round(height * scale);
|
||||
}
|
||||
}
|
||||
|
||||
if (width == _drawableWidth && height == _drawableHeight)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Libs;
|
||||
|
||||
/// <summary>
|
||||
/// Key equivalence for caches and comparisons over <em>host</em> filesystem
|
||||
/// paths. Windows resolves names case-insensitively, but Linux hosts are
|
||||
/// case-sensitive and the guest filesystem is too, so a dump can legitimately
|
||||
/// contain "DATA.BIN" alongside "Data.bin". An ignore-case cache aliases those
|
||||
/// distinct files into one entry there, which silently serves the wrong bytes
|
||||
/// or drops one of them entirely.
|
||||
/// </summary>
|
||||
internal static class HostFsPath
|
||||
{
|
||||
public static readonly StringComparer Comparer =
|
||||
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||
|
||||
public static readonly StringComparison Comparison =
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
}
|
||||
@@ -323,6 +323,37 @@ public static class JsonExports
|
||||
return 0;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "wLsJlmgEIaI",
|
||||
ExportName = "_ZN3sce4Json5Value10referValueERKNS0_6StringE",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceJson")]
|
||||
public static int ValueReferValue(CpuContext ctx)
|
||||
{
|
||||
var thisAddress = ctx[CpuRegister.Rdi];
|
||||
var keyStringAddress = ctx[CpuRegister.Rsi];
|
||||
|
||||
if (thisAddress == 0 ||
|
||||
!_strings.TryGetValue(keyStringAddress, out var keyState) ||
|
||||
!TryAllocateGuestObject(ctx, ValueObjectSize, out var childAddress))
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
var parent = GetValue(thisAddress);
|
||||
|
||||
var child = parent.ValueKind == System.Text.Json.JsonValueKind.Object &&
|
||||
parent.TryGetProperty(keyState.Value, out var property)
|
||||
? property.Clone() : _nullElement;
|
||||
|
||||
StoreValue(ctx, childAddress, child);
|
||||
|
||||
ctx[CpuRegister.Rax] = childAddress;
|
||||
TraceJsonText("Value.referValue", thisAddress, keyState.Value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "zTwZdI8AZ5Y",
|
||||
ExportName = "_ZNK3sce4Json5Value10getBooleanEv",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Ampr;
|
||||
using SharpEmu.Libs.Bink;
|
||||
using SharpEmu.Libs.Media;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
@@ -97,7 +97,7 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
private static readonly object _fdGate = new();
|
||||
private static readonly Dictionary<int, FileStream> _openFiles = new();
|
||||
private static readonly Dictionary<int, Bink2MovieBridge.BinkGuestCompletionShim>
|
||||
private static readonly Dictionary<int, HostMovieBridge.BinkGuestCompletionShim>
|
||||
_binkGuestCompletionShims = new();
|
||||
private static readonly Dictionary<int, string> _observedBinkGuestFiles = new();
|
||||
private static readonly Dictionary<int, OpenDirectory> _openDirectories = new();
|
||||
@@ -117,17 +117,12 @@ public static partial class KernelMemoryCompatExports
|
||||
private static readonly Dictionary<string, string> _guestMounts = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly HashSet<string> _tracedStatResults = new(StringComparer.Ordinal);
|
||||
// Both caches memoize host filesystem probe outcomes, so their key
|
||||
// equivalence must match the host filesystem's: Windows resolves names
|
||||
// case-insensitively, but Linux hosts are case-sensitive, and an
|
||||
// ignore-case cache there aliases distinct paths — a cached miss for
|
||||
// "/app0/DATA.BIN" keeps answering NOT_FOUND for "/app0/Data.bin" even
|
||||
// though that file exists and a fresh probe would find it.
|
||||
private static readonly StringComparer HostFsPathComparer =
|
||||
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||
private static readonly StringComparison HostFsPathComparison =
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
private static readonly HashSet<string> _negativeStatCache = new(HostFsPathComparer);
|
||||
private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(HostFsPathComparer);
|
||||
// equivalence must match the host filesystem's — see HostFsPath. On a
|
||||
// case-sensitive host an ignore-case cache aliases distinct paths: a
|
||||
// cached miss for "/app0/DATA.BIN" keeps answering NOT_FOUND for
|
||||
// "/app0/Data.bin" even though that file exists.
|
||||
private static readonly HashSet<string> _negativeStatCache = new(HostFsPath.Comparer);
|
||||
private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(HostFsPath.Comparer);
|
||||
private static long _nextFileDescriptor = 2;
|
||||
private static string _applicationTitleId = "UNKNOWN";
|
||||
|
||||
@@ -1478,7 +1473,7 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
try
|
||||
{
|
||||
if (Bink2MovieBridge.ShouldSkipGuestMovie(hostPath))
|
||||
if (HostMovieBridge.ShouldSkipGuestMovie(hostPath))
|
||||
{
|
||||
LogOpenTrace(
|
||||
"_open bink-skip path='" + guestPath + "' host='" + hostPath +
|
||||
@@ -1489,10 +1484,10 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
Bink2MovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
|
||||
HostMovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
|
||||
var observedBinkMovie = false;
|
||||
var useBinkCompletionShim = access == FileAccess.Read &&
|
||||
Bink2MovieBridge.TryTakeOverGuestMovie(
|
||||
HostMovieBridge.TryTakeOverGuestMovie(
|
||||
hostPath,
|
||||
out binkCompletionShim,
|
||||
out observedBinkMovie);
|
||||
@@ -2227,7 +2222,7 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
if (notifyBinkClose)
|
||||
{
|
||||
Bink2MovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
|
||||
HostMovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
|
||||
}
|
||||
stream.Dispose();
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
@@ -2256,7 +2251,7 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
FileStream? stream;
|
||||
Bink2MovieBridge.BinkGuestCompletionShim completionShim = default;
|
||||
HostMovieBridge.BinkGuestCompletionShim completionShim = default;
|
||||
var useBinkCompletionShim = false;
|
||||
lock (_fdGate)
|
||||
{
|
||||
@@ -2289,7 +2284,7 @@ public static partial class KernelMemoryCompatExports
|
||||
// logic can't race ahead of what's still on screen.
|
||||
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
|
||||
{
|
||||
Bink2MovieBridge.WaitForHostPlaybackToFinish(stream.Name);
|
||||
HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name);
|
||||
}
|
||||
}
|
||||
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
|
||||
@@ -3528,6 +3523,33 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "mkgXxsoxWHg",
|
||||
ExportName = "sceKernelClearVirtualRangeName",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int KernelClearVirtualRangeName(CpuContext ctx)
|
||||
{
|
||||
var address = ctx[CpuRegister.Rdi];
|
||||
var length = ctx[CpuRegister.Rsi];
|
||||
|
||||
lock (_memoryGate)
|
||||
{
|
||||
if (!TryFindVirtualQueryRegionLocked(address, findNext: false, out var region) ||
|
||||
length > region.Length ||
|
||||
address < region.Address ||
|
||||
length > region.Address + region.Length - address)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
_mappedRegionNames.Remove(region.Address);
|
||||
}
|
||||
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "rVjRvHJ0X6c",
|
||||
ExportName = "sceKernelVirtualQuery",
|
||||
@@ -5176,8 +5198,8 @@ public static partial class KernelMemoryCompatExports
|
||||
// host would let a relative path escape into a sibling directory that
|
||||
// differs from the mount root only by case (root ".../Save" vs
|
||||
// sibling ".../save").
|
||||
if (!string.Equals(candidate, matchedHostRoot, HostFsPathComparison) &&
|
||||
!candidate.StartsWith(rootWithSeparator, HostFsPathComparison))
|
||||
if (!string.Equals(candidate, matchedHostRoot, HostFsPath.Comparison) &&
|
||||
!candidate.StartsWith(rootWithSeparator, HostFsPath.Comparison))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -5278,8 +5300,8 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
var rootWithSeparator =
|
||||
Path.TrimEndingDirectorySeparator(fullRoot) + Path.DirectorySeparatorChar;
|
||||
if (!string.Equals(candidate, fullRoot, HostFsPathComparison) &&
|
||||
!candidate.StartsWith(rootWithSeparator, HostFsPathComparison))
|
||||
if (!string.Equals(candidate, fullRoot, HostFsPath.Comparison) &&
|
||||
!candidate.StartsWith(rootWithSeparator, HostFsPath.Comparison))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
@@ -5305,7 +5327,7 @@ public static partial class KernelMemoryCompatExports
|
||||
private static bool EscapesMountViaReparsePoint(string mountRoot, string candidate)
|
||||
{
|
||||
var rootTrimmed = Path.TrimEndingDirectorySeparator(mountRoot);
|
||||
if (string.Equals(candidate, rootTrimmed, HostFsPathComparison))
|
||||
if (string.Equals(candidate, rootTrimmed, HostFsPath.Comparison))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -918,15 +918,8 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
// Several Gen5 runtimes layer their own owner/count bookkeeping
|
||||
// over a NORMAL kernel mutex. Returning EDEADLK here
|
||||
// leaves that guest bookkeeping out of sync with the HLE owner and
|
||||
// turns the wrapper into a permanent lock/unlock retry loop. Keep
|
||||
// the compatibility recursion used by the original implementation;
|
||||
// ERRORCHECK mutexes still take the strict EDEADLK path below.
|
||||
state.RecursionCount++;
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1264,15 +1257,15 @@ public static class KernelPthreadCompatExports
|
||||
return CreateImplicitMutexState(ctx, mutexAddress, MutexTypeAdaptiveNp, out resolvedAddress, out state);
|
||||
}
|
||||
|
||||
if (pointedHandle != 0 && pointedHandle != mutexAddress && _mutexStates.TryGetValue(pointedHandle, out state))
|
||||
{
|
||||
_mutexStates[mutexAddress] = state;
|
||||
resolvedAddress = pointedHandle;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pointedHandle != 0)
|
||||
{
|
||||
if (_mutexStates.TryGetValue(pointedHandle, out state))
|
||||
{
|
||||
_mutexStates.TryAdd(mutexAddress, state);
|
||||
resolvedAddress = pointedHandle;
|
||||
return true;
|
||||
}
|
||||
|
||||
resolvedAddress = pointedHandle;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,26 @@ internal static class KernelPthreadState
|
||||
return Threads.TryGetValue(threadHandle, out identity);
|
||||
}
|
||||
|
||||
internal static bool TryGetCurrentThreadIdentity(
|
||||
out ulong threadHandle,
|
||||
out ThreadIdentity identity)
|
||||
{
|
||||
threadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
if (threadHandle != 0 && TryGetThreadIdentity(threadHandle, out identity))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
threadHandle = _currentThreadHandle;
|
||||
if (threadHandle != 0 && TryGetThreadIdentity(threadHandle, out identity))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
identity = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ThreadIdentity EnsureGuestThreadIdentity(ulong guestThreadHandle)
|
||||
{
|
||||
if (Threads.TryGetValue(guestThreadHandle, out var existing))
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Kernel;
|
||||
|
||||
/// <summary>
|
||||
/// Shared formatting for opt-in kernel synchronization diagnostics.
|
||||
/// Callers must gate this behind their trace flag so normal synchronization
|
||||
/// paths do not allocate strings or walk guest frame chains.
|
||||
/// </summary>
|
||||
internal static class KernelSyncTraceFormatter
|
||||
{
|
||||
internal static string FormatContext(CpuContext ctx)
|
||||
{
|
||||
_ = KernelPthreadState.TryGetCurrentThreadIdentity(out var pthread, out var identity);
|
||||
var threadName = identity.Name ?? "<unknown>";
|
||||
var returnRip = GuestThreadExecution.TryGetCurrentImportCallFrame(out var importFrame)
|
||||
? importFrame.ReturnRip
|
||||
: TryReadReturnRip(ctx);
|
||||
|
||||
return $"thread='{threadName}' pthread=0x{pthread:X16} " +
|
||||
$"gth=0x{GuestThreadExecution.CurrentGuestThreadHandle:X16} " +
|
||||
$"managed={Environment.CurrentManagedThreadId} ret=0x{returnRip:X16} " +
|
||||
$"frames={FormatFrameChain(ctx)}";
|
||||
}
|
||||
|
||||
internal static string FormatCurrentThread()
|
||||
{
|
||||
_ = KernelPthreadState.TryGetCurrentThreadIdentity(out var pthread, out var identity);
|
||||
var threadName = identity.Name ?? Thread.CurrentThread.Name ?? "<unknown>";
|
||||
return $"thread='{threadName}' pthread=0x{pthread:X16} " +
|
||||
$"gth=0x{GuestThreadExecution.CurrentGuestThreadHandle:X16} " +
|
||||
$"managed={Environment.CurrentManagedThreadId}";
|
||||
}
|
||||
|
||||
internal static string FormatFrameChain(CpuContext ctx)
|
||||
{
|
||||
Span<ulong> returns = stackalloc ulong[4];
|
||||
var count = 0;
|
||||
var frame = ctx[CpuRegister.Rbp];
|
||||
while (count < returns.Length && frame != 0)
|
||||
{
|
||||
if (!ctx.TryReadUInt64(frame, out var nextFrame) ||
|
||||
!ctx.TryReadUInt64(frame + sizeof(ulong), out var returnAddress))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
returns[count++] = returnAddress;
|
||||
if (nextFrame <= frame || nextFrame - frame > 0x100000)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
frame = nextFrame;
|
||||
}
|
||||
|
||||
return count switch
|
||||
{
|
||||
0 => "none",
|
||||
1 => $"0x{returns[0]:X16}",
|
||||
2 => $"0x{returns[0]:X16},0x{returns[1]:X16}",
|
||||
3 => $"0x{returns[0]:X16},0x{returns[1]:X16},0x{returns[2]:X16}",
|
||||
_ => $"0x{returns[0]:X16},0x{returns[1]:X16}," +
|
||||
$"0x{returns[2]:X16},0x{returns[3]:X16}",
|
||||
};
|
||||
}
|
||||
|
||||
private static ulong TryReadReturnRip(CpuContext ctx)
|
||||
{
|
||||
_ = ctx.TryReadUInt64(ctx[CpuRegister.Rsp], out var returnRip);
|
||||
return returnRip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using FFmpeg.AutoGen;
|
||||
|
||||
namespace SharpEmu.Libs.Media;
|
||||
internal sealed unsafe class FfmpegMediaStream : Stream
|
||||
{
|
||||
internal const int AudioSampleRate = 48000;
|
||||
internal const int AudioChannels = 2;
|
||||
|
||||
private readonly object _decodeGate = new();
|
||||
private readonly bool _isVideo;
|
||||
private readonly int _videoWidth;
|
||||
private readonly int _videoHeight;
|
||||
|
||||
private AVFormatContext* _formatContext;
|
||||
private AVCodecContext* _codecContext;
|
||||
private AVFrame* _frame;
|
||||
private AVPacket* _packet;
|
||||
private SwsContext* _swsContext;
|
||||
private SwrContext* _swrContext;
|
||||
private int _streamIndex;
|
||||
|
||||
private byte[] _pending = [];
|
||||
private int _pendingOffset;
|
||||
private bool _draining;
|
||||
private bool _finished;
|
||||
private int _disposed;
|
||||
|
||||
private FfmpegMediaStream(bool isVideo, int width, int height)
|
||||
{
|
||||
_isVideo = isVideo;
|
||||
_videoWidth = width;
|
||||
_videoHeight = height;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
|
||||
public override bool CanSeek => false;
|
||||
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
internal static bool TryOpenVideo(
|
||||
string path,
|
||||
int width,
|
||||
int height,
|
||||
out FfmpegMediaStream? stream) =>
|
||||
TryOpen(path, AVMediaType.AVMEDIA_TYPE_VIDEO, width, height, out stream);
|
||||
|
||||
internal static bool TryOpenAudio(string path, out FfmpegMediaStream? stream) =>
|
||||
TryOpen(path, AVMediaType.AVMEDIA_TYPE_AUDIO, 0, 0, out stream);
|
||||
|
||||
private static bool TryOpen(
|
||||
string path,
|
||||
AVMediaType mediaType,
|
||||
int width,
|
||||
int height,
|
||||
out FfmpegMediaStream? stream)
|
||||
{
|
||||
stream = null;
|
||||
FfmpegRuntime.EnsureInitialized();
|
||||
|
||||
var candidate = new FfmpegMediaStream(mediaType == AVMediaType.AVMEDIA_TYPE_VIDEO, width, height);
|
||||
AVFormatContext* formatContext = null;
|
||||
try
|
||||
{
|
||||
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
|
||||
{
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
return false;
|
||||
}
|
||||
|
||||
AVCodec* decoder = null;
|
||||
var streamIndex = ffmpeg.av_find_best_stream(formatContext, mediaType, -1, -1, &decoder, 0);
|
||||
if (streamIndex < 0 || decoder is null)
|
||||
{
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
return false;
|
||||
}
|
||||
|
||||
var codecContext = ffmpeg.avcodec_alloc_context3(decoder);
|
||||
if (codecContext is null ||
|
||||
ffmpeg.avcodec_parameters_to_context(
|
||||
codecContext,
|
||||
formatContext->streams[streamIndex]->codecpar) < 0 ||
|
||||
ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
|
||||
{
|
||||
if (codecContext is not null)
|
||||
{
|
||||
ffmpeg.avcodec_free_context(&codecContext);
|
||||
}
|
||||
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
return false;
|
||||
}
|
||||
|
||||
candidate._formatContext = formatContext;
|
||||
candidate._codecContext = codecContext;
|
||||
candidate._streamIndex = streamIndex;
|
||||
candidate._frame = ffmpeg.av_frame_alloc();
|
||||
candidate._packet = ffmpeg.av_packet_alloc();
|
||||
if (candidate._frame is null || candidate._packet is null)
|
||||
{
|
||||
candidate.Dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
stream = candidate;
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[AVPLAYER][ERROR] in-process decoder failed to open '{path}': {exception.Message}");
|
||||
if (formatContext is not null)
|
||||
{
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
}
|
||||
|
||||
candidate.Dispose();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryProbe(
|
||||
string path,
|
||||
out int width,
|
||||
out int height,
|
||||
out double frameRate,
|
||||
out double durationSeconds)
|
||||
{
|
||||
width = 0;
|
||||
height = 0;
|
||||
frameRate = 0;
|
||||
durationSeconds = 0;
|
||||
FfmpegRuntime.EnsureInitialized();
|
||||
|
||||
AVFormatContext* formatContext = null;
|
||||
try
|
||||
{
|
||||
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var streamIndex = ffmpeg.av_find_best_stream(
|
||||
formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, null, 0);
|
||||
if (streamIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stream = formatContext->streams[streamIndex];
|
||||
width = stream->codecpar->width;
|
||||
height = stream->codecpar->height;
|
||||
|
||||
var rate = stream->avg_frame_rate;
|
||||
if (rate.den > 0 && rate.num > 0)
|
||||
{
|
||||
frameRate = (double)rate.num / rate.den;
|
||||
}
|
||||
|
||||
if (stream->duration > 0 && stream->time_base.den > 0)
|
||||
{
|
||||
durationSeconds = stream->duration *
|
||||
((double)stream->time_base.num / stream->time_base.den);
|
||||
}
|
||||
else if (formatContext->duration > 0)
|
||||
{
|
||||
durationSeconds = (double)formatContext->duration / ffmpeg.AV_TIME_BASE;
|
||||
}
|
||||
|
||||
return width > 0 && height > 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (formatContext is not null)
|
||||
{
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) =>
|
||||
Read(buffer.AsSpan(offset, count));
|
||||
|
||||
public override int Read(Span<byte> buffer)
|
||||
{
|
||||
if (buffer.IsEmpty)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
lock (_decodeGate)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var written = 0;
|
||||
while (written < buffer.Length)
|
||||
{
|
||||
if (_pendingOffset >= _pending.Length)
|
||||
{
|
||||
if (_finished || !TryDecodeIntoPending())
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var available = _pending.Length - _pendingOffset;
|
||||
var take = Math.Min(available, buffer.Length - written);
|
||||
_pending.AsSpan(_pendingOffset, take).CopyTo(buffer[written..]);
|
||||
_pendingOffset += take;
|
||||
written += take;
|
||||
}
|
||||
|
||||
return written;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryDecodeIntoPending()
|
||||
{
|
||||
if (!TryReceiveFrame())
|
||||
{
|
||||
_finished = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
var produced = _isVideo ? ConvertVideoFrame() : ConvertAudioFrame();
|
||||
ffmpeg.av_frame_unref(_frame);
|
||||
if (produced is null)
|
||||
{
|
||||
_finished = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
_pending = produced;
|
||||
_pendingOffset = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
private byte[]? ConvertVideoFrame()
|
||||
{
|
||||
var width = _videoWidth > 0 ? _videoWidth : _frame->width;
|
||||
var height = _videoHeight > 0 ? _videoHeight : _frame->height;
|
||||
if (width <= 0 || height <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_swsContext = ffmpeg.sws_getCachedContext(
|
||||
_swsContext,
|
||||
_frame->width,
|
||||
_frame->height,
|
||||
(AVPixelFormat)_frame->format,
|
||||
width,
|
||||
height,
|
||||
AVPixelFormat.AV_PIX_FMT_NV12,
|
||||
ffmpeg.SWS_FAST_BILINEAR,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
if (_swsContext is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var lumaBytes = width * height;
|
||||
var output = new byte[lumaBytes + lumaBytes / 2];
|
||||
fixed (byte* outputPointer = output)
|
||||
{
|
||||
var planes = new byte*[4] { outputPointer, outputPointer + lumaBytes, null, null };
|
||||
var strides = new int[4] { width, width, 0, 0 };
|
||||
var rows = ffmpeg.sws_scale(
|
||||
_swsContext,
|
||||
_frame->data,
|
||||
_frame->linesize,
|
||||
0,
|
||||
_frame->height,
|
||||
planes,
|
||||
strides);
|
||||
return rows == height ? output : null;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[]? ConvertAudioFrame()
|
||||
{
|
||||
var outputLayout = new AVChannelLayout();
|
||||
ffmpeg.av_channel_layout_default(&outputLayout, AudioChannels);
|
||||
|
||||
var inputLayout = _frame->ch_layout;
|
||||
SwrContext* swrContext = _swrContext;
|
||||
var configureResult = ffmpeg.swr_alloc_set_opts2(
|
||||
&swrContext,
|
||||
&outputLayout,
|
||||
AVSampleFormat.AV_SAMPLE_FMT_S16,
|
||||
AudioSampleRate,
|
||||
&inputLayout,
|
||||
(AVSampleFormat)_frame->format,
|
||||
_frame->sample_rate,
|
||||
0,
|
||||
null);
|
||||
_swrContext = swrContext;
|
||||
if (configureResult < 0 || _swrContext is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ffmpeg.swr_is_initialized(_swrContext) == 0 && ffmpeg.swr_init(_swrContext) < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var maxSamples = (int)ffmpeg.av_rescale_rnd(
|
||||
ffmpeg.swr_get_delay(_swrContext, _frame->sample_rate) + _frame->nb_samples,
|
||||
AudioSampleRate,
|
||||
_frame->sample_rate,
|
||||
AVRounding.AV_ROUND_UP);
|
||||
if (maxSamples <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var output = new byte[maxSamples * AudioChannels * sizeof(short)];
|
||||
fixed (byte* outputPointer = output)
|
||||
{
|
||||
var planes = stackalloc byte*[1];
|
||||
planes[0] = outputPointer;
|
||||
var converted = ffmpeg.swr_convert(
|
||||
_swrContext,
|
||||
planes,
|
||||
maxSamples,
|
||||
_frame->extended_data,
|
||||
_frame->nb_samples);
|
||||
if (converted < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var byteCount = converted * AudioChannels * sizeof(short);
|
||||
if (byteCount == output.Length)
|
||||
{
|
||||
return output;
|
||||
}
|
||||
|
||||
var trimmed = new byte[byteCount];
|
||||
output.AsSpan(0, byteCount).CopyTo(trimmed);
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReceiveFrame()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
|
||||
if (receiveResult >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (receiveResult == ffmpeg.AVERROR_EOF ||
|
||||
receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN) ||
|
||||
_draining)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryFeedPacket())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryFeedPacket()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var readResult = ffmpeg.av_read_frame(_formatContext, _packet);
|
||||
if (readResult < 0)
|
||||
{
|
||||
_draining = true;
|
||||
return ffmpeg.avcodec_send_packet(_codecContext, null) >= 0;
|
||||
}
|
||||
|
||||
if (_packet->stream_index != _streamIndex)
|
||||
{
|
||||
ffmpeg.av_packet_unref(_packet);
|
||||
continue;
|
||||
}
|
||||
|
||||
var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
|
||||
ffmpeg.av_packet_unref(_packet);
|
||||
return sendResult >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_decodeGate)
|
||||
{
|
||||
if (_swsContext is not null)
|
||||
{
|
||||
ffmpeg.sws_freeContext(_swsContext);
|
||||
_swsContext = null;
|
||||
}
|
||||
|
||||
if (_swrContext is not null)
|
||||
{
|
||||
var swrContext = _swrContext;
|
||||
ffmpeg.swr_free(&swrContext);
|
||||
_swrContext = null;
|
||||
}
|
||||
|
||||
if (_frame is not null)
|
||||
{
|
||||
var frame = _frame;
|
||||
ffmpeg.av_frame_free(&frame);
|
||||
_frame = null;
|
||||
}
|
||||
|
||||
if (_packet is not null)
|
||||
{
|
||||
var packet = _packet;
|
||||
ffmpeg.av_packet_free(&packet);
|
||||
_packet = null;
|
||||
}
|
||||
|
||||
if (_codecContext is not null)
|
||||
{
|
||||
var codecContext = _codecContext;
|
||||
ffmpeg.avcodec_free_context(&codecContext);
|
||||
_codecContext = null;
|
||||
}
|
||||
|
||||
if (_formatContext is not null)
|
||||
{
|
||||
var formatContext = _formatContext;
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
_formatContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using FFmpeg.AutoGen;
|
||||
|
||||
namespace SharpEmu.Libs.Media;
|
||||
internal static class FfmpegRuntime
|
||||
{
|
||||
private static readonly object _gate = new();
|
||||
private static bool _initialized;
|
||||
internal static void EnsureInitialized()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
DynamicallyLoadedBindings.Initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-36
@@ -5,7 +5,7 @@ using System.Buffers;
|
||||
using FFmpeg.AutoGen;
|
||||
using SharpEmu.HLE.Host;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
namespace SharpEmu.Libs.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
|
||||
@@ -13,7 +13,7 @@ namespace SharpEmu.Libs.Bink;
|
||||
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
|
||||
/// bridge of our own to build. See docs/bink2-bridge.md.
|
||||
/// </summary>
|
||||
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
||||
internal sealed unsafe class FfmpegVideoDecoder : IMediaFrameDecoder
|
||||
{
|
||||
private const int OutputAudioChannels = 2;
|
||||
private const int OutputAudioBytesPerSample = sizeof(short);
|
||||
@@ -48,7 +48,7 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
||||
|
||||
public uint FramesPerSecondDenominator { get; }
|
||||
|
||||
private FfmpegNativeBinkFrameSource(
|
||||
private FfmpegVideoDecoder(
|
||||
AVFormatContext* formatContext,
|
||||
AVCodecContext* codecContext,
|
||||
int videoStreamIndex,
|
||||
@@ -77,43 +77,14 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
||||
_packet = ffmpeg.av_packet_alloc();
|
||||
}
|
||||
|
||||
private static bool _rootPathInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI
|
||||
/// downloads next to the executable (see SharpEmu.CLI.csproj's
|
||||
/// FetchFfmpegRuntime target); kept as loose files rather than embedded
|
||||
/// in the single-file bundle so the OS loader can resolve the normal
|
||||
/// inter-library dependencies (avcodec depends on avutil, etc.) itself.
|
||||
/// </summary>
|
||||
private static void EnsureRootPathInitialized()
|
||||
{
|
||||
if (_rootPathInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_rootPathInitialized = true;
|
||||
// SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a
|
||||
// "plugins" subfolder next to the executable rather than flat beside
|
||||
// it (see NativeLibraryFolderName in SharpEmu.CLI.csproj).
|
||||
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
|
||||
// ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize()
|
||||
// itself, but that constructor fires on first touch of the ffmpeg type --
|
||||
// which is the RootPath assignment above -- so it binds against the
|
||||
// default (empty) RootPath before the assignment's own setter body runs.
|
||||
// Every function resolved during that first pass permanently throws
|
||||
// NotSupportedException. Re-running Initialize() now, with RootPath
|
||||
// actually set, rebinds everything against the real search path.
|
||||
DynamicallyLoadedBindings.Initialize();
|
||||
}
|
||||
private static void EnsureRootPathInitialized() =>
|
||||
SharpEmu.Libs.Media.FfmpegRuntime.EnsureInitialized();
|
||||
|
||||
internal static bool TryOpen(
|
||||
string path,
|
||||
uint maximumWidth,
|
||||
uint maximumHeight,
|
||||
out FfmpegNativeBinkFrameSource? source)
|
||||
out FfmpegVideoDecoder? source)
|
||||
{
|
||||
source = null;
|
||||
EnsureRootPathInitialized();
|
||||
@@ -222,7 +193,7 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
||||
outputHeight = Math.Max(1, outputHeight);
|
||||
}
|
||||
|
||||
source = new FfmpegNativeBinkFrameSource(
|
||||
source = new FfmpegVideoDecoder(
|
||||
formatContext,
|
||||
codecContext,
|
||||
videoStreamIndex,
|
||||
+34
-53
@@ -4,29 +4,44 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
namespace SharpEmu.Libs.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Optional host-side Bink 2 bridge for games that ship a static Bink player.
|
||||
/// Host-side movie bridge for games that decode video inside their own
|
||||
/// executable instead of going through an HLE decoder.
|
||||
///
|
||||
/// The game in that case never imports libSceVideodec, so an HLE video-decoder
|
||||
/// export cannot see its movie frames. Kernel file opens identify the active
|
||||
/// .bk2 file and the presenter requests BGRA frames from a tiny native adapter.
|
||||
/// The adapter is deliberately a separate, user-supplied library: Bink 2 is a
|
||||
/// proprietary SDK and SharpEmu must neither bundle it nor depend on its ABI.
|
||||
/// Such a game never imports libSceVideodec or sceAvPlayer, so no HLE export
|
||||
/// can see its movie frames. Kernel file opens identify the active movie and
|
||||
/// the presenter requests BGRA frames from <see cref="FfmpegVideoDecoder"/> —
|
||||
/// the same decoder sceAvPlayer uses, so every format is handled in one place.
|
||||
/// </summary>
|
||||
internal static class Bink2MovieBridge
|
||||
internal static class HostMovieBridge
|
||||
{
|
||||
private const uint MaxDimension = 16384;
|
||||
private const uint MaxHostVideoWidth = 1920;
|
||||
private const uint MaxHostVideoHeight = 1080;
|
||||
|
||||
private static readonly string[] SelfDecodedMovieExtensions = [".bk2"];
|
||||
|
||||
private static bool IsSelfDecodedMovie(string hostPath)
|
||||
{
|
||||
foreach (var extension in SelfDecodedMovieExtensions)
|
||||
{
|
||||
if (hostPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static string? _activePath;
|
||||
private static Bink2MovieInfo _activeInfo;
|
||||
private static byte[]? _frameBuffer;
|
||||
private static bool _frameBufferPresented;
|
||||
private static BinkFramePlayback? _playback;
|
||||
private static MediaFramePlayback? _playback;
|
||||
private static long _frameSerial;
|
||||
private static uint _presentationWidth = MaxHostVideoWidth;
|
||||
private static uint _presentationHeight = MaxHostVideoHeight;
|
||||
@@ -62,7 +77,7 @@ internal static class Bink2MovieBridge
|
||||
/// statically linked into its executable.
|
||||
/// </summary>
|
||||
internal static bool ShouldSkipGuestMovie(string hostPath) =>
|
||||
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
|
||||
IsSelfDecodedMovie(hostPath) &&
|
||||
ResolveMode() == MovieMode.Skip;
|
||||
|
||||
/// <summary>
|
||||
@@ -71,8 +86,7 @@ internal static class Bink2MovieBridge
|
||||
/// </summary>
|
||||
internal static bool ObserveGuestMovie(string hostPath)
|
||||
{
|
||||
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(hostPath))
|
||||
if (!IsSelfDecodedMovie(hostPath) || !File.Exists(hostPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -186,9 +200,6 @@ internal static class Bink2MovieBridge
|
||||
case MovieMode.Dummy:
|
||||
AttachDummyMovieLocked(hostPath);
|
||||
return;
|
||||
case MovieMode.Ffmpeg:
|
||||
AttachFfmpegMovieLocked(hostPath);
|
||||
return;
|
||||
case MovieMode.Native:
|
||||
AttachNativeMovieLocked(hostPath);
|
||||
return;
|
||||
@@ -197,7 +208,7 @@ internal static class Bink2MovieBridge
|
||||
|
||||
private static void AttachNativeMovieLocked(string hostPath)
|
||||
{
|
||||
if (!FfmpegNativeBinkFrameSource.TryOpen(
|
||||
if (!FfmpegVideoDecoder.TryOpen(
|
||||
hostPath, _presentationWidth, _presentationHeight, out var source) ||
|
||||
source is null)
|
||||
{
|
||||
@@ -250,14 +261,13 @@ internal static class Bink2MovieBridge
|
||||
|
||||
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MovieMode.Ffmpeg;
|
||||
return MovieMode.Native;
|
||||
}
|
||||
|
||||
// Native is the default: FfmpegNativeBinkFrameSource.TryOpen degrades
|
||||
// gracefully (falls back to the guest's own decode, logging one
|
||||
// informational line) if the FFmpeg libraries SharpEmu.CLI.csproj
|
||||
// downloads next to the executable are genuinely unavailable, so
|
||||
// defaulting to Native unconditionally is safe.
|
||||
// Native is the default: FfmpegVideoDecoder.TryOpen degrades gracefully
|
||||
// (falls back to the guest's own decode, logging one informational line)
|
||||
// if the FFmpeg libraries SharpEmu.CLI.csproj downloads next to the
|
||||
// executable are genuinely unavailable, so defaulting to it is safe.
|
||||
return MovieMode.Native;
|
||||
}
|
||||
|
||||
@@ -282,43 +292,15 @@ internal static class Bink2MovieBridge
|
||||
info.Width + "x" + info.Height + ".");
|
||||
}
|
||||
|
||||
private static void AttachFfmpegMovieLocked(string hostPath)
|
||||
{
|
||||
if (!TryReadBinkInfo(hostPath, out var info) || !IsValid(info))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink FFmpeg source has an invalid header: " +
|
||||
Path.GetFileName(hostPath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FfmpegBinkFrameSource.TryOpen(
|
||||
hostPath,
|
||||
info.Width,
|
||||
info.Height,
|
||||
info.FramesPerSecondNumerator,
|
||||
info.FramesPerSecondDenominator,
|
||||
out var source) || source is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AttachPlaybackLocked(hostPath, info, source);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink FFmpeg source attached: " +
|
||||
Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + " @ " +
|
||||
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
|
||||
}
|
||||
|
||||
private static void AttachPlaybackLocked(
|
||||
string hostPath,
|
||||
Bink2MovieInfo info,
|
||||
IBinkFrameDecoder decoder)
|
||||
IMediaFrameDecoder decoder)
|
||||
{
|
||||
CloseActiveLocked();
|
||||
_activePath = hostPath;
|
||||
_activeInfo = info;
|
||||
_playback = new BinkFramePlayback(decoder);
|
||||
_playback = new MediaFramePlayback(decoder);
|
||||
}
|
||||
|
||||
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
|
||||
@@ -406,7 +388,6 @@ internal static class Bink2MovieBridge
|
||||
Skip,
|
||||
Dummy,
|
||||
Native,
|
||||
Ffmpeg,
|
||||
}
|
||||
|
||||
private static readonly Queue<string> PendingMoviePaths = new();
|
||||
+5
-5
@@ -4,9 +4,9 @@
|
||||
using System.Diagnostics;
|
||||
using SharpEmu.HLE.Host;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
namespace SharpEmu.Libs.Media;
|
||||
|
||||
internal interface IBinkFrameDecoder : IDisposable
|
||||
internal interface IMediaFrameDecoder : IDisposable
|
||||
{
|
||||
uint Width { get; }
|
||||
|
||||
@@ -23,12 +23,12 @@ internal interface IBinkFrameDecoder : IDisposable
|
||||
/// Keeps blocking codec work away from the Vulkan presentation thread and
|
||||
/// releases decoded frames according to the movie time base.
|
||||
/// </summary>
|
||||
internal sealed class BinkFramePlayback : IDisposable
|
||||
internal sealed class MediaFramePlayback : IDisposable
|
||||
{
|
||||
private const int BufferCount = 5;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly IBinkFrameDecoder _decoder;
|
||||
private readonly IMediaFrameDecoder _decoder;
|
||||
private readonly Queue<byte[]> _freeBuffers = new();
|
||||
private readonly Queue<DecodedFrame> _decodedFrames = new();
|
||||
private readonly Thread _decoderThread;
|
||||
@@ -45,7 +45,7 @@ internal sealed class BinkFramePlayback : IDisposable
|
||||
private bool _finished;
|
||||
private int _disposed;
|
||||
|
||||
internal BinkFramePlayback(IBinkFrameDecoder decoder)
|
||||
internal MediaFramePlayback(IMediaFrameDecoder decoder)
|
||||
{
|
||||
_decoder = decoder;
|
||||
Width = decoder.Width;
|
||||
@@ -373,6 +373,42 @@ public static class PadExports
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
// Size taken from the caller's own frame rather than assumed: the guest
|
||||
// reserves 0x10 bytes, points the out-param at rbp-0x30, and stores its
|
||||
// stack cookie at rbp-0x28, so only eight bytes belong to the state. A
|
||||
// sixteen-byte write would land on the cookie and fail the stack check.
|
||||
private const int TriggerEffectStateSize = 8;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "znaWI0gpuo8",
|
||||
ExportName = "scePadGetTriggerEffectState",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libScePad")]
|
||||
public static int PadGetTriggerEffectState(CpuContext ctx)
|
||||
{
|
||||
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var stateAddress = ctx[CpuRegister.Rsi];
|
||||
if (!IsPrimaryPadHandle(handle))
|
||||
{
|
||||
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
||||
}
|
||||
|
||||
if (stateAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// No host pad exposes DualSense adaptive-trigger feedback, so every
|
||||
// trigger reports the neutral "no effect engaged" state. Reporting it
|
||||
// as success is what lets the caller take its normal path instead of
|
||||
// falling back to a cached button bitmask every poll.
|
||||
Span<byte> state = stackalloc byte[TriggerEffectStateSize];
|
||||
state.Clear();
|
||||
return ctx.Memory.TryWrite(stateAddress, state)
|
||||
? ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
private static HostAdaptiveTriggerEffect DecodeTriggerEffect(ReadOnlySpan<byte> command)
|
||||
{
|
||||
var mode = BinaryPrimitives.ReadUInt32LittleEndian(command);
|
||||
|
||||
@@ -24,6 +24,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
|
||||
<InternalsVisibleTo Include="SharpEmu.Core" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -144,16 +144,6 @@ public static class UserServiceExports
|
||||
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
// Title-captured alias NID for the same username query.
|
||||
#pragma warning disable SHEM004
|
||||
[SysAbiExport(
|
||||
Nid = "znaWI0gpuo8",
|
||||
ExportName = "sceUserServiceGetUserName",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceUserService")]
|
||||
public static int UserServiceGetUserNameAlt(CpuContext ctx) => UserServiceGetUserName(ctx);
|
||||
#pragma warning restore SHEM004
|
||||
|
||||
// Name not yet in ps5_names.txt and the NID was captured from titles; revisit when the symbol is catalogued.
|
||||
#pragma warning disable SHEM006
|
||||
[SysAbiExport(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpEmu.Libs.VideoOut;
|
||||
|
||||
public static class FlipProgressTracker
|
||||
{
|
||||
private static long _lastFlipTimestamp;
|
||||
private static long _lastFlipVersion;
|
||||
private static int _hasFlipped;
|
||||
|
||||
public static void RecordFlip(long version)
|
||||
{
|
||||
Volatile.Write(ref _lastFlipVersion, version);
|
||||
Volatile.Write(ref _lastFlipTimestamp, Stopwatch.GetTimestamp());
|
||||
Volatile.Write(ref _hasFlipped, 1);
|
||||
}
|
||||
|
||||
public static bool HasFlipped => Volatile.Read(ref _hasFlipped) != 0;
|
||||
|
||||
public static long LastFlipVersion => Volatile.Read(ref _lastFlipVersion);
|
||||
|
||||
public static double? SecondsSinceLastFlip()
|
||||
{
|
||||
if (Volatile.Read(ref _hasFlipped) == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var elapsedTicks = Stopwatch.GetTimestamp() - Volatile.Read(ref _lastFlipTimestamp);
|
||||
return elapsedTicks / (double)Stopwatch.Frequency;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ public static class PerfOverlay
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static long _lastPresentTimestamp;
|
||||
private static long _lastSubmitTimestamp;
|
||||
private static long _sessionStartTimestamp;
|
||||
private static readonly double[] _frameMilliseconds = new double[FrameHistorySize];
|
||||
private static int _frameHistoryIndex;
|
||||
@@ -40,8 +41,11 @@ public static class PerfOverlay
|
||||
|
||||
// Refreshed once per second so per-frame fills never allocate.
|
||||
private static long _statsWindowStart = Stopwatch.GetTimestamp();
|
||||
// Headline FPS tracks guest VideoOut flips (RecordSubmit), not host
|
||||
// swapchain presents. Metal's free-running present timer hits ~120 Hz on
|
||||
// ProMotion even while the guest is stalled on GPU waits.
|
||||
private static double _fps;
|
||||
private static double _submittedFps;
|
||||
private static double _presentFps;
|
||||
private static double _drawsPerSecond;
|
||||
private static double _averageFrameMs;
|
||||
private static double _allocatedMbPerSecond;
|
||||
@@ -64,24 +68,29 @@ public static class PerfOverlay
|
||||
|
||||
public static void Toggle() => _enabled = !_enabled;
|
||||
|
||||
/// <summary>Called by the presenter after each successful present.</summary>
|
||||
/// <summary>Called by the presenter after each successful host present.</summary>
|
||||
public static void RecordPresent()
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
Interlocked.CompareExchange(ref _sessionStartTimestamp, now, 0);
|
||||
var last = _lastPresentTimestamp;
|
||||
_lastPresentTimestamp = now;
|
||||
Interlocked.CompareExchange(ref _sessionStartTimestamp, Stopwatch.GetTimestamp(), 0);
|
||||
Interlocked.Increment(ref _presentedInWindow);
|
||||
if (last != 0)
|
||||
{
|
||||
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
|
||||
_frameMilliseconds[_frameHistoryIndex] = milliseconds;
|
||||
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize;
|
||||
}
|
||||
_lastPresentTimestamp = Stopwatch.GetTimestamp();
|
||||
}
|
||||
|
||||
/// <summary>Called on every guest flip submission.</summary>
|
||||
public static void RecordSubmit() => Interlocked.Increment(ref _submittedInWindow);
|
||||
public static void RecordSubmit()
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
Interlocked.CompareExchange(ref _sessionStartTimestamp, now, 0);
|
||||
Interlocked.Increment(ref _submittedInWindow);
|
||||
var last = Interlocked.Exchange(ref _lastSubmitTimestamp, now);
|
||||
if (last != 0)
|
||||
{
|
||||
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
|
||||
var index = _frameHistoryIndex;
|
||||
_frameMilliseconds[index] = milliseconds;
|
||||
_frameHistoryIndex = (index + 1) % FrameHistorySize;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called per translated draw/dispatch executed.</summary>
|
||||
public static void RecordDraw() => Interlocked.Increment(ref _drawsInWindow);
|
||||
@@ -128,27 +137,47 @@ public static class PerfOverlay
|
||||
{
|
||||
var seconds = (double)elapsedTicks / Stopwatch.Frequency;
|
||||
_statsWindowStart = now;
|
||||
_fps = Interlocked.Exchange(ref _presentedInWindow, 0) / seconds;
|
||||
_submittedFps = Interlocked.Exchange(ref _submittedInWindow, 0) / seconds;
|
||||
_fps = Interlocked.Exchange(ref _submittedInWindow, 0) / seconds;
|
||||
_presentFps = Interlocked.Exchange(ref _presentedInWindow, 0) / seconds;
|
||||
_drawsPerSecond = Interlocked.Exchange(ref _drawsInWindow, 0) / seconds;
|
||||
|
||||
double totalMs = 0;
|
||||
var samples = 0;
|
||||
foreach (var ms in _frameMilliseconds)
|
||||
{
|
||||
if (ms > 0)
|
||||
{
|
||||
totalMs += ms;
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
|
||||
_averageFrameMs = samples > 0 ? totalMs / samples : 0;
|
||||
|
||||
var allocated = GC.GetTotalAllocatedBytes(precise: false);
|
||||
_allocatedMbPerSecond = (allocated - _lastAllocatedBytes) / seconds / (1024.0 * 1024.0);
|
||||
_lastAllocatedBytes = allocated;
|
||||
|
||||
// Headline MS must track *current* guest cadence. Averaging the whole
|
||||
// 128-slot history kept a single 8s boot gap on screen for minutes
|
||||
// (FPS 0 + 8000 MS) even after the stall ended.
|
||||
var lastSubmit = Interlocked.Read(ref _lastSubmitTimestamp);
|
||||
string msLabel;
|
||||
if (_fps > 0)
|
||||
{
|
||||
var lastIndex = (_frameHistoryIndex - 1 + FrameHistorySize) % FrameHistorySize;
|
||||
var lastInterval = _frameMilliseconds[lastIndex];
|
||||
_averageFrameMs = lastInterval > 0 ? lastInterval : 1000.0 / _fps;
|
||||
msLabel = $"{_averageFrameMs:0.0} MS";
|
||||
}
|
||||
else if (lastSubmit != 0)
|
||||
{
|
||||
// No guest flips this window: show stall age, but label it so it
|
||||
// is not read as "the game is rendering 20s frames" during boot
|
||||
// asset load (ALLOC high, DRAWS 0 after splash).
|
||||
_averageFrameMs = (now - lastSubmit) * 1000.0 / Stopwatch.Frequency;
|
||||
msLabel = _allocatedMbPerSecond > 50.0
|
||||
? $"LOAD {_averageFrameMs / 1000.0:0.0}S"
|
||||
: $"STALL {_averageFrameMs / 1000.0:0.0}S";
|
||||
}
|
||||
else if (_presentFps > 0)
|
||||
{
|
||||
_averageFrameMs = 1000.0 / _presentFps;
|
||||
msLabel = $"{_averageFrameMs:0.0} MS";
|
||||
}
|
||||
else
|
||||
{
|
||||
_averageFrameMs = 0;
|
||||
msLabel = "0.0 MS";
|
||||
}
|
||||
|
||||
var gen0 = GC.CollectionCount(0);
|
||||
var gen1 = GC.CollectionCount(1);
|
||||
var gen2 = GC.CollectionCount(2);
|
||||
@@ -174,7 +203,7 @@ public static class PerfOverlay
|
||||
var elapsedHours = elapsedSeconds / 3600;
|
||||
var elapsedMinutes = elapsedSeconds / 60 % 60;
|
||||
var elapsedRemainingSeconds = elapsedSeconds % 60;
|
||||
_line1 = $"FPS {_fps:0.0} FLIP {_submittedFps:0.0} {_averageFrameMs:0.0} MS";
|
||||
_line1 = $"FPS {_fps:0.0} PRES {_presentFps:0.0} {msLabel}";
|
||||
_line2 = $"DRAWS {_drawsPerSecond:0}/S {drawsPerFrame:0}/F Q {pendingWork}+{inFlightSubmissions}";
|
||||
_line3 = $"ALLOC {_allocatedMbPerSecond:0.0} MB/S GC {_gen0PerWindow}/{_gen1PerWindow}/{_gen2PerWindow}";
|
||||
var heapMb = GC.GetTotalMemory(false) / (1024 * 1024);
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SharpEmu.Libs.VideoOut;
|
||||
|
||||
public static unsafe class RenderDocCapture
|
||||
{
|
||||
private const int ApiVersion1_4_2 = 10402;
|
||||
|
||||
private const int IndexUnloadCrashHandler = 10;
|
||||
private const int IndexSetCaptureFilePathTemplate = 11;
|
||||
private const int IndexGetNumCaptures = 13;
|
||||
private const int IndexGetCapture = 14;
|
||||
private const int IndexStartFrameCapture = 19;
|
||||
private const int IndexIsFrameCapturing = 20;
|
||||
private const int IndexEndFrameCapture = 21;
|
||||
|
||||
private const int StateIdle = 0;
|
||||
private const int StateRequested = 1;
|
||||
private const int StateCapturing = 2;
|
||||
|
||||
private static IntPtr* _api;
|
||||
private static int _state = StateIdle;
|
||||
private static bool _initialized;
|
||||
|
||||
public static bool IsAvailable => _api is not null;
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
|
||||
if (!string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_RENDERDOC"),
|
||||
"1",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryLoadLibrary(out var module))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] renderdoc: SHARPEMU_RENDERDOC=1 was set but renderdoc.dll could not be loaded.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!NativeLibrary.TryGetExport(module, "RENDERDOC_GetAPI", out var getApiAddress))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] renderdoc: RENDERDOC_GetAPI is missing; in-app capture disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
void* api = null;
|
||||
var getApi = (delegate* unmanaged[Cdecl]<int, void**, int>)getApiAddress;
|
||||
if (getApi(ApiVersion1_4_2, &api) != 1 || api is null)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] renderdoc: API 1.4.2 unavailable; in-app capture disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
_api = (IntPtr*)api;
|
||||
|
||||
((delegate* unmanaged[Cdecl]<void>)_api[IndexUnloadCrashHandler])();
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] renderdoc: in-app capture ready. Press F10 to capture the next presented frame.");
|
||||
}
|
||||
|
||||
public static void SetCaptureDirectory(string titleId)
|
||||
{
|
||||
if (_api is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var safeTitleId = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId.Trim();
|
||||
foreach (var invalid in Path.GetInvalidFileNameChars())
|
||||
{
|
||||
safeTitleId = safeTitleId.Replace(invalid, '_');
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"user",
|
||||
"logs",
|
||||
"capture_logs",
|
||||
safeTitleId);
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var template = Path.Combine(directory, safeTitleId);
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(template + "\0");
|
||||
fixed (byte* pointer = bytes)
|
||||
{
|
||||
((delegate* unmanaged[Cdecl]<byte*, void>)_api[IndexSetCaptureFilePathTemplate])(
|
||||
pointer);
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] renderdoc: captures will be written under '{directory}'.");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] renderdoc: could not set the capture directory: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void RequestCapture()
|
||||
{
|
||||
if (_api is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _state, StateRequested, StateIdle) == StateIdle)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] renderdoc: capture requested; the next complete presented frame will be captured.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void OnPresent()
|
||||
{
|
||||
if (_api is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (Volatile.Read(ref _state))
|
||||
{
|
||||
case StateIdle:
|
||||
return;
|
||||
|
||||
case StateRequested:
|
||||
if (IsFrameCapturing())
|
||||
{
|
||||
Volatile.Write(ref _state, StateIdle);
|
||||
return;
|
||||
}
|
||||
|
||||
StartFrameCapture();
|
||||
if (!IsFrameCapturing())
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] renderdoc: StartFrameCapture did not begin a capture.");
|
||||
Volatile.Write(ref _state, StateIdle);
|
||||
return;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _state, StateCapturing);
|
||||
return;
|
||||
|
||||
case StateCapturing:
|
||||
var captured = EndFrameCapture() != 0;
|
||||
Volatile.Write(ref _state, StateIdle);
|
||||
if (captured)
|
||||
{
|
||||
LogNewestCapture();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][WARN] renderdoc: EndFrameCapture failed.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static void StartFrameCapture() =>
|
||||
((delegate* unmanaged[Cdecl]<IntPtr, IntPtr, void>)_api[IndexStartFrameCapture])(
|
||||
IntPtr.Zero,
|
||||
IntPtr.Zero);
|
||||
|
||||
private static bool IsFrameCapturing() =>
|
||||
((delegate* unmanaged[Cdecl]<uint>)_api[IndexIsFrameCapturing])() != 0;
|
||||
|
||||
private static uint EndFrameCapture() =>
|
||||
((delegate* unmanaged[Cdecl]<IntPtr, IntPtr, uint>)_api[IndexEndFrameCapture])(
|
||||
IntPtr.Zero,
|
||||
IntPtr.Zero);
|
||||
|
||||
private static void LogNewestCapture()
|
||||
{
|
||||
var count = ((delegate* unmanaged[Cdecl]<uint>)_api[IndexGetNumCaptures])();
|
||||
if (count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var getCapture =
|
||||
(delegate* unmanaged[Cdecl]<uint, byte*, uint*, ulong*, uint>)_api[IndexGetCapture];
|
||||
|
||||
uint pathLength = 0;
|
||||
if (getCapture(count - 1, null, &pathLength, null) == 0 || pathLength == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var buffer = new byte[pathLength];
|
||||
fixed (byte* bufferPointer = buffer)
|
||||
{
|
||||
if (getCapture(count - 1, bufferPointer, &pathLength, null) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var path = System.Text.Encoding.UTF8.GetString(buffer).TrimEnd('\0');
|
||||
Console.Error.WriteLine($"[LOADER][INFO] renderdoc: capture written to '{path}'.");
|
||||
}
|
||||
|
||||
private static bool TryLoadLibrary(out IntPtr module)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_RENDERDOC_DLL");
|
||||
if (!string.IsNullOrWhiteSpace(configured) &&
|
||||
NativeLibrary.TryLoad(configured, out module))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var name = OperatingSystem.IsWindows() ? "renderdoc.dll" : "librenderdoc.so";
|
||||
if (NativeLibrary.TryLoad(name, out module))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var candidate in KnownLibraryPaths(name))
|
||||
{
|
||||
if (File.Exists(candidate) && NativeLibrary.TryLoad(candidate, out module))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module = IntPtr.Zero;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> KnownLibraryPaths(string name)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
yield return "/usr/lib/librenderdoc.so";
|
||||
yield return "/usr/local/lib/librenderdoc.so";
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var variable in (string[])["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"])
|
||||
{
|
||||
var root = Environment.GetEnvironmentVariable(variable);
|
||||
if (!string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
yield return Path.Combine(root, "RenderDoc", name);
|
||||
}
|
||||
}
|
||||
|
||||
var localAppData = Environment.GetEnvironmentVariable("LOCALAPPDATA");
|
||||
if (!string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
yield return Path.Combine(localAppData, "RenderDoc", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -487,6 +487,10 @@ internal sealed unsafe class SdlHostWindow : IDisposable, IHostGamepadOutput
|
||||
{
|
||||
PerfOverlay.Toggle();
|
||||
}
|
||||
else if (keyEvent.key == SDL_Keycode.SDLK_F10)
|
||||
{
|
||||
RenderDocCapture.RequestCapture();
|
||||
}
|
||||
else if (keyEvent.key == SDL_Keycode.SDLK_F11)
|
||||
{
|
||||
ToggleFullscreen();
|
||||
|
||||
@@ -122,6 +122,8 @@ public static class VideoOutExports
|
||||
: titleId.Trim();
|
||||
_applicationWindowTitle = $"{application}{versionSuffix}";
|
||||
}
|
||||
|
||||
RenderDocCapture.SetCaptureDirectory(GetApplicationTitleId());
|
||||
}
|
||||
|
||||
internal static string GetApplicationTitleId()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -392,6 +392,10 @@ public static partial class Gen5MslTranslator
|
||||
if (input.ComponentCount is >= 1 and <= 4)
|
||||
{
|
||||
_vertexInputsByPc.TryAdd(input.Pc, input);
|
||||
foreach (var aliasPc in input.AliasPcs ?? [])
|
||||
{
|
||||
_vertexInputsByPc.TryAdd(aliasPc, input);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,6 +903,22 @@ public static partial class Gen5SpirvTranslator
|
||||
width);
|
||||
break;
|
||||
}
|
||||
case "VBfeI32":
|
||||
{
|
||||
// Same extract as VBfeU32 but sign-extended from the top bit
|
||||
// of the extracted field, so the result type must be signed
|
||||
// and bitcast back for storage.
|
||||
var width = BitwiseAnd(GetRawSource(instruction, 2), UInt(31));
|
||||
result = Bitcast(
|
||||
_uintType,
|
||||
_module.AddInstruction(
|
||||
SpirvOp.BitFieldSExtract,
|
||||
_intType,
|
||||
Bitcast(_intType, GetRawSource(instruction, 0)),
|
||||
BitwiseAnd(GetRawSource(instruction, 1), UInt(31)),
|
||||
width));
|
||||
break;
|
||||
}
|
||||
case "VBfiB32":
|
||||
{
|
||||
var mask = GetRawSource(instruction, 0);
|
||||
@@ -2706,20 +2722,31 @@ public static partial class Gen5SpirvTranslator
|
||||
|
||||
if (applySdwaIntegerModifiers)
|
||||
{
|
||||
// SDWA ABS/NEG are floating-point sign-bit modifiers even on
|
||||
// a bit-move opcode: ABS clears the sign bit, NEG flips it.
|
||||
// Two's-complement negating the raw bits instead turns 1.0
|
||||
// into -4.0 and -3.0 into 1.5, which silently skews every
|
||||
// pass that y-flips its clip position with an SDWA-negated
|
||||
// V_MOV_B32 - the whole of UE's DrawRectangle.
|
||||
var signBit = selector switch
|
||||
{
|
||||
<= 3 => 0x80u,
|
||||
4 or 5 => 0x8000u,
|
||||
_ => 0x80000000u,
|
||||
};
|
||||
|
||||
if ((sdwa.AbsoluteMask & (1u << sourceIndex)) != 0)
|
||||
{
|
||||
value = Bitcast(
|
||||
_uintType,
|
||||
Ext(5, _intType, Bitcast(_intType, value)));
|
||||
value = BitwiseAnd(value, UInt(~signBit));
|
||||
}
|
||||
|
||||
if ((sdwa.NegateMask & (1u << sourceIndex)) != 0)
|
||||
{
|
||||
value = _module.AddInstruction(
|
||||
SpirvOp.ISub,
|
||||
SpirvOp.BitwiseXor,
|
||||
_uintType,
|
||||
UInt(0),
|
||||
value);
|
||||
value,
|
||||
UInt(signBit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,6 +712,8 @@ public static partial class Gen5SpirvTranslator
|
||||
if (UsesSubgroupOperations())
|
||||
{
|
||||
_module.AddCapability(SpirvCapability.GroupNonUniform);
|
||||
_module.AddCapability(SpirvCapability.GroupNonUniformBallot);
|
||||
|
||||
if (UsesSubgroupShuffle())
|
||||
{
|
||||
_module.AddCapability(SpirvCapability.GroupNonUniformShuffle);
|
||||
@@ -722,10 +724,6 @@ public static partial class Gen5SpirvTranslator
|
||||
_module.AddCapability(SpirvCapability.GroupNonUniformVote);
|
||||
}
|
||||
|
||||
if (UsesSubgroupBroadcast() || UsesWaveControl())
|
||||
{
|
||||
_module.AddCapability(SpirvCapability.GroupNonUniformBallot);
|
||||
}
|
||||
}
|
||||
|
||||
_glsl = _module.ImportExtInst("GLSL.std.450");
|
||||
@@ -1365,14 +1363,18 @@ public static partial class Gen5SpirvTranslator
|
||||
variable,
|
||||
SpirvDecoration.Location,
|
||||
input.Location);
|
||||
_vertexInputsByPc.TryAdd(
|
||||
input.Pc,
|
||||
new SpirvVertexInput(
|
||||
variable,
|
||||
type,
|
||||
componentType,
|
||||
input.ComponentCount,
|
||||
componentKind));
|
||||
var vertexInput = new SpirvVertexInput(
|
||||
variable,
|
||||
type,
|
||||
componentType,
|
||||
input.ComponentCount,
|
||||
componentKind);
|
||||
_vertexInputsByPc.TryAdd(input.Pc, vertexInput);
|
||||
foreach (var aliasPc in input.AliasPcs ?? [])
|
||||
{
|
||||
_vertexInputsByPc.TryAdd(aliasPc, vertexInput);
|
||||
}
|
||||
|
||||
_interfaces.Add(variable);
|
||||
}
|
||||
}
|
||||
@@ -1799,13 +1801,16 @@ public static partial class Gen5SpirvTranslator
|
||||
|
||||
if (instruction.Opcode == "SBarrier")
|
||||
{
|
||||
var workgroup = UInt(2);
|
||||
var semantics = UInt(0x108);
|
||||
_module.AddStatement(
|
||||
SpirvOp.ControlBarrier,
|
||||
workgroup,
|
||||
workgroup,
|
||||
semantics);
|
||||
if (_stage == Gen5SpirvStage.Compute)
|
||||
{
|
||||
var workgroup = UInt(2);
|
||||
var semantics = UInt(0x108);
|
||||
_module.AddStatement(
|
||||
SpirvOp.ControlBarrier,
|
||||
workgroup,
|
||||
workgroup,
|
||||
semantics);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -302,6 +302,12 @@ public sealed record Gen5GlobalMemoryBinding(
|
||||
public bool WriteBackToGuest { get; set; } = true;
|
||||
}
|
||||
|
||||
// One attribute per distinct guest stream view. AliasPcs carries the other
|
||||
// fetch instructions that read the same view: uber-shaders fetch a stream from
|
||||
// every material branch, and the scalar evaluator visits one instruction on
|
||||
// several CFG paths. Both must resolve to this binding's single location,
|
||||
// because Metal caps a vertex function at 31 attributes and one location per
|
||||
// fetch instruction overruns that on UE's larger vertex shaders.
|
||||
public sealed record Gen5VertexInputBinding(
|
||||
uint Pc,
|
||||
uint Location,
|
||||
@@ -314,7 +320,8 @@ public sealed record Gen5VertexInputBinding(
|
||||
byte[] Data,
|
||||
int DataLength,
|
||||
bool DataPooled,
|
||||
bool PerInstance = false);
|
||||
bool PerInstance = false,
|
||||
IReadOnlyList<uint>? AliasPcs = null);
|
||||
|
||||
public sealed record Gen5ShaderEvaluation(
|
||||
IReadOnlyList<uint> InitialScalarRegisters,
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler;
|
||||
|
||||
@@ -36,6 +37,113 @@ public static class Gen5ShaderScalarEvaluator
|
||||
StringComparison.Ordinal);
|
||||
private static readonly object _scalarFallbackTraceGate = new();
|
||||
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedScalarFallbacks = [];
|
||||
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedDivergentDescriptors = [];
|
||||
|
||||
private static readonly ConditionalWeakTable<Gen5ShaderProgram, Ir.Gen5ScalarSsa> _scalarSsaCache = [];
|
||||
|
||||
private static readonly bool _divergentDescriptorGuard = !string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_IR_DESCRIPTOR_GUARD"),
|
||||
"0",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static Ir.Gen5ScalarSsa GetScalarSsa(Gen5ShaderState state) =>
|
||||
_scalarSsaCache.GetValue(
|
||||
state.Program,
|
||||
program => Ir.Gen5ScalarSsa.Build(program.Instructions, state.UserData));
|
||||
|
||||
/// <summary>
|
||||
/// The byte offset comes from an SGPR. When the instruction that produced that
|
||||
/// register is one the scalar evaluator cannot reproduce — a vector compare
|
||||
/// writing VCC, say, whose value depends on per-lane data — the register still
|
||||
/// holds whatever the linear walk left in it. Adding that to an otherwise valid
|
||||
/// base address is how descriptors turned into addresses far out of range.
|
||||
/// </summary>
|
||||
private static bool IsOffsetFromUnmodelledWriter(
|
||||
Gen5ShaderState state,
|
||||
Gen5ShaderInstruction instruction,
|
||||
Gen5ScalarMemoryControl control)
|
||||
{
|
||||
if (!_divergentDescriptorGuard || control.DynamicOffsetRegister is not { } offsetRegister)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ssa = GetScalarSsa(state);
|
||||
var reaching = ssa.GetReachingDefinitionAt(instruction.Pc, offsetRegister);
|
||||
if (reaching.State == Ir.IrReachingState.Multiple)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (reaching.State != Ir.IrReachingState.Single ||
|
||||
reaching.DefinitionPc == uint.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var writer = state.Program.Instructions
|
||||
.FirstOrDefault(candidate => candidate.Pc == reaching.DefinitionPc);
|
||||
return writer is not null && Ir.Gen5ScalarSsa.WritesVccImplicitly(writer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A descriptor assembled from registers that differ per incoming path is not a
|
||||
/// descriptor, it is whichever path the linear walk happened to take last.
|
||||
/// </summary>
|
||||
private static bool IsDescriptorFromDivergentMerge(
|
||||
Gen5ShaderState state,
|
||||
uint pc,
|
||||
uint scalarBase,
|
||||
uint registerCount)
|
||||
{
|
||||
if (!_divergentDescriptorGuard)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ssa = GetScalarSsa(state);
|
||||
if (!ssa.Graph.HasControlFlow)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var offset = 0u; offset < registerCount; offset++)
|
||||
{
|
||||
var reaching = ssa.GetReachingDefinitionAt(pc, scalarBase + offset);
|
||||
if (reaching.State == Ir.IrReachingState.Multiple)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ssa.GetScalarAt(pc, scalarBase + offset).State == Ir.IrScalarState.Merged)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void TraceDivergentDescriptor(
|
||||
Gen5ShaderState state,
|
||||
Gen5ShaderInstruction instruction,
|
||||
uint scalarBase,
|
||||
ulong baseAddress)
|
||||
{
|
||||
lock (_scalarFallbackTraceGate)
|
||||
{
|
||||
if (!_tracedDivergentDescriptors.Add((state.Program.Address, instruction.Pc)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] agc.descriptor_divergent " +
|
||||
$"shader=0x{state.Program.Address:X16} pc=0x{instruction.Pc:X} " +
|
||||
$"op={instruction.Opcode} base=s{scalarBase} " +
|
||||
$"linear_base_addr=0x{baseAddress:X16} (unbound instead of dereferenced)");
|
||||
}
|
||||
// Shaders whose empty SRT/EUD caused a null-base scalar pointer load.
|
||||
// Host submit of those translations has lost the Vulkan device; Agc skips
|
||||
// them before QueueSubmit.
|
||||
@@ -173,6 +281,13 @@ public static class Gen5ShaderScalarEvaluator
|
||||
var globalMemoryBindings = new List<Gen5GlobalMemoryBinding>();
|
||||
var globalMemoryByAddress = new Dictionary<(uint ScalarAddress, ulong BaseAddress), Gen5GlobalMemoryBinding>();
|
||||
var vertexInputBindings = new List<Gen5VertexInputBinding>();
|
||||
// Absolute element address plus record layout identifies the guest
|
||||
// stream view an attribute reads, so every fetch that resolves to it
|
||||
// shares one location instead of claiming a new one.
|
||||
var vertexInputByView =
|
||||
new Dictionary<(ulong Address, uint Stride, uint DataFormat,
|
||||
uint NumberFormat, uint ComponentCount), int>();
|
||||
var vertexInputAliasPcs = new List<List<uint>>();
|
||||
// Shared, cached, read-only: computed once per decoded program. The
|
||||
// set already includes every instruction's destination registers, so
|
||||
// the per-load additions the loop used to make are redundant.
|
||||
@@ -545,6 +660,41 @@ public static class Gen5ShaderScalarEvaluator
|
||||
return false;
|
||||
}
|
||||
|
||||
var vertexInputView = (
|
||||
SaturatingAdd(
|
||||
vertexInputBinding.BaseAddress,
|
||||
vertexInputBinding.OffsetBytes),
|
||||
vertexInputBinding.Stride,
|
||||
vertexInputBinding.DataFormat,
|
||||
vertexInputBinding.NumberFormat,
|
||||
vertexInputBinding.ComponentCount);
|
||||
if (vertexInputByView.TryGetValue(
|
||||
vertexInputView,
|
||||
out var existingVertexInput))
|
||||
{
|
||||
var aliasPcs = vertexInputAliasPcs[existingVertexInput];
|
||||
if (!aliasPcs.Contains(instruction.Pc))
|
||||
{
|
||||
aliasPcs.Add(instruction.Pc);
|
||||
}
|
||||
|
||||
// Descriptors for one view agree on size, but a path
|
||||
// that resolved a larger reachable range still has to
|
||||
// win so the capture covers every fetch.
|
||||
var aliasedBinding = vertexInputBindings[existingVertexInput];
|
||||
if (aliasedBinding.DataLength < vertexInputBinding.DataLength)
|
||||
{
|
||||
vertexInputBindings[existingVertexInput] = aliasedBinding with
|
||||
{
|
||||
DataLength = vertexInputBinding.DataLength,
|
||||
};
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
vertexInputByView[vertexInputView] = vertexInputBindings.Count;
|
||||
vertexInputAliasPcs.Add([]);
|
||||
vertexInputBindings.Add(vertexInputBinding);
|
||||
continue;
|
||||
}
|
||||
@@ -695,6 +845,18 @@ public static class Gen5ShaderScalarEvaluator
|
||||
|
||||
if (vertexInputBindings.Count != 0)
|
||||
{
|
||||
for (var index = 0; index < vertexInputBindings.Count; index++)
|
||||
{
|
||||
if (vertexInputAliasPcs[index].Count != 0)
|
||||
{
|
||||
vertexInputBindings[index] = vertexInputBindings[index] with
|
||||
{
|
||||
AliasPcs = vertexInputAliasPcs[index],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
TraceVertexInputShape(vertexInputBindings);
|
||||
if (!TryCaptureVertexInputData(
|
||||
ctx,
|
||||
vertexInputBindings,
|
||||
@@ -843,6 +1005,43 @@ public static class Gen5ShaderScalarEvaluator
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readonly bool _traceVertexInputShape =
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_VERTEX_SHAPE"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static readonly HashSet<string> _tracedVertexInputShapes = [];
|
||||
|
||||
private static void TraceVertexInputShape(
|
||||
IReadOnlyList<Gen5VertexInputBinding> bindings)
|
||||
{
|
||||
if (!_traceVertexInputShape)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var distinctPcs = bindings.Select(static binding => binding.Pc).Distinct().Count();
|
||||
var identities = bindings
|
||||
.Select(static binding =>
|
||||
$"{binding.BaseAddress:X}/{binding.Stride}/{binding.OffsetBytes}/" +
|
||||
$"{binding.DataFormat}/{binding.NumberFormat}/{binding.ComponentCount}")
|
||||
.ToArray();
|
||||
var shape =
|
||||
$"count={bindings.Count} distinct_pc={distinctPcs} " +
|
||||
$"distinct_view={identities.Distinct().Count()} " +
|
||||
$"views={string.Join(',', identities)}";
|
||||
lock (_tracedVertexInputShapes)
|
||||
{
|
||||
if (!_tracedVertexInputShapes.Add(shape))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[VERTEX-SHAPE] {shape}");
|
||||
}
|
||||
|
||||
private static void TraceTitleVertexInputs(IReadOnlyList<Gen5VertexInputBinding> bindings)
|
||||
{
|
||||
if (!string.Equals(
|
||||
@@ -1898,19 +2097,32 @@ public static class Gen5ShaderScalarEvaluator
|
||||
var address = unchecked(
|
||||
baseAddress +
|
||||
byteOffset) & ~3UL;
|
||||
var descriptorDiverged = IsDescriptorFromDivergentMerge(
|
||||
state,
|
||||
instruction.Pc,
|
||||
scalarBase.Value,
|
||||
isBufferLoad ? 4u : 2u) ||
|
||||
IsOffsetFromUnmodelledWriter(state, instruction, control);
|
||||
if (descriptorDiverged)
|
||||
{
|
||||
TraceDivergentDescriptor(state, instruction, scalarBase.Value, baseAddress);
|
||||
}
|
||||
|
||||
var bufferUnbound =
|
||||
isBufferLoad &&
|
||||
(!hasBufferDescriptor ||
|
||||
(descriptorDiverged ||
|
||||
!hasBufferDescriptor ||
|
||||
bufferDescriptor.SizeBytes == 0 ||
|
||||
(scalarRegisters[scalarBase.Value] == 0 &&
|
||||
scalarRegisters[scalarBase.Value + 1] == 0 &&
|
||||
scalarBase.Value + 3 < ScalarRegisterCount &&
|
||||
scalarRegisters[scalarBase.Value + 2] == 0 &&
|
||||
scalarRegisters[scalarBase.Value + 3] == 0));
|
||||
var scalarPointerUnbound = ShouldTreatScalarPointerAsUnbound(
|
||||
isBufferLoad,
|
||||
address,
|
||||
_strictScalarLoad);
|
||||
var scalarPointerUnbound = descriptorDiverged && !isBufferLoad ||
|
||||
ShouldTreatScalarPointerAsUnbound(
|
||||
isBufferLoad,
|
||||
address,
|
||||
_strictScalarLoad);
|
||||
if (scalarPointerUnbound)
|
||||
{
|
||||
TraceScalarPointerFallback(
|
||||
|
||||
@@ -1157,6 +1157,7 @@ public static class Gen5ShaderTranslator
|
||||
0x15D => "VSadU32",
|
||||
0x15E => "VCvtPkU8F32",
|
||||
0x148 => "VBfeU32",
|
||||
0x149 => "VBfeI32",
|
||||
0x169 => "VMulLoU32",
|
||||
0x16A => "VMulHiU32",
|
||||
0x16B => "VMulLoI32",
|
||||
@@ -1170,6 +1171,7 @@ public static class Gen5ShaderTranslator
|
||||
0x366 => "VMbcntHiU32B32",
|
||||
0x368 => "VCvtPknormI16F32",
|
||||
0x369 => "VCvtPknormU16F32",
|
||||
0x36A => "VCvtPkU16U32",
|
||||
0x373 => "VMadU32U16",
|
||||
0x346 => "VLshlAddU32",
|
||||
0x347 => "VAddLshlU32",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler.Ir;
|
||||
|
||||
public sealed class Gen5IrBranchResolver : IIrBranchResolver
|
||||
{
|
||||
public static Gen5IrBranchResolver Instance { get; } = new();
|
||||
|
||||
public bool IsBranch(Gen5ShaderInstruction instruction) =>
|
||||
IsUnconditionalBranch(instruction) ||
|
||||
IsConditional(instruction) ||
|
||||
IsTerminator(instruction);
|
||||
|
||||
public bool IsConditional(Gen5ShaderInstruction instruction) => instruction.Opcode switch
|
||||
{
|
||||
"SCbranchScc0" or
|
||||
"SCbranchScc1" or
|
||||
"SCbranchVccz" or
|
||||
"SCbranchVccnz" or
|
||||
"SCbranchExecz" or
|
||||
"SCbranchExecnz" or
|
||||
"SCbranchCdbgsys" or
|
||||
"SCbranchCdbguser" or
|
||||
"SCbranchCdbgsysOrUser" or
|
||||
"SCbranchCdbgsysAndUser" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc)
|
||||
{
|
||||
targetPc = 0;
|
||||
if (IsTerminator(instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsUnconditionalBranch(instruction) && !IsConditional(instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instruction.Encoding != Gen5ShaderEncoding.Sopp || instruction.Words.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var offset = unchecked((short)(instruction.Words[0] & 0xFFFF));
|
||||
var nextPc = (long)instruction.Pc + instruction.Words.Count * sizeof(uint);
|
||||
var target = nextPc + offset * sizeof(uint);
|
||||
if (target < 0 || target > uint.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
targetPc = (uint)target;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsUnconditionalBranch(Gen5ShaderInstruction instruction) =>
|
||||
string.Equals(instruction.Opcode, "SBranch", StringComparison.Ordinal);
|
||||
|
||||
public static bool IsTerminator(Gen5ShaderInstruction instruction) =>
|
||||
instruction.Opcode is "SEndpgm" or "SEndpgmSaved";
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler.Ir;
|
||||
|
||||
public enum IrScalarState
|
||||
{
|
||||
Unknown,
|
||||
Constant,
|
||||
Merged,
|
||||
}
|
||||
|
||||
public enum IrReachingState
|
||||
{
|
||||
None,
|
||||
Single,
|
||||
Multiple,
|
||||
}
|
||||
|
||||
public readonly record struct IrReachingDefinition(IrReachingState State, uint DefinitionPc)
|
||||
{
|
||||
public static readonly IrReachingDefinition None = new(IrReachingState.None, 0);
|
||||
|
||||
public static readonly IrReachingDefinition Multiple = new(IrReachingState.Multiple, 0);
|
||||
|
||||
public static IrReachingDefinition At(uint pc) => new(IrReachingState.Single, pc);
|
||||
|
||||
public IrReachingDefinition Join(IrReachingDefinition other)
|
||||
{
|
||||
if (State == IrReachingState.None)
|
||||
{
|
||||
return other;
|
||||
}
|
||||
|
||||
if (other.State == IrReachingState.None)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
if (State == IrReachingState.Single &&
|
||||
other.State == IrReachingState.Single &&
|
||||
DefinitionPc == other.DefinitionPc)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
return Multiple;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct IrScalarValue(IrScalarState State, uint Constant)
|
||||
{
|
||||
public static readonly IrScalarValue Unknown = new(IrScalarState.Unknown, 0);
|
||||
|
||||
public static readonly IrScalarValue Merged = new(IrScalarState.Merged, 0);
|
||||
|
||||
public static IrScalarValue FromConstant(uint value) => new(IrScalarState.Constant, value);
|
||||
|
||||
public bool IsResolved => State == IrScalarState.Constant;
|
||||
|
||||
public IrScalarValue Join(IrScalarValue other)
|
||||
{
|
||||
if (State == IrScalarState.Unknown)
|
||||
{
|
||||
return other;
|
||||
}
|
||||
|
||||
if (other.State == IrScalarState.Unknown)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
if (State == IrScalarState.Constant &&
|
||||
other.State == IrScalarState.Constant &&
|
||||
Constant == other.Constant)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
return Merged;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Gen5ScalarSsa
|
||||
{
|
||||
public const int ScalarRegisterCount = 256;
|
||||
|
||||
private Gen5ScalarSsa(
|
||||
IrControlFlowGraph graph,
|
||||
IReadOnlyList<IrScalarValue[]> entryState,
|
||||
IReadOnlyList<IrScalarValue[]> exitState,
|
||||
IReadOnlyList<IrReachingDefinition[]> entryDefinitions,
|
||||
IReadOnlyDictionary<uint, int> blockByPc,
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions)
|
||||
{
|
||||
Graph = graph;
|
||||
_entryState = entryState;
|
||||
_exitState = exitState;
|
||||
_entryDefinitions = entryDefinitions;
|
||||
_blockByPc = blockByPc;
|
||||
_instructions = instructions;
|
||||
}
|
||||
|
||||
private readonly IReadOnlyList<IrReachingDefinition[]> _entryDefinitions;
|
||||
|
||||
public IrControlFlowGraph Graph { get; }
|
||||
|
||||
private readonly IReadOnlyList<IrScalarValue[]> _entryState;
|
||||
private readonly IReadOnlyList<IrScalarValue[]> _exitState;
|
||||
private readonly IReadOnlyDictionary<uint, int> _blockByPc;
|
||||
private readonly IReadOnlyList<Gen5ShaderInstruction> _instructions;
|
||||
|
||||
public static Gen5ScalarSsa Build(
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||
IReadOnlyList<uint> userData,
|
||||
IIrBranchResolver? resolver = null)
|
||||
{
|
||||
resolver ??= Gen5IrBranchResolver.Instance;
|
||||
var graph = IrControlFlowGraph.Build(instructions, resolver);
|
||||
var blockCount = graph.Blocks.Count;
|
||||
|
||||
var entry = new List<IrScalarValue[]>(blockCount);
|
||||
var exit = new List<IrScalarValue[]>(blockCount);
|
||||
var defEntry = new List<IrReachingDefinition[]>(blockCount);
|
||||
var defExit = new List<IrReachingDefinition[]>(blockCount);
|
||||
for (var index = 0; index < blockCount; index++)
|
||||
{
|
||||
entry.Add(NewState());
|
||||
exit.Add(NewState());
|
||||
defEntry.Add(NewDefinitions());
|
||||
defExit.Add(NewDefinitions());
|
||||
}
|
||||
|
||||
if (blockCount > 0)
|
||||
{
|
||||
var initial = entry[0];
|
||||
for (var index = 0; index < userData.Count && index < ScalarRegisterCount; index++)
|
||||
{
|
||||
initial[index] = IrScalarValue.FromConstant(userData[index]);
|
||||
}
|
||||
}
|
||||
|
||||
var blockByPc = new Dictionary<uint, int>();
|
||||
for (var blockIndex = 0; blockIndex < blockCount; blockIndex++)
|
||||
{
|
||||
var range = graph.Blocks[blockIndex];
|
||||
foreach (var instruction in instructions)
|
||||
{
|
||||
if (instruction.Pc >= range.StartPc && instruction.Pc < range.EndPc)
|
||||
{
|
||||
blockByPc[instruction.Pc] = blockIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var worklist = new Queue<int>();
|
||||
for (var index = 0; index < blockCount; index++)
|
||||
{
|
||||
worklist.Enqueue(index);
|
||||
}
|
||||
|
||||
var visits = new int[blockCount];
|
||||
const int visitLimit = 8;
|
||||
while (worklist.Count > 0)
|
||||
{
|
||||
var blockIndex = worklist.Dequeue();
|
||||
if (visits[blockIndex]++ > visitLimit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var state = (IrScalarValue[])entry[blockIndex].Clone();
|
||||
if (graph.Predecessors[blockIndex].Count > 0)
|
||||
{
|
||||
state = NewState();
|
||||
var first = true;
|
||||
foreach (var predecessor in graph.Predecessors[blockIndex])
|
||||
{
|
||||
var incoming = exit[predecessor];
|
||||
for (var register = 0; register < ScalarRegisterCount; register++)
|
||||
{
|
||||
state[register] = first
|
||||
? incoming[register]
|
||||
: state[register].Join(incoming[register]);
|
||||
}
|
||||
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (blockIndex == 0)
|
||||
{
|
||||
for (var register = 0; register < userData.Count && register < ScalarRegisterCount; register++)
|
||||
{
|
||||
state[register] = state[register].Join(
|
||||
IrScalarValue.FromConstant(userData[register]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry[blockIndex] = state;
|
||||
|
||||
var definitions = NewDefinitions();
|
||||
if (graph.Predecessors[blockIndex].Count == 0)
|
||||
{
|
||||
for (var register = 0; register < userData.Count && register < ScalarRegisterCount; register++)
|
||||
{
|
||||
definitions[register] = IrReachingDefinition.At(uint.MaxValue);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var first = true;
|
||||
foreach (var predecessor in graph.Predecessors[blockIndex])
|
||||
{
|
||||
var incoming = defExit[predecessor];
|
||||
for (var register = 0; register < ScalarRegisterCount; register++)
|
||||
{
|
||||
definitions[register] = first
|
||||
? incoming[register]
|
||||
: definitions[register].Join(incoming[register]);
|
||||
}
|
||||
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
defEntry[blockIndex] = definitions;
|
||||
|
||||
var computed = Transfer(instructions, graph.Blocks[blockIndex], state);
|
||||
var computedDefinitions = TransferDefinitions(
|
||||
instructions,
|
||||
graph.Blocks[blockIndex],
|
||||
definitions);
|
||||
var changed = !SameState(exit[blockIndex], computed) ||
|
||||
!SameDefinitions(defExit[blockIndex], computedDefinitions);
|
||||
exit[blockIndex] = computed;
|
||||
defExit[blockIndex] = computedDefinitions;
|
||||
if (changed)
|
||||
{
|
||||
foreach (var successor in graph.Successors[blockIndex])
|
||||
{
|
||||
worklist.Enqueue(successor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Gen5ScalarSsa(graph, entry, exit, defEntry, blockByPc, instructions);
|
||||
}
|
||||
|
||||
public IrScalarValue GetScalarAt(uint pc, uint register)
|
||||
{
|
||||
if (register >= ScalarRegisterCount || !_blockByPc.TryGetValue(pc, out var blockIndex))
|
||||
{
|
||||
return IrScalarValue.Unknown;
|
||||
}
|
||||
|
||||
var state = (IrScalarValue[])_entryState[blockIndex].Clone();
|
||||
var range = _graphRange(blockIndex);
|
||||
foreach (var instruction in _instructions)
|
||||
{
|
||||
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instruction.Pc >= pc)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Apply(instruction, state);
|
||||
}
|
||||
|
||||
return state[register];
|
||||
}
|
||||
|
||||
public IrReachingDefinition GetReachingDefinitionAt(uint pc, uint register)
|
||||
{
|
||||
if (register >= ScalarRegisterCount || !_blockByPc.TryGetValue(pc, out var blockIndex))
|
||||
{
|
||||
return IrReachingDefinition.None;
|
||||
}
|
||||
|
||||
var definitions = (IrReachingDefinition[])_entryDefinitions[blockIndex].Clone();
|
||||
var range = _graphRange(blockIndex);
|
||||
foreach (var instruction in _instructions)
|
||||
{
|
||||
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instruction.Pc >= pc)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ApplyDefinitions(instruction, definitions);
|
||||
}
|
||||
|
||||
return definitions[register];
|
||||
}
|
||||
|
||||
public bool IsInsideDivergentMerge(uint pc) =>
|
||||
_blockByPc.TryGetValue(pc, out var blockIndex) &&
|
||||
_graphPredecessorCount(blockIndex) > 1;
|
||||
|
||||
private IrBlockRange _graphRange(int blockIndex) => Graph.Blocks[blockIndex];
|
||||
|
||||
private int _graphPredecessorCount(int blockIndex) => Graph.Predecessors[blockIndex].Count;
|
||||
|
||||
private static IrScalarValue[] NewState()
|
||||
{
|
||||
var state = new IrScalarValue[ScalarRegisterCount];
|
||||
for (var index = 0; index < state.Length; index++)
|
||||
{
|
||||
state[index] = IrScalarValue.Unknown;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private static IrReachingDefinition[] NewDefinitions()
|
||||
{
|
||||
var definitions = new IrReachingDefinition[ScalarRegisterCount];
|
||||
for (var index = 0; index < definitions.Length; index++)
|
||||
{
|
||||
definitions[index] = IrReachingDefinition.None;
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private static bool SameDefinitions(IrReachingDefinition[] left, IrReachingDefinition[] right)
|
||||
{
|
||||
for (var index = 0; index < left.Length; index++)
|
||||
{
|
||||
if (!left[index].Equals(right[index]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IrReachingDefinition[] TransferDefinitions(
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||
IrBlockRange range,
|
||||
IrReachingDefinition[] entry)
|
||||
{
|
||||
var definitions = (IrReachingDefinition[])entry.Clone();
|
||||
foreach (var instruction in instructions)
|
||||
{
|
||||
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ApplyDefinitions(instruction, definitions);
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
public const uint VccLo = 106;
|
||||
|
||||
public const uint VccHi = 107;
|
||||
|
||||
/// <summary>
|
||||
/// VOPC compares and the VOP2 carry forms write VCC without naming it: the ISA
|
||||
/// makes the destination implicit in the encoding, so the decoded instruction
|
||||
/// carries no destination operand for it. Modelling that here (rather than in
|
||||
/// the shared decoder) keeps the linear evaluator's behaviour untouched while
|
||||
/// letting the dataflow see that VCC was written.
|
||||
/// </summary>
|
||||
public static bool WritesVccImplicitly(Gen5ShaderInstruction instruction)
|
||||
{
|
||||
if (instruction.Encoding == Gen5ShaderEncoding.Vopc)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return instruction.Encoding == Gen5ShaderEncoding.Vop2 &&
|
||||
instruction.Opcode is
|
||||
"VAddCoCiU32" or
|
||||
"VSubCoCiU32" or
|
||||
"VSubrevCoCiU32";
|
||||
}
|
||||
|
||||
private static void ApplyDefinitions(
|
||||
Gen5ShaderInstruction instruction,
|
||||
IrReachingDefinition[] definitions)
|
||||
{
|
||||
foreach (var destination in instruction.Destinations)
|
||||
{
|
||||
if (destination.Kind != Gen5OperandKind.ScalarRegister ||
|
||||
destination.Value >= ScalarRegisterCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
definitions[destination.Value] = IrReachingDefinition.At(instruction.Pc);
|
||||
}
|
||||
|
||||
if (WritesVccImplicitly(instruction))
|
||||
{
|
||||
definitions[VccLo] = IrReachingDefinition.At(instruction.Pc);
|
||||
definitions[VccHi] = IrReachingDefinition.At(instruction.Pc);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SameState(IrScalarValue[] left, IrScalarValue[] right)
|
||||
{
|
||||
for (var index = 0; index < left.Length; index++)
|
||||
{
|
||||
if (!left[index].Equals(right[index]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IrScalarValue[] Transfer(
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||
IrBlockRange range,
|
||||
IrScalarValue[] entry)
|
||||
{
|
||||
var state = (IrScalarValue[])entry.Clone();
|
||||
foreach (var instruction in instructions)
|
||||
{
|
||||
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Apply(instruction, state);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private static void Apply(Gen5ShaderInstruction instruction, IrScalarValue[] state)
|
||||
{
|
||||
var resolved = ResolveResult(instruction, state);
|
||||
foreach (var destination in instruction.Destinations)
|
||||
{
|
||||
if (destination.Kind != Gen5OperandKind.ScalarRegister ||
|
||||
destination.Value >= ScalarRegisterCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
state[destination.Value] = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
private static IrScalarValue ResolveResult(
|
||||
Gen5ShaderInstruction instruction,
|
||||
IrScalarValue[] state)
|
||||
{
|
||||
if (instruction.Destinations.Count != 1)
|
||||
{
|
||||
return IrScalarValue.Unknown;
|
||||
}
|
||||
|
||||
return instruction.Opcode switch
|
||||
{
|
||||
"SMov" or "SMovB32" => Source(instruction, state, 0),
|
||||
_ => IrScalarValue.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private static IrScalarValue Source(
|
||||
Gen5ShaderInstruction instruction,
|
||||
IrScalarValue[] state,
|
||||
int index)
|
||||
{
|
||||
if (index >= instruction.Sources.Count)
|
||||
{
|
||||
return IrScalarValue.Unknown;
|
||||
}
|
||||
|
||||
var source = instruction.Sources[index];
|
||||
return source.Kind switch
|
||||
{
|
||||
Gen5OperandKind.ScalarRegister when source.Value < ScalarRegisterCount =>
|
||||
state[source.Value],
|
||||
Gen5OperandKind.LiteralConstant => IrScalarValue.FromConstant(source.Value),
|
||||
_ => IrScalarValue.Unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler.Ir;
|
||||
|
||||
public readonly record struct IrBlockRange(uint StartPc, uint EndPc);
|
||||
|
||||
public sealed class IrControlFlowGraph
|
||||
{
|
||||
private IrControlFlowGraph(
|
||||
IReadOnlyList<IrBlockRange> blocks,
|
||||
IReadOnlyDictionary<uint, int> blockByStartPc,
|
||||
IReadOnlyList<IReadOnlyList<int>> successors,
|
||||
IReadOnlyList<IReadOnlyList<int>> predecessors,
|
||||
IReadOnlySet<int> loopHeaders)
|
||||
{
|
||||
Blocks = blocks;
|
||||
BlockByStartPc = blockByStartPc;
|
||||
Successors = successors;
|
||||
Predecessors = predecessors;
|
||||
LoopHeaders = loopHeaders;
|
||||
}
|
||||
|
||||
public IReadOnlyList<IrBlockRange> Blocks { get; }
|
||||
|
||||
public IReadOnlyDictionary<uint, int> BlockByStartPc { get; }
|
||||
|
||||
public IReadOnlyList<IReadOnlyList<int>> Successors { get; }
|
||||
|
||||
public IReadOnlyList<IReadOnlyList<int>> Predecessors { get; }
|
||||
|
||||
public IReadOnlySet<int> LoopHeaders { get; }
|
||||
|
||||
public bool HasControlFlow => Blocks.Count > 1;
|
||||
|
||||
public static IrControlFlowGraph Build(
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||
IIrBranchResolver resolver)
|
||||
{
|
||||
var leaders = new SortedSet<uint>();
|
||||
if (instructions.Count > 0)
|
||||
{
|
||||
leaders.Add(instructions[0].Pc);
|
||||
}
|
||||
|
||||
for (var index = 0; index < instructions.Count; index++)
|
||||
{
|
||||
var instruction = instructions[index];
|
||||
if (!resolver.IsBranch(instruction))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolver.TryGetBranchTarget(instruction, out var target))
|
||||
{
|
||||
leaders.Add(target);
|
||||
}
|
||||
|
||||
if (index + 1 < instructions.Count)
|
||||
{
|
||||
leaders.Add(instructions[index + 1].Pc);
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = leaders.ToList();
|
||||
var ranges = new List<IrBlockRange>(ordered.Count);
|
||||
var byStart = new Dictionary<uint, int>();
|
||||
for (var index = 0; index < ordered.Count; index++)
|
||||
{
|
||||
var start = ordered[index];
|
||||
var end = index + 1 < ordered.Count
|
||||
? ordered[index + 1]
|
||||
: instructions.Count > 0 ? instructions[^1].Pc + 1 : start;
|
||||
byStart[start] = ranges.Count;
|
||||
ranges.Add(new IrBlockRange(start, end));
|
||||
}
|
||||
|
||||
var successors = new List<List<int>>(ranges.Count);
|
||||
var predecessors = new List<List<int>>(ranges.Count);
|
||||
for (var index = 0; index < ranges.Count; index++)
|
||||
{
|
||||
successors.Add([]);
|
||||
predecessors.Add([]);
|
||||
}
|
||||
|
||||
for (var blockIndex = 0; blockIndex < ranges.Count; blockIndex++)
|
||||
{
|
||||
var range = ranges[blockIndex];
|
||||
var last = instructions
|
||||
.Where(candidate => candidate.Pc >= range.StartPc && candidate.Pc < range.EndPc)
|
||||
.LastOrDefault();
|
||||
if (last is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isBranch = resolver.IsBranch(last);
|
||||
var hasTarget = isBranch && resolver.TryGetBranchTarget(last, out var target) &&
|
||||
byStart.TryGetValue(target, out var targetIndex);
|
||||
if (hasTarget)
|
||||
{
|
||||
_ = resolver.TryGetBranchTarget(last, out var resolved);
|
||||
Link(successors, predecessors, blockIndex, byStart[resolved]);
|
||||
}
|
||||
|
||||
var fallsThrough = !isBranch || resolver.IsConditional(last);
|
||||
if (fallsThrough && blockIndex + 1 < ranges.Count)
|
||||
{
|
||||
Link(successors, predecessors, blockIndex, blockIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
var headers = new HashSet<int>();
|
||||
for (var blockIndex = 0; blockIndex < ranges.Count; blockIndex++)
|
||||
{
|
||||
foreach (var successor in successors[blockIndex])
|
||||
{
|
||||
if (successor <= blockIndex)
|
||||
{
|
||||
headers.Add(successor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new IrControlFlowGraph(
|
||||
ranges,
|
||||
byStart,
|
||||
successors.Select(list => (IReadOnlyList<int>)list).ToList(),
|
||||
predecessors.Select(list => (IReadOnlyList<int>)list).ToList(),
|
||||
headers);
|
||||
}
|
||||
|
||||
private static void Link(
|
||||
List<List<int>> successors,
|
||||
List<List<int>> predecessors,
|
||||
int from,
|
||||
int to)
|
||||
{
|
||||
if (!successors[from].Contains(to))
|
||||
{
|
||||
successors[from].Add(to);
|
||||
}
|
||||
|
||||
if (!predecessors[to].Contains(from))
|
||||
{
|
||||
predecessors[to].Add(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface IIrBranchResolver
|
||||
{
|
||||
bool IsBranch(Gen5ShaderInstruction instruction);
|
||||
|
||||
bool IsConditional(Gen5ShaderInstruction instruction);
|
||||
|
||||
bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc);
|
||||
}
|
||||
@@ -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<byte> 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<byte> 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<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// 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 Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the graphics context-register path in the PM4 parser. Draw
|
||||
/// translation reads render state out of this dictionary (CB_TARGET_MASK
|
||||
/// decides whether a draw writes alpha, CB_COLOR_CONTROL decides what the draw
|
||||
/// means), so a write that lands under the wrong key, or fails to overwrite an
|
||||
/// earlier one, silently changes what every later draw does. These drive real
|
||||
/// PM4 packets through the public submit export and assert what the parser
|
||||
/// retained.
|
||||
/// </summary>
|
||||
public sealed class AgcContextRegisterTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x2_0000_0000;
|
||||
private const ulong SubmitPacketAddress = BaseAddress + 0x40;
|
||||
private const ulong CommandAddress = BaseAddress + 0x200;
|
||||
private const ulong IndirectTableAddress = BaseAddress + 0x600;
|
||||
|
||||
private const uint ItNop = 0x10;
|
||||
private const uint ItSetContextReg = 0x69;
|
||||
private const uint RCxRegsIndirect = 0x12;
|
||||
private const uint CbTargetMask = 0x8E;
|
||||
private const uint CbColorControl = 0x202;
|
||||
|
||||
// PM4 type-3 header: 0xC0000000 | ((dwords - 2) << 16) | (opcode << 8), with the
|
||||
// NOP sub-register in bits 2..7 — the parser reads it as (header >> 2) & 0x3F.
|
||||
private static uint Pm4Header(uint dwords, uint opcode, uint register = 0) =>
|
||||
0xC000_0000u | ((dwords - 2) << 16) | (opcode << 8) | ((register & 0x3Fu) << 2);
|
||||
|
||||
[Fact]
|
||||
public void SetContextRegRetainsTargetMask()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteDwords(
|
||||
memory,
|
||||
CommandAddress,
|
||||
Pm4Header(3, ItSetContextReg),
|
||||
CbTargetMask,
|
||||
0x0000_0007u);
|
||||
Submit(ctx, memory, dwordCount: 3);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsContextRegisterForTests(ctx, CbTargetMask, out var value));
|
||||
Assert.Equal(0x0000_0007u, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The indirect form carries (offset, value) pairs out of guest memory
|
||||
/// rather than inline dwords, so an offset-encoding mismatch here would
|
||||
/// store the register under a key no reader looks at.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IndirectRegisterWriteRetainsTargetMask()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteIndirectRegisterCommand(memory, (CbTargetMask, 0xFFFF_FFFFu));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsContextRegisterForTests(ctx, CbTargetMask, out var value));
|
||||
Assert.Equal(0xFFFF_FFFFu, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context registers persist across submissions on hardware until something
|
||||
/// clears them, so a mask written in one submission has to still be there
|
||||
/// for a draw in the next.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TargetMaskSurvivesASecondSubmission()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteIndirectRegisterCommand(memory, (CbTargetMask, 0x8888_8888u));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
// A second, unrelated submission: a bare NOP that touches no registers.
|
||||
WriteDwords(memory, CommandAddress, Pm4Header(2, ItNop), 0);
|
||||
Submit(ctx, memory, dwordCount: 2);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsContextRegisterForTests(ctx, CbTargetMask, out var value));
|
||||
Assert.Equal(0x8888_8888u, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Both encodings must land on the same key, or a title that sets the
|
||||
/// register one way and a reader that expects the other silently disagree.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DirectAndIndirectWritesShareOneKey()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteDwords(
|
||||
memory,
|
||||
CommandAddress,
|
||||
Pm4Header(3, ItSetContextReg),
|
||||
CbTargetMask,
|
||||
0x0000_0007u);
|
||||
Submit(ctx, memory, dwordCount: 3);
|
||||
|
||||
WriteIndirectRegisterCommand(memory, (CbTargetMask, 0x0000_000Fu));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsContextRegisterForTests(ctx, CbTargetMask, out var value));
|
||||
Assert.Equal(0x0000_000Fu, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CB_COLOR_CONTROL (0x202) MODE bits [6:4] give Normal=1,
|
||||
/// EliminateFastClear=2, Resolve=3, FmaskDecompress=5, DccDecompress=6. The
|
||||
/// value has to survive the parser intact, ROP3 bits and all, because the
|
||||
/// mode decides whether a draw shades or resolves.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x0000_0010u, 1u)] // Normal
|
||||
[InlineData(0x0000_0020u, 2u)] // EliminateFastClear
|
||||
[InlineData(0x00CC_0060u, 6u)] // DccDecompress, with ROP3=0xCC alongside
|
||||
public void ColorControlRetainsMode(uint written, uint expectedMode)
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteIndirectRegisterCommand(memory, (CbColorControl, written));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsContextRegisterForTests(ctx, CbColorControl, out var value));
|
||||
Assert.Equal(written, value);
|
||||
Assert.Equal(expectedMode, (value >> 4) & 0x7u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A later write must win. If the parser kept the first value, a draw that
|
||||
/// sets EliminateFastClear after an earlier Normal would still read Normal
|
||||
/// and the clear would be silently dropped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ColorControlLaterWriteOverwritesEarlier()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteIndirectRegisterCommand(memory, (CbColorControl, 0x00CC_0010u));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
WriteIndirectRegisterCommand(memory, (CbColorControl, 0x00CC_0020u));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsContextRegisterForTests(ctx, CbColorControl, out var value));
|
||||
Assert.Equal(0x00CC_0020u, value);
|
||||
Assert.Equal(2u, (value >> 4) & 0x7u);
|
||||
}
|
||||
|
||||
private static void WriteIndirectRegisterCommand(
|
||||
FakeCpuMemory memory,
|
||||
params (uint Offset, uint Value)[] registers)
|
||||
{
|
||||
WriteDwords(
|
||||
memory,
|
||||
CommandAddress,
|
||||
Pm4Header(4, ItNop, RCxRegsIndirect),
|
||||
(uint)registers.Length,
|
||||
(uint)(IndirectTableAddress & 0xFFFF_FFFFu),
|
||||
(uint)(IndirectTableAddress >> 32));
|
||||
|
||||
for (var index = 0; index < registers.Length; index++)
|
||||
{
|
||||
var entry = IndirectTableAddress + ((ulong)index * 8);
|
||||
WriteUInt32(memory, entry, registers[index].Offset);
|
||||
WriteUInt32(memory, entry + 4, registers[index].Value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Submit(CpuContext ctx, FakeCpuMemory memory, uint dwordCount)
|
||||
{
|
||||
WriteUInt64(memory, SubmitPacketAddress, CommandAddress);
|
||||
WriteUInt32(memory, SubmitPacketAddress + 8, dwordCount);
|
||||
ctx[CpuRegister.Rdi] = SubmitPacketAddress;
|
||||
AgcExports.DriverSubmitDcb(ctx);
|
||||
}
|
||||
|
||||
private static CpuContext CreateContext(out FakeCpuMemory memory)
|
||||
{
|
||||
memory = new FakeCpuMemory(BaseAddress, 0x1000);
|
||||
return new CpuContext(memory, Generation.Gen5);
|
||||
}
|
||||
|
||||
private static void WriteDwords(FakeCpuMemory memory, ulong address, params uint[] values)
|
||||
{
|
||||
for (var index = 0; index < values.Length; index++)
|
||||
{
|
||||
WriteUInt32(memory, address + ((ulong)index * sizeof(uint)), values[index]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,20 @@ using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// The kernel event-queue registry is process-wide static state, and these tests assert over
|
||||
// every graphics registration in it, so they cannot run beside another suite that registers
|
||||
// graphics events.
|
||||
[CollectionDefinition(GraphicsEventQueueStateCollection.Name, DisableParallelization = true)]
|
||||
public sealed class GraphicsEventQueueStateCollection
|
||||
{
|
||||
public const string Name = "GraphicsEventQueueState";
|
||||
}
|
||||
|
||||
// IT_EVENT_WRITE carries a 6-bit hardware EVENT_TYPE, but sceAgcDriverAddEqEvent registers the
|
||||
// listener with a guest-defined eventId. Those two values are not the same numbering scheme, so
|
||||
// exact ident matching never wakes anything (issue #173). TriggerRegisteredEventsByFilter wakes
|
||||
// every graphics registration instead.
|
||||
[Collection(GraphicsEventQueueStateCollection.Name)]
|
||||
public sealed class AgcEventQueueTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
@@ -66,10 +76,20 @@ public sealed class AgcEventQueueTests
|
||||
// Verify the queued event carries the registered ident and the event type as data.
|
||||
Assert.Equal(registeredEventId, ReadUInt64(memory, eventsAddress + 0x00));
|
||||
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterGraphics, ReadInt16(memory, eventsAddress + 0x08));
|
||||
Assert.Equal(0u, ReadUInt16(memory, eventsAddress + 0x0A));
|
||||
Assert.Equal(
|
||||
KernelEventQueueCompatExports.KernelEventFlagClear,
|
||||
ReadUInt16(memory, eventsAddress + 0x0A));
|
||||
Assert.Equal(1u, ReadUInt32(memory, eventsAddress + 0x0C));
|
||||
Assert.Equal(eventType, ReadUInt64(memory, eventsAddress + 0x10));
|
||||
Assert.Equal(userData, ReadUInt64(memory, eventsAddress + 0x18));
|
||||
|
||||
// Registrations live in process-wide static state, and a sibling test asserts that
|
||||
// no graphics registration exists at all. Drop this one instead of relying on
|
||||
// execution order.
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -99,6 +119,370 @@ public sealed class AgcEventQueueTests
|
||||
Assert.Equal(0, triggered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturedCompletion_DoesNotWakeDeleteAndReAddGeneration()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var handle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
|
||||
const ulong eventId = 0x20;
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
handle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0x1111));
|
||||
var staleSnapshot =
|
||||
KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||
memory,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
Assert.Single(staleSnapshot.Targets);
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.DeleteRegisteredEvent(
|
||||
handle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics));
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
handle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0x2222));
|
||||
|
||||
var staleDelivery =
|
||||
KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||
staleSnapshot,
|
||||
eventId);
|
||||
Assert.Equal(0, staleDelivery.TriggeredCount);
|
||||
Assert.Equal(1, staleDelivery.StaleCount);
|
||||
Assert.False(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out _));
|
||||
|
||||
var liveSnapshot =
|
||||
KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||
memory,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
var liveDelivery =
|
||||
KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||
liveSnapshot,
|
||||
eventId);
|
||||
Assert.Equal(1, liveDelivery.TriggeredCount);
|
||||
Assert.Equal(0, liveDelivery.StaleCount);
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out var delivered));
|
||||
Assert.Equal(0x2222UL, delivered.UserData);
|
||||
|
||||
DeleteEqueue(ctx, handle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturedCompletion_PreservesEachQueuesRegistrationGeneration()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var firstHandle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
|
||||
var secondHandle = CreateEqueue(ctx, memory, BaseAddress + 0x108);
|
||||
const ulong eventId = 0x20;
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
firstHandle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0xAAAA));
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
secondHandle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0xBBBB));
|
||||
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||
memory,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
Assert.Equal(2, snapshot.Targets.Length);
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.DeleteRegisteredEvent(
|
||||
secondHandle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics));
|
||||
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||
snapshot,
|
||||
eventId);
|
||||
|
||||
Assert.Equal(1, delivery.TriggeredCount);
|
||||
Assert.Equal(1, delivery.StaleCount);
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
firstHandle,
|
||||
out var delivered));
|
||||
Assert.Equal(0xAAAAUL, delivered.UserData);
|
||||
Assert.False(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
secondHandle,
|
||||
out _));
|
||||
|
||||
DeleteEqueue(ctx, firstHandle);
|
||||
DeleteEqueue(ctx, secondHandle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturedCompletion_DoesNotWakeDeletedEqueue()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var handle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
handle,
|
||||
ident: 0,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0));
|
||||
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||
memory,
|
||||
ident: 0,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
DeleteEqueue(ctx, handle);
|
||||
|
||||
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||
snapshot,
|
||||
data: 0);
|
||||
|
||||
Assert.Equal(0, delivery.TriggeredCount);
|
||||
Assert.Equal(1, delivery.StaleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturedCompletion_IsScopedToCreatingRuntime()
|
||||
{
|
||||
var firstMemory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var secondMemory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var firstContext = new CpuContext(firstMemory, Generation.Gen5);
|
||||
var secondContext = new CpuContext(secondMemory, Generation.Gen5);
|
||||
var firstHandle = CreateEqueue(
|
||||
firstContext,
|
||||
firstMemory,
|
||||
BaseAddress + 0x100);
|
||||
var secondHandle = CreateEqueue(
|
||||
secondContext,
|
||||
secondMemory,
|
||||
BaseAddress + 0x100);
|
||||
const ulong eventId = 0x20;
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
firstHandle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0x1111));
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
secondHandle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0x2222));
|
||||
|
||||
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||
firstMemory,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
Assert.Single(snapshot.Targets);
|
||||
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||
snapshot,
|
||||
eventId);
|
||||
|
||||
Assert.Equal(1, delivery.TriggeredCount);
|
||||
Assert.Equal(0, delivery.StaleCount);
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
firstHandle,
|
||||
out var delivered));
|
||||
Assert.Equal(0x1111UL, delivered.UserData);
|
||||
Assert.False(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
secondHandle,
|
||||
out _));
|
||||
|
||||
DeleteEqueue(firstContext, firstHandle);
|
||||
DeleteEqueue(secondContext, secondHandle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnePendingEventCanBeReservedByOnlyOneWaiter()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
var handle = ReadUInt64(memory, handleOutAddress);
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.EnqueueEvent(
|
||||
handle,
|
||||
new KernelEventQueueCompatExports.KernelQueuedEvent(
|
||||
7,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
KernelEventQueueCompatExports.KernelEventFlagClear,
|
||||
1,
|
||||
0,
|
||||
0)));
|
||||
|
||||
Assert.Equal(
|
||||
1,
|
||||
KernelEventQueueCompatExports.ReservePendingEventCountForTest(
|
||||
handle,
|
||||
eventCapacity: 1));
|
||||
Assert.Equal(
|
||||
0,
|
||||
KernelEventQueueCompatExports.ReservePendingEventCountForTest(
|
||||
handle,
|
||||
eventCapacity: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroTimeoutWithNoEventReturnsWithoutHostWait()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||
const ulong eventsAddress = BaseAddress + 0x200;
|
||||
const ulong outCountAddress = BaseAddress + 0x300;
|
||||
const ulong timeoutAddress = BaseAddress + 0x400;
|
||||
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
var handle = ReadUInt64(memory, handleOutAddress);
|
||||
WriteUInt64(memory, timeoutAddress, 0);
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
ctx[CpuRegister.Rsi] = eventsAddress;
|
||||
ctx[CpuRegister.Rdx] = 1;
|
||||
ctx[CpuRegister.Rcx] = outCountAddress;
|
||||
ctx[CpuRegister.R8] = timeoutAddress;
|
||||
|
||||
var result = KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
|
||||
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT,
|
||||
result);
|
||||
Assert.Equal(0u, ReadUInt32(memory, outCountAddress));
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.IsSynchronousPoll(
|
||||
timeoutAddress,
|
||||
timeoutUsec: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LevelUserEventPersistsButEdgeEventClears()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
var handle = ReadUInt64(memory, handleOutAddress);
|
||||
|
||||
const ulong levelIdent = 0xA1;
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
ctx[CpuRegister.Rsi] = levelIdent;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelAddUserEvent(ctx));
|
||||
ctx[CpuRegister.Rdx] = 0x1111;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
|
||||
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out var firstLevel));
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out var secondLevel));
|
||||
Assert.Equal((ushort)0, firstLevel.Flags);
|
||||
Assert.Equal(0x1111UL, firstLevel.UserData);
|
||||
Assert.Equal(firstLevel, secondLevel);
|
||||
|
||||
ctx[CpuRegister.Rsi] = levelIdent;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteUserEvent(ctx));
|
||||
|
||||
const ulong edgeIdent = 0xA2;
|
||||
ctx[CpuRegister.Rsi] = edgeIdent;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelAddUserEventEdge(ctx));
|
||||
ctx[CpuRegister.Rdx] = 0x2222;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
|
||||
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out var edge));
|
||||
Assert.Equal(
|
||||
KernelEventQueueCompatExports.KernelEventFlagClear,
|
||||
edge.Flags);
|
||||
Assert.Equal(0x2222UL, edge.UserData);
|
||||
Assert.False(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out _));
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeletingLevelRegistrationClearsItsReadyState()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
var handle = ReadUInt64(memory, handleOutAddress);
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
ctx[CpuRegister.Rsi] = 0xB1;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelAddUserEvent(ctx));
|
||||
ctx[CpuRegister.Rdx] = 0x3333;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteUserEvent(ctx));
|
||||
|
||||
Assert.False(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out _));
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
@@ -133,4 +517,24 @@ public sealed class AgcEventQueueTests
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static ulong CreateEqueue(
|
||||
CpuContext ctx,
|
||||
FakeCpuMemory memory,
|
||||
ulong handleOutAddress)
|
||||
{
|
||||
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 handle)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> { false, false, false };
|
||||
|
||||
var removed = AgcExports.CompactCompletedEntries(
|
||||
entries,
|
||||
static completed => completed,
|
||||
targetCount: 1);
|
||||
|
||||
Assert.Equal(0, removed);
|
||||
Assert.Equal(3, entries.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// 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 Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the SH-register path in the PM4 parser. A draw resolves its
|
||||
/// vertex stage from SPI_SHADER_PGM_LO_ES/HI_ES and its pixel stage from
|
||||
/// SPI_SHADER_PGM_LO_PS/HI_PS, both out of this dictionary, so a key that is
|
||||
/// dropped or written under a different encoding pairs a current pixel shader
|
||||
/// with a stale vertex shader — a failure that produces plausible-looking
|
||||
/// garbage rather than an error. These drive real PM4 packets through the
|
||||
/// public submit export and assert what the parser retained.
|
||||
/// </summary>
|
||||
public sealed class AgcShaderStageRegisterTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x2_0000_0000;
|
||||
private const ulong SubmitPacketAddress = BaseAddress + 0x40;
|
||||
private const ulong CommandAddress = BaseAddress + 0x200;
|
||||
private const ulong IndirectTableAddress = BaseAddress + 0x600;
|
||||
|
||||
private const uint ItNop = 0x10;
|
||||
private const uint ItSetShReg = 0x76;
|
||||
private const uint RShRegsIndirect = 0x11;
|
||||
|
||||
// SH register offsets. ES is the vertex stage on GFX10 — the standalone
|
||||
// PGM_LO/HI_GS pair is dead post-GCN and the merged ES/GS stage is addressed
|
||||
// through ES.
|
||||
private const uint SpiShaderPgmLoPs = 0x8;
|
||||
private const uint SpiShaderPgmLoEs = 0xC8;
|
||||
private const uint SpiShaderPgmHiEs = 0xC9;
|
||||
|
||||
private static uint Pm4Header(uint dwords, uint opcode, uint register = 0) =>
|
||||
0xC000_0000u | ((dwords - 2) << 16) | (opcode << 8) | ((register & 0x3Fu) << 2);
|
||||
|
||||
/// <summary>
|
||||
/// The baseline: a direct SET_SH_REG write of the vertex stage address has
|
||||
/// to be readable afterwards. If this fails, nothing downstream can pair
|
||||
/// shaders correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SetShRegRetainsExportShaderAddress()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteDwords(
|
||||
memory,
|
||||
CommandAddress,
|
||||
Pm4Header(3, ItSetShReg),
|
||||
SpiShaderPgmLoEs,
|
||||
0x0044_8582u);
|
||||
Submit(ctx, memory, dwordCount: 3);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsShRegisterForTests(ctx, SpiShaderPgmLoEs, out var value));
|
||||
Assert.Equal(0x0044_8582u, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The indirect encoding must land on the same keys as the direct one. A
|
||||
/// mismatch would store the stage address where the draw never reads it,
|
||||
/// leaving the draw to see whatever a previous submission left behind.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IndirectShRegisterWriteRetainsExportShaderAddress()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteIndirectShRegisterCommand(memory, (SpiShaderPgmLoEs, 0x0044_8DD1u));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsShRegisterForTests(ctx, SpiShaderPgmLoEs, out var value));
|
||||
Assert.Equal(0x0044_8DD1u, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Both stages written in one submission must both read back as written. If
|
||||
/// the vertex stage kept an older value while the pixel stage updated, every
|
||||
/// draw after it would be mis-paired.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BothStagesUpdateTogetherWithinOneSubmission()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteIndirectShRegisterCommand(
|
||||
memory,
|
||||
(SpiShaderPgmLoEs, 0x0080_2933u),
|
||||
(SpiShaderPgmLoPs, 0x0044_858Au));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
WriteIndirectShRegisterCommand(
|
||||
memory,
|
||||
(SpiShaderPgmLoEs, 0x0044_8581u),
|
||||
(SpiShaderPgmLoPs, 0x0044_8719u));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsShRegisterForTests(ctx, SpiShaderPgmLoEs, out var es));
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsShRegisterForTests(ctx, SpiShaderPgmLoPs, out var ps));
|
||||
Assert.Equal(0x0044_8581u, es);
|
||||
Assert.Equal(0x0044_8719u, ps);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updating only the pixel stage must leave the vertex stage at its previous
|
||||
/// value rather than dropping the key, or the draw falls back to whatever
|
||||
/// default the resolver finds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void PixelStageUpdateLeavesExportStageIntact()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteIndirectShRegisterCommand(memory, (SpiShaderPgmLoEs, 0x0044_8582u));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
WriteIndirectShRegisterCommand(memory, (SpiShaderPgmLoPs, 0x0044_858Au));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsShRegisterForTests(ctx, SpiShaderPgmLoEs, out var es));
|
||||
Assert.Equal(0x0044_8582u, es);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stage addresses are 64-bit: LO carries bits 39:8 and HI the top bits, and
|
||||
/// the draw combines them. A HI retained from an earlier shader while LO
|
||||
/// updates resolves to a splice of two different programs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HighAndLowHalvesUpdateTogether()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteIndirectShRegisterCommand(
|
||||
memory,
|
||||
(SpiShaderPgmLoEs, 0x0080_2933u),
|
||||
(SpiShaderPgmHiEs, 0x0000_0008u));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
WriteIndirectShRegisterCommand(
|
||||
memory,
|
||||
(SpiShaderPgmLoEs, 0x0044_8582u),
|
||||
(SpiShaderPgmHiEs, 0x0000_0004u));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsShRegisterForTests(ctx, SpiShaderPgmLoEs, out var lo));
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsShRegisterForTests(ctx, SpiShaderPgmHiEs, out var hi));
|
||||
Assert.Equal(0x0044_8582u, lo);
|
||||
Assert.Equal(0x0000_0004u, hi);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SH registers persist across submissions on hardware. A stage address set
|
||||
/// in one submission must still be there for a draw in the next.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ExportShaderAddressSurvivesASecondSubmission()
|
||||
{
|
||||
var ctx = CreateContext(out var memory);
|
||||
WriteIndirectShRegisterCommand(memory, (SpiShaderPgmLoEs, 0x0044_8583u));
|
||||
Submit(ctx, memory, dwordCount: 4);
|
||||
|
||||
WriteDwords(memory, CommandAddress, Pm4Header(2, ItNop), 0);
|
||||
Submit(ctx, memory, dwordCount: 2);
|
||||
|
||||
Assert.True(
|
||||
AgcExports.TryGetGraphicsShRegisterForTests(ctx, SpiShaderPgmLoEs, out var value));
|
||||
Assert.Equal(0x0044_8583u, value);
|
||||
}
|
||||
|
||||
private static void WriteIndirectShRegisterCommand(
|
||||
FakeCpuMemory memory,
|
||||
params (uint Offset, uint Value)[] registers)
|
||||
{
|
||||
WriteDwords(
|
||||
memory,
|
||||
CommandAddress,
|
||||
Pm4Header(4, ItNop, RShRegsIndirect),
|
||||
(uint)registers.Length,
|
||||
(uint)(IndirectTableAddress & 0xFFFF_FFFFu),
|
||||
(uint)(IndirectTableAddress >> 32));
|
||||
|
||||
for (var index = 0; index < registers.Length; index++)
|
||||
{
|
||||
var entry = IndirectTableAddress + ((ulong)index * 8);
|
||||
WriteUInt32(memory, entry, registers[index].Offset);
|
||||
WriteUInt32(memory, entry + 4, registers[index].Value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Submit(CpuContext ctx, FakeCpuMemory memory, uint dwordCount)
|
||||
{
|
||||
WriteUInt64(memory, SubmitPacketAddress, CommandAddress);
|
||||
WriteUInt32(memory, SubmitPacketAddress + 8, dwordCount);
|
||||
ctx[CpuRegister.Rdi] = SubmitPacketAddress;
|
||||
AgcExports.DriverSubmitDcb(ctx);
|
||||
}
|
||||
|
||||
private static CpuContext CreateContext(out FakeCpuMemory memory)
|
||||
{
|
||||
memory = new FakeCpuMemory(BaseAddress, 0x1000);
|
||||
return new CpuContext(memory, Generation.Gen5);
|
||||
}
|
||||
|
||||
private static void WriteDwords(FakeCpuMemory memory, ulong address, params uint[] values)
|
||||
{
|
||||
for (var index = 0; index < values.Length; index++)
|
||||
{
|
||||
WriteUInt32(memory, address + ((ulong)index * sizeof(uint)), values[index]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// sceAgcDriverSubmitAcb takes the compute queue's owner handle in rdi. That handle is the
|
||||
// eventId the guest registers with sceAgcDriverAddEqEvent, so an ACB reaching its queue fence
|
||||
// must deliver a graphics-filter completion event under that exact ident. UE 4.27's dynamic
|
||||
// resolution heuristic parks the game thread on that interrupt; without it the render side
|
||||
// never publishes GPU timings and the title deadlocks.
|
||||
[Collection(GraphicsEventQueueStateCollection.Name)]
|
||||
public sealed class AgcSubmitCompletionEventTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const int MemorySize = 0x2000;
|
||||
|
||||
private const ulong HandleOutAddress = BaseAddress + 0x100;
|
||||
private const ulong EventsAddress = BaseAddress + 0x200;
|
||||
private const ulong OutCountAddress = BaseAddress + 0x300;
|
||||
private const ulong TimeoutAddress = BaseAddress + 0x400;
|
||||
private const ulong PacketAddress = BaseAddress + 0x500;
|
||||
|
||||
[Fact]
|
||||
public void DriverSubmitAcb_DeliversCompletionEventUnderOwnerHandleIdent()
|
||||
{
|
||||
// Deliberately unusual so a graphics registration from a sibling test cannot alias it:
|
||||
// the kernel event registry is process-wide static state.
|
||||
const ulong ownerHandle = 0x5EA1;
|
||||
const ulong userData = 0xC0FF_EE00_1234_5678;
|
||||
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var equeue = CreateEqueue(ctx, memory);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
equeue,
|
||||
ownerHandle,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData));
|
||||
|
||||
SubmitEmptyAcb(ctx, memory, ownerHandle);
|
||||
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
WaitEqueue(ctx, memory, equeue));
|
||||
Assert.Equal(1u, ReadUInt32(memory, OutCountAddress));
|
||||
Assert.Equal(ownerHandle, ReadUInt64(memory, EventsAddress + 0x00));
|
||||
Assert.Equal(
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
ReadInt16(memory, EventsAddress + 0x08));
|
||||
Assert.Equal(ownerHandle, ReadUInt64(memory, EventsAddress + 0x10));
|
||||
Assert.Equal(userData, ReadUInt64(memory, EventsAddress + 0x18));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteEqueue(ctx, equeue);
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery is registration-gated: a title that never registered the ACB owner handle as a
|
||||
// graphics eventId must not observe a spurious completion interrupt.
|
||||
[Fact]
|
||||
public void DriverSubmitAcb_WithoutMatchingRegistration_DeliversNothing()
|
||||
{
|
||||
const ulong ownerHandle = 0x5EA2;
|
||||
const ulong unrelatedEventId = 0x5EA3;
|
||||
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var equeue = CreateEqueue(ctx, memory);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
equeue,
|
||||
unrelatedEventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0));
|
||||
|
||||
SubmitEmptyAcb(ctx, memory, ownerHandle);
|
||||
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT,
|
||||
WaitEqueue(ctx, memory, equeue));
|
||||
Assert.Equal(0u, ReadUInt32(memory, OutCountAddress));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteEqueue(ctx, equeue);
|
||||
}
|
||||
}
|
||||
|
||||
// The graphics queue keeps its own completion ident (0); an ACB owner handle must not be
|
||||
// able to wake a graphics-queue completion registration or vice versa.
|
||||
[Fact]
|
||||
public void DriverSubmitDcb_DeliversCompletionEventUnderGraphicsIdent()
|
||||
{
|
||||
const ulong graphicsCompletionIdent = 0;
|
||||
const ulong userData = 0xABCD_0000_0000_1111;
|
||||
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var equeue = CreateEqueue(ctx, memory);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
equeue,
|
||||
graphicsCompletionIdent,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData));
|
||||
|
||||
WriteEmptySubmitPacket(memory);
|
||||
ctx[CpuRegister.Rdi] = PacketAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
AgcExports.DriverSubmitDcb(ctx));
|
||||
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
WaitEqueue(ctx, memory, equeue));
|
||||
Assert.Equal(1u, ReadUInt32(memory, OutCountAddress));
|
||||
Assert.Equal(graphicsCompletionIdent, ReadUInt64(memory, EventsAddress + 0x00));
|
||||
Assert.Equal(userData, ReadUInt64(memory, EventsAddress + 0x18));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteEqueue(ctx, equeue);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SubmitEmptyAcb(CpuContext ctx, FakeCpuMemory memory, ulong ownerHandle)
|
||||
{
|
||||
WriteEmptySubmitPacket(memory);
|
||||
ctx[CpuRegister.Rdi] = ownerHandle;
|
||||
ctx[CpuRegister.Rsi] = PacketAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
AgcExports.DriverSubmitAcb(ctx));
|
||||
}
|
||||
|
||||
// A zero-dword submission parses as immediately complete, so the queue reaches its fence
|
||||
// without needing a live GPU backend.
|
||||
private static void WriteEmptySubmitPacket(FakeCpuMemory memory)
|
||||
{
|
||||
WriteUInt64(memory, PacketAddress, 0);
|
||||
WriteUInt32(memory, PacketAddress + 8, 0);
|
||||
}
|
||||
|
||||
private static ulong CreateEqueue(CpuContext ctx, FakeCpuMemory memory)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = HandleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
return ReadUInt64(memory, HandleOutAddress);
|
||||
}
|
||||
|
||||
// The kernel event registry is process-wide static state and deleting the queue drops its
|
||||
// registrations, so sibling suites that assert over every graphics registration stay clean.
|
||||
private static void DeleteEqueue(CpuContext ctx, ulong equeue)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = equeue;
|
||||
_ = KernelEventQueueCompatExports.KernelDeleteEqueue(ctx);
|
||||
}
|
||||
|
||||
private static int WaitEqueue(CpuContext ctx, FakeCpuMemory memory, ulong equeue)
|
||||
{
|
||||
WriteUInt64(memory, TimeoutAddress, 0);
|
||||
ctx[CpuRegister.Rdi] = equeue;
|
||||
ctx[CpuRegister.Rsi] = EventsAddress;
|
||||
ctx[CpuRegister.Rdx] = 4;
|
||||
ctx[CpuRegister.Rcx] = OutCountAddress;
|
||||
ctx[CpuRegister.R8] = TimeoutAddress;
|
||||
return KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static short ReadInt16(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[2];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadInt16LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> 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<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,96 @@ public sealed class Gen5VertexInputSpirvTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AliasedFetchInstructionsShareOneAttributeLocation()
|
||||
{
|
||||
// Metal caps a vertex function at 31 attributes, so every fetch that
|
||||
// reads one guest stream view must resolve to that view's single
|
||||
// location instead of declaring its own.
|
||||
var firstFetch = CreateVertexFetch(0);
|
||||
var secondFetch = CreateVertexFetch(4);
|
||||
var end = new Gen5ShaderInstruction(
|
||||
8,
|
||||
Gen5ShaderEncoding.Sopp,
|
||||
"SEndpgm",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null);
|
||||
var state = new Gen5ShaderState(
|
||||
new Gen5ShaderProgram(0, [firstFetch, secondFetch, end]),
|
||||
[],
|
||||
null);
|
||||
var registers = new uint[256];
|
||||
var data = new byte[16];
|
||||
var evaluation = new Gen5ShaderEvaluation(
|
||||
registers,
|
||||
registers,
|
||||
[],
|
||||
[],
|
||||
VertexInputs:
|
||||
[
|
||||
new Gen5VertexInputBinding(
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
10,
|
||||
0,
|
||||
0x1000,
|
||||
4,
|
||||
0,
|
||||
data,
|
||||
data.Length,
|
||||
DataPooled: false,
|
||||
AliasPcs: [4u]),
|
||||
]);
|
||||
|
||||
Assert.True(
|
||||
Gen5SpirvTranslator.TryCompileVertexShader(
|
||||
state,
|
||||
evaluation,
|
||||
out var shader,
|
||||
out var error),
|
||||
error);
|
||||
|
||||
var module = ParseModule(shader.Spirv);
|
||||
var locations = module
|
||||
.Where(candidate =>
|
||||
candidate.Opcode == SpirvOp.Decorate &&
|
||||
candidate.Operands.Length >= 3 &&
|
||||
candidate.Operands[1] == (uint)SpirvDecoration.Location)
|
||||
.ToArray();
|
||||
var inputVariable = Assert.Single(locations).Operands[0];
|
||||
|
||||
// Both fetches must read that variable; an unaliased second fetch would
|
||||
// fall through to the generic buffer path and leave only one load.
|
||||
Assert.Equal(
|
||||
2,
|
||||
module.Count(candidate =>
|
||||
candidate.Opcode == SpirvOp.Load &&
|
||||
candidate.Operands.Length >= 3 &&
|
||||
candidate.Operands[2] == inputVariable));
|
||||
}
|
||||
|
||||
private static Gen5ShaderInstruction CreateVertexFetch(uint pc) =>
|
||||
new(
|
||||
pc,
|
||||
Gen5ShaderEncoding.Mubuf,
|
||||
"BufferLoadFormatXyzw",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
new Gen5BufferMemoryControl(
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
IndexEnabled: true,
|
||||
OffsetEnabled: false,
|
||||
Glc: false,
|
||||
Slc: false));
|
||||
|
||||
private static IReadOnlyList<ParsedInstruction> ParseModule(byte[] spirv)
|
||||
{
|
||||
var instructions = new List<ParsedInstruction>();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Ampr;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Ampr;
|
||||
|
||||
// AmprFileRegistry is process-global static state, so the classes that index
|
||||
// or clear it must not run concurrently with each other.
|
||||
[Collection("AmprFileRegistry")]
|
||||
public class AmprFileRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeFileId_matches_utf8_fnv1a()
|
||||
{
|
||||
const string relative = "CoreData/foo/bar.bin";
|
||||
Assert.Equal(FnvUtf8("$/" + relative), AmprFileRegistry.ComputeFileId("$/" + relative));
|
||||
Assert.Equal(FnvUtf8("/app0/" + relative), AmprFileRegistry.ComputeFileId("/app0/" + relative));
|
||||
Assert.Equal(FnvUtf8("app0/" + relative), AmprFileRegistry.ComputeFileId("app0/" + relative));
|
||||
Assert.Equal(FnvUtf8(relative), AmprFileRegistry.ComputeFileId(relative));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterApp0Relative_publishes_same_ids_as_string_hashes()
|
||||
{
|
||||
AmprFileRegistry.ClearForTests();
|
||||
const string relative = "misc/loadouts/test.txt";
|
||||
var host = Path.Combine(Path.GetTempPath(), "sharpemu-ampr-test", relative);
|
||||
AmprFileRegistry.RegisterApp0RelativeForTests(relative, host);
|
||||
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("$/" + relative), out var a));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("/app0/" + relative), out var b));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("app0/" + relative), out var c));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId(relative), out var d));
|
||||
Assert.Equal(host, a);
|
||||
Assert.Equal(host, b);
|
||||
Assert.Equal(host, c);
|
||||
Assert.Equal(host, d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_publishes_all_app0_path_aliases()
|
||||
{
|
||||
AmprFileRegistry.ClearForTests();
|
||||
const string relative = "scripts/cp11/cp11main.script";
|
||||
var host = Path.Combine(Path.GetTempPath(), "sharpemu-ampr-test2", relative);
|
||||
AmprFileRegistry.Register("$/" + relative, host);
|
||||
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("$/" + relative), out var a));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("/app0/" + relative), out var b));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("app0/" + relative), out var c));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId(relative), out var d));
|
||||
Assert.Equal(host, a);
|
||||
Assert.Equal(host, b);
|
||||
Assert.Equal(host, c);
|
||||
Assert.Equal(host, d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void App0_index_cache_keeps_files_that_differ_only_by_case()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "sharpemu-ampr-case-" + Guid.NewGuid().ToString("N"));
|
||||
var cacheDir = Path.Combine(root, "..", "sharpemu-ampr-cache-" + Guid.NewGuid().ToString("N"));
|
||||
var upper = Path.Combine(root, "data", "ASSET.bin");
|
||||
var lower = Path.Combine(root, "data", "asset.bin");
|
||||
var previousCacheDir = Environment.GetEnvironmentVariable("SHARPEMU_AMPR_INDEX_CACHE");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(root, "sce_sys"));
|
||||
Directory.CreateDirectory(Path.Combine(root, "data"));
|
||||
File.WriteAllText(Path.Combine(root, "sce_sys", "param.json"), "{}");
|
||||
File.WriteAllBytes(upper, [1, 2, 3]);
|
||||
if (File.Exists(lower))
|
||||
{
|
||||
// Case-insensitive host: the two names are one file, so there is
|
||||
// nothing for an ignore-case index to lose.
|
||||
return;
|
||||
}
|
||||
|
||||
File.WriteAllBytes(lower, [4, 5, 6]);
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_AMPR_INDEX_CACHE", cacheDir);
|
||||
|
||||
var normalizedRoot = Path.GetFullPath(root);
|
||||
var expectedUpper = Path.Combine(normalizedRoot, "data", "ASSET.bin");
|
||||
var expectedLower = Path.Combine(normalizedRoot, "data", "asset.bin");
|
||||
|
||||
// Fresh tree walk, which also writes the on-disk index cache.
|
||||
AmprFileRegistry.ClearForTests();
|
||||
AmprFileRegistry.EnsureApp0Indexed(root);
|
||||
AssertResolves(expectedUpper, expectedLower);
|
||||
|
||||
// Second boot: served from the cache the walk just wrote.
|
||||
AmprFileRegistry.ClearForTests();
|
||||
AmprFileRegistry.EnsureApp0Indexed(root);
|
||||
AssertResolves(expectedUpper, expectedLower);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_AMPR_INDEX_CACHE", previousCacheDir);
|
||||
AmprFileRegistry.ClearForTests();
|
||||
TryDeleteDirectory(cacheDir);
|
||||
TryDeleteDirectory(root);
|
||||
}
|
||||
|
||||
static void AssertResolves(string expectedUpper, string expectedLower)
|
||||
{
|
||||
Assert.True(
|
||||
AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("$/data/ASSET.bin"), out var actualUpper),
|
||||
"data/ASSET.bin is missing from the app0 index.");
|
||||
Assert.True(
|
||||
AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("$/data/asset.bin"), out var actualLower),
|
||||
"data/asset.bin is missing from the app0 index.");
|
||||
Assert.Equal(expectedUpper, actualUpper);
|
||||
Assert.Equal(expectedLower, actualLower);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Temp cleanup is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
private static uint FnvUtf8(string text)
|
||||
{
|
||||
const uint offset = 2166136261;
|
||||
const uint prime = 16777619;
|
||||
var hash = offset;
|
||||
foreach (var b in System.Text.Encoding.UTF8.GetBytes(text))
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= prime;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Ampr;
|
||||
|
||||
[Collection("AmprFileRegistry")]
|
||||
public sealed class AmprWriteAddressTests
|
||||
{
|
||||
[Fact]
|
||||
|
||||
@@ -9,6 +9,7 @@ using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Ampr;
|
||||
|
||||
[Collection("AmprFileRegistry")]
|
||||
public sealed class AprStreamingContractTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -318,6 +319,53 @@ public sealed class AprStreamingContractTests
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_AlsoPublishesApp0AndDollarPathAliases()
|
||||
{
|
||||
// Resolve may register "$/asset.bin" while cooked tables look up
|
||||
// FNV("/app0/asset.bin"). Both ids must map to the same host file.
|
||||
var hostPath = Path.Combine(Path.GetTempPath(), $"sharpemu-apr-alias-{Guid.NewGuid():N}.bin");
|
||||
File.WriteAllBytes(hostPath, [1, 2, 3]);
|
||||
try
|
||||
{
|
||||
var dollarId = AmprFileRegistry.Register("$/weapons/demo.cani", hostPath);
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(dollarId, out var viaDollar));
|
||||
Assert.Equal(hostPath, viaDollar);
|
||||
|
||||
var app0Id = AmprFileRegistry.ComputeFileId("/app0/weapons/demo.cani");
|
||||
Assert.NotEqual(dollarId, app0Id);
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(app0Id, out var viaApp0));
|
||||
Assert.Equal(hostPath, viaApp0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(hostPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureApp0Indexed_PublishesCookedApp0FileIds()
|
||||
{
|
||||
var mountRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-apr-index-{Guid.NewGuid():N}");
|
||||
var relativeDir = Path.Combine(mountRoot, "weapons");
|
||||
Directory.CreateDirectory(relativeDir);
|
||||
var hostPath = Path.Combine(relativeDir, "demo.cani");
|
||||
File.WriteAllBytes(hostPath, [9, 8, 7]);
|
||||
try
|
||||
{
|
||||
AmprFileRegistry.EnsureApp0Indexed(mountRoot);
|
||||
var cookedId = AmprFileRegistry.ComputeFileId("/app0/weapons/demo.cani");
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(cookedId, out var resolved));
|
||||
Assert.Equal(Path.GetFullPath(hostPath), Path.GetFullPath(resolved));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(mountRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||
|
||||
@@ -75,49 +75,6 @@ public sealed class AvPlayerPathTests : IDisposable
|
||||
AssertPathIsInsideApp0(resolved);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "ffmpeg", "ffprobe")]
|
||||
[InlineData(true, "ffmpeg.exe", "ffprobe.exe")]
|
||||
public void MediaToolLookupUsesPlatformNames(
|
||||
bool isWindows,
|
||||
string ffmpegName,
|
||||
string ffprobeName)
|
||||
{
|
||||
var toolDirectory = Path.Combine(_tempRoot, "Media Tools");
|
||||
Directory.CreateDirectory(toolDirectory);
|
||||
var ffmpeg = Path.Combine(toolDirectory, ffmpegName);
|
||||
File.WriteAllBytes(ffmpeg, []);
|
||||
|
||||
var resolved = AvPlayerExports.FindFfmpeg(
|
||||
configured: null,
|
||||
searchPath: $"\"{toolDirectory}\"",
|
||||
isWindows);
|
||||
|
||||
Assert.Equal(ffmpeg, resolved);
|
||||
Assert.Equal(
|
||||
Path.Combine(toolDirectory, ffprobeName),
|
||||
AvPlayerExports.GetFfprobePath(ffmpeg, isWindows));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "ffmpeg")]
|
||||
[InlineData(true, "ffmpeg.exe")]
|
||||
public void MediaToolLookupFindsPackagedBinary(bool isWindows, string executable)
|
||||
{
|
||||
var publishDirectory = Path.Combine(_tempRoot, "publish");
|
||||
Directory.CreateDirectory(Path.Combine(publishDirectory, "ffmpeg"));
|
||||
var ffmpeg = Path.Combine(publishDirectory, "ffmpeg", executable);
|
||||
File.WriteAllBytes(ffmpeg, []);
|
||||
|
||||
Assert.Equal(
|
||||
ffmpeg,
|
||||
AvPlayerExports.FindFfmpeg(
|
||||
configured: null,
|
||||
searchPath: null,
|
||||
isWindows,
|
||||
publishDirectory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RelativeFileUriCannotEscapeApp0()
|
||||
{
|
||||
|
||||
@@ -61,6 +61,19 @@ public sealed class GuiSettingsTests
|
||||
Assert.Equal(1000, settings.RefreshRate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{ }""", "Carousel")]
|
||||
[InlineData("""{ "LibraryLayout": null }""", "Carousel")]
|
||||
[InlineData("""{ "LibraryLayout": "sideways" }""", "Carousel")]
|
||||
[InlineData("""{ "LibraryLayout": "grid" }""", "Grid")]
|
||||
[InlineData("""{ "LibraryLayout": "Grid" }""", "Grid")]
|
||||
public void NormalizeFromJson_LibraryLayout_FallsBackToCarousel(string json, string expected)
|
||||
{
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal(expected, settings.LibraryLayout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_CustomResolution_IsPreserved()
|
||||
{
|
||||
|
||||
@@ -57,4 +57,86 @@ public sealed class PerGameSettingsTests
|
||||
Assert.NotNull(settings);
|
||||
Assert.Equal(["SHARPEMU_TRACE", "SHARPEMU_NO_JIT"], settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveInheritedValues_AllMatchingValues_ProducesEmptySettings()
|
||||
{
|
||||
var global = new GuiSettings
|
||||
{
|
||||
LogLevel = "Info",
|
||||
ImportTraceLimit = 32,
|
||||
StrictDynlibResolution = true,
|
||||
LogToFile = false,
|
||||
WindowMode = "Borderless",
|
||||
Resolution = "2560x1440",
|
||||
DisplayIndex = 1,
|
||||
RefreshRate = 144,
|
||||
ScalingMode = "Fit",
|
||||
VSync = true,
|
||||
HdrMode = "Auto",
|
||||
EnvironmentToggles = ["SHARPEMU_LOG_IO", "SHARPEMU_VK_VALIDATION"],
|
||||
};
|
||||
var perGame = new PerGameSettings
|
||||
{
|
||||
LogLevel = "info",
|
||||
ImportTraceLimit = 32,
|
||||
StrictDynlibResolution = true,
|
||||
LogToFile = false,
|
||||
WindowMode = "borderless",
|
||||
Resolution = "2560x1440",
|
||||
DisplayIndex = 1,
|
||||
RefreshRate = 144,
|
||||
ScalingMode = "fit",
|
||||
VSync = true,
|
||||
HdrMode = "auto",
|
||||
EnvironmentToggles = ["SHARPEMU_VK_VALIDATION=1", "sharpemu_log_io"],
|
||||
};
|
||||
|
||||
perGame.RemoveInheritedValues(global);
|
||||
|
||||
Assert.True(perGame.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveInheritedValues_DifferentValues_RemainOverrides()
|
||||
{
|
||||
var global = new GuiSettings
|
||||
{
|
||||
LogLevel = "Info",
|
||||
Resolution = "1920x1080",
|
||||
VSync = true,
|
||||
EnvironmentToggles = ["SHARPEMU_LOG_IO"],
|
||||
};
|
||||
var perGame = new PerGameSettings
|
||||
{
|
||||
LogLevel = "Debug",
|
||||
Resolution = "2560x1440",
|
||||
VSync = false,
|
||||
EnvironmentToggles = ["SHARPEMU_VK_VALIDATION"],
|
||||
};
|
||||
|
||||
perGame.RemoveInheritedValues(global);
|
||||
|
||||
Assert.Equal("Debug", perGame.LogLevel);
|
||||
Assert.Equal("2560x1440", perGame.Resolution);
|
||||
Assert.False(perGame.VSync);
|
||||
Assert.Equal(["SHARPEMU_VK_VALIDATION"], perGame.EnvironmentToggles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveInheritedValues_DisabledEnvironmentEntry_MatchesMissingEntry()
|
||||
{
|
||||
var global = new GuiSettings
|
||||
{
|
||||
EnvironmentToggles = ["SHARPEMU_LOG_IO=0"],
|
||||
};
|
||||
var perGame = new PerGameSettings
|
||||
{
|
||||
EnvironmentToggles = [],
|
||||
};
|
||||
|
||||
perGame.RemoveInheritedValues(global);
|
||||
|
||||
Assert.Null(perGame.EnvironmentToggles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia.Controls;
|
||||
using SharpEmu.GUI;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.GUI;
|
||||
|
||||
public sealed class WindowChromeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(WindowState.Normal, "crop_square", "Maximize", "Maximize window")]
|
||||
[InlineData(WindowState.Maximized, "filter_none", "Restore", "Restore window")]
|
||||
public void GetMaximizeButtonState_ReturnsConsistentVisualAndAccessibleState(
|
||||
WindowState windowState,
|
||||
string expectedGlyph,
|
||||
string expectedToolTip,
|
||||
string expectedAutomationName)
|
||||
{
|
||||
var state = MainWindow.GetMaximizeButtonState(windowState);
|
||||
|
||||
Assert.Equal(expectedGlyph, state.Glyph);
|
||||
Assert.Equal(expectedToolTip, state.ToolTip);
|
||||
Assert.Equal(expectedAutomationName, state.AutomationName);
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,13 @@ public sealed class KernelEventQueueCompatExportsTests
|
||||
Assert.Equal(eventIdent, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x00..]));
|
||||
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterUser,
|
||||
BinaryPrimitives.ReadInt16LittleEndian(evt[0x08..]));
|
||||
Assert.Equal(triggerData, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x10..]));
|
||||
|
||||
// sceKernelTriggerUserEvent's third argument is the event's *udata*, not
|
||||
// its data word: the guest reads it back with sceKernelGetEventUserData,
|
||||
// which loads offset 0x18. Routing the payload to data(0x10) instead left
|
||||
// sceKernelGetEventUserData returning 0 for every triggered user event.
|
||||
Assert.Equal(triggerData, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x18..]));
|
||||
Assert.Equal(0UL, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x10..]));
|
||||
}
|
||||
|
||||
private static ulong CreateEqueue()
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Kernel;
|
||||
|
||||
public sealed class KernelEventQueueWaiterLifetimeTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const ulong HandleAddress = BaseAddress + 0x100;
|
||||
private const ulong EventsAddress = BaseAddress + 0x200;
|
||||
private const ulong OutCountAddress = BaseAddress + 0x300;
|
||||
|
||||
[Fact]
|
||||
public void DeleteEqueue_CompletesStagedWaiterAsDeleted()
|
||||
{
|
||||
var (memory, ctx, handle) = CreateEqueue();
|
||||
var waiter = StageGuestWait(ctx, handle, threadHandle: 0x701);
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
|
||||
Assert.True(waiter.TryWake());
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_DELETED,
|
||||
waiter.Resume());
|
||||
Assert.Equal(0u, ReadUInt32(memory, OutCountAddress));
|
||||
Assert.False(KernelEventQueueCompatExports.IsValidEqueue(handle));
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeletedGenerationWaiter_CannotConsumeNewQueueEvent()
|
||||
{
|
||||
var (memory, ctx, oldHandle) = CreateEqueue();
|
||||
var oldWaiter = StageGuestWait(ctx, oldHandle, threadHandle: 0x702);
|
||||
|
||||
ctx[CpuRegister.Rdi] = oldHandle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
|
||||
var newHandle = CreateEqueue(ctx, memory);
|
||||
Assert.NotEqual(oldHandle, newHandle);
|
||||
var expected = new KernelEventQueueCompatExports.KernelQueuedEvent(
|
||||
Ident: 0x77,
|
||||
Filter: KernelEventQueueCompatExports.KernelEventFilterUser,
|
||||
Flags: KernelEventQueueCompatExports.KernelEventFlagClear,
|
||||
Fflags: 1,
|
||||
Data: 0x1234,
|
||||
UserData: 0x5678);
|
||||
Assert.True(KernelEventQueueCompatExports.EnqueueEvent(
|
||||
newHandle,
|
||||
expected));
|
||||
|
||||
Assert.True(oldWaiter.TryWake());
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_DELETED,
|
||||
oldWaiter.Resume());
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
newHandle,
|
||||
out var delivered));
|
||||
Assert.Equal(expected, delivered);
|
||||
|
||||
ctx[CpuRegister.Rdi] = newHandle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
|
||||
private static (FakeCpuMemory Memory, CpuContext Context, ulong Handle)
|
||||
CreateEqueue()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x1000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
return (memory, ctx, CreateEqueue(ctx, memory));
|
||||
}
|
||||
|
||||
private static ulong CreateEqueue(CpuContext ctx, FakeCpuMemory memory)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = HandleAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
return ReadUInt64(memory, HandleAddress);
|
||||
}
|
||||
|
||||
private static IGuestThreadBlockWaiter StageGuestWait(
|
||||
CpuContext ctx,
|
||||
ulong handle,
|
||||
ulong threadHandle)
|
||||
{
|
||||
var previousThread = GuestThreadExecution.EnterGuestThread(threadHandle);
|
||||
var previousFrame = GuestThreadExecution.EnterImportCallFrame(
|
||||
returnRip: 0x1_0000 + threadHandle,
|
||||
resumeRsp: 0x2_0000 + threadHandle,
|
||||
returnSlotAddress: 0x3_0000 + threadHandle);
|
||||
try
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
ctx[CpuRegister.Rsi] = EventsAddress;
|
||||
ctx[CpuRegister.Rdx] = 1;
|
||||
ctx[CpuRegister.Rcx] = OutCountAddress;
|
||||
ctx[CpuRegister.R8] = 0;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelWaitEqueue(ctx));
|
||||
|
||||
Assert.True(GuestThreadExecution.TryConsumeCurrentThreadBlock(
|
||||
out var reason,
|
||||
out _,
|
||||
out var hasContinuation,
|
||||
out _,
|
||||
out var waiter,
|
||||
out _));
|
||||
Assert.Equal("sceKernelWaitEqueue", reason);
|
||||
Assert.True(hasContinuation);
|
||||
return Assert.IsAssignableFrom<IGuestThreadBlockWaiter>(waiter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousFrame);
|
||||
GuestThreadExecution.RestoreGuestThread(previousThread);
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(uint)];
|
||||
Assert.True(memory.TryRead(address, bytes));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||
Assert.True(memory.TryRead(address, bytes));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -2,18 +2,18 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.Libs.Bink;
|
||||
using SharpEmu.Libs.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Bink;
|
||||
namespace SharpEmu.Libs.Tests.Media;
|
||||
|
||||
public sealed class Bink2MovieBridgeTests : IDisposable
|
||||
public sealed class HostMovieBridgeTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-bink-{Guid.NewGuid():N}");
|
||||
|
||||
public Bink2MovieBridgeTests()
|
||||
public HostMovieBridgeTests()
|
||||
{
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public sealed class Bink2MovieBridgeTests : IDisposable
|
||||
{
|
||||
var path = WriteHeader("KB2j"u8, 3840, 2160, 30_000, 1_001);
|
||||
|
||||
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out var info));
|
||||
Assert.True(HostMovieBridge.TryReadBinkInfo(path, out var info));
|
||||
Assert.Equal(3840u, info.Width);
|
||||
Assert.Equal(2160u, info.Height);
|
||||
Assert.Equal(30_000u, info.FramesPerSecondNumerator);
|
||||
@@ -43,7 +43,7 @@ public sealed class Bink2MovieBridgeTests : IDisposable
|
||||
60,
|
||||
1);
|
||||
|
||||
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out _));
|
||||
Assert.True(HostMovieBridge.TryReadBinkInfo(path, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -51,7 +51,7 @@ public sealed class Bink2MovieBridgeTests : IDisposable
|
||||
{
|
||||
var path = WriteHeader("KB2j"u8, 1920, 1080, 60, 0);
|
||||
|
||||
Assert.False(Bink2MovieBridge.TryReadBinkInfo(path, out _));
|
||||
Assert.False(HostMovieBridge.TryReadBinkInfo(path, out _));
|
||||
}
|
||||
|
||||
private string WriteHeader(
|
||||
+8
-8
@@ -1,17 +1,17 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Bink;
|
||||
using SharpEmu.Libs.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Bink;
|
||||
namespace SharpEmu.Libs.Tests.Media;
|
||||
|
||||
public sealed class BinkFramePlaybackTests
|
||||
public sealed class MediaFramePlaybackTests
|
||||
{
|
||||
[Fact]
|
||||
public void FramesAdvanceAccordingToMovieClock()
|
||||
{
|
||||
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2, 3));
|
||||
using var playback = new MediaFramePlayback(new SequenceDecoder(1, 2, 3));
|
||||
|
||||
Assert.Equal(1, WaitForAdvancedFrame(playback)[0]);
|
||||
Assert.True(playback.TryGetFrame(true, out var heldFrame, out var advanced));
|
||||
@@ -22,7 +22,7 @@ public sealed class BinkFramePlaybackTests
|
||||
Assert.Equal(3, WaitForAdvancedFrame(playback)[0]);
|
||||
}
|
||||
|
||||
private static byte[] WaitForAdvancedFrame(BinkFramePlayback playback)
|
||||
private static byte[] WaitForAdvancedFrame(MediaFramePlayback playback)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
@@ -41,7 +41,7 @@ public sealed class BinkFramePlaybackTests
|
||||
[Fact]
|
||||
public void FirstFrameWaitsUntilPresentationStarts()
|
||||
{
|
||||
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2));
|
||||
using var playback = new MediaFramePlayback(new SequenceDecoder(1, 2));
|
||||
|
||||
var first = WaitForFrame(playback, advanceClock: false);
|
||||
Assert.Equal(1, first[0]);
|
||||
@@ -58,7 +58,7 @@ public sealed class BinkFramePlaybackTests
|
||||
}
|
||||
|
||||
private static byte[] WaitForFrame(
|
||||
BinkFramePlayback playback,
|
||||
MediaFramePlayback playback,
|
||||
bool advanceClock)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
@@ -75,7 +75,7 @@ public sealed class BinkFramePlaybackTests
|
||||
throw new TimeoutException("The decoder did not produce a frame.");
|
||||
}
|
||||
|
||||
private sealed class SequenceDecoder(params byte[] values) : IBinkFrameDecoder
|
||||
private sealed class SequenceDecoder(params byte[] values) : IMediaFrameDecoder
|
||||
{
|
||||
private int _index;
|
||||
|
||||
@@ -104,6 +104,45 @@ public sealed class GuestMemoryAllocatorTests
|
||||
Assert.Equal(0UL, (ulong)memory.GetPointer(address));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdjacentFixedGuestPageMappingsShareAHostGranule()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var memory = new PhysicalVirtualMemory(new GranularityAwareHostMemory());
|
||||
const ulong baseAddress = 0x0000008001600000;
|
||||
|
||||
Assert.Equal(baseAddress, memory.AllocateAt(baseAddress, 0x4000, executable: false, allowAlternative: false));
|
||||
Assert.Equal(
|
||||
baseAddress + 0x4000,
|
||||
memory.AllocateAt(baseAddress + 0x4000, 0x4000, executable: false, allowAlternative: false));
|
||||
Assert.Equal(
|
||||
baseAddress + 0x8000,
|
||||
memory.AllocateAt(baseAddress + 0x8000, 0x8000, executable: false, allowAlternative: false));
|
||||
|
||||
Assert.True(memory.IsAccessible(baseAddress, 0x10000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBackFixedRangeSharesAHostGranuleAcrossCallsOnWindows()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var memory = new PhysicalVirtualMemory(new GranularityAwareHostMemory());
|
||||
const ulong baseAddress = 0x0000008001600000;
|
||||
|
||||
Assert.True(memory.TryBackFixedRange(baseAddress, 0x4000, executable: false));
|
||||
Assert.True(memory.TryBackFixedRange(baseAddress + 0x4000, 0x4000, executable: false));
|
||||
|
||||
Assert.True(memory.IsAccessible(baseAddress, 0x8000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlignedAllocationDoesNotRetainOverallocatedMappingsOutsideMacOS()
|
||||
{
|
||||
@@ -133,6 +172,11 @@ public sealed class GuestMemoryAllocatorTests
|
||||
[Fact]
|
||||
public void TryBackFixedRangeRollsBackEarlierGapsWhenLaterGapCannotBeBacked()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout: committed | free | committed | free
|
||||
// First free gap allocates successfully, second fails.
|
||||
// The first allocation must be freed — nothing should leak.
|
||||
@@ -154,6 +198,11 @@ public sealed class GuestMemoryAllocatorTests
|
||||
[Fact]
|
||||
public void TryBackFixedRangeFillsOnlyTheFreePagesOfAPartiallyOccupiedRange()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const ulong rangeBase = 0x0000_0020_2F00_0000;
|
||||
const ulong rangeSize = 0x40_0000;
|
||||
const ulong occupiedSize = 0x4_0000;
|
||||
@@ -519,6 +568,154 @@ public sealed class GuestMemoryAllocatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class GranularityAwareHostMemory : IHostMemory
|
||||
{
|
||||
private const ulong Granularity = 0x10000;
|
||||
private const ulong Page = 0x1000;
|
||||
|
||||
private readonly SortedDictionary<ulong, (ulong Size, SortedSet<ulong> CommittedPages)> _allocations = new();
|
||||
|
||||
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection)
|
||||
{
|
||||
var reservedBase = Reserve(desiredAddress, size, protection);
|
||||
if (reservedBase != 0)
|
||||
{
|
||||
var start = desiredAddress == 0 ? reservedBase : AlignDown(desiredAddress, Page);
|
||||
Commit(start, size, protection);
|
||||
}
|
||||
|
||||
return reservedBase;
|
||||
}
|
||||
|
||||
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection)
|
||||
{
|
||||
if (desiredAddress == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var allocationBase = AlignDown(desiredAddress, Granularity);
|
||||
var end = AlignUp(desiredAddress + size, Page);
|
||||
foreach (var (existingBase, existing) in _allocations)
|
||||
{
|
||||
if (allocationBase < existingBase + existing.Size && existingBase < end)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
_allocations[allocationBase] = (end - allocationBase, new SortedSet<ulong>());
|
||||
return allocationBase;
|
||||
}
|
||||
|
||||
public bool Commit(ulong address, ulong size, HostPageProtection protection)
|
||||
{
|
||||
var start = AlignDown(address, Page);
|
||||
var end = AlignUp(address + size, Page);
|
||||
if (!TryFindAllocation(start, out var allocationBase, out var allocation) ||
|
||||
end > allocationBase + allocation.Size)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var page = start; page < end; page += Page)
|
||||
{
|
||||
allocation.CommittedPages.Add(page);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Free(ulong address) => _allocations.Remove(address);
|
||||
|
||||
public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Query(ulong address, out HostRegionInfo info)
|
||||
{
|
||||
var page = AlignDown(address, Page);
|
||||
if (TryFindAllocation(page, out var allocationBase, out var allocation))
|
||||
{
|
||||
var committed = allocation.CommittedPages.Contains(page);
|
||||
var runEnd = page + Page;
|
||||
while (runEnd < allocationBase + allocation.Size &&
|
||||
allocation.CommittedPages.Contains(runEnd) == committed)
|
||||
{
|
||||
runEnd += Page;
|
||||
}
|
||||
|
||||
info = new HostRegionInfo(
|
||||
page,
|
||||
allocationBase,
|
||||
runEnd - page,
|
||||
committed ? HostRegionState.Committed : HostRegionState.Reserved,
|
||||
0,
|
||||
committed ? HostPageProtection.ReadWrite : HostPageProtection.NoAccess,
|
||||
0,
|
||||
0);
|
||||
return true;
|
||||
}
|
||||
|
||||
var freeEnd = ulong.MaxValue;
|
||||
foreach (var existingBase in _allocations.Keys)
|
||||
{
|
||||
if (existingBase > page)
|
||||
{
|
||||
freeEnd = existingBase;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
info = new HostRegionInfo(
|
||||
page,
|
||||
0,
|
||||
freeEnd - page,
|
||||
HostRegionState.Free,
|
||||
0,
|
||||
HostPageProtection.NoAccess,
|
||||
0,
|
||||
0);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void FlushInstructionCache(ulong address, ulong size)
|
||||
{
|
||||
}
|
||||
|
||||
private bool TryFindAllocation(
|
||||
ulong address,
|
||||
out ulong allocationBase,
|
||||
out (ulong Size, SortedSet<ulong> CommittedPages) allocation)
|
||||
{
|
||||
foreach (var (existingBase, existing) in _allocations)
|
||||
{
|
||||
if (address >= existingBase && address < existingBase + existing.Size)
|
||||
{
|
||||
allocationBase = existingBase;
|
||||
allocation = existing;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
allocationBase = 0;
|
||||
allocation = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ulong AlignDown(ulong value, ulong alignment) => value & ~(alignment - 1);
|
||||
|
||||
private static ulong AlignUp(ulong value, ulong alignment) => (value + alignment - 1) & ~(alignment - 1);
|
||||
}
|
||||
|
||||
private sealed class LazyHostMemory(ulong address) : IHostMemory, IDisposable
|
||||
{
|
||||
public bool CommitSucceeds { get; set; } = true;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user