Refactors LAST, after every defect fix, so no behaviour change hides in a move diff — and so the newly-exposed review surface was already clean when it landed (the WP7 discipline). 28f8fc71's recorded trap (an inline `use super::X` inside a moved body silently changing meaning) was audited first: every `use super::` in the moved code is either at module level — where `super` still means `linux` — or inside a `mod tests` that moved with its parent. 5.1 `mod pipewire` → `linux/pipewire.rs`. It was 1,900 of that file's lines. `mod.rs` is a directory module, so a plain `mod pipewire;` resolves with no `#[path]`. 5.2 out of that, `linux/pw_pods.rs` (the 7 param-pod builders + `serialize_pod` + the PQ constant and its test) and `linux/pw_cursor.rs` (`CursorState`, `decode_bitmap_pixel`, `update_cursor_meta`, `dst_offsets`, both `composite_cursor*`). The pods are the crate's WIRE surface — a missing property is not a compile error but a link that stalls in `negotiating`; the cursor half is producer-driven and bounds-critical. Both are now pure enough to unit-test without a compositor, which is what Phase 6 needs. 5.3 `linux/portal.rs`: the ScreenCast/RemoteDesktop handshake, the cursor-mode choice and GNOME's BT.2100 probe — the async/tokio/zbus control plane, separated from the realtime half. Zero re-exports changed: `mod.rs` re-exports `gnome_hdr_monitor_active` at its old path. 5.4 `idd_push/open.rs` (the whole one-time construction path + `SharedObjectSa` + `AttachTexFail`) and `idd_push/compose_kick.rs`. Read `open.rs` when a session will not START and the parent when one stops flowing; the steady state stays with the parent by decision. Also moved the ~65-line stall REPORTING block out of `try_consume` (the hot loop) into `StallWatch::report`, taking its two correlation counters with it — they were capturer fields nothing else touched. 5.5 `windows/dxgi/selftest.rs`: `p010_reference`, both self-tests, `hdr_p010_convert_bars_on_luid`, `f32_to_f16` and the two test modules — the validation path, none of which runs in a session. The two `pub` entry points are re-exported, so `main.rs`'s subcommand and pf-encode's live e2e keep their paths. (An explicit `#[path]`, like `idd_push`'s children: this file is itself reached through a `#[path]`, so a bare `mod` would resolve to `windows/selftest.rs`.) 5.6 `CaptureSignals`. The seven shared flags/slots were created in `spawn_pipewire`, `_cb`-cloned one by one, passed as seven of `pipewire_thread`'s parameters, and then re-declared as fields on `PwHandles`, `PortalCapturer` AND `UserData` — the same list written four times, each with its own drifting copy of the doc comment. One `#[derive(Clone)]` struct instead (a refcount bump per field), which also makes it structurally obvious that producer and consumer share ONE set. And `CaptureOpts` for the four trailing `bool`s: four adjacent same-typed positional arguments are a silent-transposition footgun — swap `want_444` and `want_hdr` and it compiles, negotiates the wrong pod family, and surfaces as a black screen ten seconds later. 5.7 trait shape, targeted: `set_active(&self)` → `&mut self` (it took `&self` only because the flag happened to be an `Arc<AtomicBool>` — an implementation detail leaking into the contract); `capture_target_id` ⇒ `resize_output`'s pairing rule stated on both methods and in a cluster note; one-line cluster headers over the 13 methods. NOT done from 5.7: taking the gamescope targets as an `open_*` option instead of the trait method. It would put a Linux-only parameter on the cross-platform `open_virtual_output` and on the host's `capture_virtual_output` facade — a `#[cfg]`-shaped wart in two shared signatures to delete one already-`cfg`'d defaulted method whose lifecycle rule 2.5c had already made harmless. Wrong trade under 5.7's own "no churn for no defect fixed" principle. File sizes: `linux/mod.rs` 2,778 → 770 (target ≤900 ✓), `windows/dxgi.rs` 1,374 → 954, and `windows/idd_push.rs` 2,694 → 1,922. The last two miss the plan's ≤850/≤1,300 targets, which were computed against the ORIGINAL line counts — Phases 1–4 added several hundred lines of SAFETY proofs and defect rationale to exactly these files before the split. The moves themselves are the ones the plan specifies; closing the rest would mean inventing new splits it does not call for. Zero behaviour delta. pf-capture 20/20; workspace clippy --all-targets clean on Linux and on windows-msvc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
649 lines
29 KiB
Rust
649 lines
29 KiB
Rust
//! The P010 colour SELF-TEST and its helpers — the `hdr-p010-selftest` subcommand's whole
|
||
//! implementation, plus the f64 reference math it compares against and the f16 encoder it uploads
|
||
//! with.
|
||
//!
|
||
//! Split out of `windows/dxgi.rs` in sweep Phase 5.5: it was ~560 of that file's 1,374 lines and
|
||
//! none of it runs in a session. What remains in the parent is the production path (the win32u hook,
|
||
//! the shader sources, the three converters); this is the validation path.
|
||
//!
|
||
//! `hdr_p010_selftest_at` and `hdr_p010_convert_bars_on_luid` are re-exported by the parent, so
|
||
//! every existing `crate::capture::dxgi::…` / `pf_capture::dxgi::…` path keeps resolving.
|
||
|
||
use super::*;
|
||
|
||
/// f64 reference for the P010 colour math — the EXACT analogue of the HLSL in [`HDR_P010_COMMON`].
|
||
/// Input is one scRGB pixel (linear, Rec.709 primaries, 1.0 = 80 nits, may be >1 for HDR). Output is
|
||
/// the 10-bit studio-range (Y, Cb, Cr) codes the shader should produce for a flat (constant) block.
|
||
/// Used by [`hdr_p010_selftest`].
|
||
#[cfg(target_os = "windows")]
|
||
fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
||
fn pq_oetf(l: f64) -> f64 {
|
||
let l = l.clamp(0.0, 1.0);
|
||
let m1 = 0.1593017578125;
|
||
let m2 = 78.84375;
|
||
let c1 = 0.8359375;
|
||
let c2 = 18.8515625;
|
||
let c3 = 18.6875;
|
||
let lp = l.powf(m1);
|
||
((c1 + c2 * lp) / (1.0 + c3 * lp)).powf(m2)
|
||
}
|
||
// scRGB -> nits -> BT.2020 linear (row-major matrix, mul(M, v)).
|
||
let (r, g, b) = (r.max(0.0) * 80.0, g.max(0.0) * 80.0, b.max(0.0) * 80.0);
|
||
let m = [
|
||
[0.627403914, 0.329283038, 0.043313048],
|
||
[0.069097292, 0.919540405, 0.011362303],
|
||
[0.016391439, 0.088013308, 0.895595253],
|
||
];
|
||
let lr = m[0][0] * r + m[0][1] * g + m[0][2] * b;
|
||
let lg = m[1][0] * r + m[1][1] * g + m[1][2] * b;
|
||
let lb = m[2][0] * r + m[2][1] * g + m[2][2] * b;
|
||
// PQ encode (normalize to 10k nits).
|
||
let pr = pq_oetf(lr / 10000.0);
|
||
let pg = pq_oetf(lg / 10000.0);
|
||
let pb = pq_oetf(lb / 10000.0);
|
||
// BT.2020 non-constant-luminance, limited 10-bit.
|
||
let (kr, kg, kb) = (0.2627, 0.6780, 0.0593);
|
||
let y = kr * pr + kg * pg + kb * pb;
|
||
let cb = (pb - y) / 1.8814;
|
||
let cr = (pr - y) / 1.4746;
|
||
let yc = (64.0 + 876.0 * y).clamp(64.0, 940.0);
|
||
let cbc = (512.0 + 896.0 * cb).clamp(64.0, 960.0);
|
||
let crc = (512.0 + 896.0 * cr).clamp(64.0, 960.0);
|
||
(yc, cbc, crc)
|
||
}
|
||
|
||
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
|
||
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
|
||
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
|
||
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
|
||
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
|
||
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
|
||
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
|
||
/// (325,448,598) (226,650,535) (64,512,512).
|
||
#[cfg(target_os = "windows")]
|
||
#[doc(hidden)]
|
||
pub fn hdr_p010_convert_bars_on_luid(
|
||
luid: [u8; 8],
|
||
w: u32,
|
||
h: u32,
|
||
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
|
||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||
|
||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
|
||
}
|
||
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
|
||
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
|
||
const BARS: [(f32, f32, f32); 8] = [
|
||
(1.0, 1.0, 1.0),
|
||
(1.0, 1.0, 0.0),
|
||
(0.0, 1.0, 1.0),
|
||
(0.0, 1.0, 0.0),
|
||
(1.0, 0.0, 1.0),
|
||
(1.0, 0.0, 0.0),
|
||
(0.0, 0.0, 1.0),
|
||
(0.0, 0.0, 0.0),
|
||
];
|
||
let bar_w = (w / 8).max(1) as usize;
|
||
let mut fp16 = vec![0u16; (w * h * 4) as usize];
|
||
for y in 0..h as usize {
|
||
for x in 0..w as usize {
|
||
let (r, g, b) = BARS[(x / bar_w).min(7)];
|
||
let i = (y * w as usize + x) * 4;
|
||
fp16[i] = f32_to_f16(r);
|
||
fp16[i + 1] = f32_to_f16(g);
|
||
fp16[i + 2] = f32_to_f16(b);
|
||
fp16[i + 3] = f32_to_f16(1.0);
|
||
}
|
||
}
|
||
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
|
||
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
|
||
// their references.
|
||
unsafe {
|
||
let luid = windows::Win32::Foundation::LUID {
|
||
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
|
||
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
|
||
};
|
||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
|
||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
|
||
let mut device: Option<ID3D11Device> = None;
|
||
let mut context: Option<ID3D11DeviceContext> = None;
|
||
D3D11CreateDevice(
|
||
&adapter,
|
||
D3D_DRIVER_TYPE_UNKNOWN,
|
||
HMODULE::default(),
|
||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||
D3D11_SDK_VERSION,
|
||
Some(&mut device),
|
||
None,
|
||
Some(&mut context),
|
||
)
|
||
.context("D3D11CreateDevice(luid) for bars convert")?;
|
||
let device = device.context("null device")?;
|
||
let context = context.context("null context")?;
|
||
|
||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||
Width: w,
|
||
Height: h,
|
||
MipLevels: 1,
|
||
ArraySize: 1,
|
||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||
SampleDesc: DXGI_SAMPLE_DESC {
|
||
Count: 1,
|
||
Quality: 0,
|
||
},
|
||
Usage: D3D11_USAGE_DEFAULT,
|
||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||
..Default::default()
|
||
};
|
||
let init = D3D11_SUBRESOURCE_DATA {
|
||
pSysMem: fp16.as_ptr() as *const c_void,
|
||
SysMemPitch: w * 8,
|
||
SysMemSlicePitch: 0,
|
||
};
|
||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||
device
|
||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||
.context("CreateTexture2D(fp16 bars)")?;
|
||
let src_tex = src_tex.context("null src tex")?;
|
||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||
device
|
||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||
.context("CreateShaderResourceView(fp16 bars)")?;
|
||
let src_srv = src_srv.context("null src srv")?;
|
||
|
||
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
|
||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||
Width: w,
|
||
Height: h,
|
||
MipLevels: 1,
|
||
ArraySize: 1,
|
||
Format: DXGI_FORMAT_P010,
|
||
SampleDesc: DXGI_SAMPLE_DESC {
|
||
Count: 1,
|
||
Quality: 0,
|
||
},
|
||
Usage: D3D11_USAGE_DEFAULT,
|
||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||
..Default::default()
|
||
};
|
||
let mut p010: Option<ID3D11Texture2D> = None;
|
||
device
|
||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||
.context("CreateTexture2D(P010 bars dst)")?;
|
||
let p010 = p010.context("null p010 tex")?;
|
||
|
||
let conv = HdrP010Converter::new(&device, w, h)?;
|
||
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
|
||
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
|
||
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, w, h)?;
|
||
Ok((device, p010))
|
||
}
|
||
}
|
||
|
||
/// Colour self-test for [`HdrP010Converter`] (the `hdr-p010-selftest` subcommand): create a hardware
|
||
/// D3D11 device, upload a known scRGB FP16 pattern, run the P010 shader passes, read the Y (plane 0)
|
||
/// and UV (plane 1) planes back from a staging copy, and compare against the [`p010_reference`] f64
|
||
/// math. The ONLY validation we have without green-screening a live HDR stream. PASS if max abs error
|
||
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
|
||
///
|
||
/// `w`/`h` must be even and non-zero. Run it at the FIELD capture size, not a toy one: sessions run
|
||
/// at resolutions whose height is not 16-aligned (1080 → the encoder's align16 pool seam) and a
|
||
/// driver may treat the planar RTVs differently at real sizes. `vendor` pins the adapter by PCI
|
||
/// vendor id (`0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD) — it matters on dual-GPU boxes where
|
||
/// the default adapter is not the one the session encodes on. The chosen adapter is always printed,
|
||
/// because a PASS only means anything for the GPU it actually ran on.
|
||
#[cfg(target_os = "windows")]
|
||
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
|
||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
||
|
||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
|
||
}
|
||
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
|
||
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
|
||
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||
#[allow(non_snake_case)]
|
||
let (W, H) = (w, h);
|
||
const BLK: u32 = 16;
|
||
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
||
let named: [(&str, f32, f32, f32); 8] = [
|
||
("red1.0", 1.0, 0.0, 0.0),
|
||
("green0.5", 0.0, 0.5, 0.0),
|
||
("blue4.0", 0.0, 0.0, 4.0),
|
||
("white1.0", 1.0, 1.0, 1.0),
|
||
("black", 0.0, 0.0, 0.0),
|
||
("gray0.5", 0.5, 0.5, 0.5),
|
||
("white4.0", 4.0, 4.0, 4.0),
|
||
("amber2.0", 2.0, 1.0, 0.0),
|
||
];
|
||
|
||
let grid_cols = W / BLK; // 4
|
||
let pixel_rgb = |x: u32, y: u32| -> (f32, f32, f32, bool) {
|
||
let idx = ((y / BLK) * grid_cols + (x / BLK)) as usize;
|
||
if idx < named.len() {
|
||
let (_, r, g, b) = named[idx];
|
||
(r, g, b, true)
|
||
} else {
|
||
// Gradient (distinct per pixel; Y-only compare), within HDR scRGB range.
|
||
let r = (x as f32 / W as f32) * 3.0;
|
||
let g = (y as f32 / H as f32) * 3.0;
|
||
let b = ((x + y) as f32 / (W + H) as f32) * 3.0;
|
||
(r, g, b, false)
|
||
}
|
||
};
|
||
|
||
// Build the scRGB FP16 (R16G16B16A16_FLOAT) source as f16 bits.
|
||
let mut fp16 = vec![0u16; (W * H * 4) as usize];
|
||
let mut flat = vec![false; (W * H) as usize];
|
||
for y in 0..H {
|
||
for x in 0..W {
|
||
let (r, g, b, is_flat) = pixel_rgb(x, y);
|
||
let i = ((y * W + x) * 4) as usize;
|
||
fp16[i] = f32_to_f16(r);
|
||
fp16[i + 1] = f32_to_f16(g);
|
||
fp16[i + 2] = f32_to_f16(b);
|
||
fp16[i + 3] = f32_to_f16(1.0);
|
||
flat[(y * W + x) as usize] = is_flat;
|
||
}
|
||
}
|
||
|
||
// SAFETY: this self-test creates its own D3D11 device + immediate context (`D3D11CreateDevice`,
|
||
// both checked non-null) and uses ONLY that device for the rest of the block: every
|
||
// `CreateTexture2D`/`CreateShaderResourceView`/`HdrP010Converter::{new,convert}`/`CopyResource`/
|
||
// `Map` is invoked on that device or its context, so all resources share one device and run on this
|
||
// single thread. The source texture's `D3D11_SUBRESOURCE_DATA` points at `fp16`, a live
|
||
// `Vec<u16>` of `W*H*4` samples with `SysMemPitch = W*8`, matching the W×H R16G16B16A16 texture;
|
||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||
// proven individually at the `read_u16` closure below.
|
||
unsafe {
|
||
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
|
||
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
|
||
// the GPU it actually tested.
|
||
let adapter: Option<IDXGIAdapter> = match vendor {
|
||
None => None,
|
||
Some(want) => {
|
||
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
|
||
let mut found = None;
|
||
for i in 0.. {
|
||
let Ok(a) = factory.EnumAdapters(i) else {
|
||
break;
|
||
};
|
||
let desc = a.GetDesc().context("adapter desc")?;
|
||
if desc.VendorId == want {
|
||
found = Some(a);
|
||
break;
|
||
}
|
||
}
|
||
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
|
||
}
|
||
};
|
||
let mut device: Option<ID3D11Device> = None;
|
||
let mut context: Option<ID3D11DeviceContext> = None;
|
||
D3D11CreateDevice(
|
||
adapter.as_ref(),
|
||
if adapter.is_some() {
|
||
D3D_DRIVER_TYPE_UNKNOWN
|
||
} else {
|
||
D3D_DRIVER_TYPE_HARDWARE
|
||
},
|
||
HMODULE::default(),
|
||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||
D3D11_SDK_VERSION,
|
||
Some(&mut device),
|
||
None,
|
||
Some(&mut context),
|
||
)
|
||
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
||
let device = device.context("null device")?;
|
||
let context = context.context("null context")?;
|
||
{
|
||
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
|
||
device.cast().context("device -> IDXGIDevice")?;
|
||
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
|
||
let name = String::from_utf16_lossy(
|
||
&desc.Description[..desc
|
||
.Description
|
||
.iter()
|
||
.position(|&c| c == 0)
|
||
.unwrap_or(desc.Description.len())],
|
||
);
|
||
println!(
|
||
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
|
||
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
||
);
|
||
}
|
||
|
||
// Source FP16 texture (initialized) + SRV.
|
||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||
Width: W,
|
||
Height: H,
|
||
MipLevels: 1,
|
||
ArraySize: 1,
|
||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||
SampleDesc: DXGI_SAMPLE_DESC {
|
||
Count: 1,
|
||
Quality: 0,
|
||
},
|
||
Usage: D3D11_USAGE_DEFAULT,
|
||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||
..Default::default()
|
||
};
|
||
let init = D3D11_SUBRESOURCE_DATA {
|
||
pSysMem: fp16.as_ptr() as *const c_void,
|
||
SysMemPitch: W * 8, // 4 channels * 2 bytes
|
||
SysMemSlicePitch: 0,
|
||
};
|
||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||
device
|
||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||
.context("CreateTexture2D(fp16 src)")?;
|
||
let src_tex = src_tex.context("null src tex")?;
|
||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||
device
|
||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||
.context("CreateShaderResourceView(fp16 src)")?;
|
||
let src_srv = src_srv.context("null src srv")?;
|
||
|
||
// P010 destination texture (render-target bindable).
|
||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||
Width: W,
|
||
Height: H,
|
||
MipLevels: 1,
|
||
ArraySize: 1,
|
||
Format: DXGI_FORMAT_P010,
|
||
SampleDesc: DXGI_SAMPLE_DESC {
|
||
Count: 1,
|
||
Quality: 0,
|
||
},
|
||
Usage: D3D11_USAGE_DEFAULT,
|
||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||
..Default::default()
|
||
};
|
||
let mut p010: Option<ID3D11Texture2D> = None;
|
||
device
|
||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||
.context("CreateTexture2D(P010 dst)")?;
|
||
let p010 = p010.context("null p010 tex")?;
|
||
|
||
let conv = HdrP010Converter::new(&device, W, H)?;
|
||
let y_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16_UNORM)?;
|
||
let uv_rtv = HdrP010Converter::plane_rtv(&device, &p010, DXGI_FORMAT_R16G16_UNORM)?;
|
||
conv.convert(&context, &src_srv, &y_rtv, &uv_rtv, W, H)?;
|
||
|
||
// Staging copy of the whole P010 texture (both planes), MAP_READ.
|
||
let stage_desc = D3D11_TEXTURE2D_DESC {
|
||
Width: W,
|
||
Height: H,
|
||
MipLevels: 1,
|
||
ArraySize: 1,
|
||
Format: DXGI_FORMAT_P010,
|
||
SampleDesc: DXGI_SAMPLE_DESC {
|
||
Count: 1,
|
||
Quality: 0,
|
||
},
|
||
Usage: D3D11_USAGE_STAGING,
|
||
BindFlags: 0,
|
||
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
|
||
..Default::default()
|
||
};
|
||
let mut staging: Option<ID3D11Texture2D> = None;
|
||
device
|
||
.CreateTexture2D(&stage_desc, None, Some(&mut staging))
|
||
.context("CreateTexture2D(P010 staging)")?;
|
||
let staging = staging.context("null staging")?;
|
||
context.CopyResource(&staging, &p010);
|
||
|
||
let mut map = D3D11_MAPPED_SUBRESOURCE::default();
|
||
context
|
||
.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut map))
|
||
.context("Map(P010 staging)")?;
|
||
let row_pitch = map.RowPitch as usize; // bytes per luma row (in 16-bit samples: /2)
|
||
let base = map.pData as *const u8;
|
||
// DIAGNOSTIC (the uncertain layout spot — verify on the box if chroma is wrong): the mapped
|
||
// P010 plane offsets. Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2
|
||
// *interleaved* (Cb,Cr) u16 pairs. P010 packs plane 1 after plane 0 at the SAME row pitch; the
|
||
// chroma plane begins at byte offset RowPitch * (luma height). For a STAGING texture that
|
||
// height is the created H (no inter-plane alignment). DepthPitch (total mapped size) lets us
|
||
// sanity-check: it should be ~ RowPitch * H * 3/2. If chroma reads garbage on the box, print
|
||
// these and adjust `chroma_base` (e.g. an aligned luma height).
|
||
tracing::info!(
|
||
row_pitch,
|
||
depth_pitch = map.DepthPitch,
|
||
expected_chroma_base = row_pitch * H as usize,
|
||
expected_total = row_pitch * H as usize * 3 / 2,
|
||
"hdr-p010-selftest: mapped P010 layout (verify chroma plane offset here if chroma is wrong)"
|
||
);
|
||
// Plane 0 (luma): H rows of W u16. Plane 1 (chroma): H/2 rows of W/2 *interleaved* (Cb,Cr)
|
||
// u16 pairs, i.e. W u16 per chroma row. P010 packs plane 1 immediately after plane 0 at the
|
||
// SAME row pitch; per spec the chroma plane begins at an allocation offset of
|
||
// RowPitch * Height (luma rows). We read it from there. (DepthPitch is the full surface size;
|
||
// not all drivers report the chroma offset, so RowPitch*Height is the portable choice.)
|
||
let read_u16 = |byte_off: usize| -> u16 {
|
||
// SAFETY: `base` is the mapped staging pointer; all offsets are within the P010 surface
|
||
// (luma H*RowPitch + chroma (H/2)*RowPitch ≤ DepthPitch). Already in the fn's unsafe scope.
|
||
let p = base.add(byte_off) as *const u16;
|
||
p.read_unaligned()
|
||
};
|
||
// Luma codes: stored u16 in the high 10 bits -> code10 = stored >> 6.
|
||
let mut y_codes = vec![0u16; (W * H) as usize];
|
||
for y in 0..H {
|
||
for x in 0..W {
|
||
let off = (y as usize) * row_pitch + (x as usize) * 2;
|
||
y_codes[(y * W + x) as usize] = read_u16(off) >> 6;
|
||
}
|
||
}
|
||
let cw = W / 2;
|
||
let ch = H / 2;
|
||
let chroma_base = row_pitch * H as usize; // plane 1 offset
|
||
let mut cb_codes = vec![0u16; (cw * ch) as usize];
|
||
let mut cr_codes = vec![0u16; (cw * ch) as usize];
|
||
for cy in 0..ch {
|
||
for cx in 0..cw {
|
||
// Interleaved (Cb, Cr) per chroma sample → 2 u16 = 4 bytes per sample.
|
||
let off = chroma_base + (cy as usize) * row_pitch + (cx as usize) * 4;
|
||
cb_codes[(cy * cw + cx) as usize] = read_u16(off) >> 6;
|
||
cr_codes[(cy * cw + cx) as usize] = read_u16(off + 2) >> 6;
|
||
}
|
||
}
|
||
context.Unmap(&staging, 0);
|
||
|
||
// Compare Y over every pixel.
|
||
let mut max_y_err = 0.0f64;
|
||
for y in 0..H {
|
||
for x in 0..W {
|
||
let (r, g, b, _) = pixel_rgb(x, y);
|
||
let (ry, _, _) = p010_reference(r as f64, g as f64, b as f64);
|
||
let got = y_codes[(y * W + x) as usize] as f64;
|
||
max_y_err = max_y_err.max((got - ry).abs());
|
||
}
|
||
}
|
||
// Compare Cb/Cr over flat blocks only (uniform 2x2 footprint → exact reference).
|
||
let mut max_u_err = 0.0f64;
|
||
let mut max_v_err = 0.0f64;
|
||
for cy in 0..ch {
|
||
for cx in 0..cw {
|
||
let (sx, sy) = (cx * 2, cy * 2);
|
||
let all_flat =
|
||
(0..2).all(|dy| (0..2).all(|dx| flat[((sy + dy) * W + (sx + dx)) as usize]));
|
||
if !all_flat {
|
||
continue;
|
||
}
|
||
let (r, g, b, _) = pixel_rgb(sx, sy);
|
||
let (_, rcb, rcr) = p010_reference(r as f64, g as f64, b as f64);
|
||
let gu = cb_codes[(cy * cw + cx) as usize] as f64;
|
||
let gv = cr_codes[(cy * cw + cx) as usize] as f64;
|
||
max_u_err = max_u_err.max((gu - rcb).abs());
|
||
max_v_err = max_v_err.max((gv - rcr).abs());
|
||
}
|
||
}
|
||
|
||
// Per-colour table.
|
||
println!("HDR P010 self-test ({W}x{H}, BT.2020 PQ, 10-bit limited range)");
|
||
println!(
|
||
" {:<10} {:>14} {:>14} {:>14}",
|
||
"color", "Y exp/got", "Cb exp/got", "Cr exp/got"
|
||
);
|
||
for (idx, (name, r, g, b)) in named.iter().enumerate() {
|
||
let bx = (idx as u32 % grid_cols) * BLK + BLK / 2;
|
||
let by = (idx as u32 / grid_cols) * BLK + BLK / 2;
|
||
let (ey, ecb, ecr) = p010_reference(*r as f64, *g as f64, *b as f64);
|
||
let gy = y_codes[(by * W + bx) as usize] as f64;
|
||
let (ccx, ccy) = (bx / 2, by / 2);
|
||
let gu = cb_codes[(ccy * cw + ccx) as usize] as f64;
|
||
let gv = cr_codes[(ccy * cw + ccx) as usize] as f64;
|
||
println!(
|
||
" {:<10} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0} {:>6.1}/{:<6.0}",
|
||
name, ey, gy, ecb, gu, ecr, gv
|
||
);
|
||
}
|
||
println!(
|
||
" max abs error: Y={max_y_err:.2} (≤4) Cb={max_u_err:.2} (≤5) Cr={max_v_err:.2} (≤5)"
|
||
);
|
||
|
||
if max_y_err <= 4.0 && max_u_err <= 5.0 && max_v_err <= 5.0 {
|
||
println!("PASS");
|
||
Ok(())
|
||
} else {
|
||
println!("FAIL");
|
||
bail!(
|
||
"HDR P010 self-test FAILED (Y={max_y_err:.2} Cb={max_u_err:.2} Cr={max_v_err:.2})"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Minimal f32 → IEEE-754 half (f16) bit pattern, for uploading the FP16 scRGB self-test pattern. Not
|
||
/// on any hot path; handles normals, subnormals, and the 1.0/0.0 constants we feed. (round-to-nearest)
|
||
#[cfg(target_os = "windows")]
|
||
fn f32_to_f16(v: f32) -> u16 {
|
||
let bits = v.to_bits();
|
||
let sign = ((bits >> 16) & 0x8000) as u16;
|
||
let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15;
|
||
let mant = bits & 0x007f_ffff;
|
||
if exp <= 0 {
|
||
// Subnormal / zero in half precision.
|
||
if exp < -10 {
|
||
return sign; // too small → ±0
|
||
}
|
||
let mant = mant | 0x0080_0000; // implicit 1
|
||
let shift = (14 - exp) as u32;
|
||
let half_mant = (mant >> shift) as u16;
|
||
// Round to nearest.
|
||
let round = ((mant >> (shift - 1)) & 1) as u16;
|
||
sign | (half_mant + round)
|
||
} else if exp >= 0x1f {
|
||
sign | 0x7c00 // Inf/NaN → Inf (our inputs never hit this)
|
||
} else {
|
||
let half_exp = (exp as u16) << 10;
|
||
let half_mant = (mant >> 13) as u16;
|
||
let round = ((mant >> 12) & 1) as u16;
|
||
// ADD, never OR. `half_mant + round` can carry out of the 10-bit mantissa (all ones, then
|
||
// rounded up), and that carry must INCREMENT the exponent — which is exactly what an
|
||
// IEEE-754 round-to-nearest overflow means. `sign | half_exp | (…)` instead ORed it into bit
|
||
// 10, so for every ODD biased exponent (bit 10 already set) the carry vanished and the
|
||
// result came back a factor of ~2 low: `f32_to_f16(1.9998779) → 0x3C00 = 1.0`,
|
||
// `0.49996948 → 0.25`. Only values one ULP below a power of two are affected — which is
|
||
// precisely what a gradient test pattern is full of, so this made `hdr-p010-selftest` FAIL a
|
||
// correct shader. The subnormal branch above was already additive.
|
||
sign | (half_exp + half_mant + round)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod f16_tests {
|
||
use super::f32_to_f16;
|
||
|
||
/// Round-trip through the reference conversion the rest of the test uses as an oracle.
|
||
fn f16_to_f32(h: u16) -> f32 {
|
||
let sign = if h & 0x8000 != 0 { -1.0f32 } else { 1.0 };
|
||
let exp = ((h >> 10) & 0x1f) as i32;
|
||
let mant = (h & 0x3ff) as f32;
|
||
match exp {
|
||
0 => sign * mant * 2f32.powi(-24), // subnormal
|
||
31 => sign * f32::INFINITY, // our encoder never emits NaN
|
||
e => sign * (1.0 + mant / 1024.0) * 2f32.powi(e - 15),
|
||
}
|
||
}
|
||
|
||
/// W7: the rounding carry out of the mantissa must INCREMENT the exponent. The composition used
|
||
/// `sign | half_exp | (half_mant + round)`, which swallowed that carry for every odd biased
|
||
/// exponent — a silent factor-of-2 error on exactly the values a gradient test pattern is full
|
||
/// of, which made `hdr-p010-selftest` fail a correct shader.
|
||
#[test]
|
||
fn a_rounding_carry_increments_the_exponent() {
|
||
// The plan's canonical case: biased exponent 127 (2^0) with a mantissa that rounds up out
|
||
// of 10 bits ⇒ 2.0 = 0x4000, NOT 1.0 = 0x3C00.
|
||
assert_eq!(f32_to_f16(f32::from_bits((127 << 23) | 0x7FF000)), 0x4000);
|
||
// The two measured regressions, by value.
|
||
assert_eq!(
|
||
f32_to_f16(1.9998779),
|
||
0x4000,
|
||
"1.9998779 must not read as 1.0"
|
||
);
|
||
assert_eq!(
|
||
f32_to_f16(0.49996948),
|
||
0x3800,
|
||
"0.49996948 must not read as 0.25"
|
||
);
|
||
// …and an EVEN biased exponent, where the bug happened to be invisible (bit 10 clear), so
|
||
// the fix must not change it.
|
||
assert_eq!(f32_to_f16(f32::from_bits((128 << 23) | 0x7FF000)), 0x4400); // → 4.0
|
||
}
|
||
|
||
#[test]
|
||
fn the_constants_the_selftest_uploads_are_exact() {
|
||
assert_eq!(f32_to_f16(0.0), 0x0000);
|
||
assert_eq!(f32_to_f16(-0.0), 0x8000);
|
||
assert_eq!(f32_to_f16(1.0), 0x3C00);
|
||
assert_eq!(f32_to_f16(-1.0), 0xBC00);
|
||
assert_eq!(f32_to_f16(0.5), 0x3800);
|
||
assert_eq!(f32_to_f16(2.0), 0x4000);
|
||
assert_eq!(f32_to_f16(4.0), 0x4400);
|
||
}
|
||
|
||
/// Every HDR scRGB value the self-test patterns use must survive the round trip to within one
|
||
/// f16 ULP — the property the P010 comparison actually depends on.
|
||
#[test]
|
||
fn hdr_scrgb_values_round_trip_within_one_ulp() {
|
||
for &v in &[
|
||
0.0f32, 0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 0.1, 0.3, 0.7, 1.9998779, 0.49996948, 2.5,
|
||
3.999, 0.001,
|
||
] {
|
||
let back = f16_to_f32(f32_to_f16(v));
|
||
// One ULP at this magnitude: f16 carries 11 significand bits.
|
||
let ulp = (v.abs() / 1024.0).max(2f32.powi(-24));
|
||
assert!(
|
||
(back - v).abs() <= ulp,
|
||
"{v} round-tripped to {back} (ulp {ulp})"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn out_of_range_magnitudes_saturate_rather_than_wrap() {
|
||
// Above f16's max finite (65504) our encoder reports Inf; below its subnormal floor, ±0.
|
||
assert_eq!(f32_to_f16(1.0e30), 0x7C00);
|
||
assert_eq!(f32_to_f16(-1.0e30), 0xFC00);
|
||
assert_eq!(f32_to_f16(1.0e-30), 0x0000);
|
||
assert_eq!(f32_to_f16(-1.0e-30), 0x8000);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod hdr_selftests {
|
||
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
|
||
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
|
||
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
|
||
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
|
||
#[test]
|
||
#[ignore]
|
||
fn hdr_p010_selftest_intel_1080_live() {
|
||
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
|
||
}
|
||
}
|