[Gpu] Expose HDR output support and handle PQ buffer attribute changes

This commit is contained in:
ParantezTech 2026-10-02 03:37:17 +03:00
parent f199acd42f
commit e7a4f0a0ce
11 changed files with 224 additions and 20 deletions

View File

@ -457,7 +457,7 @@ public sealed record DepthFormatRule(GuestDepthFormat DepthFormat, GuestPixelFor
public readonly record struct DisplayPixelFormat(Format HostFormat, GuestPixelFormat GuestFormat, uint BytesPerElement, bool Bgra16);
// The six display surface pixel formats the store accepts.
// Display formats retain their encoded values; presentation applies the transfer function.
public static class DisplayFormatRule
{
public static readonly (ulong PixelFormat, DisplayPixelFormat Info)[] Table =
@ -466,6 +466,10 @@ public static class DisplayFormatRule
(0x8000000000000000, new DisplayPixelFormat(Format.B8G8R8A8Srgb, GuestPixelFormat.Bits8_8_8_8Srgb, 4, false)),
(0x8100000022000000, new DisplayPixelFormat(Format.A2B10G10R10UnormPack32, GuestPixelFormat.Bits10_10_10_2UNorm, 4, false)),
(0x8100000000000000, new DisplayPixelFormat(Format.A2R10G10B10UnormPack32, GuestPixelFormat.Bits10_10_10_2UNorm, 4, false)),
(0x8100000622000000, new DisplayPixelFormat(Format.A2B10G10R10UnormPack32, GuestPixelFormat.Bits10_10_10_2UNorm, 4, false)),
(0x8100000600000000, new DisplayPixelFormat(Format.A2R10G10B10UnormPack32, GuestPixelFormat.Bits10_10_10_2UNorm, 4, false)),
(0x8100070422000000, new DisplayPixelFormat(Format.A2B10G10R10UnormPack32, GuestPixelFormat.Bits10_10_10_2UNorm, 4, false)),
(0x8100070400000000, new DisplayPixelFormat(Format.A2R10G10B10UnormPack32, GuestPixelFormat.Bits10_10_10_2UNorm, 4, false)),
(0xc001000622000000, new DisplayPixelFormat(Format.R16G16B16A16Sfloat, GuestPixelFormat.Bits16_16_16_16Float, 8, false)),
(0xc001000600000000, new DisplayPixelFormat(Format.R16G16B16A16Sfloat, GuestPixelFormat.Bits16_16_16_16Float, 8, true)),
];

View File

@ -231,10 +231,10 @@ public static partial class ImageRequestBuilders
// Builds the request for a video-out surface registered by the guest.
public static ImageRequest DisplaySurface(in DisplaySurfaceWords surface)
{
const ulong StrictColorimetryOption = 1;
const ulong SupportedOptions = (1UL << 3) | (1UL << 5); // Strict colorimetry and premultiplied alpha.
var compression = ImageDescription.ClassifyDisplayCompression(surface.Compressed, surface.MetadataAddress, surface.DccControl, surface.DccClearColor);
if (surface.Width == 0 || surface.Height == 0 || surface.Width > 16384 || surface.Height > 16384 ||
(surface.Option != 0 && surface.Option != StrictColorimetryOption) || surface.TilingMode != 0 || surface.DataAddress == 0 ||
(surface.Option & ~SupportedOptions) != 0 || surface.TilingMode != 0 || surface.DataAddress == 0 ||
compression == DisplayCompression.Unsupported)
{
throw SubmissionScheduler.Fatal(

View File

@ -4,6 +4,7 @@
namespace SharpEmu.Libs.VideoOut;
using SharpEmu.Libs.Gpu.Metal;
using SharpEmu.Libs.Gpu;
public enum HostWindowMode
{
@ -66,6 +67,9 @@ public sealed record HostVideoOptions
public PerformanceOverlayCorner OverlayCorner { get; init; } = PerformanceOverlayCorner.TopRight;
public PerformanceOverlayMode OverlayMode { get; init; } = PerformanceOverlayMode.TitleBar;
internal bool CanUseHdr(bool displayHdrEnabled, bool surfaceSupportsHdr) =>
surfaceSupportsHdr && (HdrMode == HostHdrMode.On || HdrMode == HostHdrMode.Auto && displayHdrEnabled);
public HostVideoOptions Normalize() => this with
{
Width = Math.Clamp(Width, 640, 16384),
@ -81,14 +85,22 @@ public sealed record HostVideoOptions
public static class HostVideoHost
{
private static HostVideoOptions _currentOptions = HostVideoOptions.Default;
private static bool _videoConfigured;
public static HostVideoOptions CurrentOptions => Volatile.Read(ref _currentOptions);
internal static bool IsHdrOutputSupported =>
Volatile.Read(ref _videoConfigured) && CurrentOptions.HdrMode != HostHdrMode.Off &&
GuestGpu.Current.BackendName == "Vulkan" && VulkanVideoPresenter.QueryHdrOutputSupport();
public static bool TryConfigureVideo(HostVideoOptions options)
{
var normalized = options.Normalize();
Volatile.Write(ref _currentOptions, normalized);
return VulkanVideoPresenter.TryConfigureVideo(normalized) &
MetalVideoPresenter.TryConfigureVideo(normalized);
var configured = VulkanVideoPresenter.TryConfigureVideo(normalized) &
MetalVideoPresenter.TryConfigureVideo(normalized);
if (configured)
Volatile.Write(ref _videoConfigured, true);
return configured;
}
}

View File

@ -7,6 +7,7 @@ using SharpEmu.Logging;
using SharpEmu.HLE.Host;
using SharpEmu.Libs.Diagnostics;
using SharpEmu.Libs.Gpu;
using SharpEmu.Libs.Gpu.Images;
using SharpEmu.Libs.Audio;
using SharpEmu.Libs.Kernel;
using System.Buffers;
@ -297,7 +298,8 @@ public static partial class VideoOutExports
uint TilingMode,
uint Width,
uint Height,
uint PitchInPixel);
uint PitchInPixel,
ulong Option = 0);
[SysAbiExport(
Nid = "Up36PTk687E",
@ -514,20 +516,28 @@ public static partial class VideoOutExports
}
Span<byte> status = stackalloc byte[VideoOutOutputStatusSize];
status.Clear();
var resolutionClass = port.OutputWidth >= 3840 || port.OutputHeight >= 2160 ? 2 : 1;
BinaryPrimitives.WriteInt32LittleEndian(status[0x00..0x04], resolutionClass);
BinaryPrimitives.WriteInt32LittleEndian(status[0x04..0x08], 1);
// The status uses a refresh-rate code, not the frequency used for pacing.
var refreshRateCode = port.RefreshRate >= 119
? SceVideoOutRefreshRate119_88Hz
: SceVideoOutRefreshRate59_94Hz;
BinaryPrimitives.WriteUInt64LittleEndian(status[0x08..0x10], refreshRateCode);
WriteOutputStatus(status, port.OutputWidth, port.OutputHeight, 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,
bool hdrOutputSupported)
{
status.Clear();
var resolutionClass = width >= 3840 || height >= 2160 ? 2 : 1;
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.
var refreshRateCode = refreshRate >= 119
? SceVideoOutRefreshRate119_88Hz
: SceVideoOutRefreshRate59_94Hz;
BinaryPrimitives.WriteUInt64LittleEndian(status[0x08..0x10], refreshRateCode);
BinaryPrimitives.WriteUInt64LittleEndian(status[0x10..0x18], hdrOutputSupported ? 1UL : 0UL);
}
[SysAbiExport(
Nid = "DYhhWbJSeRg",
ExportName = "sceVideoOutColorSettingsSetGamma_",
@ -1199,7 +1209,8 @@ public static partial class VideoOutExports
attribute.TilingMode,
attribute.Width,
attribute.Height,
attribute.PitchInPixel);
attribute.PitchInPixel,
attribute.Option);
return true;
}
}
@ -1348,6 +1359,50 @@ public static partial class VideoOutExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "HuViW4HnrOw",
ExportName = "sceVideoOutSubmitChangeBufferAttribute2",
Target = Generation.Gen5,
LibraryName = "libSceVideoOut")]
public static int VideoOutSubmitChangeBufferAttribute2(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var setIndex = unchecked((int)ctx[CpuRegister.Rsi]);
var attributeAddress = ctx[CpuRegister.Rdx];
var option = ctx[CpuRegister.Rcx];
lock (_stateGate)
{
if (!_ports.TryGetValue(handle, out var port)) return OrbisVideoOutErrorInvalidHandle;
if (attributeAddress == 0) return OrbisVideoOutErrorInvalidOption;
if ((uint)setIndex >= MaxDisplayBufferGroups) return OrbisVideoOutErrorInvalidIndex;
if (option != 0) return OrbisVideoOutErrorInvalidOption;
if (port.Groups[setIndex] is null) return OrbisVideoOutErrorInvalidIndex;
if (!TryReadBufferAttribute(ctx, attributeAddress, true, out var attribute))
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
if (attribute.Width is 0 or > 16384 || attribute.Height is 0 or > 16384 ||
attribute.TilingMode != 0 || (attribute.Option & ~((1UL << 3) | (1UL << 5))) != 0 ||
!DisplayFormatRule.TryDecode(attribute.PixelFormat, out _))
return OrbisVideoOutErrorInvalidValue;
// Keep buffer addresses and membership when the guest switches SDR/HDR formats.
port.Groups[setIndex] = new VideoOutBufferGroup { Index = setIndex, Attribute = attribute };
port.OutputWidth = attribute.Width;
port.OutputHeight = attribute.Height;
var guestFormat = MapPixelFormatToGuestTextureFormat(attribute.PixelFormat);
foreach (var slot in port.BufferSlots)
{
if (slot.GroupIndex != setIndex) continue;
GuestGpu.Current.RegisterKnownDisplayBuffer(slot.AddressLeft, guestFormat);
if (slot.AddressRight != 0)
GuestGpu.Current.RegisterKnownDisplayBuffer(slot.AddressRight, guestFormat);
}
TraceVideoOut($"videoout.change_buffer_attribute2 handle={handle} set={setIndex} " +
$"fmt=0x{attribute.PixelFormat:X16} tile={attribute.TilingMode} {attribute.Width}x{attribute.Height}");
return 0;
}
}
[SysAbiExport(
Nid = "w3BY+tAEiQY",
ExportName = "sceVideoOutRegisterBuffers",

View File

@ -549,7 +549,7 @@ internal static unsafe partial class VulkanVideoPresenter
try
{
var surface = new DisplaySurfaceWords(
displayBuffer.Address, 0, displayBuffer.PixelFormat, displayBuffer.Width, displayBuffer.Height, displayBuffer.TilingMode, 0, 0, 0, false);
displayBuffer.Address, 0, displayBuffer.PixelFormat, displayBuffer.Width, displayBuffer.Height, displayBuffer.TilingMode, displayBuffer.Option, 0, 0, false);
var request = ImageRequestBuilders.DisplaySurface(surface);
_ = BeginBatchedGuestCommands();
var imageIdentifier = _imageCache.FindImage(ref request);

View File

@ -80,6 +80,8 @@ internal static unsafe partial class VulkanVideoPresenter
}
var hdrState = _window.HdrState;
_ = ChooseSurfaceFormat(formats, requestHdr: true, out var surfaceSupportsHdr);
var hdrOutputSupported = _videoOptions.CanUseHdr(hdrState.Enabled, surfaceSupportsHdr);
var guestHdrRequested = VideoOutExports.IsHdrOutputRequested;
var requestHdr = _videoOptions.HdrMode switch
{
@ -140,7 +142,8 @@ internal static unsafe partial class VulkanVideoPresenter
_imageInitialized = new bool[swapchainImageCount];
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan output color: requested={_videoOptions.HdrMode} " +
$"display_hdr={hdrState.Enabled} guest_hdr={guestHdrRequested} active={_hdrOutputActive} " +
$"display_hdr={hdrState.Enabled} guest_hdr_supported={hdrOutputSupported} " +
$"guest_hdr={guestHdrRequested} active={_hdrOutputActive} " +
$"format={_swapchainFormat} colorspace={_swapchainColorSpace} " +
$"sdr_white={_hdrSdrWhiteLevel:F3} headroom={_hdrHeadroom:F3}");
if (requestHdr && !_hdrOutputActive)
@ -148,6 +151,13 @@ internal static unsafe partial class VulkanVideoPresenter
Console.Error.WriteLine(
"[LOADER][WARN] HDR output requested but the Vulkan surface exposes no scRGB format; using SDR.");
}
lock (_gate)
{
_hdrOutputSupported = hdrOutputSupported;
_hdrCapabilitiesReady = true;
System.Threading.Monitor.PulseAll(_gate);
}
}
private void CreateGuestDrawResources()

