mirror of
https://github.com/par274/sharpemu.git
synced 2026-08-03 16:39:51 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2888f601ff | |||
| f3d9439952 | |||
| 8df4039ca4 | |||
| f36ce4084a | |||
| 4b5ea6a793 | |||
| cf3bd0b4f2 | |||
| 5ee7cd1dfa | |||
| ea9be7484f | |||
| a8fa9c96dc | |||
| c387b969e1 | |||
| 7c9740fee8 |
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ç",
|
||||
|
||||
@@ -86,6 +86,7 @@ public partial class MainWindow
|
||||
CloseGameSettings();
|
||||
LaunchSelected();
|
||||
};
|
||||
GameOptionsCloseButton.Click += (_, _) => CloseGameSettings();
|
||||
GameOptionsOpenFolderButton.Click += (_, _) => OpenSelectedGameFolder();
|
||||
GameOptionsCopyPathButton.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
|
||||
@@ -140,6 +141,7 @@ public partial class MainWindow
|
||||
!string.IsNullOrWhiteSpace(game.TitleId);
|
||||
|
||||
_isGameSettingsOpen = true;
|
||||
SetGameOptionsPagesSpan(coversConsoleRow: true);
|
||||
SetGameOptionsOpenClass(BackdropLayer, active: true);
|
||||
SetGameOptionsOpenClass(CarouselHost, active: true);
|
||||
SetGameOptionsOpenClass(LibrarySelectedDetails, active: true);
|
||||
@@ -158,6 +160,7 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
_isGameSettingsOpen = false;
|
||||
SetGameOptionsPagesSpan(coversConsoleRow: false);
|
||||
SetGameOptionsNavigationIndicator(_gameOptionsIndicatorIndex, animate: false);
|
||||
_gameSettingsTitleId = null;
|
||||
_gameEnvironmentPassthrough.Clear();
|
||||
@@ -438,6 +441,11 @@ public partial class MainWindow
|
||||
animate);
|
||||
}
|
||||
|
||||
private void SetGameOptionsPagesSpan(bool coversConsoleRow)
|
||||
{
|
||||
Grid.SetRowSpan(PagesHost, coversConsoleRow ? 2 : 1);
|
||||
}
|
||||
|
||||
private Button[] GameOptionsNavigationButtons() =>
|
||||
[
|
||||
GameOptionsGeneralNav,
|
||||
@@ -476,18 +484,6 @@ public partial class MainWindow
|
||||
("SHARPEMU_GUEST_IMAGE_CPU_SYNC", GameEnvGuestImageCpuSyncToggle),
|
||||
];
|
||||
|
||||
private static void SetGameOptionsOpenClass(Control control, bool active)
|
||||
{
|
||||
if (active)
|
||||
{
|
||||
if (!control.Classes.Contains("gameOptionsOpen"))
|
||||
{
|
||||
control.Classes.Add("gameOptionsOpen");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
control.Classes.Remove("gameOptionsOpen");
|
||||
}
|
||||
}
|
||||
private static void SetGameOptionsOpenClass(Control control, bool active) =>
|
||||
SetClass(control, "gameOptionsOpen", active);
|
||||
}
|
||||
|
||||
@@ -136,6 +136,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Button x:Name="LibraryLayoutButton"
|
||||
Classes="iconGhost"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="LibraryLayoutGlyph"
|
||||
Classes="materialSymbol compact"
|
||||
Text="grid_view" />
|
||||
</Button>
|
||||
<TextBox x:Name="SearchBox"
|
||||
PlaceholderText="{Binding [Library.SearchWatermark], Source={x:Static local:Localization.Instance}}"
|
||||
Width="240"
|
||||
@@ -147,7 +154,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Panel Grid.Row="1" x:Name="PagesHost">
|
||||
<Panel Grid.Row="1" x:Name="PagesHost" ZIndex="1">
|
||||
|
||||
<!-- Library page. Covers use a horizontal virtualized rail so the
|
||||
number of realized images stays bounded for large libraries. -->
|
||||
@@ -167,7 +174,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Transitions>
|
||||
</Panel.Transitions>
|
||||
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
|
||||
SelectionMode="Single" Padding="4,0,28,0">
|
||||
SelectionMode="Single">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
|
||||
<MenuItem x:Name="CtxLaunch"
|
||||
@@ -218,32 +225,35 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.DataTemplates>
|
||||
<DataTemplate x:DataType="local:GameEntry" x:CompileBindings="True">
|
||||
<Border Classes="coverShadow libraryGameCard"
|
||||
Width="148"
|
||||
Height="148"
|
||||
ToolTip.Tip="{Binding Name}"
|
||||
AutomationProperties.Name="{Binding Name}">
|
||||
<Border Classes="coverClip">
|
||||
<Panel>
|
||||
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="38" FontWeight="Light"
|
||||
Foreground="#C4E8ECF4"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Image Source="{Binding Cover}"
|
||||
Stretch="UniformToFill"
|
||||
RenderOptions.BitmapInterpolationMode="LowQuality"
|
||||
IsVisible="{Binding HasCover}" />
|
||||
</Panel>
|
||||
<StackPanel Spacing="7">
|
||||
<Border Classes="coverShadow libraryGameCard"
|
||||
Width="148"
|
||||
Height="148"
|
||||
ToolTip.Tip="{Binding Name}"
|
||||
AutomationProperties.Name="{Binding Name}">
|
||||
<Border Classes="coverClip">
|
||||
<Panel>
|
||||
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="38" FontWeight="Light"
|
||||
Foreground="#C4E8ECF4"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Image Source="{Binding Cover}"
|
||||
Stretch="UniformToFill"
|
||||
RenderOptions.BitmapInterpolationMode="LowQuality"
|
||||
IsVisible="{Binding HasCover}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
</Border>
|
||||
</Border>
|
||||
<TextBlock Classes="libraryTileName"
|
||||
Text="{Binding Name}"
|
||||
TextWrapping="Wrap"
|
||||
MaxLines="2"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate x:DataType="local:AddFolderTile">
|
||||
<Border Classes="addFolderTile"
|
||||
@@ -292,8 +302,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
MaxWidth="980"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Margin="4,8,0,0"
|
||||
Spacing="18">
|
||||
Margin="4,8,0,0">
|
||||
<StackPanel.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity"
|
||||
@@ -302,8 +311,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Duration="0:0:0.18" />
|
||||
</Transitions>
|
||||
</StackPanel.Transitions>
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="42"
|
||||
<Border Classes="selectedDetailsDivider" />
|
||||
<TextBlock Classes="selectedGameTitle"
|
||||
Text="{Binding Name}"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
@@ -440,7 +450,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Border>
|
||||
|
||||
<Border Padding="260,18,32,24">
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
<Grid ColumnDefinitions="*,Auto,Auto"
|
||||
ColumnSpacing="34">
|
||||
<StackPanel Spacing="3"
|
||||
VerticalAlignment="Center">
|
||||
@@ -491,6 +501,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Text="{Binding TitleId}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Button x:Name="GameOptionsCloseButton"
|
||||
Grid.Column="2"
|
||||
Classes="optionsCircle compact"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{Binding [Common.Back],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
AutomationProperties.Name="{Binding [Common.Back],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<TextBlock Classes="materialSymbol"
|
||||
Text="close" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -502,10 +526,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
RowDefinitions="Auto,*">
|
||||
<Button x:Name="GameOptionsLaunchButton"
|
||||
Classes="gameOptionsLaunch">
|
||||
<TextBlock VerticalAlignment="Center"
|
||||
Text="{Binding [Library.Context.Launch],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}" />
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Classes="materialSymbol"
|
||||
VerticalAlignment="Center"
|
||||
Text="play_arrow" />
|
||||
<TextBlock VerticalAlignment="Center"
|
||||
Text="{Binding [Library.Context.Launch],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<ScrollViewer x:Name="GameOptionsNavScroll"
|
||||
@@ -1188,8 +1220,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<StackPanel Spacing="8">
|
||||
<!--Latest commit info-->
|
||||
<Border Classes="optionsInfoRow">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="18">
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
||||
<Image Grid.Column="0"
|
||||
Source="avares://SharpEmu.GUI/Assets/commit-icon.png"
|
||||
Width="20" Height="20"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="LatestCommitLabel"
|
||||
Text="{Binding [About.Github.LatestCommitLabel], Source={x:Static local:Localization.Instance}}"
|
||||
FontSize="14"
|
||||
@@ -1200,7 +1237,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Foreground="{StaticResource SettingsDescriptionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
<Button Grid.Column="2"
|
||||
x:Name="LatestCommitHashText"
|
||||
Classes="optionAction"
|
||||
Content="Loading…"
|
||||
@@ -1213,8 +1250,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<!--Update-->
|
||||
<Border Classes="optionsInfoRow">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="18">
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
||||
<Image Grid.Column="0"
|
||||
Source="avares://SharpEmu.GUI/Assets/update-icon.png"
|
||||
Width="20" Height="20"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="UpdateLabel"
|
||||
Text="{Binding [Updater.Label], Source={x:Static local:Localization.Instance}}"
|
||||
FontSize="14"
|
||||
@@ -1225,7 +1267,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Foreground="{StaticResource SettingsDescriptionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
<Button Grid.Column="2"
|
||||
x:Name="UpdateButton"
|
||||
Classes="optionAction"
|
||||
Content="Check for updates"
|
||||
@@ -1235,8 +1277,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<!--Github-->
|
||||
<Border Classes="optionsInfoRow">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="18">
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
||||
<Image Grid.Column="0"
|
||||
Source="avares://SharpEmu.GUI/Assets/github.png"
|
||||
Width="20" Height="20"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="GithubLabel"
|
||||
Text="{Binding [About.Github.Label], Source={x:Static local:Localization.Instance}}"
|
||||
FontSize="14"
|
||||
@@ -1247,7 +1294,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Foreground="{StaticResource SettingsDescriptionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
<Button Grid.Column="2"
|
||||
x:Name="GithubButton"
|
||||
Classes="optionAction"
|
||||
Content="{Binding [About.GithubButton], Source={x:Static local:Localization.Instance}}"
|
||||
@@ -1257,8 +1304,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<!--Discord-->
|
||||
<Border Classes="optionsInfoRow">
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="18">
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="18">
|
||||
<Image Grid.Column="0"
|
||||
Source="avares://SharpEmu.GUI/Assets/discord.png"
|
||||
Width="20" Height="20"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="DiscordServerLabel"
|
||||
Text="{Binding [About.Discord.Label], Source={x:Static local:Localization.Instance}}"
|
||||
FontSize="14"
|
||||
@@ -1269,7 +1321,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Foreground="{StaticResource SettingsDescriptionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1"
|
||||
<TextBlock Grid.Column="2"
|
||||
x:Name="DiscordComingSoonText"
|
||||
Text="{Binding [About.DiscordComingSoon], Source={x:Static local:Localization.Instance}}"
|
||||
FontSize="12"
|
||||
|
||||
@@ -36,7 +36,7 @@ public partial class MainWindow : Window
|
||||
private const int MaxConsoleLines = 4000;
|
||||
private const int MaxConsoleLinesPerFlush = 500;
|
||||
private static readonly TimeSpan NavigationIndicatorAnimationDuration =
|
||||
TimeSpan.FromMilliseconds(240);
|
||||
TimeSpan.FromMilliseconds(180);
|
||||
|
||||
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
|
||||
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
|
||||
@@ -123,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
|
||||
@@ -134,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();
|
||||
@@ -219,6 +222,9 @@ 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();
|
||||
@@ -379,18 +385,73 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +555,7 @@ public partial class MainWindow : Window
|
||||
translationAnimation.InsertExpressionKeyFrame(
|
||||
1f,
|
||||
"this.FinalValue",
|
||||
new SineEaseInOut());
|
||||
new CubicEaseOut());
|
||||
|
||||
var animations = visual.Compositor.CreateImplicitAnimationCollection();
|
||||
animations[nameof(CompositionVisual.Translation)] = translationAnimation;
|
||||
@@ -676,6 +737,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)
|
||||
{
|
||||
@@ -712,6 +790,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
|
||||
@@ -747,6 +848,8 @@ public partial class MainWindow : Window
|
||||
{
|
||||
_ = CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
SeedLibraryFromCache();
|
||||
await RescanLibraryAsync();
|
||||
}
|
||||
|
||||
@@ -783,6 +886,7 @@ public partial class MainWindow : Window
|
||||
RefreshHostRefreshRates(_settings.RefreshRate);
|
||||
RefreshUpdateText();
|
||||
UpdateEmptyStateTexts();
|
||||
UpdateLibraryLayoutButton();
|
||||
UpdateRunButtons();
|
||||
}
|
||||
|
||||
@@ -1067,6 +1171,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");
|
||||
@@ -1444,6 +1549,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();
|
||||
@@ -1473,6 +1603,7 @@ public partial class MainWindow : Window
|
||||
LoadingState.IsVisible = false;
|
||||
LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games);
|
||||
UpdateDiscordPresence();
|
||||
GameLibraryCache.Save(folders, reconciliation.Games);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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}" />
|
||||
|
||||
@@ -103,6 +103,57 @@ Contextual per-game options reveal, actions, and selected-game motion.
|
||||
<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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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).
|
||||
@@ -501,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,
|
||||
@@ -2174,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",
|
||||
@@ -6426,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"/>.
|
||||
@@ -6808,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)
|
||||
{
|
||||
@@ -7444,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.
|
||||
@@ -7740,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,
|
||||
@@ -7757,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(
|
||||
@@ -7775,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;
|
||||
}
|
||||
|
||||
@@ -8052,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
|
||||
{
|
||||
@@ -8236,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;
|
||||
@@ -12512,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;
|
||||
}
|
||||
|
||||
@@ -12673,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];
|
||||
|
||||
@@ -72,7 +72,7 @@ public static class AmprExports
|
||||
private const int MaxCachedHostFiles = 1536;
|
||||
private static readonly object _hostFileCacheGate = new();
|
||||
private static readonly Dictionary<string, LinkedListNode<CachedHostFileEntry>> _hostFileByPath =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
new(HostFsPath.Comparer);
|
||||
private static readonly LinkedList<CachedHostFileEntry> _hostFileLru = new();
|
||||
|
||||
[SysAbiExport(
|
||||
|
||||
@@ -111,7 +111,7 @@ internal static class AmprFileRegistry
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (string.Equals(_indexedApp0Root, normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(_indexedApp0Root, normalizedRoot, HostFsPath.Comparison))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ internal static class AmprFileRegistry
|
||||
if (string.Equals(
|
||||
_indexingApp0Root,
|
||||
normalizedRoot,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
HostFsPath.Comparison))
|
||||
{
|
||||
Monitor.Wait(_indexGate);
|
||||
continue;
|
||||
@@ -174,20 +174,34 @@ internal static class AmprFileRegistry
|
||||
}
|
||||
|
||||
var relatives = new List<string>(256 * 1024);
|
||||
foreach (var hostPath in Directory.EnumerateFiles(
|
||||
normalizedRoot,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
try
|
||||
{
|
||||
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||
.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(relative) ||
|
||||
relative.StartsWith("..", StringComparison.Ordinal))
|
||||
foreach (var hostPath in Directory.EnumerateFiles(
|
||||
normalizedRoot,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||
.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(relative) ||
|
||||
relative.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
relatives.Add(relative);
|
||||
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
|
||||
@@ -315,7 +329,10 @@ internal static class AmprFileRegistry
|
||||
"ampr-index");
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var rootHash = ComputeFileId(normalizedRoot.ToLowerInvariant());
|
||||
// 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");
|
||||
}
|
||||
|
||||
@@ -360,7 +377,7 @@ internal static class AmprFileRegistry
|
||||
}
|
||||
|
||||
var root = reader.ReadString();
|
||||
if (!string.Equals(root, normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.Equals(root, normalizedRoot, HostFsPath.Comparison))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -470,7 +487,7 @@ internal static class AmprFileRegistry
|
||||
return;
|
||||
}
|
||||
|
||||
var relatives = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var relatives = new HashSet<string>(HostFsPath.Comparer);
|
||||
foreach (var hostPath in _hostPathsById.Values)
|
||||
{
|
||||
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -5203,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;
|
||||
}
|
||||
@@ -5305,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;
|
||||
}
|
||||
@@ -5332,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
@@ -1803,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ 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]
|
||||
@@ -62,6 +65,79 @@ public class AmprFileRegistryTests
|
||||
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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Silk.NET.Vulkan;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.VideoOut;
|
||||
|
||||
public sealed class VulkanFormatConversionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Format.R8G8B8A8Unorm, Format.A2R10G10B10UnormPack32, true)]
|
||||
[InlineData(Format.R8G8B8A8Unorm, Format.A2B10G10R10UnormPack32, true)]
|
||||
[InlineData(Format.A2R10G10B10UnormPack32, Format.R8G8B8A8Unorm, true)]
|
||||
[InlineData(Format.A2B10G10R10UnormPack32, Format.R8G8B8A8Unorm, true)]
|
||||
public void RequiresRealFormatConversion_FlagsTheBitIncompatiblePair(
|
||||
Format from,
|
||||
Format to,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, VulkanVideoPresenter.RequiresRealFormatConversion(from, to));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Format.R8G8B8A8Unorm, Format.B8G8R8A8Unorm)]
|
||||
[InlineData(Format.R8G8B8A8Unorm, Format.R8G8B8A8Srgb)]
|
||||
[InlineData(Format.R8G8B8A8Unorm, Format.R8G8B8A8Unorm)]
|
||||
[InlineData(Format.A2R10G10B10UnormPack32, Format.A2B10G10R10UnormPack32)]
|
||||
[InlineData(Format.R16G16B16A16Sfloat, Format.R32G32Sfloat)]
|
||||
public void RequiresRealFormatConversion_LeavesEveryOtherPairAlone(Format from, Format to)
|
||||
{
|
||||
Assert.False(VulkanVideoPresenter.RequiresRealFormatConversion(from, to));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BitCastOfOpaqueBlackRgba8AsA2r10g10b10_ProducesTheObservedRed()
|
||||
{
|
||||
const uint opaqueBlackRgba8 = 0xFF000000u; // bytes 00 00 00 FF, little-endian
|
||||
|
||||
var alpha2Bit = (opaqueBlackRgba8 >> 30) & 0x3u;
|
||||
var red10Bit = (opaqueBlackRgba8 >> 20) & 0x3FFu;
|
||||
var green10Bit = (opaqueBlackRgba8 >> 10) & 0x3FFu;
|
||||
var blue10Bit = opaqueBlackRgba8 & 0x3FFu;
|
||||
|
||||
Assert.Equal(3u, alpha2Bit);
|
||||
Assert.Equal(1008u, red10Bit);
|
||||
Assert.Equal(0u, green10Bit);
|
||||
Assert.Equal(0u, blue10Bit);
|
||||
|
||||
var redAsFloat = red10Bit / 1023.0;
|
||||
Assert.True(
|
||||
Math.Abs(redAsFloat - 0.9853372434443793) < 0.0001,
|
||||
$"expected ~0.9853 (matches the red observed live), got {redAsFloat}");
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,6 @@ public sealed class VulkanGuestImageAliasTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Format.R8Srgb, Format.R8Unorm)]
|
||||
[InlineData(Format.BC3SrgbBlock, Format.BC3UnormBlock)]
|
||||
public void CounterpartsOutsideTheViewClassTableAreNotAliased(
|
||||
Format existing,
|
||||
@@ -68,6 +67,15 @@ public sealed class VulkanGuestImageAliasTests
|
||||
VulkanVideoPresenter.IsAliasableGuestImageFormat(existing, requested));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void R8SrgbAndR8UnormShareOneCompatibilityClass()
|
||||
{
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.IsCompatibleGuestImageViewFormat(
|
||||
Format.R8Srgb,
|
||||
Format.R8Unorm));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AliasedPairStaysWithinOneCompatibilityClass()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user