From 4e27d8d7537a030681ce728a876a6fc7c19f9219 Mon Sep 17 00:00:00 2001 From: Gjon Gojcevic Date: Wed, 14 Oct 2026 08:55:18 -0400 Subject: [PATCH] fix(videoout): keep the output SDR on hosts without a monitor HDR report A Vulkan surface can offer HDR10 formats while the display is not in HDR (KDE Wayland with RADV lists them with the output's HDR disabled), so on hosts other than Windows the probe now reports an SDR monitor and HDR output stays off until an output-state signal exists. A probe test checks that a window without a Windows monitor report reads as SDR with an HDR10 surface. The HDR10 present test now also changes the monitor's luminance while presenting HDR10, and the presenter logs the HDR metadata it sets. AI-assisted: yes --- .../Execution/src/VulkanDevice.cpp | 1 + core/libs/prx/libSceVideoOut/CMakeLists.txt | 12 ++++++ .../prx/libSceVideoOut/src/HostHdrProbe.cpp | 2 +- .../prx/libSceVideoOut/tests/HdrPresent.cpp | 3 ++ .../prx/libSceVideoOut/tests/HdrProbe.cpp | 37 +++++++++++++++++++ docs/dev/debt/libSceVideoOut.md | 2 +- docs/user/USAGE.md | 2 +- 7 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 core/libs/prx/libSceVideoOut/tests/HdrProbe.cpp diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/VulkanDevice.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/VulkanDevice.cpp index 683deab8df..bdd2d2233d 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/VulkanDevice.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/VulkanDevice.cpp @@ -2474,6 +2474,7 @@ void VulkanDevice::setHdrMetadata() { metadata.maxContentLightLevel = PqToSdrWhiteNits(host); metadata.maxFrameAverageLightLevel = std::min(host.display.maxFullFrameLuminance, metadata.maxContentLightLevel); state->DeviceFunction("vkSetHdrMetadataEXT")(state->device, 1, &state->swapchain, &metadata); + std::fprintf(stderr, "[videoout] HDR metadata: mastering %.1f to %.4f nits, MaxCLL %.1f nits, MaxFALL %.1f nits\n", metadata.maxLuminance, metadata.minLuminance, metadata.maxContentLightLevel, metadata.maxFrameAverageLightLevel); } void VulkanDevice::useSurfaceFormat(VkSurfaceFormatKHR format) { diff --git a/core/libs/prx/libSceVideoOut/CMakeLists.txt b/core/libs/prx/libSceVideoOut/CMakeLists.txt index 8f0520ea79..5044b8d409 100644 --- a/core/libs/prx/libSceVideoOut/CMakeLists.txt +++ b/core/libs/prx/libSceVideoOut/CMakeLists.txt @@ -180,6 +180,18 @@ if(BUILD_TESTING) "PATH=path_list_prepend:$") endif() + add_test_executable(video_out_hdr_probe_tests tests/HdrProbe.cpp) + target_include_directories(video_out_hdr_probe_tests PRIVATE ${LIBS_INCLUDE_DIR} ${ANYPS5_SDL2_INCLUDE_DIR}) + target_link_libraries(video_out_hdr_probe_tests PRIVATE libSceVideoOut libSceAgcDriver libc SDL2-static) + set_target_properties(video_out_hdr_probe_tests PROPERTIES CXX_EXTENSIONS OFF) + configure_windows_unwind(video_out_hdr_probe_tests) + add_test(NAME video_out_hdr_probe COMMAND video_out_hdr_probe_tests) + set_tests_properties(video_out_hdr_probe PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77 ENVIRONMENT "SDL_VIDEODRIVER=${ANYPS5_VIDEO_OUT_TEST_DRIVER}") + if(WIN32) + set_tests_properties(video_out_hdr_probe PROPERTIES ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() + add_test_executable(video_out_hdr_present_tests tests/HdrPresent.cpp) target_include_directories(video_out_hdr_present_tests PRIVATE ${LIBS_INCLUDE_DIR} ${ANYPS5_SDL2_INCLUDE_DIR} ${ANYPS5_VULKAN_HEADERS_INCLUDE_DIR}) target_link_libraries(video_out_hdr_present_tests PRIVATE libSceVideoOut libSceAgcDriver libkernel libc) diff --git a/core/libs/prx/libSceVideoOut/src/HostHdrProbe.cpp b/core/libs/prx/libSceVideoOut/src/HostHdrProbe.cpp index 1aba8401d0..b1b562c43b 100644 --- a/core/libs/prx/libSceVideoOut/src/HostHdrProbe.cpp +++ b/core/libs/prx/libSceVideoOut/src/HostHdrProbe.cpp @@ -82,7 +82,7 @@ void HostHdrProbe::Poll(SDL_Window* window) { return; #else if (published) return; - AgcDriverSetHostDisplayHdr_nid_postfix(AgcDriver::UndescribedHdrDisplay); + AgcDriverSetHostDisplayHdr_nid_postfix({}); #endif published = true; } diff --git a/core/libs/prx/libSceVideoOut/tests/HdrPresent.cpp b/core/libs/prx/libSceVideoOut/tests/HdrPresent.cpp index 75bbb28ea4..4acc208218 100644 --- a/core/libs/prx/libSceVideoOut/tests/HdrPresent.cpp +++ b/core/libs/prx/libSceVideoOut/tests/HdrPresent.cpp @@ -147,6 +147,9 @@ int run() { check(!AgcDriverPresentingHdr10_nid_postfix(), "the SDR buffer did not bring the SDR swapchain back"); present(0); check(AgcDriverPresentingHdr10_nid_postfix(), "the HDR10 swapchain did not come back for a PQ buffer"); + AgcDriverSetHostDisplayHdr_nid_postfix(AgcDriver::DisplayHdrFromOutput(true, 600.0f, 0.05f, 350.0f)); + present(0); + check(AgcDriverPresentingHdr10_nid_postfix() && AgcDriver::CurrentHostHdr().display.maxLuminance == 600.0f, "a new monitor luminance left the HDR10 swapchain"); std::printf("HDR10 presentation passed (peak %.1f nits)\n", host.display.maxLuminance); } else { std::printf("HDR output is unavailable on this host (display in HDR: %s, surface offers HDR10: %s): HDR10 presentation not exercised\n", host.display.hdr ? "yes" : "no", host.surfaceHdr10 ? "yes" : "no"); diff --git a/core/libs/prx/libSceVideoOut/tests/HdrProbe.cpp b/core/libs/prx/libSceVideoOut/tests/HdrProbe.cpp new file mode 100644 index 0000000000..20751608dc --- /dev/null +++ b/core/libs/prx/libSceVideoOut/tests/HdrProbe.cpp @@ -0,0 +1,37 @@ +#include "prx/libSceVideoOut/include/HostHdrProbe.hpp" +#include "prx/libSceAgcDriver/Execution/include/HostHdr.hpp" +#include +#include +#include + +#undef main + +int main() { + if (SDL_InitSubSystem(SDL_INIT_VIDEO) != 0) { + std::printf("skipped, no SDL video driver: %s\n", SDL_GetError()); + return 77; + } + SDL_Window* window = SDL_CreateWindow("HDR probe test", 0, 0, 64, 64, SDL_WINDOW_HIDDEN); + if (window == nullptr) { + std::printf("skipped, no SDL window: %s\n", SDL_GetError()); + SDL_QuitSubSystem(SDL_INIT_VIDEO); + return 77; + } + const std::string driver = SDL_GetCurrentVideoDriver() != nullptr ? SDL_GetCurrentVideoDriver() : "unknown"; + AgcDriverSetSurfaceHdr10_nid_postfix(true); + int result = 0; + { + HostHdrProbe probe; + probe.Poll(window); + probe.Poll(window); + const auto host = AgcDriver::CurrentHostHdr(); + if (host.display.hdr || host.available) { + std::fprintf(stderr, "FAIL: a window without a Windows HDR monitor report reads as HDR although only the surface offers HDR10 (driver %s)\n", driver.c_str()); + result = 1; + } + } + SDL_DestroyWindow(window); + SDL_QuitSubSystem(SDL_INIT_VIDEO); + if (result == 0) std::printf("HDR probe tests passed (driver %s)\n", driver.c_str()); + return result; +} diff --git a/docs/dev/debt/libSceVideoOut.md b/docs/dev/debt/libSceVideoOut.md index a4f5c2248d..09bd3e9cf0 100644 --- a/docs/dev/debt/libSceVideoOut.md +++ b/docs/dev/debt/libSceVideoOut.md @@ -24,5 +24,5 @@ - [sceVideoOutOpen](../../../core/libs/prx/libSceVideoOut/src/Output.cpp) (libSceVideoOut) - opening a bus that is already open returns `SCE_VIDEO_OUT_ERROR_RESOURCE_BUSY` (`0x80290009`), as [shadPS4's](https://github.com/shadps4-emu/shadPS4/blob/e0a466753a1aacbc8db8253be685bf7ab4d9daae/src/core/libraries/videoout/driver.cpp#L53-L60) PS4 driver does for its main port; the PS5 result is unverified. PPSA99764 opens the main bus twice and carries on, presenting through the first handle - [Overlay output bus](../../../core/libs/prx/libSceVideoOut/src/VideoOutDriver.cpp) (libSceVideoOut) - flips on a port opened with `SCE_VIDEO_OUT_BUS_TYPE_OVERLAY` complete and signal their events like main-bus flips, but their buffers are not shown: the overlay is not composited over the main output. Presenting them as whole frames made the window alternate between the two buses - [Mouse capture and delta scaling](../../../core/libs/prx/libSceVideoOut/src/MouseInput.cpp) (libSceVideoOut) - the console's mouse reports relative motion only, so a host cursor can never coincide with the title's cursor; while a title has a mouse open and the window is focused the host cursor is hidden and captured (SDL relative mode, released on focus loss or while the pad's mouse-to-stick toggle is on). Motion deltas are scaled from the presented image's size in window units to the title's display resolution with sub-pixel carry; the console's deltas are counts, so a title that applies its own sensitivity moves its cursor that much faster than the host pointer would have. The scale takes SDL's relative motion in logical window units, the units of `SDL_GetWindowSize`; under Windows DPI scaling SDL may report it in physical pixels, which is unverified (no Windows run yet), and a drawable larger than the window does not change the scale. -- [HDR output](../../../core/libs/prx/libSceVideoOut/src/HostHdrProbe.cpp) (libSceVideoOut, libSceSystemService) - `sceVideoOutGetOutputStatus` reports dynamic range 2 and flag bit 0 (named HDR in [SharpProspero](https://github.com/SvenGDK/SharpProspero/blob/5dfdb4b0b166e83dc9ae275001afb695f12c7174/src/SharpProspero/Interop/VideoOut/VideoOutTypes.cs)) while the window's Vulkan surface offers a 10-bit HDR10 swapchain with `VK_EXT_swapchain_colorspace` and `VK_EXT_hdr_metadata`, and on Windows DXGI (`IDXGIOutput6::GetDesc1`) reports the window's monitor in `DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020`; `sceSystemServiceGetHdrToneMapLuminance` then reports the monitor's peak, full-frame peak and black level. Linux and other hosts read no luminance (SDL2 has no HDR display query and Vulkan none for a surface), and a Windows monitor that reports no usable peak gets the same default: 1000 nits peak and full frame, 0 nits black. The surface formats are read again when DXGI's monitor report changes and when the swapchain is recreated; a compositor that changes them without making the swapchain out of date is not seen until then. Every open port's output mode event fires when HDR output becomes available or unavailable and when its luminance changes; the console's events for an HDR change are unknown. Detection starts when VideoOut is first used: `sceSystemServiceGetHdrToneMapLuminance` called before that reports SDR, and no event fires for the availability found at start-up beyond the one every output-mode subscription gets when it is made. On hosts other than Windows a surface offering HDR10 is taken as an HDR display, which may not hold for every compositor or for MoltenVK. Console HDR settings (calibrated peak, the SDR-in-HDR conversion block) are not modelled +- [HDR output](../../../core/libs/prx/libSceVideoOut/src/HostHdrProbe.cpp) (libSceVideoOut, libSceSystemService) - `sceVideoOutGetOutputStatus` reports dynamic range 2 and flag bit 0 (named HDR in [SharpProspero](https://github.com/SvenGDK/SharpProspero/blob/5dfdb4b0b166e83dc9ae275001afb695f12c7174/src/SharpProspero/Interop/VideoOut/VideoOutTypes.cs)) only on Windows, while the window's Vulkan surface offers a 10-bit HDR10 swapchain with `VK_EXT_swapchain_colorspace` and `VK_EXT_hdr_metadata` and DXGI (`IDXGIOutput6::GetDesc1`) reports the window's monitor in `DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020`; `sceSystemServiceGetHdrToneMapLuminance` then reports the monitor's peak, full-frame peak and black level. A monitor that reports no usable peak gets a default of 1000 nits peak and full frame, 0 nits black. On Linux and other hosts the output stays SDR: a surface can offer HDR10 formats while the display is not in HDR (KDE Wayland with RADV lists them with the output's HDR disabled), and SDL2 and Vulkan expose no output HDR state; a compositor or output-state signal is needed first. The surface formats are read again when DXGI's monitor report changes and when the swapchain is recreated; a compositor that changes them without making the swapchain out of date is not seen until then. Every open port's output mode event fires when HDR output becomes available or unavailable and when its luminance changes; the console's events for an HDR change are unknown. Detection starts when VideoOut is first used: `sceSystemServiceGetHdrToneMapLuminance` called before that reports SDR, and no event fires for the availability found at start-up beyond the one every output-mode subscription gets when it is made. Console HDR settings (calibrated peak, the SDR-in-HDR conversion block) are not modelled - [Windows game controllers](../../../core/libs/prx/libSceVideoOut/src/VideoOutDriver.cpp) (libSceVideoOut) - SDL's DirectInput joystick backend is disabled at default hint priority before `SDL_InitSubSystem`, because its device enumeration blocks forever on some vendor HID devices (#1042). XInput and HIDAPI controllers are detected; controllers reachable only through DirectInput are not. Setting `SDL_DIRECTINPUT_ENABLED=1` restores them on hosts without such a device diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index d51247d51d..2ff78cafdb 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -156,7 +156,7 @@ Do not disable Dozen if it is the only Vulkan driver available; the application ### HDR output -Titles see HDR output when the window's Vulkan surface offers an HDR10 (`VK_COLOR_SPACE_HDR10_ST2084_EXT`) swapchain in a 10-bit format, the instance has `VK_EXT_swapchain_colorspace` and the device `VK_EXT_hdr_metadata`. On Windows the monitor showing the window must also run in HDR mode (Settings > Display > Use HDR); its luminance comes from DXGI. Other hosts and a monitor that reports no peak luminance are taken as 1000 nits peak and full-frame, 0 nits black. A title's BT.2100 PQ display buffers then reach the display unchanged; without HDR output they are converted to SDR. +HDR output is available on Windows only. Titles see it when the monitor showing the window runs in HDR mode (Settings > Display > Use HDR), the window's Vulkan surface offers an HDR10 (`VK_COLOR_SPACE_HDR10_ST2084_EXT`) swapchain in a 10-bit format, the instance has `VK_EXT_swapchain_colorspace` and the device `VK_EXT_hdr_metadata`. The monitor's luminance comes from DXGI; a monitor that reports no peak luminance is taken as 1000 nits peak and full-frame, 0 nits black. On Linux and other hosts the output is always SDR, because the surface's formats do not show whether the display is in HDR. A title's BT.2100 PQ display buffers then reach the display unchanged; without HDR output they are converted to SDR. ### Storing GPU results at each flip