View File

@ -37,6 +37,8 @@ internal static unsafe partial class VulkanVideoPresenter
private static uint _windowHeight;
private static bool _closed;
private static bool _presenterCloseRequested;
private static bool _hdrCapabilitiesReady;
private static bool _hdrOutputSupported;
private static bool _splashHidden;
@ -121,10 +123,30 @@ internal static unsafe partial class VulkanVideoPresenter
}
_videoOptions = options.Normalize();
_hdrCapabilitiesReady = false;
_hdrOutputSupported = false;
return true;
}
}
internal static bool QueryHdrOutputSupport()
{
// The guest can query HDR before registering its first display buffer.
EnsureStarted((uint)_videoOptions.Width, (uint)_videoOptions.Height);
lock (_gate)
{
while (!_hdrCapabilitiesReady && !_closed && _presenterStartupFailure is null &&
!HostSessionControl.IsShutdownRequested && !Volatile.Read(ref _presenterCloseRequested))
{
System.Threading.Monitor.Wait(_gate);
}
return _hdrCapabilitiesReady && !_closed && _presenterStartupFailure is null &&
!HostSessionControl.IsShutdownRequested && !Volatile.Read(ref _presenterCloseRequested) &&
_hdrOutputSupported;
}
}
private static void ResetHostSessionStateLocked()
{
_latestPresentation = null;

View File

@ -112,7 +112,7 @@ public sealed class ImageDescriptionTests
}
[Fact]
public void DisplayFormats_DecodeTheSixPixelFormats()
public void DisplayFormats_DecodeSupportedPixelFormats()
{
Assert.True(DisplayFormatRule.TryDecode(0x8000000022000000, out var rgba));
Assert.Equal((Format.R8G8B8A8Srgb, GuestPixelFormat.Bits8_8_8_8Srgb, 4u, false), (rgba.HostFormat, rgba.GuestFormat, rgba.BytesPerElement, rgba.Bgra16));

View File

@ -361,6 +361,22 @@ public sealed class ImageRequestBuildersTests : IClassFixture<HeadlessVulkanFixt
Assert.Equal(ImageUsageFlags.TransferSrcBit, request.View.Usage);
}
[Theory]
[InlineData(0x8100070422000000UL, Format.A2B10G10R10UnormPack32, 8UL)]
[InlineData(0x8100070400000000UL, Format.A2R10G10B10UnormPack32, 40UL)]
[InlineData(0x8100000622000000UL, Format.A2B10G10R10UnormPack32, 32UL)]
[InlineData(0x8100000600000000UL, Format.A2R10G10B10UnormPack32, 0UL)]
public void DisplaySurface_Packed10BitFormatsPreserveEncodedValues(ulong pixelFormat, Format expectedFormat, ulong option)
{
var surface = new DisplaySurfaceWords(Base, 0, pixelFormat, 3840, 2160, 0, option, 0, 0, false);
var request = ImageRequestBuilders.DisplaySurface(surface);
Assert.Equal(expectedFormat, request.Description.PixelFormat);
Assert.Equal(GuestPixelFormat.Bits10_10_10_2UNorm, request.Description.GuestFormat);
Assert.Equal(4u, request.Description.BytesPerBlock);
Assert.Equal(expectedFormat, request.View.Format);
Assert.True(DisplayFormatRule.Supports(request.Description));
}
[Fact]
public void DisplaySurface_RejectsUnsupportedAttributes()
{
@ -368,7 +384,8 @@ public sealed class ImageRequestBuildersTests : IClassFixture<HeadlessVulkanFixt
Assert.Throws<SchedulerFatalException>(() => ImageRequestBuilders.DisplaySurface(new DisplaySurfaceWords(Base, 0, 0x8000000022000000, 1920, 1080, 1, 0, 0, 0, false)));
Assert.Throws<SchedulerFatalException>(() => ImageRequestBuilders.DisplaySurface(new DisplaySurfaceWords(Base, 0, 0x1234, 1920, 1080, 0, 0, 0, 0, false)));
Assert.Throws<SchedulerFatalException>(() => ImageRequestBuilders.DisplaySurface(new DisplaySurfaceWords(Base, 0, 0x8000000022000000, 0, 1080, 0, 0, 0, 0, false)));
Assert.Equal(3, fatal.Messages.Count);
Assert.Throws<SchedulerFatalException>(() => ImageRequestBuilders.DisplaySurface(new DisplaySurfaceWords(Base, 0, 0x8100070422000000, 1920, 1080, 0, 1, 0, 0, false)));
Assert.Equal(4, fatal.Messages.Count);
}
private readonly record struct GuestSpanCheck(ulong Address, ulong Size);

