test(pf-capture): the suite the splits enable — 38 Linux + 19 Windows tests (Phase 6)

The plan's priority order was "every producer- or OS-controlled parser, blend, geometry guard or
negotiation decision", none of which had a test before this sweep (X3). Phase 5's splits are what
made most of them reachable without a compositor or a driver.

**6.1 `bitmap_extent`** — extracted from `update_cursor_meta`, which is the guard whose own SAFETY
proof says a missing bound "SIGSEGVs inside the PipeWire `.process` callback (a segfault
`catch_unwind` cannot catch)". Every input except the region size is producer-written. 5 tests:
a bitmap that fits (with and without header/pixel offsets); the last-row rule (`stride·(bh−1) + row`,
so a bitmap ending flush against the region is accepted rather than rejected by padding that is
never read — and one byte short is rejected); anything past the region; degenerate geometry; and four
distinct overflow vectors including the near-`i32::MAX` stride the SAFETY comment calls out. One
assertion I first wrote was wrong, not the code — with a single row `stride·0 == 0`, so even a
`usize::MAX`-wide row is arithmetically in range; the test now exercises the ≥2-row case that really
overflows and says why the other is fine (the caller has already capped `bw` at 1024).

**6.2 the composite blits** — 8 tests over `composite_cursor` / `composite_cursor_rgb10`: every
packed `PixelFormat` arm lands the colour in its OWN channels (so a byte-order slip cannot pass);
clipping off all four edges plus six fully-outside positions; zero-alpha, `visible: false` and
no-bitmap-yet all drawing nothing; the integer alpha blend; NV12/Yuv444 declined rather than
mis-blitted; and for the 10-bit path a bit-exact round trip of an untouched pixel (including the two
alpha bits the repack must preserve), the R-at-bit-20 vs R-at-bit-0 distinction between `X2Rgb10` and
`X2Bgr10`, and the same clipping. Plus `decode_bitmap_pixel`'s four byte orders.

**6.4 the pod builders** — 4 tests. The `dataType` bitmask is pinned per Buffers pod, because each
bit is load-bearing in a different direction: the mappable pod MUST include DmaBuf (or gamescope's
modifier-bearing format pod wins and the buffer intersection is empty — a link silently stuck in
"negotiating"), the SHM-only pod MUST exclude it (or Mutter hands dmabufs and the race-free download
path is not), and the dmabuf pod MUST exclude the mappable types (or an HDR session can be handed a
MemFd buffer, which Mutter paints 8-bit ARGB32 regardless of the negotiated 10-bit format). Also:
every pod parses back through `Pod::from_bytes`; the HDR pods carry MANDATORY PQ + BT.2020 +
LINEAR-modifier; and only the NV12 offer pins the colour matrix/range. The `dataType` reader parses
the SPA property layout literally (`key, flags, size, type, value`) — a "find the first
plausible-looking int" heuristic read the `size` word, which is also 4, and reported the wrong mask.

**6.5 `FrameToken` generation masking (W14)** — 2 tests, with `IDD_GENERATION` parked two below the
24-bit boundary so the WRAP is what gets exercised: every minted generation is non-zero, fits the
token's field, and survives the pack/unpack round trip `try_consume` performs; and the cleared-
`latest` 0 sentinel never matches a live generation.

**6.6 the cursor conversion (Windows)** — `convert`'s pixel logic extracted from its GDI plumbing
into `mono_planes_to_rgba` / `apply_and_mask_alpha` / `alpha_is_empty`, which is what makes it
testable at all (the caller needs a live `HCURSOR` and a screen DC). 6 tests: the four-state AND/XOR
truth table in one row; transparency surviving without an invert neighbour; the invert outline
covering all eight neighbours, overwriting only TRANSPARENT ones, and clipping at the edges; the
alpha-less colour cursor taking alpha from the AND mask; and a short mask not panicking.

**6.3 / 6.7** landed with the fixes they guard (`negotiation_plan` in 2.3, `f32_to_f16` in 3.5).

