Compare commits

...

13 Commits

Author SHA1 Message Date
ParantezTech 7019fe14cd Merge branch 'shader-cfg-ssa' of https://github.com/sharpemu/sharpemu into shader-cfg-ssa 2026-07-31 01:36:00 +03:00
ParantezTech 6871510c1a [shader] guard descriptors from unresolved registers 2026-07-30 05:35:09 +03:00
ParantezTech 9074ca200b [shader] guard descriptors from unresolved registers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 05:35:09 +03:00
ParantezTech 71ace8af88 [shader] add cfg and dataflow tests 2026-07-30 05:25:39 +03:00
ParantezTech 20dcb791dc [shader] add cfg and dataflow tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 05:25:39 +03:00
ParantezTech 8833847111 [shader] add scalar reaching definitions 2026-07-30 05:25:39 +03:00
ParantezTech 166152e99c [shader] add scalar reaching definitions
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 05:25:39 +03:00
ParantezTech 8bcbc7c7d4 [shader] add gen5 branch resolver 2026-07-30 05:25:39 +03:00
ParantezTech 9608569613 [shader] add gen5 branch resolver
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 05:25:39 +03:00
ParantezTech 1be0d06d0d [shader] add scalar control flow graph 2026-07-30 05:25:39 +03:00
ParantezTech ae736136d4 [shader] add scalar control flow graph
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 05:25:39 +03:00
Daniel Freak 77f22973cb [GUI] Update library page layout (#690)
* [GUI] Add horizontal game tiles

* [GUI] Update launching elements layout

* [GUI] Change cards format to squares
2026-07-30 02:47:15 +03:00
Berk 996de70f52 Game compat updates (#691)
* [agc] Add indirect draws

* [video] Match render target views

* [savedata] Allow dialog reinit

* [ime] Add default profile

* [GUI] update missing translations for profile box
2026-07-29 15:59:57 +03:00
36 changed files with 2518 additions and 437 deletions
+16
View File
@@ -0,0 +1,16 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
/// <summary>
/// Stateless trailing action that opens the library folder picker.
/// </summary>
public sealed class AddFolderTile : LibraryTile
{
public static AddFolderTile Instance { get; } = new();
private AddFolderTile()
{
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ internal enum GameEntryChanges
Background = 4, Background = 4,
} }
public sealed class GameEntry : INotifyPropertyChanged public sealed class GameEntry : LibraryTile, INotifyPropertyChanged
{ {
// Placeholder gradients for games without cover art, picked // Placeholder gradients for games without cover art, picked
// deterministically from the game name so a game keeps its color. // deterministically from the game name so a game keeps its color.
+21
View File
@@ -45,6 +45,9 @@ public sealed class GuiSettings
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary> /// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
public string Language { get; set; } = "en"; public string Language { get; set; } = "en";
/// <summary>Default text-entry profile exposed to games.</summary>
public string DefaultProfile { get; set; } = "Sharp";
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary> /// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
public bool DiscordRichPresence { get; set; } = true; public bool DiscordRichPresence { get; set; } = true;
@@ -112,6 +115,18 @@ public sealed class GuiSettings
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles); settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
settings.LogLevel ??= "Info"; settings.LogLevel ??= "Info";
settings.Language ??= "en"; settings.Language ??= "en";
var legacyProfile = settings.EnvironmentToggles
.Select(entry => entry.Split('=', 2, StringSplitOptions.TrimEntries))
.FirstOrDefault(parts =>
parts.Length == 2 &&
string.Equals(parts[0], "SHARPEMU_DEFAULT_PROFILE", StringComparison.OrdinalIgnoreCase));
settings.EnvironmentToggles.RemoveAll(entry =>
string.Equals(
entry.Split('=', 2, StringSplitOptions.TrimEntries)[0],
"SHARPEMU_DEFAULT_PROFILE",
StringComparison.OrdinalIgnoreCase));
settings.DefaultProfile = NormalizeDefaultProfile(
legacyProfile is { Length: 2 } ? legacyProfile[1] : settings.DefaultProfile);
settings.DiscordClientId ??= "1525606762248540221"; settings.DiscordClientId ??= "1525606762248540221";
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0) if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
{ {
@@ -152,6 +167,12 @@ public sealed class GuiSettings
return $"{width}x{height}"; return $"{width}x{height}";
} }
internal static string NormalizeDefaultProfile(string? value)
{
var trimmed = value?.Trim();
return string.IsNullOrEmpty(trimmed) ? "Sharp" : trimmed;
}
public void Save() public void Save()
{ {
try try
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} لعبة", "Page.GameCount.Other": "{0} لعبة",
"Library.SearchWatermark": "ابحث في المكتبة...", "Library.SearchWatermark": "ابحث في المكتبة...",
"Library.AddFolder": " إضافة مجلد", "Library.AddFolder": "إضافة مجلد",
"Library.OpenFile": "فتح ملف...", "Library.OpenFile": "فتح ملف...",
"Library.Context.Launch": "تشغيل", "Library.Context.Launch": "تشغيل",
@@ -66,6 +66,8 @@
"Options.Language.Label": "لغة المحاكي", "Options.Language.Label": "لغة المحاكي",
"Options.Language.Desc": "اللغة المستخدمة في جميع أنحاء المشغل. تُطبق فوراً.", "Options.Language.Desc": "اللغة المستخدمة في جميع أنحاء المشغل. تُطبق فوراً.",
"Options.DefaultProfile.Label": "اسم الملف الشخصي الافتراضي",
"Options.DefaultProfile.Desc": "الاسم المستخدم عندما تطلب لعبة إدخال نص. القيمة الافتراضية هي Sharp.",
"Common.On": "تشغيل", "Common.On": "تشغيل",
"Common.Off": "إيقاف", "Common.Off": "إيقاف",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} jogos", "Page.GameCount.Other": "{0} jogos",
"Library.SearchWatermark": "Pesquisar na biblioteca…", "Library.SearchWatermark": "Pesquisar na biblioteca…",
"Library.AddFolder": " Adicionar pasta", "Library.AddFolder": "Adicionar pasta",
"Library.OpenFile": "Abrir arquivo…", "Library.OpenFile": "Abrir arquivo…",
"Library.Context.Launch": "Jogar", "Library.Context.Launch": "Jogar",
@@ -76,6 +76,8 @@
"Options.Language.Label": "Idioma do emulador", "Options.Language.Label": "Idioma do emulador",
"Options.Language.Desc": "Idioma usado em toda a interface do emulador. A alteração é aplicada imediatamente.", "Options.Language.Desc": "Idioma usado em toda a interface do emulador. A alteração é aplicada imediatamente.",
"Options.DefaultProfile.Label": "Nome de perfil padrão",
"Options.DefaultProfile.Desc": "Nome usado quando um jogo solicita entrada de texto. O padrão é Sharp.",
"Common.On": "Ativado", "Common.On": "Ativado",
"Common.Off": "Desativado", "Common.Off": "Desativado",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} Spiele", "Page.GameCount.Other": "{0} Spiele",
"Library.SearchWatermark": "Bibliothek durchsuchen…", "Library.SearchWatermark": "Bibliothek durchsuchen…",
"Library.AddFolder": " Spielordner hinzufügen", "Library.AddFolder": "Spielordner hinzufügen",
"Library.OpenFile": "Datei öffnen…", "Library.OpenFile": "Datei öffnen…",
"Library.Context.Launch": "Starten", "Library.Context.Launch": "Starten",
@@ -66,6 +66,8 @@
"Options.Language.Label": "Emulator-Sprache", "Options.Language.Label": "Emulator-Sprache",
"Options.Language.Desc": "Sprache der Benutzeroberfläche. Wird sofort angewendet.", "Options.Language.Desc": "Sprache der Benutzeroberfläche. Wird sofort angewendet.",
"Options.DefaultProfile.Label": "Standardprofilname",
"Options.DefaultProfile.Desc": "Name für Texteingaben in Spielen. Der Standardwert ist Sharp.",
"Common.On": "An", "Common.On": "An",
"Common.Off": "Aus", "Common.Off": "Aus",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} spil", "Page.GameCount.Other": "{0} spil",
"Library.SearchWatermark": "Søg i biblioteket…", "Library.SearchWatermark": "Søg i biblioteket…",
"Library.AddFolder": " Tilføj mappe", "Library.AddFolder": "Tilføj mappe",
"Library.OpenFile": "Åbn fil…", "Library.OpenFile": "Åbn fil…",
"Library.Context.Launch": "Start", "Library.Context.Launch": "Start",
@@ -66,6 +66,8 @@
"Options.Language.Label": "Emulatorsprog", "Options.Language.Label": "Emulatorsprog",
"Options.Language.Desc": "Sprog der bruges i hele launcheren. Anvendes med det samme.", "Options.Language.Desc": "Sprog der bruges i hele launcheren. Anvendes med det samme.",
"Options.DefaultProfile.Label": "Standardprofilnavn",
"Options.DefaultProfile.Desc": "Navn der bruges, når et spil beder om tekstinput. Standardværdien er Sharp.",
"Common.On": "Til", "Common.On": "Til",
"Common.Off": "Fra", "Common.Off": "Fra",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} games", "Page.GameCount.Other": "{0} games",
"Library.SearchWatermark": "Search library…", "Library.SearchWatermark": "Search library…",
"Library.AddFolder": " Add folder", "Library.AddFolder": "Add folder",
"Library.OpenFile": "Open file…", "Library.OpenFile": "Open file…",
"Library.Context.Launch": "Launch", "Library.Context.Launch": "Launch",
@@ -38,6 +38,8 @@
"Options.Env.LogIo.Desc": "Log file open, read, and path-resolve activity to the console.\nUse when a game cannot find its data files during boot.", "Options.Env.LogIo.Desc": "Log file open, read, and path-resolve activity to the console.\nUse when a game cannot find its data files during boot.",
"Options.Env.LogNp.Desc": "Log NP (PlayStation Network) library calls to the console.", "Options.Env.LogNp.Desc": "Log NP (PlayStation Network) library calls to the console.",
"Options.Env.GuestImageCpuSync.Desc": "Re-upload guest surfaces the game's own CPU code rewrites.\nLeave off normally. Turn on for titles whose CPU-drawn surfaces never reach the screen.\nCosts performance and regresses some titles, such as GTA V.", "Options.Env.GuestImageCpuSync.Desc": "Re-upload guest surfaces the game's own CPU code rewrites.\nLeave off normally. Turn on for titles whose CPU-drawn surfaces never reach the screen.\nCosts performance and regresses some titles, such as GTA V.",
"Options.DefaultProfile.Label": "Default profile name",
"Options.DefaultProfile.Desc": "Name used when a game asks for text input. Defaults to Sharp.",
"Options.Section.Emulation": "EMULATION", "Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "LOGGING", "Options.Section.Logging": "LOGGING",
"Options.Section.Launcher": "LAUNCHER", "Options.Section.Launcher": "LAUNCHER",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} juegos", "Page.GameCount.Other": "{0} juegos",
"Library.SearchWatermark": "Buscar en la biblioteca…", "Library.SearchWatermark": "Buscar en la biblioteca…",
"Library.AddFolder": " Añadir carpeta", "Library.AddFolder": "Añadir carpeta",
"Library.OpenFile": "Abrir archivo…", "Library.OpenFile": "Abrir archivo…",
"Library.Context.Launch": "Iniciar", "Library.Context.Launch": "Iniciar",
@@ -66,6 +66,8 @@
"Options.Language.Label": "Idioma del emulador", "Options.Language.Label": "Idioma del emulador",
"Options.Language.Desc": "Idioma utilizado en todo el launcher. Se aplica inmediatamente.", "Options.Language.Desc": "Idioma utilizado en todo el launcher. Se aplica inmediatamente.",
"Options.DefaultProfile.Label": "Nombre de perfil predeterminado",
"Options.DefaultProfile.Desc": "Nombre utilizado cuando un juego solicita introducir texto. El valor predeterminado es Sharp.",
"Common.On": "Encendido", "Common.On": "Encendido",
"Common.Off": "Apagado", "Common.Off": "Apagado",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} jeux", "Page.GameCount.Other": "{0} jeux",
"Library.SearchWatermark": "Rechercher dans la bibliothèque…", "Library.SearchWatermark": "Rechercher dans la bibliothèque…",
"Library.AddFolder": " Ajouter un dossier", "Library.AddFolder": "Ajouter un dossier",
"Library.OpenFile": "Ouvrir un fichier…", "Library.OpenFile": "Ouvrir un fichier…",
"Library.Context.Launch": "Lancer", "Library.Context.Launch": "Lancer",
@@ -76,6 +76,8 @@
"Options.Language.Label": "Langue de l’émulateur", "Options.Language.Label": "Langue de l’émulateur",
"Options.Language.Desc": "Langue utilisée dans lensemble du lanceur. Le changement est immédiat.", "Options.Language.Desc": "Langue utilisée dans lensemble du lanceur. Le changement est immédiat.",
"Options.DefaultProfile.Label": "Nom de profil par défaut",
"Options.DefaultProfile.Desc": "Nom utilisé lorsquun jeu demande une saisie de texte. La valeur par défaut est Sharp.",
"Common.On": "Activé", "Common.On": "Activé",
"Common.Off": "Désactivé", "Common.Off": "Désactivé",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} játékok", "Page.GameCount.Other": "{0} játékok",
"Library.SearchWatermark": "Keresés a könyvtárban", "Library.SearchWatermark": "Keresés a könyvtárban",
"Library.AddFolder": " Mappa hozzáadása", "Library.AddFolder": "Mappa hozzáadása",
"Library.OpenFile": "Fájl megnyitása…", "Library.OpenFile": "Fájl megnyitása…",
"Library.Context.Launch": "Inditás", "Library.Context.Launch": "Inditás",
@@ -76,6 +76,8 @@
"Options.Language.Label": "Emulátor nyelve", "Options.Language.Label": "Emulátor nyelve",
"Options.Language.Desc": "Az indítóban használt nyelv. Azonnal érvénybe lép.", "Options.Language.Desc": "Az indítóban használt nyelv. Azonnal érvénybe lép.",
"Options.DefaultProfile.Label": "Alapértelmezett profilnév",
"Options.DefaultProfile.Desc": "A játékok szövegbeviteli kéréseinél használt név. Az alapértelmezett érték Sharp.",
"Common.On": "Be", "Common.On": "Be",
"Common.Off": "Ki", "Common.Off": "Ki",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} giochi", "Page.GameCount.Other": "{0} giochi",
"Library.SearchWatermark": "Cerca nella libreria…", "Library.SearchWatermark": "Cerca nella libreria…",
"Library.AddFolder": " Aggiungi cartella", "Library.AddFolder": "Aggiungi cartella",
"Library.OpenFile": "Apri file…", "Library.OpenFile": "Apri file…",
"Library.Context.Launch": "Avvia", "Library.Context.Launch": "Avvia",
@@ -69,6 +69,8 @@
"Options.Language.Label": "Lingua dell'emulatore", "Options.Language.Label": "Lingua dell'emulatore",
"Options.Language.Desc": "Lingua utilizzata in tutto il launcher. Viene applicata immediatamente.", "Options.Language.Desc": "Lingua utilizzata in tutto il launcher. Viene applicata immediatamente.",
"Options.DefaultProfile.Label": "Nome profilo predefinito",
"Options.DefaultProfile.Desc": "Nome usato quando un gioco richiede l'inserimento di testo. Il valore predefinito è Sharp.",
"Common.On": "On", "Common.On": "On",
"Common.Off": "Off", "Common.Off": "Off",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "ゲーム {0}本", "Page.GameCount.Other": "ゲーム {0}本",
"Library.SearchWatermark": "ライブラリを検索…", "Library.SearchWatermark": "ライブラリを検索…",
"Library.AddFolder": " フォルダーを追加", "Library.AddFolder": "フォルダーを追加",
"Library.OpenFile": "ファイルを開く…", "Library.OpenFile": "ファイルを開く…",
"Library.Context.Launch": "起動", "Library.Context.Launch": "起動",
@@ -66,6 +66,8 @@
"Options.Language.Label": "エミュレータの言語", "Options.Language.Label": "エミュレータの言語",
"Options.Language.Desc": "ランチャー全体で使用される言語。変更はすぐに適用されます。", "Options.Language.Desc": "ランチャー全体で使用される言語。変更はすぐに適用されます。",
"Options.DefaultProfile.Label": "既定のプロフィール名",
"Options.DefaultProfile.Desc": "ゲームがテキスト入力を要求したときに使用する名前です。既定値は Sharp です。",
"Common.On": "オン", "Common.On": "オン",
"Common.Off": "オフ", "Common.Off": "オフ",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "게임 {0}개", "Page.GameCount.Other": "게임 {0}개",
"Library.SearchWatermark": "라이브러리 검색…", "Library.SearchWatermark": "라이브러리 검색…",
"Library.AddFolder": " 폴더 추가", "Library.AddFolder": "폴더 추가",
"Library.OpenFile": "파일 열기…", "Library.OpenFile": "파일 열기…",
"Library.Context.Launch": "실행", "Library.Context.Launch": "실행",
@@ -66,6 +66,8 @@
"Options.Language.Label": "에뮬레이터 언어", "Options.Language.Label": "에뮬레이터 언어",
"Options.Language.Desc": "런처 전체에 사용되는 언어입니다. 변경 사항은 즉시 적용됩니다.", "Options.Language.Desc": "런처 전체에 사용되는 언어입니다. 변경 사항은 즉시 적용됩니다.",
"Options.DefaultProfile.Label": "기본 프로필 이름",
"Options.DefaultProfile.Desc": "게임에서 텍스트 입력을 요청할 때 사용할 이름입니다. 기본값은 Sharp입니다.",
"Common.On": "켬", "Common.On": "켬",
"Common.Off": "끔", "Common.Off": "끔",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} games", "Page.GameCount.Other": "{0} games",
"Library.SearchWatermark": "Zoeken in bibliotheek…", "Library.SearchWatermark": "Zoeken in bibliotheek…",
"Library.AddFolder": " Map toevoegen", "Library.AddFolder": "Map toevoegen",
"Library.OpenFile": "Bestand openen…", "Library.OpenFile": "Bestand openen…",
"Library.Context.Launch": "Starten", "Library.Context.Launch": "Starten",
@@ -66,6 +66,8 @@
"Options.Language.Label": "Taal van de emulator", "Options.Language.Label": "Taal van de emulator",
"Options.Language.Desc": "Taal die in de hele launcher wordt gebruikt. Wordt direct toegepast.", "Options.Language.Desc": "Taal die in de hele launcher wordt gebruikt. Wordt direct toegepast.",
"Options.DefaultProfile.Label": "Standaardprofielnaam",
"Options.DefaultProfile.Desc": "Naam die wordt gebruikt wanneer een game om tekstinvoer vraagt. De standaardwaarde is Sharp.",
"Common.On": "Aan", "Common.On": "Aan",
"Common.Off": "Uit", "Common.Off": "Uit",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} jogos", "Page.GameCount.Other": "{0} jogos",
"Library.SearchWatermark": "Pesquisar biblioteca…", "Library.SearchWatermark": "Pesquisar biblioteca…",
"Library.AddFolder": " Adicionar pasta", "Library.AddFolder": "Adicionar pasta",
"Library.OpenFile": "Abrir ficheiro…", "Library.OpenFile": "Abrir ficheiro…",
"Library.Context.Launch": "Iniciar", "Library.Context.Launch": "Iniciar",
@@ -76,6 +76,8 @@
"Options.Language.Label": "Idioma do emulador", "Options.Language.Label": "Idioma do emulador",
"Options.Language.Desc": "Idioma utilizado em todo o lançador. Aplica-se de imediato.", "Options.Language.Desc": "Idioma utilizado em todo o lançador. Aplica-se de imediato.",
"Options.DefaultProfile.Label": "Nome de perfil predefinido",
"Options.DefaultProfile.Desc": "Nome utilizado quando um jogo solicita a introdução de texto. O valor predefinido é Sharp.",
"Common.On": "Ativado", "Common.On": "Ativado",
"Common.Off": "Desativado", "Common.Off": "Desativado",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "Игр: {0}", "Page.GameCount.Other": "Игр: {0}",
"Library.SearchWatermark": "Поиск…", "Library.SearchWatermark": "Поиск…",
"Library.AddFolder": " Добавить папку", "Library.AddFolder": "Добавить папку",
"Library.OpenFile": "Открыть файл…", "Library.OpenFile": "Открыть файл…",
"Library.Context.Launch": "Запустить", "Library.Context.Launch": "Запустить",
@@ -109,6 +109,8 @@
"Options.Language.Label": "Язык эмулятора", "Options.Language.Label": "Язык эмулятора",
"Options.Language.Desc": "Язык интерфейса лаунчера. Изменение применяется сразу.", "Options.Language.Desc": "Язык интерфейса лаунчера. Изменение применяется сразу.",
"Options.DefaultProfile.Label": "Имя профиля по умолчанию",
"Options.DefaultProfile.Desc": "Имя, используемое, когда игра запрашивает ввод текста. Значение по умолчанию — Sharp.",
"Common.On": "Включено", "Common.On": "Включено",
"Common.Off": "Выключено", "Common.Off": "Выключено",
+3 -1
View File
@@ -7,7 +7,7 @@
"Page.GameCount.Other": "{0} oyun", "Page.GameCount.Other": "{0} oyun",
"Library.SearchWatermark": "Kütüphanede ara…", "Library.SearchWatermark": "Kütüphanede ara…",
"Library.AddFolder": " Klasör ekle", "Library.AddFolder": "Klasör ekle",
"Library.OpenFile": "Dosya aç…", "Library.OpenFile": "Dosya aç…",
"Library.Context.Launch": "Başlat", "Library.Context.Launch": "Başlat",
@@ -173,6 +173,8 @@
"Options.Env.LogIo.Desc": "Dosya açma, okuma ve yol çözümleme etkinliğini konsola günlükle.\nBir oyun açılışta veri dosyalarını bulamıyorsa kullanın.", "Options.Env.LogIo.Desc": "Dosya açma, okuma ve yol çözümleme etkinliğini konsola günlükle.\nBir oyun açılışta veri dosyalarını bulamıyorsa kullanın.",
"Options.Env.LogNp.Desc": "NP (PlayStation Network) kütüphane çağrılarını konsola günlükle.", "Options.Env.LogNp.Desc": "NP (PlayStation Network) kütüphane çağrılarını konsola günlükle.",
"Options.Env.GuestImageCpuSync.Desc": "Oyunun kendi CPU kodunun yeniden yazdığı misafir yüzeyleri tekrar yükler.\nNormalde kapalı bırakın. CPU ile çizilen yüzeyleri ekrana ulaşmayan oyunlarda açın.\nPerformansa mal olur ve GTA V gibi bazı oyunlarda soruna yol açar.", "Options.Env.GuestImageCpuSync.Desc": "Oyunun kendi CPU kodunun yeniden yazdığı misafir yüzeyleri tekrar yükler.\nNormalde kapalı bırakın. CPU ile çizilen yüzeyleri ekrana ulaşmayan oyunlarda açın.\nPerformansa mal olur ve GTA V gibi bazı oyunlarda soruna yol açar.",
"Options.DefaultProfile.Label": "Varsayilan profil adi",
"Options.DefaultProfile.Desc": "Oyun metin girisi istediginde kullanilacak ad. Varsayilan deger Sharp'tir.",
"Common.Save": "Kaydet", "Common.Save": "Kaydet",
"Common.Cancel": "İptal", "Common.Cancel": "İptal",
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})", "PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
+9
View File
@@ -0,0 +1,9 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.GUI;
/// <summary>
/// Base type for game cards and actions shown in the library rail.
/// </summary>
public abstract class LibraryTile;
+103
View File
@@ -0,0 +1,103 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
namespace SharpEmu.GUI;
/// <summary>
/// Read-only view of the visible games with one persistent action tile at
/// the end. Game collection changes keep their original indices, so Avalonia
/// can update and virtualize the rail without rebuilding every item.
/// </summary>
public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, INotifyCollectionChanged
{
private readonly ObservableCollection<GameEntry> _games;
public LibraryTileCollection(ObservableCollection<GameEntry> games)
{
_games = games;
_games.CollectionChanged += OnGamesCollectionChanged;
}
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _games.Count + 1;
bool IList.IsFixedSize => false;
bool IList.IsReadOnly => true;
bool ICollection.IsSynchronized => false;
object ICollection.SyncRoot => this;
public LibraryTile this[int index]
{
get
{
if ((uint)index < (uint)_games.Count)
{
return _games[index];
}
if (index == _games.Count)
{
return AddFolderTile.Instance;
}
throw new ArgumentOutOfRangeException(nameof(index));
}
}
object? IList.this[int index]
{
get => this[index];
set => throw new NotSupportedException();
}
public IEnumerator<LibraryTile> GetEnumerator()
{
foreach (var game in _games)
{
yield return game;
}
yield return AddFolderTile.Instance;
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
int IList.Add(object? value) => throw new NotSupportedException();
void IList.Clear() => throw new NotSupportedException();
bool IList.Contains(object? value) => ((IList)this).IndexOf(value) >= 0;
int IList.IndexOf(object? value) => value switch
{
GameEntry game => _games.IndexOf(game),
AddFolderTile => _games.Count,
_ => -1,
};
void IList.Insert(int index, object? value) => throw new NotSupportedException();
void IList.Remove(object? value) => throw new NotSupportedException();
void IList.RemoveAt(int index) => throw new NotSupportedException();
void ICollection.CopyTo(Array array, int index)
{
ArgumentNullException.ThrowIfNull(array);
foreach (var tile in this)
{
array.SetValue(tile, index++);
}
}
private void OnGamesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) =>
CollectionChanged?.Invoke(this, args);
}
+138 -133
View File
@@ -6,7 +6,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI" xmlns:local="clr-namespace:SharpEmu.GUI"
xmlns:primitives="clr-namespace:Avalonia.Controls.Primitives;assembly=Avalonia.Controls"
x:Class="SharpEmu.GUI.MainWindow" x:Class="SharpEmu.GUI.MainWindow"
Title="SharpEmu" Title="SharpEmu"
Width="1280" Height="820" Width="1280" Height="820"
@@ -91,7 +90,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
<!-- Main content --> <!-- Main content -->
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto"> <Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto">
<!-- Library / Options page switcher, with the library toolbar sharing <!-- Library / Options page switcher, with the library toolbar sharing
the same row on the right. Plain buttons (not TabItem) so there is the same row on the right. Plain buttons (not TabItem) so there is
@@ -119,10 +118,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
PlaceholderText="{Binding [Library.SearchWatermark], Source={x:Static local:Localization.Instance}}" PlaceholderText="{Binding [Library.SearchWatermark], Source={x:Static local:Localization.Instance}}"
Width="240" Width="240"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<Button x:Name="AddFolderButton"
Classes="ghost"
Content="{Binding [Library.AddFolder], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
<Button x:Name="OpenFileButton" <Button x:Name="OpenFileButton"
Classes="ghost" Classes="ghost"
Content="{Binding [Library.OpenFile], Source={x:Static local:Localization.Instance}}" Content="{Binding [Library.OpenFile], Source={x:Static local:Localization.Instance}}"
@@ -132,12 +127,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Panel Grid.Row="1" x:Name="PagesHost"> <Panel Grid.Row="1" x:Name="PagesHost">
<!-- Library page. The tile row gets extra top margin so it sits <!-- Library page. Covers use a horizontal virtualized rail so the
closer to eye level (PS5 home-screen style) instead of hugging number of realized images stays bounded for large libraries. -->
the Library/Options switcher above it. --> <Grid x:Name="LibraryPage" Margin="0,46,0,0" RowDefinitions="188,*">
<Panel x:Name="LibraryPage" Margin="0,64,0,0">
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent" <ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
SelectionMode="Single" Padding="0"> Grid.Row="0"
SelectionMode="Single" Padding="4,0,28,0">
<ListBox.ContextMenu> <ListBox.ContextMenu>
<ContextMenu x:Name="GameContextMenu" Placement="Pointer"> <ContextMenu x:Name="GameContextMenu" Placement="Pointer">
<MenuItem x:Name="CtxLaunch" <MenuItem x:Name="CtxLaunch"
@@ -190,49 +185,135 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ListBox.ContextMenu> </ListBox.ContextMenu>
<ListBox.ItemsPanel> <ListBox.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<WrapPanel /> <VirtualizingStackPanel Orientation="Horizontal" />
</ItemsPanelTemplate> </ItemsPanelTemplate>
</ListBox.ItemsPanel> </ListBox.ItemsPanel>
<ListBox.ItemTemplate> <ListBox.DataTemplates>
<DataTemplate x:DataType="local:GameEntry" x:CompileBindings="True"> <DataTemplate x:DataType="local:GameEntry" x:CompileBindings="True">
<StackPanel Width="128" Height="172" Spacing="7"> <Border Classes="coverShadow libraryGameCard"
<Border Classes="coverShadow" Width="128" Height="128"> Width="148"
<Border Classes="coverClip"> Height="148"
<Panel> ToolTip.Tip="{Binding Name}"
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}"> AutomationProperties.Name="{Binding Name}">
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold" <Border Classes="coverClip">
Foreground="#E8ECF4" Opacity="0.85" <Panel>
HorizontalAlignment="Center" VerticalAlignment="Center" /> <Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
</Border> <TextBlock Text="{Binding Initials}" FontSize="38" FontWeight="Light"
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" /> Foreground="#C4E8ECF4"
</Panel> HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border> </Border>
<Image Source="{Binding Cover}"
Stretch="UniformToFill"
RenderOptions.BitmapInterpolationMode="LowQuality"
IsVisible="{Binding HasCover}" />
</Panel>
</Border> </Border>
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold" </Border>
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
TextAlignment="Center" HorizontalAlignment="Center" />
</StackPanel>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> <DataTemplate x:DataType="local:AddFolderTile">
<Border Classes="addFolderTile"
Width="148"
Height="148"
ToolTip.Tip="{Binding [Library.AddFolder],
Source={x:Static local:Localization.Instance},
x:CompileBindings=False}"
AutomationProperties.Name="{Binding [Library.AddFolder],
Source={x:Static local:Localization.Instance},
x:CompileBindings=False}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<Border Classes="addFolderTileIcon">
<TextBlock Classes="addFolderGlyph addFolderIconGlyph"
Text="+"
FontSize="34"
FontWeight="Light"
LineHeight="34"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<TextBlock Classes="addFolderGlyph"
Text="{Binding [Library.AddFolder],
Source={x:Static local:Localization.Instance},
x:CompileBindings=False}"
FontSize="12"
FontWeight="SemiBold"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox> </ListBox>
<!-- 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"
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"
FontWeight="SemiBold"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
MaxLines="2" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Border Classes="pill" IsVisible="{Binding HasTitleId}">
<TextBlock Text="{Binding TitleId}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill" IsVisible="{Binding HasVersion}">
<TextBlock Text="{Binding VersionText}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill">
<TextBlock Text="{Binding SizeText}" FontSize="11" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
</StackPanel>
<Grid Width="360"
ColumnDefinitions="*,*"
ColumnSpacing="8"
HorizontalAlignment="Left">
<Button x:Name="LaunchButton"
Grid.Column="0"
Classes="accent"
Content="Launch"
HorizontalAlignment="Stretch"
IsEnabled="False" />
<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>
</StackPanel>
<!-- Empty state --> <!-- Empty state -->
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center" <StackPanel x:Name="EmptyState" Grid.RowSpan="2"
Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="False"> IsVisible="False">
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" /> <TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" />
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold" <TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold"
HorizontalAlignment="Center" /> HorizontalAlignment="Center" />
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started." <TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" /> FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
<Button x:Name="EmptyAddFolderButton" Classes="accent"
Content="{Binding [Library.Empty.AddFolder], Source={x:Static local:Localization.Instance}}"
HorizontalAlignment="Center" Margin="0,8,0,0" />
</StackPanel> </StackPanel>
<!-- Loading state: covers the grid while a scan (initial load, <!-- Loading state: covers the grid while a scan (initial load,
rescan, or a just-added folder) is in flight, so the screen rescan, or a just-added folder) is in flight, so the screen
never looks blank mid-scan. --> never looks blank mid-scan. -->
<StackPanel x:Name="LoadingState" Spacing="14" HorizontalAlignment="Center" VerticalAlignment="Center" <StackPanel x:Name="LoadingState" Grid.RowSpan="2"
Spacing="14" HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="False"> IsVisible="False">
<ProgressBar IsIndeterminate="True" Width="180" Height="3" /> <ProgressBar IsIndeterminate="True" Width="180" Height="3" />
<TextBlock x:Name="LoadingStateText" <TextBlock x:Name="LoadingStateText"
@@ -240,11 +321,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
FontSize="13" FontSize="13"
Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" /> Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
</StackPanel> </StackPanel>
</Panel> </Grid>
<!-- Options page: sub-tabs so future categories (Graphics, …) slot <!-- Options page: sub-tabs so future categories (Graphics, …) slot
in next to General. Card-grouped sections match the visual in next to General. Card-grouped sections match the visual
language used by the console panel and launch bar below. --> language used by the console panel below. -->
<Grid x:Name="OptionsPage" IsVisible="False"> <Grid x:Name="OptionsPage" IsVisible="False">
<TabControl> <TabControl>
<TabItem x:Name="GeneralTabItem" <TabItem x:Name="GeneralTabItem"
@@ -346,6 +427,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ComboBox> </ComboBox>
</local:SettingRow> </local:SettingRow>
<local:SettingRow x:Name="DefaultProfileRow"
Label="{Binding [Options.DefaultProfile.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.DefaultProfile.Desc], Source={x:Static local:Localization.Instance}}">
<TextBox x:Name="DefaultProfileBox"
Width="180"
MaxLength="16"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="TitleMusicRow" <local:SettingRow x:Name="TitleMusicRow"
Label="{Binding [Options.TitleMusic.Label], Source={x:Static local:Localization.Instance}}" Label="{Binding [Options.TitleMusic.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.TitleMusic.Desc], Source={x:Static local:Localization.Instance}}"> Description="{Binding [Options.TitleMusic.Desc], Source={x:Static local:Localization.Instance}}">
@@ -646,6 +736,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}" OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</local:SettingRow> </local:SettingRow>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -660,7 +751,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Border Grid.Row="2" x:Name="ConsolePanel" Classes="card" Padding="0" Height="240" <Border Grid.Row="2" x:Name="ConsolePanel" Classes="card" Padding="0" Height="240"
Margin="0,12,0,0" IsVisible="False"> Margin="0,12,0,0" IsVisible="False">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
<TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle" <TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle"
Text="{Binding [Console.Title], Source={x:Static local:Localization.Instance}}" Text="{Binding [Console.Title], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
@@ -682,7 +773,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Button Grid.Column="5" x:Name="ClearLogButton" Classes="ghost" <Button Grid.Column="5" x:Name="ClearLogButton" Classes="ghost"
Content="{Binding [Console.Clear], Source={x:Static local:Localization.Instance}}" Content="{Binding [Console.Clear], Source={x:Static local:Localization.Instance}}"
FontSize="12" FontSize="12"
Padding="10,4" /> Padding="10,4" Margin="0,0,8,0" />
<Button Grid.Column="6"
x:Name="CloseConsoleButton"
Classes="ghost"
Content="×"
AutomationProperties.Name="{Binding [Launch.Console],
Source={x:Static local:Localization.Instance}}"
FontSize="16"
Padding="10,2" />
</Grid> </Grid>
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0" <ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12"> BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12">
@@ -695,102 +794,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
</Border> </Border>
<!-- Launch bar; capped so it does not sprawl on a maximized window. -->
<Border Grid.Row="3" x:Name="LaunchBar" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Selected game cover thumbnail -->
<Border Grid.Column="0" Classes="coverClip" Width="56" Height="56" CornerRadius="8"
VerticalAlignment="Center">
<Panel x:Name="SelectedCoverPanel"
x:DataType="local:GameEntry"
x:CompileBindings="True">
<Border Background="{Binding PlaceholderBrush, FallbackValue={x:Null}}"
IsVisible="{Binding !HasCover, FallbackValue=False}">
<TextBlock Text="{Binding Initials}" FontSize="20" FontWeight="Bold"
Foreground="#E8ECF4" Opacity="0.85"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Image Source="{Binding Cover}" Stretch="UniformToFill"
IsVisible="{Binding HasCover, FallbackValue=False}" />
</Panel>
</Border>
<StackPanel Grid.Column="1" Spacing="4" VerticalAlignment="Center" Margin="14,0,14,0">
<Grid ColumnDefinitions="Auto,Auto,*">
<TextBlock Grid.Column="0" x:Name="SelectedGameTitle" Text="No game selected" FontSize="16"
FontWeight="Bold" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
MaxWidth="340" Margin="0,0,10,0" />
<!-- Title id / version / size badges, right next to the
title. The title's own MaxWidth (not a "*" column) is
what keeps them from drifting to the far right. -->
<StackPanel Grid.Column="1" x:Name="SelectedBadgesRow"
x:DataType="local:GameEntry"
x:CompileBindings="True"
Orientation="Horizontal" Spacing="6"
IsVisible="False" VerticalAlignment="Center">
<Border Classes="pill" IsVisible="{Binding HasTitleId, FallbackValue=False}">
<TextBlock Text="{Binding TitleId}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill" IsVisible="{Binding HasVersion, FallbackValue=False}">
<TextBlock Text="{Binding VersionText}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill">
<TextBlock Text="{Binding SizeText, FallbackValue=''}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
</StackPanel>
</Grid>
<TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7">
<Ellipse x:Name="StatusDot" Width="8" Height="8" Fill="{StaticResource FaintBrush}"
VerticalAlignment="Center" />
<TextBlock x:Name="StatusText" Text="Idle" FontSize="11" Foreground="{StaticResource MutedBrush}"
VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<ToggleButton x:Name="ConsoleToggle" Classes="ghost"
Content="{Binding [Launch.Console], Source={x:Static local:Localization.Instance}}" />
<Button x:Name="LaunchButton" Classes="accent"
Content="{Binding [Launch.Launch], Source={x:Static local:Localization.Instance}}"
IsEnabled="False" />
<Button x:Name="StopButton" Classes="danger"
Content="{Binding [Launch.Stop], Source={x:Static local:Localization.Instance}}"
IsEnabled="False" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
</Grid> </Grid>
<!-- Keep launch progress above the blurred library while the SDL game
process owns its independent top-level window. -->
<primitives:Popup x:Name="SessionLoadingPopup"
IsOpen="False"
PlacementTarget="{Binding #MainContent}"
Placement="Center"
Topmost="True"
ShouldUseOverlayLayer="False"
TakesFocusFromNativeControl="False"
IsLightDismissEnabled="False">
<Border Classes="card" Width="380" CornerRadius="18" Padding="22,18">
<StackPanel Spacing="12">
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<ProgressBar x:Name="SessionLoadingProgress" IsIndeterminate="True" Height="5" />
</StackPanel>
</Border>
</primitives:Popup>
<!-- Status bar --> <!-- Status bar -->
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}" <Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
ColumnDefinitions="*,Auto"> ColumnDefinitions="*,Auto">
+128 -249
View File
@@ -33,8 +33,6 @@ public partial class MainWindow : Window
{ {
private const int MaxConsoleLines = 4000; private const int MaxConsoleLines = 4000;
private const int MaxConsoleLinesPerFlush = 500; private const int MaxConsoleLinesPerFlush = 500;
private const double LaunchBlurRadius = 12;
private const double BlurTransitionSeconds = 0.24;
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE")); private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488")); private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
@@ -83,17 +81,12 @@ public partial class MainWindow : Window
]; ];
private readonly List<GameEntry> _allGames = new(); private readonly List<GameEntry> _allGames = new();
private readonly ObservableCollection<GameEntry> _visibleGames = new(); private readonly ObservableCollection<GameEntry> _visibleGames = new();
private readonly LibraryTileCollection _libraryTiles;
private readonly GameLibraryWatcher _libraryWatcher = new(); private readonly GameLibraryWatcher _libraryWatcher = new();
private readonly AvaloniaList<LogLine> _consoleLines = new(); private readonly AvaloniaList<LogLine> _consoleLines = new();
private readonly List<LogLine> _allConsoleLines = new(); private readonly List<LogLine> _allConsoleLines = new();
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new(); private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
private readonly DispatcherTimer _consoleFlushTimer; private readonly DispatcherTimer _consoleFlushTimer;
private readonly DispatcherTimer _libraryBlurTimer;
private BlurEffect? _libraryBlur;
private double _libraryBlurStartRadius;
private double _libraryBlurTargetRadius;
private long _libraryBlurStartedAt;
private bool _clearLibraryBlurWhenComplete;
private GuiSettings _settings = new(); private GuiSettings _settings = new();
private IReadOnlyList<HostDisplayOption> _hostDisplays = []; private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
@@ -123,23 +116,19 @@ public partial class MainWindow : Window
private int _detailLoadGeneration; private int _detailLoadGeneration;
private int _backdropGeneration; private int _backdropGeneration;
private bool _isClosing; private bool _isClosing;
private bool _restoringGameSelection;
private bool _addFolderInProgress;
private GameEntry? _lastSelectedGame;
// Bundled key art shown whenever no game-specific backdrop applies; the // Bundled key art shown whenever no game-specific backdrop applies; the
// plain window color remains the fallback when the asset fails to load. // plain window color remains the fallback when the asset fails to load.
private Bitmap? _defaultBackdrop; private Bitmap? _defaultBackdrop;
// Whether the native loading/closing popup should be showing; it is a
// desktop-topmost popup, so it closes while the launcher is in the
// background or minimized and reopens from this flag on activation.
private bool _sessionLoadingActive;
// Controller navigation state. // Controller navigation state.
private readonly DispatcherTimer _gamepadTimer; private readonly DispatcherTimer _gamepadTimer;
private HostGamepadButtons _previousPadButtons; private HostGamepadButtons _previousPadButtons;
private long _navLeftNextAt; private long _navLeftNextAt;
private long _navRightNextAt; private long _navRightNextAt;
private long _navUpNextAt;
private long _navDownNextAt;
//Github http client for latest commit //Github http client for latest commit
private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient(); private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient();
@@ -156,6 +145,7 @@ public partial class MainWindow : Window
{ {
InitializeComponent(); InitializeComponent();
InitializeLocalizedChoiceBoxes(); InitializeLocalizedChoiceBoxes();
_libraryTiles = new LibraryTileCollection(_visibleGames);
try try
{ {
@@ -169,7 +159,7 @@ public partial class MainWindow : Window
_defaultBackdrop = null; // color background remains the fallback _defaultBackdrop = null; // color background remains the fallback
} }
GameList.ItemsSource = _visibleGames; GameList.ItemsSource = _libraryTiles;
_libraryWatcher.RefreshRequested += OnLibraryRefreshRequested; _libraryWatcher.RefreshRequested += OnLibraryRefreshRequested;
ConsoleList.ItemsSource = _consoleLines; ConsoleList.ItemsSource = _consoleLines;
_consoleMirror = GuiConsoleMirror.Install((line, isError) => _consoleMirror = GuiConsoleMirror.Install((line, isError) =>
@@ -187,23 +177,6 @@ public partial class MainWindow : Window
}; };
_consoleFlushTimer.Start(); _consoleFlushTimer.Start();
_libraryBlurTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(16),
};
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
// Native popups float above every window on the desktop; they must
// follow the launcher into the background or a minimized state.
Activated += (_, _) =>
{
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
};
Deactivated += (_, _) =>
{
SessionLoadingPopup.IsOpen = false;
};
TitleBar.PointerPressed += OnTitleBarPointerPressed; TitleBar.PointerPressed += OnTitleBarPointerPressed;
TitleBar.DoubleTapped += OnTitleBarDoubleTapped; TitleBar.DoubleTapped += OnTitleBarDoubleTapped;
MinimizeButton.Click += (_, _) => WindowState = WindowState.Minimized; MinimizeButton.Click += (_, _) => WindowState = WindowState.Minimized;
@@ -221,18 +194,26 @@ public partial class MainWindow : Window
UpdateWindowChromeState(); UpdateWindowChromeState();
}; };
UpdateWindowChromeState(); UpdateWindowChromeState();
GameList.SelectionChanged += (_, _) => UpdateSelectedGame(); GameList.SelectionChanged += (_, _) => OnGameListSelectionChanged();
GameList.DoubleTapped += (_, _) => LaunchSelected(); GameList.DoubleTapped += OnGameListDoubleTapped;
SearchBox.TextChanged += (_, _) => RefreshVisibleGames(); SearchBox.TextChanged += (_, _) => RefreshVisibleGames();
ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines(); ConsoleSearchBox.TextChanged += (_, _) => RefreshVisibleConsoleLines();
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync(); OpenFileButton.Click += async (_, _) => await OpenFileAsync();
LaunchButton.Click += (_, _) => LaunchSelected(); LaunchButton.Click += (_, _) =>
{
if (_isRunning)
{
StopEmulator();
}
else
{
LaunchSelected();
}
};
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); }; ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
StopButton.Click += (_, _) => StopEmulator();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync(); CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow(); DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
CloseConsoleButton.Click += (_, _) => ConsoleToggle.IsChecked = false;
LibraryTabButton.Click += (_, _) => SetActivePage(0); LibraryTabButton.Click += (_, _) => SetActivePage(0);
OptionsTabButton.Click += (_, _) => SetActivePage(1); OptionsTabButton.Click += (_, _) => SetActivePage(1);
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null; ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
@@ -298,6 +279,8 @@ public partial class MainWindow : Window
SetEnvironmentToggle( SetEnvironmentToggle(
"SHARPEMU_GUEST_IMAGE_CPU_SYNC", "SHARPEMU_GUEST_IMAGE_CPU_SYNC",
EnvGuestImageCpuSyncToggle.IsChecked == true); EnvGuestImageCpuSyncToggle.IsChecked == true);
DefaultProfileBox.TextChanged += (_, _) =>
_settings.DefaultProfile = GuiSettings.NormalizeDefaultProfile(DefaultProfileBox.Text);
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged(); LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel); GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
@@ -518,8 +501,6 @@ public partial class MainWindow : Window
var now = Environment.TickCount64; var now = Environment.TickCount64;
var left = (pad.Buttons & HostGamepadButtons.Left) != 0 || pad.LeftX < 64; var left = (pad.Buttons & HostGamepadButtons.Left) != 0 || pad.LeftX < 64;
var right = (pad.Buttons & HostGamepadButtons.Right) != 0 || pad.LeftX > 192; var right = (pad.Buttons & HostGamepadButtons.Right) != 0 || pad.LeftX > 192;
var up = (pad.Buttons & HostGamepadButtons.Up) != 0 || pad.LeftY < 64;
var down = (pad.Buttons & HostGamepadButtons.Down) != 0 || pad.LeftY > 192;
if (ShouldNavigate(left, ref _navLeftNextAt, now)) if (ShouldNavigate(left, ref _navLeftNextAt, now))
{ {
@@ -531,16 +512,6 @@ public partial class MainWindow : Window
MoveSelection(1); MoveSelection(1);
} }
if (ShouldNavigate(up, ref _navUpNextAt, now))
{
MoveSelection(-TilesPerRow());
}
if (ShouldNavigate(down, ref _navDownNextAt, now))
{
MoveSelection(TilesPerRow());
}
var pressed = pad.Buttons & ~_previousPadButtons; var pressed = pad.Buttons & ~_previousPadButtons;
if ((pressed & HostGamepadButtons.Cross) != 0) if ((pressed & HostGamepadButtons.Cross) != 0)
{ {
@@ -579,26 +550,13 @@ public partial class MainWindow : Window
private void MoveSelection(int delta) private void MoveSelection(int delta)
{ {
if (_visibleGames.Count == 0)
{
return;
}
var index = GameList.SelectedIndex < 0 var index = GameList.SelectedIndex < 0
? 0 ? 0
: Math.Clamp(GameList.SelectedIndex + delta, 0, _visibleGames.Count - 1); : Math.Clamp(GameList.SelectedIndex + delta, 0, _libraryTiles.Count - 1);
GameList.SelectedIndex = index; GameList.SelectedIndex = index;
GameList.ScrollIntoView(index); GameList.ScrollIntoView(index);
} }
private int TilesPerRow()
{
// Tile footprint: 128 content + 20 item padding + 10 item margin.
const double TileOuterWidth = 158;
var width = GameList.Bounds.Width;
return width > TileOuterWidth ? (int)(width / TileOuterWidth) : 1;
}
private async Task OnOpenedAsync() private async Task OnOpenedAsync()
{ {
var version = Assembly.GetExecutingAssembly().GetName().Version; var version = Assembly.GetExecutingAssembly().GetName().Version;
@@ -660,7 +618,7 @@ public partial class MainWindow : Window
RefreshHostRefreshRates(_settings.RefreshRate); RefreshHostRefreshRates(_settings.RefreshRate);
RefreshUpdateText(); RefreshUpdateText();
UpdateEmptyStateTexts(); UpdateEmptyStateTexts();
UpdateSelectedGameTexts(); UpdateRunButtons();
} }
// ---- Discord Rich Presence ---- // ---- Discord Rich Presence ----
@@ -728,7 +686,6 @@ public partial class MainWindow : Window
_libraryWatcher.Dispose(); _libraryWatcher.Dispose();
_settings.Save(); _settings.Save();
_consoleFlushTimer.Stop(); _consoleFlushTimer.Stop();
_libraryBlurTimer.Stop();
_gamepadTimer.Stop(); _gamepadTimer.Stop();
SdlLauncherGamepad.Shutdown(); SdlLauncherGamepad.Shutdown();
_sndPreview.Stop(); _sndPreview.Stop();
@@ -883,6 +840,7 @@ public partial class MainWindow : Window
EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP"); EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP");
EnvGuestImageCpuSyncToggle.IsChecked = EnvGuestImageCpuSyncToggle.IsChecked =
_settings.EnvironmentToggles.Contains("SHARPEMU_GUEST_IMAGE_CPU_SYNC"); _settings.EnvironmentToggles.Contains("SHARPEMU_GUEST_IMAGE_CPU_SYNC");
DefaultProfileBox.Text = _settings.DefaultProfile;
WindowModeBox.SelectedIndex = ChoiceIndex(_settings.WindowMode, "Windowed", "Borderless", "Exclusive"); WindowModeBox.SelectedIndex = ChoiceIndex(_settings.WindowMode, "Windowed", "Borderless", "Exclusive");
LoadHostDisplayOptions(); LoadHostDisplayOptions();
ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer"); ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
@@ -1109,6 +1067,8 @@ public partial class MainWindow : Window
} }
} }
private const string DefaultProfileEnvironmentName = "SHARPEMU_DEFAULT_PROFILE";
private string SelectedLogLevel() private string SelectedLogLevel()
{ {
return LogLevelBox.SelectedIndex switch return LogLevelBox.SelectedIndex switch
@@ -1600,6 +1560,71 @@ public partial class MainWindow : Window
// ---- Game context menu ---- // ---- Game context menu ----
private void OnGameListSelectionChanged()
{
if (_restoringGameSelection)
{
return;
}
if (GameList.SelectedItem is AddFolderTile)
{
_restoringGameSelection = true;
try
{
GameList.SelectedItem = _lastSelectedGame;
}
finally
{
_restoringGameSelection = false;
}
// A held direction must not reopen the picker after it closes.
_navLeftNextAt = long.MaxValue;
_navRightNextAt = long.MaxValue;
StartAddFolderFromTile();
return;
}
_lastSelectedGame = GameList.SelectedItem as GameEntry;
UpdateSelectedGame();
}
private void OnGameListDoubleTapped(object? sender, TappedEventArgs e)
{
var item = (e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true);
if (item?.DataContext is AddFolderTile)
{
e.Handled = true;
return;
}
LaunchSelected();
}
private void StartAddFolderFromTile()
{
if (_addFolderInProgress)
{
return;
}
_addFolderInProgress = true;
_ = AddFolderFromTileAsync();
}
private async Task AddFolderFromTileAsync()
{
try
{
await AddFolderAsync();
}
finally
{
_addFolderInProgress = false;
}
}
/// <summary> /// <summary>
/// Selects the tile under the pointer before its context menu opens, and /// Selects the tile under the pointer before its context menu opens, and
/// suppresses the menu on empty grid space. /// suppresses the menu on empty grid space.
@@ -1741,7 +1766,6 @@ public partial class MainWindow : Window
} }
else else
{ {
UpdateSelectedGameTexts();
UpdateRunButtons(); UpdateRunButtons();
if (selectedAfter is not null && backgroundsChanged?.Contains(selectedAfter) == true) if (selectedAfter is not null && backgroundsChanged?.Contains(selectedAfter) == true)
{ {
@@ -1769,26 +1793,21 @@ public partial class MainWindow : Window
EmptyStateHint.Text = hasFilter EmptyStateHint.Text = hasFilter
? Localization.Instance.Format("Library.Empty.SearchHint", query) ? Localization.Instance.Format("Library.Empty.SearchHint", query)
: Localization.Instance.Get("Library.Empty.Hint"); : Localization.Instance.Get("Library.Empty.Hint");
EmptyAddFolderButton.IsVisible = !hasFilter;
} }
private void UpdateSelectedGame() private void UpdateSelectedGame()
{ {
if (GameList.SelectedItem is GameEntry game) if (GameList.SelectedItem is GameEntry game)
{ {
UpdateSelectedGameTexts(); LibrarySelectedDetails.DataContext = game;
SelectedCoverPanel.DataContext = game; LibrarySelectedDetails.IsVisible = true;
SelectedBadgesRow.DataContext = game;
SelectedBadgesRow.IsVisible = true;
_ = UpdateBackdropAsync(game); _ = UpdateBackdropAsync(game);
PlaySelectedGamePreview(game); PlaySelectedGamePreview(game);
} }
else else
{ {
UpdateSelectedGameTexts(); LibrarySelectedDetails.DataContext = null;
SelectedCoverPanel.DataContext = null; LibrarySelectedDetails.IsVisible = false;
SelectedBadgesRow.DataContext = null;
SelectedBadgesRow.IsVisible = false;
_ = UpdateBackdropAsync(null); _ = UpdateBackdropAsync(null);
_sndPreview.Stop(); _sndPreview.Stop();
} }
@@ -1796,25 +1815,6 @@ public partial class MainWindow : Window
UpdateRunButtons(); UpdateRunButtons();
} }
/// <summary>
/// Text-only refresh of the launch bar's title/path, split out of
/// <see cref="UpdateSelectedGame"/> so a language change can re-apply it
/// without restarting the backdrop fade or preview music.
/// </summary>
private void UpdateSelectedGameTexts()
{
if (GameList.SelectedItem is GameEntry game)
{
SelectedGameTitle.Text = game.Name;
SelectedGamePath.Text = game.Path;
}
else
{
SelectedGameTitle.Text = Localization.Instance.Get("Launch.NoGameSelected");
SelectedGamePath.Text = Localization.Instance.Get("Launch.NoGameHint");
}
}
/// <summary> /// <summary>
/// Loops the selected game's sce_sys/snd0.at9 preview music, console /// Loops the selected game's sce_sys/snd0.at9 preview music, console
/// home screen style. Silent while a game is running or when disabled /// home screen style. Silent while a game is running or when disabled
@@ -1864,18 +1864,10 @@ public partial class MainWindow : Window
if (WindowState == WindowState.Minimized) if (WindowState == WindowState.Minimized)
{ {
_sndPreview.Pause(); _sndPreview.Pause();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = false;
}
} }
else else
{ {
_sndPreview.Resume(); _sndPreview.Resume();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = _sessionLoadingActive;
}
} }
} }
} }
@@ -2006,10 +1998,20 @@ public partial class MainWindow : Window
continue; continue;
} }
if (string.Equals(name, DefaultProfileEnvironmentName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
Environment.SetEnvironmentVariable(name, value); Environment.SetEnvironmentVariable(name, value);
_appliedEnvironmentVariables.Add(name); _appliedEnvironmentVariables.Add(name);
} }
Environment.SetEnvironmentVariable(
DefaultProfileEnvironmentName,
GuiSettings.NormalizeDefaultProfile(_settings.DefaultProfile));
_appliedEnvironmentVariables.Add(DefaultProfileEnvironmentName);
Environment.SetEnvironmentVariable( Environment.SetEnvironmentVariable(
"SHARPEMU_RENDER_SCALE", "SHARPEMU_RENDER_SCALE",
_settings.RenderResolutionScale.ToString( _settings.RenderResolutionScale.ToString(
@@ -2029,16 +2031,14 @@ public partial class MainWindow : Window
}; };
_isRunning = true; _isRunning = true;
_isStopping = false;
_runningGameName = displayName; _runningGameName = displayName;
_runningGameTitleId = resolvedTitleId; _runningGameTitleId = resolvedTitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); _runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush;
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName); StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
UpdateRunButtons(); UpdateRunButtons();
UpdateDiscordPresence(); UpdateDiscordPresence();
BeginSessionUi();
_pendingLaunch = new PendingLaunch( _pendingLaunch = new PendingLaunch(
Path.GetFullPath(ebootPath), Path.GetFullPath(ebootPath),
displayName, displayName,
@@ -2084,15 +2084,14 @@ public partial class MainWindow : Window
} }
_isStopping = true; _isStopping = true;
StopButton.IsEnabled = false; UpdateRunButtons();
ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
_emulator.Stop(); _emulator.Stop();
_runningGameName = null; _runningGameName = null;
_runningGameTitleId = null; _runningGameTitleId = null;
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping"); StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence(); UpdateDiscordPresence();
ReturnToLibraryWhileStopping(); ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
} }
/// <summary> /// <summary>
@@ -2134,7 +2133,7 @@ public partial class MainWindow : Window
_emulator?.Dispose(); _emulator?.Dispose();
_emulator = null; _emulator = null;
_pendingLaunch = null; _pendingLaunch = null;
EndSessionUi(); ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
var meaningKey = exitCode switch var meaningKey = exitCode switch
{ {
@@ -2156,10 +2155,6 @@ public partial class MainWindow : Window
brush); brush);
CloseFileLogSoon(); CloseFileLogSoon();
StatusDot.Fill = exitCode == 0 || stoppedByUser ? (IBrush)SuccessLineBrush : ErrorLineBrush;
StatusText.Text = stoppedByUser
? "Game closed by the user."
: Localization.Instance.Format("Launch.Exited", exitCode, meaning);
StatusBarRight.Text = Localization.Instance.Get("Status.Idle"); StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
_runningGameName = null; _runningGameName = null;
_runningGameTitleId = null; _runningGameTitleId = null;
@@ -2240,137 +2235,6 @@ public partial class MainWindow : Window
private void OnEmulatorOutput(string line, bool isError) private void OnEmulatorOutput(string line, bool isError)
{ {
_pendingLines.Enqueue((line, isError)); _pendingLines.Enqueue((line, isError));
if (!line.Contains("Vulkan VideoOut presented first frame:", StringComparison.Ordinal) &&
!line.Contains("Vulkan VideoOut ready:", StringComparison.Ordinal))
{
return;
}
Dispatcher.UIThread.Post(() =>
{
if (_isRunning && !_isStopping)
{
ShowSessionStatus("Game is running");
}
});
}
private void BeginSessionUi()
{
_isStopping = false;
AnimateLibraryBlur(LaunchBlurRadius);
ShowSessionLoading("Loading game", "Preparing the emulation session...");
LaunchBar.IsVisible = true;
}
private void EndSessionUi()
{
HideSessionLoading();
AnimateLibraryBlur(0, clearWhenComplete: true);
LaunchBar.IsVisible = true;
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
}
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
{
_libraryBlur ??= new BlurEffect();
PagesHost.Effect = _libraryBlur;
_libraryBlurStartRadius = _libraryBlur.Radius;
_libraryBlurTargetRadius = Math.Max(0, targetRadius);
_libraryBlurStartedAt = Stopwatch.GetTimestamp();
_clearLibraryBlurWhenComplete = clearWhenComplete && _libraryBlurTargetRadius == 0;
if (Math.Abs(_libraryBlurStartRadius - _libraryBlurTargetRadius) < 0.01)
{
CompleteLibraryBlur();
return;
}
_libraryBlurTimer.Start();
}
private void AdvanceLibraryBlur()
{
if (_libraryBlur is null)
{
_libraryBlurTimer.Stop();
return;
}
var elapsed = (Stopwatch.GetTimestamp() - _libraryBlurStartedAt) /
(double)Stopwatch.Frequency;
var progress = Math.Clamp(elapsed / BlurTransitionSeconds, 0, 1);
// Cubic ease-out gives the loading transition a quick response while
// keeping the final change of sharpness unobtrusive.
var easedProgress = 1 - Math.Pow(1 - progress, 3);
_libraryBlur.Radius = _libraryBlurStartRadius +
((_libraryBlurTargetRadius - _libraryBlurStartRadius) * easedProgress);
if (progress >= 1)
{
CompleteLibraryBlur();
}
}
private void CompleteLibraryBlur()
{
_libraryBlurTimer.Stop();
if (_libraryBlur is not null)
{
_libraryBlur.Radius = _libraryBlurTargetRadius;
}
if (_clearLibraryBlurWhenComplete)
{
PagesHost.Effect = null;
_libraryBlur = null;
_clearLibraryBlurWhenComplete = false;
}
}
private void ClearLibraryBlur()
{
_libraryBlurTimer.Stop();
_libraryBlur = null;
_clearLibraryBlurWhenComplete = false;
PagesHost.Effect = null;
}
private void ShowSessionLoading(string title, string detail)
{
SessionLoadingTitle.Text = title;
SessionLoadingTitle.IsVisible = true;
SessionLoadingDetail.Text = detail;
SessionLoadingDetail.IsVisible = true;
SessionLoadingProgress.IsVisible = true;
_sessionLoadingActive = true;
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
}
private void ShowSessionStatus(string message)
{
SessionLoadingTitle.Text = message;
SessionLoadingTitle.IsVisible = true;
SessionLoadingDetail.IsVisible = false;
SessionLoadingProgress.IsVisible = false;
_sessionLoadingActive = true;
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
}
private void HideSessionLoading()
{
_sessionLoadingActive = false;
SessionLoadingPopup.IsOpen = false;
}
private void ReturnToLibraryWhileStopping()
{
AnimateLibraryBlur(LaunchBlurRadius);
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
LaunchBar.IsVisible = true;
UpdateRunButtons();
Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
} }
private void OpenFileLog(string? titleId) private void OpenFileLog(string? titleId)
@@ -2427,8 +2291,23 @@ public partial class MainWindow : Window
private void UpdateRunButtons() private void UpdateRunButtons()
{ {
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry; LaunchButton.Classes.Remove("accent");
StopButton.IsEnabled = _isRunning && !_isStopping; LaunchButton.Classes.Remove("danger");
if (_isRunning)
{
LaunchButton.Classes.Add("danger");
LaunchButton.Content = Localization.Instance.Get(
_isStopping ? "Launch.Stopping" : "Launch.Stop");
LaunchButton.IsEnabled = !_isStopping;
}
else
{
LaunchButton.Classes.Add("accent");
LaunchButton.Content = Localization.Instance.Get("Launch.Launch");
LaunchButton.IsEnabled = GameList.SelectedItem is GameEntry;
}
OpenFileButton.IsEnabled = !_isRunning; OpenFileButton.IsEnabled = !_isRunning;
} }
+105 -16
View File
@@ -6,32 +6,121 @@ Cover-art library item states and motion.
<Styles xmlns="https://github.com/avaloniaui" <Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="ListBox.tileGrid ListBoxItem"> <Style Selector="ListBox.tileGrid">
<Setter Property="Padding" Value="10" />
<Setter Property="Margin" Value="5" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Background" Value="Transparent" /> <Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Hidden" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem">
<Setter Property="Width" Value="160" />
<Setter Property="Height" Value="172" />
<Setter Property="Padding" Value="4" />
<Setter Property="Margin" Value="0,8,12,8" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="BorderBrush" Value="Transparent" /> <Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Opacity" Value="0.72" />
<Setter Property="RenderTransformOrigin" Value="50%,50%" />
<Setter Property="RenderTransform" Value="translateY(0px)" /> <Setter Property="RenderTransform" Value="translateY(0px)" />
<Setter Property="Transitions"> <Setter Property="Transitions">
<Transitions> <Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" /> <DoubleTransition Property="Opacity"
Duration="0:0:0.11"
Easing="QuadraticEaseOut" />
<TransformOperationsTransition Property="RenderTransform"
Duration="0:0:0.16"
Easing="CubicEaseOut" />
</Transitions> </Transitions>
</Setter> </Setter>
</Style> </Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover"> <Style Selector="ListBox.tileGrid ListBoxItem:pointerover,
<Setter Property="RenderTransform" Value="translateY(-3px)" /> ListBox.tileGrid ListBoxItem:selected">
<Setter Property="Opacity" Value="1" />
<Setter Property="RenderTransform" Value="translateY(-5px) scale(1.035)" />
</Style> </Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="ListBox.tileGrid ListBoxItem /template/ ContentPresenter#PART_ContentPresenter,
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" /> ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter,
ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style> </Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" /> <Style Selector="Border.libraryGameCard">
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" /> <Setter Property="CornerRadius" Value="7" />
<Setter Property="BorderThickness" Value="2" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BoxShadow" Value="0 12 28 0 #61000000" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="BorderBrush"
Duration="0:0:0.12"
Easing="QuadraticEaseOut" />
</Transitions>
</Setter>
</Style> </Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter"> <Style Selector="ListBox.tileGrid ListBoxItem:selected Border.libraryGameCard">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" /> <Setter Property="BorderBrush" Value="#E6FFFFFF" />
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" /> </Style>
<Style Selector="Border.libraryGameCard Border.coverClip">
<Setter Property="CornerRadius" Value="5" />
</Style>
<Style Selector="Border.addFolderTile">
<Setter Property="CornerRadius" Value="7" />
<Setter Property="Background" Value="#0CFFFFFF" />
<Setter Property="BorderBrush" Value="#2EFFFFFF" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="BoxShadow" Value="0 12 28 0 #3A000000" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
<BrushTransition Property="BorderBrush"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Border.addFolderTileIcon">
<Setter Property="Width" Value="60" />
<Setter Property="Height" Value="60" />
<Setter Property="CornerRadius" Value="30" />
<Setter Property="Background" Value="#1BFFFFFF" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="TextBlock.addFolderGlyph">
<Setter Property="Foreground" Value="#A6FFFFFF" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Foreground"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="TextBlock.addFolderIconGlyph">
<Setter Property="RenderTransform" Value="translateY(-7px)" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover TextBlock.addFolderGlyph,
ListBox.tileGrid ListBoxItem:selected TextBlock.addFolderGlyph">
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover Border.addFolderTile,
ListBox.tileGrid ListBoxItem:selected Border.addFolderTile">
<Setter Property="Background" Value="#1AFFFFFF" />
<Setter Property="BorderBrush" Value="#55FFFFFF" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover Border.addFolderTileIcon,
ListBox.tileGrid ListBoxItem:selected Border.addFolderTileIcon">
<Setter Property="Background" Value="#2EFFFFFF" />
</Style> </Style>
</Styles> </Styles>
+281 -11
View File
@@ -46,6 +46,9 @@ public static partial class AgcExports
private const uint ItNumInstances = 0x2F; private const uint ItNumInstances = 0x2F;
private const uint ItDrawIndexMultiAuto = 0x30; private const uint ItDrawIndexMultiAuto = 0x30;
private const uint ItDrawIndexOffset2 = 0x35; private const uint ItDrawIndexOffset2 = 0x35;
private const uint ItDrawIndexIndirectMulti = 0x38;
private const uint DrawIndexedIndirectArgsSize = 20;
private const uint DrawIndexedIndirectMaxScan = 1024;
private const uint ItWriteData = 0x37; private const uint ItWriteData = 0x37;
private const uint ItDispatchDirect = 0x15; private const uint ItDispatchDirect = 0x15;
private const uint ItDispatchIndirect = 0x16; private const uint ItDispatchIndirect = 0x16;
@@ -319,6 +322,7 @@ public static partial class AgcExports
private static int _tracedVertexRangeCount; private static int _tracedVertexRangeCount;
private static long _dcbWaitRegMemTraceCount; private static long _dcbWaitRegMemTraceCount;
private static long _createShaderTraceCount; private static long _createShaderTraceCount;
private static long _duplicateTargetTraceCount;
private static long _cbMetadataSkipTraceCount; private static long _cbMetadataSkipTraceCount;
private static long _packetPayloadTraceCount; private static long _packetPayloadTraceCount;
private static bool _tracedMissingPixelShaderBindings; private static bool _tracedMissingPixelShaderBindings;
@@ -1994,6 +1998,65 @@ public static partial class AgcExports
return ReturnPointer(ctx, drawCommand); return ReturnPointer(ctx, drawCommand);
} }
[SysAbiExport(
Nid = "1q1titRBL6o",
ExportName = "sceAgcDcbDrawIndirect",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DcbDrawIndirect(CpuContext ctx)
{
var commandBufferAddress = ctx[CpuRegister.Rdi];
var dataOffset = (uint)ctx[CpuRegister.Rsi];
var emit = Interlocked.Increment(ref _indirectDrawEmitCount);
if (emit <= 12 || emit % 250 == 0)
{
var rcx = ctx[CpuRegister.Rcx];
var dump = string.Empty;
for (var word = 0; word < 8; word++)
{
dump += TryReadUInt32(ctx, rcx + dataOffset + ((ulong)word * 4), out var raw)
? $" {raw}"
: " ?";
}
Console.Error.WriteLine(
$"[LOADER][WARN] agc.emit_indirect#{emit} buf=0x{commandBufferAddress:X16} " +
$"off=0x{dataOffset:X} rdx=0x{ctx[CpuRegister.Rdx]:X} rcx=0x{rcx:X} " +
$"r8=0x{ctx[CpuRegister.R8]:X} rcx_words:{dump}");
}
if (commandBufferAddress == 0)
{
Interlocked.Increment(ref _indirectDrawEmitRejectCount);
return ReturnPointer(ctx, 0);
}
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var drawCommand) ||
!TryWriteUInt32(ctx, drawCommand, Pm4(5, ItDrawIndirect, 0)) ||
!TryWriteUInt32(ctx, drawCommand + 4, dataOffset) ||
!TryWriteUInt32(ctx, drawCommand + 8, 0) ||
!TryWriteUInt32(ctx, drawCommand + 12, 0) ||
!TryWriteUInt32(ctx, drawCommand + 16, 0))
{
var rejects = Interlocked.Increment(ref _indirectDrawEmitRejectCount);
if (rejects <= 8 || rejects % 250 == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] agc.emit_indirect_reject#{rejects} " +
$"buf=0x{commandBufferAddress:X16} reason=alloc_or_write");
}
return ReturnPointer(ctx, 0);
}
TraceAgc(
$"agc.dcb_draw_indirect buf=0x{commandBufferAddress:X16} " +
$"draw=0x{drawCommand:X16} offset=0x{dataOffset:X}");
return ReturnPointer(ctx, drawCommand);
}
[SysAbiExport( [SysAbiExport(
Nid = "Yw0jKSqop+E", Nid = "Yw0jKSqop+E",
ExportName = "sceAgcDcbDrawIndexAuto", ExportName = "sceAgcDcbDrawIndexAuto",
@@ -2052,6 +2115,39 @@ public static partial class AgcExports
return ReturnPointer(ctx, commandAddress); return ReturnPointer(ctx, commandAddress);
} }
[SysAbiExport(
Nid = "ypVBz4uPKcQ",
ExportName = "sceAgcDcbDrawIndexIndirectMulti",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DcbDrawIndexIndirectMulti(CpuContext ctx)
{
var commandBufferAddress = ctx[CpuRegister.Rdi];
var dataOffset = (uint)ctx[CpuRegister.Rsi];
var drawCount = (uint)ctx[CpuRegister.Rdx];
var stride = DrawIndexedIndirectArgsSize;
var modifier = (uint)ctx[CpuRegister.R8];
if (commandBufferAddress == 0 ||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 8, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(8, ItDrawIndexIndirectMulti, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, dataOffset) ||
!TryWriteUInt32(ctx, commandAddress + 8, 0) ||
!TryWriteUInt32(ctx, commandAddress + 12, 0) ||
!TryWriteUInt32(ctx, commandAddress + 16, 0) ||
!TryWriteUInt32(ctx, commandAddress + 20, drawCount) ||
!TryWriteUInt32(ctx, commandAddress + 24, stride) ||
!TryWriteUInt32(ctx, commandAddress + 28, modifier))
{
return ReturnPointer(ctx, 0);
}
TraceAgc(
$"agc.dcb_draw_index_indirect_multi buf=0x{commandBufferAddress:X16} " +
$"cmd=0x{commandAddress:X16} offset=0x{dataOffset:X8} draws={drawCount} " +
$"stride={stride} modifier=0x{modifier:X8}");
return ReturnPointer(ctx, commandAddress);
}
[SysAbiExport( [SysAbiExport(
Nid = "mStuvI0zOtc", Nid = "mStuvI0zOtc",
ExportName = "sceAgcDcbDrawIndexIndirectGetSize", ExportName = "sceAgcDcbDrawIndexIndirectGetSize",
@@ -3812,6 +3908,7 @@ public static partial class AgcExports
if (TryReadSubmittedDrawCount( if (TryReadSubmittedDrawCount(
ctx, ctx,
gpuState,
state, state,
currentAddress, currentAddress,
length, length,
@@ -3835,7 +3932,8 @@ public static partial class AgcExports
var indexed = op is var indexed = op is
ItDrawIndex2 or ItDrawIndex2 or
ItDrawIndexOffset2 or ItDrawIndexOffset2 or
ItDrawIndexIndirect; ItDrawIndexIndirect or
ItDrawIndexIndirectMulti;
state.SawIndexedDraw |= indexed; state.SawIndexedDraw |= indexed;
TryTranslateGuestDraw(ctx, gpuState, state, indexCount, indexed); TryTranslateGuestDraw(ctx, gpuState, state, indexCount, indexed);
} }
@@ -4195,6 +4293,7 @@ public static partial class AgcExports
op is ItDispatchDirect or ItDispatchIndirect || op is ItDispatchDirect or ItDispatchIndirect ||
op is ItDrawIndirect or op is ItDrawIndirect or
ItDrawIndexIndirect or ItDrawIndexIndirect or
ItDrawIndexIndirectMulti or
ItDrawIndex2 or ItDrawIndex2 or
ItDrawIndexAuto or ItDrawIndexAuto or
ItDrawIndexMultiAuto or ItDrawIndexMultiAuto or
@@ -6164,8 +6263,24 @@ public static partial class AgcExports
} }
} }
private const int IndirectArgsFlushTimeoutMilliseconds = 250;
private static void FlushGpuWorkForIndirectArgs(SubmittedGpuState gpuState)
{
var pending = gpuState.WorkSequence;
if (pending == 0 || pending > long.MaxValue)
{
return;
}
GuestGpu.Current.WaitForGuestWork(
(long)pending,
IndirectArgsFlushTimeoutMilliseconds);
}
private static bool TryReadSubmittedDrawCount( private static bool TryReadSubmittedDrawCount(
CpuContext ctx, CpuContext ctx,
SubmittedGpuState gpuState,
SubmittedDcbState state, SubmittedDcbState state,
ulong packetAddress, ulong packetAddress,
uint packetLength, uint packetLength,
@@ -6196,17 +6311,89 @@ public static partial class AgcExports
drawCount = (control >> 21) & 0x7FFu; drawCount = (control >> 21) & 0x7FFu;
return true; return true;
case ItDrawIndirect or ItDrawIndexIndirect case ItDrawIndexIndirectMulti when packetLength >= 8 &&
when packetLength >= 5 && state.IndirectArgsAddress != 0: state.IndirectArgsAddress != 0:
if (!TryReadUInt32(ctx, packetAddress + 4, out var dataOffset)) if (!TryReadUInt32(ctx, packetAddress + 4, out var multiOffset) ||
!TryReadUInt32(ctx, packetAddress + 20, out var multiDraws) ||
!TryReadUInt32(ctx, packetAddress + 24, out var multiStride))
{ {
return false; return false;
} }
return TryReadUInt32(
ctx, if (multiStride < DrawIndexedIndirectArgsSize)
state.IndirectArgsAddress + dataOffset, {
out drawCount); multiStride = DrawIndexedIndirectArgsSize;
}
var multiTotal = 0UL;
var multiCapped = multiDraws == 0
? DrawIndexedIndirectMaxScan
: Math.Min(multiDraws, 4096u);
for (var draw = 0u; draw < multiCapped; draw++)
{
if (!TryReadUInt32(
ctx,
state.IndirectArgsAddress + multiOffset + ((ulong)draw * multiStride),
out var subCount))
{
break;
}
if (subCount == 0 && multiDraws == 0)
{
break;
}
multiTotal += subCount;
}
var multiProbe = Interlocked.Increment(ref _indirectMultiProbeCount);
if (multiProbe <= 12 || multiProbe % 250 == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] agc.draw_multi#{multiProbe} args=0x{state.IndirectArgsAddress:X} " +
$"off=0x{multiOffset:X} draws={multiDraws} stride={multiStride} " +
$"total={multiTotal}");
}
drawCount = (uint)Math.Min(multiTotal, uint.MaxValue);
return drawCount != 0;
case ItDrawIndirect or ItDrawIndexIndirect:
var probe = Interlocked.Increment(ref _indirectDrawProbeCount);
if (!TryReadUInt32(ctx, packetAddress + 4, out var dataOffset))
{
dataOffset = 0xFFFFFFFFu;
}
var readable = packetLength >= 5 &&
state.IndirectArgsAddress != 0 &&
dataOffset != 0xFFFFFFFFu;
var resolved = readable &&
TryReadUInt32(
ctx,
state.IndirectArgsAddress + dataOffset,
out drawCount);
if (probe <= 12 || probe % 100 == 0)
{
var dump = string.Empty;
for (var word = 0; word < 8; word++)
{
dump += TryReadUInt32(
ctx,
state.IndirectArgsAddress + dataOffset + ((ulong)word * 4),
out var raw)
? $" {raw}"
: " ?";
}
Console.Error.WriteLine(
$"[LOADER][WARN] agc.draw_indirect#{probe} op=0x{op:X} len={packetLength} " +
$"args=0x{state.IndirectArgsAddress:X} off=0x{dataOffset:X} " +
$"resolved={resolved} count={drawCount} words:{dump}");
}
return resolved;
default: default:
return false; return false;
} }
@@ -8056,6 +8243,61 @@ public static partial class AgcExports
source.Format == destination.Format; source.Format == destination.Format;
} }
private static readonly HashSet<ulong> _renderTargetAddresses = new();
private static readonly HashSet<ulong> _sampledRenderTargets = new();
private static readonly object _renderTargetProbeGate = new();
private static long _renderTargetSampleTraceCount;
private static long _indirectDrawProbeCount;
private static long _indirectDrawEmitCount;
private static long _indirectDrawEmitRejectCount;
private static long _indirectMultiProbeCount;
private static void NoteRenderTargetAddress(ulong address)
{
if (address == 0)
{
return;
}
lock (_renderTargetProbeGate)
{
if (_renderTargetAddresses.Count < 512)
{
_renderTargetAddresses.Add(address);
}
}
}
private static void NoteSampledAddress(ulong address, uint format = 0, uint numberType = 0)
{
if (address == 0)
{
return;
}
bool firstTime;
int distinctTargets;
lock (_renderTargetProbeGate)
{
if (!_renderTargetAddresses.Contains(address))
{
return;
}
firstTime = _sampledRenderTargets.Add(address);
distinctTargets = _renderTargetAddresses.Count;
}
var count = Interlocked.Increment(ref _renderTargetSampleTraceCount);
if (firstTime || count % 2000 == 0)
{
var gpuResident = GuestGpu.Current.IsGpuGuestImageAvailable(address, format, numberType);
Console.Error.WriteLine(
$"[LOADER][WARN] agc.rt_sampled#{count} addr=0x{address:X} first={firstTime} " +
$"gpu_resident={gpuResident} fmt={format}/{numberType} known_targets={distinctTargets}");
}
}
private static IReadOnlyList<RenderTargetDescriptor> GetRenderTargets( private static IReadOnlyList<RenderTargetDescriptor> GetRenderTargets(
IReadOnlyDictionary<uint, uint> registers, IReadOnlyDictionary<uint, uint> registers,
bool includeMaskedTargets = false) bool includeMaskedTargets = false)
@@ -8082,6 +8324,13 @@ public static partial class AgcExports
continue; continue;
} }
if (targets.Exists(existing => existing.Address == address))
{
continue;
}
NoteRenderTargetAddress(address);
targets.Add(new RenderTargetDescriptor( targets.Add(new RenderTargetDescriptor(
slot, slot,
address, address,
@@ -8092,6 +8341,21 @@ public static partial class AgcExports
(attrib3 >> 14) & 0x1Fu)); (attrib3 >> 14) & 0x1Fu));
} }
if (targets.Count > 1 &&
targets.Select(t => t.Address).Distinct().Count() != targets.Count)
{
var dupCount = Interlocked.Increment(ref _duplicateTargetTraceCount);
if (dupCount <= 12 || dupCount % 500 == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] agc.rt_duplicate#{dupCount} has_mask={hasTargetMask} " +
$"mask=0x{targetMask:X8} slots=[" +
string.Join(",", targets.Select(t =>
$"{t.Slot}:0x{t.Address:X}:m{(targetMask >> ((int)t.Slot * 4)) & 0xFu}")) +
"]");
}
}
return targets; return targets;
} }
@@ -9331,6 +9595,7 @@ public static partial class AgcExports
descriptor.Format, descriptor.Format,
descriptor.NumberType)) descriptor.NumberType))
{ {
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture( texture = new GuestDrawTexture(
descriptor.Address, descriptor.Address,
descriptor.Width, descriptor.Width,
@@ -9410,6 +9675,7 @@ public static partial class AgcExports
$"tile={descriptor.TileMode} mip={mipLevel}"); $"tile={descriptor.TileMode} mip={mipLevel}");
} }
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture( texture = new GuestDrawTexture(
descriptor.Address, descriptor.Address,
descriptor.Width, descriptor.Width,
@@ -9470,6 +9736,7 @@ public static partial class AgcExports
descriptor.Type, descriptor.Type,
textureDepth))) textureDepth)))
{ {
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture( texture = new GuestDrawTexture(
descriptor.Address, descriptor.Address,
descriptor.Width, descriptor.Width,
@@ -9532,7 +9799,8 @@ public static partial class AgcExports
if (readAllLayers) if (readAllLayers)
{ {
texture = new GuestDrawTexture( NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture(
descriptor.Address, descriptor.Address,
descriptor.Width, descriptor.Width,
descriptor.Height, descriptor.Height,
@@ -9594,7 +9862,8 @@ public static partial class AgcExports
if (uploadedLayers == arrayLayers) if (uploadedLayers == arrayLayers)
{ {
texture = new GuestDrawTexture( NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture(
descriptor.Address, descriptor.Address,
descriptor.Width, descriptor.Width,
descriptor.Height, descriptor.Height,
@@ -9697,7 +9966,8 @@ public static partial class AgcExports
if (IsGpuDetileEquation(gpuDetileParams.Equation) && if (IsGpuDetileEquation(gpuDetileParams.Equation) &&
(long)elementsWide * elementsHigh * bytesPerElement <= source.Length) (long)elementsWide * elementsHigh * bytesPerElement <= source.Length)
{ {
texture = new GuestDrawTexture( NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
texture = new GuestDrawTexture(
descriptor.Address, descriptor.Address,
descriptor.Width, descriptor.Width,
descriptor.Height, descriptor.Height,
+175
View File
@@ -0,0 +1,175 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
using System.Buffers.Binary;
using System.Text;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Ime;
public static class ImeDialogExports
{
private const int StatusNone = 0;
private const int StatusRunning = 1;
private const int StatusFinished = 2;
private const int EndStatusOk = 0;
private const ulong ParamMaxTextLengthOffset = 0x24;
private const ulong ParamInputTextBufferOffset = 0x28;
private const int ImeDialogErrorInvalidAddress = unchecked((int)0x80BC0001);
private const string DefaultInputText = "Sharp";
private static readonly object _gate = new();
private static int _status = StatusNone;
[SysAbiExport(
Nid = "NUeBrN7hzf0",
ExportName = "sceImeDialogInit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceImeDialog")]
public static int ImeDialogInit(CpuContext ctx)
{
var parameterAddress = ctx[CpuRegister.Rdi];
if (parameterAddress == 0)
{
return SetReturn(ctx, ImeDialogErrorInvalidAddress);
}
TryWriteInputText(ctx, parameterAddress);
lock (_gate)
{
_status = StatusFinished;
}
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "IADmD4tScBY",
ExportName = "sceImeDialogGetStatus",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceImeDialog")]
public static int ImeDialogGetStatus(CpuContext ctx)
{
int status;
lock (_gate)
{
status = _status;
}
ctx[CpuRegister.Rax] = unchecked((ulong)(long)status);
return status;
}
[SysAbiExport(
Nid = "x01jxu+vxlc",
ExportName = "sceImeDialogGetResult",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceImeDialog")]
public static int ImeDialogGetResult(CpuContext ctx)
{
var resultAddress = ctx[CpuRegister.Rdi];
if (resultAddress == 0)
{
return SetReturn(ctx, ImeDialogErrorInvalidAddress);
}
Span<byte> result = stackalloc byte[8];
result.Clear();
BinaryPrimitives.WriteInt32LittleEndian(result, EndStatusOk);
ctx.Memory.TryWrite(resultAddress, result);
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "oBmw4xrmfKs",
ExportName = "sceImeDialogAbort",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceImeDialog")]
public static int ImeDialogAbort(CpuContext ctx)
{
lock (_gate)
{
_status = StatusFinished;
}
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "gyTyVn+bXMw",
ExportName = "sceImeDialogTerm",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceImeDialog")]
public static int ImeDialogTerm(CpuContext ctx)
{
lock (_gate)
{
_status = StatusNone;
}
return SetReturn(ctx, 0);
}
private static void TryWriteInputText(CpuContext ctx, ulong parameterAddress)
{
Span<byte> field = stackalloc byte[8];
if (!ctx.Memory.TryRead(parameterAddress + ParamInputTextBufferOffset, field))
{
return;
}
var bufferAddress = BinaryPrimitives.ReadUInt64LittleEndian(field);
if (bufferAddress == 0)
{
return;
}
var maxTextLength = 16u;
Span<byte> lengthField = stackalloc byte[4];
if (ctx.Memory.TryRead(parameterAddress + ParamMaxTextLengthOffset, lengthField))
{
var declared = BinaryPrimitives.ReadUInt32LittleEndian(lengthField);
if (declared is > 0 and <= 256)
{
maxTextLength = declared;
}
}
var text = Environment.GetEnvironmentVariable("SHARPEMU_DEFAULT_PROFILE");
if (string.IsNullOrEmpty(text))
{
text = DefaultInputText;
}
if ((uint)text.Length > maxTextLength)
{
text = text[..(int)maxTextLength];
}
var encoded = Encoding.Unicode.GetBytes(text);
Span<byte> payload = stackalloc byte[encoded.Length + 2];
encoded.CopyTo(payload);
payload[^2] = 0;
payload[^1] = 0;
if (ctx.Memory.TryWrite(bufferAddress, payload))
{
Console.Error.WriteLine(
$"[LOADER][WARN] ime.dialog_autofill buf=0x{bufferAddress:X16} " +
$"max={maxTextLength} text=\"{text}\"");
}
}
private static int SetReturn(CpuContext ctx, int result)
{
ctx[CpuRegister.Rax] = unchecked((ulong)(long)result);
return result;
}
}
@@ -33,12 +33,17 @@ public static class SaveDataDialogExports
LibraryName = "libSceSaveDataDialog")] LibraryName = "libSceSaveDataDialog")]
public static int SaveDataDialogInitialize(CpuContext ctx) public static int SaveDataDialogInitialize(CpuContext ctx)
{ {
if (Interlocked.CompareExchange(ref _status, StatusInitialized, StatusNone) != StatusNone) var previous = Interlocked.CompareExchange(
ref _status,
StatusInitialized,
StatusNone);
if (previous == StatusNone || previous == StatusFinished)
{ {
return ctx.SetReturn(ErrorAlreadyInitialized); Interlocked.Exchange(ref _status, StatusInitialized);
return ctx.SetReturn(ErrorOk);
} }
return ctx.SetReturn(ErrorOk); return ctx.SetReturn(ErrorAlreadyInitialized);
} }
[SysAbiExport( [SysAbiExport(
@@ -1046,14 +1046,50 @@ internal static unsafe class VulkanVideoPresenter
targets.Count > 8 || targets.Count > 8 ||
AnyRenderTargetInvalid(targets)) AnyRenderTargetInvalid(targets))
{ {
var dropCount = Interlocked.Increment(ref _offscreenDropTraceCount);
if (dropCount <= 16 || dropCount % 500 == 0)
{
var detail = targets.Count == 0
? "<no targets>"
: string.Join(
" ",
targets.Select(t => $"0x{t.Address:X}:{t.Width}x{t.Height}"));
Console.Error.WriteLine(
$"[LOADER][WARN] vk.offscreen_drop#{dropCount} spirv={pixelSpirv.Length} " +
$"mrt={targets.Count} vs=0x{shaderAddress:X16} {detail}");
}
return; return;
} }
var firstTarget = targets[0]; var firstTarget = targets[0];
if (RenderTargetsMismatchedOrAliased(targets, firstTarget)) if (RenderTargetsMismatchedOrAliased(targets, firstTarget))
{ {
Console.Error.WriteLine( var skipCount = Interlocked.Increment(ref _mrtSkipTraceCount);
"[LOADER][WARN] Vulkan skipped MRT draw with mismatched dimensions or aliased targets."); if (skipCount <= 16 || skipCount % 200 == 0)
{
var aliased = false;
for (var i = 0; i < targets.Count && !aliased; i++)
{
for (var j = i + 1; j < targets.Count; j++)
{
if (targets[i].Address == targets[j].Address)
{
aliased = true;
break;
}
}
}
var detail = string.Join(
" ",
targets.Select(t =>
$"0x{t.Address:X}:{t.Width}x{t.Height}:f{t.Format}/{t.NumberType}"));
Console.Error.WriteLine(
$"[LOADER][WARN] vk.mrt_skip#{skipCount} mrt={targets.Count} " +
$"aliased={aliased} vs=0x{shaderAddress:X16} {detail}");
}
return; return;
} }
@@ -1298,6 +1334,8 @@ internal static unsafe class VulkanVideoPresenter
} }
} }
private static long _mrtSkipTraceCount;
private static long _offscreenDropTraceCount;
private static long _perfDrawCount; private static long _perfDrawCount;
private static long _perfDrawTicks; private static long _perfDrawTicks;
private static long _perfPipelineCreations; private static long _perfPipelineCreations;
@@ -1834,8 +1872,22 @@ internal static unsafe class VulkanVideoPresenter
lock (_gate) lock (_gate)
{ {
return _availableGuestImages.TryGetValue(address, out var availableFormat) && if (!_availableGuestImages.TryGetValue(address, out var availableFormat))
availableFormat == guestFormat; {
return false;
}
if (availableFormat == guestFormat)
{
return true;
}
return TryDecodeRenderTargetFormat(
(availableFormat >> 8) & 0x1FFu,
availableFormat & 0xFFu,
out var resident) &&
TryDecodeRenderTargetFormat(format, numberType, out var requested) &&
Presenter.IsCompatibleViewFormat(resident.Format, requested.Format);
} }
} }
@@ -12075,6 +12127,7 @@ internal static unsafe class VulkanVideoPresenter
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][WARN] Vulkan skipped storage render-target feedback loop " + $"[LOADER][WARN] Vulkan skipped storage render-target feedback loop " +
$"vs=0x{work.ShaderAddress:X16} " +
$"targets={string.Join(',', work.Targets.Where(target => target.Address != 0).Select(target => $"0x{target.Address:X16}"))}; " + $"targets={string.Join(',', work.Targets.Where(target => target.Address != 0).Select(target => $"0x{target.Address:X16}"))}; " +
"sampled aliases use ordered snapshots"); "sampled aliases use ordered snapshots");
ReturnPooledGuestData(work.Draw); ReturnPooledGuestData(work.Draw);
@@ -7,6 +7,7 @@ using System.Buffers.Binary;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using System.Numerics; using System.Numerics;
using System.Runtime.CompilerServices;
namespace SharpEmu.ShaderCompiler; namespace SharpEmu.ShaderCompiler;
@@ -36,6 +37,113 @@ public static class Gen5ShaderScalarEvaluator
StringComparison.Ordinal); StringComparison.Ordinal);
private static readonly object _scalarFallbackTraceGate = new(); private static readonly object _scalarFallbackTraceGate = new();
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedScalarFallbacks = []; 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. // Shaders whose empty SRT/EUD caused a null-base scalar pointer load.
// Host submit of those translations has lost the Vulkan device; Agc skips // Host submit of those translations has lost the Vulkan device; Agc skips
// them before QueueSubmit. // them before QueueSubmit.
@@ -1898,19 +2006,32 @@ public static class Gen5ShaderScalarEvaluator
var address = unchecked( var address = unchecked(
baseAddress + baseAddress +
byteOffset) & ~3UL; 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 = var bufferUnbound =
isBufferLoad && isBufferLoad &&
(!hasBufferDescriptor || (descriptorDiverged ||
!hasBufferDescriptor ||
bufferDescriptor.SizeBytes == 0 || bufferDescriptor.SizeBytes == 0 ||
(scalarRegisters[scalarBase.Value] == 0 && (scalarRegisters[scalarBase.Value] == 0 &&
scalarRegisters[scalarBase.Value + 1] == 0 && scalarRegisters[scalarBase.Value + 1] == 0 &&
scalarBase.Value + 3 < ScalarRegisterCount && scalarBase.Value + 3 < ScalarRegisterCount &&
scalarRegisters[scalarBase.Value + 2] == 0 && scalarRegisters[scalarBase.Value + 2] == 0 &&
scalarRegisters[scalarBase.Value + 3] == 0)); scalarRegisters[scalarBase.Value + 3] == 0));
var scalarPointerUnbound = ShouldTreatScalarPointerAsUnbound( var scalarPointerUnbound = descriptorDiverged && !isBufferLoad ||
isBufferLoad, ShouldTreatScalarPointerAsUnbound(
address, isBufferLoad,
_strictScalarLoad); address,
_strictScalarLoad);
if (scalarPointerUnbound) if (scalarPointerUnbound)
{ {
TraceScalarPointerFallback( TraceScalarPointerFallback(
@@ -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,52 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.GUI;
using System.Collections;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using Xunit;
namespace SharpEmu.Libs.Tests.GUI;
public sealed class LibraryTileCollectionTests
{
[Fact]
public void KeepsAddFolderTileAfterVisibleGames()
{
var games = new ObservableCollection<GameEntry>();
var tiles = new LibraryTileCollection(games);
var first = CreateGame("First");
var second = CreateGame("Second");
games.Add(first);
games.Add(second);
Assert.Equal(3, tiles.Count);
Assert.True(tiles is IList { IsReadOnly: true });
Assert.Same(first, tiles[0]);
Assert.Same(second, tiles[1]);
Assert.Same(AddFolderTile.Instance, tiles[2]);
}
[Fact]
public void ForwardsGameChangesAtTheirOriginalIndices()
{
var games = new ObservableCollection<GameEntry>();
var tiles = new LibraryTileCollection(games);
NotifyCollectionChangedEventArgs? change = null;
tiles.CollectionChanged += (_, args) => change = args;
var game = CreateGame("Game");
games.Add(game);
Assert.NotNull(change);
Assert.Equal(NotifyCollectionChangedAction.Add, change.Action);
Assert.Equal(0, change.NewStartingIndex);
Assert.Same(game, Assert.Single(change.NewItems!.Cast<GameEntry>()));
Assert.Same(AddFolderTile.Instance, tiles[1]);
}
private static GameEntry CreateGame(string name) =>
new(name, null, null, $"/games/{name}/eboot.bin", 0, null, null);
}
@@ -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);
}
}