The client stops promising an HDR its video processor can't tone-map #427
@@ -369,6 +369,22 @@ mod session_main {
|
||||
to (PyroWave carries 4:4:4 on any GPU, if the link can take it)."
|
||||
);
|
||||
}
|
||||
// …and the HDR promise, same discipline: `VIDEO_CAP_HDR` invites a PQ stream, and
|
||||
// a Windows box with no HDR10 swapchain whose video processor cannot tone-map
|
||||
// PQ→sRGB shows that stream as garbage — the D3D11VA Blt accepts the colorspaces
|
||||
// and renders green where the conversion is missing (Arc A370M field report,
|
||||
// 2026-08-26). `ten_bit_sdr` is deliberately NOT gated on this: a 10-bit SDR
|
||||
// stream is no tonemap, and every hardware rung decodes P010.
|
||||
let hdr_enabled =
|
||||
settings.hdr_enabled && pf_client_core::video::hdr_presentable(vulkan.as_ref());
|
||||
if settings.hdr_enabled && !hdr_enabled {
|
||||
tracing::warn!(
|
||||
"HDR requested but this device cannot present a PQ stream (no HDR10 \
|
||||
swapchain, and the video processor reports no PQ→sRGB conversion) — \
|
||||
asking for SDR instead. Advertising it would paint the stream green: \
|
||||
the driver accepts the tonemap it cannot do and renders garbage."
|
||||
);
|
||||
}
|
||||
SessionParams {
|
||||
host: addr,
|
||||
port,
|
||||
@@ -415,7 +431,7 @@ mod session_main {
|
||||
// resolved chroma ("4:4:4→4:2:0" when the host declined) and the decode path
|
||||
// frames actually took.
|
||||
video_caps: pf_client_core::video::video_caps_for(
|
||||
settings.hdr_enabled,
|
||||
hdr_enabled,
|
||||
settings.ten_bit_sdr,
|
||||
want_444,
|
||||
),
|
||||
@@ -427,8 +443,7 @@ mod session_main {
|
||||
// defaults; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session pump) pins one
|
||||
// manually on either OS and wins over both.
|
||||
#[cfg(windows)]
|
||||
display_hdr: settings
|
||||
.hdr_enabled
|
||||
display_hdr: hdr_enabled
|
||||
.then(|| pf_client_core::video_d3d11::display_hdr_volume(window_pos()))
|
||||
.flatten(),
|
||||
#[cfg(not(windows))]
|
||||
|
||||
@@ -1567,14 +1567,16 @@ pub fn av1_hardware_decodable(vk: Option<&VulkanDecodeDevice>) -> bool {
|
||||
/// and a device offering `YUV444_8` but not `YUV444_10` would land in exactly the hole this
|
||||
/// closes. Asking for both costs one extra capability query and removes the case entirely.
|
||||
///
|
||||
/// ⚠ Deliberately NOT extended to `VIDEO_CAP_10BIT`/`VIDEO_CAP_HDR`, which are advertised
|
||||
/// unprobed for the same reason this one was. The asymmetry is real: all three hardware
|
||||
/// rungs implement 10-bit 4:2:0 (`profile_for` maps `(H265, 1, 10)` and `(Av1, 1, 10)`;
|
||||
/// pf-dxvadec carries P010), so a Vulkan-only probe there would answer `false` on boxes
|
||||
/// whose VAAPI/DXVA rung decodes 10-bit perfectly and would silently withdraw HDR from
|
||||
/// them — a visible regression bought against a case that has never been observed. Gating
|
||||
/// 10-bit honestly needs a libva/D3D11 probe, which this path cannot afford (same reason
|
||||
/// [`av1_hardware_decodable`] does not consult VAAPI).
|
||||
/// ⚠ Deliberately NOT extended to `VIDEO_CAP_10BIT`, which is advertised unprobed for the
|
||||
/// same reason this one was. The asymmetry is real: all three hardware rungs implement
|
||||
/// 10-bit 4:2:0 (`profile_for` maps `(H265, 1, 10)` and `(Av1, 1, 10)`; pf-dxvadec carries
|
||||
/// P010), so a Vulkan-only probe there would answer `false` on boxes whose VAAPI/DXVA rung
|
||||
/// decodes 10-bit perfectly and would silently withdraw depth from them — a visible
|
||||
/// regression bought against a case that has never been observed. Gating decode-10-bit
|
||||
/// honestly needs a libva/D3D11 probe, which this path cannot afford (same reason
|
||||
/// [`av1_hardware_decodable`] does not consult VAAPI). `VIDEO_CAP_HDR` IS gated since the
|
||||
/// 2026-08-26 Arc field report — but on a different question, PRESENTATION, where the
|
||||
/// decode reasoning above does not apply: see [`hdr_presentable`].
|
||||
pub fn hevc_444_hardware_decodable(vk: Option<&VulkanDecodeDevice>) -> bool {
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
{
|
||||
@@ -1596,13 +1598,51 @@ pub fn hevc_444_hardware_decodable(vk: Option<&VulkanDecodeDevice>) -> bool {
|
||||
/// above and any future caller cannot disagree about the magic number.
|
||||
const CHROMA_444: u8 = 3;
|
||||
|
||||
/// Can this client PRESENT a PQ (HDR) stream correctly — the promise `VIDEO_CAP_HDR`
|
||||
/// makes, asked the way [`hevc_444_hardware_decodable`] asks its question: by the caller,
|
||||
/// once, while the device bundle is still borrowable.
|
||||
///
|
||||
/// Decode is not the question — every hardware rung carries 10-bit 4:2:0 (see the note on
|
||||
/// [`hevc_444_hardware_decodable`]) — presentation is. On Windows the D3D11VA hand-off
|
||||
/// shows a PQ stream either as HDR10 pass-through ([`VulkanDecodeDevice::d3d11_hdr10`])
|
||||
/// or through the video processor's PQ→sRGB tonemap, and that tonemap is a driver
|
||||
/// capability nothing validated: the Blt succeeds and renders garbage where it is missing
|
||||
/// (field report 2026-08-26 — Arc A370M client, every HDR session green with a
|
||||
/// decode-recovery storm, AV1 8-bit SDR at the same 2880x1620@120 clean). A device that
|
||||
/// can do neither must not invite a PQ stream it can only show as garbage — and the
|
||||
/// promise has to hold for D3D11VA specifically, not just the rung `auto` picks first,
|
||||
/// because D3D11VA is in every Windows ladder (first on Intel/unknown, the demotion
|
||||
/// target on NVIDIA/AMD).
|
||||
///
|
||||
/// Everywhere else the answer is `true`: every Vulkan-presenter lane (native Vulkan,
|
||||
/// VAAPI, software) tonemaps PQ in our own CSC shader, no driver opinion involved. The
|
||||
/// same holds on Windows when the D3D11 import path is absent — the D3D11VA rung is then
|
||||
/// skipped entirely (`supports_d3d11() == false`) and PQ presents through the shader.
|
||||
pub fn hdr_presentable(vk: Option<&VulkanDecodeDevice>) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
vk.is_none_or(|v| {
|
||||
!v.d3d11_import
|
||||
|| v.d3d11_hdr10
|
||||
|| crate::video_d3d11::pq_tonemap_supported(v.adapter_luid)
|
||||
})
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = vk;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The desktop session's `video_caps` bitfield, as a pure function of the two user
|
||||
/// switches that move it — so the rule can be tested without a GPU, a host or a Hello.
|
||||
///
|
||||
/// `want_444` is the "Full chroma" setting **already ANDed with this device's ability to
|
||||
/// decode it** ([`hevc_444_hardware_decodable`]). Split that way on purpose: the caller
|
||||
/// owns the expensive driver question and can log its own refusal with the user's setting
|
||||
/// in hand, while the bit arithmetic — the part that was wrong — stays testable.
|
||||
/// decode it** ([`hevc_444_hardware_decodable`]), and `hdr_enabled` the HDR setting
|
||||
/// **already ANDed with this device's ability to present PQ** ([`hdr_presentable`]).
|
||||
/// Split that way on purpose: the caller owns the expensive driver questions and can log
|
||||
/// its own refusal with the user's setting in hand, while the bit arithmetic — the part
|
||||
/// that was wrong — stays testable.
|
||||
///
|
||||
/// `MULTI_SLICE` is unconditional and is decoder truth for THIS embedder: every desktop
|
||||
/// decode stack (Vulkan Video, D3D11VA, VAAPI, openh264/rav1d) handles AUs carrying
|
||||
|
||||
@@ -54,14 +54,14 @@ use windows::core::Interface;
|
||||
use windows::Win32::d3d11::{
|
||||
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Multithread, ID3D11Texture2D,
|
||||
ID3D11VideoContext1, ID3D11VideoDevice, ID3D11VideoProcessor, ID3D11VideoProcessorEnumerator,
|
||||
ID3D11VideoProcessorOutputView, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_CREATE_DEVICE_VIDEO_SUPPORT,
|
||||
D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX, D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_SDK_VERSION,
|
||||
D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE,
|
||||
D3D11_VIDEO_PROCESSOR_CONTENT_DESC, D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC,
|
||||
D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_STREAM,
|
||||
D3D11_VIDEO_USAGE_PLAYBACK_NORMAL, D3D11_VPIV_DIMENSION_TEXTURE2D,
|
||||
D3D11_VPOV_DIMENSION_TEXTURE2D,
|
||||
ID3D11VideoProcessorEnumerator1, ID3D11VideoProcessorOutputView, D3D11_BIND_RENDER_TARGET,
|
||||
D3D11_BIND_SHADER_RESOURCE, D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
D3D11_CREATE_DEVICE_VIDEO_SUPPORT, D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX,
|
||||
D3D11_RESOURCE_MISC_SHARED_NTHANDLE, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
|
||||
D3D11_USAGE_DEFAULT, D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE, D3D11_VIDEO_PROCESSOR_CONTENT_DESC,
|
||||
D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC, D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC,
|
||||
D3D11_VIDEO_PROCESSOR_STREAM, D3D11_VIDEO_USAGE_PLAYBACK_NORMAL,
|
||||
D3D11_VPIV_DIMENSION_TEXTURE2D, D3D11_VPOV_DIMENSION_TEXTURE2D,
|
||||
};
|
||||
use windows::Win32::d3dcommon::{D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_11_1};
|
||||
use windows::Win32::dxgi::{
|
||||
@@ -71,8 +71,8 @@ use windows::Win32::dxgi::{
|
||||
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_RATIONAL, DXGI_SAMPLE_DESC,
|
||||
DXGI_SHARED_RESOURCE_READ, DXGI_SHARED_RESOURCE_WRITE,
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_P010, DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_RATIONAL,
|
||||
DXGI_SAMPLE_DESC, DXGI_SHARED_RESOURCE_READ, DXGI_SHARED_RESOURCE_WRITE,
|
||||
};
|
||||
use windows::Win32::windef::RECT;
|
||||
use windows::Win32::winnt::HANDLE;
|
||||
@@ -205,6 +205,86 @@ pub(crate) fn create_device(luid: Option<[u8; 8]>) -> Result<(ID3D11Device, ID3D
|
||||
Ok((device, context))
|
||||
}
|
||||
|
||||
/// Can this adapter's video processor actually CONVERT a PQ decode surface to sRGB — the
|
||||
/// tonemap [`HandoffRing::present`] relies on for a PQ stream whenever the presenter has no
|
||||
/// HDR10 swapchain to pass it through ([`crate::video::VulkanDecodeDevice::d3d11_hdr10`]
|
||||
/// false)?
|
||||
///
|
||||
/// Asked because setting the colorspaces is not a negotiation: `VideoProcessorSetStream/
|
||||
/// OutputColorSpace1` accept anything, and `VideoProcessorBlt` succeeds either way — a
|
||||
/// driver that cannot do the conversion renders garbage instead of failing. The host
|
||||
/// records the sibling failure on NVIDIA in `pf-capture`'s `VideoConverter` docs (RGB→P010
|
||||
/// "renders green"); field report 2026-08-26 is this direction on the client: an Arc A370M
|
||||
/// went green on every HDR session while AV1 8-bit SDR at the same 2880x1620@120 streamed
|
||||
/// clean.
|
||||
///
|
||||
/// One throwaway device + enumerator on the presenter's adapter, asked the exact pair the
|
||||
/// SDR ring sets: P010 `YCBCR_STUDIO_G2084_LEFT_P2020` in, BGRA8 `RGB_FULL_G22_NONE_P709`
|
||||
/// out. Only the driver's definitive "no" answers `false`; an API failure answers `true`
|
||||
/// (today's behaviour) — a box whose D3D11 is broken enough to fail the probe fails
|
||||
/// D3D11VA construction too, and PQ then presents through a rung whose tonemap is our own
|
||||
/// shader. Cost is a few ms, paid once per connect and only on the path that needs the
|
||||
/// answer (`crate::video::hdr_presentable` short-circuits it away everywhere else).
|
||||
pub(crate) fn pq_tonemap_supported(luid: Option<[u8; 8]>) -> bool {
|
||||
fn probe(luid: Option<[u8; 8]>) -> Result<bool> {
|
||||
let (device, _context) = create_device(luid)?;
|
||||
let video_device: ID3D11VideoDevice = device
|
||||
.cast()
|
||||
.context("device lacks ID3D11VideoDevice (created without VIDEO_SUPPORT)")?;
|
||||
// The enumerator wants a content shape; conversion support is a format/colorspace
|
||||
// fact, so any plausible size asks the same question.
|
||||
let rate = DXGI_RATIONAL {
|
||||
Numerator: 60,
|
||||
Denominator: 1,
|
||||
};
|
||||
let desc = D3D11_VIDEO_PROCESSOR_CONTENT_DESC {
|
||||
InputFrameFormat: D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE,
|
||||
InputFrameRate: rate,
|
||||
InputWidth: 1920,
|
||||
InputHeight: 1080,
|
||||
OutputFrameRate: rate,
|
||||
OutputWidth: 1920,
|
||||
OutputHeight: 1080,
|
||||
Usage: D3D11_VIDEO_USAGE_PLAYBACK_NORMAL,
|
||||
};
|
||||
// SAFETY: COM calls on the live device/enumerator just created, over a borrowed
|
||||
// fully-initialized stack descriptor; the conversion query fills a BOOL by value.
|
||||
unsafe {
|
||||
let enumerator = video_device
|
||||
.CreateVideoProcessorEnumerator(&desc)
|
||||
.context("CreateVideoProcessorEnumerator")?;
|
||||
let enumerator1: ID3D11VideoProcessorEnumerator1 = enumerator
|
||||
.cast()
|
||||
.context("enumerator lacks ID3D11VideoProcessorEnumerator1 (pre-Win10?)")?;
|
||||
let ok = enumerator1
|
||||
.CheckVideoProcessorFormatConversion(
|
||||
DXGI_FORMAT_P010,
|
||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
|
||||
DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709,
|
||||
)
|
||||
.context("CheckVideoProcessorFormatConversion")?;
|
||||
Ok(ok.as_bool())
|
||||
}
|
||||
}
|
||||
match probe(luid) {
|
||||
Ok(supported) => {
|
||||
if !supported {
|
||||
tracing::warn!(
|
||||
"video processor reports NO P010 PQ→sRGB conversion — a PQ stream on the \
|
||||
D3D11VA rung would render garbage (green) instead of tone-mapping"
|
||||
);
|
||||
}
|
||||
supported
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %format!("{e:#}"),
|
||||
"PQ tonemap probe failed — assuming supported");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One shareable ring slot: the NV12/P010 texture, its keyed mutex, and the NT handle the
|
||||
/// presenter imports. Handle closed on drop (the presenter never owns it).
|
||||
struct Slot {
|
||||
@@ -638,13 +718,14 @@ impl HandoffRing {
|
||||
/// `tex_*` is the DXVA-aligned decode surface (>= the frame); the gap is the padding the
|
||||
/// stream source rect excludes.
|
||||
///
|
||||
/// Keyed by DECODER rather than latched once per process. Two rungs shared this hand-off
|
||||
/// until M10 (libavcodec's D3D11VA and the native one), and a single process-wide latch
|
||||
/// meant a session that pinned the native rung and then demoted logged the native layout
|
||||
/// and nothing else, leaving the rung that actually painted the session's frames
|
||||
/// undocumented in exactly the report that needs it. One rung fills the ring today, so the
|
||||
/// set holds one short entry — kept keyed because the property is about which decoder
|
||||
/// wrote the surface, and that is the question a new-GPU forensics report asks.
|
||||
/// Keyed by DECODER × LAYOUT rather than latched once per process. Decoder, because two
|
||||
/// rungs shared this hand-off until M10 and a process-wide latch left whichever rung a
|
||||
/// session demoted onto undocumented in exactly the report that needs it. Layout
|
||||
/// (frame + pool dims + PQ), because a mid-stream `Reconfigure` or in-band SDR↔PQ flip
|
||||
/// rebuilds the decode pool at a new shape, and a latch keyed on the decoder alone left
|
||||
/// the frame-vs-padding relationship — the very thing a green-bar/smear report hinges
|
||||
/// on — logged only for the shape the session STARTED at. `slice` stays out of the key:
|
||||
/// it varies per frame and would turn one line per shape into one per DPB slot.
|
||||
fn log_layout_once(
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -656,12 +737,13 @@ fn log_layout_once(
|
||||
) {
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
|
||||
type LayoutKey = (String, u32, u32, u32, u32, bool);
|
||||
static SEEN: OnceLock<Mutex<HashSet<LayoutKey>>> = OnceLock::new();
|
||||
let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
|
||||
// A poisoned lock costs a log line, never a frame: a panic while holding it can only have
|
||||
// happened inside the set, and the worst outcome of ignoring it is a repeated line.
|
||||
let first = match seen.lock() {
|
||||
Ok(mut seen) => seen.insert(decoder.to_owned()),
|
||||
Ok(mut seen) => seen.insert((decoder.to_owned(), width, height, tex_w, tex_h, pq)),
|
||||
Err(_) => false,
|
||||
};
|
||||
if first {
|
||||
|
||||
Reference in New Issue
Block a user