38 Linux tests, up from 6 at the start of the sweep — ci.yml already runs them. 19 `#[test]`s on the
Windows side; Phase 0.1's `--all-targets` lint type-checks them all, but note it does not RUN them
(the workflow lints only), so the Windows ones execute on a Windows box. clippy --all-targets clean
on both targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 11:31:34 +02:00
co-authored by Claude Opus 5
parent 306f4a514d
commit 14914bc72f
4 changed files with 754 additions and 60 deletions
+345 -20
View File
@@ -156,27 +156,14 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
} }
let row = bw as usize * 4; let row = bw as usize * 4;
let stride = if stride < row { row } else { stride }; let stride = if stride < row { row } else { stride };
// `span` is the exact byte extent the strided loop reads: `stride·(bh-1) + row`. Compute it let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size)
// with checked arithmetic (a producer stride near `i32::MAX` would otherwise overflow) and else {
// require the whole pixel block `[bmp_off + pix_off, +span)` to lie inside the region before return;
// fabricating the slice — this is the check whose absence made the read go out of bounds.
let span = match stride
.checked_mul(bh as usize - 1)
.and_then(|v| v.checked_add(row))
{
Some(s) => s,
None => return,
}; };
match bmp_off // SAFETY: `bitmap_extent` returned `Some`, which means (see its contract) the whole range
.checked_add(pix_off) // `[bmp_off + pix_off, +len)` lies inside `region_size` and `len` is EXACTLY the extent the
.and_then(|v| v.checked_add(span)) // strided loop below reads. `data` is the producer's meta-region base, live for this callback.
{ let src = unsafe { std::slice::from_raw_parts(data.add(extent.start), extent.len()) };
Some(end) if end <= region_size => {}
_ => return,
}
// SAFETY: `bmp_off + pix_off + span <= region_size` (checked directly above), so the slice
// is fully within the producer's meta region; `span` is exactly the strided loop's extent.
let src = unsafe { std::slice::from_raw_parts(data.add(bmp_off + pix_off), span) };
let mut rgba = vec![0u8; bw as usize * bh as usize * 4]; let mut rgba = vec![0u8; bw as usize * bh as usize * 4];
for y in 0..bh as usize { for y in 0..bh as usize {
for x in 0..bw as usize { for x in 0..bw as usize {
@@ -195,6 +182,40 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy
cursor.serial = cursor.serial.wrapping_add(1); cursor.serial = cursor.serial.wrapping_add(1);
} }
/// The byte range inside the producer's cursor-meta region that a `bh`-row, `row`-wide,
/// `stride`-strided bitmap at `bmp_off + pix_off` occupies — or `None` when it does not fit, or when
/// any of the arithmetic overflows.
///
/// THE bound on `update_cursor_meta`. Every input except `region_size` is producer-written, and
/// `region_size` is the real byte size of the meta region libspa handed us (which is why the caller
/// takes `spa_buffer_find_meta` rather than `find_meta_data`). Without this check the offsets drive
/// out-of-bounds pointer arithmetic and an oversized `slice::from_raw_parts` — an OOB read that
/// SIGSEGVs inside the PipeWire `.process` callback, where `catch_unwind` cannot help. A stride near
/// `i32::MAX` is enough to overflow the multiply on its own, so every step is checked.
///
/// `len()` of the returned range is EXACTLY `stride·(bh1) + row`: the last row contributes only its
/// `row` visible bytes, not a full stride, so a bitmap that ends flush against the region's end is
/// accepted rather than rejected by a padding byte that is never read.
///
/// Extracted (sweep Phase 6.1) purely so it can be tested — the caller is unreachable without a live
/// compositor.
fn bitmap_extent(
bmp_off: usize,
pix_off: usize,
stride: usize,
row: usize,
bh: usize,
region_size: usize,
) -> Option<std::ops::Range<usize>> {
if bh == 0 || row == 0 || stride < row {
return None;
}
let span = stride.checked_mul(bh - 1)?.checked_add(row)?;
let start = bmp_off.checked_add(pix_off)?;
let end = start.checked_add(span)?;
(end <= region_size).then_some(start..end)
}
/// Destination channel byte offsets (R,G,B) and bytes-per-pixel for a packed-RGB `PixelFormat`, /// Destination channel byte offsets (R,G,B) and bytes-per-pixel for a packed-RGB `PixelFormat`,
/// or `None` for a layout the CPU cursor blit doesn't handle (YUV/10-bit — those never reach /// or `None` for a layout the CPU cursor blit doesn't handle (YUV/10-bit — those never reach
/// the CPU de-pad path anyway). /// the CPU de-pad path anyway).
@@ -307,3 +328,307 @@ pub(super) fn composite_cursor(
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// A solid-colour cursor state at `(x, y)`, `w`×`h`, alpha `a`.
fn cursor(x: i32, y: i32, w: u32, h: u32, rgb: (u8, u8, u8), a: u8) -> CursorState {
let mut px = Vec::with_capacity((w * h * 4) as usize);
for _ in 0..w * h {
px.extend_from_slice(&[rgb.0, rgb.1, rgb.2, a]);
}
CursorState {
visible: true,
x,
y,
rgba: Arc::new(px),
bw: w,
bh: h,
serial: 1,
hot_x: 0,
hot_y: 0,
}
}
// ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably -------------------------
#[test]
fn bitmap_extent_accepts_a_bitmap_that_fits() {
// 4×2 RGBA, tightly packed: 32 bytes at offset 0.
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 32), Some(0..32));
// …and the same bitmap behind a header + pixel offset.
assert_eq!(bitmap_extent(24, 8, 16, 16, 2, 64), Some(32..64));
}
#[test]
fn bitmap_extent_charges_the_last_row_only_its_visible_bytes() {
// stride 32, row 16, 3 rows ⇒ 32*2 + 16 = 80, NOT 96. A bitmap ending flush against the
// region must be accepted: the trailing stride padding is never read.
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 80), Some(0..80));
assert_eq!(bitmap_extent(0, 0, 32, 16, 3, 79), None, "one byte short");
}
#[test]
fn bitmap_extent_rejects_anything_past_the_region() {
assert_eq!(bitmap_extent(0, 0, 16, 16, 2, 31), None);
// An offset alone can push it out.
assert_eq!(bitmap_extent(1, 0, 16, 16, 2, 32), None);
assert_eq!(bitmap_extent(0, 1, 16, 16, 2, 32), None);
// A region of zero accepts nothing.
assert_eq!(bitmap_extent(0, 0, 16, 16, 1, 0), None);
}
/// The producer picks `stride` and both offsets, so each is an overflow vector on its own.
#[test]
fn bitmap_extent_survives_hostile_arithmetic() {
// stride × (bh-1) overflows.
assert_eq!(bitmap_extent(0, 0, usize::MAX, 16, 3, usize::MAX), None);
// span + row overflows. Needs ≥2 rows so `stride·(bh1)` is already at the ceiling: with a
// SINGLE row `stride·0 == 0`, and even a `usize::MAX`-wide row is then arithmetically in
// range — which is correct rather than a miss, since the caller has already capped `bw` at
// 1024 and `row` is therefore ≤ 4096.
assert_eq!(
bitmap_extent(0, 0, usize::MAX, usize::MAX, 2, usize::MAX),
None
);
// bmp_off + pix_off overflows.
assert_eq!(bitmap_extent(usize::MAX, 1, 16, 16, 1, usize::MAX), None);
// start + span overflows.
assert_eq!(
bitmap_extent(usize::MAX - 8, 0, 16, 16, 1, usize::MAX),
None
);
// A near-i32::MAX stride — the case the SAFETY comment calls out — must not wrap.
assert_eq!(bitmap_extent(0, 0, i32::MAX as usize, 16, 1024, 4096), None);
}
#[test]
fn bitmap_extent_rejects_degenerate_geometry() {
assert_eq!(bitmap_extent(0, 0, 16, 16, 0, 4096), None, "zero rows");
assert_eq!(bitmap_extent(0, 0, 16, 0, 2, 4096), None, "zero-width row");
assert_eq!(bitmap_extent(0, 0, 8, 16, 2, 4096), None, "stride < row");
}
// ---- composite_cursor: clipping, alpha, and every layout --------------------------------
/// Read pixel `(x, y)`'s (R, G, B) out of a packed frame, honouring the layout's byte order.
fn px_rgb(buf: &[u8], w: usize, x: usize, y: usize, fmt: PixelFormat) -> (u8, u8, u8) {
let (ri, gi, bi, bpp) = dst_offsets(fmt).expect("packed layout");
let i = (y * w + x) * bpp;
(buf[i + ri], buf[i + gi], buf[i + bi])
}
#[test]
fn every_packed_layout_lands_the_colour_in_its_own_channels() {
for fmt in [
PixelFormat::Bgrx,
PixelFormat::Bgra,
PixelFormat::Rgbx,
PixelFormat::Rgba,
PixelFormat::Rgb,
PixelFormat::Bgr,
] {
let bpp = dst_offsets(fmt).unwrap().3;
let (w, h) = (4usize, 4usize);
let mut buf = vec![0u8; w * h * bpp];
// Opaque pure red at (1, 1).
composite_cursor(&mut buf, w, h, fmt, &cursor(1, 1, 1, 1, (255, 0, 0), 255));
assert_eq!(px_rgb(&buf, w, 1, 1, fmt), (255, 0, 0), "{fmt:?}");
// Nothing else moved.
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (0, 0, 0), "{fmt:?}");
assert_eq!(px_rgb(&buf, w, 2, 1, fmt), (0, 0, 0), "{fmt:?}");
}
}
#[test]
fn a_cursor_hanging_off_every_edge_is_clipped_not_wrapped() {
let (w, h, fmt) = (4usize, 4usize, PixelFormat::Bgrx);
// Top-left: only the bottom-right quarter of a 2×2 lands, at (0, 0).
let mut buf = vec![0u8; w * h * 4];
composite_cursor(
&mut buf,
w,
h,
fmt,
&cursor(-1, -1, 2, 2, (10, 20, 30), 255),
);
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (10, 20, 30));
assert_eq!(px_rgb(&buf, w, 1, 0, fmt), (0, 0, 0));
assert_eq!(px_rgb(&buf, w, 0, 1, fmt), (0, 0, 0));
// Bottom-right: only the top-left quarter lands, at (3, 3).
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &cursor(3, 3, 2, 2, (10, 20, 30), 255));
assert_eq!(px_rgb(&buf, w, 3, 3, fmt), (10, 20, 30));
assert_eq!(px_rgb(&buf, w, 2, 3, fmt), (0, 0, 0));
// Fully outside in each direction: the frame is untouched.
for pos in [(-2, 0), (0, -2), (4, 0), (0, 4), (-9, -9), (99, 99)] {
let mut buf = vec![0u8; w * h * 4];
composite_cursor(
&mut buf,
w,
h,
fmt,
&cursor(pos.0, pos.1, 2, 2, (255, 255, 255), 255),
);
assert!(buf.iter().all(|&b| b == 0), "drew something at {pos:?}");
}
}
#[test]
fn transparent_and_hidden_cursors_draw_nothing() {
let (w, h, fmt) = (2usize, 2usize, PixelFormat::Bgrx);
// Alpha 0 — every pixel skipped.
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 2, 2, (255, 255, 255), 0));
assert!(buf.iter().all(|&b| b == 0));
// `visible: false` — the whole blit is skipped.
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
c.visible = false;
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &c);
assert!(buf.iter().all(|&b| b == 0));
// No bitmap yet — likewise.
let mut c = cursor(0, 0, 2, 2, (255, 255, 255), 255);
c.rgba = Arc::new(Vec::new());
let mut buf = vec![0u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &c);
assert!(buf.iter().all(|&b| b == 0));
}
#[test]
fn half_alpha_blends_toward_the_destination() {
let (w, h, fmt) = (1usize, 1usize, PixelFormat::Bgrx);
// dst = white, src = black at 50% ⇒ mid grey (integer blend: (0*128 + 255*127)/255 = 127).
let mut buf = vec![255u8; w * h * 4];
composite_cursor(&mut buf, w, h, fmt, &cursor(0, 0, 1, 1, (0, 0, 0), 128));
assert_eq!(px_rgb(&buf, w, 0, 0, fmt), (127, 127, 127));
}
/// A layout the CPU blit cannot address must be declined, not mis-blitted.
#[test]
fn unsupported_layouts_are_declined() {
assert!(dst_offsets(PixelFormat::Nv12).is_none());
assert!(dst_offsets(PixelFormat::Yuv444).is_none());
let (w, h) = (2usize, 2usize);
let mut buf = vec![0u8; w * h * 4];
composite_cursor(
&mut buf,
w,
h,
PixelFormat::Nv12,
&cursor(0, 0, 2, 2, (255, 255, 255), 255),
);
assert!(buf.iter().all(|&b| b == 0), "NV12 must not be blitted");
}
// ---- composite_cursor_rgb10: the 10-bit unpack/repack round trip -------------------------
/// Pack an `x:R:G:B` (`X2Rgb10`) pixel — R at bit 20, G at 10, B at 0 — the way a producer does.
fn pack_x2rgb10(r: u32, g: u32, b: u32) -> [u8; 4] {
(0xC000_0000 | (r << 20) | (g << 10) | b).to_le_bytes()
}
#[test]
fn the_10bit_path_round_trips_an_untouched_pixel() {
// Alpha 0 ⇒ the blend is skipped entirely, so the packed pixel must come back bit-identical
// (including the top two bits, which are alpha and must survive the repack).
for (r, g, b) in [(0, 0, 0), (1023, 1023, 1023), (940, 64, 512), (1, 2, 3)] {
let src = pack_x2rgb10(r, g, b);
let mut buf = src.to_vec();
composite_cursor(
&mut buf,
1,
1,
PixelFormat::X2Rgb10,
&cursor(0, 0, 1, 1, (255, 255, 255), 0),
);
assert_eq!(buf, src, "({r},{g},{b}) was modified by a zero-alpha blend");
}
}
#[test]
fn the_10bit_path_writes_the_right_channel_at_the_right_shift() {
// Opaque pure red, 8-bit 255 → 10-bit 1023 (the `v<<2 | v>>6` expansion).
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
composite_cursor(
&mut buf,
1,
1,
PixelFormat::X2Rgb10,
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
);
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
assert_eq!((v >> 20) & 0x3ff, 1023, "R");
assert_eq!((v >> 10) & 0x3ff, 0, "G");
assert_eq!(v & 0x3ff, 0, "B");
assert_eq!(v & 0xc000_0000, 0xc000_0000, "alpha bits preserved");
// X2Bgr10 puts R at bit 0 and B at 20 — the SAME cursor must land in the other end.
let mut buf = pack_x2rgb10(0, 0, 0).to_vec();
composite_cursor(
&mut buf,
1,
1,
PixelFormat::X2Bgr10,
&cursor(0, 0, 1, 1, (255, 0, 0), 255),
);
let v = u32::from_le_bytes(buf[..4].try_into().unwrap());
assert_eq!(v & 0x3ff, 1023, "R at bit 0 for x:B:G:R");
assert_eq!((v >> 20) & 0x3ff, 0, "B untouched");
}
#[test]
fn the_10bit_path_clips_like_the_8bit_one() {
let (w, h) = (2usize, 2usize);
let mut buf: Vec<u8> = (0..w * h).flat_map(|_| pack_x2rgb10(0, 0, 0)).collect();
let before = buf.clone();
// Entirely off-frame.
composite_cursor(
&mut buf,
w,
h,
PixelFormat::X2Rgb10,
&cursor(-5, -5, 2, 2, (255, 255, 255), 255),
);
assert_eq!(buf, before);
// Straddling the top-left corner: only (0, 0) is written.
composite_cursor(
&mut buf,
w,
h,
PixelFormat::X2Rgb10,
&cursor(-1, -1, 2, 2, (255, 255, 255), 255),
);
let p0 = u32::from_le_bytes(buf[0..4].try_into().unwrap());
let p1 = u32::from_le_bytes(buf[4..8].try_into().unwrap());
assert_eq!((p0 >> 20) & 0x3ff, 1023);
assert_eq!((p1 >> 20) & 0x3ff, 0);
}
// ---- decode_bitmap_pixel: the producer's byte order ------------------------------------
#[test]
fn each_bitmap_format_is_decoded_to_straight_rgba() {
let s = [1u8, 2, 3, 4];
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_RGBA, &s),
(1, 2, 3, 4)
);
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_BGRA, &s),
(3, 2, 1, 4)
);
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ARGB, &s),
(2, 3, 4, 1)
);
assert_eq!(
decode_bitmap_pixel(spa::sys::SPA_VIDEO_FORMAT_ABGR, &s),
(4, 3, 2, 1)
);
// An unknown 4-byte format reads as RGBA rather than being rejected — documented behaviour.
assert_eq!(decode_bitmap_pixel(0xdead_beef, &s), (1, 2, 3, 4));
}
}
+149
View File
@@ -347,6 +347,155 @@ pub(super) fn build_cursor_meta_param() -> Result<Vec<u8>> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
/// The `SPA_PARAM_BUFFERS_dataType` bitmask a serialized Buffers pod carries.
///
/// A deliberately literal SPA reader rather than a heuristic scan: an object property is
/// `{ key: u32, flags: u32, value: spa_pod }` and a `spa_pod` is `{ size: u32, type: u32, body }`,
/// so the `i32` sits exactly 16 bytes past the key — and the intervening `size` word is itself
/// `4`, which is why "find the first plausible-looking int" reads the wrong field.
fn buffers_data_type(pod: &[u8]) -> i32 {
let key = spa::sys::SPA_PARAM_BUFFERS_dataType.to_ne_bytes();
let at = pod
.windows(4)
.position(|w| w == key)
.expect("dataType key present in the Buffers pod");
let word = |off: usize| u32::from_ne_bytes(pod[off..off + 4].try_into().unwrap());
assert_eq!(word(at + 8), 4, "dataType's value pod should be 4 bytes");
assert_eq!(
word(at + 12),
spa::sys::SPA_TYPE_Int,
"dataType's value pod should be an Int"
);
i32::from_ne_bytes(pod[at + 16..at + 20].try_into().unwrap())
}
const MEM_PTR: i32 = 1 << spa::sys::SPA_DATA_MemPtr;
const MEM_FD: i32 = 1 << spa::sys::SPA_DATA_MemFd;
const DMABUF: i32 = 1 << spa::sys::SPA_DATA_DmaBuf;
/// The three Buffers pods differ ONLY in this bitmask, and each bit is load-bearing:
/// `build_mappable_buffers` must include DmaBuf or gamescope's modifier-bearing pod wins the
/// format intersection and the BUFFER intersection is then empty (a link stuck in
/// "negotiating"); `build_shm_only_buffers` must EXCLUDE it or Mutter hands dmabufs and the
/// race-free download path is not race-free; `build_dmabuf_buffers` must exclude the mappable
/// types or an HDR session can be handed a MemFd buffer, which Mutter paints 8-bit ARGB32
/// regardless of the negotiated 10-bit format.
#[test]
fn each_buffers_pod_requests_exactly_its_own_data_types() {
assert_eq!(
buffers_data_type(&build_mappable_buffers().unwrap()),
MEM_PTR | MEM_FD | DMABUF,
"the CPU path must accept mappable dmabufs too"
);
assert_eq!(
buffers_data_type(&build_shm_only_buffers().unwrap()),
MEM_PTR | MEM_FD,
"PUNKTFUNK_FORCE_SHM must exclude DmaBuf"
);
assert_eq!(
buffers_data_type(&build_dmabuf_buffers().unwrap()),
DMABUF,
"the zero-copy/HDR path must exclude SHM"
);
}
/// Every pod builder must produce a pod libspa will accept back — a serializer that silently
/// emitted a malformed object would fail only at negotiation, on a live compositor.
#[test]
fn every_pod_round_trips_through_pod_from_bytes() {
let mut pods: Vec<(&str, Vec<u8>)> = vec![
("mappable buffers", build_mappable_buffers().unwrap()),
("shm-only buffers", build_shm_only_buffers().unwrap()),
("dmabuf buffers", build_dmabuf_buffers().unwrap()),
("cursor meta", build_cursor_meta_param().unwrap()),
(
"default format",
serialize_pod(build_default_format_obj(None)).unwrap(),
),
(
"dmabuf BGRx",
build_dmabuf_format(VideoFormat::BGRx, &[0, 1, 2], Some((1920, 1080, 60))).unwrap(),
),
(
"dmabuf NV12",
build_dmabuf_format(VideoFormat::NV12, &[0], Some((1280, 720, 60))).unwrap(),
),
(
"hdr xRGB",
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, None).unwrap(),
),
(
"hdr xBGR",
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, Some((3840, 2160, 120))).unwrap(),
),
];
for (name, bytes) in &mut pods {
assert!(!bytes.is_empty(), "{name} serialized to nothing");
assert_eq!(bytes.len() % 8, 0, "{name} is not 8-byte aligned/padded");
assert!(
spa::pod::Pod::from_bytes(bytes).is_some(),
"{name} did not parse back as a pod"
);
}
}
/// The HDR pods carry BOTH colorimetry properties MANDATORY — Mutter's HDR pods do the same, so
/// the intersection only exists if we speak them. Dropping either would negotiate an SDR-labelled
/// 10-bit stream (or nothing at all).
#[test]
fn the_hdr_pods_carry_mandatory_pq_and_bt2020() {
for fmt in [VideoFormat::xRGB_210LE, VideoFormat::xBGR_210LE] {
let pod = build_hdr_dmabuf_format(fmt, None).unwrap();
for (name, key) in [
(
"transferFunction",
spa::sys::SPA_FORMAT_VIDEO_transferFunction,
),
("colorPrimaries", spa::sys::SPA_FORMAT_VIDEO_colorPrimaries),
("modifier", spa::sys::SPA_FORMAT_VIDEO_modifier),
] {
assert!(
pod.windows(4).any(|w| w == key.to_ne_bytes()),
"{fmt:?} pod is missing {name}"
);
}
// The PQ id and BT.2020 id must both appear as values.
assert!(
pod.windows(4)
.any(|w| w == SPA_VIDEO_TRANSFER_SMPTE2084.to_ne_bytes()),
"{fmt:?} pod does not carry the PQ transfer id"
);
assert!(
pod.windows(4)
.any(|w| w == spa::sys::SPA_VIDEO_COLOR_PRIMARIES_BT2020.to_ne_bytes()),
"{fmt:?} pod does not carry BT.2020 primaries"
);
}
}
/// An NV12 offer pins BT.709 limited so gamescope's producer-side RGB→YUV shader matches OUR
/// bitstream colorimetry; the packed-RGB offer must NOT carry those (it is not YUV).
#[test]
fn only_the_nv12_offer_pins_the_colour_matrix() {
let nv12 = build_dmabuf_format(VideoFormat::NV12, &[0], None).unwrap();
let bgrx = build_dmabuf_format(VideoFormat::BGRx, &[0], None).unwrap();
for (name, key) in [
("colorMatrix", spa::sys::SPA_FORMAT_VIDEO_colorMatrix),
("colorRange", spa::sys::SPA_FORMAT_VIDEO_colorRange),
] {
assert!(
nv12.windows(4).any(|w| w == key.to_ne_bytes()),
"NV12 offer is missing {name}"
);
assert!(
!bgrx.windows(4).any(|w| w == key.to_ne_bytes()),
"packed-RGB offer should not pin {name}"
);
}
}
/// Pin our hand-written PQ transfer id against the real libspa binding. We can't take the /// Pin our hand-written PQ transfer id against the real libspa binding. We can't take the
/// constant from `pw::spa::sys` directly (older distro headers don't export it — see /// constant from `pw::spa::sys` directly (older distro headers don't export it — see
/// [`super::SPA_VIDEO_TRANSFER_SMPTE2084`]), so assert the two agree wherever the symbol /// [`super::SPA_VIDEO_TRANSFER_SMPTE2084`]), so assert the two agree wherever the symbol
+55
View File
@@ -1829,6 +1829,61 @@ mod tests {
use super::stall::Stall; use super::stall::Stall;
use super::*; use super::*;
/// W14: the mint must stay inside the publish token's 24-bit generation field, and must skip 0.
///
/// `IDD_GENERATION` is a full `u32` while `FrameToken` carries 24 bits and `unpack` MASKS what it
/// reads, so an unmasked `self.generation` stops matching any token past 2²⁴ recreates and
/// `try_consume`'s `tok.generation != self.generation` becomes permanently true — every frame
/// rejected, forever. The counter is parked just below the boundary here so the wrap is what gets
/// exercised, not the happy path. (Same-module access to the private static; no capturer is
/// running, and no other test touches it.)
#[test]
fn the_ring_generation_survives_the_publish_token() {
IDD_GENERATION.store(frame::FrameToken::GENERATION_MASK - 2, Ordering::Relaxed);
let mut seen = Vec::new();
for _ in 0..8 {
let g = next_generation();
assert_ne!(g, 0, "0 also means the cleared-`latest` sentinel");
assert_eq!(
g & frame::FrameToken::GENERATION_MASK,
g,
"generation {g} does not fit the token's field"
);
// The round trip `try_consume` actually performs.
let tok = frame::FrameToken {
generation: g,
seq: 12345,
slot: 2,
};
let back = frame::FrameToken::unpack(tok.pack());
assert_eq!(back.generation, g, "generation lost in the token");
assert_eq!(back.seq, 12345, "seq lost in the token");
assert_eq!(back.slot, 2, "slot lost in the token");
seen.push(g);
}
// The wrap really happened (we started 2 below the mask), and produced no duplicate 0.
assert!(
seen.contains(&frame::FrameToken::GENERATION_MASK),
"{seen:?}"
);
assert!(
seen.iter().any(|&g| g < 8),
"the counter should have wrapped: {seen:?}"
);
}
/// The 0 sentinel `recreate_ring` stores must be REJECTED by the generation compare, whatever
/// the live generation is — that is what stops a consume from the unwritten new ring.
#[test]
fn the_cleared_latest_sentinel_never_matches_a_live_generation() {
let cleared = frame::FrameToken::unpack(0);
assert_eq!(cleared.generation, 0);
assert_eq!(cleared.seq, 0);
for g in [1u32, 2, 0x7F_FFFF, frame::FrameToken::GENERATION_MASK] {
assert_ne!(cleared.generation, g, "sentinel matched generation {g}");
}
}
/// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and /// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and
/// return what each `note_fresh` produced. /// return what each `note_fresh` produced.
fn watch_run(offsets_ms: &[u64]) -> Vec<Option<Stall>> { fn watch_run(offsets_ms: &[u64]) -> Vec<Option<Stall>> {
@@ -402,15 +402,13 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
let color = read_bitmap_32(dc, ii.hbmColor)?; let color = read_bitmap_32(dc, ii.hbmColor)?;
let (w, h) = (color.w as u32, color.h as u32); let (w, h) = (color.w as u32, color.h as u32);
let mut rgba = bgra_to_rgba(&color.bgra); let mut rgba = bgra_to_rgba(&color.bgra);
if rgba.chunks_exact(4).all(|p| p[3] == 0) { if alpha_is_empty(&rgba) {
// Alpha-less color cursor: transparency lives in the AND mask. // Alpha-less color cursor: transparency lives in the AND mask.
let mask = read_bitmap_32(dc, ii.hbmMask)?; let mask = read_bitmap_32(dc, ii.hbmMask)?;
if mask.w != color.w || mask.h < color.h { if mask.w != color.w || mask.h < color.h {
return None; return None;
} }
for (px, m) in rgba.chunks_exact_mut(4).zip(mask.bgra.chunks_exact(4)) { apply_and_mask_alpha(&mut rgba, &mask.bgra);
px[3] = if m[0] != 0 { 0 } else { 0xFF }; // mask white (AND=1) = transparent
}
} }
Some((rgba, w, h)) Some((rgba, w, h))
} else { } else {
@@ -419,42 +417,8 @@ fn convert(ii: &ICONINFO) -> Option<(Vec<u8>, u32, u32)> {
return None; return None;
} }
let (w, h) = (mask.w as usize, (mask.h / 2) as usize); let (w, h) = (mask.w as usize, (mask.h / 2) as usize);
let row = w * 4; let (and_plane, xor_plane) = mask.bgra.split_at(h * w * 4);
let (and_plane, xor_plane) = mask.bgra.split_at(h * row); let rgba = mono_planes_to_rgba(and_plane, xor_plane, w, h);
let mut rgba = vec![0u8; w * h * 4];
let mut invert = vec![false; w * h];
for i in 0..w * h {
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
let px = &mut rgba[i * 4..i * 4 + 4];
match (a, x) {
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
(true, false) => {} // transparent (already zeroed)
(true, true) => {
px.copy_from_slice(&[0, 0, 0, 0xFF]);
invert[i] = true;
}
}
}
// White outline around invert regions so the (now black) shape survives dark
// backgrounds: any transparent 8-neighbor of an invert pixel turns opaque white.
for y in 0..h as i32 {
for x in 0..w as i32 {
if !invert[(y * w as i32 + x) as usize] {
continue;
}
for (dx, dy) in NEIGHBORS {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
continue;
}
let o = (ny * w as i32 + nx) as usize * 4;
if rgba[o + 3] == 0 {
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
}
}
}
}
Some((rgba, w as u32, h as u32)) Some((rgba, w as u32, h as u32))
} }
})(); })();
@@ -536,3 +500,204 @@ fn bgra_to_rgba(bgra: &[u8]) -> Vec<u8> {
} }
out out
} }
/// Whether a 32bpp RGBA buffer's alpha channel is entirely zero — the "old-style cursor with no
/// alpha" test, whose transparency lives in the AND mask instead ([`apply_and_mask_alpha`]).
fn alpha_is_empty(rgba: &[u8]) -> bool {
rgba.chunks_exact(4).all(|p| p[3] == 0)
}
/// Take alpha from an expanded AND mask: mask WHITE (AND bit 1) means transparent, black opaque.
/// `mask_bgra` is the 32bpp expansion `GetDIBits` produces from the 1bpp mask, so any non-zero
/// channel byte is "set".
fn apply_and_mask_alpha(rgba: &mut [u8], mask_bgra: &[u8]) {
for (px, m) in rgba.chunks_exact_mut(4).zip(mask_bgra.chunks_exact(4)) {
px[3] = if m[0] != 0 { 0 } else { 0xFF };
}
}
/// The monochrome-cursor truth table, plus the white outline that makes an INVERT region legible.
///
/// A monochrome `HCURSOR` has no colour bitmap: `hbmMask` is DOUBLE height — the AND plane over the
/// XOR plane — and the pair encodes four states (the WebRTC/Chromium table):
///
/// | AND | XOR | meaning | straight-alpha result |
/// |-----|-----|-------------|------------------------------------------|
/// | 0 | 0 | black | opaque black |
/// | 0 | 1 | white | opaque white |
/// | 1 | 0 | transparent | fully transparent |
/// | 1 | 1 | INVERT dst | opaque black + a grown white outline |
///
/// INVERT is unrepresentable in straight alpha (it is a per-pixel XOR against whatever is behind
/// it), so it becomes opaque black and every TRANSPARENT 8-neighbour of an invert pixel is turned
/// opaque white. That outline is what keeps the text I-beam — which is almost entirely invert
/// pixels — legible over dark content; the earlier translucent-grey stand-in did not.
///
/// Extracted from `convert`'s GDI plumbing (sweep Phase 6.6) so the table is testable: the caller
/// needs a live `HCURSOR` and a screen DC, this needs two byte slices.
fn mono_planes_to_rgba(and_plane: &[u8], xor_plane: &[u8], w: usize, h: usize) -> Vec<u8> {
let mut rgba = vec![0u8; w * h * 4];
let mut invert = vec![false; w * h];
for i in 0..w * h {
let (a, x) = (and_plane[i * 4] != 0, xor_plane[i * 4] != 0);
let px = &mut rgba[i * 4..i * 4 + 4];
match (a, x) {
(false, false) => px.copy_from_slice(&[0, 0, 0, 0xFF]),
(false, true) => px.copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]),
(true, false) => {} // transparent (already zeroed)
(true, true) => {
px.copy_from_slice(&[0, 0, 0, 0xFF]);
invert[i] = true;
}
}
}
for y in 0..h as i32 {
for x in 0..w as i32 {
if !invert[(y * w as i32 + x) as usize] {
continue;
}
for (dx, dy) in NEIGHBORS {
let (nx, ny) = (x + dx, y + dy);
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
continue;
}
let o = (ny * w as i32 + nx) as usize * 4;
if rgba[o + 3] == 0 {
rgba[o..o + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
}
}
}
}
rgba
}
#[cfg(test)]
mod tests {
use super::*;
/// Expand a 1-bit-per-pixel plane (as `GetDIBits` does) into the 32bpp form the converters read:
/// any non-zero channel byte means "bit set".
fn plane(bits: &[u8]) -> Vec<u8> {
bits.iter()
.flat_map(|&b| {
let v = if b != 0 { 0xFF } else { 0 };
[v, v, v, 0]
})
.collect()
}
fn px(rgba: &[u8], i: usize) -> [u8; 4] {
rgba[i * 4..i * 4 + 4].try_into().unwrap()
}
const OPAQUE_BLACK: [u8; 4] = [0, 0, 0, 0xFF];
const OPAQUE_WHITE: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF];
const TRANSPARENT: [u8; 4] = [0, 0, 0, 0];
/// All four AND/XOR states, in one 4×1 row — the table `mono_planes_to_rgba` documents.
#[test]
fn the_monochrome_truth_table_is_exact() {
// (0,0) black (0,1) white (1,0) transparent (1,1) invert
let and = plane(&[0, 0, 1, 1]);
let xor = plane(&[0, 1, 0, 1]);
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
assert_eq!(px(&out, 0), OPAQUE_BLACK, "AND=0 XOR=0 ⇒ black");
assert_eq!(px(&out, 1), OPAQUE_WHITE, "AND=0 XOR=1 ⇒ white");
// Pixel 2 is transparent by the table, but it is an 8-neighbour of the invert pixel at 3,
// so the outline claims it — that IS the documented behaviour.
assert_eq!(
px(&out, 2),
OPAQUE_WHITE,
"outline grows into adjacent transparency"
);
assert_eq!(px(&out, 3), OPAQUE_BLACK, "AND=1 XOR=1 ⇒ black + outline");
}
/// Transparency survives when there is no invert pixel next to it.
#[test]
fn transparent_pixels_stay_transparent_without_an_invert_neighbour() {
let and = plane(&[1, 1, 1, 1]);
let xor = plane(&[0, 0, 0, 0]);
let out = mono_planes_to_rgba(&and, &xor, 4, 1);
for i in 0..4 {
assert_eq!(px(&out, i), TRANSPARENT, "pixel {i}");
}
}
/// The outline grows into all eight neighbours, and only into TRANSPARENT ones — it must not
/// repaint a black or white shape pixel.
#[test]
fn the_invert_outline_covers_eight_neighbours_and_overwrites_nothing() {
// 3×3, invert at the centre, everything else transparent.
let and = plane(&[1, 1, 1, 1, 1, 1, 1, 1, 1]);
let mut xor = plane(&[0; 9]);
for b in &mut xor[4 * 4..4 * 4 + 3] {
*b = 0xFF; // centre pixel's XOR bit
}
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
assert_eq!(px(&out, 4), OPAQUE_BLACK, "the invert pixel itself");
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
assert_eq!(px(&out, i), OPAQUE_WHITE, "neighbour {i} outlined");
}
// Now surround it with BLACK shape pixels (AND=0, XOR=0): the outline must leave them alone.
let and = plane(&[0, 0, 0, 0, 1, 0, 0, 0, 0]);
let out = mono_planes_to_rgba(&and, &xor, 3, 3);
for i in [0, 1, 2, 3, 5, 6, 7, 8] {
assert_eq!(
px(&out, i),
OPAQUE_BLACK,
"neighbour {i} must not be repainted"
);
}
}
/// The outline must clip at the bitmap edges rather than wrap to the opposite side.
#[test]
fn the_outline_clips_at_the_edges() {
// 2×2 with the invert at (0, 0): only (1,0), (0,1) and (1,1) can be outlined.
let and = plane(&[1, 1, 1, 1]);
let mut xor = plane(&[0; 4]);
for b in &mut xor[0..3] {
*b = 0xFF;
}
let out = mono_planes_to_rgba(&and, &xor, 2, 2);
assert_eq!(px(&out, 0), OPAQUE_BLACK);
for i in [1, 2, 3] {
assert_eq!(px(&out, i), OPAQUE_WHITE, "in-bounds neighbour {i}");
}
}
// ---- the alpha-less colour path ---------------------------------------------------------
#[test]
fn an_empty_alpha_channel_is_detected() {
assert!(alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 0]));
assert!(!alpha_is_empty(&[1, 2, 3, 0, 4, 5, 6, 1]));
assert!(alpha_is_empty(&[]), "no pixels ⇒ vacuously empty");
}
/// Mask WHITE (AND bit 1) = transparent, black = opaque — and the colour bytes are untouched.
#[test]
fn the_and_mask_supplies_alpha_for_an_alpha_less_cursor() {
let mut rgba = vec![
10, 20, 30, 0, // pixel 0
40, 50, 60, 0, // pixel 1
];
let mask = plane(&[1, 0]); // pixel 0 masked out, pixel 1 kept
apply_and_mask_alpha(&mut rgba, &mask);
assert_eq!(px(&rgba, 0), [10, 20, 30, 0], "masked ⇒ transparent");
assert_eq!(px(&rgba, 1), [40, 50, 60, 0xFF], "unmasked ⇒ opaque");
}
/// A mask with FEWER pixels than the colour bitmap must not panic — `zip` stops at the shorter
/// side, leaving the tail at whatever alpha it had (the caller has already required
/// `mask.h >= color.h`, so this is the belt).
#[test]
fn a_short_mask_does_not_panic() {
let mut rgba = vec![1, 2, 3, 0, 4, 5, 6, 0, 7, 8, 9, 0];
apply_and_mask_alpha(&mut rgba, &plane(&[0]));
assert_eq!(px(&rgba, 0), [1, 2, 3, 0xFF]);
assert_eq!(px(&rgba, 1), [4, 5, 6, 0]);
}
}