[GPU] Restore scene visibility and half-wave shuffles (#1025)

* [Agc] Emit addressed draw events for occlusion query counters

* [Gpu] Keep fallback occlusion results coherent and conservatively visible

* [Gpu] Honor disabled color buffers during depth-only draws

* [Gpu] Upload complete unchecked constant vertex attributes

* [Agc] Emit direct draws for every draw modifier

sceAgcDcbDrawIndex and sceAgcDcbDrawIndexAuto returned without writing a
packet unless the modifier was exactly 0x40000000. The modifier names the
shader stage and user registers of the bound pipeline, so any other
pipeline lost its draws without a trace. Unreal bakes its color-grading
LUT through a geometry-stage pipeline, which left the LUT unwritten and
tinted every 3D frame of Poppy Playtime blue.

Both draws now emit their packets for any modifier. Modifiers other than
the vertex-stage default are logged once each.

(cherry picked from commit 8d46a6068b7d1a999662eeac1ee17f9e6d87c2eb)

* [VideoOut] Separate guest display mode from presentation buffer dimensions

* [GPU] Map half-wave shuffle lanes into the host subgroup

* [Docs] Explain display modes and internal resolution limitations

* [GUI] Translate guest display mode settings in all supported languages
This commit is contained in:
kuba 2026-10-07 11:21:15 +02:00 committed by GitHub
parent 8b631d3b03
commit 98f223ce69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 507 additions and 47 deletions

View File

@ -0,0 +1,32 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# 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.

View File

@ -1011,7 +1011,7 @@ internal static partial class Program
private static void PrintUsage()
{
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--console=<ps5>] [--console-language=<language>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--overlay=<on|off>] [--overlay-mode=<full|minimal|titlebar>] [--overlay-corner=<topleft|topright|bottomright|bottomleft>] [--debug-server[=host:port]] <path-to-eboot.bin>");
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--console=<ps5>] [--console-language=<language>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--guest-resolution=<1920x1080|3840x2160>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--overlay=<on|off>] [--overlay-mode=<full|minimal|titlebar>] [--overlay-corner=<topleft|topright|bottomright|bottomleft>] [--debug-server[=host:port]] <path-to-eboot.bin>");
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,

View File

@ -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";
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
public double RenderResolutionScale { get; set; } = 1.0;
/// <summary>
/// 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");

View File

@ -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": "معدل التحديث",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "リフレッシュレート",

View File

@ -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": "새로 고침 빈도",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "Частота обновления",

View File

@ -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ı",

View File

@ -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,

View File

@ -856,6 +856,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="GameGuestResolutionRow"
Classes="optionRow"
Label="{Binding [Options.GuestResolution.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.GuestResolution.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="GameGuestResolutionBox"
Classes="optionValue"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="GameDisplayRow"
Classes="optionRow"
Label="{Binding [Options.Display.Label],
@ -1490,6 +1498,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
Classes="optionValue"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="GuestResolutionRow"
Classes="optionRow"
Label="{Binding [Options.GuestResolution.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.GuestResolution.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="GuestResolutionBox" Classes="optionValue" VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="DisplayRow"
Classes="optionRow"
Label="{Binding [Options.Display.Label], Source={x:Static local:Localization.Instance}}"

View File

@ -322,6 +322,7 @@ public partial class MainWindow : Window
WindowModeBox.SelectionChanged += (_, _) => _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()}");

View File

@ -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,

View File

@ -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}");
}
}
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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];

View File

@ -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);
}
}

View File

@ -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));
}

View File

@ -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.

View File

@ -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,

View File

@ -519,18 +519,19 @@ public static partial class VideoOutExports
}
Span<byte> 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<byte> status, uint width, uint height, uint refreshRate,
internal static void WriteOutputStatus(Span<byte> 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.

View File

@ -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,

View File

@ -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)

View File

@ -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<ulong> 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<byte> buffer = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
}

View File

@ -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<byte> 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;
}
}

View File

@ -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()
{

View File

@ -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]

View File

@ -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);
}
}

View File

@ -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)]

View File

@ -17,12 +17,16 @@ public sealed class WaveShuffleDeviceTests(HeadlessVulkanFixture fixture, ITestO
: IClassFixture<HeadlessVulkanFixture>
{
[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, GpuBuffer[]> { [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}.");
}
}

View File

@ -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<byte> status = stackalloc byte[0x30];
VideoOutExports.WriteOutputStatus(status, options.GuestResolution, 60, false);
Assert.Equal((int)mode, System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(status));
}
}

View File

@ -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]);