diff --git a/docs/rendering-resolution.md b/docs/rendering-resolution.md new file mode 100644 index 0000000..26a231d --- /dev/null +++ b/docs/rendering-resolution.md @@ -0,0 +1,32 @@ + + +# Rendering resolution + +Options → Rendering exposes two independent sizes, globally and per game: + +- **Resolution** sets the host window size or exclusive fullscreen mode. +- **Game display mode** reports a 1080p display to the game by default. Select + 3840×2160 to request a 4K display. A registered presentation buffer cannot + change this setting. + +The CLI equivalents are `--resolution=1280x720` and +`--guest-resolution=1920x1080` (or `3840x2160`). For example, a 1080p game display +can be presented in a 720p window; changing the window alone does not reduce the +work the game renders. + +The game chooses its internal render targets. Some games adapt those targets to +the reported display mode; others render at a fixed resolution and may still +register 4K buffers with a 1080p display. Silent Hill: The Short Message does so +in current testing. The size of that presentation buffer alone also does not +establish the sizes of its intermediate scene targets. + +Arbitrary internal resolution scaling is not implemented. The previous +`RenderResolutionScale` setting and its `SHARPEMU_RENDER_SCALE` export had no +renderer consumer; they have been removed. They did not change rendering work. +A correct implementation must preserve guest memory layouts while scaling host +attachments, viewports, scissors, image fetches, compute accesses, aliases and +readbacks consistently. Reducing only the output image or window is not an +internal-resolution implementation. diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index 2c88471..4d93c62 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -1011,7 +1011,7 @@ internal static partial class Program private static void PrintUsage() { - Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--console=] [--console-language=] [--log-level=] [--log-file[=]] [--window-mode=] [--resolution=] [--display=] [--refresh-rate=] [--scaling=] [--vsync=] [--hdr=] [--overlay=] [--overlay-mode=] [--overlay-corner=] [--debug-server[=host:port]] "); + Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=] [--console=] [--console-language=] [--log-level=] [--log-file[=]] [--window-mode=] [--resolution=] [--guest-resolution=<1920x1080|3840x2160>] [--display=] [--refresh-rate=] [--scaling=] [--vsync=] [--hdr=] [--overlay=] [--overlay-mode=] [--overlay-corner=] [--debug-server[=host:port]] "); Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin"""); Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient."); } @@ -1076,6 +1076,7 @@ internal static partial class Program var systemLanguage = 1; HostWindowMode? windowModeOverride = null; HostScalingMode? scalingModeOverride = null; + GuestDisplayResolution? guestResolutionOverride = null; int? windowWidthOverride = null; int? windowHeightOverride = null; int? displayIndexOverride = null; @@ -1135,6 +1136,18 @@ internal static partial class Program windowHeightOverride = windowHeight; continue; } + if (TrySplitOption(argument, "--guest-resolution", out var guestResolutionText)) + { + if (guestResolutionText is not ("1920x1080" or "3840x2160")) + { + ebootPath = string.Empty; + runtimeOptions = default; + return false; + } + guestResolutionOverride = guestResolutionText == "3840x2160" + ? GuestDisplayResolution.UltraHd : GuestDisplayResolution.Hd; + continue; + } if (TrySplitOption(argument, "--display", out var displayText)) { if (!int.TryParse(displayText, out var displayIndex) || displayIndex < 0) @@ -1392,6 +1405,7 @@ internal static partial class Program { WindowMode = windowModeOverride ?? configuredVideoOptions.WindowMode, ScalingMode = scalingModeOverride ?? configuredVideoOptions.ScalingMode, + GuestResolution = guestResolutionOverride ?? configuredVideoOptions.GuestResolution, Width = windowWidthOverride ?? configuredVideoOptions.Width, Height = windowHeightOverride ?? configuredVideoOptions.Height, DisplayIndex = displayIndexOverride ?? configuredVideoOptions.DisplayIndex, @@ -1432,6 +1446,8 @@ internal static partial class Program { WindowMode = windowMode, ScalingMode = scalingMode, + GuestResolution = effective.GuestResolution == "3840x2160" + ? GuestDisplayResolution.UltraHd : GuestDisplayResolution.Hd, Width = hasResolution ? configuredWidth : defaults.Width, Height = hasResolution ? configuredHeight : defaults.Height, DisplayIndex = effective.DisplayIndex, diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs index 1e48bba..54655a1 100644 --- a/src/SharpEmu.GUI/GuiSettings.cs +++ b/src/SharpEmu.GUI/GuiSettings.cs @@ -70,6 +70,11 @@ public sealed class GuiSettings public string Resolution { get; set; } = "1920x1080"; + public string GuestResolution { get; set; } = "1920x1080"; + + internal static string NormalizeGuestResolution(string? value) => + string.Equals(value, "3840x2160", StringComparison.OrdinalIgnoreCase) ? "3840x2160" : "1920x1080"; + public int DisplayIndex { get; set; } public int RefreshRate { get; set; } @@ -89,9 +94,6 @@ public sealed class GuiSettings public string BinkPlaybackMode { get; set; } = "Guest"; - /// Internal render resolution scale (1.0 = native, 0.5 = half). - public double RenderResolutionScale { get; set; } = 1.0; - /// /// Discord application ID used for Rich Presence; the default is the /// SharpEmu application. Override to rebrand what Discord shows as @@ -184,6 +186,7 @@ public sealed class GuiSettings "Ukrainian"); settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive"); settings.Resolution = NormalizeResolution(settings.Resolution); + settings.GuestResolution = NormalizeGuestResolution(settings.GuestResolution); settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer"); settings.HdrMode = NormalizeChoice(settings.HdrMode, "Auto", "On", "Off"); settings.BinkPlaybackMode = NormalizeChoice(settings.BinkPlaybackMode, "Guest", "Host", "Skip"); diff --git a/src/SharpEmu.GUI/Languages/ar.json b/src/SharpEmu.GUI/Languages/ar.json index 7c7c318..0c6ee8d 100644 --- a/src/SharpEmu.GUI/Languages/ar.json +++ b/src/SharpEmu.GUI/Languages/ar.json @@ -219,6 +219,8 @@ "Options.WindowMode.Exclusive": "حصري", "Options.Resolution.Label": "الدقة", "Options.Resolution.Desc": "حجم النافذة الأولي أو دقة ملء الشاشة الحصرية.", + "Options.GuestResolution.Label": "وضع عرض اللعبة", + "Options.GuestResolution.Desc": "دقة العرض المُبلّغ عنها للعبة. الافتراضي 1080p. تختار الألعاب دقة التصيير الداخلية؛ وقد تتجاهل الألعاب ذات الدقة الثابتة هذا الإعداد.", "Options.Display.Label": "الشاشة", "Options.Display.Desc": "الشاشة المستخدمة للتوسيط وملء الشاشة.", "Options.RefreshRate.Label": "معدل التحديث", diff --git a/src/SharpEmu.GUI/Languages/az.json b/src/SharpEmu.GUI/Languages/az.json index 8a418d1..29c3913 100644 --- a/src/SharpEmu.GUI/Languages/az.json +++ b/src/SharpEmu.GUI/Languages/az.json @@ -74,6 +74,8 @@ "Options.WindowMode.Exclusive": "Eksklüziv", "Options.Resolution.Label": "Təsvir ölçüsü", "Options.Resolution.Desc": "Pəncərənin başlanğıc ölçüsü və ya eksklüziv tam ekranın təsvir ölçüsü.", + "Options.GuestResolution.Label": "Oyunun ekran rejimi", + "Options.GuestResolution.Desc": "Oyuna bildirilən ekran təsvir ölçüsü. Standart 1080p-dir. Daxili render ölçüsünü oyun seçir; sabit ölçülü oyunlar bu ayarı nəzərə almaya bilər.", "Options.Display.Label": "Ekran", "Options.Display.Desc": "Pəncərənin mərkəzə yerləşdirilməsi və tam ekran üçün istifadə olunan monitor.", "Options.RefreshRate.Label": "Yenilənmə tezliyi", diff --git a/src/SharpEmu.GUI/Languages/br.json b/src/SharpEmu.GUI/Languages/br.json index 964ebbf..f6c9b8c 100644 --- a/src/SharpEmu.GUI/Languages/br.json +++ b/src/SharpEmu.GUI/Languages/br.json @@ -220,6 +220,8 @@ "Options.WindowMode.Exclusive": "Exclusiva", "Options.Resolution.Label": "Resolução", "Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de tela cheia exclusiva.", + "Options.GuestResolution.Label": "Modo de exibição do jogo", + "Options.GuestResolution.Desc": "Resolução informada ao jogo. O padrão é 1080p. O jogo escolhe a resolução interna de renderização; jogos com resolução fixa podem ignorar esta opção.", "Options.Display.Label": "Tela", "Options.Display.Desc": "Monitor usado para centralização e tela cheia.", "Options.RefreshRate.Label": "Taxa de atualização", diff --git a/src/SharpEmu.GUI/Languages/de.json b/src/SharpEmu.GUI/Languages/de.json index e4b6b76..6991a68 100644 --- a/src/SharpEmu.GUI/Languages/de.json +++ b/src/SharpEmu.GUI/Languages/de.json @@ -219,6 +219,8 @@ "Options.WindowMode.Exclusive": "Exklusiv", "Options.Resolution.Label": "Auflösung", "Options.Resolution.Desc": "Anfängliche Fenstergröße oder exklusive Vollbildauflösung.", + "Options.GuestResolution.Label": "Anzeigemodus des Spiels", + "Options.GuestResolution.Desc": "Dem Spiel gemeldete Bildschirmauflösung. Standard ist 1080p. Spiele wählen ihre interne Renderauflösung selbst; Spiele mit fester Auflösung können diese Einstellung ignorieren.", "Options.Display.Label": "Anzeige", "Options.Display.Desc": "Monitor für Zentrierung und Vollbilddarstellung.", "Options.RefreshRate.Label": "Bildwiederholrate", diff --git a/src/SharpEmu.GUI/Languages/dk.json b/src/SharpEmu.GUI/Languages/dk.json index b10d989..0a4b553 100644 --- a/src/SharpEmu.GUI/Languages/dk.json +++ b/src/SharpEmu.GUI/Languages/dk.json @@ -219,6 +219,8 @@ "Options.WindowMode.Exclusive": "Eksklusiv", "Options.Resolution.Label": "Opløsning", "Options.Resolution.Desc": "Oprindelig vinduesstørrelse eller opløsning i eksklusiv fuldskærm.", + "Options.GuestResolution.Label": "Spillets skærmtilstand", + "Options.GuestResolution.Desc": "Skærmopløsning oplyst til spillet. Standard er 1080p. Spillet vælger den interne renderopløsning; spil med fast opløsning kan ignorere denne indstilling.", "Options.Display.Label": "Skærm", "Options.Display.Desc": "Skærm, der bruges til centrering og fuldskærm.", "Options.RefreshRate.Label": "Opdateringshastighed", diff --git a/src/SharpEmu.GUI/Languages/ee.json b/src/SharpEmu.GUI/Languages/ee.json index c6cef57..1fe2b6c 100644 --- a/src/SharpEmu.GUI/Languages/ee.json +++ b/src/SharpEmu.GUI/Languages/ee.json @@ -85,6 +85,8 @@ "Options.WindowMode.Exclusive": "Eksklusiivne", "Options.Resolution.Label": "Eraldusvõime", "Options.Resolution.Desc": "Algne akna suurus või eksklusiivse täisekraani eraldusvõime.", + "Options.GuestResolution.Label": "Mängu kuvarežiim", + "Options.GuestResolution.Desc": "Mängule teatatud ekraani eraldusvõime. Vaikimisi 1080p. Mäng valib sisemise renderduse eraldusvõime; fikseeritud eraldusvõimega mängud võivad seda sätet eirata.", "Options.Display.Label": "Kuvar", "Options.Display.Desc": "Monitor, mida kasutatakse tsentreerimiseks ja täisekraanrežiimiks.", "Options.RefreshRate.Label": "Värskendussagedus", diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json index cedc742..e12e4df 100644 --- a/src/SharpEmu.GUI/Languages/en.json +++ b/src/SharpEmu.GUI/Languages/en.json @@ -85,6 +85,8 @@ "Options.WindowMode.Exclusive": "Exclusive", "Options.Resolution.Label": "Resolution", "Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.", + "Options.GuestResolution.Label": "Game display mode", + "Options.GuestResolution.Desc": "Display resolution reported to the game. Defaults to 1080p. Games choose their internal rendering resolution; fixed-resolution games may ignore this setting.", "Options.Display.Label": "Display", "Options.Display.Desc": "Monitor used for centering and fullscreen.", "Options.RefreshRate.Label": "Refresh rate", diff --git a/src/SharpEmu.GUI/Languages/es.json b/src/SharpEmu.GUI/Languages/es.json index ed8abdb..b1583a2 100644 --- a/src/SharpEmu.GUI/Languages/es.json +++ b/src/SharpEmu.GUI/Languages/es.json @@ -220,6 +220,8 @@ "Options.WindowMode.Exclusive": "Exclusiva", "Options.Resolution.Label": "Resolución", "Options.Resolution.Desc": "Tamaño inicial de la ventana o resolución de pantalla completa exclusiva.", + "Options.GuestResolution.Label": "Modo de pantalla del juego", + "Options.GuestResolution.Desc": "Resolución de pantalla comunicada al juego. El valor predeterminado es 1080p. El juego elige su resolución interna de renderizado; los juegos con resolución fija pueden ignorar este ajuste.", "Options.Display.Label": "Pantalla", "Options.Display.Desc": "Monitor utilizado para centrar y mostrar a pantalla completa.", "Options.RefreshRate.Label": "Frecuencia de actualización", diff --git a/src/SharpEmu.GUI/Languages/fr.json b/src/SharpEmu.GUI/Languages/fr.json index 0c425e2..f7e4a39 100644 --- a/src/SharpEmu.GUI/Languages/fr.json +++ b/src/SharpEmu.GUI/Languages/fr.json @@ -220,6 +220,8 @@ "Options.WindowMode.Exclusive": "Exclusif", "Options.Resolution.Label": "Résolution", "Options.Resolution.Desc": "Taille initiale de la fenêtre ou résolution du plein écran exclusif.", + "Options.GuestResolution.Label": "Mode d’affichage du jeu", + "Options.GuestResolution.Desc": "Résolution d’écran indiquée au jeu. La valeur par défaut est 1080p. Le jeu choisit sa résolution de rendu interne ; les jeux à résolution fixe peuvent ignorer ce réglage.", "Options.Display.Label": "Écran", "Options.Display.Desc": "Moniteur utilisé pour le centrage et le plein écran.", "Options.RefreshRate.Label": "Fréquence de rafraîchissement", diff --git a/src/SharpEmu.GUI/Languages/hu.json b/src/SharpEmu.GUI/Languages/hu.json index af24a81..dbc4fce 100644 --- a/src/SharpEmu.GUI/Languages/hu.json +++ b/src/SharpEmu.GUI/Languages/hu.json @@ -220,6 +220,8 @@ "Options.WindowMode.Exclusive": "Kizárólagos", "Options.Resolution.Label": "Felbontás", "Options.Resolution.Desc": "Kezdeti ablakméret vagy kizárólagos teljes képernyős felbontás.", + "Options.GuestResolution.Label": "A játék megjelenítési módja", + "Options.GuestResolution.Desc": "A játéknak jelzett képernyőfelbontás. Alapértelmezés: 1080p. A belső renderelési felbontást a játék választja; a rögzített felbontású játékok figyelmen kívül hagyhatják ezt a beállítást.", "Options.Display.Label": "Kijelző", "Options.Display.Desc": "A középre igazításhoz és teljes képernyőhöz használt monitor.", "Options.RefreshRate.Label": "Frissítési gyakoriság", diff --git a/src/SharpEmu.GUI/Languages/it.json b/src/SharpEmu.GUI/Languages/it.json index 97a6885..4bd1552 100644 --- a/src/SharpEmu.GUI/Languages/it.json +++ b/src/SharpEmu.GUI/Languages/it.json @@ -224,6 +224,8 @@ "Options.WindowMode.Exclusive": "Esclusiva", "Options.Resolution.Label": "Risoluzione", "Options.Resolution.Desc": "Dimensione iniziale della finestra o risoluzione dello schermo intero esclusivo.", + "Options.GuestResolution.Label": "Modalità schermo del gioco", + "Options.GuestResolution.Desc": "Risoluzione dello schermo comunicata al gioco. Il valore predefinito è 1080p. Il gioco sceglie la risoluzione interna di rendering; i giochi con risoluzione fissa possono ignorare questa impostazione.", "Options.Display.Label": "Schermo", "Options.Display.Desc": "Monitor utilizzato per il centraggio e lo schermo intero.", "Options.RefreshRate.Label": "Frequenza di aggiornamento", diff --git a/src/SharpEmu.GUI/Languages/ja.json b/src/SharpEmu.GUI/Languages/ja.json index 697da9d..9377619 100644 --- a/src/SharpEmu.GUI/Languages/ja.json +++ b/src/SharpEmu.GUI/Languages/ja.json @@ -219,6 +219,8 @@ "Options.WindowMode.Exclusive": "排他", "Options.Resolution.Label": "解像度", "Options.Resolution.Desc": "初期ウィンドウサイズまたは排他フルスクリーンの解像度。", + "Options.GuestResolution.Label": "ゲームの表示モード", + "Options.GuestResolution.Desc": "ゲームに通知する画面解像度。既定値は1080pです。内部描画解像度はゲームが決定するため、解像度が固定されたゲームではこの設定が反映されない場合があります。", "Options.Display.Label": "ディスプレイ", "Options.Display.Desc": "中央配置とフルスクリーンに使用するモニター。", "Options.RefreshRate.Label": "リフレッシュレート", diff --git a/src/SharpEmu.GUI/Languages/ko.json b/src/SharpEmu.GUI/Languages/ko.json index 443677a..e1ce2f7 100644 --- a/src/SharpEmu.GUI/Languages/ko.json +++ b/src/SharpEmu.GUI/Languages/ko.json @@ -219,6 +219,8 @@ "Options.WindowMode.Exclusive": "독점", "Options.Resolution.Label": "해상도", "Options.Resolution.Desc": "초기 창 크기 또는 독점 전체 화면 해상도.", + "Options.GuestResolution.Label": "게임 디스플레이 모드", + "Options.GuestResolution.Desc": "게임에 전달하는 화면 해상도입니다. 기본값은 1080p입니다. 내부 렌더링 해상도는 게임이 결정하며, 해상도가 고정된 게임은 이 설정을 무시할 수 있습니다.", "Options.Display.Label": "디스플레이", "Options.Display.Desc": "가운데 배치와 전체 화면에 사용할 모니터.", "Options.RefreshRate.Label": "새로 고침 빈도", diff --git a/src/SharpEmu.GUI/Languages/nl.json b/src/SharpEmu.GUI/Languages/nl.json index 0c83373..9938f9c 100644 --- a/src/SharpEmu.GUI/Languages/nl.json +++ b/src/SharpEmu.GUI/Languages/nl.json @@ -219,6 +219,8 @@ "Options.WindowMode.Exclusive": "Exclusief", "Options.Resolution.Label": "Resolutie", "Options.Resolution.Desc": "Initiële venstergrootte of resolutie voor exclusief volledig scherm.", + "Options.GuestResolution.Label": "Weergavemodus van het spel", + "Options.GuestResolution.Desc": "Schermresolutie die aan het spel wordt doorgegeven. Standaard 1080p. Het spel kiest de interne renderresolutie; spellen met een vaste resolutie kunnen deze instelling negeren.", "Options.Display.Label": "Beeldscherm", "Options.Display.Desc": "Monitor die wordt gebruikt voor centrering en volledig scherm.", "Options.RefreshRate.Label": "Verversingssnelheid", diff --git a/src/SharpEmu.GUI/Languages/pl.json b/src/SharpEmu.GUI/Languages/pl.json index f15bb64..8d19639 100644 --- a/src/SharpEmu.GUI/Languages/pl.json +++ b/src/SharpEmu.GUI/Languages/pl.json @@ -85,6 +85,8 @@ "Options.WindowMode.Exclusive": "Wyłączne", "Options.Resolution.Label": "Rozdzielczość", "Options.Resolution.Desc": "Początkowy rozmiar okna lub rozdzielczość pełnego ekranu wyłącznego.", + "Options.GuestResolution.Label": "Tryb wyświetlania gry", + "Options.GuestResolution.Desc": "Rozdzielczość ekranu zgłaszana grze. Domyślnie 1080p. Gra wybiera wewnętrzną rozdzielczość renderowania; gry ze stałą rozdzielczością mogą zignorować to ustawienie.", "Options.Display.Label": "Wyświetlacz", "Options.Display.Desc": "Monitor używany do centrowania i trybu pełnoekranowego.", "Options.RefreshRate.Label": "Częstotliwość odświeżania", diff --git a/src/SharpEmu.GUI/Languages/pt.json b/src/SharpEmu.GUI/Languages/pt.json index 27f1d15..479bc89 100644 --- a/src/SharpEmu.GUI/Languages/pt.json +++ b/src/SharpEmu.GUI/Languages/pt.json @@ -220,6 +220,8 @@ "Options.WindowMode.Exclusive": "Exclusivo", "Options.Resolution.Label": "Resolução", "Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de ecrã inteiro exclusivo.", + "Options.GuestResolution.Label": "Modo de apresentação do jogo", + "Options.GuestResolution.Desc": "Resolução do ecrã comunicada ao jogo. A predefinição é 1080p. O jogo escolhe a resolução interna de renderização; jogos com resolução fixa podem ignorar esta opção.", "Options.Display.Label": "Ecrã", "Options.Display.Desc": "Monitor utilizado para centrar e apresentar em ecrã inteiro.", "Options.RefreshRate.Label": "Taxa de atualização", diff --git a/src/SharpEmu.GUI/Languages/ru.json b/src/SharpEmu.GUI/Languages/ru.json index 7bbd4e8..08a0f98 100644 --- a/src/SharpEmu.GUI/Languages/ru.json +++ b/src/SharpEmu.GUI/Languages/ru.json @@ -81,6 +81,8 @@ "Options.WindowMode.Exclusive": "Эксклюзивный", "Options.Resolution.Label": "Разрешение", "Options.Resolution.Desc": "Начальный размер окна или разрешение эксклюзивного полноэкранного режима.", + "Options.GuestResolution.Label": "Режим экрана игры", + "Options.GuestResolution.Desc": "Разрешение экрана, сообщаемое игре. По умолчанию 1080p. Игра выбирает внутреннее разрешение рендеринга; игры с фиксированным разрешением могут игнорировать эту настройку.", "Options.Display.Label": "Монитор", "Options.Display.Desc": "Монитор, используемый для центрирования окна и полноэкранного режима.", "Options.RefreshRate.Label": "Частота обновления", diff --git a/src/SharpEmu.GUI/Languages/tr.json b/src/SharpEmu.GUI/Languages/tr.json index 6135c15..b6ca332 100644 --- a/src/SharpEmu.GUI/Languages/tr.json +++ b/src/SharpEmu.GUI/Languages/tr.json @@ -63,6 +63,8 @@ "Options.WindowMode.Desc": "Normal pencere, kenarlıksız masaüstü veya özel tam ekran.", "Options.Resolution.Label": "Çözünürlük", "Options.Resolution.Desc": "Başlangıç pencere boyutu veya özel tam ekran çözünürlüğü.", + "Options.GuestResolution.Label": "Oyunun ekran modu", + "Options.GuestResolution.Desc": "Oyuna bildirilen ekran çözünürlüğü. Varsayılan 1080p’dir. Dahili işleme çözünürlüğünü oyun seçer; sabit çözünürlüklü oyunlar bu ayarı yok sayabilir.", "Options.Display.Label": "Ekran", "Options.Display.Desc": "Ortalama ve tam ekran için kullanılan monitör.", "Options.RefreshRate.Label": "Yenileme hızı", diff --git a/src/SharpEmu.GUI/MainWindow.GameOptions.cs b/src/SharpEmu.GUI/MainWindow.GameOptions.cs index 2f42710..f441fa8 100644 --- a/src/SharpEmu.GUI/MainWindow.GameOptions.cs +++ b/src/SharpEmu.GUI/MainWindow.GameOptions.cs @@ -45,6 +45,7 @@ public partial class MainWindow GameLogLevelBox.ItemsSource = _logLevelChoices; GameWindowModeBox.ItemsSource = _windowModeChoices; GameScalingModeBox.ItemsSource = _scalingModeChoices; + GameGuestResolutionBox.ItemsSource = new[] { "1920x1080", "3840x2160" }; GameHdrModeBox.ItemsSource = _hdrModeChoices; GameOverlayModeBox.ItemsSource = _overlayModeChoices; GameOverlayCornerBox.ItemsSource = _overlayCornerChoices; @@ -115,6 +116,7 @@ public partial class MainWindow GameRefreshRateBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); GameScalingModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); GameVSyncToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings(); + GameGuestResolutionBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); GameHdrModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); GameOverlayEnabledToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings(); GameOverlayModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings(); @@ -216,6 +218,7 @@ public partial class MainWindow _scalingModeChoices, effective.ScalingMode, "Fit"); + GameGuestResolutionBox.SelectedItem = effective.GuestResolution; GameVSyncToggle.IsChecked = effective.VSync; GameOverlayEnabledToggle.IsChecked = effective.OverlayEnabled; GameOverlayModeBox.SelectedItem = FindChoice(_overlayModeChoices, effective.OverlayMode, "TitleBar"); @@ -277,6 +280,7 @@ public partial class MainWindow LogToFile = GameLogToFileToggle.IsChecked == true, WindowMode = SelectedComboText(GameWindowModeBox, "Windowed"), Resolution = SelectedComboText(GameResolutionBox, "1920x1080"), + GuestResolution = SelectedComboText(GameGuestResolutionBox, "1920x1080"), DisplayIndex = GameDisplayBox.SelectedItem is HostDisplayOption display ? display.Index : 0, diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml index 518bb78..9581eb3 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml +++ b/src/SharpEmu.GUI/MainWindow.axaml @@ -856,6 +856,14 @@ SPDX-License-Identifier: GPL-2.0-or-later VerticalAlignment="Center" /> + + + + + + _settings.WindowMode = SelectedComboText(WindowModeBox, "Windowed"); DisplayBox.SelectionChanged += (_, _) => OnHostDisplayChanged(); ResolutionBox.SelectionChanged += (_, _) => OnHostResolutionChanged(); + GuestResolutionBox.SelectionChanged += (_, _) => _settings.GuestResolution = SelectedComboText(GuestResolutionBox, "1920x1080"); RefreshRateBox.SelectionChanged += (_, _) => OnHostRefreshRateChanged(); ScalingModeBox.SelectionChanged += (_, _) => _settings.ScalingMode = SelectedComboText(ScalingModeBox, "Fit"); VSyncToggle.IsCheckedChanged += (_, _) => _settings.VSync = VSyncToggle.IsChecked == true; @@ -1249,6 +1250,7 @@ public partial class MainWindow : Window LogLevelBox.ItemsSource = _logLevelChoices; WindowModeBox.ItemsSource = _windowModeChoices; ScalingModeBox.ItemsSource = _scalingModeChoices; + GuestResolutionBox.ItemsSource = new[] { "1920x1080", "3840x2160" }; HdrModeBox.ItemsSource = _hdrModeChoices; BinkPlaybackBox.ItemsSource = _binkPlaybackChoices; OverlayModeBox.ItemsSource = _overlayModeChoices; @@ -1324,6 +1326,7 @@ public partial class MainWindow : Window LoadHostDisplayOptions(); ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer"); VSyncToggle.IsChecked = _settings.VSync; + GuestResolutionBox.SelectedItem = GuiSettings.NormalizeGuestResolution(_settings.GuestResolution); HdrModeBox.SelectedIndex = ChoiceIndex(_settings.HdrMode, "Auto", "On", "Off"); OverlayEnabledToggle.IsChecked = _settings.OverlayEnabled; OverlayModeBox.SelectedIndex = ChoiceIndex(_settings.OverlayMode, "Full", "Minimal", "TitleBar"); @@ -2560,13 +2563,6 @@ public partial class MainWindow : Window }); _appliedEnvironmentVariables.Add(BinkModeEnvironmentName); - Environment.SetEnvironmentVariable( - "SHARPEMU_RENDER_SCALE", - _settings.RenderResolutionScale.ToString( - "0.###", - System.Globalization.CultureInfo.InvariantCulture)); - _appliedEnvironmentVariables.Add("SHARPEMU_RENDER_SCALE"); - foreach (var entry in customEnvironment) { if (!CustomEnvironmentVariables.TryParseEntry(entry, out var name, out var value)) @@ -2784,6 +2780,7 @@ public partial class MainWindow : Window arguments.Add($"--window-mode={launch.Settings.WindowMode.ToLowerInvariant()}"); arguments.Add($"--resolution={launch.Settings.Resolution}"); + arguments.Add($"--guest-resolution={launch.Settings.GuestResolution}"); arguments.Add($"--display={launch.Settings.DisplayIndex}"); arguments.Add($"--refresh-rate={launch.Settings.RefreshRate}"); arguments.Add($"--scaling={launch.Settings.ScalingMode.ToLowerInvariant()}"); diff --git a/src/SharpEmu.GUI/PerGameSettings.cs b/src/SharpEmu.GUI/PerGameSettings.cs index 58b2cff..7c8e403 100644 --- a/src/SharpEmu.GUI/PerGameSettings.cs +++ b/src/SharpEmu.GUI/PerGameSettings.cs @@ -26,6 +26,8 @@ public sealed class PerGameSettings public string? Resolution { get; set; } + public string? GuestResolution { get; set; } + public int? DisplayIndex { get; set; } public int? RefreshRate { get; set; } @@ -52,6 +54,7 @@ public sealed class PerGameSettings LogToFile is null && WindowMode is null && Resolution is null && + GuestResolution is null && DisplayIndex is null && RefreshRate is null && ScalingMode is null && @@ -145,6 +148,11 @@ public sealed class PerGameSettings Resolution = null; } + if (string.Equals(GuestResolution, global.GuestResolution, StringComparison.OrdinalIgnoreCase)) + { + GuestResolution = null; + } + if (DisplayIndex == global.DisplayIndex) { DisplayIndex = null; @@ -272,6 +280,7 @@ public sealed record EffectiveLaunchSettings( bool LogToFile, string WindowMode, string Resolution, + string GuestResolution, int DisplayIndex, int RefreshRate, string ScalingMode, @@ -289,6 +298,7 @@ public sealed record EffectiveLaunchSettings( perGame?.LogToFile ?? global.LogToFile, perGame?.WindowMode ?? global.WindowMode, perGame?.Resolution ?? global.Resolution, + GuiSettings.NormalizeGuestResolution(perGame?.GuestResolution ?? global.GuestResolution), Math.Max(0, perGame?.DisplayIndex ?? global.DisplayIndex), Math.Clamp(perGame?.RefreshRate ?? global.RefreshRate, 0, 1000), perGame?.ScalingMode ?? global.ScalingMode, diff --git a/src/SharpEmu.Libs/Agc/AgcExports.DrawPackets.cs b/src/SharpEmu.Libs/Agc/AgcExports.DrawPackets.cs index dfb8c60..0317c42 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.DrawPackets.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.DrawPackets.cs @@ -1,6 +1,7 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +using System.Collections.Concurrent; using SharpEmu.HLE; using SharpEmu.Libs.Kernel; @@ -133,13 +134,15 @@ public static partial class AgcExports var commandBufferAddress = ctx[CpuRegister.Rdi]; var indexCount = (uint)ctx[CpuRegister.Rsi]; var indexAddress = ctx[CpuRegister.Rdx]; - var modifier = (uint)ctx[CpuRegister.Rcx]; + var modifier = ctx[CpuRegister.Rcx]; - if (commandBufferAddress == 0 || modifier != 0x4000_0000) + if (commandBufferAddress == 0) { return ReturnPointer(ctx, 0); } + NoteDrawModifier("sceAgcDcbDrawIndex", modifier); + if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var baseCommand) || !TryWriteUInt32(ctx, baseCommand, Pm4(3, ItIndexBase, 0)) || !TryWriteUInt32(ctx, baseCommand + 4, (uint)indexAddress) || @@ -239,11 +242,13 @@ public static partial class AgcExports var commandBufferAddress = ctx[CpuRegister.Rdi]; var indexCount = (uint)ctx[CpuRegister.Rsi]; var modifier = ctx[CpuRegister.Rdx]; - if (commandBufferAddress == 0 || modifier != 0x4000_0000) + if (commandBufferAddress == 0) { return ReturnPointer(ctx, 0); } + NoteDrawModifier("sceAgcDcbDrawIndexAuto", modifier); + if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 7, out var commandAddress) || !TryWriteUInt32(ctx, commandAddress, Pm4(7, ItNop, RDrawIndexAuto)) || !TryWriteUInt32(ctx, commandAddress + 4, indexCount) || @@ -457,4 +462,19 @@ public static partial class AgcExports #pragma warning restore SHEM006 private static long _indirectDrawEmitRejectCount; + + private static readonly ConcurrentDictionary<(string, ulong), byte> _reportedDrawModifiers = new(); + + // The modifier names the shader stage that receives the draw and the user registers the + // command processor fills for indirect draws. A direct draw emits the same packet for every + // modifier; rejecting the unfamiliar ones dropped whole passes, such as Unreal's volume + // rasterization through the geometry stage, without a trace. + private static void NoteDrawModifier(string export, ulong modifier) + { + if (modifier != 0x4000_0000 && _reportedDrawModifiers.TryAdd((export, modifier), 0)) + { + Console.Error.WriteLine( + $"[LOADER][INFO] agc.draw_modifier {export} modifier=0x{modifier:X} stage={(uint)modifier >> 29} flags=0x{(uint)modifier & 7u:X}"); + } + } } diff --git a/src/SharpEmu.Libs/Agc/AgcExports.Events.cs b/src/SharpEmu.Libs/Agc/AgcExports.Events.cs index d21a8b6..2637559 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.Events.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.Events.cs @@ -64,14 +64,24 @@ public static partial class AgcExports var commandBufferAddress = ctx[CpuRegister.Rdi]; var eventType = (uint)(ctx[CpuRegister.Rsi] & 0xFF); var eventAddress = ctx[CpuRegister.Rdx]; - if (commandBufferAddress == 0 || eventType > 0x3F || eventAddress != 0) + if (commandBufferAddress == 0 || eventType > 0x3F || (eventAddress != 0 && (eventType & ~1u) != 0x38)) { return ReturnPointer(ctx, 0); } - if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) || - !TryWriteUInt32(ctx, commandAddress, Pm4(2, ItEventWrite, 0)) || - !TryWriteUInt32(ctx, commandAddress + 4, eventType)) + // Counter selection and counter dumps carry a payload in the address fields. + var hasAddress = (eventType & ~1u) == 0x38; + var packetDwords = hasAddress ? 4u : 2u; + if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) || + !TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItEventWrite, 0)) || + !TryWriteUInt32(ctx, commandAddress + 4, hasAddress ? eventType | 0x100u : eventType)) + { + return ReturnPointer(ctx, 0); + } + + if (hasAddress && + (!TryWriteUInt32(ctx, commandAddress + 8, (uint)eventAddress & ~7u) || + !TryWriteUInt32(ctx, commandAddress + 12, (uint)(eventAddress >> 32)))) { return ReturnPointer(ctx, 0); } diff --git a/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Labels.cs b/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Labels.cs index 4cb3dac..c15fb34 100644 --- a/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Labels.cs +++ b/src/SharpEmu.Libs/Gpu/GpuCommands/GpuCommandInterpreter.Labels.cs @@ -435,15 +435,21 @@ public sealed partial class GpuCommandInterpreter throw _host.Fatal($"The occlusion-counter dump is invalid: index=0x{eventIndex:X8} address=0x{eventAddress:X16}."); } - // Publish visible occlusion results for each depth block. + // Until real sample queries are implemented, conservatively report visibility. + // A one-sample delta can disappear when the guest scales or reduces the counts. + const ulong visibleCounterStep = 0x2FFFFFF; const ulong readyBit = 1UL << 63; var result = readyBit | SyntheticOcclusionCounter; for (var depthBlock = 0u; depthBlock < 16u; depthBlock++) { - WriteQword(eventAddress + ((ulong)depthBlock * 2 * sizeof(ulong)), result); + // Query reducers can read a GPU-cached copy of this range. Use the same + // ordered transfer path as clears instead of changing only CPU backing. + var destination = eventAddress + ((ulong)depthBlock * 2 * sizeof(ulong)); + _host.FillBuffer(destination, sizeof(uint), (uint)result, false); + _host.FillBuffer(destination + sizeof(uint), sizeof(uint), (uint)(result >> 32), false); } - SyntheticOcclusionCounter = (SyntheticOcclusionCounter + 1) & (readyBit - 1); + SyntheticOcclusionCounter = (SyntheticOcclusionCounter + visibleCounterStep) & (readyBit - 1); break; } diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs b/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs index 4265201..df493cc 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/VertexInputResolver.cs @@ -360,7 +360,24 @@ public static class VertexInputResolver for (var bufferIndex = 0; bufferIndex < buffers.Count; bufferIndex++) { var buffer = buffers[bufferIndex]; - result[bufferIndex] = new VertexInputBuffer(buffer.Address, buffer.Stride, buffer.RecordCount, buffer.FetchIndex != 0); + uint minimumFetchBytes = 0; + foreach (var resourceIndex in buffer.AttributeIndices) + { + var resource = resources[resourceIndex]; + var descriptor = resource.Descriptor; + // OOB_SELECT=2 only tests for an unbound (zero-record) descriptor. + // A constant stream still reads the whole formatted element, even + // when NUM_RECORDS is smaller than that element's byte width. + if (descriptor.Stride == 0 && descriptor.OutOfBounds == 2 && descriptor.RecordCount != 0 && + VertexAttributeFormats.TryResolve(in descriptor, 4, out var format, out _)) + { + minimumFetchBytes = Math.Max(minimumFetchBytes, Images.ViewFormatRules.BlockBytes(format)); + } + } + result[bufferIndex] = new VertexInputBuffer(buffer.Address, buffer.Stride, buffer.RecordCount, buffer.FetchIndex != 0) + { + MinimumFetchBytes = minimumFetchBytes, + }; foreach (var resourceIndex in buffer.AttributeIndices) { var resource = resources[resourceIndex]; diff --git a/src/SharpEmu.Libs/Gpu/Rendering/ColorTargetResolver.cs b/src/SharpEmu.Libs/Gpu/Rendering/ColorTargetResolver.cs index 3e5e630..32d916e 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/ColorTargetResolver.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/ColorTargetResolver.cs @@ -29,6 +29,13 @@ public static class ColorTargetResolver public static ColorTargetResolution? Resolve(ContextRegisters context, uint slot, uint drawLayerOffset, bool ignoreTargetMask, out uint resolvedSlot) { resolvedSlot = slot == FirstBoundSlot ? FirstBound(context) : slot; + // Disabling CB takes precedence over stale target addresses and masks. + // Depth-only passes may leave the preceding color targets bound. + if (context.ColorControl.Mode == 0) + { + return null; + } + return ImageRequestBuilders.ColorTarget(in context.ColorTargets[resolvedSlot], context.RenderTargetMaskForSlot(resolvedSlot), drawLayerOffset, ignoreTargetMask); } } diff --git a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs index ad86efc..857eaa8 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/RenderExecutor.cs @@ -606,7 +606,8 @@ public sealed partial class RenderExecutor { var context = banks.Context; var shaderInterface = context.ShaderInterface; - var hasColorOutput = (context.RenderTargetMask & shaderInterface.ColorShaderMask) != 0; + var hasColorOutput = context.ColorControl.Mode != 0 && + (context.RenderTargetMask & shaderInterface.ColorShaderMask) != 0; return banks.Shader.Pixel.Address != 0 && (hasColorOutput || PixelShaderHasDepthOrCoverageSideEffects(shaderInterface)); } diff --git a/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs b/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs index fa55ad3..8d6aa08 100644 --- a/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs +++ b/src/SharpEmu.Libs/Gpu/Rendering/ShaderStageResources.cs @@ -100,7 +100,9 @@ public readonly record struct DispatchThreadLimits(uint X, uint Y, uint Z); // The vertex buffer words of one fetch slot as the vertex program declares them. public readonly record struct VertexInputBuffer(ulong Address, uint Stride, uint RecordCount, bool PerInstance = false) { - public ulong Size => Stride != 0 ? (ulong)Stride * RecordCount : RecordCount; + public uint MinimumFetchBytes { get; init; } + + public ulong Size => Math.Max(Stride != 0 ? (ulong)Stride * RecordCount : RecordCount, MinimumFetchBytes); } // One attribute of the vertex tables: its buffer words, the registers it fills and its buffer slot. diff --git a/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs index b0a8f9c..4581d72 100644 --- a/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs +++ b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs @@ -21,6 +21,13 @@ public enum HostScalingMode Integer, } +// The display mode exposed to the guest, independent of host window and guest buffer sizes. +public enum GuestDisplayResolution +{ + Hd = 1, + UltraHd = 2, +} + public enum HostHdrMode { Auto, @@ -55,6 +62,8 @@ public sealed record HostVideoOptions public int Height { get; init; } = 1080; + public GuestDisplayResolution GuestResolution { get; init; } = GuestDisplayResolution.Hd; + public int DisplayIndex { get; init; } public int RefreshRate { get; init; } @@ -82,6 +91,7 @@ public sealed record HostVideoOptions { Width = Math.Clamp(Width, 640, 16384), Height = Math.Clamp(Height, 360, 16384), + GuestResolution = Enum.IsDefined(GuestResolution) ? GuestResolution : GuestDisplayResolution.Hd, DisplayIndex = Math.Max(0, DisplayIndex), RefreshRate = Math.Clamp(RefreshRate, 0, 1000), HdrMode = Enum.IsDefined(HdrMode) ? HdrMode : HostHdrMode.Auto, diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs index 2688f09..25eec50 100644 --- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs +++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs @@ -519,18 +519,19 @@ public static partial class VideoOutExports } Span status = stackalloc byte[VideoOutOutputStatusSize]; - WriteOutputStatus(status, port.OutputWidth, port.OutputHeight, port.RefreshRate, + WriteOutputStatus(status, HostVideoHost.CurrentOptions.GuestResolution, port.RefreshRate, HostVideoHost.IsHdrOutputSupported); return ctx.Memory.TryWrite(statusAddress, status) ? (int)OrbisGen2Result.ORBIS_GEN2_OK : (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; } - internal static void WriteOutputStatus(Span status, uint width, uint height, uint refreshRate, + internal static void WriteOutputStatus(Span status, GuestDisplayResolution resolution, uint refreshRate, bool hdrOutputSupported) { status.Clear(); - var resolutionClass = width >= 3840 || height >= 2160 ? 2 : 1; + // Buffer registration describes a source image, not the connected display. + var resolutionClass = (int)resolution; BinaryPrimitives.WriteInt32LittleEndian(status[0x00..0x04], resolutionClass); BinaryPrimitives.WriteInt32LittleEndian(status[0x04..0x08], hdrOutputSupported ? 2 : 1); // The status uses a refresh-rate code, not the frequency used for pacing. diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs index 7b0868a..6732552 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs @@ -3955,7 +3955,7 @@ public static partial class Gen5SpirvTranslator { targetLane = BitwiseAnd(targetLane, UInt(31)); } - var shuffled = ShuffleLane(value, targetLane); + var shuffled = ShuffleHalfWaveLane(value, targetLane); if (control.FetchInactive) { return shuffled; @@ -3968,7 +3968,7 @@ public static partial class Gen5SpirvTranslator UInt(1), UInt(0)); var sourceActive = IsNotZero( - ShuffleLane(activeWord, targetLane)); + ShuffleHalfWaveLane(activeWord, targetLane)); return _module.AddInstruction( SpirvOp.Select, _uintType, @@ -3991,7 +3991,7 @@ public static partial class Gen5SpirvTranslator // operations are limited to a single half-wave for some encodings, so we // must not clamp wave64 lanes to 31; use the full lane mask instead. safeTarget = BitwiseAnd(safeTarget, UInt(_waveLaneCount == 64 ? 63u : 31u)); - var shuffled = ShuffleLane(value, safeTarget); + var shuffled = ShuffleHalfWaveLane(value, safeTarget); var sourceAvailable = inRange; if (!control.FetchInactive) @@ -4002,7 +4002,7 @@ public static partial class Gen5SpirvTranslator Load(_boolType, _exec), UInt(1), UInt(0)); - var shuffledActive = ShuffleLane(activeWord, safeTarget); + var shuffledActive = ShuffleHalfWaveLane(activeWord, safeTarget); sourceAvailable = _module.AddInstruction( SpirvOp.LogicalAnd, _boolType, @@ -5124,11 +5124,9 @@ public static partial class Gen5SpirvTranslator } var targetLane = IAdd(rowBase, selector); - // Mask to guest wave size — on Radeon hardware DPP is limited to a - // single half-wave for some encodings, but we must not clamp wave64 - // lanes to 31; use the full lane mask instead. + // Select a guest lane first; ShuffleHalfWaveLane maps it to the host subgroup. targetLane = BitwiseAnd(targetLane, UInt(_waveLaneCount == 64 ? 63u : 31u)); - var shuffled = ShuffleLane(value, targetLane); + var shuffled = ShuffleHalfWaveLane(value, targetLane); var fetchInactive = (control.OperandSelect & 1) != 0; if (fetchInactive) { @@ -5142,7 +5140,7 @@ public static partial class Gen5SpirvTranslator UInt(1), UInt(0)); var sourceActive = IsNotZero( - ShuffleLane(activeWord, targetLane)); + ShuffleHalfWaveLane(activeWord, targetLane)); return _module.AddInstruction( SpirvOp.Select, _uintType, diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs index d0a48f5..a896618 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs @@ -8303,6 +8303,15 @@ public static partial class Gen5SpirvTranslator ? value : _module.AddInstruction(SpirvOp.GroupNonUniformShuffle, _uintType, UInt(3), value, lane); + // DPP row/quad operations and PERMLANE16 never cross a 32-lane half-wave. + // Guest lanes 32..63 belong to the second host subgroup on 32-lane devices; + // using those guest indices directly in OpGroupNonUniformShuffle is undefined. + // Preserve the current physical half on devices whose subgroup is wider than 32. + private uint ShuffleHalfWaveLane(uint value, uint guestLane) => + _subgroupInvocationIdInput == 0 ? value : ShuffleLane(value, + BitwiseOr(BitwiseAnd(Load(_uintType, _subgroupInvocationIdInput), UInt(0xFFFF_FFE0)), + BitwiseAnd(guestLane, UInt(31)))); + private uint CurrentLaneBit() { if (_subgroupInvocationIdInput == 0) diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcDirectDrawModifierTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcDirectDrawModifierTests.cs new file mode 100644 index 0000000..6e015b7 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcDirectDrawModifierTests.cs @@ -0,0 +1,90 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.Libs.Agc; +using SharpEmu.Libs.Gpu.GpuCommands; +using SharpEmu.Libs.Tests.Gpu.GpuCommands; +using Xunit; + +namespace SharpEmu.Libs.Tests.Agc; + +// Direct draws carry a modifier naming the shader stage and the user registers of the pipeline. +// Every modifier must still produce the draw: Unreal's volume passes use a geometry-stage modifier. +public sealed class AgcDirectDrawModifierTests +{ + private const ulong BaseAddress = 0x2_1000_0000; + private const ulong CommandBufferAddress = BaseAddress + 0x80; + private const ulong CommandAddress = BaseAddress + 0x200; + + public static TheoryData Modifiers => new() { 0x4000_0000UL, 0x6000_0000UL, 0xA000_0000UL, 0x4008_0205UL, 0x1_6000_0000UL }; + + [Theory] + [MemberData(nameof(Modifiers))] + public void DrawIndexAutoEmitsTheDrawForEveryModifier(ulong modifier) + { + var memory = CreateCommandBuffer(); + var context = new CpuContext(memory, Generation.Gen5); + context[CpuRegister.Rdi] = CommandBufferAddress; + context[CpuRegister.Rsi] = 6; + context[CpuRegister.Rdx] = modifier; + + AgcExports.DcbDrawIndexAuto(context); + + Assert.Equal(CommandAddress, context[CpuRegister.Rax]); + var runner = new StreamRunner(); + Assert.Equal(SubmissionProgress.Complete, runner.Run(ReadDwords(memory, CommandAddress, 7))); + var draw = Assert.Single(runner.Host.AutoDraws); + Assert.Equal(6u, draw.VertexCount); + } + + [Theory] + [MemberData(nameof(Modifiers))] + public void DrawIndexEmitsTheDrawForEveryModifier(ulong modifier) + { + var memory = CreateCommandBuffer(); + var context = new CpuContext(memory, Generation.Gen5); + context[CpuRegister.Rdi] = CommandBufferAddress; + context[CpuRegister.Rsi] = 36; + context[CpuRegister.Rdx] = StreamRunner.DataAddress; + context[CpuRegister.Rcx] = modifier; + + AgcExports.DcbDrawIndex(context); + + Assert.NotEqual(0UL, context[CpuRegister.Rax]); + var runner = new StreamRunner(); + Assert.Equal(SubmissionProgress.Complete, runner.Run(ReadDwords(memory, CommandAddress, 11))); + var draw = Assert.Single(runner.Host.IndexedDraws); + Assert.Equal(36u, draw.IndexCount); + Assert.Equal(StreamRunner.DataAddress, draw.IndexAddress); + } + + private static FakeCpuMemory CreateCommandBuffer() + { + var memory = new FakeCpuMemory(BaseAddress, 0x1000); + WriteUInt64(memory, CommandBufferAddress + 0x10, CommandAddress); + WriteUInt64(memory, CommandBufferAddress + 0x18, CommandAddress + 0x100); + return memory; + } + + private static uint[] ReadDwords(FakeCpuMemory memory, ulong address, int count) + { + var bytes = new byte[count * sizeof(uint)]; + Assert.True(memory.TryRead(address, bytes)); + var dwords = new uint[count]; + for (var index = 0; index < count; index++) + { + dwords[index] = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(index * sizeof(uint))); + } + + return dwords; + } + + private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value) + { + Span buffer = stackalloc byte[sizeof(ulong)]; + BinaryPrimitives.WriteUInt64LittleEndian(buffer, value); + Assert.True(memory.TryWrite(address, buffer)); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcEventWriteTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcEventWriteTests.cs new file mode 100644 index 0000000..5e7a3cb --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcEventWriteTests.cs @@ -0,0 +1,77 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.Libs.Agc; +using SharpEmu.Libs.Gpu.GpuCommands; +using SharpEmu.Libs.Tests.Gpu.GpuCommands; +using Xunit; + +namespace SharpEmu.Libs.Tests.Agc; + +public sealed class AgcEventWriteTests +{ + [Fact] + public void DrawCounterDumpsPublishBothHalvesOfQueryResults() + { + var runner = new StreamRunner(); + var begin = Build(0x39, StreamRunner.LabelAddress); + var end = Build(0x39, StreamRunner.LabelAddress + 8); + + Assert.Equal(0x139u, begin[1]); + Assert.Equal(SubmissionProgress.Complete, runner.Run(begin, end)); + for (var block = 0u; block < 16; block++) + { + var address = StreamRunner.LabelAddress + block * 16; + var first = runner.Host.ReadQword(address); + var last = runner.Host.ReadQword(address + 8); + Assert.NotEqual(0UL, first & last & (1UL << 63)); + Assert.True(last > first); + } + } + + [Theory] + [InlineData(0x38u, 0x1234567800UL, 4)] + [InlineData(0x39u, 0x1234567800UL, 4)] + [InlineData(0x3Au, 0UL, 2)] + [InlineData(0x10u, 0UL, 2)] + public void DrawEventsPreservePayloadAndAdvanceCursor(uint eventType, ulong payload, int words) + { + var packet = Build(eventType, payload); + Assert.Equal(words, packet.Length); + Assert.Equal(eventType | (words == 4 ? 0x100u : 0), packet[1]); + if (words == 4) + { + Assert.Equal((uint)payload, packet[2]); + Assert.Equal((uint)(payload >> 32), packet[3]); + } + } + + private static uint[] Build(uint eventType, ulong payload) + { + const ulong memoryAddress = 0x2_1000_0000; + const ulong commandBuffer = memoryAddress + 0x80; + const ulong packetAddress = memoryAddress + 0x200; + var memory = new FakeCpuMemory(memoryAddress, 0x1000); + var context = new CpuContext(memory, Generation.Gen5); + Span bytes = stackalloc byte[16]; + BinaryPrimitives.WriteUInt64LittleEndian(bytes, packetAddress); + BinaryPrimitives.WriteUInt64LittleEndian(bytes[8..], packetAddress + 0x100); + Assert.True(memory.TryWrite(commandBuffer + 0x10, bytes)); + context[CpuRegister.Rdi] = commandBuffer; + context[CpuRegister.Rsi] = eventType; + context[CpuRegister.Rdx] = payload; + + AgcExports.DcbEventWrite(context); + Assert.Equal(packetAddress, context[CpuRegister.Rax]); + Assert.True(memory.TryRead(commandBuffer + 0x10, bytes[..8])); + var length = checked((int)(BinaryPrimitives.ReadUInt64LittleEndian(bytes) - packetAddress)); + Assert.InRange(length, 8, 16); + Assert.True(memory.TryRead(packetAddress, bytes[..length])); + var packet = new uint[length / 4]; + for (var i = 0; i < packet.Length; i++) + packet[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * 4)..]); + return packet; + } +} diff --git a/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs b/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs index 3237d57..5f67277 100644 --- a/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs +++ b/tests/SharpEmu.Libs.Tests/GUI/PerGameSettingsTests.cs @@ -8,6 +8,33 @@ namespace SharpEmu.Libs.Tests.GUI; public sealed class PerGameSettingsTests { + [Fact] + public void GuestResolutionDefaultsToHdAndCanBeOverriddenPerGame() + { + var global = new GuiSettings { Resolution = "3840x2160" }; + Assert.Equal("1920x1080", EffectiveLaunchSettings.Resolve(global, null).GuestResolution); + var game = new PerGameSettings { GuestResolution = "3840x2160" }; + game.RemoveInheritedValues(global); + Assert.False(game.IsEmpty); + var restored = PerGameSettings.NormalizeFromJson(System.Text.Json.JsonSerializer.Serialize(game)); + Assert.Equal("3840x2160", EffectiveLaunchSettings.Resolve(global, restored).GuestResolution); + global.GuestResolution = "3840x2160"; + game.RemoveInheritedValues(global); + Assert.True(game.IsEmpty); + Assert.Equal("3840x2160", EffectiveLaunchSettings.Resolve(global, game).GuestResolution); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("invalid")] + [InlineData("7680x4320")] + public void InvalidGuestResolutionFallsBackToHd(string? value) + { + Assert.Equal("1920x1080", EffectiveLaunchSettings.Resolve( + new GuiSettings(), new PerGameSettings { GuestResolution = value }).GuestResolution); + } + [Fact] public void NewGameInheritsWritableApp0AndGlobalCrashCapture() { diff --git a/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs index 1425f35..a79f769 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/GpuCommands/GpuCommandInterpreterLabelTests.cs @@ -325,12 +325,16 @@ public sealed class GpuCommandInterpreterLabelTests Assert.Contains("event type is unknown", runner.RunExpectingFatal(StreamRunner.Packet(PacketOpcode.EventWrite, 0x3F)).Message); runner.Run(StreamRunner.Packet(PacketOpcode.EventWrite, 0x39 | (1u << 8), StreamRunner.Low(Label), StreamRunner.High(Label))); + // CPU-only stores leave GPU query-reduction inputs stale. Require the + // host transfer path for both words of every counter, including ready. + Assert.Equal(32, runner.Host.Calls.Count); + Assert.All(runner.Host.Calls, call => Assert.StartsWith("fill ", call)); Assert.Equal(1UL << 63, runner.Host.ReadQword(Label)); Assert.Equal(1UL << 63, runner.Host.ReadQword(Label + 15 * 16)); Assert.Equal(0UL, runner.Host.ReadQword(Label + 8)); runner.Run(StreamRunner.Packet(PacketOpcode.EventWrite, 0x39 | (1u << 8), StreamRunner.Low(Label), StreamRunner.High(Label))); - Assert.Equal((1UL << 63) | 1, runner.Host.ReadQword(Label)); + Assert.Equal((1UL << 63) | 0x2FFFFFFUL, runner.Host.ReadQword(Label)); } [Fact] diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/VertexInputResolverTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/VertexInputResolverTests.cs index 63b750e..de20b9d 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/VertexInputResolverTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/VertexInputResolverTests.cs @@ -354,4 +354,36 @@ public sealed class VertexInputResolverTests : IDisposable Assert.Contains("records=4/5", fatal.Message); } + [Theory] + [InlineData(74u, 3, 12ul)] + [InlineData(77u, 4, 16ul)] + [InlineData(77u, 3, 16ul)] + [InlineData(64u, 2, 8ul)] + public void MergeBuffers_ConstantUncheckedStreamIncludesFullFormattedFetch(uint format, int components, ulong expectedSize) + { + var resource = Attribute(StreamBase, 0, 1, 0); + resource = resource with + { + Descriptor = resource.Descriptor with { Word3 = IdentitySelect | (format << 12) | (2u << 28) }, + RegisterCount = components, + }; + var buffer = Assert.Single(VertexInputResolver.MergeBuffers([resource], ShaderAddress)); + Assert.Equal(1u, buffer.RecordCount); + Assert.Equal(0u, buffer.Stride); + Assert.Equal(expectedSize, buffer.Size); + } + + [Theory] + [InlineData(0u, 1u)] + [InlineData(1u, 1u)] + [InlineData(3u, 1u)] + [InlineData(2u, 0u)] + public void MergeBuffers_BoundedOrUnboundConstantStreamKeepsDeclaredFootprint(uint outOfBounds, uint records) + { + var resource = Attribute(StreamBase, 0, records, 0); + resource = resource with { Descriptor = resource.Descriptor with { Word3 = resource.Descriptor.Word3 | (outOfBounds << 28) } }; + var buffer = Assert.Single(VertexInputResolver.MergeBuffers([resource], ShaderAddress)); + Assert.Equal((ulong)records, buffer.Size); + } + } diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorStateTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorStateTests.cs index a046fdc..7eefe51 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorStateTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Rendering/RenderExecutorStateTests.cs @@ -322,6 +322,25 @@ public sealed class RenderExecutorStateTests : IDisposable Assert.Contains("create_graphics_pipeline colors=0 depth=True topology=TriangleList restart=False", _pipelines.Calls); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Attachments_DisabledColorBufferIgnoresStaleTargetsAndPreservesDepthEffects(bool exportsDepth) + { + var banks = Banks(withDepth: true); + banks.Context.ColorControl = ColorControlRegisters.Decode(0); + banks.Context.ColorTargets[0] = RegisterWords.Color(ColorBase, 32, 32); + banks.Context.ShaderInterface.DepthShaderControl = new DepthShaderControlRegisters { DepthExportEnable = exportsDepth }; + + _executor.DrawIndexed(1, banks, Indexed(3)); + + var rendering = Assert.Single(_host.BegunRenderings); + Assert.Equal((0u, 64u, 64u), (rendering.ColorAttachmentCount, rendering.Width, rendering.Height)); + Assert.Equal(Format.D32Sfloat, rendering.DepthFormat); + Assert.Equal(exportsDepth, Assert.Single(_pipelines.PipelineRequests).PixelActive); + Assert.True(Assert.Single(_host.DynamicStates).DepthWriteEnabled); + } + [Theory] [InlineData(0x0u, 0u, 64u)] [InlineData(0xFu, 1u, 32u)] diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/WaveShuffleDeviceTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/WaveShuffleDeviceTests.cs index bd09267..2ebadd9 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/WaveShuffleDeviceTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/WaveShuffleDeviceTests.cs @@ -17,12 +17,16 @@ public sealed class WaveShuffleDeviceTests(HeadlessVulkanFixture fixture, ITestO : IClassFixture { [Theory] - [InlineData("Dpp8")] - [InlineData("Dpp16")] - [InlineData("Permlane16")] - [InlineData("Permlanex16")] - [InlineData("Reduction")] - public void Wave64Shuffle_PreservesUpperHalf(string operation) + [InlineData("Dpp8", 32)] + [InlineData("Dpp8", 64)] + [InlineData("Dpp16", 32)] + [InlineData("Dpp16", 64)] + [InlineData("Permlane16", 32)] + [InlineData("Permlane16", 64)] + [InlineData("Permlanex16", 32)] + [InlineData("Permlanex16", 64)] + [InlineData("Reduction", 64)] + public void WaveShuffle_PreservesEachHalf(string operation, uint waveSize) { var vulkan = fixture.Vulkan; if (!GatePrerequisites.Ready(vulkan, shaderInt64: true)) return; @@ -79,7 +83,7 @@ public sealed class WaveShuffleDeviceTests(HeadlessVulkanFixture fixture, ITestO var (plan, resources, layout) = Prepare(Program([.. instructions])); var request = new ShaderCompileRequest(plan, resources, layout) { - LocalSizeX = 8, LocalSizeY = 8, ThreadCountX = 8, ThreadCountY = 8, WaveSize = 64, + LocalSizeX = 8, LocalSizeY = waveSize / 8, ThreadCountX = 8, ThreadCountY = waveSize / 8, WaveSize = waveSize, }; Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); using var harness = new ImageTestHarness(vulkan); @@ -90,7 +94,7 @@ public sealed class WaveShuffleDeviceTests(HeadlessVulkanFixture fixture, ITestO harness.Run(() => runner.Dispatch(registers, new Dictionary { [DescriptorBindingKind.Buffers] = [result] }, 1)); var actual = runner.ReadBack(result, 0, 256); - for (uint lane = 0; lane < 64; lane++) + for (uint lane = 0; lane < waveSize; lane++) { var expected = operation switch { @@ -104,6 +108,6 @@ public sealed class WaveShuffleDeviceTests(HeadlessVulkanFixture fixture, ITestO Assert.Equal(expected, BinaryPrimitives.ReadUInt32LittleEndian(actual.AsSpan((int)lane * 4))); } harness.AssertNoValidationMessages(); - output.WriteLine($"Verified {operation} on {vulkan.DeviceName}."); + output.WriteLine($"Verified wave{waveSize} {operation} on {vulkan.DeviceName}."); } } diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs index 4d81175..40755b3 100644 --- a/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs +++ b/tests/SharpEmu.Libs.Tests/VideoOut/HostVideoOptionsTests.cs @@ -18,6 +18,7 @@ public sealed class HostVideoOptionsTests DisplayIndex = -2, RefreshRate = 5000, HdrMode = (HostHdrMode)99, + GuestResolution = (GuestDisplayResolution)99, }.Normalize(); Assert.Equal(640, options.Width); @@ -25,5 +26,18 @@ public sealed class HostVideoOptionsTests Assert.Equal(0, options.DisplayIndex); Assert.Equal(1000, options.RefreshRate); Assert.Equal(HostHdrMode.Auto, options.HdrMode); + Assert.Equal(GuestDisplayResolution.Hd, options.GuestResolution); + } + [Theory] + [InlineData(1920, 1080, GuestDisplayResolution.UltraHd)] + [InlineData(3840, 2160, GuestDisplayResolution.Hd)] + public void GuestModeIsIndependentOfWindowSize(int width, int height, GuestDisplayResolution mode) + { + var options = new HostVideoOptions { Width = width, Height = height, GuestResolution = mode }.Normalize(); + Assert.Equal(mode, options.GuestResolution); + Assert.Equal(GuestDisplayResolution.Hd, HostVideoOptions.Default.GuestResolution); + Span status = stackalloc byte[0x30]; + VideoOutExports.WriteOutputStatus(status, options.GuestResolution, 60, false); + Assert.Equal((int)mode, System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(status)); } } diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutOutputSupportTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutOutputSupportTests.cs index cbf2e08..34e8d30 100644 --- a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutOutputSupportTests.cs +++ b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutOutputSupportTests.cs @@ -48,7 +48,7 @@ public sealed class VideoOutOutputSupportTests Assert.Equal(expectedHdr, supported); var bytes = Enumerable.Repeat((byte)0xA5, 0x32).ToArray(); - VideoOutExports.WriteOutputStatus(bytes.AsSpan(1, 0x30), 3840, 2160, 120, supported); + VideoOutExports.WriteOutputStatus(bytes.AsSpan(1, 0x30), GuestDisplayResolution.UltraHd, 120, supported); Assert.Equal(0xA5, bytes[0]); Assert.Equal(0xA5, bytes[^1]);