View File

@ -40,6 +40,62 @@ public sealed class VideoOutFlipRequestTests : IDisposable
_ = VideoOutExports.VideoOutClose(_context);
}
[Fact]
public void ChangeBufferAttribute2UpdatesTheRegisteredGroupAndValidatesArguments()
{
var manager = new ModuleManager();
manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5));
Assert.True(manager.TryGetExport("HuViW4HnrOw", out var export));
Assert.Equal("sceVideoOutSubmitChangeBufferAttribute2", export.Name);
var ports = (System.Collections.IDictionary)typeof(VideoOutExports)
.GetField("_ports", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!.GetValue(null)!;
var port = ports[_handle]!;
// Seed registration without starting a native presenter in this state test.
var groups = (Array)port.GetType().GetProperty("Groups")!.GetValue(port)!;
groups.SetValue(Activator.CreateInstance(groups.GetType().GetElementType()!, nonPublic: true), 0);
var slots = (Array)port.GetType().GetProperty("BufferSlots")!.GetValue(port)!;
var slot = slots.GetValue(0)!;
slot.GetType().GetProperty("GroupIndex")!.SetValue(slot, 0);
slot.GetType().GetProperty("AddressLeft")!.SetValue(slot, MemoryBase);
const ulong pqFormat = 0x8100070422000000;
var attribute = new byte[0x50];
BinaryPrimitives.WriteUInt32LittleEndian(attribute.AsSpan(0x0C), 3840);
BinaryPrimitives.WriteUInt32LittleEndian(attribute.AsSpan(0x10), 2160);
BinaryPrimitives.WriteUInt64LittleEndian(attribute.AsSpan(0x18), 8);
BinaryPrimitives.WriteUInt64LittleEndian(attribute.AsSpan(0x20), pqFormat);
Assert.True(_memory.TryWrite(StatusAddress, attribute));
ulong Change(int setIndex = 0, ulong address = StatusAddress, ulong option = 0, int? handle = null)
{
_context[CpuRegister.Rdi] = unchecked((ulong)(handle ?? _handle));
_context[CpuRegister.Rsi] = unchecked((ulong)setIndex);
_context[CpuRegister.Rdx] = address;
_context[CpuRegister.Rcx] = option;
Assert.True(manager.TryDispatch("HuViW4HnrOw", _context, out _));
return _context[CpuRegister.Rax];
}
Assert.Equal(0UL, Change());
Assert.True(VideoOutExports.TryGetDisplayBufferInfo(_handle, 0, out var info));
Assert.Equal(new VideoOutExports.DisplayBufferInfo(MemoryBase, pqFormat, 0, 3840, 2160, 3840, 8), info);
Assert.Equal(3840u, port.GetType().GetProperty("OutputWidth")!.GetValue(port));
Assert.Equal(2160u, port.GetType().GetProperty("OutputHeight")!.GetValue(port));
Assert.Equal(unchecked((ulong)(int)0x8029000B), Change(handle: -1));
Assert.Equal(unchecked((ulong)(int)0x8029001A), Change(address: 0));
Assert.Equal(unchecked((ulong)(int)0x8029001A), Change(option: 1));
Assert.Equal(unchecked((ulong)(int)0x8029000A), Change(setIndex: -1));
Assert.Equal(unchecked((ulong)(int)0x8029000A), Change(setIndex: 4));
Assert.Equal(unchecked((ulong)(int)0x8029000A), Change(setIndex: 1));
Assert.Equal(unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT), Change(address: MemoryBase + 0x1000));
BinaryPrimitives.WriteUInt32LittleEndian(attribute.AsSpan(0x0C), 0);
Assert.True(_memory.TryWrite(StatusAddress, attribute));
Assert.Equal(unchecked((ulong)(int)0x80290001), Change());
Assert.True(VideoOutExports.TryGetDisplayBufferInfo(_handle, 0, out var afterFailure));
Assert.Equal(info, afterFailure);
}
[Fact]
public void CleanupPreservesAnotherPortsPendingFlip()
{

View File

@ -3,6 +3,7 @@
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut;
using Xunit;
namespace SharpEmu.Libs.Tests.VideoOut;
@ -23,6 +24,33 @@ public sealed class VideoOutOutputSupportTests
private static readonly ulong MemoryFault =
unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
[Theory]
[InlineData(HostHdrMode.Auto, false, true, false)]
[InlineData(HostHdrMode.Auto, true, false, false)]
[InlineData(HostHdrMode.Auto, true, true, true)]
[InlineData(HostHdrMode.On, false, true, true)]
[InlineData(HostHdrMode.On, true, true, true)]
[InlineData(HostHdrMode.On, true, false, false)]
[InlineData(HostHdrMode.Off, true, true, false)]
public void OutputStatusReportsHdrOnlyWhenAllowedAndSupported(
HostHdrMode mode, bool displayHdrEnabled, bool surfaceSupportsHdr, bool expectedHdr)
{
var supported = new HostVideoOptions { HdrMode = mode }.CanUseHdr(displayHdrEnabled, surfaceSupportsHdr);
Assert.Equal(expectedHdr, supported);
var bytes = Enumerable.Repeat((byte)0xA5, 0x32).ToArray();
VideoOutExports.WriteOutputStatus(bytes.AsSpan(1, 0x30), 3840, 2160, 120, supported);
Assert.Equal(0xA5, bytes[0]);
Assert.Equal(0xA5, bytes[^1]);
var status = bytes.AsSpan(1, 0x30);
Assert.Equal(2U, BinaryPrimitives.ReadUInt32LittleEndian(status));
Assert.Equal(expectedHdr ? 2U : 1U, BinaryPrimitives.ReadUInt32LittleEndian(status[4..]));
Assert.Equal(13UL, BinaryPrimitives.ReadUInt64LittleEndian(status[8..]));
Assert.Equal(expectedHdr ? 1UL : 0UL, BinaryPrimitives.ReadUInt64LittleEndian(status[0x10..]));
Assert.All(status[0x18..].ToArray(), value => Assert.Equal(0, value));
}
[Theory]
[InlineData(Generation.Gen4)]
[InlineData(Generation.Gen5)]