mirror of
https://github.com/par274/sharpemu.git
synced 2026-08-02 07:59:44 +08:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da36a45bf3 | |||
| 7c9740fee8 | |||
| 544f588cfd | |||
| ecd657006a | |||
| a7ec3d5a77 | |||
| 97bd8c422e | |||
| c4ae4a2059 | |||
| 93c9f14081 | |||
| 532251c0c3 | |||
| 816ec4ad27 | |||
| 82c2c7f48c | |||
| 531e35b6d5 | |||
| 3f9bd2b92b | |||
| eb0653eded | |||
| e1695cf87f | |||
| 0dd543354d | |||
| fc5b6baaa7 | |||
| e5e02c0908 | |||
| 539baa66e7 | |||
| b572738547 | |||
| b75e4e01a0 | |||
| 79aa764d03 | |||
| ec65419c0a | |||
| c990b7799f | |||
| e7149bf41f | |||
| 444af50f4b | |||
| 5864328e35 | |||
| 753ddf93be |
@@ -214,6 +214,12 @@ public sealed partial class DirectExecutionBackend
|
||||
Console.Error.WriteLine($"[LOADER][TRACE] Raw sentinel recoveries: {num2} (last import index={importIndex})");
|
||||
_lastReportedRawSentinelRecoveries = num2;
|
||||
}
|
||||
if (importStubEntry.IsLeaf &&
|
||||
TryDispatchHotMemoryLeaf(cpuContext, importStubEntry, argPackPtr, out var hotMemoryResult))
|
||||
{
|
||||
return hotMemoryResult;
|
||||
}
|
||||
|
||||
if (importStubEntry.IsLeaf &&
|
||||
TryDispatchLeafImport(cpuContext, importStubEntry, argPackPtr, num, out var leafResult))
|
||||
{
|
||||
@@ -381,7 +387,8 @@ public sealed partial class DirectExecutionBackend
|
||||
bool flag4 = !string.IsNullOrWhiteSpace(_importFilter);
|
||||
bool flag5 = false;
|
||||
ExportedFunction? matchedExport = importStubEntry.Export;
|
||||
bool periodicTrace = num <= 128 ||
|
||||
bool periodicTrace = _logImportPeriodic &&
|
||||
(num <= 128 ||
|
||||
(num >= 240 && num <= 400) ||
|
||||
(num >= 900 && num <= 1300) ||
|
||||
num % 100000 == 0L ||
|
||||
@@ -389,7 +396,7 @@ public sealed partial class DirectExecutionBackend
|
||||
(importStubEntry.Nid == "rTXw65xmLIA" && (num <= 256 || num % 128 == 0)) ||
|
||||
flag ||
|
||||
flag2 ||
|
||||
flag3;
|
||||
flag3);
|
||||
if (matchedExport is not null)
|
||||
{
|
||||
if (flag4)
|
||||
@@ -1274,6 +1281,41 @@ public sealed partial class DirectExecutionBackend
|
||||
Mxcsr: context.Mxcsr,
|
||||
RestoreFullFpuState: false);
|
||||
|
||||
/// <summary>
|
||||
/// Ultra-thin path for hot memcpy/memmove leaf imports: skip
|
||||
/// CpuContext register marshalling, import-call frames, and vector return
|
||||
/// stores when guest memory can satisfy the copy directly.
|
||||
/// </summary>
|
||||
private unsafe bool TryDispatchHotMemoryLeaf(
|
||||
CpuContext cpuContext,
|
||||
ImportStubEntry importStubEntry,
|
||||
nint argPackPtr,
|
||||
out ulong result)
|
||||
{
|
||||
result = 0;
|
||||
var nid = importStubEntry.Nid;
|
||||
if (nid is not ("Q3VBxCXhUHs" or "+P6FRGH4LfA"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var destination = *(ulong*)argPackPtr;
|
||||
var source = *(ulong*)(argPackPtr + 8);
|
||||
var count = *(ulong*)(argPackPtr + 16);
|
||||
if (count > (ulong)int.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (count != 0 && !cpuContext.Memory.TryCopy(destination, source, count))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = destination;
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryDispatchLeafImport(
|
||||
CpuContext cpuContext,
|
||||
ImportStubEntry importStubEntry,
|
||||
@@ -1337,7 +1379,7 @@ public sealed partial class DirectExecutionBackend
|
||||
Volatile.Write(ref activeGuestThreadState.LastReturnRip, returnRip);
|
||||
Volatile.Write(ref activeGuestThreadState.LastImportNid, importStubEntry.Nid);
|
||||
}
|
||||
if (dispatchIndex % 100000 == 0)
|
||||
if (_logImportPeriodic && dispatchIndex % 100000 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] Import#{dispatchIndex}: {export.LibraryName}:{export.Name} ({importStubEntry.Nid}) " +
|
||||
@@ -1471,7 +1513,12 @@ public sealed partial class DirectExecutionBackend
|
||||
"xk0AcarP3V4" or // scePadOpen
|
||||
"yH17Q6NWtVg" or // sceUserServiceGetEvent
|
||||
"D-CzAxQL0XI" or // sceUserServiceGetPlatformPrivacySetting
|
||||
"K-jXhbt2gn4"; // scePthreadMutexTrylock
|
||||
"K-jXhbt2gn4" or // scePthreadMutexTrylock
|
||||
// Hot memory leaves: skip non-NoBlock call-frame bookkeeping on top of TryCopy.
|
||||
"Q3VBxCXhUHs" or // memcpy
|
||||
"+P6FRGH4LfA" or // memmove
|
||||
"DfivPArhucg" or // memcmp
|
||||
"8zTFvBIAIN8"; // memset
|
||||
|
||||
private bool ShouldLogImportResult(string nid, OrbisGen2Result result)
|
||||
{
|
||||
|
||||
@@ -344,6 +344,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private bool _logAllImports;
|
||||
|
||||
private bool _logImportPeriodic;
|
||||
|
||||
private bool _logImportFrames;
|
||||
|
||||
private bool _logImportRecent;
|
||||
@@ -1161,6 +1163,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
_logFiber = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_FIBER"), "1", StringComparison.Ordinal);
|
||||
_logBootstrap = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_BOOTSTRAP"), "1", StringComparison.Ordinal);
|
||||
_logAllImports = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_ALL_IMPORTS"), "1", StringComparison.Ordinal);
|
||||
// Periodic Import# spam (every 100k, early bands, NID samples) is on
|
||||
// only when explicitly requested — default stderr traffic was a measurable tax.
|
||||
_logImportPeriodic = string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_PERIODIC"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
_logImportFrames = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_FRAMES"), "1", StringComparison.Ordinal);
|
||||
_logImportRecent = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_RECENT"), "1", StringComparison.Ordinal);
|
||||
_logStackCheck = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STACK_CHK"), "1", StringComparison.Ordinal);
|
||||
|
||||
@@ -401,6 +401,9 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(app0VariableName, app0Root);
|
||||
// Overlap the cooked-id APR walk with module load so the first ReadFile
|
||||
// miss mid-boot does not stall on a cold USB index.
|
||||
SharpEmu.Libs.Ampr.AmprFileRegistry.BeginApp0IndexPreload(app0Root);
|
||||
return new App0BindingScope(app0VariableName);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ cascade order so individual launcher views do not redefine global visuals.
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Inputs.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Console.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Library.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/OptionsNav.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Options.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/GameOptions.axaml" />
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
|
||||
@@ -18,6 +18,7 @@ public partial class App : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnMainWindowClose;
|
||||
desktop.MainWindow = new MainWindow();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>
|
||||
/// Remembers the last completed library scan so a cold start can paint the
|
||||
/// grid immediately instead of waiting on a recursive walk of every game
|
||||
/// folder. The cache is a display seed, never an authority: startup still
|
||||
/// runs the normal scan and reconciles over it, so a stale file can only
|
||||
/// ever cost one frame of wrong content, not a wrong library.
|
||||
/// </summary>
|
||||
internal static class GameLibraryCache
|
||||
{
|
||||
private const int CurrentVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
internal static string CachePath =>
|
||||
Path.Combine(AppContext.BaseDirectory, "user", "library_cache.json");
|
||||
|
||||
internal sealed class CachedGame
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? TitleId { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
public string? CoverPath { get; set; }
|
||||
public string? BackgroundPath { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class CacheDocument
|
||||
{
|
||||
public int Version { get; set; }
|
||||
public List<string> Folders { get; set; } = [];
|
||||
public List<CachedGame> Games { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached games when the file matches the configured folder
|
||||
/// set and the executables still exist. Entries whose executable is gone
|
||||
/// are dropped so a removed game never flashes on screen.
|
||||
/// </summary>
|
||||
internal static List<GameEntry> Load(IReadOnlyList<string> folders)
|
||||
{
|
||||
var games = new List<GameEntry>();
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CachePath))
|
||||
{
|
||||
return games;
|
||||
}
|
||||
|
||||
var document = JsonSerializer.Deserialize<CacheDocument>(
|
||||
File.ReadAllText(CachePath),
|
||||
SerializerOptions);
|
||||
if (document is null || document.Version != CurrentVersion)
|
||||
{
|
||||
return games;
|
||||
}
|
||||
|
||||
if (!SameFolders(document.Folders, folders))
|
||||
{
|
||||
return games;
|
||||
}
|
||||
|
||||
foreach (var cached in document.Games)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cached.Path) || !File.Exists(cached.Path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
games.Add(new GameEntry(
|
||||
cached.Name,
|
||||
cached.TitleId,
|
||||
cached.Version,
|
||||
cached.Path,
|
||||
cached.SizeBytes,
|
||||
Existing(cached.CoverPath),
|
||||
Existing(cached.BackgroundPath)));
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Could not read the library cache: {exception.Message}");
|
||||
games.Clear();
|
||||
}
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
internal static void Save(IReadOnlyList<string> folders, IReadOnlyList<GameEntry> games)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(CachePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var document = new CacheDocument
|
||||
{
|
||||
Version = CurrentVersion,
|
||||
Folders = [.. folders],
|
||||
};
|
||||
|
||||
foreach (var game in games)
|
||||
{
|
||||
document.Games.Add(new CachedGame
|
||||
{
|
||||
Name = game.Name,
|
||||
TitleId = game.TitleId,
|
||||
Version = game.Version,
|
||||
Path = game.Path,
|
||||
SizeBytes = game.SizeBytes,
|
||||
CoverPath = game.CoverPath,
|
||||
BackgroundPath = game.BackgroundPath,
|
||||
});
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
CachePath,
|
||||
JsonSerializer.Serialize(document, SerializerOptions));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Could not write the library cache: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Existing(string? path) =>
|
||||
!string.IsNullOrWhiteSpace(path) && File.Exists(path) ? path : null;
|
||||
|
||||
private static bool SameFolders(
|
||||
IReadOnlyList<string> cached,
|
||||
IReadOnlyList<string> configured)
|
||||
{
|
||||
if (cached.Count != configured.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var known = new HashSet<string>(cached, GameLibraryPath.Comparer);
|
||||
foreach (var folder in configured)
|
||||
{
|
||||
if (!known.Contains(folder))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ public sealed class GuiSettings
|
||||
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
|
||||
public bool PlayTitleMusic { get; set; } = true;
|
||||
|
||||
public string LibraryLayout { get; set; } = "Carousel";
|
||||
|
||||
public string? EmulatorPath { get; set; }
|
||||
|
||||
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
|
||||
@@ -132,6 +134,7 @@ public sealed class GuiSettings
|
||||
{
|
||||
settings.RenderResolutionScale = 1.0;
|
||||
}
|
||||
settings.LibraryLayout = NormalizeChoice(settings.LibraryLayout, "Carousel", "Grid");
|
||||
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
||||
settings.Resolution = NormalizeResolution(settings.Resolution);
|
||||
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ إضافة مجلد ألعاب",
|
||||
|
||||
"Library.Loading": "جارٍ تحميل المكتبة...",
|
||||
"Library.Stat.Version": "الإصدار",
|
||||
"Library.Stat.Installed": "المثبت",
|
||||
"Library.Stat.TitleId": "معرّف اللعبة",
|
||||
"Common.Back": "رجوع",
|
||||
|
||||
"Options.General": "عام",
|
||||
"Options.Logging": "التسجيل",
|
||||
"Options.Section.Emulation": "المحاكاة",
|
||||
"Options.Section.Logging": "التسجيل",
|
||||
"Options.Section.Launcher": "المُشغِّل",
|
||||
@@ -143,10 +148,6 @@
|
||||
"Options.Env.GuestImageCpuSync.Desc": "إعادة رفع أسطح الضيف التي تعيد كتابتها شيفرة المعالج الخاصة باللعبة.\nاتركه مغلقًا عادة. شغّله للألعاب التي لا تصل أسطحها المرسومة بالمعالج إلى الشاشة.\nيكلّف أداءً ويسبب مشاكل في بعض الألعاب مثل GTA V.",
|
||||
"Common.Save": "حفظ",
|
||||
"Common.Cancel": "إلغاء",
|
||||
"PerGame.Title": "إعدادات خاصة باللعبة — {0} ({1})",
|
||||
"PerGame.InheritNote": "الصفوف غير المحددة ترث الإعدادات الافتراضية العامة.",
|
||||
"PerGame.EnvToggles.Label": "مفاتيح البيئة",
|
||||
"PerGame.EnvToggles.Desc": "تجاوز المجموعة العامة من مفاتيح SHARPEMU_* لهذه اللعبة.",
|
||||
"Options.About": "حول",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "الكود المصدري والمشكلات وتطوير المشروع.",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Adicionar pasta do jogo",
|
||||
|
||||
"Library.Loading": "Carregando biblioteca…",
|
||||
"Library.Stat.Version": "Versão",
|
||||
"Library.Stat.Installed": "Instalado",
|
||||
"Library.Stat.TitleId": "ID do título",
|
||||
"Common.Back": "Voltar",
|
||||
|
||||
"Options.General": "Opções Gerais",
|
||||
"Options.Logging": "Logs",
|
||||
"Options.Env.Tab": "Ambiente",
|
||||
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
|
||||
"Options.Env.Desc": "Variáveis de ambiente passadas ao emulador durante a inicialização.",
|
||||
@@ -151,10 +156,6 @@
|
||||
"Options.Env.LogIo.Desc": "Registra no console a abertura e leitura de arquivos e a resolução de caminhos.\nUse quando um jogo não encontrar seus arquivos de dados durante a inicialização.",
|
||||
"Common.Save": "Salvar",
|
||||
"Common.Cancel": "Cancelar",
|
||||
"PerGame.Title": "Configurações por jogo — {0} ({1})",
|
||||
"PerGame.InheritNote": "As linhas desmarcadas herdam os padrões globais.",
|
||||
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
|
||||
"PerGame.EnvToggles.Desc": "Substitui o conjunto global de opções SHARPEMU_* para este jogo.",
|
||||
"About.Github.LatestCommitLabel": "Último commit",
|
||||
"About.Github.LatestCommitDescription": "Último commit na branch main",
|
||||
"Updater.Auto.Label": "Verificar atualizações ao iniciar",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Spielordner hinzufügen",
|
||||
|
||||
"Library.Loading": "Bibliothek wird geladen…",
|
||||
"Library.Stat.Version": "Version",
|
||||
"Library.Stat.Installed": "Installiert",
|
||||
"Library.Stat.TitleId": "Titel-ID",
|
||||
"Common.Back": "Zurück",
|
||||
|
||||
"Options.General": "Allgemein",
|
||||
"Options.Logging": "Protokollierung",
|
||||
"Options.Section.Emulation": "EMULATION",
|
||||
"Options.Section.Logging": "PROTOKOLLIERUNG",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
@@ -143,10 +148,6 @@
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Gast-Oberflächen neu hochladen, die der eigene CPU-Code des Spiels überschreibt.\nNormalerweise aus lassen. Für Titel aktivieren, deren CPU-gezeichnete Oberflächen nie auf dem Bildschirm erscheinen.\nKostet Leistung und verursacht bei einigen Titeln wie GTA V Regressionen.",
|
||||
"Common.Save": "Speichern",
|
||||
"Common.Cancel": "Abbrechen",
|
||||
"PerGame.Title": "Spielspezifische Einstellungen — {0} ({1})",
|
||||
"PerGame.InheritNote": "Nicht angehakte Zeilen übernehmen die globalen Standardwerte.",
|
||||
"PerGame.EnvToggles.Label": "Umgebungsschalter",
|
||||
"PerGame.EnvToggles.Desc": "Die globalen SHARPEMU_*-Schalter für dieses Spiel überschreiben.",
|
||||
"Options.About": "Über",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Quellcode, Issues und Projektentwicklung.",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Tilføj spilmappe",
|
||||
|
||||
"Library.Loading": "Indlæser bibliotek…",
|
||||
"Library.Stat.Version": "Version",
|
||||
"Library.Stat.Installed": "Installeret",
|
||||
"Library.Stat.TitleId": "Titel-id",
|
||||
"Common.Back": "Tilbage",
|
||||
|
||||
"Options.General": "Generelt",
|
||||
"Options.Logging": "Logging",
|
||||
"Options.Section.Emulation": "EMULERING",
|
||||
"Options.Section.Logging": "LOGGING",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
@@ -143,10 +148,6 @@
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Genindlæs gæsteoverflader, som spillets egen CPU-kode omskriver.\nLad den være slået fra normalt. Slå til for titler, hvis CPU-tegnede overflader aldrig når skærmen.\nKoster ydeevne og giver regressioner i nogle titler, såsom GTA V.",
|
||||
"Common.Save": "Gem",
|
||||
"Common.Cancel": "Annuller",
|
||||
"PerGame.Title": "Indstillinger pr. spil — {0} ({1})",
|
||||
"PerGame.InheritNote": "Umarkerede rækker arver de globale standardværdier.",
|
||||
"PerGame.EnvToggles.Label": "Miljøkontakter",
|
||||
"PerGame.EnvToggles.Desc": "Tilsidesæt det globale sæt SHARPEMU_*-kontakter for dette spil.",
|
||||
"Options.About": "Om",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Kildekode, issues og projektudvikling.",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"Library.SearchWatermark": "Search library…",
|
||||
"Library.AddFolder": "Add folder",
|
||||
"Library.OpenFile": "Open file…",
|
||||
"Library.View.Grid": "Stacked view",
|
||||
"Library.View.Carousel": "Row view",
|
||||
|
||||
"Library.Context.Launch": "Launch",
|
||||
"Library.Context.OpenFolder": "Open game folder",
|
||||
@@ -24,8 +26,13 @@
|
||||
"Library.Empty.AddFolder": "+ Add game folder",
|
||||
|
||||
"Library.Loading": "Loading library…",
|
||||
"Library.Stat.Version": "Version",
|
||||
"Library.Stat.Installed": "Installed",
|
||||
"Library.Stat.TitleId": "Title ID",
|
||||
"Common.Back": "Back",
|
||||
|
||||
"Options.General": "General",
|
||||
"Options.Logging": "Logging",
|
||||
"Options.Env.Tab": "Environment",
|
||||
"Options.Section.Environment": "ENVIRONMENT VARIABLES",
|
||||
"Options.Env.Desc": "Switches passed to the emulator as environment variables at launch.",
|
||||
@@ -117,12 +124,6 @@
|
||||
"Common.Save": "Save",
|
||||
"Common.Cancel": "Cancel",
|
||||
|
||||
"PerGame.Title": "Per-game settings — {0} ({1})",
|
||||
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
|
||||
"PerGame.Tab.General": "General",
|
||||
"PerGame.Tab.Graphics": "Graphics",
|
||||
"PerGame.EnvToggles.Label": "Environment toggles",
|
||||
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
|
||||
|
||||
"Console.Title": "CONSOLE",
|
||||
"Console.SearchWatermark": "Search...",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Añadir carpeta de juegos",
|
||||
|
||||
"Library.Loading": "Cargando biblioteca…",
|
||||
"Library.Stat.Version": "Versión",
|
||||
"Library.Stat.Installed": "Instalado",
|
||||
"Library.Stat.TitleId": "ID del título",
|
||||
"Common.Back": "Volver",
|
||||
|
||||
"Options.General": "General",
|
||||
"Options.Logging": "Logs",
|
||||
"Options.Section.Emulation": "EMULACIÓN",
|
||||
"Options.Section.Logging": "LOGS",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
@@ -153,10 +158,6 @@
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Volver a subir las superficies del invitado que reescribe el propio código de CPU del juego.\nDejar desactivado normalmente. Activar en títulos cuyas superficies dibujadas por CPU nunca llegan a la pantalla.\nCuesta rendimiento y causa regresiones en algunos títulos, como GTA V.",
|
||||
"Common.Save": "Guardar",
|
||||
"Common.Cancel": "Cancelar",
|
||||
"PerGame.Title": "Ajustes por juego — {0} ({1})",
|
||||
"PerGame.InheritNote": "Las filas sin marcar heredan los valores globales.",
|
||||
"PerGame.EnvToggles.Label": "Variables de entorno",
|
||||
"PerGame.EnvToggles.Desc": "Sustituir el conjunto global de opciones SHARPEMU_* para este juego.",
|
||||
"Updater.Auto.Label": "Buscar actualizaciones al iniciar",
|
||||
"Updater.Auto.Desc": "Consulta GitHub sin retrasar el arranque.",
|
||||
"Updater.Label": "Actualizaciones",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Ajouter un dossier de jeux",
|
||||
|
||||
"Library.Loading": "Chargement de la bibliothèque…",
|
||||
"Library.Stat.Version": "Version",
|
||||
"Library.Stat.Installed": "Installé",
|
||||
"Library.Stat.TitleId": "ID du titre",
|
||||
"Common.Back": "Retour",
|
||||
|
||||
"Options.General": "Général",
|
||||
"Options.Logging": "Journalisation",
|
||||
"Options.Env.Tab": "Environnement",
|
||||
"Options.Section.Environment": "VARIABLES D’ENVIRONNEMENT",
|
||||
"Options.Env.Desc": "Paramètres passés à l’émulateur comme variables d’environnement au lancement.",
|
||||
@@ -151,10 +156,6 @@
|
||||
"Options.Env.LogIo.Desc": "Journaliser l’ouverture et la lecture des fichiers ainsi que la résolution des chemins dans la console.\nÀ utiliser quand un jeu ne trouve pas ses fichiers de données au démarrage.",
|
||||
"Common.Save": "Enregistrer",
|
||||
"Common.Cancel": "Annuler",
|
||||
"PerGame.Title": "Paramètres par jeu — {0} ({1})",
|
||||
"PerGame.InheritNote": "Les lignes non cochées héritent des valeurs globales par défaut.",
|
||||
"PerGame.EnvToggles.Label": "Variables d’environnement",
|
||||
"PerGame.EnvToggles.Desc": "Remplacer l’ensemble global des options SHARPEMU_* pour ce jeu.",
|
||||
"About.Github.LatestCommitLabel": "Dernier commit",
|
||||
"About.Github.LatestCommitDescription": "Dernier commit sur la branche main",
|
||||
"Updater.Auto.Label": "Vérifier les mises à jour au démarrage",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Játékmappa hozzáadása",
|
||||
|
||||
"Library.Loading": "Könyvtár betöltése",
|
||||
"Library.Stat.Version": "Verzió",
|
||||
"Library.Stat.Installed": "Telepítve",
|
||||
"Library.Stat.TitleId": "Címazonosító",
|
||||
"Common.Back": "Vissza",
|
||||
|
||||
"Options.General": "Általános",
|
||||
"Options.Logging": "Logolás",
|
||||
"Options.Env.Tab": "Környezet",
|
||||
"Options.Section.Environment": "KÖRNYEZETI VÁLTOZÓK",
|
||||
"Options.Env.Desc": "Indításkor környezeti változóként az emulátorhoz átadott kapcsolók.",
|
||||
@@ -151,10 +156,6 @@
|
||||
"Options.Env.LogIo.Desc": "A fájlmegnyitások, olvasások és útvonal-feloldások naplózása a konzolra.\nAkkor használd, ha egy játék indításkor nem találja az adatfájljait.",
|
||||
"Common.Save": "Mentés",
|
||||
"Common.Cancel": "Mégse",
|
||||
"PerGame.Title": "Játékonkénti beállítások — {0} ({1})",
|
||||
"PerGame.InheritNote": "A be nem jelölt sorok a globális alapértelmezéseket öröklik.",
|
||||
"PerGame.EnvToggles.Label": "Környezeti kapcsolók",
|
||||
"PerGame.EnvToggles.Desc": "A globális SHARPEMU_* kapcsolókészlet felülírása ennél a játéknál.",
|
||||
"About.Github.LatestCommitLabel": "Legutóbbi commit",
|
||||
"About.Github.LatestCommitDescription": "A main ág legutóbbi commitja",
|
||||
"Updater.Auto.Label": "Frissítések keresése indításkor",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Aggiungi cartella giochi",
|
||||
|
||||
"Library.Loading": "Caricamento libreria…",
|
||||
"Library.Stat.Version": "Versione",
|
||||
"Library.Stat.Installed": "Installato",
|
||||
"Library.Stat.TitleId": "ID titolo",
|
||||
"Common.Back": "Indietro",
|
||||
|
||||
"Options.General": "Generale",
|
||||
"Options.Logging": "Log",
|
||||
"Options.Section.Emulation": "EMULAZIONE",
|
||||
"Options.Section.Logging": "LOG",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
@@ -148,10 +153,6 @@
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Ricarica le superfici guest riscritte dal codice CPU del gioco.\nLasciare disattivato normalmente. Attivare per i titoli le cui superfici disegnate dalla CPU non raggiungono mai lo schermo.\nCosta prestazioni e causa regressioni in alcuni titoli, come GTA V.",
|
||||
"Common.Save": "Salva",
|
||||
"Common.Cancel": "Annulla",
|
||||
"PerGame.Title": "Impostazioni per gioco — {0} ({1})",
|
||||
"PerGame.InheritNote": "Le righe non selezionate ereditano i valori globali.",
|
||||
"PerGame.EnvToggles.Label": "Variabili d'ambiente",
|
||||
"PerGame.EnvToggles.Desc": "Sovrascrivi l'insieme globale delle opzioni SHARPEMU_* per questo gioco.",
|
||||
"Options.About": "Informazioni",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Codice sorgente, issue e sviluppo del progetto.",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ ゲームフォルダーを追加",
|
||||
|
||||
"Library.Loading": "ライブラリを読み込み中…",
|
||||
"Library.Stat.Version": "バージョン",
|
||||
"Library.Stat.Installed": "インストール済み",
|
||||
"Library.Stat.TitleId": "タイトルID",
|
||||
"Common.Back": "戻る",
|
||||
|
||||
"Options.General": "一般",
|
||||
"Options.Logging": "ロギング",
|
||||
"Options.Section.Emulation": "エミュレーション",
|
||||
"Options.Section.Logging": "ロギング",
|
||||
"Options.Section.Launcher": "ランチャー",
|
||||
@@ -143,10 +148,6 @@
|
||||
"Options.Env.GuestImageCpuSync.Desc": "ゲーム自身の CPU コードが書き換えるゲスト表面を再アップロードします。\n通常はオフのままにしてください。CPU で描画した表面が画面に反映されないタイトルで有効にします。\n性能を犠牲にし、GTA V など一部のタイトルでは不具合が生じます。",
|
||||
"Common.Save": "保存",
|
||||
"Common.Cancel": "キャンセル",
|
||||
"PerGame.Title": "ゲームごとの設定 — {0} ({1})",
|
||||
"PerGame.InheritNote": "チェックされていない行はグローバルの既定値を継承します。",
|
||||
"PerGame.EnvToggles.Label": "環境スイッチ",
|
||||
"PerGame.EnvToggles.Desc": "このゲームに対してグローバルのSHARPEMU_*スイッチを上書きします。",
|
||||
"Options.About": "情報",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "ソースコード、Issue、プロジェクトの開発。",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ 게임 폴더 추가",
|
||||
|
||||
"Library.Loading": "라이브러리 불러오는 중…",
|
||||
"Library.Stat.Version": "버전",
|
||||
"Library.Stat.Installed": "설치됨",
|
||||
"Library.Stat.TitleId": "타이틀 ID",
|
||||
"Common.Back": "뒤로",
|
||||
|
||||
"Options.General": "일반",
|
||||
"Options.Logging": "로깅",
|
||||
"Options.Section.Emulation": "에뮬레이션",
|
||||
"Options.Section.Logging": "로깅",
|
||||
"Options.Section.Launcher": "런처",
|
||||
@@ -143,10 +148,6 @@
|
||||
"Options.Env.GuestImageCpuSync.Desc": "게임의 자체 CPU 코드가 다시 쓰는 게스트 표면을 다시 업로드합니다.\n평소에는 꺼 두세요. CPU로 그린 표면이 화면에 나타나지 않는 타이틀에서 켜세요.\n성능을 소모하며 GTA V 등 일부 타이틀에서는 문제가 생깁니다.",
|
||||
"Common.Save": "저장",
|
||||
"Common.Cancel": "취소",
|
||||
"PerGame.Title": "게임별 설정 — {0} ({1})",
|
||||
"PerGame.InheritNote": "선택하지 않은 항목은 전역 기본값을 따릅니다.",
|
||||
"PerGame.EnvToggles.Label": "환경 스위치",
|
||||
"PerGame.EnvToggles.Desc": "이 게임에 대해 전역 SHARPEMU_* 스위치 설정을 재정의합니다.",
|
||||
"Options.About": "정보",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "소스 코드, 이슈, 프로젝트 개발.",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Gamemap toevoegen",
|
||||
|
||||
"Library.Loading": "Bibliotheek laden…",
|
||||
"Library.Stat.Version": "Versie",
|
||||
"Library.Stat.Installed": "Geïnstalleerd",
|
||||
"Library.Stat.TitleId": "Titel-ID",
|
||||
"Common.Back": "Terug",
|
||||
|
||||
"Options.General": "Algemeen",
|
||||
"Options.Logging": "Logging",
|
||||
"Options.Section.Emulation": "EMULATIE",
|
||||
"Options.Section.Logging": "LOGGING",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
@@ -143,10 +148,6 @@
|
||||
"Options.Env.GuestImageCpuSync.Desc": "Gastoppervlakken opnieuw uploaden die de eigen CPU-code van de game herschrijft.\nNormaal uit laten. Inschakelen voor titels waarvan de door de CPU getekende oppervlakken nooit het scherm bereiken.\nKost prestaties en veroorzaakt regressies in sommige titels, zoals GTA V.",
|
||||
"Common.Save": "Opslaan",
|
||||
"Common.Cancel": "Annuleren",
|
||||
"PerGame.Title": "Instellingen per game — {0} ({1})",
|
||||
"PerGame.InheritNote": "Niet-aangevinkte rijen erven de globale standaardwaarden.",
|
||||
"PerGame.EnvToggles.Label": "Omgevingsschakelaars",
|
||||
"PerGame.EnvToggles.Desc": "Overschrijf de globale set SHARPEMU_*-schakelaars voor deze game.",
|
||||
"Options.About": "Over",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Broncode, issues en projectontwikkeling.",
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
"Library.Empty.AddFolder": "+ Adicionar pasta de jogos",
|
||||
|
||||
"Library.Loading": "A carregar biblioteca…",
|
||||
"Library.Stat.Version": "Versão",
|
||||
"Library.Stat.Installed": "Instalado",
|
||||
"Library.Stat.TitleId": "ID do título",
|
||||
"Common.Back": "Voltar",
|
||||
|
||||
"Options.General": "Geral",
|
||||
"Options.Logging": "Registos",
|
||||
"Options.Env.Tab": "Ambiente",
|
||||
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
|
||||
"Options.Env.Desc": "Switches passados ao emulador como variáveis de ambiente no arranque.",
|
||||
@@ -151,10 +156,6 @@
|
||||
"Options.Env.LogIo.Desc": "Registar na consola a abertura e leitura de ficheiros e a resolução de caminhos.\nUtilize quando um jogo não encontrar os seus ficheiros de dados durante o arranque.",
|
||||
"Common.Save": "Guardar",
|
||||
"Common.Cancel": "Cancelar",
|
||||
"PerGame.Title": "Definições por jogo — {0} ({1})",
|
||||
"PerGame.InheritNote": "As linhas não assinaladas herdam as predefinições globais.",
|
||||
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
|
||||
"PerGame.EnvToggles.Desc": "Substituir o conjunto global de opções SHARPEMU_* para este jogo.",
|
||||
"About.Github.LatestCommitLabel": "Último commit",
|
||||
"About.Github.LatestCommitDescription": "Último commit no ramo main",
|
||||
"Updater.Auto.Label": "Procurar atualizações no arranque",
|
||||
|
||||
@@ -24,8 +24,13 @@
|
||||
"Library.Empty.AddFolder": "+ Добавить папку с играми",
|
||||
|
||||
"Library.Loading": "Загрузка библиотеки…",
|
||||
"Library.Stat.Version": "Версия",
|
||||
"Library.Stat.Installed": "Установлено",
|
||||
"Library.Stat.TitleId": "ID игры",
|
||||
"Common.Back": "Назад",
|
||||
|
||||
"Options.General": "Основные",
|
||||
"Options.Logging": "Логгирование",
|
||||
"Options.Env.Tab": "Окружение",
|
||||
"Options.Section.Environment": "ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ",
|
||||
"Options.Env.Desc": "Параметры, передаваемые эмулятору как переменные окружения при запуске.",
|
||||
@@ -117,12 +122,6 @@
|
||||
"Common.Save": "Сохранить",
|
||||
"Common.Cancel": "Отмена",
|
||||
|
||||
"PerGame.Title": "Настройки игры — {0} ({1})",
|
||||
"PerGame.InheritNote": "Неотмеченные строки наследуют глобальные настройки.",
|
||||
"PerGame.Tab.General": "Основные",
|
||||
"PerGame.Tab.Graphics": "Графика",
|
||||
"PerGame.EnvToggles.Label": "Переключатели окружения",
|
||||
"PerGame.EnvToggles.Desc": "Переопределить глобальный набор переключателей SHARPEMU_* для этой игры.",
|
||||
|
||||
"Console.Title": "КОНСОЛЬ",
|
||||
"Console.SearchWatermark": "Поиск...",
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"Library.SearchWatermark": "Kütüphanede ara…",
|
||||
"Library.AddFolder": "Klasör ekle",
|
||||
"Library.OpenFile": "Dosya aç…",
|
||||
"Library.View.Grid": "Alt alta görünüm",
|
||||
"Library.View.Carousel": "Tek sıra görünüm",
|
||||
|
||||
"Library.Context.Launch": "Başlat",
|
||||
"Library.Context.OpenFolder": "Oyun klasörünü aç",
|
||||
@@ -23,8 +25,13 @@
|
||||
"Library.Empty.AddFolder": "+ Oyun klasörü ekle",
|
||||
|
||||
"Library.Loading": "Kütüphane yükleniyor…",
|
||||
"Library.Stat.Version": "Sürüm",
|
||||
"Library.Stat.Installed": "Yüklü",
|
||||
"Library.Stat.TitleId": "Başlık kimliği",
|
||||
"Common.Back": "Geri",
|
||||
|
||||
"Options.General": "Genel",
|
||||
"Options.Logging": "Günlükleme",
|
||||
"Options.Section.Emulation": "EMÜLASYON",
|
||||
"Options.Section.Logging": "GÜNLÜKLEME",
|
||||
"Options.Section.Launcher": "BAŞLATICI",
|
||||
@@ -177,12 +184,6 @@
|
||||
"Options.DefaultProfile.Desc": "Oyun metin girisi istediginde kullanilacak ad. Varsayilan deger Sharp'tir.",
|
||||
"Common.Save": "Kaydet",
|
||||
"Common.Cancel": "İptal",
|
||||
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
|
||||
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
|
||||
"PerGame.Tab.General": "Genel",
|
||||
"PerGame.Tab.Graphics": "Grafik",
|
||||
"PerGame.EnvToggles.Label": "Ortam anahtarları",
|
||||
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
|
||||
"Options.About": "Hakkında",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Kaynak kodu, hata kayıtları ve proje geliştirme.",
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.VisualTree;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>Inline per-game settings navigation, loading and persistence.</summary>
|
||||
public partial class MainWindow
|
||||
{
|
||||
private static readonly string[] GameEnvironmentToggleNames =
|
||||
[
|
||||
"SHARPEMU_BTHID_UNAVAILABLE",
|
||||
"SHARPEMU_DISABLE_IMPORT_LOOP_GUARD",
|
||||
"SHARPEMU_WRITABLE_APP0",
|
||||
"SHARPEMU_VK_VALIDATION",
|
||||
"SHARPEMU_DUMP_SPIRV",
|
||||
"SHARPEMU_LOG_DIRECT_MEMORY",
|
||||
"SHARPEMU_LOG_IO",
|
||||
"SHARPEMU_LOG_NP",
|
||||
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
|
||||
];
|
||||
|
||||
private readonly List<string> _gameEnvironmentPassthrough = new();
|
||||
private IReadOnlyList<HostDisplayOption> _gameHostDisplays = [];
|
||||
private bool _isGameSettingsOpen;
|
||||
private bool _isLoadingGameSettings;
|
||||
private bool _updatingGameHostDisplayOptions;
|
||||
private int _gameOptionsIndicatorIndex;
|
||||
private int _gameOptionsSectionIndex;
|
||||
private string? _gameSettingsTitleId;
|
||||
|
||||
private void WireGameOptions()
|
||||
{
|
||||
GameSettingsButton.Click += (_, _) => OpenSelectedGameSettings();
|
||||
|
||||
GameLogLevelBox.ItemsSource = _logLevelChoices;
|
||||
GameWindowModeBox.ItemsSource = _windowModeChoices;
|
||||
GameScalingModeBox.ItemsSource = _scalingModeChoices;
|
||||
GameHdrModeBox.ItemsSource = _hdrModeChoices;
|
||||
|
||||
var navigationButtons = GameOptionsNavigationButtons();
|
||||
for (var index = 0; index < navigationButtons.Length; index++)
|
||||
{
|
||||
var section = index;
|
||||
navigationButtons[index].Click += (_, _) =>
|
||||
{
|
||||
if (section < GameOptionsSectionPanels().Length)
|
||||
{
|
||||
SetGameOptionsSection(section);
|
||||
}
|
||||
else
|
||||
{
|
||||
CloseGameSettings();
|
||||
}
|
||||
};
|
||||
navigationButtons[index].PointerEntered += (_, _) =>
|
||||
{
|
||||
if (_isGameSettingsOpen)
|
||||
{
|
||||
SetGameOptionsNavigationIndicator(section);
|
||||
}
|
||||
};
|
||||
navigationButtons[index].GotFocus += (_, _) =>
|
||||
{
|
||||
if (_isGameSettingsOpen)
|
||||
{
|
||||
SetGameOptionsNavigationIndicator(section);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
GameOptionsNavHost.PointerExited += (_, _) =>
|
||||
{
|
||||
if (_isGameSettingsOpen)
|
||||
{
|
||||
SetGameOptionsNavigationIndicator(_gameOptionsSectionIndex);
|
||||
}
|
||||
};
|
||||
|
||||
GameOptionsLaunchButton.Click += (_, _) =>
|
||||
{
|
||||
CloseGameSettings();
|
||||
LaunchSelected();
|
||||
};
|
||||
GameOptionsCloseButton.Click += (_, _) => CloseGameSettings();
|
||||
GameOptionsOpenFolderButton.Click += (_, _) => OpenSelectedGameFolder();
|
||||
GameOptionsCopyPathButton.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
|
||||
GameOptionsCopyTitleIdButton.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
|
||||
GameOptionsRemoveButton.Click += (_, _) =>
|
||||
{
|
||||
CloseGameSettings();
|
||||
RemoveSelectedFromLibrary();
|
||||
};
|
||||
|
||||
GameStrictToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameLogLevelBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameTraceImportsBox.ValueChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameLogToFileToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameWindowModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameDisplayBox.SelectionChanged += (_, _) => OnGameHostDisplayChanged();
|
||||
GameResolutionBox.SelectionChanged += (_, _) => OnGameHostResolutionChanged();
|
||||
GameRefreshRateBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameScalingModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameVSyncToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameHdrModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
foreach (var (_, toggle) in GameEnvironmentToggles())
|
||||
{
|
||||
toggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||
}
|
||||
|
||||
SetGameOptionsSection(0, animateIndicator: false);
|
||||
}
|
||||
|
||||
private void OpenSelectedGameSettings()
|
||||
{
|
||||
if (GameList.SelectedItem is not GameEntry game)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(game.TitleId))
|
||||
{
|
||||
AppendConsoleLine(
|
||||
"[GUI][WARN] Per-game settings require a title ID, which this game does not have.",
|
||||
WarningLineBrush);
|
||||
return;
|
||||
}
|
||||
|
||||
_gameSettingsTitleId = game.TitleId;
|
||||
GameOptionsOverlay.DataContext = game;
|
||||
LoadGameSettings(game.TitleId);
|
||||
SetGameOptionsSection(0, animateIndicator: false);
|
||||
GameOptionsLaunchButton.IsEnabled = !_isRunning;
|
||||
GameOptionsCopyTitleIdButton.IsEnabled =
|
||||
!string.IsNullOrWhiteSpace(game.TitleId);
|
||||
|
||||
_isGameSettingsOpen = true;
|
||||
SetGameOptionsPagesSpan(coversConsoleRow: true);
|
||||
SetGameOptionsOpenClass(BackdropLayer, active: true);
|
||||
SetGameOptionsOpenClass(CarouselHost, active: true);
|
||||
SetGameOptionsOpenClass(LibrarySelectedDetails, active: true);
|
||||
SetGameOptionsOpenClass(GameOptionsOverlay, active: true);
|
||||
GameList.IsHitTestVisible = false;
|
||||
LibraryToolbar.IsHitTestVisible = false;
|
||||
GameOptionsOverlay.IsHitTestVisible = true;
|
||||
GameOptionsGeneralNav.Focus();
|
||||
}
|
||||
|
||||
private void CloseGameSettings(bool restoreLibrary = true)
|
||||
{
|
||||
if (!_isGameSettingsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isGameSettingsOpen = false;
|
||||
SetGameOptionsPagesSpan(coversConsoleRow: false);
|
||||
SetGameOptionsNavigationIndicator(_gameOptionsIndicatorIndex, animate: false);
|
||||
_gameSettingsTitleId = null;
|
||||
_gameEnvironmentPassthrough.Clear();
|
||||
SetGameOptionsOpenClass(BackdropLayer, active: false);
|
||||
SetGameOptionsOpenClass(CarouselHost, active: false);
|
||||
SetGameOptionsOpenClass(LibrarySelectedDetails, active: false);
|
||||
SetGameOptionsOpenClass(GameOptionsOverlay, active: false);
|
||||
GameOptionsOverlay.IsHitTestVisible = false;
|
||||
GameOptionsOverlay.DataContext = null;
|
||||
GameList.IsHitTestVisible = true;
|
||||
LibraryToolbar.IsHitTestVisible = true;
|
||||
|
||||
if (restoreLibrary && _activePageIndex == 0)
|
||||
{
|
||||
GameList.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadGameSettings(string titleId)
|
||||
{
|
||||
var effective = EffectiveLaunchSettings.Resolve(
|
||||
_settings,
|
||||
PerGameSettings.Load(titleId));
|
||||
|
||||
_isLoadingGameSettings = true;
|
||||
_updatingGameHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
GameStrictToggle.IsChecked = effective.StrictDynlibResolution;
|
||||
GameLogLevelBox.SelectedItem = FindChoice(
|
||||
_logLevelChoices,
|
||||
effective.LogLevel,
|
||||
"Info");
|
||||
GameTraceImportsBox.Value = Math.Clamp(effective.ImportTraceLimit, 0, 4096);
|
||||
GameLogToFileToggle.IsChecked = effective.LogToFile;
|
||||
GameWindowModeBox.SelectedItem = FindChoice(
|
||||
_windowModeChoices,
|
||||
effective.WindowMode,
|
||||
"Windowed");
|
||||
GameScalingModeBox.SelectedItem = FindChoice(
|
||||
_scalingModeChoices,
|
||||
effective.ScalingMode,
|
||||
"Fit");
|
||||
GameVSyncToggle.IsChecked = effective.VSync;
|
||||
GameHdrModeBox.SelectedItem = FindChoice(
|
||||
_hdrModeChoices,
|
||||
effective.HdrMode,
|
||||
"Auto");
|
||||
|
||||
_gameHostDisplays = HostDisplayOptions.BuildDisplays(
|
||||
HostDisplayCatalog.Query(),
|
||||
effective.DisplayIndex);
|
||||
GameDisplayBox.ItemsSource = _gameHostDisplays;
|
||||
var display = HostDisplayOptions.SelectDisplay(
|
||||
_gameHostDisplays,
|
||||
effective.DisplayIndex);
|
||||
GameDisplayBox.SelectedItem = display;
|
||||
PopulateGameHostModes(
|
||||
display,
|
||||
effective.Resolution,
|
||||
effective.RefreshRate);
|
||||
|
||||
_gameEnvironmentPassthrough.Clear();
|
||||
foreach (var entry in effective.EnvironmentToggles)
|
||||
{
|
||||
if (!IsKnownGameEnvironmentEntry(entry))
|
||||
{
|
||||
_gameEnvironmentPassthrough.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (name, toggle) in GameEnvironmentToggles())
|
||||
{
|
||||
toggle.IsChecked = IsEnvironmentEnabled(
|
||||
effective.EnvironmentToggles,
|
||||
name);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingGameHostDisplayOptions = false;
|
||||
_isLoadingGameSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistOpenGameSettings()
|
||||
{
|
||||
if (!_isGameSettingsOpen ||
|
||||
_isLoadingGameSettings ||
|
||||
_updatingGameHostDisplayOptions ||
|
||||
string.IsNullOrWhiteSpace(_gameSettingsTitleId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = new PerGameSettings
|
||||
{
|
||||
LogLevel = SelectedComboText(GameLogLevelBox, "Info"),
|
||||
ImportTraceLimit = (int)(GameTraceImportsBox.Value ?? 0),
|
||||
StrictDynlibResolution = GameStrictToggle.IsChecked == true,
|
||||
LogToFile = GameLogToFileToggle.IsChecked == true,
|
||||
WindowMode = SelectedComboText(GameWindowModeBox, "Windowed"),
|
||||
Resolution = SelectedComboText(GameResolutionBox, "1920x1080"),
|
||||
DisplayIndex = GameDisplayBox.SelectedItem is HostDisplayOption display
|
||||
? display.Index
|
||||
: 0,
|
||||
RefreshRate = SelectedGameRefreshRate(),
|
||||
ScalingMode = SelectedComboText(GameScalingModeBox, "Fit"),
|
||||
VSync = GameVSyncToggle.IsChecked == true,
|
||||
HdrMode = SelectedComboText(GameHdrModeBox, "Auto"),
|
||||
EnvironmentToggles = BuildGameEnvironmentEntries(),
|
||||
};
|
||||
settings.RemoveInheritedValues(_settings);
|
||||
settings.Save(_gameSettingsTitleId);
|
||||
}
|
||||
|
||||
private void OnGameHostDisplayChanged()
|
||||
{
|
||||
if (_isLoadingGameSettings ||
|
||||
_updatingGameHostDisplayOptions ||
|
||||
GameDisplayBox.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_updatingGameHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
PopulateGameHostModes(
|
||||
display,
|
||||
GameResolutionBox.SelectedItem as string ?? "1920x1080",
|
||||
SelectedGameRefreshRate());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingGameHostDisplayOptions = false;
|
||||
}
|
||||
|
||||
PersistOpenGameSettings();
|
||||
}
|
||||
|
||||
private void OnGameHostResolutionChanged()
|
||||
{
|
||||
if (_isLoadingGameSettings ||
|
||||
_updatingGameHostDisplayOptions ||
|
||||
GameDisplayBox.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_updatingGameHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
PopulateGameRefreshRates(
|
||||
display,
|
||||
GameResolutionBox.SelectedItem as string,
|
||||
SelectedGameRefreshRate());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingGameHostDisplayOptions = false;
|
||||
}
|
||||
|
||||
PersistOpenGameSettings();
|
||||
}
|
||||
|
||||
private void PopulateGameHostModes(
|
||||
HostDisplayOption display,
|
||||
string selectedResolution,
|
||||
int selectedRefreshRate)
|
||||
{
|
||||
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
|
||||
GameResolutionBox.ItemsSource = resolutions;
|
||||
GameResolutionBox.SelectedItem = resolutions.FirstOrDefault(resolution =>
|
||||
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase))
|
||||
?? resolutions[0];
|
||||
PopulateGameRefreshRates(
|
||||
display,
|
||||
GameResolutionBox.SelectedItem as string,
|
||||
selectedRefreshRate);
|
||||
}
|
||||
|
||||
private void PopulateGameRefreshRates(
|
||||
HostDisplayOption display,
|
||||
string? resolution,
|
||||
int selectedRefreshRate)
|
||||
{
|
||||
var refreshRates = HostDisplayOptions.BuildRefreshRates(
|
||||
display,
|
||||
resolution,
|
||||
selectedRefreshRate,
|
||||
Localization.Instance.Get("Options.RefreshRate.Automatic"));
|
||||
GameRefreshRateBox.ItemsSource = refreshRates;
|
||||
GameRefreshRateBox.SelectedItem = refreshRates.FirstOrDefault(
|
||||
refreshRate => refreshRate.Value == selectedRefreshRate) ?? refreshRates[0];
|
||||
}
|
||||
|
||||
private int SelectedGameRefreshRate() =>
|
||||
GameRefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate
|
||||
? refreshRate.Value
|
||||
: 0;
|
||||
|
||||
private List<string> BuildGameEnvironmentEntries()
|
||||
{
|
||||
var entries = new List<string>(_gameEnvironmentPassthrough);
|
||||
foreach (var (name, toggle) in GameEnvironmentToggles())
|
||||
{
|
||||
if (toggle.IsChecked == true)
|
||||
{
|
||||
entries.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static LocalizedChoice FindChoice(
|
||||
IEnumerable<LocalizedChoice> choices,
|
||||
string value,
|
||||
string fallback) =>
|
||||
choices.FirstOrDefault(choice =>
|
||||
string.Equals(choice.Value, value, StringComparison.OrdinalIgnoreCase))
|
||||
?? choices.First(choice =>
|
||||
string.Equals(choice.Value, fallback, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsEnvironmentEnabled(
|
||||
IEnumerable<string> entries,
|
||||
string name)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var parts = entry.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||
if (!string.Equals(parts[0], name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return parts.Length == 1 || parts[1] != "0";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsKnownGameEnvironmentEntry(string entry)
|
||||
{
|
||||
var name = entry.Split('=', 2, StringSplitOptions.TrimEntries)[0];
|
||||
return GameEnvironmentToggleNames.Contains(name, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void SetGameOptionsSection(int section, bool animateIndicator = true)
|
||||
{
|
||||
var buttons = GameOptionsSectionButtons();
|
||||
var panels = GameOptionsSectionPanels();
|
||||
section = Math.Clamp(section, 0, buttons.Length - 1);
|
||||
_gameOptionsSectionIndex = section;
|
||||
SetGameOptionsNavigationIndicator(section, animateIndicator);
|
||||
|
||||
for (var index = 0; index < buttons.Length; index++)
|
||||
{
|
||||
var active = index == section;
|
||||
SetActiveClass(buttons[index], active);
|
||||
SetOptionsPanelInteraction(panels[index], active);
|
||||
}
|
||||
|
||||
SetActiveClass(GameOptionsBackNav, active: false);
|
||||
}
|
||||
|
||||
private void SetGameOptionsNavigationIndicator(int section, bool animate = true)
|
||||
{
|
||||
var buttons = GameOptionsNavigationButtons();
|
||||
_gameOptionsIndicatorIndex = Math.Clamp(section, 0, buttons.Length - 1);
|
||||
var button = buttons[_gameOptionsIndicatorIndex];
|
||||
MoveNavigationIndicator(
|
||||
GameOptionsNavIndicator,
|
||||
GameOptionsNavHost,
|
||||
button,
|
||||
_gameOptionsIndicatorIndex,
|
||||
animate);
|
||||
}
|
||||
|
||||
private void SetGameOptionsPagesSpan(bool coversConsoleRow)
|
||||
{
|
||||
Grid.SetRowSpan(PagesHost, coversConsoleRow ? 2 : 1);
|
||||
}
|
||||
|
||||
private Button[] GameOptionsNavigationButtons() =>
|
||||
[
|
||||
GameOptionsGeneralNav,
|
||||
GameOptionsLoggingNav,
|
||||
GameOptionsRenderingNav,
|
||||
GameOptionsEnvironmentNav,
|
||||
GameOptionsBackNav,
|
||||
];
|
||||
|
||||
private Button[] GameOptionsSectionButtons() =>
|
||||
[
|
||||
GameOptionsGeneralNav,
|
||||
GameOptionsLoggingNav,
|
||||
GameOptionsRenderingNav,
|
||||
GameOptionsEnvironmentNav,
|
||||
];
|
||||
|
||||
private Control[] GameOptionsSectionPanels() =>
|
||||
[
|
||||
GameOptionsGeneralPanel,
|
||||
GameOptionsLoggingPanel,
|
||||
GameOptionsRenderingPanel,
|
||||
GameOptionsEnvironmentPanel,
|
||||
];
|
||||
|
||||
private (string Name, ToggleSwitch Toggle)[] GameEnvironmentToggles() =>
|
||||
[
|
||||
("SHARPEMU_BTHID_UNAVAILABLE", GameEnvBthidToggle),
|
||||
("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD", GameEnvLoopGuardToggle),
|
||||
("SHARPEMU_WRITABLE_APP0", GameEnvWritableApp0Toggle),
|
||||
("SHARPEMU_VK_VALIDATION", GameEnvVkValidationToggle),
|
||||
("SHARPEMU_DUMP_SPIRV", GameEnvDumpSpirvToggle),
|
||||
("SHARPEMU_LOG_DIRECT_MEMORY", GameEnvLogDirectMemoryToggle),
|
||||
("SHARPEMU_LOG_IO", GameEnvLogIoToggle),
|
||||
("SHARPEMU_LOG_NP", GameEnvLogNpToggle),
|
||||
("SHARPEMU_GUEST_IMAGE_CPU_SYNC", GameEnvGuestImageCpuSyncToggle),
|
||||
];
|
||||
|
||||
private static void SetGameOptionsOpenClass(Control control, bool active) =>
|
||||
SetClass(control, "gameOptionsOpen", active);
|
||||
}
|
||||
@@ -26,11 +26,22 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</DataTemplate>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid x:Name="RootLayout" RowDefinitions="Auto,*,Auto">
|
||||
<Grid x:Name="RootLayout" RowDefinitions="Auto,*">
|
||||
|
||||
<!-- Selected-game backdrop: key art behind the main content, dimmed by
|
||||
a scrim so tiles and text stay readable. Fades on selection. -->
|
||||
<Panel Grid.Row="1" ClipToBounds="True">
|
||||
<Panel x:Name="BackdropLayer"
|
||||
Grid.Row="1"
|
||||
Classes="backdropLayer"
|
||||
ClipToBounds="True"
|
||||
RenderTransformOrigin="50%,50%">
|
||||
<Panel.Transitions>
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform"
|
||||
Duration="0:0:0.72"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Panel.Transitions>
|
||||
<Image x:Name="BackdropImage" Stretch="UniformToFill" Opacity="0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Image.Transitions>
|
||||
@@ -42,16 +53,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Border>
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Offset="0" Color="#730D1017" />
|
||||
<GradientStop Offset="0.55" Color="#BF0D1017" />
|
||||
<GradientStop Offset="1" Color="#EB0D1017" />
|
||||
<GradientStop Offset="0" Color="#730C0C0C" />
|
||||
<GradientStop Offset="0.55" Color="#BF0C0C0C" />
|
||||
<GradientStop Offset="1" Color="#EB0C0C0C" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- Title bar; hidden in fullscreen (F11) along with the status bar, so
|
||||
the game gets the whole screen. -->
|
||||
<!-- Title bar; hidden in fullscreen (F11) so the game gets the whole screen. -->
|
||||
<Grid x:Name="TitleBar"
|
||||
Grid.Row="0"
|
||||
Height="44"
|
||||
@@ -73,25 +83,36 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Classes="windowChrome"
|
||||
ToolTip.Tip="Minimize"
|
||||
AutomationProperties.Name="Minimize window">
|
||||
<TextBlock Text="—" FontSize="16" />
|
||||
<TextBlock Classes="materialSymbol compact windowChromeGlyph"
|
||||
Text="minimize" />
|
||||
</Button>
|
||||
<Button x:Name="MaximizeButton"
|
||||
Classes="windowChrome"
|
||||
ToolTip.Tip="Restore"
|
||||
AutomationProperties.Name="Restore window">
|
||||
<TextBlock x:Name="MaximizeGlyph" Text="❐" FontSize="13" />
|
||||
<TextBlock x:Name="MaximizeGlyph"
|
||||
Classes="materialSymbol compact windowChromeGlyph"
|
||||
Text="filter_none" />
|
||||
</Button>
|
||||
<Button x:Name="CloseButton"
|
||||
Classes="windowChrome windowClose"
|
||||
ToolTip.Tip="Close"
|
||||
AutomationProperties.Name="Close window">
|
||||
<TextBlock Text="×" FontSize="20" />
|
||||
<TextBlock Classes="materialSymbol windowChromeGlyph windowCloseGlyph"
|
||||
Text="close" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Main content -->
|
||||
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto">
|
||||
<!-- Settings use the same opaque neutral surface as contextual
|
||||
per-game settings, keeping both views in one visual system -->
|
||||
<Border x:Name="OptionsPageSurface"
|
||||
Grid.RowSpan="3"
|
||||
Margin="-32,-24,-32,-20"
|
||||
Background="{StaticResource GameOptionsMenuBrush}"
|
||||
IsVisible="False" />
|
||||
|
||||
<!-- Library / Options page switcher, with the library toolbar sharing
|
||||
the same row on the right. Plain buttons (not TabItem) so there is
|
||||
@@ -115,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"
|
||||
@@ -126,14 +154,27 @@ 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. -->
|
||||
<Grid x:Name="LibraryPage" Margin="0,46,0,0" RowDefinitions="188,*">
|
||||
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
|
||||
Grid.Row="0"
|
||||
SelectionMode="Single" Padding="4,0,28,0">
|
||||
<Panel x:Name="CarouselHost"
|
||||
Grid.Row="0"
|
||||
Classes="carouselHost"
|
||||
RenderTransformOrigin="50%,50%">
|
||||
<Panel.Transitions>
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform"
|
||||
Duration="0:0:0.32"
|
||||
Easing="CubicEaseOut" />
|
||||
<DoubleTransition Property="Opacity"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Panel.Transitions>
|
||||
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
|
||||
SelectionMode="Single">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
|
||||
<MenuItem x:Name="CtxLaunch"
|
||||
@@ -184,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"
|
||||
@@ -244,22 +288,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.DataTemplates>
|
||||
</ListBox>
|
||||
</ListBox>
|
||||
</Panel>
|
||||
|
||||
<!-- Selected-game identity and actions share one detail surface so
|
||||
the selected title and metadata are never rendered twice. -->
|
||||
<StackPanel x:Name="LibrarySelectedDetails"
|
||||
Grid.Row="1"
|
||||
Classes="selectedDetailsHost"
|
||||
x:DataType="local:GameEntry"
|
||||
x:CompileBindings="True"
|
||||
IsVisible="False"
|
||||
MaxWidth="980"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
Margin="4,8,0,0"
|
||||
Spacing="18">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="42"
|
||||
Margin="4,8,0,0">
|
||||
<StackPanel.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity"
|
||||
Duration="0:0:0.16" />
|
||||
<TransformOperationsTransition Property="RenderTransform"
|
||||
Duration="0:0:0.18" />
|
||||
</Transitions>
|
||||
</StackPanel.Transitions>
|
||||
<Border Classes="selectedDetailsDivider" />
|
||||
<TextBlock Classes="selectedGameTitle"
|
||||
Text="{Binding Name}"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
@@ -278,25 +332,36 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<Grid Width="360"
|
||||
ColumnDefinitions="*,*"
|
||||
ColumnSpacing="8"
|
||||
HorizontalAlignment="Left">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="12"
|
||||
HorizontalAlignment="Left">
|
||||
<Button x:Name="LaunchButton"
|
||||
Grid.Column="0"
|
||||
Classes="accent"
|
||||
Classes="playButton"
|
||||
Content="Launch"
|
||||
HorizontalAlignment="Stretch"
|
||||
IsEnabled="False" />
|
||||
<Button x:Name="GameSettingsButton"
|
||||
Classes="optionsCircle"
|
||||
ToolTip.Tip="{Binding [Library.Context.GameSettings],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
AutomationProperties.Name="{Binding [Library.Context.GameSettings],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<TextBlock Classes="materialSymbol"
|
||||
Text="more_horiz" />
|
||||
</Button>
|
||||
<ToggleButton x:Name="ConsoleToggle"
|
||||
Grid.Column="1"
|
||||
Classes="ghost"
|
||||
Padding="22,10"
|
||||
HorizontalAlignment="Stretch"
|
||||
Content="{Binding [Launch.Console],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}" />
|
||||
</Grid>
|
||||
Classes="optionsCircle"
|
||||
ToolTip.Tip="{Binding [Launch.Console],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
AutomationProperties.Name="{Binding [Launch.Console],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<TextBlock Classes="materialSymbol"
|
||||
Text="terminal" />
|
||||
</ToggleButton>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Empty state -->
|
||||
@@ -324,6 +389,591 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Contextual per-game settings use the same layered reveal as the
|
||||
original UI branch while keeping the current launch settings. -->
|
||||
<Grid x:Name="GameOptionsOverlay"
|
||||
Classes="gameOptionsOverlay"
|
||||
x:DataType="local:GameEntry"
|
||||
x:CompileBindings="True"
|
||||
RowDefinitions="1*,3*"
|
||||
IsHitTestVisible="False">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity"
|
||||
Duration="0:0:0.2"
|
||||
Easing="CubicEaseOut" />
|
||||
<TransformOperationsTransition Property="RenderTransform"
|
||||
Duration="0:0:0.38"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Background="Transparent">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Border Grid.Row="1"
|
||||
Margin="-32,0,-32,-20"
|
||||
Background="{StaticResource GameOptionsMenuBrush}" />
|
||||
<Border Grid.Row="1"
|
||||
Height="1"
|
||||
Margin="-32,0,-32,0"
|
||||
ZIndex="1"
|
||||
IsHitTestVisible="False"
|
||||
VerticalAlignment="Top"
|
||||
BoxShadow="0 18 42 0 #A6000000" />
|
||||
|
||||
<!-- Anchor the cover to the actual menu-surface boundary so it
|
||||
overlaps both the key art and the settings surface -->
|
||||
<Border Grid.Row="1"
|
||||
Width="180"
|
||||
Height="180"
|
||||
Margin="52,-152,0,0"
|
||||
ZIndex="2"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
BoxShadow="0 26 54 0 #D1000000">
|
||||
<Border Classes="coverClip"
|
||||
Background="{Binding PlaceholderBrush}">
|
||||
<Panel>
|
||||
<TextBlock Text="{Binding Initials}"
|
||||
FontSize="28"
|
||||
FontWeight="Medium"
|
||||
Foreground="#A6FFFFFF"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding !HasCover}" />
|
||||
<Image Source="{Binding Cover}"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="{Binding HasCover}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border Padding="260,18,32,24">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto"
|
||||
ColumnSpacing="34">
|
||||
<StackPanel Spacing="3"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}"
|
||||
FontSize="42"
|
||||
FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Path}"
|
||||
MaxWidth="620"
|
||||
HorizontalAlignment="Left"
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource SecondaryTextBrush}"
|
||||
Opacity="0.8"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="34"
|
||||
VerticalAlignment="Center">
|
||||
<StackPanel Spacing="4"
|
||||
IsVisible="{Binding HasVersion}">
|
||||
<TextBlock Classes="gameStatLabel"
|
||||
Text="{Binding [Library.Stat.Version],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}" />
|
||||
<TextBlock Classes="gameStatValue"
|
||||
FontSize="15"
|
||||
Text="{Binding VersionText}" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="gameStatLabel"
|
||||
Text="{Binding [Library.Stat.Installed],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}" />
|
||||
<TextBlock Classes="gameStatValue"
|
||||
FontSize="15"
|
||||
Text="{Binding SizeText}" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4"
|
||||
IsVisible="{Binding HasTitleId}">
|
||||
<TextBlock Classes="gameStatLabel"
|
||||
Text="{Binding [Library.Stat.TitleId],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}" />
|
||||
<TextBlock Classes="gameStatValue"
|
||||
FontSize="15"
|
||||
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>
|
||||
|
||||
<Grid Grid.Row="1"
|
||||
Margin="32,12,32,8"
|
||||
ColumnDefinitions="220,*"
|
||||
ColumnSpacing="54">
|
||||
<Grid Margin="0,62,0,0"
|
||||
RowDefinitions="Auto,*">
|
||||
<Button x:Name="GameOptionsLaunchButton"
|
||||
Classes="gameOptionsLaunch">
|
||||
<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"
|
||||
Grid.Row="1"
|
||||
Margin="0,12,0,0"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Hidden"
|
||||
BringIntoViewOnFocusChange="True">
|
||||
<ScrollViewer.Styles>
|
||||
<Style Selector="ScrollBar">
|
||||
<Setter Property="IsHitTestVisible" Value="False" />
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
</ScrollViewer.Styles>
|
||||
<Grid x:Name="GameOptionsNavHost"
|
||||
Background="Transparent"
|
||||
VerticalAlignment="Top">
|
||||
<Border x:Name="GameOptionsNavIndicator"
|
||||
Classes="optionsNavIndicator"
|
||||
IsHitTestVisible="False"
|
||||
VerticalAlignment="Top" />
|
||||
|
||||
<StackPanel>
|
||||
<Button x:Name="GameOptionsGeneralNav"
|
||||
Classes="optionsNav active"
|
||||
AutomationProperties.Name="{Binding [Options.General],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<Grid ColumnDefinitions="36,*">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="materialSymbol optionsNavIcon"
|
||||
Text="tune" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="optionsNavLabel"
|
||||
Text="{Binding [Options.General],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button x:Name="GameOptionsLoggingNav"
|
||||
Classes="optionsNav"
|
||||
AutomationProperties.Name="{Binding [Options.Logging],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<Grid ColumnDefinitions="36,*">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="materialSymbol optionsNavIcon"
|
||||
Text="terminal" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="optionsNavLabel"
|
||||
Text="{Binding [Options.Logging],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button x:Name="GameOptionsRenderingNav"
|
||||
Classes="optionsNav"
|
||||
AutomationProperties.Name="{Binding [Options.Graphics],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<Grid ColumnDefinitions="36,*">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="materialSymbol optionsNavIcon"
|
||||
Text="monitor" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="optionsNavLabel"
|
||||
Text="{Binding [Options.Graphics],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button x:Name="GameOptionsEnvironmentNav"
|
||||
Classes="optionsNav"
|
||||
AutomationProperties.Name="{Binding [Options.Env.Tab],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<Grid ColumnDefinitions="36,*">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="materialSymbol optionsNavIcon"
|
||||
Text="deployed_code" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="optionsNavLabel"
|
||||
Text="{Binding [Options.Env.Tab],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button x:Name="GameOptionsBackNav"
|
||||
Classes="optionsNav"
|
||||
AutomationProperties.Name="{Binding [Common.Back],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<Grid ColumnDefinitions="36,*">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="materialSymbol optionsNavIcon"
|
||||
Text="arrow_back" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="optionsNavLabel"
|
||||
Text="{Binding [Common.Back],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="1"
|
||||
Margin="0,36,0,0">
|
||||
<ScrollViewer x:Name="GameOptionsGeneralPanel"
|
||||
Classes="optionsSectionPanel active">
|
||||
<StackPanel Spacing="12">
|
||||
<Border Classes="optionsGroup">
|
||||
<StackPanel Spacing="8">
|
||||
<local:SettingRow x:Name="GameStrictRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.Strict.Label],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
Description="{Binding [Options.Strict.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameStrictToggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<WrapPanel Margin="0,12,0,0"
|
||||
ItemWidth="260"
|
||||
ItemHeight="54"
|
||||
Orientation="Horizontal">
|
||||
<Button x:Name="GameOptionsOpenFolderButton"
|
||||
Classes="gameAction"
|
||||
Margin="0,0,10,10">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="11">
|
||||
<TextBlock Classes="materialSymbol"
|
||||
Text="folder_open" />
|
||||
<TextBlock Text="{Binding [Library.Context.OpenFolder],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="GameOptionsCopyPathButton"
|
||||
Classes="gameAction"
|
||||
Margin="0,0,10,10">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="11">
|
||||
<TextBlock Classes="materialSymbol"
|
||||
Text="content_copy" />
|
||||
<TextBlock Text="{Binding [Library.Context.CopyPath],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="GameOptionsCopyTitleIdButton"
|
||||
Classes="gameAction"
|
||||
Margin="0,0,10,10">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="11">
|
||||
<TextBlock Classes="materialSymbol"
|
||||
Text="tag" />
|
||||
<TextBlock Text="{Binding [Library.Context.CopyTitleId],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button x:Name="GameOptionsRemoveButton"
|
||||
Classes="gameAction dangerAction"
|
||||
Margin="0,0,10,10">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="11">
|
||||
<TextBlock Classes="materialSymbol"
|
||||
Text="delete" />
|
||||
<TextBlock Text="{Binding [Library.Context.Remove],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer x:Name="GameOptionsLoggingPanel"
|
||||
Classes="optionsSectionPanel">
|
||||
<StackPanel>
|
||||
<Border Classes="optionsGroup">
|
||||
<StackPanel Spacing="8">
|
||||
<local:SettingRow x:Name="GameLogLevelRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.LogLevel.Label],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
Description="{Binding [Options.LogLevel.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ComboBox x:Name="GameLogLevelBox"
|
||||
Classes="optionValue"
|
||||
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
|
||||
HorizontalContentAlignment="Left"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
|
||||
<local:SettingRow x:Name="GameTraceImportsRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.TraceImports.Label],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
Description="{Binding [Options.TraceImports.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<settingsControls:SplitNumericUpDown x:Name="GameTraceImportsBox"
|
||||
Classes="optionValue"
|
||||
Minimum="0"
|
||||
Maximum="4096"
|
||||
Increment="16"
|
||||
Value="0"
|
||||
FormatString="0"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
|
||||
<local:SettingRow x:Name="GameLogToFileRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.LogToFile.Label],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
Description="{Binding [Options.LogToFile.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameLogToFileToggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer x:Name="GameOptionsRenderingPanel"
|
||||
Classes="optionsSectionPanel">
|
||||
<StackPanel>
|
||||
<Border Classes="optionsGroup">
|
||||
<StackPanel Spacing="8">
|
||||
<local:SettingRow x:Name="GameWindowModeRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.WindowMode.Label],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
Description="{Binding [Options.WindowMode.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ComboBox x:Name="GameWindowModeBox"
|
||||
Classes="optionValue"
|
||||
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
|
||||
HorizontalContentAlignment="Left"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
|
||||
<local:SettingRow x:Name="GameResolutionRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.Resolution.Label],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
Description="{Binding [Options.Resolution.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ComboBox x:Name="GameResolutionBox"
|
||||
Classes="optionValue"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
|
||||
<local:SettingRow x:Name="GameDisplayRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.Display.Label],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
Description="{Binding [Options.Display.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ComboBox x:Name="GameDisplayBox"
|
||||
Classes="optionValue"
|
||||
Width="260"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
|
||||
<local:SettingRow x:Name="GameRefreshRateRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.RefreshRate.Label],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
Description="{Binding [Options.RefreshRate.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ComboBox x:Name="GameRefreshRateBox"
|
||||
Classes="optionValue"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
|
||||
<local:SettingRow x:Name="GameScalingModeRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.Scaling.Label],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
Description="{Binding [Options.Scaling.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ComboBox x:Name="GameScalingModeBox"
|
||||
Classes="optionValue"
|
||||
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
|
||||
HorizontalContentAlignment="Left"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
|
||||
<local:SettingRow x:Name="GameVSyncRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.VSync.Label],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
Description="{Binding [Options.VSync.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameVSyncToggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
|
||||
<local:SettingRow x:Name="GameHdrModeRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.Hdr.Label],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}"
|
||||
Description="{Binding [Options.Hdr.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ComboBox x:Name="GameHdrModeBox"
|
||||
Classes="optionValue"
|
||||
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
|
||||
HorizontalContentAlignment="Left"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<ScrollViewer x:Name="GameOptionsEnvironmentPanel"
|
||||
Classes="optionsSectionPanel">
|
||||
<StackPanel>
|
||||
<Border Classes="optionsGroup">
|
||||
<StackPanel Spacing="8">
|
||||
<local:SettingRow Classes="optionRow"
|
||||
Label="SHARPEMU_BTHID_UNAVAILABLE"
|
||||
Description="{Binding [Options.Env.Bthid.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameEnvBthidToggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow Classes="optionRow"
|
||||
Label="SHARPEMU_DISABLE_IMPORT_LOOP_GUARD"
|
||||
Description="{Binding [Options.Env.LoopGuard.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameEnvLoopGuardToggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow Classes="optionRow"
|
||||
Label="SHARPEMU_WRITABLE_APP0"
|
||||
Description="{Binding [Options.Env.WritableApp0.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameEnvWritableApp0Toggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow Classes="optionRow"
|
||||
Label="SHARPEMU_VK_VALIDATION"
|
||||
Description="{Binding [Options.Env.VkValidation.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameEnvVkValidationToggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow Classes="optionRow"
|
||||
Label="SHARPEMU_DUMP_SPIRV"
|
||||
Description="{Binding [Options.Env.DumpSpirv.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameEnvDumpSpirvToggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow Classes="optionRow"
|
||||
Label="SHARPEMU_LOG_DIRECT_MEMORY"
|
||||
Description="{Binding [Options.Env.LogDirectMemory.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameEnvLogDirectMemoryToggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow Classes="optionRow"
|
||||
Label="SHARPEMU_LOG_IO"
|
||||
Description="{Binding [Options.Env.LogIo.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameEnvLogIoToggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow Classes="optionRow"
|
||||
Label="SHARPEMU_LOG_NP"
|
||||
Description="{Binding [Options.Env.LogNp.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameEnvLogNpToggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow Classes="optionRow"
|
||||
Label="SHARPEMU_GUEST_IMAGE_CPU_SYNC"
|
||||
Description="{Binding [Options.Env.GuestImageCpuSync.Desc],
|
||||
Source={x:Static local:Localization.Instance},
|
||||
x:CompileBindings=False}">
|
||||
<ToggleSwitch x:Name="GameEnvGuestImageCpuSyncToggle"
|
||||
Classes="optionToggle" />
|
||||
</local:SettingRow>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Options page: stable local navigation keeps each settings group
|
||||
focused while preserving every setting from the current launcher -->
|
||||
<Grid x:Name="OptionsPage"
|
||||
@@ -339,19 +989,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Classes="optionsNavIndicator"
|
||||
Height="54"
|
||||
VerticalAlignment="Top"
|
||||
IsHitTestVisible="False">
|
||||
<Border.RenderTransform>
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Y"
|
||||
Duration="0:0:0.16"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<StackPanel>
|
||||
<Button x:Name="OptionsGeneralNav" Classes="optionsNav active">
|
||||
@@ -591,7 +1229,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<TextBlock x:Name="LatestCommitDescription"
|
||||
Text="{Binding [About.Github.LatestCommitDescription], Source={x:Static local:Localization.Instance}}"
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource MutedBrush}"
|
||||
Foreground="{StaticResource SettingsDescriptionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
@@ -616,7 +1254,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<TextBlock x:Name="UpdateStatusText"
|
||||
Text="Current build: dev"
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource MutedBrush}"
|
||||
Foreground="{StaticResource SettingsDescriptionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
@@ -638,7 +1276,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<TextBlock x:Name="GithubDesc"
|
||||
Text="{Binding [About.Github.Desc], Source={x:Static local:Localization.Instance}}"
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource MutedBrush}"
|
||||
Foreground="{StaticResource SettingsDescriptionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
@@ -660,7 +1298,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<TextBlock x:Name="DiscordServerDesc"
|
||||
Text="{Binding [About.Discord.Desc], Source={x:Static local:Localization.Instance}}"
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource MutedBrush}"
|
||||
Foreground="{StaticResource SettingsDescriptionBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1"
|
||||
@@ -873,20 +1511,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- Status bar -->
|
||||
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
|
||||
ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="EmulatorPathText" Grid.Column="0" Text="Emulator: locating…" FontSize="11"
|
||||
Foreground="{StaticResource FaintBrush}" VerticalAlignment="Center" Margin="16,0"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="StatusBarRight" Grid.Column="1" Text="" FontSize="11"
|
||||
Foreground="{StaticResource FaintBrush}" VerticalAlignment="Center" Margin="16,0" />
|
||||
</Grid>
|
||||
|
||||
<!-- Portable resize hit targets for the frameless desktop window. They
|
||||
are disabled while maximized or fullscreen in code-behind. -->
|
||||
<Panel x:Name="ResizeHandles"
|
||||
Grid.RowSpan="3"
|
||||
Grid.RowSpan="2"
|
||||
ZIndex="1000"
|
||||
IsVisible="False">
|
||||
<Border Tag="North"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Automation;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
@@ -12,6 +13,7 @@ using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Rendering.Composition;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using SharpEmu.Core.Cpu;
|
||||
@@ -33,6 +35,8 @@ public partial class MainWindow : Window
|
||||
{
|
||||
private const int MaxConsoleLines = 4000;
|
||||
private const int MaxConsoleLinesPerFlush = 500;
|
||||
private static readonly TimeSpan NavigationIndicatorAnimationDuration =
|
||||
TimeSpan.FromMilliseconds(180);
|
||||
|
||||
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
|
||||
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
|
||||
@@ -119,6 +123,7 @@ public partial class MainWindow : Window
|
||||
private bool _isClosing;
|
||||
private bool _restoringGameSelection;
|
||||
private bool _addFolderInProgress;
|
||||
private bool _isLibraryGridLayout;
|
||||
private GameEntry? _lastSelectedGame;
|
||||
|
||||
// Bundled key art shown whenever no game-specific backdrop applies; the
|
||||
@@ -130,6 +135,8 @@ public partial class MainWindow : Window
|
||||
private HostGamepadButtons _previousPadButtons;
|
||||
private long _navLeftNextAt;
|
||||
private long _navRightNextAt;
|
||||
private long _navUpNextAt;
|
||||
private long _navDownNextAt;
|
||||
|
||||
//Github http client for latest commit
|
||||
private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient();
|
||||
@@ -165,8 +172,6 @@ public partial class MainWindow : Window
|
||||
ConsoleList.ItemsSource = _consoleLines;
|
||||
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
|
||||
_pendingLines.Enqueue((line, isError)));
|
||||
Closed += (_, _) => _emulator?.Stop();
|
||||
|
||||
_consoleFlushTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(80),
|
||||
@@ -217,8 +222,12 @@ public partial class MainWindow : Window
|
||||
CloseConsoleButton.Click += (_, _) => ConsoleToggle.IsChecked = false;
|
||||
LibraryTabButton.Click += (_, _) => SetActivePage(0);
|
||||
OptionsTabButton.Click += (_, _) => SetActivePage(1);
|
||||
LibraryLayoutButton.Click += (_, _) => ToggleLibraryLayout();
|
||||
LibraryPage.SizeChanged += (_, _) => UpdateLibraryGridHeight();
|
||||
LibrarySelectedDetails.SizeChanged += (_, _) => UpdateLibraryGridHeight();
|
||||
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
WireOptionsNavigation();
|
||||
WireGameOptions();
|
||||
|
||||
// The settings page edits _settings live, so a launch started while
|
||||
// it is open already uses the new values.
|
||||
@@ -290,14 +299,15 @@ public partial class MainWindow : Window
|
||||
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
||||
CtxCopyPath.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Clipboard.Path");
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
|
||||
CtxCopyTitleId.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Clipboard.TitleId");
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
|
||||
CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings();
|
||||
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
|
||||
|
||||
Opened += async (_, _) => await OnOpenedAsync();
|
||||
Closing += (_, _) => OnWindowClosing();
|
||||
Closing += (_, _) => BeginWindowClosing();
|
||||
Closed += (_, _) => CompleteWindowClosing();
|
||||
|
||||
SdlLauncherGamepad.EnsureStarted();
|
||||
_gamepadTimer = new DispatcherTimer
|
||||
@@ -341,6 +351,11 @@ public partial class MainWindow : Window
|
||||
{
|
||||
if (index == _activePageIndex)
|
||||
{
|
||||
if (index == 0 && _isGameSettingsOpen)
|
||||
{
|
||||
CloseGameSettings();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,26 +364,94 @@ public partial class MainWindow : Window
|
||||
_settings.Save(); // leaving the Options page
|
||||
}
|
||||
|
||||
if (_isGameSettingsOpen)
|
||||
{
|
||||
CloseGameSettings(restoreLibrary: false);
|
||||
}
|
||||
|
||||
_activePageIndex = index;
|
||||
SetActiveClass(LibraryTabButton, index == 0);
|
||||
SetActiveClass(OptionsTabButton, index == 1);
|
||||
LibraryPage.IsVisible = index == 0;
|
||||
LibraryToolbar.IsVisible = index == 0;
|
||||
OptionsPageSurface.IsVisible = index == 1;
|
||||
OptionsPage.IsVisible = index == 1;
|
||||
|
||||
if (index == 1)
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() => SetOptionsNavigationIndicator(_optionsSectionIndex, animate: false),
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetActiveClass(Button button, bool active)
|
||||
private void SetLibraryLayout(bool grid)
|
||||
{
|
||||
_isLibraryGridLayout = grid;
|
||||
SetClass(GameList, "gridLayout", grid);
|
||||
SetClass(LibrarySelectedDetails, "gridLayout", grid);
|
||||
LibraryPage.RowDefinitions[0].Height = grid
|
||||
? GridLength.Auto
|
||||
: new GridLength(188);
|
||||
LibraryPage.Margin = grid
|
||||
? new Thickness(0, 6, 0, 0)
|
||||
: new Thickness(0, 46, 0, 0);
|
||||
UpdateLibraryGridHeight();
|
||||
UpdateLibraryLayoutButton();
|
||||
|
||||
if (GameList.SelectedItem is { } selected)
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() => GameList.ScrollIntoView(selected),
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLibraryGridHeight()
|
||||
{
|
||||
var pageHeight = LibraryPage.Bounds.Height;
|
||||
if (!_isLibraryGridLayout || pageHeight <= 0)
|
||||
{
|
||||
GameList.MaxHeight = double.PositiveInfinity;
|
||||
return;
|
||||
}
|
||||
|
||||
GameList.MaxHeight = Math.Max(
|
||||
0,
|
||||
pageHeight - LibrarySelectedDetails.DesiredSize.Height);
|
||||
}
|
||||
|
||||
private void ToggleLibraryLayout()
|
||||
{
|
||||
SetLibraryLayout(!_isLibraryGridLayout);
|
||||
_settings.LibraryLayout = _isLibraryGridLayout ? "Grid" : "Carousel";
|
||||
_settings.Save();
|
||||
}
|
||||
|
||||
private void UpdateLibraryLayoutButton()
|
||||
{
|
||||
LibraryLayoutGlyph.Text = _isLibraryGridLayout ? "view_carousel" : "grid_view";
|
||||
var label = Localization.Instance.Get(
|
||||
_isLibraryGridLayout ? "Library.View.Carousel" : "Library.View.Grid");
|
||||
ToolTip.SetTip(LibraryLayoutButton, label);
|
||||
AutomationProperties.SetName(LibraryLayoutButton, label);
|
||||
}
|
||||
|
||||
private static void SetActiveClass(Button button, bool active) =>
|
||||
SetClass(button, "active", active);
|
||||
|
||||
private static void SetClass(Control control, string className, bool active)
|
||||
{
|
||||
if (active)
|
||||
{
|
||||
if (!button.Classes.Contains("active"))
|
||||
if (!control.Classes.Contains(className))
|
||||
{
|
||||
button.Classes.Add("active");
|
||||
control.Classes.Add(className);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
button.Classes.Remove("active");
|
||||
control.Classes.Remove(className);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,17 +530,69 @@ public partial class MainWindow : Window
|
||||
active ? KeyboardNavigationMode.Continue : KeyboardNavigationMode.None);
|
||||
}
|
||||
|
||||
private void SetOptionsNavigationIndicator(int section)
|
||||
private void SetOptionsNavigationIndicator(int section, bool animate = true)
|
||||
{
|
||||
if (OptionsNavIndicator.RenderTransform is not TranslateTransform transform)
|
||||
var buttons = OptionsNavigationButtons();
|
||||
var button = buttons[Math.Clamp(section, 0, buttons.Length - 1)];
|
||||
MoveNavigationIndicator(
|
||||
OptionsNavIndicator,
|
||||
OptionsNavHost,
|
||||
button,
|
||||
section,
|
||||
animate);
|
||||
}
|
||||
|
||||
private static void ConfigureNavigationIndicatorAnimation(Border indicator)
|
||||
{
|
||||
if (ElementComposition.GetElementVisual(indicator) is not { } visual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var buttons = OptionsNavigationButtons();
|
||||
var button = buttons[Math.Clamp(section, 0, buttons.Length - 1)];
|
||||
transform.Y = button.TranslatePoint(default, OptionsNavHost)?.Y
|
||||
var translationAnimation = visual.Compositor.CreateVector3KeyFrameAnimation();
|
||||
translationAnimation.Duration = NavigationIndicatorAnimationDuration;
|
||||
translationAnimation.Target = nameof(CompositionVisual.Translation);
|
||||
translationAnimation.InsertExpressionKeyFrame(
|
||||
1f,
|
||||
"this.FinalValue",
|
||||
new CubicEaseOut());
|
||||
|
||||
var animations = visual.Compositor.CreateImplicitAnimationCollection();
|
||||
animations[nameof(CompositionVisual.Translation)] = translationAnimation;
|
||||
visual.ImplicitAnimations = animations;
|
||||
}
|
||||
|
||||
private static void MoveNavigationIndicator(
|
||||
Border indicator,
|
||||
Control host,
|
||||
Button button,
|
||||
int section,
|
||||
bool animate = true)
|
||||
{
|
||||
if (ElementComposition.GetElementVisual(indicator) is not { } visual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetY = button.TranslatePoint(default, host)?.Y
|
||||
?? section * button.Bounds.Height;
|
||||
|
||||
if (!animate)
|
||||
{
|
||||
visual.ImplicitAnimations = null;
|
||||
visual.StopAnimation(nameof(CompositionVisual.Translation));
|
||||
}
|
||||
else if (visual.ImplicitAnimations is null)
|
||||
{
|
||||
ConfigureNavigationIndicatorAnimation(indicator);
|
||||
}
|
||||
|
||||
visual.Translation = new Vector3D(0, targetY, 0);
|
||||
|
||||
if (!animate)
|
||||
{
|
||||
ConfigureNavigationIndicatorAnimation(indicator);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Github http client config ----
|
||||
@@ -582,7 +717,7 @@ public partial class MainWindow : Window
|
||||
SetActivePage(1);
|
||||
}
|
||||
|
||||
if (_activePageIndex != 0)
|
||||
if (_activePageIndex != 0 || _isGameSettingsOpen)
|
||||
{
|
||||
_previousPadButtons = pad.Buttons;
|
||||
return;
|
||||
@@ -602,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)
|
||||
{
|
||||
@@ -638,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
|
||||
@@ -673,6 +848,8 @@ public partial class MainWindow : Window
|
||||
{
|
||||
_ = CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
SeedLibraryFromCache();
|
||||
await RescanLibraryAsync();
|
||||
}
|
||||
|
||||
@@ -709,6 +886,7 @@ public partial class MainWindow : Window
|
||||
RefreshHostRefreshRates(_settings.RefreshRate);
|
||||
RefreshUpdateText();
|
||||
UpdateEmptyStateTexts();
|
||||
UpdateLibraryLayoutButton();
|
||||
UpdateRunButtons();
|
||||
}
|
||||
|
||||
@@ -799,6 +977,13 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnKeyDown(object sender, KeyEventArgs args)
|
||||
{
|
||||
if (args.Key == Key.Escape && _isGameSettingsOpen)
|
||||
{
|
||||
CloseGameSettings();
|
||||
args.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Key == Key.F11 && !_isRunning)
|
||||
{
|
||||
WindowState = WindowState == WindowState.FullScreen
|
||||
@@ -816,22 +1001,44 @@ public partial class MainWindow : Window
|
||||
// still needs a preview hook for its own shortcuts.
|
||||
}
|
||||
|
||||
private void OnWindowClosing()
|
||||
private void BeginWindowClosing()
|
||||
{
|
||||
if (_isClosing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isClosing = true;
|
||||
Interlocked.Increment(ref _libraryScanGeneration);
|
||||
Interlocked.Increment(ref _detailLoadGeneration);
|
||||
_libraryWatcher.Dispose();
|
||||
_settings.Save();
|
||||
_consoleFlushTimer.Stop();
|
||||
_gamepadTimer.Stop();
|
||||
SdlLauncherGamepad.Shutdown();
|
||||
_sndPreview.Stop();
|
||||
_discord?.Dispose();
|
||||
_consoleWindow?.Close();
|
||||
_emulator?.Dispose();
|
||||
_consoleMirror?.Dispose();
|
||||
DropFileLog();
|
||||
}
|
||||
|
||||
private void CompleteWindowClosing()
|
||||
{
|
||||
RunShutdownStep("library watcher", _libraryWatcher.Dispose);
|
||||
RunShutdownStep("settings", _settings.Save);
|
||||
RunShutdownStep("SDL gamepad", SdlLauncherGamepad.Shutdown);
|
||||
RunShutdownStep("title music", _sndPreview.Stop);
|
||||
RunShutdownStep("Discord Rich Presence", () => _discord?.Dispose());
|
||||
RunShutdownStep("console window", () => _consoleWindow?.Close());
|
||||
RunShutdownStep("emulator process", () => _emulator?.Dispose());
|
||||
RunShutdownStep("console mirror", () => _consoleMirror?.Dispose());
|
||||
RunShutdownStep("file log", DropFileLog);
|
||||
}
|
||||
|
||||
private static void RunShutdownStep(string component, Action action)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Failed to clean up {component}: {exception}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
@@ -868,14 +1075,17 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
var isMaximized = WindowState == WindowState.Maximized;
|
||||
glyph.Text = isMaximized ? "❐" : "□";
|
||||
ToolTip.SetTip(button, isMaximized ? "Restore" : "Maximize");
|
||||
AutomationProperties.SetName(
|
||||
button,
|
||||
isMaximized ? "Restore window" : "Maximize window");
|
||||
var state = GetMaximizeButtonState(WindowState);
|
||||
glyph.Text = state.Glyph;
|
||||
ToolTip.SetTip(button, state.ToolTip);
|
||||
AutomationProperties.SetName(button, state.AutomationName);
|
||||
}
|
||||
|
||||
internal static WindowMaximizeButtonState GetMaximizeButtonState(WindowState windowState) =>
|
||||
windowState == WindowState.Maximized
|
||||
? new("filter_none", "Restore", "Restore window")
|
||||
: new("crop_square", "Maximize", "Maximize window");
|
||||
|
||||
private void UpdateWindowChromeState()
|
||||
{
|
||||
UpdateMaximizeButton();
|
||||
@@ -886,11 +1096,6 @@ public partial class MainWindow : Window
|
||||
titleBar.IsVisible = !isFullscreen;
|
||||
}
|
||||
|
||||
if (StatusBar is { } statusBar)
|
||||
{
|
||||
statusBar.IsVisible = !isFullscreen;
|
||||
}
|
||||
|
||||
if (ResizeHandles is { } handles)
|
||||
{
|
||||
handles.IsVisible = CanResize && WindowState == WindowState.Normal;
|
||||
@@ -966,6 +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");
|
||||
@@ -1278,9 +1484,6 @@ public partial class MainWindow : Window
|
||||
? Path.GetFullPath(found)
|
||||
: null;
|
||||
|
||||
EmulatorPathText.Text = _emulatorExePath is not null
|
||||
? Localization.Instance.Format("Status.EmulatorPath", _emulatorExePath)
|
||||
: Localization.Instance.Get("Status.EmulatorNotFound");
|
||||
}
|
||||
|
||||
// ---- Game library ----
|
||||
@@ -1346,6 +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();
|
||||
@@ -1355,11 +1583,6 @@ public partial class MainWindow : Window
|
||||
var excluded = new HashSet<string>(_settings.ExcludedGames, GameLibraryPath.Comparer);
|
||||
_libraryWatcher.Watch(folders);
|
||||
var showLoadingState = showProgress && _allGames.Count == 0;
|
||||
if (showProgress)
|
||||
{
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
|
||||
}
|
||||
|
||||
if (showLoadingState)
|
||||
{
|
||||
EmptyState.IsVisible = false;
|
||||
@@ -1380,12 +1603,7 @@ public partial class MainWindow : Window
|
||||
LoadingState.IsVisible = false;
|
||||
LoadGameDetailsInBackground(reconciliation.CoversToLoad, reconciliation.Games);
|
||||
UpdateDiscordPresence();
|
||||
if (showProgress)
|
||||
{
|
||||
StatusBarRight.Text = folders.Length == 0
|
||||
? Localization.Instance.Get("Status.AddFolderPrompt")
|
||||
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
|
||||
}
|
||||
GameLibraryCache.Save(folders, reconciliation.Games);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1782,24 +2000,6 @@ public partial class MainWindow : Window
|
||||
CtxGameSettings.IsEnabled = !string.IsNullOrWhiteSpace(game.TitleId);
|
||||
}
|
||||
|
||||
private void OpenSelectedGameSettings()
|
||||
{
|
||||
if (GameList.SelectedItem is not GameEntry game)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(game.TitleId))
|
||||
{
|
||||
AppendConsoleLine(
|
||||
"[GUI][WARN] Per-game settings require a title ID, which this game does not have.",
|
||||
WarningLineBrush);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = new PerGameSettingsDialog(game.TitleId, game.Name, _settings).ShowDialog(this);
|
||||
}
|
||||
|
||||
private void OpenSelectedGameFolder()
|
||||
{
|
||||
if (GameList.SelectedItem is not GameEntry game)
|
||||
@@ -1830,12 +2030,13 @@ public partial class MainWindow : Window
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.CouldNotOpenFolder", ex.Message);
|
||||
AppendConsoleLine(
|
||||
Localization.Instance.Format("Status.CouldNotOpenFolder", ex.Message),
|
||||
WarningLineBrush);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Copies <paramref name="text"/> and reports it via <paramref name="whatKey"/>, e.g. "Clipboard.Path".</summary>
|
||||
private async Task CopyToClipboardAsync(string? text, string whatKey)
|
||||
private async Task CopyToClipboardAsync(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || Clipboard is null)
|
||||
{
|
||||
@@ -1843,7 +2044,6 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
await Clipboard.SetTextAsync(text);
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.CopiedToClipboard", Localization.Instance.Get(whatKey));
|
||||
}
|
||||
|
||||
private void RemoveSelectedFromLibrary()
|
||||
@@ -1863,7 +2063,6 @@ public partial class MainWindow : Window
|
||||
string.Equals(g.Path, game.Path, GameLibraryPath.Comparison));
|
||||
GameList.SelectedItem = null;
|
||||
RefreshVisibleGames();
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name);
|
||||
}
|
||||
|
||||
private void RefreshVisibleGames(IReadOnlySet<GameEntry>? backgroundsChanged = null)
|
||||
@@ -2173,7 +2372,6 @@ public partial class MainWindow : Window
|
||||
_runningGameName = displayName;
|
||||
_runningGameTitleId = resolvedTitleId;
|
||||
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
|
||||
UpdateRunButtons();
|
||||
UpdateDiscordPresence();
|
||||
|
||||
@@ -2226,7 +2424,6 @@ public partial class MainWindow : Window
|
||||
_emulator.Stop();
|
||||
_runningGameName = null;
|
||||
_runningGameTitleId = null;
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
|
||||
UpdateDiscordPresence();
|
||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
|
||||
@@ -2293,7 +2490,6 @@ public partial class MainWindow : Window
|
||||
brush);
|
||||
CloseFileLogSoon();
|
||||
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
|
||||
_runningGameName = null;
|
||||
_runningGameTitleId = null;
|
||||
UpdateRunButtons();
|
||||
@@ -2446,6 +2642,9 @@ public partial class MainWindow : Window
|
||||
LaunchButton.IsEnabled = GameList.SelectedItem is GameEntry;
|
||||
}
|
||||
|
||||
GameSettingsButton.IsEnabled =
|
||||
GameList.SelectedItem is GameEntry game &&
|
||||
!string.IsNullOrWhiteSpace(game.TitleId);
|
||||
OpenFileButton.IsEnabled = !_isRunning;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,74 @@ public sealed class PerGameSettings
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes values that already match the global configuration so the
|
||||
/// remaining object contains only effective per-game overrides.
|
||||
/// </summary>
|
||||
internal void RemoveInheritedValues(GuiSettings global)
|
||||
{
|
||||
if (string.Equals(LogLevel, global.LogLevel, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
LogLevel = null;
|
||||
}
|
||||
|
||||
if (ImportTraceLimit == global.ImportTraceLimit)
|
||||
{
|
||||
ImportTraceLimit = null;
|
||||
}
|
||||
|
||||
if (StrictDynlibResolution == global.StrictDynlibResolution)
|
||||
{
|
||||
StrictDynlibResolution = null;
|
||||
}
|
||||
|
||||
if (LogToFile == global.LogToFile)
|
||||
{
|
||||
LogToFile = null;
|
||||
}
|
||||
|
||||
if (string.Equals(WindowMode, global.WindowMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WindowMode = null;
|
||||
}
|
||||
|
||||
if (string.Equals(Resolution, global.Resolution, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Resolution = null;
|
||||
}
|
||||
|
||||
if (DisplayIndex == global.DisplayIndex)
|
||||
{
|
||||
DisplayIndex = null;
|
||||
}
|
||||
|
||||
if (RefreshRate == global.RefreshRate)
|
||||
{
|
||||
RefreshRate = null;
|
||||
}
|
||||
|
||||
if (string.Equals(ScalingMode, global.ScalingMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ScalingMode = null;
|
||||
}
|
||||
|
||||
if (VSync == global.VSync)
|
||||
{
|
||||
VSync = null;
|
||||
}
|
||||
|
||||
if (string.Equals(HdrMode, global.HdrMode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HdrMode = null;
|
||||
}
|
||||
|
||||
if (EnvironmentToggles is { } environmentToggles &&
|
||||
EnvironmentEntriesEqual(environmentToggles, global.EnvironmentToggles))
|
||||
{
|
||||
EnvironmentToggles = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(string titleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(titleId))
|
||||
@@ -130,6 +198,35 @@ public sealed class PerGameSettings
|
||||
|
||||
return trimmed.Length == 0 ? "UNKNOWN" : trimmed;
|
||||
}
|
||||
|
||||
private static bool EnvironmentEntriesEqual(
|
||||
IEnumerable<string> left,
|
||||
IEnumerable<string> right) =>
|
||||
NormalizeEnvironmentEntries(left).SetEquals(NormalizeEnvironmentEntries(right));
|
||||
|
||||
private static HashSet<string> NormalizeEnvironmentEntries(IEnumerable<string> entries)
|
||||
{
|
||||
var normalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var parts = entry.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0 || string.IsNullOrWhiteSpace(parts[0]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parts.Length == 2 && parts[1] == "0")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(parts.Length == 2 && parts[1] != "1"
|
||||
? $"{parts[0]}={parts[1]}"
|
||||
: parts[0]);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record EffectiveLaunchSettings(
|
||||
|
||||
@@ -1,426 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
public sealed class PerGameSettingsDialog : Window
|
||||
{
|
||||
private static readonly string[] LogLevels =
|
||||
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
|
||||
private static readonly string[] WindowModes = { "Windowed", "Borderless", "Exclusive" };
|
||||
private static readonly string[] ScalingModes = { "Fit", "Cover", "Stretch", "Integer" };
|
||||
private static readonly string[] HdrModes = { "Auto", "On", "Off" };
|
||||
|
||||
private static readonly string[] EnvToggles =
|
||||
{
|
||||
"SHARPEMU_BTHID_UNAVAILABLE",
|
||||
"SHARPEMU_DISABLE_IMPORT_LOOP_GUARD",
|
||||
"SHARPEMU_WRITABLE_APP0",
|
||||
"SHARPEMU_VK_VALIDATION",
|
||||
"SHARPEMU_DUMP_SPIRV",
|
||||
"SHARPEMU_LOG_DIRECT_MEMORY",
|
||||
"SHARPEMU_LOG_IO",
|
||||
"SHARPEMU_LOG_NP",
|
||||
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
|
||||
};
|
||||
|
||||
private readonly string _titleId;
|
||||
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
|
||||
private bool _updatingHostDisplayOptions;
|
||||
|
||||
private readonly SettingRow _logLevelRow;
|
||||
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
|
||||
|
||||
private readonly SettingRow _traceRow;
|
||||
private readonly NumericUpDown _trace = new()
|
||||
{
|
||||
Minimum = 0, Maximum = 4096, Increment = 16, Width = 160, FormatString = "0",
|
||||
};
|
||||
|
||||
private readonly SettingRow _strictRow;
|
||||
private readonly ToggleSwitch _strict = new();
|
||||
|
||||
private readonly SettingRow _logToFileRow;
|
||||
private readonly ToggleSwitch _logToFile = new();
|
||||
|
||||
private readonly SettingRow _windowModeRow;
|
||||
private readonly ComboBox _windowMode = new() { ItemsSource = WindowModes, Width = 160 };
|
||||
|
||||
private readonly SettingRow _resolutionRow;
|
||||
private readonly ComboBox _resolution = new() { Width = 160 };
|
||||
|
||||
private readonly SettingRow _displayIndexRow;
|
||||
private readonly ComboBox _displayIndex = new() { Width = 240 };
|
||||
|
||||
private readonly SettingRow _refreshRateRow;
|
||||
private readonly ComboBox _refreshRate = new() { Width = 160 };
|
||||
|
||||
private readonly SettingRow _scalingModeRow;
|
||||
private readonly ComboBox _scalingMode = new() { ItemsSource = ScalingModes, Width = 160 };
|
||||
|
||||
private readonly SettingRow _vsyncRow;
|
||||
private readonly ToggleSwitch _vsync = new();
|
||||
|
||||
private readonly SettingRow _hdrModeRow;
|
||||
private readonly ComboBox _hdrMode = new() { ItemsSource = HdrModes, Width = 160 };
|
||||
|
||||
private readonly SettingRow _envRow;
|
||||
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
|
||||
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
|
||||
|
||||
public PerGameSettingsDialog(string titleId, string displayName, GuiSettings global)
|
||||
{
|
||||
_titleId = titleId;
|
||||
var loc = Localization.Instance;
|
||||
|
||||
Title = loc.Format("PerGame.Title", displayName, titleId);
|
||||
Width = 520;
|
||||
MaxHeight = 720;
|
||||
SizeToContent = SizeToContent.Height;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
CanResize = false;
|
||||
|
||||
Background = new SolidColorBrush(Color.Parse("#0D1017"));
|
||||
|
||||
_strict.OnContent = _logToFile.OnContent = _vsync.OnContent = loc.Get("Common.On");
|
||||
_strict.OffContent = _logToFile.OffContent = _vsync.OffContent = loc.Get("Common.Off");
|
||||
|
||||
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
|
||||
_traceRow = Row(loc.Get("Options.TraceImports.Label"), loc.Get("Options.TraceImports.Desc"), _trace);
|
||||
_strictRow = Row(loc.Get("Options.Strict.Label"), loc.Get("Options.Strict.Desc"), _strict);
|
||||
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
|
||||
_windowModeRow = Row(loc.Get("Options.WindowMode.Label"), loc.Get("Options.WindowMode.Desc"), _windowMode);
|
||||
_resolutionRow = Row(loc.Get("Options.Resolution.Label"), loc.Get("Options.Resolution.Desc"), _resolution);
|
||||
_displayIndexRow = Row(loc.Get("Options.Display.Label"), loc.Get("Options.Display.Desc"), _displayIndex);
|
||||
_refreshRateRow = Row(loc.Get("Options.RefreshRate.Label"), loc.Get("Options.RefreshRate.Desc"), _refreshRate);
|
||||
_scalingModeRow = Row(loc.Get("Options.Scaling.Label"), loc.Get("Options.Scaling.Desc"), _scalingMode);
|
||||
_vsyncRow = Row(loc.Get("Options.VSync.Label"), loc.Get("Options.VSync.Desc"), _vsync);
|
||||
_hdrModeRow = Row(loc.Get("Options.Hdr.Label"), loc.Get("Options.Hdr.Desc"), _hdrMode);
|
||||
_envRow = new SettingRow
|
||||
{
|
||||
Label = loc.Get("PerGame.EnvToggles.Label"),
|
||||
Description = loc.Get("PerGame.EnvToggles.Desc"),
|
||||
ShowOverride = true,
|
||||
};
|
||||
|
||||
foreach (var name in EnvToggles)
|
||||
{
|
||||
var box = new ToggleSwitch { OnContent = name, OffContent = name };
|
||||
_envBoxes.Add((name, box));
|
||||
_envList.Children.Add(box);
|
||||
}
|
||||
|
||||
var general = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
|
||||
general.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
|
||||
general.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
|
||||
general.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
|
||||
|
||||
var graphics = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
|
||||
graphics.Children.Add(Card(
|
||||
loc.Get("Options.Section.Display"),
|
||||
_windowModeRow,
|
||||
_resolutionRow,
|
||||
_displayIndexRow,
|
||||
_refreshRateRow,
|
||||
_scalingModeRow,
|
||||
_vsyncRow,
|
||||
_hdrModeRow));
|
||||
|
||||
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = loc.Get("PerGame.InheritNote"),
|
||||
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
|
||||
FontSize = 12,
|
||||
});
|
||||
content.Children.Add(new TabControl
|
||||
{
|
||||
ItemsSource = new[]
|
||||
{
|
||||
new TabItem { Header = loc.Get("PerGame.Tab.General"), Content = general },
|
||||
new TabItem { Header = loc.Get("PerGame.Tab.Graphics"), Content = graphics },
|
||||
},
|
||||
});
|
||||
|
||||
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
|
||||
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
|
||||
save.Click += (_, _) => { Persist(); Close(); };
|
||||
cancel.Click += (_, _) => Close();
|
||||
|
||||
var buttonBar = new Border
|
||||
{
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#8B94A7")) { Opacity = 0.25 },
|
||||
BorderThickness = new Thickness(0, 1, 0, 0),
|
||||
Padding = new(16),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Children = { cancel, save },
|
||||
},
|
||||
};
|
||||
|
||||
var root = new Grid { RowDefinitions = new RowDefinitions("*,Auto") };
|
||||
var scroller = new ScrollViewer { Content = content };
|
||||
Grid.SetRow(scroller, 0);
|
||||
Grid.SetRow(buttonBar, 1);
|
||||
root.Children.Add(scroller);
|
||||
root.Children.Add(buttonBar);
|
||||
Content = root;
|
||||
|
||||
_displayIndex.SelectionChanged += (_, _) => OnHostDisplayChanged();
|
||||
_resolution.SelectionChanged += (_, _) => OnHostResolutionChanged();
|
||||
LoadValues(global);
|
||||
_envRow.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == SettingRow.IsOverriddenProperty)
|
||||
{
|
||||
_envList.IsEnabled = _envRow.IsOverridden;
|
||||
}
|
||||
};
|
||||
_envList.IsEnabled = _envRow.IsOverridden;
|
||||
}
|
||||
|
||||
private static SettingRow Row(string label, string description, Control value) => new()
|
||||
{
|
||||
Label = label,
|
||||
Description = description,
|
||||
ShowOverride = true,
|
||||
Content = value,
|
||||
};
|
||||
|
||||
private static Border Card(string title, params Control[] rows)
|
||||
{
|
||||
var stack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 14 };
|
||||
stack.Children.Add(new TextBlock { Text = title, Classes = { "sectionTitle" } });
|
||||
foreach (var row in rows)
|
||||
{
|
||||
stack.Children.Add(row);
|
||||
}
|
||||
|
||||
var card = new Border { Child = stack };
|
||||
card.Classes.Add("card");
|
||||
return card;
|
||||
}
|
||||
|
||||
private void LoadValues(GuiSettings global)
|
||||
{
|
||||
var existing = PerGameSettings.Load(_titleId);
|
||||
var displayIndex = Math.Max(0, existing?.DisplayIndex ?? global.DisplayIndex);
|
||||
var resolution = existing?.Resolution ?? global.Resolution;
|
||||
var refreshRate = Math.Clamp(existing?.RefreshRate ?? global.RefreshRate, 0, 1000);
|
||||
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
_hostDisplays = HostDisplayOptions.BuildDisplays(HostDisplayCatalog.Query(), displayIndex);
|
||||
_displayIndex.ItemsSource = _hostDisplays;
|
||||
var display = HostDisplayOptions.SelectDisplay(_hostDisplays, displayIndex);
|
||||
_displayIndex.SelectedItem = display;
|
||||
PopulateHostModes(display, resolution, refreshRate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = false;
|
||||
}
|
||||
|
||||
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
|
||||
_trace.Value = global.ImportTraceLimit;
|
||||
_strict.IsChecked = global.StrictDynlibResolution;
|
||||
_logToFile.IsChecked = global.LogToFile;
|
||||
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, global.WindowMode, "Windowed");
|
||||
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, global.ScalingMode, "Fit");
|
||||
_vsync.IsChecked = global.VSync;
|
||||
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, global.HdrMode, "Auto");
|
||||
foreach (var (name, box) in _envBoxes)
|
||||
{
|
||||
box.IsChecked = IsEnvironmentEnabled(global.EnvironmentToggles, name, defaultValue: false);
|
||||
}
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.LogLevel is { } level && Array.IndexOf(LogLevels, level) >= 0)
|
||||
{
|
||||
_logLevelRow.IsOverridden = true;
|
||||
_logLevel.SelectedItem = level;
|
||||
}
|
||||
|
||||
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
|
||||
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
|
||||
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
|
||||
if (existing.WindowMode is { } windowMode && WindowModes.Contains(windowMode, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_windowModeRow.IsOverridden = true;
|
||||
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, windowMode, "Windowed");
|
||||
}
|
||||
if (existing.Resolution is not null)
|
||||
{
|
||||
_resolutionRow.IsOverridden = true;
|
||||
}
|
||||
if (existing.DisplayIndex is not null)
|
||||
{
|
||||
_displayIndexRow.IsOverridden = true;
|
||||
}
|
||||
if (existing.RefreshRate is not null)
|
||||
{
|
||||
_refreshRateRow.IsOverridden = true;
|
||||
}
|
||||
if (existing.ScalingMode is { } scalingMode && ScalingModes.Contains(scalingMode, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_scalingModeRow.IsOverridden = true;
|
||||
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, scalingMode, "Fit");
|
||||
}
|
||||
if (existing.VSync is { } vsync)
|
||||
{
|
||||
_vsyncRow.IsOverridden = true;
|
||||
_vsync.IsChecked = vsync;
|
||||
}
|
||||
if (existing.HdrMode is { } hdrMode && HdrModes.Contains(hdrMode, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_hdrModeRow.IsOverridden = true;
|
||||
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, hdrMode, "Auto");
|
||||
}
|
||||
if (existing.EnvironmentToggles is { } env)
|
||||
{
|
||||
_envRow.IsOverridden = true;
|
||||
foreach (var (name, box) in _envBoxes)
|
||||
{
|
||||
box.IsChecked = IsEnvironmentEnabled(env, name, defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ChoiceOrDefault(string[] choices, string? value, string fallback) =>
|
||||
choices.FirstOrDefault(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
|
||||
|
||||
private void OnHostDisplayChanged()
|
||||
{
|
||||
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
PopulateHostModes(
|
||||
display,
|
||||
_resolution.SelectedItem as string ?? "1920x1080",
|
||||
SelectedRefreshRate());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHostResolutionChanged()
|
||||
{
|
||||
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedRefreshRate = SelectedRefreshRate();
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateHostModes(
|
||||
HostDisplayOption display,
|
||||
string selectedResolution,
|
||||
int selectedRefreshRate)
|
||||
{
|
||||
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
|
||||
_resolution.ItemsSource = resolutions;
|
||||
_resolution.SelectedItem = resolutions.FirstOrDefault(resolution =>
|
||||
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
|
||||
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||
}
|
||||
|
||||
private void PopulateRefreshRates(
|
||||
HostDisplayOption display,
|
||||
string? resolution,
|
||||
int selectedRefreshRate)
|
||||
{
|
||||
var rates = HostDisplayOptions.BuildRefreshRates(
|
||||
display,
|
||||
resolution,
|
||||
selectedRefreshRate,
|
||||
Localization.Instance.Get("Options.RefreshRate.Automatic"));
|
||||
_refreshRate.ItemsSource = rates;
|
||||
_refreshRate.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
|
||||
}
|
||||
|
||||
private int SelectedRefreshRate() =>
|
||||
_refreshRate.SelectedItem is HostRefreshRateOption refreshRate ? refreshRate.Value : 0;
|
||||
|
||||
private void Persist()
|
||||
{
|
||||
var settings = new PerGameSettings
|
||||
{
|
||||
LogLevel = _logLevelRow.IsOverridden ? _logLevel.SelectedItem as string : null,
|
||||
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
|
||||
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
|
||||
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
|
||||
WindowMode = _windowModeRow.IsOverridden ? _windowMode.SelectedItem as string : null,
|
||||
Resolution = _resolutionRow.IsOverridden ? _resolution.SelectedItem as string : null,
|
||||
DisplayIndex = _displayIndexRow.IsOverridden && _displayIndex.SelectedItem is HostDisplayOption display
|
||||
? display.Index
|
||||
: null,
|
||||
RefreshRate = _refreshRateRow.IsOverridden ? SelectedRefreshRate() : null,
|
||||
ScalingMode = _scalingModeRow.IsOverridden ? _scalingMode.SelectedItem as string : null,
|
||||
VSync = _vsyncRow.IsOverridden ? _vsync.IsChecked == true : null,
|
||||
HdrMode = _hdrModeRow.IsOverridden ? _hdrMode.SelectedItem as string : null,
|
||||
EnvironmentToggles = _envRow.IsOverridden ? BuildEnvironmentEntries() : null,
|
||||
};
|
||||
settings.Save(_titleId);
|
||||
}
|
||||
|
||||
private List<string> BuildEnvironmentEntries()
|
||||
{
|
||||
return _envBoxes
|
||||
.Where(entry => entry.Box.IsChecked == true)
|
||||
.Select(entry => entry.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool IsEnvironmentEnabled(
|
||||
IEnumerable<string> entries,
|
||||
string name,
|
||||
bool defaultValue)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(entry, name + "=1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Presenters;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
@@ -18,17 +16,9 @@ public sealed class SettingRow : ContentControl
|
||||
public static readonly StyledProperty<string?> DescriptionProperty =
|
||||
AvaloniaProperty.Register<SettingRow, string?>(nameof(Description));
|
||||
|
||||
public static readonly StyledProperty<bool> ShowOverrideProperty =
|
||||
AvaloniaProperty.Register<SettingRow, bool>(nameof(ShowOverride));
|
||||
|
||||
public static readonly StyledProperty<bool> IsOverriddenProperty =
|
||||
AvaloniaProperty.Register<SettingRow, bool>(
|
||||
nameof(IsOverridden), defaultBindingMode: BindingMode.TwoWay);
|
||||
|
||||
public static readonly StyledProperty<FontFamily?> LabelFontFamilyProperty =
|
||||
AvaloniaProperty.Register<SettingRow, FontFamily?>(nameof(LabelFontFamily));
|
||||
|
||||
private ContentPresenter? _slot;
|
||||
private TextBlock? _label;
|
||||
|
||||
public string? Label
|
||||
@@ -43,18 +33,6 @@ public sealed class SettingRow : ContentControl
|
||||
set => SetValue(DescriptionProperty, value);
|
||||
}
|
||||
|
||||
public bool ShowOverride
|
||||
{
|
||||
get => GetValue(ShowOverrideProperty);
|
||||
set => SetValue(ShowOverrideProperty, value);
|
||||
}
|
||||
|
||||
public bool IsOverridden
|
||||
{
|
||||
get => GetValue(IsOverriddenProperty);
|
||||
set => SetValue(IsOverriddenProperty, value);
|
||||
}
|
||||
|
||||
public FontFamily? LabelFontFamily
|
||||
{
|
||||
get => GetValue(LabelFontFamilyProperty);
|
||||
@@ -64,20 +42,14 @@ public sealed class SettingRow : ContentControl
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
_slot = e.NameScope.Find<ContentPresenter>("PART_Slot");
|
||||
_label = e.NameScope.Find<TextBlock>("PART_Label");
|
||||
UpdateSlotEnabled();
|
||||
UpdateLabelFont();
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == ShowOverrideProperty || change.Property == IsOverriddenProperty)
|
||||
{
|
||||
UpdateSlotEnabled();
|
||||
}
|
||||
else if (change.Property == LabelFontFamilyProperty)
|
||||
if (change.Property == LabelFontFamilyProperty)
|
||||
{
|
||||
UpdateLabelFont();
|
||||
}
|
||||
@@ -90,12 +62,4 @@ public sealed class SettingRow : ContentControl
|
||||
_label.FontFamily = family;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSlotEnabled()
|
||||
{
|
||||
if (_slot is not null)
|
||||
{
|
||||
_slot.IsEnabled = !ShowOverride || IsOverridden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,23 @@ Shared launcher button variants and page switcher styles.
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.iconGhost">
|
||||
<Setter Property="Width" Value="32" />
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.iconGhost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ToggleButton.ghost">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
|
||||
@@ -19,6 +19,12 @@ Window chrome button sizing and interaction states.
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.windowChromeGlyph">
|
||||
<Setter Property="IsHitTestVisible" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.windowCloseGlyph">
|
||||
<Setter Property="Margin" Value="0,-1,0,1" />
|
||||
</Style>
|
||||
<Style Selector="Button.windowChrome:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Contextual per-game options reveal, actions, and selected-game motion.
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style Selector="TextBlock.gameStatLabel">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="Opacity" Value="0.75" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.gameStatValue">
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Panel.backdropLayer">
|
||||
<Setter Property="RenderTransform" Value="translateY(0px) scale(1)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Panel.carouselHost">
|
||||
<Setter Property="RenderTransform" Value="translateY(0px) scale(1)" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost">
|
||||
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Grid.gameOptionsOverlay">
|
||||
<Setter Property="RenderTransform" Value="translateY(96px)" />
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Panel.backdropLayer.gameOptionsOpen">
|
||||
<Setter Property="RenderTransform" Value="translateY(-92px) scale(1.04)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Panel.carouselHost.gameOptionsOpen">
|
||||
<Setter Property="RenderTransform" Value="translateY(-18px) scale(1)" />
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost.gameOptionsOpen">
|
||||
<Setter Property="RenderTransform" Value="translateY(-28px)" />
|
||||
<Setter Property="Opacity" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Grid.gameOptionsOverlay.gameOptionsOpen">
|
||||
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.playButton">
|
||||
<Setter Property="MinHeight" Value="54" />
|
||||
<Setter Property="MinWidth" Value="200" />
|
||||
<Setter Property="Padding" Value="34,13" />
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="Foreground" Value="#111111" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.playButton:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.playButton.danger">
|
||||
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.playButton.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource DangerHoverBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsCircle, ToggleButton.optionsCircle">
|
||||
<Setter Property="Width" Value="54" />
|
||||
<Setter Property="Height" Value="54" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="27" />
|
||||
<Setter Property="Background" Value="#12FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsCircle:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||
ToggleButton.optionsCircle:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||
ToggleButton.optionsCircle:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="#24FFFFFF" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsCircle.compact, ToggleButton.optionsCircle.compact">
|
||||
<Setter Property="Width" Value="40" />
|
||||
<Setter Property="Height" Value="40" />
|
||||
<Setter Property="CornerRadius" Value="20" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost">
|
||||
<Setter Property="Spacing" Value="18" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.selectedGameTitle">
|
||||
<Setter Property="FontSize" Value="42" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost.gridLayout">
|
||||
<Setter Property="Spacing" Value="12" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost.gridLayout TextBlock.selectedGameTitle">
|
||||
<Setter Property="FontSize" Value="26" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.selectedDetailsDivider">
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="MinWidth" Value="440" />
|
||||
<Setter Property="Margin" Value="0,0,0,18" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="Background">
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,0%">
|
||||
<GradientStop Offset="0" Color="#00FFFFFF" />
|
||||
<GradientStop Offset="0.06" Color="#2EFFFFFF" />
|
||||
<GradientStop Offset="0.55" Color="#14FFFFFF" />
|
||||
<GradientStop Offset="1" Color="#00FFFFFF" />
|
||||
</LinearGradientBrush>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.playButton">
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
<Setter Property="MinWidth" Value="168" />
|
||||
<Setter Property="Padding" Value="28,10" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.optionsCircle,
|
||||
StackPanel.selectedDetailsHost.gridLayout ToggleButton.optionsCircle">
|
||||
<Setter Property="Width" Value="44" />
|
||||
<Setter Property="Height" Value="44" />
|
||||
<Setter Property="CornerRadius" Value="22" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameOptionsLaunch">
|
||||
<Setter Property="Width" Value="220" />
|
||||
<Setter Property="Height" Value="46" />
|
||||
<Setter Property="Padding" Value="20,0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Background" Value="White" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="#111111" />
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameOptionsLaunch /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="BoxShadow" Value="0 0 0 0 Transparent" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameOptionsLaunch:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="#EAF3FF" />
|
||||
<Setter Property="Foreground" Value="#111111" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameAction">
|
||||
<Setter Property="Width" Value="250" />
|
||||
<Setter Property="Height" Value="48" />
|
||||
<Setter Property="Padding" Value="16,0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="#0FFFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#14FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameAction:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="#1AFFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#28FFFFFF" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameAction.dangerAction">
|
||||
<Setter Property="Foreground" Value="{StaticResource DangerBrush}" />
|
||||
<Setter Property="BorderBrush" Value="#48FF646E" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.gameAction.dangerAction:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="#1FFF646E" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource DangerBrush}" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -9,8 +9,25 @@ Cover-art library item states and motion.
|
||||
<Style Selector="ListBox.tileGrid">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="4,0,28,0" />
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Hidden" />
|
||||
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
|
||||
<Setter Property="ItemsPanel">
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.tileGrid.gridLayout">
|
||||
<Setter Property="Padding" Value="4,0,14,0" />
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
|
||||
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
|
||||
<Setter Property="ItemsPanel">
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem">
|
||||
<Setter Property="Width" Value="160" />
|
||||
@@ -35,6 +52,25 @@ Cover-art library item states and motion.
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<!-- The title rides along in the item template so both layouts share one
|
||||
template. The rail hides it because the selected game already names
|
||||
itself in the details below the covers. -->
|
||||
<Style Selector="TextBlock.libraryTileName">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LineHeight" Value="16" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid.gridLayout TextBlock.libraryTileName">
|
||||
<Setter Property="IsVisible" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid.gridLayout ListBoxItem">
|
||||
<Setter Property="Height" Value="200" />
|
||||
<Setter Property="Margin" Value="0,6,12,10" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Top" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover,
|
||||
ListBox.tileGrid ListBoxItem:selected">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
|
||||
@@ -1,108 +1,12 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Options page navigation, section transitions and content layout
|
||||
Options page section transitions and content layout
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:SharpEmu.GUI"
|
||||
xmlns:settingsControls="using:SharpEmu.GUI.Controls.Settings">
|
||||
<Style Selector="Border.optionsNavSurface">
|
||||
<Setter Property="Width" Value="244" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="VerticalAlignment" Value="Top" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.optionsNavIndicator">
|
||||
<Setter Property="Height" Value="54" />
|
||||
<Setter Property="Margin" Value="0,3,0,0" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Background" Value="#13FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E6FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="2" />
|
||||
<Setter Property="BoxShadow" Value="0 8 24 0 #24000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav">
|
||||
<Setter Property="Height" Value="61" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="Padding" Value="16,0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="FocusAdorner" Value="{x:Null}" />
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||
Button.optionsNav:focus-visible /template/ ContentPresenter#PART_ContentPresenter,
|
||||
Button.optionsNav.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pressed">
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.optionsNavIcon">
|
||||
<Setter Property="Width" Value="20" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
<Setter Property="Padding" Value="0,0,0,5" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Foreground"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.optionsNavLabel">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="LineHeight" Value="20" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Padding" Value="0,0,0,4" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Foreground"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pointerover TextBlock.optionsNavIcon,
|
||||
Button.optionsNav:focus-visible TextBlock.optionsNavIcon,
|
||||
Button.optionsNav.active TextBlock.optionsNavIcon">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pointerover TextBlock.optionsNavLabel,
|
||||
Button.optionsNav:focus-visible TextBlock.optionsNavLabel,
|
||||
Button.optionsNav.active TextBlock.optionsNavLabel">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.optionsContentSurface">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
@@ -147,11 +51,6 @@ Options page navigation, section transitions and content layout
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="local|SettingRow.optionRow:focus-within /template/ Border#PART_RowSurface">
|
||||
<Setter Property="Background" Value="#0DFFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#22FFFFFF" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ComboBox.optionValue, settingsControls|SplitNumericUpDown.optionValue, TextBox.optionValue">
|
||||
<Setter Property="Width" Value="190" />
|
||||
<Setter Property="MinHeight" Value="44" />
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Shared navigation for global and per-game options
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style Selector="Border.optionsNavSurface">
|
||||
<Setter Property="Width" Value="244" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="VerticalAlignment" Value="Top" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.optionsNavIndicator">
|
||||
<Setter Property="Height" Value="54" />
|
||||
<Setter Property="Margin" Value="0,3,0,0" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Background" Value="#13FFFFFF" />
|
||||
<Setter Property="BorderBrush" Value="#E6FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="2" />
|
||||
<Setter Property="BoxShadow" Value="0 8 24 0 #24000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav">
|
||||
<Setter Property="Height" Value="61" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="Padding" Value="16,0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="FocusAdorner" Value="{x:Null}" />
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||
Button.optionsNav:focus-visible /template/ ContentPresenter#PART_ContentPresenter,
|
||||
Button.optionsNav.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pressed">
|
||||
<Setter Property="RenderTransform" Value="none" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.optionsNavIcon">
|
||||
<Setter Property="Width" Value="20" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
<Setter Property="Padding" Value="0,0,0,5" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Foreground"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.optionsNavLabel">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="LineHeight" Value="20" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Padding" Value="0,0,0,4" />
|
||||
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Foreground"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.optionsNav:pointerover TextBlock.optionsNavIcon,
|
||||
Button.optionsNav:focus-visible TextBlock.optionsNavIcon,
|
||||
Button.optionsNav.active TextBlock.optionsNavIcon,
|
||||
Button.optionsNav:pointerover TextBlock.optionsNavLabel,
|
||||
Button.optionsNav:focus-visible TextBlock.optionsNavLabel,
|
||||
Button.optionsNav.active TextBlock.optionsNavLabel">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -12,11 +12,22 @@ Control theme for the shared launcher settings row.
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border x:Name="PART_RowSurface"
|
||||
Classes="settingRowSurface"
|
||||
Background="#06FFFFFF"
|
||||
BorderBrush="#0AFFFFFF"
|
||||
BorderThickness="1"
|
||||
CornerRadius="14"
|
||||
Padding="18,12">
|
||||
<Border.Transitions>
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
<BrushTransition Property="BorderBrush"
|
||||
Duration="0:0:0.18"
|
||||
Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Border.Transitions>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="3" Margin="0,0,18,0">
|
||||
<TextBlock x:Name="PART_Label"
|
||||
@@ -28,7 +39,7 @@ Control theme for the shared launcher settings row.
|
||||
<TextBlock Text="{TemplateBinding Description}"
|
||||
FontSize="11"
|
||||
FontWeight="Normal"
|
||||
Foreground="{StaticResource MutedBrush}"
|
||||
Foreground="{StaticResource SettingsDescriptionBrush}"
|
||||
LineHeight="16"
|
||||
MaxWidth="690"
|
||||
HorizontalAlignment="Left"
|
||||
@@ -37,20 +48,9 @@ Control theme for the shared launcher settings row.
|
||||
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
|
||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="12"
|
||||
VerticalAlignment="Center">
|
||||
<ToggleSwitch OnContent="Override"
|
||||
OffContent="Override"
|
||||
MinWidth="0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{TemplateBinding ShowOverride}"
|
||||
IsChecked="{Binding IsOverridden, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
|
||||
<ContentPresenter x:Name="PART_Slot"
|
||||
Content="{TemplateBinding Content}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<ContentPresenter Grid.Column="1"
|
||||
Content="{TemplateBinding Content}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
|
||||
@@ -10,25 +10,23 @@ Shared colors and brushes used throughout the launcher.
|
||||
|
||||
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
|
||||
|
||||
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0" Color="#12151F" />
|
||||
<GradientStop Offset="0.55" Color="#0D1017" />
|
||||
<GradientStop Offset="1" Color="#0B0D14" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
|
||||
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
|
||||
<SolidColorBrush x:Key="ElevatedBrush" Color="#1B2230" />
|
||||
<SolidColorBrush x:Key="TextBrush" Color="#E8ECF4" />
|
||||
<SolidColorBrush x:Key="MutedBrush" Color="#8B94A7" />
|
||||
<SolidColorBrush x:Key="FaintBrush" Color="#5A6478" />
|
||||
<SolidColorBrush x:Key="BgBrush" Color="#0C0C0C" />
|
||||
<SolidColorBrush x:Key="ChromeBrush" Color="#070707" />
|
||||
<SolidColorBrush x:Key="CardBrush" Color="#161616" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#12FFFFFF" />
|
||||
<SolidColorBrush x:Key="ElevatedBrush" Color="#1E1E1E" />
|
||||
<SolidColorBrush x:Key="GameOptionsMenuBrush" Color="#1B1B1B" />
|
||||
<SolidColorBrush x:Key="TextBrush" Color="#F7F8FB" />
|
||||
<SolidColorBrush x:Key="SecondaryTextBrush" Color="#C0C5CF" />
|
||||
<SolidColorBrush x:Key="MutedBrush" Color="#777F8E" />
|
||||
<SolidColorBrush x:Key="SettingsDescriptionBrush" Color="#8A8A8A" />
|
||||
<SolidColorBrush x:Key="FaintBrush" Color="#4D5461" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#7C5CFC" />
|
||||
<SolidColorBrush x:Key="AccentHoverBrush" Color="#8F73FF" />
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
||||
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
|
||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
||||
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#FF646E" />
|
||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#FF858D" />
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#66F2A3" />
|
||||
<SolidColorBrush x:Key="InfoBrush" Color="#64A7FF" />
|
||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#0BFFFFFF" />
|
||||
<SolidColorBrush x:Key="TileSelectedBrush" Color="#12FFFFFF" />
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
internal readonly record struct WindowMaximizeButtonState(
|
||||
string Glyph,
|
||||
string ToolTip,
|
||||
string AutomationName);
|
||||
@@ -330,6 +330,11 @@ public static partial class AgcExports
|
||||
private static long _labelProducerSequence;
|
||||
private static readonly object _labelProducerGate = new();
|
||||
private static readonly List<LabelProducerTrace> _labelProducers = [];
|
||||
private const int LabelProducerSoftBound = 4096;
|
||||
// Raised when a compaction pass frees nothing because every record is still
|
||||
// active, so registration does not rescan the whole list on every add while
|
||||
// a queue is suspended. Reset once compaction can make progress again.
|
||||
private static int _labelProducerCompactionBound = LabelProducerSoftBound;
|
||||
private static readonly HashSet<(object Memory, ulong Address)>
|
||||
_tracedProducerlessWaits = new();
|
||||
private static long _shaderTranslationMissTraceCount;
|
||||
@@ -604,10 +609,20 @@ public static partial class AgcExports
|
||||
public uint DrawIndexOffset { get; set; }
|
||||
public bool PredicateSkip { get; set; }
|
||||
public string QueueName { get; set; } = "graphics";
|
||||
// Ident this queue's end-of-pipe completion interrupt is published under.
|
||||
// The graphics queue keeps 0; a compute queue takes the owner handle it
|
||||
// was submitted with, which is the same value the guest registers through
|
||||
// sceAgcDriverAddEqEvent.
|
||||
public ulong CompletionEventId { get; set; }
|
||||
public ulong ActiveSubmissionId { get; set; }
|
||||
public Queue<PendingSubmission> PendingSubmissions { get; } = new();
|
||||
public bool HasActiveSubmission { get; set; }
|
||||
public bool IsSuspended { get; set; }
|
||||
|
||||
// Set when parsing stops on an INDIRECT_BUFFER packet so the caller can
|
||||
// continue into the buffer it links to.
|
||||
public ulong PendingChainAddress { get; set; }
|
||||
public uint PendingChainDwords { get; set; }
|
||||
public ulong CompletionEventNotifiedSubmissionId { get; set; }
|
||||
public Dictionary<(uint Op, uint Register), uint> FramePacketCounts { get; } = new();
|
||||
public uint FramePacketCount { get; set; }
|
||||
@@ -3250,6 +3265,7 @@ public static partial class AgcExports
|
||||
!TryReadUInt64(ctx, packetAddress, out var commandAddress) ||
|
||||
!TryReadUInt32(ctx, packetAddress + 8, out var dwordCount))
|
||||
{
|
||||
TraceAgc($"agc.driver_submit_dcb_rejected packet=0x{packetAddress:X16}");
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
@@ -3262,10 +3278,9 @@ public static partial class AgcExports
|
||||
}
|
||||
}
|
||||
|
||||
if (tracePackets)
|
||||
{
|
||||
TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}");
|
||||
}
|
||||
TraceAgc(
|
||||
$"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} " +
|
||||
$"dwords={dwordCount} end=0x{commandAddress + ((ulong)dwordCount * sizeof(uint)):X16}");
|
||||
|
||||
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
|
||||
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
||||
@@ -3312,12 +3327,10 @@ public static partial class AgcExports
|
||||
}
|
||||
}
|
||||
|
||||
if (tracePackets)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.driver_submit_acb owner={ownerHandle} packet=0x{packetAddress:X16} " +
|
||||
$"addr=0x{commandAddress:X16} dwords={dwordCount}");
|
||||
}
|
||||
TraceAgc(
|
||||
$"agc.driver_submit_acb owner={ownerHandle} packet=0x{packetAddress:X16} " +
|
||||
$"addr=0x{commandAddress:X16} dwords={dwordCount} " +
|
||||
$"end=0x{commandAddress + ((ulong)dwordCount * sizeof(uint)):X16}");
|
||||
|
||||
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
|
||||
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
||||
@@ -3330,6 +3343,7 @@ public static partial class AgcExports
|
||||
}
|
||||
|
||||
queueState.QueueName = $"acb.compute[{ownerHandle}]";
|
||||
queueState.CompletionEventId = ownerHandle;
|
||||
EnqueueSubmittedDcb(
|
||||
ctx,
|
||||
gpuState,
|
||||
@@ -3520,33 +3534,47 @@ public static partial class AgcExports
|
||||
SubmittedDcbState state,
|
||||
ulong submissionId)
|
||||
{
|
||||
if (!ReferenceEquals(state, gpuState.Graphics) ||
|
||||
state.CompletionEventNotifiedSubmissionId == submissionId)
|
||||
if (state.CompletionEventNotifiedSubmissionId == submissionId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
state.CompletionEventNotifiedSubmissionId = submissionId;
|
||||
// Hardware raises an end-of-pipe interrupt for every submission on every
|
||||
// queue, so this is unconditional. It stays safe for titles that do not
|
||||
// want it because delivery is registration-gated: TriggerRegisteredEvents
|
||||
// only queues onto equeues that registered this exact ident through
|
||||
// sceAgcDriverAddEqEvent. Graphics keeps ident 0; a compute queue uses the
|
||||
// owner handle it was submitted under.
|
||||
var completionEventId = state.CompletionEventId;
|
||||
var isGraphics = ReferenceEquals(state, gpuState.Graphics);
|
||||
var queueName = state.QueueName;
|
||||
void TriggerCompletionEvents()
|
||||
{
|
||||
var triggered = KernelEventQueueCompatExports.TriggerRegisteredEvents(
|
||||
ident: 0,
|
||||
completionEventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
data: 0);
|
||||
if (_compatibilitySubmitCompletionEvent)
|
||||
completionEventId);
|
||||
// The broad fan-out wakes graphics registrations whose ident never
|
||||
// matches anything the driver publishes. That is a compatibility
|
||||
// guess rather than hardware behavior, so it stays opt-in and stays
|
||||
// on the graphics queue where it was measured.
|
||||
if (isGraphics && _compatibilitySubmitCompletionEvent)
|
||||
{
|
||||
triggered += KernelEventQueueCompatExports.TriggerRegisteredEventsDistinct(
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
}
|
||||
TraceAgc(
|
||||
$"agc.driver_submit_dcb completion submission={submissionId} " +
|
||||
$"queues={triggered}");
|
||||
$"agc.completion_event queue={queueName} submission={submissionId} " +
|
||||
$"event=0x{completionEventId:X} queues={triggered}");
|
||||
}
|
||||
|
||||
// A DCB is complete only after its translated Vulkan work and ordered
|
||||
// guest-memory writes have finished. Put the notification on that same
|
||||
// logical graphics queue instead of approximating completion with a
|
||||
// timer, which can wake Unity while its upload data is still stale.
|
||||
// A submission is complete only after its translated Vulkan work and
|
||||
// ordered guest-memory writes have finished. Put the notification on that
|
||||
// same logical queue instead of approximating completion with a timer or a
|
||||
// ThreadPool hop, either of which can only make the interrupt late and
|
||||
// reorder it against registration changes (and can wake Unity while its
|
||||
// upload data is still stale).
|
||||
if (GuestGpu.Current.SubmitOrderedGuestAction(
|
||||
TriggerCompletionEvents,
|
||||
$"agc submit completion {submissionId}") == 0)
|
||||
@@ -3574,33 +3602,72 @@ public static partial class AgcExports
|
||||
using var guestQueueScope = GuestGpu.Current.EnterGuestQueue(
|
||||
state.QueueName,
|
||||
state.ActiveSubmissionId);
|
||||
var windowByteCount = checked((int)(dwordCount * sizeof(uint)));
|
||||
var rented = GuestDataPool.Shared.Rent(windowByteCount);
|
||||
try
|
||||
// A submission is one link of a chain, not necessarily the whole stream:
|
||||
// when a title's command arena fills mid-frame it continues in a fresh
|
||||
// buffer and links the two with an INDIRECT_BUFFER packet, then submits
|
||||
// only the first link. Stopping at the end of the submitted window drops
|
||||
// every packet past the switch -- including the flip and the end-of-frame
|
||||
// completion labels the guest is waiting on.
|
||||
for (var chainDepth = 0; ; chainDepth++)
|
||||
{
|
||||
if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount)))
|
||||
if (chainDepth > MaxSubmittedChainDepth)
|
||||
{
|
||||
_dcbWindowBuffer = rented;
|
||||
_dcbWindowStart = commandAddress;
|
||||
_dcbWindowByteLength = windowByteCount;
|
||||
TraceAgc(
|
||||
$"agc.dcb_chain_depth_exceeded queue={state.QueueName} " +
|
||||
$"submission={state.ActiveSubmissionId} addr=0x{commandAddress:X16}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return ParseSubmittedDcbCore(
|
||||
ctx,
|
||||
gpuState,
|
||||
state,
|
||||
commandAddress,
|
||||
dwordCount,
|
||||
tracePackets);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dcbWindowBuffer = null;
|
||||
_dcbWindowByteLength = 0;
|
||||
GuestDataPool.Shared.Return(rented);
|
||||
state.PendingChainAddress = 0;
|
||||
state.PendingChainDwords = 0;
|
||||
var windowByteCount = checked((int)(dwordCount * sizeof(uint)));
|
||||
var rented = GuestDataPool.Shared.Rent(windowByteCount);
|
||||
bool suspended;
|
||||
try
|
||||
{
|
||||
if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount)))
|
||||
{
|
||||
_dcbWindowBuffer = rented;
|
||||
_dcbWindowStart = commandAddress;
|
||||
_dcbWindowByteLength = windowByteCount;
|
||||
}
|
||||
|
||||
suspended = ParseSubmittedDcbCore(
|
||||
ctx,
|
||||
gpuState,
|
||||
state,
|
||||
commandAddress,
|
||||
dwordCount,
|
||||
tracePackets);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dcbWindowBuffer = null;
|
||||
_dcbWindowByteLength = 0;
|
||||
GuestDataPool.Shared.Return(rented);
|
||||
}
|
||||
|
||||
if (suspended)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var chainAddress = state.PendingChainAddress;
|
||||
var chainDwords = state.PendingChainDwords;
|
||||
if (chainAddress == 0 || chainDwords == 0 || chainDwords > 1_000_000)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
commandAddress = chainAddress;
|
||||
dwordCount = chainDwords;
|
||||
}
|
||||
}
|
||||
|
||||
// Deep enough for a title that links one continuation buffer per frame,
|
||||
// shallow enough that a self-referencing chain cannot spin forever.
|
||||
private const int MaxSubmittedChainDepth = 64;
|
||||
|
||||
private static bool ParseSubmittedDcbCore(
|
||||
CpuContext ctx,
|
||||
SubmittedGpuState gpuState,
|
||||
@@ -3727,6 +3794,32 @@ public static partial class AgcExports
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op == ItIndirectBuffer &&
|
||||
length >= 4 &&
|
||||
TryReadUInt32(ctx, currentAddress + 4, out var chainLow) &&
|
||||
TryReadUInt32(ctx, currentAddress + 8, out var chainHigh) &&
|
||||
TryReadUInt32(ctx, currentAddress + 12, out var chainDwords))
|
||||
{
|
||||
var chainAddress = ((ulong)(chainHigh & 0xFFFFu) << 32) | chainLow;
|
||||
var chainLength = chainDwords & 0xFFFFFu;
|
||||
// Titles emit a zeroed INDIRECT_BUFFER as padding for a branch they
|
||||
// decided not to take. Only a populated one redirects the stream.
|
||||
if (chainAddress != 0 && chainLength != 0)
|
||||
{
|
||||
state.PendingChainAddress = chainAddress;
|
||||
state.PendingChainDwords = chainLength;
|
||||
TraceAgc(
|
||||
$"agc.dcb_chain queue={state.QueueName} " +
|
||||
$"submission={state.ActiveSubmissionId} " +
|
||||
$"packet=0x{currentAddress:X16} " +
|
||||
$"target=0x{chainAddress:X16} dwords={chainLength}");
|
||||
|
||||
// The link is a jump, not a call: whatever follows it in this
|
||||
// buffer is unreachable padding.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (op == ItNop &&
|
||||
register is RDrawReset or RAcbReset &&
|
||||
length >= 2)
|
||||
@@ -4398,9 +4491,21 @@ public static partial class AgcExports
|
||||
};
|
||||
lock (_labelProducerGate)
|
||||
{
|
||||
if (_labelProducers.Count >= 4096)
|
||||
if (_labelProducers.Count >= _labelProducerCompactionBound)
|
||||
{
|
||||
_labelProducers.RemoveRange(0, 1024);
|
||||
// Active producer records are synchronization state, not a
|
||||
// diagnostic cache. Removing one can hide an earlier
|
||||
// same-submission label write and make a valid in-stream fence
|
||||
// suspend forever. Compact only completed history; if all
|
||||
// records are active, correctness takes precedence over the
|
||||
// soft diagnostic bound.
|
||||
var removed = CompactCompletedEntries(
|
||||
_labelProducers,
|
||||
static candidate => candidate.Completed,
|
||||
targetCount: LabelProducerSoftBound * 3 / 4);
|
||||
_labelProducerCompactionBound = removed == 0
|
||||
? _labelProducers.Count * 2
|
||||
: LabelProducerSoftBound;
|
||||
}
|
||||
|
||||
_labelProducers.Add(producer);
|
||||
@@ -4421,6 +4526,36 @@ public static partial class AgcExports
|
||||
return producer;
|
||||
}
|
||||
|
||||
internal static int CompactCompletedEntries<T>(
|
||||
List<T> entries,
|
||||
Func<T, bool> isCompleted,
|
||||
int targetCount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ArgumentNullException.ThrowIfNull(isCompleted);
|
||||
targetCount = Math.Max(0, targetCount);
|
||||
|
||||
// Single order-preserving pass. Removing one-by-one would shift the
|
||||
// tail on every eviction, which is quadratic on a list this size and
|
||||
// runs while the label gate is held.
|
||||
var removable = entries.Count - targetCount;
|
||||
var removed = 0;
|
||||
var write = 0;
|
||||
for (var read = 0; read < entries.Count; read++)
|
||||
{
|
||||
if (removed < removable && isCompleted(entries[read]))
|
||||
{
|
||||
removed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
entries[write++] = entries[read];
|
||||
}
|
||||
|
||||
entries.RemoveRange(write, entries.Count - write);
|
||||
return removed;
|
||||
}
|
||||
|
||||
private static void CompleteLabelProducer(LabelProducerTrace? producer)
|
||||
{
|
||||
if (producer is null)
|
||||
@@ -6081,6 +6216,13 @@ public static partial class AgcExports
|
||||
GpuWaitRegistry.RecordProduced(
|
||||
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
|
||||
}
|
||||
else if (!wroteData && dataSelection is 1 or 2)
|
||||
{
|
||||
// See ApplySubmittedReleaseMem: a dropped label write strands
|
||||
// every waiter on this label permanently.
|
||||
ReportLabelWriteFailure(
|
||||
"release_mem_standard", destinationAddress, data, dataSelection);
|
||||
}
|
||||
|
||||
if (tracePacket)
|
||||
{
|
||||
@@ -6096,6 +6238,33 @@ public static partial class AgcExports
|
||||
writesGuestMemory ? writeLength : 0);
|
||||
}
|
||||
|
||||
private static long _labelWriteFailureCount;
|
||||
|
||||
/// <summary>
|
||||
/// Reports a GPU release-label write that could not reach guest memory.
|
||||
/// Rate-limited (first 16, then powers of two) because a wedged queue can
|
||||
/// retry, but never silenced: this is the difference between a diagnosable
|
||||
/// fault and a permanently suspended graphics queue with no explanation.
|
||||
/// </summary>
|
||||
private static void ReportLabelWriteFailure(
|
||||
string packet,
|
||||
ulong destinationAddress,
|
||||
ulong data,
|
||||
uint dataSelection)
|
||||
{
|
||||
var count = Interlocked.Increment(ref _labelWriteFailureCount);
|
||||
if (count > 16 && (count & (count - 1)) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] agc.label_write_failed packet={packet} " +
|
||||
$"dst=0x{destinationAddress:X16} data=0x{data:X16} " +
|
||||
$"data_sel={dataSelection} count={count} — a suspended WAIT_REG_MEM " +
|
||||
$"on this label can no longer be satisfied or deadlock-broken.");
|
||||
}
|
||||
|
||||
private static (uint Destination, uint DataSelection)
|
||||
DecodeStandardReleaseMemControl(uint control) =>
|
||||
(
|
||||
@@ -6156,6 +6325,15 @@ public static partial class AgcExports
|
||||
GpuWaitRegistry.RecordProduced(
|
||||
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
|
||||
}
|
||||
else if (!wroteData && dataSelection is 1 or 2)
|
||||
{
|
||||
// A label write that fails is not a benign miss: this packet
|
||||
// is the producer a suspended WAIT_REG_MEM is waiting for, and
|
||||
// RecordProduced above is skipped, so the deadlock breaker has
|
||||
// no value to replay either. The queue then never resumes.
|
||||
// Never let that happen quietly.
|
||||
ReportLabelWriteFailure("release_mem", destinationAddress, data, dataSelection);
|
||||
}
|
||||
|
||||
if (tracePacket)
|
||||
{
|
||||
@@ -13582,6 +13760,58 @@ public static partial class AgcExports
|
||||
return ReturnPointer(ctx, cmd);
|
||||
}
|
||||
|
||||
// Matches the 4-dword INDIRECT_BUFFER packet CbBranch writes below.
|
||||
[SysAbiExport(
|
||||
Nid = "uZW-mqsxkrM",
|
||||
ExportName = "sceAgcCbBranchGetSize",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int CbBranchGetSize(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 4u * sizeof(uint);
|
||||
return (int)ctx[CpuRegister.Rax];
|
||||
}
|
||||
|
||||
// How a title continues a frame whose command arena filled: it branches from
|
||||
// the tail of the exhausted buffer into a fresh one and submits only the first
|
||||
// buffer, leaving the driver to follow the link. Dropping this packet strands
|
||||
// everything written after the switch -- for UE 4.27 that is the rest of the
|
||||
// frame, including its flip and the end-of-frame labels the guest's AGC
|
||||
// interrupt thread needs before it will trigger the backbuffer event.
|
||||
//
|
||||
// The branch target and its length arrive on the stack, past six register
|
||||
// arguments (verified against a live call: the values matched the continuation
|
||||
// buffer the title had already written into).
|
||||
[SysAbiExport(
|
||||
Nid = "w1KFAHVqpaU",
|
||||
ExportName = "sceAgcCbBranch",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int CbBranch(CpuContext ctx)
|
||||
{
|
||||
var commandBufferAddress = ctx[CpuRegister.Rdi];
|
||||
if (commandBufferAddress == 0 ||
|
||||
!TryReadUInt64(ctx, ctx[CpuRegister.Rsp] + (2 * sizeof(ulong)), out var target) ||
|
||||
!TryReadUInt64(ctx, ctx[CpuRegister.Rsp] + (3 * sizeof(ulong)), out var targetDwords))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 4, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(4, ItIndirectBuffer, RZero)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)(target & 0xFFFF_FFFFUL)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)((target >> 32) & 0xFFFFUL)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)targetDwords & 0xFFFFFu))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
TraceAgc(
|
||||
$"agc.cb_branch buf=0x{commandBufferAddress:X16} cmd=0x{commandAddress:X16} " +
|
||||
$"target=0x{target:X16} dwords={targetDwords}");
|
||||
return ReturnPointer(ctx, commandAddress);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "b-oySn+G2tE",
|
||||
ExportName = "sceAgcAcbJumpGetSize",
|
||||
|
||||
@@ -442,6 +442,50 @@ internal static class GpuWaitRegistry
|
||||
return collected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops produced-label values that no registered waiter is watching. Called
|
||||
/// under <see cref="_gate"/> when the table reaches its soft bound. A value
|
||||
/// still watched by a waiter is the only thing that can release that waiter
|
||||
/// once the guest recycles its label, so those are always retained even if
|
||||
/// the table has to grow past the bound.
|
||||
/// </summary>
|
||||
private static void PruneUnwatchedProducedLocked()
|
||||
{
|
||||
List<(object Memory, ulong Address)>? unwatched = null;
|
||||
foreach (var (key, _) in _lastProduced)
|
||||
{
|
||||
if (_waiters.TryGetValue(key.Item2, out var list))
|
||||
{
|
||||
var watched = false;
|
||||
foreach (var waiter in list)
|
||||
{
|
||||
if (ReferenceEquals(waiter.Memory, key.Item1))
|
||||
{
|
||||
watched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (watched)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
(unwatched ??= []).Add(key);
|
||||
}
|
||||
|
||||
if (unwatched is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var key in unwatched)
|
||||
{
|
||||
_lastProduced.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records the value a label producer wrote, for the deadlock
|
||||
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
|
||||
public static bool RecordProduced(object memory, ulong address, ulong value)
|
||||
@@ -450,7 +494,14 @@ internal static class GpuWaitRegistry
|
||||
{
|
||||
if (_lastProduced.Count >= 8192)
|
||||
{
|
||||
_lastProduced.Clear();
|
||||
// These entries are release state, not a cache. CollectDeadlockBroken
|
||||
// can only free a waiter whose label the guest has since recycled by
|
||||
// replaying the value a real producer wrote to it, so clearing the
|
||||
// table wholesale strands every such waiter forever — the suspended
|
||||
// queue then never resumes and the title wedges with its render
|
||||
// thread parked. Drop only values no live waiter is watching, and
|
||||
// let the table exceed the bound when they all are.
|
||||
PruneUnwatchedProducedLocked();
|
||||
}
|
||||
|
||||
_lastProduced[(memory, address)] = value;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
@@ -25,7 +26,6 @@ public static class AmprExports
|
||||
private const uint KernelEventQueueRecordType = 2;
|
||||
private const uint WriteAddressRecordType = 3;
|
||||
private static readonly ConcurrentDictionary<ulong, CommandBufferState> _commandBuffers = new();
|
||||
private static readonly ConcurrentDictionary<string, Lazy<CachedHostFile>> _hostFileCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly bool _traceAmpr =
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AMPR"), "1", StringComparison.Ordinal);
|
||||
private static readonly bool _traceAmprReads =
|
||||
@@ -40,7 +40,7 @@ public static class AmprExports
|
||||
public ulong CommandCount;
|
||||
}
|
||||
|
||||
private sealed class CachedHostFile
|
||||
private sealed class CachedHostFile : IDisposable
|
||||
{
|
||||
public CachedHostFile(string path)
|
||||
{
|
||||
@@ -55,8 +55,26 @@ public static class AmprExports
|
||||
|
||||
public SafeFileHandle Handle { get; }
|
||||
public long Length { get; }
|
||||
|
||||
public void Dispose() => Handle.Dispose();
|
||||
}
|
||||
|
||||
private sealed class CachedHostFileEntry
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required CachedHostFile File { get; init; }
|
||||
}
|
||||
|
||||
// Keep a bounded LRU of open host files. An unbounded cache exhausts the
|
||||
// process FD limit (~10k on macOS) during large asset storms, after
|
||||
// which every new open throws IOException and surfaces as NOT_FOUND — the
|
||||
// guest then reports InvalidFileFourCC on empty buffers.
|
||||
private const int MaxCachedHostFiles = 1536;
|
||||
private static readonly object _hostFileCacheGate = new();
|
||||
private static readonly Dictionary<string, LinkedListNode<CachedHostFileEntry>> _hostFileByPath =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly LinkedList<CachedHostFileEntry> _hostFileLru = new();
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "8aI7R7WaOlc",
|
||||
ExportName = "sceAmprCommandBufferConstructor",
|
||||
@@ -80,6 +98,7 @@ public static class AmprExports
|
||||
}
|
||||
|
||||
TraceAmpr(ctx, "ctor", commandBuffer, buffer, size);
|
||||
TryPreindexApp0();
|
||||
ctx[CpuRegister.Rax] = commandBuffer;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -107,6 +126,7 @@ public static class AmprExports
|
||||
}
|
||||
|
||||
TraceAmpr(ctx, "apr_ctor", commandBuffer, aux0, aux1);
|
||||
TryPreindexApp0();
|
||||
ctx[CpuRegister.Rax] = commandBuffer;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -271,8 +291,19 @@ public static class AmprExports
|
||||
|
||||
if (!AmprFileRegistry.TryGetHostPath(fileId, out var hostPath))
|
||||
{
|
||||
TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
// Cooked Insomniac content ids are FNV("/app0/...") and may never
|
||||
// pass through APR resolve. Index app0 once, then retry the lookup.
|
||||
var app0Root = KernelMemoryCompatExports.ResolveGuestPath("$/");
|
||||
if (!string.IsNullOrEmpty(app0Root))
|
||||
{
|
||||
AmprFileRegistry.EnsureApp0Indexed(app0Root);
|
||||
}
|
||||
|
||||
if (!AmprFileRegistry.TryGetHostPath(fileId, out hostPath))
|
||||
{
|
||||
TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
}
|
||||
|
||||
// Offset -1 means "continue after the previous read of this file id".
|
||||
@@ -779,7 +810,9 @@ public static class AmprExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
const int ChunkSize = 1024 * 1024;
|
||||
// 4 MiB chunks cut syscall/Rosetta round-trips on DeS' large sequential
|
||||
// APR reads without blowing the ArrayPool for small probes.
|
||||
const int ChunkSize = 4 * 1024 * 1024;
|
||||
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Min((ulong)ChunkSize, size));
|
||||
|
||||
try
|
||||
@@ -857,27 +890,100 @@ public static class AmprExports
|
||||
cachePath = hostPath;
|
||||
}
|
||||
|
||||
var lazy = _hostFileCache.GetOrAdd(
|
||||
cachePath,
|
||||
static path => new Lazy<CachedHostFile>(() => new CachedHostFile(path), isThreadSafe: true));
|
||||
lock (_hostFileCacheGate)
|
||||
{
|
||||
if (_hostFileByPath.TryGetValue(cachePath, out var existing))
|
||||
{
|
||||
_hostFileLru.Remove(existing);
|
||||
_hostFileLru.AddFirst(existing);
|
||||
file = existing.Value.File;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
CachedHostFile opened;
|
||||
try
|
||||
{
|
||||
file = lazy.Value;
|
||||
return true;
|
||||
opened = new CachedHostFile(cachePath);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
_hostFileCache.TryRemove(cachePath, out _);
|
||||
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
|
||||
return false;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
_hostFileCache.TryRemove(cachePath, out _);
|
||||
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
return false;
|
||||
// Likely EMFILE from a prior unbounded cache, or a transient miss.
|
||||
// Evict everything we hold and retry once so a full FD table can
|
||||
// recover without restarting the process.
|
||||
EvictAllCachedHostFiles();
|
||||
try
|
||||
{
|
||||
opened = new CachedHostFile(cachePath);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
|
||||
return false;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
lock (_hostFileCacheGate)
|
||||
{
|
||||
if (_hostFileByPath.TryGetValue(cachePath, out var raced))
|
||||
{
|
||||
opened.Dispose();
|
||||
_hostFileLru.Remove(raced);
|
||||
_hostFileLru.AddFirst(raced);
|
||||
file = raced.Value.File;
|
||||
return true;
|
||||
}
|
||||
|
||||
while (_hostFileByPath.Count >= MaxCachedHostFiles)
|
||||
{
|
||||
EvictLeastRecentlyUsedHostFileLocked();
|
||||
}
|
||||
|
||||
var entry = new CachedHostFileEntry { Path = cachePath, File = opened };
|
||||
var node = _hostFileLru.AddFirst(entry);
|
||||
_hostFileByPath[cachePath] = node;
|
||||
file = opened;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EvictAllCachedHostFiles()
|
||||
{
|
||||
List<CachedHostFile> doomed;
|
||||
lock (_hostFileCacheGate)
|
||||
{
|
||||
doomed = _hostFileLru.Select(entry => entry.File).ToList();
|
||||
_hostFileLru.Clear();
|
||||
_hostFileByPath.Clear();
|
||||
}
|
||||
|
||||
foreach (var cached in doomed)
|
||||
{
|
||||
cached.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EvictLeastRecentlyUsedHostFileLocked()
|
||||
{
|
||||
var last = _hostFileLru.Last;
|
||||
if (last is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hostFileLru.RemoveLast();
|
||||
_hostFileByPath.Remove(last.Value.Path);
|
||||
last.Value.File.Dispose();
|
||||
}
|
||||
|
||||
private static bool AppendReadFileRecord(
|
||||
@@ -1004,6 +1110,19 @@ public static class AmprExports
|
||||
return false;
|
||||
}
|
||||
|
||||
// GPU WAIT_REG_MEM often watches these APR completion labels
|
||||
// (e.g. 0x20505xxx DEADBEEF/counter fences). Without
|
||||
// RecordProduced the wait sits producerless after the guest recycles
|
||||
// the dword, and CollectDeadlockBroken cannot replay the wake.
|
||||
_ = GpuWaitRegistry.RecordProduced(ctx.Memory, address, value);
|
||||
if ((address & 4ul) == 0)
|
||||
{
|
||||
// 32-bit GPU waits use the low dword; also latch that view when the
|
||||
// address is dword-aligned so a u32 compare against ref sees it.
|
||||
_ = GpuWaitRegistry.RecordProduced(
|
||||
ctx.Memory, address, unchecked((uint)value));
|
||||
}
|
||||
|
||||
TraceAmpr(ctx, "complete_write_address", address, value, 0);
|
||||
return true;
|
||||
}
|
||||
@@ -1021,6 +1140,15 @@ public static class AmprExports
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void TryPreindexApp0()
|
||||
{
|
||||
var app0Root = KernelMemoryCompatExports.ResolveGuestPath("$/");
|
||||
if (!string.IsNullOrEmpty(app0Root))
|
||||
{
|
||||
AmprFileRegistry.EnsureApp0Indexed(app0Root);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraceAmpr(CpuContext ctx, string operation, ulong commandBuffer, ulong arg0, ulong arg1)
|
||||
{
|
||||
if (!_traceAmpr)
|
||||
|
||||
@@ -2,15 +2,35 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpEmu.Libs.Ampr;
|
||||
|
||||
internal static class AmprFileRegistry
|
||||
{
|
||||
private static readonly ConcurrentDictionary<uint, string> _hostPathsById = new();
|
||||
private const uint CacheMagicV2 = 0x32495041u; // 'API2'
|
||||
private const uint CacheVersionV2 = 2;
|
||||
private const uint CacheMagicV3 = 0x33495041u; // 'API3'
|
||||
private const uint CacheVersionV3 = 3;
|
||||
|
||||
private static readonly ConcurrentDictionary<uint, string> _hostPathsById = new(
|
||||
concurrencyLevel: Math.Max(4, Environment.ProcessorCount),
|
||||
capacity: 1_048_576);
|
||||
private static readonly object _indexGate = new();
|
||||
private static string? _indexedApp0Root;
|
||||
private static string? _indexingApp0Root;
|
||||
private static int _preloadStarted;
|
||||
|
||||
public static uint Register(string guestPath, string hostPath)
|
||||
{
|
||||
if (TryGetApp0Relative(guestPath, out var relative) && relative.Length != 0)
|
||||
{
|
||||
RegisterApp0Relative(relative, hostPath);
|
||||
return ComputeFileId("$/" + relative);
|
||||
}
|
||||
|
||||
var id = ComputeFileId(guestPath);
|
||||
_hostPathsById[id] = hostPath;
|
||||
return id;
|
||||
@@ -21,18 +41,559 @@ internal static class AmprFileRegistry
|
||||
return _hostPathsById.TryGetValue(id, out hostPath!);
|
||||
}
|
||||
|
||||
/// <summary>Test hook: wipe registry state between cases.</summary>
|
||||
internal static void ClearForTests()
|
||||
{
|
||||
lock (_indexGate)
|
||||
{
|
||||
_hostPathsById.Clear();
|
||||
_indexedApp0Root = null;
|
||||
_indexingApp0Root = null;
|
||||
_preloadStarted = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Test hook for the allocation-free alias publisher.</summary>
|
||||
internal static void RegisterApp0RelativeForTests(string relative, string hostPath) =>
|
||||
RegisterApp0Relative(relative, hostPath);
|
||||
|
||||
/// <summary>
|
||||
/// Kick off <see cref="EnsureApp0Indexed"/> on a background thread as soon as
|
||||
/// the host knows app0. otherwise pays the full tree walk on the
|
||||
/// first cooked-id APR miss mid-boot (~8s under Rosetta for DeS).
|
||||
/// </summary>
|
||||
public static void BeginApp0IndexPreload(string? app0Root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(app0Root) || !Directory.Exists(app0Root))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.Exchange(ref _preloadStarted, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var root = app0Root;
|
||||
ThreadPool.UnsafeQueueUserWorkItem(
|
||||
static state =>
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureApp0Indexed((string)state!);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] ampr.app0_index_preload_failed: {exception.Message}");
|
||||
}
|
||||
},
|
||||
root);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indexes every file under app0 under both <c>$/</c> and <c>/app0/</c> FNV
|
||||
/// ids. Cooked asset tables ship precomputed ids; without this walk, a title
|
||||
/// that never resolves those paths through APR leaves ReadFile permanently
|
||||
/// NOT_FOUND.
|
||||
/// </summary>
|
||||
public static void EnsureApp0Indexed(string app0Root)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(app0Root) || !Directory.Exists(app0Root))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedRoot = Path.GetFullPath(app0Root);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
lock (_indexGate)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (string.Equals(_indexedApp0Root, normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_indexingApp0Root is not null)
|
||||
{
|
||||
// Another thread (preload) owns the walk — wait instead of
|
||||
// stacking a second 8s index on the guest APR miss path.
|
||||
if (string.Equals(
|
||||
_indexingApp0Root,
|
||||
normalizedRoot,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Monitor.Wait(_indexGate);
|
||||
continue;
|
||||
}
|
||||
|
||||
Monitor.Wait(_indexGate, 50);
|
||||
continue;
|
||||
}
|
||||
|
||||
_indexingApp0Root = normalizedRoot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cachePathV3 = GetIndexCachePath(normalizedRoot, version: 3);
|
||||
var cachePathV2 = GetIndexCachePath(normalizedRoot, version: 2);
|
||||
if (TryLoadIndexCache(normalizedRoot, cachePathV3, preferV3: true, out var cachedFiles))
|
||||
{
|
||||
lock (_indexGate)
|
||||
{
|
||||
_indexedApp0Root = normalizedRoot;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] ampr.app0_index_cache_hit root={normalizedRoot} " +
|
||||
$"files={cachedFiles} ids={_hostPathsById.Count} " +
|
||||
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryLoadIndexCache(normalizedRoot, cachePathV2, preferV3: false, out cachedFiles))
|
||||
{
|
||||
lock (_indexGate)
|
||||
{
|
||||
_indexedApp0Root = normalizedRoot;
|
||||
}
|
||||
|
||||
// Promote v2 (rehash-on-load) to v3 (precomputed ids) so the
|
||||
// next boot skips the Rosetta FNV storm.
|
||||
TrySaveIndexCache(normalizedRoot, cachePathV3, cachedFiles);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] ampr.app0_index_cache_hit root={normalizedRoot} " +
|
||||
$"files={cachedFiles} ids={_hostPathsById.Count} " +
|
||||
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1} upgraded=v3");
|
||||
return;
|
||||
}
|
||||
|
||||
var relatives = new List<string>(256 * 1024);
|
||||
foreach (var hostPath in Directory.EnumerateFiles(
|
||||
normalizedRoot,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||
.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(relative) ||
|
||||
relative.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
relatives.Add(relative);
|
||||
}
|
||||
|
||||
// Hash + dictionary fill dominates under Rosetta once the walk is
|
||||
// done; parallelize across cores without re-walking the tree.
|
||||
Parallel.ForEach(
|
||||
relatives,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
|
||||
},
|
||||
relative =>
|
||||
{
|
||||
var hostPath = Path.Combine(normalizedRoot, relative.Replace('/', Path.DirectorySeparatorChar));
|
||||
RegisterApp0Relative(relative, hostPath);
|
||||
});
|
||||
|
||||
lock (_indexGate)
|
||||
{
|
||||
_indexedApp0Root = normalizedRoot;
|
||||
}
|
||||
|
||||
TrySaveIndexCache(normalizedRoot, cachePathV3, relatives.Count);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] ampr.app0_indexed root={normalizedRoot} " +
|
||||
$"files={relatives.Count} ids={_hostPathsById.Count} " +
|
||||
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_indexGate)
|
||||
{
|
||||
_indexingApp0Root = null;
|
||||
Monitor.PulseAll(_indexGate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the four Insomniac path aliases for one app0-relative file
|
||||
/// without allocating intermediate guest-path strings.
|
||||
/// </summary>
|
||||
private static void RegisterApp0Relative(string relative, string hostPath)
|
||||
{
|
||||
// "$/" + relative
|
||||
Publish(FnvContinueAscii(FnvContinueAscii(OffsetBasis, (byte)'$'), (byte)'/'), relative, hostPath);
|
||||
// "/app0/" + relative
|
||||
Publish(FnvContinueAsciiPrefix(OffsetBasis, "/app0/"u8), relative, hostPath);
|
||||
// "app0/" + relative
|
||||
Publish(FnvContinueAsciiPrefix(OffsetBasis, "app0/"u8), relative, hostPath);
|
||||
// bare relative
|
||||
Publish(OffsetBasis, relative, hostPath);
|
||||
}
|
||||
|
||||
private static void Publish(uint hash, string relative, string hostPath)
|
||||
{
|
||||
_hostPathsById[FnvContinueUtf8(hash, relative)] = hostPath;
|
||||
}
|
||||
|
||||
internal static uint ComputeFileId(string guestPath)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(guestPath);
|
||||
return FnvContinueUtf8(OffsetBasis, guestPath);
|
||||
}
|
||||
|
||||
const uint offsetBasis = 2166136261;
|
||||
const uint prime = 16777619;
|
||||
|
||||
var hash = offsetBasis;
|
||||
foreach (var b in bytes)
|
||||
internal static IEnumerable<string> EnumerateApp0PathAliases(string guestPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(guestPath))
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= prime;
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!TryGetApp0Relative(guestPath, out var relative) ||
|
||||
string.IsNullOrEmpty(relative))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return "$/" + relative;
|
||||
yield return "/app0/" + relative;
|
||||
yield return "app0/" + relative;
|
||||
yield return relative;
|
||||
}
|
||||
|
||||
private static bool TryGetApp0Relative(string guestPath, out string relative)
|
||||
{
|
||||
relative = string.Empty;
|
||||
var normalized = guestPath.Replace('\\', '/');
|
||||
|
||||
if (normalized.StartsWith("$/", StringComparison.Ordinal))
|
||||
{
|
||||
relative = normalized[2..].TrimStart('/');
|
||||
return relative.Length != 0;
|
||||
}
|
||||
|
||||
if (normalized.StartsWith("/app0/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relative = normalized["/app0/".Length..].TrimStart('/');
|
||||
return relative.Length != 0;
|
||||
}
|
||||
|
||||
if (normalized.StartsWith("app0/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relative = normalized["app0/".Length..].TrimStart('/');
|
||||
return relative.Length != 0;
|
||||
}
|
||||
|
||||
// Bare relative paths are treated as app0-relative by ResolveGuestPath.
|
||||
if (!normalized.StartsWith('/') &&
|
||||
!Path.IsPathFullyQualified(guestPath))
|
||||
{
|
||||
relative = normalized.TrimStart('/');
|
||||
return relative.Length != 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetIndexCachePath(string normalizedRoot, int version)
|
||||
{
|
||||
var overrideDir = Environment.GetEnvironmentVariable("SHARPEMU_AMPR_INDEX_CACHE");
|
||||
var cacheDir = !string.IsNullOrWhiteSpace(overrideDir)
|
||||
? overrideDir
|
||||
: Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"SharpEmu",
|
||||
"ampr-index");
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var rootHash = ComputeFileId(normalizedRoot.ToLowerInvariant());
|
||||
return Path.Combine(cacheDir, $"app0-{rootHash:x8}.v{version}.idx");
|
||||
}
|
||||
|
||||
private static bool TryLoadIndexCache(
|
||||
string normalizedRoot,
|
||||
string cachePath,
|
||||
bool preferV3,
|
||||
out int fileCount)
|
||||
{
|
||||
fileCount = 0;
|
||||
try
|
||||
{
|
||||
if (!File.Exists(cachePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_AMPR_REINDEX"),
|
||||
"1",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(cachePath);
|
||||
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false);
|
||||
var magic = reader.ReadUInt32();
|
||||
var version = reader.ReadUInt32();
|
||||
var isV3 = magic == CacheMagicV3 && version == CacheVersionV3;
|
||||
var isV2 = magic == CacheMagicV2 && version == CacheVersionV2;
|
||||
if (preferV3)
|
||||
{
|
||||
if (!isV3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (!isV2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var root = reader.ReadString();
|
||||
if (!string.Equals(root, normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedFiles = reader.ReadInt32();
|
||||
var expectedParamTicks = reader.ReadInt64();
|
||||
var actualParamTicks = GetParamJsonWriteTicks(normalizedRoot);
|
||||
if (actualParamTicks == 0 || actualParamTicks != expectedParamTicks)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedFiles < 0 || expectedFiles > 8_000_000)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isV3)
|
||||
{
|
||||
// Precomputed FNV ids — no Rosetta hash storm on every boot.
|
||||
var entries = new (string Relative, uint Id0, uint Id1, uint Id2, uint Id3)[expectedFiles];
|
||||
for (var i = 0; i < expectedFiles; i++)
|
||||
{
|
||||
entries[i] = (
|
||||
reader.ReadString(),
|
||||
reader.ReadUInt32(),
|
||||
reader.ReadUInt32(),
|
||||
reader.ReadUInt32(),
|
||||
reader.ReadUInt32());
|
||||
}
|
||||
|
||||
Parallel.ForEach(
|
||||
entries,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
|
||||
},
|
||||
entry =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.Relative) ||
|
||||
entry.Relative.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hostPath = normalizedRoot.EndsWith(Path.DirectorySeparatorChar)
|
||||
? normalizedRoot + entry.Relative.Replace('/', Path.DirectorySeparatorChar)
|
||||
: normalizedRoot + Path.DirectorySeparatorChar +
|
||||
entry.Relative.Replace('/', Path.DirectorySeparatorChar);
|
||||
_hostPathsById[entry.Id0] = hostPath;
|
||||
_hostPathsById[entry.Id1] = hostPath;
|
||||
_hostPathsById[entry.Id2] = hostPath;
|
||||
_hostPathsById[entry.Id3] = hostPath;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var relatives = new string[expectedFiles];
|
||||
for (var i = 0; i < expectedFiles; i++)
|
||||
{
|
||||
relatives[i] = reader.ReadString();
|
||||
}
|
||||
|
||||
Parallel.ForEach(
|
||||
relatives,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
|
||||
},
|
||||
relative =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(relative) ||
|
||||
relative.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hostPath = Path.Combine(
|
||||
normalizedRoot,
|
||||
relative.Replace('/', Path.DirectorySeparatorChar));
|
||||
RegisterApp0Relative(relative, hostPath);
|
||||
});
|
||||
}
|
||||
|
||||
fileCount = expectedFiles;
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_hostPathsById.Clear();
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] ampr.app0_index_cache_load_failed: {exception.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TrySaveIndexCache(
|
||||
string normalizedRoot,
|
||||
string cachePath,
|
||||
int fileCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
var paramTicks = GetParamJsonWriteTicks(normalizedRoot);
|
||||
if (paramTicks == 0 || fileCount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var relatives = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var hostPath in _hostPathsById.Values)
|
||||
{
|
||||
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||
.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(relative) ||
|
||||
relative.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
relatives.Add(relative);
|
||||
}
|
||||
|
||||
var tempPath = cachePath + ".tmp";
|
||||
using (var stream = File.Create(tempPath))
|
||||
using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: false))
|
||||
{
|
||||
writer.Write(CacheMagicV3);
|
||||
writer.Write(CacheVersionV3);
|
||||
writer.Write(normalizedRoot);
|
||||
writer.Write(relatives.Count);
|
||||
writer.Write(paramTicks);
|
||||
foreach (var relative in relatives)
|
||||
{
|
||||
writer.Write(relative);
|
||||
// Mirror RegisterApp0Relative id order: $/ /app0/ app0/ bare.
|
||||
writer.Write(ComputeApp0AliasIds(relative, out var id1, out var id2, out var id3));
|
||||
writer.Write(id1);
|
||||
writer.Write(id2);
|
||||
writer.Write(id3);
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(tempPath, cachePath, overwrite: true);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] ampr.app0_index_cache_saved path={cachePath} " +
|
||||
$"files={relatives.Count}");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] ampr.app0_index_cache_save_failed: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ComputeApp0AliasIds(
|
||||
string relative,
|
||||
out uint app0Slash,
|
||||
out uint app0,
|
||||
out uint bare)
|
||||
{
|
||||
var dollar = FnvContinueUtf8(
|
||||
FnvContinueAscii(FnvContinueAscii(OffsetBasis, (byte)'$'), (byte)'/'),
|
||||
relative);
|
||||
app0Slash = FnvContinueUtf8(FnvContinueAsciiPrefix(OffsetBasis, "/app0/"u8), relative);
|
||||
app0 = FnvContinueUtf8(FnvContinueAsciiPrefix(OffsetBasis, "app0/"u8), relative);
|
||||
bare = FnvContinueUtf8(OffsetBasis, relative);
|
||||
return dollar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cheap dump fingerprint. Full-tree walks are too expensive for cache
|
||||
/// validation; param.json changes with title updates. Force a rebuild with
|
||||
/// SHARPEMU_AMPR_REINDEX=1 after manual dump edits.
|
||||
/// </summary>
|
||||
private static long GetParamJsonWriteTicks(string normalizedRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var paramPath = Path.Combine(normalizedRoot, "sce_sys", "param.json");
|
||||
return File.Exists(paramPath)
|
||||
? File.GetLastWriteTimeUtc(paramPath).Ticks
|
||||
: 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private const uint OffsetBasis = 2166136261;
|
||||
private const uint FnvPrime = 16777619;
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static uint FnvContinueAscii(uint hash, byte value)
|
||||
{
|
||||
hash ^= value;
|
||||
return hash * FnvPrime;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static uint FnvContinueAsciiPrefix(uint hash, ReadOnlySpan<byte> ascii)
|
||||
{
|
||||
foreach (var value in ascii)
|
||||
{
|
||||
hash ^= value;
|
||||
hash *= FnvPrime;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static uint FnvContinueUtf8(uint hash, string text)
|
||||
{
|
||||
// Game asset paths are overwhelmingly ASCII; avoid Encoding.GetBytes
|
||||
// allocations on the 223k-file DeS index hot path.
|
||||
Span<byte> utf8Scratch = stackalloc byte[4];
|
||||
for (var i = 0; i < text.Length; i++)
|
||||
{
|
||||
var ch = text[i];
|
||||
if (ch < 0x80)
|
||||
{
|
||||
hash ^= (byte)ch;
|
||||
hash *= FnvPrime;
|
||||
continue;
|
||||
}
|
||||
|
||||
var written = Encoding.UTF8.GetBytes(text.AsSpan(i, 1), utf8Scratch);
|
||||
for (var b = 0; b < written; b++)
|
||||
{
|
||||
hash ^= utf8Scratch[b];
|
||||
hash *= FnvPrime;
|
||||
}
|
||||
}
|
||||
|
||||
return hash;
|
||||
|
||||
@@ -253,7 +253,7 @@ internal static partial class MetalVideoPresenter
|
||||
if (writeBackBuffers.Count > 0)
|
||||
{
|
||||
var committed = FlushBatchedGuestCommands();
|
||||
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
|
||||
WaitForCommittedCommandBuffer(committed);
|
||||
WriteBuffersBackToGuest(writeBackBuffers);
|
||||
}
|
||||
|
||||
|
||||
@@ -580,7 +580,7 @@ internal static partial class MetalVideoPresenter
|
||||
if (writeBackBuffers.Count > 0)
|
||||
{
|
||||
var committed = FlushBatchedGuestCommands();
|
||||
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
|
||||
WaitForCommittedCommandBuffer(committed);
|
||||
WriteBuffersBackToGuest(writeBackBuffers);
|
||||
}
|
||||
|
||||
@@ -668,7 +668,7 @@ internal static partial class MetalVideoPresenter
|
||||
TagSnapshotResources(commandBuffer);
|
||||
if (writeBackBuffers.Count > 0)
|
||||
{
|
||||
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("waitUntilCompleted"));
|
||||
WaitForCommittedCommandBuffer(commandBuffer);
|
||||
WriteBuffersBackToGuest(writeBackBuffers);
|
||||
}
|
||||
|
||||
@@ -2082,6 +2082,30 @@ internal static partial class MetalVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until a committed command buffer finishes. Skips the ObjC wait
|
||||
/// when status is already Completed (common for tiny label/writeback
|
||||
/// batches), avoiding redundant waitUntilCompleted round-trips on the
|
||||
/// ordered queue.
|
||||
/// </summary>
|
||||
private static void WaitForCommittedCommandBuffer(nint commandBuffer)
|
||||
{
|
||||
if (commandBuffer == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// MTLCommandBufferStatusCompleted = 4.
|
||||
const nint completed = 4;
|
||||
if (MetalNative.Send(commandBuffer, MetalNative.Selector("status")) >= completed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("waitUntilCompleted"));
|
||||
}
|
||||
|
||||
private static void ReturnPooledGuestData(TranslatedGuestDraw draw)
|
||||
{
|
||||
foreach (var buffer in draw.GlobalMemoryBuffers)
|
||||
|
||||
@@ -646,6 +646,30 @@ internal static partial class MetalVideoPresenter
|
||||
var pixelSize = hostWindow.PixelSize;
|
||||
var width = (double)pixelSize.Width;
|
||||
var height = (double)pixelSize.Height;
|
||||
|
||||
// Retina hosts often present at 3840x2160 into a 1920x1080 window.
|
||||
// Cap is opt-in: defaulting it on silently drops present resolution for
|
||||
// every Metal title. SHARPEMU_METAL_CAP_DRAWABLE=1 enables the long-edge
|
||||
// cap; SHARPEMU_METAL_FULL_DRAWABLE=1 remains a no-op when the cap is off.
|
||||
if (string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_METAL_CAP_DRAWABLE"),
|
||||
"1",
|
||||
StringComparison.Ordinal) &&
|
||||
!string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_METAL_FULL_DRAWABLE"),
|
||||
"1",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
const double maxLongEdge = 1920.0;
|
||||
var longEdge = Math.Max(width, height);
|
||||
if (longEdge > maxLongEdge)
|
||||
{
|
||||
var scale = maxLongEdge / longEdge;
|
||||
width = Math.Round(width * scale);
|
||||
height = Math.Round(height * scale);
|
||||
}
|
||||
}
|
||||
|
||||
if (width == _drawableWidth && height == _drawableHeight)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -323,6 +323,37 @@ public static class JsonExports
|
||||
return 0;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "wLsJlmgEIaI",
|
||||
ExportName = "_ZN3sce4Json5Value10referValueERKNS0_6StringE",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceJson")]
|
||||
public static int ValueReferValue(CpuContext ctx)
|
||||
{
|
||||
var thisAddress = ctx[CpuRegister.Rdi];
|
||||
var keyStringAddress = ctx[CpuRegister.Rsi];
|
||||
|
||||
if (thisAddress == 0 ||
|
||||
!_strings.TryGetValue(keyStringAddress, out var keyState) ||
|
||||
!TryAllocateGuestObject(ctx, ValueObjectSize, out var childAddress))
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
var parent = GetValue(thisAddress);
|
||||
|
||||
var child = parent.ValueKind == System.Text.Json.JsonValueKind.Object &&
|
||||
parent.TryGetProperty(keyState.Value, out var property)
|
||||
? property.Clone() : _nullElement;
|
||||
|
||||
StoreValue(ctx, childAddress, child);
|
||||
|
||||
ctx[CpuRegister.Rax] = childAddress;
|
||||
TraceJsonText("Value.referValue", thisAddress, keyState.Value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "zTwZdI8AZ5Y",
|
||||
ExportName = "_ZNK3sce4Json5Value10getBooleanEv",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3528,6 +3528,33 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "mkgXxsoxWHg",
|
||||
ExportName = "sceKernelClearVirtualRangeName",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int KernelClearVirtualRangeName(CpuContext ctx)
|
||||
{
|
||||
var address = ctx[CpuRegister.Rdi];
|
||||
var length = ctx[CpuRegister.Rsi];
|
||||
|
||||
lock (_memoryGate)
|
||||
{
|
||||
if (!TryFindVirtualQueryRegionLocked(address, findNext: false, out var region) ||
|
||||
length > region.Length ||
|
||||
address < region.Address ||
|
||||
length > region.Address + region.Length - address)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
_mappedRegionNames.Remove(region.Address);
|
||||
}
|
||||
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "rVjRvHJ0X6c",
|
||||
ExportName = "sceKernelVirtualQuery",
|
||||
|
||||
@@ -75,6 +75,26 @@ internal static class KernelPthreadState
|
||||
return Threads.TryGetValue(threadHandle, out identity);
|
||||
}
|
||||
|
||||
internal static bool TryGetCurrentThreadIdentity(
|
||||
out ulong threadHandle,
|
||||
out ThreadIdentity identity)
|
||||
{
|
||||
threadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
if (threadHandle != 0 && TryGetThreadIdentity(threadHandle, out identity))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
threadHandle = _currentThreadHandle;
|
||||
if (threadHandle != 0 && TryGetThreadIdentity(threadHandle, out identity))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
identity = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ThreadIdentity EnsureGuestThreadIdentity(ulong guestThreadHandle)
|
||||
{
|
||||
if (Threads.TryGetValue(guestThreadHandle, out var existing))
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Kernel;
|
||||
|
||||
/// <summary>
|
||||
/// Shared formatting for opt-in kernel synchronization diagnostics.
|
||||
/// Callers must gate this behind their trace flag so normal synchronization
|
||||
/// paths do not allocate strings or walk guest frame chains.
|
||||
/// </summary>
|
||||
internal static class KernelSyncTraceFormatter
|
||||
{
|
||||
internal static string FormatContext(CpuContext ctx)
|
||||
{
|
||||
_ = KernelPthreadState.TryGetCurrentThreadIdentity(out var pthread, out var identity);
|
||||
var threadName = identity.Name ?? "<unknown>";
|
||||
var returnRip = GuestThreadExecution.TryGetCurrentImportCallFrame(out var importFrame)
|
||||
? importFrame.ReturnRip
|
||||
: TryReadReturnRip(ctx);
|
||||
|
||||
return $"thread='{threadName}' pthread=0x{pthread:X16} " +
|
||||
$"gth=0x{GuestThreadExecution.CurrentGuestThreadHandle:X16} " +
|
||||
$"managed={Environment.CurrentManagedThreadId} ret=0x{returnRip:X16} " +
|
||||
$"frames={FormatFrameChain(ctx)}";
|
||||
}
|
||||
|
||||
internal static string FormatCurrentThread()
|
||||
{
|
||||
_ = KernelPthreadState.TryGetCurrentThreadIdentity(out var pthread, out var identity);
|
||||
var threadName = identity.Name ?? Thread.CurrentThread.Name ?? "<unknown>";
|
||||
return $"thread='{threadName}' pthread=0x{pthread:X16} " +
|
||||
$"gth=0x{GuestThreadExecution.CurrentGuestThreadHandle:X16} " +
|
||||
$"managed={Environment.CurrentManagedThreadId}";
|
||||
}
|
||||
|
||||
internal static string FormatFrameChain(CpuContext ctx)
|
||||
{
|
||||
Span<ulong> returns = stackalloc ulong[4];
|
||||
var count = 0;
|
||||
var frame = ctx[CpuRegister.Rbp];
|
||||
while (count < returns.Length && frame != 0)
|
||||
{
|
||||
if (!ctx.TryReadUInt64(frame, out var nextFrame) ||
|
||||
!ctx.TryReadUInt64(frame + sizeof(ulong), out var returnAddress))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
returns[count++] = returnAddress;
|
||||
if (nextFrame <= frame || nextFrame - frame > 0x100000)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
frame = nextFrame;
|
||||
}
|
||||
|
||||
return count switch
|
||||
{
|
||||
0 => "none",
|
||||
1 => $"0x{returns[0]:X16}",
|
||||
2 => $"0x{returns[0]:X16},0x{returns[1]:X16}",
|
||||
3 => $"0x{returns[0]:X16},0x{returns[1]:X16},0x{returns[2]:X16}",
|
||||
_ => $"0x{returns[0]:X16},0x{returns[1]:X16}," +
|
||||
$"0x{returns[2]:X16},0x{returns[3]:X16}",
|
||||
};
|
||||
}
|
||||
|
||||
private static ulong TryReadReturnRip(CpuContext ctx)
|
||||
{
|
||||
_ = ctx.TryReadUInt64(ctx[CpuRegister.Rsp], out var returnRip);
|
||||
return returnRip;
|
||||
}
|
||||
}
|
||||
@@ -373,6 +373,42 @@ public static class PadExports
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
// Size taken from the caller's own frame rather than assumed: the guest
|
||||
// reserves 0x10 bytes, points the out-param at rbp-0x30, and stores its
|
||||
// stack cookie at rbp-0x28, so only eight bytes belong to the state. A
|
||||
// sixteen-byte write would land on the cookie and fail the stack check.
|
||||
private const int TriggerEffectStateSize = 8;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "znaWI0gpuo8",
|
||||
ExportName = "scePadGetTriggerEffectState",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libScePad")]
|
||||
public static int PadGetTriggerEffectState(CpuContext ctx)
|
||||
{
|
||||
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var stateAddress = ctx[CpuRegister.Rsi];
|
||||
if (!IsPrimaryPadHandle(handle))
|
||||
{
|
||||
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
||||
}
|
||||
|
||||
if (stateAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// No host pad exposes DualSense adaptive-trigger feedback, so every
|
||||
// trigger reports the neutral "no effect engaged" state. Reporting it
|
||||
// as success is what lets the caller take its normal path instead of
|
||||
// falling back to a cached button bitmask every poll.
|
||||
Span<byte> state = stackalloc byte[TriggerEffectStateSize];
|
||||
state.Clear();
|
||||
return ctx.Memory.TryWrite(stateAddress, state)
|
||||
? ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
private static HostAdaptiveTriggerEffect DecodeTriggerEffect(ReadOnlySpan<byte> command)
|
||||
{
|
||||
var mode = BinaryPrimitives.ReadUInt32LittleEndian(command);
|
||||
|
||||
@@ -24,6 +24,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
|
||||
<InternalsVisibleTo Include="SharpEmu.Core" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -144,16 +144,6 @@ public static class UserServiceExports
|
||||
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
// Title-captured alias NID for the same username query.
|
||||
#pragma warning disable SHEM004
|
||||
[SysAbiExport(
|
||||
Nid = "znaWI0gpuo8",
|
||||
ExportName = "sceUserServiceGetUserName",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceUserService")]
|
||||
public static int UserServiceGetUserNameAlt(CpuContext ctx) => UserServiceGetUserName(ctx);
|
||||
#pragma warning restore SHEM004
|
||||
|
||||
// Name not yet in ps5_names.txt and the NID was captured from titles; revisit when the symbol is catalogued.
|
||||
#pragma warning disable SHEM006
|
||||
[SysAbiExport(
|
||||
|
||||
@@ -30,6 +30,7 @@ public static class PerfOverlay
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static long _lastPresentTimestamp;
|
||||
private static long _lastSubmitTimestamp;
|
||||
private static long _sessionStartTimestamp;
|
||||
private static readonly double[] _frameMilliseconds = new double[FrameHistorySize];
|
||||
private static int _frameHistoryIndex;
|
||||
@@ -40,8 +41,11 @@ public static class PerfOverlay
|
||||
|
||||
// Refreshed once per second so per-frame fills never allocate.
|
||||
private static long _statsWindowStart = Stopwatch.GetTimestamp();
|
||||
// Headline FPS tracks guest VideoOut flips (RecordSubmit), not host
|
||||
// swapchain presents. Metal's free-running present timer hits ~120 Hz on
|
||||
// ProMotion even while the guest is stalled on GPU waits.
|
||||
private static double _fps;
|
||||
private static double _submittedFps;
|
||||
private static double _presentFps;
|
||||
private static double _drawsPerSecond;
|
||||
private static double _averageFrameMs;
|
||||
private static double _allocatedMbPerSecond;
|
||||
@@ -64,24 +68,29 @@ public static class PerfOverlay
|
||||
|
||||
public static void Toggle() => _enabled = !_enabled;
|
||||
|
||||
/// <summary>Called by the presenter after each successful present.</summary>
|
||||
/// <summary>Called by the presenter after each successful host present.</summary>
|
||||
public static void RecordPresent()
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
Interlocked.CompareExchange(ref _sessionStartTimestamp, now, 0);
|
||||
var last = _lastPresentTimestamp;
|
||||
_lastPresentTimestamp = now;
|
||||
Interlocked.CompareExchange(ref _sessionStartTimestamp, Stopwatch.GetTimestamp(), 0);
|
||||
Interlocked.Increment(ref _presentedInWindow);
|
||||
if (last != 0)
|
||||
{
|
||||
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
|
||||
_frameMilliseconds[_frameHistoryIndex] = milliseconds;
|
||||
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize;
|
||||
}
|
||||
_lastPresentTimestamp = Stopwatch.GetTimestamp();
|
||||
}
|
||||
|
||||
/// <summary>Called on every guest flip submission.</summary>
|
||||
public static void RecordSubmit() => Interlocked.Increment(ref _submittedInWindow);
|
||||
public static void RecordSubmit()
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
Interlocked.CompareExchange(ref _sessionStartTimestamp, now, 0);
|
||||
Interlocked.Increment(ref _submittedInWindow);
|
||||
var last = Interlocked.Exchange(ref _lastSubmitTimestamp, now);
|
||||
if (last != 0)
|
||||
{
|
||||
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
|
||||
var index = _frameHistoryIndex;
|
||||
_frameMilliseconds[index] = milliseconds;
|
||||
_frameHistoryIndex = (index + 1) % FrameHistorySize;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called per translated draw/dispatch executed.</summary>
|
||||
public static void RecordDraw() => Interlocked.Increment(ref _drawsInWindow);
|
||||
@@ -128,27 +137,47 @@ public static class PerfOverlay
|
||||
{
|
||||
var seconds = (double)elapsedTicks / Stopwatch.Frequency;
|
||||
_statsWindowStart = now;
|
||||
_fps = Interlocked.Exchange(ref _presentedInWindow, 0) / seconds;
|
||||
_submittedFps = Interlocked.Exchange(ref _submittedInWindow, 0) / seconds;
|
||||
_fps = Interlocked.Exchange(ref _submittedInWindow, 0) / seconds;
|
||||
_presentFps = Interlocked.Exchange(ref _presentedInWindow, 0) / seconds;
|
||||
_drawsPerSecond = Interlocked.Exchange(ref _drawsInWindow, 0) / seconds;
|
||||
|
||||
double totalMs = 0;
|
||||
var samples = 0;
|
||||
foreach (var ms in _frameMilliseconds)
|
||||
{
|
||||
if (ms > 0)
|
||||
{
|
||||
totalMs += ms;
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
|
||||
_averageFrameMs = samples > 0 ? totalMs / samples : 0;
|
||||
|
||||
var allocated = GC.GetTotalAllocatedBytes(precise: false);
|
||||
_allocatedMbPerSecond = (allocated - _lastAllocatedBytes) / seconds / (1024.0 * 1024.0);
|
||||
_lastAllocatedBytes = allocated;
|
||||
|
||||
// Headline MS must track *current* guest cadence. Averaging the whole
|
||||
// 128-slot history kept a single 8s boot gap on screen for minutes
|
||||
// (FPS 0 + 8000 MS) even after the stall ended.
|
||||
var lastSubmit = Interlocked.Read(ref _lastSubmitTimestamp);
|
||||
string msLabel;
|
||||
if (_fps > 0)
|
||||
{
|
||||
var lastIndex = (_frameHistoryIndex - 1 + FrameHistorySize) % FrameHistorySize;
|
||||
var lastInterval = _frameMilliseconds[lastIndex];
|
||||
_averageFrameMs = lastInterval > 0 ? lastInterval : 1000.0 / _fps;
|
||||
msLabel = $"{_averageFrameMs:0.0} MS";
|
||||
}
|
||||
else if (lastSubmit != 0)
|
||||
{
|
||||
// No guest flips this window: show stall age, but label it so it
|
||||
// is not read as "the game is rendering 20s frames" during boot
|
||||
// asset load (ALLOC high, DRAWS 0 after splash).
|
||||
_averageFrameMs = (now - lastSubmit) * 1000.0 / Stopwatch.Frequency;
|
||||
msLabel = _allocatedMbPerSecond > 50.0
|
||||
? $"LOAD {_averageFrameMs / 1000.0:0.0}S"
|
||||
: $"STALL {_averageFrameMs / 1000.0:0.0}S";
|
||||
}
|
||||
else if (_presentFps > 0)
|
||||
{
|
||||
_averageFrameMs = 1000.0 / _presentFps;
|
||||
msLabel = $"{_averageFrameMs:0.0} MS";
|
||||
}
|
||||
else
|
||||
{
|
||||
_averageFrameMs = 0;
|
||||
msLabel = "0.0 MS";
|
||||
}
|
||||
|
||||
var gen0 = GC.CollectionCount(0);
|
||||
var gen1 = GC.CollectionCount(1);
|
||||
var gen2 = GC.CollectionCount(2);
|
||||
@@ -174,7 +203,7 @@ public static class PerfOverlay
|
||||
var elapsedHours = elapsedSeconds / 3600;
|
||||
var elapsedMinutes = elapsedSeconds / 60 % 60;
|
||||
var elapsedRemainingSeconds = elapsedSeconds % 60;
|
||||
_line1 = $"FPS {_fps:0.0} FLIP {_submittedFps:0.0} {_averageFrameMs:0.0} MS";
|
||||
_line1 = $"FPS {_fps:0.0} PRES {_presentFps:0.0} {msLabel}";
|
||||
_line2 = $"DRAWS {_drawsPerSecond:0}/S {drawsPerFrame:0}/F Q {pendingWork}+{inFlightSubmissions}";
|
||||
_line3 = $"ALLOC {_allocatedMbPerSecond:0.0} MB/S GC {_gen0PerWindow}/{_gen1PerWindow}/{_gen2PerWindow}";
|
||||
var heapMb = GC.GetTotalMemory(false) / (1024 * 1024);
|
||||
|
||||
@@ -1777,6 +1777,57 @@ internal static unsafe class VulkanVideoPresenter
|
||||
uint depth) =>
|
||||
checked(GetGuestImageByteCount(format, width, height) * Math.Max(depth, 1u));
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on the backing extent that guest CPU-write tracking is
|
||||
/// armed over. Deliberately equal to the presenter-side re-upload budget
|
||||
/// used by the AGC flip/acquire sync path: arming a range larger than the
|
||||
/// sync path is willing to read back would fault and dirty forever without
|
||||
/// ever producing a re-upload, so it is pure cost.
|
||||
/// </summary>
|
||||
internal const ulong MaxTrackedGuestImageBytes = 128UL * 1024UL * 1024UL;
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a guest surface is eligible for CPU-write tracking.
|
||||
/// The predicate is byte-based on purpose: the cost that actually scales
|
||||
/// with surface size is the dirty re-upload (one allocation plus a guest
|
||||
/// memory read of the whole extent per dirty flip), not the arming itself
|
||||
/// (one mprotect and, per write burst, one fault for the whole range).
|
||||
/// A resolution cap was the wrong proxy — it ignored bytes-per-texel and
|
||||
/// volume depth while excluding the 4K UI sheets that most need
|
||||
/// invalidation.
|
||||
/// </summary>
|
||||
internal static bool ShouldTrackGuestImageWrites(ulong byteCount) =>
|
||||
byteCount != 0 && byteCount <= MaxTrackedGuestImageBytes;
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a sampled guest image whose backing memory the parse
|
||||
/// thread just re-read should be re-uploaded from those bytes.
|
||||
/// <para>
|
||||
/// <paramref name="isCpuBacked"/> is a latch that flips false the first
|
||||
/// time an address is used as a render target and never flips back, so it
|
||||
/// cannot be the sole gate: a font atlas or UI sheet that was also
|
||||
/// rendered into is permanently frozen at its first upload afterwards.
|
||||
/// The write tracker answers the real question. A parse-time generation
|
||||
/// above zero means a guest CPU store was observed on the backing range,
|
||||
/// and a generation the last upload does not already cover means those
|
||||
/// bytes are newer than the host image.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Requiring a positive generation keeps the pure GPU-feedback case
|
||||
/// (render into an image, then sample it) safe: such a surface is tracked
|
||||
/// but never CPU-written, so its generation stays zero and the live image
|
||||
/// is preserved instead of being overwritten with guest memory.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static bool ShouldRefreshGuestImageFromCpu(
|
||||
bool isCpuBacked,
|
||||
long textureWriteGeneration,
|
||||
bool hasUploadedGeneration,
|
||||
long uploadedGeneration) =>
|
||||
isCpuBacked ||
|
||||
(textureWriteGeneration > 0 &&
|
||||
(!hasUploadedGeneration || uploadedGeneration != textureWriteGeneration));
|
||||
|
||||
// Maps a UNORM swapchain format to the sRGB view of the same bit layout,
|
||||
// or Undefined when no counterpart exists. Used to encode linear-float
|
||||
// guest flips on their way into a UNORM swapchain.
|
||||
@@ -1792,6 +1843,28 @@ internal static unsafe class VulkanVideoPresenter
|
||||
internal static bool IsLinearFloatPresentSource(Format format) =>
|
||||
format is Format.R16G16B16A16Sfloat or Format.R32G32B32A32Sfloat;
|
||||
|
||||
// A guest image accepts a request in a different Vulkan format without
|
||||
// being recreated when the two formats are the same texel layout read
|
||||
// through different transfer functions (sRGB vs UNORM counterparts).
|
||||
// Both must also be legal alias views of each other so the shared
|
||||
// mutable-format image can serve either identity. Same-class numeric
|
||||
// reinterpretation (R32Uint over R8G8B8A8Unorm, packed 10:10:10:2 over
|
||||
// 8:8:8:8) is excluded: the attachment keeps the existing image's
|
||||
// format, and a fragment shader translated for the other numeric type
|
||||
// would no longer match it.
|
||||
internal static bool IsAliasableGuestImageFormat(
|
||||
Format existingFormat,
|
||||
Format requestedFormat) =>
|
||||
existingFormat != requestedFormat &&
|
||||
Presenter.IsCompatibleViewFormat(existingFormat, requestedFormat) &&
|
||||
Presenter.GetStorageImageFormat(existingFormat) ==
|
||||
Presenter.GetStorageImageFormat(requestedFormat);
|
||||
|
||||
internal static bool IsCompatibleGuestImageViewFormat(
|
||||
Format imageFormat,
|
||||
Format viewFormat) =>
|
||||
Presenter.IsCompatibleViewFormat(imageFormat, viewFormat);
|
||||
|
||||
private static byte[]? TakeGuestImageInitialData(ulong address)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -3246,6 +3319,11 @@ internal static unsafe class VulkanVideoPresenter
|
||||
private readonly VulkanHostBufferPool _hostBufferPool;
|
||||
private readonly List<GuestBufferAllocation> _guestBufferAllocations = [];
|
||||
private readonly Queue<PendingGuestSubmission> _pendingGuestSubmissions = new();
|
||||
// Submissions whose fence timed out. Keep GPU objects alive until the
|
||||
// fence signals (or the device is lost) so a single hung compute
|
||||
// dispatch cannot re-block every subsequent capacity wait for the full
|
||||
// fence timeout (~3s → ~0.3 FPS).
|
||||
private readonly Queue<PendingGuestSubmission> _abandonedGuestSubmissions = new();
|
||||
private readonly Dictionary<string, ulong> _lastSubmittedTimelineByGuestQueue =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly Stack<DescriptorPool> _recycledDescriptorPools = new();
|
||||
@@ -4131,6 +4209,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
ShaderStorageImageExtendedFormats = supportedFeatures.ShaderStorageImageExtendedFormats,
|
||||
ShaderStorageImageReadWithoutFormat = supportedFeatures.ShaderStorageImageReadWithoutFormat,
|
||||
ShaderStorageImageWriteWithoutFormat = supportedFeatures.ShaderStorageImageWriteWithoutFormat,
|
||||
TextureCompressionBC = supportedFeatures.TextureCompressionBC,
|
||||
RobustBufferAccess = supportedFeatures.RobustBufferAccess,
|
||||
};
|
||||
|
||||
@@ -4170,6 +4249,13 @@ internal static unsafe class VulkanVideoPresenter
|
||||
"translated shaders using unformatted storage image load/store will fail.");
|
||||
}
|
||||
|
||||
if (!supportedFeatures.TextureCompressionBC)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] GPU does not support textureCompressionBC " +
|
||||
"guest BC1-BC7 textures cannot be sampled directly.");
|
||||
}
|
||||
|
||||
var maintenance8Features = new PhysicalDeviceMaintenance8FeaturesKHR
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceMaintenance8FeaturesKhr,
|
||||
@@ -5200,6 +5286,45 @@ internal static unsafe class VulkanVideoPresenter
|
||||
{
|
||||
CollectCompletedGuestSubmissions(waitForOldest: true);
|
||||
}
|
||||
|
||||
while (_abandonedGuestSubmissions.Count != 0)
|
||||
{
|
||||
CollectAbandonedGuestSubmissions();
|
||||
if (_abandonedGuestSubmissions.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!_abandonedGuestSubmissions.TryPeek(out var oldest))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var fence = oldest.Fence;
|
||||
var result = _vk.WaitForFences(
|
||||
_device,
|
||||
1,
|
||||
&fence,
|
||||
true,
|
||||
_guestFenceWaitTimeoutNs);
|
||||
if (result == Result.Timeout || result == Result.ErrorDeviceLost)
|
||||
{
|
||||
if (result == Result.ErrorDeviceLost)
|
||||
{
|
||||
_deviceLost = true;
|
||||
}
|
||||
|
||||
while (_abandonedGuestSubmissions.TryDequeue(out var abandoned))
|
||||
{
|
||||
RetireGuestSubmission(abandoned);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
Check(result, $"vkWaitForFences(abandoned: {oldest.DebugName})");
|
||||
CollectAbandonedGuestSubmissions();
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectCompletedGuestSubmissions(bool waitForOldest, ulong maxWaitNs = 0)
|
||||
@@ -5227,18 +5352,26 @@ internal static unsafe class VulkanVideoPresenter
|
||||
// would otherwise block the render thread forever, starving
|
||||
// the swapchain present (black screen). Log the culprit and
|
||||
// continue so at least the last good frame can be shown.
|
||||
if (!isProbeWait && _tracedFenceTimeouts.Add(oldest.DebugName))
|
||||
if (isProbeWait)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_tracedFenceTimeouts.Add(oldest.DebugName))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] vk.fence_wait_timeout submission='{oldest.DebugName}' " +
|
||||
$"— GPU work not completing after {_guestFenceWaitTimeoutNs / 1_000_000}ms; " +
|
||||
"render thread continuing (present not blocked).");
|
||||
"abandoning in-flight tracking so later frames are not re-blocked.");
|
||||
}
|
||||
|
||||
return;
|
||||
// Move out of the blocking queue without destroying GPU
|
||||
// objects yet — the work may still be running. Poll and
|
||||
// retire from the abandoned list once the fence signals.
|
||||
_pendingGuestSubmissions.Dequeue();
|
||||
_abandonedGuestSubmissions.Enqueue(oldest);
|
||||
}
|
||||
|
||||
if (result == Result.ErrorDeviceLost)
|
||||
else if (result == Result.ErrorDeviceLost)
|
||||
{
|
||||
_deviceLost = true;
|
||||
if (!_deviceLostLogged)
|
||||
@@ -5279,42 +5412,77 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
|
||||
_pendingGuestSubmissions.Dequeue();
|
||||
RetireGuestSubmission(submission);
|
||||
}
|
||||
|
||||
if (!_deviceLost)
|
||||
CollectAbandonedGuestSubmissions();
|
||||
ProcessDeferredTextureDestroys();
|
||||
}
|
||||
|
||||
private void CollectAbandonedGuestSubmissions()
|
||||
{
|
||||
var pending = _abandonedGuestSubmissions.Count;
|
||||
for (var i = 0; i < pending; i++)
|
||||
{
|
||||
if (!_abandonedGuestSubmissions.TryDequeue(out var submission))
|
||||
{
|
||||
foreach (var image in submission.TraceImages)
|
||||
{
|
||||
TraceGuestImageContents(image);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var resources in submission.Resources)
|
||||
var status = _vk.GetFenceStatus(_device, submission.Fence);
|
||||
if (status == Result.NotReady && !_deviceLost)
|
||||
{
|
||||
DestroyTranslatedDrawResources(resources);
|
||||
_abandonedGuestSubmissions.Enqueue(submission);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (buffer, memory) in submission.RetireBuffers)
|
||||
if (status == Result.ErrorDeviceLost)
|
||||
{
|
||||
_vk.DestroyBuffer(_device, buffer, null);
|
||||
_vk.FreeMemory(_device, memory, null);
|
||||
_deviceLost = true;
|
||||
}
|
||||
else if (status != Result.NotReady && status != Result.Success)
|
||||
{
|
||||
Check(status, $"vkGetFenceStatus(abandoned: {submission.DebugName})");
|
||||
}
|
||||
|
||||
// The fence has signalled, so the detile dispatch that used these
|
||||
// is done reading them; hand them back for the next texture.
|
||||
foreach (var transients in submission.RetireDetile)
|
||||
{
|
||||
_detilePass?.Retire(transients);
|
||||
}
|
||||
RetireGuestSubmission(submission);
|
||||
}
|
||||
}
|
||||
|
||||
ReleaseGuestCommandBuffer(submission.CommandBuffer);
|
||||
ReleaseGuestFence(submission.Fence, needsReset: true);
|
||||
if (submission.Timeline > _completedTimeline)
|
||||
private void RetireGuestSubmission(PendingGuestSubmission submission)
|
||||
{
|
||||
if (!_deviceLost)
|
||||
{
|
||||
foreach (var image in submission.TraceImages)
|
||||
{
|
||||
_completedTimeline = submission.Timeline;
|
||||
TraceGuestImageContents(image);
|
||||
}
|
||||
}
|
||||
|
||||
ProcessDeferredTextureDestroys();
|
||||
// The fence has signalled, so the detile dispatch that used these
|
||||
// is done reading them; hand them back for the next texture.
|
||||
foreach (var transients in submission.RetireDetile)
|
||||
{
|
||||
_detilePass?.Retire(transients);
|
||||
}
|
||||
|
||||
foreach (var resources in submission.Resources)
|
||||
{
|
||||
DestroyTranslatedDrawResources(resources);
|
||||
}
|
||||
|
||||
foreach (var (buffer, memory) in submission.RetireBuffers)
|
||||
{
|
||||
_vk.DestroyBuffer(_device, buffer, null);
|
||||
_vk.FreeMemory(_device, memory, null);
|
||||
}
|
||||
|
||||
ReleaseGuestCommandBuffer(submission.CommandBuffer);
|
||||
ReleaseGuestFence(submission.Fence, needsReset: true);
|
||||
if (submission.Timeline > _completedTimeline)
|
||||
{
|
||||
_completedTimeline = submission.Timeline;
|
||||
}
|
||||
}
|
||||
|
||||
private void WaitForAllGuestSubmissionsForCpuVisibility()
|
||||
@@ -8187,8 +8355,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
out TextureResource resource)
|
||||
{
|
||||
resource = default!;
|
||||
if (!guestImage.IsCpuBacked ||
|
||||
guestImage.Width != texture.Width ||
|
||||
if (guestImage.Width != texture.Width ||
|
||||
guestImage.Height != texture.Height ||
|
||||
guestImage.Depth != GetGuestTextureDepth(texture.Type, texture.Depth) ||
|
||||
IsGuestTexture3D(guestImage.Type) != IsGuestTexture3D(texture.Type) ||
|
||||
@@ -8198,6 +8365,32 @@ internal static unsafe class VulkanVideoPresenter
|
||||
return false;
|
||||
}
|
||||
|
||||
// IsCpuBacked alone used to gate this path, but it is a latch that
|
||||
// flips false the first time the address is used as a render target
|
||||
// and never flips back. On PS5 that address is unified memory: a
|
||||
// surface that was rendered into once and is later rewritten by the
|
||||
// guest CPU (glyph atlas rasterization, a 4K UI sheet redrawn on the
|
||||
// brightness screen) must still be re-read. Fall back on the write
|
||||
// tracker, which reports genuine CPU stores and leaves pure
|
||||
// render-into-then-sample feedback untouched.
|
||||
bool hasUploadedGeneration;
|
||||
long uploadedGeneration;
|
||||
lock (_gate)
|
||||
{
|
||||
hasUploadedGeneration = _cpuBackedUploadGenerations.TryGetValue(
|
||||
texture.Address,
|
||||
out uploadedGeneration);
|
||||
}
|
||||
|
||||
if (!ShouldRefreshGuestImageFromCpu(
|
||||
guestImage.IsCpuBacked,
|
||||
texture.WriteGeneration,
|
||||
hasUploadedGeneration,
|
||||
uploadedGeneration))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var rowLength = texture.TileMode == 0
|
||||
? Math.Max(texture.Pitch, texture.Width)
|
||||
: texture.Width;
|
||||
@@ -12166,6 +12359,14 @@ internal static unsafe class VulkanVideoPresenter
|
||||
? GetDepthOnlyColorTarget(depthOnlyTarget)
|
||||
: work.Targets[index];
|
||||
targets[index] = GetOrCreateGuestImage(targetDescriptor, formats[index]);
|
||||
// A view-compatible alias accept can return an image whose
|
||||
// identity differs from the request (sRGB vs UNORM
|
||||
// counterpart). The render pass, framebuffer views, and
|
||||
// pipeline cache key must all follow the format that actually
|
||||
// backs the attachment; a pipeline keyed on the requested
|
||||
// format could later be replayed inside a render pass of the
|
||||
// other identity.
|
||||
formats[index] = targets[index].Format;
|
||||
if (work.Targets[index].Address != 0 &&
|
||||
TakeGuestImageInitialData(work.Targets[index].Address) is { } initialData &&
|
||||
!targets[index].Initialized &&
|
||||
@@ -13165,6 +13366,20 @@ internal static unsafe class VulkanVideoPresenter
|
||||
$"address 0x{target.Address:X16}.");
|
||||
}
|
||||
|
||||
if (!supportsStorageUsage)
|
||||
{
|
||||
// sRGB targets must stay shareable with later UNORM
|
||||
// ImageLoad/Store aliases of the same surface. The image is
|
||||
// created with MUTABLE_FORMAT | EXTENDED_USAGE, so carrying
|
||||
// storage usage is legal as long as the view-compatible UNORM
|
||||
// counterpart supports storage; stores then go through the
|
||||
// UNORM alias view instead of recreating the image.
|
||||
var storageCounterpart = GetStorageImageFormat(format);
|
||||
supportsStorageUsage = storageCounterpart != format &&
|
||||
IsCompatibleViewFormat(format, storageCounterpart) &&
|
||||
SupportsStorageImage(storageCounterpart);
|
||||
}
|
||||
|
||||
// Storage/UAV images keep native guest dimensions (compute shaders index them directly).
|
||||
var physicalWidth = requiresStorage
|
||||
? target.Width
|
||||
@@ -13190,13 +13405,29 @@ internal static unsafe class VulkanVideoPresenter
|
||||
format);
|
||||
if (_guestImages.TryGetValue(target.Address, out var existing))
|
||||
{
|
||||
// View-compatible formats (sRGB vs UNORM of the same texel
|
||||
// layout) are the same guest surface accessed through
|
||||
// different number formats — render as sRGB, ImageLoad/Store
|
||||
// as UNORM is a standard PS5 pattern (AvPlayer movie copies,
|
||||
// post-process chains). Recreating the image for that case
|
||||
// ping-pongs content between two VkImages and every transition
|
||||
// loses the rendered pixels; the mutable-format image accepts
|
||||
// an alternate-format view instead. Aliasing is limited to
|
||||
// sRGB/UNORM counterparts: broader same-class reinterpretation
|
||||
// (e.g. R32Uint over R8G8B8A8Unorm) would attach pipelines
|
||||
// whose fragment output type no longer matches the attachment,
|
||||
// so those keep the recreate path.
|
||||
var exactFormatMatch =
|
||||
existing.GuestFormat == guestFormat &&
|
||||
existing.Format == format;
|
||||
if (existing.LogicalWidth == target.Width &&
|
||||
existing.LogicalHeight == target.Height &&
|
||||
existing.LogicalDepth == depth &&
|
||||
existing.Type == type &&
|
||||
existing.MipLevels == mipLevels &&
|
||||
existing.GuestFormat == guestFormat &&
|
||||
existing.Format == format)
|
||||
(exactFormatMatch ||
|
||||
(IsAliasableGuestImageFormat(existing.Format, format) &&
|
||||
(!requiresStorage || existing.SupportsStorageUsage))))
|
||||
{
|
||||
if (requiresStorage && !existing.SupportsStorageUsage)
|
||||
{
|
||||
@@ -13280,20 +13511,33 @@ internal static unsafe class VulkanVideoPresenter
|
||||
retained.IsCpuBacked = false;
|
||||
retained.CpuContentFingerprint = 0;
|
||||
_guestImages.Add(target.Address, retained);
|
||||
var retainedByteCount = GetTextureByteCount(
|
||||
target.Format,
|
||||
target.Width,
|
||||
target.Height,
|
||||
depth);
|
||||
lock (_gate)
|
||||
{
|
||||
_cpuBackedUploadGenerations.Remove(target.Address);
|
||||
_guestImageExtents[target.Address] = (
|
||||
target.Width,
|
||||
target.Height,
|
||||
GetTextureByteCount(
|
||||
target.Format,
|
||||
target.Width,
|
||||
target.Height,
|
||||
depth));
|
||||
retainedByteCount);
|
||||
}
|
||||
|
||||
TrackCpuBackedGuestImage(retained);
|
||||
// Arm the exact extent the flip/acquire sync path would read
|
||||
// back, budgeted by bytes rather than by resolution: the old
|
||||
// 1920x1080 cap left every 4K surface permanently
|
||||
// un-invalidated, so a guest CPU rewrite of one was never
|
||||
// reflected and the sample served stale bytes.
|
||||
if (ShouldTrackGuestImageWrites(retainedByteCount))
|
||||
{
|
||||
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
||||
target.Address,
|
||||
retainedByteCount,
|
||||
CurrentGuestWorkSequenceForDiagnostics,
|
||||
"vulkan.render-target");
|
||||
}
|
||||
|
||||
if (_traceGuestImageEvents)
|
||||
{
|
||||
@@ -13432,19 +13676,31 @@ internal static unsafe class VulkanVideoPresenter
|
||||
SetDebugName(ObjectType.Framebuffer, framebuffer.Handle, $"{debugName} framebuffer");
|
||||
}
|
||||
_guestImages.Add(target.Address, resource);
|
||||
var createdByteCount = GetTextureByteCount(
|
||||
target.Format,
|
||||
target.Width,
|
||||
target.Height,
|
||||
depth);
|
||||
lock (_gate)
|
||||
{
|
||||
_guestImageExtents[target.Address] = (
|
||||
target.Width,
|
||||
target.Height,
|
||||
GetTextureByteCount(
|
||||
target.Format,
|
||||
target.Width,
|
||||
target.Height,
|
||||
depth));
|
||||
createdByteCount);
|
||||
}
|
||||
|
||||
TrackCpuBackedGuestImage(resource);
|
||||
// See the retained-variant path above: track the full backing
|
||||
// extent under a byte budget instead of a resolution cap so
|
||||
// oversized render targets the guest later rewrites with the CPU
|
||||
// are re-uploaded on the next sample.
|
||||
if (ShouldTrackGuestImageWrites(createdByteCount))
|
||||
{
|
||||
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
||||
target.Address,
|
||||
createdByteCount,
|
||||
CurrentGuestWorkSequenceForDiagnostics,
|
||||
"vulkan.render-target");
|
||||
}
|
||||
|
||||
if (_traceGuestImageEvents)
|
||||
{
|
||||
@@ -13458,24 +13714,25 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
private void TrackCpuBackedGuestImage(GuestImageResource image)
|
||||
{
|
||||
// Arm ≤1080p guest images so native CPU stores fault. Drain skips
|
||||
// false overlap dirties with a 4 KiB zero probe unless IsCpuBacked.
|
||||
if (image.Width == 0 ||
|
||||
image.Height == 0 ||
|
||||
image.Width > 1920 ||
|
||||
image.Height > 1080)
|
||||
if (image.Width == 0 || image.Height == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var depth = Math.Max(image.Depth, 1u);
|
||||
var byteCount = GetVulkanImageByteCount(
|
||||
image.Format,
|
||||
image.Width,
|
||||
image.Height,
|
||||
depth);
|
||||
if (!ShouldTrackGuestImageWrites(byteCount))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
||||
image.Address,
|
||||
GetVulkanImageByteCount(
|
||||
image.Format,
|
||||
image.Width,
|
||||
image.Height,
|
||||
depth),
|
||||
byteCount,
|
||||
CurrentGuestWorkSequenceForDiagnostics,
|
||||
"vulkan.render-target");
|
||||
}
|
||||
@@ -14185,8 +14442,23 @@ internal static unsafe class VulkanVideoPresenter
|
||||
{
|
||||
Format.R8Unorm or
|
||||
Format.R8Uint or
|
||||
Format.R8Sint => 8,
|
||||
Format.R16Sfloat => 16,
|
||||
Format.R8Sint or
|
||||
Format.R8SNorm => 8,
|
||||
// Every single-channel 16-bit format shares this class, not just
|
||||
// the float one. Omitting the rest made GetVulkanImageByteCount
|
||||
// return zero for them, and a zero expected size rejects the
|
||||
// guest's upload outright — the texture then samples as blank
|
||||
// for the life of the run. Silent Hill uploads R16Unorm at
|
||||
// 144x81 through 1024x1024 and every one was dropped.
|
||||
Format.R16Sfloat or
|
||||
Format.R16Unorm or
|
||||
Format.R16SNorm or
|
||||
Format.R16Uint or
|
||||
Format.R16Sint or
|
||||
Format.R8G8Unorm or
|
||||
Format.R8G8SNorm or
|
||||
Format.R8G8Uint or
|
||||
Format.R8G8Sint => 16,
|
||||
Format.R32Uint or
|
||||
Format.R32Sint or
|
||||
Format.R32Sfloat or
|
||||
@@ -15261,6 +15533,9 @@ internal static unsafe class VulkanVideoPresenter
|
||||
Format.R8G8B8A8Uint or
|
||||
Format.R8G8B8A8Sint or
|
||||
Format.R8G8B8A8Unorm or
|
||||
Format.R8G8B8A8Srgb or
|
||||
Format.B8G8R8A8Unorm or
|
||||
Format.B8G8R8A8Srgb or
|
||||
Format.A2R10G10B10UnormPack32 or
|
||||
Format.A2B10G10R10UnormPack32 => 4,
|
||||
Format.R16G16B16A16Uint or
|
||||
|
||||
@@ -392,6 +392,10 @@ public static partial class Gen5MslTranslator
|
||||
if (input.ComponentCount is >= 1 and <= 4)
|
||||
{
|
||||
_vertexInputsByPc.TryAdd(input.Pc, input);
|
||||
foreach (var aliasPc in input.AliasPcs ?? [])
|
||||
{
|
||||
_vertexInputsByPc.TryAdd(aliasPc, input);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,6 +903,22 @@ public static partial class Gen5SpirvTranslator
|
||||
width);
|
||||
break;
|
||||
}
|
||||
case "VBfeI32":
|
||||
{
|
||||
// Same extract as VBfeU32 but sign-extended from the top bit
|
||||
// of the extracted field, so the result type must be signed
|
||||
// and bitcast back for storage.
|
||||
var width = BitwiseAnd(GetRawSource(instruction, 2), UInt(31));
|
||||
result = Bitcast(
|
||||
_uintType,
|
||||
_module.AddInstruction(
|
||||
SpirvOp.BitFieldSExtract,
|
||||
_intType,
|
||||
Bitcast(_intType, GetRawSource(instruction, 0)),
|
||||
BitwiseAnd(GetRawSource(instruction, 1), UInt(31)),
|
||||
width));
|
||||
break;
|
||||
}
|
||||
case "VBfiB32":
|
||||
{
|
||||
var mask = GetRawSource(instruction, 0);
|
||||
@@ -2706,20 +2722,31 @@ public static partial class Gen5SpirvTranslator
|
||||
|
||||
if (applySdwaIntegerModifiers)
|
||||
{
|
||||
// SDWA ABS/NEG are floating-point sign-bit modifiers even on
|
||||
// a bit-move opcode: ABS clears the sign bit, NEG flips it.
|
||||
// Two's-complement negating the raw bits instead turns 1.0
|
||||
// into -4.0 and -3.0 into 1.5, which silently skews every
|
||||
// pass that y-flips its clip position with an SDWA-negated
|
||||
// V_MOV_B32 - the whole of UE's DrawRectangle.
|
||||
var signBit = selector switch
|
||||
{
|
||||
<= 3 => 0x80u,
|
||||
4 or 5 => 0x8000u,
|
||||
_ => 0x80000000u,
|
||||
};
|
||||
|
||||
if ((sdwa.AbsoluteMask & (1u << sourceIndex)) != 0)
|
||||
{
|
||||
value = Bitcast(
|
||||
_uintType,
|
||||
Ext(5, _intType, Bitcast(_intType, value)));
|
||||
value = BitwiseAnd(value, UInt(~signBit));
|
||||
}
|
||||
|
||||
if ((sdwa.NegateMask & (1u << sourceIndex)) != 0)
|
||||
{
|
||||
value = _module.AddInstruction(
|
||||
SpirvOp.ISub,
|
||||
SpirvOp.BitwiseXor,
|
||||
_uintType,
|
||||
UInt(0),
|
||||
value);
|
||||
value,
|
||||
UInt(signBit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1365,14 +1365,18 @@ public static partial class Gen5SpirvTranslator
|
||||
variable,
|
||||
SpirvDecoration.Location,
|
||||
input.Location);
|
||||
_vertexInputsByPc.TryAdd(
|
||||
input.Pc,
|
||||
new SpirvVertexInput(
|
||||
variable,
|
||||
type,
|
||||
componentType,
|
||||
input.ComponentCount,
|
||||
componentKind));
|
||||
var vertexInput = new SpirvVertexInput(
|
||||
variable,
|
||||
type,
|
||||
componentType,
|
||||
input.ComponentCount,
|
||||
componentKind);
|
||||
_vertexInputsByPc.TryAdd(input.Pc, vertexInput);
|
||||
foreach (var aliasPc in input.AliasPcs ?? [])
|
||||
{
|
||||
_vertexInputsByPc.TryAdd(aliasPc, vertexInput);
|
||||
}
|
||||
|
||||
_interfaces.Add(variable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,12 @@ public sealed record Gen5GlobalMemoryBinding(
|
||||
public bool WriteBackToGuest { get; set; } = true;
|
||||
}
|
||||
|
||||
// One attribute per distinct guest stream view. AliasPcs carries the other
|
||||
// fetch instructions that read the same view: uber-shaders fetch a stream from
|
||||
// every material branch, and the scalar evaluator visits one instruction on
|
||||
// several CFG paths. Both must resolve to this binding's single location,
|
||||
// because Metal caps a vertex function at 31 attributes and one location per
|
||||
// fetch instruction overruns that on UE's larger vertex shaders.
|
||||
public sealed record Gen5VertexInputBinding(
|
||||
uint Pc,
|
||||
uint Location,
|
||||
@@ -314,7 +320,8 @@ public sealed record Gen5VertexInputBinding(
|
||||
byte[] Data,
|
||||
int DataLength,
|
||||
bool DataPooled,
|
||||
bool PerInstance = false);
|
||||
bool PerInstance = false,
|
||||
IReadOnlyList<uint>? AliasPcs = null);
|
||||
|
||||
public sealed record Gen5ShaderEvaluation(
|
||||
IReadOnlyList<uint> InitialScalarRegisters,
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler;
|
||||
|
||||
@@ -36,6 +37,113 @@ public static class Gen5ShaderScalarEvaluator
|
||||
StringComparison.Ordinal);
|
||||
private static readonly object _scalarFallbackTraceGate = new();
|
||||
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedScalarFallbacks = [];
|
||||
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedDivergentDescriptors = [];
|
||||
|
||||
private static readonly ConditionalWeakTable<Gen5ShaderProgram, Ir.Gen5ScalarSsa> _scalarSsaCache = [];
|
||||
|
||||
private static readonly bool _divergentDescriptorGuard = !string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_IR_DESCRIPTOR_GUARD"),
|
||||
"0",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static Ir.Gen5ScalarSsa GetScalarSsa(Gen5ShaderState state) =>
|
||||
_scalarSsaCache.GetValue(
|
||||
state.Program,
|
||||
program => Ir.Gen5ScalarSsa.Build(program.Instructions, state.UserData));
|
||||
|
||||
/// <summary>
|
||||
/// The byte offset comes from an SGPR. When the instruction that produced that
|
||||
/// register is one the scalar evaluator cannot reproduce — a vector compare
|
||||
/// writing VCC, say, whose value depends on per-lane data — the register still
|
||||
/// holds whatever the linear walk left in it. Adding that to an otherwise valid
|
||||
/// base address is how descriptors turned into addresses far out of range.
|
||||
/// </summary>
|
||||
private static bool IsOffsetFromUnmodelledWriter(
|
||||
Gen5ShaderState state,
|
||||
Gen5ShaderInstruction instruction,
|
||||
Gen5ScalarMemoryControl control)
|
||||
{
|
||||
if (!_divergentDescriptorGuard || control.DynamicOffsetRegister is not { } offsetRegister)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ssa = GetScalarSsa(state);
|
||||
var reaching = ssa.GetReachingDefinitionAt(instruction.Pc, offsetRegister);
|
||||
if (reaching.State == Ir.IrReachingState.Multiple)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (reaching.State != Ir.IrReachingState.Single ||
|
||||
reaching.DefinitionPc == uint.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var writer = state.Program.Instructions
|
||||
.FirstOrDefault(candidate => candidate.Pc == reaching.DefinitionPc);
|
||||
return writer is not null && Ir.Gen5ScalarSsa.WritesVccImplicitly(writer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A descriptor assembled from registers that differ per incoming path is not a
|
||||
/// descriptor, it is whichever path the linear walk happened to take last.
|
||||
/// </summary>
|
||||
private static bool IsDescriptorFromDivergentMerge(
|
||||
Gen5ShaderState state,
|
||||
uint pc,
|
||||
uint scalarBase,
|
||||
uint registerCount)
|
||||
{
|
||||
if (!_divergentDescriptorGuard)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ssa = GetScalarSsa(state);
|
||||
if (!ssa.Graph.HasControlFlow)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var offset = 0u; offset < registerCount; offset++)
|
||||
{
|
||||
var reaching = ssa.GetReachingDefinitionAt(pc, scalarBase + offset);
|
||||
if (reaching.State == Ir.IrReachingState.Multiple)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ssa.GetScalarAt(pc, scalarBase + offset).State == Ir.IrScalarState.Merged)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void TraceDivergentDescriptor(
|
||||
Gen5ShaderState state,
|
||||
Gen5ShaderInstruction instruction,
|
||||
uint scalarBase,
|
||||
ulong baseAddress)
|
||||
{
|
||||
lock (_scalarFallbackTraceGate)
|
||||
{
|
||||
if (!_tracedDivergentDescriptors.Add((state.Program.Address, instruction.Pc)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] agc.descriptor_divergent " +
|
||||
$"shader=0x{state.Program.Address:X16} pc=0x{instruction.Pc:X} " +
|
||||
$"op={instruction.Opcode} base=s{scalarBase} " +
|
||||
$"linear_base_addr=0x{baseAddress:X16} (unbound instead of dereferenced)");
|
||||
}
|
||||
// Shaders whose empty SRT/EUD caused a null-base scalar pointer load.
|
||||
// Host submit of those translations has lost the Vulkan device; Agc skips
|
||||
// them before QueueSubmit.
|
||||
@@ -173,6 +281,13 @@ public static class Gen5ShaderScalarEvaluator
|
||||
var globalMemoryBindings = new List<Gen5GlobalMemoryBinding>();
|
||||
var globalMemoryByAddress = new Dictionary<(uint ScalarAddress, ulong BaseAddress), Gen5GlobalMemoryBinding>();
|
||||
var vertexInputBindings = new List<Gen5VertexInputBinding>();
|
||||
// Absolute element address plus record layout identifies the guest
|
||||
// stream view an attribute reads, so every fetch that resolves to it
|
||||
// shares one location instead of claiming a new one.
|
||||
var vertexInputByView =
|
||||
new Dictionary<(ulong Address, uint Stride, uint DataFormat,
|
||||
uint NumberFormat, uint ComponentCount), int>();
|
||||
var vertexInputAliasPcs = new List<List<uint>>();
|
||||
// Shared, cached, read-only: computed once per decoded program. The
|
||||
// set already includes every instruction's destination registers, so
|
||||
// the per-load additions the loop used to make are redundant.
|
||||
@@ -545,6 +660,41 @@ public static class Gen5ShaderScalarEvaluator
|
||||
return false;
|
||||
}
|
||||
|
||||
var vertexInputView = (
|
||||
SaturatingAdd(
|
||||
vertexInputBinding.BaseAddress,
|
||||
vertexInputBinding.OffsetBytes),
|
||||
vertexInputBinding.Stride,
|
||||
vertexInputBinding.DataFormat,
|
||||
vertexInputBinding.NumberFormat,
|
||||
vertexInputBinding.ComponentCount);
|
||||
if (vertexInputByView.TryGetValue(
|
||||
vertexInputView,
|
||||
out var existingVertexInput))
|
||||
{
|
||||
var aliasPcs = vertexInputAliasPcs[existingVertexInput];
|
||||
if (!aliasPcs.Contains(instruction.Pc))
|
||||
{
|
||||
aliasPcs.Add(instruction.Pc);
|
||||
}
|
||||
|
||||
// Descriptors for one view agree on size, but a path
|
||||
// that resolved a larger reachable range still has to
|
||||
// win so the capture covers every fetch.
|
||||
var aliasedBinding = vertexInputBindings[existingVertexInput];
|
||||
if (aliasedBinding.DataLength < vertexInputBinding.DataLength)
|
||||
{
|
||||
vertexInputBindings[existingVertexInput] = aliasedBinding with
|
||||
{
|
||||
DataLength = vertexInputBinding.DataLength,
|
||||
};
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
vertexInputByView[vertexInputView] = vertexInputBindings.Count;
|
||||
vertexInputAliasPcs.Add([]);
|
||||
vertexInputBindings.Add(vertexInputBinding);
|
||||
continue;
|
||||
}
|
||||
@@ -695,6 +845,18 @@ public static class Gen5ShaderScalarEvaluator
|
||||
|
||||
if (vertexInputBindings.Count != 0)
|
||||
{
|
||||
for (var index = 0; index < vertexInputBindings.Count; index++)
|
||||
{
|
||||
if (vertexInputAliasPcs[index].Count != 0)
|
||||
{
|
||||
vertexInputBindings[index] = vertexInputBindings[index] with
|
||||
{
|
||||
AliasPcs = vertexInputAliasPcs[index],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
TraceVertexInputShape(vertexInputBindings);
|
||||
if (!TryCaptureVertexInputData(
|
||||
ctx,
|
||||
vertexInputBindings,
|
||||
@@ -843,6 +1005,43 @@ public static class Gen5ShaderScalarEvaluator
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readonly bool _traceVertexInputShape =
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_VERTEX_SHAPE"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static readonly HashSet<string> _tracedVertexInputShapes = [];
|
||||
|
||||
private static void TraceVertexInputShape(
|
||||
IReadOnlyList<Gen5VertexInputBinding> bindings)
|
||||
{
|
||||
if (!_traceVertexInputShape)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var distinctPcs = bindings.Select(static binding => binding.Pc).Distinct().Count();
|
||||
var identities = bindings
|
||||
.Select(static binding =>
|
||||
$"{binding.BaseAddress:X}/{binding.Stride}/{binding.OffsetBytes}/" +
|
||||
$"{binding.DataFormat}/{binding.NumberFormat}/{binding.ComponentCount}")
|
||||
.ToArray();
|
||||
var shape =
|
||||
$"count={bindings.Count} distinct_pc={distinctPcs} " +
|
||||
$"distinct_view={identities.Distinct().Count()} " +
|
||||
$"views={string.Join(',', identities)}";
|
||||
lock (_tracedVertexInputShapes)
|
||||
{
|
||||
if (!_tracedVertexInputShapes.Add(shape))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[VERTEX-SHAPE] {shape}");
|
||||
}
|
||||
|
||||
private static void TraceTitleVertexInputs(IReadOnlyList<Gen5VertexInputBinding> bindings)
|
||||
{
|
||||
if (!string.Equals(
|
||||
@@ -1898,19 +2097,32 @@ public static class Gen5ShaderScalarEvaluator
|
||||
var address = unchecked(
|
||||
baseAddress +
|
||||
byteOffset) & ~3UL;
|
||||
var descriptorDiverged = IsDescriptorFromDivergentMerge(
|
||||
state,
|
||||
instruction.Pc,
|
||||
scalarBase.Value,
|
||||
isBufferLoad ? 4u : 2u) ||
|
||||
IsOffsetFromUnmodelledWriter(state, instruction, control);
|
||||
if (descriptorDiverged)
|
||||
{
|
||||
TraceDivergentDescriptor(state, instruction, scalarBase.Value, baseAddress);
|
||||
}
|
||||
|
||||
var bufferUnbound =
|
||||
isBufferLoad &&
|
||||
(!hasBufferDescriptor ||
|
||||
(descriptorDiverged ||
|
||||
!hasBufferDescriptor ||
|
||||
bufferDescriptor.SizeBytes == 0 ||
|
||||
(scalarRegisters[scalarBase.Value] == 0 &&
|
||||
scalarRegisters[scalarBase.Value + 1] == 0 &&
|
||||
scalarBase.Value + 3 < ScalarRegisterCount &&
|
||||
scalarRegisters[scalarBase.Value + 2] == 0 &&
|
||||
scalarRegisters[scalarBase.Value + 3] == 0));
|
||||
var scalarPointerUnbound = ShouldTreatScalarPointerAsUnbound(
|
||||
isBufferLoad,
|
||||
address,
|
||||
_strictScalarLoad);
|
||||
var scalarPointerUnbound = descriptorDiverged && !isBufferLoad ||
|
||||
ShouldTreatScalarPointerAsUnbound(
|
||||
isBufferLoad,
|
||||
address,
|
||||
_strictScalarLoad);
|
||||
if (scalarPointerUnbound)
|
||||
{
|
||||
TraceScalarPointerFallback(
|
||||
|
||||
@@ -1157,6 +1157,7 @@ public static class Gen5ShaderTranslator
|
||||
0x15D => "VSadU32",
|
||||
0x15E => "VCvtPkU8F32",
|
||||
0x148 => "VBfeU32",
|
||||
0x149 => "VBfeI32",
|
||||
0x169 => "VMulLoU32",
|
||||
0x16A => "VMulHiU32",
|
||||
0x16B => "VMulLoI32",
|
||||
@@ -1170,6 +1171,7 @@ public static class Gen5ShaderTranslator
|
||||
0x366 => "VMbcntHiU32B32",
|
||||
0x368 => "VCvtPknormI16F32",
|
||||
0x369 => "VCvtPknormU16F32",
|
||||
0x36A => "VCvtPkU16U32",
|
||||
0x373 => "VMadU32U16",
|
||||
0x346 => "VLshlAddU32",
|
||||
0x347 => "VAddLshlU32",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler.Ir;
|
||||
|
||||
public sealed class Gen5IrBranchResolver : IIrBranchResolver
|
||||
{
|
||||
public static Gen5IrBranchResolver Instance { get; } = new();
|
||||
|
||||
public bool IsBranch(Gen5ShaderInstruction instruction) =>
|
||||
IsUnconditionalBranch(instruction) ||
|
||||
IsConditional(instruction) ||
|
||||
IsTerminator(instruction);
|
||||
|
||||
public bool IsConditional(Gen5ShaderInstruction instruction) => instruction.Opcode switch
|
||||
{
|
||||
"SCbranchScc0" or
|
||||
"SCbranchScc1" or
|
||||
"SCbranchVccz" or
|
||||
"SCbranchVccnz" or
|
||||
"SCbranchExecz" or
|
||||
"SCbranchExecnz" or
|
||||
"SCbranchCdbgsys" or
|
||||
"SCbranchCdbguser" or
|
||||
"SCbranchCdbgsysOrUser" or
|
||||
"SCbranchCdbgsysAndUser" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc)
|
||||
{
|
||||
targetPc = 0;
|
||||
if (IsTerminator(instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsUnconditionalBranch(instruction) && !IsConditional(instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instruction.Encoding != Gen5ShaderEncoding.Sopp || instruction.Words.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var offset = unchecked((short)(instruction.Words[0] & 0xFFFF));
|
||||
var nextPc = (long)instruction.Pc + instruction.Words.Count * sizeof(uint);
|
||||
var target = nextPc + offset * sizeof(uint);
|
||||
if (target < 0 || target > uint.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
targetPc = (uint)target;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsUnconditionalBranch(Gen5ShaderInstruction instruction) =>
|
||||
string.Equals(instruction.Opcode, "SBranch", StringComparison.Ordinal);
|
||||
|
||||
public static bool IsTerminator(Gen5ShaderInstruction instruction) =>
|
||||
instruction.Opcode is "SEndpgm" or "SEndpgmSaved";
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler.Ir;
|
||||
|
||||
public enum IrScalarState
|
||||
{
|
||||
Unknown,
|
||||
Constant,
|
||||
Merged,
|
||||
}
|
||||
|
||||
public enum IrReachingState
|
||||
{
|
||||
None,
|
||||
Single,
|
||||
Multiple,
|
||||
}
|
||||
|
||||
public readonly record struct IrReachingDefinition(IrReachingState State, uint DefinitionPc)
|
||||
{
|
||||
public static readonly IrReachingDefinition None = new(IrReachingState.None, 0);
|
||||
|
||||
public static readonly IrReachingDefinition Multiple = new(IrReachingState.Multiple, 0);
|
||||
|
||||
public static IrReachingDefinition At(uint pc) => new(IrReachingState.Single, pc);
|
||||
|
||||
public IrReachingDefinition Join(IrReachingDefinition other)
|
||||
{
|
||||
if (State == IrReachingState.None)
|
||||
{
|
||||
return other;
|
||||
}
|
||||
|
||||
if (other.State == IrReachingState.None)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
if (State == IrReachingState.Single &&
|
||||
other.State == IrReachingState.Single &&
|
||||
DefinitionPc == other.DefinitionPc)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
return Multiple;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct IrScalarValue(IrScalarState State, uint Constant)
|
||||
{
|
||||
public static readonly IrScalarValue Unknown = new(IrScalarState.Unknown, 0);
|
||||
|
||||
public static readonly IrScalarValue Merged = new(IrScalarState.Merged, 0);
|
||||
|
||||
public static IrScalarValue FromConstant(uint value) => new(IrScalarState.Constant, value);
|
||||
|
||||
public bool IsResolved => State == IrScalarState.Constant;
|
||||
|
||||
public IrScalarValue Join(IrScalarValue other)
|
||||
{
|
||||
if (State == IrScalarState.Unknown)
|
||||
{
|
||||
return other;
|
||||
}
|
||||
|
||||
if (other.State == IrScalarState.Unknown)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
if (State == IrScalarState.Constant &&
|
||||
other.State == IrScalarState.Constant &&
|
||||
Constant == other.Constant)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
|
||||
return Merged;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Gen5ScalarSsa
|
||||
{
|
||||
public const int ScalarRegisterCount = 256;
|
||||
|
||||
private Gen5ScalarSsa(
|
||||
IrControlFlowGraph graph,
|
||||
IReadOnlyList<IrScalarValue[]> entryState,
|
||||
IReadOnlyList<IrScalarValue[]> exitState,
|
||||
IReadOnlyList<IrReachingDefinition[]> entryDefinitions,
|
||||
IReadOnlyDictionary<uint, int> blockByPc,
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions)
|
||||
{
|
||||
Graph = graph;
|
||||
_entryState = entryState;
|
||||
_exitState = exitState;
|
||||
_entryDefinitions = entryDefinitions;
|
||||
_blockByPc = blockByPc;
|
||||
_instructions = instructions;
|
||||
}
|
||||
|
||||
private readonly IReadOnlyList<IrReachingDefinition[]> _entryDefinitions;
|
||||
|
||||
public IrControlFlowGraph Graph { get; }
|
||||
|
||||
private readonly IReadOnlyList<IrScalarValue[]> _entryState;
|
||||
private readonly IReadOnlyList<IrScalarValue[]> _exitState;
|
||||
private readonly IReadOnlyDictionary<uint, int> _blockByPc;
|
||||
private readonly IReadOnlyList<Gen5ShaderInstruction> _instructions;
|
||||
|
||||
public static Gen5ScalarSsa Build(
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||
IReadOnlyList<uint> userData,
|
||||
IIrBranchResolver? resolver = null)
|
||||
{
|
||||
resolver ??= Gen5IrBranchResolver.Instance;
|
||||
var graph = IrControlFlowGraph.Build(instructions, resolver);
|
||||
var blockCount = graph.Blocks.Count;
|
||||
|
||||
var entry = new List<IrScalarValue[]>(blockCount);
|
||||
var exit = new List<IrScalarValue[]>(blockCount);
|
||||
var defEntry = new List<IrReachingDefinition[]>(blockCount);
|
||||
var defExit = new List<IrReachingDefinition[]>(blockCount);
|
||||
for (var index = 0; index < blockCount; index++)
|
||||
{
|
||||
entry.Add(NewState());
|
||||
exit.Add(NewState());
|
||||
defEntry.Add(NewDefinitions());
|
||||
defExit.Add(NewDefinitions());
|
||||
}
|
||||
|
||||
if (blockCount > 0)
|
||||
{
|
||||
var initial = entry[0];
|
||||
for (var index = 0; index < userData.Count && index < ScalarRegisterCount; index++)
|
||||
{
|
||||
initial[index] = IrScalarValue.FromConstant(userData[index]);
|
||||
}
|
||||
}
|
||||
|
||||
var blockByPc = new Dictionary<uint, int>();
|
||||
for (var blockIndex = 0; blockIndex < blockCount; blockIndex++)
|
||||
{
|
||||
var range = graph.Blocks[blockIndex];
|
||||
foreach (var instruction in instructions)
|
||||
{
|
||||
if (instruction.Pc >= range.StartPc && instruction.Pc < range.EndPc)
|
||||
{
|
||||
blockByPc[instruction.Pc] = blockIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var worklist = new Queue<int>();
|
||||
for (var index = 0; index < blockCount; index++)
|
||||
{
|
||||
worklist.Enqueue(index);
|
||||
}
|
||||
|
||||
var visits = new int[blockCount];
|
||||
const int visitLimit = 8;
|
||||
while (worklist.Count > 0)
|
||||
{
|
||||
var blockIndex = worklist.Dequeue();
|
||||
if (visits[blockIndex]++ > visitLimit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var state = (IrScalarValue[])entry[blockIndex].Clone();
|
||||
if (graph.Predecessors[blockIndex].Count > 0)
|
||||
{
|
||||
state = NewState();
|
||||
var first = true;
|
||||
foreach (var predecessor in graph.Predecessors[blockIndex])
|
||||
{
|
||||
var incoming = exit[predecessor];
|
||||
for (var register = 0; register < ScalarRegisterCount; register++)
|
||||
{
|
||||
state[register] = first
|
||||
? incoming[register]
|
||||
: state[register].Join(incoming[register]);
|
||||
}
|
||||
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (blockIndex == 0)
|
||||
{
|
||||
for (var register = 0; register < userData.Count && register < ScalarRegisterCount; register++)
|
||||
{
|
||||
state[register] = state[register].Join(
|
||||
IrScalarValue.FromConstant(userData[register]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entry[blockIndex] = state;
|
||||
|
||||
var definitions = NewDefinitions();
|
||||
if (graph.Predecessors[blockIndex].Count == 0)
|
||||
{
|
||||
for (var register = 0; register < userData.Count && register < ScalarRegisterCount; register++)
|
||||
{
|
||||
definitions[register] = IrReachingDefinition.At(uint.MaxValue);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var first = true;
|
||||
foreach (var predecessor in graph.Predecessors[blockIndex])
|
||||
{
|
||||
var incoming = defExit[predecessor];
|
||||
for (var register = 0; register < ScalarRegisterCount; register++)
|
||||
{
|
||||
definitions[register] = first
|
||||
? incoming[register]
|
||||
: definitions[register].Join(incoming[register]);
|
||||
}
|
||||
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
defEntry[blockIndex] = definitions;
|
||||
|
||||
var computed = Transfer(instructions, graph.Blocks[blockIndex], state);
|
||||
var computedDefinitions = TransferDefinitions(
|
||||
instructions,
|
||||
graph.Blocks[blockIndex],
|
||||
definitions);
|
||||
var changed = !SameState(exit[blockIndex], computed) ||
|
||||
!SameDefinitions(defExit[blockIndex], computedDefinitions);
|
||||
exit[blockIndex] = computed;
|
||||
defExit[blockIndex] = computedDefinitions;
|
||||
if (changed)
|
||||
{
|
||||
foreach (var successor in graph.Successors[blockIndex])
|
||||
{
|
||||
worklist.Enqueue(successor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Gen5ScalarSsa(graph, entry, exit, defEntry, blockByPc, instructions);
|
||||
}
|
||||
|
||||
public IrScalarValue GetScalarAt(uint pc, uint register)
|
||||
{
|
||||
if (register >= ScalarRegisterCount || !_blockByPc.TryGetValue(pc, out var blockIndex))
|
||||
{
|
||||
return IrScalarValue.Unknown;
|
||||
}
|
||||
|
||||
var state = (IrScalarValue[])_entryState[blockIndex].Clone();
|
||||
var range = _graphRange(blockIndex);
|
||||
foreach (var instruction in _instructions)
|
||||
{
|
||||
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instruction.Pc >= pc)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Apply(instruction, state);
|
||||
}
|
||||
|
||||
return state[register];
|
||||
}
|
||||
|
||||
public IrReachingDefinition GetReachingDefinitionAt(uint pc, uint register)
|
||||
{
|
||||
if (register >= ScalarRegisterCount || !_blockByPc.TryGetValue(pc, out var blockIndex))
|
||||
{
|
||||
return IrReachingDefinition.None;
|
||||
}
|
||||
|
||||
var definitions = (IrReachingDefinition[])_entryDefinitions[blockIndex].Clone();
|
||||
var range = _graphRange(blockIndex);
|
||||
foreach (var instruction in _instructions)
|
||||
{
|
||||
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instruction.Pc >= pc)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ApplyDefinitions(instruction, definitions);
|
||||
}
|
||||
|
||||
return definitions[register];
|
||||
}
|
||||
|
||||
public bool IsInsideDivergentMerge(uint pc) =>
|
||||
_blockByPc.TryGetValue(pc, out var blockIndex) &&
|
||||
_graphPredecessorCount(blockIndex) > 1;
|
||||
|
||||
private IrBlockRange _graphRange(int blockIndex) => Graph.Blocks[blockIndex];
|
||||
|
||||
private int _graphPredecessorCount(int blockIndex) => Graph.Predecessors[blockIndex].Count;
|
||||
|
||||
private static IrScalarValue[] NewState()
|
||||
{
|
||||
var state = new IrScalarValue[ScalarRegisterCount];
|
||||
for (var index = 0; index < state.Length; index++)
|
||||
{
|
||||
state[index] = IrScalarValue.Unknown;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private static IrReachingDefinition[] NewDefinitions()
|
||||
{
|
||||
var definitions = new IrReachingDefinition[ScalarRegisterCount];
|
||||
for (var index = 0; index < definitions.Length; index++)
|
||||
{
|
||||
definitions[index] = IrReachingDefinition.None;
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private static bool SameDefinitions(IrReachingDefinition[] left, IrReachingDefinition[] right)
|
||||
{
|
||||
for (var index = 0; index < left.Length; index++)
|
||||
{
|
||||
if (!left[index].Equals(right[index]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IrReachingDefinition[] TransferDefinitions(
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||
IrBlockRange range,
|
||||
IrReachingDefinition[] entry)
|
||||
{
|
||||
var definitions = (IrReachingDefinition[])entry.Clone();
|
||||
foreach (var instruction in instructions)
|
||||
{
|
||||
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ApplyDefinitions(instruction, definitions);
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
public const uint VccLo = 106;
|
||||
|
||||
public const uint VccHi = 107;
|
||||
|
||||
/// <summary>
|
||||
/// VOPC compares and the VOP2 carry forms write VCC without naming it: the ISA
|
||||
/// makes the destination implicit in the encoding, so the decoded instruction
|
||||
/// carries no destination operand for it. Modelling that here (rather than in
|
||||
/// the shared decoder) keeps the linear evaluator's behaviour untouched while
|
||||
/// letting the dataflow see that VCC was written.
|
||||
/// </summary>
|
||||
public static bool WritesVccImplicitly(Gen5ShaderInstruction instruction)
|
||||
{
|
||||
if (instruction.Encoding == Gen5ShaderEncoding.Vopc)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return instruction.Encoding == Gen5ShaderEncoding.Vop2 &&
|
||||
instruction.Opcode is
|
||||
"VAddCoCiU32" or
|
||||
"VSubCoCiU32" or
|
||||
"VSubrevCoCiU32";
|
||||
}
|
||||
|
||||
private static void ApplyDefinitions(
|
||||
Gen5ShaderInstruction instruction,
|
||||
IrReachingDefinition[] definitions)
|
||||
{
|
||||
foreach (var destination in instruction.Destinations)
|
||||
{
|
||||
if (destination.Kind != Gen5OperandKind.ScalarRegister ||
|
||||
destination.Value >= ScalarRegisterCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
definitions[destination.Value] = IrReachingDefinition.At(instruction.Pc);
|
||||
}
|
||||
|
||||
if (WritesVccImplicitly(instruction))
|
||||
{
|
||||
definitions[VccLo] = IrReachingDefinition.At(instruction.Pc);
|
||||
definitions[VccHi] = IrReachingDefinition.At(instruction.Pc);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SameState(IrScalarValue[] left, IrScalarValue[] right)
|
||||
{
|
||||
for (var index = 0; index < left.Length; index++)
|
||||
{
|
||||
if (!left[index].Equals(right[index]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IrScalarValue[] Transfer(
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||
IrBlockRange range,
|
||||
IrScalarValue[] entry)
|
||||
{
|
||||
var state = (IrScalarValue[])entry.Clone();
|
||||
foreach (var instruction in instructions)
|
||||
{
|
||||
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Apply(instruction, state);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private static void Apply(Gen5ShaderInstruction instruction, IrScalarValue[] state)
|
||||
{
|
||||
var resolved = ResolveResult(instruction, state);
|
||||
foreach (var destination in instruction.Destinations)
|
||||
{
|
||||
if (destination.Kind != Gen5OperandKind.ScalarRegister ||
|
||||
destination.Value >= ScalarRegisterCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
state[destination.Value] = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
private static IrScalarValue ResolveResult(
|
||||
Gen5ShaderInstruction instruction,
|
||||
IrScalarValue[] state)
|
||||
{
|
||||
if (instruction.Destinations.Count != 1)
|
||||
{
|
||||
return IrScalarValue.Unknown;
|
||||
}
|
||||
|
||||
return instruction.Opcode switch
|
||||
{
|
||||
"SMov" or "SMovB32" => Source(instruction, state, 0),
|
||||
_ => IrScalarValue.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private static IrScalarValue Source(
|
||||
Gen5ShaderInstruction instruction,
|
||||
IrScalarValue[] state,
|
||||
int index)
|
||||
{
|
||||
if (index >= instruction.Sources.Count)
|
||||
{
|
||||
return IrScalarValue.Unknown;
|
||||
}
|
||||
|
||||
var source = instruction.Sources[index];
|
||||
return source.Kind switch
|
||||
{
|
||||
Gen5OperandKind.ScalarRegister when source.Value < ScalarRegisterCount =>
|
||||
state[source.Value],
|
||||
Gen5OperandKind.LiteralConstant => IrScalarValue.FromConstant(source.Value),
|
||||
_ => IrScalarValue.Unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler.Ir;
|
||||
|
||||
public readonly record struct IrBlockRange(uint StartPc, uint EndPc);
|
||||
|
||||
public sealed class IrControlFlowGraph
|
||||
{
|
||||
private IrControlFlowGraph(
|
||||
IReadOnlyList<IrBlockRange> blocks,
|
||||
IReadOnlyDictionary<uint, int> blockByStartPc,
|
||||
IReadOnlyList<IReadOnlyList<int>> successors,
|
||||
IReadOnlyList<IReadOnlyList<int>> predecessors,
|
||||
IReadOnlySet<int> loopHeaders)
|
||||
{
|
||||
Blocks = blocks;
|
||||
BlockByStartPc = blockByStartPc;
|
||||
Successors = successors;
|
||||
Predecessors = predecessors;
|
||||
LoopHeaders = loopHeaders;
|
||||
}
|
||||
|
||||
public IReadOnlyList<IrBlockRange> Blocks { get; }
|
||||
|
||||
public IReadOnlyDictionary<uint, int> BlockByStartPc { get; }
|
||||
|
||||
public IReadOnlyList<IReadOnlyList<int>> Successors { get; }
|
||||
|
||||
public IReadOnlyList<IReadOnlyList<int>> Predecessors { get; }
|
||||
|
||||
public IReadOnlySet<int> LoopHeaders { get; }
|
||||
|
||||
public bool HasControlFlow => Blocks.Count > 1;
|
||||
|
||||
public static IrControlFlowGraph Build(
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||
IIrBranchResolver resolver)
|
||||
{
|
||||
var leaders = new SortedSet<uint>();
|
||||
if (instructions.Count > 0)
|
||||
{
|
||||
leaders.Add(instructions[0].Pc);
|
||||
}
|
||||
|
||||
for (var index = 0; index < instructions.Count; index++)
|
||||
{
|
||||
var instruction = instructions[index];
|
||||
if (!resolver.IsBranch(instruction))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolver.TryGetBranchTarget(instruction, out var target))
|
||||
{
|
||||
leaders.Add(target);
|
||||
}
|
||||
|
||||
if (index + 1 < instructions.Count)
|
||||
{
|
||||
leaders.Add(instructions[index + 1].Pc);
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = leaders.ToList();
|
||||
var ranges = new List<IrBlockRange>(ordered.Count);
|
||||
var byStart = new Dictionary<uint, int>();
|
||||
for (var index = 0; index < ordered.Count; index++)
|
||||
{
|
||||
var start = ordered[index];
|
||||
var end = index + 1 < ordered.Count
|
||||
? ordered[index + 1]
|
||||
: instructions.Count > 0 ? instructions[^1].Pc + 1 : start;
|
||||
byStart[start] = ranges.Count;
|
||||
ranges.Add(new IrBlockRange(start, end));
|
||||
}
|
||||
|
||||
var successors = new List<List<int>>(ranges.Count);
|
||||
var predecessors = new List<List<int>>(ranges.Count);
|
||||
for (var index = 0; index < ranges.Count; index++)
|
||||
{
|
||||
successors.Add([]);
|
||||
predecessors.Add([]);
|
||||
}
|
||||
|
||||
for (var blockIndex = 0; blockIndex < ranges.Count; blockIndex++)
|
||||
{
|
||||
var range = ranges[blockIndex];
|
||||
var last = instructions
|
||||
.Where(candidate => candidate.Pc >= range.StartPc && candidate.Pc < range.EndPc)
|
||||
.LastOrDefault();
|
||||
if (last is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isBranch = resolver.IsBranch(last);
|
||||
var hasTarget = isBranch && resolver.TryGetBranchTarget(last, out var target) &&
|
||||
byStart.TryGetValue(target, out var targetIndex);
|
||||
if (hasTarget)
|
||||
{
|
||||
_ = resolver.TryGetBranchTarget(last, out var resolved);
|
||||
Link(successors, predecessors, blockIndex, byStart[resolved]);
|
||||
}
|
||||
|
||||
var fallsThrough = !isBranch || resolver.IsConditional(last);
|
||||
if (fallsThrough && blockIndex + 1 < ranges.Count)
|
||||
{
|
||||
Link(successors, predecessors, blockIndex, blockIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
var headers = new HashSet<int>();
|
||||
for (var blockIndex = 0; blockIndex < ranges.Count; blockIndex++)
|
||||
{
|
||||
foreach (var successor in successors[blockIndex])
|
||||
{
|
||||
if (successor <= blockIndex)
|
||||
{
|
||||
headers.Add(successor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new IrControlFlowGraph(
|
||||
ranges,
|
||||
byStart,
|
||||
successors.Select(list => (IReadOnlyList<int>)list).ToList(),
|
||||
predecessors.Select(list => (IReadOnlyList<int>)list).ToList(),
|
||||
headers);
|
||||
}
|
||||
|
||||
private static void Link(
|
||||
List<List<int>> successors,
|
||||
List<List<int>> predecessors,
|
||||
int from,
|
||||
int to)
|
||||
{
|
||||
if (!successors[from].Contains(to))
|
||||
{
|
||||
successors[from].Add(to);
|
||||
}
|
||||
|
||||
if (!predecessors[to].Contains(from))
|
||||
{
|
||||
predecessors[to].Add(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface IIrBranchResolver
|
||||
{
|
||||
bool IsBranch(Gen5ShaderInstruction instruction);
|
||||
|
||||
bool IsConditional(Gen5ShaderInstruction instruction);
|
||||
|
||||
bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// The kernel event-queue registry is process-wide static state; serialize against
|
||||
// other suites that register graphics events.
|
||||
[CollectionDefinition(AgcCommandBufferChainCollection.Name, DisableParallelization = true)]
|
||||
public sealed class AgcCommandBufferChainCollection
|
||||
{
|
||||
public const string Name = "AgcCommandBufferChainState";
|
||||
}
|
||||
|
||||
// A submission is one link of a chain, not always the whole command stream. When a
|
||||
// title's command arena fills mid-frame it continues in a fresh buffer, links the two
|
||||
// with an INDIRECT_BUFFER packet and submits only the first link, so a parser that
|
||||
// stops at the end of the submitted window silently drops the rest of that frame --
|
||||
// including its flip and the end-of-frame labels the guest waits on.
|
||||
[Collection(AgcCommandBufferChainCollection.Name)]
|
||||
public sealed class AgcCommandBufferChainTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const int MemorySize = 0x4000;
|
||||
|
||||
private const ulong HandleOutAddress = BaseAddress + 0x100;
|
||||
private const ulong EventsAddress = BaseAddress + 0x200;
|
||||
private const ulong OutCountAddress = BaseAddress + 0x300;
|
||||
private const ulong TimeoutAddress = BaseAddress + 0x400;
|
||||
private const ulong SubmitPacketAddress = BaseAddress + 0x500;
|
||||
private const ulong StackAddress = BaseAddress + 0x600;
|
||||
|
||||
private const ulong CommandBufferAddress = BaseAddress + 0x800;
|
||||
private const ulong FirstLinkAddress = BaseAddress + 0x1000;
|
||||
private const ulong SecondLinkAddress = BaseAddress + 0x2000;
|
||||
private const ulong WaitLabelAddress = BaseAddress + 0x3000;
|
||||
|
||||
// Graphics-queue completions land on ident 0, so its absence is how a suspended
|
||||
// queue is observed without standing up a GPU backend.
|
||||
private const ulong GraphicsCompletionIdent = 0;
|
||||
|
||||
[Fact]
|
||||
public void SubmittedDcb_FollowsIndirectBufferIntoTheChainedBuffer()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var equeue = CreateEqueue(ctx, memory);
|
||||
|
||||
try
|
||||
{
|
||||
RegisterGraphicsCompletion(equeue);
|
||||
|
||||
// The chained buffer parks on a label that never reaches its reference,
|
||||
// so reaching it at all suspends the queue.
|
||||
var secondLinkDwords = WriteUnsatisfiedWait(ctx, memory, SecondLinkAddress);
|
||||
var firstLinkDwords = WriteChain(
|
||||
ctx,
|
||||
memory,
|
||||
FirstLinkAddress,
|
||||
SecondLinkAddress,
|
||||
secondLinkDwords);
|
||||
|
||||
SubmitDcb(ctx, memory, FirstLinkAddress, firstLinkDwords);
|
||||
|
||||
Assert.NotEqual(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
WaitEqueue(ctx, memory, equeue));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteEqueue(ctx, equeue);
|
||||
}
|
||||
}
|
||||
|
||||
// Titles emit a zeroed INDIRECT_BUFFER as padding for a branch they decided not to
|
||||
// take. Treating that as a redirect truncates the frame it appears in.
|
||||
[Fact]
|
||||
public void SubmittedDcb_KeepsParsingPastAnEmptyIndirectBuffer()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var equeue = CreateEqueue(ctx, memory);
|
||||
|
||||
try
|
||||
{
|
||||
RegisterGraphicsCompletion(equeue);
|
||||
|
||||
var paddingDwords = WriteChain(ctx, memory, FirstLinkAddress, target: 0, targetDwords: 0);
|
||||
var waitDwords = WriteUnsatisfiedWait(
|
||||
ctx,
|
||||
memory,
|
||||
FirstLinkAddress + (paddingDwords * sizeof(uint)));
|
||||
|
||||
SubmitDcb(ctx, memory, FirstLinkAddress, paddingDwords + waitDwords);
|
||||
|
||||
Assert.NotEqual(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
WaitEqueue(ctx, memory, equeue));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteEqueue(ctx, equeue);
|
||||
}
|
||||
}
|
||||
|
||||
// A chain whose target is never satisfied must not be mistaken for a completed
|
||||
// submission: without the redirect the empty first link completes immediately.
|
||||
[Fact]
|
||||
public void SubmittedDcb_WithoutAChain_CompletesImmediately()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var equeue = CreateEqueue(ctx, memory);
|
||||
|
||||
try
|
||||
{
|
||||
RegisterGraphicsCompletion(equeue);
|
||||
|
||||
var paddingDwords = WriteChain(ctx, memory, FirstLinkAddress, target: 0, targetDwords: 0);
|
||||
SubmitDcb(ctx, memory, FirstLinkAddress, paddingDwords);
|
||||
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
WaitEqueue(ctx, memory, equeue));
|
||||
Assert.Equal(GraphicsCompletionIdent, ReadUInt64(memory, EventsAddress + 0x00));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteEqueue(ctx, equeue);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterGraphicsCompletion(ulong equeue) =>
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
equeue,
|
||||
GraphicsCompletionIdent,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0));
|
||||
|
||||
private static uint WriteChain(
|
||||
CpuContext ctx,
|
||||
FakeCpuMemory memory,
|
||||
ulong linkAddress,
|
||||
ulong target,
|
||||
uint targetDwords)
|
||||
{
|
||||
PointCommandBufferAt(memory, linkAddress);
|
||||
ctx[CpuRegister.Rdi] = CommandBufferAddress;
|
||||
ctx[CpuRegister.Rsi] = target;
|
||||
ctx[CpuRegister.Rdx] = targetDwords;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbJump(ctx));
|
||||
Assert.Equal(linkAddress, ctx[CpuRegister.Rax]);
|
||||
return 4;
|
||||
}
|
||||
|
||||
private static uint WriteUnsatisfiedWait(CpuContext ctx, FakeCpuMemory memory, ulong linkAddress)
|
||||
{
|
||||
PointCommandBufferAt(memory, linkAddress);
|
||||
WriteUInt32(memory, WaitLabelAddress, 0);
|
||||
ctx[CpuRegister.Rsp] = StackAddress;
|
||||
ctx[CpuRegister.Rdi] = CommandBufferAddress;
|
||||
ctx[CpuRegister.Rsi] = 0; // 32-bit compare
|
||||
ctx[CpuRegister.Rdx] = 3; // equal
|
||||
ctx[CpuRegister.Rcx] = 4; // memory space
|
||||
ctx[CpuRegister.R8] = 2;
|
||||
ctx[CpuRegister.R9] = WaitLabelAddress;
|
||||
WriteUInt64(memory, StackAddress + 8, 1); // reference the label never reaches
|
||||
WriteUInt64(memory, StackAddress + 16, 0xFFFF_FFFF); // mask
|
||||
WriteUInt32(memory, StackAddress + 24, 0x10); // poll interval
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx));
|
||||
return 7;
|
||||
}
|
||||
|
||||
private static void PointCommandBufferAt(FakeCpuMemory memory, ulong linkAddress)
|
||||
{
|
||||
WriteUInt64(memory, CommandBufferAddress + 0x10, linkAddress);
|
||||
WriteUInt64(memory, CommandBufferAddress + 0x18, linkAddress + 0x400);
|
||||
}
|
||||
|
||||
private static void SubmitDcb(
|
||||
CpuContext ctx,
|
||||
FakeCpuMemory memory,
|
||||
ulong commandAddress,
|
||||
uint dwordCount)
|
||||
{
|
||||
WriteUInt64(memory, SubmitPacketAddress, commandAddress);
|
||||
WriteUInt32(memory, SubmitPacketAddress + 8, dwordCount);
|
||||
ctx[CpuRegister.Rdi] = SubmitPacketAddress;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverSubmitDcb(ctx));
|
||||
}
|
||||
|
||||
private static ulong CreateEqueue(CpuContext ctx, FakeCpuMemory memory)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = HandleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
return ReadUInt64(memory, HandleOutAddress);
|
||||
}
|
||||
|
||||
private static void DeleteEqueue(CpuContext ctx, ulong equeue)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = equeue;
|
||||
_ = KernelEventQueueCompatExports.KernelDeleteEqueue(ctx);
|
||||
}
|
||||
|
||||
private static int WaitEqueue(CpuContext ctx, FakeCpuMemory memory, ulong equeue)
|
||||
{
|
||||
WriteUInt64(memory, TimeoutAddress, 0);
|
||||
ctx[CpuRegister.Rdi] = equeue;
|
||||
ctx[CpuRegister.Rsi] = EventsAddress;
|
||||
ctx[CpuRegister.Rdx] = 4;
|
||||
ctx[CpuRegister.Rcx] = OutCountAddress;
|
||||
ctx[CpuRegister.R8] = TimeoutAddress;
|
||||
return KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,20 @@ using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// The kernel event-queue registry is process-wide static state, and these tests assert over
|
||||
// every graphics registration in it, so they cannot run beside another suite that registers
|
||||
// graphics events.
|
||||
[CollectionDefinition(GraphicsEventQueueStateCollection.Name, DisableParallelization = true)]
|
||||
public sealed class GraphicsEventQueueStateCollection
|
||||
{
|
||||
public const string Name = "GraphicsEventQueueState";
|
||||
}
|
||||
|
||||
// IT_EVENT_WRITE carries a 6-bit hardware EVENT_TYPE, but sceAgcDriverAddEqEvent registers the
|
||||
// listener with a guest-defined eventId. Those two values are not the same numbering scheme, so
|
||||
// exact ident matching never wakes anything (issue #173). TriggerRegisteredEventsByFilter wakes
|
||||
// every graphics registration instead.
|
||||
[Collection(GraphicsEventQueueStateCollection.Name)]
|
||||
public sealed class AgcEventQueueTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
@@ -66,10 +76,20 @@ public sealed class AgcEventQueueTests
|
||||
// Verify the queued event carries the registered ident and the event type as data.
|
||||
Assert.Equal(registeredEventId, ReadUInt64(memory, eventsAddress + 0x00));
|
||||
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterGraphics, ReadInt16(memory, eventsAddress + 0x08));
|
||||
Assert.Equal(0u, ReadUInt16(memory, eventsAddress + 0x0A));
|
||||
Assert.Equal(
|
||||
KernelEventQueueCompatExports.KernelEventFlagClear,
|
||||
ReadUInt16(memory, eventsAddress + 0x0A));
|
||||
Assert.Equal(1u, ReadUInt32(memory, eventsAddress + 0x0C));
|
||||
Assert.Equal(eventType, ReadUInt64(memory, eventsAddress + 0x10));
|
||||
Assert.Equal(userData, ReadUInt64(memory, eventsAddress + 0x18));
|
||||
|
||||
// Registrations live in process-wide static state, and a sibling test asserts that
|
||||
// no graphics registration exists at all. Drop this one instead of relying on
|
||||
// execution order.
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -99,6 +119,370 @@ public sealed class AgcEventQueueTests
|
||||
Assert.Equal(0, triggered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturedCompletion_DoesNotWakeDeleteAndReAddGeneration()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var handle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
|
||||
const ulong eventId = 0x20;
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
handle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0x1111));
|
||||
var staleSnapshot =
|
||||
KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||
memory,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
Assert.Single(staleSnapshot.Targets);
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.DeleteRegisteredEvent(
|
||||
handle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics));
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
handle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0x2222));
|
||||
|
||||
var staleDelivery =
|
||||
KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||
staleSnapshot,
|
||||
eventId);
|
||||
Assert.Equal(0, staleDelivery.TriggeredCount);
|
||||
Assert.Equal(1, staleDelivery.StaleCount);
|
||||
Assert.False(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out _));
|
||||
|
||||
var liveSnapshot =
|
||||
KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||
memory,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
var liveDelivery =
|
||||
KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||
liveSnapshot,
|
||||
eventId);
|
||||
Assert.Equal(1, liveDelivery.TriggeredCount);
|
||||
Assert.Equal(0, liveDelivery.StaleCount);
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out var delivered));
|
||||
Assert.Equal(0x2222UL, delivered.UserData);
|
||||
|
||||
DeleteEqueue(ctx, handle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturedCompletion_PreservesEachQueuesRegistrationGeneration()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var firstHandle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
|
||||
var secondHandle = CreateEqueue(ctx, memory, BaseAddress + 0x108);
|
||||
const ulong eventId = 0x20;
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
firstHandle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0xAAAA));
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
secondHandle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0xBBBB));
|
||||
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||
memory,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
Assert.Equal(2, snapshot.Targets.Length);
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.DeleteRegisteredEvent(
|
||||
secondHandle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics));
|
||||
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||
snapshot,
|
||||
eventId);
|
||||
|
||||
Assert.Equal(1, delivery.TriggeredCount);
|
||||
Assert.Equal(1, delivery.StaleCount);
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
firstHandle,
|
||||
out var delivered));
|
||||
Assert.Equal(0xAAAAUL, delivered.UserData);
|
||||
Assert.False(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
secondHandle,
|
||||
out _));
|
||||
|
||||
DeleteEqueue(ctx, firstHandle);
|
||||
DeleteEqueue(ctx, secondHandle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturedCompletion_DoesNotWakeDeletedEqueue()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var handle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
handle,
|
||||
ident: 0,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0));
|
||||
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||
memory,
|
||||
ident: 0,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
DeleteEqueue(ctx, handle);
|
||||
|
||||
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||
snapshot,
|
||||
data: 0);
|
||||
|
||||
Assert.Equal(0, delivery.TriggeredCount);
|
||||
Assert.Equal(1, delivery.StaleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturedCompletion_IsScopedToCreatingRuntime()
|
||||
{
|
||||
var firstMemory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var secondMemory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var firstContext = new CpuContext(firstMemory, Generation.Gen5);
|
||||
var secondContext = new CpuContext(secondMemory, Generation.Gen5);
|
||||
var firstHandle = CreateEqueue(
|
||||
firstContext,
|
||||
firstMemory,
|
||||
BaseAddress + 0x100);
|
||||
var secondHandle = CreateEqueue(
|
||||
secondContext,
|
||||
secondMemory,
|
||||
BaseAddress + 0x100);
|
||||
const ulong eventId = 0x20;
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
firstHandle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0x1111));
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
secondHandle,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0x2222));
|
||||
|
||||
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||
firstMemory,
|
||||
eventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||
Assert.Single(snapshot.Targets);
|
||||
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||
snapshot,
|
||||
eventId);
|
||||
|
||||
Assert.Equal(1, delivery.TriggeredCount);
|
||||
Assert.Equal(0, delivery.StaleCount);
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
firstHandle,
|
||||
out var delivered));
|
||||
Assert.Equal(0x1111UL, delivered.UserData);
|
||||
Assert.False(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
secondHandle,
|
||||
out _));
|
||||
|
||||
DeleteEqueue(firstContext, firstHandle);
|
||||
DeleteEqueue(secondContext, secondHandle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnePendingEventCanBeReservedByOnlyOneWaiter()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
var handle = ReadUInt64(memory, handleOutAddress);
|
||||
|
||||
Assert.True(KernelEventQueueCompatExports.EnqueueEvent(
|
||||
handle,
|
||||
new KernelEventQueueCompatExports.KernelQueuedEvent(
|
||||
7,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
KernelEventQueueCompatExports.KernelEventFlagClear,
|
||||
1,
|
||||
0,
|
||||
0)));
|
||||
|
||||
Assert.Equal(
|
||||
1,
|
||||
KernelEventQueueCompatExports.ReservePendingEventCountForTest(
|
||||
handle,
|
||||
eventCapacity: 1));
|
||||
Assert.Equal(
|
||||
0,
|
||||
KernelEventQueueCompatExports.ReservePendingEventCountForTest(
|
||||
handle,
|
||||
eventCapacity: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroTimeoutWithNoEventReturnsWithoutHostWait()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||
const ulong eventsAddress = BaseAddress + 0x200;
|
||||
const ulong outCountAddress = BaseAddress + 0x300;
|
||||
const ulong timeoutAddress = BaseAddress + 0x400;
|
||||
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
var handle = ReadUInt64(memory, handleOutAddress);
|
||||
WriteUInt64(memory, timeoutAddress, 0);
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
ctx[CpuRegister.Rsi] = eventsAddress;
|
||||
ctx[CpuRegister.Rdx] = 1;
|
||||
ctx[CpuRegister.Rcx] = outCountAddress;
|
||||
ctx[CpuRegister.R8] = timeoutAddress;
|
||||
|
||||
var result = KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
|
||||
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT,
|
||||
result);
|
||||
Assert.Equal(0u, ReadUInt32(memory, outCountAddress));
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.IsSynchronousPoll(
|
||||
timeoutAddress,
|
||||
timeoutUsec: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LevelUserEventPersistsButEdgeEventClears()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
var handle = ReadUInt64(memory, handleOutAddress);
|
||||
|
||||
const ulong levelIdent = 0xA1;
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
ctx[CpuRegister.Rsi] = levelIdent;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelAddUserEvent(ctx));
|
||||
ctx[CpuRegister.Rdx] = 0x1111;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
|
||||
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out var firstLevel));
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out var secondLevel));
|
||||
Assert.Equal((ushort)0, firstLevel.Flags);
|
||||
Assert.Equal(0x1111UL, firstLevel.UserData);
|
||||
Assert.Equal(firstLevel, secondLevel);
|
||||
|
||||
ctx[CpuRegister.Rsi] = levelIdent;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteUserEvent(ctx));
|
||||
|
||||
const ulong edgeIdent = 0xA2;
|
||||
ctx[CpuRegister.Rsi] = edgeIdent;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelAddUserEventEdge(ctx));
|
||||
ctx[CpuRegister.Rdx] = 0x2222;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
|
||||
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out var edge));
|
||||
Assert.Equal(
|
||||
KernelEventQueueCompatExports.KernelEventFlagClear,
|
||||
edge.Flags);
|
||||
Assert.Equal(0x2222UL, edge.UserData);
|
||||
Assert.False(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out _));
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeletingLevelRegistrationClearsItsReadyState()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
var handle = ReadUInt64(memory, handleOutAddress);
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
ctx[CpuRegister.Rsi] = 0xB1;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelAddUserEvent(ctx));
|
||||
ctx[CpuRegister.Rdx] = 0x3333;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteUserEvent(ctx));
|
||||
|
||||
Assert.False(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
handle,
|
||||
out _));
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
@@ -133,4 +517,24 @@ public sealed class AgcEventQueueTests
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static ulong CreateEqueue(
|
||||
CpuContext ctx,
|
||||
FakeCpuMemory memory,
|
||||
ulong handleOutAddress)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
return ReadUInt64(memory, handleOutAddress);
|
||||
}
|
||||
|
||||
private static void DeleteEqueue(CpuContext ctx, ulong handle)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
public sealed class AgcLabelProducerRetentionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProducerHistoryCompactionNeverEvictsActiveRecords()
|
||||
{
|
||||
var entries = new List<(string Name, bool Completed)>
|
||||
{
|
||||
("active-a", false),
|
||||
("complete-a", true),
|
||||
("complete-b", true),
|
||||
("active-b", false),
|
||||
("complete-c", true),
|
||||
};
|
||||
|
||||
var removed = AgcExports.CompactCompletedEntries(
|
||||
entries,
|
||||
static entry => entry.Completed,
|
||||
targetCount: 2);
|
||||
|
||||
Assert.Equal(3, removed);
|
||||
Assert.Equal(
|
||||
["active-a", "active-b"],
|
||||
entries.Select(static entry => entry.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProducerHistoryMayExceedSoftBoundWhileAllRecordsAreActive()
|
||||
{
|
||||
var entries = new List<bool> { false, false, false };
|
||||
|
||||
var removed = AgcExports.CompactCompletedEntries(
|
||||
entries,
|
||||
static completed => completed,
|
||||
targetCount: 1);
|
||||
|
||||
Assert.Equal(0, removed);
|
||||
Assert.Equal(3, entries.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// sceAgcDriverSubmitAcb takes the compute queue's owner handle in rdi. That handle is the
|
||||
// eventId the guest registers with sceAgcDriverAddEqEvent, so an ACB reaching its queue fence
|
||||
// must deliver a graphics-filter completion event under that exact ident. UE 4.27's dynamic
|
||||
// resolution heuristic parks the game thread on that interrupt; without it the render side
|
||||
// never publishes GPU timings and the title deadlocks.
|
||||
[Collection(GraphicsEventQueueStateCollection.Name)]
|
||||
public sealed class AgcSubmitCompletionEventTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const int MemorySize = 0x2000;
|
||||
|
||||
private const ulong HandleOutAddress = BaseAddress + 0x100;
|
||||
private const ulong EventsAddress = BaseAddress + 0x200;
|
||||
private const ulong OutCountAddress = BaseAddress + 0x300;
|
||||
private const ulong TimeoutAddress = BaseAddress + 0x400;
|
||||
private const ulong PacketAddress = BaseAddress + 0x500;
|
||||
|
||||
[Fact]
|
||||
public void DriverSubmitAcb_DeliversCompletionEventUnderOwnerHandleIdent()
|
||||
{
|
||||
// Deliberately unusual so a graphics registration from a sibling test cannot alias it:
|
||||
// the kernel event registry is process-wide static state.
|
||||
const ulong ownerHandle = 0x5EA1;
|
||||
const ulong userData = 0xC0FF_EE00_1234_5678;
|
||||
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var equeue = CreateEqueue(ctx, memory);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
equeue,
|
||||
ownerHandle,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData));
|
||||
|
||||
SubmitEmptyAcb(ctx, memory, ownerHandle);
|
||||
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
WaitEqueue(ctx, memory, equeue));
|
||||
Assert.Equal(1u, ReadUInt32(memory, OutCountAddress));
|
||||
Assert.Equal(ownerHandle, ReadUInt64(memory, EventsAddress + 0x00));
|
||||
Assert.Equal(
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
ReadInt16(memory, EventsAddress + 0x08));
|
||||
Assert.Equal(ownerHandle, ReadUInt64(memory, EventsAddress + 0x10));
|
||||
Assert.Equal(userData, ReadUInt64(memory, EventsAddress + 0x18));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteEqueue(ctx, equeue);
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery is registration-gated: a title that never registered the ACB owner handle as a
|
||||
// graphics eventId must not observe a spurious completion interrupt.
|
||||
[Fact]
|
||||
public void DriverSubmitAcb_WithoutMatchingRegistration_DeliversNothing()
|
||||
{
|
||||
const ulong ownerHandle = 0x5EA2;
|
||||
const ulong unrelatedEventId = 0x5EA3;
|
||||
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var equeue = CreateEqueue(ctx, memory);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
equeue,
|
||||
unrelatedEventId,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData: 0));
|
||||
|
||||
SubmitEmptyAcb(ctx, memory, ownerHandle);
|
||||
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT,
|
||||
WaitEqueue(ctx, memory, equeue));
|
||||
Assert.Equal(0u, ReadUInt32(memory, OutCountAddress));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteEqueue(ctx, equeue);
|
||||
}
|
||||
}
|
||||
|
||||
// The graphics queue keeps its own completion ident (0); an ACB owner handle must not be
|
||||
// able to wake a graphics-queue completion registration or vice versa.
|
||||
[Fact]
|
||||
public void DriverSubmitDcb_DeliversCompletionEventUnderGraphicsIdent()
|
||||
{
|
||||
const ulong graphicsCompletionIdent = 0;
|
||||
const ulong userData = 0xABCD_0000_0000_1111;
|
||||
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
var equeue = CreateEqueue(ctx, memory);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||
equeue,
|
||||
graphicsCompletionIdent,
|
||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||
userData));
|
||||
|
||||
WriteEmptySubmitPacket(memory);
|
||||
ctx[CpuRegister.Rdi] = PacketAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
AgcExports.DriverSubmitDcb(ctx));
|
||||
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
WaitEqueue(ctx, memory, equeue));
|
||||
Assert.Equal(1u, ReadUInt32(memory, OutCountAddress));
|
||||
Assert.Equal(graphicsCompletionIdent, ReadUInt64(memory, EventsAddress + 0x00));
|
||||
Assert.Equal(userData, ReadUInt64(memory, EventsAddress + 0x18));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteEqueue(ctx, equeue);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SubmitEmptyAcb(CpuContext ctx, FakeCpuMemory memory, ulong ownerHandle)
|
||||
{
|
||||
WriteEmptySubmitPacket(memory);
|
||||
ctx[CpuRegister.Rdi] = ownerHandle;
|
||||
ctx[CpuRegister.Rsi] = PacketAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
AgcExports.DriverSubmitAcb(ctx));
|
||||
}
|
||||
|
||||
// A zero-dword submission parses as immediately complete, so the queue reaches its fence
|
||||
// without needing a live GPU backend.
|
||||
private static void WriteEmptySubmitPacket(FakeCpuMemory memory)
|
||||
{
|
||||
WriteUInt64(memory, PacketAddress, 0);
|
||||
WriteUInt32(memory, PacketAddress + 8, 0);
|
||||
}
|
||||
|
||||
private static ulong CreateEqueue(CpuContext ctx, FakeCpuMemory memory)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = HandleOutAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
return ReadUInt64(memory, HandleOutAddress);
|
||||
}
|
||||
|
||||
// The kernel event registry is process-wide static state and deleting the queue drops its
|
||||
// registrations, so sibling suites that assert over every graphics registration stay clean.
|
||||
private static void DeleteEqueue(CpuContext ctx, ulong equeue)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = equeue;
|
||||
_ = KernelEventQueueCompatExports.KernelDeleteEqueue(ctx);
|
||||
}
|
||||
|
||||
private static int WaitEqueue(CpuContext ctx, FakeCpuMemory memory, ulong equeue)
|
||||
{
|
||||
WriteUInt64(memory, TimeoutAddress, 0);
|
||||
ctx[CpuRegister.Rdi] = equeue;
|
||||
ctx[CpuRegister.Rsi] = EventsAddress;
|
||||
ctx[CpuRegister.Rdx] = 4;
|
||||
ctx[CpuRegister.Rcx] = OutCountAddress;
|
||||
ctx[CpuRegister.R8] = TimeoutAddress;
|
||||
return KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static short ReadInt16(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[2];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadInt16LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,96 @@ public sealed class Gen5VertexInputSpirvTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AliasedFetchInstructionsShareOneAttributeLocation()
|
||||
{
|
||||
// Metal caps a vertex function at 31 attributes, so every fetch that
|
||||
// reads one guest stream view must resolve to that view's single
|
||||
// location instead of declaring its own.
|
||||
var firstFetch = CreateVertexFetch(0);
|
||||
var secondFetch = CreateVertexFetch(4);
|
||||
var end = new Gen5ShaderInstruction(
|
||||
8,
|
||||
Gen5ShaderEncoding.Sopp,
|
||||
"SEndpgm",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
null);
|
||||
var state = new Gen5ShaderState(
|
||||
new Gen5ShaderProgram(0, [firstFetch, secondFetch, end]),
|
||||
[],
|
||||
null);
|
||||
var registers = new uint[256];
|
||||
var data = new byte[16];
|
||||
var evaluation = new Gen5ShaderEvaluation(
|
||||
registers,
|
||||
registers,
|
||||
[],
|
||||
[],
|
||||
VertexInputs:
|
||||
[
|
||||
new Gen5VertexInputBinding(
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
10,
|
||||
0,
|
||||
0x1000,
|
||||
4,
|
||||
0,
|
||||
data,
|
||||
data.Length,
|
||||
DataPooled: false,
|
||||
AliasPcs: [4u]),
|
||||
]);
|
||||
|
||||
Assert.True(
|
||||
Gen5SpirvTranslator.TryCompileVertexShader(
|
||||
state,
|
||||
evaluation,
|
||||
out var shader,
|
||||
out var error),
|
||||
error);
|
||||
|
||||
var module = ParseModule(shader.Spirv);
|
||||
var locations = module
|
||||
.Where(candidate =>
|
||||
candidate.Opcode == SpirvOp.Decorate &&
|
||||
candidate.Operands.Length >= 3 &&
|
||||
candidate.Operands[1] == (uint)SpirvDecoration.Location)
|
||||
.ToArray();
|
||||
var inputVariable = Assert.Single(locations).Operands[0];
|
||||
|
||||
// Both fetches must read that variable; an unaliased second fetch would
|
||||
// fall through to the generic buffer path and leave only one load.
|
||||
Assert.Equal(
|
||||
2,
|
||||
module.Count(candidate =>
|
||||
candidate.Opcode == SpirvOp.Load &&
|
||||
candidate.Operands.Length >= 3 &&
|
||||
candidate.Operands[2] == inputVariable));
|
||||
}
|
||||
|
||||
private static Gen5ShaderInstruction CreateVertexFetch(uint pc) =>
|
||||
new(
|
||||
pc,
|
||||
Gen5ShaderEncoding.Mubuf,
|
||||
"BufferLoadFormatXyzw",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
new Gen5BufferMemoryControl(
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
IndexEnabled: true,
|
||||
OffsetEnabled: false,
|
||||
Glc: false,
|
||||
Slc: false));
|
||||
|
||||
private static IReadOnlyList<ParsedInstruction> ParseModule(byte[] spirv)
|
||||
{
|
||||
var instructions = new List<ParsedInstruction>();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Agc;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
public sealed class GpuWaitRegistryProducedRetentionTests
|
||||
{
|
||||
private const ulong WatchedLabel = 0x7020_0000_1000UL;
|
||||
|
||||
// A suspended DCB whose label the guest has recycled can only be released by
|
||||
// replaying the value a real producer wrote to that label. Recording enough
|
||||
// unrelated producers to cross the table's soft bound must not discard that
|
||||
// value, or the waiter is stranded and the graphics queue never resumes.
|
||||
[Fact]
|
||||
public void ProducedValueSurvivesBoundCrossingWhileAWaiterWatchesIt()
|
||||
{
|
||||
GpuWaitRegistry.Clear();
|
||||
var memory = new object();
|
||||
|
||||
GpuWaitRegistry.Register(WatchedLabel, NewWaiter(memory, WatchedLabel));
|
||||
Assert.True(GpuWaitRegistry.RecordProduced(memory, WatchedLabel, 1));
|
||||
|
||||
// Cross the soft bound with labels nobody is waiting on.
|
||||
for (var i = 0; i < 9000; i++)
|
||||
{
|
||||
GpuWaitRegistry.RecordProduced(memory, 0x7030_0000_0000UL + ((ulong)i * 8), 1);
|
||||
}
|
||||
|
||||
// The guest has since recycled the label, so its memory no longer holds
|
||||
// the produced value — the registry's record is the only way back.
|
||||
var broken = GpuWaitRegistry.CollectDeadlockBroken(memory, nowTicks: 1_000_000, minAgeTicks: 1);
|
||||
|
||||
Assert.NotNull(broken);
|
||||
Assert.Contains(broken!, waiter => waiter.WaitAddress == WatchedLabel);
|
||||
GpuWaitRegistry.Clear();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnwatchedProducedValuesArePrunedAtTheBound()
|
||||
{
|
||||
GpuWaitRegistry.Clear();
|
||||
var memory = new object();
|
||||
|
||||
for (var i = 0; i < 9000; i++)
|
||||
{
|
||||
GpuWaitRegistry.RecordProduced(memory, 0x7030_0000_0000UL + ((ulong)i * 8), 1);
|
||||
}
|
||||
|
||||
// Nothing was watching any of them, so a waiter registered afterwards on
|
||||
// a pruned label has no produced value to replay and stays suspended.
|
||||
GpuWaitRegistry.Register(WatchedLabel, NewWaiter(memory, WatchedLabel));
|
||||
var broken = GpuWaitRegistry.CollectDeadlockBroken(memory, nowTicks: 1_000_000, minAgeTicks: 1);
|
||||
|
||||
Assert.Null(broken);
|
||||
GpuWaitRegistry.Clear();
|
||||
}
|
||||
|
||||
private static GpuWaitRegistry.WaitingDcb NewWaiter(object memory, ulong address) => new()
|
||||
{
|
||||
WaitAddress = address,
|
||||
ReferenceValue = 1,
|
||||
Mask = 0xFFFF_FFFFUL,
|
||||
CompareFunction = 3, // equal
|
||||
Memory = memory,
|
||||
QueueName = "dcb.graphics",
|
||||
RegisteredTicks = 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Ampr;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Ampr;
|
||||
|
||||
public class AmprFileRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeFileId_matches_utf8_fnv1a()
|
||||
{
|
||||
const string relative = "CoreData/foo/bar.bin";
|
||||
Assert.Equal(FnvUtf8("$/" + relative), AmprFileRegistry.ComputeFileId("$/" + relative));
|
||||
Assert.Equal(FnvUtf8("/app0/" + relative), AmprFileRegistry.ComputeFileId("/app0/" + relative));
|
||||
Assert.Equal(FnvUtf8("app0/" + relative), AmprFileRegistry.ComputeFileId("app0/" + relative));
|
||||
Assert.Equal(FnvUtf8(relative), AmprFileRegistry.ComputeFileId(relative));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterApp0Relative_publishes_same_ids_as_string_hashes()
|
||||
{
|
||||
AmprFileRegistry.ClearForTests();
|
||||
const string relative = "misc/loadouts/test.txt";
|
||||
var host = Path.Combine(Path.GetTempPath(), "sharpemu-ampr-test", relative);
|
||||
AmprFileRegistry.RegisterApp0RelativeForTests(relative, host);
|
||||
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("$/" + relative), out var a));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("/app0/" + relative), out var b));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("app0/" + relative), out var c));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId(relative), out var d));
|
||||
Assert.Equal(host, a);
|
||||
Assert.Equal(host, b);
|
||||
Assert.Equal(host, c);
|
||||
Assert.Equal(host, d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_publishes_all_app0_path_aliases()
|
||||
{
|
||||
AmprFileRegistry.ClearForTests();
|
||||
const string relative = "scripts/cp11/cp11main.script";
|
||||
var host = Path.Combine(Path.GetTempPath(), "sharpemu-ampr-test2", relative);
|
||||
AmprFileRegistry.Register("$/" + relative, host);
|
||||
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("$/" + relative), out var a));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("/app0/" + relative), out var b));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId("app0/" + relative), out var c));
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||
AmprFileRegistry.ComputeFileId(relative), out var d));
|
||||
Assert.Equal(host, a);
|
||||
Assert.Equal(host, b);
|
||||
Assert.Equal(host, c);
|
||||
Assert.Equal(host, d);
|
||||
}
|
||||
|
||||
private static uint FnvUtf8(string text)
|
||||
{
|
||||
const uint offset = 2166136261;
|
||||
const uint prime = 16777619;
|
||||
var hash = offset;
|
||||
foreach (var b in System.Text.Encoding.UTF8.GetBytes(text))
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= prime;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
@@ -318,6 +318,53 @@ public sealed class AprStreamingContractTests
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_AlsoPublishesApp0AndDollarPathAliases()
|
||||
{
|
||||
// Resolve may register "$/asset.bin" while cooked tables look up
|
||||
// FNV("/app0/asset.bin"). Both ids must map to the same host file.
|
||||
var hostPath = Path.Combine(Path.GetTempPath(), $"sharpemu-apr-alias-{Guid.NewGuid():N}.bin");
|
||||
File.WriteAllBytes(hostPath, [1, 2, 3]);
|
||||
try
|
||||
{
|
||||
var dollarId = AmprFileRegistry.Register("$/weapons/demo.cani", hostPath);
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(dollarId, out var viaDollar));
|
||||
Assert.Equal(hostPath, viaDollar);
|
||||
|
||||
var app0Id = AmprFileRegistry.ComputeFileId("/app0/weapons/demo.cani");
|
||||
Assert.NotEqual(dollarId, app0Id);
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(app0Id, out var viaApp0));
|
||||
Assert.Equal(hostPath, viaApp0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(hostPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnsureApp0Indexed_PublishesCookedApp0FileIds()
|
||||
{
|
||||
var mountRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-apr-index-{Guid.NewGuid():N}");
|
||||
var relativeDir = Path.Combine(mountRoot, "weapons");
|
||||
Directory.CreateDirectory(relativeDir);
|
||||
var hostPath = Path.Combine(relativeDir, "demo.cani");
|
||||
File.WriteAllBytes(hostPath, [9, 8, 7]);
|
||||
try
|
||||
{
|
||||
AmprFileRegistry.EnsureApp0Indexed(mountRoot);
|
||||
var cookedId = AmprFileRegistry.ComputeFileId("/app0/weapons/demo.cani");
|
||||
Assert.True(AmprFileRegistry.TryGetHostPath(cookedId, out var resolved));
|
||||
Assert.Equal(Path.GetFullPath(hostPath), Path.GetFullPath(resolved));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(mountRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||
|
||||
@@ -61,6 +61,19 @@ public sealed class GuiSettingsTests
|
||||
Assert.Equal(1000, settings.RefreshRate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{ }""", "Carousel")]
|
||||
[InlineData("""{ "LibraryLayout": null }""", "Carousel")]
|
||||
[InlineData("""{ "LibraryLayout": "sideways" }""", "Carousel")]
|
||||
[InlineData("""{ "LibraryLayout": "grid" }""", "Grid")]
|
||||
[InlineData("""{ "LibraryLayout": "Grid" }""", "Grid")]
|
||||
public void NormalizeFromJson_LibraryLayout_FallsBackToCarousel(string json, string expected)
|
||||
{
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal(expected, settings.LibraryLayout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_CustomResolution_IsPreserved()
|
||||
{
|
||||
|
||||
@@ -57,4 +57,86 @@ public sealed class PerGameSettingsTests
|
||||
Assert.NotNull(settings);
|
||||
Assert.Equal(["SHARPEMU_TRACE", "SHARPEMU_NO_JIT"], settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveInheritedValues_AllMatchingValues_ProducesEmptySettings()
|
||||
{
|
||||
var global = new GuiSettings
|
||||
{
|
||||
LogLevel = "Info",
|
||||
ImportTraceLimit = 32,
|
||||
StrictDynlibResolution = true,
|
||||
LogToFile = false,
|
||||
WindowMode = "Borderless",
|
||||
Resolution = "2560x1440",
|
||||
DisplayIndex = 1,
|
||||
RefreshRate = 144,
|
||||
ScalingMode = "Fit",
|
||||
VSync = true,
|
||||
HdrMode = "Auto",
|
||||
EnvironmentToggles = ["SHARPEMU_LOG_IO", "SHARPEMU_VK_VALIDATION"],
|
||||
};
|
||||
var perGame = new PerGameSettings
|
||||
{
|
||||
LogLevel = "info",
|
||||
ImportTraceLimit = 32,
|
||||
StrictDynlibResolution = true,
|
||||
LogToFile = false,
|
||||
WindowMode = "borderless",
|
||||
Resolution = "2560x1440",
|
||||
DisplayIndex = 1,
|
||||
RefreshRate = 144,
|
||||
ScalingMode = "fit",
|
||||
VSync = true,
|
||||
HdrMode = "auto",
|
||||
EnvironmentToggles = ["SHARPEMU_VK_VALIDATION=1", "sharpemu_log_io"],
|
||||
};
|
||||
|
||||
perGame.RemoveInheritedValues(global);
|
||||
|
||||
Assert.True(perGame.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveInheritedValues_DifferentValues_RemainOverrides()
|
||||
{
|
||||
var global = new GuiSettings
|
||||
{
|
||||
LogLevel = "Info",
|
||||
Resolution = "1920x1080",
|
||||
VSync = true,
|
||||
EnvironmentToggles = ["SHARPEMU_LOG_IO"],
|
||||
};
|
||||
var perGame = new PerGameSettings
|
||||
{
|
||||
LogLevel = "Debug",
|
||||
Resolution = "2560x1440",
|
||||
VSync = false,
|
||||
EnvironmentToggles = ["SHARPEMU_VK_VALIDATION"],
|
||||
};
|
||||
|
||||
perGame.RemoveInheritedValues(global);
|
||||
|
||||
Assert.Equal("Debug", perGame.LogLevel);
|
||||
Assert.Equal("2560x1440", perGame.Resolution);
|
||||
Assert.False(perGame.VSync);
|
||||
Assert.Equal(["SHARPEMU_VK_VALIDATION"], perGame.EnvironmentToggles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveInheritedValues_DisabledEnvironmentEntry_MatchesMissingEntry()
|
||||
{
|
||||
var global = new GuiSettings
|
||||
{
|
||||
EnvironmentToggles = ["SHARPEMU_LOG_IO=0"],
|
||||
};
|
||||
var perGame = new PerGameSettings
|
||||
{
|
||||
EnvironmentToggles = [],
|
||||
};
|
||||
|
||||
perGame.RemoveInheritedValues(global);
|
||||
|
||||
Assert.Null(perGame.EnvironmentToggles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia.Controls;
|
||||
using SharpEmu.GUI;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.GUI;
|
||||
|
||||
public sealed class WindowChromeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(WindowState.Normal, "crop_square", "Maximize", "Maximize window")]
|
||||
[InlineData(WindowState.Maximized, "filter_none", "Restore", "Restore window")]
|
||||
public void GetMaximizeButtonState_ReturnsConsistentVisualAndAccessibleState(
|
||||
WindowState windowState,
|
||||
string expectedGlyph,
|
||||
string expectedToolTip,
|
||||
string expectedAutomationName)
|
||||
{
|
||||
var state = MainWindow.GetMaximizeButtonState(windowState);
|
||||
|
||||
Assert.Equal(expectedGlyph, state.Glyph);
|
||||
Assert.Equal(expectedToolTip, state.ToolTip);
|
||||
Assert.Equal(expectedAutomationName, state.AutomationName);
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,13 @@ public sealed class KernelEventQueueCompatExportsTests
|
||||
Assert.Equal(eventIdent, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x00..]));
|
||||
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterUser,
|
||||
BinaryPrimitives.ReadInt16LittleEndian(evt[0x08..]));
|
||||
Assert.Equal(triggerData, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x10..]));
|
||||
|
||||
// sceKernelTriggerUserEvent's third argument is the event's *udata*, not
|
||||
// its data word: the guest reads it back with sceKernelGetEventUserData,
|
||||
// which loads offset 0x18. Routing the payload to data(0x10) instead left
|
||||
// sceKernelGetEventUserData returning 0 for every triggered user event.
|
||||
Assert.Equal(triggerData, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x18..]));
|
||||
Assert.Equal(0UL, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x10..]));
|
||||
}
|
||||
|
||||
private static ulong CreateEqueue()
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Kernel;
|
||||
|
||||
public sealed class KernelEventQueueWaiterLifetimeTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const ulong HandleAddress = BaseAddress + 0x100;
|
||||
private const ulong EventsAddress = BaseAddress + 0x200;
|
||||
private const ulong OutCountAddress = BaseAddress + 0x300;
|
||||
|
||||
[Fact]
|
||||
public void DeleteEqueue_CompletesStagedWaiterAsDeleted()
|
||||
{
|
||||
var (memory, ctx, handle) = CreateEqueue();
|
||||
var waiter = StageGuestWait(ctx, handle, threadHandle: 0x701);
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
|
||||
Assert.True(waiter.TryWake());
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_DELETED,
|
||||
waiter.Resume());
|
||||
Assert.Equal(0u, ReadUInt32(memory, OutCountAddress));
|
||||
Assert.False(KernelEventQueueCompatExports.IsValidEqueue(handle));
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeletedGenerationWaiter_CannotConsumeNewQueueEvent()
|
||||
{
|
||||
var (memory, ctx, oldHandle) = CreateEqueue();
|
||||
var oldWaiter = StageGuestWait(ctx, oldHandle, threadHandle: 0x702);
|
||||
|
||||
ctx[CpuRegister.Rdi] = oldHandle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
|
||||
var newHandle = CreateEqueue(ctx, memory);
|
||||
Assert.NotEqual(oldHandle, newHandle);
|
||||
var expected = new KernelEventQueueCompatExports.KernelQueuedEvent(
|
||||
Ident: 0x77,
|
||||
Filter: KernelEventQueueCompatExports.KernelEventFilterUser,
|
||||
Flags: KernelEventQueueCompatExports.KernelEventFlagClear,
|
||||
Fflags: 1,
|
||||
Data: 0x1234,
|
||||
UserData: 0x5678);
|
||||
Assert.True(KernelEventQueueCompatExports.EnqueueEvent(
|
||||
newHandle,
|
||||
expected));
|
||||
|
||||
Assert.True(oldWaiter.TryWake());
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_DELETED,
|
||||
oldWaiter.Resume());
|
||||
Assert.True(
|
||||
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||
newHandle,
|
||||
out var delivered));
|
||||
Assert.Equal(expected, delivered);
|
||||
|
||||
ctx[CpuRegister.Rdi] = newHandle;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||
}
|
||||
|
||||
private static (FakeCpuMemory Memory, CpuContext Context, ulong Handle)
|
||||
CreateEqueue()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x1000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
return (memory, ctx, CreateEqueue(ctx, memory));
|
||||
}
|
||||
|
||||
private static ulong CreateEqueue(CpuContext ctx, FakeCpuMemory memory)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = HandleAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||
return ReadUInt64(memory, HandleAddress);
|
||||
}
|
||||
|
||||
private static IGuestThreadBlockWaiter StageGuestWait(
|
||||
CpuContext ctx,
|
||||
ulong handle,
|
||||
ulong threadHandle)
|
||||
{
|
||||
var previousThread = GuestThreadExecution.EnterGuestThread(threadHandle);
|
||||
var previousFrame = GuestThreadExecution.EnterImportCallFrame(
|
||||
returnRip: 0x1_0000 + threadHandle,
|
||||
resumeRsp: 0x2_0000 + threadHandle,
|
||||
returnSlotAddress: 0x3_0000 + threadHandle);
|
||||
try
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
ctx[CpuRegister.Rsi] = EventsAddress;
|
||||
ctx[CpuRegister.Rdx] = 1;
|
||||
ctx[CpuRegister.Rcx] = OutCountAddress;
|
||||
ctx[CpuRegister.R8] = 0;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
KernelEventQueueCompatExports.KernelWaitEqueue(ctx));
|
||||
|
||||
Assert.True(GuestThreadExecution.TryConsumeCurrentThreadBlock(
|
||||
out var reason,
|
||||
out _,
|
||||
out var hasContinuation,
|
||||
out _,
|
||||
out var waiter,
|
||||
out _));
|
||||
Assert.Equal("sceKernelWaitEqueue", reason);
|
||||
Assert.True(hasContinuation);
|
||||
return Assert.IsAssignableFrom<IGuestThreadBlockWaiter>(waiter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousFrame);
|
||||
GuestThreadExecution.RestoreGuestThread(previousThread);
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(uint)];
|
||||
Assert.True(memory.TryRead(address, bytes));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||
Assert.True(memory.TryRead(address, bytes));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,47 @@ public sealed class PadExportsTests
|
||||
_ctx[CpuRegister.Rdi] = unchecked((ulong)handle);
|
||||
Assert.Equal(expected, PadExports.PadSetTiltCorrectionState(_ctx));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the calling frame observed in PPSA10112: the out-param points at
|
||||
/// rbp-0x30 and the caller's stack cookie sits at rbp-0x28, so the state is
|
||||
/// eight bytes. Writing more would smash the cookie and fail the guest's
|
||||
/// stack check, which is the failure mode this size guards against.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetTriggerEffectState_WritesEightBytesAndLeavesTheCookieIntact()
|
||||
{
|
||||
const ulong stateAddress = Base + 0x100;
|
||||
const ulong cookieAddress = stateAddress + 8;
|
||||
const ulong cookie = 0xC0DEC0DECAFEBA00UL;
|
||||
|
||||
Assert.True(_memory.TryWrite(stateAddress, new byte[] { 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE }));
|
||||
Assert.True(_memory.TryWrite(cookieAddress, BitConverter.GetBytes(cookie)));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = 0;
|
||||
_ctx[CpuRegister.Rsi] = stateAddress;
|
||||
|
||||
Assert.Equal(0, PadExports.PadGetTriggerEffectState(_ctx));
|
||||
|
||||
Span<byte> state = stackalloc byte[8];
|
||||
Assert.True(_memory.TryRead(stateAddress, state));
|
||||
foreach (var value in state)
|
||||
{
|
||||
Assert.Equal(0, value);
|
||||
}
|
||||
|
||||
Span<byte> guard = stackalloc byte[8];
|
||||
Assert.True(_memory.TryRead(cookieAddress, guard));
|
||||
Assert.Equal(cookie, BitConverter.ToUInt64(guard));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2)]
|
||||
[InlineData(-1)]
|
||||
public void GetTriggerEffectState_RejectsForeignHandles(int handle)
|
||||
{
|
||||
_ctx[CpuRegister.Rdi] = unchecked((ulong)handle);
|
||||
_ctx[CpuRegister.Rsi] = Base + 0x100;
|
||||
Assert.Equal(InvalidHandle, PadExports.PadGetTriggerEffectState(_ctx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using Silk.NET.Vulkan;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.VideoOut;
|
||||
|
||||
public sealed class VulkanGuestImageAliasTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Format.R8G8B8A8Srgb, Format.R8G8B8A8Unorm)]
|
||||
[InlineData(Format.R8G8B8A8Unorm, Format.R8G8B8A8Srgb)]
|
||||
public void SrgbAndUnormCounterpartsShareOneGuestImage(
|
||||
Format existing,
|
||||
Format requested)
|
||||
{
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.IsAliasableGuestImageFormat(existing, requested));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Format.R8G8B8A8Unorm)]
|
||||
[InlineData(Format.R8G8B8A8Srgb)]
|
||||
[InlineData(Format.R16G16B16A16Sfloat)]
|
||||
public void IdenticalFormatsUseTheExactMatchPath(Format format)
|
||||
{
|
||||
// Equal formats are accepted by the exact-match condition; the alias
|
||||
// helper only reports genuinely different identities.
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.IsAliasableGuestImageFormat(format, format));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Format.R32Uint, Format.R8G8B8A8Unorm)]
|
||||
[InlineData(Format.R32Sint, Format.R8G8B8A8Unorm)]
|
||||
[InlineData(Format.R32Sfloat, Format.R8G8B8A8Unorm)]
|
||||
[InlineData(Format.R16G16Sfloat, Format.R8G8B8A8Unorm)]
|
||||
[InlineData(Format.A2B10G10R10UnormPack32, Format.R8G8B8A8Unorm)]
|
||||
[InlineData(Format.B10G11R11UfloatPack32, Format.R8G8B8A8Srgb)]
|
||||
public void SameClassNumericReinterpretationKeepsRecreatePath(
|
||||
Format existing,
|
||||
Format requested)
|
||||
{
|
||||
// These pairs are legal Vulkan view aliases (same 32-bit class), but
|
||||
// accepting them would attach fragment shaders translated for the
|
||||
// other numeric encoding to the existing attachment format.
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.IsCompatibleGuestImageViewFormat(existing, requested));
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.IsAliasableGuestImageFormat(existing, requested));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Format.R8Srgb, Format.R8Unorm)]
|
||||
[InlineData(Format.BC3SrgbBlock, Format.BC3UnormBlock)]
|
||||
public void CounterpartsOutsideTheViewClassTableAreNotAliased(
|
||||
Format existing,
|
||||
Format requested)
|
||||
{
|
||||
// sRGB/UNORM counterparts that the view-compatibility table cannot
|
||||
// alias must keep the recreate path: the shared image could never
|
||||
// legally serve the second identity through an alias view.
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.IsCompatibleGuestImageViewFormat(existing, requested));
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.IsAliasableGuestImageFormat(existing, requested));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AliasedPairStaysWithinOneCompatibilityClass()
|
||||
{
|
||||
// The alias accept creates identity views of both formats on one
|
||||
// mutable-format image; this guards the compatibility table entries
|
||||
// the accept relies on.
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.IsCompatibleGuestImageViewFormat(
|
||||
Format.R8G8B8A8Srgb,
|
||||
Format.R8G8B8A8Unorm));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.VideoOut;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the two policy decisions that let a guest CPU rewrite reach a host
|
||||
/// image that the GPU also renders into. Both used to be answered by proxies
|
||||
/// that silently excluded real surfaces: a 1920x1080 arming cap (which left
|
||||
/// every 4K target un-invalidated) and an IsCpuBacked latch (which flips false
|
||||
/// the first time an address is used as a render target and never flips back).
|
||||
/// The Vulkan device code around them cannot be unit-tested without a device,
|
||||
/// so the decisions themselves are exercised here.
|
||||
/// </summary>
|
||||
public sealed class VulkanGuestImageCpuSyncPolicyTests
|
||||
{
|
||||
private const uint Rgba8Format = 10;
|
||||
private const uint Rgba16FFormat = 11;
|
||||
private const uint Rgba32FFormat = 14;
|
||||
|
||||
[Theory]
|
||||
// 4K UI sheets are the whole point of the change: under the old
|
||||
// resolution cap none of these were ever armed.
|
||||
[InlineData(Rgba8Format, 3840u, 2160u)]
|
||||
[InlineData(Rgba16FFormat, 3840u, 2160u)]
|
||||
[InlineData(Rgba32FFormat, 3840u, 2160u)]
|
||||
// Supersampled and unusual-aspect targets above 1080p in one axis only.
|
||||
[InlineData(Rgba8Format, 2560u, 1440u)]
|
||||
[InlineData(Rgba8Format, 1920u, 2160u)]
|
||||
[InlineData(Rgba8Format, 3840u, 1080u)]
|
||||
public void OversizedTargetsRemainEligibleForWriteTracking(
|
||||
uint format,
|
||||
uint width,
|
||||
uint height)
|
||||
{
|
||||
var byteCount = VulkanVideoPresenter.GetGuestImageByteCount(
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
depth: 1);
|
||||
|
||||
Assert.True(VulkanVideoPresenter.ShouldTrackGuestImageWrites(byteCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubHdTargetsRemainEligibleForWriteTracking()
|
||||
{
|
||||
var byteCount = VulkanVideoPresenter.GetGuestImageByteCount(
|
||||
Rgba8Format,
|
||||
1280u,
|
||||
720u,
|
||||
depth: 1);
|
||||
|
||||
Assert.True(VulkanVideoPresenter.ShouldTrackGuestImageWrites(byteCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyExtentIsNotTracked()
|
||||
{
|
||||
Assert.False(VulkanVideoPresenter.ShouldTrackGuestImageWrites(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtentBeyondTheReUploadBudgetIsNotTracked()
|
||||
{
|
||||
// Arming beyond the budget the flip/acquire sync path is willing to
|
||||
// read back can only cost faults; it can never produce a re-upload.
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.ShouldTrackGuestImageWrites(
|
||||
VulkanVideoPresenter.MaxTrackedGuestImageBytes));
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.ShouldTrackGuestImageWrites(
|
||||
VulkanVideoPresenter.MaxTrackedGuestImageBytes + 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VolumeDepthCountsAgainstTheTrackingBudget()
|
||||
{
|
||||
// A resolution cap could not see volume depth at all. 512^3 RGBA8 is
|
||||
// 512 MiB of backing memory behind a "512x512" surface.
|
||||
var byteCount = VulkanVideoPresenter.GetGuestImageByteCount(
|
||||
Rgba8Format,
|
||||
512u,
|
||||
512u,
|
||||
depth: 512u);
|
||||
|
||||
Assert.False(VulkanVideoPresenter.ShouldTrackGuestImageWrites(byteCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CpuBackedImageAlwaysRefreshes()
|
||||
{
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: true,
|
||||
textureWriteGeneration: -1,
|
||||
hasUploadedGeneration: false,
|
||||
uploadedGeneration: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderTargetLatchNoLongerBlocksAnObservedCpuWrite()
|
||||
{
|
||||
// The surface was rendered into (IsCpuBacked latched false) and the
|
||||
// guest CPU then rewrote its backing memory, so the parse thread
|
||||
// shipped fresh texels carrying a newer generation than the upload.
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: false,
|
||||
textureWriteGeneration: 3,
|
||||
hasUploadedGeneration: true,
|
||||
uploadedGeneration: 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservedCpuWriteRefreshesEvenWithNoRecordedUpload()
|
||||
{
|
||||
// The render-target recreate/retain paths drop the recorded upload
|
||||
// generation; a tracked CPU write must still win.
|
||||
Assert.True(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: false,
|
||||
textureWriteGeneration: 1,
|
||||
hasUploadedGeneration: false,
|
||||
uploadedGeneration: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GpuFeedbackSurfaceKeepsItsLiveImage()
|
||||
{
|
||||
// Tracked but never CPU-written: generation zero. Render-into-then-
|
||||
// sample must not be overwritten with guest memory.
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: false,
|
||||
textureWriteGeneration: 0,
|
||||
hasUploadedGeneration: false,
|
||||
uploadedGeneration: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntrackedSurfaceKeepsItsLiveImage()
|
||||
{
|
||||
// -1 is the "no tracker generation" sentinel the parse thread ships
|
||||
// when the range is not tracked (or tracking is disabled entirely,
|
||||
// as on Windows).
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: false,
|
||||
textureWriteGeneration: -1,
|
||||
hasUploadedGeneration: false,
|
||||
uploadedGeneration: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlreadyUploadedGenerationDoesNotRefreshAgain()
|
||||
{
|
||||
// The upload recorded this exact generation, so the host image is
|
||||
// current: re-uploading every draw would restage the whole surface.
|
||||
Assert.False(
|
||||
VulkanVideoPresenter.ShouldRefreshGuestImageFromCpu(
|
||||
isCpuBacked: false,
|
||||
textureWriteGeneration: 4,
|
||||
hasUploadedGeneration: true,
|
||||
uploadedGeneration: 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Generic;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.ShaderCompiler.Ir;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler.Tests;
|
||||
|
||||
public sealed class Gen5ImplicitVccTests
|
||||
{
|
||||
private static Gen5ShaderInstruction Vopc(uint pc, string opcode = "VCmpEqU32") =>
|
||||
new(pc, Gen5ShaderEncoding.Vopc, opcode, [0u], [], [], null);
|
||||
|
||||
private static Gen5ShaderInstruction Vop2(uint pc, string opcode) =>
|
||||
new(pc, Gen5ShaderEncoding.Vop2, opcode, [0u], [], [Gen5Operand.Vector(0)], null);
|
||||
|
||||
private static Gen5ShaderInstruction Nop(uint pc) =>
|
||||
new(pc, Gen5ShaderEncoding.Sop1, "SNop", [0u], [], [], null);
|
||||
|
||||
[Fact]
|
||||
public void VopcIsRecognisedAsAnImplicitVccWriter()
|
||||
{
|
||||
Assert.True(Gen5ScalarSsa.WritesVccImplicitly(Vopc(0)));
|
||||
Assert.True(Gen5ScalarSsa.WritesVccImplicitly(Vopc(0, "VCmpLtF32")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vop2CarryFormsAreRecognised()
|
||||
{
|
||||
Assert.True(Gen5ScalarSsa.WritesVccImplicitly(Vop2(0, "VAddCoCiU32")));
|
||||
Assert.True(Gen5ScalarSsa.WritesVccImplicitly(Vop2(0, "VSubCoCiU32")));
|
||||
Assert.True(Gen5ScalarSsa.WritesVccImplicitly(Vop2(0, "VSubrevCoCiU32")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlainVop2DoesNotWriteVcc()
|
||||
{
|
||||
Assert.False(Gen5ScalarSsa.WritesVccImplicitly(Vop2(0, "VAddF32")));
|
||||
Assert.False(Gen5ScalarSsa.WritesVccImplicitly(Nop(0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VopcDefinesBothVccHalves()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program = [Vopc(0), Nop(4)];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
|
||||
var low = ssa.GetReachingDefinitionAt(4, Gen5ScalarSsa.VccLo);
|
||||
var high = ssa.GetReachingDefinitionAt(4, Gen5ScalarSsa.VccHi);
|
||||
|
||||
Assert.Equal(IrReachingState.Single, low.State);
|
||||
Assert.Equal(0u, low.DefinitionPc);
|
||||
Assert.Equal(IrReachingState.Single, high.State);
|
||||
Assert.Equal(0u, high.DefinitionPc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutTheImplicitWriteVccWouldLookUndefined()
|
||||
{
|
||||
// The regression this models: before implicit VCC was tracked the offset
|
||||
// register reported "none" and its stale value was used as a byte offset.
|
||||
List<Gen5ShaderInstruction> program = [Nop(0), Nop(4)];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
|
||||
Assert.Equal(IrReachingState.None, ssa.GetReachingDefinitionAt(4, Gen5ScalarSsa.VccLo).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VccWrittenOnBothBranchesBecomesMultiple()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
new(0, Gen5ShaderEncoding.Sopp, "SCbranchScc0", [2u], [], [], null),
|
||||
Vopc(4),
|
||||
new(8, Gen5ShaderEncoding.Sopp, "SBranch", [1u], [], [], null),
|
||||
Vopc(12),
|
||||
Nop(16),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
|
||||
Assert.Equal(
|
||||
IrReachingState.Multiple,
|
||||
ssa.GetReachingDefinitionAt(16, Gen5ScalarSsa.VccLo).State);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Generic;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.ShaderCompiler.Ir;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler.Tests;
|
||||
|
||||
public sealed class Gen5ReachingDefinitionTests
|
||||
{
|
||||
private static Gen5ShaderInstruction Load(uint pc, uint destination) =>
|
||||
new(
|
||||
pc,
|
||||
Gen5ShaderEncoding.Smem,
|
||||
"SLoadDwordx4",
|
||||
[0u, 0u],
|
||||
[Gen5Operand.Scalar(0)],
|
||||
[Gen5Operand.Scalar(destination)],
|
||||
null);
|
||||
|
||||
private static Gen5ShaderInstruction Nop(uint pc) =>
|
||||
new(pc, Gen5ShaderEncoding.Sop1, "SNop", [0u], [], [], null);
|
||||
|
||||
private static Gen5ShaderInstruction Branch(uint pc, string opcode, short wordOffset) =>
|
||||
new(
|
||||
pc,
|
||||
Gen5ShaderEncoding.Sopp,
|
||||
opcode,
|
||||
[unchecked((uint)(ushort)wordOffset)],
|
||||
[],
|
||||
[],
|
||||
null);
|
||||
|
||||
[Fact]
|
||||
public void SingleDefinitionIsTracked()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program = [Load(0, 16), Nop(4)];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
var reaching = ssa.GetReachingDefinitionAt(4, 16);
|
||||
|
||||
Assert.Equal(IrReachingState.Single, reaching.State);
|
||||
Assert.Equal(0u, reaching.DefinitionPc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoDefinitionsOnDifferentPathsBecomeMultiple()
|
||||
{
|
||||
// The case that produced garbage descriptors: the same register is
|
||||
// written on both sides of a branch and read after the join.
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
Branch(0, "SCbranchScc0", 2),
|
||||
Load(4, 16),
|
||||
Branch(8, "SBranch", 1),
|
||||
Load(12, 16),
|
||||
Nop(16),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
|
||||
Assert.Equal(IrReachingState.Multiple, ssa.GetReachingDefinitionAt(16, 16).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefinitionOnOnlyOnePathIsAlsoMultipleAtTheJoin()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
Load(0, 16),
|
||||
Branch(4, "SCbranchScc0", 1),
|
||||
Load(8, 16),
|
||||
Nop(12),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
|
||||
Assert.Equal(IrReachingState.Multiple, ssa.GetReachingDefinitionAt(12, 16).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefinitionBeforeTheBranchStaysSingle()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
Load(0, 16),
|
||||
Branch(4, "SCbranchScc0", 1),
|
||||
Load(8, 16),
|
||||
Nop(12),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
var reaching = ssa.GetReachingDefinitionAt(4, 16);
|
||||
|
||||
Assert.Equal(IrReachingState.Single, reaching.State);
|
||||
Assert.Equal(0u, reaching.DefinitionPc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserDataCountsAsADefinition()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program = [Nop(0)];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: [0x1111, 0x2222]);
|
||||
|
||||
Assert.Equal(IrReachingState.Single, ssa.GetReachingDefinitionAt(0, 0).State);
|
||||
Assert.Equal(IrReachingState.None, ssa.GetReachingDefinitionAt(0, 64).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnwrittenRegisterHasNoDefinition()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program = [Load(0, 16), Nop(4)];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
|
||||
Assert.Equal(IrReachingState.None, ssa.GetReachingDefinitionAt(4, 32).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReachingDefinitionSeesLoadsThatConstantPropagationCannot()
|
||||
{
|
||||
// The whole point of the second analysis: SLoadDwordx4 has no compile-time
|
||||
// value, so constant propagation reports Unknown and never Merged. Reaching
|
||||
// definitions still proves the register has two possible writers.
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
Branch(0, "SCbranchScc0", 2),
|
||||
Load(4, 16),
|
||||
Branch(8, "SBranch", 1),
|
||||
Load(12, 16),
|
||||
Nop(16),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
|
||||
Assert.Equal(IrScalarState.Unknown, ssa.GetScalarAt(16, 16).State);
|
||||
Assert.Equal(IrReachingState.Multiple, ssa.GetReachingDefinitionAt(16, 16).State);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Generic;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.ShaderCompiler.Ir;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler.Tests;
|
||||
|
||||
public sealed class Gen5ScalarSsaTests
|
||||
{
|
||||
private static Gen5ShaderInstruction Mov(uint pc, uint destination, uint literal) =>
|
||||
new(
|
||||
pc,
|
||||
Gen5ShaderEncoding.Sop1,
|
||||
"SMov",
|
||||
[0u],
|
||||
[new Gen5Operand(Gen5OperandKind.LiteralConstant, literal)],
|
||||
[Gen5Operand.Scalar(destination)],
|
||||
null);
|
||||
|
||||
private static Gen5ShaderInstruction Nop(uint pc) =>
|
||||
new(pc, Gen5ShaderEncoding.Sop1, "SNop", [0u], [], [], null);
|
||||
|
||||
private static Gen5ShaderInstruction Branch(uint pc, string opcode, short wordOffset) =>
|
||||
new(
|
||||
pc,
|
||||
Gen5ShaderEncoding.Sopp,
|
||||
opcode,
|
||||
[unchecked((uint)(ushort)wordOffset)],
|
||||
[],
|
||||
[],
|
||||
null);
|
||||
|
||||
[Fact]
|
||||
public void StraightLineMovResolvesToAConstant()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
Mov(0, destination: 8, literal: 0x1234),
|
||||
Nop(4),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
var value = ssa.GetScalarAt(4, 8);
|
||||
|
||||
Assert.Equal(IrScalarState.Constant, value.State);
|
||||
Assert.Equal(0x1234u, value.Constant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserDataSeedsTheEntryState()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program = [Nop(0)];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: [0x40F55240, 0x00000004]);
|
||||
|
||||
Assert.Equal(IrScalarState.Constant, ssa.GetScalarAt(0, 0).State);
|
||||
Assert.Equal(0x40F55240u, ssa.GetScalarAt(0, 0).Constant);
|
||||
Assert.Equal(0x00000004u, ssa.GetScalarAt(0, 1).Constant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConflictingValuesFromTwoPathsBecomeMerged()
|
||||
{
|
||||
// if (cc) s8 = 0xAAAA else s8 = 0xBBBB; use s8
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
Branch(0, "SCbranchScc0", 2),
|
||||
Mov(4, destination: 8, literal: 0xAAAA),
|
||||
Branch(8, "SBranch", 1),
|
||||
Mov(12, destination: 8, literal: 0xBBBB),
|
||||
Nop(16),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
|
||||
Assert.True(ssa.Graph.HasControlFlow);
|
||||
Assert.Equal(IrScalarState.Merged, ssa.GetScalarAt(16, 8).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameValueOnBothPathsStaysResolved()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
Branch(0, "SCbranchScc0", 2),
|
||||
Mov(4, destination: 8, literal: 0xCAFE),
|
||||
Branch(8, "SBranch", 1),
|
||||
Mov(12, destination: 8, literal: 0xCAFE),
|
||||
Nop(16),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
var value = ssa.GetScalarAt(16, 8);
|
||||
|
||||
Assert.Equal(IrScalarState.Constant, value.State);
|
||||
Assert.Equal(0xCAFEu, value.Constant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterWrittenOnOnlyOnePathIsMergedAtTheJoin()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
Mov(0, destination: 8, literal: 0x1111),
|
||||
Branch(4, "SCbranchScc0", 1),
|
||||
Mov(8, destination: 8, literal: 0x2222),
|
||||
Nop(12),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
|
||||
Assert.Equal(IrScalarState.Merged, ssa.GetScalarAt(12, 8).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValueBeforeTheBranchIsStillResolved()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
Mov(0, destination: 8, literal: 0x1111),
|
||||
Branch(4, "SCbranchScc0", 1),
|
||||
Mov(8, destination: 8, literal: 0x2222),
|
||||
Nop(12),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
var value = ssa.GetScalarAt(4, 8);
|
||||
|
||||
Assert.Equal(IrScalarState.Constant, value.State);
|
||||
Assert.Equal(0x1111u, value.Constant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeJoinIsReportedForDivergentBlocks()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
Branch(0, "SCbranchScc0", 1),
|
||||
Mov(4, destination: 8, literal: 1),
|
||||
Nop(8),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
|
||||
Assert.True(ssa.IsInsideDivergentMerge(8));
|
||||
Assert.False(ssa.IsInsideDivergentMerge(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoopDoesNotHangTheFixpoint()
|
||||
{
|
||||
List<Gen5ShaderInstruction> program =
|
||||
[
|
||||
Mov(0, destination: 8, literal: 1),
|
||||
Nop(4),
|
||||
Branch(8, "SCbranchScc1", -3),
|
||||
Nop(12),
|
||||
];
|
||||
|
||||
var ssa = Gen5ScalarSsa.Build(program, userData: []);
|
||||
|
||||
Assert.NotEmpty(ssa.Graph.LoopHeaders);
|
||||
Assert.Equal(IrScalarState.Constant, ssa.GetScalarAt(12, 8).State);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Generic;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.ShaderCompiler.Ir;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler.Tests;
|
||||
|
||||
public sealed class IrControlFlowTests
|
||||
{
|
||||
private sealed class Resolver : IIrBranchResolver
|
||||
{
|
||||
public Dictionary<uint, (bool Conditional, uint Target)> Branches { get; } = [];
|
||||
|
||||
public bool IsBranch(Gen5ShaderInstruction instruction) =>
|
||||
Branches.ContainsKey(instruction.Pc);
|
||||
|
||||
public bool IsConditional(Gen5ShaderInstruction instruction) =>
|
||||
Branches.TryGetValue(instruction.Pc, out var branch) && branch.Conditional;
|
||||
|
||||
public bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc)
|
||||
{
|
||||
if (Branches.TryGetValue(instruction.Pc, out var branch))
|
||||
{
|
||||
targetPc = branch.Target;
|
||||
return true;
|
||||
}
|
||||
|
||||
targetPc = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Gen5ShaderInstruction Instruction(uint pc) =>
|
||||
new(pc, Gen5ShaderEncoding.Sop1, "SMov", [], [], [], null);
|
||||
|
||||
private static List<Gen5ShaderInstruction> Straight(params uint[] pcs)
|
||||
{
|
||||
var list = new List<Gen5ShaderInstruction>();
|
||||
foreach (var pc in pcs)
|
||||
{
|
||||
list.Add(Instruction(pc));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StraightLineCodeIsASingleBlock()
|
||||
{
|
||||
var instructions = Straight(0, 4, 8, 12);
|
||||
|
||||
var cfg = IrControlFlowGraph.Build(instructions, new Resolver());
|
||||
|
||||
Assert.Single(cfg.Blocks);
|
||||
Assert.False(cfg.HasControlFlow);
|
||||
Assert.Empty(cfg.LoopHeaders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConditionalBranchSplitsIntoThreeBlocks()
|
||||
{
|
||||
var instructions = Straight(0, 4, 8, 12, 16);
|
||||
var resolver = new Resolver();
|
||||
resolver.Branches[4] = (Conditional: true, Target: 12);
|
||||
|
||||
var cfg = IrControlFlowGraph.Build(instructions, resolver);
|
||||
|
||||
Assert.Equal(3, cfg.Blocks.Count);
|
||||
Assert.True(cfg.HasControlFlow);
|
||||
|
||||
var entry = cfg.BlockByStartPc[0];
|
||||
var fallthrough = cfg.BlockByStartPc[8];
|
||||
var target = cfg.BlockByStartPc[12];
|
||||
|
||||
Assert.Contains(target, cfg.Successors[entry]);
|
||||
Assert.Contains(fallthrough, cfg.Successors[entry]);
|
||||
Assert.Contains(entry, cfg.Predecessors[target]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnconditionalBranchDoesNotFallThrough()
|
||||
{
|
||||
var instructions = Straight(0, 4, 8, 12);
|
||||
var resolver = new Resolver();
|
||||
resolver.Branches[4] = (Conditional: false, Target: 12);
|
||||
|
||||
var cfg = IrControlFlowGraph.Build(instructions, resolver);
|
||||
|
||||
var entry = cfg.BlockByStartPc[0];
|
||||
var skipped = cfg.BlockByStartPc[8];
|
||||
var target = cfg.BlockByStartPc[12];
|
||||
|
||||
Assert.Equal([target], cfg.Successors[entry]);
|
||||
Assert.DoesNotContain(skipped, cfg.Successors[entry]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackwardBranchMarksALoopHeader()
|
||||
{
|
||||
var instructions = Straight(0, 4, 8, 12);
|
||||
var resolver = new Resolver();
|
||||
resolver.Branches[8] = (Conditional: true, Target: 4);
|
||||
|
||||
var cfg = IrControlFlowGraph.Build(instructions, resolver);
|
||||
|
||||
var header = cfg.BlockByStartPc[4];
|
||||
Assert.Contains(header, cfg.LoopHeaders);
|
||||
Assert.Contains(header, cfg.Successors[cfg.BlockByStartPc[4]]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MergeBlockRecordsBothPredecessors()
|
||||
{
|
||||
var instructions = Straight(0, 4, 8, 12, 16, 20);
|
||||
var resolver = new Resolver();
|
||||
resolver.Branches[0] = (Conditional: true, Target: 12);
|
||||
resolver.Branches[8] = (Conditional: false, Target: 16);
|
||||
|
||||
var cfg = IrControlFlowGraph.Build(instructions, resolver);
|
||||
|
||||
var merge = cfg.BlockByStartPc[16];
|
||||
Assert.Equal(2, cfg.Predecessors[merge].Count);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user