diff --git a/crates/pf-capture/src/linux/pw_cursor.rs b/crates/pf-capture/src/linux/pw_cursor.rs index f13feee2..9219ad61 100644 --- a/crates/pf-capture/src/linux/pw_cursor.rs +++ b/crates/pf-capture/src/linux/pw_cursor.rs @@ -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 stride = if stride < row { row } else { stride }; - // `span` is the exact byte extent the strided loop reads: `stride·(bh-1) + row`. Compute it - // with checked arithmetic (a producer stride near `i32::MAX` would otherwise overflow) and - // require the whole pixel block `[bmp_off + pix_off, +span)` to lie inside the region before - // 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, + let Some(extent) = bitmap_extent(bmp_off, pix_off, stride, row, bh as usize, region_size) + else { + return; }; - match bmp_off - .checked_add(pix_off) - .and_then(|v| v.checked_add(span)) - { - 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) }; + // SAFETY: `bitmap_extent` returned `Some`, which means (see its contract) the whole range + // `[bmp_off + pix_off, +len)` lies inside `region_size` and `len` is EXACTLY the extent the + // 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()) }; let mut rgba = vec![0u8; bw as usize * bh as usize * 4]; for y in 0..bh 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); } +/// 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·(bh−1) + 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> { + 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`, /// or `None` for a layout the CPU cursor blit doesn't handle (YUV/10-bit — those never reach /// 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·(bh−1)` 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 = (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)); + } +} diff --git a/crates/pf-capture/src/linux/pw_pods.rs b/crates/pf-capture/src/linux/pw_pods.rs index a562d2b2..b1bd7b71 100644 --- a/crates/pf-capture/src/linux/pw_pods.rs +++ b/crates/pf-capture/src/linux/pw_pods.rs @@ -347,6 +347,155 @@ pub(super) fn build_cursor_meta_param() -> Result> { #[cfg(test)] 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)> = 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 /// 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 diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index 1a2629cd..0a2c2198 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -1829,6 +1829,61 @@ mod tests { use super::stall::Stall; 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 /// return what each `note_fresh` produced. fn watch_run(offsets_ms: &[u64]) -> Vec> { diff --git a/crates/pf-capture/src/windows/idd_push/cursor_poll.rs b/crates/pf-capture/src/windows/idd_push/cursor_poll.rs index 7c25cd4c..4bce086b 100644 --- a/crates/pf-capture/src/windows/idd_push/cursor_poll.rs +++ b/crates/pf-capture/src/windows/idd_push/cursor_poll.rs @@ -402,15 +402,13 @@ fn convert(ii: &ICONINFO) -> Option<(Vec, u32, u32)> { let color = read_bitmap_32(dc, ii.hbmColor)?; let (w, h) = (color.w as u32, color.h as u32); 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. let mask = read_bitmap_32(dc, ii.hbmMask)?; if mask.w != color.w || mask.h < color.h { return None; } - for (px, m) in rgba.chunks_exact_mut(4).zip(mask.bgra.chunks_exact(4)) { - px[3] = if m[0] != 0 { 0 } else { 0xFF }; // mask white (AND=1) = transparent - } + apply_and_mask_alpha(&mut rgba, &mask.bgra); } Some((rgba, w, h)) } else { @@ -419,42 +417,8 @@ fn convert(ii: &ICONINFO) -> Option<(Vec, u32, u32)> { return None; } 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 * row); - 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]); - } - } - } - } + let (and_plane, xor_plane) = mask.bgra.split_at(h * w * 4); + let rgba = mono_planes_to_rgba(and_plane, xor_plane, w, h); Some((rgba, w as u32, h as u32)) } })(); @@ -536,3 +500,204 @@ fn bgra_to_rgba(bgra: &[u8]) -> Vec { } 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 { + 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 { + 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]); + } +}