Compare commits

...

11 Commits

Author SHA1 Message Date
ParantezTech 2888f601ff [videoout] guard degenerate guest buffer ranges 2026-08-03 09:52:57 +03:00
Foued Attar f3d9439952 Fix Vulkan presenter synchronization and frame handling issues (#747)
* [VideoOut/Vulkan] Fix boot deadlock, writeback stall, and presentation bugs

- Presenter thread now starts on the compute dispatch path too, fixing
  a boot deadlock when a title's first GPU work is compute (Ghost of
  Yotei G-Buffer clear).
- Guest render-target format swaps between sibling pixel formats now
  reinterpret in place instead of recreating blank, preserving
  GPU-written content.
- Vectorized the guest-buffer writeback scan (equal-byte skip + coarse
  per-page pre-check), fixing multi-second stalls on fragmented buffers
  that starved JobWorker completion signals.
- _presentedSequence now advances on every Render() early-return path,
  fixing an unthrottled busy loop on stale/dropped presentations.

* Remove unnecessary Silk.NET.Windowing dependency
2026-08-03 00:36:33 +03:00
999sian 8df4039ca4 Ampr: fix path case on Linux (#750) 2026-08-03 00:36:00 +03:00
Foued Attar f36ce4084a [Memory/Kernel] Fix Windows allocation-granularity and mutex-resolution bugs (#748)
- Fixed guest mappings now go through a granule-aware allocator so
  adjacent PS5 16 KiB pages sharing a 64 KiB Windows allocation
  granule no longer collide and fail.
- TryBackFixedRange routes free/reserved gaps through the same
  granule-safe path, fixing strays that stranded the rest of a granule.
- NORMAL pthread mutex self-relock reverted to real EDEADLK instead of
  silent compatibility recursion, which was starving other threads.
- TryResolveMutexState now checks the handle-keyed lookup before
  falling through to "not found" on a fresh, never-cached address.
2026-08-03 00:15:42 +03:00
Arseny Yankovsky 4b5ea6a793 AGC: honour DCC fast clears instead of drawing the clear quad (#738)
On GFX10 a colour clear is not a packet. The driver programs
CB_COLORn_CLEAR_WORD0/1 and draws a covering quad which the colour block
turns into DCC clear codes, discarding whatever the pixel shader
exported. We executed that quad as an ordinary draw, so the shaded output
landed in the surface instead of a clear.

That alone would be a wrong-pixels bug, but the blend those quads use
makes it compound. Every draw into the target blends src=ONE,
dst=ONE_MINUS_SRC_ALPHA, so alpha follows a <- a_src + a_dst*(1 - a_src),
whose fixed point is 1. A guest colour attachment is cleared once on
first use and loaded on every pass after, so nothing ever resets it and
the channel climbs until it saturates. Where such a surface is a
compositing layer, the final image is ui.rgb + scene.rgb*(1 - ui.a) and a
saturated alpha multiplies the scene away entirely - the scene renders
correctly the whole time and is then masked to black.

Recognise the clear and perform it: the attachment is reset and the quad
is dropped, which reproduces the observable effect without modelling DCC
block state. The reset drops the image's Initialized flag so the next
render pass clears via AttachmentLoadOp.Clear, rather than enqueuing a
CmdClearColorImage - the latter lands outside the following render pass,
and a target cleared that way was still observed reading back its
previous contents.

Restricted to clear-to-zero: the reset clears to zero, so a nonzero
CLEAR_WORD would be cleared to the wrong colour and is left to be drawn.
Zero is zero under every encoding the register pair can carry, so the
test needs no format handling.

The clip-space span test is load-bearing rather than defensive. Fills
sharing the vertex count, topology and blend outnumber the clears by two
orders of magnitude and sit well outside the frame; treating those as
clears erases the UI and blanks video surfaces.
2026-08-02 16:39:25 +03:00
Astell cf3bd0b4f2 Astrobot - Vulkan fix : 0x8A (#736) 2026-08-02 13:28:41 +03:00
AlexC 5ee7cd1dfa Icons in about section (#735) 2026-08-01 18:01:38 +03:00
Arseny Yankovsky ea9be7484f AGC: correct GS program registers, stop dropping rect-list draws, add a missing size export (#734)
Three defects found while bringing up a PS5 title. None are title-specific.

SPI_SHADER_PGM_LO_GS / HI_GS were 0x8A/0x8B, which are actually
SPI_SHADER_PGM_RSRC1/RSRC2_GS. Reading them as an address produced a
58-bit value (observed live: 0x30004622C008300). The correct offsets are
0x88/0x89, consistent with SPI_SHADER_PGM_CHKSUM_GS = 0x80 and
SPI_SHADER_PGM_LO_ES = 0xC8 already in the table.

Draw translation dropped any RECT_LIST draw whose vertex program exported
no parameters while the pixel shader had interpolated inputs. A
disposition census over a real run measured this deleting ~620 of every
5000 draws (12%). Rect lists are what AMD drivers emit for clears, blits
and resolves, so the guard was deleting clears and leaving previous frame
contents on screen as trails. Rendering a draw whose interpolants are
undefined is strictly better than deleting it.

sceAgcDcbDrawIndexIndirectMultiGetSize was unimplemented while
sceAgcDcbDrawIndexIndirectMulti emits an eight-dword packet. A title that
sizes its command buffer from the missing export under-reserves by three
dwords. NID derived with Ps5Nid.Compute, which reproduces the committed
NIDs of the neighbouring exports.

Tests: AgcContextRegisterTests and AgcShaderStageRegisterTests drive real
PM4 packets through sceAgcDriverSubmitDcb and assert what the parser
retained for the context and SH register dictionaries, via two new
internal accessors; neither dictionary had any test surface, which is why
register questions previously cost five-minute game runs.
2026-08-01 17:30:02 +03:00
Astell a8fa9c96dc Astrobot - Vulkan fix (#733)
* log and vulkan fix

* Astrobot - Vulkan fix : OpControlBarrier
2026-08-01 17:17:45 +03:00
Berk c387b969e1 [GUI] few stability patches for GUI (#732) 2026-08-01 15:05:13 +03:00
Berk 7c9740fee8 [GUI] Add grid settings to GUI and implement grid snapping (#729) 2026-07-31 13:27:43 +03:00
29 changed files with 2730 additions and 240 deletions
+287 -21
View File
@@ -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);
}
+168
View File
@@ -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;
}
}
+3
View File
@@ -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");
+2
View File
@@ -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",
+2
View File
@@ -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ç",
+10 -14
View File
@@ -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);
}
+98 -46
View File
@@ -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"
+137 -6
View File
@@ -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" />
+218 -44
View File
@@ -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 &lt;- 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];
+1 -1
View File
@@ -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(
+33 -16
View File
@@ -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)
+21
View File
@@ -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()
{