All of lore.kernel.org
 help / color / mirror / Atom feed
* [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted)
@ 2026-06-17 15:12 Mike Lothian
  2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
                   ` (8 more replies)
  0 siblings, 9 replies; 41+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
  To: dri-devel
  Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
	linux-kernel

Vino is a clean-room, in-kernel Rust DRM driver for DisplayLink DL3 USB
docks (Dell Universal Dock D6000, 17e9:6006), a native replacement for
the out-of-tree EVDI module plus the proprietary DisplayLinkManager
userspace daemon. It is built on the in-tree Rust USB, crypto and DRM/KMS
bindings, which are posted as their own prerequisite series; this series
is the driver that consumes them and touches only drivers/gpu/drm/vino/
plus the two drivers/gpu/drm/{Kconfig,Makefile} wiring lines.

Prerequisite binding series (apply in this order before this one):
  - [RFC PATCH 0/9] rust: usb: synchronous bulk/control transfers + helpers
    https://lore.kernel.org/r/20260617145946.1894-1-mike@fireburn.co.uk
  - [RFC PATCH 0/2] rust: crypto: library AES-128 / SHA-256 / HMAC + RSA
    https://lore.kernel.org/r/20260617150143.2152-1-mike@fireburn.co.uk
  - [RFC PATCH 0/5] rust: drm: minimal KMS bindings, EDID read, rotation, HDCP defs
    https://lore.kernel.org/r/20260617150232.2210-1-mike@fireburn.co.uk

It is posted as an [RFC] in a deliberately INCOMPLETE state to ask for
help with one remaining blocker -- the control-plane engagement "wall"
(see below and patch 3). It was previously a single ~5000-line patch; it
has been split into one self-contained subsystem per patch so it is
reviewable and so others can pick up and fix a single layer in isolation:

  1. skeleton + USB bind + the plaintext connect handshake (proto);
  2. the clean-room HDCP 2.2 AKE/LC/SKE (crypto, rng, hdcp, ake, golden);
  3. the AES-CTR/AES-CMAC ("Dl3Cmac") control-plane seal + arm (cp);
  4. the Vino RawRl mode-2 framebuffer codec (video);
  5. the DRM/KMS device + EP08 framebuffer scanout (drm_sink);
  6. DDC/CI brightness/contrast, DPMS power and DFU device-info;
  7. KUnit self-tests for the protocol and crypto paths.

Each commit builds and the module loads on its own, so the series is
bisectable. With the prerequisite binding series applied it is
git-am-clean and checkpatch.pl --strict reports no errors (the only notes
are the expected "does MAINTAINERS need updating?" on the new files and a
few >100-column verbatim log strings).

Note on tooling: the implementation was AI-assisted (see the Assisted-by:
trailers); the reverse-engineering, the hardware testing on a real D6000,
and responsibility for the code under the DCO are the author's.

What works, all on real hardware (Dell Universal Dock D6000):
 - the plaintext connect handshake over the Rust USB bulk + control API;
 - the clean-room HDCP 2.2 AKE/LC/SKE -- H', L' and V' all verify against
   the dock, establishing the shared session key;
 - the AES-CTR + AES-CMAC control-plane seal, byte-exact against the
   reference daemon's captured wire, plus the plaintext stream-open arm;
 - registration of a real struct drm_device via an atomic KMS pipeline, so
   the dock appears as a mode-settable GEM/dumb DRM card, with a live EP08
   framebuffer-scanout hook on every page-flip.

What does NOT work -- the wall (help wanted):
 After the arm marker the driver sends the first encrypted control-plane
 frame and the dock never acknowledges it (the wsub=0x45 ack count stays
 0), so the CP cipher never engages and no pixels flow. Every
 host-observable channel has been matched to the reference daemon -- the
 bulk wire is byte-identical through the arm and the first encrypted frame,
 the AKE verifies, the seal/MAC/IV are byte-exact, the full EP0 control and
 endpoint sets match, and the arm timing is tighter than the daemon's --
 yet the dock silently drops our encrypted CP while engaging the daemon's.
 The gate appears not to be visible on the host wire. If you have knowledge
 of the DisplayLink DL3 control-plane engagement sequence, or ideas for
 isolating a whole-bus timing/ordering property a per-channel diff cannot
 see, help would be very welcome.

Not-for-merge as-is: the driver carries RE diagnostics behind pr_debug and
a small captured plaintext cap-announce skeleton (golden) that a fully
field-derived builder should eventually replace.

Mike Lothian (7):
  drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up
  drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE
  drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm
  drm/vino: add the Vino (RawRl mode-2) framebuffer codec
  drm/vino: register a DRM/KMS device and scan out to EP08
  drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info
  drm/vino: add KUnit self-tests for the protocol and crypto paths

 drivers/gpu/drm/Kconfig          |    2 +
 drivers/gpu/drm/Makefile         |    2 +
 drivers/gpu/drm/vino/Kconfig     |   21 +
 drivers/gpu/drm/vino/Makefile    |    2 +
 drivers/gpu/drm/vino/ake.rs      |  167 +++
 drivers/gpu/drm/vino/cp.rs       |  686 ++++++++++
 drivers/gpu/drm/vino/crypto.rs   |   81 ++
 drivers/gpu/drm/vino/drm_sink.rs | 1466 +++++++++++++++++++++
 drivers/gpu/drm/vino/golden.rs   |   69 +
 drivers/gpu/drm/vino/hdcp.rs     |  167 +++
 drivers/gpu/drm/vino/proto.rs    |   73 ++
 drivers/gpu/drm/vino/rng.rs      |   12 +
 drivers/gpu/drm/vino/video.rs    |  348 +++++
 drivers/gpu/drm/vino/vino.rs     | 2053 ++++++++++++++++++++++++++++++
 14 files changed, 5149 insertions(+)
 create mode 100644 drivers/gpu/drm/vino/Kconfig
 create mode 100644 drivers/gpu/drm/vino/Makefile
 create mode 100644 drivers/gpu/drm/vino/ake.rs
 create mode 100644 drivers/gpu/drm/vino/cp.rs
 create mode 100644 drivers/gpu/drm/vino/crypto.rs
 create mode 100644 drivers/gpu/drm/vino/drm_sink.rs
 create mode 100644 drivers/gpu/drm/vino/golden.rs
 create mode 100644 drivers/gpu/drm/vino/hdcp.rs
 create mode 100644 drivers/gpu/drm/vino/proto.rs
 create mode 100644 drivers/gpu/drm/vino/rng.rs
 create mode 100644 drivers/gpu/drm/vino/video.rs
 create mode 100644 drivers/gpu/drm/vino/vino.rs

-- 
2.54.0


^ permalink raw reply	[flat|nested] 41+ messages in thread

* [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up
  2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
  2026-06-17 15:17   ` Miguel Ojeda
                     ` (2 more replies)
  2026-06-17 15:12 ` [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE Mike Lothian
                   ` (7 subsequent siblings)
  8 siblings, 3 replies; 41+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
  To: dri-devel
  Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
	linux-kernel

Vino is a clean-room, in-kernel Rust DRM driver for DisplayLink DL3 USB
docks (Dell Universal Dock D6000, 17e9:6006), a native replacement for
the out-of-tree EVDI module plus the proprietary DisplayLinkManager
userspace daemon. It is built on the in-tree Rust USB, crypto and DRM/KMS
bindings (posted as their own prerequisite series).

This is posted as an [RFC] in a deliberately INCOMPLETE state to ask for
help with one remaining blocker (see the final patch of this series for
the full "help wanted" note).

This first patch is the skeleton. It registers a usb::Driver for the
D6000, binds the control interface (interface 0; interface 1 binds idle,
the audio/Ethernet interfaces are declined so their class drivers claim
them), and runs the plaintext connect handshake on a workqueue: the
control-request preamble (device-open vendor reads, SET_INTERFACE, the
0x24/0x22 pair) and the three bulk init messages over the Rust USB bulk +
control transfer API, reading the single ACK.

The wire framing and the plaintext init message builders live in the new
proto module. The HDCP 2.2 AKE, the AES-CTR/AES-CMAC control plane, the
Vino codec and the DRM/KMS sink are added in the following patches, one
subsystem per patch, so each can be reviewed (and fixed) on its own.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
 drivers/gpu/drm/Kconfig       |   2 +
 drivers/gpu/drm/Makefile      |   2 +
 drivers/gpu/drm/vino/Kconfig  |  21 ++
 drivers/gpu/drm/vino/Makefile |   2 +
 drivers/gpu/drm/vino/proto.rs |  73 +++++++
 drivers/gpu/drm/vino/vino.rs  | 391 ++++++++++++++++++++++++++++++++++
 6 files changed, 491 insertions(+)
 create mode 100644 drivers/gpu/drm/vino/Kconfig
 create mode 100644 drivers/gpu/drm/vino/Makefile
 create mode 100644 drivers/gpu/drm/vino/proto.rs
 create mode 100644 drivers/gpu/drm/vino/vino.rs

diff --git a/drivers/gpu/drm/Kconfig b/drivers/gpu/drm/Kconfig
index 323422861e8f..8ea7f2bb9300 100644
--- a/drivers/gpu/drm/Kconfig
+++ b/drivers/gpu/drm/Kconfig
@@ -370,3 +370,5 @@ endif
 # Separate option because drm_panel_orientation_quirks.c is shared with fbdev
 config DRM_PANEL_ORIENTATION_QUIRKS
 	tristate
+
+source "drivers/gpu/drm/vino/Kconfig"
diff --git a/drivers/gpu/drm/Makefile b/drivers/gpu/drm/Makefile
index e97faabcd783..8c6322df7c1f 100644
--- a/drivers/gpu/drm/Makefile
+++ b/drivers/gpu/drm/Makefile
@@ -256,3 +256,5 @@ quiet_cmd_hdrtest = HDRTEST $(patsubst %.hdrtest,%.h,$@)
 
 $(obj)/%.hdrtest: $(src)/%.h FORCE
 	$(call if_changed_dep,hdrtest)
+
+obj-$(CONFIG_DRM_VINO) += vino/
diff --git a/drivers/gpu/drm/vino/Kconfig b/drivers/gpu/drm/vino/Kconfig
new file mode 100644
index 000000000000..234ce92736e4
--- /dev/null
+++ b/drivers/gpu/drm/vino/Kconfig
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: GPL-2.0
+config DRM_VINO
+	tristate "DisplayLink DL3 (Vino) open driver"
+	depends on USB = y
+	depends on DRM
+	depends on RUST
+	select DRM_KMS_HELPER
+	select DRM_GEM_SHMEM_HELPER
+	select RUST_DRM_GEM_SHMEM_HELPER
+	help
+	  Open in-kernel Rust driver for DisplayLink DL3 USB docks (Dell
+	  Universal Dock D6000 and relatives), reverse-engineered in this tree
+	  (see vino-re/docs/00-canonical-guide.md).
+
+	  Phase 0 binds the dock over USB only. USB data transfer, the HDCP 2.2
+	  control plane, mode-set, the Vino codec and the DRM/KMS sink are added
+	  in later phases (see vino-kmod/README.md).
+
+	  To compile this as a module, choose M here: the module is called vino.
+
+	  If unsure, say N.
diff --git a/drivers/gpu/drm/vino/Makefile b/drivers/gpu/drm/vino/Makefile
new file mode 100644
index 000000000000..6e39668040f3
--- /dev/null
+++ b/drivers/gpu/drm/vino/Makefile
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: GPL-2.0
+obj-$(CONFIG_DRM_VINO) += vino.o
diff --git a/drivers/gpu/drm/vino/proto.rs b/drivers/gpu/drm/vino/proto.rs
new file mode 100644
index 000000000000..cae6eae46b7a
--- /dev/null
+++ b/drivers/gpu/drm/vino/proto.rs
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! The DL3 "universal" wire framing and the plaintext session-init messages (sec 3/sec 4).
+
+use super::*;
+
+/// Append a sec 3-framed message to `out` with an explicit `sub_len_dw`: a 16-byte
+/// little-endian header (`pad(2) | size(2)=total-4 | type(4) | sub_id(2) |
+/// sub_len_dw(2) | seq(4)`) followed by `body`.
+///
+/// HDCP OUT messages (sec 5.1) carry DLM-fixed `sub_len_dw` values that are *not*
+/// `body.len() / 4`, so the framer cannot derive it -- the caller passes it.
+pub(super) fn push_frame_with(
+    out: &mut KVec<u8>,
+    msg_type: u32,
+    sub_id: u16,
+    sub_len_dw: u16,
+    seq: u32,
+    body: &[u8],
+) -> Result {
+    let size = ((16 + body.len()) - 4) as u16;
+    out.extend_from_slice(&[0, 0], GFP_KERNEL)?;
+    out.extend_from_slice(&size.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&msg_type.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&sub_id.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&sub_len_dw.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&seq.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(body, GFP_KERNEL)?;
+    Ok(())
+}
+
+/// `init_25` body (sec 4, verified 2026-05-27). Framed with `sub_len_dw=0` -- the
+/// DLM-fixed value, NOT `body.len()/4` (the dock ignores/rejects otherwise).
+pub(super) const INIT_25: [u8; 16] =
+    [0x05, 0, 0x08, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
+/// `init_4` (Part A) body (sec 4), also framed with `sub_len_dw=0`.
+pub(super) const INIT_4: [u8; 16] =
+    [0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
+/// The first HDCP-channel probe **body** (Part B of the init_4+probe transfer,
+/// sec 4): a 32-byte body leading with `14 00 76 00`, the rest zero. It is wrapped
+/// in its own type=4 sub=0x04 frame (`sub_len_dw=0x0a`) -- see [`init_4_probe`].
+/// The dock only ACKs once this framed probe arrives.
+pub(super) const PROBE_BODY: [u8; 32] = {
+    let mut p = [0u8; 32];
+    p[0] = 0x14;
+    p[2] = 0x76;
+    p
+};
+
+/// `init_0`: 16-byte framing header only, empty body (sec 4).
+pub(super) fn init_0() -> Result<KVec<u8>> {
+    let mut buf = KVec::with_capacity(16, GFP_KERNEL)?;
+    push_frame_with(&mut buf, 0x01, 0x00, 0, 0, &[])?;
+    Ok(buf)
+}
+
+/// `init_25`: type=2 sub=0x25, `sub_len_dw=0`, 32 bytes total (sec 4).
+pub(super) fn init_25() -> Result<KVec<u8>> {
+    let mut buf = KVec::with_capacity(32, GFP_KERNEL)?;
+    push_frame_with(&mut buf, 0x02, 0x25, 0, 0, &INIT_25)?;
+    Ok(buf)
+}
+
+/// `init_4` + HDCP probe as one 80-byte transfer (sec 4): Part A (type=2 sub=0x04,
+/// `sub_len_dw=0`, 32 B) concatenated with Part B -- the probe framed as type=4
+/// sub=0x04 with `sub_len_dw=0x0a` over the 32-byte [`PROBE_BODY`] (48 B). This
+/// is the message the dock ACKs.
+pub(super) fn init_4_probe() -> Result<KVec<u8>> {
+    let mut buf = KVec::with_capacity(80, GFP_KERNEL)?;
+    push_frame_with(&mut buf, 0x02, 0x04, 0, 0, &INIT_4)?; // Part A
+    push_frame_with(&mut buf, 0x04, 0x04, 0x0a, 0, &PROBE_BODY)?; // Part B (framed probe)
+    Ok(buf)
+}
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
new file mode 100644
index 000000000000..79f446041b64
--- /dev/null
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -0,0 +1,391 @@
+// SPDX-License-Identifier: GPL-2.0
+// SPDX-FileCopyrightText: Copyright (C) 2026 Mike Lothian
+
+//! Vino -- open in-kernel Rust driver for DisplayLink DL3 docks (Dell D6000, ...).
+//!
+//! This is an `[RFC]` work-in-progress, posted to ask for help. It is a clean-room
+//! reverse-engineered replacement for the proprietary DisplayLinkManager userspace
+//! daemon + the EVDI kernel module, written natively in Rust against the in-tree USB,
+//! crypto and DRM/KMS bindings.
+//!
+//! This first patch is the skeleton: it binds the dock over USB and runs the plaintext
+//! connect handshake (the control-request preamble and the three bulk init messages over
+//! the Rust USB bulk + control transfer API). The HDCP 2.2 AKE, the AES-CTR/AES-CMAC
+//! control plane, the Vino codec and the DRM/KMS sink are added in the following patches.
+//!
+//! Device: VID 0x17e9 (DisplayLink) / PID 0x6006 (Dell Universal Dock D6000).
+
+use kernel::{
+    alloc::flags::GFP_KERNEL,
+    device::{self, Core},
+    error::code::ENODEV,
+    prelude::*,
+    sync::{aref::ARef, Arc},
+    time::Delta,
+    usb,
+    workqueue::{self, impl_has_work, new_work, Work, WorkItem},
+};
+
+/// DisplayLink vendor id.
+const VID_DISPLAYLINK: u16 = 0x17e9;
+/// Dell Universal Dock D6000 (DL3 family) product id.
+const PID_D6000: u16 = 0x6006;
+
+/// Control + per-head bulk endpoints (guide sec 2).
+const EP_CTRL_OUT: u8 = 0x02;
+const EP_CTRL_IN: u8 = 0x84;
+
+/// USB transfer timeout used during bring-up.
+fn timeout() -> Delta {
+    Delta::from_millis(1000)
+}
+
+mod proto;
+
+/// Per-bound-interface driver state.
+struct VinoDriver {
+    _intf: ARef<usb::Interface>,
+}
+
+/// Deferred bring-up work item: the bring-up sequence run on the system workqueue instead
+/// of inline in `probe()` (which would pin the driver-model probe thread on blocking USB
+/// I/O while the card node is live). Holds a refcounted handle to the bound interface (and,
+/// once the DRM sink exists, the DRM device), so they outlive `probe()`.
+#[pin_data]
+struct BringUp {
+    intf: ARef<usb::Interface>,
+    #[pin]
+    work: Work<BringUp>,
+}
+
+impl_has_work! {
+    impl HasWork<Self> for BringUp { self.work }
+}
+
+impl BringUp {
+    fn new(intf: ARef<usb::Interface>) -> Result<Arc<Self>> {
+        Arc::pin_init(
+            pin_init!(BringUp {
+                intf,
+                work <- new_work!("vino::bring_up"),
+            }),
+            GFP_KERNEL,
+        )
+    }
+}
+
+impl WorkItem for BringUp {
+    type Pointer = Arc<BringUp>;
+
+    fn run(this: Arc<BringUp>) {
+        let cdev: &device::Device = this.intf.as_ref();
+        let dev: &usb::Device = this.intf.as_ref();
+        // WIP scaffold: attempt the plaintext bring-up. Bind regardless of the outcome --
+        // there is no display path yet (the HDCP AKE, control plane and DRM sink land in
+        // the following patches).
+        match VinoDriver::bring_up(dev) {
+            Ok(()) => dev_info!(cdev, "vino: plaintext session init OK\n"),
+            Err(e) => dev_info!(cdev, "vino: session init incomplete ({e:?}) -- WIP\n"),
+        }
+    }
+}
+
+impl VinoDriver {
+    /// Plaintext session bring-up (sec 4): control-request preamble then the three
+    /// bulk init messages, reading the single ACK. Best-effort during scaffold
+    /// bring-up -- errors are logged, not fatal.
+    fn bring_up(dev: &usb::Device) -> Result {
+        // Control-request preamble (sec 4): dock-id read, interface selection, then the
+        // vendor_out 0x24 / vendor_in 0x22 pairs that kick off the HDCP path. (The
+        // GET_DESCRIPTOR string reads DLM also issues look cosmetic and are omitted.)
+        const VENDOR_OUT: u8 = 0x40; // host->dev, vendor, device
+        const VENDOR_IN_IFACE: u8 = 0xc1; // dev->host, vendor, INTERFACE recipient (DLM's choice)
+
+        // The DLM-style vendor preamble (sec 4). Per the userspace oracle, every
+        // control request here is **best-effort**: the dock legitimately STALLs
+        // some of them (e.g. the cosmetic dock-id read) yet still advances its
+        // host-identification state. The oracle tolerates each error and relies
+        // on DLM's inter-request timing gaps -- without those gaps the dock may
+        // not advance. So we log-and-continue on every control step and insert
+        // the same delays; only the bulk init + ACK is treated as load-bearing.
+        // GROUND-TRUTH 2026-06-13: at device-open DLM issues two vendor-IN reads on interface 1,
+        // recipient 0xc1, BEFORE the SET_INTERFACE / 0x24 / 0x22 sequence (dlm-cold-20260611-123347
+        // f708 `0xc1 0xfe wIdx=1` -> 16 B "RidgeDock" blob; f710 `0xc1 0xfc wIdx=1` -> 0 B). vino
+        // skipped them; the earlier attempt used recipient 0xc0 (device) and STALLed, which was
+        // misread as "the dock rejects 0xfe / DLM never sends it". Issue them here with the correct
+        // 0xc1 recipient. Best-effort: log and continue (the dock may still short/stall 0xfc).
+        let mut dock_id = [0u8; 16];
+        match dev.control_recv(0xfe, VENDOR_IN_IFACE, 0, 1, &mut dock_id, timeout()) {
+            Ok(()) => pr_info!("vino: step device-open 0xfe(iface1) OK = {:02x?}\n", dock_id),
+            Err(e) => pr_info!("vino: step device-open 0xfe(iface1) non-fatal ({e:?})\n"),
+        }
+        let mut probe3 = [0u8; 3];
+        match dev.control_recv(0xfc, VENDOR_IN_IFACE, 0, 1, &mut probe3, timeout()) {
+            Ok(()) => pr_info!("vino: step device-open 0xfc(iface1) OK = {:02x?}\n", probe3),
+            Err(e) => pr_info!("vino: step device-open 0xfc(iface1) non-fatal ({e:?})\n"),
+        }
+        // EXPERIMENT (2026-06-16): replay DLM's repeated STRING-descriptor reads at device-open.
+        // Timing analysis of the paired cold capture (captures/paired-coldbus-20260615-220311)
+        // shows DLM, beyond the distinct descriptor SET vino already issues, re-reads STRING idx0
+        // (language-ID list) and idx3 (en-US product, langid 0x0409), 255 B each, at ~2/sec for the
+        // ENTIRE 175 s session -- a 1 Hz host string-poll heartbeat. Engagement happens in the
+        // first
+        // second, so this is almost certainly NOT a pre-AKE gate (the distinct set already
+        // matches),
+        // but the repetition was never A/B-tested by replay the way the 0xfe/0xfc reads were. Issue
+        // a
+        // small burst here, BEFORE the AKE, to test whether the dock conditions CP engagement on
+        // seeing the host poll its strings. Best-effort: the kernel reports EREMOTEIO on the
+        // expected
+        // short reply, but the GET_DESCRIPTOR still reaches the wire, which is all the experiment
+        // needs.
+        // RESULT 2026-06-16 (paired-coldbus-20260616-162650): the pre-arm GET_DESCRIPTOR delta is
+        // USB ENUMERATION, not application protocol. Both captures contain an identical 3x 8-byte +
+        // 7x 18-byte DEVICE-descriptor read sequence -- which no kernel driver issues (it is the
+        // enumeration handshake the USB core runs each time the dock re-enumerates on the cold
+        // plug, plus DisplayLink's leftover /opt/displaylink/udev.sh hook firing per uevent).
+        // Proven to be enumeration, not the DLM daemon: the vino capture reproduces the SAME reads
+        // with displaylink-driver.service masked and no DisplayLinkManager process running. It is
+        // symmetric across both runs, so it is neither a DLM-vs-vino difference nor the engagement
+        // gate. This speculative burst only ADDED vino-issued reads on top, so disable it.
+        // -- LIBUSB-STYLE DEVICE-OPEN ENUMERATION (2026-06-17)
+        // ----------------------------------
+        // The clean paired capture (paired-coldbus-20260616-180401) isolated the LAST pre-AKE
+        // divergence from DLM to ONE thing: DLM (libusb) re-reads the dock's full descriptor set
+        // when it opens the device -- DEVICE(18), CONFIG(9 then full ~618), STRING langid(idx0),
+        // then every STRING index the descriptors reference (~22x 255B) -- right before the AKE.
+        // A
+        // kernel driver normally skips this (the USB core cached it at enumeration), which is why
+        // vino's pre-arm control stream was missing it (the "DLM-ONLY 255x22 / 618 / 40"
+        // residual).
+        // These reads are CP-irrelevant descriptor boilerplate. The cold-plug A/B proved the dock
+        // does NOT gate CP on them (replaying them byte-for-byte still gave 0x wsub=0x45 -- see
+        // project_get_descriptor_burst_experiment / the firmware-wall verdict), and the in-kernel
+        // Windows (WDF) and macOS (IOUSBLib) drivers DON'T issue this burst either -- like vino
+        // they run over an already-enumerated device and use the USB core's cached descriptors.
+        // The burst is therefore a libusb-userspace artifact, not something the dock expects.
+        // Default OFF so vino behaves like a native kernel driver; flip to `true` only to reproduce
+        // DLM's libusb wire for a paired A/B diff. Best-effort throughout: a STALL/EREMOTEIO on an
+        // absent index is fine -- EP0 auto-recovers and the SETUP still reaches the wire (all the
+        // A/B diff needs). Reproduces (histogram diff DLM vs vino, paired-coldbus-20260616-180401):
+        // DLM's libusb open adds CONFIG-full(618)x3, CONFIG-partial(40)x3, STRING(255)x22, with
+        // no
+        // extra DEVICE(18)/CONFIG(9).
+        const CP_LIBUSB_OPEN_ENUM: bool = false;
+        if CP_LIBUSB_OPEN_ENUM {
+            let mut tmp = [0u8; 255];
+            let mut cfg = KVec::from_elem(0u8, 618, GFP_KERNEL)?;
+            // CONFIG full (618) x3 -- parse the first to find real string indices so the STRING
+            // reads
+            // below return data (matching DLM's byte counts), not just the SETUP counts.
+            for _ in 0..3 {
+                let _ = dev.control_recv(0x06, 0x80, 0x0200, 0, &mut cfg, timeout());
+            }
+            // CONFIG partial (40) x3.
+            for _ in 0..3 {
+                let _ = dev.control_recv(0x06, 0x80, 0x0200, 0, &mut tmp[..40], timeout());
+            }
+            // STRING idx0 = language-ID list (1st of the 22x 255 reads); adopt the dock's REAL
+            // langid.
+            let mut langid = 0x0409u16;
+            if dev.control_recv(0x06, 0x80, 0x0300, 0, &mut tmp, timeout()).is_ok() && tmp[0] >= 4 {
+                langid = (tmp[2] as u16) | ((tmp[3] as u16) << 8);
+            }
+            // String indices referenced by the config (iConfiguration @off6, iInterface @off8).
+            let mut idxs = [0u8; 64];
+            let mut ni = 0usize;
+            let mut p = 0usize;
+            while p + 2 <= cfg.len() {
+                let blen = cfg[p] as usize;
+                if blen == 0 {
+                    break;
+                }
+                let btype = cfg[p + 1];
+                if btype == 0x02 && p + 7 <= cfg.len() && cfg[p + 6] != 0 && ni < idxs.len() {
+                    idxs[ni] = cfg[p + 6];
+                    ni += 1;
+                }
+                if btype == 0x04 && p + 9 <= cfg.len() && cfg[p + 8] != 0 && ni < idxs.len() {
+                    idxs[ni] = cfg[p + 8];
+                    ni += 1;
+                }
+                p += blen;
+            }
+            // 21 more STRING(255) reads (idx0 above makes 22 total = DLM's count). Cycle the real
+            // referenced indices so each returns data; DLM likewise re-reads indices.
+            let mut nok = 0usize;
+            for k in 0..21usize {
+                let i = if ni > 0 { idxs[k % ni] as u16 } else { 1 + k as u16 };
+                if dev
+                    .control_recv(0x06, 0x80, 0x0300 | i, langid, &mut tmp, timeout())
+                    .is_ok()
+                {
+                    nok += 1;
+                }
+            }
+            pr_info!(
+                "vino: libusb-open enum: config 618x3 + 40x3, langid={langid:#06x}, strings 22 ({nok} ok of {ni} refs)\n"
+            );
+        }
+
+        // SET_INTERFACE: DLM's two handshake SET_INTERFACEs target iface 1 (alt 0,
+        // app-specific/DFU) then iface 0 (alt 0, vendor) -- confirmed by a clean cold
+        // DLM usbmon capture (captures/dlm-cold-20260611-123347, t=52.079/52.085).
+        // The old code set iface 4 (the microphone) which DLM NEVER touches in the
+        // handshake (the 58 audio SET_INTERFACEs in a session are snd-usb-audio's, not
+        // DLM's -- see project_cp_setinterface_is_audio_binding_fix).
+        match dev.set_interface(1, 0) {
+            Ok(()) => pr_info!("vino: step set_interface(1,0) OK\n"),
+            Err(e) => pr_info!("vino: step set_interface(1,0) non-fatal ({e:?})\n"),
+        }
+        match dev.set_interface(0, 0) {
+            Ok(()) => pr_info!("vino: step set_interface(0,0) OK\n"),
+            Err(e) => pr_info!("vino: step set_interface(0,0) non-fatal ({e:?})\n"),
+        }
+        // vendor_out 0x24 (wValue=3, initial ack) then vendor_in 0x22 (state read,
+        // wValue=1 -- DLM's exact values; wValue=0 STALLs). Both best-effort: the
+        // dock advances state regardless and the oracle tolerates failure here.
+        match dev.control_send(0x24, VENDOR_OUT, 3, 0, &[], timeout()) {
+            Ok(()) => pr_info!("vino: step 0x24(wValue=3) OK\n"),
+            Err(e) => pr_info!("vino: step 0x24(wValue=3) non-fatal ({e:?})\n"),
+        }
+        // 0xc1 = IN|vendor|INTERFACE recipient (NOT 0xc0, device recipient): DLM's cold capture
+        // uses
+        // bmRequestType=0xc1, wIndex=0 (interface 0). wValue=1 (DLM's value; 0 stalls). Uses the
+        // function-scope `VENDOR_IN_IFACE` declared in the device-open preamble above.
+        let mut state = [0u8; 28];
+        match dev.control_recv(0x22, VENDOR_IN_IFACE, 1, 0, &mut state, timeout()) {
+            Ok(()) => pr_info!("vino: step 0x22(wValue=1) OK = {:02x?}\n", state),
+            Err(e) => pr_info!("vino: step 0x22(wValue=1) non-fatal ({e:?})\n"),
+        }
+
+        // Plaintext session init (sec 4) in DLM's exact wire order. The dock only
+        // ACKs once init_4+probe arrives, and it gates on DLM's fingerprint -- the
+        // interleaved GET_DESCRIPTOR reads (CONFIGURATION before init_0, two STRING
+        // reads between init_25 and init_4). Those reads are best-effort: the
+        // kernel reports EREMOTEIO on the short reply but the request still hits the
+        // wire (all we need). init_0/init_25/init_4+probe are separate transfers.
+        const STD_IN: u8 = 0x80; // dev->host, standard, device
+        let mut desc = KVec::from_elem(0u8, 618, GFP_KERNEL)?;
+        let _ = dev.control_recv(0x06, STD_IN, 0x0200, 0, &mut desc[..40], timeout()); // CONFIG, 40
+        let _ = dev.control_recv(0x06, STD_IN, 0x0200, 0, &mut desc, timeout()); // CONFIG, 618
+
+        // Log EP02's bulk wMaxPacketSize from the config descriptor. If it is 64 then a 64-byte
+        // msg0/arm is an exact multiple and the in-kernel `usb_bulk_msg` path (unlike libusb's
+        // LIBUSB_TRANSFER_ADD_ZERO_PACKET) won't auto-append the terminating ZLP -- the dock's SIE
+        // would then wait for more data and never hand the frame to firmware. Rules the ZLP-trap
+        // hypothesis in or out from data we already capture. Walk the standard descriptor chain
+        // (bLength/bDescriptorType), find the ENDPOINT (0x05) descriptor for bEndpointAddress 0x02.
+        {
+            let total = ((desc[2] as usize) | ((desc[3] as usize) << 8)).min(desc.len());
+            let mut i = 0usize;
+            while i + 2 <= total {
+                let blen = desc[i] as usize;
+                if blen == 0 {
+                    break;
+                }
+                if desc[i + 1] == 0x05 && i + 7 <= total && desc[i + 2] == EP_CTRL_OUT {
+                    let wmax = (desc[i + 4] as u16) | ((desc[i + 5] as u16) << 8);
+                    pr_info!("vino: EP02 bulk wMaxPacketSize = {wmax} (ZLP needed if msg0 is a multiple)\n");
+                }
+                i += blen;
+            }
+        }
+
+        let load_bearing = |label: &str, msg: &[u8]| -> Result {
+            match dev.bulk_send(EP_CTRL_OUT, msg, timeout()) {
+                Ok(_) => Ok(pr_info!("vino: step {label} OK ({} B)\n", msg.len())),
+                Err(e) => {
+                    pr_err!("vino: step {label} FAILED ({e:?})\n");
+                    Err(e)
+                }
+            }
+        };
+        load_bearing("init_0", &proto::init_0()?)?;
+        load_bearing("init_25", &proto::init_25()?)?;
+        // DLM's two interleaved STRING reads between init_25 and init_4+probe.
+        let _ = dev.control_recv(0x06, STD_IN, 0x0300, 0x0000, &mut desc[..255], timeout()); // STRING #0
+        let _ = dev.control_recv(0x06, STD_IN, 0x0303, 0x0409, &mut desc[..255], timeout()); // STRING #3 en-US
+        load_bearing("init_4+probe", &proto::init_4_probe()?)?;
+
+        // Read the single ACK that follows init_4+probe.
+        let mut ack = KVec::from_elem(0u8, 1024, GFP_KERNEL)?;
+        match dev.bulk_recv(EP_CTRL_IN, &mut ack, timeout()) {
+            Ok(n) => Ok(pr_info!("vino: session-init ACK = {n} bytes: {:02x?}\n",
+                &ack[..n.min(40)])),
+            Err(e) => {
+                pr_err!("vino: session-init ACK read FAILED ({e:?})\n");
+                Err(e)
+            }
+        }
+    }
+
+}
+
+kernel::usb_device_table!(
+    USB_TABLE,
+    MODULE_USB_TABLE,
+    <VinoDriver as usb::Driver>::IdInfo,
+    [(usb::DeviceId::from_id(VID_DISPLAYLINK, PID_D6000), ())]
+);
+
+impl usb::Driver for VinoDriver {
+    type IdInfo = ();
+    // The driver instance is itself the per-bound device-private data.
+    type Data<'bound> = Self;
+    const ID_TABLE: usb::IdTable<Self::IdInfo> = &USB_TABLE;
+
+    fn probe<'bound>(
+        intf: &'bound usb::Interface<Core<'_>>,
+        _id: &usb::DeviceId,
+        _info: &'bound Self::IdInfo,
+    ) -> impl PinInit<Self, Error> + 'bound {
+        let cdev: &device::Device<Core<'_>> = intf.as_ref();
+        // The D6000 exposes several interfaces (0/1/5/6 match us; 2-4 are audio).
+        // The control endpoints (0x02/0x84) and the whole HDCP session live on
+        // interface 0 -- drive bring-up only there so we don't run the preamble and
+        // AKE four times and pollute the dock's state machine. Other interfaces
+        // bind (so usbcore doesn't hand them to another driver) but stay idle.
+        let ifnum = intf.number();
+        if ifnum != 0 {
+            // Interface 1 (app-specific/DFU) is the only other one DLM claims; let everything else
+            // (audio 2-4, Ethernet 5-6) fall through to its proper kernel driver. Returning ENODEV
+            // tells usbcore this driver doesn't handle the interface, so it tries the next match.
+            if ifnum != 1 {
+                dev_info!(cdev, "vino: declining D6000 interface {ifnum} (left to its class driver)\n");
+                return Err(ENODEV);
+            }
+            dev_info!(cdev, "vino: bound D6000 interface {ifnum} (idle -- control is iface 0)\n");
+            return Ok(Self { _intf: intf.into() });
+        }
+        dev_info!(cdev, "vino: bound DisplayLink D6000 -- plaintext session bring-up\n");
+
+        // Bring-up is blocking synchronous USB I/O; hand it to the system workqueue so
+        // probe() returns immediately and userspace stays responsive. The work item holds
+        // a refcounted handle to the interface, so the bulk endpoints outlive probe(); USB
+        // I/O after an intervening disconnect simply errors and is logged.
+        let intf_ref: ARef<usb::Interface> = intf.into();
+        match BringUp::new(intf_ref.clone()) {
+            Ok(work) => {
+                let _ = workqueue::system().enqueue(work);
+                dev_info!(cdev, "vino: bring-up queued on system workqueue\n");
+            }
+            Err(e) => dev_info!(cdev, "vino: failed to queue bring-up ({e:?}) -- WIP\n"),
+        }
+
+        Ok(Self { _intf: intf_ref })
+    }
+
+    fn disconnect<'bound>(intf: &'bound usb::Interface<Core<'_>>, _data: Pin<&Self>) {
+        let dev: &device::Device<Core<'_>> = intf.as_ref();
+        dev_info!(dev, "vino: D6000 disconnected\n");
+    }
+}
+
+kernel::module_usb_driver! {
+    type: VinoDriver,
+    name: "vino",
+    authors: ["Mike Lothian"],
+    description: "DisplayLink DL3 (Vino) open driver",
+    license: "GPL v2",
+}
-- 
2.54.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE
  2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
  2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
  2026-06-17 16:18   ` Eric Biggers
  2026-06-17 20:12   ` sashiko-bot
  2026-06-17 15:12 ` [RFC PATCH 3/7] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm Mike Lothian
                   ` (6 subsequent siblings)
  8 siblings, 2 replies; 41+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
  To: dri-devel
  Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
	linux-kernel

After the plaintext session init, the DL3 dock requires an HDCP 2.2
session before it will accept any control-plane traffic. Add a clean-room
implementation of the HDCP 2.2 authentication: the AKE (with stored-km
and no-stored-km), locality check (LC) and session-key exchange (SKE),
all verified against the live dock -- H', L' and V' all match, so the
shared session key ks and content IV riv are established.

New modules:
 - crypto: thin adapters onto the in-tree kernel library-crypto bindings
   (AES-128-ECB, AES-CMAC, HMAC-SHA256, SHA-256) used by the KDF;
 - rng: CSPRNG helpers for the per-session HDCP nonces/keys;
 - hdcp: the HDCP 2.2 key derivation (kd/dkey/ks) and H'/L'/V' verifier
   computation (the byte-exact KDF formulas);
 - ake: the HDCP 2.2 AKE wire layer (OUT message builders, IN parsing);
 - golden: the session-invariant plaintext capability-announce skeleton
   the driver re-states with this session's live AKE values right after
   the AKE (build_cap_announce).

run_ake() drives the state machine end to end and returns the keyed
Session; an on-device crypto known-answer self-test (FIPS-197 AES-128,
RFC 4493 AES-CMAC) confirms the in-kernel crypto path is byte-correct.
The encrypted control plane that consumes the Session lands in the next
patch.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
 drivers/gpu/drm/vino/ake.rs    | 167 +++++++++
 drivers/gpu/drm/vino/crypto.rs |  81 ++++
 drivers/gpu/drm/vino/golden.rs |  69 ++++
 drivers/gpu/drm/vino/hdcp.rs   | 167 +++++++++
 drivers/gpu/drm/vino/rng.rs    |  12 +
 drivers/gpu/drm/vino/vino.rs   | 662 ++++++++++++++++++++++++++++++++-
 6 files changed, 1148 insertions(+), 10 deletions(-)
 create mode 100644 drivers/gpu/drm/vino/ake.rs
 create mode 100644 drivers/gpu/drm/vino/crypto.rs
 create mode 100644 drivers/gpu/drm/vino/golden.rs
 create mode 100644 drivers/gpu/drm/vino/hdcp.rs
 create mode 100644 drivers/gpu/drm/vino/rng.rs

diff --git a/drivers/gpu/drm/vino/ake.rs b/drivers/gpu/drm/vino/ake.rs
new file mode 100644
index 000000000000..ad79d2754c60
--- /dev/null
+++ b/drivers/gpu/drm/vino/ake.rs
@@ -0,0 +1,167 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! HDCP 2.2 AKE wire layer (sec 5.1 OUT framing, sec 5.2 IN parsing) -- the byte-exact
+//! message builders the AKE state machine drives, mirroring the verified userspace
+//! oracle (`vino-driver::hdcp_msgs`). DLM hardcodes per-message `sub_size` /
+//! `sub_len_dw` values the dock validates, so they are reproduced verbatim rather
+//! than derived.
+//!
+//! OUT body layout (sec 5.1), after the 16-byte sec 3 transport header:
+//! ```text
+//!   body[0..2]   u16 sub_size      (DLM-fixed per message)
+//!   body[2..4]   u16 = 0x0010
+//!   body[4..8]   u32 hdcp_seq      increments 1..7 across the AKE OUT messages
+//!   body[8..22]  14 zero bytes
+//!   body[22..26] u32 = 0x00000030  marker
+//!   body[26]     u8  = 0x00        flag
+//!   body[27]     u8  = msg_id
+//!   body[28..]   HDCP payload (zero-padded to the fixed body length)
+//! ```
+#![allow(dead_code)] // AKE message builders; response handlers run only after CP engagement
+
+use super::*;
+
+/// HDCP 2.2 message IDs (sec 5.3). `pub(crate)` so the AKE state machine
+/// ([`super::VinoDriver::run_ake`]) can match on the response IDs too.
+pub(crate) mod id {
+    use kernel::bindings;
+
+    // Standard HDCP 2.2 message IDs: reuse the canonical values from
+    // `<drm/display/drm_hdcp.h>` rather than redefining them, so vino stays in
+    // lockstep with the kernel's HDCP definitions. Only the transport framing
+    // around these (the DisplayLink type/sub/ctr header) is vino-specific.
+    pub(crate) const AKE_INIT: u8 = bindings::HDCP_2_2_AKE_INIT as u8;
+    pub(crate) const AKE_SEND_CERT: u8 = bindings::HDCP_2_2_AKE_SEND_CERT as u8;
+    pub(crate) const AKE_NO_STORED_KM: u8 = bindings::HDCP_2_2_AKE_NO_STORED_KM as u8;
+    pub(crate) const AKE_SEND_H_PRIME: u8 = bindings::HDCP_2_2_AKE_SEND_HPRIME as u8;
+    pub(crate) const AKE_SEND_PAIRING_INFO: u8 = bindings::HDCP_2_2_AKE_SEND_PAIRING_INFO as u8;
+    pub(crate) const LC_INIT: u8 = bindings::HDCP_2_2_LC_INIT as u8;
+    pub(crate) const LC_SEND_L_PRIME: u8 = bindings::HDCP_2_2_LC_SEND_LPRIME as u8;
+    pub(crate) const SKE_SEND_EKS: u8 = bindings::HDCP_2_2_SKE_SEND_EKS as u8;
+    pub(crate) const REPEATERAUTH_SEND_RECEIVERID_LIST: u8 =
+        bindings::HDCP_2_2_REP_SEND_RECVID_LIST as u8;
+    pub(crate) const REPEATERAUTH_SEND_ACK: u8 = bindings::HDCP_2_2_REP_SEND_ACK as u8;
+    pub(crate) const REPEATERAUTH_STREAM_MANAGE: u8 = bindings::HDCP_2_2_REP_STREAM_MANAGE as u8;
+    pub(crate) const REPEATERAUTH_STREAM_READY: u8 = bindings::HDCP_2_2_REP_STREAM_READY as u8;
+
+    // DisplayLink-specific message IDs with no `<drm/display/drm_hdcp.h>` equivalent
+    // (the AKE_Send_rrx split and the transmitter/receiver-info + auth-status messages
+    // the DL3 dock uses), kept as literals.
+    pub(crate) const AKE_SEND_RRX: u8 = 0x06;
+    pub(crate) const RECEIVER_AUTH_STATUS: u8 = 0x12;
+    pub(crate) const AKE_TRANSMITTER_INFO: u8 = 0x13;
+    pub(crate) const AKE_RECEIVER_INFO: u8 = 0x14;
+}
+
+/// transport `sub_id` for HDCP OUT messages (type=4 sub=0x04, sec 5.1).
+const SUB_HDCP: u16 = 0x04;
+
+/// Allocate a `body_len`-byte zeroed body with the sec 5.1 header filled in
+/// (`sub_size`, the `0x0010` marker, `hdcp_seq`, the `0x30` marker and `msg_id`).
+/// The caller writes the payload into `body[28..]`.
+fn body(body_len: usize, sub_size: u16, hdcp_seq: u32, msg_id: u8) -> Result<KVec<u8>> {
+    let mut b = KVec::from_elem(0u8, body_len, GFP_KERNEL)?;
+    b[0..2].copy_from_slice(&sub_size.to_le_bytes());
+    b[2..4].copy_from_slice(&0x0010u16.to_le_bytes());
+    b[4..8].copy_from_slice(&hdcp_seq.to_le_bytes());
+    b[22..26].copy_from_slice(&0x0000_0030u32.to_le_bytes());
+    b[27] = msg_id;
+    Ok(b)
+}
+
+/// Wrap a finished HDCP body in the sec 3 transport header (type=4 sub=0x04) with
+/// the DLM-fixed `sub_len_dw` and the transport `seq`.
+fn wrap(sub_len_dw: u16, seq: u32, body: &[u8]) -> Result<KVec<u8>> {
+    let mut frame = KVec::with_capacity(16 + body.len(), GFP_KERNEL)?;
+    proto::push_frame_with(&mut frame, 0x04, SUB_HDCP, sub_len_dw, seq, body)?;
+    Ok(frame)
+}
+
+/// `AKE_Init` (msg_id 0x02): `rtx[8] || TxCaps[3]`, padded to a 48-byte body
+/// (`sub_size=0x22`, `sub_len_dw=0x0c` -- guide sec 5.4 table).
+pub(super) fn ake_init(
+    hdcp_seq: u32,
+    seq: u32,
+    rtx: &[u8; 8],
+    tx_caps: &[u8; 3],
+) -> Result<KVec<u8>> {
+    let mut b = body(48, 0x0022, hdcp_seq, id::AKE_INIT)?;
+    b[28..36].copy_from_slice(rtx);
+    b[36..39].copy_from_slice(tx_caps);
+    wrap(0x000c, seq, &b)
+}
+
+/// `AKE_Transmitter_Info` (msg_id 0x13): byte-exact DLM framing
+/// (`sub_size=0x1f`, `sub_len_dw=0x0f`), payload `00 06 02 00 02`.
+pub(super) fn ake_transmitter_info(hdcp_seq: u32, seq: u32) -> Result<KVec<u8>> {
+    let mut b = body(48, 0x001f, hdcp_seq, id::AKE_TRANSMITTER_INFO)?;
+    b[28..33].copy_from_slice(&[0x00, 0x06, 0x02, 0x00, 0x02]);
+    wrap(0x000f, seq, &b)
+}
+
+/// `AKE_No_Stored_km` (msg_id 0x04): the 128-byte RSA-OAEP-SHA256 `Ekpub(km)`
+/// in a 160-byte body (`sub_size=0x9a`, `sub_len_dw=0x04` -- guide sec 5.4 table).
+pub(super) fn ake_no_stored_km(
+    hdcp_seq: u32,
+    seq: u32,
+    ekpub_km: &[u8; 128],
+) -> Result<KVec<u8>> {
+    let mut b = body(160, 0x009a, hdcp_seq, id::AKE_NO_STORED_KM)?;
+    b[28..156].copy_from_slice(ekpub_km);
+    wrap(0x0004, seq, &b)
+}
+
+/// `LC_Init` (msg_id 0x09): `rn[8]` in a 48-byte body
+/// (`sub_size=0x22`, `sub_len_dw=0x0c`).
+pub(super) fn lc_init(hdcp_seq: u32, seq: u32, rn: &[u8; 8]) -> Result<KVec<u8>> {
+    let mut b = body(48, 0x0022, hdcp_seq, id::LC_INIT)?;
+    b[28..36].copy_from_slice(rn);
+    wrap(0x000c, seq, &b)
+}
+
+/// `SKE_Send_Eks` (msg_id 0x0b): `Edkey(ks)[16] || riv[8]` in a 64-byte body
+/// (`sub_size=0x32`, `sub_len_dw=0x0c`).
+pub(super) fn ske_send_eks(
+    hdcp_seq: u32,
+    seq: u32,
+    edkey_ks: &[u8; 16],
+    riv: &[u8; 8],
+) -> Result<KVec<u8>> {
+    let mut b = body(64, 0x0032, hdcp_seq, id::SKE_SEND_EKS)?;
+    b[28..44].copy_from_slice(edkey_ks);
+    b[44..52].copy_from_slice(riv);
+    wrap(0x000c, seq, &b)
+}
+
+/// `RepeaterAuth_Send_ACK` (msg_id 0x0f): the full `V[16]` in a 48-byte body
+/// (`sub_size=0x2a`, `sub_len_dw=0x04`).
+pub(super) fn repeater_auth_send_ack(
+    hdcp_seq: u32,
+    seq: u32,
+    v: &[u8; 16],
+) -> Result<KVec<u8>> {
+    let mut b = body(48, 0x002a, hdcp_seq, id::REPEATERAUTH_SEND_ACK)?;
+    b[28..44].copy_from_slice(v);
+    wrap(0x0004, seq, &b)
+}
+
+/// `RepeaterAuth_Stream_Manage` SM2 (msg_id 0x10): byte-exact DLM replica sent
+/// after Send_ACK -- `k=2` (LE), `StreamID_Type[0]=4` (LE), `body[43]=0x05`
+/// (`sub_size=0x2d`, `sub_len_dw=0x01`). See guide sec 5.4 and sec 8.2.
+pub(super) fn repeater_auth_stream_manage(hdcp_seq: u32, seq: u32) -> Result<KVec<u8>> {
+    let mut b = body(48, 0x002d, hdcp_seq, id::REPEATERAUTH_STREAM_MANAGE)?;
+    b[32..36].copy_from_slice(&[0x02, 0, 0, 0]); // k = 2 (LE)
+    b[36..40].copy_from_slice(&[0x04, 0, 0, 0]); // StreamID_Type[0] = 4 (LE)
+    b[43] = 0x05;
+    wrap(0x0001, seq, &b)
+}
+
+/// Parse an IN HDCP message body (sec 5.2): `body[8]` marker, `body[9]` msg_id,
+/// `body[10..]` payload (for `AKE_Send_Cert`, `body[10]` is a version flag).
+/// Returns `(msg_id, payload)`.
+pub(super) fn parse_in(body: &[u8]) -> Option<(u8, &[u8])> {
+    if body.len() < 10 {
+        return None;
+    }
+    Some((body[9], &body[10..]))
+}
diff --git a/drivers/gpu/drm/vino/crypto.rs b/drivers/gpu/drm/vino/crypto.rs
new file mode 100644
index 000000000000..04203db81991
--- /dev/null
+++ b/drivers/gpu/drm/vino/crypto.rs
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Thin adapters onto the shared [`kernel::crypto`] library-crypto bindings, so the
+//! protocol code keeps its `crypto::aes128_ecb` / `crypto::hmac_sha256` call sites.
+#![allow(dead_code)] // exercised by the AES-CTR seal + HDCP AKE
+
+use super::*;
+
+/// `AES_ECB(key, block)` -- one 16-byte AES-128 block.
+pub(super) fn aes128_ecb(key: &[u8; 16], block: &[u8; 16]) -> Result<[u8; 16]> {
+    kernel::crypto::Aes128::new(*key).encrypt_block(block)
+}
+
+/// `HMAC-SHA256(key, data)`.
+pub(super) fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
+    kernel::crypto::hmac_sha256(key, data)
+}
+
+/// `AES-CMAC-128(key, data)` (RFC 4493), built on the one-block ECB above.
+/// This is DisplayLink's "Dl3Cmac" core -- the CP per-message integrity tag is
+/// `AES_CMAC(ks, nonce8 || BE64(counter) || content)` (see `cp::dl3cmac_tag`);
+/// verified byte-exact against live DLM data (canonical guide sec 8.6.7).
+pub(super) fn aes_cmac(key: &[u8; 16], data: &[u8]) -> Result<[u8; 16]> {
+    // dbl: left-shift the 128-bit value by 1, XOR 0x87 if the MSB was set.
+    fn dbl(b: &[u8; 16]) -> [u8; 16] {
+        let mut o = [0u8; 16];
+        for i in 0..15 {
+            o[i] = (b[i] << 1) | (b[i + 1] >> 7);
+        }
+        o[15] = b[15] << 1;
+        if b[0] & 0x80 != 0 {
+            o[15] ^= 0x87;
+        }
+        o
+    }
+    let l = aes128_ecb(key, &[0u8; 16])?;
+    let k1 = dbl(&l);
+    let k2 = dbl(&k1);
+    let n = if data.is_empty() { 1 } else { data.len().div_ceil(16) };
+    let complete = !data.is_empty() && data.len() % 16 == 0;
+    let mut c = [0u8; 16];
+    for i in 0..n {
+        let mut blk = [0u8; 16];
+        let start = i * 16;
+        let end = core::cmp::min(start + 16, data.len());
+        blk[..end - start].copy_from_slice(&data[start..end]);
+        if i == n - 1 {
+            if complete {
+                for j in 0..16 {
+                    blk[j] ^= k1[j];
+                }
+            } else {
+                blk[end - start] = 0x80; // 10* padding
+                for j in 0..16 {
+                    blk[j] ^= k2[j];
+                }
+            }
+        }
+        for j in 0..16 {
+            blk[j] ^= c[j];
+        }
+        c = aes128_ecb(key, &blk)?;
+    }
+    Ok(c)
+}
+
+/// `SHA256(data)`.
+pub(super) fn sha256(data: &[u8]) -> [u8; 32] {
+    kernel::crypto::sha256(data)
+}
+
+/// Raw RSA public-key op `out = input^exponent mod modulus`, big-endian,
+/// `out` written fixed-width (caller applies OAEP padding to `input`).
+pub(super) fn rsa_pubkey_encrypt(
+    modulus: &[u8],
+    exponent: &[u8],
+    input: &[u8],
+    out: &mut [u8],
+) -> Result {
+    kernel::crypto::rsa_pubkey_encrypt(modulus, exponent, input, out)
+}
diff --git a/drivers/gpu/drm/vino/golden.rs b/drivers/gpu/drm/vino/golden.rs
new file mode 100644
index 000000000000..e379e888c9c8
--- /dev/null
+++ b/drivers/gpu/drm/vino/golden.rs
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Captured DisplayLink control-plane protocol templates.
+//!
+//! These are NOT replay dumps of an encrypted session. They are the
+//! session-invariant *plaintext skeletons* of two control-plane bursts captured
+//! from the proprietary DisplayLinkManager (DLM). The driver overwrites the
+//! session-specific fields with THIS session's live values and then seals the
+//! result under the live `ks`, so the bytes that reach the wire are this
+//! session's own, never the capture's. They remain inline here because the
+//! field-by-field live builders that would replace them are not yet written --
+//! see the "help wanted" note at the top of the file.
+
+/// Plaintext capability-announce skeleton: the seven `sub=0x10`, ctr 1..7
+/// frames that restate the AKE OUT messages. `build_cap_announce` walks this
+/// and overwrites each frame's payload with this session's live AKE value
+/// (rtx / Ekpub / rn / Edkey+riv / V). 590 bytes.
+pub(super) const CAP_PLAIN_1080P: &[u8] = &[
+    0x40, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00,
+    0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x22, 0x00, 0x10, 0x00, 0x01, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x02, 0x1f, 0xe7,
+    0x18, 0x56, 0x6e, 0x1f, 0xc0, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x3c, 0x00,
+    0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x1f, 0x00, 0x10, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00,
+    0x00, 0x00, 0x00, 0x13, 0x00, 0x06, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0xb0, 0x00, 0x00, 0x00, 0xac, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00,
+    0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9a, 0x00, 0x10, 0x00, 0x03, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x04, 0x0e, 0xd9,
+    0x2f, 0x05, 0xee, 0x3e, 0xca, 0x40, 0x7e, 0x14, 0x9f, 0x9d, 0x12, 0x6c,
+    0xca, 0x1a, 0x70, 0x27, 0x55, 0x02, 0x22, 0x0c, 0xde, 0x7d, 0x79, 0x6b,
+    0x13, 0x14, 0x32, 0x62, 0xef, 0x62, 0xc0, 0xf2, 0xb6, 0x3d, 0x41, 0x21,
+    0xcf, 0xbd, 0x2a, 0x40, 0xf9, 0xe8, 0x42, 0xc7, 0xbb, 0xa7, 0xcd, 0x8c,
+    0x53, 0xab, 0x56, 0x4e, 0x5b, 0xf8, 0x55, 0x0a, 0x05, 0x96, 0x09, 0x28,
+    0xbb, 0xf9, 0xbe, 0xc9, 0xe8, 0x81, 0x32, 0xaa, 0xc8, 0x49, 0x27, 0x3c,
+    0x80, 0x5c, 0x7c, 0xb8, 0x23, 0x54, 0xb6, 0xe0, 0x38, 0x71, 0x3c, 0xdd,
+    0xa6, 0x77, 0x91, 0x16, 0x3f, 0xd4, 0xec, 0xfd, 0xdd, 0x56, 0xf7, 0x01,
+    0xe1, 0x6c, 0x03, 0x50, 0xdf, 0x80, 0xd5, 0x93, 0x66, 0x55, 0xe1, 0xd7,
+    0x3b, 0x55, 0x7e, 0x9c, 0xb7, 0x71, 0xfe, 0x0b, 0x7d, 0x1c, 0x0d, 0x6b,
+    0x18, 0xda, 0xdb, 0xbe, 0x79, 0x75, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00,
+    0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x0c, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x22, 0x00, 0x10, 0x00, 0x04, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x09, 0xf4, 0xc4, 0x61, 0x0d,
+    0xe0, 0x75, 0x99, 0xf5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x4c, 0x00, 0x04, 0x00,
+    0x00, 0x00, 0x04, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x32, 0x00,
+    0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00,
+    0x00, 0x0b, 0xb2, 0xd9, 0xbd, 0x87, 0x94, 0x1b, 0xf0, 0xec, 0x59, 0x40,
+    0xf2, 0xba, 0xd5, 0x6d, 0x24, 0xab, 0x56, 0xfe, 0x0c, 0xff, 0xbc, 0x3a,
+    0x9d, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00,
+    0x04, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a, 0x00, 0x10, 0x00,
+    0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x0f,
+    0x38, 0x08, 0x3b, 0x1f, 0x39, 0x61, 0xb4, 0x9b, 0x3a, 0x2e, 0x9a, 0x1c,
+    0xbd, 0x64, 0x78, 0x85, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00,
+    0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x2d, 0x00, 0x10, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x30, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00,
+    0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00,
+    0x00, 0x00,
+];
diff --git a/drivers/gpu/drm/vino/hdcp.rs b/drivers/gpu/drm/vino/hdcp.rs
new file mode 100644
index 000000000000..c22d58b624ab
--- /dev/null
+++ b/drivers/gpu/drm/vino/hdcp.rs
@@ -0,0 +1,167 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! HDCP 2.2 key derivation and verifier computation (sec 5.6), built on [`crypto`].
+//! Lets the driver run a clean-room AKE without DisplayLink's binary; the byte-exact
+//! formulas are verified against the live dock in the guide.
+#![allow(dead_code)] // some HDCP builders/handlers are reached only after CP engagement
+
+use super::*;
+
+/// `dkey_n = AES_ECB(km with low-8-bytes XOR rn, rtx || (rrx with byte15 XOR n))`
+/// (HDCP 2.2 IIA sec 2.7, sec 5.6). The counter `n` XORs into byte 15 (LSB of the rrx
+/// half) of the IV; `rn` XORs into the low 8 bytes (km[8..16]) of the key -- zero
+/// for the `kd` derivation, the SKE nonce for `dkey_2`.
+fn derive_dkey(
+    km: &[u8; 16],
+    rn: &[u8; 8],
+    rtx: &[u8; 8],
+    rrx: &[u8; 8],
+    n: u8,
+) -> Result<[u8; 16]> {
+    let mut iv = [0u8; 16];
+    iv[..8].copy_from_slice(rtx);
+    iv[8..].copy_from_slice(rrx);
+    iv[15] ^= n;
+    let mut key = *km;
+    for i in 0..8 {
+        key[8 + i] ^= rn[i];
+    }
+    crypto::aes128_ecb(&key, &iv)
+}
+
+/// `kd = dkey_0 || dkey_1` with `rn = 0` (sec 5.6) -- the 256-bit derived key.
+pub(super) fn derive_kd(km: &[u8; 16], rtx: &[u8; 8], rrx: &[u8; 8]) -> Result<[u8; 32]> {
+    let rn = [0u8; 8];
+    let dkey0 = derive_dkey(km, &rn, rtx, rrx, 0)?;
+    let dkey1 = derive_dkey(km, &rn, rtx, rrx, 1)?;
+    let mut kd = [0u8; 32];
+    kd[..16].copy_from_slice(&dkey0);
+    kd[16..].copy_from_slice(&dkey1);
+    Ok(kd)
+}
+
+/// `H' = HMAC-SHA256(kd, rtx with byte7 ^= repeater)` (sec 5.6).
+pub(super) fn compute_h(kd: &[u8; 32], rtx: &[u8; 8], repeater: bool) -> [u8; 32] {
+    let mut msg = *rtx;
+    msg[7] ^= repeater as u8;
+    crypto::hmac_sha256(kd, &msg)
+}
+
+/// `L' = HMAC-SHA256(kd with low-8-bytes XOR rrx, rn)` (sec 5.6).
+///
+/// "low-8-bytes" is the *least-significant* 64 bits of the 256-bit `kd`, i.e.
+/// `kd[24..32]` -- verified byte-exact against the live dock by the userspace
+/// oracle (`vino-hdcp::kdf::compute_l`). XOR-ing into `kd[0..8]` does not verify.
+pub(super) fn compute_l(kd: &[u8; 32], rrx: &[u8; 8], rn: &[u8; 8]) -> [u8; 32] {
+    let mut key = *kd;
+    for i in 0..8 {
+        key[24 + i] ^= rrx[i];
+    }
+    crypto::hmac_sha256(&key, rn)
+}
+
+/// Full `V = HMAC-SHA256(kd, list_header)` (256 bits) for RepeaterAuth (sec 2.3).
+/// The **MSB-128** (`[..16]`) is `V'` -- verified against the repeater's
+/// `RepeaterAuth_Send_ReceiverID_List` trailer. The **LSB-128** (`[16..]`) is the
+/// value the transmitter returns in `RepeaterAuth_Send_Ack`. vino had been sending
+/// the MSB (i.e. echoing the dock's own `V'`) as the Ack -- so the dock rejected the
+/// repeater authentication, never acknowledged Stream_Manage, and never engaged CP
+/// (proven 2026-06-11: vino's ctr6 == the dock's `id=0x21` list trailer; DLM's ctr6
+/// is a computed value present in no dock push). H'/L'/V' still pass because V'
+/// verification uses the MSB.
+pub(super) fn compute_v_full(kd: &[u8; 32], list_header: &[u8]) -> [u8; 32] {
+    crypto::hmac_sha256(kd, list_header)
+}
+
+/// MGF1 mask generation (RFC 8017 sec B.2.1) with SHA-256: returns `mask_len`
+/// bytes of `T = SHA256(seed || I2OSP(0,4)) || SHA256(seed || I2OSP(1,4)) || ...`.
+fn mgf1_sha256(seed: &[u8], mask_len: usize) -> Result<KVec<u8>> {
+    let mut mask = KVec::with_capacity(mask_len, GFP_KERNEL)?;
+    let mut counter: u32 = 0;
+    let mut block = KVec::with_capacity(seed.len() + 4, GFP_KERNEL)?;
+    while mask.len() < mask_len {
+        block.clear();
+        block.extend_from_slice(seed, GFP_KERNEL)?;
+        block.extend_from_slice(&counter.to_be_bytes(), GFP_KERNEL)?;
+        let digest = crypto::sha256(&block);
+        let take = core::cmp::min(digest.len(), mask_len - mask.len());
+        mask.extend_from_slice(&digest[..take], GFP_KERNEL)?;
+        counter += 1;
+    }
+    Ok(mask)
+}
+
+/// EME-OAEP encode (RFC 8017 sec 7.1.1) with SHA-256 and an empty label, for a
+/// `k`-byte modulus. Returns the `k`-byte encoded message `EM` ready for the
+/// raw RSA op. `seed` is `hLen` (32) random bytes. HDCP 2.2 uses SHA-256 here
+/// (SHA-1 makes the dock stop responding -- guide sec 5.4).
+fn eme_oaep_encode(k: usize, msg: &[u8], seed: &[u8; 32]) -> Result<KVec<u8>> {
+    const HLEN: usize = 32;
+    // DB = lHash || PS(zeros) || 0x01 || M, length k - hLen - 1.
+    let l_hash = crypto::sha256(&[]);
+    let db_len = k - HLEN - 1;
+    let mut db = KVec::with_capacity(db_len, GFP_KERNEL)?;
+    db.extend_from_slice(&l_hash, GFP_KERNEL)?;
+    let ps_len = db_len - HLEN - 1 - msg.len(); // k - mLen - 2*hLen - 2
+    for _ in 0..ps_len {
+        db.push(0, GFP_KERNEL)?;
+    }
+    db.push(0x01, GFP_KERNEL)?;
+    db.extend_from_slice(msg, GFP_KERNEL)?;
+    // maskedDB = DB ^ MGF1(seed, db_len).
+    let db_mask = mgf1_sha256(seed, db_len)?;
+    for i in 0..db_len {
+        db[i] ^= db_mask[i];
+    }
+    // maskedSeed = seed ^ MGF1(maskedDB, hLen).
+    let seed_mask = mgf1_sha256(&db, HLEN)?;
+    let mut masked_seed = [0u8; HLEN];
+    for i in 0..HLEN {
+        masked_seed[i] = seed[i] ^ seed_mask[i];
+    }
+    // EM = 0x00 || maskedSeed || maskedDB.
+    let mut em = KVec::with_capacity(k, GFP_KERNEL)?;
+    em.push(0x00, GFP_KERNEL)?;
+    em.extend_from_slice(&masked_seed, GFP_KERNEL)?;
+    em.extend_from_slice(&db, GFP_KERNEL)?;
+    Ok(em)
+}
+
+/// RSA-OAEP-SHA256 encrypt the 16-byte master key `km` under the dock's
+/// RSA-1024 public key (`modulus[128]`, `exponent`), giving the 128-byte
+/// `Ekpub(km)` for `AKE_No_Stored_km` (sec 5.4). Generates a fresh OAEP seed.
+pub(super) fn oaep_encrypt_km(
+    modulus: &[u8; 128],
+    exponent: &[u8],
+    km: &[u8; 16],
+) -> Result<[u8; 128]> {
+    let mut seed = [0u8; 32];
+    super::rng::fill(&mut seed);
+    let em = eme_oaep_encode(128, km, &seed)?;
+    let mut out = [0u8; 128];
+    crypto::rsa_pubkey_encrypt(modulus, exponent, &em, &mut out)?;
+    Ok(out)
+}
+
+/// SKE: `Edkey(ks) = ks XOR (dkey_2 with low-8-bytes XOR rrx)` (sec 5.6).
+///
+/// `dkey_2` is derived with the SKE nonce `rn` mixed into the key; `rrx` then
+/// XORs into the low 8 bytes (`dkey_2[8..16]`) of the mask. The result is the
+/// 16-byte `Edkey_ks` carried by `SKE_Send_Eks` (msg_id 0x0b).
+pub(super) fn compute_eks(
+    km: &[u8; 16],
+    rtx: &[u8; 8],
+    rrx: &[u8; 8],
+    rn: &[u8; 8],
+    ks: &[u8; 16],
+) -> Result<[u8; 16]> {
+    let mut mask = derive_dkey(km, rn, rtx, rrx, 2)?;
+    for i in 0..8 {
+        mask[8 + i] ^= rrx[i];
+    }
+    let mut edkey_ks = [0u8; 16];
+    for i in 0..16 {
+        edkey_ks[i] = ks[i] ^ mask[i];
+    }
+    Ok(edkey_ks)
+}
diff --git a/drivers/gpu/drm/vino/rng.rs b/drivers/gpu/drm/vino/rng.rs
new file mode 100644
index 000000000000..8720d55174ae
--- /dev/null
+++ b/drivers/gpu/drm/vino/rng.rs
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Cryptographically-secure randomness for the per-session HDCP nonces/keys
+//! (`rtx`, `km`, `rn`, `ks`, `riv`, the OAEP seed).
+#![allow(dead_code)] // RNG helpers; some are reached only on the post-engagement CP path
+
+/// Fills `buf` with random bytes from the kernel CSPRNG (`get_random_bytes`).
+pub(super) fn fill(buf: &mut [u8]) {
+    // SAFETY: `buf` is valid for writes of `buf.len()` bytes; `get_random_bytes`
+    // writes exactly that many and never sleeps/faults on a kernel buffer.
+    unsafe { kernel::bindings::get_random_bytes(buf.as_mut_ptr().cast(), buf.len()) };
+}
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index 79f446041b64..db4c38b6dc92 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -6,19 +6,45 @@
 //! This is an `[RFC]` work-in-progress, posted to ask for help. It is a clean-room
 //! reverse-engineered replacement for the proprietary DisplayLinkManager userspace
 //! daemon + the EVDI kernel module, written natively in Rust against the in-tree USB,
-//! crypto and DRM/KMS bindings.
+//! crypto and DRM/KMS bindings (the prerequisite binding patches are posted as their
+//! own series).
 //!
-//! This first patch is the skeleton: it binds the dock over USB and runs the plaintext
-//! connect handshake (the control-request preamble and the three bulk init messages over
-//! the Rust USB bulk + control transfer API). The HDCP 2.2 AKE, the AES-CTR/AES-CMAC
-//! control plane, the Vino codec and the DRM/KMS sink are added in the following patches.
+//! # What works
+//!
+//! On probe the driver runs, all on real hardware (Dell Universal Dock D6000):
+//! - the plaintext connect handshake over the Rust USB bulk + control transfer API;
+//! - the clean-room HDCP 2.2 AKE / LC / SKE -- H', L' and V' all verify against the
+//!   dock, so the session key `ks` is established and shared;
+//! - the AES-CTR + AES-CMAC ("Dl3Cmac") control-plane seal, byte-exact against the
+//!   reference daemon's captured wire;
+//! - the plaintext `type=2 sub=0x24` stream-open arm marker; and
+//! - registration of a real `struct drm_device` (see [`drm_sink`]) via the simple
+//!   display pipe, so the dock appears to userspace as a mode-settable GEM/dumb DRM
+//!   card, with a live EP08 framebuffer-scanout hook on every page-flip.
+//!
+//! # What does NOT work -- the wall (help wanted)
+//!
+//! After the arm marker the driver sends the first encrypted control-plane frame
+//! (msg0) and the dock **never acknowledges it** (`wsub=0x45` ack count stays 0), so
+//! the CP cipher never engages and no pixels ever flow. Every host-observable channel
+//! has been matched to the reference daemon -- the bulk wire is byte-identical through
+//! the arm + msg0, the AKE verifies, the seal/MAC/IV are byte-exact, the full EP0
+//! control-transfer set matches, the endpoint set matches, the arm timing is tighter
+//! than the daemon's -- and the dock still silently drops our encrypted CP while it
+//! engages the daemon's. The gate appears to be something not visible on the host wire
+//! (dock-internal session state, or a whole-bus timing/ordering property a per-channel
+//! diff cannot see). **If you know the DL3 / DisplayLink control-plane engagement
+//! sequence, or have ideas for the remaining paired full-bus diff, please help.**
+//!
+//! Note: `send_cp_setup` builds msg0's body field-by-field except for a small captured
+//! cap-announce skeleton ([`golden`]); a fully field-derived cap-announce is open work.
 //!
 //! Device: VID 0x17e9 (DisplayLink) / PID 0x6006 (Dell Universal Dock D6000).
 
 use kernel::{
     alloc::flags::GFP_KERNEL,
     device::{self, Core},
-    error::code::ENODEV,
+    error::code::{ENODEV, EINVAL},
     prelude::*,
     sync::{aref::ARef, Arc},
     time::Delta,
@@ -34,6 +60,9 @@
 /// Control + per-head bulk endpoints (guide sec 2).
 const EP_CTRL_OUT: u8 = 0x02;
 const EP_CTRL_IN: u8 = 0x84;
+/// EP84 (dock->host) drain buffer size. The dock's capability block can reach ~5.8 KiB, so a
+/// single bulk read needs a generously sized buffer to avoid truncating and misframing it.
+const EP84_BUF: usize = 16384;
 
 /// USB transfer timeout used during bring-up.
 fn timeout() -> Delta {
@@ -41,6 +70,26 @@ fn timeout() -> Delta {
 }
 
 mod proto;
+mod crypto;
+mod rng;
+mod hdcp;
+mod ake;
+mod golden;
+
+/// The shared secrets a completed HDCP 2.2 AKE leaves behind: the SKE session key
+/// `ks` and content IV `riv` key the AES-CTR control plane (sec 6), and `kd` is kept
+/// for any further repeater verification. Consumed by the Phase 2b/2c CP + video.
+#[allow(dead_code)] // ks/riv/kd are consumed by the post-engagement CP stream (open blocker)
+struct Session {
+    ks: [u8; 16],
+    riv: [u8; 8],
+    kd: [u8; 32],
+    /// The 7-frame **plaintext capability-announce** to send between the init markers and
+    /// the arm marker (see `VinoDriver::build_cap_announce`). Built LIVE
+    /// from this session's AKE values (rtx/ekpub/rn/edkey+riv/V) -- NOT a stale replay. Empty
+    /// for a non-repeater dock (the announce path is only exercised on the D6000, repeater=1).
+    cap_announce: KVec<u8>,
+}
 
 /// Per-bound-interface driver state.
 struct VinoDriver {
@@ -80,21 +129,94 @@ impl WorkItem for BringUp {
     fn run(this: Arc<BringUp>) {
         let cdev: &device::Device = this.intf.as_ref();
         let dev: &usb::Device = this.intf.as_ref();
-        // WIP scaffold: attempt the plaintext bring-up. Bind regardless of the outcome --
-        // there is no display path yet (the HDCP AKE, control plane and DRM sink land in
-        // the following patches).
+        // WIP scaffold: plaintext bring-up then the clean-room HDCP 2.2 AKE/LC/SKE. Bind
+        // regardless of the outcome; the control plane and DRM sink land in later patches.
         match VinoDriver::bring_up(dev) {
-            Ok(()) => dev_info!(cdev, "vino: plaintext session init OK\n"),
+            Ok(()) => {
+                dev_info!(cdev, "vino: plaintext session init OK\n");
+                match VinoDriver::run_ake(dev) {
+                    Ok(session) => {
+                        dev_info!(cdev, "vino: HDCP AKE + LC + SKE complete (session keyed)\n");
+                        // Dev diagnostic: the live session key/riv, so the dock's encrypted
+                        // EP84 replies can be decoded offline from a usbmon capture. Behind
+                        // pr_debug, so compiled out unless dynamic debug is enabled.
+                        pr_debug!("vino: SESSION ks={:02x?} riv={:02x?}\n", &session.ks, &session.riv);
+                    }
+                    Err(e) => dev_info!(cdev, "vino: HDCP AKE incomplete ({e:?}) -- WIP\n"),
+                }
+            }
             Err(e) => dev_info!(cdev, "vino: session init incomplete ({e:?}) -- WIP\n"),
         }
     }
 }
 
+/// On-device crypto known-answer self-test. Confirms the IN-KERNEL crypto path (which the CP seal
+/// depends on) is byte-correct -- something only ever checked offline (Python `verify-kdf.py`)
+/// before.
+/// Runs three checks and logs PASS/FAIL:
+///   1. AES-128-ECB vs the FIPS-197 test vector.
+///   2. AES-CMAC vs the RFC 4493 test vector (subkey + full-block path).
+///   3. The full `cp::seal_livemac` vs cold-ref's REAL msg0: known plaintext + known `ks`/`riv`
+///      must reproduce the captured wire ciphertext+tag byte-for-byte. A FAIL here (with 1+2
+///      passing) would localize a bug in our seal framing; a FAIL in 1/2 means the kernel
+///      primitive itself is wrong. If all PASS, the crypto we send is correct and the
+///      CP-engagement wall is NOT our crypto.
+fn crypto_selftest() {
+    use core::sync::atomic::{AtomicBool, Ordering};
+    static RAN: AtomicBool = AtomicBool::new(false);
+    if RAN.swap(true, Ordering::Relaxed) {
+        return;
+    }
+
+    // 1. AES-128-ECB KAT (FIPS-197 Appendix B / C.1).
+    let ecb_key = [
+        0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
+        0x0f,
+    ];
+    let ecb_pt = [
+        0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
+        0xff,
+    ];
+    let ecb_expect = [
+        0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5,
+        0x5a,
+    ];
+    match crypto::aes128_ecb(&ecb_key, &ecb_pt) {
+        Ok(out) if out == ecb_expect => pr_info!("vino: selftest AES-128-ECB PASS\n"),
+        Ok(out) => pr_err!("vino: selftest AES-128-ECB FAIL got={out:02x?}\n"),
+        Err(e) => pr_err!("vino: selftest AES-128-ECB ERR ({e:?})\n"),
+    }
+
+    // 2. AES-CMAC KAT (RFC 4493 sec 4 example 2: a single 16-byte block).
+    let cmac_key = [
+        0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f,
+        0x3c,
+    ];
+    let cmac_msg = [
+        0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, 0x17,
+        0x2a,
+    ];
+    let cmac_expect = [
+        0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a, 0x28,
+        0x7c,
+    ];
+    match crypto::aes_cmac(&cmac_key, &cmac_msg) {
+        Ok(out) if out == cmac_expect => pr_info!("vino: selftest AES-CMAC PASS\n"),
+        Ok(out) => pr_err!("vino: selftest AES-CMAC FAIL got={out:02x?}\n"),
+        Err(e) => pr_err!("vino: selftest AES-CMAC ERR ({e:?})\n"),
+    }
+}
+
 impl VinoDriver {
     /// Plaintext session bring-up (sec 4): control-request preamble then the three
     /// bulk init messages, reading the single ACK. Best-effort during scaffold
     /// bring-up -- errors are logged, not fatal.
     fn bring_up(dev: &usb::Device) -> Result {
+        // Verify the KERNEL crypto path is byte-correct before we rely on it for CP. The KDF was
+        // only ever checked offline (Python); this confirms the in-kernel AES-ECB, AES-CMAC and the
+        // full `seal_livemac` reproduce ground-truth vectors on THIS device. Logs PASS/FAIL once.
+        crypto_selftest();
+
         // Control-request preamble (sec 4): dock-id read, interface selection, then the
         // vendor_out 0x24 / vendor_in 0x22 pairs that kick off the HDCP path. (The
         // GET_DESCRIPTOR string reads DLM also issues look cosmetic and are omitted.)
@@ -320,6 +442,526 @@ fn bring_up(dev: &usb::Device) -> Result {
         }
     }
 
+
+    /// Whether to service EP83 (interrupt-IN status) during bring-up. Measured 2026-06-16
+    /// (paired-coldbus-20260616-162650): DLM polls EP83 0x in the pre-arm window (14x total, all
+    /// post-engagement) while vino polled it 5x pre-arm -- injecting interrupt-IN traffic into the
+    /// critical arm/msg0 window that DLM never generates. Disabled so the pre-arm wire matches DLM;
+    /// re-enable if a post-engagement status channel is ever needed (DLM only services it once the
+    /// dock has already acked).
+    const POLL_EP83_DURING_BRINGUP: bool = false;
+
+    /// Reads the next HDCP response (type=4 sub=0x25, sec 5.2) from EP `0x84`,
+    /// skipping any non-HDCP frames (e.g. plain ACKs) in between, and returns the
+    /// parsed `(msg_id, payload)`. Bounded retry so a chatty dock can't wedge us.
+    fn recv_hdcp(dev: &usb::Device) -> Result<(u8, KVec<u8>)> {
+        const SUB_HDCP_RESP: u16 = 0x25;
+        let mut buf = KVec::from_elem(0u8, 4096, GFP_KERNEL)?;
+        for _ in 0..24 {
+            // Read EP84 FIRST. The dock replies to AKE messages sub-millisecond (DLM cold capture:
+            // ~0.1-0.7 ms between EP84 IN frames), but it interleaves status/cap pushes that we
+            // skip. Polling EP83 (a ~2 ms idle wait) BEFORE every read added ~2 ms x
+            // N-skipped-frames
+            // of latency per reply -- making vino's AKE ~400 ms vs DLM's ~62 ms, slow enough that
+            // the
+            // dock starts downstream HDCP and NAKs our arm/Stream_Manage. So only service EP83 when
+            // EP84 came back empty (same reorder as `drain_ep84`). See the cold wire diff.
+            let n = dev.bulk_recv(EP_CTRL_IN, &mut buf, timeout())?;
+            if n < 16 {
+                if Self::POLL_EP83_DURING_BRINGUP {
+                    Self::poll_ep83(dev);
+                }
+                continue;
+            }
+            // DIAGNOSTIC (2026-06-11): log EVERY frame the dock returns during the AKE --
+            // including
+            // wsub!=0x25 and cap-block (sub=0x84) pushes we'd otherwise skip -- so we can see
+            // whether
+            // the dock interleaves its capability blocks with the HDCP replies (the suspected
+            // reason
+            // its cap phase never completes / it won't engage CP). Inner id/sub at off 16/18.
+            {
+                let wsub = u16::from_le_bytes([buf[8], buf[9]]);
+                let iid = if n >= 18 { u16::from_le_bytes([buf[16], buf[17]]) } else { 0 };
+                let isub = if n >= 20 { u16::from_le_bytes([buf[18], buf[19]]) } else { 0 };
+                pr_debug!("vino: AKE-EP84 {n}B wsub={wsub:#x} inner_id={iid:#x} inner_sub={isub:#x}\n");
+            }
+            if u16::from_le_bytes([buf[8], buf[9]]) != SUB_HDCP_RESP {
+                continue; // non-HDCP frame -- skip
+            }
+            if let Some((id, payload)) = ake::parse_in(&buf[16..n]) {
+                // Inner msg_id 0 is a status/ACK frame (the dock emits one as a
+                // sub=0x25 frame after each OUT message, e.g. the `14 00 76 00...`
+                // frame after AKE_Init) -- skip it and keep reading for the real
+                // HDCP response, mirroring the oracle's recv_hdcp_msg.
+                if id == 0 {
+                    continue;
+                }
+                let mut pl = KVec::with_capacity(payload.len(), GFP_KERNEL)?;
+                pl.extend_from_slice(payload, GFP_KERNEL)?;
+                return Ok((id, pl));
+            }
+        }
+        Err(EINVAL)
+    }
+
+
+    /// Pace like DLM after a RepeaterAuth OUT (ctr6 Send_Ack / ctr7 Stream_Manage):
+    /// read the dock's per-frame `id=0x14 sub=0x10` ack off EP84 BEFORE the next OUT,
+    /// so vino never transmits while the dock is mid-NAK.
+    ///
+    /// Ground truth (cold wire diff, captures/dlm-cold-20260611-123347 vs vino-cold):
+    /// DLM reads that ack after EVERY cap/AKE OUT --
+    /// ctr4->ack->ctr5->ack->ctr6->ack->ctr7->
+    /// ack->arm, ~0.2 ms apart, whole ctr7->arm gap 0.46 ms. Commit d74a4d7 dropped the
+    /// drain for ctr6/ctr7, so `run_ake` sent ctr6->ctr7 back-to-back with no read; the
+    /// dock (busy with downstream HDCP after SKE) then NAK'd each OUT ~100 ms (vino's
+    /// V'->arm gap measured ~200 ms), and the arm landed after the dock had left its
+    /// freshly-keyed CP window -> CP never engaged (0 `wsub=0x45`). Restoring the read
+    /// re-paces vino to DLM and lets the arm land tight. Best-effort: returns as soon as
+    /// the matching ack arrives, or immediately if nothing is queued (dock idle).
+    fn pace_cap_ack(dev: &usb::Device, want_ctr: u16) {
+        let Ok(mut buf) = KVec::from_elem(0u8, 4096, GFP_KERNEL) else {
+            return;
+        };
+        for _ in 0..8 {
+            match dev.bulk_recv(EP_CTRL_IN, &mut buf, Delta::from_millis(30)) {
+                Ok(len) if len >= 22 => {
+                    let wsub = u16::from_le_bytes([buf[8], buf[9]]);
+                    let iid = u16::from_le_bytes([buf[16], buf[17]]);
+                    let ictr = u16::from_le_bytes([buf[20], buf[21]]);
+                    // The per-frame cap-ack: wsub=0x25, inner id=0x14 sub=0x10 ctr=want.
+                    // An interleaved cap push (sub=0x84) or earlier ack -- keep reading.
+                    if wsub == 0x25 && iid == 0x14 && ictr == want_ctr {
+                        return;
+                    }
+                }
+                // Nothing queued within the short window -- the dock is idle, don't block.
+                _ => return,
+            }
+        }
+    }
+
+
+    /// After ctr7 (Stream_Manage) and its ack, WAIT for the dock's terminal capability block
+    /// `id=0x0b sub=0x84` before letting the caller arm. This is the dock's "cap-complete"
+    /// signal: DLM receives it and only then arms (cold-ref: `id=0x21` @52.1465 -> `id=0x0b`
+    /// @52.1469 -> arm @52.1474). vino's lockstep ([`pace_cap_ack`]) only consumed the `id=0x14`
+    /// ctr acks, so it armed right after ctr7's ack -- BEFORE the dock had emitted `id=0x0b`
+    /// (vino received every other cap block id=0x213/0x0d/0x10/0x28/0x18/0x21 but armed one push
+    /// early). The dock then NAK'd msg0 ~100 ms and dumped a 16 KB error block
+    /// (`type=0x1003 wsub=0x37`) that DLM never produces, instead of engaging CP -- the true
+    /// gate, found on cold plug `vino-cold-20260612-080549`. The dock emits `id=0x0b` a few ms
+    /// after `id=0x21` once it settles downstream HDCP, so draining EP84 until it arrives keeps
+    /// the arm tight (DLM ~ 0.5 ms after ctr7) yet correctly ordered. Best-effort, bounded.
+    fn wait_cap_complete(dev: &usb::Device, kd: &[u8; 32]) {
+        let Ok(mut buf) = KVec::from_elem(0u8, EP84_BUF, GFP_KERNEL) else {
+            return;
+        };
+        // Drain EP84 until the dock goes QUIET, not merely until id=0x0b. Cold plug #2
+        // (vino-cold-20260612-082707) showed DLM's LAST pre-arm push is the id=0x28 that
+        // follows id=0x0b (cold-ref: id=0x0b@52.1469 -> ack ctr7 -> id=0x28@52.1472 ->
+        // arm@52.1474),
+        // whereas vino stopped at id=0x0b and armed -- leaving id=0x28 (and the rest of the dock's
+        // terminal cap burst) un-drained in the dock's EP84 queue. With its IN queue backed up the
+        // dock NAK'd vino's msg0 ~100 ms (it can't accept the OUT while it still owes IN data) and
+        // then dumped the 16 KB error block. So after id=0x0b, keep reading until a read times out
+        // (the dock has sent everything), then return so the caller arms into a clean dock -- like
+        // DLM. Bounded: id=0x0b is the marker; QUIET_GAP short reads of silence end the drain.
+        //
+        // * 2026-06-12 (HDCP 2.3 Adaptation sec RepeaterAuth, pdfs/): one of the frames drained
+        // here is
+        // the dock's `RepeaterAuth_Stream_Ready` (HDCP msg 0x11) -- the 3rd `id=0x28` DLM receives
+        // and
+        // vino historically did not. The spec requires the transmitter to RECEIVE it within 100 ms
+        // of
+        // `Stream_Manage` and verify `M == M'` before transmitting content; the dock's exactly-100
+        // ms
+        // msg0 NAK on a cold plug is that window. We now RECOGNISE it in this same drain (no added
+        // latency vs the old broken 10x1 s poll) and log `M'` plus candidate `M`s so the next
+        // capture
+        // pins the exact `STREAMID_TYPE || seq_num_M` the dock hashes. The HDCP msg_id rides at
+        // `body[9]` = `buf[25]` in an EP84 reply (`ake::parse_in`); `M'[32]` follows at
+        // `buf[26..58]`.
+        // Verification is logged-only for now (the DisplayLink field offsets in `Stream_Manage` are
+        // not yet confirmed, so a wrong guess must not block the arm); the arm is gated on
+        // receiving
+        // Stream_Ready when it arrives, else on the existing id=0x0b + quiet fallback. `M` key is
+        // `SHA256(kd)`; `M = HMAC-SHA256(STREAMID_TYPE || seq_num_M, SHA256(kd))`, seq_num_M = 0.
+        let sha_kd = crypto::sha256(kd);
+        let mut saw_0b = false;
+        let mut saw_ready = false;
+        let mut quiet = 0usize;
+        const QUIET_GAP: usize = 3; // ~3 consecutive empty short reads => dock done pushing
+        const MAX_ROUNDS: usize = 48;
+        for _ in 0..MAX_ROUNDS {
+            match dev.bulk_recv(EP_CTRL_IN, &mut buf, Delta::from_millis(5)) {
+                Ok(len) if len >= 20 => {
+                    quiet = 0;
+                    let iid = u16::from_le_bytes([buf[16], buf[17]]);
+                    let isub = u16::from_le_bytes([buf[18], buf[19]]);
+                    let mid = if len >= 26 { buf[25] } else { 0 }; // HDCP msg_id (body[9])
+                    if isub == 0x84 && iid == 0x0b {
+                        saw_0b = true;
+                    }
+                    if mid == ake::id::REPEATERAUTH_STREAM_READY && len >= 58 {
+                        saw_ready = true;
+                        let mprime = &buf[26..58];
+                        pr_info!("vino: AKE: Stream_Ready (0x11) M'={mprime:02x?}\n");
+                        // M = HMAC-SHA256(SHA256(kd), data) where data is the Content Stream
+                        // Management input the dock hashes: `k` 7-byte stream entries followed by
+                        // the 3-byte `seq_num_M` (=0 on the first Stream_Manage). Cracked from the
+                        // DLM aarch64 decompile (`FUN_0057be04`: data = memcpy(streams, k*7) ||
+                        // BE16(field) || field, keyed by the 32-byte SHA256(kd) at session+0x37);
+                        // reproduces DLM's captured M' byte-exact (captures/.../FINDINGS.md).
+                        // vino's
+                        // two streams carry the same StreamID_Type bytes its Stream_Manage sends
+                        // (`repeater_auth_stream_manage`: type 0x04 and 0x05), so the dock computes
+                        // the same M. (Earlier code guessed a 5-byte STREAMID_TYPE||seq layout and
+                        // so
+                        // always mismatched -- host-side only, never gated the dock.)
+                        let m_data: [u8; 17] = [
+                            0, 0, 0, 0x04, 0, 0, 0, // stream 0: StreamID_Type[0] = 4
+                            0, 0, 0, 0x05, 0, 0, 0, // stream 1: StreamID_Type[1] = 5
+                            0, 0, 0, // seq_num_M = 0 (first Stream_Manage, big-endian)
+                        ];
+                        let m = crypto::hmac_sha256(&sha_kd, &m_data);
+                        let eq = if &m[..] == mprime { "==" } else { "!=" };
+                        pr_info!("vino: AKE:   M {} M' (CSM stream-entry layout)\n", eq);
+                    } else if mid == ake::id::RECEIVER_AUTH_STATUS && len >= 27 {
+                        pr_info!("vino: AKE: RECEIVER_AUTH_STATUS=0x{:02x}\n", buf[26]);
+                    }
+                    // * 2026-06-12: arm the INSTANT both terminal markers have arrived -- the
+                    // cap-complete
+                    // id=0x0b AND the Stream_Ready (the trailing id=0x28 / HDCP 0x11). DLM arms
+                    // 0.46 ms
+                    // after its last cap block; a cold-plug cadence diff
+                    // (vino-cold-20260612-113706) showed
+                    // vino was instead waiting QUIET_GAP x 5 ms of EMPTY reads AFTER already
+                    // seeing both
+                    // markers, landing the arm ~68 ms late -- outside the dock's freshly-keyed CP
+                    // window, so
+                    // the dock errored on the arm (27 KB type=0x1001 dump) instead of engaging.
+                    // Once both
+                    // markers are in, the terminal burst is complete; arm now, like DLM. (The
+                    // empty-read
+                    // quiet path below remains the fallback when Stream_Ready never arrives.)
+                    if saw_0b && saw_ready {
+                        pr_info!("vino: cap-complete (id=0x0b + Stream_Ready 0x11) -- arming now\n");
+                        return;
+                    }
+                }
+                // Empty/short read = a quiet window. Fallback when Stream_Ready (0x11) never
+                // arrives:
+                // once id=0x0b has arrived AND the dock has been quiet for QUIET_GAP rounds, the
+                // terminal burst is drained -- arm now.
+                _ => {
+                    if saw_0b {
+                        quiet += 1;
+                        if quiet >= QUIET_GAP {
+                            pr_info!(
+                                "vino: cap-complete drained (id=0x0b{}+ quiet) -- arming now\n",
+                                if saw_ready { ", Stream_Ready 0x11, " } else { " (no 0x11) " }
+                            );
+                            return;
+                        }
+                    }
+                }
+            }
+        }
+        pr_info!(
+            "vino: cap-complete drain budget hit (saw_0b={saw_0b} saw_ready={saw_ready}) -- arming anyway\n"
+        );
+    }
+
+
+    /// Drives a full clean-room HDCP 2.2 AKE + LC + SKE (and RepeaterAuth for a
+    /// repeater sink) over EP `0x02`/`0x84`, verifying `H'`, `L'` and `V'` against
+    /// our own KDF (sec 5). On success returns the [`Session`] keys.
+    ///
+    /// All HDCP transfers use transport `seq=0`; the `hdcp_seq` counter increments
+    /// 1..7 across the OUT messages (sec 5.1). Best-effort: any mismatch/short read
+    /// aborts with an error the caller logs.
+    fn run_ake(dev: &usb::Device) -> Result<Session> {
+        use ake::id;
+
+        // Flush any STALE EP84 frames the dock still has queued from a PRIOR session before
+        // starting a fresh AKE. On a warm rmmod/insmod re-probe the dock is not power-cycled, so
+        // its previous CP/cap replies (including a multi-KB residual block) sit in its EP84 queue;
+        // if we don't drain them, the first `recv_hdcp` picks up a stale frame and the whole AKE
+        // reply stream is shifted. Harmless on a true cold plug -- the queue is already empty, so
+        // the first read just times out. Best-effort.
+        if let Ok(mut flush) = KVec::from_elem(0u8, EP84_BUF, GFP_KERNEL) {
+            let mut flushed = 0usize;
+            for _ in 0..32 {
+                match dev.bulk_recv(EP_CTRL_IN, &mut flush, Delta::from_millis(20)) {
+                    Ok(n) if n > 0 => flushed += 1,
+                    _ => break,
+                }
+            }
+            if flushed > 0 {
+                pr_info!("vino: flushed {flushed} stale EP84 frame(s) before AKE\n");
+            }
+        }
+
+        // (1) AKE_Init -- fresh rtx, TxCaps = 00 00 00 (DLM-exact).
+        let mut rtx = [0u8; 8];
+        rng::fill(&mut rtx);
+        dev.bulk_send(EP_CTRL_OUT, &ake::ake_init(1, 0, &rtx, &[0; 3])?, timeout())?;
+
+        // (2) AKE_Send_Cert: payload = REPEATER(1) || cert_rx(522). Extract the
+        // RSA-1024 public key (modulus[5..133], exponent[133..136]).
+        let (cid, cert_msg) = Self::recv_hdcp(dev)?;
+        if cid != id::AKE_SEND_CERT || cert_msg.len() < 1 + 136 {
+            pr_err!("vino: AKE: bad AKE_Send_Cert (id={cid:#x}, {} B)\n", cert_msg.len());
+            return Err(EINVAL);
+        }
+        let repeater = cert_msg[0] != 0;
+        let cert = &cert_msg[1..];
+        let mut modulus = [0u8; 128];
+        modulus.copy_from_slice(&cert[5..133]);
+        let mut exponent = [0u8; 3];
+        exponent.copy_from_slice(&cert[133..136]);
+
+        // (3) AKE_Transmitter_Info, then (4) read AKE_Receiver_Info (RxCaps unused).
+        dev.bulk_send(EP_CTRL_OUT, &ake::ake_transmitter_info(2, 0)?, timeout())?;
+        let _ = Self::recv_hdcp(dev)?;
+
+        // (5) AKE_No_Stored_km -- fresh km, RSA-OAEP-SHA256 to Ekpub(km).
+        let mut km = [0u8; 16];
+        rng::fill(&mut km);
+        let ekpub = hdcp::oaep_encrypt_km(&modulus, &exponent, &km)?;
+        dev.bulk_send(EP_CTRL_OUT, &ake::ake_no_stored_km(3, 0, &ekpub)?, timeout())?;
+
+        // (6) AKE_Send_Rrx.
+        let (rid, rrx_pl) = Self::recv_hdcp(dev)?;
+        if rid != id::AKE_SEND_RRX || rrx_pl.len() < 8 {
+            pr_err!("vino: AKE: bad AKE_Send_Rrx (id={rid:#x})\n");
+            return Err(EINVAL);
+        }
+        let mut rrx = [0u8; 8];
+        rrx.copy_from_slice(&rrx_pl[..8]);
+
+        // (7)/(8) AKE_Send_H_prime -- verify H' = HMAC(kd, rtx^REPEATER).
+        let (hid, hp) = Self::recv_hdcp(dev)?;
+        if hid != id::AKE_SEND_H_PRIME || hp.len() < 32 {
+            pr_err!("vino: AKE: bad H' (id={hid:#x})\n");
+            return Err(EINVAL);
+        }
+        let kd = hdcp::derive_kd(&km, &rtx, &rrx)?;
+        if hdcp::compute_h(&kd, &rtx, repeater)[..] != hp[..32] {
+            pr_err!("vino: AKE: H' mismatch -- authentication failed\n");
+            return Err(EINVAL);
+        }
+        pr_info!("vino: AKE: H' verified\n");
+
+        // (9) AKE_Send_Pairing_Info (Ekh_km) -- read and discard (no-stored path).
+        let _ = Self::recv_hdcp(dev)?;
+
+        // (10) Locality Check -- LC_Init(rn) then verify L'.
+        let mut rn = [0u8; 8];
+        rng::fill(&mut rn);
+        dev.bulk_send(EP_CTRL_OUT, &ake::lc_init(4, 0, &rn)?, timeout())?;
+        let (lid, lp) = Self::recv_hdcp(dev)?;
+        if lid != id::LC_SEND_L_PRIME || lp.len() < 32 {
+            pr_err!("vino: AKE: bad L' (id={lid:#x})\n");
+            return Err(EINVAL);
+        }
+        if hdcp::compute_l(&kd, &rrx, &rn)[..] != lp[..32] {
+            pr_err!("vino: AKE: L' mismatch -- locality check failed\n");
+            return Err(EINVAL);
+        }
+        pr_info!("vino: AKE: L' verified\n");
+
+        // (11) Session Key Exchange -- send Edkey(ks) || riv. The session key and IV are
+        // fresh-random per session.
+        let mut ks = [0u8; 16];
+        let mut riv = [0u8; 8];
+        rng::fill(&mut ks);
+        rng::fill(&mut riv);
+        let edkey = hdcp::compute_eks(&km, &rtx, &rrx, &rn, &ks)?;
+        // Dev diagnostic: the full SKE secrets, so the SKE delivery can be verified OFFLINE
+        // (edkey == ks XOR derive_dkey(km,rtx,rrx,rn,2), and the dock unwrapping to the same ks).
+        // Behind pr_debug, so compiled out unless dynamic debug is enabled.
+        pr_debug!("vino: SKE-SECRETS km={km:02x?} rtx={rtx:02x?} rrx={rrx:02x?} rn={rn:02x?}\n");
+        pr_debug!("vino: SKE-SECRETS ks={ks:02x?} edkey={edkey:02x?}\n");
+        // * riv DERIVATION -- THE CP-ENGAGEMENT BUG, FIXED 2026-06-11.
+        // The SKE delivers the BASE riv (byte7 low-3 head/direction-selector bits cleared); the
+        // dock
+        // derives the per-direction CP riv from that base. GROUND TRUTH from cold-ref AND the live
+        // vino cold-plug diff (captures/dlm-cold-20260611-123347 + vino-cold-20260611-130522):
+        // delivered base byte7 = e8 -> host OUT-CP riv = ec (base | 0x04) -> dock IN-CP riv = ed
+        // (^1).
+        // vino had been sealing OUT-CP with the RAW random `riv` (byte7 e.g. f9 = base f8 | 0x01)
+        // while delivering base f8 -- so the dock, deriving its keystream from f8 (expecting
+        // host-OUT
+        // = fc), could NOT decrypt vino's CP and SILENTLY DROPPED every post-arm frame (0 sub=0x45,
+        // EP84 dead after the arm) even though ks/seal/MAC/frame-format were all byte-correct. The
+        // off-by-one-bit IV was the whole wall. Fix: deliver base, seal OUT with base | 0x04.
+        // The SKE delivers the FULL random riv as-is (DLM does NOT mask the low bits -- verified
+        // on
+        // two decrypted DLM sessions: cold-ref delivers ...e8, dl3cmac delivers ...e7). The host CP
+        // OUT riv = delivered XOR 0x04 (flip byte7 bit 2): cold-ref e8->ec, dl3cmac e7->e3.
+        // cp::in_riv
+        // then ^1 for the dock->host IN stream (ec->ed). vino had been masking the delivered riv
+        // and
+        // sealing with the raw random LSBs, so the dock (deriving its keystream as delivered^0x04)
+        // got a different keystream and silently dropped every CP frame. See the vino cold-plug
+        // diff.
+        let riv_ske = riv; // deliver the full random riv, unmasked, exactly like DLM
+        riv[7] ^= 0x04; // host OUT-CP riv = delivered ^ 0x04
+        dev.bulk_send(EP_CTRL_OUT, &ake::ske_send_eks(5, 0, &edkey, &riv_ske)?, timeout())?;
+        // Dev diagnostic: the live session key/out-riv the dock must hold to decrypt our CP.
+        pr_debug!("vino: SESSION ks={ks:02x?} out_riv={riv:02x?}\n");
+
+        // The LIVE plaintext capability-announce (`build_cap_announce`),
+        // built once V is known below. Empty unless the dock is a repeater (D6000 always is).
+        let mut cap_announce = KVec::new();
+
+        // (12) RepeaterAuth -- verify V' over the ReceiverID_List, ACK, then SM2.
+        if repeater {
+            let (vid, list) = Self::recv_hdcp(dev)?;
+            if vid != id::REPEATERAUTH_SEND_RECEIVERID_LIST || list.len() < 16 {
+                pr_err!("vino: AKE: bad ReceiverID_List (id={vid:#x})\n");
+                return Err(EINVAL);
+            }
+            let split = list.len() - 16;
+            // V = HMAC(kd, list_header): MSB-128 = V' (verify vs the list trailer);
+            // LSB-128 = the RepeaterAuth_Send_Ack value (NOT the MSB -- that was THE bug).
+            let v_full = hdcp::compute_v_full(&kd, &list[..split]);
+            let mut v_ack = [0u8; 16];
+            v_ack.copy_from_slice(&v_full[16..]);
+            if v_full[..16] != list[split..] {
+                pr_err!("vino: AKE: V' mismatch -- repeater verification failed\n");
+                return Err(EINVAL);
+            }
+            pr_info!("vino: AKE: V' verified\n");
+            dev.bulk_send(EP_CTRL_OUT, &ake::repeater_auth_send_ack(6, 0, &v_ack)?, timeout())?;
+            // Read the dock's ctr6 ack before sending ctr7 -- DLM's lockstep pacing, without
+            // which the dock NAKs the back-to-back OUTs ~100 ms each (see `pace_cap_ack`).
+            Self::pace_cap_ack(dev, 6);
+            dev.bulk_send(EP_CTRL_OUT, &ake::repeater_auth_stream_manage(7, 0)?, timeout())?;
+            // Read the dock's ctr7 ack before returning, so the caller's arm marker lands
+            // tight after ctr7 (DLM: 0.46 ms) instead of while the dock is still NAKing.
+            Self::pace_cap_ack(dev, 7);
+            // Then drain the dock's terminal cap burst -- id=0x0b (cap-complete) AND the dock's
+            // `RepeaterAuth_Stream_Ready` (HDCP 0x11, the 3rd id=0x28) -- before the caller arms.
+            // DLM arms only after this burst (cold-ref: id=0x21 -> id=0x0b -> id=0x28/0x11 ->
+            // arm);
+            // arming early makes the dock NAK msg0 ~100 ms and dump a 16 KB error block instead of
+            // engaging. `wait_cap_complete` recognises + verifies the Stream_Ready in place (HDCP
+            // 2.3 Adaptation sec RepeaterAuth). `kd` is needed to check `M == M'`.
+            Self::wait_cap_complete(dev, &kd);
+
+            // Build the LIVE capability-announce now that every field is known. This is the
+            // plaintext re-statement of the 7 AKE OUT messages the dock requires between the
+            // init markers and the arm marker (`CP_CAP_PHASE`). See `build_cap_announce`.
+            // Pass `riv_ske` (the value SKE_Send_Eks actually delivered), NOT `riv` (= session
+            // OUT-CP seal riv = riv_ske ^ 0x04). The cap-announce ctr5 frame is a byte-faithful
+            // re-statement of SKE_Send_Eks, so it must carry the IDENTICAL riv.
+            cap_announce = Self::build_cap_announce(&rtx, &ekpub, &rn, &edkey, &riv_ske, &v_ack)?;
+        }
+
+        Ok(Session { ks, riv, kd, cap_announce })
+    }
+
+
+    /// Build the LIVE plaintext **capability-announce** the dock requires before the arm
+    /// marker. Ground truth: the cold-ref raw wire
+    /// (`captures/cold-ref-20260608-200850/`, t~36.754-36.813) shows DLM, *after* the HDCP
+    /// AKE, sends 7 plaintext `type=4 wsub=0x04` frames that are a re-statement of the 7 AKE
+    /// OUT messages -- `id=0x22/0x1f/0x9a/0x22/0x32/0x2a/0x2d`, `sub=0x10`, ctr 1-7 -- each
+    /// carrying THIS session's real value: f1=rtx, f2=const TxCaps, f3=Ekpub(km)[128],
+    /// f4=rn, f5=Edkey(ks)[16]||riv_base[8], f6=V[16], f7=const Stream_Manage config. The dock
+    /// ACKs each (`id=0x14 sub=0x10 ctr=N`) and only then engages its CP cipher; skipping the
+    /// announce leaves it cipher-off (the long-standing "0 `sub=0x45` acks" symptom).
+    ///
+    /// [`golden::CAP_PLAIN_1080P`] is a byte-correct *skeleton* (headers/aux/lead bytes and the
+    /// two constant frames are session-invariant -- verified across the cold-ref and matched
+    /// sessions) but its 5 variable payloads are a STALE foreign session's values. Replaying it
+    /// verbatim delivers the dock a stale Ekpub/Edkey/riv that re-key it to a foreign `ks`
+    /// (the `cap_phase`-clobbers-`ks` bug). So we clone the skeleton and overwrite ONLY the 5
+    /// session-specific payloads. Each payload sits at frame offset 44 (16-byte wire header +
+    /// 22 inner-prefix bytes + the `30 00 00 00 00` marker + 1 lead byte = 28 inner bytes), and
+    /// frames are stored `[u16 len][frame]`. `riv` here is the SKE-*delivered* riv (`riv_ske`),
+    /// written verbatim -- frame 5 is a byte-faithful re-statement of `SKE_Send_Eks`, so it must
+    /// carry the EXACT delivered riv. (It earlier wrote `riv & 0xF8`, which equals the delivered
+    /// value only when the random riv's low 3 bits are zero -- true for cold-ref's `e8` but wrong
+    /// for 7 of 8 live sessions, so the dock saw a different riv in the announce than in SKE.
+    /// Ground truth: cold-ref ctr5 capture t=36.812413 delivers riv `...40e8` == its SKE riv.)
+    fn build_cap_announce(
+        rtx: &[u8; 8],
+        ekpub: &[u8; 128],
+        rn: &[u8; 8],
+        edkey: &[u8; 16],
+        riv: &[u8; 8],
+        v: &[u8; 16],
+    ) -> Result<KVec<u8>> {
+        let mut blob = KVec::with_capacity(golden::CAP_PLAIN_1080P.len(), GFP_KERNEL)?;
+        blob.extend_from_slice(golden::CAP_PLAIN_1080P, GFP_KERNEL)?;
+
+        // Walk the skeleton; for each frame, overwrite the payload (at frame+44) keyed by ctr.
+        let mut off = 0usize;
+        while off + 2 <= blob.len() {
+            let len = u16::from_le_bytes([blob[off], blob[off + 1]]) as usize;
+            let frame = off + 2;
+            if frame + len > blob.len() {
+                break;
+            }
+            // ctr (inner offset 4) identifies which AKE message this announce frame restates.
+            let ctr = u16::from_le_bytes([blob[frame + 16 + 4], blob[frame + 16 + 5]]);
+            let pay = frame + 44; // 16 hdr + 22 inner-prefix + 5 marker + 1 lead
+            match ctr {
+                1 => blob[pay..pay + 8].copy_from_slice(rtx), // AKE_Init
+                3 => blob[pay..pay + 128].copy_from_slice(ekpub), // AKE_No_Stored_km Ekpub
+                4 => blob[pay..pay + 8].copy_from_slice(rn), // LC_Init
+                5 => {
+                    // SKE_Send_Eks: Edkey(ks)[16] || riv[8] (the delivered riv, verbatim)
+                    blob[pay..pay + 16].copy_from_slice(edkey);
+                    blob[pay + 16..pay + 24].copy_from_slice(riv);
+                }
+                6 => blob[pay..pay + 16].copy_from_slice(v), // RepeaterAuth_Send_Ack V
+                _ => {} // ctr 2 (TxCaps) and 7 (Stream_Manage) are session-invariant
+            }
+            off = frame + len;
+        }
+        Ok(blob)
+    }
+
+
+    /// Poll EP 0x83 (interrupt-IN status endpoint). DLM submits URBs here CONTINUOUSLY and the dock
+    /// pushes 6-byte status events; the dock may gate CP/downstream-HDCP engagement on the host
+    /// servicing this endpoint (flagged in `vino-driver/src/bin/bringup.rs`). vino never polled it
+    /// --
+    /// invisible in the EP02/EP84 bulk-wire comparison. Reads up to a few events (short timeout so
+    /// a
+    /// URB is pending when the dock pushes). `usb_bulk_msg` auto-routes the interrupt endpoint.
+    fn poll_ep83(dev: &usb::Device) -> usize {
+        // EP83 (interrupt-IN) transfers need DMA-capable memory -- allocate on the HEAP.
+        // A stack array trips usb_hcd_map_urb_for_dma's "transfer buffer is on stack"
+        // WARNING (VMAP_STACK can't be DMA-mapped) and the broken submit also stalls the
+        // bring-up (poll_ep83 runs inside every drain round). Best-effort: bail on OOM.
+        let mut buf = match KVec::from_elem(0u8, 64, GFP_KERNEL) {
+            Ok(b) => b,
+            Err(_) => return 0,
+        };
+        let mut n = 0usize;
+        // Short timeout: a pending URB gives the dock a window to push, but a 30 ms block on the
+        // (normally idle) EP83 stalls the bring-up loop (see drain_ep84). 2 ms is enough to catch a
+        // ready event without serializing the handshake.
+        for _ in 0..4 {
+            match dev.interrupt_recv(0x83, &mut buf, Delta::from_millis(2)) {
+                Ok(len) if len > 0 => {
+                    n += 1;
+                    let s = &buf[..len.min(8)];
+                    pr_info!("vino: EP83 status event {len}B {s:02x?}\n");
+                }
+                _ => break,
+            }
+        }
+        n
+    }
+
 }
 
 kernel::usb_device_table!(
-- 
2.54.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH 3/7] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm
  2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
  2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
  2026-06-17 15:12 ` [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
  2026-06-17 20:15   ` sashiko-bot
  2026-06-17 15:12 ` [RFC PATCH 4/7] drm/vino: add the Vino (RawRl mode-2) framebuffer codec Mike Lothian
                   ` (5 subsequent siblings)
  8 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
  To: dri-devel
  Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
	linux-kernel

With the HDCP session keyed, the dock's control plane (CP) is an
AES-CTR-encrypted, AES-CMAC-authenticated ("Dl3Cmac") message channel.
Add the cp module: the control-plane message builders (mode-set, EDID
read/parse, cursor, the interactive seal) plus seal_livemac(), which
encrypts and frames a CP message under the live ks/riv -- byte-exact
against the reference daemon's captured wire (the on-device self-test
gains a third known-answer check that reproduces the daemon's real msg0).

send_cp_setup() drives the post-SKE sequence: it opens the async EP84
bulk-IN reader, sends the plaintext type=2 sub=0x24 stream-open arm
marker, then the first live encrypted CP frame, and counts the dock's
encrypted wsub=0x45 acks. The EP84 drain/parse helpers and the
lockstep-reply decoder land here too.

This is THE WALL: on a cold dock the ack count stays 0 -- the dock runs
the entire plaintext handshake but never engages the encrypted CP (see
the final patch's "help wanted" note). CP_ENGAGED is left clear, which
gates the EP08 video added in a later patch.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
 drivers/gpu/drm/vino/cp.rs   | 635 +++++++++++++++++++++++++++++++++++
 drivers/gpu/drm/vino/vino.rs | 607 ++++++++++++++++++++++++++++++++-
 2 files changed, 1237 insertions(+), 5 deletions(-)
 create mode 100644 drivers/gpu/drm/vino/cp.rs

diff --git a/drivers/gpu/drm/vino/cp.rs b/drivers/gpu/drm/vino/cp.rs
new file mode 100644
index 000000000000..2668931d8500
--- /dev/null
+++ b/drivers/gpu/drm/vino/cp.rs
@@ -0,0 +1,635 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Encrypted-control-plane message builders (the inner plaintext of the type=4
+//! sub=0x24 AES-CTR frames) plus the AES-CTR `seal` that encrypts and frames them.
+//! Layouts are from the reverse-engineered protocol; offsets cite the guide and
+//! should be re-checked against a capture before they drive real hardware.
+#![allow(dead_code)] // some seal/handler paths run only after the dock engages CP (open blocker)
+
+use super::*;
+
+/// Common CP inner header: `[id u16][sub u16][counter u16][00 00]` (sec 6.1/sec 8.6.4).
+fn header(out: &mut KVec<u8>, id: u16, sub: u16, counter: u16) -> Result {
+    out.extend_from_slice(&id.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&sub.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&counter.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&[0, 0], GFP_KERNEL)?;
+    Ok(())
+}
+
+fn pad_to(out: &mut KVec<u8>, len: usize) -> Result {
+    while out.len() < len {
+        out.push(0, GFP_KERNEL)?;
+    }
+    Ok(())
+}
+
+/// OUT heartbeat (sec 6.1): `id=0x16 sub=0x75`, two AES blocks (`10 27` at block1+6).
+pub(super) fn heartbeat(counter: u16) -> Result<KVec<u8>> {
+    let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
+    header(&mut b, 0x16, 0x75, counter)?;
+    pad_to(&mut b, 22)?; // block0 tail + block1[0..6]
+    b.extend_from_slice(&[0x10, 0x27], GFP_KERNEL)?; // block1[6..8]
+    pad_to(&mut b, 32)?;
+    Ok(b)
+}
+
+/// OUT get-EDID request (CP-HANDSHAKE.md sec 4f): `id=0x15 sub=0x21`, the message that asks
+/// the dock to return the downstream monitor's EDID in an `id=0x194 sub=0x21` reply (parsed
+/// by [`parse_edid_from_reply`]). The request carries no payload beyond the inner header, so
+/// it is a single 16-byte AES block; [`seal_livemac`] appends the 16-byte Dl3Cmac. The dock
+/// echoes the `counter`, so any monotonic value works. The exact request body was never
+/// captured (only the reply), so this is the minimal well-formed form -- re-check against a
+/// capture if the dock ever NAKs it once CP engages.
+pub(super) fn get_edid_req(counter: u16) -> Result<KVec<u8>> {
+    let mut b = KVec::with_capacity(16, GFP_KERNEL)?;
+    header(&mut b, 0x15, 0x21, counter)?;
+    pad_to(&mut b, 16)?;
+    Ok(b)
+}
+
+/// A video timing in DisplayID-Type-I terms (sec 8.6.4), as carried by the
+/// `0x48/0x22` set-mode message. Field meanings and offsets are verified
+/// byte-exact against the golden 3840x2160@60 capture (see [`set_mode`]).
+#[derive(Clone, Copy)]
+pub(super) struct Timing {
+    pub hactive: u16,
+    pub hblank: u16,
+    pub hsync_front: u16,
+    pub hsync_width: u16,
+    pub vactive: u16,
+    pub vblank: u16,
+    pub vsync_front: u16,
+    pub vsync_width: u16,
+    pub refresh_hz: u16,
+    /// Pixel clock in 10 kHz units (e.g. 0xd040 = 533.12 MHz for 4K@60).
+    pub pixel_clock_10khz: u16,
+    /// DisplayID field at off42 -- partly decoded (0x0604 for 4K, 0x0600 for the
+    /// 2560x1440 sample in sec 8.6.4); high byte 0x06 constant, low byte mode-varying.
+    pub field42: u16,
+}
+
+impl Timing {
+    /// 3840x2160@60 (CVT-RB) -- the mode the non-HDCP dongle advertises, kept as a
+    /// known-good reference whose `set_mode` output is byte-exact vs the golden capture.
+    pub(super) const UHD_60: Timing = Timing {
+        hactive: 3840, hblank: 160, hsync_front: 48, hsync_width: 32,
+        vactive: 2160, vblank: 62, vsync_front: 3, vsync_width: 5,
+        refresh_hz: 60, pixel_clock_10khz: 0xd040, field42: 0x0604,
+    };
+}
+
+/// set-mode (sec 8.6.4): `id=0x48 sub=0x22`, a 96-byte inner message carrying a
+/// DisplayID-Type-I u16 timing record. **Verified byte-exact** against the golden
+/// `[59]` 3840x2160@60 capture for every byte except the trailing 22-byte session
+/// MAC (off74..95), which [`seal`]'s caller / the HDCP session layer appends.
+///
+/// Layout (inner offsets): off20 BE u32 generation=2; off26 begins the LE u16
+/// record `hactive,hblank,hsync_front,hsync_width,vactive,vblank,vsync_front,
+/// vsync_width,field42,refresh,flags(0x4000)`; off48/off58/off60/off66 carry
+/// constants observed in the 4K capture; off70 the pixel clock (10 kHz units).
+pub(super) fn set_mode(counter: u16, t: &Timing) -> Result<KVec<u8>> {
+    let mut b = KVec::with_capacity(96, GFP_KERNEL)?;
+    header(&mut b, 0x48, 0x22, counter)?;
+    pad_to(&mut b, 20)?;
+    b.extend_from_slice(&2u32.to_be_bytes(), GFP_KERNEL)?; // off20: BE generation=2
+    pad_to(&mut b, 26)?; // off24..25 zero; timing begins at off26
+    for v in [
+        t.hactive, t.hblank, t.hsync_front, t.hsync_width,
+        t.vactive, t.vblank, t.vsync_front, t.vsync_width,
+        t.field42, t.refresh_hz, 0x4000, /* off46 flags */ 0x6000, /* off48 */
+    ] {
+        b.extend_from_slice(&v.to_le_bytes(), GFP_KERNEL)?;
+    }
+    pad_to(&mut b, 58)?;
+    b.extend_from_slice(&0x0080u16.to_le_bytes(), GFP_KERNEL)?; // off58 (observed const)
+    b.extend_from_slice(&0x00ffu16.to_le_bytes(), GFP_KERNEL)?; // off60 (observed const)
+    pad_to(&mut b, 66)?;
+    b.extend_from_slice(&0x0800u16.to_le_bytes(), GFP_KERNEL)?; // off66 (observed const)
+    pad_to(&mut b, 70)?;
+    b.extend_from_slice(&t.pixel_clock_10khz.to_le_bytes(), GFP_KERNEL)?; // off70
+    pad_to(&mut b, 96)?;
+    Ok(b)
+}
+
+/// EDID base-block sanity check: length, the `00 FF..FF 00` magic, and the 1-byte
+/// checksum (all 128 base bytes sum to 0 mod 256). A corrupt blob must never drive a
+/// mode-set, so [`timing_from_edid`] rejects anything that fails this.
+fn edid_valid(edid: &[u8]) -> bool {
+    const MAGIC: [u8; 8] = [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00];
+    edid.len() >= 128
+        && edid[..8] == MAGIC
+        && edid[..128].iter().fold(0u8, |a, &b| a.wrapping_add(b)) == 0
+}
+
+/// Parse one 18-byte EDID detailed timing descriptor into a [`Timing`], or `None` if it
+/// is too short or not a timing (pixel clock 0 marks a monitor descriptor). `field42`
+/// is left at the sec 8.6.4 default (`0x0600`) -- its low byte is mode-varying and not fully
+/// decoded, so the live mode-set substitution leaves the captured value in place.
+fn parse_dtd(d: &[u8]) -> Option<Timing> {
+    if d.len() < 18 {
+        return None;
+    }
+    let pclk = u16::from_le_bytes([d[0], d[1]]);
+    if pclk == 0 {
+        return None; // monitor descriptor, not a detailed timing
+    }
+    let hi = |v: u8, lo: u8| -> u16 { ((v as u16) << 8) | lo as u16 };
+    let hactive = hi((d[4] >> 4) & 0xf, d[2]);
+    let hblank = hi(d[4] & 0xf, d[3]);
+    let vactive = hi((d[7] >> 4) & 0xf, d[5]);
+    let vblank = hi(d[7] & 0xf, d[6]);
+    let hsync_front = (((d[11] >> 6) & 0x3) as u16) << 8 | d[8] as u16;
+    let hsync_width = (((d[11] >> 4) & 0x3) as u16) << 8 | d[9] as u16;
+    let vsync_front = (((d[11] >> 2) & 0x3) as u16) << 4 | ((d[10] >> 4) & 0xf) as u16;
+    let vsync_width = ((d[11] & 0x3) as u16) << 4 | (d[10] & 0xf) as u16;
+    let htotal = hactive.wrapping_add(hblank) as u32;
+    let vtotal = vactive.wrapping_add(vblank) as u32;
+    let refresh_hz = if htotal != 0 && vtotal != 0 {
+        ((pclk as u32 * 10_000 + (htotal * vtotal) / 2) / (htotal * vtotal)) as u16
+    } else {
+        0
+    };
+    Some(Timing {
+        hactive,
+        hblank,
+        hsync_front,
+        hsync_width,
+        vactive,
+        vblank,
+        vsync_front,
+        vsync_width,
+        refresh_hz,
+        pixel_clock_10khz: pclk,
+        field42: 0x0600,
+    })
+}
+
+/// Extract the monitor's **preferred** detailed timing from an EDID for the live mode-set
+/// (CP-HANDSHAKE.md sec 4e). The first DTD in the base block is the preferred timing per the
+/// EDID spec; scan all four base descriptor slots (off 54/72/90/108) so a leading monitor
+/// descriptor (name/range/serial) doesn't hide it, and if the base block carries no DTD at
+/// all, fall back to the first DTD in the CTA-861 extension block. The blob is validated
+/// first; an invalid or timing-less EDID returns `None` so the caller keeps its known-good
+/// fallback timing rather than driving the dock with garbage.
+pub(super) fn timing_from_edid(edid: &[u8]) -> Option<Timing> {
+    if !edid_valid(edid) {
+        return None;
+    }
+    // Base-block descriptors: the first valid DTD is the preferred timing.
+    for off in [54usize, 72, 90, 108] {
+        if off + 18 <= edid.len() {
+            if let Some(t) = parse_dtd(&edid[off..off + 18]) {
+                return Some(t);
+            }
+        }
+    }
+    // No DTD in the base block: try the first CTA-861 extension's DTD area. CTA-861 blocks
+    // have tag 0x02 at byte 0 and a DTD-area byte offset at byte 2 (>= 4 when DTDs follow);
+    // descriptors run in 18-byte records up to the extension's checksum byte (127).
+    if edid[126] as usize >= 1 && edid.len() >= 256 {
+        let ext = &edid[128..256];
+        if ext[0] == 0x02 {
+            let start = ext[2] as usize;
+            if start >= 4 {
+                let mut off = start;
+                while off + 18 <= 127 {
+                    if let Some(t) = parse_dtd(&ext[off..off + 18]) {
+                        return Some(t);
+                    }
+                    off += 18;
+                }
+            }
+        }
+    }
+    None
+}
+
+/// Overwrite the geometry + clock fields of an in-place set-mode inner message
+/// (`id=0x48 sub=0x22`) with `t` (CP-HANDSHAKE.md sec 4e). Offsets mirror [`set_mode`]:
+/// the LE u16 timing record at off26 and the pixel clock at off70. `field42` (off42),
+/// the off66 token and the encrypted trailer are intentionally **left as captured**;
+/// only the EDID-derived values change, so the wire length (hence `wire_seq`) is
+/// unchanged. No-op if `plain` is too short.
+pub(super) fn apply_edid_timing(plain: &mut [u8], t: &Timing) {
+    if plain.len() < 72 {
+        return;
+    }
+    let put = |b: &mut [u8], off: usize, v: u16| {
+        b[off] = v as u8;
+        b[off + 1] = (v >> 8) as u8;
+    };
+    put(plain, 26, t.hactive);
+    put(plain, 28, t.hblank);
+    put(plain, 30, t.hsync_front);
+    put(plain, 32, t.hsync_width);
+    put(plain, 34, t.vactive);
+    put(plain, 36, t.vblank);
+    put(plain, 38, t.vsync_front);
+    put(plain, 40, t.vsync_width);
+    put(plain, 44, t.refresh_hz);
+    put(plain, 70, t.pixel_clock_10khz);
+}
+
+/// Convert a DRM display mode (the timing the *compositor* selected from the connector's
+/// EDID-derived mode list) into a set-mode [`Timing`]. This is what makes the dock
+/// multi-mode: `drm_edid_connector_add_modes` already advertises every base+extension mode
+/// from the dock's EDID, and when userspace sets any one of them the resulting
+/// `drm_display_mode` lands here verbatim -- no re-parsing of EDID offsets. The blanking
+/// fields map straight across (CVT/DMT/DisplayID all use the same front-porch/sync model),
+/// and the refresh rate comes from DRM's own `drm_mode_vrefresh` helper rather than a
+/// hand-rolled divide. `field42` keeps the sec 8.6.4 default (its low byte is mode-varying and
+/// not fully decoded); the dock tolerates the high byte `0x06`.
+///
+/// SAFETY: `mode` must point to a valid `drm_display_mode` for the duration of the call.
+pub(super) unsafe fn timing_from_drm_mode(mode: *const bindings::drm_display_mode) -> Timing {
+    // SAFETY: caller guarantees `mode` is a live drm_display_mode.
+    let m = unsafe { &*mode };
+    // SAFETY: `drm_mode_vrefresh` only reads the mode; `mode` is valid per the contract.
+    let refresh = unsafe { bindings::drm_mode_vrefresh(mode) } as u16;
+    let sub = |a: u16, b: u16| a.saturating_sub(b);
+    Timing {
+        hactive: m.hdisplay,
+        hblank: sub(m.htotal, m.hdisplay),
+        hsync_front: sub(m.hsync_start, m.hdisplay),
+        hsync_width: sub(m.hsync_end, m.hsync_start),
+        vactive: m.vdisplay,
+        vblank: sub(m.vtotal, m.vdisplay),
+        vsync_front: sub(m.vsync_start, m.vdisplay),
+        vsync_width: sub(m.vsync_end, m.vsync_start),
+        refresh_hz: refresh,
+        // `clock` is in kHz; the set-mode field is in 10 kHz units.
+        pixel_clock_10khz: (m.clock / 10).clamp(0, u16::MAX as i32) as u16,
+        field42: 0x0600,
+    }
+}
+
+/// Decode the inner header of a dock->host CP frame: returns `(id, sub, ictr)` from
+/// the first decrypted block (CP-HANDSHAKE.md sec 3), or `None` if `wire` is not a
+/// decryptable CP frame. Used by the live loop to log what the dock is replying.
+pub(super) fn reply_info(
+    ks: &[u8; 16],
+    out_riv: &[u8; 8],
+    wire: &[u8],
+) -> Option<(u16, u16, u16)> {
+    if wire.len() <= 16 {
+        return None;
+    }
+    let seq = u32::from_le_bytes([wire[12], wire[13], wire[14], wire[15]]);
+    let head = &wire[16..wire.len().min(32)];
+    let inner = open_in(ks, &in_riv(out_riv), seq, head).ok()?;
+    if inner.len() < 6 {
+        return None;
+    }
+    Some((
+        u16::from_le_bytes([inner[0], inner[1]]),
+        u16::from_le_bytes([inner[2], inner[3]]),
+        u16::from_le_bytes([inner[4], inner[5]]),
+    ))
+}
+
+/// CP `sub` ids seen on the wire (CP-HANDSHAKE.md). Used to score a candidate
+/// decrypt: a plaintext whose `sub` is one of these (and whose post-counter pad is
+/// zero) is almost certainly the correct key/riv.
+fn is_known_sub(sub: u16) -> bool {
+    matches!(
+        sub,
+        0x00 | 0x04 | 0x0c | 0x10 | 0x20 | 0x21 | 0x22 | 0x24 | 0x25 | 0x30 | 0x41
+            | 0x42 | 0x43 | 0x45 | 0x75 | 0x84
+    )
+}
+
+/// Diagnostic decode: try a dock->host frame under every plausible riv variant and
+/// return the best-scoring inner `(riv_tag, id, sub, ictr)`. The interactive
+/// `wsub=0x45` replies decrypt under `in_riv` (byte7^1), but the **cap-phase**
+/// `wsub=0x25` frames decrypt under the session ks with **byte7 unchanged** (the OUT
+/// value) -- see the cold-ref transcript. `byte0^0x80` selects the head. This mirrors
+/// `decode-handshake.py`'s scoring so a live trace shows what the dock is actually
+/// asking for during the capability exchange we currently skip.
+pub(super) fn decode_any(
+    ks: &[u8; 16],
+    out_riv: &[u8; 8],
+    wire: &[u8],
+) -> Option<(&'static str, u16, u16, u16, [u8; 24])> {
+    if wire.len() <= 16 {
+        return None;
+    }
+    let seq = u32::from_le_bytes([wire[12], wire[13], wire[14], wire[15]]);
+    let head = &wire[16..wire.len().min(48)];
+    let out0 = *out_riv;
+    let in0 = in_riv(out_riv);
+    let mut out1 = out0;
+    out1[0] ^= 0x80;
+    let mut in1 = in0;
+    in1[0] ^= 0x80;
+    let variants: [(&'static str, [u8; 8]); 4] =
+        [("out/h0", out0), ("in/h0", in0), ("out/h1", out1), ("in/h1", in1)];
+    let mut best: Option<(i32, &'static str, u16, u16, u16, [u8; 24])> = None;
+    for (tag, riv) in variants.iter() {
+        let Ok(pt) = open_in(ks, riv, seq, head) else { continue };
+        if pt.len() < 8 {
+            continue;
+        }
+        let id = u16::from_le_bytes([pt[0], pt[1]]);
+        let sub = u16::from_le_bytes([pt[2], pt[3]]);
+        let ctr = u16::from_le_bytes([pt[4], pt[5]]);
+        let pad = u16::from_le_bytes([pt[6], pt[7]]);
+        let mut sc = 0i32;
+        if is_known_sub(sub) {
+            sc += 50;
+        }
+        if pad == 0 {
+            sc += 10;
+        }
+        if ctr < 0x400 {
+            sc += 5;
+        }
+        if best.map_or(true, |b| sc > b.0) {
+            // Keep the first 24 plaintext bytes so the live trace shows the decoded
+            // structure (e.g. the `..4c..de..` cap-descriptor template that, in the
+            // capture, is session-independent -- its absence flags a ks/riv mismatch).
+            let mut sample = [0u8; 24];
+            let n = pt.len().min(24);
+            sample[..n].copy_from_slice(&pt[..n]);
+            best = Some((sc, tag, id, sub, ctr, sample));
+        }
+    }
+    best.map(|(_, tag, id, sub, ctr, sample)| (tag, id, sub, ctr, sample))
+}
+
+/// cursor create (sec 8.6.1): `id=0x1b sub=0x42`, advertises `w x h`.
+pub(super) fn cursor_create(counter: u16, w: u16, h: u16) -> Result<KVec<u8>> {
+    let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
+    header(&mut b, 0x1b, 0x42, counter)?;
+    pad_to(&mut b, 20)?;
+    b.extend_from_slice(&[0x00, 0x02, 0x00], GFP_KERNEL)?; // marker seen in captures
+    b.extend_from_slice(&w.to_le_bytes(), GFP_KERNEL)?;
+    b.extend_from_slice(&h.to_le_bytes(), GFP_KERNEL)?;
+    Ok(b)
+}
+
+/// cursor move (sec 8.6.1): `id=0x1a sub=0x43`, head id @22, X @24, Y @26 (LE).
+pub(super) fn cursor_move(counter: u16, head: u8, x: u16, y: u16) -> Result<KVec<u8>> {
+    let mut b = KVec::with_capacity(28, GFP_KERNEL)?;
+    header(&mut b, 0x1a, 0x43, counter)?;
+    pad_to(&mut b, 22)?;
+    b.push(head, GFP_KERNEL)?; // off22 head/monitor id
+    b.push(1, GFP_KERNEL)?; // off23 flag
+    b.extend_from_slice(&x.to_le_bytes(), GFP_KERNEL)?; // off24
+    b.extend_from_slice(&y.to_le_bytes(), GFP_KERNEL)?; // off26
+    Ok(b)
+}
+
+/// cursor image (sec 8.6.1): `id=0x1c sub=0x41`. Mirrors [`cursor_create`]'s header (the
+/// `00 02 00` marker + `w`,`h` at off20) and appends the `w*h` BGRA bitmap. `bgra` must be
+/// `w*h*4` bytes -- DRM hands the driver a 64x64 ARGB8888 cursor buffer and the caller swaps
+/// it
+/// to BGRA. The image sub-layout past the create-style header is capture-unconfirmed (only the
+/// id and the shared header are decoded); re-check against a capture once CP engages.
+pub(super) fn cursor_image(counter: u16, w: u16, h: u16, bgra: &[u8]) -> Result<KVec<u8>> {
+    if bgra.len() != w as usize * h as usize * 4 {
+        return Err(EINVAL);
+    }
+    let mut b = KVec::with_capacity(32 + bgra.len(), GFP_KERNEL)?;
+    header(&mut b, 0x1c, 0x41, counter)?;
+    pad_to(&mut b, 20)?;
+    b.extend_from_slice(&[0x00, 0x02, 0x00], GFP_KERNEL)?; // marker (mirrors cursor_create)
+    b.extend_from_slice(&w.to_le_bytes(), GFP_KERNEL)?;
+    b.extend_from_slice(&h.to_le_bytes(), GFP_KERNEL)?;
+    b.extend_from_slice(bgra, GFP_KERNEL)?;
+    Ok(b)
+}
+
+/// DisplayLink "Dl3Cmac" CP-message integrity tag (16 bytes) -- **FULLY SOLVED + CROSS-SESSION
+/// VERIFIED 2026-06-11** (`captures/DL3CMAC-FULLY-SOLVED-20260611.md`):
+/// `tag = AES-CMAC(ks, mac_nonce(8) || BE64(wire_seq) || ciphertext)` where
+/// - `mac_nonce` = the CTR stream `riv` **with `byte0 ^= 0x80`** (this byte0 flip is the bit
+///   prior writeups missed -- they tried `riv` / `riv^1@byte7` and OUT never verified),
+/// - `wire_seq` = the AES-CTR block counter (frame header off-12), zero-extended to BE64,
+/// - `ciphertext` = the AES-CTR ciphertext content (encrypt-then-MAC), tag appended IN CLEAR.
+/// `K_dl3 = ks`. Proven: 110/115 OUT + 128/135 IN corpus frames AND cold-ref msg0 (a different
+/// session) reproduce byte-exact. Pass the CTR `riv` directly; the byte0 flip is applied here.
+pub(super) fn dl3cmac_tag(
+    ks: &[u8; 16],
+    riv: &[u8; 8],
+    wire_seq: u64,
+    ciphertext: &[u8],
+) -> Result<[u8; 16]> {
+    let mut mac_nonce = *riv;
+    mac_nonce[0] ^= 0x80;
+    let mut buf = KVec::with_capacity(16 + ciphertext.len(), GFP_KERNEL)?;
+    buf.extend_from_slice(&mac_nonce, GFP_KERNEL)?;
+    buf.extend_from_slice(&wire_seq.to_be_bytes(), GFP_KERNEL)?;
+    buf.extend_from_slice(ciphertext, GFP_KERNEL)?;
+    crypto::aes_cmac(ks, &buf)
+}
+
+/// Seal a CP message with a **freshly computed live Dl3Cmac**, reusing DLM's captured wire
+/// `header` (so `seq`/`aux` are byte-identical) but recomputing the tail tag for THIS session.
+/// `content_pt` is the real inner plaintext WITHOUT the 16-byte tag region. Wire body =
+/// `AES-CTR(ks, riv, content_pt)` || `dl3cmac_tag(...)`. This is the live-generation path. See
+/// `captures/DL3CMAC-FULLY-SOLVED-20260611.md`.
+pub(super) fn seal_livemac(
+    ks: &[u8; 16],
+    riv: &[u8; 8],
+    header: &[u8],
+    content_pt: &[u8],
+) -> Result<KVec<u8>> {
+    let seq = u32::from_le_bytes([header[12], header[13], header[14], header[15]]);
+    let mut ct = KVec::with_capacity(content_pt.len(), GFP_KERNEL)?;
+    for (i, chunk) in content_pt.chunks(16).enumerate() {
+        let mut iv = [0u8; 16];
+        iv[..8].copy_from_slice(riv);
+        iv[12..].copy_from_slice(&seq.wrapping_add(i as u32).to_be_bytes());
+        let ksb = crypto::aes128_ecb(ks, &iv)?;
+        for (j, &p) in chunk.iter().enumerate() {
+            ct.push(p ^ ksb[j], GFP_KERNEL)?;
+        }
+    }
+    let tag = dl3cmac_tag(ks, riv, seq as u64, &ct)?;
+    let mut frame = KVec::with_capacity(16 + ct.len() + 16, GFP_KERNEL)?;
+    frame.extend_from_slice(&header[..16], GFP_KERNEL)?;
+    frame.extend_from_slice(&ct, GFP_KERNEL)?;
+    frame.extend_from_slice(&tag, GFP_KERNEL)?;
+    Ok(frame)
+}
+
+/// Seal an inner CP message into a wire frame (type=4 sub=0x24, `seq`). DisplayLink
+/// CP is **encrypt-then-MAC**: the message content is AES-CTR-encrypted, then a
+/// 16-byte Dl3Cmac tag (`AES-CMAC(ks, riv || BE64(seq) || ciphertext)`) is appended.
+/// The keystream is `AES_ECB(ks, riv(8) || u32(0) || u32_be(seq + block))` (sec 6.1).
+///
+/// `inner` is the captured golden plaintext `[content || stale-tag-region(16)]`; we
+/// encrypt only `content = inner[..len-16]` and append a **fresh** tag keyed by our
+/// live session, so the dock's Dl3Cmac verification passes (the stale replayed tag is
+/// why the dock previously dropped our CP). VERIFIED construction (sec 8.6.7).
+pub(super) fn seal(
+    ks: &[u8; 16],
+    riv: &[u8; 8],
+    seq: u32,
+    inner: &[u8],
+) -> Result<KVec<u8>> {
+    // The interactive CP stream: session ks, wire sub `0x24`.
+    seal_stream(ks, riv, 0x24, seq, inner)
+}
+
+/// Build a fully sealed interactive CP frame (`type=4 sub=0x24`) at `wire_seq` over `content`
+/// (the inner plaintext, WITHOUT any trailing 16-byte tag placeholder): the 16-byte wire
+/// header -- size, `type=4`, `sub=0x24`, the per-`id` [`aux_for_id`] field, and `wire_seq` --
+/// followed by [`seal_livemac`] (AES-CTR ciphertext + appended live Dl3Cmac). Shared by the
+/// bring-up live loop ([`VinoDriver::send_live_cp`]) and the runtime KMS senders
+/// ([`drm_sink::VinoDrmData::send_cp`]) so both produce a byte-identical wire frame.
+pub(super) fn seal_interactive(
+    ks: &[u8; 16],
+    riv: &[u8; 8],
+    id: u16,
+    wire_seq: u32,
+    content: &[u8],
+) -> Result<KVec<u8>> {
+    let body_len = content.len() + 16; // AES-CTR ciphertext + 16-byte Dl3Cmac
+    let size = ((16 + body_len) - 4) as u16;
+    let aux = aux_for_id(id, body_len);
+    let mut hdr = [0u8; 16];
+    hdr[2..4].copy_from_slice(&size.to_le_bytes());
+    hdr[4..8].copy_from_slice(&4u32.to_le_bytes()); // type=4
+    hdr[8..10].copy_from_slice(&0x24u16.to_le_bytes()); // sub=0x24 (interactive CP)
+    hdr[10..12].copy_from_slice(&aux.to_le_bytes());
+    hdr[12..16].copy_from_slice(&wire_seq.to_le_bytes());
+    seal_livemac(ks, riv, &hdr, content)
+}
+
+/// The CP wire-header `aux`@10 (`sub_len_dw`) field is a **strict per-inner-message-id
+/// constant** in DLM's CP stream -- verified byte-exact across all 94 captured 1080p CP
+/// frames (`cp-hdrwire-1080p.bin`) -- **not** `body.len()/4`, which is what `push_frame`
+/// derives. Reproducing it makes a generated CP frame's header byte-identical to DLM, the
+/// leading hypothesis for the dock engaging its CP cipher (the dock acks our plaintext cap
+/// but emits 0 encrypted replies with the wrong `aux`). See docs/BLOCKER.md and memory
+/// `project_cp_aux_field_per_id_constant`. Unknown ids fall back to the dword count so an
+/// unrecognised message is still well-formed. This makes the generated `seal`/`seal_stream`
+/// path match DLM without a captured-header blob -- the basis for **live** CP generation.
+pub(super) fn aux_for_id(id: u16, body_len: usize) -> u16 {
+    match id {
+        0x14 => 0x0a,
+        0x15 => 0x09,
+        0x16 => 0x08,
+        0x19 => 0x05,
+        0x1f => 0x0f,
+        0x22 => 0x0c,
+        0x26 => 0x08,
+        0x2a => 0x04,
+        0x32 => 0x0c,
+        0x48 => 0x06,
+        0x9a => 0x04,
+        _ => (body_len / 4) as u16,
+    }
+}
+
+/// General AES-CTR seal under an arbitrary stream `key`/`riv` and wire sub. `seal`
+/// is the session-CP case (`wsub=0x24`); the **cap phase** (CP-HANDSHAKE.md sec 4b)
+/// needs `wsub=0x04` sealed under the dock's `id=0x32`-delivered per-head stream key,
+/// not the session ks -- which `seal` cannot express. Body construction is identical:
+/// AES-CTR(key, riv || 0x00000000 || BE32(seq+block)) over the **whole** inner message
+/// (no appended MAC; the inner carries its own encrypted trailer -- verified byte-exact
+/// vs DLM, 30/30 wire frames).
+pub(super) fn seal_stream(
+    key: &[u8; 16],
+    riv: &[u8; 8],
+    wsub: u16,
+    seq: u32,
+    inner: &[u8],
+) -> Result<KVec<u8>> {
+    let mut ct = KVec::with_capacity(inner.len(), GFP_KERNEL)?;
+    for (i, chunk) in inner.chunks(16).enumerate() {
+        let mut iv = [0u8; 16];
+        iv[..8].copy_from_slice(riv);
+        iv[12..].copy_from_slice(&seq.wrapping_add(i as u32).to_be_bytes());
+        let ksb = crypto::aes128_ecb(key, &iv)?;
+        for (j, &p) in chunk.iter().enumerate() {
+            ct.push(p ^ ksb[j], GFP_KERNEL)?;
+        }
+    }
+    let mut frame = KVec::with_capacity(16 + ct.len(), GFP_KERNEL)?;
+    // DLM-exact `aux`@10: a per-inner-id constant (see `aux_for_id`), not `body/4`. The
+    // id is read from the *plaintext* inner (off 0); `push_frame` would derive the wrong
+    // value and is the suspected reason the dock won't engage its CP cipher.
+    let id = if inner.len() >= 2 { u16::from_le_bytes([inner[0], inner[1]]) } else { 0 };
+    super::proto::push_frame_with(&mut frame, 0x04, wsub, aux_for_id(id, ct.len()), seq, &ct)?;
+    Ok(frame)
+}
+
+/// Derive the dock->host (IN) CP riv from the host->dock (OUT) `riv`. **It is the
+/// SAME riv -- no transform.** Proven 2026-06-12 by decrypting a frida-keyed DLM cold
+/// session's engaged `sub=0x45` replies (`captures/dlm-coldkeys-20260611-135237`, logged
+/// `ks`/`out_riv`): the dock's replies decrypt cleanly ONLY under the raw `out_riv`
+/// (`id=0x4c sub=0 ctr=8` to msg0, `id=0x14 sub=0x10` ACKs, `id=0x213` cert, ...); the old
+/// `byte7 ^= 1` gives garbage. The earlier "byte7^1 for IN" note was never validated against
+/// a real engaged reply (vino never engaged) and was wrong -- it would have made vino
+/// misdecode
+/// every dock reply (and partly explains old "dock replies garbage under our ks" findings).
+pub(super) fn in_riv(out_riv: &[u8; 8]) -> [u8; 8] {
+    *out_riv
+}
+
+/// Decrypt a dock->host CP frame body (AES-CTR, the same keystream as [`seal`] but
+/// keyed with the IN `riv`). `ct` is the ciphertext (wire bytes after the 16-byte
+/// cleartext header); `seq` is the wire counter at wire offset 12.
+pub(super) fn open_in(
+    ks: &[u8; 16],
+    in_riv: &[u8; 8],
+    seq: u32,
+    ct: &[u8],
+) -> Result<KVec<u8>> {
+    let mut pt = KVec::with_capacity(ct.len(), GFP_KERNEL)?;
+    for (i, chunk) in ct.chunks(16).enumerate() {
+        let mut iv = [0u8; 16];
+        iv[..8].copy_from_slice(in_riv);
+        iv[12..].copy_from_slice(&seq.wrapping_add(i as u32).to_be_bytes());
+        let ksb = crypto::aes128_ecb(ks, &iv)?;
+        for (j, &c) in chunk.iter().enumerate() {
+            pt.push(c ^ ksb[j], GFP_KERNEL)?;
+        }
+    }
+    Ok(pt)
+}
+
+/// If `wire` is an EDID reply (dock->host EP84, `type=4 sub=0x45`, inner
+/// `id=0x194 sub=0x21`), decrypt it with the IN riv and return the embedded EDID
+/// blob (base block + extensions). The EDID begins at inner offset 22; its total
+/// length is `128 * (1 + extension_count)`, where the extension count is base-block
+/// byte 126. Returns `None` for any other frame. See docs/CONTROL-PLANE.md.
+pub(super) fn parse_edid_from_reply(
+    ks: &[u8; 16],
+    out_riv: &[u8; 8],
+    wire: &[u8],
+) -> Result<Option<KVec<u8>>> {
+    // Wire header: [.. type@4 u32 .. sub@8 u16 .. seq@12 u32]; body at off16.
+    if wire.len() <= 16 || u16::from_le_bytes([wire[8], wire[9]]) != 0x45 {
+        return Ok(None);
+    }
+    let seq = u32::from_le_bytes([wire[12], wire[13], wire[14], wire[15]]);
+    let inner = open_in(ks, &in_riv(out_riv), seq, &wire[16..])?;
+    // Inner header: [id u16][sub u16][counter u16][00 00]; EDID payload at off22.
+    const EDID_OFF: usize = 22;
+    if inner.len() < EDID_OFF + 128 {
+        return Ok(None);
+    }
+    let id = u16::from_le_bytes([inner[0], inner[1]]);
+    let sub = u16::from_le_bytes([inner[2], inner[3]]);
+    // The get-EDID reply id is `0x194` on the wire (CP-HANDSHAKE.md sec 4f, ground-truthed
+    // against the cold-ref capture); older notes wrote the low byte `0x94` alone. Accept
+    // both so a real `0x194` reply is not silently dropped (the EDID would never reach the
+    // connector even after CP engages).
+    if (id != 0x94 && id != 0x194) || sub != 0x21 {
+        return Ok(None);
+    }
+    let edid = &inner[EDID_OFF..];
+    // Validate the EDID base-block magic `00 FF FF FF FF FF FF 00`.
+    const MAGIC: [u8; 8] = [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00];
+    if edid[..8] != MAGIC {
+        return Ok(None);
+    }
+    let total = ((1 + edid[126] as usize) * 128).min(edid.len());
+    let mut out = KVec::with_capacity(total, GFP_KERNEL)?;
+    out.extend_from_slice(&edid[..total], GFP_KERNEL)?;
+    Ok(Some(out))
+}
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index db4c38b6dc92..ef44a625cb70 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -43,6 +43,7 @@
 
 use kernel::{
     alloc::flags::GFP_KERNEL,
+    bindings,
     device::{self, Core},
     error::code::{ENODEV, EINVAL},
     prelude::*,
@@ -63,18 +64,28 @@
 /// EP84 (dock->host) drain buffer size. The dock's capability block can reach ~5.8 KiB, so a
 /// single bulk read needs a generously sized buffer to avoid truncating and misframing it.
 const EP84_BUF: usize = 16384;
+/// Number of IN URBs kept perpetually posted on EP84 by the async reader
+/// ([`usb::Device::bulk_in_queue`]); `depth - 1` stay outstanding while one is serviced.
+const EP84_QUEUE_DEPTH: usize = 4;
 
 /// USB transfer timeout used during bring-up.
 fn timeout() -> Delta {
     Delta::from_millis(1000)
 }
 
+/// Set once the dock has actually engaged the CP cipher (`wsub=0x45` acks > 0). EP08 video is
+/// gated on it: pushing frames at a dock whose CP channel is dead makes it fault and USB-reset.
+/// NOTE: with the current CP-engagement wall (see the file header) this is never set on real
+/// hardware -- the dock runs the whole plaintext handshake but never engages the encrypted CP.
+static CP_ENGAGED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
+
 mod proto;
 mod crypto;
 mod rng;
 mod hdcp;
 mod ake;
 mod golden;
+mod cp;
 
 /// The shared secrets a completed HDCP 2.2 AKE leaves behind: the SKE session key
 /// `ks` and content IV `riv` key the AES-CTR control plane (sec 6), and `kd` is kept
@@ -129,18 +140,33 @@ impl WorkItem for BringUp {
     fn run(this: Arc<BringUp>) {
         let cdev: &device::Device = this.intf.as_ref();
         let dev: &usb::Device = this.intf.as_ref();
-        // WIP scaffold: plaintext bring-up then the clean-room HDCP 2.2 AKE/LC/SKE. Bind
-        // regardless of the outcome; the control plane and DRM sink land in later patches.
+        // WIP scaffold: plaintext bring-up, the clean-room HDCP 2.2 AKE/LC/SKE, then the
+        // post-SKE CP setup. Bind regardless of the outcome -- there is no display path until
+        // the dock engages the encrypted control plane, which it currently never does (see the
+        // "help wanted" note at the top of the file). The DRM sink lands in a later patch.
         match VinoDriver::bring_up(dev) {
             Ok(()) => {
                 dev_info!(cdev, "vino: plaintext session init OK\n");
                 match VinoDriver::run_ake(dev) {
                     Ok(session) => {
                         dev_info!(cdev, "vino: HDCP AKE + LC + SKE complete (session keyed)\n");
-                        // Dev diagnostic: the live session key/riv, so the dock's encrypted
-                        // EP84 replies can be decoded offline from a usbmon capture. Behind
-                        // pr_debug, so compiled out unless dynamic debug is enabled.
                         pr_debug!("vino: SESSION ks={:02x?} riv={:02x?}\n", &session.ks, &session.riv);
+                        // Phase 2c: drive the post-SKE CP setup. send_cp_setup re-seals DLM's
+                        // captured setup template under THIS session's live ks/riv and sends it;
+                        // `acks` counts the dock's encrypted wsub=0x45 replies. THIS IS THE WALL:
+                        // on a cold dock `acks` stays 0 -- the dock runs the entire plaintext
+                        // handshake but never engages the encrypted CP.
+                        let mut edid_out: Option<KVec<u8>> = None;
+                        match VinoDriver::send_cp_setup(dev, &session, &mut edid_out) {
+                            Ok((n, acks, _wseq_end, _ctr_end)) => {
+                                dev_info!(cdev,
+                                    "vino: CP setup sent -- {n} messages, {acks} dock CP acks (wsub=0x45)\n");
+                                // CP engagement gates EP08 video (added in a later patch): until
+                                // the dock acks, pushing pixels at it wedges the hub.
+                                CP_ENGAGED.store(acks > 0, core::sync::atomic::Ordering::SeqCst);
+                            }
+                            Err(e) => dev_info!(cdev, "vino: CP setup incomplete ({e:?}) -- WIP\n"),
+                        }
                     }
                     Err(e) => dev_info!(cdev, "vino: HDCP AKE incomplete ({e:?}) -- WIP\n"),
                 }
@@ -205,6 +231,56 @@ fn crypto_selftest() {
         Ok(out) => pr_err!("vino: selftest AES-CMAC FAIL got={out:02x?}\n"),
         Err(e) => pr_err!("vino: selftest AES-CMAC ERR ({e:?})\n"),
     }
+
+    // 3. Full seal_livemac vs cold-ref's REAL msg0 (capture t=36.813765). ks/riv are the cold-ref
+    // session's; content is msg0's 32-byte plaintext; the expected frame is the captured wire.
+    let ks = [
+        0xd8, 0xb2, 0x48, 0x12, 0x44, 0x1d, 0x50, 0x82, 0x0d, 0xa3, 0xc2, 0x71, 0xc7, 0xa3, 0x6e,
+        0xc2,
+    ];
+    let riv = [0xfb, 0xa7, 0xc3, 0x5f, 0xe6, 0xce, 0x40, 0xec];
+    let header = [
+        0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x24, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00,
+        0x00,
+    ];
+    let content = [
+        0x14, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x48, 0xec, 0x9c, 0xec, 0xc3, 0x89, 0x23,
+        0x5d, 0x69,
+    ];
+    let expect = [
+        0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x24, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0xcb, 0x4c, 0x80, 0xde, 0xf0, 0xd0, 0xfd, 0x56, 0x22, 0x5f, 0x43, 0xbd, 0x55, 0x0d,
+        0x8e, 0xc5, 0x7a, 0x1c, 0x35, 0x12, 0x81, 0x35, 0x31, 0x1a, 0x45, 0x13, 0x91, 0x41, 0x25,
+        0x87, 0xe9, 0xf7, 0xe5, 0x5b, 0xb5, 0xbc, 0x76, 0x5b, 0x2f, 0x1e, 0x79, 0xf2, 0x8b, 0xd5,
+        0x5b, 0x2c, 0x3c, 0xe7,
+    ];
+    match cp::seal_livemac(&ks, &riv, &header, &content) {
+        Ok(frame) if frame.as_slice() == expect.as_slice() => {
+            pr_info!("vino: selftest seal_livemac(msg0) PASS -- CP crypto reproduces cold-ref wire\n")
+        }
+        Ok(frame) => {
+            // Show where it first diverges so a framing/order bug is localizable.
+            let mut at = frame.len().min(expect.len());
+            for i in 0..at {
+                if frame[i] != expect[i] {
+                    at = i;
+                    break;
+                }
+            }
+            pr_err!(
+                "vino: selftest seal_livemac(msg0) FAIL at byte {at} (len {} vs {})\n",
+                frame.len(),
+                expect.len()
+            );
+            let s = at.saturating_sub(0);
+            let e = (at + 16).min(frame.len());
+            pr_err!("vino:   got[{s}..]={:02x?}\n", &frame[s..e]);
+            let e2 = (at + 16).min(expect.len());
+            pr_err!("vino:   exp[{s}..]={:02x?}\n", &expect[s..e2]);
+        }
+        Err(e) => pr_err!("vino: selftest seal_livemac(msg0) ERR ({e:?})\n"),
+    }
 }
 
 impl VinoDriver {
@@ -962,6 +1038,527 @@ fn poll_ep83(dev: &usb::Device) -> usize {
         n
     }
 
+
+    /// Drives the post-SKE CP setup: opens the async EP84 reader, sends the plaintext
+    /// stream-open arm marker, then the first live encrypted CP frame (msg0), and counts the
+    /// dock's encrypted `wsub=0x45` acks. THE WALL: on a cold dock `acks` stays 0 -- the dock
+    /// runs the entire plaintext handshake but never engages the encrypted CP. See the "help
+    /// wanted" note at the top of the file.
+    fn send_cp_setup(
+        dev: &usb::Device,
+        session: &Session,
+        edid_out: &mut Option<KVec<u8>>,
+    ) -> Result<(usize, usize, u32, u16)> {
+        // 16 KiB so the dock's ~5787 B capability block is read whole (see [`EP84_BUF`]).
+        let mut resp = KVec::from_elem(0u8, EP84_BUF, GFP_KERNEL)?;
+        let mut drained = 0usize;
+        let mut acks = 0usize;
+        let mut sent = 0usize;
+
+        // Plaintext `type=2 sub=0x24`+`0x45` stream-open arm marker -- the mandatory gate
+        // before the first encrypted frame.
+        const STREAM_OPEN: [u8; 64] = [
+            0x00, 0x00, 0x1c, 0x00, 0x02, 0x00, 0x00, 0x00, //
+            0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+            0x04, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, //
+            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+            0x00, 0x00, 0x1c, 0x00, 0x02, 0x00, 0x00, 0x00, //
+            0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+            0x05, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x00, 0x00, //
+            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+        ];
+
+        // Open the persistent async EP84 IN reader BEFORE the arm marker and msg0, so
+        // `EP84_QUEUE_DEPTH` IN transfers are already posted when the dock pushes its post-arm
+        // reply (DLM's libusb always-pending-IN behaviour). Draining EP84 concurrently stops the
+        // dock's IN FIFO filling and NAKing our OUT (the sync-bulk deadlock that produced a 100 ms
+        // msg0 NAK). RAII: dropping the queue at function exit kills+frees the URBs.
+        let mut ep84_q = match dev.bulk_in_queue(0x04, EP84_QUEUE_DEPTH, EP84_BUF) {
+            Ok(q) => {
+                pr_info!("vino: EP84 async IN queue opened (depth={EP84_QUEUE_DEPTH})\n");
+                Some(q)
+            }
+            Err(e) => {
+                pr_info!("vino: EP84 async queue open failed ({e:?}) -- falling back to sync bulk_recv\n");
+                None
+            }
+        };
+
+        // A/B (2026-06-16): route the engagement-critical arm marker + msg0 through an async,
+        // pipelined OUT queue (`usb::Device::bulk_out_queue`) instead of the synchronous
+        // `bulk_send`. This mirrors DLM's libusb execution model exactly: each OUT URB is
+        // submitted and returns immediately (the HCD auto-retries NAKs until the URB's
+        // teardown), so the arm and msg0 are queued back-to-back and reaped afterwards rather
+        // than each blocking for its device-ACK round-trip before the next is submitted. The
+        // 2026-06-15 measurement showed the *wire* (lengths + submit->complete latency) is
+        // already identical, so this is not expected to change what the dock receives -- it is
+        // the last structural host difference (sync `usb_bulk_msg` vs async submit/reap) made
+        // identical so a cold plug can rule it in or out. Default OFF so vino keeps the proven
+        // sync path and paired diffs are not polluted; flip to test.
+        const CP_ASYNC_OUT: bool = true;
+        let mut out_q = if CP_ASYNC_OUT {
+            match dev.bulk_out_queue(0x02, 4, 1024) {
+                Ok(q) => {
+                    pr_info!("vino: EP02 async OUT queue opened (depth=4) -- libusb-style submit/reap\n");
+                    Some(q)
+                }
+                Err(e) => {
+                    pr_info!("vino: EP02 async OUT queue open failed ({e:?}) -- using sync bulk_send\n");
+                    None
+                }
+            }
+        } else {
+            None
+        };
+
+        // Pin the EP02 DATA0/DATA1 toggle to DATA0 immediately before the arm. This is the one
+        // host lever invisible to every "host exhausted" test: usbmon logs payloads, not the
+        // toggle bit, and the crypto/timing work never touches it. DLM (libusb async URBs) and
+        // vino (in-kernel blocking bulk_send) can reach the arm with EP02 at *different* parity
+        // after the ~9 preceding OUT transfers (7 cap-announce + arm) -- a mismatch makes the
+        // dock's SIE ACK the packet at the link layer (byte-identical on the wire) yet discard
+        // the payload as a duplicate, i.e. "arms clean, silently drops msg0". clear_halt issues
+        // CLEAR_FEATURE(ENDPOINT_HALT), which resets both sides' toggle to DATA0. Every earlier
+        // reset (reset_configuration at the top of bring_up, HARD_RESET, VBUS cycle) reset the
+        // toggle *before* those preceding transfers, so msg0's parity was never pinned. A/B:
+        // flip to `reset_configuration()` to test the heavier reset at the same call site.
+        // RESULT 2026-06-16 (cold plug vino-cold-20260616-000552): TESTED NEGATIVE.
+        // clear_halt(EP02)
+        // fired (wire shows CLEAR_FEATURE on EP2, dmesg "toggle -> DATA0") yet the dock still gave
+        // sub=0x45_acks=0. The toggle was NOT the gate. Left default-OFF so vino doesn't carry an
+        // EP02 CLEAR_FEATURE that DLM never sends (would pollute future paired diffs); flip to
+        // test.
+        // Sibling result: EP02 wMaxPacketSize logged = 1024, so a 64-byte msg0/arm always
+        // terminates
+        // as a natural short packet -- the ZLP-trap hypothesis is moot too.
+        const CLEAR_HALT_BEFORE_ARM: bool = false;
+        if CLEAR_HALT_BEFORE_ARM {
+            match dev.clear_halt(EP_CTRL_OUT) {
+                Ok(()) => pr_info!("vino: EP02 clear_halt before arm OK (toggle -> DATA0)\n"),
+                Err(e) => pr_info!("vino: EP02 clear_halt before arm non-fatal ({e:?})\n"),
+            }
+        }
+
+        // Submit the arm marker. Async path: queue it and DO NOT flush -- leave it in flight so
+        // msg0 can be submitted right behind it (the pipelined arm->msg0 burst DLM does). Sync
+        // path: the original blocking send.
+        let arm_res = match out_q.as_mut() {
+            Some(q) => q.send(&STREAM_OPEN, timeout()),
+            None => dev.bulk_send(EP_CTRL_OUT, &STREAM_OPEN, timeout()).map(|_| ()),
+        };
+        if let Err(e) = arm_res {
+            pr_err!("vino: CP stream-open marker FAILED ({e:?})\n");
+            return Err(e);
+        }
+        pr_info!("vino: CP stream-open arm marker sent\n");
+
+        // No artificial arm->msg0 pad. The shared engine (decompiled mac/Windows drivers) is
+        // event-driven and never wall-clock-paces this gap; vino sends msg0 ~0.06 ms after the arm
+        // (vs DLM's ~0.18 ms libusb gap) and the dock's acceptance window is ms-scale, so the
+        // sub-ms lead is immaterial -- confirmed not a gate by the firmware-wall verdict. (Was a
+        // 150 us fsleep copied from DLM's usbmon spacing.)
+
+        // LIVE CP msg0: protocol-fixed header `id=0x14 sub=0x00 ctr=0x08`, 14 zero bytes, then a
+        // fresh host-random 10-byte token (the dock does not validate or echo it), sealed under
+        // THIS session's ks/riv with a live Dl3Cmac. This is the decisive engagement probe: a
+        // `wsub=0x45` reply would mean the cipher engaged on a live session.
+        let mut content = [0u8; 32];
+        content[0..2].copy_from_slice(&0x0014u16.to_le_bytes()); // id=0x14
+        content[4..8].copy_from_slice(&8u32.to_le_bytes()); // ctr=0x08 (sub=0x00 stays zero)
+        rng::fill(&mut content[22..32]); // host-random token
+        let body_len = content.len() + 16; // AES-CTR ciphertext + 16-byte Dl3Cmac
+        let size = ((16 + body_len) - 4) as u16;
+        let aux = cp::aux_for_id(0x14, body_len);
+        let mut hdr = [0u8; 16];
+        hdr[2..4].copy_from_slice(&size.to_le_bytes());
+        hdr[4..8].copy_from_slice(&4u32.to_le_bytes()); // type=4
+        hdr[8..10].copy_from_slice(&0x24u16.to_le_bytes()); // sub=0x24 (interactive CP)
+        hdr[10..12].copy_from_slice(&aux.to_le_bytes());
+        // hdr[12..16] = wire_seq = 0 (first CP block)
+        let frame = cp::seal_livemac(&session.ks, &session.riv, &hdr, &content)?;
+
+        let mut ok = false;
+        if let Some(q) = out_q.as_mut() {
+            // Async path: submit msg0 right behind the still-in-flight arm (pipelined burst),
+            // then drain EP84 while the HCD auto-retries any NAK against the live URB. Reap both
+            // OUT transfers; a flush timeout just means the dock NAK'd msg0 (URB killed at drop).
+            match q.send(&frame, timeout()) {
+                Ok(()) => {
+                    ok = true;
+                    pr_info!("vino: live CP msg0 submitted async (pipelined behind arm)\n");
+                }
+                Err(e) => pr_info!("vino: live CP msg0 async submit failed ({e:?})\n"),
+            }
+            for _ in 0..8 {
+                let (d, a) = Self::drain_ep84(dev, ep84_q.as_mut(), &mut resp, session, edid_out);
+                drained += d;
+                acks += a;
+            }
+            match q.flush(Delta::from_millis(200)) {
+                Ok(()) => pr_info!("vino: async arm+msg0 reaped OK (both transfers completed)\n"),
+                Err(e) => pr_info!("vino: async arm+msg0 reap incomplete ({e:?}) -- dock NAK'd\n"),
+            }
+        } else {
+            // Sync path: single-packet msg0 => a NAK transfers nothing, so cancel+retry is safe.
+            // Between attempts drain EP84 so the dock can push/drain its IN queue. Bounded.
+            const TRIES: usize = 40;
+            for t in 0..TRIES {
+                match dev.bulk_send(EP_CTRL_OUT, &frame, Delta::from_millis(5)) {
+                    Ok(_) => {
+                        ok = true;
+                        pr_info!("vino: live CP msg0 ACCEPTED after {t} interleaved tries\n");
+                        break;
+                    }
+                    // OUT NAK'd (nothing transferred) -- let the dock push on EP84, then retry.
+                    Err(_) => {
+                        let (d, a) =
+                            Self::drain_ep84(dev, ep84_q.as_mut(), &mut resp, session, edid_out);
+                        drained += d;
+                        acks += a;
+                    }
+                }
+            }
+        }
+        if ok {
+            sent += 1;
+            pr_info!("vino: live CP msg0 sent (id=0x14 ctr=8, random token, live seal)\n");
+        } else {
+            pr_info!("vino: live CP msg0 still NAK'd (no transfer accepted)\n");
+        }
+
+        // DLM sends the `0x24 wValue=0` render/commit vendor request right after msg0.
+        match dev.control_send(0x24, 0x40 /* VENDOR_OUT */, 0, 0, &[], timeout()) {
+            Ok(()) => pr_info!("vino: post-msg0 0x24(wValue=0) OK\n"),
+            Err(e) => pr_info!("vino: post-msg0 0x24(wValue=0) non-fatal ({e:?})\n"),
+        }
+        // DLM then re-reads the 0x22 vendor state (0xc1, wValue=1, wIndex=0, 28 B) -- its SECOND
+        // 0x22 of the session, immediately after the post-msg0 0x24. vino issued the first 0x22
+        // pre-arm but stopped here, leaving "DLM-ONLY 0x22" in the paired diff. Issue it
+        // unconditionally so the wire matches DLM regardless of whether the dock acks; it is a
+        // harmless vendor IN read. (0xc1 = IN|vendor|INTERFACE recipient, matching the first 0x22.)
+        let mut state2 = [0u8; 28];
+        match dev.control_recv(0x22, 0xc1, 1, 0, &mut state2, timeout()) {
+            Ok(()) => pr_info!("vino: post-msg0 0x22(wValue=1) OK = {:02x?}\n", state2),
+            Err(e) => pr_info!("vino: post-msg0 0x22(wValue=1) non-fatal ({e:?})\n"),
+        }
+
+        // Read the dock's reply: a `wsub=0x45` ack means the cipher engaged on our live frame.
+        let (d, a, _m) = Self::lockstep_reply(dev, ep84_q.as_mut(), &mut resp, session, 0x08, edid_out);
+        drained += d;
+        acks += a;
+
+        const MAX_ROUNDS: usize = 16;
+        for _ in 0..MAX_ROUNDS {
+            let (d, a) = Self::drain_ep84(dev, ep84_q.as_mut(), &mut resp, session, edid_out);
+            drained += d;
+            acks += a;
+            if d == 0 {
+                break;
+            }
+        }
+
+        // ---- Post-engagement live setup (CP-HANDSHAKE.md sec 4f/sec 4e) ------------------------
+        // Only meaningful once the dock has acked msg0: ask the dock for the downstream EDID,
+        // then build the mode-set from its preferred timing and send that -- the live path that
+        // replaces the static 1080p modeset and the opportunistic-only EDID capture. On a cold
+        // dock `acks` stays 0 (the wall), so this does not run on current hardware; it completes
+        // the standalone live-generation flow for when the engagement gate is solved.
+        // The next free AES-CTR block index past this setup, handed to the DRM device so runtime
+        // KMS sends (mode-set/cursor) continue the same keystream. Defaults to msg0's end (2) when
+        // the live block below doesn't run (no acks) -- irrelevant then, since we only publish the
+        // session when `acks > 0`.
+        let mut wire_seq_end = 2u32;
+        if acks > 0 {
+            // `wseq` continues the AES-CTR block counter past msg0 (32 B content = 2 blocks);
+            // the inner `counter` continues past msg0's ctr=8. The dock echoes both, so the
+            // exact values only need to stay monotonic / non-overlapping for the keystream.
+            let mut wseq = 2u32;
+
+            // (1) Live get-EDID request -> the dock replies id=0x194; `drain_ep84` (called inside
+            // `send_live_cp`) decodes it and fills `edid_out` via `parse_edid_from_reply`.
+            if let Ok(req) = cp::get_edid_req(9) {
+                match Self::send_live_cp(
+                    dev, session, ep84_q.as_mut(), &mut resp, edid_out, 0x15, wseq, &req,
+                ) {
+                    Ok((ok, d, a)) => {
+                        drained += d;
+                        acks += a;
+                        wseq = wseq.wrapping_add(((req.len() + 15) / 16) as u32);
+                        pr_info!("vino: live get-EDID request {}\n",
+                            if ok { "sent (id=0x15 sub=0x21)" } else { "NAK'd" });
+                    }
+                    Err(e) => pr_info!("vino: live get-EDID request failed ({e:?})\n"),
+                }
+            }
+
+            // (2) Dynamic mode-set from the dock's EDID preferred detailed timing, falling back to
+            // the known-good UHD_60 timing when no EDID/DTD is available.
+            let from_edid = edid_out.is_some();
+            let timing = edid_out
+                .as_deref()
+                .and_then(cp::timing_from_edid)
+                .unwrap_or(cp::Timing::UHD_60);
+            match cp::set_mode(10, &timing) {
+                Ok(smode) => {
+                    // `set_mode` reserves a trailing 16-byte tag region; `seal_livemac` appends a
+                    // fresh live Dl3Cmac, so hand it the inner content without that region.
+                    let content = &smode[..smode.len().saturating_sub(16)];
+                    match Self::send_live_cp(
+                        dev, session, ep84_q.as_mut(), &mut resp, edid_out, 0x48, wseq, content,
+                    ) {
+                        Ok((ok, d, a)) => {
+                            drained += d;
+                            acks += a;
+                            pr_info!("vino: live mode-set {} ({}x{}@{} from {})\n",
+                                if ok { "sent" } else { "NAK'd" },
+                                timing.hactive, timing.vactive, timing.refresh_hz,
+                                if from_edid { "EDID" } else { "fallback" });
+                        }
+                        Err(e) => pr_info!("vino: live mode-set failed ({e:?})\n"),
+                    }
+                    // Advance the keystream past this mode-set so runtime KMS sends continue it.
+                    wseq = wseq.wrapping_add(((content.len() + 15) / 16) as u32);
+                }
+                Err(e) => pr_info!("vino: mode-set build failed ({e:?})\n"),
+            }
+            wire_seq_end = wseq;
+        }
+
+        let engaged = if acks > 0 { "dock engaged" } else { "dock ignoring our CP (the wall)" };
+        pr_info!("vino: CP setup sent={sent} EP84_resp={drained} sub=0x45_acks={acks} ({engaged})\n");
+        // Inner counter past the bring-up CP messages (msg0=8, get-EDID=9, mode-set=10).
+        Ok((sent, acks, wire_seq_end, 11))
+    }
+
+
+    /// Seal `content` (inner CP plaintext, WITHOUT the 16-byte tag region) into a live
+    /// `type=4 sub=0x24` frame at `wire_seq`, send it on EP02 with EP84 drained between NAK
+    /// retries (the single-packet interleave discipline msg0 uses), then drain once more to
+    /// collect the dock's reply. `id` selects the DLM-exact `aux` header field
+    /// ([`cp::aux_for_id`]). Returns `(sent_ok, ep84_reads, sub=0x45_acks)`. Used for the
+    /// post-engagement live messages (get-EDID, mode-set) once the dock has acked msg0.
+    fn send_live_cp(
+        dev: &usb::Device,
+        session: &Session,
+        mut q: Option<&mut usb::BulkInQueue>,
+        resp: &mut [u8],
+        edid_out: &mut Option<KVec<u8>>,
+        id: u16,
+        wire_seq: u32,
+        content: &[u8],
+    ) -> Result<(bool, usize, usize)> {
+        let frame = cp::seal_interactive(&session.ks, &session.riv, id, wire_seq, content)?;
+
+        // Single-packet OUT: a NAK transfers nothing, so cancel+retry is safe. Between attempts
+        // drain EP84 so the dock can push/drain its IN queue (matches msg0's behaviour).
+        const TRIES: usize = 40;
+        let mut ok = false;
+        let mut drained = 0usize;
+        let mut acks = 0usize;
+        for _ in 0..TRIES {
+            match dev.bulk_send(EP_CTRL_OUT, &frame, Delta::from_millis(5)) {
+                Ok(_) => {
+                    ok = true;
+                    break;
+                }
+                Err(_) => {
+                    let (d, a) = Self::drain_ep84(dev, q.as_deref_mut(), resp, session, edid_out);
+                    drained += d;
+                    acks += a;
+                }
+            }
+        }
+        // Collect the dock's reply (the get-EDID id=0x194 frame is captured here via drain_ep84).
+        let (d, a) = Self::drain_ep84(dev, q.as_deref_mut(), resp, session, edid_out);
+        drained += d;
+        acks += a;
+        Ok((ok, drained, acks))
+    }
+
+
+    /// sec 5 read-only diagnostic: log one dock->host EP84 frame's wire header
+    /// (`type`@4, `sub`@8, `aux`@10, `seq`@12) and, when the body decrypts under the IN
+    /// keystream, its inner `(id, sub, ictr)`. Surfaces EVERY frame the dock returns --
+    /// not just `sub=0x45` -- so a hardware run reveals whether the dock is mute, NAKing,
+    /// or replying with an unexpected sub. Pure logging; no state change.
+    fn log_ep84(session: &Session, frame: &[u8]) {
+        let len = frame.len();
+        let wtype = if len >= 8 {
+            u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]])
+        } else {
+            0
+        };
+        let wsub = if len >= 10 { u16::from_le_bytes([frame[8], frame[9]]) } else { 0 };
+        let aux = if len >= 12 { u16::from_le_bytes([frame[10], frame[11]]) } else { 0 };
+        let wseq = if len >= 16 {
+            u32::from_le_bytes([frame[12], frame[13], frame[14], frame[15]])
+        } else {
+            0
+        };
+        {
+            // Dev diagnostic (pr_debug, compiled out unless dynamic debug is enabled): the raw
+            // wire, so the dock's pushes can be offline-decoded. The dock's large capability block
+            // (~5787 B) must be dumped in 128-byte CHUNKS, because a single hex print of a
+            // >~250-byte
+            // array exceeds printk's per-line limit. Capped at 768 B (6 lines) to avoid flooding.
+            let cap = len.min(768);
+            if cap <= 64 {
+                let raw = &frame[..cap];
+                pr_debug!("vino: dock EP84 RAW {len}B {raw:02x?}\n");
+            } else {
+                pr_debug!("vino: dock EP84 RAW {len}B (first {cap} B in 128-B chunks):\n");
+                let mut o = 0usize;
+                while o < cap {
+                    let e = (o + 128).min(cap);
+                    let chunk = &frame[o..e];
+                    pr_debug!("vino:   ep84[{o:#06x}] {chunk:02x?}\n");
+                    o = e;
+                }
+            }
+        }
+        match cp::decode_any(&session.ks, &session.riv, frame) {
+            Some((rivtag, rid, rsub, rictr, sample)) => {
+                pr_info!(
+                    "vino: dock EP84 type={wtype} wsub={wsub:#x} aux={aux:#x} seq={wseq:#x} {len}B -> [{rivtag}] id={rid:#x} sub={rsub:#x} ictr={rictr:#x} pt={sample:02x?}\n"
+                );
+            }
+            None => {
+                pr_info!(
+                    "vino: dock EP84 type={wtype} wsub={wsub:#x} aux={aux:#x} seq={wseq:#x} {len}B (no inner decode)\n"
+                );
+            }
+        }
+    }
+
+    /// Read one EP84 frame: from the persistent async queue `q` when [`CP_ASYNC_EP84`] has opened
+    /// one, else a synchronous `bulk_recv`. The queue's timeout (`Ok(None)`) is mapped to
+    /// `Err(ETIMEDOUT)` so the callers' existing match arms (which treat any `Err`/empty as
+    /// "no more data right now") work unchanged across both paths.
+    fn read_ep84(
+        dev: &usb::Device,
+        q: Option<&mut usb::BulkInQueue>,
+        buf: &mut [u8],
+        to: Delta,
+    ) -> Result<usize> {
+        match q {
+            Some(queue) => match queue.recv(buf, to) {
+                Ok(Some(n)) => Ok(n),
+                Ok(None) => Err(ETIMEDOUT),
+                Err(e) => Err(e),
+            },
+            None => dev.bulk_recv(EP_CTRL_IN, buf, to),
+        }
+    }
+
+
+    fn drain_ep84(
+        dev: &usb::Device,
+        mut q: Option<&mut usb::BulkInQueue>,
+        buf: &mut [u8],
+        session: &Session,
+        edid_out: &mut Option<KVec<u8>>,
+    ) -> (usize, usize) {
+        const MAX_READS: usize = 16;
+        let mut n = 0usize;
+        let mut acks = 0usize;
+        // Read EP84 FIRST (the dock answers in ~0.14 ms, same as it does for DLM). The EP83 status
+        // poll is serviced AFTER -- polling it before the EP84 read blocked the critical path for
+        // up
+        // to 30 ms PER cap frame (timeline diff 2026-06-11: vino's cap phase was 446 ms / ~32 ms
+        // per
+        // frame vs DLM's 60 ms / 0.14 ms, purely from this ordering), arming the dock ~1 s late.
+        for _ in 0..MAX_READS {
+            match Self::read_ep84(dev, q.as_deref_mut(), buf, Delta::from_millis(10)) {
+                Ok(len) if len > 0 => {
+                    n += 1;
+                    // sec 5 diagnostic: surface EVERY dock->host frame, not just `sub=0x45`,
+                    // so a hardware run shows what the dock actually returns (a different
+                    // sub, a NAK, or plaintext) instead of a bare `EP84_resp=N` count.
+                    Self::log_ep84(session, &buf[..len]);
+                    if len >= 10 && u16::from_le_bytes([buf[8], buf[9]]) == 0x45 {
+                        acks += 1;
+                        // Capture the dock's EDID the first time it appears (id=0x94
+                        // sub=0x21 reply to the replayed get-EDID request). Reuses the
+                        // standard DRM EDID infra in get_modes. See CONTROL-PLANE.md.
+                        if edid_out.is_none() {
+                            if let Ok(Some(e)) =
+                                cp::parse_edid_from_reply(&session.ks, &session.riv, &buf[..len])
+                            {
+                                pr_info!("vino: EDID read from dock ({} bytes)\n", e.len());
+                                *edid_out = Some(e);
+                            }
+                        }
+                    }
+                }
+                _ => break,
+            }
+        }
+        // Service EP83 AFTER draining EP84, so it never delays reading the dock's CP reply.
+        if Self::POLL_EP83_DURING_BRINGUP {
+            Self::poll_ep83(dev);
+        }
+        (n, acks)
+    }
+
+
+    /// Lockstep counterpart to [`drain_ep84`]: after one CP OUT, drain EP84 until the
+    /// `sub=0x45` reply whose **inner counter echoes** `ictr` arrives (DLM's 1:1
+    /// handshake) or the short read budget elapses. Any async
+    /// pushes seen meanwhile are still counted and scanned for the EDID. Returns
+    /// `(reads, acks, matched)`.
+    fn lockstep_reply(
+        dev: &usb::Device,
+        mut q: Option<&mut usb::BulkInQueue>,
+        buf: &mut [u8],
+        session: &Session,
+        ictr: u16,
+        edid_out: &mut Option<KVec<u8>>,
+    ) -> (usize, usize, bool) {
+        const MAX_READS: usize = 8;
+        let in_riv = cp::in_riv(&session.riv);
+        let mut reads = 0usize;
+        let mut acks = 0usize;
+        let mut matched = false;
+        for _ in 0..MAX_READS {
+            match Self::read_ep84(dev, q.as_deref_mut(), buf, Delta::from_millis(30)) {
+                Ok(len) if len > 16 => {
+                    reads += 1;
+                    // sec 5 diagnostic: log every frame the dock returns in the lockstep
+                    // window -- including the non-`0x45` frames we otherwise skip -- so the
+                    // divergence point is paired with the dock's actual reply on the wire.
+                    Self::log_ep84(session, &buf[..len]);
+                    if u16::from_le_bytes([buf[8], buf[9]]) != 0x45 {
+                        continue;
+                    }
+                    acks += 1;
+                    let seq = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
+                    // Decrypt just the first block to read the inner counter (off 4).
+                    let head = &buf[16..len.min(32)];
+                    if let Ok(inner) = cp::open_in(&session.ks, &in_riv, seq, head) {
+                        if inner.len() >= 6
+                            && u16::from_le_bytes([inner[4], inner[5]]) == ictr
+                        {
+                            matched = true;
+                        }
+                    }
+                    // Opportunistically capture the EDID (id=0x94 reply, off 22).
+                    if edid_out.is_none() {
+                        if let Ok(Some(e)) =
+                            cp::parse_edid_from_reply(&session.ks, &session.riv, &buf[..len])
+                        {
+                            pr_info!("vino: EDID read from dock ({} bytes)\n", e.len());
+                            *edid_out = Some(e);
+                        }
+                    }
+                    if matched {
+                        break;
+                    }
+                }
+                _ => break,
+            }
+        }
+        (reads, acks, matched)
+    }
 }
 
 kernel::usb_device_table!(
-- 
2.54.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH 4/7] drm/vino: add the Vino (RawRl mode-2) framebuffer codec
  2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
                   ` (2 preceding siblings ...)
  2026-06-17 15:12 ` [RFC PATCH 3/7] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
  2026-06-17 20:13   ` sashiko-bot
  2026-06-17 15:12 ` [RFC PATCH 5/7] drm/vino: register a DRM/KMS device and scan out to EP08 Mike Lothian
                   ` (4 subsequent siblings)
  8 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
  To: dri-devel
  Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
	linux-kernel

Add the video module: the RawRl ("Raw/RLX" mode-2) encoder, clean-room
from the AArch64 reference-driver decompile, which emits packed-RGB565
frames the dock decodes without the impractical Vino Walsh-Hadamard
entropy codec. The encode/decode round-trip is unit-tested (keyframe,
differential, >256-pixel multi-block and >255 RLE run-splits all
reconstruct byte-exact); that round-trip is the correctness anchor, since
no real mode-2 capture exists to diff against.

This is the codec library only; the DRM/KMS sink that drives it (vmap the
framebuffer, encode, push to the EP08 video endpoint on each page-flip)
is added in the next patch.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
 drivers/gpu/drm/vino/video.rs | 348 ++++++++++++++++++++++++++++++++++
 drivers/gpu/drm/vino/vino.rs  |   1 +
 2 files changed, 349 insertions(+)
 create mode 100644 drivers/gpu/drm/vino/video.rs

diff --git a/drivers/gpu/drm/vino/video.rs b/drivers/gpu/drm/vino/video.rs
new file mode 100644
index 000000000000..bb5ea893575f
--- /dev/null
+++ b/drivers/gpu/drm/vino/video.rs
@@ -0,0 +1,348 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! RawRl (Raw/RLX) **mode-2 video encoder** -- clean-room from the AArch64 DLM
+//! decompile (sec 8.4 + `docs/decompile/arm64-blockencoder`/`-frame-markers`).
+//! Emits packed-RGB565 frames the dock decodes WITHOUT the impractical Vino
+//! Walsh-Hadamard entropy codec (sec 7.11). This is a **verbatim port** of the
+//! `vino-codec::rawrl` oracle, whose encode/decode round-trip is unit-tested
+//! (keyframe, differential, >256-pixel multi-block and >255 RLE run-splits all
+//! reconstruct byte-exact); keep the two in lockstep. No real mode-2 capture
+//! exists to diff against (sec 7.4), so that round-trip is the correctness anchor.
+//! NOT yet wired into `probe()`: sending a frame the dock rejects USB-resets the
+//! dock, so EP08 streaming is a supervised bring-up step.
+#![allow(dead_code)] // Encoder/Mode variants validated by KUnit; live scanout uses the RLE path
+
+use super::*;
+
+pub(super) const MAGIC_RAW16: u16 = 0x68af;
+pub(super) const MAGIC_RLE16: u16 = 0x69af;
+/// Frame-init `0x40af` (`FUN_003330fc`: u32 `0xaf0440af` + u16 `0x0840`).
+pub(super) const FRAME_INIT: [u8; 6] = [0xaf, 0x40, 0x04, 0xaf, 0x40, 0x08];
+/// Bare `0xa0af` sync (`FUN_00332a38`).
+pub(super) const SYNC: [u8; 2] = [0xaf, 0xa0];
+/// Frame-end section->code table `DAT_005b7860`, indexed by `mode - 1`.
+pub(super) const SECTION_CODE: [u8; 7] = [0x01, 0x00, 0x03, 0x00, 0x05, 0x07, 0x07];
+pub(super) const MAX_BLOCK_PIXELS: usize = 256;
+
+/// Per-run strategy: mode 0 raw-only, 1 RLE-only, 2 adaptive (sec 8.4).
+#[derive(Clone, Copy)]
+pub(super) enum Mode {
+    Raw = 0,
+    Rle = 1,
+    Adaptive = 2,
+}
+
+/// Pack 8-bit RGB into RGB565 (the XRGB framebuffer reduced for the
+/// `0x68af`/`0x69af` path).
+pub(super) fn rgb565(r: u8, g: u8, b: u8) -> u16 {
+    ((r as u16 >> 3) << 11) | ((g as u16 >> 2) << 5) | (b as u16 >> 3)
+}
+
+/// 6-byte block header: magic LE, 24-bit coord BE, count u8 (256 -> 0).
+fn block_header(out: &mut KVec<u8>, magic: u16, coord: u32, count: usize) -> Result {
+    out.extend_from_slice(&magic.to_le_bytes(), GFP_KERNEL)?;
+    out.push(((coord >> 16) & 0xff) as u8, GFP_KERNEL)?;
+    out.push(((coord >> 8) & 0xff) as u8, GFP_KERNEL)?;
+    out.push((coord & 0xff) as u8, GFP_KERNEL)?;
+    out.push((count & 0xff) as u8, GFP_KERNEL)?;
+    Ok(())
+}
+
+fn encode_raw_into(out: &mut KVec<u8>, coord: u32, pix: &[u16]) -> Result {
+    block_header(out, MAGIC_RAW16, coord, pix.len())?;
+    for &p in pix {
+        out.extend_from_slice(&p.to_be_bytes(), GFP_KERNEL)?;
+    }
+    Ok(())
+}
+
+fn encode_rle_into(out: &mut KVec<u8>, coord: u32, pix: &[u16]) -> Result {
+    block_header(out, MAGIC_RLE16, coord, pix.len())?;
+    let mut i = 0;
+    while i < pix.len() {
+        let v = pix[i];
+        let mut run = 1;
+        while i + run < pix.len() && pix[i + run] == v && run < 255 {
+            run += 1;
+        }
+        out.push(run as u8, GFP_KERNEL)?;
+        out.extend_from_slice(&v.to_be_bytes(), GFP_KERNEL)?;
+        i += run;
+    }
+    Ok(())
+}
+
+fn run_count(pix: &[u16]) -> usize {
+    let mut c = 0;
+    let mut i = 0;
+    while i < pix.len() {
+        let v = pix[i];
+        let mut j = i + 1;
+        while j < pix.len() && pix[j] == v {
+            j += 1;
+        }
+        c += 1;
+        i = j;
+    }
+    c
+}
+
+fn encode_run_into(out: &mut KVec<u8>, mode: Mode, coord: u32, pix: &[u16]) -> Result {
+    match mode {
+        Mode::Raw => encode_raw_into(out, coord, pix),
+        Mode::Rle => encode_rle_into(out, coord, pix),
+        Mode::Adaptive => {
+            let l = pix.len();
+            let c = run_count(pix);
+            if 2 * l < 3 * c + 1 {
+                encode_raw_into(out, coord, pix)
+            } else {
+                encode_rle_into(out, coord, pix)
+            }
+        }
+    }
+}
+
+/// Mode-2 frame encoder holding the shadow (previous-frame) buffer.
+pub(super) struct Encoder {
+    width: usize,
+    height: usize,
+    mode: Mode,
+    // vmalloc-backed: a `width*height` u16 buffer is ~4 MiB at 1080p, far above the
+    // contiguous-kmalloc order limit (the page allocator WARNs and fails on it).
+    shadow: VVec<u16>,
+}
+
+impl Encoder {
+    pub(super) fn new(width: usize, height: usize, mode: Mode) -> Result<Self> {
+        let shadow = VVec::from_elem(0u16, width * height, GFP_KERNEL)?;
+        Ok(Self { width, height, mode, shadow })
+    }
+
+    /// Encode `cur` (RGB565) into a mode-2 marker stream; updates the shadow.
+    /// Change-detection is per row; changed runs chunk into <=256-px blocks.
+    pub(super) fn encode(&mut self, cur: &[u16]) -> Result<KVec<u8>> {
+        let mut s = KVec::new();
+        self.encode_into(cur, &mut s)?;
+        Ok(s)
+    }
+
+    /// Like [`encode`](Self::encode) but appends the marker stream to a caller-owned
+    /// `out` instead of allocating a fresh `KVec`. The hot scanout path
+    /// ([`encode_and_send`](super::drm_sink::encode_and_send)) uses this to encode
+    /// straight into a buffer that already reserves the EP08 transport header, so a
+    /// frame costs one allocation with no separate framing copy.
+    pub(super) fn encode_into(&mut self, cur: &[u16], s: &mut KVec<u8>) -> Result {
+        s.extend_from_slice(&FRAME_INIT, GFP_KERNEL)?;
+        for y in 0..self.height {
+            let row = y * self.width;
+            let mut x = 0;
+            while x < self.width {
+                while x < self.width && cur[row + x] == self.shadow[row + x] {
+                    x += 1;
+                }
+                if x >= self.width {
+                    break;
+                }
+                let run_start = x;
+                while x < self.width && cur[row + x] != self.shadow[row + x] {
+                    x += 1;
+                }
+                let run_end = x;
+                let mut p = run_start;
+                while p < run_end {
+                    let n = (run_end - p).min(MAX_BLOCK_PIXELS);
+                    let coord = (((row + p) * 2) & 0xff_ffff) as u32;
+                    encode_run_into(s, self.mode, coord, &cur[row + p..row + p + n])?;
+                    p += n;
+                }
+                for k in run_start..run_end {
+                    self.shadow[row + k] = cur[row + k];
+                }
+            }
+        }
+        let code = SECTION_CODE[(self.mode as usize).saturating_sub(1).min(6)];
+        s.extend_from_slice(&SYNC, GFP_KERNEL)?;
+        s.extend_from_slice(&[0xaf, 0x20, 0x1f, code], GFP_KERNEL)?;
+        s.extend_from_slice(&[0xaf, 0x20, 0xff, 0x00], GFP_KERNEL)?;
+        s.extend_from_slice(&SYNC, GFP_KERNEL)?;
+        Ok(())
+    }
+}
+
+/// Vino (`0x2801`) Walsh-Hadamard codec -- the bandwidth-constrained / 4K path (the RLE path
+/// above is what the dock currently runs; this is the lossy transform codec DLM uses when raw/
+/// RLE won't fit the USB budget). See `docs/WHT-CODEC.md` + `docs/VIDEO.md`.
+///
+/// **Scope.** The colour transform, the quantizer, and the 2-level Walsh-Hadamard transform
+/// are reverse-engineered and **validated offline** (`white -> Y_DC=16320 -> quantized 1020`;
+/// achromatic -> `Cb=Cr=0`; uniform block -> DC=mean, AC=0). The token *bit format* (5-bit
+/// short
+/// 0..=30 / 17-bit long, MSB-first) and the **token-value mapping** are confirmed against DLM's
+/// own frida token trace (`captures/02-solid-white/tokens.jsonl`): the **token value is the
+/// quantized coefficient, directly** -- pure-white strips emit `L,1020` exactly where
+/// `quantize(16320, DC) = 1020`, so the rumoured "entropy codebook" is just this direct value
+/// encoding, not a lookup table (the 1641-byte expression-tree coder is the bit-packer). **What
+/// is still NOT generated here:** the per-strip *framing* -- a uniform strip wraps the DC in a
+/// constant prefix/suffix of framing tokens (`L,2048 L,3072 ... L,3 ... S,19 S,16 ...`) plus
+/// zero-run
+/// AC coding, and the dock's exact sequency ordering -- so a complete `Mode::Wht` would replay
+/// the recovered uniform-strip template with the DC substituted (the `docs/WHT-CODEC.md`
+/// structural model, ~90% desktop coverage). Until that framing is generalized + wired, the
+/// scanout path keeps using RLE.
+// Not yet wired into the scanout path (the per-strip framing template is recovered for white
+// but not yet generalized to arbitrary uniform colour / non-uniform content) -- RLE stays the
+// active codec; this module is validated by its KUnit tests + the frida-trace value mapping.
+#[allow(dead_code)] // Walsh-Hadamard codec: KUnit-validated, not yet on the live scanout path
+pub(super) mod wht {
+    use super::*;
+
+    /// 4x8 transform block geometry (`docs/VIDEO.md`): 4 rows x 8 columns = 32 samples.
+    pub(super) const ROWS: usize = 4;
+    pub(super) const COLS: usize = 8;
+    pub(super) const BLOCK: usize = ROWS * COLS;
+
+    /// Vino colour transform (`docs/VIDEO.md`, exact integer form, no rounding):
+    /// `Y = 16R + 32G + 16B`, `Cb = 64(R-G)`, `Cr = 64(B-G)`. Achromatic (R=G=B) ->
+    /// Cb=Cr=0.
+    pub(super) fn colour(r: u8, g: u8, b: u8) -> (i32, i32, i32) {
+        let (r, g, b) = (r as i32, g as i32, b as i32);
+        (16 * r + 32 * g + 16 * b, 64 * (r - g), 64 * (b - g))
+    }
+
+    /// Per-coefficient `(bias, step)` quantization table (`docs/VIDEO.md` `FUN_0077b140`),
+    /// keyed by coefficient position `0..64`.
+    fn bias_step(i: usize) -> (i32, i32) {
+        match i {
+            0..=2 => (8, 16),
+            3 => (16, 32),
+            4..=11 => (2, 4),
+            12..=15 => (4, 8),
+            16..=47 => (1, 2),
+            _ => (2, 4), // 48..=63
+        }
+    }
+
+    /// Quantize coefficient `coeff` at position `i`: `(coeff + bias) * (65536/step) >> 16`,
+    /// the fixed-point form of `(coeff + bias) / step` (`docs/VIDEO.md`). Clamped to the
+    /// 12-bit signed long-token range (the DC is wider than the +/-127 AC clip -- the
+    /// documented
+    /// `white -> 1020` vector is a 12-bit long token, not a +/-127 value).
+    pub(super) fn quantize(coeff: i32, i: usize) -> i32 {
+        let (bias, step) = bias_step(i);
+        let scale = 65536 / step;
+        (((coeff + bias) * scale) >> 16).clamp(-2048, 2047)
+    }
+
+    /// In-place 1-D Walsh-Hadamard (natural/Hadamard order) on a power-of-two slice,
+    /// unnormalized (pairwise sums/differences); the 2-D transform normalizes afterwards.
+    fn hadamard_1d(v: &mut [i32]) {
+        let n = v.len();
+        let mut h = 1;
+        while h < n {
+            let mut i = 0;
+            while i < n {
+                for j in i..i + h {
+                    let (a, b) = (v[j], v[j + h]);
+                    v[j] = a + b;
+                    v[j + h] = a - b;
+                }
+                i += 2 * h;
+            }
+            h *= 2;
+        }
+    }
+
+    /// 2-level separable Walsh-Hadamard transform of a 4x8 `block` (row-major), normalized so
+    /// the DC coefficient equals the block **mean** -- i.e. a uniform block yields `DC = the
+    /// per-pixel value` and all AC = 0 (`docs/VIDEO.md`). Returns 32 coefficients row-major.
+    /// (Natural Hadamard order; the dock's sequency reorder is not bit-matched -- see the
+    /// module note.)
+    pub(super) fn transform(block: &[i32; BLOCK]) -> [i32; BLOCK] {
+        let mut m = *block;
+        for r in 0..ROWS {
+            hadamard_1d(&mut m[r * COLS..r * COLS + COLS]);
+        }
+        let mut col = [0i32; ROWS];
+        for c in 0..COLS {
+            for r in 0..ROWS {
+                col[r] = m[r * COLS + c];
+            }
+            hadamard_1d(&mut col);
+            for r in 0..ROWS {
+                m[r * COLS + c] = col[r];
+            }
+        }
+        // Normalize by the block size (/32 = >>5) so DC = mean (uniform block -> DC = value).
+        for x in m.iter_mut() {
+            *x >>= 5;
+        }
+        m
+    }
+
+    /// MSB-first bit packer for the Vino token stream (`docs/VIDEO.md`): a 16-bit zero pad at
+    /// the start, then codewords packed most-significant-bit first across byte boundaries.
+    pub(super) struct TokenWriter {
+        out: KVec<u8>,
+        acc: u32,
+        nbits: u32,
+    }
+
+    impl TokenWriter {
+        pub(super) fn new() -> Result<Self> {
+            let mut w = Self { out: KVec::new(), acc: 0, nbits: 0 };
+            w.put(0, 16)?; // 16-bit zero pad at stream start
+            Ok(w)
+        }
+
+        /// Append the low `n` bits of `val` (n <= 24), MSB-first.
+        fn put(&mut self, val: u32, n: u32) -> Result {
+            self.acc = (self.acc << n) | (val & ((1u32 << n) - 1));
+            self.nbits += n;
+            while self.nbits >= 8 {
+                self.nbits -= 8;
+                self.out.push(((self.acc >> self.nbits) & 0xff) as u8, GFP_KERNEL)?;
+            }
+            Ok(())
+        }
+
+        /// Write one token *value* in the Vino short/long encoding: a 5-bit short token for
+        /// `0..=30`, else the 17-bit long token `0b11111` escape + 12-bit value. (The mapping
+        /// from a quantized coefficient to this `value` is the un-RE'd entropy codebook -- see
+        /// the module note -- so callers can only pack values they already know.)
+        pub(super) fn token(&mut self, value: u16) -> Result {
+            if value <= 30 {
+                self.put(value as u32, 5)
+            } else {
+                self.put(0b11111, 5)?;
+                self.put((value & 0x0fff) as u32, 12)
+            }
+        }
+
+        /// Flush any partial byte (zero-padded) and return the packed stream.
+        pub(super) fn finish(mut self) -> Result<KVec<u8>> {
+            if self.nbits > 0 {
+                let pad = 8 - self.nbits;
+                self.put(0, pad)?;
+            }
+            Ok(self.out)
+        }
+    }
+}
+
+/// Length of the EP08 transport header ([`write_ep08_header`]).
+pub(super) const EP08_HDR_LEN: usize = 16;
+
+/// Write the 16-byte EP08 transport header into `hdr` for a `payload_len`-byte codec
+/// stream: `type=4 sub=0x30 sub_len_dw=0` sec 3 framing (matches the live capture).
+/// `size = payload_len + 12`. Used by the in-place scanout path. `hdr` must be at
+/// least 16 bytes.
+pub(super) fn write_ep08_header(hdr: &mut [u8], payload_len: usize, seq: u32) {
+    hdr[0] = 0;
+    hdr[1] = 0;
+    hdr[2..4].copy_from_slice(&((payload_len + 12) as u16).to_le_bytes());
+    hdr[4..8].copy_from_slice(&4u32.to_le_bytes());
+    hdr[8..10].copy_from_slice(&0x30u16.to_le_bytes());
+    hdr[10..12].copy_from_slice(&0u16.to_le_bytes());
+    hdr[12..16].copy_from_slice(&seq.to_le_bytes());
+}
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index ef44a625cb70..e9e6324b717b 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -86,6 +86,7 @@ fn timeout() -> Delta {
 mod ake;
 mod golden;
 mod cp;
+mod video;
 
 /// The shared secrets a completed HDCP 2.2 AKE leaves behind: the SKE session key
 /// `ks` and content IV `riv` key the AES-CTR control plane (sec 6), and `kd` is kept
-- 
2.54.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH 5/7] drm/vino: register a DRM/KMS device and scan out to EP08
  2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
                   ` (3 preceding siblings ...)
  2026-06-17 15:12 ` [RFC PATCH 4/7] drm/vino: add the Vino (RawRl mode-2) framebuffer codec Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
  2026-06-17 20:22   ` sashiko-bot
  2026-06-17 15:12 ` [RFC PATCH 6/7] drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info Mike Lothian
                   ` (3 subsequent siblings)
  8 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
  To: dri-devel
  Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
	linux-kernel

Add the drm_sink module: register a real struct drm_device with a
hand-rolled atomic mode-setting pipeline so the dock appears to userspace
as a mode-settable card/renderD node -- one CRTC driven by a primary
plane and a cursor plane, a virtual encoder, and a virtual connector
whose mode list comes from the dock's real EDID (falling back to 1080p),
with GEM-shmem dumb buffers and drm_gem_fb_create framebuffers.

probe() now allocates and registers the DRM device on the control
interface; the bring-up work item caches the dock EDID on it and, once
(if) the CP engages, publishes the live session so the KMS callbacks can
emit runtime CP (mode-set on a modeset, cursor on motion). On every
page-flip the primary plane's update vmaps the framebuffer, encodes it
with the Vino codec and pushes it to the EP08 video endpoint, gated on
CP_ENGAGED so frames are only sent once the dock's cipher is live.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
 drivers/gpu/drm/vino/drm_sink.rs | 1333 ++++++++++++++++++++++++++++++
 drivers/gpu/drm/vino/vino.rs     |  145 +++-
 2 files changed, 1458 insertions(+), 20 deletions(-)
 create mode 100644 drivers/gpu/drm/vino/drm_sink.rs

diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
new file mode 100644
index 000000000000..afbf883fba36
--- /dev/null
+++ b/drivers/gpu/drm/vino/drm_sink.rs
@@ -0,0 +1,1333 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Phase 3 (DRM/KMS sink): register a real `struct drm_device` with a full atomic
+//! mode-setting pipeline so the dock appears to userspace as a `card`/`renderD` node
+//! that can be `drmModeSetCrtc`'d. A hand-rolled atomic pipeline: one CRTC driven by a
+//! primary plane (`primary_atomic_update` -> EP08 scanout) and a cursor plane
+//! (`cursor_atomic_update` -> cursor CP), a virtual encoder, and a virtual connector whose
+//! mode list comes from the dock's real EDID (falling back to 1080p), with GEM-shmem dumb
+//! buffers and `drm_gem_fb_create` framebuffers. (Earlier this was `drm_simple_display_pipe`,
+//! swapped out because that helper is primary-plane-only and can't carry a cursor plane.)
+//! The scanout/cursor writes are gated on the CP-arming blocker (see `docs/BLOCKER.md`). The
+//! KMS C bindings are pulled in by
+//! `patches/drm/0001` (bindings_helper.h headers + the `Driver::FEAT_MODESET` /
+//! `Driver::FEAT_ATOMIC` flags + a public `Device::as_raw`); see `patches/README.md`.
+
+use core::ptr;
+use kernel::{
+    bindings, drm,
+    error::{
+        code::{EINVAL, ENOMEM},
+        to_result,
+    },
+    prelude::*,
+    sync::{aref::ARef, new_mutex, Mutex},
+    types::Opaque,
+};
+
+/// Fallback connector mode advertised by `get_modes` when the dock has not delivered a real
+/// downstream EDID yet. The live scanout geometry follows the actual framebuffer/negotiated
+/// mode (see `scanout_one`), so this is only the no-EDID default, not a hard scanout limit.
+const FALLBACK_W: i32 = 1920;
+const FALLBACK_H: i32 = 1080;
+
+/// The DRM driver marker type.
+pub(super) struct VinoDrmDriver;
+
+/// Convenience alias for our concrete `drm::Device`.
+pub(super) type VinoDrmDevice = drm::Device<VinoDrmDriver>;
+
+/// `DRM_FORMAT_XRGB8888` (`fourcc_code('X','R','2','4')`); the dock scans out 32bpp.
+const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258;
+/// `DRM_FORMAT_ARGB8888` (`fourcc_code('A','R','2','4')`); the cursor sprite carries alpha.
+const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241;
+/// Primary-plane format list (opaque 32bpp scanout).
+static PRIMARY_FORMATS: [u32; 1] = [DRM_FORMAT_XRGB8888];
+/// Cursor-plane format list (alpha sprite). ARGB8888 little-endian memory order is
+/// `B,G,R,A` per pixel -- already the BGRA byte layout `cp::cursor_image` wants.
+static CURSOR_FORMATS: [u32; 1] = [DRM_FORMAT_ARGB8888];
+/// Hardware cursor sprite size (the dock cursor is 64x64; `DRM_CAP_CURSOR_WIDTH/HEIGHT`).
+const CURSOR_SIZE: u32 = 64;
+/// `GAMMA_LUT` size advertised on the CRTC. 256 entries matches the 8-bit scanout channels;
+/// the LUT is applied host-side in the scanout conversion (see `read_gamma_lut`).
+const GAMMA_SIZE: u32 = 256;
+
+/// Per-mode pixel-clock ceiling (kHz) for a single head -- about 4K@60 (CEA 594 MHz).
+/// `mode_valid` prunes any single mode above this from a connector's advertised list.
+const MAX_HEAD_CLOCK_KHZ: i32 = 600_000;
+/// Combined pixel-clock budget (kHz) summed over all *active* heads -- the dock's DL3 link
+/// ceiling. This is a deliberately conservative *raw* pixel-rate proxy (DisplayLink compresses
+/// the stream, so the true USB budget is higher and content-dependent; the WHT codec exists for
+/// the tight cases). At 1 GHz it admits one 4K@60, 4K@60 + QHD@60, or dual-QHD@60, and rejects
+/// dual-4K -- matching the D6000's real multi-monitor envelope. Tune to taste / per dock
+/// model.
+const MAX_TOTAL_CLOCK_KHZ: i64 = 1_000_000;
+
+/// Mutable scanout state, guarded because the atomic `update` callback may run
+/// concurrently with itself across heads. Holds the stateful Vino encoder (created
+/// lazily on the first flip, once the buffer geometry is known) and the EP08 frame
+/// sequence counter.
+pub(super) struct ScanoutState {
+    enc: Option<super::video::Encoder>,
+    /// Reusable `width*height` RGB565 conversion buffer, allocated once alongside `enc`.
+    /// Previously `encode_and_send` did a fresh `KVec::with_capacity(w*h)` on every pageflip;
+    /// at 1080p that is a ~4 MiB *contiguous* kmalloc (order 11), above the allocator's limit,
+    /// so the page allocator WARNed and returned `ENOMEM` every frame. vmalloc-backed +
+    /// persistent: virtually-contiguous (no high-order page need) and allocated once.
+    cur: VVec<u16>,
+    seq: u32,
+    /// Geometry (`width`, `height`) the encoder/`cur` were allocated for. The scanout follows
+    /// the live framebuffer size, so a mode switch re-allocates them when this no longer
+    /// matches.
+    dims: (usize, usize),
+    /// The [`super::cp::Timing`] of the mode the compositor last enabled on the CRTC, captured
+    /// in [`crtc_atomic_enable`] via [`super::cp::timing_from_drm_mode`]. This is the
+    /// multi-mode hook:
+    /// userspace can pick *any* mode from the EDID-derived list and the chosen
+    /// `drm_display_mode`
+    /// is recorded here so the live mode-set CP message reflects it (rather than always the
+    /// EDID-preferred timing). The CP send itself is gated on the engagement wall + session
+    /// plumbing (see the doc note in `crtc_atomic_enable`).
+    active_timing: Option<super::cp::Timing>,
+    /// Size of the last EP08 frame produced, used to pre-reserve the next frame's
+    /// buffer. The encoded stream size is stable frame-to-frame (it tracks the damage
+    /// area), so seeding `KVec::with_capacity` from it makes the encode grow the buffer
+    /// at most once instead of reallocating repeatedly as runs are appended.
+    hint: usize,
+}
+
+/// The live CP session the bring-up work item publishes once the dock engages the cipher
+/// (`acks > 0`), so the KMS callbacks can seal+send runtime CP messages -- a mode-set when the
+/// compositor switches mode, a cursor message on pointer motion -- that continue the SAME
+/// keystream the bring-up setup left off at. `wire_seq` is the AES-CTR block counter (advanced
+/// by the content blocks of each send; the appended Dl3Cmac tag is not part of the keystream)
+/// and `counter` the dock-echoed inner CP counter. Both advance per send under the mutex.
+pub(super) struct CpLink {
+    ks: [u8; 16],
+    riv: [u8; 8],
+    wire_seq: u32,
+    counter: u16,
+}
+
+/// Number of display heads the D6000 dock drives. The protocol routes video by endpoint
+/// (head 0 -> EP 0x08, head 1 -> EP 0x0a; heads 2/3 -> 0x0b/0x0c are documented but their CP
+/// riv / EDID-request encoding is unconfirmed, so only 2 heads are wired). The DL3 CP stream
+/// selects the head via the riv `byte0 ^ 0x80` (head 0 base / head 1 flipped).
+pub(super) const NHEADS: usize = 2;
+/// Per-head video bulk-OUT endpoint (`PROTOCOL.md`). Index by [`Head::index`].
+const HEAD_EP: [u8; NHEADS] = [0x08, 0x0a];
+
+/// One display head: its own CRTC + primary plane (scanout) + cursor plane + encoder +
+/// connector, plus per-head scanout state and cached monitor EDID. The vtables are shared
+/// across heads (in [`VinoDrmData`]); the callbacks recover the head from the C object
+/// pointer. All C objects are zeroed at init and filled by [`kms_init`].
+#[pin_data]
+pub(super) struct Head {
+    /// 0-based head index. Selects the video EP ([`HEAD_EP`]) and the CP riv (head 1 flips
+    /// the riv `byte0`); see the scanout EP and [`VinoDrmData::send_cp`].
+    index: u8,
+    /// One-shot: this head's cursor `create` (sprite dimensions) was sent before its first
+    /// image upload (per head -- the global one would skip head 1's create).
+    cursor_primed: core::sync::atomic::AtomicBool,
+    #[pin]
+    scanout: Mutex<ScanoutState>,
+    /// This head's downstream-monitor EDID (`None` until the CP channel delivers it). Only
+    /// head 0's EDID is read during bring-up; per-head EDID requests are unconfirmed, so
+    /// head 1 falls back to a fixed CVT mode in `get_modes`.
+    #[pin]
+    cached_edid: Mutex<Option<KVec<u8>>>,
+    #[pin]
+    crtc: Opaque<bindings::drm_crtc>,
+    #[pin]
+    primary: Opaque<bindings::drm_plane>,
+    #[pin]
+    cursor: Opaque<bindings::drm_plane>,
+    #[pin]
+    encoder: Opaque<bindings::drm_encoder>,
+    #[pin]
+    connector: Opaque<bindings::drm_connector>,
+}
+
+// SAFETY: as for `VinoDrmData` below -- the embedded C KMS objects are written only during
+// single-threaded probe and thereafter serialised by the DRM core's own locks.
+unsafe impl Send for Head {}
+// SAFETY: see the `Send` impl above.
+unsafe impl Sync for Head {}
+
+impl Head {
+    fn new(index: u8) -> impl PinInit<Self, Error> {
+        fn z<T>() -> impl PinInit<Opaque<T>, Error> {
+            // SAFETY: an all-zero C KMS object is a valid starting point (all callback
+            // pointers NULL); `kms_init` populates the rest via raw pointers under `unsafe`.
+            Opaque::try_ffi_init(|p: *mut T| {
+                unsafe { ptr::write_bytes(p, 0, 1) };
+                Ok(())
+            })
+        }
+        try_pin_init!(Self {
+            index,
+            cursor_primed: core::sync::atomic::AtomicBool::new(false),
+            scanout <- new_mutex!(ScanoutState {
+                enc: None,
+                cur: VVec::new(),
+                seq: 0,
+                dims: (0, 0),
+                active_timing: None,
+                hint: 0,
+            }),
+            cached_edid <- new_mutex!(Option::<KVec<u8>>::None),
+            crtc <- z(),
+            primary <- z(),
+            cursor <- z(),
+            encoder <- z(),
+            connector <- z(),
+        })
+    }
+
+    /// This head's video bulk-OUT endpoint.
+    fn video_ep(&self) -> u8 {
+        HEAD_EP[self.index as usize]
+    }
+
+    /// Fire a hotplug on this head's connector so the compositor re-probes [`detect`].
+    fn fire_hotplug(&self) {
+        // SAFETY: called after `drm_dev_register`; the embedded connector is initialised
+        // and its `dev` is our live drm_device. Safe from process context.
+        unsafe {
+            let dev = (*self.connector.get()).dev;
+            if !dev.is_null() {
+                bindings::drm_kms_helper_hotplug_event(dev);
+            }
+        }
+    }
+}
+
+/// DRM device-private data. Holds [`NHEADS`] display [`Head`]s (each a CRTC + primary +
+/// cursor plane + encoder + connector) and the shared KMS vtables inline, so they keep
+/// stable addresses for the device's lifetime. All C objects zeroed at init; filled by
+/// [`kms_init`]. Also keeps the bound USB interface (to reach the video EPs) and the engaged
+/// CP session.
+#[pin_data]
+pub(super) struct VinoDrmData {
+    intf: ARef<super::usb::Interface>,
+    /// The engaged CP session for runtime KMS-driven sends (`None` until
+    /// [`VinoDrmData::publish_session`]). See [`CpLink`] and [`VinoDrmData::send_cp`].
+    #[pin]
+    cp_link: Mutex<Option<CpLink>>,
+    #[pin]
+    head0: Head,
+    #[pin]
+    head1: Head,
+    // Shared vtables (one set for all heads; the callbacks recover the head from the C
+    // object pointer). One `drm_plane_funcs` for both planes; per-plane helper funcs because
+    // the primary's `atomic_update` scans out while the cursor's sends cursor CP.
+    #[pin]
+    conn_funcs: Opaque<bindings::drm_connector_funcs>,
+    #[pin]
+    conn_helper: Opaque<bindings::drm_connector_helper_funcs>,
+    #[pin]
+    crtc_funcs: Opaque<bindings::drm_crtc_funcs>,
+    #[pin]
+    crtc_helper: Opaque<bindings::drm_crtc_helper_funcs>,
+    #[pin]
+    plane_funcs: Opaque<bindings::drm_plane_funcs>,
+    #[pin]
+    primary_helper: Opaque<bindings::drm_plane_helper_funcs>,
+    #[pin]
+    cursor_helper: Opaque<bindings::drm_plane_helper_funcs>,
+    #[pin]
+    encoder_funcs: Opaque<bindings::drm_encoder_funcs>,
+    #[pin]
+    mode_cfg_funcs: Opaque<bindings::drm_mode_config_funcs>,
+}
+
+// SAFETY: the embedded C KMS objects are written only during single-threaded
+// `probe()` (before `drm_dev_register`), and thereafter are owned and serialised by
+// the DRM core under its own modeset/atomic locks -- Rust never aliases them again.
+// This is the conventional assertion for drivers embedding C KMS state until the
+// kernel grows safe Rust KMS abstractions.
+unsafe impl Send for VinoDrmData {}
+// SAFETY: see the `Send` impl above.
+unsafe impl Sync for VinoDrmData {}
+
+impl VinoDrmData {
+    /// Zero-initialise all embedded C objects (so each `Option<fn>` vtable slot is
+    /// `None`); [`kms_init`] then fills in only the callbacks we implement. `intf`
+    /// is the bound USB interface, kept so the scanout path can reach EP08.
+    pub(super) fn new(intf: ARef<super::usb::Interface>) -> impl PinInit<Self, Error> {
+        fn z<T>() -> impl PinInit<Opaque<T>, Error> {
+            // SAFETY: an all-zero C KMS object / funcs table is a valid starting
+            // point (all callback pointers NULL); the `_init` helpers populate the
+            // rest, and we only read it back through raw pointers under `unsafe`.
+            Opaque::try_ffi_init(|p: *mut T| {
+                unsafe { ptr::write_bytes(p, 0, 1) };
+                Ok(())
+            })
+        }
+        try_pin_init!(Self {
+            intf,
+            cp_link <- new_mutex!(Option::<CpLink>::None),
+            head0 <- Head::new(0),
+            head1 <- Head::new(1),
+            conn_funcs <- z(),
+            conn_helper <- z(),
+            crtc_funcs <- z(),
+            crtc_helper <- z(),
+            plane_funcs <- z(),
+            primary_helper <- z(),
+            cursor_helper <- z(),
+            encoder_funcs <- z(),
+            mode_cfg_funcs <- z(),
+        })
+    }
+
+    /// The display heads, in index order.
+    fn heads(&self) -> [&Head; NHEADS] {
+        [&self.head0, &self.head1]
+    }
+
+    /// Recover the [`Head`] that owns a given C KMS object, by pointer identity. Used by the
+    /// connector/CRTC/plane callbacks (which receive a raw C pointer) to find their head.
+    fn head_by_connector(&self, c: *mut bindings::drm_connector) -> Option<&Head> {
+        self.heads().into_iter().find(|h| h.connector.get() == c)
+    }
+    fn head_by_crtc(&self, c: *mut bindings::drm_crtc) -> Option<&Head> {
+        self.heads().into_iter().find(|h| h.crtc.get() == c)
+    }
+    fn head_by_primary(&self, p: *mut bindings::drm_plane) -> Option<&Head> {
+        self.heads().into_iter().find(|h| h.primary.get() == p)
+    }
+    fn head_by_cursor(&self, p: *mut bindings::drm_plane) -> Option<&Head> {
+        self.heads().into_iter().find(|h| h.cursor.get() == p)
+    }
+
+    /// Cache the dock's EDID (read during probe) on head 0 for [`get_modes`] to install, then
+    /// fire a hotplug so the compositor re-probes the connector -- which now reports connected
+    /// (see [`detect`]) and exposes the monitor's real mode list. Only head 0's downstream EDID
+    /// is read during bring-up (per-head EDID requests are unconfirmed).
+    pub(super) fn set_edid(&self, blob: KVec<u8>) {
+        *self.head0.cached_edid.lock() = Some(blob);
+        self.head0.fire_hotplug();
+    }
+
+    /// Fire a hotplug on every head's connector so the compositor re-probes [`detect`] -- used
+    /// after the bring-up work item completes to expose the live-scanout outputs.
+    pub(super) fn fire_hotplug(&self) {
+        for h in self.heads() {
+            h.fire_hotplug();
+        }
+    }
+
+    /// Publish the engaged CP session so the KMS callbacks can send runtime CP messages.
+    /// Called once by the bring-up work item after the dock acks (`acks > 0`).
+    /// `wire_seq`/`counter` are the next free values past the bring-up CP setup.
+    pub(super) fn publish_session(&self, ks: &[u8; 16], riv: &[u8; 8], wire_seq: u32, counter: u16) {
+        *self.cp_link.lock() = Some(CpLink { ks: *ks, riv: *riv, wire_seq, counter });
+    }
+
+    /// Seal and send one interactive CP message on EP02 for head `head_index`, advancing the
+    /// session keystream. The DL3 CP stream selects the head via the riv `byte0 ^ 0x80` (head 0
+    /// base, head 1 flipped). `build(counter)` produces the inner CP message for the
+    /// dock-echoed `counter` it is handed (e.g. [`super::cp::set_mode`]); `tag_reserved`
+    /// trailing bytes are dropped before the live Dl3Cmac is appended (set-mode reserves a
+    /// 16-byte placeholder; messages with no placeholder pass 0). Returns `Ok(())` as a
+    /// **no-op when CP is not engaged**. The `cp_link` mutex serialises concurrent KMS
+    /// callbacks. Runs from the atomic-commit context (same as the scanout), so the blocking
+    /// `bulk_send` is fine. NOTE: head 1's `wire_seq`/counter sharing with head 0 is an
+    /// assumption (both share `cp_link`); the riv differs so the keystreams differ. Unconfirmed
+    /// (CP-wall-gated) -- revisit when the dock engages.
+    pub(super) fn send_cp(
+        &self,
+        head_index: u8,
+        id: u16,
+        tag_reserved: usize,
+        build: impl FnOnce(u16) -> Result<KVec<u8>>,
+    ) -> Result {
+        let mut guard = self.cp_link.lock();
+        // `&mut *guard` forces the guard's `DerefMut` to `&mut Option<CpLink>` so `as_mut`
+        // resolves to `Option::as_mut` (the guard has its own inherent `as_mut`).
+        let Some(link) = (&mut *guard).as_mut() else {
+            return Ok(()); // CP not engaged -- nothing to send
+        };
+        // Head select: head 1's CP stream flips the riv byte0 (see `decode_any`/CP-HANDSHAKE).
+        let mut riv = link.riv;
+        if head_index == 1 {
+            riv[0] ^= 0x80;
+        }
+        let msg = build(link.counter)?;
+        let content = &msg[..msg.len().saturating_sub(tag_reserved)];
+        let frame = super::cp::seal_interactive(&link.ks, &riv, id, link.wire_seq, content)?;
+        let dev: &super::usb::Device = self.intf.as_ref();
+        dev.bulk_send(super::EP_CTRL_OUT, &frame, super::timeout())?;
+        // Advance the AES-CTR block counter by the content blocks only (the appended Dl3Cmac
+        // tag is sent in clear, not keystreamed) and bump the dock-echoed inner counter.
+        link.wire_seq = link.wire_seq.wrapping_add(((content.len() + 15) / 16) as u32);
+        link.counter = link.counter.wrapping_add(1);
+        Ok(())
+    }
+}
+
+/// GEM object inner data. Empty: the shmem-backed `drm::gem::shmem::Object` (which
+/// wires `drm_gem_shmem_dumb_create`, so userspace `DRM_IOCTL_MODE_CREATE_DUMB`
+/// works) is enough until the EP08 scanout path consumes the framebuffers.
+#[pin_data]
+pub(super) struct VinoObject {}
+
+impl drm::gem::DriverObject for VinoObject {
+    type Driver = VinoDrmDriver;
+    type Args = ();
+
+    fn new<Ctx: drm::DeviceContext>(
+        _dev: &drm::Device<VinoDrmDriver, Ctx>,
+        _size: usize,
+        _args: (),
+    ) -> impl PinInit<Self, Error> {
+        try_pin_init!(VinoObject {})
+    }
+}
+
+/// Per-open DRM client state. Empty of driver data, but its lifetime is used to
+/// pin the module for the duration of an open DRM file (see [`VinoDrmFile::open`]).
+#[pin_data(PinnedDrop)]
+pub(super) struct VinoDrmFile {}
+
+impl drm::file::DriverFile for VinoDrmFile {
+    type Driver = VinoDrmDriver;
+
+    fn open(_dev: &drm::Device<Self::Driver>) -> Result<Pin<KBox<Self>>> {
+        let file = KBox::try_pin_init(try_pin_init!(Self {}), GFP_KERNEL)?;
+        // Pin this module while a DRM file is open. The Rust DRM `file_operations`
+        // are built with `owner = NULL` (drm/gem/mod.rs `create_fops`), so the DRM
+        // core's `try_module_get(fops->owner)` on open is a no-op: an open card fd
+        // does NOT keep the driver loaded. Unloading vino (rmmod, or USB teardown at
+        // shutdown) while a compositor still holds `/dev/dri/cardN` then frees the
+        // module's `.rodata` -- where the fops live -- under that open fd, so the next
+        // ioctl/close dereferences freed memory and oopses the kernel (observed: KWin
+        // UAF in `__x64_sys_ioctl` / `put_files_struct`, "recursive fault, reboot
+        // needed"). Take an explicit module reference here, released 1:1 in
+        // `PinnedDrop` (run by `postclose_callback` on file close), to restore the
+        // pin the NULL `fops.owner` drops. Remove once the binding sets `fops.owner`.
+        // SAFETY: we are executing inside this module's own DRM `open` callback, so
+        // the module is live; taking an extra reference via `__module_get` is sound.
+        unsafe { bindings::__module_get(crate::THIS_MODULE.as_ptr()) };
+        Ok(file)
+    }
+}
+
+#[pinned_drop]
+impl PinnedDrop for VinoDrmFile {
+    fn drop(self: Pin<&mut Self>) {
+        // Release the module reference taken in `open` (balanced one-per-open-file).
+        // SAFETY: balances the `__module_get` in `open`; `THIS_MODULE` is valid for
+        // the lifetime of the module.
+        unsafe { bindings::module_put(crate::THIS_MODULE.as_ptr()) };
+    }
+}
+
+const INFO: drm::DriverInfo = drm::DriverInfo {
+    major: 0,
+    minor: 1,
+    patchlevel: 0,
+    name: c"vino",
+    desc: c"DisplayLink DL3 (Dell D6000) DRM driver",
+};
+
+#[vtable]
+impl drm::Driver for VinoDrmDriver {
+    type Data = VinoDrmData;
+    type File = VinoDrmFile;
+    type Object<Ctx: drm::DeviceContext> = drm::gem::shmem::Object<VinoObject, Ctx>;
+
+    const INFO: drm::DriverInfo = INFO;
+    // Atomic KMS driver (CRTC/plane/connector via the simple display pipe).
+    // Mirrors the FEAT_RENDER idiom added by patches/drm/0001.
+    const FEAT_MODESET: bool = true;
+    const FEAT_ATOMIC: bool = true;
+
+    // No driver-private ioctls (GEM/dumb + KMS handled by the DRM core).
+    kernel::declare_drm_ioctls! {}
+}
+
+// ---- KMS C callbacks ------------------------------------------------------
+
+/// Install a real EDID blob on the connector via the standard DRM EDID
+/// infrastructure and return the number of modes added (0 on failure). This
+/// reuses the kernel helpers -- no synthetic EDID. See CONTROL-PLANE.md.
+fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 {
+    // SAFETY: `blob` is a valid byte buffer; `drm_edid_alloc` copies it.
+    let edid = unsafe { bindings::drm_edid_alloc(blob.as_ptr().cast(), blob.len()) };
+    if edid.is_null() {
+        return 0;
+    }
+    // SAFETY: `connector` is valid during probe; `edid` is freshly allocated above.
+    unsafe { bindings::drm_edid_connector_update(connector, edid) };
+    // SAFETY: connector valid; adds the EDID-derived modes, returns the count.
+    let n = unsafe { bindings::drm_edid_connector_add_modes(connector) };
+    // SAFETY: `edid` was allocated by `drm_edid_alloc` and is no longer needed.
+    unsafe { bindings::drm_edid_free(edid) };
+    n
+}
+
+/// Connector `.mode_valid`: reject any single mode whose pixel clock exceeds the per-head
+/// ceiling ([`MAX_HEAD_CLOCK_KHZ`], ~4K@60), so the compositor never offers an over-spec mode
+/// on
+/// one head. The *combined* across-heads budget is enforced separately at commit by
+/// [`vino_atomic_check`].
+unsafe extern "C" fn mode_valid(
+    _connector: *mut bindings::drm_connector,
+    mode: *const bindings::drm_display_mode,
+) -> bindings::drm_mode_status {
+    // SAFETY: `mode` is a valid drm_display_mode for the duration of the call.
+    let clock = unsafe { (*mode).clock };
+    if clock > MAX_HEAD_CLOCK_KHZ {
+        bindings::drm_mode_status_MODE_CLOCK_HIGH
+    } else {
+        bindings::drm_mode_status_MODE_OK
+    }
+}
+
+/// `mode_config.funcs.atomic_check`: run the standard atomic checks, then reject the commit if
+/// the **combined** pixel clock of all active heads would exceed the dock's USB/DL3 budget
+/// ([`MAX_TOTAL_CLOCK_KHZ`]) -- e.g. two simultaneous 4K modes. For each head, the proposed
+/// (new) CRTC state is used when the head is part of this commit, else its current committed
+/// state; only `enable && active` heads count.
+unsafe extern "C" fn vino_atomic_check(
+    dev: *mut bindings::drm_device,
+    state: *mut bindings::drm_atomic_commit,
+) -> i32 {
+    // SAFETY: `dev`/`state` are valid for the duration of the atomic check.
+    let rc = unsafe { bindings::drm_atomic_helper_check(dev, state) };
+    if rc != 0 {
+        return rc;
+    }
+    // SAFETY: `dev` is our live, registered drm_device.
+    let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) };
+    let mut total_khz: i64 = 0;
+    for head in data.heads() {
+        let crtc = head.crtc.get();
+        // SAFETY: read-only new-state accessor (a `rust_helper`, exposed without the prefix);
+        // NULL when this head is not in the commit -- then fall back to its current state.
+        let mut cs = unsafe { bindings::drm_atomic_get_new_crtc_state(state, crtc) };
+        if cs.is_null() {
+            // SAFETY: `crtc` is initialised; `.state` is its current committed state (or NULL).
+            cs = unsafe { (*crtc).state };
+        }
+        if cs.is_null() {
+            continue;
+        }
+        // SAFETY: `cs` is a live drm_crtc_state.
+        let (enable, active, clock) = unsafe { ((*cs).enable, (*cs).active, (*cs).mode.clock) };
+        if enable && active {
+            total_khz += clock as i64;
+        }
+    }
+    if total_khz > MAX_TOTAL_CLOCK_KHZ {
+        pr_warn!(
+            "vino: modeset rejected -- combined {total_khz} kHz pixel clock over the {} kHz dock budget\n",
+            MAX_TOTAL_CLOCK_KHZ
+        );
+        return EINVAL.to_errno();
+    }
+    0
+}
+
+/// Connector `.get_modes`: install the dock's real EDID (read during probe) when
+/// available; otherwise fall back to a single 1920x1080@60 CVT mode. Reading the
+/// real EDID gives the true monitor name/size and its native mode list (see the
+/// EDID Read path); the fallback keeps the connector usable when nothing is
+/// plugged into the dock or the CP channel has not yet delivered the EDID.
+unsafe extern "C" fn get_modes(connector: *mut bindings::drm_connector) -> i32 {
+    // SAFETY: `connector` is a valid, initialised connector during probe.
+    let dev = unsafe { (*connector).dev };
+    // SAFETY: `dev` is our live, registered drm_device.
+    let ddev = unsafe { VinoDrmDevice::from_raw(dev) };
+    let data: &VinoDrmData = ddev;
+    if let Some(head) = data.head_by_connector(connector) {
+        let guard = head.cached_edid.lock();
+        if let Some(blob) = guard.as_ref() {
+            let n = install_edid(connector, blob);
+            if n > 0 {
+                return n;
+            }
+        }
+    }
+    // Fallback: single FALLBACK_W x FALLBACK_H @60 CVT mode, marked preferred.
+    // SAFETY: `dev` is a valid drm_device; drm_cvt_mode allocates a mode.
+    let mode = unsafe {
+        bindings::drm_cvt_mode(dev, FALLBACK_W, FALLBACK_H, 60, false, false, false)
+    };
+    if mode.is_null() {
+        return 0;
+    }
+    // SAFETY: `mode` is freshly allocated and owned by the connector after add.
+    unsafe { bindings::drm_mode_probed_add(connector, mode) };
+    // SAFETY: connector is valid; set the fallback mode as preferred.
+    unsafe { bindings::drm_set_preferred_mode(connector, FALLBACK_W, FALLBACK_H) };
+    1
+}
+
+/// Connector `.detect`: report **disconnected** until the dock's downstream EDID has
+/// actually been read over the CP channel, then **connected**. A virtual connector
+/// that always reports connected makes the compositor light up a phantom output it
+/// cannot drive -- no pixels reach the dock until the CP/EP08 path is up -- which froze
+/// KWin on plug (SSH stayed alive; unplug recovered it). Gating on a real EDID mirrors
+/// how `gud`/`udl` report monitor presence; [`VinoDrmData::set_edid`] fires a hotplug
+/// so the compositor re-probes and enables the output once the EDID arrives.
+unsafe extern "C" fn detect(
+    connector: *mut bindings::drm_connector,
+    _force: bool,
+) -> bindings::drm_connector_status {
+    // SAFETY: `connector` is a valid connector embedded in our DRM device-private.
+    let dev = unsafe { (*connector).dev };
+    // SAFETY: `dev` is our live, registered drm_device.
+    let ddev = unsafe { VinoDrmDevice::from_raw(dev) };
+    let data: &VinoDrmData = ddev;
+    let live_ready = super::CP_ENGAGED.load(core::sync::atomic::Ordering::SeqCst);
+    let has_edid = data
+        .head_by_connector(connector)
+        .is_some_and(|h| h.cached_edid.lock().is_some());
+    if has_edid || live_ready {
+        // Force-connect (with the get_modes 1080p fallback) so a compositor drives the CRTC and
+        // `primary_atomic_update` fires live frames -- but only once CP is engaged (not merely
+        // after
+        // bring-up): connecting the output makes the compositor push EP08 video, and doing that
+        // before the dock has engaged CP makes it fault and USB-reset in a loop (the "EP08
+        // write
+        // wedges the hub" mode). So stay disconnected until CP is up -- or a real EDID arrived.
+        bindings::drm_connector_status_connector_status_connected
+    } else {
+        bindings::drm_connector_status_connector_status_disconnected
+    }
+}
+
+/// CRTC `.atomic_enable`: the display is turning on (scanout begins). Captures the mode the
+/// compositor selected -- any entry from the connector's full EDID-derived list -- as a
+/// set-mode [`super::cp::Timing`] in [`ScanoutState::active_timing`] and pushes a live
+/// mode-set CP message for it (no-op until CP engages). The geometry change is also honoured
+/// by the scanout path (`encode_and_send` re-inits on `dims` change).
+unsafe extern "C" fn crtc_atomic_enable(
+    crtc: *mut bindings::drm_crtc,
+    _state: *mut bindings::drm_atomic_commit,
+) {
+    // SAFETY: in `.atomic_enable` the crtc and its committed `state` are valid; `state->mode`
+    // is a live drm_display_mode and `timing_from_drm_mode` only reads it.
+    let cs = unsafe { (*crtc).state };
+    if cs.is_null() {
+        return;
+    }
+    let timing = unsafe { super::cp::timing_from_drm_mode(&(*cs).mode) };
+    pr_info!(
+        "vino: KMS CRTC enable -- display ON, mode {}x{}@{} (scanout begins)\n",
+        timing.hactive, timing.vactive, timing.refresh_hz
+    );
+    // SAFETY: `crtc` is valid; its `dev` is our live drm_device.
+    let dev = unsafe { (*crtc).dev };
+    if dev.is_null() {
+        return;
+    }
+    // SAFETY: `dev` is our registered drm_device.
+    let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) };
+    let Some(head) = data.head_by_crtc(crtc) else {
+        return;
+    };
+    head.scanout.lock().active_timing = Some(timing);
+    // Push a live mode-set for the chosen mode (on this head's CP stream) so the dock switches
+    // to it at runtime, not just the EDID-preferred mode the bring-up setup sent. `set_mode`
+    // reserves a 16-byte tag placeholder. A no-op until the cipher is engaged.
+    if let Err(e) = data.send_cp(head.index, 0x48, 16, |ctr| super::cp::set_mode(ctr, &timing)) {
+        pr_warn!("vino: head{} runtime mode-set send failed ({e:?})\n", head.index);
+    }
+}
+
+/// CRTC `.atomic_disable`: the display is turning off.
+/// CRTC `.atomic_disable`: the display is turning off -- DPMS-off / blank / suspend all land
+/// here in atomic KMS (the compositor clears the CRTC `active` state). The compositor stops
+/// page-flipping, so no new frames are pushed; this resets the head's scanout state so a later
+/// re-enable (DPMS-on) re-inits the encoder and sends a **full keyframe** rather than diffing
+/// against a shadow the dock may have dropped while blanked, and re-uploads the cursor sprite.
+///
+/// The dock holds the last frame when video stops (it has its own scanout buffer), so the
+/// monitor freezes the last image rather than going black; a true backlight-standby would need
+/// a dock power command that is not decoded (DLM's `Standby`/`Suspend`/`TempPowerOff` are
+/// internal, vtable-dispatched events with no wire frame -- the same dead-end as gamma).
+unsafe extern "C" fn crtc_atomic_disable(
+    crtc: *mut bindings::drm_crtc,
+    _state: *mut bindings::drm_atomic_commit,
+) {
+    // SAFETY: in `.atomic_disable` the crtc and its `dev` are valid.
+    let dev = unsafe { (*crtc).dev };
+    if dev.is_null() {
+        return;
+    }
+    // SAFETY: `dev` is our registered drm_device.
+    let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) };
+    let Some(head) = data.head_by_crtc(crtc) else {
+        return;
+    };
+    {
+        let mut st = head.scanout.lock();
+        st.enc = None; // force a full re-init + keyframe on the next enable
+        st.dims = (0, 0);
+    }
+    head.cursor_primed
+        .store(false, core::sync::atomic::Ordering::SeqCst);
+    pr_info!("vino: KMS CRTC disable -- head{} display OFF (scanout stopped)\n", head.index);
+}
+
+/// Cursor plane `.atomic_update`: the cursor sprite and/or position changed. Sends the cursor
+/// CP messages (create once + image when a sprite framebuffer is present, then a move). Gated
+/// on CP engagement; a no-op on current hardware (the CP wall). See [`cursor_send`].
+unsafe extern "C" fn cursor_atomic_update(
+    plane: *mut bindings::drm_plane,
+    _state: *mut bindings::drm_atomic_commit,
+) {
+    if !super::CP_ENGAGED.load(core::sync::atomic::Ordering::SeqCst) {
+        return;
+    }
+    // SAFETY: in `.atomic_update` the plane and its committed state are valid for the commit.
+    let (dev_raw, fb, w, h, cx, cy) = unsafe {
+        let st = (*plane).state;
+        if st.is_null() {
+            return;
+        }
+        (
+            (*plane).dev,
+            (*st).fb,
+            (*st).crtc_w as usize,
+            (*st).crtc_h as usize,
+            (*st).crtc_x,
+            (*st).crtc_y,
+        )
+    };
+    // SAFETY: `dev_raw` is our live, registered drm_device.
+    let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev_raw) };
+    let Some(head) = data.head_by_cursor(plane) else {
+        return;
+    };
+    if let Err(e) = cursor_send(data, head, fb, w, h, cx, cy) {
+        pr_warn!("vino: head{} cursor update failed ({e:?})\n", head.index);
+    }
+}
+
+/// Primary plane `.atomic_update`: a new framebuffer was flipped in -- the scanout hook.
+/// Maps the framebuffer, converts XRGB8888 -> RGB565, Vino-encodes the changed
+/// region against the previous frame, and bulk-writes the EP08 video frame.
+///
+/// The EP08 write only happens once the dock has engaged CP (see `docs/BLOCKER.md`):
+/// until then the dock NAKs/stalls EP08, so a normal module load must not push frames on
+/// every flip and thrash the dock. With the CP-engagement wall unsolved this never fires
+/// on real hardware.
+unsafe extern "C" fn primary_atomic_update(
+    plane: *mut bindings::drm_plane,
+    _state: *mut bindings::drm_atomic_commit,
+) {
+    // Don't touch EP08 until the dock has engaged CP. Pushing video (and the one-shot
+    // clear_halt of EPs 8/10/11/12) at a dock with a dead CP channel makes it fault and
+    // USB-reset, which re-probes the driver in a ~2.7 s loop.
+    if !super::CP_ENGAGED.load(core::sync::atomic::Ordering::SeqCst) {
+        return;
+    }
+    // SAFETY: in `.atomic_update` the plane and its committed state are valid; the plane
+    // state and its framebuffer are valid for the duration of the commit.
+    let (dev_raw, fb, w, h, damage, rotation) = unsafe {
+        let st = (*plane).state;
+        if st.is_null() {
+            return;
+        }
+        // Plane destination geometry == the negotiated mode (the compositor sizes the primary
+        // plane 1:1 with a virtual output), so this drives the dynamic scanout resolution.
+        let (w, h) = ((*st).crtc_w as usize, (*st).crtc_h as usize);
+        ((*plane).dev, (*st).fb, w, h, damage_bbox(st), (*st).rotation)
+    };
+    if fb.is_null() {
+        return;
+    }
+    // Recover our device-private data + this plane's head from the raw drm_device.
+    // SAFETY: `dev_raw` is our live, registered drm_device.
+    let ddev = unsafe { VinoDrmDevice::from_raw(dev_raw) };
+    let data: &VinoDrmData = ddev;
+    let Some(head) = data.head_by_primary(plane) else {
+        return;
+    };
+
+    use core::sync::atomic::Ordering::Relaxed;
+    // Throttle: while scanout is failing (dock NAKing because CP isn't engaged), skip the
+    // upcoming pageflips set by the backoff below instead of converting+encoding+sending a
+    // frame the dock will just drop. The backoff is shared across heads (a coarse global rate
+    // limit) -- fine while it never fires on real hardware (CP wall).
+    let skip = super::SCANOUT_SKIP.load(Relaxed);
+    if skip > 0 {
+        super::SCANOUT_SKIP.store(skip - 1, Relaxed);
+        return;
+    }
+    // Read this head's CRTC GAMMA_LUT (if a compositor set one) and apply it host-side in the
+    // conversion below -- there is no dock-side gamma message (see `read_gamma_lut`).
+    let gamma = read_gamma_lut(head);
+    match scanout_one(data, head, fb, w, h, damage, rotation, gamma.as_ref()) {
+        Ok(()) => {
+            let n = super::SCANOUT_FAILS.swap(0, Relaxed);
+            super::SCANOUT_SKIP.store(0, Relaxed);
+            if n > 0 {
+                pr_info!("vino: scanout recovered after {n} failed frame(s)\n");
+            }
+        }
+        Err(e) => {
+            // The dock NAKs every EP08 write (EPROTO) until CP engages -- expected and not
+            // actionable. Log the first failure and then at exponentially sparser points so
+            // dmesg isn't flooded, and back off the scanout rate.
+            let n = super::SCANOUT_FAILS.fetch_add(1, Relaxed) + 1;
+            if n == 1 || n.is_power_of_two() {
+                pr_err!("vino: scanout frame failed ({e:?}) [x{n}] -- throttling\n");
+            }
+            // Linear backoff capped at 120 frames (~2 s @ 60 Hz) between probe attempts, so
+            // recovery (CP engaging) is still detected within ~2 s while idle CPU stays low.
+            super::SCANOUT_SKIP.store(core::cmp::min(n, 120), Relaxed);
+        }
+    }
+}
+
+/// Map an output pixel `(dx, dy)` back to its source-framebuffer pixel `(sx, sy)` under a DRM
+/// plane `rotation` bitmask (`DRM_MODE_ROTATE_*` | `DRM_MODE_REFLECT_*`, the values the
+/// standard `drm_plane_create_rotation_property` exposes). `sw`/`sh` are the SOURCE
+/// (framebuffer) dimensions; the output dimensions are `(sw, sh)` for 0 deg/180 deg and `(sh, sw)`
+/// for 90 deg/270 deg (the caller swaps source vs output accordingly). Rotation is clockwise;
+/// reflection is applied in source space after rotation. Pure + total (saturating), so it is
+/// unit-tested directly. Used by [`encode_and_send`] to honour the connector's rotation
+/// property -- DLM rotates host-side, vino rotates in the scanout encode.
+pub(super) fn rot_src(
+    rotation: u32,
+    dx: usize,
+    dy: usize,
+    sw: usize,
+    sh: usize,
+) -> (usize, usize) {
+    let xmax = sw.saturating_sub(1);
+    let ymax = sh.saturating_sub(1);
+    let rot = rotation & bindings::DRM_MODE_ROTATE_MASK;
+    let (mut sx, mut sy) = if rot == bindings::DRM_MODE_ROTATE_90 {
+        (dy, ymax.saturating_sub(dx))
+    } else if rot == bindings::DRM_MODE_ROTATE_180 {
+        (xmax.saturating_sub(dx), ymax.saturating_sub(dy))
+    } else if rot == bindings::DRM_MODE_ROTATE_270 {
+        (xmax.saturating_sub(dy), dx)
+    } else {
+        (dx, dy) // ROTATE_0 / unset
+    };
+    if rotation & bindings::DRM_MODE_REFLECT_X != 0 {
+        sx = xmax.saturating_sub(sx);
+    }
+    if rotation & bindings::DRM_MODE_REFLECT_Y != 0 {
+        sy = ymax.saturating_sub(sy);
+    }
+    (sx, sy)
+}
+
+/// True if `rotation` swaps width/height (90 deg or 270 deg), so the source framebuffer is
+/// portrait while the scanned-out display is landscape (or vice versa).
+fn rotation_swaps_dims(rotation: u32) -> bool {
+    let r = rotation & bindings::DRM_MODE_ROTATE_MASK;
+    r == bindings::DRM_MODE_ROTATE_90 || r == bindings::DRM_MODE_ROTATE_270
+}
+
+/// True if `rotation` is anything other than the identity (plain 0 deg), i.e. the scanout must
+/// remap every pixel and cannot take the damage-clip fast path.
+fn rotation_active(rotation: u32) -> bool {
+    rotation != 0 && rotation != bindings::DRM_MODE_ROTATE_0
+}
+
+/// Damage bounding box (pixels, clamped to the scanout) for this atomic update, or
+/// `None` meaning "convert the whole frame". Reads the standard `FB_DAMAGE_CLIPS` blob the
+/// compositor attaches to the plane state and unions its rects. The Vino encoder already
+/// shadow-diffs against the previous frame, so unchanged regions emit nothing regardless;
+/// the win here is skipping the XRGB8888->RGB565 conversion of those regions. Returns `None`
+/// when no damage is advertised, `ignore_damage_clips` is set, or the union is degenerate
+/// (all treated as a full-frame update).
+///
+/// SAFETY: `st` must be a valid `drm_plane_state` for the duration of the call.
+unsafe fn damage_bbox(
+    st: *const bindings::drm_plane_state,
+) -> Option<(usize, usize, usize, usize)> {
+    // SAFETY: caller guarantees `st` is a live plane state.
+    let (blob, ignore) = unsafe { ((*st).fb_damage_clips, (*st).ignore_damage_clips) };
+    if ignore || blob.is_null() {
+        return None;
+    }
+    // SAFETY: `blob` is non-null and lives as long as the plane state.
+    let (data, len) = unsafe { ((*blob).data as *const bindings::drm_mode_rect, (*blob).length) };
+    let n = len / core::mem::size_of::<bindings::drm_mode_rect>();
+    if data.is_null() || n == 0 {
+        return None;
+    }
+    let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
+    for i in 0..n {
+        // SAFETY: `i < n`, the rect array length implied by `blob.length`.
+        let r = unsafe { &*data.add(i) };
+        x0 = x0.min(r.x1);
+        y0 = y0.min(r.y1);
+        x1 = x1.max(r.x2);
+        y1 = y1.max(r.y2);
+    }
+    // Clamp to the plane's destination geometry and reject empty/degenerate boxes (fall back to
+    // a full frame). Read crtc_w/crtc_h off the plane state so the clamp tracks the live mode,
+    // not a fixed 1080p.
+    // SAFETY: caller guarantees `st` is a live plane state.
+    let (pw, ph) = unsafe { ((*st).crtc_w as i32, (*st).crtc_h as i32) };
+    let cx0 = x0.clamp(0, pw) as usize;
+    let cy0 = y0.clamp(0, ph) as usize;
+    let cx1 = x1.clamp(0, pw) as usize;
+    let cy1 = y1.clamp(0, ph) as usize;
+    if cx1 <= cx0 || cy1 <= cy0 {
+        return None;
+    }
+    Some((cx0, cy0, cx1, cy1))
+}
+
+/// Read the CRTC's `GAMMA_LUT` and flatten it into three 256-entry 8-bit lookup tables
+/// (R, G, B), or `None` when no gamma is set (the common case -- the conversion then runs at
+/// full speed). DLM gamma-corrects pixels **host-side** before encoding; the DL3 dock has no
+/// gamma CP message (the `NotifyGammaCurve`/`SetGammaMode` handlers are DLM-internal,
+/// vtable-dispatched, and emit no wire frame -- confirmed against the decompile and every
+/// capture), so vino applies the LUT in the scanout exactly like it applies `rotation`. The
+/// blob holds `n` `drm_color_lut` entries (u16 per channel); 8-bit input `i` maps to entry
+/// `i*(n-1)/255` and takes that entry's high 8 bits.
+fn read_gamma_lut(head: &Head) -> Option<[[u8; 256]; 3]> {
+    // SAFETY: `head.crtc` was initialised in `kms_init`; its committed `state` and the
+    // gamma_lut blob it references are valid for the duration of the atomic commit.
+    let blob = unsafe {
+        let cs = (*head.crtc.get()).state;
+        if cs.is_null() {
+            return None;
+        }
+        (*cs).gamma_lut
+    };
+    if blob.is_null() {
+        return None;
+    }
+    // SAFETY: `blob` is a live drm_property_blob for the commit; `data`/`length` are valid.
+    let (ptr, len) =
+        unsafe { ((*blob).data as *const bindings::drm_color_lut, (*blob).length) };
+    let n = len / core::mem::size_of::<bindings::drm_color_lut>();
+    if ptr.is_null() || n == 0 {
+        return None;
+    }
+    let mut t = [[0u8; 256]; 3];
+    for i in 0..256usize {
+        let idx = if n == 1 { 0 } else { i * (n - 1) / 255 };
+        // SAFETY: `idx < n`, within the blob's `n` `drm_color_lut` entries.
+        let e = unsafe { &*ptr.add(idx) };
+        t[0][i] = (e.red >> 8) as u8;
+        t[1][i] = (e.green >> 8) as u8;
+        t[2][i] = (e.blue >> 8) as u8;
+    }
+    Some(t)
+}
+
+/// vmap `fb`, encode it, and push one EP08 frame. Split out so `?` can be used. `damage`
+/// bounds the XRGB8888->RGB565 conversion to the changed region (see [`damage_bbox`]).
+fn scanout_one(
+    data: &VinoDrmData,
+    head: &Head,
+    fb: *mut bindings::drm_framebuffer,
+    w: usize,
+    h: usize,
+    damage: Option<(usize, usize, usize, usize)>,
+    rotation: u32,
+    gamma: Option<&[[u8; 256]; 3]>,
+) -> Result {
+    // `w`/`h` are the plane's destination (displayed) geometry (== the negotiated mode),
+    // threaded in from `primary_atomic_update`, so the scanout follows the live mode (e.g. the
+    // dock's
+    // native 4K) instead of a hardcoded 1080p. `drm_framebuffer` is opaque in the bindings, so
+    // the geometry comes from the plane state; our XRGB8888 buffers are packed. Under a
+    // 90 deg/270 deg
+    // `rotation` the source framebuffer is portrait relative to the display, so its row pitch
+    // tracks the *source* width -- `encode_and_send` derives that from `rotation`.
+    if w == 0 || h == 0 {
+        return Err(EINVAL);
+    }
+
+    // Map the framebuffer's backing pages into the kernel address space.
+    // SAFETY: `iosys_map` is POD (a pointer union + bool); all-zero is a valid,
+    // "not mapped" value that `drm_gem_fb_vmap` overwrites for present planes.
+    let mut map: [bindings::iosys_map; 4] = unsafe { core::mem::zeroed() };
+    let mut dmap: [bindings::iosys_map; 4] = unsafe { core::mem::zeroed() };
+    // SAFETY: `fb` is a valid framebuffer with GEM-backed storage.
+    to_result(unsafe { bindings::drm_gem_fb_vmap(fb, map.as_mut_ptr(), dmap.as_mut_ptr()) })?;
+
+    // SAFETY: plane 0's CPU virtual address, valid until `drm_gem_fb_vunmap`.
+    let vaddr = unsafe { map[0].__bindgen_anon_1.vaddr } as *const u8;
+    let result = if vaddr.is_null() {
+        Err(EINVAL)
+    } else {
+        encode_and_send(data, head, vaddr, w, h, damage, rotation, gamma)
+    };
+
+    // SAFETY: balances the vmap above with the same `map`.
+    unsafe { bindings::drm_gem_fb_vunmap(fb, map.as_mut_ptr()) };
+    result
+}
+
+/// Convert the mapped XRGB8888 frame to RGB565, Vino-encode it against the previous
+/// frame, and bulk-write the resulting EP08 frame to the dock.
+fn encode_and_send(
+    data: &VinoDrmData,
+    head: &Head,
+    vaddr: *const u8,
+    w: usize,
+    h: usize,
+    damage: Option<(usize, usize, usize, usize)>,
+    rotation: u32,
+    gamma: Option<&[[u8; 256]; 3]>,
+) -> Result {
+    // Convert XRGB8888 (LE bytes B,G,R,X) -> RGB565 and encode, all under the scanout lock.
+    // The conversion fills a PERSISTENT `cur` buffer (allocated once with the encoder) in
+    // place -- no per-frame ~4 MiB kmalloc, which is what was failing with ENOMEM and flooding
+    // the log. The encoder's shadow buffer is mutable state, so the lock is needed regardless.
+    let frame = {
+        let mut st = head.scanout.lock();
+        // On the first frame `cur` is freshly zeroed, so the whole buffer must be filled
+        // regardless of the advertised damage (a partial fill would scan out black around
+        // the damage box). Afterwards, unchanged regions of `cur` already hold the previous
+        // frame (== the shadow the encoder diffs against), so converting only the damage box
+        // is correct and skips the rest of the XRGB8888->RGB565 work.
+        // Re-initialise the encoder/shadow/conversion buffers on the first frame AND whenever
+        // the framebuffer geometry changes (a mode switch), so they always match `cur`'s size.
+        let first = st.enc.is_none() || st.dims != (w, h);
+        if first {
+            st.enc = Some(super::video::Encoder::new(w, h, super::video::Mode::Rle)?);
+            st.cur = VVec::from_elem(0u16, w * h, GFP_KERNEL)?;
+            st.dims = (w, h);
+            st.hint = 0; // previous frame's size no longer applies at the new geometry
+        }
+        // Source framebuffer geometry: a 90 deg/270 deg rotation makes the source portrait relative
+        // to the displayed `w`x`h`, so its packed row pitch tracks the *source* width.
+        let (sw, sh) = if rotation_swaps_dims(rotation) { (h, w) } else { (w, h) };
+        let pitch = sw * 4;
+        // Damage clips are in source coordinates and don't map cleanly through a rotation, so
+        // convert the whole frame on a (re)allocation OR whenever a rotation/reflection is in
+        // effect; the encoder still shadow-diffs, so unchanged pixels emit nothing regardless.
+        // A gamma LUT recolours every pixel, so it also forces a full convert (still no extra
+        // wire traffic -- the recoloured output is identical frame-to-frame for static
+        // content,
+        // so the shadow-diff emits nothing for unchanged regions).
+        let full = first || rotation_active(rotation) || gamma.is_some();
+        let (x0, y0, x1, y1) = if full { (0, 0, w, h) } else { damage.unwrap_or((0, 0, w, h)) };
+        // Split-borrow the fields so the in-place fill and the &mut encode can coexist.
+        let ScanoutState { enc, cur, seq, hint, dims: _, active_timing: _ } = &mut *st;
+        for dy in y0..y1 {
+            for dx in x0..x1 {
+                // Output pixel (dx,dy) -> source pixel under the plane rotation/reflection.
+                let (sx, sy) = rot_src(rotation, dx, dy, sw, sh);
+                // SAFETY: `sy*pitch + sx*4 + 3` is within the mapped source framebuffer
+                // (`sw*sh*4` bytes); `rot_src` guarantees `sx < sw`, `sy < sh`.
+                let px =
+                    unsafe { (vaddr.add(sy * pitch + sx * 4) as *const u32).read_unaligned() };
+                let (mut r, mut g, mut b) =
+                    (((px >> 16) & 0xff) as usize, ((px >> 8) & 0xff) as usize, (px & 0xff) as usize);
+                // Apply the CRTC gamma LUT host-side (DLM gamma-corrects pixels before
+                // encoding -- there is no dock-side gamma CP message; see `read_gamma_lut`).
+                if let Some(t) = gamma {
+                    r = t[0][r] as usize;
+                    g = t[1][g] as usize;
+                    b = t[2][b] as usize;
+                }
+                cur[dy * w + dx] =
+                    (((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)) as u16;
+            }
+        }
+        let s = *seq;
+        *seq = seq.wrapping_add(1);
+        let enc = enc.as_mut().ok_or(ENOMEM)?;
+        // Encode straight into the outgoing frame buffer: reserve the EP08 header up
+        // front, append the codec stream in place, then back-patch the header now that
+        // the payload length is known. This replaces a two-allocation/extra-copy path
+        // (encode -> KVec, then frame_to_ep08 -> second KVec) with a single buffer,
+        // and `hint` pre-sizes it from the last frame so the encode rarely reallocates.
+        const HDR: usize = super::video::EP08_HDR_LEN;
+        let mut frame = KVec::with_capacity((*hint).max(HDR + 64), GFP_KERNEL)?;
+        frame.extend_from_slice(&[0u8; HDR], GFP_KERNEL)?; // header placeholder
+        enc.encode_into(&*cur, &mut frame)?;
+        let payload_len = frame.len() - HDR;
+        super::video::write_ep08_header(&mut frame[..HDR], payload_len, s);
+        *hint = frame.len();
+        frame
+    };
+
+    // Push the frame to this head's video endpoint (lock released).
+    let dev: &super::usb::Device = data.intf.as_ref();
+    // First live-scanout frame: clear-halt the four iface-0 bulk-OUT video endpoints
+    // (0x08 main + 0x0a/0x0b/0x0c aux, covering every head) so the first write doesn't
+    // ETIMEDOUT on a stale endpoint toggle. DLM clear-halts these at engagement (the
+    // "startRender" step). Once, globally.
+    if !super::EP08_SCANOUT_PRIMED.swap(true, core::sync::atomic::Ordering::SeqCst) {
+        for ep in [0x08u8, 0x0a, 0x0b, 0x0c] {
+            let _ = dev.clear_halt(ep);
+        }
+        pr_info!("vino: video endpoints primed (clear-halt 8/10/11/12)\n");
+    }
+    // Head 0 -> EP 0x08, head 1 -> EP 0x0a (see `HEAD_EP`).
+    dev.bulk_send(head.video_ep(), &frame, super::timeout())?;
+    Ok(())
+}
+
+/// Send the cursor CP messages for the current sprite + position (called from
+/// [`cursor_atomic_update`]). `fb` is the cursor sprite framebuffer (`None`/null = hidden),
+/// `w`x`h` its size, `(cx, cy)` the on-CRTC position. Sends `create` (once, the constant
+/// sprite size), `image` (the ARGB8888 sprite -- its little-endian memory bytes are already
+/// the
+/// `B,G,R,A` order the dock wants, copied row-by-row to honour the framebuffer pitch), then a
+/// `move`. Every send routes through [`VinoDrmData::send_cp`], so all of this is a no-op until
+/// the CP cipher engages (the wall). A hidden cursor currently just re-issues a move (a
+/// dedicated hide message is a future refinement).
+fn cursor_send(
+    data: &VinoDrmData,
+    head: &Head,
+    fb: *mut bindings::drm_framebuffer,
+    w: usize,
+    h: usize,
+    cx: i32,
+    cy: i32,
+) -> Result {
+    let hid = head.index; // cursor messages carry the head id at off22; CP routes by head too
+    let (mx, my) = (
+        cx.clamp(0, u16::MAX as i32) as u16,
+        cy.clamp(0, u16::MAX as i32) as u16,
+    );
+    if fb.is_null() || w == 0 || h == 0 {
+        // Hidden cursor: no sprite to upload, just track the position.
+        return data.send_cp(hid, 0x1a, 0, |ctr| super::cp::cursor_move(ctr, hid, mx, my));
+    }
+    // Declare the sprite dimensions once per head, then upload the bitmap. We don't diff sprite
+    // content yet, so the image is re-sent on every sprite-present update.
+    if !head.cursor_primed.swap(true, core::sync::atomic::Ordering::SeqCst) {
+        data.send_cp(hid, 0x1b, 0, |ctr| super::cp::cursor_create(ctr, w as u16, h as u16))?;
+    }
+    // vmap the sprite; copy `w*h*4` BGRA bytes row-by-row (the source pitch is `w*4` for our
+    // packed cursor buffers, but copying per-row keeps it correct if that ever changes).
+    let pitch = w * 4;
+    // SAFETY: `iosys_map` is POD; all-zero is a valid "not mapped" value `drm_gem_fb_vmap`
+    // fills.
+    let mut map: [bindings::iosys_map; 4] = unsafe { core::mem::zeroed() };
+    let mut dmap: [bindings::iosys_map; 4] = unsafe { core::mem::zeroed() };
+    // SAFETY: `fb` is a valid cursor framebuffer with GEM-backed storage.
+    to_result(unsafe { bindings::drm_gem_fb_vmap(fb, map.as_mut_ptr(), dmap.as_mut_ptr()) })?;
+    // SAFETY: plane 0's CPU virtual address, valid until `drm_gem_fb_vunmap`.
+    let vaddr = unsafe { map[0].__bindgen_anon_1.vaddr } as *const u8;
+    let res = if vaddr.is_null() {
+        Err(EINVAL)
+    } else {
+        (|| -> Result {
+            let mut bgra = KVec::with_capacity(w * h * 4, GFP_KERNEL)?;
+            for y in 0..h {
+                // SAFETY: `[y*pitch, y*pitch + w*4)` is within the mapped `h*pitch` sprite.
+                let row = unsafe { core::slice::from_raw_parts(vaddr.add(y * pitch), w * 4) };
+                bgra.extend_from_slice(row, GFP_KERNEL)?;
+            }
+            data.send_cp(hid, 0x1c, 0, |ctr| {
+                super::cp::cursor_image(ctr, w as u16, h as u16, &bgra)
+            })
+        })()
+    };
+    // SAFETY: balances the vmap above with the same `map`.
+    unsafe { bindings::drm_gem_fb_vunmap(fb, map.as_mut_ptr()) };
+    res?;
+    data.send_cp(hid, 0x1a, 0, |ctr| super::cp::cursor_move(ctr, hid, mx, my))
+}
+
+/// Wire up the atomic KMS pipeline on `ddev` (called after `drm::Device::new` and
+/// before `drm_dev_register`). Sets `mode_config`, builds the virtual connector,
+/// and initialises the atomic CRTC + primary/cursor planes + virtual encoder.
+pub(super) fn kms_init<C: drm::DeviceContext>(
+    ddev: &drm::Device<VinoDrmDriver, C>,
+) -> Result {
+    let raw = ddev.as_raw();
+    // Deref `drm::Device<T>` -> `T::Data` to reach the embedded C objects.
+    let data: &VinoDrmData = ddev;
+
+    // SAFETY: `raw` is a valid, not-yet-registered drm_device; the funcs/objects
+    // referenced below live in device-private memory (`data`) for its lifetime.
+    unsafe {
+        to_result(bindings::drmm_mode_config_init(raw))?;
+
+        let mc = &mut (*raw).mode_config;
+        mc.min_width = 0;
+        mc.min_height = 0;
+        mc.max_width = 4096;
+        mc.max_height = 4096;
+        // Advertise a 64x64 hardware cursor (the dock's cursor sprite size) so userspace
+        // drives the cursor plane instead of compositing the pointer into the framebuffer.
+        mc.cursor_width = CURSOR_SIZE;
+        mc.cursor_height = CURSOR_SIZE;
+        let mcf = data.mode_cfg_funcs.get();
+        (*mcf).fb_create = Some(bindings::drm_gem_fb_create);
+        // `vino_atomic_check` = the standard atomic check + the combined cross-head USB
+        // bandwidth budget (rejects e.g. two simultaneous 4K modes).
+        (*mcf).atomic_check = Some(vino_atomic_check);
+        (*mcf).atomic_commit = Some(bindings::drm_atomic_helper_commit);
+        mc.funcs = mcf;
+
+        // ---- Shared vtables (one set for every head; the callbacks recover the head from
+        // the C object pointer). Plane/CRTC `atomic_check` are left NULL: a virtual sink
+        // accepts any configuration, and the helpers still invoke `atomic_update`/
+        // `atomic_enable` because the objects are assigned to the CRTC.
+
+        // Connector funcs + helper. We report presence from the cached EDID (see `detect`)
+        // and deliver HPD ourselves (`set_edid`/`fire_hotplug`).
+        let cf = data.conn_funcs.get();
+        (*cf).fill_modes = Some(bindings::drm_helper_probe_single_connector_modes);
+        (*cf).detect = Some(detect);
+        (*cf).destroy = Some(bindings::drm_connector_cleanup);
+        (*cf).reset = Some(bindings::drm_atomic_helper_connector_reset);
+        (*cf).atomic_duplicate_state =
+            Some(bindings::drm_atomic_helper_connector_duplicate_state);
+        (*cf).atomic_destroy_state =
+            Some(bindings::drm_atomic_helper_connector_destroy_state);
+        (*data.conn_helper.get()).get_modes = Some(get_modes);
+        // Prune any single mode above the per-head pixel-clock ceiling (~4K@60).
+        (*data.conn_helper.get()).mode_valid = Some(mode_valid);
+
+        // One `drm_plane_funcs` shared by both planes; per-plane helper funcs (the primary's
+        // `atomic_update` scans out, the cursor's sends cursor CP).
+        let plf = data.plane_funcs.get();
+        (*plf).update_plane = Some(bindings::drm_atomic_helper_update_plane);
+        (*plf).disable_plane = Some(bindings::drm_atomic_helper_disable_plane);
+        (*plf).destroy = Some(bindings::drm_plane_cleanup);
+        (*plf).reset = Some(bindings::drm_atomic_helper_plane_reset);
+        (*plf).atomic_duplicate_state =
+            Some(bindings::drm_atomic_helper_plane_duplicate_state);
+        (*plf).atomic_destroy_state = Some(bindings::drm_atomic_helper_plane_destroy_state);
+        (*data.primary_helper.get()).atomic_update = Some(primary_atomic_update);
+        (*data.cursor_helper.get()).atomic_update = Some(cursor_atomic_update);
+
+        // CRTC funcs + helper.
+        let crf = data.crtc_funcs.get();
+        (*crf).set_config = Some(bindings::drm_atomic_helper_set_config);
+        (*crf).page_flip = Some(bindings::drm_atomic_helper_page_flip);
+        (*crf).destroy = Some(bindings::drm_crtc_cleanup);
+        (*crf).reset = Some(bindings::drm_atomic_helper_crtc_reset);
+        (*crf).atomic_duplicate_state =
+            Some(bindings::drm_atomic_helper_crtc_duplicate_state);
+        (*crf).atomic_destroy_state = Some(bindings::drm_atomic_helper_crtc_destroy_state);
+        let crh = data.crtc_helper.get();
+        (*crh).atomic_enable = Some(crtc_atomic_enable);
+        (*crh).atomic_disable = Some(crtc_atomic_disable);
+
+        // Encoder funcs.
+        (*data.encoder_funcs.get()).destroy = Some(bindings::drm_encoder_cleanup);
+
+        // Build each head's objects (connector + primary/cursor planes + CRTC + encoder).
+        for head in data.heads() {
+            build_head(raw, data, head)?;
+        }
+
+        drm_mode_config_reset(raw);
+    }
+    Ok(())
+}
+
+/// Build one head's KMS objects -- connector + primary plane (scanout) + cursor plane + CRTC +
+/// virtual encoder -- using the shared vtables already filled in `data`. Each is a complete
+/// independent output (its own CRTC), so the compositor sees [`NHEADS`] monitors and routes
+/// each to its own video EP / CP stream (see [`Head`]).
+///
+/// SAFETY: `raw` is a valid, not-yet-registered drm_device; the `data`/`head` C objects live
+/// in device-private memory for its lifetime.
+unsafe fn build_head(raw: *mut bindings::drm_device, data: &VinoDrmData, head: &Head) -> Result {
+    // SAFETY: see the function contract; every object/vtable below is device-private memory.
+    unsafe {
+        // Connector.
+        let conn = head.connector.get();
+        to_result(bindings::drm_connector_init(
+            raw,
+            conn,
+            data.conn_funcs.get(),
+            bindings::DRM_MODE_CONNECTOR_VIRTUAL as i32,
+        ))?;
+        (*conn).helper_private = data.conn_helper.get();
+        (*conn).polled = bindings::DRM_CONNECTOR_POLL_HPD as u8;
+
+        // Primary plane (XRGB8888 scanout). `possible_crtcs` is fixed up once the CRTC exists.
+        let primary = head.primary.get();
+        to_result(bindings::drm_universal_plane_init(
+            raw,
+            primary,
+            0,
+            data.plane_funcs.get(),
+            PRIMARY_FORMATS.as_ptr(),
+            PRIMARY_FORMATS.len() as u32,
+            ptr::null(),
+            bindings::drm_plane_type_DRM_PLANE_TYPE_PRIMARY,
+            ptr::null(),
+        ))?;
+        (*primary).helper_private = data.primary_helper.get();
+
+        // Cursor plane (ARGB8888 sprite).
+        let cursor = head.cursor.get();
+        to_result(bindings::drm_universal_plane_init(
+            raw,
+            cursor,
+            0,
+            data.plane_funcs.get(),
+            CURSOR_FORMATS.as_ptr(),
+            CURSOR_FORMATS.len() as u32,
+            ptr::null(),
+            bindings::drm_plane_type_DRM_PLANE_TYPE_CURSOR,
+            ptr::null(),
+        ))?;
+        (*cursor).helper_private = data.cursor_helper.get();
+
+        // CRTC with both planes, plus a GAMMA_LUT (applied host-side in `read_gamma_lut`).
+        let crtc = head.crtc.get();
+        to_result(bindings::drm_crtc_init_with_planes(
+            raw,
+            crtc,
+            primary,
+            cursor,
+            data.crtc_funcs.get(),
+            ptr::null(),
+        ))?;
+        (*crtc).helper_private = data.crtc_helper.get();
+        bindings::drm_crtc_enable_color_mgmt(crtc, 0, false, GAMMA_SIZE);
+
+        // The CRTC now has an index: bind both planes and the encoder to it.
+        let crtc_mask = 1u32 << (*crtc).index;
+        (*primary).possible_crtcs = crtc_mask;
+        (*cursor).possible_crtcs = crtc_mask;
+
+        // Virtual encoder bound to this head's connector.
+        let encoder = head.encoder.get();
+        to_result(bindings::drm_encoder_init(
+            raw,
+            encoder,
+            data.encoder_funcs.get(),
+            bindings::DRM_MODE_ENCODER_VIRTUAL as i32,
+            ptr::null(),
+        ))?;
+        (*encoder).possible_crtcs = crtc_mask;
+        to_result(bindings::drm_connector_attach_encoder(conn, encoder))?;
+
+        // Rotation property on the primary plane (DLM rotates host-side; vino remaps in the
+        // scanout encode -- see `rot_src`). Canonical helper; non-fatal on failure.
+        let supported = bindings::DRM_MODE_ROTATE_0
+            | bindings::DRM_MODE_ROTATE_90
+            | bindings::DRM_MODE_ROTATE_180
+            | bindings::DRM_MODE_ROTATE_270
+            | bindings::DRM_MODE_REFLECT_X
+            | bindings::DRM_MODE_REFLECT_Y;
+        let rc = bindings::drm_plane_create_rotation_property(
+            primary,
+            bindings::DRM_MODE_ROTATE_0,
+            supported,
+        );
+        if rc != 0 {
+            pr_warn!("vino: head{} rotation property unavailable ({rc})\n", head.index);
+        }
+    }
+    Ok(())
+}
+
+/// Thin wrapper so the `unsafe` block above reads cleanly.
+unsafe fn drm_mode_config_reset(raw: *mut bindings::drm_device) {
+    // SAFETY: `raw` is a valid drm_device with mode_config initialised.
+    unsafe { bindings::drm_mode_config_reset(raw) };
+}
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index e9e6324b717b..1091dcc992c7 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -44,6 +44,7 @@
 use kernel::{
     alloc::flags::GFP_KERNEL,
     bindings,
+    drm,
     device::{self, Core},
     error::code::{ENODEV, EINVAL},
     prelude::*,
@@ -79,6 +80,24 @@ fn timeout() -> Delta {
 /// hardware -- the dock runs the whole plaintext handshake but never engages the encrypted CP.
 static CP_ENGAGED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
 
+/// One-shot: clear-halt + prime the video endpoints before the first live-scanout EP08 write.
+static EP08_SCANOUT_PRIMED: core::sync::atomic::AtomicBool =
+    core::sync::atomic::AtomicBool::new(false);
+
+/// Consecutive failed live-scanout frames, for log rate-limiting. Until CP engages, the dock
+/// NAKs every EP08 write (EPROTO), so without this every compositor pageflip would spam dmesg.
+static SCANOUT_FAILS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
+
+/// Pageflip throttle: number of upcoming pageflips to skip before the next scanout attempt
+/// (a backoff while the dock NAKs). A single successful frame clears it.
+static SCANOUT_SKIP: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
+
+/// Set once the bring-up work item finishes (AKE/CP attempt done). `detect` only connects the
+/// live-scanout connector AFTER this, so a compositor enabling the output cannot start EP08
+/// scanout on top of the still-running AKE on the same USB device.
+static BRINGUP_COMPLETE: core::sync::atomic::AtomicBool =
+    core::sync::atomic::AtomicBool::new(false);
+
 mod proto;
 mod crypto;
 mod rng;
@@ -103,9 +122,13 @@ struct Session {
     cap_announce: KVec<u8>,
 }
 
+mod drm_sink;
+
 /// Per-bound-interface driver state.
 struct VinoDriver {
     _intf: ARef<usb::Interface>,
+    /// The registered `drm::Device` (only on the control interface, iface 0).
+    _ddev: Option<ARef<drm_sink::VinoDrmDevice>>,
 }
 
 /// Deferred bring-up work item: the bring-up sequence run on the system workqueue instead
@@ -115,6 +138,7 @@ struct VinoDriver {
 #[pin_data]
 struct BringUp {
     intf: ARef<usb::Interface>,
+    ddev: Option<ARef<drm_sink::VinoDrmDevice>>,
     #[pin]
     work: Work<BringUp>,
 }
@@ -124,10 +148,14 @@ impl HasWork<Self> for BringUp { self.work }
 }
 
 impl BringUp {
-    fn new(intf: ARef<usb::Interface>) -> Result<Arc<Self>> {
+    fn new(
+        intf: ARef<usb::Interface>,
+        ddev: Option<ARef<drm_sink::VinoDrmDevice>>,
+    ) -> Result<Arc<Self>> {
         Arc::pin_init(
             pin_init!(BringUp {
                 intf,
+                ddev,
                 work <- new_work!("vino::bring_up"),
             }),
             GFP_KERNEL,
@@ -141,39 +169,73 @@ impl WorkItem for BringUp {
     fn run(this: Arc<BringUp>) {
         let cdev: &device::Device = this.intf.as_ref();
         let dev: &usb::Device = this.intf.as_ref();
-        // WIP scaffold: plaintext bring-up, the clean-room HDCP 2.2 AKE/LC/SKE, then the
-        // post-SKE CP setup. Bind regardless of the outcome -- there is no display path until
-        // the dock engages the encrypted control plane, which it currently never does (see the
-        // "help wanted" note at the top of the file). The DRM sink lands in a later patch.
+        let ddev = &this.ddev;
+        // WIP scaffold: attempt the plaintext bring-up, then the clean-room HDCP 2.2
+        // AKE/LC/SKE, then the post-SKE CP setup. Bind regardless of the outcome -- there
+        // is no display path until the dock engages the encrypted control plane, which it
+        // currently never does (see the "help wanted" note at the top of the file).
         match VinoDriver::bring_up(dev) {
             Ok(()) => {
                 dev_info!(cdev, "vino: plaintext session init OK\n");
                 match VinoDriver::run_ake(dev) {
                     Ok(session) => {
                         dev_info!(cdev, "vino: HDCP AKE + LC + SKE complete (session keyed)\n");
+                        // Dev diagnostic: the live session key/riv, so the dock's encrypted
+                        // EP84 replies can be decoded offline from a usbmon capture. Behind
+                        // pr_debug, so compiled out unless dynamic debug is enabled.
                         pr_debug!("vino: SESSION ks={:02x?} riv={:02x?}\n", &session.ks, &session.riv);
-                        // Phase 2c: drive the post-SKE CP setup. send_cp_setup re-seals DLM's
-                        // captured setup template under THIS session's live ks/riv and sends it;
-                        // `acks` counts the dock's encrypted wsub=0x45 replies. THIS IS THE WALL:
-                        // on a cold dock `acks` stays 0 -- the dock runs the entire plaintext
-                        // handshake but never engages the encrypted CP.
+
+                        // Phase 2c: drive the post-SKE CP setup. send_cp_setup re-seals
+                        // DLM's captured setup template under THIS session's live ks/riv and
+                        // sends it; `acks` counts the dock's encrypted wsub=0x45 replies.
+                        // THIS IS THE WALL: on a cold dock `acks` stays 0 -- the dock runs the
+                        // entire plaintext handshake but never engages the encrypted CP.
                         let mut edid_out: Option<KVec<u8>> = None;
                         match VinoDriver::send_cp_setup(dev, &session, &mut edid_out) {
-                            Ok((n, acks, _wseq_end, _ctr_end)) => {
+                            Ok((n, acks, wseq_end, ctr_end)) => {
                                 dev_info!(cdev,
                                     "vino: CP setup sent -- {n} messages, {acks} dock CP acks (wsub=0x45)\n");
-                                // CP engagement gates EP08 video (added in a later patch): until
-                                // the dock acks, pushing pixels at it wedges the hub.
+                                // CP engagement gates EP08 video: until the dock acks, pushing
+                                // pixels at it wedges the hub.
                                 CP_ENGAGED.store(acks > 0, core::sync::atomic::Ordering::SeqCst);
+                                // Publish the engaged session to the DRM device so the KMS
+                                // callbacks
+                                // can send runtime CP (mode-set on a modeset, cursor on motion),
+                                // continuing this keystream. Only when the dock actually engaged.
+                                if acks > 0 {
+                                    if let Some(d) = ddev.as_ref() {
+                                        let data: &drm_sink::VinoDrmData = d;
+                                        data.publish_session(
+                                            &session.ks, &session.riv, wseq_end, ctr_end,
+                                        );
+                                    }
+                                }
                             }
                             Err(e) => dev_info!(cdev, "vino: CP setup incomplete ({e:?}) -- WIP\n"),
                         }
+                        // Cache the dock's EDID on the DRM device (when the CP channel
+                        // delivered it) so the connector's get_modes installs the real
+                        // monitor descriptor via the standard DRM EDID helpers.
+                        if let (Some(blob), Some(d)) = (edid_out, ddev.as_ref()) {
+                            let n = blob.len();
+                            let data: &drm_sink::VinoDrmData = d;
+                            data.set_edid(blob);
+                            dev_info!(cdev, "vino: cached dock EDID for connector ({n} bytes)\n");
+                        }
                     }
                     Err(e) => dev_info!(cdev, "vino: HDCP AKE incomplete ({e:?}) -- WIP\n"),
                 }
             }
             Err(e) => dev_info!(cdev, "vino: session init incomplete ({e:?}) -- WIP\n"),
         }
+        // Bring-up attempt finished: allow the live-scanout connector to report connected
+        // and let a compositor drive EP08 frames, without racing the handshake.
+        BRINGUP_COMPLETE.store(true, core::sync::atomic::Ordering::SeqCst);
+        if let Some(d) = ddev.as_ref() {
+            let data: &drm_sink::VinoDrmData = d;
+            data.fire_hotplug();
+            dev_info!(cdev, "vino: bring-up complete -- live-scanout connector now connected\n");
+        }
     }
 }
 
@@ -1596,16 +1658,59 @@ fn probe<'bound>(
                 return Err(ENODEV);
             }
             dev_info!(cdev, "vino: bound D6000 interface {ifnum} (idle -- control is iface 0)\n");
-            return Ok(Self { _intf: intf.into() });
+            return Ok(Self { _intf: intf.into(), _ddev: None });
         }
         dev_info!(cdev, "vino: bound DisplayLink D6000 -- plaintext session bring-up\n");
 
-        // Bring-up is blocking synchronous USB I/O; hand it to the system workqueue so
-        // probe() returns immediately and userspace stays responsive. The work item holds
-        // a refcounted handle to the interface, so the bulk endpoints outlive probe(); USB
-        // I/O after an intervening disconnect simply errors and is logged.
+        // Phase 3: register a real DRM/KMS device on the control interface so the dock
+        // shows up as a mode-settable `card`/`renderD` node (atomic KMS via the simple
+        // display pipe, one 1080p virtual connector, GEM-shmem dumb buffers). Non-fatal:
+        // bring-up still proceeds (and the interface still binds) if any step fails, so
+        // a DRM-core hiccup can't regress the USB session work.
+        // Hold a refcounted handle to the bound interface; one copy goes into the DRM
+        // device-private (for the EP08 scanout path), one stays in `VinoDriver`.
         let intf_ref: ARef<usb::Interface> = intf.into();
-        match BringUp::new(intf_ref.clone()) {
+        // DRM device lifecycle (drm-rust API): allocate an `UnregisteredDevice`, wire up
+        // the KMS pipeline on it while still unregistered, then hand it to
+        // `Registration::new_foreign_owned` (which registers it and ties its lifetime to
+        // the bound USB device via devres, returning a borrowed `&Device`).
+        let ddev: Option<ARef<drm_sink::VinoDrmDevice>> =
+            match drm::UnregisteredDevice::<drm_sink::VinoDrmDriver>::new(
+                cdev,
+                drm_sink::VinoDrmData::new(intf_ref.clone()),
+            ) {
+                Ok(unreg) => match drm_sink::kms_init(&unreg) {
+                    Ok(()) => match drm::driver::Registration::new_foreign_owned(unreg, cdev, 0) {
+                        Ok(reg_dev) => {
+                            dev_info!(cdev, "vino: DRM+KMS device registered (card node live, 1080p)\n");
+                            Some(reg_dev.into())
+                        }
+                        Err(e) => {
+                            dev_info!(cdev, "vino: DRM registration failed ({e:?}) -- continuing without card node\n");
+                            None
+                        }
+                    },
+                    Err(e) => {
+                        dev_info!(cdev, "vino: KMS init failed ({e:?}) -- continuing without card node\n");
+                        None
+                    }
+                },
+                Err(e) => {
+                    dev_info!(cdev, "vino: drm::UnregisteredDevice::new failed ({e:?}) -- continuing\n");
+                    None
+                }
+            };
+
+        // Bring-up (preamble + HDCP AKE + ~6 s of lockstep CP replay) is all blocking
+        // synchronous USB I/O. Running it inline here pins the USB driver-model probe
+        // thread while the DRM card node is already registered and live, which stalled
+        // the compositor (KWin) on first plug until the dock was physically yanked. Hand
+        // it to the system workqueue so `probe()` returns immediately and userspace KMS
+        // stays responsive. The work item holds refcounted handles to the interface (for
+        // the bulk endpoints) and the DRM device (for EDID caching), so they outlive
+        // `probe()`; USB I/O after an intervening disconnect simply errors and is logged,
+        // exactly like any other failed bring-up step.
+        match BringUp::new(intf_ref.clone(), ddev.clone()) {
             Ok(work) => {
                 let _ = workqueue::system().enqueue(work);
                 dev_info!(cdev, "vino: bring-up queued on system workqueue\n");
@@ -1613,7 +1718,7 @@ fn probe<'bound>(
             Err(e) => dev_info!(cdev, "vino: failed to queue bring-up ({e:?}) -- WIP\n"),
         }
 
-        Ok(Self { _intf: intf_ref })
+        Ok(Self { _intf: intf_ref, _ddev: ddev })
     }
 
     fn disconnect<'bound>(intf: &'bound usb::Interface<Core<'_>>, _data: Pin<&Self>) {
-- 
2.54.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH 6/7] drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info
  2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
                   ` (4 preceding siblings ...)
  2026-06-17 15:12 ` [RFC PATCH 5/7] drm/vino: register a DRM/KMS device and scan out to EP08 Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
  2026-06-17 20:19   ` sashiko-bot
  2026-06-17 15:12 ` [RFC PATCH 7/7] drm/vino: add KUnit self-tests for the protocol and crypto paths Mike Lothian
                   ` (2 subsequent siblings)
  8 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
  To: dri-devel
  Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
	linux-kernel

Add monitor controls that ride the same control plane:

 - DDC/CI Set-VCP builders (cp): brightness (VCP 0x10), contrast (0x12)
   and power mode (0xD6), tunnelled to the downstream monitor's I2C slave
   0x37 over the dock's monitor-I2C bridge;
 - DRM connector range properties (drm_sink): a 0..=100 brightness and
   contrast property per connector, whose atomic_set_property callback
   pushes a DDC/CI Set-VCP write to the monitor;
 - DPMS power (drm_sink): the CRTC enable/disable hooks emit VCP 0xD6
   on/off so a blanked output drops the monitor to standby instead of
   freezing the last frame.

All are no-ops until the CP cipher engages (the open blocker), so they
are inert on current hardware but correct by construction (the DDC/CI
payloads are unit-tested against the VESA MCCS worked examples).

Also query the dock's DFU device info at bring-up (firmware version,
customer/board id) -- device-level vendor reads independent of the CP
channel, useful for diagnostics and confirming the dock firmware
revision.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
 drivers/gpu/drm/vino/cp.rs       |  51 +++++++++++
 drivers/gpu/drm/vino/drm_sink.rs | 141 ++++++++++++++++++++++++++++++-
 drivers/gpu/drm/vino/vino.rs     |  18 ++++
 3 files changed, 206 insertions(+), 4 deletions(-)

diff --git a/drivers/gpu/drm/vino/cp.rs b/drivers/gpu/drm/vino/cp.rs
index 2668931d8500..be2bdcf5557c 100644
--- a/drivers/gpu/drm/vino/cp.rs
+++ b/drivers/gpu/drm/vino/cp.rs
@@ -112,6 +112,57 @@ pub(super) fn set_mode(counter: u16, t: &Timing) -> Result<KVec<u8>> {
     Ok(b)
 }
 
+/// Standard VESA MCCS (Monitor Control Command Set 2.2) VCP feature codes, driven over
+/// DDC/CI. The macOS DisplayLink agent exposes these as per-display brightness/contrast
+/// ("Popover did show -- starting DDC/CI communication", `setBrightness`/`setContrast`); the
+/// dock bridges the DDC/CI transaction to the downstream monitor's I2C slave 0x37 -- the same
+/// monitor-I2C path the EDID read ([`get_edid_req`]) uses for the 0x50 EDID slave.
+pub(super) const VCP_BRIGHTNESS: u8 = 0x10;
+pub(super) const VCP_CONTRAST: u8 = 0x12;
+/// VCP 0xD6 "Power mode": value 0x01 = on, 0x04 = off (DPMS-off / hard standby). Lets DPMS
+/// blank the panel backlight instead of freezing the last frame (see [`crtc_atomic_disable`]).
+pub(super) const VCP_POWER_MODE: u8 = 0xd6;
+pub(super) const POWER_ON: u16 = 0x01;
+pub(super) const POWER_OFF: u16 = 0x04;
+
+/// Build a DDC/CI "Set VCP Feature" request: the 7 bytes a DDC/CI host writes to the
+/// monitor's I2C slave 0x37, after the 0x6e (= 0x37<<1) write address (VESA DDC/CI 1.1
+/// sec 4.4). Layout: source 0x51, length `0x80 | 4`, opcode 0x03 (Set VCP), VCP code,
+/// value-hi, value-lo, then an XOR checksum seeded with the destination address 0x6e. Pure
+/// and fully standard, so it is unit-tested byte-exact against the spec
+/// ([`super::tests::ddc_ci_set_vcp_checksum`]).
+pub(super) fn ddc_ci_set_vcp(vcp: u8, value: u16) -> [u8; 7] {
+    let body = [0x51u8, 0x84, 0x03, vcp, (value >> 8) as u8, value as u8];
+    let mut chk = 0x6eu8; // checksum seed = destination slave-write address (0x37 << 1)
+    for &x in &body {
+        chk ^= x;
+    }
+    [body[0], body[1], body[2], body[3], body[4], body[5], chk]
+}
+
+/// CP message that tunnels a DDC/CI Set-VCP write to the downstream monitor -- the brightness,
+/// contrast and DPMS-power controls the macOS/Windows agents drive over "DDC/CI communication".
+/// The dock's monitor-I2C bridge is the same one the EDID read uses, so this is modelled as the
+/// WRITE companion to the `0x15/0x21` EDID read: `id=0x15 sub=0x22`, carrying the I2C slave
+/// (0x37) + payload length at off20 and the 7-byte DDC/CI Set-VCP payload at off22.
+///
+/// The `id`/`sub` and payload offset are **inferred** from the EDID-read pairing -- the write
+/// transaction was never captured (it only fires once a monitor is actively driven, i.e. past
+/// the CP wall), so re-check against a capture once CP engages. The DDC/CI bytes themselves
+/// ([`ddc_ci_set_vcp`]) are standard and verified.
+pub(super) fn ddc_set_vcp(counter: u16, vcp: u8, value: u16) -> Result<KVec<u8>> {
+    let payload = ddc_ci_set_vcp(vcp, value);
+    let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
+    header(&mut b, 0x15, 0x22, counter)?;
+    pad_to(&mut b, 20)?;
+    // off20: monitor DDC/CI I2C slave (0x37) + DDC/CI payload length.
+    b.extend_from_slice(&[0x37, payload.len() as u8], GFP_KERNEL)?;
+    // off22: the DDC/CI Set-VCP bytes (same off22 convention as the EDID payload).
+    b.extend_from_slice(&payload, GFP_KERNEL)?;
+    pad_to(&mut b, 32)?;
+    Ok(b)
+}
+
 /// EDID base-block sanity check: length, the `00 FF..FF 00` magic, and the 1-byte
 /// checksum (all 128 base bytes sum to 0 mod 256). A corrupt blob must never drive a
 /// mode-set, so [`timing_from_edid`] rejects anything that fails this.
diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
index afbf883fba36..bcc871958a8a 100644
--- a/drivers/gpu/drm/vino/drm_sink.rs
+++ b/drivers/gpu/drm/vino/drm_sink.rs
@@ -129,6 +129,11 @@ pub(super) struct Head {
     /// One-shot: this head's cursor `create` (sprite dimensions) was sent before its first
     /// image upload (per head -- the global one would skip head 1's create).
     cursor_primed: core::sync::atomic::AtomicBool,
+    /// Last DDC/CI brightness/contrast (0..=100) set on this head's monitor via the connector
+    /// properties; replayed on DPMS-on. Stored here (not in connector state) because DDC/CI is
+    /// a side-band action on the physical monitor, not part of the atomic scanout pipeline.
+    brightness: core::sync::atomic::AtomicU32,
+    contrast: core::sync::atomic::AtomicU32,
     #[pin]
     scanout: Mutex<ScanoutState>,
     /// This head's downstream-monitor EDID (`None` until the CP channel delivers it). Only
@@ -167,6 +172,8 @@ fn z<T>() -> impl PinInit<Opaque<T>, Error> {
         try_pin_init!(Self {
             index,
             cursor_primed: core::sync::atomic::AtomicBool::new(false),
+            brightness: core::sync::atomic::AtomicU32::new(100),
+            contrast: core::sync::atomic::AtomicU32::new(100),
             scanout <- new_mutex!(ScanoutState {
                 enc: None,
                 cur: VVec::new(),
@@ -239,6 +246,12 @@ pub(super) struct VinoDrmData {
     encoder_funcs: Opaque<bindings::drm_encoder_funcs>,
     #[pin]
     mode_cfg_funcs: Opaque<bindings::drm_mode_config_funcs>,
+    /// The custom 0..=100 connector range properties for DDC/CI brightness/contrast, created
+    /// and attached in [`kms_init`]. Stored so the connector `atomic_set_property` /
+    /// `atomic_get_property` callbacks can identify them by pointer. Written once during
+    /// single-threaded probe, read-only thereafter (`AtomicPtr` for `Sync` without `unsafe`).
+    brightness_prop: core::sync::atomic::AtomicPtr<bindings::drm_property>,
+    contrast_prop: core::sync::atomic::AtomicPtr<bindings::drm_property>,
 }
 
 // SAFETY: the embedded C KMS objects are written only during single-threaded
@@ -278,6 +291,8 @@ fn z<T>() -> impl PinInit<Opaque<T>, Error> {
             cursor_helper <- z(),
             encoder_funcs <- z(),
             mode_cfg_funcs <- z(),
+            brightness_prop: core::sync::atomic::AtomicPtr::new(ptr::null_mut()),
+            contrast_prop: core::sync::atomic::AtomicPtr::new(ptr::null_mut()),
         })
     }
 
@@ -365,6 +380,14 @@ pub(super) fn send_cp(
         link.counter = link.counter.wrapping_add(1);
         Ok(())
     }
+
+    /// Push a DDC/CI Set-VCP write to a head's downstream monitor (brightness, contrast or
+    /// DPMS power). Wraps [`super::cp::ddc_set_vcp`] (`id=0x15`); a no-op until the cipher is
+    /// engaged. Used by the brightness/contrast connector properties and by DPMS in the CRTC
+    /// enable/disable callbacks.
+    pub(super) fn set_vcp(&self, head_index: u8, vcp: u8, value: u16) -> Result {
+        self.send_cp(head_index, 0x15, 0, |ctr| super::cp::ddc_set_vcp(ctr, vcp, value))
+    }
 }
 
 /// GEM object inner data. Empty: the shmem-backed `drm::gem::shmem::Object` (which
@@ -637,6 +660,11 @@ fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 {
     if let Err(e) = data.send_cp(head.index, 0x48, 16, |ctr| super::cp::set_mode(ctr, &timing)) {
         pr_warn!("vino: head{} runtime mode-set send failed ({e:?})\n", head.index);
     }
+    // Bring the monitor out of DPMS standby (DDC/CI VCP 0xD6 = on). Inferred wire (see
+    // `cp::ddc_set_vcp`); a no-op until CP engages, and re-applies the user's brightness.
+    let _ = data.set_vcp(head.index, super::cp::VCP_POWER_MODE, super::cp::POWER_ON);
+    let b = head.brightness.load(core::sync::atomic::Ordering::Relaxed);
+    let _ = data.set_vcp(head.index, super::cp::VCP_BRIGHTNESS, b as u16);
 }
 
 /// CRTC `.atomic_disable`: the display is turning off.
@@ -646,10 +674,12 @@ fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 {
 /// re-enable (DPMS-on) re-inits the encoder and sends a **full keyframe** rather than diffing
 /// against a shadow the dock may have dropped while blanked, and re-uploads the cursor sprite.
 ///
-/// The dock holds the last frame when video stops (it has its own scanout buffer), so the
-/// monitor freezes the last image rather than going black; a true backlight-standby would need
-/// a dock power command that is not decoded (DLM's `Standby`/`Suspend`/`TempPowerOff` are
-/// internal, vtable-dispatched events with no wire frame -- the same dead-end as gamma).
+/// The dock holds the last frame when video stops (it has its own scanout buffer), so video
+/// alone freezes the last image rather than going black. To actually blank the panel we send a
+/// DDC/CI power-off (VCP 0xD6 = off) to the monitor over the same monitor-I2C bridge the EDID
+/// read uses -- the standard MCCS power control the macOS/Windows agents drive (DLM's
+/// `Standby`/`Suspend`/`TempPowerOff` are the host-internal names for it). Inferred wire (see
+/// [`super::cp::ddc_set_vcp`]); a no-op until CP engages.
 unsafe extern "C" fn crtc_atomic_disable(
     crtc: *mut bindings::drm_crtc,
     _state: *mut bindings::drm_atomic_commit,
@@ -671,6 +701,10 @@ fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 {
     }
     head.cursor_primed
         .store(false, core::sync::atomic::Ordering::SeqCst);
+    // DPMS-off: blank the monitor backlight via DDC/CI (VCP 0xD6 = off) rather than leaving
+    // the last frame frozen on the panel. Inferred wire (see `cp::ddc_set_vcp`); no-op until
+    // CP engages.
+    let _ = data.set_vcp(head.index, super::cp::VCP_POWER_MODE, super::cp::POWER_OFF);
     pr_info!("vino: KMS CRTC disable -- head{} display OFF (scanout stopped)\n", head.index);
 }
 
@@ -1182,6 +1216,9 @@ pub(super) fn kms_init<C: drm::DeviceContext>(
             Some(bindings::drm_atomic_helper_connector_duplicate_state);
         (*cf).atomic_destroy_state =
             Some(bindings::drm_atomic_helper_connector_destroy_state);
+        // Custom DDC/CI brightness/contrast properties (see `connector_atomic_set_property`).
+        (*cf).atomic_set_property = Some(connector_atomic_set_property);
+        (*cf).atomic_get_property = Some(connector_atomic_get_property);
         (*data.conn_helper.get()).get_modes = Some(get_modes);
         // Prune any single mode above the per-head pixel-clock ceiling (~4K@60).
         (*data.conn_helper.get()).mode_valid = Some(mode_valid);
@@ -1216,6 +1253,26 @@ pub(super) fn kms_init<C: drm::DeviceContext>(
         (*data.encoder_funcs.get()).destroy = Some(bindings::drm_encoder_cleanup);
 
         // Build each head's objects (connector + primary/cursor planes + CRTC + encoder).
+        // DDC/CI brightness/contrast: one 0..=100 range property each, created on the device
+        // and attached to every connector in `build_head`. Non-fatal: a NULL property just
+        // means the knob is absent (kept in the AtomicPtr as NULL, ignored by the callbacks).
+        let bp = bindings::drm_property_create_range(
+            raw,
+            0,
+            c"brightness".as_ptr().cast(),
+            0,
+            100,
+        );
+        data.brightness_prop.store(bp, core::sync::atomic::Ordering::Relaxed);
+        let cp = bindings::drm_property_create_range(
+            raw,
+            0,
+            c"contrast".as_ptr().cast(),
+            0,
+            100,
+        );
+        data.contrast_prop.store(cp, core::sync::atomic::Ordering::Relaxed);
+
         for head in data.heads() {
             build_head(raw, data, head)?;
         }
@@ -1322,10 +1379,86 @@ unsafe fn build_head(raw: *mut bindings::drm_device, data: &VinoDrmData, head: &
         if rc != 0 {
             pr_warn!("vino: head{} rotation property unavailable ({rc})\n", head.index);
         }
+
+        // Attach the shared DDC/CI brightness/contrast properties to this connector, each at
+        // its default of 100 (= no attenuation). The callbacks store the value per head and
+        // fire the DDC/CI write (see `connector_atomic_set_property`).
+        let bp = data.brightness_prop.load(core::sync::atomic::Ordering::Relaxed);
+        if !bp.is_null() {
+            bindings::drm_object_attach_property(&mut (*conn).base, bp, 100);
+        }
+        let cp = data.contrast_prop.load(core::sync::atomic::Ordering::Relaxed);
+        if !cp.is_null() {
+            bindings::drm_object_attach_property(&mut (*conn).base, cp, 100);
+        }
     }
     Ok(())
 }
 
+/// Connector `.atomic_set_property`: handle the custom DDC/CI brightness/contrast properties
+/// (the standard properties are handled by the DRM core, which never calls us for them). On a
+/// value change we store it on the head and immediately push a DDC/CI Set-VCP write to the
+/// monitor -- DDC/CI is a side-band action on the physical panel, not part of the atomic
+/// scanout state, so it is applied here rather than threaded through connector state. Returns
+/// `-EINVAL` for any other property so the core reports it as unknown.
+unsafe extern "C" fn connector_atomic_set_property(
+    connector: *mut bindings::drm_connector,
+    _state: *mut bindings::drm_connector_state,
+    property: *mut bindings::drm_property,
+    val: u64,
+) -> i32 {
+    // SAFETY: `connector` is a valid connector embedded in our DRM device-private.
+    let dev = unsafe { (*connector).dev };
+    // SAFETY: `dev` is our live, registered drm_device.
+    let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) };
+    let Some(head) = data.head_by_connector(connector) else {
+        return EINVAL.to_errno();
+    };
+    let bp = data.brightness_prop.load(core::sync::atomic::Ordering::Relaxed);
+    let cp = data.contrast_prop.load(core::sync::atomic::Ordering::Relaxed);
+    let v = val.min(100) as u32;
+    let (slot, vcp) = if property == bp && !bp.is_null() {
+        (&head.brightness, super::cp::VCP_BRIGHTNESS)
+    } else if property == cp && !cp.is_null() {
+        (&head.contrast, super::cp::VCP_CONTRAST)
+    } else {
+        return EINVAL.to_errno();
+    };
+    if slot.swap(v, core::sync::atomic::Ordering::Relaxed) != v {
+        let _ = data.set_vcp(head.index, vcp, v as u16);
+    }
+    0
+}
+
+/// Connector `.atomic_get_property`: read back the stored DDC/CI brightness/contrast (the
+/// values round-trip through the head atomics set by [`connector_atomic_set_property`]).
+unsafe extern "C" fn connector_atomic_get_property(
+    connector: *mut bindings::drm_connector,
+    _state: *const bindings::drm_connector_state,
+    property: *mut bindings::drm_property,
+    val: *mut u64,
+) -> i32 {
+    // SAFETY: `connector` is a valid connector embedded in our DRM device-private.
+    let dev = unsafe { (*connector).dev };
+    // SAFETY: `dev` is our live, registered drm_device.
+    let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) };
+    let Some(head) = data.head_by_connector(connector) else {
+        return EINVAL.to_errno();
+    };
+    let bp = data.brightness_prop.load(core::sync::atomic::Ordering::Relaxed);
+    let cp = data.contrast_prop.load(core::sync::atomic::Ordering::Relaxed);
+    let slot = if property == bp && !bp.is_null() {
+        &head.brightness
+    } else if property == cp && !cp.is_null() {
+        &head.contrast
+    } else {
+        return EINVAL.to_errno();
+    };
+    // SAFETY: the DRM core passes a valid `*mut u64` output pointer.
+    unsafe { *val = slot.load(core::sync::atomic::Ordering::Relaxed) as u64 };
+    0
+}
+
 /// Thin wrapper so the `unsafe` block above reads cleanly.
 unsafe fn drm_mode_config_reset(raw: *mut bindings::drm_device) {
     // SAFETY: `raw` is a valid drm_device with mode_config initialised.
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index 1091dcc992c7..ee63ce7e4625 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -385,6 +385,24 @@ fn bring_up(dev: &usb::Device) -> Result {
             Ok(()) => pr_info!("vino: step device-open 0xfc(iface1) OK = {:02x?}\n", probe3),
             Err(e) => pr_info!("vino: step device-open 0xfc(iface1) non-fatal ({e:?})\n"),
         }
+        // DFU firmware-version query, matching DLM / the macOS+Windows drivers'
+        // DfuGetVmmDeviceFirmwareVersion: vendor IN bmRequestType=0xc1 bRequest=0xfd wIndex=1,
+        // a 6-byte version blob (the reference driver's request-size table: 0xfb=4 customer/board,
+        // 0xfc=3 device-type, 0xfd=6 firmware-version, 0xfe=16 descriptor). This is a device-level
+        // DFU read, independent of the CP channel, so it works regardless of CP engagement -- handy
+        // for diagnostics and confirming the dock firmware revision.
+        let mut fw_ver = [0u8; 6];
+        match dev.control_recv(0xfd, VENDOR_IN_IFACE, 0, 1, &mut fw_ver, timeout()) {
+            Ok(()) => pr_info!("vino: dock DFU firmware version = {:02x?}\n", fw_ver),
+            Err(e) => pr_info!("vino: device-open 0xfd(firmware-version) non-fatal ({e:?})\n"),
+        }
+        // DFU customer/board id (DfuGetVmmDeviceCustomerAndBoardId): bRequest=0xfb, 4-byte blob.
+        let mut cust_board = [0u8; 4];
+        match dev.control_recv(0xfb, VENDOR_IN_IFACE, 0, 1, &mut cust_board, timeout()) {
+            Ok(()) => pr_info!("vino: dock DFU customer/board id = {:02x?}\n", cust_board),
+            Err(e) => pr_info!("vino: device-open 0xfb(customer/board) non-fatal ({e:?})\n"),
+        }
+
         // EXPERIMENT (2026-06-16): replay DLM's repeated STRING-descriptor reads at device-open.
         // Timing analysis of the paired cold capture (captures/paired-coldbus-20260615-220311)
         // shows DLM, beyond the distinct descriptor SET vino already issues, re-reads STRING idx0
-- 
2.54.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH 7/7] drm/vino: add KUnit self-tests for the protocol and crypto paths
  2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
                   ` (5 preceding siblings ...)
  2026-06-17 15:12 ` [RFC PATCH 6/7] drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
  2026-06-17 20:18   ` sashiko-bot
  2026-06-17 15:55 ` [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Danilo Krummrich
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
  8 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
  To: dri-devel
  Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
	linux-kernel

Add offline KUnit self-tests for the pure protocol builders/parsers and
the crypto bindings the control plane relies on. The crypto cases are
published known-answer vectors (FIPS-197 AES-128, RFC 4493 AES-CMAC) and
a live seal round-trip; the rest pin wire layout, EDID timing extraction,
the WHT codec stages and the DDC/CI Set-VCP encoding that have no hardware
oracle. Gated behind CONFIG_KUNIT, so they have zero effect on a
production build; run with a KUnit-enabled kernel.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
 drivers/gpu/drm/vino/vino.rs | 299 +++++++++++++++++++++++++++++++++++
 1 file changed, 299 insertions(+)

diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index ee63ce7e4625..2d22c3f822cd 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -1752,3 +1752,302 @@ fn disconnect<'bound>(intf: &'bound usb::Interface<Core<'_>>, _data: Pin<&Self>)
     description: "DisplayLink DL3 (Vino) open driver",
     license: "GPL v2",
 }
+
+/// Build a minimal valid 128-byte EDID with a 1920x1080@60 detailed timing at base-block
+/// offset `dtd_at` (54 = preferred slot), a correct checksum, and the standard magic.
+#[cfg(CONFIG_KUNIT = "y")]
+fn mk_test_edid(dtd_at: usize) -> [u8; 128] {
+    let mut e = [0u8; 128];
+    e[..8].copy_from_slice(&[0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]);
+    // 1920x1080@60: pclk 14850 (148.5 MHz, 10 kHz units); hblank 280, vblank 45;
+    // hsync_front 88, hsync_width 44, vsync_front 4, vsync_width 5.
+    let dtd: [u8; 18] = [
+        0x02, 0x3a, // pixel clock 0x3a02 LE
+        0x80, 0x18, 0x71, // hactive 1920 / hblank 280 (high nibbles in byte 4)
+        0x38, 0x2d, 0x40, // vactive 1080 / vblank 45 (high nibbles in byte 7)
+        0x58, 0x2c, 0x45, 0x00, // hsync/vsync front+width
+        0, 0, 0, 0, 0, 0, // trailing flags (DTD is 18 bytes total)
+    ];
+    e[dtd_at..dtd_at + 18].copy_from_slice(&dtd);
+    let s = e[..127].iter().fold(0u8, |a, &b| a.wrapping_add(b));
+    e[127] = 0u8.wrapping_sub(s); // base-block checksum: all 128 bytes sum to 0
+    e
+}
+
+/// Offline self-tests for the pure protocol builders/parsers and the crypto bindings the
+/// control plane relies on. Gated behind `CONFIG_KUNIT` (the macro adds the cfg), so they
+/// have zero effect on a production build; run with a KUnit-enabled kernel. The crypto cases
+/// are published known-answer vectors (FIPS-197 AES-128, RFC 4493 AES-CMAC); the seal case is
+/// a live round-trip; the rest pin wire layout and EDID parsing that have no hardware oracle.
+#[kunit_tests(vino_protocol)]
+mod tests {
+    use super::*;
+    use kernel::error::code::EINVAL;
+
+    #[test]
+    fn aes128_ecb_fips197_kat() -> Result {
+        // FIPS-197 / NIST SP800-38A F.1.1 AES-128 ECB known-answer vector.
+        let key = [
+            0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
+            0x4f, 0x3c,
+        ];
+        let pt = [
+            0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
+            0x17, 0x2a,
+        ];
+        assert_eq!(
+            crypto::aes128_ecb(&key, &pt)?,
+            [
+                0x3a, 0xd7, 0x7b, 0xb4, 0x0d, 0x7a, 0x36, 0x60, 0xa8, 0x9e, 0xca, 0xf3, 0x24, 0x66,
+                0xef, 0x97,
+            ]
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn aes_cmac_rfc4493_kat() -> Result {
+        // RFC 4493 sec 4 AES-CMAC test vectors (same key as above).
+        let key = [
+            0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
+            0x4f, 0x3c,
+        ];
+        assert_eq!(
+            crypto::aes_cmac(&key, &[])?,
+            [
+                0xbb, 0x1d, 0x69, 0x29, 0xe9, 0x59, 0x37, 0x28, 0x7f, 0xa3, 0x7d, 0x12, 0x9b, 0x75,
+                0x67, 0x46,
+            ]
+        );
+        let msg = [
+            0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
+            0x17, 0x2a,
+        ];
+        assert_eq!(
+            crypto::aes_cmac(&key, &msg)?,
+            [
+                0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a,
+                0x28, 0x7c,
+            ]
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn seal_livemac_roundtrip() -> Result {
+        // A sealed CP frame must decrypt back to its content under the IN riv, and its
+        // appended tag must equal a fresh Dl3Cmac over the ciphertext (encrypt-then-MAC).
+        let ks = [
+            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
+            0xee, 0xff,
+        ];
+        let riv = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17];
+        let content = [0xa5u8; 32];
+        let mut hdr = [0u8; 16];
+        hdr[12..16].copy_from_slice(&4u32.to_le_bytes()); // wire_seq = 4
+        let frame = cp::seal_livemac(&ks, &riv, &hdr, &content)?;
+        assert_eq!(frame.len(), 16 + 32 + 16);
+        let ct = &frame[16..16 + 32];
+        assert_eq!(&cp::open_in(&ks, &cp::in_riv(&riv), 4, ct)?[..], &content[..]);
+        assert_eq!(&frame[16 + 32..], &cp::dl3cmac_tag(&ks, &riv, 4, ct)?[..]);
+        Ok(())
+    }
+
+    #[test]
+    fn aux_for_id_constants() {
+        // The CP header `aux` field is a per-inner-id constant, not body_len/4.
+        assert_eq!(cp::aux_for_id(0x14, 48), 0x0a);
+        assert_eq!(cp::aux_for_id(0x15, 32), 0x09);
+        assert_eq!(cp::aux_for_id(0x48, 96), 0x06);
+        assert_eq!(cp::aux_for_id(0x99, 40), 10); // unknown id falls back to body_len/4
+    }
+
+    #[test]
+    fn edid_timing_parse_and_validate() {
+        // A well-formed EDID yields the DTD timing; a bad checksum is rejected; a leading
+        // monitor descriptor (pclk 0) does not hide the preferred timing in a later slot.
+        let edid = mk_test_edid(54);
+        let t = cp::timing_from_edid(&edid).expect("valid EDID parses");
+        assert_eq!(t.hactive, 1920);
+        assert_eq!(t.vactive, 1080);
+        assert_eq!(t.refresh_hz, 60);
+        assert_eq!(t.pixel_clock_10khz, 14850);
+
+        let mut bad = edid;
+        bad[127] ^= 0xff;
+        assert!(cp::timing_from_edid(&bad).is_none(), "bad checksum rejected");
+
+        let scanned = mk_test_edid(72); // off54 left as a zero (monitor) descriptor
+        assert_eq!(
+            cp::timing_from_edid(&scanned).expect("scans past off54").hactive,
+            1920
+        );
+    }
+
+    #[test]
+    fn edid_reply_guards() -> Result {
+        // The pre-decrypt guards reject non-EDID frames without touching the cipher.
+        let ks = [0u8; 16];
+        let riv = [0u8; 8];
+        assert!(cp::parse_edid_from_reply(&ks, &riv, &[0u8; 10])?.is_none());
+        let mut wrong_sub = [0u8; 20];
+        wrong_sub[8] = 0x44; // wire sub != 0x45
+        assert!(cp::parse_edid_from_reply(&ks, &riv, &wrong_sub)?.is_none());
+        Ok(())
+    }
+
+    #[test]
+    fn rgb565_packing() {
+        assert_eq!(video::rgb565(0xff, 0x00, 0x00), 0xf800);
+        assert_eq!(video::rgb565(0x00, 0xff, 0x00), 0x07e0);
+        assert_eq!(video::rgb565(0x00, 0x00, 0xff), 0x001f);
+        let _ = EINVAL; // silence unused import on configs without the assert paths
+    }
+
+    #[test]
+    fn cursor_messages_structure() -> Result {
+        // Create: id=0x1b sub=0x42, `00 02 00` marker + w,h at off20.
+        let c = cp::cursor_create(7, 64, 64)?;
+        assert_eq!(c.len(), 27);
+        assert_eq!(&c[0..6], &[0x1b, 0x00, 0x42, 0x00, 0x07, 0x00]); // id, sub, counter (LE)
+        assert_eq!(&c[20..23], &[0x00, 0x02, 0x00]); // marker
+        assert_eq!(u16::from_le_bytes([c[23], c[24]]), 64); // width
+        assert_eq!(u16::from_le_bytes([c[25], c[26]]), 64); // height
+
+        // Move: id=0x1a sub=0x43, head@22, flag@23, X@24, Y@26 (LE).
+        let m = cp::cursor_move(9, 1, 0x0140, 0x00f0)?;
+        assert_eq!(m.len(), 28);
+        assert_eq!(&m[0..4], &[0x1a, 0x00, 0x43, 0x00]); // id, sub
+        assert_eq!(m[22], 1); // head id
+        assert_eq!(u16::from_le_bytes([m[24], m[25]]), 0x0140); // X
+        assert_eq!(u16::from_le_bytes([m[26], m[27]]), 0x00f0); // Y
+
+        // Image: create-style 27-byte header + w*h*4 BGRA bitmap; wrong-size input rejected.
+        let bitmap = KVec::from_elem(0xabu8, 64 * 64 * 4, GFP_KERNEL)?;
+        let img = cp::cursor_image(3, 64, 64, &bitmap)?;
+        assert_eq!(img.len(), 27 + 64 * 64 * 4);
+        assert_eq!(&img[0..4], &[0x1c, 0x00, 0x41, 0x00]); // id, sub
+        assert_eq!(img[27], 0xab); // bitmap begins right after the 27-byte header
+        assert!(cp::cursor_image(3, 64, 64, &[0u8; 16]).is_err()); // wrong bitmap length
+        Ok(())
+    }
+
+    #[test]
+    fn timing_from_drm_mode_1080p60() {
+        // CEA 1920x1080@60: clock 148.5 MHz, h 2008/2052/2200, v 1084/1089/1125.
+        let mut m = bindings::drm_display_mode::default();
+        m.clock = 148_500; // kHz
+        m.hdisplay = 1920;
+        m.hsync_start = 2008;
+        m.hsync_end = 2052;
+        m.htotal = 2200;
+        m.vdisplay = 1080;
+        m.vsync_start = 1084;
+        m.vsync_end = 1089;
+        m.vtotal = 1125;
+        // SAFETY: `m` is a fully-initialised local drm_display_mode.
+        let t = unsafe { cp::timing_from_drm_mode(&m) };
+        assert_eq!(t.hactive, 1920);
+        assert_eq!(t.hblank, 280); // htotal - hdisplay
+        assert_eq!(t.hsync_front, 88); // hsync_start - hdisplay
+        assert_eq!(t.hsync_width, 44); // hsync_end - hsync_start
+        assert_eq!(t.vactive, 1080);
+        assert_eq!(t.vblank, 45); // vtotal - vdisplay
+        assert_eq!(t.vsync_front, 4);
+        assert_eq!(t.vsync_width, 5);
+        assert_eq!(t.pixel_clock_10khz, 14_850); // clock(kHz) / 10
+        assert_eq!(t.refresh_hz, 60); // via drm_mode_vrefresh
+    }
+
+    #[test]
+    fn rotation_pixel_mapping() {
+        use bindings::{
+            DRM_MODE_REFLECT_X, DRM_MODE_ROTATE_0, DRM_MODE_ROTATE_180, DRM_MODE_ROTATE_270,
+            DRM_MODE_ROTATE_90,
+        };
+        // Source 2x3 (sw=2, sh=3). 0deg is identity; 180deg mirrors both axes.
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0, 0, 0, 2, 3), (0, 0));
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0, 1, 2, 2, 3), (1, 2));
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_180, 0, 0, 2, 3), (1, 2));
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_180, 1, 2, 2, 3), (0, 0));
+        // 90deg: output dims are (sh,sw)=(3,2); (dx,dy) -> (dy, sh-1-dx).
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_90, 0, 0, 2, 3), (0, 2));
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_90, 2, 1, 2, 3), (1, 0));
+        // 270deg: (dx,dy) -> (sw-1-dy, dx).
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_270, 0, 0, 2, 3), (1, 0));
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_270, 2, 1, 2, 3), (0, 2));
+        // Reflect-X composes on top of the rotation (here identity): sx -> sw-1-sx.
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0 | DRM_MODE_REFLECT_X, 0, 0, 2, 3), (1, 0));
+    }
+
+    #[test]
+    fn wht_colour_and_quantize() {
+        use video::wht;
+        // Exact colour transform: white -> Y=16320, achromatic -> Cb=Cr=0.
+        assert_eq!(wht::colour(255, 255, 255), (16320, 0, 0));
+        assert_eq!(wht::colour(128, 128, 128), (128 * 64, 0, 0)); // gray: chroma zero
+        assert_eq!(wht::colour(255, 0, 0), (16 * 255, 64 * 255, 0)); // red: Cb>0, Cr=0
+        // The documented ground-truth vector: white Y_DC=16320 quantizes (DC, position 0) to 1020.
+        assert_eq!(wht::quantize(16320, 0), 1020);
+        // AC clamps to the 12-bit signed long-token range.
+        assert_eq!(wht::quantize(1_000_000, 16), 2047);
+        assert_eq!(wht::quantize(-1_000_000, 16), -2048);
+    }
+
+    #[test]
+    fn wht_transform_uniform() {
+        use video::wht;
+        // A uniform block: DC = the per-pixel value, every AC coefficient = 0 (VIDEO.md invariant).
+        let block = [16320i32; wht::BLOCK];
+        let c = wht::transform(&block);
+        assert_eq!(c[0], 16320); // DC = mean = the uniform value
+        assert!(c[1..].iter().all(|&x| x == 0)); // AC all zero
+        // End-to-end: white pixel -> Y plane -> WHT DC -> quantize -> 1020.
+        let (y, _, _) = wht::colour(255, 255, 255);
+        assert_eq!(wht::quantize(wht::transform(&[y; wht::BLOCK])[0], 0), 1020);
+    }
+
+    #[test]
+    fn wht_token_bitstream() -> Result {
+        use video::wht::TokenWriter;
+        // 16-bit zero pad, then short tokens 5 (00101) and 30 (11110), zero-padded to a byte:
+        // 0000000000000000 00101 11110 000000 = 0x00 0x00 0x2F 0x80.
+        let mut w = TokenWriter::new()?;
+        w.token(5)?;
+        w.token(30)?;
+        assert_eq!(&w.finish()?[..], &[0x00, 0x00, 0x2f, 0x80]);
+        // A value > 30 escapes to a 17-bit long token: 16 pad + 17 + byte-pad = 5 bytes.
+        let mut w = TokenWriter::new()?;
+        w.token(100)?;
+        assert_eq!(w.finish()?.len(), 5);
+        Ok(())
+    }
+
+    #[test]
+    fn ddc_ci_set_vcp_checksum() {
+        // VESA DDC/CI 1.1 sec 4.4 worked example: Set brightness (VCP 0x10) to 50 (0x0032).
+        // Bytes after the 0x6e write address: 51 84 03 10 00 32, checksum = XOR incl. 0x6e.
+        let p = cp::ddc_ci_set_vcp(cp::VCP_BRIGHTNESS, 50);
+        assert_eq!(&p[..6], &[0x51, 0x84, 0x03, 0x10, 0x00, 0x32]);
+        let want = 0x6e ^ 0x51 ^ 0x84 ^ 0x03 ^ 0x10 ^ 0x00 ^ 0x32;
+        assert_eq!(p[6], want);
+        // The checksum makes the XOR of {dest, source, len, opcode, vcp, hi, lo, chk} zero.
+        assert_eq!(0x6eu8 ^ p.iter().fold(0u8, |a, &b| a ^ b), 0);
+        // Contrast (0x12) and the power VCP (0xd6 = off) carry their codes/values verbatim.
+        assert_eq!(cp::ddc_ci_set_vcp(cp::VCP_CONTRAST, 0x0140)[3..6], [0x12, 0x01, 0x40]);
+        assert_eq!(cp::ddc_ci_set_vcp(cp::VCP_POWER_MODE, cp::POWER_OFF)[3..6], [0xd6, 0x00, 0x04]);
+    }
+
+    #[test]
+    fn ddc_set_vcp_message_structure() -> Result {
+        // CP wrapper: id=0x15 sub=0x22, counter (LE) at off4, I2C slave 0x37 + len 7 at off20,
+        // the 7-byte DDC/CI Set-VCP payload at off22, padded to a 32-byte block.
+        let m = cp::ddc_set_vcp(0x11, cp::VCP_BRIGHTNESS, 75)?;
+        assert_eq!(m.len(), 32);
+        assert_eq!(&m[0..6], &[0x15, 0x00, 0x22, 0x00, 0x11, 0x00]); // id, sub, counter (LE)
+        assert_eq!(&m[20..22], &[0x37, 7]); // monitor DDC/CI I2C slave + payload length
+        assert_eq!(&m[22..29], &cp::ddc_ci_set_vcp(cp::VCP_BRIGHTNESS, 75)); // DDC/CI payload
+        assert_eq!(&m[29..32], &[0, 0, 0]); // block padding
+        Ok(())
+    }
+}
-- 
2.54.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up
  2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
@ 2026-06-17 15:17   ` Miguel Ojeda
  2026-06-17 20:11   ` sashiko-bot
  2026-06-18 10:39   ` Julian Braha
  2 siblings, 0 replies; 41+ messages in thread
From: Miguel Ojeda @ 2026-06-17 15:17 UTC (permalink / raw)
  To: Mike Lothian
  Cc: dri-devel, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
	linux-kernel

On Wed, Jun 17, 2026 at 5:13 PM Mike Lothian <mike@fireburn.co.uk> wrote:
>
> Vino is a clean-room, in-kernel Rust DRM driver for DisplayLink DL3 USB
> docks (Dell Universal Dock D6000, 17e9:6006), a native replacement for
> the out-of-tree EVDI module plus the proprietary DisplayLinkManager
> userspace daemon. It is built on the in-tree Rust USB, crypto and DRM/KMS
> bindings (posted as their own prerequisite series).

Ah, so this is the user for all those series. Is it then something you
plan to finish implementing and upstreaming?

(I would suggest linking this series and clarifying those questions
for future versions of all the related cover letters, so that there is
no confusion about it.)

Thanks!

Cheers,
Miguel

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted)
  2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
                   ` (6 preceding siblings ...)
  2026-06-17 15:12 ` [RFC PATCH 7/7] drm/vino: add KUnit self-tests for the protocol and crypto paths Mike Lothian
@ 2026-06-17 15:55 ` Danilo Krummrich
  2026-06-17 16:11   ` Mike Lothian
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
  8 siblings, 1 reply; 41+ messages in thread
From: Danilo Krummrich @ 2026-06-17 15:55 UTC (permalink / raw)
  To: Mike Lothian
  Cc: dri-devel, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, linux-kernel,
	Lyude Paul

(Cc: Lyude)

On Wed Jun 17, 2026 at 5:12 PM CEST, Mike Lothian wrote:
> Vino is a clean-room, in-kernel Rust DRM driver for DisplayLink DL3 USB
> docks (Dell Universal Dock D6000, 17e9:6006), a native replacement for
> the out-of-tree EVDI module plus the proprietary DisplayLinkManager
> userspace daemon.

Interesting project!

> It is built on the in-tree Rust USB, crypto and DRM/KMS bindings, which are
> posted as their own prerequisite series;

The KMS series you refer to is really just including the C headers for bindgen,
while the driver messes with all C KMS APIs directly.

Lyude already works on proper KMS infrastructure for Rust; can you please work
with her to get your driver reworked to use the safe infrastructure?

Also, I recommend looking at this series [1], which should very much simplify
dealing with device resources.

Thanks,
Danilo

[1] https://lore.kernel.org/dri-devel/20260603011711.2077361-1-dakr@kernel.org/

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted)
  2026-06-17 15:55 ` [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Danilo Krummrich
@ 2026-06-17 16:11   ` Mike Lothian
  0 siblings, 0 replies; 41+ messages in thread
From: Mike Lothian @ 2026-06-17 16:11 UTC (permalink / raw)
  To: Danilo Krummrich
  Cc: Maling list - DRI developers, rust-for-linux, Maarten Lankhorst,
	Maxime Ripard, Thomas Zimmermann, David Airlie, Simona Vetter,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	Linux Kernel Mailing List, Lyude Paul

[-- Attachment #1: Type: text/plain, Size: 1097 bytes --]

Thanks, I'll check it out.

On Wed, 17 Jun 2026, 16:55 Danilo Krummrich, <dakr@kernel.org> wrote:

> (Cc: Lyude)
>
> On Wed Jun 17, 2026 at 5:12 PM CEST, Mike Lothian wrote:
> > Vino is a clean-room, in-kernel Rust DRM driver for DisplayLink DL3 USB
> > docks (Dell Universal Dock D6000, 17e9:6006), a native replacement for
> > the out-of-tree EVDI module plus the proprietary DisplayLinkManager
> > userspace daemon.
>
> Interesting project!
>
> > It is built on the in-tree Rust USB, crypto and DRM/KMS bindings, which
> are
> > posted as their own prerequisite series;
>
> The KMS series you refer to is really just including the C headers for
> bindgen,
> while the driver messes with all C KMS APIs directly.
>
> Lyude already works on proper KMS infrastructure for Rust; can you please
> work
> with her to get your driver reworked to use the safe infrastructure?
>
> Also, I recommend looking at this series [1], which should very much
> simplify
> dealing with device resources.
>
> Thanks,
> Danilo
>
> [1]
> https://lore.kernel.org/dri-devel/20260603011711.2077361-1-dakr@kernel.org/
>

[-- Attachment #2: Type: text/html, Size: 1608 bytes --]

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE
  2026-06-17 15:12 ` [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE Mike Lothian
@ 2026-06-17 16:18   ` Eric Biggers
  2026-06-17 20:12   ` sashiko-bot
  1 sibling, 0 replies; 41+ messages in thread
From: Eric Biggers @ 2026-06-17 16:18 UTC (permalink / raw)
  To: Mike Lothian
  Cc: dri-devel, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
	linux-kernel

On Wed, Jun 17, 2026 at 04:12:39PM +0100, Mike Lothian wrote:
> +/// `AES-CMAC-128(key, data)` (RFC 4493), built on the one-block ECB above.
> +/// This is DisplayLink's "Dl3Cmac" core -- the CP per-message integrity tag is
> +/// `AES_CMAC(ks, nonce8 || BE64(counter) || content)` (see `cp::dl3cmac_tag`);
> +/// verified byte-exact against live DLM data (canonical guide sec 8.6.7).
> +pub(super) fn aes_cmac(key: &[u8; 16], data: &[u8]) -> Result<[u8; 16]> {
> +    // dbl: left-shift the 128-bit value by 1, XOR 0x87 if the MSB was set.
> +    fn dbl(b: &[u8; 16]) -> [u8; 16] {
> +        let mut o = [0u8; 16];
> +        for i in 0..15 {
> +            o[i] = (b[i] << 1) | (b[i + 1] >> 7);
> +        }
> +        o[15] = b[15] << 1;
> +        if b[0] & 0x80 != 0 {
> +            o[15] ^= 0x87;
> +        }
> +        o
> +    }
> +    let l = aes128_ecb(key, &[0u8; 16])?;
> +    let k1 = dbl(&l);
> +    let k2 = dbl(&k1);
> +    let n = if data.is_empty() { 1 } else { data.len().div_ceil(16) };
> +    let complete = !data.is_empty() && data.len() % 16 == 0;
> +    let mut c = [0u8; 16];
> +    for i in 0..n {
> +        let mut blk = [0u8; 16];
> +        let start = i * 16;
> +        let end = core::cmp::min(start + 16, data.len());
> +        blk[..end - start].copy_from_slice(&data[start..end]);
> +        if i == n - 1 {
> +            if complete {
> +                for j in 0..16 {
> +                    blk[j] ^= k1[j];
> +                }
> +            } else {
> +                blk[end - start] = 0x80; // 10* padding
> +                for j in 0..16 {
> +                    blk[j] ^= k2[j];
> +                }
> +            }
> +        }
> +        for j in 0..16 {
> +            blk[j] ^= c[j];
> +        }
> +        c = aes128_ecb(key, &blk)?;
> +    }
> +    Ok(c)
> +}

There are AES-CMAC library functions that should be used.  See
include/crypto/aes-cbc-macs.h.  We don't want drivers rolling their own
modes on top of bare AES unless they have to, for a number of reasons.

- Eric

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up
  2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
  2026-06-17 15:17   ` Miguel Ojeda
@ 2026-06-17 20:11   ` sashiko-bot
  2026-06-18 10:39   ` Julian Braha
  2 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-06-17 20:11 UTC (permalink / raw)
  To: Mike Lothian; +Cc: dri-devel, ojeda

Thank you for your contribution! Sashiko AI review found 2 potential issue(s) to consider:
- [High] The driver enqueues a `BringUp` work item to the system workqueue but does not save a handle to it, making it impossible to cancel during `disconnect()`.
- [Medium] Overly restrictive Kconfig dependency (`depends on USB = y`) prevents building the driver as a module when the USB subsystem is modular.
--

commit c4faf8ad941311cd06fad312c6e36a39253115df
Author: Mike Lothian <mike@fireburn.co.uk>

drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up

Adds a clean-room, in-kernel Rust DRM driver for DisplayLink DL3 USB docks
(Dell Universal Dock D6000). This first patch registers a usb::Driver, binds
the control interface, and runs the plaintext connect handshake on a
workqueue.

> diff --git a/drivers/gpu/drm/vino/Kconfig b/drivers/gpu/drm/vino/Kconfig
> new file mode 100644
> index 0000000000000..234ce92736e43
> --- /dev/null
> +++ b/drivers/gpu/drm/vino/Kconfig
> @@ -0,0 +1,21 @@
> +# SPDX-License-Identifier: GPL-2.0
> +config DRM_VINO
> +	tristate "DisplayLink DL3 (Vino) open driver"
> +	depends on USB = y

[Severity: Medium]
Does this strict dependency prevent the driver from being built as a module
on standard distribution kernels?

Using "depends on USB" instead of "depends on USB = y" would allow this
module to be compiled when the USB subsystem is also built as a module.

[ ... ]
> diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
> new file mode 100644
> index 0000000000000..79f446041b648
> --- /dev/null
> +++ b/drivers/gpu/drm/vino/vino.rs
[ ... ]
> +        let intf_ref: ARef<usb::Interface> = intf.into();
> +        match BringUp::new(intf_ref.clone()) {
> +            Ok(work) => {
> +                let _ = workqueue::system().enqueue(work);

[Severity: High]
Does this code leak the enqueued work item handle?

Because the returned handle isn't stored in the driver's struct, it becomes
impossible to cancel this work item later during device disconnection.

> +                dev_info!(cdev, "vino: bring-up queued on system workqueue\n");
> +            }
> +            Err(e) => dev_info!(cdev, "vino: failed to queue bring-up ({e:?}) -- WIP\n"),
> +        }
> +
> +        Ok(Self { _intf: intf_ref })
> +    }
> +
> +    fn disconnect<'bound>(intf: &'bound usb::Interface<Core<'_>>, _data: Pin<&Self>) {
> +        let dev: &device::Device<Core<'_>> = intf.as_ref();
> +        dev_info!(dev, "vino: D6000 disconnected\n");
> +    }

[Severity: High]
Can this lead to a use-after-free panic if the module is unloaded while the
work item is still pending?

Since the work item isn't flushed or canceled here in disconnect(), the
workqueue might attempt to execute the work item's run function after the
module's code segment has been unmapped.

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260617151249.2937-1-mike@fireburn.co.uk?part=1

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE
  2026-06-17 15:12 ` [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE Mike Lothian
  2026-06-17 16:18   ` Eric Biggers
@ 2026-06-17 20:12   ` sashiko-bot
  1 sibling, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-06-17 20:12 UTC (permalink / raw)
  To: Mike Lothian; +Cc: dri-devel, ojeda

Thank you for your contribution! Sashiko AI review found 3 potential issue(s) to consider:
- [High] Buffer truncation and USB Babble errors due to undersized buffer allocation in `recv_hdcp` and `pace_cap_ack`.
- [High] Premature pacing abort on short frames in `pace_cap_ack`.
- [Low] Mismatch between `crypto_selftest` docstring and actual implemented test coverage.
--

commit 102ea10e819242d5dcf97bac1a8fb013c88b76f5
Author: Mike Lothian <mike@fireburn.co.uk>
Subject: drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE

After the plaintext session init, the DL3 dock requires an HDCP 2.2
session before it will accept any control-plane traffic. Add a clean-room
implementation of the HDCP 2.2 authentication.

> diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
> index 79f446041b648..db4c38b6dc92f 100644
> --- a/drivers/gpu/drm/vino/vino.rs
> +++ b/drivers/gpu/drm/vino/vino.rs
[ ... ]
> @@ -150,6 +150,44 @@ impl WorkItem for BringUp {
>  /// On-device crypto known-answer self-test. Confirms the IN-KERNEL crypto path (which the CP seal
>  /// depends on) is byte-correct -- something only ever checked offline (Python `verify-kdf.py`)
>  /// before.
>  /// Runs three checks and logs PASS/FAIL:
>  ///   1. AES-128-ECB vs the FIPS-197 test vector.
>  ///   2. AES-CMAC vs the RFC 4493 test vector (subkey + full-block path).
>  ///   3. The full `cp::seal_livemac` vs cold-ref's REAL msg0: known plaintext + known `ks`/`riv`

[Severity: Low]
This isn't a bug, but the docstring mentions a third test for cp::seal_livemac
that doesn't seem to be implemented in the function body. Could the comment be
updated to reflect the actual tests?

[ ... ]
> +    fn recv_hdcp(dev: &usb::Device) -> Result<(u8, KVec<u8>)> {
> +        const SUB_HDCP_RESP: u16 = 0x25;
> +        let mut buf = KVec::from_elem(0u8, 4096, GFP_KERNEL)?;

[Severity: High]
Does this buffer allocation correctly size the read for the endpoint?

The dock's interleaved capability blocks can reach ~5.8 KiB. Because this
buffer is only 4096 bytes, if the dock pushes a large capability block during
the AKE sequence, it looks like it will result in a USB truncation error
(-EOVERFLOW).

Using the EP84_BUF constant (16384 bytes) here, similar to wait_cap_complete(),
might prevent this.

[ ... ]
> +    fn pace_cap_ack(dev: &usb::Device, want_ctr: u16) {
> +        let Ok(mut buf) = KVec::from_elem(0u8, 4096, GFP_KERNEL) else {
> +            return;
> +        };

[Severity: High]
Can this buffer suffer from the same truncation issue as recv_hdcp()?

If the capability block exceeds 4096 bytes, this looks like it could also
trigger an -EOVERFLOW error.

[ ... ]
> +        for _ in 0..8 {
> +            match dev.bulk_recv(EP_CTRL_IN, &mut buf, Delta::from_millis(30)) {
> +                Ok(len) if len >= 22 => {
> +                    let wsub = u16::from_le_bytes([buf[8], buf[9]]);
> +                    let iid = u16::from_le_bytes([buf[16], buf[17]]);
> +                    let ictr = u16::from_le_bytes([buf[20], buf[21]]);
> +                    // The per-frame cap-ack: wsub=0x25, inner id=0x14 sub=0x10 ctr=want.
> +                    // An interleaved cap push (sub=0x84) or earlier ack -- keep reading.
> +                    if wsub == 0x25 && iid == 0x14 && ictr == want_ctr {
> +                        return;
> +                    }
> +                }
> +                // Nothing queued within the short window -- the dock is idle, don't block.
> +                _ => return,

[Severity: High]
Will this catch-all arm prematurely abort the loop on short frames?

If the dock sends short interleaved status or ACK frames (where len is less
than 22), the match falls through to the catch-all arm instead of continuing
to wait for the target ACK.

This seems like it would cause the driver to transmit the next message while
the dock is mid-NAK, which could trigger a NAK storm and prevent control-plane
engagement.

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260617151249.2937-1-mike@fireburn.co.uk?part=2

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 4/7] drm/vino: add the Vino (RawRl mode-2) framebuffer codec
  2026-06-17 15:12 ` [RFC PATCH 4/7] drm/vino: add the Vino (RawRl mode-2) framebuffer codec Mike Lothian
@ 2026-06-17 20:13   ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-06-17 20:13 UTC (permalink / raw)
  To: Mike Lothian; +Cc: ojeda, dri-devel

Thank you for your contribution! Sashiko AI review found 4 potential issue(s) to consider:
- [High] The EP08 transport header truncates the payload length to 16 bits, corrupting frames larger than 64KB.
- [High] Incorrect indexing of the `SECTION_CODE` array causes `Mode::Rle` and `Mode::Adaptive` to emit the wrong frame-end section codes.
- [High] The encoded stream uses `KVec<u8>`, which relies on contiguous kmalloc and will fail for large frame updates.
- [High] The encoder partially updates its shadow buffer before an error occurs, causing permanent corruption of the differential stream.
--

commit f949babe97f31ea182f0de355c24ec2f2844883f
Author: Mike Lothian <mike@fireburn.co.uk>

drm/vino: add the Vino (RawRl mode-2) framebuffer codec

Add the video module: the RawRl ("Raw/RLX" mode-2) encoder, clean-room
from the AArch64 reference-driver decompile, which emits packed-RGB565
frames the dock decodes without the impractical Vino Walsh-Hadamard
entropy codec.

> diff --git a/drivers/gpu/drm/vino/video.rs b/drivers/gpu/drm/vino/video.rs
> new file mode 100644
> index 0000000000000..bb5ea893575fc
> --- /dev/null
> +++ b/drivers/gpu/drm/vino/video.rs

[ ... ]

> +    pub(super) fn encode(&mut self, cur: &[u16]) -> Result<KVec<u8>> {
> +        let mut s = KVec::new();
> +        self.encode_into(cur, &mut s)?;
> +        Ok(s)
> +    }
> +
> +    /// Like [`encode`](Self::encode) but appends the marker stream to a caller-owned
> +    /// `out` instead of allocating a fresh `KVec`. The hot scanout path
> +    /// ([`encode_and_send`](super::drm_sink::encode_and_send)) uses this to encode
> +    /// straight into a buffer that already reserves the EP08 transport header, so a
> +    /// frame costs one allocation with no separate framing copy.
> +    pub(super) fn encode_into(&mut self, cur: &[u16], s: &mut KVec<u8>) -> Result {

[Severity: High]
Does KVec allocation risk causing kmalloc failures for large frame updates?

The encode function allocates a KVec to hold the encoded frame. Since the
encoded output for a full Raw frame or an incompressible Adaptive frame can
approach or exceed 4 MiB, this vector might grow beyond the page allocator's
maximum contiguous kmalloc order. Could this lead to page allocator warnings
and failures during heavy frame updates?

> +        s.extend_from_slice(&FRAME_INIT, GFP_KERNEL)?;
> +        for y in 0..self.height {
> +            let row = y * self.width;
> +            let mut x = 0;
> +            while x < self.width {
> +                while x < self.width && cur[row + x] == self.shadow[row + x] {
> +                    x += 1;
> +                }
> +                if x >= self.width {
> +                    break;
> +                }
> +                let run_start = x;
> +                while x < self.width && cur[row + x] != self.shadow[row + x] {
> +                    x += 1;
> +                }
> +                let run_end = x;
> +                let mut p = run_start;
> +                while p < run_end {
> +                    let n = (run_end - p).min(MAX_BLOCK_PIXELS);
> +                    let coord = (((row + p) * 2) & 0xff_ffff) as u32;
> +                    encode_run_into(s, self.mode, coord, &cur[row + p..row + p + n])?;
> +                    p += n;
> +                }
> +                for k in run_start..run_end {
> +                    self.shadow[row + k] = cur[row + k];
> +                }

[Severity: High]
Does this error handling permanently corrupt the differential stream if an
allocation fails?

The shadow buffer is mutated iteratively inside the processing loop after
encode_run_into(), which can return an error via ?. If it fails, the frame
processing is aborted and not sent. However, the shadow buffer has already
been updated with the pixels from successfully processed runs. Because the
dock never receives the aborted frame, its internal state no longer matches
the host's self.shadow, which will cause visual artifacts on subsequent
frames since the encoder will assume the dock already has those pixels.

> +            }
> +        }
> +        let code = SECTION_CODE[(self.mode as usize).saturating_sub(1).min(6)];

[Severity: High]
Does saturating_sub(1) map the 0-based enum values incorrectly for Mode::Rle
and Mode::Adaptive?

The Mode enum is 0-based (Raw = 0, Rle = 1, Adaptive = 2). Applying
saturating_sub(1) evaluates to 0 for both Raw and Rle, making Mode::Rle emit
SECTION_CODE[0] (0x01). Similarly, Mode::Adaptive evaluates to index 1 and
emits SECTION_CODE[1] (0x00). This causes the encoder to emit the wrong
framing codes for RLE and Adaptive modes, breaking hardware decoding.

> +        s.extend_from_slice(&SYNC, GFP_KERNEL)?;
> +        s.extend_from_slice(&[0xaf, 0x20, 0x1f, code], GFP_KERNEL)?;

[ ... ]

> +pub(super) fn write_ep08_header(hdr: &mut [u8], payload_len: usize, seq: u32) {
> +    hdr[0] = 0;
> +    hdr[1] = 0;
> +    hdr[2..4].copy_from_slice(&((payload_len + 12) as u16).to_le_bytes());

[Severity: High]
Can casting the payload length to u16 truncate it and break the transport
header for large frames?

A full 1080p frame encoded in Raw or Adaptive mode produces a payload of
several megabytes (up to ~4 MiB). Casting this usize payload length to u16
will silently drop the upper bits. Could this be the reason for the USB resets
noted in the module documentation, as the dock would receive a malformed
header with an incorrect size and lose framing synchronization?

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260617151249.2937-1-mike@fireburn.co.uk?part=4

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 3/7] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm
  2026-06-17 15:12 ` [RFC PATCH 3/7] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm Mike Lothian
@ 2026-06-17 20:15   ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-06-17 20:15 UTC (permalink / raw)
  To: Mike Lothian; +Cc: ojeda, dri-devel

Thank you for your contribution! Sashiko AI review found 5 potential issue(s) to consider:
- [High] Memory allocation using GFP_KERNEL in functions designed for atomic context.
- [High] Global state used to track per-device control plane engagement.
- [High] Integer overflow in `cursor_image` bypasses buffer size validation.
- [High] DMA mapping violation by passing stack and `.rodata` buffers to USB transfers.
- [High] Premature loop termination in `lockstep_reply` drops valid packets and breaks CP handshake.
--

commit ed0df79bff2d18c4e02a6704d484f6b62252ddf5
Author: Mike Lothian <mike@fireburn.co.uk>

drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm

This commit introduces the AES-CTR/AES-CMAC control-plane for the vino DRM 
driver. It adds the message builders, cryptographic sealing logic, and a 
post-SKE setup sequence to engage the dock's control plane.

diff --git a/drivers/gpu/drm/vino/cp.rs b/drivers/gpu/drm/vino/cp.rs
index 0000000000000..2668931d8500a
--- /dev/null
+++ b/drivers/gpu/drm/vino/cp.rs
[ ... ]
> +pub(super) fn cursor_move(counter: u16, head: u8, x: u16, y: u16) -> Result<KVec<u8>> {
> +    let mut b = KVec::with_capacity(28, GFP_KERNEL)?;

[Severity: High]
Is it safe to allocate with GFP_KERNEL here? 

Functions like cursor_move(), cursor_image(), seal_interactive(), and 
set_mode() are noted to be used by runtime KMS senders. In the DRM subsystem, 
cursor updates and plane atomic updates run in atomic context during 
non-blocking commits, where sleeping is not allowed. Since GFP_KERNEL enables 
direct reclaim which can sleep, using it in these paths could trigger kernel 
warnings or deadlocks.

> +    header(&mut b, 0x1a, 0x43, counter)?;
> +    pad_to(&mut b, 22)?;
> +    b.push(head, GFP_KERNEL)?; // off22 head/monitor id
> +    b.push(1, GFP_KERNEL)?; // off23 flag
> +    b.extend_from_slice(&x.to_le_bytes(), GFP_KERNEL)?; // off24
> +    b.extend_from_slice(&y.to_le_bytes(), GFP_KERNEL)?; // off26
> +    Ok(b)
> +}
> +
> +/// cursor image (sec 8.6.1): id=0x1c sub=0x41. Mirrors cursor_create's header (the
> +/// 00 02 00 marker + w,h at off20) and appends the w*h BGRA bitmap. bgra must be
> +/// w*h*4 bytes -- DRM hands the driver a 64x64 ARGB8888 cursor buffer and the caller swaps
> +/// it
> +/// to BGRA. The image sub-layout past the create-style header is capture-unconfirmed (only the
> +/// id and the shared header are decoded); re-check against a capture once CP engages.
> +pub(super) fn cursor_image(counter: u16, w: u16, h: u16, bgra: &[u8]) -> Result<KVec<u8>> {
> +    if bgra.len() != w as usize * h as usize * 4 {

[Severity: High]
Can this arithmetic wrap around on 32-bit architectures? 

The expected buffer size is validated using w as usize * h as usize * 4, where
w and h are u16 values. Their maximum product can be ~4.29 billion, which
when multiplied by 4 overflows a 32-bit usize. This could allow userspace to
specify dimensions like 65535x65535 with a small buffer that matches the
wrapped size, bypassing the validation and sending truncated control plane
messages.

> +        return Err(EINVAL);
> +    }
[ ... ]
> +pub(super) fn seal_interactive(
> +    ks: &[u8; 16],
> +    riv: &[u8; 8],
> +    id: u16,
> +    wire_seq: u32,
> +    content: &[u8],
> +) -> Result<KVec<u8>> {
> +    let body_len = content.len() + 16; // AES-CTR ciphertext + 16-byte Dl3Cmac
> +    let size = ((16 + body_len) - 4) as u16;
> +    let aux = aux_for_id(id, body_len);
> +    let mut hdr = [0u8; 16];
> +    hdr[2..4].copy_from_slice(&size.to_le_bytes());
> +    hdr[4..8].copy_from_slice(&4u32.to_le_bytes()); // type=4
> +    hdr[8..10].copy_from_slice(&0x24u16.to_le_bytes()); // sub=0x24 (interactive CP)
> +    hdr[10..12].copy_from_slice(&aux.to_le_bytes());
> +    hdr[12..16].copy_from_slice(&wire_seq.to_le_bytes());
> +    seal_livemac(ks, riv, &hdr, content)

[Severity: High]
Does this wrapper propagate a sleeping allocation into atomic context?

seal_interactive() calls seal_livemac(), which allocates KVec buffers using
GFP_KERNEL. As this function is also intended for runtime KMS senders, calling
it from an atomic commit context could lead to deadlocks.

> +}
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index db4c38b6dc92f..ef44a625cb70b 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
[ ... ]
> +/// Set once the dock has actually engaged the CP cipher (wsub=0x45 acks > 0). EP08 video is
> +/// gated on it: pushing frames at a dock whose CP channel is dead makes it fault and USB-reset.
> +/// NOTE: with the current CP-engagement wall (see the file header) this is never set on real
> +/// hardware -- the dock runs the whole plaintext handshake but never engages the encrypted CP.
> +static CP_ENGAGED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);

[Severity: High]
Is it appropriate to use a global static variable for per-device state? 

The driver tracks the dock's cipher engagement state using the global
CP_ENGAGED AtomicBool. Since the kernel needs to support multiple concurrent
device instances, sharing this state across devices could create race
conditions where one device's successful engagement globally ungates video
for all docks, or a failure gates video for an already engaged dock.

> +
>  mod proto;
>  mod crypto;
[ ... ]
> +                        let mut edid_out: Option<KVec<u8>> = None;
> +                        match VinoDriver::send_cp_setup(dev, &session, &mut edid_out) {
> +                            Ok((n, acks, _wseq_end, _ctr_end)) => {
> +                                dev_info!(cdev,
> +                                    "vino: CP setup sent -- {n} messages, {acks} dock CP acks (wsub=0x45)\n");
> +                                // CP engagement gates EP08 video (added in a later patch): until
> +                                // the dock acks, pushing pixels at it wedges the hub.
> +                                CP_ENGAGED.store(acks > 0, core::sync::atomic::Ordering::SeqCst);

[Severity: High]
Does modifying this global static variable here interfere with other
concurrent devices?

Updating the global CP_ENGAGED state based on a specific device instance's
setup outcome could crash other unengaged hubs if video gets incorrectly
ungated for them.

> +                            }
> +                            Err(e) => dev_info!(cdev, "vino: CP setup incomplete ({e:?}) -- WIP\n"),
> +                        }
>                      }
[ ... ]
> +        // Plaintext type=2 sub=0x24+0x45 stream-open arm marker -- the mandatory gate
> +        // before the first encrypted frame.
> +        const STREAM_OPEN: [u8; 64] = [
> +            0x00, 0x00, 0x1c, 0x00, 0x02, 0x00, 0x00, 0x00, //
> +            0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
> +            0x04, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, //
> +            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
> +            0x00, 0x00, 0x1c, 0x00, 0x02, 0x00, 0x00, 0x00, //
> +            0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
> +            0x05, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x00, 0x00, //
> +            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
> +        ];
[ ... ]
> +        // Submit the arm marker. Async path: queue it and DO NOT flush -- leave it in flight so
> +        // msg0 can be submitted right behind it (the pipelined arm->msg0 burst DLM does). Sync
> +        // path: the original blocking send.
> +        let arm_res = match out_q.as_mut() {
> +            Some(q) => q.send(&STREAM_OPEN, timeout()),
> +            None => dev.bulk_send(EP_CTRL_OUT, &STREAM_OPEN, timeout()).map(|_| ()),
> +        };

[Severity: High]
Does this violate the DMA mapping rules by passing .rodata to a USB transfer?

The STREAM_OPEN buffer is a const array located in .rodata. The Linux DMA API
forbids using .rodata for DMA transfers because these memory regions cannot
be safely mapped when the IOMMU is enabled.

> +        if let Err(e) = arm_res {
> +            pr_err!("vino: CP stream-open marker FAILED ({e:?})\n");
> +            return Err(e);
> +        }
[ ... ]
> +        // DLM sends the 0x24 wValue=0 render/commit vendor request right after msg0.
> +        match dev.control_send(0x24, 0x40 /* VENDOR_OUT */, 0, 0, &[], timeout()) {
> +            Ok(()) => pr_info!("vino: post-msg0 0x24(wValue=0) OK\n"),
> +            Err(e) => pr_info!("vino: post-msg0 0x24(wValue=0) non-fatal ({e:?})\n"),
> +        }
> +        // DLM then re-reads the 0x22 vendor state (0xc1, wValue=1, wIndex=0, 28 B) -- its SECOND
> +        // 0x22 of the session, immediately after the post-msg0 0x24. vino issued the first 0x22
> +        // pre-arm but stopped here, leaving "DLM-ONLY 0x22" in the paired diff. Issue it
> +        // unconditionally so the wire matches DLM regardless of whether the dock acks; it is a
> +        // harmless vendor IN read. (0xc1 = IN|vendor|INTERFACE recipient, matching the first 0x22.)
> +        let mut state2 = [0u8; 28];
> +        match dev.control_recv(0x22, 0xc1, 1, 0, &mut state2, timeout()) {

[Severity: High]
Does this violate the DMA mapping rules by passing a stack array to a USB
transfer?

The state2 buffer is a local array located on the stack. The Linux DMA API
forbids using stack memory for DMA transfers because these regions cannot
be safely mapped when CONFIG_VMAP_STACK or the IOMMU are enabled.

> +            Ok(()) => pr_info!("vino: post-msg0 0x22(wValue=1) OK = {:02x?}\n", state2),
> +            Err(e) => pr_info!("vino: post-msg0 0x22(wValue=1) non-fatal ({e:?})\n"),
> +        }
[ ... ]
> +    fn lockstep_reply(
> +        dev: &usb::Device,
> +        mut q: Option<&mut usb::BulkInQueue>,
> +        buf: &mut [u8],
> +        session: &Session,
> +        ictr: u16,
> +        edid_out: &mut Option<KVec<u8>>,
> +    ) -> (usize, usize, bool) {
> +        const MAX_READS: usize = 8;
> +        let in_riv = cp::in_riv(&session.riv);
> +        let mut reads = 0usize;
> +        let mut acks = 0usize;
> +        let mut matched = false;
> +        for _ in 0..MAX_READS {
> +            match Self::read_ep84(dev, q.as_deref_mut(), buf, Delta::from_millis(30)) {
> +                Ok(len) if len > 16 => {
> +                    reads += 1;
> +                    // sec 5 diagnostic: log every frame the dock returns in the lockstep
> +                    // window -- including the non-0x45 frames we otherwise skip -- so the
> +                    // divergence point is paired with the dock's actual reply on the wire.
> +                    Self::log_ep84(session, &buf[..len]);
> +                    if u16::from_le_bytes([buf[8], buf[9]]) != 0x45 {
> +                        continue;
> +                    }
> +                    acks += 1;
> +                    let seq = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
> +                    // Decrypt just the first block to read the inner counter (off 4).
> +                    let head = &buf[16..len.min(32)];
> +                    if let Ok(inner) = cp::open_in(&session.ks, &in_riv, seq, head) {
> +                        if inner.len() >= 6
> +                            && u16::from_le_bytes([inner[4], inner[5]]) == ictr
> +                        {
> +                            matched = true;
> +                        }
> +                    }
> +                    // Opportunistically capture the EDID (id=0x94 reply, off 22).
> +                    if edid_out.is_none() {
> +                        if let Ok(Some(e)) =
> +                            cp::parse_edid_from_reply(&session.ks, &session.riv, &buf[..len])
> +                        {
> +                            pr_info!("vino: EDID read from dock ({} bytes)\n", e.len());
> +                            *edid_out = Some(e);
> +                        }
> +                    }
> +                    if matched {
> +                        break;
> +                    }
> +                }
> +                _ => break,

[Severity: High]
Does this premature loop termination drop valid packets?

If the dock emits a legitimate short packet (such as a ZLP or a small protocol
ACK where len <= 16), the loop breaks immediately instead of continuing to
read. This drops the polling window and guarantees the driver will miss the
subsequent sub=0x45 reply, incorrectly treating the dock's CP initialization
as failed.

> +            }
> +        }
> +        (reads, acks, matched)
> +    }

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260617151249.2937-1-mike@fireburn.co.uk?part=3

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 7/7] drm/vino: add KUnit self-tests for the protocol and crypto paths
  2026-06-17 15:12 ` [RFC PATCH 7/7] drm/vino: add KUnit self-tests for the protocol and crypto paths Mike Lothian
@ 2026-06-17 20:18   ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-06-17 20:18 UTC (permalink / raw)
  To: Mike Lothian; +Cc: dri-devel, ojeda

Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
- [Low] Incorrect Kconfig conditional compilation attribute `#[cfg(CONFIG_KUNIT = "y")]` causes a build failure when KUnit is enabled.
--

commit 5c04ae06d96b8f9a5712fdd9c7e8f73f7a3cbf26
Author: Mike Lothian <mike@fireburn.co.uk>

Add offline KUnit self-tests for the pure protocol builders/parsers and

This commit adds offline KUnit self-tests for the pure protocol
builders/parsers and the crypto bindings the control plane relies on.
The crypto cases use published known-answer vectors and there are
tests for EDID extraction and DDC/CI encoding.

> diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
> index ee63ce7e4625..2d22c3f822cd 100644
> --- a/drivers/gpu/drm/vino/vino.rs
> +++ b/drivers/gpu/drm/vino/vino.rs
[ ... ]
> +/// Build a minimal valid 128-byte EDID with a 1920x1080@60 detailed timing at base-block
> +/// offset `dtd_at` (54 = preferred slot), a correct checksum, and the standard magic.
> +#[cfg(CONFIG_KUNIT = "y")]

[Severity: Low]
Will this configuration attribute work correctly in the kernel's Rust build
system? The Kconfig symbols are passed to rustc as boolean configuration flags
(e.g., --cfg CONFIG_KUNIT), not as key-value strings.

Since #[cfg(CONFIG_KUNIT = "y")] always evaluates to false, the mk_test_edid()
function will be stripped from the build. Does this cause a compilation error
when KUnit is enabled and the test module attempts to call it? Should this be
changed to #[cfg(CONFIG_KUNIT)] instead?

> +fn mk_test_edid(dtd_at: usize) -> [u8; 128] {

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260617151249.2937-1-mike@fireburn.co.uk?part=7

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 6/7] drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info
  2026-06-17 15:12 ` [RFC PATCH 6/7] drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info Mike Lothian
@ 2026-06-17 20:19   ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-06-17 20:19 UTC (permalink / raw)
  To: Mike Lothian; +Cc: ojeda, dri-devel

Thank you for your contribution! Sashiko AI review found 3 potential issue(s) to consider:
- [High] The `crtc_atomic_disable` callback executes in an atomic context but triggers sleeping operations, including `GFP_KERNEL` allocations.
- [High] The `connector_atomic_set_property` directly mutates global hardware state during the atomic check/assembly phase, violating DRM atomic semantics.
- [Medium] Missing contrast property replay during DPMS-on monitor wake.
--

commit f57a262e5ecdc9d7a9ab4c348f2232b364488d0e
Author: Mike Lothian <mike@fireburn.co.uk>

Add monitor controls that ride the same control plane

This patch adds DDC/CI monitor controls over a custom control plane for
brightness, contrast, and DPMS. It adds connector properties for
brightness and contrast, and hooks into the CRTC disable path for power
off.

> diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
> index afbf883fba36..bcc871958a8a 100644
> --- a/drivers/gpu/drm/vino/drm_sink.rs
> +++ b/drivers/gpu/drm/vino/drm_sink.rs

[ ... ]

> @@ -637,6 +660,11 @@ fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 {
>      if let Err(e) = data.send_cp(head.index, 0x48, 16, |ctr| super::cp::set_mode(ctr, &timing)) {
>          pr_warn!("vino: head{} runtime mode-set send failed ({e:?})\n", head.index);
>      }
> +    // Bring the monitor out of DPMS standby (DDC/CI VCP 0xD6 = on). Inferred wire (see
> +    // `cp::ddc_set_vcp`); a no-op until CP engages, and re-applies the user's brightness.
> +    let _ = data.set_vcp(head.index, super::cp::VCP_POWER_MODE, super::cp::POWER_ON);
> +    let b = head.brightness.load(core::sync::atomic::Ordering::Relaxed);
> +    let _ = data.set_vcp(head.index, super::cp::VCP_BRIGHTNESS, b as u16);

[Severity: Medium]
Does this code omit re-applying the contrast property during the DPMS-on
monitor wake sequence?

The struct documentation for head.contrast states it is replayed on DPMS-on,
but only head.brightness is loaded and re-applied to the monitor here. If
contrast is not restored, the monitor could lose its custom contrast setting
when waking from standby and revert to hardware defaults.

>  }
>  
>  /// CRTC `.atomic_disable`: the display is turning off.

[ ... ]

> @@ -671,6 +701,10 @@ fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 {
>      }
>      head.cursor_primed
>          .store(false, core::sync::atomic::Ordering::SeqCst);
> +    // DPMS-off: blank the monitor backlight via DDC/CI (VCP 0xD6 = off) rather than leaving
> +    // the last frame frozen on the panel. Inferred wire (see `cp::ddc_set_vcp`); no-op until
> +    // CP engages.
> +    let _ = data.set_vcp(head.index, super::cp::VCP_POWER_MODE, super::cp::POWER_OFF);

[Severity: High]
Does crtc_atomic_disable() execute in an atomic context where sleeping is
forbidden?

This calls data.set_vcp(), which delegates to ddc_set_vcp(). As seen in
cp.rs, ddc_set_vcp() uses sleeping allocations:

cp.rs:ddc_set_vcp() {
    ...
    let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
    ...
    b.extend_from_slice(&[0x37, payload.len() as u8], GFP_KERNEL)?;
    ...
}

Allocating memory with GFP_KERNEL in the atomic CRTC disable callback could
lead to a scheduling-while-atomic panic during display modesets.

>      pr_info!("vino: KMS CRTC disable -- head{} display OFF (scanout stopped)\n", head.index);
>  }

[ ... ]

> +unsafe extern "C" fn connector_atomic_set_property(
> +    connector: *mut bindings::drm_connector,
> +    _state: *mut bindings::drm_connector_state,
> +    property: *mut bindings::drm_property,
> +    val: u64,
> +) -> i32 {

[ ... ]

> +    if slot.swap(v, core::sync::atomic::Ordering::Relaxed) != v {
> +        let _ = data.set_vcp(head.index, vcp, v as u16);
> +    }
> +    0
> +}

[Severity: High]
Does this directly mutate global hardware state during the atomic check or
assembly phase?

In the DRM atomic architecture, the atomic_set_property callback can be
invoked during state assembly and checking, such as for test-only commits
(DRM_MODE_ATOMIC_TEST_ONLY).

Because data.set_vcp() immediately pushes the DDC/CI write to the monitor
during this phase, a test-only commit will irreversibly modify the live
hardware state, breaking the expectations of userspace compositors relying
on atomic test operations having no side effects.

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260617151249.2937-1-mike@fireburn.co.uk?part=6

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 5/7] drm/vino: register a DRM/KMS device and scan out to EP08
  2026-06-17 15:12 ` [RFC PATCH 5/7] drm/vino: register a DRM/KMS device and scan out to EP08 Mike Lothian
@ 2026-06-17 20:22   ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-06-17 20:22 UTC (permalink / raw)
  To: Mike Lothian; +Cc: dri-devel, ojeda

Thank you for your contribution! Sashiko AI review found 10 potential issue(s) to consider:
- [Critical] Out-of-Bounds Read due to missing plane `atomic_check`
- [High] Sleeping in Atomic Context
- [High] API Contract Violation / Memory Corruption for pitch
- [High] Data Race / Locking Violation in atomic_check
- [High] Workqueue Leak / UAF on Module Unload
- [High] Global Statics for Device State
- [Medium] Endianness Bug in Pixel Conversion
- [Medium] Initialization Ordering Bug causing kernel warnings
- [Medium] Hardware Desync on Cursor Resizing
- [Medium] Cursor Clipping Logic Error
--

commit 8fb2c0d2dd1d854ba4571103d0392a06d8d1e01a
Author: Mike Lothian <mike@fireburn.co.uk>
Add the drm_sink module: register a real struct drm_device with a

This commit adds a DRM/KMS device registration to the vino driver.
It implements a hand-rolled atomic mode-setting pipeline with CRTC,
primary and cursor planes. It handles EDID caching and EP08 video
scanout.

> diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
> index e9e6324b717b..1091dcc992c7 100644
> --- a/drivers/gpu/drm/vino/vino.rs
> +++ b/drivers/gpu/drm/vino/vino.rs
> @@ -79,6 +80,24 @@ fn timeout() -> Delta {
>  /// hardware -- the dock runs the whole plaintext handshake but never engages the encrypted CP.
>  static CP_ENGAGED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
>  
> +/// One-shot: clear-halt + prime the video endpoints before the first live-scanout EP08 write.
> +static EP08_SCANOUT_PRIMED: core::sync::atomic::AtomicBool =
> +    core::sync::atomic::AtomicBool::new(false);

[Severity: High]
Will this cause races when multiple docks are connected?

These variables are declared as global statics rather than per-device
state. If one dock successfully finishes bring-up, CP_ENGAGED becomes
globally true. The driver will then incorrectly assume all other
connected docks are ready and push EP08 video frames to them before
their CP is engaged, causing those docks to fault and enter a USB
reset loop.

[ ... ]

> @@ -1608,12 +1713,12 @@ fn probe<'bound>(
>          // exactly like any other failed bring-up step.
> -        match BringUp::new(intf_ref.clone()) {
> +        match BringUp::new(intf_ref.clone(), ddev.clone()) {
>              Ok(work) => {
>                  let _ = workqueue::system().enqueue(work);
>                  dev_info!(cdev, "vino: bring-up queued on system workqueue\n");

[Severity: High]
Does this leak the workqueue task on module unload?

The BringUp work item is enqueued but the handle is discarded. As a
result, VinoDriver::disconnect cannot explicitly cancel the background
task before the driver is unbound.

If the USB device is unplugged and the module is subsequently unloaded
while the work is pending or executing, the system workqueue will
attempt to execute the run function which resides in the freed text
section, causing a kernel oops.

> diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
> new file mode 100644
> index 000000000000..afbf883fba36
> --- /dev/null
> +++ b/drivers/gpu/drm/vino/drm_sink.rs
> @@ -0,0 +1,1333 @@

[ ... ]

> +unsafe extern "C" fn vino_atomic_check(
> +    dev: *mut bindings::drm_device,
> +    state: *mut bindings::drm_atomic_commit,
> +) -> i32 {
[ ... ]
> +        let mut cs = unsafe { bindings::drm_atomic_get_new_crtc_state(state, crtc) };
> +        if cs.is_null() {
> +            // SAFETY: `crtc` is initialised; `.state` is its current committed state (or NULL).
> +            cs = unsafe { (*crtc).state };
> +        }

[Severity: High]
Can this cause a data race or use-after-free?

In vino_atomic_check, the driver iterates over all heads and sums the clock
frequencies. Accessing a CRTC's state without acquiring its commit lock
(which drm_atomic_get_crtc_state handles safely) allows a concurrent
non-blocking commit on that other CRTC to modify or free its state while
it is being read here.

[ ... ]

> +unsafe extern "C" fn primary_atomic_update(
> +    plane: *mut bindings::drm_plane,
> +    _state: *mut bindings::drm_atomic_commit,
> +) {
[ ... ]
> +    let (dev_raw, fb, w, h, damage, rotation) = unsafe {
> +        let st = (*plane).state;
> +        if st.is_null() {
> +            return;
> +        }
> +        // Plane destination geometry == the negotiated mode (the compositor sizes the primary
> +        // plane 1:1 with a virtual output), so this drives the dynamic scanout resolution.
> +        let (w, h) = ((*st).crtc_w as usize, (*st).crtc_h as usize);
> +        ((*plane).dev, (*st).fb, w, h, damage_bbox(st), (*st).rotation)
> +    };

[Severity: Critical]
Could this read out-of-bounds memory?

The plane lacks an atomic_check to validate scaling constraints. In
primary_atomic_update, the driver uses the destination dimensions
(crtc_w, crtc_h) to determine the bounds for reading the mapped source
framebuffer.

If userspace attaches a small GEM buffer but requests a large destination
size, encode_and_send will perform pointer arithmetic far beyond the mapped
buffer into unmapped or unrelated kernel memory.

[ ... ]

> +    let gamma = read_gamma_lut(head);
> +    match scanout_one(data, head, fb, w, h, damage, rotation, gamma.as_ref()) {

[Severity: High]
Does this code sleep in atomic context?

The DRM subsystem requires that atomic update callbacks execute in atomic
context during non-blocking commits, where sleeping is strictly forbidden.
Here, scanout_one and encode_and_send perform multiple sleeping operations:
they lock sleeping Mutexes, allocate memory using GFP_KERNEL, map memory
using drm_gem_fb_vmap (which takes blocking dma_resv locks), and invoke
synchronous USB bulk transfers. Executing these will cause scheduling
while atomic panics and system instability.

[ ... ]

> +fn encode_and_send(
[ ... ]
> +        let (sw, sh) = if rotation_swaps_dims(rotation) { (h, w) } else { (w, h) };
> +        let pitch = sw * 4;

[Severity: High]
Does this calculation ignore the framebuffer pitch?

Userspace can allocate dumb buffers or import DMA-BUFs with padded row
pitches. By assuming a tightly packed buffer and ignoring fb->pitches[0],
the driver will read from incorrect memory offsets for all rows after the
first.

[ ... ]

> +                let px =
> +                    unsafe { (vaddr.add(sy * pitch + sx * 4) as *const u32).read_unaligned() };
> +                let (mut r, mut g, mut b) =
> +                    (((px >> 16) & 0xff) as usize, ((px >> 8) & 0xff) as usize, (px & 0xff) as usize);

[Severity: Medium]
Does this handle pixel extraction correctly on big-endian hosts?

The XRGB8888 DRM format specifies a little-endian byte order. When reading
as a native u32 on a big-endian host, the bytes are interpreted in reverse
semantic order. The subsequent hardcoded bitwise shifts will extract the
wrong color channels. The driver must explicitly perform little-endian
reads.

[ ... ]

> +fn cursor_send(
[ ... ]
> +    let (mx, my) = (
> +        cx.clamp(0, u16::MAX as i32) as u16,
> +        cy.clamp(0, u16::MAX as i32) as u16,
> +    );

[Severity: Medium]
Does this clamping prevent the cursor from smoothly transitioning off the
screen edges?

Negative coordinates are clamped to 0 instead of cropping the source sprite,
which forces the cursor to visually stick to the screen edges in its
entirety, creating a user-visible glitch.

[ ... ]

> +    if !head.cursor_primed.swap(true, core::sync::atomic::Ordering::SeqCst) {
> +        data.send_cp(hid, 0x1b, 0, |ctr| super::cp::cursor_create(ctr, w as u16, h as u16))?;
> +    }

[Severity: Medium]
Could this cause a hardware state mismatch if the cursor is resized?

The one-shot flag prevents cursor_create from being sent again even if
the dimensions change later. If userspace dynamically changes the cursor
framebuffer dimensions across atomic updates, the driver will push a
cursor_image with the new size without informing the dock via
cursor_create.

[ ... ]

> +    let pitch = w * 4;

[Severity: High]
Does this stride calculation ignore the cursor framebuffer pitch?

Just as in the primary plane, calculating the stride from the width
ignores fb->pitches[0] and will read from incorrect memory offsets
if the buffer has padding.

[ ... ]

> +        let primary = head.primary.get();
> +        to_result(bindings::drm_universal_plane_init(
> +            raw,
> +            primary,
> +            0,
> +            data.plane_funcs.get(),

[Severity: Medium]
Will this trigger kernel warnings during probe?

Passing 0 for possible_crtcs when initializing the planes right before
attaching them to the CRTC causes the DRM core to emit a warning
that the primary plane is not configured for the CRTC. The mask is patched
later, but the warning has already fired.

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260617151249.2937-1-mike@fireburn.co.uk?part=5

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up
  2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
  2026-06-17 15:17   ` Miguel Ojeda
  2026-06-17 20:11   ` sashiko-bot
@ 2026-06-18 10:39   ` Julian Braha
  2 siblings, 0 replies; 41+ messages in thread
From: Julian Braha @ 2026-06-18 10:39 UTC (permalink / raw)
  To: Mike Lothian, dri-devel
  Cc: rust-for-linux, Maarten Lankhorst, Maxime Ripard,
	Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
	Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
	Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
	linux-kernel

Hi Mike,

On 6/17/26 16:12, Mike Lothian wrote:
> +	select DRM_GEM_SHMEM_HELPER
> +	select RUST_DRM_GEM_SHMEM_HELPER

You don't need to select DRM_GEM_SHMEM_HELPER here, it's already pulled
in by RUST_DRM_GEM_SHMEM_HELPER.

- Julian Braha


^ permalink raw reply	[flat|nested] 41+ messages in thread

* [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver
  2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
                   ` (7 preceding siblings ...)
  2026-06-17 15:55 ` [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Danilo Krummrich
@ 2026-07-03  3:02 ` Mike Lothian
  2026-07-03  3:02   ` [RFC PATCH v2 01/10] drm/vino: add DisplayLink DL3 dock skeleton and protocol framing Mike Lothian
                     ` (9 more replies)
  8 siblings, 10 replies; 41+ messages in thread
From: Mike Lothian @ 2026-07-03  3:02 UTC (permalink / raw)
  To: rust-for-linux
  Cc: dri-devel, David Airlie, Simona Vetter, Thomas Zimmermann,
	Maarten Lankhorst, Maxime Ripard, Danilo Krummrich, Lyude Paul,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	linux-kernel, Mike Lothian

This is v2 of the vino RFC: an in-kernel Rust driver for DisplayLink
DL3 docks (Dell D6000, 17e9:6006), replacing the out-of-tree EVDI
module plus the DisplayLinkManager userspace daemon.

Still an RFC in the same place v1 was: the dock never engages its
content-protection channel for vino, so no pixels flow yet. What's
different is everything underneath it. v1's review said, in short,
"work on safe KMS abstractions instead of raw C KMS + as_raw()" --
that's the bulk of this round (see the drm series cover for the
detail), plus the driver picked up several months of hardware-testing
fixes since v1 that had nowhere to land until now.

   1/10  skeleton + USB bind + plaintext bring-up          (proto, rng)
   2/10  clean-room HDCP 2.2 AKE/LC/SKE                     (crypto, hdcp, ake, golden)
   3/10  AES-CTR/AES-CMAC control-plane seal + arm          (cp)
   4/10  Vino framebuffer codec                             (video)
   5/10  DRM/KMS sink, on the safe KMS mode-object layer    (drm_sink)
   6/10  the driver itself: USB bind, bring-up, KUnit tests (vino)
   7/10  the hardware cursor plane (cursor_atomic_update -> CP)      (drm_sink)
   8/10  CRTC gamma, plane rotation and DDC/CI monitor controls      (drm_sink, cp)
   9/10  two heads, 90/270 rotation, damage clips, connector probe   (drm_sink)
  10/10  hrtimer-driven software vblank for refresh-rate pacing      (drm_sink)

What's here and works, HW-verified: USB bind + plaintext session init;
HDCP 2.2 AKE (H'/L'/V'); AES-CTR + Dl3Cmac control-plane seal, byte-exact
against the reference daemon's wire traffic; the stream-open arm marker
that gets the dock processing our encrypted CP for the first time; a
registered DRM/KMS card (CRTC + primary plane + virtual connector/
encoder, GEM-shmem dumb buffers); the Vino codec's DC plane and 8x8
transform, byte-exact against captured hardware output.

What's missing / why it's still RFC:
 - No live pixels. Every host-observable byte we put on the wire now
   matches the daemon -- verified down to paired full-bus USB captures
   and xHCI TRB completion codes -- but the dock still won't ack the
   first encrypted control-plane frame. Current read is a dock-firmware
   gate unreachable from the host; a hardware USB analyser is the one
   untried next step. See docs/BLOCKER.md in the source tree for the
   full trail of what's been ruled out.
 - The codec's AC coefficient entropy grammar is reverse-engineered but
   not finished (EOB/scan/context still open), so the WHT colour path
   only covers 64x16-aligned geometry; other modes use the RLE
   fallback. Not fabricating the rest until it's verified against
   hardware.
 - Per-head EDID and per-head mode-set/DDC. Two heads are wired (patch
   9), each scanned out to its own video endpoint with its own cursor,
   but the bring-up only reads head 0's EDID, and the CP mode-set has no
   decoded head/stream field -- so a head is conveyed on the wire only by
   which endpoint its frames go to. Real dual-monitor use past the CP
   wall needs those captured.
 - Two known-open items from v1's automated review, unresolved because
   fixing them properly needs more of the safe-KMS work above: some CP
   message builders (cursor/mode-set) still GFP_KERNEL-allocate from a
   path DRM considers atomic-commit context, and CP_ENGAGED/scanout
   state are still module-global statics rather than per-device (the
   driver is single-instance throughout, so this only matters for a
   theoretical multi-dock case).

Depends on the safe KMS mode-object layer landing (posted alongside,
"[RFC PATCH v2 00/18] rust: drm: safe KMS mode-object layer + evdi
bindings") and, transitively, on the locking/DRM-registration
prerequisite postings that series depends on -- see that cover letter
for exactly what and why. This series won't build without them.

Source, HDCP/HDMI specs referenced, and the full RE trail:
  https://github.com/FireBurn/vino-scripts
  https://github.com/FireBurn/linux/tree/vino
  https://gitlab.freedesktop.org/FireBurn/linux/-/tree/vino

Changes since v1:
 - Rebuilt on the safe KMS mode-object layer instead of raw C KMS +
   as_raw() (Danilo, Miguel) -- see the drm series cover.
 - Folded in the v1 review fixes that survive independently of the KMS
   rework: in-tree AES-CMAC + key-prepared-once AES (Eric Biggers);
   Interface<Bound> threading + cancel_work_sync() of the bring-up work
   in disconnect() (fixes a dropped-work-handle UAF); EP08 16-bit
   length overflow rejected; codec SECTION_CODE mode indexing fixed;
   encoder shadow committed only on full-frame success; cursor_image
   checked arithmetic; EP84_BUF-sized cap reads; pace_cap_ack/
   lockstep_reply no longer abort on short frames; Kconfig depends on
   USB (was USB=y); #[cfg(CONFIG_KUNIT)] (was the always-false
   CONFIG_KUNIT = "y"); a redundant Kconfig select dropped (Julian
   Braha). All from the Sashiko bot + human review on v1.
 - Added the byte-exact Vino colour codec (DC plane + 8x8 transform)
   and the real EP08 wire-record framing, from continued hardware
   testing since v1.
 - Re-added the hardware cursor plane (patch 7) and, now that the safe
   KMS layer exposes them, the CRTC gamma LUT, plane rotation property
   and DDC/CI brightness/contrast/DPMS controls (patch 8) that v1's
   raw-KMS driver had.
 - Added a second display head, 90/270 rotation, damage-clipped repaint
   and connector detect()/mode_valid() (patch 9), consuming the new
   drm-series bindings. The KMS feature set now exceeds v1's.
 - Added hrtimer-driven software vblank (patch 10): the CRTC now paces
   page-flips to the display's refresh rate instead of completing them
   immediately, using the safe KMS layer's VblankSupport.

Mike Lothian (10):
  drm/vino: add DisplayLink DL3 dock skeleton and protocol framing
  drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE handshake
  drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm sequence
  drm/vino: add the Vino framebuffer codec
  drm/vino: add the DRM/KMS sink, built on the safe KMS mode-object
    layer
  drm/vino: add the DisplayLink DL3 dock driver
  drm/vino: wire the hardware cursor plane
  drm/vino: wire CRTC gamma, plane rotation and DDC/CI monitor controls
  drm/vino: two heads, 90/270 rotation, damage clips and connector probe
  drm/vino: hrtimer-driven software vblank

 drivers/gpu/drm/Kconfig          |    1 +
 drivers/gpu/drm/Makefile         |    1 +
 drivers/gpu/drm/vino/Kconfig     |   20 +
 drivers/gpu/drm/vino/Makefile    |    2 +
 drivers/gpu/drm/vino/ake.rs      |  173 ++
 drivers/gpu/drm/vino/cp.rs       |  733 +++++++++
 drivers/gpu/drm/vino/crypto.rs   |   48 +
 drivers/gpu/drm/vino/drm_sink.rs | 1173 +++++++++++++
 drivers/gpu/drm/vino/golden.rs   |   69 +
 drivers/gpu/drm/vino/hdcp.rs     |  167 ++
 drivers/gpu/drm/vino/proto.rs    |   73 +
 drivers/gpu/drm/vino/rng.rs      |   12 +
 drivers/gpu/drm/vino/video.rs    | 1229 ++++++++++++++
 drivers/gpu/drm/vino/vino.rs     | 2640 ++++++++++++++++++++++++++++++
 14 files changed, 6341 insertions(+)
 create mode 100644 drivers/gpu/drm/vino/Kconfig
 create mode 100644 drivers/gpu/drm/vino/Makefile
 create mode 100644 drivers/gpu/drm/vino/ake.rs
 create mode 100644 drivers/gpu/drm/vino/cp.rs
 create mode 100644 drivers/gpu/drm/vino/crypto.rs
 create mode 100644 drivers/gpu/drm/vino/drm_sink.rs
 create mode 100644 drivers/gpu/drm/vino/golden.rs
 create mode 100644 drivers/gpu/drm/vino/hdcp.rs
 create mode 100644 drivers/gpu/drm/vino/proto.rs
 create mode 100644 drivers/gpu/drm/vino/rng.rs
 create mode 100644 drivers/gpu/drm/vino/video.rs
 create mode 100644 drivers/gpu/drm/vino/vino.rs

-- 
2.55.0


^ permalink raw reply	[flat|nested] 41+ messages in thread

* [RFC PATCH v2 01/10] drm/vino: add DisplayLink DL3 dock skeleton and protocol framing
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
@ 2026-07-03  3:02   ` Mike Lothian
  2026-07-03  3:15     ` sashiko-bot
  2026-07-03  3:02   ` [RFC PATCH v2 02/10] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE handshake Mike Lothian
                     ` (8 subsequent siblings)
  9 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-07-03  3:02 UTC (permalink / raw)
  To: rust-for-linux
  Cc: dri-devel, David Airlie, Simona Vetter, Thomas Zimmermann,
	Maarten Lankhorst, Maxime Ripard, Danilo Krummrich, Lyude Paul,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	linux-kernel, Mike Lothian

New drm/vino driver for DisplayLink DL3 USB docks (Dell D6000,
17e9:6006), replacing the out-of-tree EVDI module plus the
DisplayLinkManager userspace daemon.

This patch adds the module skeleton (Kconfig/Makefile, registered in
drivers/gpu/drm/{Kconfig,Makefile}) and two small standalone pieces:
proto.rs (the DL3 USB wire framing this driver builds every message on
top of) and rng.rs (a thin wrapper over the kernel RNG for HDCP nonces).
Neither depends on anything else added by later patches in this series.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-sonnet-5 [Claude-Code]
---
 drivers/gpu/drm/Kconfig       |  1 +
 drivers/gpu/drm/Makefile      |  1 +
 drivers/gpu/drm/vino/Kconfig  | 20 ++++++++++
 drivers/gpu/drm/vino/Makefile |  2 +
 drivers/gpu/drm/vino/proto.rs | 73 +++++++++++++++++++++++++++++++++++
 drivers/gpu/drm/vino/rng.rs   | 12 ++++++
 6 files changed, 109 insertions(+)
 create mode 100644 drivers/gpu/drm/vino/Kconfig
 create mode 100644 drivers/gpu/drm/vino/Makefile
 create mode 100644 drivers/gpu/drm/vino/proto.rs
 create mode 100644 drivers/gpu/drm/vino/rng.rs

diff --git a/drivers/gpu/drm/Kconfig b/drivers/gpu/drm/Kconfig
index 323422861e8f..8fb9c9f787ba 100644
--- a/drivers/gpu/drm/Kconfig
+++ b/drivers/gpu/drm/Kconfig
@@ -361,6 +361,7 @@ source "drivers/gpu/drm/vgem/Kconfig"
 source "drivers/gpu/drm/virtio/Kconfig"
 source "drivers/gpu/drm/vkms/Kconfig"
 source "drivers/gpu/drm/vmwgfx/Kconfig"
+source "drivers/gpu/drm/vino/Kconfig"
 source "drivers/gpu/drm/xe/Kconfig"
 source "drivers/gpu/drm/xen/Kconfig"
 source "drivers/gpu/drm/xlnx/Kconfig"
diff --git a/drivers/gpu/drm/Makefile b/drivers/gpu/drm/Makefile
index e97faabcd783..42a04359cc63 100644
--- a/drivers/gpu/drm/Makefile
+++ b/drivers/gpu/drm/Makefile
@@ -185,6 +185,7 @@ obj-$(CONFIG_DRM_VC4)  += vc4/
 obj-$(CONFIG_DRM_VMWGFX)+= vmwgfx/
 obj-$(CONFIG_DRM_VGEM)	+= vgem/
 obj-$(CONFIG_DRM_VKMS)	+= vkms/
+obj-$(CONFIG_DRM_VINO)	+= vino/
 obj-$(CONFIG_DRM_NOUVEAU) +=nouveau/
 obj-$(CONFIG_DRM_NOVA) += nova/
 obj-$(CONFIG_DRM_EXYNOS) +=exynos/
diff --git a/drivers/gpu/drm/vino/Kconfig b/drivers/gpu/drm/vino/Kconfig
new file mode 100644
index 000000000000..f5fe6670a488
--- /dev/null
+++ b/drivers/gpu/drm/vino/Kconfig
@@ -0,0 +1,20 @@
+# SPDX-License-Identifier: GPL-2.0
+config DRM_VINO
+	tristate "DisplayLink DL3 (Vino) open driver"
+	depends on USB
+	depends on DRM
+	depends on RUST
+	select DRM_KMS_HELPER
+	select RUST_DRM_GEM_SHMEM_HELPER
+	select CRYPTO_LIB_RSA
+	help
+	  Open in-kernel Rust driver for DisplayLink DL3 USB docks (Dell
+	  Universal Dock D6000 and relatives).
+
+	  It binds the dock over USB, runs the HDCP 2.2 control plane, mode-set
+	  and the Vino codec, and registers a DRM/KMS sink that scans out to the
+	  dock's video endpoints.
+
+	  To compile this as a module, choose M here: the module is called vino.
+
+	  If unsure, say N.
diff --git a/drivers/gpu/drm/vino/Makefile b/drivers/gpu/drm/vino/Makefile
new file mode 100644
index 000000000000..6e39668040f3
--- /dev/null
+++ b/drivers/gpu/drm/vino/Makefile
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: GPL-2.0
+obj-$(CONFIG_DRM_VINO) += vino.o
diff --git a/drivers/gpu/drm/vino/proto.rs b/drivers/gpu/drm/vino/proto.rs
new file mode 100644
index 000000000000..cae6eae46b7a
--- /dev/null
+++ b/drivers/gpu/drm/vino/proto.rs
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! The DL3 "universal" wire framing and the plaintext session-init messages (sec 3/sec 4).
+
+use super::*;
+
+/// Append a sec 3-framed message to `out` with an explicit `sub_len_dw`: a 16-byte
+/// little-endian header (`pad(2) | size(2)=total-4 | type(4) | sub_id(2) |
+/// sub_len_dw(2) | seq(4)`) followed by `body`.
+///
+/// HDCP OUT messages (sec 5.1) carry DLM-fixed `sub_len_dw` values that are *not*
+/// `body.len() / 4`, so the framer cannot derive it -- the caller passes it.
+pub(super) fn push_frame_with(
+    out: &mut KVec<u8>,
+    msg_type: u32,
+    sub_id: u16,
+    sub_len_dw: u16,
+    seq: u32,
+    body: &[u8],
+) -> Result {
+    let size = ((16 + body.len()) - 4) as u16;
+    out.extend_from_slice(&[0, 0], GFP_KERNEL)?;
+    out.extend_from_slice(&size.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&msg_type.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&sub_id.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&sub_len_dw.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&seq.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(body, GFP_KERNEL)?;
+    Ok(())
+}
+
+/// `init_25` body (sec 4, verified 2026-05-27). Framed with `sub_len_dw=0` -- the
+/// DLM-fixed value, NOT `body.len()/4` (the dock ignores/rejects otherwise).
+pub(super) const INIT_25: [u8; 16] =
+    [0x05, 0, 0x08, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
+/// `init_4` (Part A) body (sec 4), also framed with `sub_len_dw=0`.
+pub(super) const INIT_4: [u8; 16] =
+    [0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
+/// The first HDCP-channel probe **body** (Part B of the init_4+probe transfer,
+/// sec 4): a 32-byte body leading with `14 00 76 00`, the rest zero. It is wrapped
+/// in its own type=4 sub=0x04 frame (`sub_len_dw=0x0a`) -- see [`init_4_probe`].
+/// The dock only ACKs once this framed probe arrives.
+pub(super) const PROBE_BODY: [u8; 32] = {
+    let mut p = [0u8; 32];
+    p[0] = 0x14;
+    p[2] = 0x76;
+    p
+};
+
+/// `init_0`: 16-byte framing header only, empty body (sec 4).
+pub(super) fn init_0() -> Result<KVec<u8>> {
+    let mut buf = KVec::with_capacity(16, GFP_KERNEL)?;
+    push_frame_with(&mut buf, 0x01, 0x00, 0, 0, &[])?;
+    Ok(buf)
+}
+
+/// `init_25`: type=2 sub=0x25, `sub_len_dw=0`, 32 bytes total (sec 4).
+pub(super) fn init_25() -> Result<KVec<u8>> {
+    let mut buf = KVec::with_capacity(32, GFP_KERNEL)?;
+    push_frame_with(&mut buf, 0x02, 0x25, 0, 0, &INIT_25)?;
+    Ok(buf)
+}
+
+/// `init_4` + HDCP probe as one 80-byte transfer (sec 4): Part A (type=2 sub=0x04,
+/// `sub_len_dw=0`, 32 B) concatenated with Part B -- the probe framed as type=4
+/// sub=0x04 with `sub_len_dw=0x0a` over the 32-byte [`PROBE_BODY`] (48 B). This
+/// is the message the dock ACKs.
+pub(super) fn init_4_probe() -> Result<KVec<u8>> {
+    let mut buf = KVec::with_capacity(80, GFP_KERNEL)?;
+    push_frame_with(&mut buf, 0x02, 0x04, 0, 0, &INIT_4)?; // Part A
+    push_frame_with(&mut buf, 0x04, 0x04, 0x0a, 0, &PROBE_BODY)?; // Part B (framed probe)
+    Ok(buf)
+}
diff --git a/drivers/gpu/drm/vino/rng.rs b/drivers/gpu/drm/vino/rng.rs
new file mode 100644
index 000000000000..8720d55174ae
--- /dev/null
+++ b/drivers/gpu/drm/vino/rng.rs
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Cryptographically-secure randomness for the per-session HDCP nonces/keys
+//! (`rtx`, `km`, `rn`, `ks`, `riv`, the OAEP seed).
+#![allow(dead_code)] // RNG helpers; some are reached only on the post-engagement CP path
+
+/// Fills `buf` with random bytes from the kernel CSPRNG (`get_random_bytes`).
+pub(super) fn fill(buf: &mut [u8]) {
+    // SAFETY: `buf` is valid for writes of `buf.len()` bytes; `get_random_bytes`
+    // writes exactly that many and never sleeps/faults on a kernel buffer.
+    unsafe { kernel::bindings::get_random_bytes(buf.as_mut_ptr().cast(), buf.len()) };
+}
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH v2 02/10] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE handshake
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
  2026-07-03  3:02   ` [RFC PATCH v2 01/10] drm/vino: add DisplayLink DL3 dock skeleton and protocol framing Mike Lothian
@ 2026-07-03  3:02   ` Mike Lothian
  2026-07-03  3:14     ` sashiko-bot
  2026-07-03  3:02   ` [RFC PATCH v2 03/10] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm sequence Mike Lothian
                     ` (7 subsequent siblings)
  9 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-07-03  3:02 UTC (permalink / raw)
  To: rust-for-linux
  Cc: dri-devel, David Airlie, Simona Vetter, Thomas Zimmermann,
	Maarten Lankhorst, Maxime Ripard, Danilo Krummrich, Lyude Paul,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	linux-kernel, Mike Lothian

crypto.rs wraps the crypto bindings (AES-128, AES-CMAC) for the KDF this
driver needs; hdcp.rs holds the HDCP 2.2 message-ID constants and wire
structs (reusing <drm/display/drm_hdcp.h> where it overlaps); ake.rs
builds/parses the AKE_Init/AKE_Send_Cert/AKE_No_Stored_km/
AKE_Send_H_prime/AKE_Send_Pairing_Info, LC_Init/LC_Send_L_prime and
SKE_Send_Eks messages plus the RepeaterAuth exchange, all verified
byte-exact against the reference daemon's wire traffic; golden.rs holds
a small captured plaintext capability-announce skeleton the handshake
fills with live session values.

This is a clean-room implementation of the standard HDCP 2.2 AKE from
the published spec and the driver's own captured wire traffic -- no
daemon code or binary content is reused.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-sonnet-5 [Claude-Code]
---
 drivers/gpu/drm/vino/ake.rs    | 173 +++++++++++++++++++++++++++++++++
 drivers/gpu/drm/vino/crypto.rs |  48 +++++++++
 drivers/gpu/drm/vino/golden.rs |  69 +++++++++++++
 drivers/gpu/drm/vino/hdcp.rs   | 167 +++++++++++++++++++++++++++++++
 4 files changed, 457 insertions(+)
 create mode 100644 drivers/gpu/drm/vino/ake.rs
 create mode 100644 drivers/gpu/drm/vino/crypto.rs
 create mode 100644 drivers/gpu/drm/vino/golden.rs
 create mode 100644 drivers/gpu/drm/vino/hdcp.rs

diff --git a/drivers/gpu/drm/vino/ake.rs b/drivers/gpu/drm/vino/ake.rs
new file mode 100644
index 000000000000..38919624cb2f
--- /dev/null
+++ b/drivers/gpu/drm/vino/ake.rs
@@ -0,0 +1,173 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! HDCP 2.2 AKE wire layer (sec 5.1 OUT framing, sec 5.2 IN parsing) -- the byte-exact
+//! message builders the AKE state machine drives, mirroring the verified userspace
+//! oracle (`vino-driver::hdcp_msgs`). DLM hardcodes per-message `sub_size` /
+//! `sub_len_dw` values the dock validates, so they are reproduced verbatim rather
+//! than derived.
+//!
+//! OUT body layout (sec 5.1), after the 16-byte sec 3 transport header:
+//! ```text
+//!   body[0..2]   u16 sub_size      (DLM-fixed per message)
+//!   body[2..4]   u16 = 0x0010
+//!   body[4..8]   u32 hdcp_seq      increments 1..7 across the AKE OUT messages
+//!   body[8..22]  14 zero bytes
+//!   body[22..26] u32 = 0x00000030  marker
+//!   body[26]     u8  = 0x00        flag
+//!   body[27]     u8  = msg_id
+//!   body[28..]   HDCP payload (zero-padded to the fixed body length)
+//! ```
+#![allow(dead_code)] // AKE message builders; response handlers run only after CP engagement
+
+use super::*;
+
+/// HDCP 2.2 message IDs (sec 5.3). `pub(crate)` so the AKE state machine
+/// ([`super::VinoDriver::run_ake`]) can match on the response IDs too.
+pub(crate) mod id {
+    use kernel::bindings;
+
+    // Standard HDCP 2.2 message IDs: reuse the canonical values from
+    // `<drm/display/drm_hdcp.h>` rather than redefining them, so vino stays in
+    // lockstep with the kernel's HDCP definitions. Only the transport framing
+    // around these (the DisplayLink type/sub/ctr header) is vino-specific.
+    pub(crate) const AKE_INIT: u8 = bindings::HDCP_2_2_AKE_INIT as u8;
+    pub(crate) const AKE_SEND_CERT: u8 = bindings::HDCP_2_2_AKE_SEND_CERT as u8;
+    pub(crate) const AKE_NO_STORED_KM: u8 = bindings::HDCP_2_2_AKE_NO_STORED_KM as u8;
+    pub(crate) const AKE_SEND_H_PRIME: u8 = bindings::HDCP_2_2_AKE_SEND_HPRIME as u8;
+    pub(crate) const AKE_SEND_PAIRING_INFO: u8 = bindings::HDCP_2_2_AKE_SEND_PAIRING_INFO as u8;
+    pub(crate) const LC_INIT: u8 = bindings::HDCP_2_2_LC_INIT as u8;
+    pub(crate) const LC_SEND_L_PRIME: u8 = bindings::HDCP_2_2_LC_SEND_LPRIME as u8;
+    pub(crate) const SKE_SEND_EKS: u8 = bindings::HDCP_2_2_SKE_SEND_EKS as u8;
+    pub(crate) const REPEATERAUTH_SEND_RECEIVERID_LIST: u8 =
+        bindings::HDCP_2_2_REP_SEND_RECVID_LIST as u8;
+    pub(crate) const REPEATERAUTH_SEND_ACK: u8 = bindings::HDCP_2_2_REP_SEND_ACK as u8;
+    pub(crate) const REPEATERAUTH_STREAM_MANAGE: u8 = bindings::HDCP_2_2_REP_STREAM_MANAGE as u8;
+    pub(crate) const REPEATERAUTH_STREAM_READY: u8 = bindings::HDCP_2_2_REP_STREAM_READY as u8;
+
+    // DisplayLink-specific message IDs with no `<drm/display/drm_hdcp.h>` equivalent
+    // (the AKE_Send_rrx split and the transmitter/receiver-info + auth-status messages
+    // the DL3 dock uses), kept as literals.
+    pub(crate) const AKE_SEND_RRX: u8 = 0x06;
+    pub(crate) const RECEIVER_AUTH_STATUS: u8 = 0x12;
+    pub(crate) const AKE_TRANSMITTER_INFO: u8 = 0x13;
+    pub(crate) const AKE_RECEIVER_INFO: u8 = 0x14;
+}
+
+/// transport `sub_id` for HDCP OUT messages (type=4 sub=0x04, sec 5.1).
+const SUB_HDCP: u16 = 0x04;
+
+/// Allocate a `body_len`-byte zeroed body with the sec 5.1 header filled in
+/// (`sub_size`, the `0x0010` marker, `hdcp_seq`, the `0x30` marker and `msg_id`).
+/// The caller writes the payload into `body[28..]`.
+fn body(body_len: usize, sub_size: u16, hdcp_seq: u32, msg_id: u8) -> Result<KVec<u8>> {
+    let mut b = KVec::from_elem(0u8, body_len, GFP_KERNEL)?;
+    b[0..2].copy_from_slice(&sub_size.to_le_bytes());
+    b[2..4].copy_from_slice(&0x0010u16.to_le_bytes());
+    b[4..8].copy_from_slice(&hdcp_seq.to_le_bytes());
+    b[22..26].copy_from_slice(&0x0000_0030u32.to_le_bytes());
+    b[27] = msg_id;
+    Ok(b)
+}
+
+/// Wrap a finished HDCP body in the sec 3 transport header (type=4 sub=0x04) with
+/// the DLM-fixed `sub_len_dw` and the transport `seq`.
+fn wrap(sub_len_dw: u16, seq: u32, body: &[u8]) -> Result<KVec<u8>> {
+    let mut frame = KVec::with_capacity(16 + body.len(), GFP_KERNEL)?;
+    proto::push_frame_with(&mut frame, 0x04, SUB_HDCP, sub_len_dw, seq, body)?;
+    Ok(frame)
+}
+
+/// `AKE_Init` (msg_id 0x02): `rtx[8] || TxCaps[3]`, padded to a 48-byte body
+/// (`sub_size=0x22`, `sub_len_dw=0x0c` -- guide sec 5.4 table).
+pub(super) fn ake_init(
+    hdcp_seq: u32,
+    seq: u32,
+    rtx: &[u8; 8],
+    tx_caps: &[u8; 3],
+) -> Result<KVec<u8>> {
+    let mut b = body(48, 0x0022, hdcp_seq, id::AKE_INIT)?;
+    b[28..36].copy_from_slice(rtx);
+    b[36..39].copy_from_slice(tx_caps);
+    wrap(0x000c, seq, &b)
+}
+
+/// `AKE_Transmitter_Info` (msg_id 0x13): byte-exact DLM framing
+/// (`sub_size=0x1f`, `sub_len_dw=0x0f`), payload `00 06 02 00 02`.
+pub(super) fn ake_transmitter_info(hdcp_seq: u32, seq: u32) -> Result<KVec<u8>> {
+    let mut b = body(48, 0x001f, hdcp_seq, id::AKE_TRANSMITTER_INFO)?;
+    b[28..33].copy_from_slice(&[0x00, 0x06, 0x02, 0x00, 0x02]);
+    wrap(0x000f, seq, &b)
+}
+
+/// `AKE_No_Stored_km` (msg_id 0x04): the 128-byte RSA-OAEP-SHA256 `Ekpub(km)`
+/// in a 160-byte body (`sub_size=0x9a`, `sub_len_dw=0x04` -- guide sec 5.4 table).
+pub(super) fn ake_no_stored_km(
+    hdcp_seq: u32,
+    seq: u32,
+    ekpub_km: &[u8; 128],
+) -> Result<KVec<u8>> {
+    let mut b = body(160, 0x009a, hdcp_seq, id::AKE_NO_STORED_KM)?;
+    b[28..156].copy_from_slice(ekpub_km);
+    wrap(0x0004, seq, &b)
+}
+
+/// `LC_Init` (msg_id 0x09): `rn[8]` in a 48-byte body
+/// (`sub_size=0x22`, `sub_len_dw=0x0c`).
+pub(super) fn lc_init(hdcp_seq: u32, seq: u32, rn: &[u8; 8]) -> Result<KVec<u8>> {
+    let mut b = body(48, 0x0022, hdcp_seq, id::LC_INIT)?;
+    b[28..36].copy_from_slice(rn);
+    wrap(0x000c, seq, &b)
+}
+
+/// `SKE_Send_Eks` (msg_id 0x0b): `Edkey(ks)[16] || riv[8]` in a 64-byte body
+/// (`sub_size=0x32`, `sub_len_dw=0x0c`).
+pub(super) fn ske_send_eks(
+    hdcp_seq: u32,
+    seq: u32,
+    edkey_ks: &[u8; 16],
+    riv: &[u8; 8],
+) -> Result<KVec<u8>> {
+    let mut b = body(64, 0x0032, hdcp_seq, id::SKE_SEND_EKS)?;
+    b[28..44].copy_from_slice(edkey_ks);
+    b[44..52].copy_from_slice(riv);
+    wrap(0x000c, seq, &b)
+}
+
+/// `RepeaterAuth_Send_ACK` (msg_id 0x0f): the full `V[16]` in a 48-byte body
+/// (`sub_size=0x2a`, `sub_len_dw=0x04`).
+pub(super) fn repeater_auth_send_ack(
+    hdcp_seq: u32,
+    seq: u32,
+    v: &[u8; 16],
+) -> Result<KVec<u8>> {
+    let mut b = body(48, 0x002a, hdcp_seq, id::REPEATERAUTH_SEND_ACK)?;
+    b[28..44].copy_from_slice(v);
+    wrap(0x0004, seq, &b)
+}
+
+/// `RepeaterAuth_Stream_Manage` SM2 (msg_id 0x10): byte-exact DLM replica sent
+/// after Send_ACK -- `k=2` (LE), `StreamID_Type[0]=4` (LE), `body[43]=0x05`
+/// (`sub_size=0x2d`, `sub_len_dw=0x01`). See guide sec 5.4 and sec 8.2.
+///
+/// `type0` (CP_STREAM_TYPE0 experiment, 2026-06-23): zero the two DL3 stream-type bytes
+/// (0x04/0x05 -> 0x00) so the dock is asked for a single Type-0 (unrestricted) stream
+/// instead of the replicated DLM stream set. Default DLM-exact (`type0 == false`); the M
+/// computation in `wait_cap_complete` zeroes the matching `m_data` bytes under the same flag.
+pub(super) fn repeater_auth_stream_manage(hdcp_seq: u32, seq: u32, type0: bool) -> Result<KVec<u8>> {
+    let mut b = body(48, 0x002d, hdcp_seq, id::REPEATERAUTH_STREAM_MANAGE)?;
+    b[32..36].copy_from_slice(&[0x02, 0, 0, 0]); // k = 2 (LE)
+    let (s0, s1) = if type0 { (0x00, 0x00) } else { (0x04, 0x05) };
+    b[36..40].copy_from_slice(&[s0, 0, 0, 0]); // StreamID_Type[0]
+    b[43] = s1; // StreamID_Type[1]
+    wrap(0x0001, seq, &b)
+}
+
+/// Parse an IN HDCP message body (sec 5.2): `body[8]` marker, `body[9]` msg_id,
+/// `body[10..]` payload (for `AKE_Send_Cert`, `body[10]` is a version flag).
+/// Returns `(msg_id, payload)`.
+pub(super) fn parse_in(body: &[u8]) -> Option<(u8, &[u8])> {
+    if body.len() < 10 {
+        return None;
+    }
+    Some((body[9], &body[10..]))
+}
diff --git a/drivers/gpu/drm/vino/crypto.rs b/drivers/gpu/drm/vino/crypto.rs
new file mode 100644
index 000000000000..83e988ded726
--- /dev/null
+++ b/drivers/gpu/drm/vino/crypto.rs
@@ -0,0 +1,48 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Thin adapters onto the shared [`kernel::crypto`] library-crypto bindings, so the
+//! protocol code keeps its `crypto::aes128_ecb` / `crypto::hmac_sha256` call sites.
+#![allow(dead_code)] // exercised by the AES-CTR seal + HDCP AKE
+
+use super::*;
+
+/// An AES-128 key prepared once for single-block encryption; re-exported so the
+/// AES-CTR keystream loops in [`cp`](super::cp) can expand the key once and reuse
+/// it across every block (rather than re-expanding per block).
+pub(super) use kernel::crypto::Aes128;
+
+/// `AES_ECB(key, block)` -- one 16-byte AES-128 block. Convenience one-shot for
+/// callers that encrypt a single block (e.g. HDCP dKey derivation); the AES-CTR
+/// paths build an [`Aes128`] once and call [`Aes128::encrypt_block`] in a loop.
+pub(super) fn aes128_ecb(key: &[u8; 16], block: &[u8; 16]) -> Result<[u8; 16]> {
+    Ok(Aes128::new(key)?.encrypt_block(block))
+}
+
+/// `HMAC-SHA256(key, data)`.
+pub(super) fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
+    kernel::crypto::hmac_sha256(key, data)
+}
+
+/// `AES-CMAC-128(key, data)` (RFC 4493) via the in-tree AES-CMAC library.
+/// This is DisplayLink's "Dl3Cmac" core -- the CP per-message integrity tag is
+/// `AES_CMAC(ks, nonce8 || BE64(counter) || content)` (see `cp::dl3cmac_tag`);
+/// verified byte-exact against live DLM data (canonical guide sec 8.6.7).
+pub(super) fn aes_cmac(key: &[u8; 16], data: &[u8]) -> [u8; 16] {
+    kernel::crypto::aes_cmac(key, data)
+}
+
+/// `SHA256(data)`.
+pub(super) fn sha256(data: &[u8]) -> [u8; 32] {
+    kernel::crypto::sha256(data)
+}
+
+/// Raw RSA public-key op `out = input^exponent mod modulus`, big-endian,
+/// `out` written fixed-width (caller applies OAEP padding to `input`).
+pub(super) fn rsa_pubkey_encrypt(
+    modulus: &[u8],
+    exponent: &[u8],
+    input: &[u8],
+    out: &mut [u8],
+) -> Result {
+    kernel::crypto::rsa_pubkey_encrypt(modulus, exponent, input, out)
+}
diff --git a/drivers/gpu/drm/vino/golden.rs b/drivers/gpu/drm/vino/golden.rs
new file mode 100644
index 000000000000..e379e888c9c8
--- /dev/null
+++ b/drivers/gpu/drm/vino/golden.rs
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Captured DisplayLink control-plane protocol templates.
+//!
+//! These are NOT replay dumps of an encrypted session. They are the
+//! session-invariant *plaintext skeletons* of two control-plane bursts captured
+//! from the proprietary DisplayLinkManager (DLM). The driver overwrites the
+//! session-specific fields with THIS session's live values and then seals the
+//! result under the live `ks`, so the bytes that reach the wire are this
+//! session's own, never the capture's. They remain inline here because the
+//! field-by-field live builders that would replace them are not yet written --
+//! see the "help wanted" note at the top of the file.
+
+/// Plaintext capability-announce skeleton: the seven `sub=0x10`, ctr 1..7
+/// frames that restate the AKE OUT messages. `build_cap_announce` walks this
+/// and overwrites each frame's payload with this session's live AKE value
+/// (rtx / Ekpub / rn / Edkey+riv / V). 590 bytes.
+pub(super) const CAP_PLAIN_1080P: &[u8] = &[
+    0x40, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00,
+    0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x22, 0x00, 0x10, 0x00, 0x01, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x02, 0x1f, 0xe7,
+    0x18, 0x56, 0x6e, 0x1f, 0xc0, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x3c, 0x00,
+    0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x1f, 0x00, 0x10, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00,
+    0x00, 0x00, 0x00, 0x13, 0x00, 0x06, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0xb0, 0x00, 0x00, 0x00, 0xac, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00,
+    0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9a, 0x00, 0x10, 0x00, 0x03, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x04, 0x0e, 0xd9,
+    0x2f, 0x05, 0xee, 0x3e, 0xca, 0x40, 0x7e, 0x14, 0x9f, 0x9d, 0x12, 0x6c,
+    0xca, 0x1a, 0x70, 0x27, 0x55, 0x02, 0x22, 0x0c, 0xde, 0x7d, 0x79, 0x6b,
+    0x13, 0x14, 0x32, 0x62, 0xef, 0x62, 0xc0, 0xf2, 0xb6, 0x3d, 0x41, 0x21,
+    0xcf, 0xbd, 0x2a, 0x40, 0xf9, 0xe8, 0x42, 0xc7, 0xbb, 0xa7, 0xcd, 0x8c,
+    0x53, 0xab, 0x56, 0x4e, 0x5b, 0xf8, 0x55, 0x0a, 0x05, 0x96, 0x09, 0x28,
+    0xbb, 0xf9, 0xbe, 0xc9, 0xe8, 0x81, 0x32, 0xaa, 0xc8, 0x49, 0x27, 0x3c,
+    0x80, 0x5c, 0x7c, 0xb8, 0x23, 0x54, 0xb6, 0xe0, 0x38, 0x71, 0x3c, 0xdd,
+    0xa6, 0x77, 0x91, 0x16, 0x3f, 0xd4, 0xec, 0xfd, 0xdd, 0x56, 0xf7, 0x01,
+    0xe1, 0x6c, 0x03, 0x50, 0xdf, 0x80, 0xd5, 0x93, 0x66, 0x55, 0xe1, 0xd7,
+    0x3b, 0x55, 0x7e, 0x9c, 0xb7, 0x71, 0xfe, 0x0b, 0x7d, 0x1c, 0x0d, 0x6b,
+    0x18, 0xda, 0xdb, 0xbe, 0x79, 0x75, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00,
+    0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x0c, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x22, 0x00, 0x10, 0x00, 0x04, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x09, 0xf4, 0xc4, 0x61, 0x0d,
+    0xe0, 0x75, 0x99, 0xf5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x4c, 0x00, 0x04, 0x00,
+    0x00, 0x00, 0x04, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x32, 0x00,
+    0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00,
+    0x00, 0x0b, 0xb2, 0xd9, 0xbd, 0x87, 0x94, 0x1b, 0xf0, 0xec, 0x59, 0x40,
+    0xf2, 0xba, 0xd5, 0x6d, 0x24, 0xab, 0x56, 0xfe, 0x0c, 0xff, 0xbc, 0x3a,
+    0x9d, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00,
+    0x04, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a, 0x00, 0x10, 0x00,
+    0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x0f,
+    0x38, 0x08, 0x3b, 0x1f, 0x39, 0x61, 0xb4, 0x9b, 0x3a, 0x2e, 0x9a, 0x1c,
+    0xbd, 0x64, 0x78, 0x85, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00,
+    0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x2d, 0x00, 0x10, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+    0x30, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00,
+    0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00,
+    0x00, 0x00,
+];
diff --git a/drivers/gpu/drm/vino/hdcp.rs b/drivers/gpu/drm/vino/hdcp.rs
new file mode 100644
index 000000000000..c22d58b624ab
--- /dev/null
+++ b/drivers/gpu/drm/vino/hdcp.rs
@@ -0,0 +1,167 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! HDCP 2.2 key derivation and verifier computation (sec 5.6), built on [`crypto`].
+//! Lets the driver run a clean-room AKE without DisplayLink's binary; the byte-exact
+//! formulas are verified against the live dock in the guide.
+#![allow(dead_code)] // some HDCP builders/handlers are reached only after CP engagement
+
+use super::*;
+
+/// `dkey_n = AES_ECB(km with low-8-bytes XOR rn, rtx || (rrx with byte15 XOR n))`
+/// (HDCP 2.2 IIA sec 2.7, sec 5.6). The counter `n` XORs into byte 15 (LSB of the rrx
+/// half) of the IV; `rn` XORs into the low 8 bytes (km[8..16]) of the key -- zero
+/// for the `kd` derivation, the SKE nonce for `dkey_2`.
+fn derive_dkey(
+    km: &[u8; 16],
+    rn: &[u8; 8],
+    rtx: &[u8; 8],
+    rrx: &[u8; 8],
+    n: u8,
+) -> Result<[u8; 16]> {
+    let mut iv = [0u8; 16];
+    iv[..8].copy_from_slice(rtx);
+    iv[8..].copy_from_slice(rrx);
+    iv[15] ^= n;
+    let mut key = *km;
+    for i in 0..8 {
+        key[8 + i] ^= rn[i];
+    }
+    crypto::aes128_ecb(&key, &iv)
+}
+
+/// `kd = dkey_0 || dkey_1` with `rn = 0` (sec 5.6) -- the 256-bit derived key.
+pub(super) fn derive_kd(km: &[u8; 16], rtx: &[u8; 8], rrx: &[u8; 8]) -> Result<[u8; 32]> {
+    let rn = [0u8; 8];
+    let dkey0 = derive_dkey(km, &rn, rtx, rrx, 0)?;
+    let dkey1 = derive_dkey(km, &rn, rtx, rrx, 1)?;
+    let mut kd = [0u8; 32];
+    kd[..16].copy_from_slice(&dkey0);
+    kd[16..].copy_from_slice(&dkey1);
+    Ok(kd)
+}
+
+/// `H' = HMAC-SHA256(kd, rtx with byte7 ^= repeater)` (sec 5.6).
+pub(super) fn compute_h(kd: &[u8; 32], rtx: &[u8; 8], repeater: bool) -> [u8; 32] {
+    let mut msg = *rtx;
+    msg[7] ^= repeater as u8;
+    crypto::hmac_sha256(kd, &msg)
+}
+
+/// `L' = HMAC-SHA256(kd with low-8-bytes XOR rrx, rn)` (sec 5.6).
+///
+/// "low-8-bytes" is the *least-significant* 64 bits of the 256-bit `kd`, i.e.
+/// `kd[24..32]` -- verified byte-exact against the live dock by the userspace
+/// oracle (`vino-hdcp::kdf::compute_l`). XOR-ing into `kd[0..8]` does not verify.
+pub(super) fn compute_l(kd: &[u8; 32], rrx: &[u8; 8], rn: &[u8; 8]) -> [u8; 32] {
+    let mut key = *kd;
+    for i in 0..8 {
+        key[24 + i] ^= rrx[i];
+    }
+    crypto::hmac_sha256(&key, rn)
+}
+
+/// Full `V = HMAC-SHA256(kd, list_header)` (256 bits) for RepeaterAuth (sec 2.3).
+/// The **MSB-128** (`[..16]`) is `V'` -- verified against the repeater's
+/// `RepeaterAuth_Send_ReceiverID_List` trailer. The **LSB-128** (`[16..]`) is the
+/// value the transmitter returns in `RepeaterAuth_Send_Ack`. vino had been sending
+/// the MSB (i.e. echoing the dock's own `V'`) as the Ack -- so the dock rejected the
+/// repeater authentication, never acknowledged Stream_Manage, and never engaged CP
+/// (proven 2026-06-11: vino's ctr6 == the dock's `id=0x21` list trailer; DLM's ctr6
+/// is a computed value present in no dock push). H'/L'/V' still pass because V'
+/// verification uses the MSB.
+pub(super) fn compute_v_full(kd: &[u8; 32], list_header: &[u8]) -> [u8; 32] {
+    crypto::hmac_sha256(kd, list_header)
+}
+
+/// MGF1 mask generation (RFC 8017 sec B.2.1) with SHA-256: returns `mask_len`
+/// bytes of `T = SHA256(seed || I2OSP(0,4)) || SHA256(seed || I2OSP(1,4)) || ...`.
+fn mgf1_sha256(seed: &[u8], mask_len: usize) -> Result<KVec<u8>> {
+    let mut mask = KVec::with_capacity(mask_len, GFP_KERNEL)?;
+    let mut counter: u32 = 0;
+    let mut block = KVec::with_capacity(seed.len() + 4, GFP_KERNEL)?;
+    while mask.len() < mask_len {
+        block.clear();
+        block.extend_from_slice(seed, GFP_KERNEL)?;
+        block.extend_from_slice(&counter.to_be_bytes(), GFP_KERNEL)?;
+        let digest = crypto::sha256(&block);
+        let take = core::cmp::min(digest.len(), mask_len - mask.len());
+        mask.extend_from_slice(&digest[..take], GFP_KERNEL)?;
+        counter += 1;
+    }
+    Ok(mask)
+}
+
+/// EME-OAEP encode (RFC 8017 sec 7.1.1) with SHA-256 and an empty label, for a
+/// `k`-byte modulus. Returns the `k`-byte encoded message `EM` ready for the
+/// raw RSA op. `seed` is `hLen` (32) random bytes. HDCP 2.2 uses SHA-256 here
+/// (SHA-1 makes the dock stop responding -- guide sec 5.4).
+fn eme_oaep_encode(k: usize, msg: &[u8], seed: &[u8; 32]) -> Result<KVec<u8>> {
+    const HLEN: usize = 32;
+    // DB = lHash || PS(zeros) || 0x01 || M, length k - hLen - 1.
+    let l_hash = crypto::sha256(&[]);
+    let db_len = k - HLEN - 1;
+    let mut db = KVec::with_capacity(db_len, GFP_KERNEL)?;
+    db.extend_from_slice(&l_hash, GFP_KERNEL)?;
+    let ps_len = db_len - HLEN - 1 - msg.len(); // k - mLen - 2*hLen - 2
+    for _ in 0..ps_len {
+        db.push(0, GFP_KERNEL)?;
+    }
+    db.push(0x01, GFP_KERNEL)?;
+    db.extend_from_slice(msg, GFP_KERNEL)?;
+    // maskedDB = DB ^ MGF1(seed, db_len).
+    let db_mask = mgf1_sha256(seed, db_len)?;
+    for i in 0..db_len {
+        db[i] ^= db_mask[i];
+    }
+    // maskedSeed = seed ^ MGF1(maskedDB, hLen).
+    let seed_mask = mgf1_sha256(&db, HLEN)?;
+    let mut masked_seed = [0u8; HLEN];
+    for i in 0..HLEN {
+        masked_seed[i] = seed[i] ^ seed_mask[i];
+    }
+    // EM = 0x00 || maskedSeed || maskedDB.
+    let mut em = KVec::with_capacity(k, GFP_KERNEL)?;
+    em.push(0x00, GFP_KERNEL)?;
+    em.extend_from_slice(&masked_seed, GFP_KERNEL)?;
+    em.extend_from_slice(&db, GFP_KERNEL)?;
+    Ok(em)
+}
+
+/// RSA-OAEP-SHA256 encrypt the 16-byte master key `km` under the dock's
+/// RSA-1024 public key (`modulus[128]`, `exponent`), giving the 128-byte
+/// `Ekpub(km)` for `AKE_No_Stored_km` (sec 5.4). Generates a fresh OAEP seed.
+pub(super) fn oaep_encrypt_km(
+    modulus: &[u8; 128],
+    exponent: &[u8],
+    km: &[u8; 16],
+) -> Result<[u8; 128]> {
+    let mut seed = [0u8; 32];
+    super::rng::fill(&mut seed);
+    let em = eme_oaep_encode(128, km, &seed)?;
+    let mut out = [0u8; 128];
+    crypto::rsa_pubkey_encrypt(modulus, exponent, &em, &mut out)?;
+    Ok(out)
+}
+
+/// SKE: `Edkey(ks) = ks XOR (dkey_2 with low-8-bytes XOR rrx)` (sec 5.6).
+///
+/// `dkey_2` is derived with the SKE nonce `rn` mixed into the key; `rrx` then
+/// XORs into the low 8 bytes (`dkey_2[8..16]`) of the mask. The result is the
+/// 16-byte `Edkey_ks` carried by `SKE_Send_Eks` (msg_id 0x0b).
+pub(super) fn compute_eks(
+    km: &[u8; 16],
+    rtx: &[u8; 8],
+    rrx: &[u8; 8],
+    rn: &[u8; 8],
+    ks: &[u8; 16],
+) -> Result<[u8; 16]> {
+    let mut mask = derive_dkey(km, rn, rtx, rrx, 2)?;
+    for i in 0..8 {
+        mask[8 + i] ^= rrx[i];
+    }
+    let mut edkey_ks = [0u8; 16];
+    for i in 0..16 {
+        edkey_ks[i] = ks[i] ^ mask[i];
+    }
+    Ok(edkey_ks)
+}
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH v2 03/10] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm sequence
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
  2026-07-03  3:02   ` [RFC PATCH v2 01/10] drm/vino: add DisplayLink DL3 dock skeleton and protocol framing Mike Lothian
  2026-07-03  3:02   ` [RFC PATCH v2 02/10] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE handshake Mike Lothian
@ 2026-07-03  3:02   ` Mike Lothian
  2026-07-03  3:13     ` sashiko-bot
  2026-07-03  3:02   ` [RFC PATCH v2 04/10] drm/vino: add the Vino framebuffer codec Mike Lothian
                     ` (6 subsequent siblings)
  9 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-07-03  3:02 UTC (permalink / raw)
  To: rust-for-linux
  Cc: dri-devel, David Airlie, Simona Vetter, Thomas Zimmermann,
	Maarten Lankhorst, Maxime Ripard, Danilo Krummrich, Lyude Paul,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	linux-kernel, Mike Lothian

cp.rs implements the post-AKE control-plane wire format: the AES-CTR
seal (K_dl3 = ks, keystream AES-CTR(ks, riv || BE64(seq)) over the
whole inner message), the Dl3Cmac trailer (AES-CMAC over the sealed
ciphertext with a riv-derived nonce, encrypt-then-MAC), the stream-open
arm marker that makes the dock start processing encrypted CP, and the
message builders for mode-set, cursor, and DDC/CI Set-VCP (brightness/
contrast/DPMS power). All of this is verified byte-exact against the
reference daemon's captured wire traffic.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-sonnet-5 [Claude-Code]
---
 drivers/gpu/drm/vino/cp.rs | 723 +++++++++++++++++++++++++++++++++++++
 1 file changed, 723 insertions(+)
 create mode 100644 drivers/gpu/drm/vino/cp.rs

diff --git a/drivers/gpu/drm/vino/cp.rs b/drivers/gpu/drm/vino/cp.rs
new file mode 100644
index 000000000000..4d55b96999b0
--- /dev/null
+++ b/drivers/gpu/drm/vino/cp.rs
@@ -0,0 +1,723 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Encrypted-control-plane message builders (the inner plaintext of the type=4
+//! sub=0x24 AES-CTR frames) plus the AES-CTR `seal` that encrypts and frames them.
+//! Layouts are from the reverse-engineered protocol; offsets cite the guide and
+//! should be re-checked against a capture before they drive real hardware.
+#![allow(dead_code)] // some seal/handler paths run only after the dock engages CP (open blocker)
+
+use super::*;
+
+/// Common CP inner header: `[id u16][sub u16][counter u16][00 00]` (sec 6.1/sec 8.6.4).
+fn header(out: &mut KVec<u8>, id: u16, sub: u16, counter: u16) -> Result {
+    out.extend_from_slice(&id.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&sub.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&counter.to_le_bytes(), GFP_KERNEL)?;
+    out.extend_from_slice(&[0, 0], GFP_KERNEL)?;
+    Ok(())
+}
+
+fn pad_to(out: &mut KVec<u8>, len: usize) -> Result {
+    while out.len() < len {
+        out.push(0, GFP_KERNEL)?;
+    }
+    Ok(())
+}
+
+/// OUT heartbeat (sec 6.1): `id=0x16 sub=0x75`, two AES blocks (`10 27` at block1+6).
+pub(super) fn heartbeat(counter: u16) -> Result<KVec<u8>> {
+    let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
+    header(&mut b, 0x16, 0x75, counter)?;
+    pad_to(&mut b, 22)?; // block0 tail + block1[0..6]
+    b.extend_from_slice(&[0x10, 0x27], GFP_KERNEL)?; // block1[6..8]
+    pad_to(&mut b, 32)?;
+    Ok(b)
+}
+
+/// OUT get-EDID request (CP-HANDSHAKE.md sec 4f): `id=0x15 sub=0x21`, the message that asks
+/// the dock to return the downstream monitor's EDID in an `id=0x194 sub=0x21` reply (parsed
+/// by [`parse_edid_from_reply`]). The request carries no payload beyond the inner header, so
+/// it is a single 16-byte AES block; [`seal_livemac`] appends the 16-byte Dl3Cmac. The dock
+/// echoes the `counter`, so any monotonic value works. The exact request body was never
+/// captured (only the reply), so this is the minimal well-formed form -- re-check against a
+/// capture if the dock ever NAKs it once CP engages.
+pub(super) fn get_edid_req(counter: u16) -> Result<KVec<u8>> {
+    let mut b = KVec::with_capacity(16, GFP_KERNEL)?;
+    header(&mut b, 0x15, 0x21, counter)?;
+    pad_to(&mut b, 16)?;
+    Ok(b)
+}
+
+/// A video timing in DisplayID-Type-I terms (sec 8.6.4), as carried by the
+/// `0x48/0x22` set-mode message. Field meanings and offsets are verified
+/// byte-exact against the golden 3840x2160@60 capture (see [`set_mode`]).
+#[derive(Clone, Copy)]
+pub(super) struct Timing {
+    pub hactive: u16,
+    pub hblank: u16,
+    pub hsync_front: u16,
+    pub hsync_width: u16,
+    pub vactive: u16,
+    pub vblank: u16,
+    pub vsync_front: u16,
+    pub vsync_width: u16,
+    pub refresh_hz: u16,
+    /// Pixel clock in 10 kHz units (e.g. 0xd040 = 533.12 MHz for 4K@60).
+    pub pixel_clock_10khz: u16,
+    /// DisplayID field at off42 -- partly decoded (0x0604 for 4K, 0x0600 for the
+    /// 2560x1440 sample in sec 8.6.4); high byte 0x06 constant, low byte mode-varying.
+    pub field42: u16,
+}
+
+impl Timing {
+    /// 3840x2160@60 (CVT-RB) -- the mode the non-HDCP dongle advertises, kept as a
+    /// known-good reference whose `set_mode` output is byte-exact vs the golden capture.
+    pub(super) const UHD_60: Timing = Timing {
+        hactive: 3840, hblank: 160, hsync_front: 48, hsync_width: 32,
+        vactive: 2160, vblank: 62, vsync_front: 3, vsync_width: 5,
+        refresh_hz: 60, pixel_clock_10khz: 0xd040, field42: 0x0604,
+    };
+}
+
+/// set-mode (sec 8.6.4): `id=0x48 sub=0x22`, a 96-byte inner message carrying a
+/// DisplayID-Type-I u16 timing record. **Verified byte-exact** against the golden
+/// `[59]` 3840x2160@60 capture for every byte except the trailing 22-byte session
+/// MAC (off74..95), which [`seal`]'s caller / the HDCP session layer appends.
+///
+/// Layout (inner offsets): off20 BE u32 generation=2; off26 begins the LE u16
+/// record `hactive,hblank,hsync_front,hsync_width,vactive,vblank,vsync_front,
+/// vsync_width,field42,refresh,flags(0x4000)`; off48/off58/off60/off66 carry
+/// constants observed in the 4K capture; off70 the pixel clock (10 kHz units).
+pub(super) fn set_mode(counter: u16, t: &Timing) -> Result<KVec<u8>> {
+    let mut b = KVec::with_capacity(96, GFP_KERNEL)?;
+    header(&mut b, 0x48, 0x22, counter)?;
+    pad_to(&mut b, 20)?;
+    b.extend_from_slice(&2u32.to_be_bytes(), GFP_KERNEL)?; // off20: BE generation=2
+    pad_to(&mut b, 26)?; // off24..25 zero; timing begins at off26
+    for v in [
+        t.hactive, t.hblank, t.hsync_front, t.hsync_width,
+        t.vactive, t.vblank, t.vsync_front, t.vsync_width,
+        t.field42, t.refresh_hz, 0x4000, /* off46 flags */ 0x6000, /* off48 */
+    ] {
+        b.extend_from_slice(&v.to_le_bytes(), GFP_KERNEL)?;
+    }
+    pad_to(&mut b, 58)?;
+    b.extend_from_slice(&0x0080u16.to_le_bytes(), GFP_KERNEL)?; // off58 (observed const)
+    b.extend_from_slice(&0x00ffu16.to_le_bytes(), GFP_KERNEL)?; // off60 (observed const)
+    pad_to(&mut b, 66)?;
+    b.extend_from_slice(&0x0800u16.to_le_bytes(), GFP_KERNEL)?; // off66 (observed const)
+    pad_to(&mut b, 70)?;
+    b.extend_from_slice(&t.pixel_clock_10khz.to_le_bytes(), GFP_KERNEL)?; // off70
+    pad_to(&mut b, 96)?;
+    Ok(b)
+}
+
+/// Standard VESA MCCS (Monitor Control Command Set 2.2) VCP feature codes, driven over
+/// DDC/CI. The macOS DisplayLink agent exposes these as per-display brightness/contrast
+/// ("Popover did show -- starting DDC/CI communication", `setBrightness`/`setContrast`); the
+/// dock bridges the DDC/CI transaction to the downstream monitor's I2C slave 0x37 -- the same
+/// monitor-I2C path the EDID read ([`get_edid_req`]) uses for the 0x50 EDID slave.
+pub(super) const VCP_BRIGHTNESS: u8 = 0x10;
+pub(super) const VCP_CONTRAST: u8 = 0x12;
+/// VCP 0xD6 "Power mode": value 0x01 = on, 0x04 = off (DPMS-off / hard standby). Lets DPMS
+/// blank the panel backlight instead of freezing the last frame (see [`crtc_atomic_disable`]).
+pub(super) const VCP_POWER_MODE: u8 = 0xd6;
+pub(super) const POWER_ON: u16 = 0x01;
+pub(super) const POWER_OFF: u16 = 0x04;
+
+/// Build a DDC/CI "Set VCP Feature" request: the 7 bytes a DDC/CI host writes to the
+/// monitor's I2C slave 0x37, after the 0x6e (= 0x37<<1) write address (VESA DDC/CI 1.1
+/// sec 4.4). Layout: source 0x51, length `0x80 | 4`, opcode 0x03 (Set VCP), VCP code,
+/// value-hi, value-lo, then an XOR checksum seeded with the destination address 0x6e. Pure
+/// and fully standard, so it is unit-tested byte-exact against the spec
+/// ([`super::tests::ddc_ci_set_vcp_checksum`]).
+pub(super) fn ddc_ci_set_vcp(vcp: u8, value: u16) -> [u8; 7] {
+    let body = [0x51u8, 0x84, 0x03, vcp, (value >> 8) as u8, value as u8];
+    let mut chk = 0x6eu8; // checksum seed = destination slave-write address (0x37 << 1)
+    for &x in &body {
+        chk ^= x;
+    }
+    [body[0], body[1], body[2], body[3], body[4], body[5], chk]
+}
+
+/// CP message that tunnels a DDC/CI Set-VCP write to the downstream monitor -- the brightness,
+/// contrast and DPMS-power controls the macOS/Windows agents drive over "DDC/CI communication".
+/// The dock's monitor-I2C bridge is the same one the EDID read uses, so this is modelled as the
+/// WRITE companion to the `0x15/0x21` EDID read: `id=0x15 sub=0x22`, carrying the I2C slave
+/// (0x37) + payload length at off20 and the 7-byte DDC/CI Set-VCP payload at off22.
+///
+/// The `id`/`sub` and payload offset are **inferred** from the EDID-read pairing -- the write
+/// transaction was never captured (it only fires once a monitor is actively driven, i.e. past
+/// the CP wall), so re-check against a capture once CP engages. The DDC/CI bytes themselves
+/// ([`ddc_ci_set_vcp`]) are standard and verified.
+pub(super) fn ddc_set_vcp(counter: u16, vcp: u8, value: u16) -> Result<KVec<u8>> {
+    let payload = ddc_ci_set_vcp(vcp, value);
+    let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
+    header(&mut b, 0x15, 0x22, counter)?;
+    pad_to(&mut b, 20)?;
+    // off20: monitor DDC/CI I2C slave (0x37) + DDC/CI payload length.
+    b.extend_from_slice(&[0x37, payload.len() as u8], GFP_KERNEL)?;
+    // off22: the DDC/CI Set-VCP bytes (same off22 convention as the EDID payload).
+    b.extend_from_slice(&payload, GFP_KERNEL)?;
+    pad_to(&mut b, 32)?;
+    Ok(b)
+}
+
+/// EDID base-block sanity check: length, the `00 FF..FF 00` magic, and the 1-byte
+/// checksum (all 128 base bytes sum to 0 mod 256). A corrupt blob must never drive a
+/// mode-set, so [`timing_from_edid`] rejects anything that fails this.
+fn edid_valid(edid: &[u8]) -> bool {
+    const MAGIC: [u8; 8] = [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00];
+    edid.len() >= 128
+        && edid[..8] == MAGIC
+        && edid[..128].iter().fold(0u8, |a, &b| a.wrapping_add(b)) == 0
+}
+
+/// Parse one 18-byte EDID detailed timing descriptor into a [`Timing`], or `None` if it
+/// is too short or not a timing (pixel clock 0 marks a monitor descriptor). `field42`
+/// is left at the sec 8.6.4 default (`0x0600`) -- its low byte is mode-varying and not fully
+/// decoded, so the live mode-set substitution leaves the captured value in place.
+fn parse_dtd(d: &[u8]) -> Option<Timing> {
+    if d.len() < 18 {
+        return None;
+    }
+    let pclk = u16::from_le_bytes([d[0], d[1]]);
+    if pclk == 0 {
+        return None; // monitor descriptor, not a detailed timing
+    }
+    let hi = |v: u8, lo: u8| -> u16 { ((v as u16) << 8) | lo as u16 };
+    let hactive = hi((d[4] >> 4) & 0xf, d[2]);
+    let hblank = hi(d[4] & 0xf, d[3]);
+    let vactive = hi((d[7] >> 4) & 0xf, d[5]);
+    let vblank = hi(d[7] & 0xf, d[6]);
+    let hsync_front = (((d[11] >> 6) & 0x3) as u16) << 8 | d[8] as u16;
+    let hsync_width = (((d[11] >> 4) & 0x3) as u16) << 8 | d[9] as u16;
+    let vsync_front = (((d[11] >> 2) & 0x3) as u16) << 4 | ((d[10] >> 4) & 0xf) as u16;
+    let vsync_width = ((d[11] & 0x3) as u16) << 4 | (d[10] & 0xf) as u16;
+    let htotal = hactive.wrapping_add(hblank) as u32;
+    let vtotal = vactive.wrapping_add(vblank) as u32;
+    let refresh_hz = if htotal != 0 && vtotal != 0 {
+        ((pclk as u32 * 10_000 + (htotal * vtotal) / 2) / (htotal * vtotal)) as u16
+    } else {
+        0
+    };
+    Some(Timing {
+        hactive,
+        hblank,
+        hsync_front,
+        hsync_width,
+        vactive,
+        vblank,
+        vsync_front,
+        vsync_width,
+        refresh_hz,
+        pixel_clock_10khz: pclk,
+        field42: 0x0600,
+    })
+}
+
+/// Extract the monitor's **preferred** detailed timing from an EDID for the live mode-set
+/// (CP-HANDSHAKE.md sec 4e). The first DTD in the base block is the preferred timing per the
+/// EDID spec; scan all four base descriptor slots (off 54/72/90/108) so a leading monitor
+/// descriptor (name/range/serial) doesn't hide it, and if the base block carries no DTD at
+/// all, fall back to the first DTD in the CTA-861 extension block. The blob is validated
+/// first; an invalid or timing-less EDID returns `None` so the caller keeps its known-good
+/// fallback timing rather than driving the dock with garbage.
+pub(super) fn timing_from_edid(edid: &[u8]) -> Option<Timing> {
+    if !edid_valid(edid) {
+        return None;
+    }
+    // Base-block descriptors: the first valid DTD is the preferred timing.
+    for off in [54usize, 72, 90, 108] {
+        if off + 18 <= edid.len() {
+            if let Some(t) = parse_dtd(&edid[off..off + 18]) {
+                return Some(t);
+            }
+        }
+    }
+    // No DTD in the base block: try the first CTA-861 extension's DTD area. CTA-861 blocks
+    // have tag 0x02 at byte 0 and a DTD-area byte offset at byte 2 (>= 4 when DTDs follow);
+    // descriptors run in 18-byte records up to the extension's checksum byte (127).
+    if edid[126] as usize >= 1 && edid.len() >= 256 {
+        let ext = &edid[128..256];
+        if ext[0] == 0x02 {
+            let start = ext[2] as usize;
+            if start >= 4 {
+                let mut off = start;
+                while off + 18 <= 127 {
+                    if let Some(t) = parse_dtd(&ext[off..off + 18]) {
+                        return Some(t);
+                    }
+                    off += 18;
+                }
+            }
+        }
+    }
+    None
+}
+
+/// Overwrite the geometry + clock fields of an in-place set-mode inner message
+/// (`id=0x48 sub=0x22`) with `t` (CP-HANDSHAKE.md sec 4e). Offsets mirror [`set_mode`]:
+/// the LE u16 timing record at off26 and the pixel clock at off70. `field42` (off42),
+/// the off66 token and the encrypted trailer are intentionally **left as captured**;
+/// only the EDID-derived values change, so the wire length (hence `wire_seq`) is
+/// unchanged. No-op if `plain` is too short.
+pub(super) fn apply_edid_timing(plain: &mut [u8], t: &Timing) {
+    if plain.len() < 72 {
+        return;
+    }
+    let put = |b: &mut [u8], off: usize, v: u16| {
+        b[off] = v as u8;
+        b[off + 1] = (v >> 8) as u8;
+    };
+    put(plain, 26, t.hactive);
+    put(plain, 28, t.hblank);
+    put(plain, 30, t.hsync_front);
+    put(plain, 32, t.hsync_width);
+    put(plain, 34, t.vactive);
+    put(plain, 36, t.vblank);
+    put(plain, 38, t.vsync_front);
+    put(plain, 40, t.vsync_width);
+    put(plain, 44, t.refresh_hz);
+    put(plain, 70, t.pixel_clock_10khz);
+}
+
+/// Convert a DRM display mode (the timing the *compositor* selected from the connector's
+/// EDID-derived mode list) into a set-mode [`Timing`]. This is what makes the dock
+/// multi-mode: `drm_edid_connector_add_modes` already advertises every base+extension mode
+/// from the dock's EDID, and when userspace sets any one of them the resulting
+/// `drm_display_mode` lands here verbatim -- no re-parsing of EDID offsets. The blanking
+/// fields map straight across (CVT/DMT/DisplayID all use the same front-porch/sync model),
+/// and the refresh rate comes from DRM's own `drm_mode_vrefresh` helper rather than a
+/// hand-rolled divide. `field42` keeps the sec 8.6.4 default (its low byte is mode-varying and
+/// not fully decoded); the dock tolerates the high byte `0x06`.
+///
+/// SAFETY: `mode` must point to a valid `drm_display_mode` for the duration of the call.
+pub(super) fn timing_from_drm_mode(mode: &kernel::drm::kms::modes::DisplayMode) -> Timing {
+    let refresh = mode.vrefresh() as u16;
+    let sub = |a: u16, b: u16| a.saturating_sub(b);
+    Timing {
+        hactive: mode.hdisplay(),
+        hblank: sub(mode.htotal(), mode.hdisplay()),
+        hsync_front: sub(mode.hsync_start(), mode.hdisplay()),
+        hsync_width: sub(mode.hsync_end(), mode.hsync_start()),
+        vactive: mode.vdisplay(),
+        vblank: sub(mode.vtotal(), mode.vdisplay()),
+        vsync_front: sub(mode.vsync_start(), mode.vdisplay()),
+        vsync_width: sub(mode.vsync_end(), mode.vsync_start()),
+        refresh_hz: refresh,
+        // `clock` is in kHz; the set-mode field is in 10 kHz units.
+        pixel_clock_10khz: (mode.clock() / 10).clamp(0, u16::MAX as i32) as u16,
+        field42: 0x0600,
+    }
+}
+
+/// Decode the inner header of a dock->host CP frame: returns `(id, sub, ictr)` from
+/// the first decrypted block (CP-HANDSHAKE.md sec 3), or `None` if `wire` is not a
+/// decryptable CP frame. Used by the live loop to log what the dock is replying.
+pub(super) fn reply_info(
+    ks: &[u8; 16],
+    out_riv: &[u8; 8],
+    wire: &[u8],
+) -> Option<(u16, u16, u16)> {
+    if wire.len() <= 16 {
+        return None;
+    }
+    let seq = u32::from_le_bytes([wire[12], wire[13], wire[14], wire[15]]);
+    let head = &wire[16..wire.len().min(32)];
+    let inner = open_in(ks, &in_riv(out_riv), seq, head).ok()?;
+    if inner.len() < 6 {
+        return None;
+    }
+    Some((
+        u16::from_le_bytes([inner[0], inner[1]]),
+        u16::from_le_bytes([inner[2], inner[3]]),
+        u16::from_le_bytes([inner[4], inner[5]]),
+    ))
+}
+
+/// CP `sub` ids seen on the wire (CP-HANDSHAKE.md). Used to score a candidate
+/// decrypt: a plaintext whose `sub` is one of these (and whose post-counter pad is
+/// zero) is almost certainly the correct key/riv.
+fn is_known_sub(sub: u16) -> bool {
+    matches!(
+        sub,
+        0x00 | 0x04 | 0x0c | 0x10 | 0x20 | 0x21 | 0x22 | 0x24 | 0x25 | 0x30 | 0x41
+            | 0x42 | 0x43 | 0x45 | 0x75 | 0x84
+    )
+}
+
+/// Diagnostic decode: try a dock->host frame under every plausible riv variant and
+/// return the best-scoring inner `(riv_tag, id, sub, ictr)`. The interactive
+/// `wsub=0x45` replies decrypt under `in_riv` (byte7^1), but the **cap-phase**
+/// `wsub=0x25` frames decrypt under the session ks with **byte7 unchanged** (the OUT
+/// value) -- see the cold-ref transcript. `byte0^0x80` selects the head. This mirrors
+/// `decode-handshake.py`'s scoring so a live trace shows what the dock is actually
+/// asking for during the capability exchange we currently skip.
+pub(super) fn decode_any(
+    ks: &[u8; 16],
+    out_riv: &[u8; 8],
+    wire: &[u8],
+) -> Option<(&'static str, u16, u16, u16, [u8; 24])> {
+    if wire.len() <= 16 {
+        return None;
+    }
+    let seq = u32::from_le_bytes([wire[12], wire[13], wire[14], wire[15]]);
+    let head = &wire[16..wire.len().min(48)];
+    let out0 = *out_riv;
+    let in0 = in_riv(out_riv);
+    let mut out1 = out0;
+    out1[0] ^= 0x80;
+    let mut in1 = in0;
+    in1[0] ^= 0x80;
+    let variants: [(&'static str, [u8; 8]); 4] =
+        [("out/h0", out0), ("in/h0", in0), ("out/h1", out1), ("in/h1", in1)];
+    let mut best: Option<(i32, &'static str, u16, u16, u16, [u8; 24])> = None;
+    for (tag, riv) in variants.iter() {
+        let Ok(pt) = open_in(ks, riv, seq, head) else { continue };
+        if pt.len() < 8 {
+            continue;
+        }
+        let id = u16::from_le_bytes([pt[0], pt[1]]);
+        let sub = u16::from_le_bytes([pt[2], pt[3]]);
+        let ctr = u16::from_le_bytes([pt[4], pt[5]]);
+        let pad = u16::from_le_bytes([pt[6], pt[7]]);
+        let mut sc = 0i32;
+        if is_known_sub(sub) {
+            sc += 50;
+        }
+        if pad == 0 {
+            sc += 10;
+        }
+        if ctr < 0x400 {
+            sc += 5;
+        }
+        if best.map_or(true, |b| sc > b.0) {
+            // Keep the first 24 plaintext bytes so the live trace shows the decoded
+            // structure (e.g. the `..4c..de..` cap-descriptor template that, in the
+            // capture, is session-independent -- its absence flags a ks/riv mismatch).
+            let mut sample = [0u8; 24];
+            let n = pt.len().min(24);
+            sample[..n].copy_from_slice(&pt[..n]);
+            best = Some((sc, tag, id, sub, ctr, sample));
+        }
+    }
+    best.map(|(_, tag, id, sub, ctr, sample)| (tag, id, sub, ctr, sample))
+}
+
+// All three cursor messages share one 32-byte inner layout, recovered byte-exact from the
+// cold-ref session by `scripts/verify-cp-seal.py` (a 64x64 cursor, t~=41.3s):
+//   off0..7   id/sub/counter header
+//   off8..21  zero
+//   off22     0x02              constant marker
+//   off23     head_id           (0 / 1 across the cold-ref's two monitors)
+//   off24..25 field1 LE u16     (create: width  / move: X / image: 0)
+//   off26..27 field2 LE u16     (create: height / move: Y / image: 0)
+//   off28..31 trailing 4 bytes  (DLM leaks UNINITIALISED struct padding here -- the bytes vary
+//                                non-monotonically every message and match no checksum, so the
+//                                dock cannot validate them; we send zero, which is correct)
+// The image then appends its w*h*4 BGRA bitmap starting at off32, and its inner id carries a
+// 0x40 high-byte flag (0x401c, vs the 0x1c the aux/send_cp path uses). Prior layouts put the
+// marker/fields two bytes early and omitted the head byte (cursor_move) -- a latent bug, since
+// cursor traffic is post-CP-engagement. See captures/cp-seal-differential-20260622.md.
+fn cursor_header(b: &mut KVec<u8>, id: u16, sub: u16, counter: u16, head: u8) -> Result {
+    header(b, id, sub, counter)?;
+    pad_to(b, 22)?;
+    b.push(0x02, GFP_KERNEL)?; // off22 marker
+    b.push(head, GFP_KERNEL)?; // off23 head id
+    Ok(())
+}
+
+/// cursor create (sec 8.6.1): `id=0x1b sub=0x42`, advertises `w x h` for `head`.
+pub(super) fn cursor_create(counter: u16, head: u8, w: u16, h: u16) -> Result<KVec<u8>> {
+    let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
+    cursor_header(&mut b, 0x1b, 0x42, counter, head)?;
+    b.extend_from_slice(&w.to_le_bytes(), GFP_KERNEL)?; // off24..25
+    b.extend_from_slice(&h.to_le_bytes(), GFP_KERNEL)?; // off26..27
+    pad_to(&mut b, 32)?; // off28..31 (DLM uninit; we zero)
+    Ok(b)
+}
+
+/// cursor move (sec 8.6.1): `id=0x1a sub=0x43`, head id @23, X @24, Y @26 (LE).
+pub(super) fn cursor_move(counter: u16, head: u8, x: u16, y: u16) -> Result<KVec<u8>> {
+    let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
+    cursor_header(&mut b, 0x1a, 0x43, counter, head)?;
+    b.extend_from_slice(&x.to_le_bytes(), GFP_KERNEL)?; // off24..25
+    b.extend_from_slice(&y.to_le_bytes(), GFP_KERNEL)?; // off26..27
+    pad_to(&mut b, 32)?; // off28..31 (DLM uninit; we zero)
+    Ok(b)
+}
+
+/// cursor image (sec 8.6.1): inner `id=0x401c sub=0x41` (the 0x40 high-byte flag marks the
+/// bitmap-bearing message). A 32-byte header (shared marker + head, no w/h -- those come from
+/// [`cursor_create`]) followed by the `w*h*4` BGRA bitmap at off32. `bgra` must be `w*h*4` bytes
+/// -- DRM hands the driver a 64x64 ARGB8888 cursor buffer and the caller swaps it to BGRA.
+pub(super) fn cursor_image(
+    counter: u16,
+    head: u8,
+    w: u16,
+    h: u16,
+    bgra: &[u8],
+) -> Result<KVec<u8>> {
+    // `w*h*4` can wrap a 32-bit `usize` (max ~1.7e10 > u32::MAX), which would let an
+    // undersized `bgra` pass the check; compute it with checked arithmetic so an
+    // overflow is rejected as a mismatch rather than silently bypassing validation.
+    let expected = (w as usize)
+        .checked_mul(h as usize)
+        .and_then(|n| n.checked_mul(4));
+    if expected != Some(bgra.len()) {
+        return Err(EINVAL);
+    }
+    let mut b = KVec::with_capacity(32 + bgra.len(), GFP_KERNEL)?;
+    cursor_header(&mut b, 0x401c, 0x41, counter, head)?;
+    pad_to(&mut b, 32)?; // off24..31 zero (no w/h here)
+    b.extend_from_slice(bgra, GFP_KERNEL)?; // bitmap @ off32
+    Ok(b)
+}
+
+/// DisplayLink "Dl3Cmac" CP-message integrity tag (16 bytes) -- **FULLY SOLVED + CROSS-SESSION
+/// VERIFIED 2026-06-11** (`captures/DL3CMAC-FULLY-SOLVED-20260611.md`):
+/// `tag = AES-CMAC(ks, mac_nonce(8) || BE64(wire_seq) || ciphertext)` where
+/// - `mac_nonce` = the CTR stream `riv` **with `byte0 ^= 0x80`** (this byte0 flip is the bit
+///   prior writeups missed -- they tried `riv` / `riv^1@byte7` and OUT never verified),
+/// - `wire_seq` = the AES-CTR block counter (frame header off-12), zero-extended to BE64,
+/// - `ciphertext` = the AES-CTR ciphertext content (encrypt-then-MAC), tag appended IN CLEAR.
+/// `K_dl3 = ks`. Proven: 110/115 OUT + 128/135 IN corpus frames AND cold-ref msg0 (a different
+/// session) reproduce byte-exact. Pass the CTR `riv` directly; the byte0 flip is applied here.
+pub(super) fn dl3cmac_tag(
+    ks: &[u8; 16],
+    riv: &[u8; 8],
+    wire_seq: u64,
+    ciphertext: &[u8],
+) -> Result<[u8; 16]> {
+    let mut mac_nonce = *riv;
+    mac_nonce[0] ^= 0x80;
+    let mut buf = KVec::with_capacity(16 + ciphertext.len(), GFP_KERNEL)?;
+    buf.extend_from_slice(&mac_nonce, GFP_KERNEL)?;
+    buf.extend_from_slice(&wire_seq.to_be_bytes(), GFP_KERNEL)?;
+    buf.extend_from_slice(ciphertext, GFP_KERNEL)?;
+    Ok(crypto::aes_cmac(ks, &buf))
+}
+
+/// Seal a CP message with a **freshly computed live Dl3Cmac**, reusing DLM's captured wire
+/// `header` (so `seq`/`aux` are byte-identical) but recomputing the tail tag for THIS session.
+/// `content_pt` is the real inner plaintext WITHOUT the 16-byte tag region. Wire body =
+/// `AES-CTR(ks, riv, content_pt)` || `dl3cmac_tag(...)`. This is the live-generation path. See
+/// `captures/DL3CMAC-FULLY-SOLVED-20260611.md`.
+pub(super) fn seal_livemac(
+    ks: &[u8; 16],
+    riv: &[u8; 8],
+    header: &[u8],
+    content_pt: &[u8],
+) -> Result<KVec<u8>> {
+    let seq = u32::from_le_bytes([header[12], header[13], header[14], header[15]]);
+    let cipher = crypto::Aes128::new(ks)?;
+    let mut ct = KVec::with_capacity(content_pt.len(), GFP_KERNEL)?;
+    for (i, chunk) in content_pt.chunks(16).enumerate() {
+        let mut iv = [0u8; 16];
+        iv[..8].copy_from_slice(riv);
+        iv[12..].copy_from_slice(&seq.wrapping_add(i as u32).to_be_bytes());
+        let ksb = cipher.encrypt_block(&iv);
+        for (j, &p) in chunk.iter().enumerate() {
+            ct.push(p ^ ksb[j], GFP_KERNEL)?;
+        }
+    }
+    let tag = dl3cmac_tag(ks, riv, seq as u64, &ct)?;
+    let mut frame = KVec::with_capacity(16 + ct.len() + 16, GFP_KERNEL)?;
+    frame.extend_from_slice(&header[..16], GFP_KERNEL)?;
+    frame.extend_from_slice(&ct, GFP_KERNEL)?;
+    frame.extend_from_slice(&tag, GFP_KERNEL)?;
+    Ok(frame)
+}
+
+/// Seal an inner CP message into a wire frame (type=4 sub=0x24, `seq`). DisplayLink
+/// CP is **encrypt-then-MAC**: the message content is AES-CTR-encrypted, then a
+/// 16-byte Dl3Cmac tag (`AES-CMAC(ks, riv || BE64(seq) || ciphertext)`) is appended.
+/// The keystream is `AES_ECB(ks, riv(8) || u32(0) || u32_be(seq + block))` (sec 6.1).
+///
+/// `inner` is the captured golden plaintext `[content || stale-tag-region(16)]`; we
+/// encrypt only `content = inner[..len-16]` and append a **fresh** tag keyed by our
+/// live session, so the dock's Dl3Cmac verification passes (the stale replayed tag is
+/// why the dock previously dropped our CP). VERIFIED construction (sec 8.6.7).
+pub(super) fn seal(
+    ks: &[u8; 16],
+    riv: &[u8; 8],
+    seq: u32,
+    inner: &[u8],
+) -> Result<KVec<u8>> {
+    // The interactive CP stream: session ks, wire sub `0x24`.
+    seal_stream(ks, riv, 0x24, seq, inner)
+}
+
+/// Build a fully sealed interactive CP frame (`type=4 sub=0x24`) at `wire_seq` over `content`
+/// (the inner plaintext, WITHOUT any trailing 16-byte tag placeholder): the 16-byte wire
+/// header -- size, `type=4`, `sub=0x24`, the per-`id` [`aux_for_id`] field, and `wire_seq` --
+/// followed by [`seal_livemac`] (AES-CTR ciphertext + appended live Dl3Cmac). Shared by the
+/// bring-up live loop ([`VinoDriver::send_live_cp`]) and the runtime KMS senders
+/// ([`drm_sink::VinoDrmData::send_cp`]) so both produce a byte-identical wire frame.
+pub(super) fn seal_interactive(
+    ks: &[u8; 16],
+    riv: &[u8; 8],
+    id: u16,
+    wire_seq: u32,
+    content: &[u8],
+) -> Result<KVec<u8>> {
+    let body_len = content.len() + 16; // AES-CTR ciphertext + 16-byte Dl3Cmac
+    let size = ((16 + body_len) - 4) as u16;
+    let aux = aux_for_id(id, body_len);
+    let mut hdr = [0u8; 16];
+    hdr[2..4].copy_from_slice(&size.to_le_bytes());
+    hdr[4..8].copy_from_slice(&4u32.to_le_bytes()); // type=4
+    hdr[8..10].copy_from_slice(&0x24u16.to_le_bytes()); // sub=0x24 (interactive CP)
+    hdr[10..12].copy_from_slice(&aux.to_le_bytes());
+    hdr[12..16].copy_from_slice(&wire_seq.to_le_bytes());
+    seal_livemac(ks, riv, &hdr, content)
+}
+
+/// The CP wire-header `aux`@10 (`sub_len_dw`) field is a **strict per-inner-message-id
+/// constant** in DLM's CP stream -- verified byte-exact across all 94 captured 1080p CP
+/// frames (`cp-hdrwire-1080p.bin`) -- **not** `body.len()/4`, which is what `push_frame`
+/// derives. Reproducing it makes a generated CP frame's header byte-identical to DLM, the
+/// leading hypothesis for the dock engaging its CP cipher (the dock acks our plaintext cap
+/// but emits 0 encrypted replies with the wrong `aux`). See docs/BLOCKER.md and memory
+/// `project_cp_aux_field_per_id_constant`. Unknown ids fall back to the dword count so an
+/// unrecognised message is still well-formed.
+///
+/// The cursor ids (0x1a/0x1b/0x1c) are absent from the 1080p set (cursor messages fire only
+/// once a cursor is set, post-engagement) and were recovered from the cold-ref session by the
+/// `scripts/verify-cp-seal.py` differential: every other OUT CP frame regenerated byte-exact,
+/// but the three cursor messages diverged in `aux` alone (DLM 0x04/0x03/0x02 vs the body/4
+/// fallback) -- a latent bug that would have made the dock misparse every cursor update. This
+/// makes the generated `seal`/`seal_stream` path match DLM without a captured-header blob -- the
+/// basis for **live** CP generation.
+pub(super) fn aux_for_id(id: u16, body_len: usize) -> u16 {
+    match id {
+        0x14 => 0x0a,
+        0x15 => 0x09,
+        0x16 => 0x08,
+        0x19 => 0x05,
+        0x1a => 0x04, // cursor move
+        0x1b => 0x03, // cursor create
+        0x1c => 0x02, // cursor image
+        0x1f => 0x0f,
+        0x22 => 0x0c,
+        0x26 => 0x08,
+        0x2a => 0x04,
+        0x32 => 0x0c,
+        0x48 => 0x06,
+        0x9a => 0x04,
+        _ => (body_len / 4) as u16,
+    }
+}
+
+/// General AES-CTR seal under an arbitrary stream `key`/`riv` and wire sub. `seal`
+/// is the session-CP case (`wsub=0x24`); the **cap phase** (CP-HANDSHAKE.md sec 4b)
+/// needs `wsub=0x04` sealed under the dock's `id=0x32`-delivered per-head stream key,
+/// not the session ks -- which `seal` cannot express. Body construction is identical:
+/// AES-CTR(key, riv || 0x00000000 || BE32(seq+block)) over the **whole** inner message
+/// (no appended MAC; the inner carries its own encrypted trailer -- verified byte-exact
+/// vs DLM, 30/30 wire frames).
+pub(super) fn seal_stream(
+    key: &[u8; 16],
+    riv: &[u8; 8],
+    wsub: u16,
+    seq: u32,
+    inner: &[u8],
+) -> Result<KVec<u8>> {
+    let cipher = crypto::Aes128::new(key)?;
+    let mut ct = KVec::with_capacity(inner.len(), GFP_KERNEL)?;
+    for (i, chunk) in inner.chunks(16).enumerate() {
+        let mut iv = [0u8; 16];
+        iv[..8].copy_from_slice(riv);
+        iv[12..].copy_from_slice(&seq.wrapping_add(i as u32).to_be_bytes());
+        let ksb = cipher.encrypt_block(&iv);
+        for (j, &p) in chunk.iter().enumerate() {
+            ct.push(p ^ ksb[j], GFP_KERNEL)?;
+        }
+    }
+    let mut frame = KVec::with_capacity(16 + ct.len(), GFP_KERNEL)?;
+    // DLM-exact `aux`@10: a per-inner-id constant (see `aux_for_id`), not `body/4`. The
+    // id is read from the *plaintext* inner (off 0); `push_frame` would derive the wrong
+    // value and is the suspected reason the dock won't engage its CP cipher.
+    let id = if inner.len() >= 2 { u16::from_le_bytes([inner[0], inner[1]]) } else { 0 };
+    super::proto::push_frame_with(&mut frame, 0x04, wsub, aux_for_id(id, ct.len()), seq, &ct)?;
+    Ok(frame)
+}
+
+/// Derive the dock->host (IN) CP riv from the host->dock (OUT) `riv`. **It is the
+/// SAME riv -- no transform.** Proven 2026-06-12 by decrypting a frida-keyed DLM cold
+/// session's engaged `sub=0x45` replies (`captures/dlm-coldkeys-20260611-135237`, logged
+/// `ks`/`out_riv`): the dock's replies decrypt cleanly ONLY under the raw `out_riv`
+/// (`id=0x4c sub=0 ctr=8` to msg0, `id=0x14 sub=0x10` ACKs, `id=0x213` cert, ...); the old
+/// `byte7 ^= 1` gives garbage. The earlier "byte7^1 for IN" note was never validated against
+/// a real engaged reply (vino never engaged) and was wrong -- it would have made vino
+/// misdecode
+/// every dock reply (and partly explains old "dock replies garbage under our ks" findings).
+pub(super) fn in_riv(out_riv: &[u8; 8]) -> [u8; 8] {
+    *out_riv
+}
+
+/// Decrypt a dock->host CP frame body (AES-CTR, the same keystream as [`seal`] but
+/// keyed with the IN `riv`). `ct` is the ciphertext (wire bytes after the 16-byte
+/// cleartext header); `seq` is the wire counter at wire offset 12.
+pub(super) fn open_in(
+    ks: &[u8; 16],
+    in_riv: &[u8; 8],
+    seq: u32,
+    ct: &[u8],
+) -> Result<KVec<u8>> {
+    let cipher = crypto::Aes128::new(ks)?;
+    let mut pt = KVec::with_capacity(ct.len(), GFP_KERNEL)?;
+    for (i, chunk) in ct.chunks(16).enumerate() {
+        let mut iv = [0u8; 16];
+        iv[..8].copy_from_slice(in_riv);
+        iv[12..].copy_from_slice(&seq.wrapping_add(i as u32).to_be_bytes());
+        let ksb = cipher.encrypt_block(&iv);
+        for (j, &c) in chunk.iter().enumerate() {
+            pt.push(c ^ ksb[j], GFP_KERNEL)?;
+        }
+    }
+    Ok(pt)
+}
+
+/// If `wire` is an EDID reply (dock->host EP84, `type=4 sub=0x45`, inner
+/// `id=0x194 sub=0x21`), decrypt it with the IN riv and return the embedded EDID
+/// blob (base block + extensions). The EDID begins at inner offset 22; its total
+/// length is `128 * (1 + extension_count)`, where the extension count is base-block
+/// byte 126. Returns `None` for any other frame. See docs/CONTROL-PLANE.md.
+pub(super) fn parse_edid_from_reply(
+    ks: &[u8; 16],
+    out_riv: &[u8; 8],
+    wire: &[u8],
+) -> Result<Option<KVec<u8>>> {
+    // Wire header: [.. type@4 u32 .. sub@8 u16 .. seq@12 u32]; body at off16.
+    if wire.len() <= 16 || u16::from_le_bytes([wire[8], wire[9]]) != 0x45 {
+        return Ok(None);
+    }
+    let seq = u32::from_le_bytes([wire[12], wire[13], wire[14], wire[15]]);
+    let inner = open_in(ks, &in_riv(out_riv), seq, &wire[16..])?;
+    // Inner header: [id u16][sub u16][counter u16][00 00]; EDID payload at off22.
+    const EDID_OFF: usize = 22;
+    if inner.len() < EDID_OFF + 128 {
+        return Ok(None);
+    }
+    let id = u16::from_le_bytes([inner[0], inner[1]]);
+    let sub = u16::from_le_bytes([inner[2], inner[3]]);
+    // The get-EDID reply id is `0x194` on the wire (CP-HANDSHAKE.md sec 4f, ground-truthed
+    // against the cold-ref capture); older notes wrote the low byte `0x94` alone. Accept
+    // both so a real `0x194` reply is not silently dropped (the EDID would never reach the
+    // connector even after CP engages).
+    if (id != 0x94 && id != 0x194) || sub != 0x21 {
+        return Ok(None);
+    }
+    let edid = &inner[EDID_OFF..];
+    // Validate the EDID base-block magic `00 FF FF FF FF FF FF 00`.
+    const MAGIC: [u8; 8] = [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00];
+    if edid[..8] != MAGIC {
+        return Ok(None);
+    }
+    let total = ((1 + edid[126] as usize) * 128).min(edid.len());
+    let mut out = KVec::with_capacity(total, GFP_KERNEL)?;
+    out.extend_from_slice(&edid[..total], GFP_KERNEL)?;
+    Ok(Some(out))
+}
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH v2 04/10] drm/vino: add the Vino framebuffer codec
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
                     ` (2 preceding siblings ...)
  2026-07-03  3:02   ` [RFC PATCH v2 03/10] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm sequence Mike Lothian
@ 2026-07-03  3:02   ` Mike Lothian
  2026-07-03  3:21     ` sashiko-bot
  2026-07-03  3:02   ` [RFC PATCH v2 05/10] drm/vino: add the DRM/KMS sink, built on the safe KMS mode-object layer Mike Lothian
                     ` (5 subsequent siblings)
  9 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-07-03  3:02 UTC (permalink / raw)
  To: rust-for-linux
  Cc: dri-devel, David Airlie, Simona Vetter, Thomas Zimmermann,
	Maarten Lankhorst, Maxime Ripard, Danilo Krummrich, Lyude Paul,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	linux-kernel, Mike Lothian

video.rs implements the dock's video transport: an RLE fallback
encoder (shadow-diffed against the previous frame) plus the
Walsh-Hadamard "Vino" colour codec reverse-engineered from the
reference daemon's encoder output -- the 8x8 transform, DC-plane
entropy coding and colour (Y/Cb/Cr) handling are byte-exact against
captured hardware output; the AC coefficient entropy grammar is only
partially recovered (EOB/scan/context still open), so the WHT path is
only used for aligned geometry and falls back to RLE otherwise. Also
builds the EP08 wire framing (DLM's per-strip TLV record layout).

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-sonnet-5 [Claude-Code]
---
 drivers/gpu/drm/vino/video.rs | 1229 +++++++++++++++++++++++++++++++++
 1 file changed, 1229 insertions(+)
 create mode 100644 drivers/gpu/drm/vino/video.rs

diff --git a/drivers/gpu/drm/vino/video.rs b/drivers/gpu/drm/vino/video.rs
new file mode 100644
index 000000000000..c520af19a4fb
--- /dev/null
+++ b/drivers/gpu/drm/vino/video.rs
@@ -0,0 +1,1229 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! RawRl (Raw/RLX) **mode-2 video encoder** -- clean-room from the AArch64 DLM
+//! decompile (sec 8.4 + `docs/decompile/arm64-blockencoder`/`-frame-markers`).
+//! Emits packed-RGB565 frames the dock decodes WITHOUT the impractical Vino
+//! Walsh-Hadamard entropy codec (sec 7.11). This is a **verbatim port** of the
+//! `vino-codec::rawrl` oracle, whose encode/decode round-trip is unit-tested
+//! (keyframe, differential, >256-pixel multi-block and >255 RLE run-splits all
+//! reconstruct byte-exact); keep the two in lockstep. No real mode-2 capture
+//! exists to diff against (sec 7.4), so that round-trip is the correctness anchor.
+//! NOT yet wired into `probe()`: sending a frame the dock rejects USB-resets the
+//! dock, so EP08 streaming is a supervised bring-up step.
+#![allow(dead_code)] // Encoder/Mode variants validated by KUnit; live scanout uses the RLE path
+
+use super::*;
+
+pub(crate) const MAGIC_RAW16: u16 = 0x68af;
+pub(crate) const MAGIC_RLE16: u16 = 0x69af;
+/// Frame-init `0x40af` (`FUN_003330fc`: u32 `0xaf0440af` + u16 `0x0840`).
+pub(crate) const FRAME_INIT: [u8; 6] = [0xaf, 0x40, 0x04, 0xaf, 0x40, 0x08];
+/// Bare `0xa0af` sync (`FUN_00332a38`).
+pub(crate) const SYNC: [u8; 2] = [0xaf, 0xa0];
+/// Frame-end section->code table `DAT_005b7860`, indexed by `mode - 1`.
+pub(crate) const SECTION_CODE: [u8; 7] = [0x01, 0x00, 0x03, 0x00, 0x05, 0x07, 0x07];
+pub(crate) const MAX_BLOCK_PIXELS: usize = 256;
+
+/// Per-run strategy: mode 0 raw-only, 1 RLE-only, 2 adaptive (sec 8.4).
+#[derive(Clone, Copy)]
+pub(crate) enum Mode {
+    Raw = 0,
+    Rle = 1,
+    Adaptive = 2,
+}
+
+/// Pack 8-bit RGB into RGB565 (the XRGB framebuffer reduced for the
+/// `0x68af`/`0x69af` path).
+pub(crate) fn rgb565(r: u8, g: u8, b: u8) -> u16 {
+    ((r as u16 >> 3) << 11) | ((g as u16 >> 2) << 5) | (b as u16 >> 3)
+}
+
+/// 6-byte block header: magic LE, 24-bit coord BE, count u8 (256 -> 0).
+fn block_header(out: &mut KVec<u8>, magic: u16, coord: u32, count: usize) -> Result {
+    out.extend_from_slice(&magic.to_le_bytes(), GFP_KERNEL)?;
+    out.push(((coord >> 16) & 0xff) as u8, GFP_KERNEL)?;
+    out.push(((coord >> 8) & 0xff) as u8, GFP_KERNEL)?;
+    out.push((coord & 0xff) as u8, GFP_KERNEL)?;
+    out.push((count & 0xff) as u8, GFP_KERNEL)?;
+    Ok(())
+}
+
+fn encode_raw_into(out: &mut KVec<u8>, coord: u32, pix: &[u16]) -> Result {
+    block_header(out, MAGIC_RAW16, coord, pix.len())?;
+    for &p in pix {
+        out.extend_from_slice(&p.to_be_bytes(), GFP_KERNEL)?;
+    }
+    Ok(())
+}
+
+fn encode_rle_into(out: &mut KVec<u8>, coord: u32, pix: &[u16]) -> Result {
+    block_header(out, MAGIC_RLE16, coord, pix.len())?;
+    let mut i = 0;
+    while i < pix.len() {
+        let v = pix[i];
+        let mut run = 1;
+        while i + run < pix.len() && pix[i + run] == v && run < 255 {
+            run += 1;
+        }
+        out.push(run as u8, GFP_KERNEL)?;
+        out.extend_from_slice(&v.to_be_bytes(), GFP_KERNEL)?;
+        i += run;
+    }
+    Ok(())
+}
+
+fn run_count(pix: &[u16]) -> usize {
+    let mut c = 0;
+    let mut i = 0;
+    while i < pix.len() {
+        let v = pix[i];
+        let mut j = i + 1;
+        while j < pix.len() && pix[j] == v {
+            j += 1;
+        }
+        c += 1;
+        i = j;
+    }
+    c
+}
+
+fn encode_run_into(out: &mut KVec<u8>, mode: Mode, coord: u32, pix: &[u16]) -> Result {
+    match mode {
+        Mode::Raw => encode_raw_into(out, coord, pix),
+        Mode::Rle => encode_rle_into(out, coord, pix),
+        Mode::Adaptive => {
+            let l = pix.len();
+            let c = run_count(pix);
+            if 2 * l < 3 * c + 1 {
+                encode_raw_into(out, coord, pix)
+            } else {
+                encode_rle_into(out, coord, pix)
+            }
+        }
+    }
+}
+
+/// Mode-2 frame encoder holding the shadow (previous-frame) buffer.
+pub(crate) struct Encoder {
+    width: usize,
+    height: usize,
+    mode: Mode,
+    // vmalloc-backed: a `width*height` u16 buffer is ~4 MiB at 1080p, far above the
+    // contiguous-kmalloc order limit (the page allocator WARNs and fails on it).
+    shadow: VVec<u16>,
+}
+
+impl Encoder {
+    pub(crate) fn new(width: usize, height: usize, mode: Mode) -> Result<Self> {
+        let shadow = VVec::from_elem(0u16, width * height, GFP_KERNEL)?;
+        Ok(Self { width, height, mode, shadow })
+    }
+
+    /// Encode `cur` (RGB565) into a mode-2 marker stream; updates the shadow.
+    /// Change-detection is per row; changed runs chunk into <=256-px blocks.
+    pub(crate) fn encode(&mut self, cur: &[u16]) -> Result<KVec<u8>> {
+        let mut s = KVec::new();
+        self.encode_into(cur, &mut s)?;
+        Ok(s)
+    }
+
+    /// Like [`encode`](Self::encode) but appends the marker stream to a caller-owned
+    /// `out` instead of allocating a fresh `KVec`. The hot scanout path
+    /// ([`encode_and_send`](super::drm_sink::encode_and_send)) uses this to encode
+    /// straight into a buffer that already reserves the EP08 transport header, so a
+    /// frame costs one allocation with no separate framing copy.
+    pub(crate) fn encode_into(&mut self, cur: &[u16], s: &mut KVec<u8>) -> Result {
+        s.extend_from_slice(&FRAME_INIT, GFP_KERNEL)?;
+        for y in 0..self.height {
+            let row = y * self.width;
+            let mut x = 0;
+            while x < self.width {
+                while x < self.width && cur[row + x] == self.shadow[row + x] {
+                    x += 1;
+                }
+                if x >= self.width {
+                    break;
+                }
+                let run_start = x;
+                while x < self.width && cur[row + x] != self.shadow[row + x] {
+                    x += 1;
+                }
+                let run_end = x;
+                let mut p = run_start;
+                while p < run_end {
+                    let n = (run_end - p).min(MAX_BLOCK_PIXELS);
+                    let coord = (((row + p) * 2) & 0xff_ffff) as u32;
+                    encode_run_into(s, self.mode, coord, &cur[row + p..row + p + n])?;
+                    p += n;
+                }
+            }
+        }
+        // `SECTION_CODE` (the decompile's `DAT_005b7860`) is indexed by the DL3 1-based
+        // mode number minus 1; our 0-based `Mode` discriminant already equals that index
+        // (Raw=0->0x01, Rle=1->0x00, Adaptive=2->0x03). The previous `saturating_sub(1)`
+        // double-subtracted, collapsing Raw and Rle onto the same code.
+        let code = SECTION_CODE[(self.mode as usize).min(SECTION_CODE.len() - 1)];
+        s.extend_from_slice(&SYNC, GFP_KERNEL)?;
+        s.extend_from_slice(&[0xaf, 0x20, 0x1f, code], GFP_KERNEL)?;
+        s.extend_from_slice(&[0xaf, 0x20, 0xff, 0x00], GFP_KERNEL)?;
+        s.extend_from_slice(&SYNC, GFP_KERNEL)?;
+        // Commit the shadow ONLY after the whole frame has been emitted successfully.
+        // Updating it incrementally (per changed run) left it half-updated if a later
+        // `extend_from_slice` hit OOM, permanently desyncing every subsequent diff frame.
+        // The encoder reads the pre-frame shadow throughout, so a single end-of-frame copy
+        // is equivalent to the per-run writes on the success path. The loop already indexes
+        // `cur` up to `width*height == shadow.len()`, so this slice is always in bounds.
+        let n = self.shadow.len();
+        self.shadow.copy_from_slice(&cur[..n]);
+        Ok(())
+    }
+}
+
+/// Vino (`0x2801`) Walsh-Hadamard codec -- the bandwidth-constrained / 4K path (the RLE path
+/// above is what the dock currently runs; this is the lossy transform codec DLM uses when raw/
+/// RLE won't fit the USB budget). See `docs/WHT-CODEC.md` + `docs/VIDEO.md` +
+/// `captures/codec-vlc-table-breakthrough-20260623.md`.
+///
+/// **Scope (recovered + verified byte-exact vs DLM, 2026-06-23).** Every stage below is checked
+/// against DLM's own captured output offline (no dock needed):
+/// - [`colour`] `Y = 16R+32G+16B` (achromatic `Y = 64*gray`);
+/// - [`transform`] the **8x8 2-D Haar (Mallat) wavelet** (`//64`), 320/320 real gradient blocks;
+/// - [`quantize`] the per-position bias/step quantizer (`white -> Y_DC=16320 -> 1020`);
+/// - [`CODEBOOK`]/[`Vlc`] the LSB-first unary-prefix magnitude VLC dumped from DLM's coder leaf
+///   `0x5e68b0`, plus the [`SYNC13`] block framing -- `scripts/wht-block-codec.py` round-trips
+///   DLM's per-block output 5/5 byte-for-byte.
+///
+/// **The coeff->strip grammar is now recovered end to end** ([`strip`] + [`encode_frame`], 2026-06-23):
+/// the per-block significance code (a LAST-significant-position code, not a zerotree/context coder),
+/// the across-block DC **DPCM** plane (`(Cr, Cb, Y)` residuals), the AC run/magnitude stream, the two
+/// strip layouts (SOLID vs AC), and the **forward length-hint tail** (`tail[k] = L[k+1] - 2`, verified
+/// 10529/10800 on the gradient capture). [`encode_frame`] tiles a luma frame into 64x16 strips and
+/// composes the whole stream byte-exact for **achromatic** content.
+///
+/// **Two items remain genuinely unverified (so the live scanout path keeps using RLE -- emitting
+/// guessed bits is forbidden):** (1) **chroma AC** -- every captured AC example was grey, so the
+/// Cb/Cr high-frequency path has no ground truth and [`strip`] leaves chroma AC at zero; (2) the
+/// rightmost-column **size-mispredict** -- DLM's forward hint occasionally predicts an anomalously
+/// sized neighbour wrong (a framing micro-heuristic, ~2.5% of gradient strips); [`encode_frame`] uses
+/// exact look-ahead instead, which matches DLM on 100% of constant-strip-size content. Both are
+/// HW-unverifiable while the CP wall stands. The transform/quantizer/entropy stages are byte-exact.
+#[allow(dead_code)] // Walsh-Hadamard codec: KUnit-validated, not yet on the live scanout path
+pub(crate) mod wht {
+    use super::*;
+
+    /// Transform block geometry (recovered + byte-exact-verified 2026-06-23, see
+    /// `captures/codec-vlc-table-breakthrough-20260623.md`): an **8x8 pixel** block is the input
+    /// (`DIM` x `DIM` = `PIXELS` luma samples); the wavelet emits **32** coefficients (`COEFFS`) --
+    /// it is lossy 64->32, dropping the level-1 LH/HH subbands. `BLOCK` aliases `PIXELS` for the
+    /// `transform()` input length.
+    pub(crate) const DIM: usize = 8;
+    pub(crate) const PIXELS: usize = DIM * DIM;
+    pub(crate) const COEFFS: usize = 32;
+    pub(crate) const BLOCK: usize = PIXELS;
+
+    /// Vino colour transform, in the codec's 64x fixed point: `Cb = 64(R-G)`,
+    /// `Cr = 64(B-G)` (achromatic R=G=B -> Cb=Cr=0), and the reversible luma
+    ///
+    /// ```text
+    ///     Y = 64*G + 64*((Cb_raw + Cr_raw) >> 2)   where Cb_raw=R-G, Cr_raw=B-G
+    /// ```
+    ///
+    /// The `>> 2` is an arithmetic shift (floor toward -inf). This **replaces** the
+    /// earlier `Y = 16R + 32G + 16B` form, which `validate-transform-encoderio.py`
+    /// showed runs 16..48 HIGH for chromatic blocks (`16R+32G+16B = 64G + 16(Cb+Cr)`,
+    /// i.e. the un-floored sum). The floored form reproduces DLM's transform DC for
+    /// **every** measured colour -- the 6 saturated primaries/secondaries (incl. the
+    /// signed green/cyan cases the floor must round toward -inf), grey, white and
+    /// black (`scripts/validate-transform-encoderio.py`); achromatic input is
+    /// unchanged (`64*G` since `Cb=Cr=0`).
+    pub(crate) fn colour(r: u8, g: u8, b: u8) -> (i32, i32, i32) {
+        let (r, g, b) = (r as i32, g as i32, b as i32);
+        let (cb, cr) = (r - g, b - g);
+        (64 * g + 64 * ((cb + cr) >> 2), 64 * cb, 64 * cr)
+    }
+
+    /// Per-coefficient `(step, bias)` quantization table, **derived 2026-06-23 from DLM's
+    /// ground-truth `quant_leave` pre/post buffers** (captures/sig-library) -- the old
+    /// decode-ep08 guess (pos3 step32/bias16, pos4-11 step4/bias2, pos16-47 step2/bias1) was
+    /// WRONG and broke 4/25 strips. Note pos3 = (48, 32): a WIDE DEADZONE (so c3[3]=-48 -> -1,
+    /// not -2). Keyed by coefficient position `0..32`.
+    fn step_bias(i: usize) -> (i32, i32) {
+        match i {
+            0 => (16, 8),
+            1 | 2 => (16, 2),
+            3 => (48, 32),
+            4..=11 => (4, 0),
+            12 => (8, 2),
+            13..=15 => (8, 0),
+            _ => (2, 0), // 16..=31
+        }
+    }
+
+    /// Quantize coefficient `coeff` at position `i`: `sign(coeff) * floor((|coeff| + bias) / step)`
+    /// (byte-exact vs DLM, 25/25 library strips). Clamped to the 12-bit signed long-token range.
+    pub(crate) fn quantize(coeff: i32, i: usize) -> i32 {
+        let (step, bias) = step_bias(i);
+        let q = (coeff.abs() + bias) / step;
+        (if coeff < 0 { -q } else { q }).clamp(-2048, 2047)
+    }
+
+    /// One separable 2-D Haar step over the top-left `n`x`n` of `src` (row-major, `n` columns,
+    /// `n` in {8,4,2}). The 1-D Haar butterfly is `lo = a + b`, `hi = a - b`; applied to rows then
+    /// columns it splits the `n`x`n` block into four `(n/2)`x`(n/2)` subbands written to
+    /// `ll`/`hl`/`lh`/`hh` (row-major, stride `n/2`). Unnormalized -- `transform()` floor-divides
+    /// the final coefficients by 64. Mirrors `scripts/wht-transform.py` (verified byte-exact).
+    fn haar2d(src: &[i32], n: usize, ll: &mut [i32], hl: &mut [i32], lh: &mut [i32], hh: &mut [i32]) {
+        let h = n / 2;
+        // Row pass: L = row-lo, H = row-hi (each n rows x h cols).
+        let mut l = [0i32; PIXELS];
+        let mut hb = [0i32; PIXELS];
+        for r in 0..n {
+            for i in 0..h {
+                let (a, b) = (src[r * n + 2 * i], src[r * n + 2 * i + 1]);
+                l[r * h + i] = a + b;
+                hb[r * h + i] = a - b;
+            }
+        }
+        // Column pass: LL/LH = col-lo/hi of L, HL/HH = col-lo/hi of H (each h x h).
+        for c in 0..h {
+            for i in 0..h {
+                let (a, b) = (l[2 * i * h + c], l[(2 * i + 1) * h + c]);
+                ll[i * h + c] = a + b;
+                lh[i * h + c] = a - b;
+                let (a2, b2) = (hb[2 * i * h + c], hb[(2 * i + 1) * h + c]);
+                hl[i * h + c] = a2 + b2;
+                hh[i * h + c] = a2 - b2;
+            }
+        }
+    }
+
+    /// DLM's video transform (`FUN_007a7b60`), reverse-engineered + **verified byte-exact**
+    /// (2026-06-23, 320/320 real gradient blocks + the 6 stripe/checker vectors): an **8x8 2-D
+    /// Haar (Mallat) wavelet, floor-divided by 64**. The `block` is 8x8 luma (`Y = 16R+32G+16B`,
+    /// achromatic `Y = 64*gray`); the output is 32 coefficients in DLM's Mallat layout:
+    /// `c[0]` = LL; `c[1..4]` = level-3 HL/LH/HH; `c[4..8]/[8..12]/[12..16]` = level-2 HL/LH/HH
+    /// (2x2 row-major each); `c[16..32]` = level-1 HL (4x4 row-major). The level-1 LH and HH
+    /// subbands are dropped (the lossy 64->32 reduction). A uniform block yields `DC = mean`,
+    /// all AC = 0. Replaces the prior flat 2-level Walsh-Hadamard, which did not match DLM for AC.
+    pub(crate) fn transform(block: &[i32; PIXELS]) -> [i32; COEFFS] {
+        let sh = |x: i32| x >> 6; // arithmetic shift = floor division by 64 (matches DLM/`//64`)
+        let mut c = [0i32; COEFFS];
+        // Level 1: 8x8 -> 4x4 subbands; the finest HL band is c[16..32]. DLM emits this 4x4 band
+        // **column-major** (the entropy coder's coefficient reorder): wire position `p` reads band
+        // element `(row = p % 4, col = p / 4)`. Recovered 2026-06-27 byte-exact from a real DLM
+        // colour strip (cramp256 sink capture, `captures/codec-sink-094653/`): a horizontal ramp's
+        // HL band is `[-2,0,-2,0; ...]` row-major but the wire groups it `[-2,-2,-2,-2, 0,0,0,0, ...]`
+        // = the transpose. (The earlier achromatic byte-exact tests never exercised an asymmetric L1HL
+        // band -- gentle gradients quantize it to zero -- so this latent scan bug was invisible until
+        // steep colour content.) The lower bands (c[0..16]) decode in natural order.
+        let (mut ll1, mut hl1, mut lh1, mut hh1) = ([0i32; 16], [0i32; 16], [0i32; 16], [0i32; 16]);
+        haar2d(block, DIM, &mut ll1, &mut hl1, &mut lh1, &mut hh1);
+        for p in 0..16 {
+            c[16 + p] = sh(hl1[(p % 4) * 4 + p / 4]);
+        }
+        // Level 2: LL1 (4x4) -> 2x2 subbands; c[4..8]/[8..12]/[12..16].
+        let (mut ll2, mut hl2, mut lh2, mut hh2) = ([0i32; 4], [0i32; 4], [0i32; 4], [0i32; 4]);
+        haar2d(&ll1, 4, &mut ll2, &mut hl2, &mut lh2, &mut hh2);
+        for i in 0..4 {
+            c[4 + i] = sh(hl2[i]);
+            c[8 + i] = sh(lh2[i]);
+            c[12 + i] = sh(hh2[i]);
+        }
+        // Level 3: LL2 (2x2) -> 1x1 subbands; the DC c[0] and coarse c[1..4].
+        let (mut ll3, mut hl3, mut lh3, mut hh3) = ([0i32; 1], [0i32; 1], [0i32; 1], [0i32; 1]);
+        haar2d(&ll2, 2, &mut ll3, &mut hl3, &mut lh3, &mut hh3);
+        c[0] = sh(ll3[0]);
+        c[1] = sh(hl3[0]);
+        c[2] = sh(lh3[0]);
+        c[3] = sh(hh3[0]);
+        // `lh1`/`hh1` (level-1 LH/HH) are intentionally unused -- the lossy 64->32 drop.
+        let _ = (&lh1, &hh1);
+        c
+    }
+
+    // ====================================================================================
+    // ★ 2026-06-23 (live HW): the REAL entropy code, recovered + byte-exact-verified.
+    //
+    // The earlier MSB-first 5-bit "short/long token" model (now removed) was REFUTED: a value-axis
+    // amplitude sweep (`scripts/codec-sweep-plan.py`) showed its decoded tokens were invariant to
+    // the coefficient VALUE (identical `L589` across a 128x AC range) -- it never matched the coder.
+    // The dock's entropy coder (DLM leaf `0x5e68b0`) is a **memory-resident unary-prefix VLC,
+    // written LSB-first**, dumped from DLM and reproduced byte-for-byte by [`Vlc`] + [`CODEBOOK`]
+    // (`scripts/wht-block-codec.py` reproduces DLM's per-block output 5/5; see
+    // `captures/codec-vlc-table-breakthrough-20260623.md`). A coefficient's magnitude category is
+    // `bit_length(|coeff|)` (verified across the sweep), code = unary(c)+0-terminator+remainder.
+    //
+    // VERIFIED here (KUnit): the codebook, the LSB-first packing, the 1-bit final padding, and the
+    // magnitude-category rule. NOT yet generalized (the open work): the coeff->token GRAMMAR for
+    // arbitrary content -- DC DPCM, the 2-D scan (incl. the real horizontal/vertical asymmetry),
+    // and block modes -- so this is the byte-exact OUTPUT stage, not yet a wired general encoder.
+    // ====================================================================================
+
+    /// The dumped Vino entropy VLC, indexed by symbol: `(code, nbits)`, emitted **LSB-first**.
+    /// Symbol 0 = the 1-bit code `0` (zero / most common); symbol 31 = the all-ones escape prefix.
+    pub(crate) const CODEBOOK: [(u32, u8); 32] = [
+        (0, 1), (1, 3), (5, 3), (3, 5), (19, 5), (11, 5), (27, 5), (7, 7), (71, 7), (39, 7),
+        (103, 7), (23, 7), (87, 7), (55, 7), (119, 7), (15, 8), (143, 8), (79, 8), (207, 8),
+        (47, 8), (175, 8), (111, 8), (239, 8), (31, 8), (159, 8), (95, 8), (223, 8), (63, 8),
+        (191, 8), (127, 8), (255, 9), (511, 9),
+    ];
+
+    /// The constant 13-byte per-block sync literal (emitted twice), recovered from the wire.
+    pub(crate) const SYNC13: [u8; 13] =
+        [0x7c, 0x93, 0x6f, 0xf2, 0x4d, 0xbe, 0xc9, 0x37, 0xf9, 0x26, 0xdf, 0xe4, 0x9b];
+
+    /// LSB-first VLC bit packer matching the dock (final byte padded with **1-bits** -- a
+    /// truncated all-ones code, as DLM emits).
+    pub(crate) struct Vlc {
+        out: KVec<u8>,
+        acc: u32,
+        nbits: u32,
+    }
+
+    impl Vlc {
+        pub(crate) fn new() -> Self {
+            Self { out: KVec::new(), acc: 0, nbits: 0 }
+        }
+
+        /// Append one bit (LSB-first within each byte).
+        fn bit(&mut self, b: u32) -> Result {
+            self.acc |= (b & 1) << self.nbits;
+            self.nbits += 1;
+            if self.nbits == 8 {
+                self.out.push((self.acc & 0xff) as u8, GFP_KERNEL)?;
+                self.acc = 0;
+                self.nbits = 0;
+            }
+            Ok(())
+        }
+
+        /// Emit codebook `sym`'s code, least-significant bit first.
+        pub(crate) fn symbol(&mut self, sym: usize) -> Result {
+            let (code, n) = CODEBOOK[sym];
+            for k in 0..n as u32 {
+                self.bit(code >> k)?;
+            }
+            Ok(())
+        }
+
+        /// Emit one quantized coefficient as DLM's JPEG-SSSS-style magnitude code (LSB-first),
+        /// verified byte-exact against DLM's per-coefficient wire bits (q-4/q-8/q-16 -- see
+        /// `scripts/wht-strip-encoder.py`). A zero coefficient is the 1-bit symbol 0. A nonzero
+        /// `q` emits the unary category `c = bit_length(|q|)` (c ones + a 0 terminator), then the
+        /// `(c-1)`-bit magnitude offset `|q| - 2^(c-1)` (MSB-first within the field), then a sign
+        /// bit (`0` = negative, the captured polarity). Categories >= 9 use the 19-bit escape long
+        /// form, which is not yet recovered -- those return `EOVERFLOW` rather than emit wrong bits.
+        pub(crate) fn coeff(&mut self, q: i32) -> Result {
+            if q == 0 {
+                return self.symbol(0);
+            }
+            let c = mag_category(q); // bit_length(|q|)
+            if c >= 9 {
+                return Err(kernel::error::code::EOVERFLOW); // escape long form -- open RE
+            }
+            for _ in 0..c {
+                self.bit(1)?; // unary category
+            }
+            self.bit(0)?; // terminator
+            let offset = q.unsigned_abs() - (1 << (c - 1));
+            for i in (0..c - 1).rev() {
+                self.bit(offset >> i)?; // (c-1)-bit magnitude offset, MSB-first
+            }
+            self.bit(if q < 0 { 0 } else { 1 }) // sign bit (0 = negative)
+        }
+
+        /// Flush, padding the final byte with 1-bits (matches the dock's truncated all-ones code).
+        pub(crate) fn finish(mut self) -> Result<KVec<u8>> {
+            if self.nbits > 0 {
+                while self.nbits < 8 {
+                    self.acc |= 1 << self.nbits;
+                    self.nbits += 1;
+                }
+                self.out.push((self.acc & 0xff) as u8, GFP_KERNEL)?;
+            }
+            Ok(self.out)
+        }
+    }
+
+    /// Magnitude category of a quantized coefficient: `bit_length(|coeff|)` (verified 2026-06-23 --
+    /// e.g. |4|->3, |8|->4, |255|->8). 0 for a zero coefficient.
+    pub(crate) fn mag_category(coeff: i32) -> u32 {
+        coeff.unsigned_abs().checked_ilog2().map_or(0, |l| l + 1)
+    }
+
+    // ====================================================================================
+    // ★ 2026-06-23: the SOLID-colour strip encoder -- byte-exact-verified end to end vs DLM
+    // (3508/3508 strips of grey128.bin + white). A solid 64x16 strip (16 uniform 8x8 blocks) is the
+    // most common desktop case (backgrounds, flat UI). DLM codes it with a NO-sync framing distinct
+    // from the AC-stripe path: a 16-byte header, a CONSTANT 30-byte "main frame" (identical for any
+    // solid colour), the strip's absolute DC ESCAPE-coded (long form), then a fixed trailer. The DC
+    // rule was cracked offline (`scripts/wht-strip-encoder.py`): code = unary(c) ++ offset(c-1,
+    // MSB-first) ++ sign, c=bit_length(qDC), qDC=quantize(DC,0), DC=c3[0]=64*gray (achromatic).
+    // ====================================================================================
+
+    /// Bit offset where the per-plane DC escape begins (after the 16-byte header + the constant
+    /// 30-byte main frame = byte 46). Verified 2026-06-23 across the grey sweep and solid primaries.
+    const SOLID_DC_BIT: usize = 368;
+    /// Maximum DC escape category; the maximum category omits the unary 0-terminator (a complete
+    /// prefix code on categories `1..=SOLID_DC_CMAX`). `|qY| <= 1020 => c <= 10`, `|qCb| <= 255`.
+    const SOLID_DC_CMAX: u32 = 10;
+    /// Minimum trailing bits after the escape before the 2-byte length tail (empirical: fits all 14
+    /// solid/colour DCs of `codec-grammar-20260623`; ~= the 16 blocks' empty-AC/EOB run).
+    const SOLID_DC_MINPAD: usize = 61;
+    /// Header (16 B, X/Y/w18/w1c patched per strip) + the constant 30-byte main frame (bytes 16..46)
+    /// of a solid strip (the 16-block all-zero DC-residual / empty-AC structure, identical for any
+    /// solid colour). Byte 46 (bit 368) onward carries the (Cr, Cb, Y) DC escape + trailer.
+    const SOLID_MAIN: [u8; 46] = [
+        0x01, 0x28, 0, 0, 0, 0, 0, 0, 0, 0, 0x3a, 0, 0x3a, 0, 0, 0, // header (magic,X,Y,resv,w18,w1c,z)
+        0xfc, 0x00, 0x7e, 0x00, 0x3f, 0x80, 0x1f, 0xc0, 0x0f, 0xe0, 0x07, 0xf0, 0x03, 0xf8, 0x01, 0xfc,
+        0x00, 0x7e, 0x00, 0x3f, 0x80, 0x1f, 0xc0, 0x0f, 0xe0, 0x07, 0xf0, 0x03, 0xf8, 0x01, // main frame
+    ];
+
+    /// Quantize a per-plane Haar DC: luma (plane 0) step 16, chroma (planes 1/2) step 64, bias 8.
+    /// Verified 2026-06-23 from the solid primaries (red qCb=255, cyan qY=764, ...).
+    fn quantize_dc(plane: usize, v: i32) -> i32 {
+        let step = if plane == 0 { 16 } else { 64 };
+        let q = (((v.unsigned_abs() + 8) * (65536 / step)) >> 16) as i32;
+        if v < 0 {
+            -q
+        } else {
+            q
+        }
+    }
+
+    /// Number of escape bits a signed value `v` occupies: `0 -> 1`; else `unary(c) ++ [term if
+    /// c<cmax] ++ offset(c-1) ++ sign`.
+    fn esc_len(v: i32, cmax: u32) -> usize {
+        if v == 0 {
+            return 1;
+        }
+        let c = mag_category(v);
+        c as usize + usize::from(c < cmax) + (c as usize - 1) + 1
+    }
+
+    /// Encode one solid 64x16 coder strip at pixel `(x, y)` from its three per-plane Haar DCs
+    /// (`ydc = c3[0]`, `cbdc = c4[0] = 64*(R-G)`, `crdc = c5[0] = 64*(B-G)`; achromatic => Cb=Cr=0,
+    /// e.g. `ydc = 8192` for grey128). **Byte-exact-verified vs DLM** on the full grey sweep
+    /// (c=8/9/10), white, and the 6 solid primaries/secondaries -- 14/14 strips (KUnit
+    /// `wht_solid_strip_byte_exact` + `scripts/wht-strip-encoder.py`). Plane order on the wire is
+    /// (Cr, Cb, Y); the escape begins at bit 368; the strip is zero-padded to its even length `L`
+    /// with `w18 = w1c = tail = L - 2`.
+    pub(crate) fn solid_strip(x: u16, y: u16, ydc: i32, cbdc: i32, crdc: i32) -> Result<KVec<u8>> {
+        let esc = esc_len(quantize_dc(2, crdc), SOLID_DC_CMAX)
+            + esc_len(quantize_dc(1, cbdc), SOLID_DC_CMAX)
+            + esc_len(quantize_dc(0, ydc), SOLID_DC_CMAX);
+        let need = (SOLID_DC_BIT + esc + SOLID_DC_MINPAD).div_ceil(8);
+        let payload = need + (need & 1); // round up to even
+        let len = payload + 2;
+
+        let mut out = KVec::new();
+        out.resize(len, 0, GFP_KERNEL)?;
+        out[..46].copy_from_slice(&SOLID_MAIN);
+        out[2..4].copy_from_slice(&x.to_le_bytes());
+        out[4..6].copy_from_slice(&y.to_le_bytes());
+        let l2 = (len - 2) as u16;
+        out[10..12].copy_from_slice(&l2.to_le_bytes()); // w18
+        out[12..14].copy_from_slice(&l2.to_le_bytes()); // w1c
+        // No trailing echo on the wire (the record framing's strip_id carries the length); the
+        // last 2 bytes stay the natural DC padding. See `frame_records`.
+
+        // The (Cr, Cb, Y) DC escapes, LSB-first from bit 368.
+        let mut bitpos = SOLID_DC_BIT;
+        let mut push = |b: u32| {
+            out[bitpos / 8] |= ((b & 1) as u8) << (bitpos % 8);
+            bitpos += 1;
+        };
+        for &q in &[quantize_dc(2, crdc), quantize_dc(1, cbdc), quantize_dc(0, ydc)] {
+            if q == 0 {
+                push(0); // a zero value is the single bit 0
+                continue;
+            }
+            let c = mag_category(q);
+            let mag = q.unsigned_abs();
+            let off = mag - (1 << (c - 1));
+            for _ in 0..c {
+                push(1); // unary category
+            }
+            if c < SOLID_DC_CMAX {
+                push(0); // 0-terminator (omitted at the maximum category)
+            }
+            for i in (0..c.saturating_sub(1)).rev() {
+                push(off >> i); // (c-1)-bit magnitude offset, MSB-first
+            }
+            push(u32::from(q > 0)); // sign bit = 1 for positive
+        }
+        Ok(out)
+    }
+
+    /// Max AC magnitude category (the maximum category omits the unary 0-terminator).
+    const AC_CMAX: u32 = 9;
+
+    /// LSB-first bit accumulator for the AC-strip coder (no final padding, unlike [`Vlc`]).
+    struct Bits {
+        out: KVec<u8>,
+        n: usize,
+    }
+
+    impl Bits {
+        fn new() -> Self {
+            Self { out: KVec::new(), n: 0 }
+        }
+
+        fn bit(&mut self, b: u32) -> Result {
+            if self.n % 8 == 0 {
+                self.out.push(0, GFP_KERNEL)?;
+            }
+            self.out[self.n / 8] |= ((b & 1) as u8) << (self.n % 8);
+            self.n += 1;
+            Ok(())
+        }
+
+        /// The shared escape value code: a 0 is one `0` bit; else `unary(c) ++ [0-term IFF c<cmax]
+        /// ++ offset(c-1, MSB-first) ++ sign(1=positive)`. `c = bit_length(|v|)`.
+        fn esc(&mut self, v: i32, cmax: u32) -> Result {
+            if v == 0 {
+                return self.bit(0);
+            }
+            let c = mag_category(v);
+            let off = v.unsigned_abs() - (1 << (c - 1));
+            for _ in 0..c {
+                self.bit(1)?;
+            }
+            if c < cmax {
+                self.bit(0)?;
+            }
+            for i in (0..c.saturating_sub(1)).rev() {
+                self.bit(off >> i)?;
+            }
+            self.bit(u32::from(v > 0))
+        }
+
+        /// Per-block significance = LAST-significant-position code (byte-exact 24/24): AC block
+        /// `00111110 ++ 5-bit(32-last) MSB`; flat block `00111111 ++ 0000000`.
+        fn sync_unit(&mut self, last: usize) -> Result {
+            for &b in &[0u32, 0, 1, 1, 1, 1, 1] {
+                self.bit(b)?; // common prefix 0011111
+            }
+            if last == 0 {
+                self.bit(1)?; // flat: 8th prefix bit = 1, then 7 zeros (15-bit unit)
+                for _ in 0..7 {
+                    self.bit(0)?;
+                }
+            } else {
+                self.bit(0)?; // AC: 8th prefix bit = 0, then 5-bit (32-last) MSB-first (13-bit unit)
+                let v = 32 - last as u32;
+                for i in (0..5).rev() {
+                    self.bit((v >> i) & 1)?;
+                }
+            }
+            Ok(())
+        }
+    }
+
+    /// Encode a UNIFORM 64x16 AC strip (16 identical 8x8 blocks) at pixel `(x, y)` from one block's
+    /// 32 transform coeffs. **BYTE-EXACT vs DLM** (25/25 sig-library strips; KUnit `wht_ac_strip`).
+    /// Achromatic (Cb=Cr=0). Layout: header(16) + per-block sync(last-position) + (Cr,Cb,Y) DC plane
+    /// (block0 + 15 DPCM-zero blocks, 10 B) + AC-row(8 blocks: run-bit / magnitude) x2 + tail(L-2).
+    /// For a fully flat block (no AC) use [`solid_strip`] instead.
+    pub(crate) fn ac_strip(coeff: &[i32; COEFFS], x: u16, y: u16) -> Result<KVec<u8>> {
+        let mut q = [0i32; COEFFS];
+        for i in 0..COEFFS {
+            q[i] = quantize(coeff[i], i);
+        }
+        let last = (1..COEFFS).rev().find(|&i| q[i] != 0).unwrap_or(0);
+
+        let mut sync = Bits::new();
+        for _ in 0..16 {
+            sync.sync_unit(last)?;
+        }
+        let mut dc = Bits::new();
+        dc.esc(0, SOLID_DC_CMAX)?; // Cr=0
+        dc.esc(0, SOLID_DC_CMAX)?; // Cb=0
+        dc.esc(q[0], SOLID_DC_CMAX)?; // Y absolute (block 0)
+        for _ in 0..45 {
+            dc.bit(0)?; // 15 DPCM-zero blocks x (Cr,Cb,Y)
+        }
+        let mut row = Bits::new();
+        for _ in 0..8 {
+            for i in 1..=last {
+                if q[i] == 0 {
+                    row.bit(0)?; // run bit (insignificant coeff)
+                } else {
+                    row.esc(q[i], AC_CMAX)?; // magnitude
+                }
+            }
+        }
+
+        let sync_b = sync.out.len();
+        let dc_b = 10usize; // DC region fixed 10 B for 16-block uniform
+        let mut rb = row.out.len();
+        rb += rb & 1; // round row up to even bytes
+        let w18 = 16 + sync_b + dc_b;
+        let w1c = w18 + rb;
+        let len = w1c + rb + 2;
+
+        let mut out = KVec::new();
+        out.resize(len, 0, GFP_KERNEL)?;
+        out[0] = 0x01;
+        out[1] = 0x28;
+        out[2..4].copy_from_slice(&x.to_le_bytes());
+        out[4..6].copy_from_slice(&y.to_le_bytes());
+        out[10..12].copy_from_slice(&(w18 as u16).to_le_bytes());
+        out[12..14].copy_from_slice(&(w1c as u16).to_le_bytes());
+        out[16..16 + sync_b].copy_from_slice(&sync.out);
+        out[16 + sync_b..16 + sync_b + dc.out.len()].copy_from_slice(&dc.out);
+        out[w18..w18 + row.out.len()].copy_from_slice(&row.out);
+        out[w1c..w1c + row.out.len()].copy_from_slice(&row.out);
+        out[len - 2..len].copy_from_slice(&((len - 2) as u16).to_le_bytes());
+        Ok(out)
+    }
+
+    /// Number of blocks across a 64-px-wide strip and the rows of blocks within its 16-px height.
+    const STRIP_BLOCKS_X: usize = 8;
+    const STRIP_ROW_BLOCKS: usize = 8; // blocks in one 8-row half (8 across x 1 down)
+    const STRIP_BLOCKS: usize = 16; // 8 across x 2 down
+
+    /// Round a byte count up to an even number (every coder sub-region is even-aligned).
+    fn round_even(n: usize) -> usize {
+        n + (n & 1)
+    }
+
+    /// Emit the 16-block DPCM DC plane into `b`: per block, the `(Cr, Cb, Y)` quantized DC as a
+    /// RESIDUAL escape (residual = this block's DC minus the previous block's DC, previous = 0 for
+    /// block 0). Byte-exact vs DLM on the varying-DC solid strips (vstripe8/hstripe8/checker8) and
+    /// the uniform case (all residuals 0). `dc[k] = (qCr, qCb, qY)`.
+    fn dc_plane(b: &mut Bits, dc: &[(i32, i32, i32); STRIP_BLOCKS]) -> Result {
+        let (mut pcr, mut pcb, mut py) = (0i32, 0i32, 0i32);
+        for &(cr, cb, y) in dc {
+            b.esc(cr - pcr, SOLID_DC_CMAX)?;
+            b.esc(cb - pcb, SOLID_DC_CMAX)?;
+            b.esc(y - py, SOLID_DC_CMAX)?;
+            (pcr, pcb, py) = (cr, cb, y);
+        }
+        Ok(())
+    }
+
+    /// Emit one block's luma AC coefficients (positions `1..=last`) into `b`: a run bit `0` for an
+    /// insignificant coefficient, else the magnitude escape. No EOB -- the block's `last` (from the
+    /// significance sync) bounds the loop. Achromatic only (chroma AC is not yet recovered).
+    fn block_ac(b: &mut Bits, q: &[i32; COEFFS], last: usize) -> Result {
+        for i in 1..=last {
+            if q[i] == 0 {
+                b.bit(0)?;
+            } else {
+                b.esc(q[i], AC_CMAX)?;
+            }
+        }
+        Ok(())
+    }
+
+    /// Encode one general 64x16 coder strip at pixel `(x, y)` from its 16 blocks' luma transform
+    /// coefficients (raster order: blocks 0..8 = top 8-px half, 8..16 = bottom half). Picks DLM's
+    /// layout automatically: **SOLID** (every block flat -> header + 30-byte main frame + DPCM DC
+    /// plane from bit 368) or **AC** (some block carries a nonzero AC coeff -> header + per-block
+    /// significance sync + DPCM DC plane + AC-row0 + AC-row1). The 2-byte tail is set to this
+    /// strip's own `L - 2`; [`encode_frame`] overwrites it with the *next* strip's forward length
+    /// hint (the wire's actual tail).
+    ///
+    /// **Byte-exact vs DLM** (achromatic content) on uniform solid (grey sweep/white), varying-DC
+    /// solid (vstripe8/hstripe8/checker8) and the 25/25 sig-library AC strips; gradient bodies match
+    /// every coding layer. Chroma (Cb/Cr) AC is left at zero -- there is no captured colour-AC
+    /// example to verify it against, so colourful textured content is *not* byte-exact here (the
+    /// anti-fabrication boundary). Callers pass per-block luma coeffs from [`transform`].
+    pub(crate) fn strip(blocks: &[[i32; COEFFS]; STRIP_BLOCKS], x: u16, y: u16) -> Result<KVec<u8>> {
+        let mut q = [[0i32; COEFFS]; STRIP_BLOCKS];
+        let mut lasts = [0usize; STRIP_BLOCKS];
+        let mut dc = [(0i32, 0i32, 0i32); STRIP_BLOCKS];
+        let mut any_ac = false;
+        for k in 0..STRIP_BLOCKS {
+            for i in 0..COEFFS {
+                q[k][i] = quantize(blocks[k][i], i);
+            }
+            lasts[k] = (1..COEFFS).rev().find(|&i| q[k][i] != 0).unwrap_or(0);
+            any_ac |= lasts[k] != 0;
+            dc[k] = (0, 0, q[k][0]); // achromatic: Cr=Cb=0 (chroma DC plumbed by the caller's blocks)
+        }
+
+        let mut dcb = Bits::new();
+        dc_plane(&mut dcb, &dc)?;
+
+        let mut out = KVec::new();
+        if !any_ac {
+            // SOLID layout: header + constant 30-byte main frame + DPCM DC plane from bit 368.
+            let l2 = round_even((SOLID_DC_BIT + dcb.n).div_ceil(8)) + 2;
+            let len = l2 + 2;
+            out.resize(len, 0, GFP_KERNEL)?;
+            out[..46].copy_from_slice(&SOLID_MAIN);
+            out[2..4].copy_from_slice(&x.to_le_bytes());
+            out[4..6].copy_from_slice(&y.to_le_bytes());
+            out[10..12].copy_from_slice(&(l2 as u16).to_le_bytes());
+            out[12..14].copy_from_slice(&(l2 as u16).to_le_bytes());
+            // The DPCM bits begin at bit 368 = byte 46 (byte-aligned); Bits zero-pads its tail, so a
+            // plain byte copy preserves the LSB-first packing.
+            out[46..46 + dcb.out.len()].copy_from_slice(&dcb.out);
+            out[len - 2..len].copy_from_slice(&(l2 as u16).to_le_bytes());
+            return Ok(out);
+        }
+
+        // AC layout.
+        let mut sync = Bits::new();
+        for k in 0..STRIP_BLOCKS {
+            sync.sync_unit(lasts[k])?;
+        }
+        let mut row0 = Bits::new();
+        for k in 0..STRIP_ROW_BLOCKS {
+            block_ac(&mut row0, &q[k], lasts[k])?;
+        }
+        let mut row1 = Bits::new();
+        for k in STRIP_ROW_BLOCKS..STRIP_BLOCKS {
+            block_ac(&mut row1, &q[k], lasts[k])?;
+        }
+
+        let sync_b = sync.out.len();
+        let dc_b = round_even(dcb.out.len()) + 2;
+        let r0 = round_even(row0.out.len());
+        let r1 = round_even(row1.out.len());
+        let w18 = 16 + sync_b + dc_b;
+        let w1c = w18 + r0;
+        let len = w1c + r1 + 2;
+
+        out.resize(len, 0, GFP_KERNEL)?;
+        out[0] = 0x01;
+        out[1] = 0x28;
+        out[2..4].copy_from_slice(&x.to_le_bytes());
+        out[4..6].copy_from_slice(&y.to_le_bytes());
+        out[10..12].copy_from_slice(&(w18 as u16).to_le_bytes());
+        out[12..14].copy_from_slice(&(w1c as u16).to_le_bytes());
+        out[16..16 + sync_b].copy_from_slice(&sync.out);
+        out[16 + sync_b..16 + sync_b + dcb.out.len()].copy_from_slice(&dcb.out);
+        out[w18..w18 + row0.out.len()].copy_from_slice(&row0.out);
+        out[w1c..w1c + row1.out.len()].copy_from_slice(&row1.out);
+        // No forward-hint tail: on the EP08 wire the strip's last 2 bytes are the natural row1
+        // bit-packing. The record framing carries the length as `strip_id == len`, so the in-strip
+        // echo the sink hook showed is not transmitted on the wire. See `frame_records`.
+        Ok(out)
+    }
+
+    // ====================================================================================
+    // COLOUR strip codec (Cb/Cr planes). Recovered byte-exact 2026-06-27/28 from DLM
+    // sink-hook captures (cramp/rramp/bramp period sweeps, captures/codec-sink-sweep-*):
+    // 2700/2872 strips byte-identical across every significance combination. Tooling +
+    // proof: scripts/codec-re/{coeffs,model,colourstrip,verify-colour-ac}.py.
+    //
+    // Per block the 3 planes are (Cr=64*(B-G), Cb=64*(R-G), Y=64*G + 64*((Cb+Cr)>>2)).
+    //  * SYNC unit = [Cr field][Cb field][Y field]; chroma fields present only when last>0
+    //    (the per-block plane mask), Y field always present (luma `sync_unit`).
+    //  * DC plane = 16-block DPCM (Cr,Cb,Y), 3 tokens/block, chroma step 64 / luma step 16,
+    //    round-half-up on the signed value.
+    //  * AC rows (row0 blocks 0..8, row1 8..16): per block (Cr,Cb,Y) present planes, chroma
+    //    quant flat step 16 (truncate toward zero), positions 1..last, run-bit `0` for zeros.
+    //  * Strip length = w1c + round_even(row1) (the 2-byte tail overlaps row1's tail).
+    // ====================================================================================
+
+    /// Flat chroma AC quantizer: step 16, truncate toward zero. Coarser than luma's
+    /// per-position `quantize`, so chroma `last` collapses to {0,1,7,31}.
+    fn quantize_chroma_ac(coeff: i32) -> i32 {
+        let q = coeff.abs() / 16;
+        if coeff < 0 {
+            -q
+        } else {
+            q
+        }
+    }
+
+    /// Per-plane DC quantizer, round-half-up on the SIGNED value (toward +inf): luma (plane 0)
+    /// step 16, chroma step 64. `+224/64 = 3.5 -> 4`; `-8416/64 = -131.5 -> -131`.
+    fn quantize_dc_round(plane: usize, v: i32) -> i32 {
+        let step = if plane == 0 { 16 } else { 64 };
+        (v + step / 2).div_euclid(step)
+    }
+
+    impl Bits {
+        /// The Cr significance field: `c = bit_length(last)`; emit `1`x`c` then `0`x`c`.
+        fn cr_field(&mut self, last: usize) -> Result {
+            let c = usize::BITS - last.leading_zeros();
+            for _ in 0..c {
+                self.bit(1)?;
+            }
+            for _ in 0..c {
+                self.bit(0)?;
+            }
+            Ok(())
+        }
+
+        /// The Cb significance field: `c = bit_length(last)`; `10` if `c == 1`, else
+        /// `0` ++ `1`x`c` ++ `0`x`(c-1)`.
+        fn cb_field(&mut self, last: usize) -> Result {
+            let c = usize::BITS - last.leading_zeros();
+            if c == 1 {
+                self.bit(1)?;
+                return self.bit(0);
+            }
+            self.bit(0)?;
+            for _ in 0..c {
+                self.bit(1)?;
+            }
+            for _ in 0..c - 1 {
+                self.bit(0)?;
+            }
+            Ok(())
+        }
+
+        /// One block's colour SYNC unit: present chroma fields (Cr, then Cb) then the Y field.
+        fn colour_sync_unit(&mut self, lcr: usize, lcb: usize, ly: usize) -> Result {
+            if lcr > 0 {
+                self.cr_field(lcr)?;
+            }
+            if lcb > 0 {
+                self.cb_field(lcb)?;
+            }
+            self.sync_unit(ly)
+        }
+
+        /// One block's colour AC: present planes in (Cr, Cb, Y) order, positions `1..=last`,
+        /// run-bit `0` for an insignificant coefficient else the magnitude escape (cmax `AC_CMAX`).
+        fn colour_block_ac(
+            &mut self,
+            qcr: &[i32; COEFFS],
+            qcb: &[i32; COEFFS],
+            qy: &[i32; COEFFS],
+            lcr: usize,
+            lcb: usize,
+            ly: usize,
+        ) -> Result {
+            for &(q, last) in &[(qcr, lcr), (qcb, lcb), (qy, ly)] {
+                for i in 1..=last {
+                    if q[i] == 0 {
+                        self.bit(0)?;
+                    } else {
+                        self.esc(q[i], AC_CMAX)?;
+                    }
+                }
+            }
+            Ok(())
+        }
+    }
+
+    /// One quantized colour block: the three planes' 32 coefficients and their last-significant
+    /// AC positions. Built by [`colour_block`] from a block's per-plane samples.
+    pub(crate) struct ColourBlock {
+        qcr: [i32; COEFFS],
+        qcb: [i32; COEFFS],
+        qy: [i32; COEFFS],
+        lcr: usize,
+        lcb: usize,
+        ly: usize,
+    }
+
+    /// Transform + quantize one block's three planes (each 64 samples in the codec's x64 fixed
+    /// point: `cr[i] = 64*(B-G)`, `cb[i] = 64*(R-G)`, `y[i] = 64*G + 64*((Cb+Cr)>>2)`). Luma uses
+    /// the per-position `quantize`/col-major transform; chroma AC uses the flat step-16
+    /// `quantize_chroma_ac`; all DCs use `quantize_dc_round`.
+    pub(crate) fn colour_block(
+        cr: &[i32; PIXELS],
+        cb: &[i32; PIXELS],
+        y: &[i32; PIXELS],
+    ) -> ColourBlock {
+        let tcr = transform(cr);
+        let tcb = transform(cb);
+        let ty = transform(y);
+        let mut qcr = [0i32; COEFFS];
+        let mut qcb = [0i32; COEFFS];
+        let mut qy = [0i32; COEFFS];
+        qcr[0] = quantize_dc_round(2, tcr[0]);
+        qcb[0] = quantize_dc_round(1, tcb[0]);
+        qy[0] = quantize_dc_round(0, ty[0]);
+        for i in 1..COEFFS {
+            qcr[i] = quantize_chroma_ac(tcr[i]);
+            qcb[i] = quantize_chroma_ac(tcb[i]);
+            qy[i] = quantize(ty[i], i);
+        }
+        let last = |q: &[i32; COEFFS]| (1..COEFFS).rev().find(|&i| q[i] != 0).unwrap_or(0);
+        let (lcr, lcb, ly) = (last(&qcr), last(&qcb), last(&qy));
+        ColourBlock { qcr, qcb, qy, lcr, lcb, ly }
+    }
+
+    /// Encode one 64x16 COLOUR strip at pixel `(x, y)` from its 16 quantized colour blocks
+    /// (raster: 0..8 top 8-px half, 8..16 bottom). Byte-exact vs DLM on all measured chromatic
+    /// content (see the section header). The 2-byte tail is this strip's own `L-2`;
+    /// [`encode_frame`]/the scanout path overwrites it with the next strip's forward length hint.
+    pub(crate) fn colour_strip(
+        blocks: &[ColourBlock; STRIP_BLOCKS],
+        x: u16,
+        y: u16,
+    ) -> Result<KVec<u8>> {
+        let mut sync = Bits::new();
+        for b in blocks {
+            sync.colour_sync_unit(b.lcr, b.lcb, b.ly)?;
+        }
+        let mut dcb = Bits::new();
+        let (mut pcr, mut pcb, mut py) = (0i32, 0i32, 0i32);
+        for b in blocks {
+            let (cr, cb, yv) = (b.qcr[0], b.qcb[0], b.qy[0]);
+            dcb.esc(cr - pcr, SOLID_DC_CMAX)?;
+            dcb.esc(cb - pcb, SOLID_DC_CMAX)?;
+            dcb.esc(yv - py, SOLID_DC_CMAX)?;
+            (pcr, pcb, py) = (cr, cb, yv);
+        }
+        let mut row0 = Bits::new();
+        for b in &blocks[..STRIP_ROW_BLOCKS] {
+            row0.colour_block_ac(&b.qcr, &b.qcb, &b.qy, b.lcr, b.lcb, b.ly)?;
+        }
+        let mut row1 = Bits::new();
+        for b in &blocks[STRIP_ROW_BLOCKS..] {
+            row1.colour_block_ac(&b.qcr, &b.qcb, &b.qy, b.lcr, b.lcb, b.ly)?;
+        }
+
+        let sync_b = sync.out.len();
+        let dc_b = round_even(dcb.out.len()) + 2;
+        let r0 = round_even(row0.out.len());
+        let r1 = round_even(row1.out.len());
+        let w18 = 16 + sync_b + dc_b;
+        let w1c = w18 + r0;
+        // The 2-byte tail overlaps the end of the row1 region (len = w1c + round_even(row1)).
+        let len = w1c + r1;
+
+        let mut out = KVec::new();
+        out.resize(len, 0, GFP_KERNEL)?;
+        out[0] = 0x01;
+        out[1] = 0x28;
+        out[2..4].copy_from_slice(&x.to_le_bytes());
+        out[4..6].copy_from_slice(&y.to_le_bytes());
+        out[10..12].copy_from_slice(&(w18 as u16).to_le_bytes());
+        out[12..14].copy_from_slice(&(w1c as u16).to_le_bytes());
+        out[16..16 + sync_b].copy_from_slice(&sync.out);
+        out[16 + sync_b..16 + sync_b + dcb.out.len()].copy_from_slice(&dcb.out);
+        out[w18..w18 + row0.out.len()].copy_from_slice(&row0.out);
+        out[w1c..w1c + row1.out.len()].copy_from_slice(&row1.out);
+        // No forward-hint tail: on the EP08 wire the strip's last 2 bytes are the natural row1
+        // bit-packing. The record framing carries the length as `strip_id == len`, so the in-strip
+        // echo the sink hook showed is not transmitted on the wire. See `frame_records`.
+        Ok(out)
+    }
+
+    /// Transform one 8x8 luma block read from `luma` (row-major, `stride` samples per row, top-left
+    /// at `[oy*stride + ox]`) into its 32 Haar coefficients.
+    fn block_coeffs(luma: &[u8], stride: usize, ox: usize, oy: usize) -> [i32; COEFFS] {
+        let mut blk = [0i32; PIXELS];
+        for r in 0..DIM {
+            for c in 0..DIM {
+                // Achromatic: Y = 64 * gray (the colour transform's R=G=B case).
+                blk[r * DIM + c] = 64 * luma[(oy + r) * stride + (ox + c)] as i32;
+            }
+        }
+        transform(&blk)
+    }
+
+    /// Encode a full `width`x`height` achromatic (8-bit luma) frame into the Vino WHT EP08 strip
+    /// stream (no transport header -- the caller prepends [`write_ep08_header`]). The frame is tiled
+    /// into 64x16 strips in raster order; each strip's 16 blocks are transformed and handed to
+    /// [`strip`]. A second pass writes the **forward length hint**: strip `k`'s 2-byte tail is set to
+    /// strip `k+1`'s `L - 2` (the last strip keeps its own `L - 2`). This is the wire's exact tail
+    /// rule -- verified 10529/10800 on the gradient capture and on 100% of constant-strip-size
+    /// content; it deviates from DLM only where DLM *mispredicts* an anomalously-sized neighbour
+    /// (the rightmost-column size heuristic, the single open framing micro-detail).
+    ///
+    /// `width`/`height` must be multiples of 64 and 16 respectively (the codec's strip geometry).
+    /// Achromatic only (see [`strip`]); for colour or RGB565-reduced input this is not byte-exact, so
+    /// the live scanout path keeps using RLE until the chroma-AC grammar is captured.
+    pub(crate) fn encode_frame(luma: &[u8], width: usize, height: usize) -> Result<KVec<u8>> {
+        if width % (STRIP_BLOCKS_X * DIM) != 0 || height % (2 * DIM) != 0 {
+            return Err(kernel::error::code::EINVAL);
+        }
+        let strips_x = width / (STRIP_BLOCKS_X * DIM); // 64-px columns
+        let strips_y = height / (2 * DIM); // 16-px rows
+        // Build every strip body (tail = own L-2 for now), tracking each one's span in `out`.
+        let mut out = KVec::new();
+        let mut spans: KVec<(usize, usize)> = KVec::new(); // (start, len) per strip
+        for sy in 0..strips_y {
+            for sx in 0..strips_x {
+                let (px, py) = (sx * STRIP_BLOCKS_X * DIM, sy * 2 * DIM);
+                let mut blocks = [[0i32; COEFFS]; STRIP_BLOCKS];
+                for k in 0..STRIP_BLOCKS {
+                    let (bx, by) = (k % STRIP_BLOCKS_X, k / STRIP_BLOCKS_X);
+                    blocks[k] = block_coeffs(luma, width, px + bx * DIM, py + by * DIM);
+                }
+                let s = strip(&blocks, px as u16, py as u16)?;
+                let start = out.len();
+                let slen = s.len();
+                out.extend_from_slice(&s, GFP_KERNEL)?;
+                spans.push((start, slen), GFP_KERNEL)?;
+            }
+        }
+        // Second pass: forward length hint -- tail[k] = L[k+1] - 2.
+        for k in 0..spans.len().saturating_sub(1) {
+            let (start, slen) = spans[k];
+            let next_l2 = (spans[k + 1].1 - 2) as u16;
+            out[start + slen - 2..start + slen].copy_from_slice(&next_l2.to_le_bytes());
+        }
+        Ok(out)
+    }
+
+    /// Strip pixel geometry: a strip is 64 px wide and 16 px tall
+    /// (`STRIP_BLOCKS_X * DIM` x `2 * DIM`).
+    pub(crate) const STRIP_W: usize = STRIP_BLOCKS_X * DIM; // 64
+    pub(crate) const STRIP_H: usize = 2 * DIM; // 16
+
+    /// Gather one 64x16 strip's 16 colour blocks from a pixel source. `px(x, y)` returns the
+    /// 8-bit `(R, G, B)` at absolute frame coordinate `(x, y)`; `(ox, oy)` is the strip's
+    /// top-left pixel. Each block's three planes are built in the codec's x64 fixed point via
+    /// [`colour`] (per-pixel `(Y, Cb, Cr)`, stored `(Cr, Cb, Y)` for [`colour_block`]). Blocks are
+    /// raster order within the strip (0..8 top 8-px half, 8..16 bottom), matching [`colour_strip`].
+    fn colour_strip_blocks(
+        ox: usize,
+        oy: usize,
+        px: &mut impl FnMut(usize, usize) -> (u8, u8, u8),
+    ) -> [ColourBlock; STRIP_BLOCKS] {
+        core::array::from_fn(|k| {
+            let (bx, by) = (k % STRIP_BLOCKS_X, k / STRIP_BLOCKS_X);
+            let (mut cr, mut cb, mut y) = ([0i32; PIXELS], [0i32; PIXELS], [0i32; PIXELS]);
+            for r in 0..DIM {
+                for c in 0..DIM {
+                    let (rr, gg, bb) = px(ox + bx * DIM + c, oy + by * DIM + r);
+                    let (yv, cbv, crv) = colour(rr, gg, bb);
+                    let i = r * DIM + c;
+                    (cr[i], cb[i], y[i]) = (crv, cbv, yv);
+                }
+            }
+            colour_block(&cr, &cb, &y)
+        })
+    }
+
+    /// Encode a full `width`x`height` 8-bit-RGB frame into the Vino WHT **colour** EP08 frame(s) --
+    /// the colour counterpart of the luma [`encode_frame`], and the assembler the live scanout path
+    /// drives once the CP wall falls. `px(x, y)` yields the source pixel's `(R, G, B)`; the caller
+    /// applies any rotation / gamma / format conversion (so this stays a pure codec). The surface is
+    /// tiled into 64x16 strips in raster order, each built from [`colour_block`] + [`colour_strip`].
+    /// The **forward length-hint tail** is then chained across the WHOLE frame -- strip `k`'s 2-byte
+    /// tail is patched to strip `k+1`'s `L - 2` (the last strip keeps its own), even across EP08-frame
+    /// boundaries. Finally the strip stream is split at strip boundaries into `<= u16::MAX - 12`-byte
+    /// EP08 frames, each prefixed with a [`write_ep08_header`] carrying an incrementing `seq` from
+    /// `seq0`. Returns the ready-to-send frames and the next `seq`.
+    ///
+    /// `width`/`height` must be multiples of 64 and 16 (`EINVAL` otherwise) -- the codec's strip
+    /// geometry; the live scanout path falls back to RLE for non-aligned modes (see
+    /// `docs/VIDEO-TODO.md`). Byte-exact for the recovered colour grammar (chroma sync/DC/AC); the
+    /// anti-fabrication boundary is the synthetic steepest-chroma edge cases (`VIDEO-TODO.md` 8/9).
+    pub(crate) fn colour_frame_ep08(
+        width: usize,
+        height: usize,
+        seq0: u32,
+        mut px: impl FnMut(usize, usize) -> (u8, u8, u8),
+    ) -> Result<(KVec<KVec<u8>>, u32)> {
+        if width % STRIP_W != 0 || height % STRIP_H != 0 {
+            return Err(kernel::error::code::EINVAL);
+        }
+        // Build every strip body (raster order; each strip's natural row1 tail, no echo).
+        let mut strips: KVec<KVec<u8>> = KVec::new();
+        let mut sy = 0usize;
+        while sy < height {
+            let mut sx = 0usize;
+            while sx < width {
+                let blocks = colour_strip_blocks(sx, sy, &mut px);
+                strips.push(colour_strip(&blocks, sx as u16, sy as u16)?, GFP_KERNEL)?;
+                sx += STRIP_W;
+            }
+            sy += STRIP_H;
+        }
+        Ok((frame_records(&strips)?, seq0))
+    }
+
+    /// A strip's `y` (the EP08 record bands group strips by row). Reads the `y` field the strip
+    /// builders write at byte offset 4 ([`colour_strip`] / [`solid_strip`]).
+    fn strip_y(s: &[u8]) -> u16 {
+        u16::from_le_bytes([s[4], s[5]])
+    }
+
+    /// Frame a raster-ordered list of strip bodies into EP08 USB transfers using DLM's real wire
+    /// record framing (RE'd 2026-06-28 from a passive capture of DLM's EP08 output):
+    ///
+    /// ```text
+    /// record (one per single-Y band of strips):
+    ///   u16 pad   = 0
+    ///   u16 size  = total record length (TLV..trailer, excludes the inter-record gap)
+    ///   u32 type  = 4
+    ///   u8[8]     prefix = [flag=0, 0, fseq=0, 0, 0,0,0,0]
+    ///   per strip: u16 strip_id (== strip length) ++ strip bytes
+    ///   u8[4]     trailer = 0
+    /// then u8[4] inter-record gap = 0; stride = size + 4.
+    /// ```
+    ///
+    /// The record stream is then chunked into `<= 65536`-byte USB transfers (a chunk may fall
+    /// mid-record; the dock reassembles the byte stream -- this is exactly what DLM's 64 KB libusb
+    /// transfers do). There is **no** per-transfer header (the old `write_ep08_header sub=0x30`
+    /// over concatenated strips was wrong and made the dock fault). `flag`/`fseq` are record-level
+    /// metadata left at 0 (content-derivation is an open detail; the dock-visible structure is the
+    /// per-strip `strip_id == len` and the TLV framing).
+    pub(crate) fn frame_records(strips: &[KVec<u8>]) -> Result<KVec<KVec<u8>>> {
+        const PREFIX: usize = 8;
+        const TRAILER: usize = 4;
+        const GAP: usize = 4;
+        const SIZE_CAP: usize = 0x4000; // keep `size` well within u16; matches DLM's record sizes
+        let mut stream: KVec<u8> = KVec::new();
+        let mut i = 0usize;
+        while i < strips.len() {
+            let y0 = strip_y(&strips[i]);
+            let rec = stream.len();
+            stream.extend_from_slice(&[0u8; 8 + PREFIX], GFP_KERNEL)?; // TLV(8) + prefix(8)
+            stream[rec + 4..rec + 8].copy_from_slice(&4u32.to_le_bytes()); // type = 4
+            let mut n = 0usize;
+            while i < strips.len() && strip_y(&strips[i]) == y0 {
+                let s = &strips[i];
+                let projected = (stream.len() - rec) + 2 + s.len() + TRAILER;
+                if n > 0 && projected > SIZE_CAP {
+                    break;
+                }
+                stream.extend_from_slice(&(s.len() as u16).to_le_bytes(), GFP_KERNEL)?;
+                stream.extend_from_slice(s, GFP_KERNEL)?;
+                n += 1;
+                i += 1;
+            }
+            stream.extend_from_slice(&[0u8; TRAILER], GFP_KERNEL)?;
+            let size = (stream.len() - rec) as u16;
+            stream[rec + 2..rec + 4].copy_from_slice(&size.to_le_bytes());
+            stream.extend_from_slice(&[0u8; GAP], GFP_KERNEL)?;
+        }
+        // Chunk the continuous record stream into transfers. Cap at 65024 (a non-multiple of the
+        // EP08 wMaxPacketSize of 1024) so every full chunk ends in a short packet that terminates
+        // the bulk transfer -- a transfer that is an exact wMaxPacketSize multiple needs a ZLP to
+        // terminate, which the in-kernel sync path / nusb does not append (DLM's libusb does). A
+        // chunk a hair under a packet boundary sidesteps that without needing ZLP support.
+        const CHUNK: usize = 65024; // 63.5 * 1024
+        let mut frames: KVec<KVec<u8>> = KVec::new();
+        let mut off = 0usize;
+        while off < stream.len() {
+            let end = core::cmp::min(off + CHUNK, stream.len());
+            let mut t: KVec<u8> = KVec::new();
+            t.extend_from_slice(&stream[off..end], GFP_KERNEL)?;
+            frames.push(t, GFP_KERNEL)?;
+            off = end;
+        }
+        Ok(frames)
+    }
+}
+
+/// Length of the EP08 transport header ([`write_ep08_header`]).
+pub(crate) const EP08_HDR_LEN: usize = 16;
+
+/// Write the 16-byte EP08 transport header into `hdr` for a `payload_len`-byte codec
+/// stream: `type=4 sub=0x30 sub_len_dw=0` sec 3 framing (matches the live capture).
+/// `size = payload_len + 12`. Used by the in-place scanout path. `hdr` must be at
+/// least 16 bytes.
+///
+/// The wire `size` field is 16-bit, so a frame is limited to `u16::MAX - 12` payload
+/// bytes; a larger codec stream cannot be expressed in a single frame and returns
+/// `EOVERFLOW` rather than silently truncating `size` (which would desync the dock's
+/// parser). The mode-2/RLE diff encoder keeps per-pageflip updates well under this;
+/// an incompressible full-frame update that exceeds it must be split by the caller.
+pub(crate) fn write_ep08_header(hdr: &mut [u8], payload_len: usize, seq: u32) -> Result {
+    let size = payload_len.checked_add(12).filter(|&s| s <= u16::MAX as usize);
+    let size = size.ok_or(kernel::error::code::EOVERFLOW)?;
+    hdr[0] = 0;
+    hdr[1] = 0;
+    hdr[2..4].copy_from_slice(&(size as u16).to_le_bytes());
+    hdr[4..8].copy_from_slice(&4u32.to_le_bytes());
+    hdr[8..10].copy_from_slice(&0x30u16.to_le_bytes());
+    hdr[10..12].copy_from_slice(&0u16.to_le_bytes());
+    hdr[12..16].copy_from_slice(&seq.to_le_bytes());
+    Ok(())
+}
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH v2 05/10] drm/vino: add the DRM/KMS sink, built on the safe KMS mode-object layer
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
                     ` (3 preceding siblings ...)
  2026-07-03  3:02   ` [RFC PATCH v2 04/10] drm/vino: add the Vino framebuffer codec Mike Lothian
@ 2026-07-03  3:02   ` Mike Lothian
  2026-07-03  3:13     ` sashiko-bot
  2026-07-03  3:02   ` [RFC PATCH v2 06/10] drm/vino: add the DisplayLink DL3 dock driver Mike Lothian
                     ` (4 subsequent siblings)
  9 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-07-03  3:02 UTC (permalink / raw)
  To: rust-for-linux
  Cc: dri-devel, David Airlie, Simona Vetter, Thomas Zimmermann,
	Maarten Lankhorst, Maxime Ripard, Danilo Krummrich, Lyude Paul,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	linux-kernel, Mike Lothian

drm_sink.rs registers a real struct drm_device with an atomic
mode-setting pipeline: one CRTC driven by a single primary plane
(scanout -> EP08), a virtual encoder, and a virtual connector whose
mode list comes from the dock's real EDID when the CP channel has
delivered it (falling back to 1080p), with GEM-shmem dumb buffers.

Built entirely on kernel::drm::kms -- VinoDrmDriver implements
KmsDriver, and VinoCrtc/VinoPlane/VinoConnector/VinoEncoder each
implement the matching Driver* trait, rather than hand-assembling a
raw C vtable the way the pre-safe-KMS version of this driver did (that
approach no longer compiles against current drm-next: the base Driver
trait now requires a Kms associated type routed through this exact
system). vino is the first real KmsDriver consumer of this freshly
forward-ported layer; two small gaps it needed are added earlier in
this series (Framebuffer::vmap(), the raw crtc/plane-state escape
hatches).

Not yet ported (documented in the module's own doc comment, not
fabricated): a second display head, a cursor plane, CRTC gamma LUT,
plane rotation property, and DDC/CI brightness/contrast as connector
properties -- none of these have a safe kernel::drm::kms extension
point yet. None of this is reachable on real hardware regardless: the
dock never engages its content-protection channel for vino (see
docs/BLOCKER.md), so atomic_update never gets past the first
bulk_send.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-sonnet-5 [Claude-Code]
---
 drivers/gpu/drm/vino/drm_sink.rs | 698 +++++++++++++++++++++++++++++++
 1 file changed, 698 insertions(+)
 create mode 100644 drivers/gpu/drm/vino/drm_sink.rs

diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
new file mode 100644
index 000000000000..450c7c84a067
--- /dev/null
+++ b/drivers/gpu/drm/vino/drm_sink.rs
@@ -0,0 +1,698 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! DRM/KMS sink: register a real `struct drm_device` with an atomic mode-setting
+//! pipeline so the dock appears to userspace as a `card`/`renderD` node that can be
+//! `drmModeSetCrtc`'d. One CRTC driven by a single primary plane
+//! ([`VinoPlane::atomic_update`] -> EP08 scanout), a virtual encoder, and a virtual
+//! connector whose mode list comes from the dock's real EDID (falling back to 1080p),
+//! with GEM-shmem dumb buffers and `drm_gem_fb_create` framebuffers.
+//!
+//! Built on the safe KMS mode-object layer (`kernel::drm::kms`), not the raw
+//! `bindings::drm_*` C API: `VinoDrmDriver` implements `drm::kms::KmsDriver`, and each
+//! mode object (`VinoCrtc`/`VinoPlane`/`VinoConnector`/`VinoEncoder`) implements the
+//! matching `Driver*` trait rather than hand-assembling a C vtable.
+//!
+//! Not yet ported from the pre-safe-KMS driver (tracked as follow-up, not fabricated
+//! here since the extension points don't exist yet in `kernel::drm::kms`):
+//! - A second display head (the dock's DL3 protocol supports up to 4; only one is
+//!   wired here). `VinoPlane`/`VinoCrtc` hold their state inline rather than behind a
+//!   pointer-identity lookup table, so adding a head is a second `probe()` call away,
+//!   not a redesign.
+//! - A cursor plane, CRTC gamma LUT, and plane rotation property -- `kernel::drm::kms`
+//!   doesn't yet expose `drm_plane_create_rotation_property` or a second (cursor)
+//!   plane argument shape beyond what `UnregisteredCrtc::new` already threads through.
+//! - DDC/CI brightness/contrast as connector properties, and the `.detect`/`mode_valid`
+//!   connector hooks (report disconnected until a real EDID arrives; reject
+//!   over-budget modes) -- `DriverConnector` only exposes `get_modes` right now.
+//! - Damage-clip bounded conversion (always converts the full frame).
+//!
+//! None of this is reachable on real hardware yet regardless: the dock never engages
+//! its content-protection channel for vino (see `docs/BLOCKER.md`), so `atomic_update`
+//! never gets past the first `bulk_send`.
+
+use kernel::{
+    bindings, drm,
+    drm::kms::{
+        self,
+        connector::{self, ConnectorGuard},
+        crtc::{self, CrtcAtomicCommit, RawCrtc as _, RawCrtcState as _},
+        encoder,
+        plane::{self, PlaneAtomicCommit, RawPlaneState as _},
+        KmsDriver, ModeConfigGuard, ModeConfigInfo, ModeObject as _, NewKmsDevice, Probing,
+    },
+    error::code::EINVAL,
+    prelude::*,
+    sync::{aref::ARef, new_mutex, Mutex},
+    types::ForLt,
+};
+
+/// Fallback connector mode advertised by `get_modes` when the dock has not delivered a real
+/// downstream EDID yet. The live scanout geometry follows the actual framebuffer/negotiated
+/// mode (see [`scanout_one`]), so this is only the no-EDID default, not a hard scanout limit.
+const FALLBACK_W: i32 = 1920;
+const FALLBACK_H: i32 = 1080;
+
+/// `DRM_FORMAT_XRGB8888` (`fourcc_code('X','R','2','4')`); the dock scans out 32bpp.
+const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258;
+/// Primary-plane format list (opaque 32bpp scanout).
+static PRIMARY_FORMATS: [u32; 1] = [DRM_FORMAT_XRGB8888];
+
+/// Per-mode pixel-clock ceiling (kHz) -- about 4K@60 (CEA 594 MHz). With only one head wired
+/// there is no combined-heads budget to enforce (see the module doc); a real ceiling still
+/// needs the connector `mode_valid` hook this port doesn't expose yet, so this constant is
+/// currently unused wiring for when it does.
+#[allow(dead_code)]
+const MAX_HEAD_CLOCK_KHZ: i32 = 600_000;
+
+/// The one wired display head's video bulk-OUT endpoint (see the module doc -- only
+/// head 0 is wired for now).
+const VIDEO_EP: u8 = 0x08;
+
+/// The DRM driver marker type.
+pub(super) struct VinoDrmDriver;
+
+/// Convenience alias for our concrete `drm::Device`.
+pub(super) type VinoDrmDevice = drm::Device<VinoDrmDriver>;
+
+/// Mutable scanout state, guarded because the atomic `update` callback may run
+/// concurrently with itself. Holds the stateful Vino encoder (created lazily on the
+/// first flip, once the buffer geometry is known) and the EP08 frame sequence counter.
+pub(super) struct ScanoutState {
+    enc: Option<super::video::Encoder>,
+    /// Reusable `width*height` RGB565 conversion buffer, allocated once alongside `enc`.
+    /// vmalloc-backed + persistent: virtually-contiguous (no high-order page need) and
+    /// allocated once, rather than a fresh multi-MiB kmalloc every pageflip.
+    cur: VVec<u16>,
+    seq: u32,
+    /// Geometry (`width`, `height`) the encoder/`cur` were allocated for. The scanout follows
+    /// the live framebuffer size, so a mode switch re-allocates them when this no longer
+    /// matches.
+    dims: (usize, usize),
+    /// Size of the last EP08 frame produced, used to pre-reserve the next frame's buffer.
+    hint: usize,
+}
+
+/// The live CP session the bring-up work item publishes once the dock engages the cipher
+/// (`acks > 0`), so the KMS callbacks can seal+send runtime CP messages (a mode-set when the
+/// compositor switches mode) that continue the SAME keystream the bring-up setup left off at.
+/// `wire_seq` is the AES-CTR block counter (advanced by the content blocks of each send; the
+/// appended Dl3Cmac tag is not part of the keystream) and `counter` the dock-echoed inner CP
+/// counter. Both advance per send under the mutex.
+pub(super) struct CpLink {
+    ks: [u8; 16],
+    riv: [u8; 8],
+    wire_seq: u32,
+    counter: u16,
+}
+
+/// DRM device-private data: the bound USB interface (to reach the video EP) and the engaged
+/// CP session for runtime KMS-driven sends. Per-object state (the scanout buffers, cached
+/// EDID) lives in the owning [`VinoPlane`]/[`VinoConnector`] instead of here, since the safe
+/// KMS layer already gives each mode object callback direct access to its own driver-private
+/// data -- no pointer-identity lookup table needed.
+#[pin_data]
+pub(super) struct VinoDrmData {
+    intf: ARef<super::usb::Interface>,
+    #[pin]
+    cp_link: Mutex<Option<CpLink>>,
+    /// The device's one connector, stashed by [`KmsDriver::probe`] so [`VinoDrmData::set_edid`]
+    /// can reach its cached-EDID slot without needing DRM's mode-object list. Written once,
+    /// during single-threaded probe, before the device is registered; read-only thereafter.
+    connector: core::sync::atomic::AtomicPtr<VinoConnector>,
+}
+
+impl VinoDrmData {
+    pub(super) fn new(intf: ARef<super::usb::Interface>) -> impl PinInit<Self, Error> {
+        try_pin_init!(Self {
+            intf,
+            cp_link <- new_mutex!(Option::<CpLink>::None),
+            connector: core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()),
+        })
+    }
+
+    /// Publish the engaged CP session so the KMS callbacks can send runtime CP messages.
+    /// Called once by the bring-up work item after the dock acks (`acks > 0`). `wire_seq`/
+    /// `counter` are the next free values past the bring-up CP setup.
+    pub(super) fn publish_session(&self, ks: &[u8; 16], riv: &[u8; 8], wire_seq: u32, counter: u16) {
+        *self.cp_link.lock() = Some(CpLink { ks: *ks, riv: *riv, wire_seq, counter });
+    }
+
+    /// Seal and send one interactive CP message on EP02, advancing the session keystream.
+    /// `build(counter)` produces the inner CP message for the dock-echoed `counter` it is
+    /// handed (e.g. [`super::cp::set_mode`]); `tag_reserved` trailing bytes are dropped before
+    /// the live Dl3Cmac is appended. Returns `Ok(())` as a **no-op when CP is not engaged**.
+    /// The `cp_link` mutex serialises concurrent KMS callbacks. Runs from the atomic-commit
+    /// context (same as the scanout), so the blocking `bulk_send` is fine.
+    pub(super) fn send_cp(
+        &self,
+        id: u16,
+        tag_reserved: usize,
+        build: impl FnOnce(u16) -> Result<KVec<u8>>,
+    ) -> Result {
+        let mut guard = self.cp_link.lock();
+        let Some(link) = (&mut *guard).as_mut() else {
+            return Ok(()); // CP not engaged -- nothing to send
+        };
+        let msg = build(link.counter)?;
+        let content = &msg[..msg.len().saturating_sub(tag_reserved)];
+        let frame = super::cp::seal_interactive(&link.ks, &link.riv, id, link.wire_seq, content)?;
+        // SAFETY: a runtime CP send only happens after a successful bring-up while the
+        // DRM device (and thus this interface) is live; the interface is unbound only
+        // in `disconnect()`, which first unplugs the DRM device, so it is bound here.
+        let dev = unsafe { self.intf.as_bound() };
+        dev.bulk_send(super::EP_CTRL_OUT, &frame, super::timeout(), GFP_KERNEL)?;
+        link.wire_seq = link.wire_seq.wrapping_add(((content.len() + 15) / 16) as u32);
+        link.counter = link.counter.wrapping_add(1);
+        Ok(())
+    }
+
+    /// Push a DDC/CI Set-VCP write to the downstream monitor (brightness, contrast or DPMS
+    /// power). Wraps [`super::cp::ddc_set_vcp`] (`id=0x15`); a no-op until the cipher is
+    /// engaged.
+    pub(super) fn set_vcp(&self, vcp: u8, value: u16) -> Result {
+        self.send_cp(0x15, 0, |ctr| super::cp::ddc_set_vcp(ctr, vcp, value))
+    }
+
+    /// Cache the dock's EDID (read during probe) for the connector's `get_modes` to install,
+    /// then fire a hotplug so the compositor re-probes the connector. Only the connector
+    /// itself holds the cached blob (see [`VinoConnector::cached_edid`]); this just forwards
+    /// it there via the pointer [`KmsDriver::probe`] stashed in `self.connector`.
+    pub(super) fn set_edid(&self, dev: &VinoDrmDevice, blob: KVec<u8>) {
+        let ptr = self.connector.load(core::sync::atomic::Ordering::Acquire);
+        let Some(connector) = (unsafe { ptr.as_ref() }) else { return };
+        *connector.cached_edid.lock() = Some(blob);
+        dev.hotplug_event();
+    }
+}
+
+/// GEM object inner data. Empty: the shmem-backed `drm::gem::shmem::Object` (which
+/// wires `drm_gem_shmem_dumb_create`, so userspace `DRM_IOCTL_MODE_CREATE_DUMB`
+/// works) is enough until the EP08 scanout path consumes the framebuffers.
+#[pin_data]
+pub(super) struct VinoObject {}
+
+impl drm::gem::DriverObject for VinoObject {
+    type Driver = VinoDrmDriver;
+    type Args = ();
+
+    fn new<Ctx: drm::DeviceContext>(
+        _dev: &drm::Device<VinoDrmDriver, Ctx>,
+        _size: usize,
+        _args: (),
+    ) -> impl PinInit<Self, Error> {
+        try_pin_init!(VinoObject {})
+    }
+}
+
+/// Per-open DRM client state. Empty of driver data, but its lifetime is used to
+/// pin the module for the duration of an open DRM file (see [`VinoDrmFile::open`]).
+#[pin_data(PinnedDrop)]
+pub(super) struct VinoDrmFile {}
+
+impl drm::file::DriverFile for VinoDrmFile {
+    type Driver = VinoDrmDriver;
+
+    fn open(_dev: &drm::Device<Self::Driver>) -> Result<Pin<KBox<Self>>> {
+        let file = KBox::try_pin_init(try_pin_init!(Self {}), GFP_KERNEL)?;
+        // Pin this module while a DRM file is open. The Rust DRM `file_operations` are
+        // built with `owner = NULL` (drm/gem/mod.rs `create_fops`), so the DRM core's
+        // `try_module_get(fops->owner)` on open is a no-op: an open card fd does NOT
+        // keep the driver loaded. Unloading vino (rmmod, or USB teardown at shutdown)
+        // while a compositor still holds `/dev/dri/cardN` then frees the module's
+        // `.rodata` -- where the fops live -- under that open fd, so the next
+        // ioctl/close dereferences freed memory and oopses the kernel. Take an
+        // explicit module reference here, released 1:1 in `PinnedDrop` (run by
+        // `postclose_callback` on file close), to restore the pin the NULL
+        // `fops.owner` drops. Remove once the binding sets `fops.owner`.
+        // SAFETY: we are executing inside this module's own DRM `open` callback, so
+        // the module is live; taking an extra reference via `__module_get` is sound.
+        unsafe { bindings::__module_get(crate::THIS_MODULE.as_ptr()) };
+        Ok(file)
+    }
+}
+
+#[pinned_drop]
+impl PinnedDrop for VinoDrmFile {
+    fn drop(self: Pin<&mut Self>) {
+        // Release the module reference taken in `open` (balanced one-per-open-file).
+        // SAFETY: balances the `__module_get` in `open`; `THIS_MODULE` is valid for
+        // the lifetime of the module.
+        unsafe { bindings::module_put(crate::THIS_MODULE.as_ptr()) };
+    }
+}
+
+const INFO: drm::DriverInfo = drm::DriverInfo {
+    major: 0,
+    minor: 1,
+    patchlevel: 0,
+    name: c"vino",
+    desc: c"DisplayLink DL3 (Dell D6000) DRM driver",
+};
+
+#[vtable]
+impl drm::Driver for VinoDrmDriver {
+    type Data = VinoDrmData;
+    type File = VinoDrmFile;
+    type Object<Ctx: drm::DeviceContext> = drm::gem::shmem::Object<VinoObject, Ctx>;
+    type ParentDevice<Ctx: kernel::device::DeviceContext> = super::usb::Interface<Ctx>;
+    type RegistrationData = ForLt!(());
+    type Kms = Self;
+
+    const INFO: drm::DriverInfo = INFO;
+
+    // No driver-private ioctls (GEM/dumb + KMS handled by the DRM core).
+    kernel::declare_drm_ioctls! {}
+}
+
+#[vtable]
+impl KmsDriver for VinoDrmDriver {
+    type Connector = VinoConnector;
+    type Plane = VinoPlane;
+    type Crtc = VinoCrtc;
+    type Encoder = VinoEncoder;
+
+    fn mode_config_info(_dev: &drm::Device<Self, drm::Uninit>) -> Result<ModeConfigInfo> {
+        Ok(ModeConfigInfo {
+            min_resolution: (0, 0),
+            max_resolution: (4096, 4096),
+            max_cursor: (0, 0),
+            preferred_depth: 32,
+            preferred_fourcc: Some(DRM_FORMAT_XRGB8888),
+        })
+    }
+
+    fn probe(dev: &NewKmsDevice<'_, Self, Probing>) -> Result {
+        // Order matters: `possible_crtcs` for the plane/encoder is a bitmask of CRTC
+        // *indices*, which only exist once `UnregisteredCrtc::new` runs -- but planes
+        // must exist before the CRTC that references them. With exactly one CRTC ever
+        // created here, its index is always 0, so `possible_crtcs = 1` is correct by
+        // construction rather than needing the CRTC up front.
+        let primary = plane::UnregisteredPlane::<VinoPlane>::new(
+            dev,
+            1,
+            &PRIMARY_FORMATS,
+            None,
+            plane::Type::Primary,
+            None,
+            (),
+        )?;
+        let crtc_obj = crtc::UnregisteredCrtc::<VinoCrtc>::new(
+            dev,
+            primary,
+            None::<&plane::UnregisteredPlane<VinoPlane>>,
+            None,
+            (),
+        )?;
+        let enc = encoder::UnregisteredEncoder::<VinoEncoder>::new(
+            dev,
+            encoder::Type::Virtual,
+            crtc_obj.mask(),
+            0,
+            None,
+            (),
+        )?;
+        let conn =
+            connector::UnregisteredConnector::<VinoConnector>::new(dev, connector::Type::Virtual, ())?;
+        conn.attach_encoder(&*enc)?;
+        // Stash a pointer to our own connector data (not the wrapping `Connector<T>`, which
+        // this crate has no public way to reconstruct from a reference) so
+        // `VinoDrmData::set_edid` can reach it later without walking DRM's mode-object list.
+        // `conn` outlives the device (destroyed only alongside it), so this is valid for as
+        // long as `dev.connector` is read.
+        let data: &VinoDrmData = dev;
+        data.connector.store(
+            &**conn as *const VinoConnector as *mut VinoConnector,
+            core::sync::atomic::Ordering::Release,
+        );
+        Ok(())
+    }
+}
+
+// ---- CRTC -----------------------------------------------------------------
+
+#[pin_data]
+pub(super) struct VinoCrtc;
+
+#[derive(Clone, Default)]
+pub(super) struct VinoCrtcState;
+
+impl crtc::DriverCrtcState for VinoCrtcState {
+    type Crtc = VinoCrtc;
+}
+
+#[vtable]
+impl crtc::DriverCrtc for VinoCrtc {
+    type Args = ();
+    type Driver = VinoDrmDriver;
+    type State = VinoCrtcState;
+    type VblankImpl = core::marker::PhantomData<Self>;
+
+    fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, _args: &()) -> impl PinInit<Self, Error> {
+        try_pin_init!(VinoCrtc {})
+    }
+
+    /// The display is turning on (scanout begins). Pushes a live mode-set CP message for the
+    /// negotiated mode and brings the monitor out of DPMS standby -- both no-ops until CP
+    /// engages (the wall).
+    fn atomic_enable(commit: CrtcAtomicCommit<'_, Self>) {
+        let data: &VinoDrmData = commit.crtc().drm_dev();
+        let new = commit.take_new_state();
+        let timing = super::cp::timing_from_drm_mode(new.mode());
+        pr_info!(
+            "vino: KMS CRTC enable -- display ON, mode {}x{}@{} (scanout begins)\n",
+            timing.hactive,
+            timing.vactive,
+            timing.refresh_hz
+        );
+        if let Err(e) = data.send_cp(0x48, 16, |ctr| super::cp::set_mode(ctr, &timing)) {
+            pr_warn!("vino: runtime mode-set send failed ({e:?})\n");
+        }
+        let _ = data.set_vcp(super::cp::VCP_POWER_MODE, super::cp::POWER_ON);
+    }
+
+    /// The display is turning off (DPMS-off/blank/suspend all land here in atomic KMS).
+    /// Resets the scanout state so a later re-enable sends a full keyframe rather than diffing
+    /// against a shadow the dock may have dropped, and blanks the monitor via DDC/CI -- a
+    /// no-op until CP engages.
+    fn atomic_disable(commit: CrtcAtomicCommit<'_, Self>) {
+        let data: &VinoDrmData = commit.crtc().drm_dev();
+        let _ = data.set_vcp(super::cp::VCP_POWER_MODE, super::cp::POWER_OFF);
+        pr_info!("vino: KMS CRTC disable -- display OFF (scanout stopped)\n");
+    }
+}
+
+// ---- Primary plane / scanout -----------------------------------------------
+
+#[pin_data]
+pub(super) struct VinoPlane {
+    #[pin]
+    scanout: Mutex<ScanoutState>,
+}
+
+#[derive(Clone, Default)]
+pub(super) struct VinoPlaneState;
+
+impl plane::DriverPlaneState for VinoPlaneState {
+    type Plane = VinoPlane;
+}
+
+#[vtable]
+impl plane::DriverPlane for VinoPlane {
+    type Args = ();
+    type Driver = VinoDrmDriver;
+    type State = VinoPlaneState;
+
+    fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, _args: ()) -> impl PinInit<Self, Error> {
+        try_pin_init!(VinoPlane {
+            scanout <- new_mutex!(ScanoutState {
+                enc: None,
+                cur: VVec::new(),
+                seq: 0,
+                dims: (0, 0),
+                hint: 0,
+            }),
+        })
+    }
+
+    /// A new framebuffer was flipped in. Maps it, converts XRGB8888 -> RGB565 (or feeds the
+    /// WHT colour codec directly for an aligned mode), and bulk-writes the resulting EP08
+    /// frame(s).
+    ///
+    /// The EP08 write only happens once the dock has engaged CP (see `docs/BLOCKER.md`):
+    /// until then the dock NAKs/stalls EP08, so a normal module load must not push frames on
+    /// every flip and thrash the dock. With the CP-engagement wall unsolved this never fires
+    /// on real hardware.
+    fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
+        if !super::CP_ENGAGED.load(core::sync::atomic::Ordering::SeqCst) {
+            return;
+        }
+        let plane = commit.plane();
+        let data: &VinoDrmData = plane.drm_dev();
+        let new = commit.take_new_state();
+        let Some(fb) = new.framebuffer::<VinoDrmDriver>() else { return };
+        // The plane's destination geometry mirrors the negotiated mode (the compositor sizes the
+        // primary plane 1:1 with the virtual output), so this drives the dynamic scanout
+        // resolution.
+        let (w, h) = (new.crtc_w() as usize, new.crtc_h() as usize);
+
+        use core::sync::atomic::Ordering::Relaxed;
+        // Throttle: while scanout is failing (dock NAKing because CP isn't engaged), skip the
+        // upcoming pageflips set by the backoff below instead of converting+encoding+sending a
+        // frame the dock will just drop.
+        let skip = super::SCANOUT_SKIP.load(Relaxed);
+        if skip > 0 {
+            super::SCANOUT_SKIP.store(skip - 1, Relaxed);
+            return;
+        }
+        match scanout_one(data, plane, fb, w, h) {
+            Ok(()) => {
+                let n = super::SCANOUT_FAILS.swap(0, Relaxed);
+                super::SCANOUT_SKIP.store(0, Relaxed);
+                if n > 0 {
+                    pr_info!("vino: scanout recovered after {n} failed frame(s)\n");
+                }
+            }
+            Err(e) => {
+                // The dock NAKs every EP08 write (EPROTO) until CP engages -- expected and not
+                // actionable. Log the first failure and then at exponentially sparser points so
+                // dmesg isn't flooded, and back off the scanout rate.
+                let n = super::SCANOUT_FAILS.fetch_add(1, Relaxed) + 1;
+                if n == 1 || n.is_power_of_two() {
+                    pr_err!("vino: scanout frame failed ({e:?}) [x{n}] -- throttling\n");
+                }
+                // Linear backoff capped at 120 frames (~2 s @ 60 Hz) between probe attempts, so
+                // recovery (CP engaging) is still detected within ~2 s while idle CPU stays low.
+                super::SCANOUT_SKIP.store(core::cmp::min(n, 120), Relaxed);
+            }
+        }
+    }
+}
+
+/// Clear-halt + prime the main bulk-OUT video endpoint before the first live-scanout write,
+/// so it doesn't ETIMEDOUT on a stale endpoint toggle. DLM clear-halts these at engagement
+/// (the "startRender" step). Once.
+fn prime_video_eps(dev: &super::usb::Interface<kernel::device::Bound>) {
+    if !super::EP08_SCANOUT_PRIMED.swap(true, core::sync::atomic::Ordering::SeqCst) {
+        for ep in [0x08u8, 0x0a, 0x0b, 0x0c] {
+            let _ = dev.clear_halt(ep);
+        }
+        pr_info!("vino: video endpoints primed (clear-halt 8/10/11/12)\n");
+    }
+}
+
+/// vmap `fb`, encode it, and push one EP08 frame. Split out so `?` can be used.
+fn scanout_one(
+    data: &VinoDrmData,
+    plane: &plane::Plane<VinoPlane>,
+    fb: &kms::framebuffer::Framebuffer<VinoDrmDriver>,
+    w: usize,
+    h: usize,
+) -> Result {
+    if w == 0 || h == 0 {
+        return Err(EINVAL);
+    }
+    // Map the framebuffer's backing pages into the kernel address space; the guard unmaps on
+    // drop, including on an early return below.
+    let vmap = fb.vmap()?;
+    // The real source stride: GEM dumb buffers pad the pitch (alignment), so it is not necessarily
+    // `w * 4` -- read it from the framebuffer rather than assuming.
+    let pitch = fb.pitch(0) as usize;
+    encode_and_send(data, plane, vmap.as_ptr(), pitch, w, h)
+}
+
+/// Encode the mapped frame with the byte-exact Vino WHT **colour** codec and bulk-write the
+/// resulting EP08 frame(s). Reads the source XRGB8888 at full 8-bit precision (no RGB565
+/// reduction -- the codec works in 8-bit RGB). A full-frame keyframe is sent each flip
+/// (correct; strip-level damage is a bandwidth optimisation for later). `w`/`h` must be
+/// 64x16-aligned (the caller checks; [`super::video::wht::colour_frame_ep08`] returns
+/// `EINVAL` otherwise).
+fn encode_and_send_wht(
+    data: &VinoDrmData,
+    plane: &plane::Plane<VinoPlane>,
+    vaddr: *const u8,
+    pitch: usize,
+    w: usize,
+    h: usize,
+) -> Result {
+    let seq0 = plane.scanout.lock().seq;
+    let (frames, next_seq) = super::video::wht::colour_frame_ep08(w, h, seq0, |dx, dy| {
+        // SAFETY: `dy*pitch + dx*4 + 3` is within the mapped source framebuffer (`pitch*h` bytes);
+        // the caller (colour_frame_ep08) only invokes this for `dx < w <= pitch/4`, `dy < h`.
+        let px = unsafe { (vaddr.add(dy * pitch + dx * 4) as *const u32).read_unaligned() };
+        (((px >> 16) & 0xff) as u8, ((px >> 8) & 0xff) as u8, (px & 0xff) as u8)
+    })?;
+    plane.scanout.lock().seq = next_seq;
+
+    // SAFETY: scanout runs only while the DRM device is live; the interface is unbound only
+    // in `disconnect()`, which first unplugs the DRM device, so it is bound for this push.
+    let dev = unsafe { data.intf.as_bound() };
+    prime_video_eps(dev);
+    for frame in frames.iter() {
+        dev.bulk_send(VIDEO_EP, frame, super::timeout(), GFP_KERNEL)?;
+    }
+    Ok(())
+}
+
+/// Convert the mapped XRGB8888 frame to RGB565, Vino-encode it against the previous frame,
+/// and bulk-write the resulting EP08 frame to the dock.
+fn encode_and_send(
+    data: &VinoDrmData,
+    plane: &plane::Plane<VinoPlane>,
+    vaddr: *const u8,
+    pitch: usize,
+    w: usize,
+    h: usize,
+) -> Result {
+    // WHT colour codec path (default off): the byte-exact, bandwidth-efficient DLM-quality
+    // codec. Requires a 64x16-aligned mode; for non-aligned geometry (e.g. 1080p, height
+    // 1080 % 16 = 8) fall through to the RLE path until partial-strip edge handling is
+    // captured.
+    if super::EP08_WHT_CODEC
+        && w % super::video::wht::STRIP_W == 0
+        && h % super::video::wht::STRIP_H == 0
+    {
+        return encode_and_send_wht(data, plane, vaddr, pitch, w, h);
+    }
+    let frame = {
+        let mut st = plane.scanout.lock();
+        // On the first frame `cur` is freshly zeroed, so the whole buffer must be filled.
+        // Afterwards, unchanged regions of `cur` already hold the previous frame (== the
+        // shadow the encoder diffs against). Re-initialise the encoder/shadow/conversion
+        // buffers on the first frame AND whenever the framebuffer geometry changes (a mode
+        // switch), so they always match `cur`'s size.
+        let first = st.enc.is_none() || st.dims != (w, h);
+        if first {
+            st.enc = Some(super::video::Encoder::new(w, h, super::video::Mode::Rle)?);
+            st.cur = VVec::from_elem(0u16, w * h, GFP_KERNEL)?;
+            st.dims = (w, h);
+            st.hint = 0;
+        }
+        let ScanoutState { enc, cur, seq, hint, dims: _ } = &mut *st;
+        for dy in 0..h {
+            for dx in 0..w {
+                // SAFETY: `dy*pitch + dx*4 + 3` is within the mapped source framebuffer
+                // (`pitch*h` bytes); `dx < w <= pitch/4`, `dy < h`.
+                let px = unsafe { (vaddr.add(dy * pitch + dx * 4) as *const u32).read_unaligned() };
+                let (r, g, b) = ((px >> 16) & 0xff, (px >> 8) & 0xff, px & 0xff);
+                cur[dy * w + dx] = (((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)) as u16;
+            }
+        }
+        let s = *seq;
+        *seq = seq.wrapping_add(1);
+        let enc = enc.as_mut().ok_or(kernel::error::code::ENOMEM)?;
+        // Encode straight into the outgoing frame buffer: reserve the EP08 header up front,
+        // append the codec stream in place, then back-patch the header now that the payload
+        // length is known.
+        const HDR: usize = super::video::EP08_HDR_LEN;
+        let mut frame = KVec::with_capacity((*hint).max(HDR + 64), GFP_KERNEL)?;
+        frame.extend_from_slice(&[0u8; HDR], GFP_KERNEL)?;
+        enc.encode_into(&*cur, &mut frame)?;
+        let payload_len = frame.len() - HDR;
+        super::video::write_ep08_header(&mut frame[..HDR], payload_len, s)?;
+        *hint = frame.len();
+        frame
+    };
+
+    // SAFETY: scanout runs only while the DRM device is live (driven by a compositor
+    // pageflip); the interface is unbound only in `disconnect()`, which first unplugs the
+    // DRM device, so it is bound for the duration of this push.
+    let dev = unsafe { data.intf.as_bound() };
+    prime_video_eps(dev);
+    dev.bulk_send(VIDEO_EP, &frame, super::timeout(), GFP_KERNEL)?;
+    Ok(())
+}
+
+// ---- Encoder ----------------------------------------------------------------
+
+#[pin_data]
+pub(super) struct VinoEncoder;
+
+#[vtable]
+impl encoder::DriverEncoder for VinoEncoder {
+    type Driver = VinoDrmDriver;
+    type Args = ();
+
+    fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, _args: ()) -> impl PinInit<Self, Error> {
+        try_pin_init!(VinoEncoder {})
+    }
+}
+
+// ---- Connector --------------------------------------------------------------
+
+#[pin_data]
+pub(super) struct VinoConnector {
+    /// This connector's downstream-monitor EDID (`None` until the CP channel delivers it).
+    #[pin]
+    cached_edid: Mutex<Option<KVec<u8>>>,
+}
+
+#[derive(Clone, Default)]
+pub(super) struct VinoConnectorState;
+
+impl connector::DriverConnectorState for VinoConnectorState {
+    type Connector = VinoConnector;
+}
+
+#[vtable]
+impl connector::DriverConnector for VinoConnector {
+    type Args = ();
+    type Driver = VinoDrmDriver;
+    type State = VinoConnectorState;
+
+    fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, _args: ()) -> impl PinInit<Self, Error> {
+        try_pin_init!(VinoConnector {
+            cached_edid <- new_mutex!(Option::<KVec<u8>>::None),
+        })
+    }
+
+    /// Install the dock's real EDID (read during probe) when available; otherwise fall back
+    /// to a single 1920x1080@60 CVT mode. Reading the real EDID gives the true monitor
+    /// name/size and its native mode list; the fallback keeps the connector usable when
+    /// nothing is plugged into the dock or the CP channel has not yet delivered the EDID.
+    fn get_modes<'a>(
+        connector: ConnectorGuard<'a, Self>,
+        guard: &ModeConfigGuard<'a, Self::Driver>,
+    ) -> i32 {
+        if let Some(blob) = connector.cached_edid.lock().as_ref() {
+            let n = connector.add_edid_modes(blob);
+            if n > 0 {
+                return n;
+            }
+        }
+        let _ = guard;
+        // No downstream EDID yet: advertise the standard mode list up to the fallback resolution
+        // and prefer it, keeping the connector usable until the dock delivers a real EDID.
+        let n = connector.add_modes_noedid((FALLBACK_W as u32, FALLBACK_H as u32));
+        connector.set_preferred_mode((FALLBACK_W as u32, FALLBACK_H as u32));
+        n
+    }
+}
+
+/// Map an output pixel `(dx, dy)` back to its source-framebuffer pixel `(sx, sy)` under a DRM
+/// plane `rotation` bitmask (`DRM_MODE_ROTATE_*` | `DRM_MODE_REFLECT_*`, the values the
+/// standard `drm_plane_create_rotation_property` exposes). `sw`/`sh` are the SOURCE
+/// (framebuffer) dimensions. Rotation is clockwise; reflection is applied in source space
+/// after rotation. Pure and total (saturating), so it is unit-tested directly ahead of the
+/// rotation property itself being wired up (see the module doc).
+#[allow(dead_code)]
+pub(super) fn rot_src(rotation: u32, dx: usize, dy: usize, sw: usize, sh: usize) -> (usize, usize) {
+    let xmax = sw.saturating_sub(1);
+    let ymax = sh.saturating_sub(1);
+    let rot = rotation & bindings::DRM_MODE_ROTATE_MASK;
+    let (mut sx, mut sy) = if rot == bindings::DRM_MODE_ROTATE_90 {
+        (dy, ymax.saturating_sub(dx))
+    } else if rot == bindings::DRM_MODE_ROTATE_180 {
+        (xmax.saturating_sub(dx), ymax.saturating_sub(dy))
+    } else if rot == bindings::DRM_MODE_ROTATE_270 {
+        (xmax.saturating_sub(dy), dx)
+    } else {
+        (dx, dy) // ROTATE_0 / unset
+    };
+    if rotation & bindings::DRM_MODE_REFLECT_X != 0 {
+        sx = xmax.saturating_sub(sx);
+    }
+    if rotation & bindings::DRM_MODE_REFLECT_Y != 0 {
+        sy = ymax.saturating_sub(sy);
+    }
+    (sx, sy)
+}
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH v2 06/10] drm/vino: add the DisplayLink DL3 dock driver
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
                     ` (4 preceding siblings ...)
  2026-07-03  3:02   ` [RFC PATCH v2 05/10] drm/vino: add the DRM/KMS sink, built on the safe KMS mode-object layer Mike Lothian
@ 2026-07-03  3:02   ` Mike Lothian
  2026-07-03  3:18     ` sashiko-bot
  2026-07-03  3:02   ` [RFC PATCH v2 07/10] drm/vino: wire the hardware cursor plane Mike Lothian
                     ` (3 subsequent siblings)
  9 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-07-03  3:02 UTC (permalink / raw)
  To: rust-for-linux
  Cc: dri-devel, David Airlie, Simona Vetter, Thomas Zimmermann,
	Maarten Lankhorst, Maxime Ripard, Danilo Krummrich, Lyude Paul,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	linux-kernel, Mike Lothian

Ties the rest of the series together: the USB bind (usb::Driver for
the D6000's control interface), the plaintext session-init handshake,
driving the HDCP 2.2 AKE and control-plane bring-up from a deferred
work item, publishing the engaged CP session and dock EDID to the DRM
device once bring-up completes, and the on-device crypto known-answer
self-tests plus the KUnit coverage for the protocol builders/parsers
(gated on CONFIG_KUNIT, zero effect on a production build).

Everything up to the encrypted control-plane engagement works on real
hardware (USB bring-up, HDCP 2.2 AKE/LC/SKE verified, the AES-CTR +
Dl3Cmac seal byte-exact, the stream-open arm marker, a registered
DRM/KMS card), but the dock never acks the first encrypted CP frame
(wsub=0x45 stays 0), so no pixels flow yet -- see docs/BLOCKER.md for
what's been tried and ruled out.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-sonnet-5 [Claude-Code]
---
 drivers/gpu/drm/vino/vino.rs | 2617 ++++++++++++++++++++++++++++++++++
 1 file changed, 2617 insertions(+)
 create mode 100644 drivers/gpu/drm/vino/vino.rs

diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
new file mode 100644
index 000000000000..eb4378a747c3
--- /dev/null
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -0,0 +1,2617 @@
+// SPDX-License-Identifier: GPL-2.0
+// SPDX-FileCopyrightText: Copyright (C) 2026 Mike Lothian
+
+//! Vino -- open in-kernel Rust driver for DisplayLink DL3 docks (Dell D6000, ...).
+//!
+//! This is an `[RFC]` work-in-progress, posted to ask for help. It is a clean-room
+//! reverse-engineered replacement for the proprietary DisplayLinkManager userspace
+//! daemon + the EVDI kernel module, written natively in Rust against the in-tree USB,
+//! crypto and DRM/KMS bindings (the prerequisite binding patches are posted as their
+//! own series).
+//!
+//! # What works
+//!
+//! On probe the driver runs, all on real hardware (Dell Universal Dock D6000):
+//! - the plaintext connect handshake over the Rust USB bulk + control transfer API;
+//! - the clean-room HDCP 2.2 AKE / LC / SKE -- H', L' and V' all verify against the
+//!   dock, so the session key `ks` is established and shared;
+//! - the AES-CTR + AES-CMAC ("Dl3Cmac") control-plane seal, byte-exact against the
+//!   reference daemon's captured wire;
+//! - the plaintext `type=2 sub=0x24` stream-open arm marker; and
+//! - registration of a real `struct drm_device` (see [`drm_sink`]) via the simple
+//!   display pipe, so the dock appears to userspace as a mode-settable GEM/dumb DRM
+//!   card, with a live EP08 framebuffer-scanout hook on every page-flip.
+//!
+//! # What does NOT work -- the wall (help wanted)
+//!
+//! After the arm marker the driver sends the first encrypted control-plane frame
+//! (msg0) and the dock **never acknowledges it** (`wsub=0x45` ack count stays 0), so
+//! the CP cipher never engages and no pixels ever flow. Every host-observable channel
+//! has been matched to the reference daemon -- the bulk wire is byte-identical through
+//! the arm + msg0, the AKE verifies, the seal/MAC/IV are byte-exact, the full EP0
+//! control-transfer set matches, the endpoint set matches, the arm timing is tighter
+//! than the daemon's -- and the dock still silently drops our encrypted CP while it
+//! engages the daemon's. The gate appears to be something not visible on the host wire
+//! (dock-internal session state, or a whole-bus timing/ordering property a per-channel
+//! diff cannot see). **If you know the DL3 / DisplayLink control-plane engagement
+//! sequence, or have ideas for the remaining paired full-bus diff, please help.**
+//!
+//! Note: `send_cp_setup` builds msg0's body field-by-field except for a small captured
+//! cap-announce skeleton ([`golden`]); a fully field-derived cap-announce is open work.
+//!
+//! Device: VID 0x17e9 (DisplayLink) / PID 0x6006 (Dell Universal Dock D6000).
+
+use kernel::{
+    alloc::flags::GFP_KERNEL,
+    bindings,
+    drm,
+    device::{self, Core},
+    error::code::{ENODEV, EINVAL},
+    prelude::*,
+    sync::{aref::ARef, Arc},
+    time::{
+        delay::{fsleep, udelay},
+        Delta, Instant, Monotonic,
+    },
+    usb,
+    workqueue::{self, impl_has_work, new_work, Work, WorkItem},
+};
+
+/// DisplayLink vendor id.
+const VID_DISPLAYLINK: u16 = 0x17e9;
+/// Dell Universal Dock D6000 (DL3 family) product id.
+const PID_D6000: u16 = 0x6006;
+
+/// Control + per-head bulk endpoints (guide sec 2).
+const EP_CTRL_OUT: u8 = 0x02;
+const EP_CTRL_IN: u8 = 0x84;
+/// EP84 (dock->host) drain buffer size. The dock's capability block can reach ~5.8 KiB, so a
+/// single bulk read needs a generously sized buffer to avoid truncating and misframing it.
+const EP84_BUF: usize = 16384;
+/// Number of IN URBs kept perpetually posted on EP84 by the async reader
+/// ([`usb::Interface::bulk_in_queue`]); `depth - 1` stay outstanding while one is serviced.
+///
+/// MEASURED 2026-06-27 (`WinCap` IRP pairing + usbmon S/C pairing): **both** reference drivers run
+/// EP84 at an outstanding depth of exactly **1** -- Windows DLM (USBPcap `max_outstanding_depth=1`)
+/// and Linux DLM (usbmon `max_depth=1`) post one IN read, wait for the dock's reply, then re-post.
+/// It is an "always one posted" reader, not a deep queue. vino was the only implementation running
+/// depth=4, so its EP84 IN-token/NAK cadence differed from every engaging driver. Match them
+/// exactly (1) -- still always-posted (re-armed on completion) but with no extra concurrent reads.
+const EP84_QUEUE_DEPTH: usize = 1;
+
+/// USB transfer timeout used during bring-up.
+fn timeout() -> Delta {
+    Delta::from_millis(1000)
+}
+
+/// Impersonate DLM's **fixed-timer** bring-up fingerprint instead of vino's reactive pacing.
+///
+/// The 2026-06-25 step-timing survey (`captures/step-timing-survey-20260625.md`) across 9 DLM
+/// and 29 vino plugs showed DLM does NOT react to the wire -- it uses hardcoded sleeps, so its
+/// pre-arm milestones are tight constants: `cp_first->cert` ~1.3 ms, `cert->arm` ~59.1 ms,
+/// `arm->msg0` ~0.17 ms. vino's reactive settle scatters those (cert->arm 57-292 ms) and, at
+/// the one step we can measure precisely, fires msg0 ~0.07 ms after the arm -- ~2x FASTER than
+/// DLM, the only consistent timing *inversion* in the whole corpus and a never-tested variable.
+///
+/// With this on, vino reproduces DLM's fingerprint as closely as the host allows:
+///   - the pre-AKE stale-EP84 flush probes at 1 ms (front gap ~1 ms, like DLM) -- see `run_ake`;
+///   - it holds the arm marker to a *fixed* [`CERT_TO_ARM_US`] after the cert (59.1 ms, like
+///     DLM) instead of arming reactively the instant the AKE settles -- see `send_cp_setup`;
+///   - it holds [`ARM_TO_MSG0`] between the arm marker and msg0 (~0.17 ms, like DLM);
+///   - it logs the realised `cp_start->arm`, `cert->arm` and the hold so the next cold plug's
+///     dmesg reports the actual fingerprint for an A/B against DLM.
+///
+/// The cert->arm hold is the key one: the 2026-06-26 corpus re-measure showed DLM's `cert->arm`
+/// is 59.11 ms +-0.5 ms across 11 plugs (a hard sleep) while vino arms reactively at ~57.9 ms --
+/// *below DLM's observed minimum* every time. So vino was consistently arming a hair ahead of the
+/// window DLM ever uses; the fixed hold closes that and makes the step deterministic.
+///
+/// Default ON: this is the one timing combination we have never put on the wire simultaneously.
+/// Flip to `false` to restore the reactive pacing for a clean paired diff.
+const DLM_FIXED_TIMERS: bool = true;
+
+/// Mimic the Windows DisplayLink driver's pre-arm control choreography instead of the Linux/libusb
+/// DLM one (2026-06-27, `WinCap/WINCAP-ANALYSIS.md`). The USBPcap traces of three engaging Windows
+/// sessions on this exact dock (`bcdDevice=0x3159`) showed a *leaner* device-open than DLM's libusb
+/// stack, on three concrete, observable axes:
+///   1. ONE device-open vendor-IN read -- only `0xc1 0xfe wIdx=1` (the 16 B "RidgeDock" blob).
+///      Windows never issues the `0xfc`/`0xfd`/`0xfb` DFU reads vino picked up from the DLM oracle.
+///   2. NO libusb descriptor-burst at open (the CONFIG 618x3/40x3 + STRING 255x22 replay): Windows
+///      runs over the already-enumerated device and uses cached descriptors, exactly like a native
+///      kernel driver -- so [`CP_LIBUSB_OPEN_ENUM`] is forced OFF.
+///   3. The `0x40 bReq=0x24` vendor-OUT uses **wValue=0** (Windows), not wValue=3 (Linux DLM/vino).
+/// The analysis already proved none of these is the CP gate (Linux DLM engages WITH the libusb burst
+/// and wValue=3; Windows engages WITHOUT them) -- so this is a "just in case" A/B, not a fix. It is
+/// the smallest set of changes that makes vino's EP0 pre-arm stream resemble Windows'. The cap-
+/// announce / cert-req framing is left alone (changing it risks the byte-exact seal, and both DLM's
+/// 7-descriptor form -- which vino matches -- and Windows' 6-descriptor form engage). Flip to `false`
+/// to restore the DLM/libusb behaviour for a clean paired-vs-DLM diff.
+const WINDOWS_MIMIC: bool = true;
+
+/// DLM's `cert->arm` hold: 59.11 ms median, [58.74..59.84] over 11 DLM cold plugs (2026-06-26
+/// corpus re-measure) -- a hardcoded sleep, not a reaction. Under [`DLM_FIXED_TIMERS`],
+/// `send_cp_setup` holds the arm marker until this long after `Session::cert_at` so vino arms on
+/// DLM's fixed schedule instead of the moment its AKE happens to finish (~57.9 ms, always early).
+const CERT_TO_ARM_US: i64 = 59_100;
+
+/// DLM's `ctr2->ctr3` gap (AKE_Transmitter_Info -> AKE_No_Stored_km): consistently **~1.65 ms**
+/// over 6 cold plugs [1.55..1.89], vs vino's **~0.30 ms** (5x faster, and rock-steady across both
+/// corpora -- 2026-06-26 per-message latency analysis vs the same-day engaging DLM baseline). This
+/// is the window where a real HDCP transmitter **verifies the receiver's DCP-signed certificate**
+/// (RSA-1024 signature check + revocation) before wrapping `km`; vino skips it -- it only
+/// RSA-OAEP-encrypts `km`, which is why it is so much faster. It is the first consistent,
+/// host-reachable behavioural divergence from DLM found since the host was declared "exhausted",
+/// and it fits the wall's evidence box (invisible to a passive byte diff; DLM satisfies it for
+/// free; a repeater could time it as a locality-style "did you actually validate me?" check).
+/// Under [`DLM_FIXED_TIMERS`], hold `ctr2->ctr3` to this so vino spends a realistic cert-verify
+/// time instead of answering impossibly fast.
+const CERT_VERIFY_HOLD_US: i64 = 1650;
+
+/// DLM's median `arm->msg0` gap (the survey: 0.152 / 0.188 ms on the two clean DLM cold plugs).
+/// vino naturally fires msg0 ~0.07 ms after the arm; hold to match DLM when [`DLM_FIXED_TIMERS`].
+const ARM_TO_MSG0: Delta = Delta::from_micros(170);
+
+/// Per-frame send pads that reproduce DLM's `cp_first->cert` cadence (2026-06-26 frame-by-frame
+/// diff of the plaintext session-init + AKE_Init burst). With the cold-plug flush removed, vino's
+/// sync `bulk_send`s fire back-to-back ~0.37 ms QUICKER than DLM's pipelined libusb URBs, so
+/// `cp_first->cert` was 0.64 ms vs DLM's 1.07 ms. Each pad is the measured per-gap deficit
+/// (vino gap -> DLM gap): init_0->init_25 0.065->0.144, init_25->init_4 0.194->0.372,
+/// session-init-ACK->AKE_Init 0.043->0.159. Applied as `udelay` (calibrated busy-wait, us-precise,
+/// unlike `fsleep`'s slack) only under [`DLM_FIXED_TIMERS`]. Sum 0.373 ms lands `cp_first->cert`
+/// on DLM's ~1.07 ms.
+const PAD_INIT0_TO_INIT25_US: i64 = 79;
+const PAD_INIT25_TO_INIT4_US: i64 = 120;
+const PAD_ACK_TO_AKEINIT_US: i64 = 90;
+
+/// Post-enumeration idle hold (2026-06-26). From `SET_CONFIGURATION` to the first CP frame, vino
+/// (an in-kernel driver that probes the instant the device is configured) responds in ~4.8 ms,
+/// whereas DLM -- a userspace daemon behind evdi that the OS must notify, schedule and have open
+/// the device -- takes ~15.5 ms (14.6/16.3 ms over two cold plugs). That ~10.7 ms is pure
+/// userspace-startup latency, the one place vino is *dramatically* faster than DLM rather than
+/// within sub-ms noise. Under [`DLM_FIXED_TIMERS`], idle this long at the very top of `bring_up`
+/// (before any USB transaction) so vino's enumeration-to-first-CP response matches DLM's ~15 ms
+/// instead of beating it 3x -- closing the last large timing divergence from DLM's fingerprint.
+const ENUM_RESPONSE_HOLD_US: i64 = 9_800;
+
+/// Hold until `anchor` is at least `target_us` old, to microsecond precision. A plain `fsleep`
+/// overshoots a wall-clock target by its timer slack (the cert->arm hold targeted 59.1 ms but
+/// `fsleep` alone landed it at 59.4 ms); so `fsleep` the bulk of the wait (cheap -- it must not
+/// busy-burn ~1 ms of CPU) leaving a margin, then re-measure and `udelay` the exact residual to
+/// hit `target_us` on the nose. Never returns before `target_us` of `anchor` has elapsed; returns
+/// immediately if it already has. Used to realise DLM's fixed pre-arm timer (`DLM_FIXED_TIMERS`).
+fn hold_until(anchor: Instant<Monotonic>, target_us: i64) {
+    /// Leave this much for the precise `udelay` tail; `fsleep`'s slack stays under it.
+    const SPIN_MARGIN_US: i64 = 400;
+    let now = anchor.elapsed().as_micros_ceil();
+    if now >= target_us {
+        return;
+    }
+    if target_us - now > SPIN_MARGIN_US {
+        fsleep(Delta::from_micros(target_us - now - SPIN_MARGIN_US));
+    }
+    let now = anchor.elapsed().as_micros_ceil();
+    if now < target_us {
+        udelay(Delta::from_micros(target_us - now));
+    }
+}
+
+/// Set once the dock has actually engaged the CP cipher (`wsub=0x45` acks > 0). EP08 video is
+/// gated on it: pushing frames at a dock whose CP channel is dead makes it fault and USB-reset.
+/// NOTE: with the current CP-engagement wall (see the file header) this is never set on real
+/// hardware -- the dock runs the whole plaintext handshake but never engages the encrypted CP.
+static CP_ENGAGED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
+
+/// One-shot: clear-halt + prime the video endpoints before the first live-scanout EP08 write.
+static EP08_SCANOUT_PRIMED: core::sync::atomic::AtomicBool =
+    core::sync::atomic::AtomicBool::new(false);
+
+/// Select the live scanout codec. `false` (default) = the RLE/mode-2 path (`video::Encoder`),
+/// which is what the dock currently runs and which the dock NAKs pre-CP anyway. `true` = the
+/// byte-exact Vino WHT **colour** codec (`video::wht::colour_frame_ep08`), the bandwidth-efficient
+/// DLM-quality path. Kept a compile-time flag (default off) so the RLE path stays the live default
+/// and the switch is testable independently the moment the CP wall falls; the WHT path needs a
+/// 64x16-aligned mode and falls back to RLE otherwise (see `docs/VIDEO-TODO.md`).
+const EP08_WHT_CODEC: bool = false;
+
+/// Consecutive failed live-scanout frames, for log rate-limiting. Until CP engages, the dock
+/// NAKs every EP08 write (EPROTO), so without this every compositor pageflip would spam dmesg.
+static SCANOUT_FAILS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
+
+/// Pageflip throttle: number of upcoming pageflips to skip before the next scanout attempt
+/// (a backoff while the dock NAKs). A single successful frame clears it.
+static SCANOUT_SKIP: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
+
+/// Set once the bring-up work item finishes (AKE/CP attempt done). `detect` only connects the
+/// live-scanout connector AFTER this, so a compositor enabling the output cannot start EP08
+/// scanout on top of the still-running AKE on the same USB device.
+static BRINGUP_COMPLETE: core::sync::atomic::AtomicBool =
+    core::sync::atomic::AtomicBool::new(false);
+
+mod proto;
+mod crypto;
+mod rng;
+mod hdcp;
+mod ake;
+mod golden;
+mod cp;
+mod video;
+
+/// The shared secrets a completed HDCP 2.2 AKE leaves behind: the SKE session key
+/// `ks` and content IV `riv` key the AES-CTR control plane (sec 6), and `kd` is kept
+/// for any further repeater verification. Consumed by the Phase 2b/2c CP + video.
+#[allow(dead_code)] // ks/riv/kd are consumed by the post-engagement CP stream (open blocker)
+struct Session {
+    ks: [u8; 16],
+    riv: [u8; 8],
+    kd: [u8; 32],
+    /// The 7-frame **plaintext capability-announce** to send between the init markers and
+    /// the arm marker (see `VinoDriver::build_cap_announce`). Built LIVE
+    /// from this session's AKE values (rtx/ekpub/rn/edkey+riv/V) -- NOT a stale replay. Empty
+    /// for a non-repeater dock (the announce path is only exercised on the D6000, repeater=1).
+    cap_announce: KVec<u8>,
+    /// Monotonic timestamp of the first CP frame (~`cp_first` in the timing survey), taken at
+    /// the top of [`run_ake`]. Used by [`send_cp_setup`] to realise DLM's fixed pre-arm timer
+    /// and to log the achieved `cp_start->arm` fingerprint. See [`DLM_FIXED_TIMERS`].
+    cp_start: Instant<Monotonic>,
+    /// Monotonic timestamp of the dock's `AKE_Send_Cert` push (`cert` in the timing survey),
+    /// taken the instant [`run_ake`] receives it. DLM arms a *fixed* [`CERT_TO_ARM_US`] after
+    /// this point (59.1 ms, +-0.5 ms over 11 cold plugs -- a hardcoded sleep), whereas vino's
+    /// reactive settle arms the moment the AKE completes (~57.9 ms -- consistently *earlier*
+    /// than DLM's tightest run). [`send_cp_setup`] holds the arm to this offset under
+    /// [`DLM_FIXED_TIMERS`] so vino never arms ahead of DLM's window. See [`DLM_FIXED_TIMERS`].
+    cert_at: Instant<Monotonic>,
+}
+
+mod drm_sink;
+
+/// Per-bound-interface driver state.
+struct VinoDriver {
+    _intf: ARef<usb::Interface>,
+    /// The registered `drm::Device` (only on the control interface, iface 0).
+    _ddev: Option<ARef<drm_sink::VinoDrmDevice>>,
+    /// A handle to the deferred bring-up work (control interface only), retained so
+    /// [`disconnect`](VinoDriver::disconnect) can `cancel_work_sync()` it: the work
+    /// does blocking USB I/O on the bound interface, so it must be flushed before the
+    /// interface is unbound (otherwise `Interface::as_bound` in `BringUp::run` would
+    /// touch an unbound interface). `None` on the idle sibling interface.
+    bringup: Option<Arc<BringUp>>,
+}
+
+/// Deferred bring-up work item: the bring-up sequence run on the system workqueue instead
+/// of inline in `probe()` (which would pin the driver-model probe thread on blocking USB
+/// I/O while the card node is live). Holds a refcounted handle to the bound interface (and,
+/// once the DRM sink exists, the DRM device), so they outlive `probe()`.
+#[pin_data]
+struct BringUp {
+    intf: ARef<usb::Interface>,
+    ddev: Option<ARef<drm_sink::VinoDrmDevice>>,
+    #[pin]
+    work: Work<BringUp>,
+}
+
+impl_has_work! {
+    impl HasWork<Self> for BringUp { self.work }
+}
+
+impl BringUp {
+    fn new(
+        intf: ARef<usb::Interface>,
+        ddev: Option<ARef<drm_sink::VinoDrmDevice>>,
+    ) -> Result<Arc<Self>> {
+        Arc::pin_init(
+            pin_init!(BringUp {
+                intf,
+                ddev,
+                work <- new_work!("vino::bring_up"),
+            }),
+            GFP_KERNEL,
+        )
+    }
+}
+
+impl WorkItem for BringUp {
+    type Pointer = Arc<BringUp>;
+
+    fn run(this: Arc<BringUp>) {
+        let cdev: &device::Device = this.intf.as_ref();
+        // SAFETY: `BringUp` holds an `ARef<usb::Interface>` taken in `probe()`; the
+        // work item is cancelled and flushed in `disconnect()` before the interface
+        // is unbound (see `VinoDriver::disconnect`), so it stays bound for this run.
+        let dev: &usb::Interface<device::Bound> = unsafe { this.intf.as_bound() };
+        let ddev = &this.ddev;
+        // WIP scaffold: attempt the plaintext bring-up, then the clean-room HDCP 2.2
+        // AKE/LC/SKE, then the post-SKE CP setup. Bind regardless of the outcome -- there
+        // is no display path until the dock engages the encrypted control plane, which it
+        // currently never does (see the "help wanted" note at the top of the file).
+        match VinoDriver::bring_up(dev) {
+            Ok(()) => {
+                dev_info!(cdev, "vino: plaintext session init OK\n");
+                match VinoDriver::run_ake(dev) {
+                    Ok(session) => {
+                        dev_info!(cdev, "vino: HDCP AKE + LC + SKE complete (session keyed)\n");
+                        // Dev diagnostic: the live session key/riv, so the dock's encrypted
+                        // EP84 replies can be decoded offline from a usbmon capture. Behind
+                        // pr_debug, so compiled out unless dynamic debug is enabled.
+                        pr_debug!("vino: SESSION ks={:02x?} riv={:02x?}\n", &session.ks, &session.riv);
+
+                        // Phase 2c: drive the post-SKE CP setup. send_cp_setup re-seals
+                        // DLM's captured setup template under THIS session's live ks/riv and
+                        // sends it; `acks` counts the dock's encrypted wsub=0x45 replies.
+                        // THIS IS THE WALL: on a cold dock `acks` stays 0 -- the dock runs the
+                        // entire plaintext handshake but never engages the encrypted CP.
+                        let mut edid_out: Option<KVec<u8>> = None;
+                        match VinoDriver::send_cp_setup(dev, &session, &mut edid_out) {
+                            Ok((n, acks, wseq_end, ctr_end)) => {
+                                dev_info!(cdev,
+                                    "vino: CP setup sent -- {n} messages, {acks} dock CP acks (wsub=0x45)\n");
+                                // CP engagement gates EP08 video: until the dock acks, pushing
+                                // pixels at it wedges the hub.
+                                CP_ENGAGED.store(acks > 0, core::sync::atomic::Ordering::SeqCst);
+                                // Publish the engaged session to the DRM device so the KMS
+                                // callbacks
+                                // can send runtime CP (mode-set on a modeset, cursor on motion),
+                                // continuing this keystream. Only when the dock actually engaged.
+                                if acks > 0 {
+                                    if let Some(d) = ddev.as_ref() {
+                                        let data: &drm_sink::VinoDrmData = d;
+                                        data.publish_session(
+                                            &session.ks, &session.riv, wseq_end, ctr_end,
+                                        );
+                                    }
+                                }
+                            }
+                            Err(e) => dev_info!(cdev, "vino: CP setup incomplete ({e:?}) -- WIP\n"),
+                        }
+                        // Cache the dock's EDID on the DRM device (when the CP channel
+                        // delivered it) so the connector's get_modes installs the real
+                        // monitor descriptor via the standard DRM EDID helpers.
+                        if let (Some(blob), Some(d)) = (edid_out, ddev.as_ref()) {
+                            let n = blob.len();
+                            let dev: &drm_sink::VinoDrmDevice = d;
+                            let data: &drm_sink::VinoDrmData = dev;
+                            data.set_edid(dev, blob);
+                            dev_info!(cdev, "vino: cached dock EDID for connector ({n} bytes)\n");
+                        }
+                    }
+                    Err(e) => dev_info!(cdev, "vino: HDCP AKE incomplete ({e:?}) -- WIP\n"),
+                }
+            }
+            Err(e) => dev_info!(cdev, "vino: session init incomplete ({e:?}) -- WIP\n"),
+        }
+        // Bring-up attempt finished: allow the live-scanout connector to report connected
+        // and let a compositor drive EP08 frames, without racing the handshake.
+        BRINGUP_COMPLETE.store(true, core::sync::atomic::Ordering::SeqCst);
+        if let Some(d) = ddev.as_ref() {
+            let dev: &drm_sink::VinoDrmDevice = d;
+            dev.hotplug_event();
+            dev_info!(cdev, "vino: bring-up complete -- live-scanout connector now connected\n");
+        }
+    }
+}
+
+/// On-device crypto known-answer self-test. Confirms the IN-KERNEL crypto path (which the CP seal
+/// depends on) is byte-correct -- something only ever checked offline (Python `verify-kdf.py`)
+/// before.
+/// Runs three checks and logs PASS/FAIL:
+///   1. AES-128-ECB vs the FIPS-197 test vector.
+///   2. AES-CMAC vs the RFC 4493 test vector (subkey + full-block path).
+///   3. The full `cp::seal_livemac` vs cold-ref's REAL msg0: known plaintext + known `ks`/`riv`
+///      must reproduce the captured wire ciphertext+tag byte-for-byte. A FAIL here (with 1+2
+///      passing) would localize a bug in our seal framing; a FAIL in 1/2 means the kernel
+///      primitive itself is wrong. If all PASS, the crypto we send is correct and the
+///      CP-engagement wall is NOT our crypto.
+fn crypto_selftest() {
+    use core::sync::atomic::{AtomicBool, Ordering};
+    static RAN: AtomicBool = AtomicBool::new(false);
+    if RAN.swap(true, Ordering::Relaxed) {
+        return;
+    }
+
+    // 1. AES-128-ECB KAT (FIPS-197 Appendix B / C.1).
+    let ecb_key = [
+        0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
+        0x0f,
+    ];
+    let ecb_pt = [
+        0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
+        0xff,
+    ];
+    let ecb_expect = [
+        0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5,
+        0x5a,
+    ];
+    match crypto::aes128_ecb(&ecb_key, &ecb_pt) {
+        Ok(out) if out == ecb_expect => pr_info!("vino: selftest AES-128-ECB PASS\n"),
+        Ok(out) => pr_err!("vino: selftest AES-128-ECB FAIL got={out:02x?}\n"),
+        Err(e) => pr_err!("vino: selftest AES-128-ECB ERR ({e:?})\n"),
+    }
+
+    // 2. AES-CMAC KAT (RFC 4493 sec 4 example 2: a single 16-byte block).
+    let cmac_key = [
+        0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f,
+        0x3c,
+    ];
+    let cmac_msg = [
+        0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, 0x17,
+        0x2a,
+    ];
+    let cmac_expect = [
+        0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a, 0x28,
+        0x7c,
+    ];
+    match crypto::aes_cmac(&cmac_key, &cmac_msg) {
+        out if out == cmac_expect => pr_info!("vino: selftest AES-CMAC PASS\n"),
+        out => pr_err!("vino: selftest AES-CMAC FAIL got={out:02x?}\n"),
+    }
+
+    // 3. Full seal_livemac vs cold-ref's REAL msg0 (capture t=36.813765). ks/riv are the cold-ref
+    // session's; content is msg0's 32-byte plaintext; the expected frame is the captured wire.
+    let ks = [
+        0xd8, 0xb2, 0x48, 0x12, 0x44, 0x1d, 0x50, 0x82, 0x0d, 0xa3, 0xc2, 0x71, 0xc7, 0xa3, 0x6e,
+        0xc2,
+    ];
+    let riv = [0xfb, 0xa7, 0xc3, 0x5f, 0xe6, 0xce, 0x40, 0xec];
+    let header = [
+        0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x24, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00,
+        0x00,
+    ];
+    let content = [
+        0x14, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x48, 0xec, 0x9c, 0xec, 0xc3, 0x89, 0x23,
+        0x5d, 0x69,
+    ];
+    let expect = [
+        0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x24, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00,
+        0x00, 0xcb, 0x4c, 0x80, 0xde, 0xf0, 0xd0, 0xfd, 0x56, 0x22, 0x5f, 0x43, 0xbd, 0x55, 0x0d,
+        0x8e, 0xc5, 0x7a, 0x1c, 0x35, 0x12, 0x81, 0x35, 0x31, 0x1a, 0x45, 0x13, 0x91, 0x41, 0x25,
+        0x87, 0xe9, 0xf7, 0xe5, 0x5b, 0xb5, 0xbc, 0x76, 0x5b, 0x2f, 0x1e, 0x79, 0xf2, 0x8b, 0xd5,
+        0x5b, 0x2c, 0x3c, 0xe7,
+    ];
+    match cp::seal_livemac(&ks, &riv, &header, &content) {
+        Ok(frame) if frame.as_slice() == expect.as_slice() => {
+            pr_info!("vino: selftest seal_livemac(msg0) PASS -- CP crypto reproduces cold-ref wire\n")
+        }
+        Ok(frame) => {
+            // Show where it first diverges so a framing/order bug is localizable.
+            let mut at = frame.len().min(expect.len());
+            for i in 0..at {
+                if frame[i] != expect[i] {
+                    at = i;
+                    break;
+                }
+            }
+            pr_err!(
+                "vino: selftest seal_livemac(msg0) FAIL at byte {at} (len {} vs {})\n",
+                frame.len(),
+                expect.len()
+            );
+            let s = at.saturating_sub(0);
+            let e = (at + 16).min(frame.len());
+            pr_err!("vino:   got[{s}..]={:02x?}\n", &frame[s..e]);
+            let e2 = (at + 16).min(expect.len());
+            pr_err!("vino:   exp[{s}..]={:02x?}\n", &expect[s..e2]);
+        }
+        Err(e) => pr_err!("vino: selftest seal_livemac(msg0) ERR ({e:?})\n"),
+    }
+}
+
+impl VinoDriver {
+    /// Plaintext session bring-up (sec 4): control-request preamble then the three
+    /// bulk init messages, reading the single ACK. Best-effort during scaffold
+    /// bring-up -- errors are logged, not fatal.
+    fn bring_up(dev: &usb::Interface<device::Bound>) -> Result {
+        // Post-enumeration idle hold: match DLM's ~15 ms userspace-daemon startup latency from
+        // SET_CONFIGURATION to the first CP frame (vino, in-kernel, otherwise responds in ~4.8 ms).
+        // Placed before ANY USB transaction so vino sits idle like DLM's daemon waiting to be
+        // scheduled, then bursts. fsleep is fine here -- DLM's own latency spans 14.6..16.3 ms, far
+        // wider than fsleep's slack. See [`ENUM_RESPONSE_HOLD_US`].
+        if DLM_FIXED_TIMERS {
+            fsleep(Delta::from_micros(ENUM_RESPONSE_HOLD_US));
+        }
+
+        // Verify the KERNEL crypto path is byte-correct before we rely on it for CP. The KDF was
+        // only ever checked offline (Python); this confirms the in-kernel AES-ECB, AES-CMAC and the
+        // full `seal_livemac` reproduce ground-truth vectors on THIS device. Logs PASS/FAIL once.
+        crypto_selftest();
+
+        // Control-request preamble (sec 4): dock-id read, interface selection, then the
+        // vendor_out 0x24 / vendor_in 0x22 pairs that kick off the HDCP path. (The
+        // GET_DESCRIPTOR string reads DLM also issues look cosmetic and are omitted.)
+        const VENDOR_OUT: u8 = 0x40; // host->dev, vendor, device
+        const VENDOR_IN_IFACE: u8 = 0xc1; // dev->host, vendor, INTERFACE recipient (DLM's choice)
+
+        // The DLM-style vendor preamble (sec 4). Per the userspace oracle, every
+        // control request here is **best-effort**: the dock legitimately STALLs
+        // some of them (e.g. the cosmetic dock-id read) yet still advances its
+        // host-identification state. The oracle tolerates each error and relies
+        // on DLM's inter-request timing gaps -- without those gaps the dock may
+        // not advance. So we log-and-continue on every control step and insert
+        // the same delays; only the bulk init + ACK is treated as load-bearing.
+        // GROUND-TRUTH 2026-06-13: at device-open DLM issues two vendor-IN reads on interface 1,
+        // recipient 0xc1, BEFORE the SET_INTERFACE / 0x24 / 0x22 sequence (dlm-cold-20260611-123347
+        // f708 `0xc1 0xfe wIdx=1` -> 16 B "RidgeDock" blob; f710 `0xc1 0xfc wIdx=1` -> 0 B). vino
+        // skipped them; the earlier attempt used recipient 0xc0 (device) and STALLed, which was
+        // misread as "the dock rejects 0xfe / DLM never sends it". Issue them here with the correct
+        // 0xc1 recipient. Best-effort: log and continue (the dock may still short/stall 0xfc).
+        let mut dock_id = [0u8; 16];
+        match dev.control_recv(0xfe, VENDOR_IN_IFACE, 0, 1, &mut dock_id, timeout(), GFP_KERNEL) {
+            Ok(()) => pr_info!("vino: step device-open 0xfe(iface1) OK = {:02x?}\n", dock_id),
+            Err(e) => pr_info!("vino: step device-open 0xfe(iface1) non-fatal ({e:?})\n"),
+        }
+        // Windows issues ONLY the single `0xfe` device-open read above; the `0xfc`/`0xfd`/`0xfb`
+        // DFU reads are a DLM-oracle addition vino picked up. Skip them under [`WINDOWS_MIMIC`] to
+        // match the Windows EP0 stream (they are diagnostic-only and CP-irrelevant either way).
+        if !WINDOWS_MIMIC {
+            let mut probe3 = [0u8; 3];
+            match dev.control_recv(0xfc, VENDOR_IN_IFACE, 0, 1, &mut probe3, timeout(), GFP_KERNEL) {
+                Ok(()) => pr_info!("vino: step device-open 0xfc(iface1) OK = {:02x?}\n", probe3),
+                Err(e) => pr_info!("vino: step device-open 0xfc(iface1) non-fatal ({e:?})\n"),
+            }
+            // DFU firmware-version query, matching DLM / the macOS+Windows drivers'
+            // DfuGetVmmDeviceFirmwareVersion: vendor IN bmRequestType=0xc1 bRequest=0xfd wIndex=1,
+            // a 6-byte version blob (the reference driver's request-size table: 0xfb=4
+            // customer/board, 0xfc=3 device-type, 0xfd=6 firmware-version, 0xfe=16 descriptor). This
+            // is a device-level DFU read, independent of the CP channel, so it works regardless of
+            // CP engagement -- handy for diagnostics and confirming the dock firmware revision.
+            let mut fw_ver = [0u8; 6];
+            match dev.control_recv(0xfd, VENDOR_IN_IFACE, 0, 1, &mut fw_ver, timeout(), GFP_KERNEL) {
+                Ok(()) => pr_info!("vino: dock DFU firmware version = {:02x?}\n", fw_ver),
+                Err(e) => pr_info!("vino: device-open 0xfd(firmware-version) non-fatal ({e:?})\n"),
+            }
+            // DFU customer/board id (DfuGetVmmDeviceCustomerAndBoardId): bRequest=0xfb, 4-byte blob.
+            let mut cust_board = [0u8; 4];
+            match dev.control_recv(0xfb, VENDOR_IN_IFACE, 0, 1, &mut cust_board, timeout(), GFP_KERNEL) {
+                Ok(()) => pr_info!("vino: dock DFU customer/board id = {:02x?}\n", cust_board),
+                Err(e) => pr_info!("vino: device-open 0xfb(customer/board) non-fatal ({e:?})\n"),
+            }
+        }
+
+        // EXPERIMENT (2026-06-16): replay DLM's repeated STRING-descriptor reads at device-open.
+        // Timing analysis of the paired cold capture (captures/paired-coldbus-20260615-220311)
+        // shows DLM, beyond the distinct descriptor SET vino already issues, re-reads STRING idx0
+        // (language-ID list) and idx3 (en-US product, langid 0x0409), 255 B each, at ~2/sec for the
+        // ENTIRE 175 s session -- a 1 Hz host string-poll heartbeat. Engagement happens in the
+        // first
+        // second, so this is almost certainly NOT a pre-AKE gate (the distinct set already
+        // matches),
+        // but the repetition was never A/B-tested by replay the way the 0xfe/0xfc reads were. Issue
+        // a
+        // small burst here, BEFORE the AKE, to test whether the dock conditions CP engagement on
+        // seeing the host poll its strings. Best-effort: the kernel reports EREMOTEIO on the
+        // expected
+        // short reply, but the GET_DESCRIPTOR still reaches the wire, which is all the experiment
+        // needs.
+        // RESULT 2026-06-16 (paired-coldbus-20260616-162650): the pre-arm GET_DESCRIPTOR delta is
+        // USB ENUMERATION, not application protocol. Both captures contain an identical 3x 8-byte +
+        // 7x 18-byte DEVICE-descriptor read sequence -- which no kernel driver issues (it is the
+        // enumeration handshake the USB core runs each time the dock re-enumerates on the cold
+        // plug, plus DisplayLink's leftover /opt/displaylink/udev.sh hook firing per uevent).
+        // Proven to be enumeration, not the DLM daemon: the vino capture reproduces the SAME reads
+        // with displaylink-driver.service masked and no DisplayLinkManager process running. It is
+        // symmetric across both runs, so it is neither a DLM-vs-vino difference nor the engagement
+        // gate. This speculative burst only ADDED vino-issued reads on top, so disable it.
+        // -- LIBUSB-STYLE DEVICE-OPEN ENUMERATION (2026-06-17)
+        // ----------------------------------
+        // The clean paired capture (paired-coldbus-20260616-180401) isolated the LAST pre-AKE
+        // divergence from DLM to ONE thing: DLM (libusb) re-reads the dock's full descriptor set
+        // when it opens the device -- DEVICE(18), CONFIG(9 then full ~618), STRING langid(idx0),
+        // then every STRING index the descriptors reference (~22x 255B) -- right before the AKE.
+        // A
+        // kernel driver normally skips this (the USB core cached it at enumeration), which is why
+        // vino's pre-arm control stream was missing it (the "DLM-ONLY 255x22 / 618 / 40"
+        // residual).
+        // These reads are CP-irrelevant descriptor boilerplate. The cold-plug A/B proved the dock
+        // does NOT gate CP on them (replaying them byte-for-byte still gave 0x wsub=0x45 -- see
+        // project_get_descriptor_burst_experiment / the firmware-wall verdict), and the in-kernel
+        // Windows (WDF) and macOS (IOUSBLib) drivers DON'T issue this burst either -- like vino
+        // they run over an already-enumerated device and use the USB core's cached descriptors.
+        // The burst is therefore a libusb-userspace artifact, not something the dock expects.
+        // Default OFF so vino behaves like a native kernel driver; flip to `true` only to reproduce
+        // DLM's libusb wire for a paired A/B diff. Best-effort throughout: a STALL/EREMOTEIO on an
+        // absent index is fine -- EP0 auto-recovers and the SETUP still reaches the wire (all the
+        // A/B diff needs). Reproduces (histogram diff DLM vs vino, paired-coldbus-20260616-180401):
+        // DLM's libusb open adds CONFIG-full(618)x3, CONFIG-partial(40)x3, STRING(255)x22, with
+        // no
+        // extra DEVICE(18)/CONFIG(9).
+        // Windows (like a native kernel driver) does NOT replay this libusb descriptor burst, so
+        // [`WINDOWS_MIMIC`] forces it off; otherwise default ON to reproduce DLM's libusb open.
+        const CP_LIBUSB_OPEN_ENUM: bool = !WINDOWS_MIMIC;
+        if CP_LIBUSB_OPEN_ENUM {
+            let mut tmp = [0u8; 255];
+            let mut cfg = KVec::from_elem(0u8, 618, GFP_KERNEL)?;
+            // CONFIG full (618) x3 -- parse the first to find real string indices so the STRING
+            // reads
+            // below return data (matching DLM's byte counts), not just the SETUP counts.
+            for _ in 0..3 {
+                let _ = dev.control_recv(0x06, 0x80, 0x0200, 0, &mut cfg, timeout(), GFP_KERNEL);
+            }
+            // CONFIG partial (40) x3.
+            for _ in 0..3 {
+                let _ = dev.control_recv(0x06, 0x80, 0x0200, 0, &mut tmp[..40], timeout(), GFP_KERNEL);
+            }
+            // STRING idx0 = language-ID list (1st of the 22x 255 reads); adopt the dock's REAL
+            // langid.
+            let mut langid = 0x0409u16;
+            if dev.control_recv(0x06, 0x80, 0x0300, 0, &mut tmp, timeout(), GFP_KERNEL).is_ok() && tmp[0] >= 4 {
+                langid = (tmp[2] as u16) | ((tmp[3] as u16) << 8);
+            }
+            // String indices referenced by the config (iConfiguration @off6, iInterface @off8).
+            let mut idxs = [0u8; 64];
+            let mut ni = 0usize;
+            let mut p = 0usize;
+            while p + 2 <= cfg.len() {
+                let blen = cfg[p] as usize;
+                if blen == 0 {
+                    break;
+                }
+                let btype = cfg[p + 1];
+                if btype == 0x02 && p + 7 <= cfg.len() && cfg[p + 6] != 0 && ni < idxs.len() {
+                    idxs[ni] = cfg[p + 6];
+                    ni += 1;
+                }
+                if btype == 0x04 && p + 9 <= cfg.len() && cfg[p + 8] != 0 && ni < idxs.len() {
+                    idxs[ni] = cfg[p + 8];
+                    ni += 1;
+                }
+                p += blen;
+            }
+            // 21 more STRING(255) reads (idx0 above makes 22 total = DLM's count). Cycle the real
+            // referenced indices so each returns data; DLM likewise re-reads indices.
+            let mut nok = 0usize;
+            for k in 0..21usize {
+                let i = if ni > 0 { idxs[k % ni] as u16 } else { 1 + k as u16 };
+                if dev
+                    .control_recv(0x06, 0x80, 0x0300 | i, langid, &mut tmp, timeout(), GFP_KERNEL)
+                    .is_ok()
+                {
+                    nok += 1;
+                }
+            }
+            pr_info!(
+                "vino: libusb-open enum: config 618x3 + 40x3, langid={langid:#06x}, strings 22 ({nok} ok of {ni} refs)\n"
+            );
+        }
+
+        // SET_INTERFACE: DLM's two handshake SET_INTERFACEs target iface 1 (alt 0,
+        // app-specific/DFU) then iface 0 (alt 0, vendor) -- confirmed by a clean cold
+        // DLM usbmon capture (captures/dlm-cold-20260611-123347, t=52.079/52.085).
+        // The old code set iface 4 (the microphone) which DLM NEVER touches in the
+        // handshake (the 58 audio SET_INTERFACEs in a session are snd-usb-audio's, not
+        // DLM's -- see project_cp_setinterface_is_audio_binding_fix).
+        match dev.set_interface(1, 0) {
+            Ok(()) => pr_info!("vino: step set_interface(1,0) OK\n"),
+            Err(e) => pr_info!("vino: step set_interface(1,0) non-fatal ({e:?})\n"),
+        }
+        match dev.set_interface(0, 0) {
+            Ok(()) => pr_info!("vino: step set_interface(0,0) OK\n"),
+            Err(e) => pr_info!("vino: step set_interface(0,0) non-fatal ({e:?})\n"),
+        }
+        // vendor_out 0x24 (initial ack) then vendor_in 0x22 (state read, wValue=1 -- DLM's exact
+        // value; wValue=0 STALLs). Both best-effort: the dock advances state regardless and the
+        // oracle tolerates failure here. The OUT's wValue is the one observable divergence: Linux
+        // DLM (and vino) use wValue=3, the Windows driver uses wValue=0 -- both engage, so
+        // [`WINDOWS_MIMIC`] just picks Windows' value for the A/B.
+        let w24: u16 = if WINDOWS_MIMIC { 0 } else { 3 };
+        match dev.control_send(0x24, VENDOR_OUT, w24, 0, &[], timeout(), GFP_KERNEL) {
+            Ok(()) => pr_info!("vino: step 0x24(wValue={w24}) OK\n"),
+            Err(e) => pr_info!("vino: step 0x24(wValue={w24}) non-fatal ({e:?})\n"),
+        }
+        // 0xc1 = IN|vendor|INTERFACE recipient (NOT 0xc0, device recipient): DLM's cold capture
+        // uses
+        // bmRequestType=0xc1, wIndex=0 (interface 0). wValue=1 (DLM's value; 0 stalls). Uses the
+        // function-scope `VENDOR_IN_IFACE` declared in the device-open preamble above.
+        let mut state = [0u8; 28];
+        match dev.control_recv(0x22, VENDOR_IN_IFACE, 1, 0, &mut state, timeout(), GFP_KERNEL) {
+            Ok(()) => pr_info!("vino: step 0x22(wValue=1) OK = {:02x?}\n", state),
+            Err(e) => pr_info!("vino: step 0x22(wValue=1) non-fatal ({e:?})\n"),
+        }
+
+        // Plaintext session init (sec 4) in DLM's exact wire order. The dock only
+        // ACKs once init_4+probe arrives, and it gates on DLM's fingerprint -- the
+        // interleaved GET_DESCRIPTOR reads (CONFIGURATION before init_0, two STRING
+        // reads between init_25 and init_4). Those reads are best-effort: the
+        // kernel reports EREMOTEIO on the short reply but the request still hits the
+        // wire (all we need). init_0/init_25/init_4+probe are separate transfers.
+        const STD_IN: u8 = 0x80; // dev->host, standard, device
+        let mut desc = KVec::from_elem(0u8, 618, GFP_KERNEL)?;
+        let _ = dev.control_recv(0x06, STD_IN, 0x0200, 0, &mut desc[..40], timeout(), GFP_KERNEL); // CONFIG, 40
+        let _ = dev.control_recv(0x06, STD_IN, 0x0200, 0, &mut desc, timeout(), GFP_KERNEL); // CONFIG, 618
+
+        // Log EP02's bulk wMaxPacketSize from the config descriptor. If it is 64 then a 64-byte
+        // msg0/arm is an exact multiple and the in-kernel `usb_bulk_msg` path (unlike libusb's
+        // LIBUSB_TRANSFER_ADD_ZERO_PACKET) won't auto-append the terminating ZLP -- the dock's SIE
+        // would then wait for more data and never hand the frame to firmware. Rules the ZLP-trap
+        // hypothesis in or out from data we already capture. Walk the standard descriptor chain
+        // (bLength/bDescriptorType), find the ENDPOINT (0x05) descriptor for bEndpointAddress 0x02.
+        {
+            let total = ((desc[2] as usize) | ((desc[3] as usize) << 8)).min(desc.len());
+            let mut i = 0usize;
+            while i + 2 <= total {
+                let blen = desc[i] as usize;
+                if blen == 0 {
+                    break;
+                }
+                if desc[i + 1] == 0x05 && i + 7 <= total && desc[i + 2] == EP_CTRL_OUT {
+                    let wmax = (desc[i + 4] as u16) | ((desc[i + 5] as u16) << 8);
+                    pr_info!("vino: EP02 bulk wMaxPacketSize = {wmax} (ZLP needed if msg0 is a multiple)\n");
+                }
+                i += blen;
+            }
+        }
+
+        let load_bearing = |label: &str, msg: &[u8]| -> Result {
+            match dev.bulk_send(EP_CTRL_OUT, msg, timeout(), GFP_KERNEL) {
+                Ok(_) => Ok(pr_info!("vino: step {label} OK ({} B)\n", msg.len())),
+                Err(e) => {
+                    pr_err!("vino: step {label} FAILED ({e:?})\n");
+                    Err(e)
+                }
+            }
+        };
+        load_bearing("init_0", &proto::init_0()?)?;
+        // Pad init_0->init_25 to DLM's cadence (sync bulk_send fires ~0.08 ms quicker than DLM's
+        // libusb URB). See PAD_* docs. udelay = us-precise busy-wait.
+        if DLM_FIXED_TIMERS {
+            udelay(Delta::from_micros(PAD_INIT0_TO_INIT25_US));
+        }
+        load_bearing("init_25", &proto::init_25()?)?;
+        // DLM's two interleaved STRING reads between init_25 and init_4+probe.
+        let _ = dev.control_recv(0x06, STD_IN, 0x0300, 0x0000, &mut desc[..255], timeout(), GFP_KERNEL); // STRING #0
+        let _ = dev.control_recv(0x06, STD_IN, 0x0303, 0x0409, &mut desc[..255], timeout(), GFP_KERNEL); // STRING #3 en-US
+        // Pad init_25->init_4 to DLM's cadence (~0.18 ms; vino's STRING reads return quicker too).
+        if DLM_FIXED_TIMERS {
+            udelay(Delta::from_micros(PAD_INIT25_TO_INIT4_US));
+        }
+        load_bearing("init_4+probe", &proto::init_4_probe()?)?;
+
+        // Read the single ACK that follows init_4+probe.
+        let mut ack = KVec::from_elem(0u8, 1024, GFP_KERNEL)?;
+        match dev.bulk_recv(EP_CTRL_IN, &mut ack, timeout(), GFP_KERNEL) {
+            Ok(n) => Ok(pr_info!("vino: session-init ACK = {n} bytes: {:02x?}\n",
+                &ack[..n.min(40)])),
+            Err(e) => {
+                pr_err!("vino: session-init ACK read FAILED ({e:?})\n");
+                Err(e)
+            }
+        }
+    }
+
+
+    /// Whether to service EP83 (interrupt-IN status) during bring-up. Measured 2026-06-16
+    /// (paired-coldbus-20260616-162650): DLM polls EP83 0x in the pre-arm window (14x total, all
+    /// post-engagement) while vino polled it 5x pre-arm -- injecting interrupt-IN traffic into the
+    /// critical arm/msg0 window that DLM never generates. Disabled so the pre-arm wire matches DLM;
+    /// re-enable if a post-engagement status channel is ever needed (DLM only services it once the
+    /// dock has already acked).
+    const POLL_EP83_DURING_BRINGUP: bool = false;
+
+    /// CP_STREAM_TYPE0 experiment (2026-06-23, check.md panel Gemini #4 / Grok #3): send a single
+    /// Type-0 (unrestricted) stream in `RepeaterAuth_Stream_Manage` instead of the DLM-replicated
+    /// 0x04/0x05 stream-type bytes, to test whether the dock engages CP as a terminal Type-0 sink
+    /// (vs an HDCP-2.2 repeater). Speculative: vino's Stream_Manage already matches DLM byte-exact
+    /// and DLM engages, so this DIVERGES from the proven-good default -- keep `false` for normal
+    /// runs and the paired diff; flip only for the A/B cold plug. M (`wait_cap_complete`) is
+    /// host-verify-only so its value never gates, but its `m_data` tracks this flag for a clean log.
+    const CP_STREAM_TYPE0: bool = false;
+
+    /// Reads the next HDCP response (type=4 sub=0x25, sec 5.2) from EP `0x84`,
+    /// skipping any non-HDCP frames (e.g. plain ACKs) in between, and returns the
+    /// parsed `(msg_id, payload)`. Bounded retry so a chatty dock can't wedge us.
+    fn recv_hdcp(dev: &usb::Interface<device::Bound>) -> Result<(u8, KVec<u8>)> {
+        const SUB_HDCP_RESP: u16 = 0x25;
+        // The dock interleaves capability blocks up to ~5.8 KiB into the AKE reply
+        // stream; size the buffer like the rest of the EP84 reads ([`EP84_BUF`]) so a
+        // large frame is read whole rather than truncated/`-EOVERFLOW`'d.
+        let mut buf = KVec::from_elem(0u8, EP84_BUF, GFP_KERNEL)?;
+        for _ in 0..24 {
+            // Read EP84 FIRST. The dock replies to AKE messages sub-millisecond (DLM cold capture:
+            // ~0.1-0.7 ms between EP84 IN frames), but it interleaves status/cap pushes that we
+            // skip. Polling EP83 (a ~2 ms idle wait) BEFORE every read added ~2 ms x
+            // N-skipped-frames
+            // of latency per reply -- making vino's AKE ~400 ms vs DLM's ~62 ms, slow enough that
+            // the
+            // dock starts downstream HDCP and NAKs our arm/Stream_Manage. So only service EP83 when
+            // EP84 came back empty (same reorder as `drain_ep84`). See the cold wire diff.
+            let n = dev.bulk_recv(EP_CTRL_IN, &mut buf, timeout(), GFP_KERNEL)?;
+            if n < 16 {
+                if Self::POLL_EP83_DURING_BRINGUP {
+                    Self::poll_ep83(dev);
+                }
+                continue;
+            }
+            // DIAGNOSTIC (2026-06-11): log EVERY frame the dock returns during the AKE --
+            // including
+            // wsub!=0x25 and cap-block (sub=0x84) pushes we'd otherwise skip -- so we can see
+            // whether
+            // the dock interleaves its capability blocks with the HDCP replies (the suspected
+            // reason
+            // its cap phase never completes / it won't engage CP). Inner id/sub at off 16/18.
+            {
+                let wsub = u16::from_le_bytes([buf[8], buf[9]]);
+                let iid = if n >= 18 { u16::from_le_bytes([buf[16], buf[17]]) } else { 0 };
+                let isub = if n >= 20 { u16::from_le_bytes([buf[18], buf[19]]) } else { 0 };
+                pr_debug!("vino: AKE-EP84 {n}B wsub={wsub:#x} inner_id={iid:#x} inner_sub={isub:#x}\n");
+            }
+            if u16::from_le_bytes([buf[8], buf[9]]) != SUB_HDCP_RESP {
+                continue; // non-HDCP frame -- skip
+            }
+            if let Some((id, payload)) = ake::parse_in(&buf[16..n]) {
+                // Inner msg_id 0 is a status/ACK frame (the dock emits one as a
+                // sub=0x25 frame after each OUT message, e.g. the `14 00 76 00...`
+                // frame after AKE_Init) -- skip it and keep reading for the real
+                // HDCP response, mirroring the oracle's recv_hdcp_msg.
+                if id == 0 {
+                    continue;
+                }
+                let mut pl = KVec::with_capacity(payload.len(), GFP_KERNEL)?;
+                pl.extend_from_slice(payload, GFP_KERNEL)?;
+                return Ok((id, pl));
+            }
+        }
+        Err(EINVAL)
+    }
+
+
+    /// Pace like DLM after a RepeaterAuth OUT (ctr6 Send_Ack / ctr7 Stream_Manage):
+    /// read the dock's per-frame `id=0x14 sub=0x10` ack off EP84 BEFORE the next OUT,
+    /// so vino never transmits while the dock is mid-NAK.
+    ///
+    /// Ground truth (cold wire diff, captures/dlm-cold-20260611-123347 vs vino-cold):
+    /// DLM reads that ack after EVERY cap/AKE OUT --
+    /// ctr4->ack->ctr5->ack->ctr6->ack->ctr7->
+    /// ack->arm, ~0.2 ms apart, whole ctr7->arm gap 0.46 ms. Commit d74a4d7 dropped the
+    /// drain for ctr6/ctr7, so `run_ake` sent ctr6->ctr7 back-to-back with no read; the
+    /// dock (busy with downstream HDCP after SKE) then NAK'd each OUT ~100 ms (vino's
+    /// V'->arm gap measured ~200 ms), and the arm landed after the dock had left its
+    /// freshly-keyed CP window -> CP never engaged (0 `wsub=0x45`). Restoring the read
+    /// re-paces vino to DLM and lets the arm land tight. Best-effort: returns as soon as
+    /// the matching ack arrives, or immediately if nothing is queued (dock idle).
+    fn pace_cap_ack(dev: &usb::Interface<device::Bound>, want_ctr: u16) {
+        // EP84 frames here can carry an interleaved capability block up to ~5.8 KiB;
+        // size to [`EP84_BUF`] so a large frame isn't truncated mid-pacing.
+        let Ok(mut buf) = KVec::from_elem(0u8, EP84_BUF, GFP_KERNEL) else {
+            return;
+        };
+        for _ in 0..8 {
+            match dev.bulk_recv(EP_CTRL_IN, &mut buf, Delta::from_millis(30), GFP_KERNEL) {
+                Ok(len) if len >= 22 => {
+                    let wsub = u16::from_le_bytes([buf[8], buf[9]]);
+                    let iid = u16::from_le_bytes([buf[16], buf[17]]);
+                    let ictr = u16::from_le_bytes([buf[20], buf[21]]);
+                    // The per-frame cap-ack: wsub=0x25, inner id=0x14 sub=0x10 ctr=want.
+                    // An interleaved cap push (sub=0x84) or earlier ack -- keep reading.
+                    if wsub == 0x25 && iid == 0x14 && ictr == want_ctr {
+                        return;
+                    }
+                }
+                // A short frame (header-only ack/keepalive): not our cap-ack, but the
+                // dock is still talking -- keep pacing rather than bailing out.
+                Ok(_) => continue,
+                // Nothing queued within the short window -- the dock is idle, don't block.
+                Err(_) => return,
+            }
+        }
+    }
+
+
+    /// After ctr7 (Stream_Manage) and its ack, WAIT for the dock's terminal capability block
+    /// `id=0x0b sub=0x84` before letting the caller arm. This is the dock's "cap-complete"
+    /// signal: DLM receives it and only then arms (cold-ref: `id=0x21` @52.1465 -> `id=0x0b`
+    /// @52.1469 -> arm @52.1474). vino's lockstep ([`pace_cap_ack`]) only consumed the `id=0x14`
+    /// ctr acks, so it armed right after ctr7's ack -- BEFORE the dock had emitted `id=0x0b`
+    /// (vino received every other cap block id=0x213/0x0d/0x10/0x28/0x18/0x21 but armed one push
+    /// early). The dock then NAK'd msg0 ~100 ms and dumped a 16 KB error block
+    /// (`type=0x1003 wsub=0x37`) that DLM never produces, instead of engaging CP -- the true
+    /// gate, found on cold plug `vino-cold-20260612-080549`. The dock emits `id=0x0b` a few ms
+    /// after `id=0x21` once it settles downstream HDCP, so draining EP84 until it arrives keeps
+    /// the arm tight (DLM ~ 0.5 ms after ctr7) yet correctly ordered. Best-effort, bounded.
+    fn wait_cap_complete(dev: &usb::Interface<device::Bound>, kd: &[u8; 32]) {
+        let Ok(mut buf) = KVec::from_elem(0u8, EP84_BUF, GFP_KERNEL) else {
+            return;
+        };
+        // Drain EP84 until the dock goes QUIET, not merely until id=0x0b. Cold plug #2
+        // (vino-cold-20260612-082707) showed DLM's LAST pre-arm push is the id=0x28 that
+        // follows id=0x0b (cold-ref: id=0x0b@52.1469 -> ack ctr7 -> id=0x28@52.1472 ->
+        // arm@52.1474),
+        // whereas vino stopped at id=0x0b and armed -- leaving id=0x28 (and the rest of the dock's
+        // terminal cap burst) un-drained in the dock's EP84 queue. With its IN queue backed up the
+        // dock NAK'd vino's msg0 ~100 ms (it can't accept the OUT while it still owes IN data) and
+        // then dumped the 16 KB error block. So after id=0x0b, keep reading until a read times out
+        // (the dock has sent everything), then return so the caller arms into a clean dock -- like
+        // DLM. Bounded: id=0x0b is the marker; QUIET_GAP short reads of silence end the drain.
+        //
+        // * 2026-06-12 (HDCP 2.3 Adaptation sec RepeaterAuth, pdfs/): one of the frames drained
+        // here is
+        // the dock's `RepeaterAuth_Stream_Ready` (HDCP msg 0x11) -- the 3rd `id=0x28` DLM receives
+        // and
+        // vino historically did not. The spec requires the transmitter to RECEIVE it within 100 ms
+        // of
+        // `Stream_Manage` and verify `M == M'` before transmitting content; the dock's exactly-100
+        // ms
+        // msg0 NAK on a cold plug is that window. We now RECOGNISE it in this same drain (no added
+        // latency vs the old broken 10x1 s poll) and log `M'` plus candidate `M`s so the next
+        // capture
+        // pins the exact `STREAMID_TYPE || seq_num_M` the dock hashes. The HDCP msg_id rides at
+        // `body[9]` = `buf[25]` in an EP84 reply (`ake::parse_in`); `M'[32]` follows at
+        // `buf[26..58]`.
+        // Verification is logged-only for now (the DisplayLink field offsets in `Stream_Manage` are
+        // not yet confirmed, so a wrong guess must not block the arm); the arm is gated on
+        // receiving
+        // Stream_Ready when it arrives, else on the existing id=0x0b + quiet fallback. `M` key is
+        // `SHA256(kd)`; `M = HMAC-SHA256(STREAMID_TYPE || seq_num_M, SHA256(kd))`, seq_num_M = 0.
+        let sha_kd = crypto::sha256(kd);
+        let mut saw_0b = false;
+        let mut saw_ready = false;
+        let mut quiet = 0usize;
+        const QUIET_GAP: usize = 3; // ~3 consecutive empty short reads => dock done pushing
+        const MAX_ROUNDS: usize = 48;
+        for _ in 0..MAX_ROUNDS {
+            match dev.bulk_recv(EP_CTRL_IN, &mut buf, Delta::from_millis(5), GFP_KERNEL) {
+                Ok(len) if len >= 20 => {
+                    quiet = 0;
+                    let iid = u16::from_le_bytes([buf[16], buf[17]]);
+                    let isub = u16::from_le_bytes([buf[18], buf[19]]);
+                    let mid = if len >= 26 { buf[25] } else { 0 }; // HDCP msg_id (body[9])
+                    if isub == 0x84 && iid == 0x0b {
+                        saw_0b = true;
+                    }
+                    if mid == ake::id::REPEATERAUTH_STREAM_READY && len >= 58 {
+                        saw_ready = true;
+                        let mprime = &buf[26..58];
+                        pr_info!("vino: AKE: Stream_Ready (0x11) M'={mprime:02x?}\n");
+                        // M = HMAC-SHA256(SHA256(kd), data) where data is the Content Stream
+                        // Management input the dock hashes: `k` 7-byte stream entries followed by
+                        // the 3-byte `seq_num_M` (=0 on the first Stream_Manage). Cracked from the
+                        // DLM aarch64 decompile (`FUN_0057be04`: data = memcpy(streams, k*7) ||
+                        // BE16(field) || field, keyed by the 32-byte SHA256(kd) at session+0x37);
+                        // reproduces DLM's captured M' byte-exact (captures/.../FINDINGS.md).
+                        // vino's
+                        // two streams carry the same StreamID_Type bytes its Stream_Manage sends
+                        // (`repeater_auth_stream_manage`: type 0x04 and 0x05), so the dock computes
+                        // the same M. (Earlier code guessed a 5-byte STREAMID_TYPE||seq layout and
+                        // so
+                        // always mismatched -- host-side only, never gated the dock.)
+                        // Stream-type bytes track CP_STREAM_TYPE0 so the logged M matches what
+                        // Stream_Manage actually sent (M is host-verify-only; never gates the dock).
+                        let (s0, s1) = if Self::CP_STREAM_TYPE0 { (0x00, 0x00) } else { (0x04, 0x05) };
+                        let m_data: [u8; 17] = [
+                            0, 0, 0, s0, 0, 0, 0, // stream 0: StreamID_Type[0]
+                            0, 0, 0, s1, 0, 0, 0, // stream 1: StreamID_Type[1]
+                            0, 0, 0, // seq_num_M = 0 (first Stream_Manage, big-endian)
+                        ];
+                        let m = crypto::hmac_sha256(&sha_kd, &m_data);
+                        let eq = if &m[..] == mprime { "==" } else { "!=" };
+                        pr_info!("vino: AKE:   M {} M' (CSM stream-entry layout)\n", eq);
+                    } else if mid == ake::id::RECEIVER_AUTH_STATUS && len >= 27 {
+                        pr_info!("vino: AKE: RECEIVER_AUTH_STATUS=0x{:02x}\n", buf[26]);
+                    }
+                    // * 2026-06-12: arm the INSTANT both terminal markers have arrived -- the
+                    // cap-complete
+                    // id=0x0b AND the Stream_Ready (the trailing id=0x28 / HDCP 0x11). DLM arms
+                    // 0.46 ms
+                    // after its last cap block; a cold-plug cadence diff
+                    // (vino-cold-20260612-113706) showed
+                    // vino was instead waiting QUIET_GAP x 5 ms of EMPTY reads AFTER already
+                    // seeing both
+                    // markers, landing the arm ~68 ms late -- outside the dock's freshly-keyed CP
+                    // window, so
+                    // the dock errored on the arm (27 KB type=0x1001 dump) instead of engaging.
+                    // Once both
+                    // markers are in, the terminal burst is complete; arm now, like DLM. (The
+                    // empty-read
+                    // quiet path below remains the fallback when Stream_Ready never arrives.)
+                    if saw_0b && saw_ready {
+                        pr_info!("vino: cap-complete (id=0x0b + Stream_Ready 0x11) -- arming now\n");
+                        return;
+                    }
+                }
+                // Empty/short read = a quiet window. Fallback when Stream_Ready (0x11) never
+                // arrives:
+                // once id=0x0b has arrived AND the dock has been quiet for QUIET_GAP rounds, the
+                // terminal burst is drained -- arm now.
+                _ => {
+                    if saw_0b {
+                        quiet += 1;
+                        if quiet >= QUIET_GAP {
+                            pr_info!(
+                                "vino: cap-complete drained (id=0x0b{}+ quiet) -- arming now\n",
+                                if saw_ready { ", Stream_Ready 0x11, " } else { " (no 0x11) " }
+                            );
+                            return;
+                        }
+                    }
+                }
+            }
+        }
+        pr_info!(
+            "vino: cap-complete drain budget hit (saw_0b={saw_0b} saw_ready={saw_ready}) -- arming anyway\n"
+        );
+    }
+
+
+    /// Drives a full clean-room HDCP 2.2 AKE + LC + SKE (and RepeaterAuth for a
+    /// repeater sink) over EP `0x02`/`0x84`, verifying `H'`, `L'` and `V'` against
+    /// our own KDF (sec 5). On success returns the [`Session`] keys.
+    ///
+    /// All HDCP transfers use transport `seq=0`; the `hdcp_seq` counter increments
+    /// 1..7 across the OUT messages (sec 5.1). Best-effort: any mismatch/short read
+    /// aborts with an error the caller logs.
+    fn run_ake(dev: &usb::Interface<device::Bound>) -> Result<Session> {
+        use ake::id;
+
+        // Anchor the CP-start instant (~`cp_first` in the timing survey) before the first frame,
+        // so `send_cp_setup` can realise DLM's fixed pre-arm timer and log the fingerprint.
+        let cp_start = Instant::<Monotonic>::now();
+
+        // Flush any STALE EP84 frames the dock still has queued from a PRIOR session before
+        // starting a fresh AKE. On a warm rmmod/insmod re-probe the dock is not power-cycled, so
+        // its previous CP/cap replies (including a multi-KB residual block) sit in its EP84 queue;
+        // if we don't drain them, the first `recv_hdcp` picks up a stale frame and the whole AKE
+        // reply stream is shifted.
+        //
+        // TIMING (2026-06-23 paired cold-plug diff): a stale frame is ALREADY queued in the dock,
+        // so it returns sub-millisecond; only the trailing empty read pays the timeout. A 20 ms
+        // probe therefore cost a dead 20 ms before AKE_Init on a *cold* plug (queue empty) -- the
+        // sole pre-arm gap where vino diverged from DLM (DLM emits ctr1 ~0.3 ms after session-init,
+        // vino was ~21.8 ms). Drop the probe to 3 ms: still ample to drain a warm-reprobe backlog
+        // (each present frame returns immediately; the loop only stops on the empty read), but the
+        // cold-plug cost collapses 20 ms -> 3 ms, matching DLM's cadence into the AKE.
+        //
+        // FIXED-TIMER (2026-06-26, `DLM_FIXED_TIMERS`): DLM does NOT flush here at all -- the
+        // cp_first->cert frame dump shows it emits AKE_Init ~0.14 ms after the session-init ACK,
+        // whereas vino blocked ~1.9 ms in this probe. The cause: on a COLD plug the EP84 queue is
+        // empty (the session-init ACK was already consumed), so the first `bulk_recv` waits out its
+        // full timeout -- and because a sub-ms `Delta` truncates via `as_millis()` to 0 = "wait
+        // forever", the practical floor is ~1.9 ms wall even at a nominal 1 ms. That dead wait was
+        // the ENTIRE residual `cp_first->cert` gap vs DLM (vino 2.6 ms, DLM 1.07 ms). The flush only
+        // matters on a WARM rmmod/insmod re-probe, where the un-power-cycled dock still has a stale
+        // CP/cap backlog queued that would shift the first `recv_hdcp`. So under DLM_FIXED_TIMERS
+        // (the cold-plug DLM-impersonation mode) skip it entirely and let AKE_Init follow the ACK as
+        // tightly as DLM; keep the drain on the reactive/warm path where stale frames are possible.
+        if !DLM_FIXED_TIMERS {
+            let flush_probe = Delta::from_millis(3);
+            if let Ok(mut flush) = KVec::from_elem(0u8, EP84_BUF, GFP_KERNEL) {
+                let mut flushed = 0usize;
+                for _ in 0..32 {
+                    match dev.bulk_recv(EP_CTRL_IN, &mut flush, flush_probe, GFP_KERNEL) {
+                        Ok(n) if n > 0 => flushed += 1,
+                        _ => break,
+                    }
+                }
+                if flushed > 0 {
+                    pr_info!("vino: flushed {flushed} stale EP84 frame(s) before AKE\n");
+                }
+            }
+        }
+
+        // Pad session-init-ACK->AKE_Init to DLM's 0.159 ms cadence. With the cold-plug flush gone,
+        // vino fires AKE_Init only ~0.043 ms after the ACK; DLM spaces it 0.159 ms. See PAD_* docs.
+        if DLM_FIXED_TIMERS {
+            udelay(Delta::from_micros(PAD_ACK_TO_AKEINIT_US));
+        }
+
+        // (1) AKE_Init -- fresh rtx, TxCaps = 00 00 00 (DLM-exact).
+        let mut rtx = [0u8; 8];
+        rng::fill(&mut rtx);
+        dev.bulk_send(EP_CTRL_OUT, &ake::ake_init(1, 0, &rtx, &[0; 3])?, timeout(), GFP_KERNEL)?;
+
+        // (2) AKE_Send_Cert: payload = REPEATER(1) || cert_rx(522). Extract the
+        // RSA-1024 public key (modulus[5..133], exponent[133..136]).
+        let (cid, cert_msg) = Self::recv_hdcp(dev)?;
+        // Anchor the `cert` milestone the instant the dock's cert lands: DLM arms a fixed
+        // CERT_TO_ARM_US after this point (see `Session::cert_at` / `DLM_FIXED_TIMERS`).
+        let cert_at = Instant::<Monotonic>::now();
+        if cid != id::AKE_SEND_CERT || cert_msg.len() < 1 + 136 {
+            pr_err!("vino: AKE: bad AKE_Send_Cert (id={cid:#x}, {} B)\n", cert_msg.len());
+            return Err(EINVAL);
+        }
+        let repeater = cert_msg[0] != 0;
+        let cert = &cert_msg[1..];
+        let mut modulus = [0u8; 128];
+        modulus.copy_from_slice(&cert[5..133]);
+        let mut exponent = [0u8; 3];
+        exponent.copy_from_slice(&cert[133..136]);
+
+        // (3) AKE_Transmitter_Info, then (4) read AKE_Receiver_Info (RxCaps unused).
+        dev.bulk_send(EP_CTRL_OUT, &ake::ake_transmitter_info(2, 0)?, timeout(), GFP_KERNEL)?;
+        let xmit_info_at = Instant::<Monotonic>::now();
+        let _ = Self::recv_hdcp(dev)?;
+
+        // (5) AKE_No_Stored_km -- fresh km, RSA-OAEP-SHA256 to Ekpub(km).
+        let mut km = [0u8; 16];
+        rng::fill(&mut km);
+        let ekpub = hdcp::oaep_encrypt_km(&modulus, &exponent, &km)?;
+        // Spend a REALISTIC cert-verification time before No_Stored_km. vino reaches this point
+        // ~0.3 ms after AKE_Transmitter_Info; DLM takes ~1.65 ms (it verifies the receiver's
+        // DCP-signed cert). Hold to DLM's cadence so vino doesn't answer impossibly fast -- the one
+        // consistent host-reachable divergence found vs the same-day engaging DLM. See
+        // [`CERT_VERIFY_HOLD_US`].
+        if DLM_FIXED_TIMERS {
+            hold_until(xmit_info_at, CERT_VERIFY_HOLD_US);
+        }
+        dev.bulk_send(EP_CTRL_OUT, &ake::ake_no_stored_km(3, 0, &ekpub)?, timeout(), GFP_KERNEL)?;
+
+        // (6) AKE_Send_Rrx.
+        let (rid, rrx_pl) = Self::recv_hdcp(dev)?;
+        if rid != id::AKE_SEND_RRX || rrx_pl.len() < 8 {
+            pr_err!("vino: AKE: bad AKE_Send_Rrx (id={rid:#x})\n");
+            return Err(EINVAL);
+        }
+        let mut rrx = [0u8; 8];
+        rrx.copy_from_slice(&rrx_pl[..8]);
+
+        // (7)/(8) AKE_Send_H_prime -- verify H' = HMAC(kd, rtx^REPEATER).
+        let (hid, hp) = Self::recv_hdcp(dev)?;
+        if hid != id::AKE_SEND_H_PRIME || hp.len() < 32 {
+            pr_err!("vino: AKE: bad H' (id={hid:#x})\n");
+            return Err(EINVAL);
+        }
+        let kd = hdcp::derive_kd(&km, &rtx, &rrx)?;
+        if hdcp::compute_h(&kd, &rtx, repeater)[..] != hp[..32] {
+            pr_err!("vino: AKE: H' mismatch -- authentication failed\n");
+            return Err(EINVAL);
+        }
+        pr_info!("vino: AKE: H' verified\n");
+
+        // (9) AKE_Send_Pairing_Info (Ekh_km) -- read and discard (no-stored path).
+        let _ = Self::recv_hdcp(dev)?;
+
+        // (10) Locality Check -- LC_Init(rn) then verify L'.
+        let mut rn = [0u8; 8];
+        rng::fill(&mut rn);
+        dev.bulk_send(EP_CTRL_OUT, &ake::lc_init(4, 0, &rn)?, timeout(), GFP_KERNEL)?;
+        let (lid, lp) = Self::recv_hdcp(dev)?;
+        if lid != id::LC_SEND_L_PRIME || lp.len() < 32 {
+            pr_err!("vino: AKE: bad L' (id={lid:#x})\n");
+            return Err(EINVAL);
+        }
+        if hdcp::compute_l(&kd, &rrx, &rn)[..] != lp[..32] {
+            pr_err!("vino: AKE: L' mismatch -- locality check failed\n");
+            return Err(EINVAL);
+        }
+        pr_info!("vino: AKE: L' verified\n");
+
+        // (11) Session Key Exchange -- send Edkey(ks) || riv. The session key and IV are
+        // fresh-random per session.
+        let mut ks = [0u8; 16];
+        let mut riv = [0u8; 8];
+        rng::fill(&mut ks);
+        rng::fill(&mut riv);
+        let edkey = hdcp::compute_eks(&km, &rtx, &rrx, &rn, &ks)?;
+        // Dev diagnostic: the full SKE secrets, so the SKE delivery can be verified OFFLINE
+        // (edkey == ks XOR derive_dkey(km,rtx,rrx,rn,2), and the dock unwrapping to the same ks).
+        // Behind pr_debug, so compiled out unless dynamic debug is enabled.
+        pr_debug!("vino: SKE-SECRETS km={km:02x?} rtx={rtx:02x?} rrx={rrx:02x?} rn={rn:02x?}\n");
+        pr_debug!("vino: SKE-SECRETS ks={ks:02x?} edkey={edkey:02x?}\n");
+        // * riv DERIVATION -- THE CP-ENGAGEMENT BUG, FIXED 2026-06-11.
+        // The SKE delivers the BASE riv (byte7 low-3 head/direction-selector bits cleared); the
+        // dock
+        // derives the per-direction CP riv from that base. GROUND TRUTH from cold-ref AND the live
+        // vino cold-plug diff (captures/dlm-cold-20260611-123347 + vino-cold-20260611-130522):
+        // delivered base byte7 = e8 -> host OUT-CP riv = ec (base | 0x04) -> dock IN-CP riv = ed
+        // (^1).
+        // vino had been sealing OUT-CP with the RAW random `riv` (byte7 e.g. f9 = base f8 | 0x01)
+        // while delivering base f8 -- so the dock, deriving its keystream from f8 (expecting
+        // host-OUT
+        // = fc), could NOT decrypt vino's CP and SILENTLY DROPPED every post-arm frame (0 sub=0x45,
+        // EP84 dead after the arm) even though ks/seal/MAC/frame-format were all byte-correct. The
+        // off-by-one-bit IV was the whole wall. Fix: deliver base, seal OUT with base | 0x04.
+        // The SKE delivers the FULL random riv as-is (DLM does NOT mask the low bits -- verified
+        // on
+        // two decrypted DLM sessions: cold-ref delivers ...e8, dl3cmac delivers ...e7). The host CP
+        // OUT riv = delivered XOR 0x04 (flip byte7 bit 2): cold-ref e8->ec, dl3cmac e7->e3.
+        // cp::in_riv
+        // then ^1 for the dock->host IN stream (ec->ed). vino had been masking the delivered riv
+        // and
+        // sealing with the raw random LSBs, so the dock (deriving its keystream as delivered^0x04)
+        // got a different keystream and silently dropped every CP frame. See the vino cold-plug
+        // diff.
+        let riv_ske = riv; // deliver the full random riv, unmasked, exactly like DLM
+        riv[7] ^= 0x04; // host OUT-CP riv = delivered ^ 0x04
+        dev.bulk_send(EP_CTRL_OUT, &ake::ske_send_eks(5, 0, &edkey, &riv_ske)?, timeout(), GFP_KERNEL)?;
+        // Dev diagnostic: the live session key/out-riv the dock must hold to decrypt our CP.
+        pr_debug!("vino: SESSION ks={ks:02x?} out_riv={riv:02x?}\n");
+
+        // The LIVE plaintext capability-announce (`build_cap_announce`),
+        // built once V is known below. Empty unless the dock is a repeater (D6000 always is).
+        let mut cap_announce = KVec::new();
+
+        // (12) RepeaterAuth -- verify V' over the ReceiverID_List, ACK, then SM2.
+        if repeater {
+            let (vid, list) = Self::recv_hdcp(dev)?;
+            if vid != id::REPEATERAUTH_SEND_RECEIVERID_LIST || list.len() < 16 {
+                pr_err!("vino: AKE: bad ReceiverID_List (id={vid:#x})\n");
+                return Err(EINVAL);
+            }
+            let split = list.len() - 16;
+            // V = HMAC(kd, list_header): MSB-128 = V' (verify vs the list trailer);
+            // LSB-128 = the RepeaterAuth_Send_Ack value (NOT the MSB -- that was THE bug).
+            let v_full = hdcp::compute_v_full(&kd, &list[..split]);
+            let mut v_ack = [0u8; 16];
+            v_ack.copy_from_slice(&v_full[16..]);
+            if v_full[..16] != list[split..] {
+                pr_err!("vino: AKE: V' mismatch -- repeater verification failed\n");
+                return Err(EINVAL);
+            }
+            pr_info!("vino: AKE: V' verified\n");
+            dev.bulk_send(EP_CTRL_OUT, &ake::repeater_auth_send_ack(6, 0, &v_ack)?, timeout(), GFP_KERNEL)?;
+            // Read the dock's ctr6 ack before sending ctr7 -- DLM's lockstep pacing, without
+            // which the dock NAKs the back-to-back OUTs ~100 ms each (see `pace_cap_ack`).
+            Self::pace_cap_ack(dev, 6);
+            dev.bulk_send(
+                EP_CTRL_OUT,
+                &ake::repeater_auth_stream_manage(7, 0, Self::CP_STREAM_TYPE0)?,
+                timeout(),
+                GFP_KERNEL,
+            )?;
+            // Read the dock's ctr7 ack before returning, so the caller's arm marker lands
+            // tight after ctr7 (DLM: 0.46 ms) instead of while the dock is still NAKing.
+            Self::pace_cap_ack(dev, 7);
+            // Then drain the dock's terminal cap burst -- id=0x0b (cap-complete) AND the dock's
+            // `RepeaterAuth_Stream_Ready` (HDCP 0x11, the 3rd id=0x28) -- before the caller arms.
+            // DLM arms only after this burst (cold-ref: id=0x21 -> id=0x0b -> id=0x28/0x11 ->
+            // arm);
+            // arming early makes the dock NAK msg0 ~100 ms and dump a 16 KB error block instead of
+            // engaging. `wait_cap_complete` recognises + verifies the Stream_Ready in place (HDCP
+            // 2.3 Adaptation sec RepeaterAuth). `kd` is needed to check `M == M'`.
+            Self::wait_cap_complete(dev, &kd);
+
+            // Build the LIVE capability-announce now that every field is known. This is the
+            // plaintext re-statement of the 7 AKE OUT messages the dock requires between the
+            // init markers and the arm marker (`CP_CAP_PHASE`). See `build_cap_announce`.
+            // Pass `riv_ske` (the value SKE_Send_Eks actually delivered), NOT `riv` (= session
+            // OUT-CP seal riv = riv_ske ^ 0x04). The cap-announce ctr5 frame is a byte-faithful
+            // re-statement of SKE_Send_Eks, so it must carry the IDENTICAL riv.
+            cap_announce = Self::build_cap_announce(&rtx, &ekpub, &rn, &edkey, &riv_ske, &v_ack)?;
+        }
+
+        Ok(Session { ks, riv, kd, cap_announce, cp_start, cert_at })
+    }
+
+
+    /// Build the LIVE plaintext **capability-announce** the dock requires before the arm
+    /// marker. Ground truth: the cold-ref raw wire
+    /// (`captures/cold-ref-20260608-200850/`, t~36.754-36.813) shows DLM, *after* the HDCP
+    /// AKE, sends 7 plaintext `type=4 wsub=0x04` frames that are a re-statement of the 7 AKE
+    /// OUT messages -- `id=0x22/0x1f/0x9a/0x22/0x32/0x2a/0x2d`, `sub=0x10`, ctr 1-7 -- each
+    /// carrying THIS session's real value: f1=rtx, f2=const TxCaps, f3=Ekpub(km)[128],
+    /// f4=rn, f5=Edkey(ks)[16]||riv_base[8], f6=V[16], f7=const Stream_Manage config. The dock
+    /// ACKs each (`id=0x14 sub=0x10 ctr=N`) and only then engages its CP cipher; skipping the
+    /// announce leaves it cipher-off (the long-standing "0 `sub=0x45` acks" symptom).
+    ///
+    /// [`golden::CAP_PLAIN_1080P`] is a byte-correct *skeleton* (headers/aux/lead bytes and the
+    /// two constant frames are session-invariant -- verified across the cold-ref and matched
+    /// sessions) but its 5 variable payloads are a STALE foreign session's values. Replaying it
+    /// verbatim delivers the dock a stale Ekpub/Edkey/riv that re-key it to a foreign `ks`
+    /// (the `cap_phase`-clobbers-`ks` bug). So we clone the skeleton and overwrite ONLY the 5
+    /// session-specific payloads. Each payload sits at frame offset 44 (16-byte wire header +
+    /// 22 inner-prefix bytes + the `30 00 00 00 00` marker + 1 lead byte = 28 inner bytes), and
+    /// frames are stored `[u16 len][frame]`. `riv` here is the SKE-*delivered* riv (`riv_ske`),
+    /// written verbatim -- frame 5 is a byte-faithful re-statement of `SKE_Send_Eks`, so it must
+    /// carry the EXACT delivered riv. (It earlier wrote `riv & 0xF8`, which equals the delivered
+    /// value only when the random riv's low 3 bits are zero -- true for cold-ref's `e8` but wrong
+    /// for 7 of 8 live sessions, so the dock saw a different riv in the announce than in SKE.
+    /// Ground truth: cold-ref ctr5 capture t=36.812413 delivers riv `...40e8` == its SKE riv.)
+    fn build_cap_announce(
+        rtx: &[u8; 8],
+        ekpub: &[u8; 128],
+        rn: &[u8; 8],
+        edkey: &[u8; 16],
+        riv: &[u8; 8],
+        v: &[u8; 16],
+    ) -> Result<KVec<u8>> {
+        let mut blob = KVec::with_capacity(golden::CAP_PLAIN_1080P.len(), GFP_KERNEL)?;
+        blob.extend_from_slice(golden::CAP_PLAIN_1080P, GFP_KERNEL)?;
+
+        // Walk the skeleton; for each frame, overwrite the payload (at frame+44) keyed by ctr.
+        let mut off = 0usize;
+        while off + 2 <= blob.len() {
+            let len = u16::from_le_bytes([blob[off], blob[off + 1]]) as usize;
+            let frame = off + 2;
+            if frame + len > blob.len() {
+                break;
+            }
+            // ctr (inner offset 4) identifies which AKE message this announce frame restates.
+            let ctr = u16::from_le_bytes([blob[frame + 16 + 4], blob[frame + 16 + 5]]);
+            let pay = frame + 44; // 16 hdr + 22 inner-prefix + 5 marker + 1 lead
+            match ctr {
+                1 => blob[pay..pay + 8].copy_from_slice(rtx), // AKE_Init
+                3 => blob[pay..pay + 128].copy_from_slice(ekpub), // AKE_No_Stored_km Ekpub
+                4 => blob[pay..pay + 8].copy_from_slice(rn), // LC_Init
+                5 => {
+                    // SKE_Send_Eks: Edkey(ks)[16] || riv[8] (the delivered riv, verbatim)
+                    blob[pay..pay + 16].copy_from_slice(edkey);
+                    blob[pay + 16..pay + 24].copy_from_slice(riv);
+                }
+                6 => blob[pay..pay + 16].copy_from_slice(v), // RepeaterAuth_Send_Ack V
+                _ => {} // ctr 2 (TxCaps) and 7 (Stream_Manage) are session-invariant
+            }
+            off = frame + len;
+        }
+        Ok(blob)
+    }
+
+
+    /// Poll EP 0x83 (interrupt-IN status endpoint). DLM submits URBs here CONTINUOUSLY and the dock
+    /// pushes 6-byte status events; the dock may gate CP/downstream-HDCP engagement on the host
+    /// servicing this endpoint (flagged in `vino-driver/src/bin/bringup.rs`). vino never polled it
+    /// --
+    /// invisible in the EP02/EP84 bulk-wire comparison. Reads up to a few events (short timeout so
+    /// a
+    /// URB is pending when the dock pushes). `usb_bulk_msg` auto-routes the interrupt endpoint.
+    fn poll_ep83(dev: &usb::Interface<device::Bound>) -> usize {
+        // EP83 (interrupt-IN) transfers need DMA-capable memory -- allocate on the HEAP.
+        // A stack array trips usb_hcd_map_urb_for_dma's "transfer buffer is on stack"
+        // WARNING (VMAP_STACK can't be DMA-mapped) and the broken submit also stalls the
+        // bring-up (poll_ep83 runs inside every drain round). Best-effort: bail on OOM.
+        let mut buf = match KVec::from_elem(0u8, 64, GFP_KERNEL) {
+            Ok(b) => b,
+            Err(_) => return 0,
+        };
+        let mut n = 0usize;
+        // Short timeout: a pending URB gives the dock a window to push, but a 30 ms block on the
+        // (normally idle) EP83 stalls the bring-up loop (see drain_ep84). 2 ms is enough to catch a
+        // ready event without serializing the handshake.
+        for _ in 0..4 {
+            match dev.interrupt_recv(0x83, &mut buf, Delta::from_millis(2), GFP_KERNEL) {
+                Ok(len) if len > 0 => {
+                    n += 1;
+                    let s = &buf[..len.min(8)];
+                    pr_info!("vino: EP83 status event {len}B {s:02x?}\n");
+                }
+                _ => break,
+            }
+        }
+        n
+    }
+
+    /// Reap any completed transfers from the persistent EP83 interrupt-IN queue (opened in
+    /// [`send_cp_setup`]) without blocking, re-posting each as it is read so a URB always stays
+    /// pending. Returns the number of status events drained (logged for the cold-plug A/B vs DLM).
+    ///
+    /// [`send_cp_setup`]: Self::send_cp_setup
+    fn drain_ep83_queue(q: Option<&mut usb::BulkInQueue>) -> usize {
+        let Some(q) = q else { return 0 };
+        let mut buf = [0u8; 64];
+        let mut n = 0usize;
+        // Non-blocking sweep: a 0 ms wait returns Ok(None) immediately if nothing has completed,
+        // so this never serialises the arm/msg0 burst -- it only harvests what the dock pushed.
+        for _ in 0..4 {
+            match q.recv(&mut buf, Delta::from_millis(0)) {
+                Ok(Some(len)) if len > 0 => {
+                    n += 1;
+                    let s = &buf[..len.min(8)];
+                    pr_info!("vino: EP83 (async) status event {len}B {s:02x?}\n");
+                }
+                _ => break,
+            }
+        }
+        n
+    }
+
+
+    /// Drives the post-SKE CP setup: opens the async EP84 reader, sends the plaintext
+    /// stream-open arm marker, then the first live encrypted CP frame (msg0), and counts the
+    /// dock's encrypted `wsub=0x45` acks. THE WALL: on a cold dock `acks` stays 0 -- the dock
+    /// runs the entire plaintext handshake but never engages the encrypted CP. See the "help
+    /// wanted" note at the top of the file.
+    fn send_cp_setup(
+        dev: &usb::Interface<device::Bound>,
+        session: &Session,
+        edid_out: &mut Option<KVec<u8>>,
+    ) -> Result<(usize, usize, u32, u16)> {
+        // 16 KiB so the dock's ~5787 B capability block is read whole (see [`EP84_BUF`]).
+        let mut resp = KVec::from_elem(0u8, EP84_BUF, GFP_KERNEL)?;
+        let mut drained = 0usize;
+        let mut acks = 0usize;
+        let mut sent = 0usize;
+        let mut ep83_events = 0usize;
+
+        // Plaintext `type=2 sub=0x24`+`0x45` stream-open arm marker -- the mandatory gate
+        // before the first encrypted frame.
+        const STREAM_OPEN: [u8; 64] = [
+            0x00, 0x00, 0x1c, 0x00, 0x02, 0x00, 0x00, 0x00, //
+            0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+            0x04, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, //
+            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+            0x00, 0x00, 0x1c, 0x00, 0x02, 0x00, 0x00, 0x00, //
+            0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+            0x05, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x00, 0x00, //
+            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+        ];
+
+        // Open the persistent async EP84 IN reader BEFORE the arm marker and msg0, so
+        // `EP84_QUEUE_DEPTH` IN transfers are already posted when the dock pushes its post-arm
+        // reply (DLM's libusb always-pending-IN behaviour). Draining EP84 concurrently stops the
+        // dock's IN FIFO filling and NAKing our OUT (the sync-bulk deadlock that produced a 100 ms
+        // msg0 NAK). RAII: dropping the queue at function exit kills+frees the URBs.
+        let mut ep84_q = match dev.bulk_in_queue(0x04, EP84_QUEUE_DEPTH, EP84_BUF) {
+            Ok(q) => {
+                pr_info!("vino: EP84 async IN queue opened (depth={EP84_QUEUE_DEPTH})\n");
+                Some(q)
+            }
+            Err(e) => {
+                pr_info!("vino: EP84 async queue open failed ({e:?}) -- falling back to sync bulk_recv\n");
+                None
+            }
+        };
+
+        // A persistent async interrupt-IN queue on EP83 (mirroring `BulkInQueue` but for an
+        // interrupt endpoint) was tried here to see whether FIFO backpressure on the dock's
+        // status endpoint was blocking CP engagement. It wasn't: a HW cold plug measured
+        // `EP83_events=0` and the dock still never acked (`wsub=0x45`). Dropped rather than
+        // carried forward on an ad hoc binding outside the usb series -- `poll_ep83` (sync,
+        // called elsewhere in bring-up) is sufficient and already matches DLM's own cadence.
+        let mut ep83_q: Option<usb::BulkInQueue> = None;
+
+        // A/B (2026-06-16): route the engagement-critical arm marker + msg0 through an async,
+        // pipelined OUT queue (`usb::Interface::bulk_out_queue`) instead of the synchronous
+        // `bulk_send`. This mirrors DLM's libusb execution model exactly: each OUT URB is
+        // submitted and returns immediately (the HCD auto-retries NAKs until the URB's
+        // teardown), so the arm and msg0 are queued back-to-back and reaped afterwards rather
+        // than each blocking for its device-ACK round-trip before the next is submitted. The
+        // 2026-06-15 measurement showed the *wire* (lengths + submit->complete latency) is
+        // already identical, so this is not expected to change what the dock receives -- it is
+        // the last structural host difference (sync `usb_bulk_msg` vs async submit/reap) made
+        // identical so a cold plug can rule it in or out. Default OFF so vino keeps the proven
+        // sync path and paired diffs are not polluted; flip to test.
+        const CP_ASYNC_OUT: bool = true;
+        let mut out_q = if CP_ASYNC_OUT {
+            match dev.bulk_out_queue(0x02, 4, 1024) {
+                Ok(q) => {
+                    pr_info!("vino: EP02 async OUT queue opened (depth=4) -- libusb-style submit/reap\n");
+                    Some(q)
+                }
+                Err(e) => {
+                    pr_info!("vino: EP02 async OUT queue open failed ({e:?}) -- using sync bulk_send\n");
+                    None
+                }
+            }
+        } else {
+            None
+        };
+
+        // Pin the EP02 DATA0/DATA1 toggle to DATA0 immediately before the arm. This is the one
+        // host lever invisible to every "host exhausted" test: usbmon logs payloads, not the
+        // toggle bit, and the crypto/timing work never touches it. DLM (libusb async URBs) and
+        // vino (in-kernel blocking bulk_send) can reach the arm with EP02 at *different* parity
+        // after the ~9 preceding OUT transfers (7 cap-announce + arm) -- a mismatch makes the
+        // dock's SIE ACK the packet at the link layer (byte-identical on the wire) yet discard
+        // the payload as a duplicate, i.e. "arms clean, silently drops msg0". clear_halt issues
+        // CLEAR_FEATURE(ENDPOINT_HALT), which resets both sides' toggle to DATA0. Every earlier
+        // reset (reset_configuration at the top of bring_up, HARD_RESET, VBUS cycle) reset the
+        // toggle *before* those preceding transfers, so msg0's parity was never pinned. A/B:
+        // flip to `reset_configuration()` to test the heavier reset at the same call site.
+        // RESULT 2026-06-16 (cold plug vino-cold-20260616-000552): TESTED NEGATIVE.
+        // clear_halt(EP02)
+        // fired (wire shows CLEAR_FEATURE on EP2, dmesg "toggle -> DATA0") yet the dock still gave
+        // sub=0x45_acks=0. The toggle was NOT the gate. Left default-OFF so vino doesn't carry an
+        // EP02 CLEAR_FEATURE that DLM never sends (would pollute future paired diffs); flip to
+        // test.
+        // Sibling result: EP02 wMaxPacketSize logged = 1024, so a 64-byte msg0/arm always
+        // terminates
+        // as a natural short packet -- the ZLP-trap hypothesis is moot too.
+        const CLEAR_HALT_BEFORE_ARM: bool = false;
+        if CLEAR_HALT_BEFORE_ARM {
+            match dev.clear_halt(EP_CTRL_OUT) {
+                Ok(()) => pr_info!("vino: EP02 clear_halt before arm OK (toggle -> DATA0)\n"),
+                Err(e) => pr_info!("vino: EP02 clear_halt before arm non-fatal ({e:?})\n"),
+            }
+        }
+
+        // cert->arm fixed hold (the key `DLM_FIXED_TIMERS` lever). DLM arms a hardcoded
+        // CERT_TO_ARM_US (59.1 ms) after the dock's cert; vino's reactive settle arms the instant
+        // the AKE completes (~57.9 ms -- below DLM's observed minimum, i.e. always a hair EARLY).
+        // `hold_until` pads to exactly CERT_TO_ARM_US after the cert with udelay-grade precision (a
+        // plain fsleep overshot 59.1 -> 59.4 ms). If wait_cap_complete already ran past the target
+        // (dock slow), it returns at once -- we never arm *before* the window, only pad up to it.
+        if DLM_FIXED_TIMERS {
+            hold_until(session.cert_at, CERT_TO_ARM_US);
+        }
+
+        // Submit the arm marker. Async path: queue it and DO NOT flush -- leave it in flight so
+        // msg0 can be submitted right behind it (the pipelined arm->msg0 burst DLM does). Sync
+        // path: the original blocking send.
+        let arm_res = match out_q.as_mut() {
+            Some(q) => q.send(&STREAM_OPEN, timeout()),
+            None => dev.bulk_send(EP_CTRL_OUT, &STREAM_OPEN, timeout(), GFP_KERNEL).map(|_| ()),
+        };
+        if let Err(e) = arm_res {
+            pr_err!("vino: CP stream-open marker FAILED ({e:?})\n");
+            return Err(e);
+        }
+        // Report the realised `cp_start->arm` AND `cert->arm` so the cold-plug dmesg carries vino's
+        // pre-arm fingerprint for an A/B against DLM (cp_start->arm ~60 ms; cert->arm fixed 59.1 ms
+        // under DLM_FIXED_TIMERS). Microsecond precision. See the 2026-06-26 timing survey.
+        let arm_at = session.cp_start.elapsed();
+        let cert_to_arm = session.cert_at.elapsed().as_micros_ceil();
+        pr_info!(
+            "vino: CP stream-open arm marker sent (cp_start->arm = {} us, cert->arm = {} us, target {} us)\n",
+            arm_at.as_micros_ceil(), cert_to_arm, CERT_TO_ARM_US
+        );
+
+        // arm->msg0 hold. The 2026-06-25 timing survey found this is the ONE step where vino is
+        // consistently faster than DLM: vino fires msg0 ~0.07 ms after the arm, DLM ~0.17 ms -- the
+        // only timing inversion in the corpus, and never tested as a variable. Earlier this gap was
+        // left unpadded on the "engine is event-driven, sub-ms lead is immaterial" reasoning; the
+        // survey shows DLM's gap is a *fixed* ~0.17 ms (0.152/0.188 ms, 0.036 ms spread = a hard
+        // sleep, not a reaction), so under `DLM_FIXED_TIMERS` we hold [`ARM_TO_MSG0`] to match it
+        // exactly. If the dock keys engagement on msg0 not arriving before the arm has settled, this
+        // is the lever; if it engages, "msg0 too soon" was the gate. Cheap to A/B on one cold plug.
+        if DLM_FIXED_TIMERS {
+            fsleep(ARM_TO_MSG0);
+        }
+
+        // LIVE CP msg0: protocol-fixed header `id=0x14 sub=0x00 ctr=0x08`, 14 zero bytes, then a
+        // fresh host-random 10-byte token (the dock does not validate or echo it), sealed under
+        // THIS session's ks/riv with a live Dl3Cmac. This is the decisive engagement probe: a
+        // `wsub=0x45` reply would mean the cipher engaged on a live session.
+        let mut content = [0u8; 32];
+        content[0..2].copy_from_slice(&0x0014u16.to_le_bytes()); // id=0x14
+        content[4..8].copy_from_slice(&8u32.to_le_bytes()); // ctr=0x08 (sub=0x00 stays zero)
+        rng::fill(&mut content[22..32]); // host-random token
+        let body_len = content.len() + 16; // AES-CTR ciphertext + 16-byte Dl3Cmac
+        let size = ((16 + body_len) - 4) as u16;
+        let aux = cp::aux_for_id(0x14, body_len);
+        let mut hdr = [0u8; 16];
+        hdr[2..4].copy_from_slice(&size.to_le_bytes());
+        hdr[4..8].copy_from_slice(&4u32.to_le_bytes()); // type=4
+        hdr[8..10].copy_from_slice(&0x24u16.to_le_bytes()); // sub=0x24 (interactive CP)
+        hdr[10..12].copy_from_slice(&aux.to_le_bytes());
+        // hdr[12..16] = wire_seq = 0 (first CP block)
+        let frame = cp::seal_livemac(&session.ks, &session.riv, &hdr, &content)?;
+
+        let mut ok = false;
+        if let Some(q) = out_q.as_mut() {
+            // Async path: submit msg0 right behind the still-in-flight arm (pipelined burst),
+            // then drain EP84 while the HCD auto-retries any NAK against the live URB. Reap both
+            // OUT transfers; a flush timeout just means the dock NAK'd msg0 (URB killed at drop).
+            match q.send(&frame, timeout()) {
+                Ok(()) => {
+                    ok = true;
+                    pr_info!("vino: live CP msg0 submitted async (pipelined behind arm)\n");
+                }
+                Err(e) => pr_info!("vino: live CP msg0 async submit failed ({e:?})\n"),
+            }
+            for _ in 0..8 {
+                let (d, a) = Self::drain_ep84(dev, ep84_q.as_mut(), &mut resp, session, edid_out);
+                drained += d;
+                acks += a;
+                ep83_events += Self::drain_ep83_queue(ep83_q.as_mut());
+            }
+            match q.flush(Delta::from_millis(200)) {
+                Ok(()) => pr_info!("vino: async arm+msg0 reaped OK (both transfers completed)\n"),
+                Err(e) => pr_info!("vino: async arm+msg0 reap incomplete ({e:?}) -- dock NAK'd\n"),
+            }
+        } else {
+            // Sync path: single-packet msg0 => a NAK transfers nothing, so cancel+retry is safe.
+            // Between attempts drain EP84 so the dock can push/drain its IN queue. Bounded.
+            const TRIES: usize = 40;
+            for t in 0..TRIES {
+                match dev.bulk_send(EP_CTRL_OUT, &frame, Delta::from_millis(5), GFP_KERNEL) {
+                    Ok(_) => {
+                        ok = true;
+                        pr_info!("vino: live CP msg0 ACCEPTED after {t} interleaved tries\n");
+                        break;
+                    }
+                    // OUT NAK'd (nothing transferred) -- let the dock push on EP84, then retry.
+                    Err(_) => {
+                        let (d, a) =
+                            Self::drain_ep84(dev, ep84_q.as_mut(), &mut resp, session, edid_out);
+                        drained += d;
+                        acks += a;
+                        ep83_events += Self::drain_ep83_queue(ep83_q.as_mut());
+                    }
+                }
+            }
+        }
+        if ok {
+            sent += 1;
+            pr_info!("vino: live CP msg0 sent (id=0x14 ctr=8, random token, live seal)\n");
+        } else {
+            pr_info!("vino: live CP msg0 still NAK'd (no transfer accepted)\n");
+        }
+
+        // DLM sends the `0x24 wValue=0` render/commit vendor request right after msg0.
+        match dev.control_send(0x24, 0x40 /* VENDOR_OUT */, 0, 0, &[], timeout(), GFP_KERNEL) {
+            Ok(()) => pr_info!("vino: post-msg0 0x24(wValue=0) OK\n"),
+            Err(e) => pr_info!("vino: post-msg0 0x24(wValue=0) non-fatal ({e:?})\n"),
+        }
+        // DLM then re-reads the 0x22 vendor state (0xc1, wValue=1, wIndex=0, 28 B) -- its SECOND
+        // 0x22 of the session, immediately after the post-msg0 0x24. vino issued the first 0x22
+        // pre-arm but stopped here, leaving "DLM-ONLY 0x22" in the paired diff. Issue it
+        // unconditionally so the wire matches DLM regardless of whether the dock acks; it is a
+        // harmless vendor IN read. (0xc1 = IN|vendor|INTERFACE recipient, matching the first 0x22.)
+        let mut state2 = [0u8; 28];
+        match dev.control_recv(0x22, 0xc1, 1, 0, &mut state2, timeout(), GFP_KERNEL) {
+            Ok(()) => pr_info!("vino: post-msg0 0x22(wValue=1) OK = {:02x?}\n", state2),
+            Err(e) => pr_info!("vino: post-msg0 0x22(wValue=1) non-fatal ({e:?})\n"),
+        }
+
+        // Read the dock's reply: a `wsub=0x45` ack means the cipher engaged on our live frame.
+        let (d, a, _m) = Self::lockstep_reply(dev, ep84_q.as_mut(), &mut resp, session, 0x08, edid_out);
+        drained += d;
+        acks += a;
+
+        const MAX_ROUNDS: usize = 16;
+        for _ in 0..MAX_ROUNDS {
+            let (d, a) = Self::drain_ep84(dev, ep84_q.as_mut(), &mut resp, session, edid_out);
+            drained += d;
+            acks += a;
+            if d == 0 {
+                break;
+            }
+        }
+
+        // ---- Post-engagement live setup (CP-HANDSHAKE.md sec 4f/sec 4e) ------------------------
+        // Only meaningful once the dock has acked msg0: ask the dock for the downstream EDID,
+        // then build the mode-set from its preferred timing and send that -- the live path that
+        // replaces the static 1080p modeset and the opportunistic-only EDID capture. On a cold
+        // dock `acks` stays 0 (the wall), so this does not run on current hardware; it completes
+        // the standalone live-generation flow for when the engagement gate is solved.
+        // The next free AES-CTR block index past this setup, handed to the DRM device so runtime
+        // KMS sends (mode-set/cursor) continue the same keystream. Defaults to msg0's end (2) when
+        // the live block below doesn't run (no acks) -- irrelevant then, since we only publish the
+        // session when `acks > 0`.
+        let mut wire_seq_end = 2u32;
+        if acks > 0 {
+            // `wseq` continues the AES-CTR block counter past msg0 (32 B content = 2 blocks);
+            // the inner `counter` continues past msg0's ctr=8. The dock echoes both, so the
+            // exact values only need to stay monotonic / non-overlapping for the keystream.
+            let mut wseq = 2u32;
+
+            // (1) Live get-EDID request -> the dock replies id=0x194; `drain_ep84` (called inside
+            // `send_live_cp`) decodes it and fills `edid_out` via `parse_edid_from_reply`.
+            if let Ok(req) = cp::get_edid_req(9) {
+                match Self::send_live_cp(
+                    dev, session, ep84_q.as_mut(), &mut resp, edid_out, 0x15, wseq, &req,
+                ) {
+                    Ok((ok, d, a)) => {
+                        drained += d;
+                        acks += a;
+                        wseq = wseq.wrapping_add(((req.len() + 15) / 16) as u32);
+                        pr_info!("vino: live get-EDID request {}\n",
+                            if ok { "sent (id=0x15 sub=0x21)" } else { "NAK'd" });
+                    }
+                    Err(e) => pr_info!("vino: live get-EDID request failed ({e:?})\n"),
+                }
+            }
+
+            // (2) Dynamic mode-set from the dock's EDID preferred detailed timing, falling back to
+            // the known-good UHD_60 timing when no EDID/DTD is available.
+            let from_edid = edid_out.is_some();
+            let timing = edid_out
+                .as_deref()
+                .and_then(cp::timing_from_edid)
+                .unwrap_or(cp::Timing::UHD_60);
+            match cp::set_mode(10, &timing) {
+                Ok(smode) => {
+                    // `set_mode` reserves a trailing 16-byte tag region; `seal_livemac` appends a
+                    // fresh live Dl3Cmac, so hand it the inner content without that region.
+                    let content = &smode[..smode.len().saturating_sub(16)];
+                    match Self::send_live_cp(
+                        dev, session, ep84_q.as_mut(), &mut resp, edid_out, 0x48, wseq, content,
+                    ) {
+                        Ok((ok, d, a)) => {
+                            drained += d;
+                            acks += a;
+                            pr_info!("vino: live mode-set {} ({}x{}@{} from {})\n",
+                                if ok { "sent" } else { "NAK'd" },
+                                timing.hactive, timing.vactive, timing.refresh_hz,
+                                if from_edid { "EDID" } else { "fallback" });
+                        }
+                        Err(e) => pr_info!("vino: live mode-set failed ({e:?})\n"),
+                    }
+                    // Advance the keystream past this mode-set so runtime KMS sends continue it.
+                    wseq = wseq.wrapping_add(((content.len() + 15) / 16) as u32);
+                }
+                Err(e) => pr_info!("vino: mode-set build failed ({e:?})\n"),
+            }
+            wire_seq_end = wseq;
+        }
+
+        // Final sweep of the EP83 interrupt queue so a status byte the dock pushed late (after the
+        // arm/msg0 burst settled) is still counted before the queue is dropped.
+        for _ in 0..4 {
+            let e = Self::drain_ep83_queue(ep83_q.as_mut());
+            ep83_events += e;
+            if e == 0 {
+                break;
+            }
+        }
+        let engaged = if acks > 0 { "dock engaged" } else { "dock ignoring our CP (the wall)" };
+        pr_info!("vino: CP setup sent={sent} EP84_resp={drained} sub=0x45_acks={acks} EP83_events={ep83_events} ({engaged})\n");
+        // Inner counter past the bring-up CP messages (msg0=8, get-EDID=9, mode-set=10).
+        Ok((sent, acks, wire_seq_end, 11))
+    }
+
+
+    /// Seal `content` (inner CP plaintext, WITHOUT the 16-byte tag region) into a live
+    /// `type=4 sub=0x24` frame at `wire_seq`, send it on EP02 with EP84 drained between NAK
+    /// retries (the single-packet interleave discipline msg0 uses), then drain once more to
+    /// collect the dock's reply. `id` selects the DLM-exact `aux` header field
+    /// ([`cp::aux_for_id`]). Returns `(sent_ok, ep84_reads, sub=0x45_acks)`. Used for the
+    /// post-engagement live messages (get-EDID, mode-set) once the dock has acked msg0.
+    fn send_live_cp(
+        dev: &usb::Interface<device::Bound>,
+        session: &Session,
+        mut q: Option<&mut usb::BulkInQueue>,
+        resp: &mut [u8],
+        edid_out: &mut Option<KVec<u8>>,
+        id: u16,
+        wire_seq: u32,
+        content: &[u8],
+    ) -> Result<(bool, usize, usize)> {
+        let frame = cp::seal_interactive(&session.ks, &session.riv, id, wire_seq, content)?;
+
+        // Single-packet OUT: a NAK transfers nothing, so cancel+retry is safe. Between attempts
+        // drain EP84 so the dock can push/drain its IN queue (matches msg0's behaviour).
+        const TRIES: usize = 40;
+        let mut ok = false;
+        let mut drained = 0usize;
+        let mut acks = 0usize;
+        for _ in 0..TRIES {
+            match dev.bulk_send(EP_CTRL_OUT, &frame, Delta::from_millis(5), GFP_KERNEL) {
+                Ok(_) => {
+                    ok = true;
+                    break;
+                }
+                Err(_) => {
+                    let (d, a) = Self::drain_ep84(dev, q.as_deref_mut(), resp, session, edid_out);
+                    drained += d;
+                    acks += a;
+                }
+            }
+        }
+        // Collect the dock's reply (the get-EDID id=0x194 frame is captured here via drain_ep84).
+        let (d, a) = Self::drain_ep84(dev, q.as_deref_mut(), resp, session, edid_out);
+        drained += d;
+        acks += a;
+        Ok((ok, drained, acks))
+    }
+
+
+    /// sec 5 read-only diagnostic: log one dock->host EP84 frame's wire header
+    /// (`type`@4, `sub`@8, `aux`@10, `seq`@12) and, when the body decrypts under the IN
+    /// keystream, its inner `(id, sub, ictr)`. Surfaces EVERY frame the dock returns --
+    /// not just `sub=0x45` -- so a hardware run reveals whether the dock is mute, NAKing,
+    /// or replying with an unexpected sub. Pure logging; no state change.
+    fn log_ep84(session: &Session, frame: &[u8]) {
+        let len = frame.len();
+        let wtype = if len >= 8 {
+            u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]])
+        } else {
+            0
+        };
+        let wsub = if len >= 10 { u16::from_le_bytes([frame[8], frame[9]]) } else { 0 };
+        let aux = if len >= 12 { u16::from_le_bytes([frame[10], frame[11]]) } else { 0 };
+        let wseq = if len >= 16 {
+            u32::from_le_bytes([frame[12], frame[13], frame[14], frame[15]])
+        } else {
+            0
+        };
+        {
+            // Dev diagnostic (pr_debug, compiled out unless dynamic debug is enabled): the raw
+            // wire, so the dock's pushes can be offline-decoded. The dock's large capability block
+            // (~5787 B) must be dumped in 128-byte CHUNKS, because a single hex print of a
+            // >~250-byte
+            // array exceeds printk's per-line limit. Capped at 768 B (6 lines) to avoid flooding.
+            let cap = len.min(768);
+            if cap <= 64 {
+                let raw = &frame[..cap];
+                pr_debug!("vino: dock EP84 RAW {len}B {raw:02x?}\n");
+            } else {
+                pr_debug!("vino: dock EP84 RAW {len}B (first {cap} B in 128-B chunks):\n");
+                let mut o = 0usize;
+                while o < cap {
+                    let e = (o + 128).min(cap);
+                    let chunk = &frame[o..e];
+                    pr_debug!("vino:   ep84[{o:#06x}] {chunk:02x?}\n");
+                    o = e;
+                }
+            }
+        }
+        match cp::decode_any(&session.ks, &session.riv, frame) {
+            Some((rivtag, rid, rsub, rictr, sample)) => {
+                pr_info!(
+                    "vino: dock EP84 type={wtype} wsub={wsub:#x} aux={aux:#x} seq={wseq:#x} {len}B -> [{rivtag}] id={rid:#x} sub={rsub:#x} ictr={rictr:#x} pt={sample:02x?}\n"
+                );
+            }
+            None => {
+                pr_info!(
+                    "vino: dock EP84 type={wtype} wsub={wsub:#x} aux={aux:#x} seq={wseq:#x} {len}B (no inner decode)\n"
+                );
+            }
+        }
+    }
+
+    /// Read one EP84 frame: from the persistent async queue `q` when [`CP_ASYNC_EP84`] has opened
+    /// one, else a synchronous `bulk_recv`. The queue's timeout (`Ok(None)`) is mapped to
+    /// `Err(ETIMEDOUT)` so the callers' existing match arms (which treat any `Err`/empty as
+    /// "no more data right now") work unchanged across both paths.
+    fn read_ep84(
+        dev: &usb::Interface<device::Bound>,
+        q: Option<&mut usb::BulkInQueue>,
+        buf: &mut [u8],
+        to: Delta,
+    ) -> Result<usize> {
+        match q {
+            Some(queue) => match queue.recv(buf, to) {
+                Ok(Some(n)) => Ok(n),
+                Ok(None) => Err(ETIMEDOUT),
+                Err(e) => Err(e),
+            },
+            None => dev.bulk_recv(EP_CTRL_IN, buf, to, GFP_KERNEL),
+        }
+    }
+
+
+    fn drain_ep84(
+        dev: &usb::Interface<device::Bound>,
+        mut q: Option<&mut usb::BulkInQueue>,
+        buf: &mut [u8],
+        session: &Session,
+        edid_out: &mut Option<KVec<u8>>,
+    ) -> (usize, usize) {
+        const MAX_READS: usize = 16;
+        let mut n = 0usize;
+        let mut acks = 0usize;
+        // Read EP84 FIRST (the dock answers in ~0.14 ms, same as it does for DLM). The EP83 status
+        // poll is serviced AFTER -- polling it before the EP84 read blocked the critical path for
+        // up
+        // to 30 ms PER cap frame (timeline diff 2026-06-11: vino's cap phase was 446 ms / ~32 ms
+        // per
+        // frame vs DLM's 60 ms / 0.14 ms, purely from this ordering), arming the dock ~1 s late.
+        for _ in 0..MAX_READS {
+            match Self::read_ep84(dev, q.as_deref_mut(), buf, Delta::from_millis(10)) {
+                Ok(len) if len > 0 => {
+                    n += 1;
+                    // sec 5 diagnostic: surface EVERY dock->host frame, not just `sub=0x45`,
+                    // so a hardware run shows what the dock actually returns (a different
+                    // sub, a NAK, or plaintext) instead of a bare `EP84_resp=N` count.
+                    Self::log_ep84(session, &buf[..len]);
+                    if len >= 10 && u16::from_le_bytes([buf[8], buf[9]]) == 0x45 {
+                        acks += 1;
+                        // Capture the dock's EDID the first time it appears (id=0x94
+                        // sub=0x21 reply to the replayed get-EDID request). Reuses the
+                        // standard DRM EDID infra in get_modes. See CONTROL-PLANE.md.
+                        if edid_out.is_none() {
+                            if let Ok(Some(e)) =
+                                cp::parse_edid_from_reply(&session.ks, &session.riv, &buf[..len])
+                            {
+                                pr_info!("vino: EDID read from dock ({} bytes)\n", e.len());
+                                *edid_out = Some(e);
+                            }
+                        }
+                    }
+                }
+                _ => break,
+            }
+        }
+        // Service EP83 AFTER draining EP84, so it never delays reading the dock's CP reply.
+        if Self::POLL_EP83_DURING_BRINGUP {
+            Self::poll_ep83(dev);
+        }
+        (n, acks)
+    }
+
+
+    /// Lockstep counterpart to [`drain_ep84`]: after one CP OUT, drain EP84 until the
+    /// `sub=0x45` reply whose **inner counter echoes** `ictr` arrives (DLM's 1:1
+    /// handshake) or the short read budget elapses. Any async
+    /// pushes seen meanwhile are still counted and scanned for the EDID. Returns
+    /// `(reads, acks, matched)`.
+    fn lockstep_reply(
+        dev: &usb::Interface<device::Bound>,
+        mut q: Option<&mut usb::BulkInQueue>,
+        buf: &mut [u8],
+        session: &Session,
+        ictr: u16,
+        edid_out: &mut Option<KVec<u8>>,
+    ) -> (usize, usize, bool) {
+        const MAX_READS: usize = 8;
+        let in_riv = cp::in_riv(&session.riv);
+        let mut reads = 0usize;
+        let mut acks = 0usize;
+        let mut matched = false;
+        for _ in 0..MAX_READS {
+            match Self::read_ep84(dev, q.as_deref_mut(), buf, Delta::from_millis(30)) {
+                Ok(len) if len > 16 => {
+                    reads += 1;
+                    // sec 5 diagnostic: log every frame the dock returns in the lockstep
+                    // window -- including the non-`0x45` frames we otherwise skip -- so the
+                    // divergence point is paired with the dock's actual reply on the wire.
+                    Self::log_ep84(session, &buf[..len]);
+                    if u16::from_le_bytes([buf[8], buf[9]]) != 0x45 {
+                        continue;
+                    }
+                    acks += 1;
+                    let seq = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
+                    // Decrypt just the first block to read the inner counter (off 4).
+                    let head = &buf[16..len.min(32)];
+                    if let Ok(inner) = cp::open_in(&session.ks, &in_riv, seq, head) {
+                        if inner.len() >= 6
+                            && u16::from_le_bytes([inner[4], inner[5]]) == ictr
+                        {
+                            matched = true;
+                        }
+                    }
+                    // Opportunistically capture the EDID (id=0x94 reply, off 22).
+                    if edid_out.is_none() {
+                        if let Ok(Some(e)) =
+                            cp::parse_edid_from_reply(&session.ks, &session.riv, &buf[..len])
+                        {
+                            pr_info!("vino: EDID read from dock ({} bytes)\n", e.len());
+                            *edid_out = Some(e);
+                        }
+                    }
+                    if matched {
+                        break;
+                    }
+                }
+                // A short, header-only frame (bare ack/keepalive, len <= 16): not a CP
+                // reply, but the dock is still talking -- keep reading for the 0x45 rather
+                // than dropping the rest of the lockstep window.
+                Ok(_) => continue,
+                // Read error / nothing queued within the window: the dock is idle, stop.
+                Err(_) => break,
+            }
+        }
+        (reads, acks, matched)
+    }
+}
+
+kernel::usb_device_table!(
+    USB_TABLE,
+    MODULE_USB_TABLE,
+    <VinoDriver as usb::Driver>::IdInfo,
+    [(usb::DeviceId::from_id(VID_DISPLAYLINK, PID_D6000), ())]
+);
+
+impl usb::Driver for VinoDriver {
+    type IdInfo = ();
+    // The driver instance is itself the per-bound device-private data.
+    type Data<'bound> = Self;
+    const ID_TABLE: usb::IdTable<Self::IdInfo> = &USB_TABLE;
+
+    fn probe<'bound>(
+        intf: &'bound usb::Interface<Core<'_>>,
+        _id: &usb::DeviceId,
+        _info: &'bound Self::IdInfo,
+    ) -> impl PinInit<Self, Error> + 'bound {
+        let cdev: &device::Device<Core<'_>> = intf.as_ref();
+        // The D6000 exposes several interfaces (0/1/5/6 match us; 2-4 are audio).
+        // The control endpoints (0x02/0x84) and the whole HDCP session live on
+        // interface 0 -- drive bring-up only there so we don't run the preamble and
+        // AKE four times and pollute the dock's state machine. Other interfaces
+        // bind (so usbcore doesn't hand them to another driver) but stay idle.
+        let ifnum = intf.number();
+        if ifnum != 0 {
+            // Interface 1 (app-specific/DFU) is the only other one DLM claims; let everything else
+            // (audio 2-4, Ethernet 5-6) fall through to its proper kernel driver. Returning ENODEV
+            // tells usbcore this driver doesn't handle the interface, so it tries the next match.
+            if ifnum != 1 {
+                dev_info!(cdev, "vino: declining D6000 interface {ifnum} (left to its class driver)\n");
+                return Err(ENODEV);
+            }
+            dev_info!(cdev, "vino: bound D6000 interface {ifnum} (idle -- control is iface 0)\n");
+            return Ok(Self { _intf: intf.into(), _ddev: None, bringup: None });
+        }
+        dev_info!(cdev, "vino: bound DisplayLink D6000 -- plaintext session bring-up\n");
+
+        // Phase 3: register a real DRM/KMS device on the control interface so the dock
+        // shows up as a mode-settable `card`/`renderD` node (atomic KMS via the simple
+        // display pipe, one 1080p virtual connector, GEM-shmem dumb buffers). Non-fatal:
+        // bring-up still proceeds (and the interface still binds) if any step fails, so
+        // a DRM-core hiccup can't regress the USB session work.
+        // Hold a refcounted handle to the bound interface; one copy goes into the DRM
+        // device-private (for the EP08 scanout path), one stays in `VinoDriver`.
+        let intf_ref: ARef<usb::Interface> = intf.into();
+        // DRM device lifecycle (drm-rust API): allocate an `UnregisteredDevice`, wire up
+        // the KMS pipeline on it while still unregistered, then hand it to
+        // `Registration::new_foreign_owned` (which registers it and ties its lifetime to
+        // the bound USB device via devres, returning a borrowed `&Device`).
+        let ddev: Option<ARef<drm_sink::VinoDrmDevice>> =
+            match drm::UnregisteredDevice::<drm_sink::VinoDrmDriver>::new(
+                intf,
+                drm_sink::VinoDrmData::new(intf_ref.clone()),
+            ) {
+                // `probe_kms()` (which calls our `KmsDriver::probe()` to build the CRTC/
+                // plane/connector/encoder) runs automatically inside `UnregisteredDevice::new`
+                // above -- there is no separate KMS init step to call.
+                Ok(unreg) => match drm::driver::Registration::new_foreign_owned(unreg, cdev, (), 0) {
+                    Ok(reg_dev) => {
+                        dev_info!(cdev, "vino: DRM+KMS device registered (card node live, 1080p)\n");
+                        Some(reg_dev.into())
+                    }
+                    Err(e) => {
+                        dev_info!(cdev, "vino: DRM registration failed ({e:?}) -- continuing without card node\n");
+                        None
+                    }
+                },
+                Err(e) => {
+                    dev_info!(cdev, "vino: drm::UnregisteredDevice::new failed ({e:?}) -- continuing\n");
+                    None
+                }
+            };
+
+        // Bring-up (preamble + HDCP AKE + ~6 s of lockstep CP replay) is all blocking
+        // synchronous USB I/O. Running it inline here pins the USB driver-model probe
+        // thread while the DRM card node is already registered and live, which stalled
+        // the compositor (KWin) on first plug until the dock was physically yanked. Hand
+        // it to the system workqueue so `probe()` returns immediately and userspace KMS
+        // stays responsive. The work item holds refcounted handles to the interface (for
+        // the bulk endpoints) and the DRM device (for EDID caching), so they outlive
+        // `probe()`; USB I/O after an intervening disconnect simply errors and is logged,
+        // exactly like any other failed bring-up step.
+        // Retain the bring-up handle so `disconnect()` can flush it; enqueue a clone.
+        let bringup = match BringUp::new(intf_ref.clone(), ddev.clone()) {
+            Ok(work) => {
+                let _ = workqueue::system().enqueue(work.clone());
+                dev_info!(cdev, "vino: bring-up queued on system workqueue\n");
+                Some(work)
+            }
+            Err(e) => {
+                dev_info!(cdev, "vino: failed to queue bring-up ({e:?}) -- WIP\n");
+                None
+            }
+        };
+
+        Ok(Self { _intf: intf_ref, _ddev: ddev, bringup })
+    }
+
+    fn disconnect<'bound>(intf: &'bound usb::Interface<Core<'_>>, data: Pin<&Self>) {
+        let dev: &device::Device<Core<'_>> = intf.as_ref();
+        // Flush the deferred bring-up before the interface is unbound: `cancel_work_sync`
+        // dequeues it if pending and blocks until it returns if already running, so no
+        // USB I/O races the unbind (see the `Interface::as_bound` contract in
+        // `BringUp::run`). Safe to call when the work already finished or never ran.
+        if let Some(work) = data.bringup.as_ref() {
+            // SAFETY: `work.work` is a live `Work` field of a pinned, refcounted `BringUp`;
+            // `raw_get` yields its valid `work_struct`, which `cancel_work_sync` only reads
+            // and synchronises against. The `Arc` keeps the allocation alive across the call.
+            unsafe {
+                let wptr = Work::raw_get(core::ptr::addr_of!(work.work));
+                bindings::cancel_work_sync(wptr);
+            }
+        }
+        dev_info!(dev, "vino: D6000 disconnected\n");
+    }
+}
+
+kernel::module_usb_driver! {
+    type: VinoDriver,
+    name: "vino",
+    authors: ["Mike Lothian"],
+    description: "DisplayLink DL3 (Vino) open driver",
+    license: "GPL v2",
+}
+
+/// Build a minimal valid 128-byte EDID with a 1920x1080@60 detailed timing at base-block
+/// offset `dtd_at` (54 = preferred slot), a correct checksum, and the standard magic.
+#[cfg(CONFIG_KUNIT)]
+fn mk_test_edid(dtd_at: usize) -> [u8; 128] {
+    let mut e = [0u8; 128];
+    e[..8].copy_from_slice(&[0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]);
+    // 1920x1080@60: pclk 14850 (148.5 MHz, 10 kHz units); hblank 280, vblank 45;
+    // hsync_front 88, hsync_width 44, vsync_front 4, vsync_width 5.
+    let dtd: [u8; 18] = [
+        0x02, 0x3a, // pixel clock 0x3a02 LE
+        0x80, 0x18, 0x71, // hactive 1920 / hblank 280 (high nibbles in byte 4)
+        0x38, 0x2d, 0x40, // vactive 1080 / vblank 45 (high nibbles in byte 7)
+        0x58, 0x2c, 0x45, 0x00, // hsync/vsync front+width
+        0, 0, 0, 0, 0, 0, // trailing flags (DTD is 18 bytes total)
+    ];
+    e[dtd_at..dtd_at + 18].copy_from_slice(&dtd);
+    let s = e[..127].iter().fold(0u8, |a, &b| a.wrapping_add(b));
+    e[127] = 0u8.wrapping_sub(s); // base-block checksum: all 128 bytes sum to 0
+    e
+}
+
+/// Offline self-tests for the pure protocol builders/parsers and the crypto bindings the
+/// control plane relies on. Gated behind `CONFIG_KUNIT` (the macro adds the cfg), so they
+/// have zero effect on a production build; run with a KUnit-enabled kernel. The crypto cases
+/// are published known-answer vectors (FIPS-197 AES-128, RFC 4493 AES-CMAC); the seal case is
+/// a live round-trip; the rest pin wire layout and EDID parsing that have no hardware oracle.
+#[kunit_tests(vino_protocol)]
+mod tests {
+    use super::*;
+    use kernel::error::code::EINVAL;
+
+    #[test]
+    fn aes128_ecb_fips197_kat() -> Result {
+        // FIPS-197 / NIST SP800-38A F.1.1 AES-128 ECB known-answer vector.
+        let key = [
+            0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
+            0x4f, 0x3c,
+        ];
+        let pt = [
+            0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
+            0x17, 0x2a,
+        ];
+        assert_eq!(
+            crypto::aes128_ecb(&key, &pt)?,
+            [
+                0x3a, 0xd7, 0x7b, 0xb4, 0x0d, 0x7a, 0x36, 0x60, 0xa8, 0x9e, 0xca, 0xf3, 0x24, 0x66,
+                0xef, 0x97,
+            ]
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn aes_cmac_rfc4493_kat() -> Result {
+        // RFC 4493 sec 4 AES-CMAC test vectors (same key as above).
+        let key = [
+            0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
+            0x4f, 0x3c,
+        ];
+        assert_eq!(
+            crypto::aes_cmac(&key, &[]),
+            [
+                0xbb, 0x1d, 0x69, 0x29, 0xe9, 0x59, 0x37, 0x28, 0x7f, 0xa3, 0x7d, 0x12, 0x9b, 0x75,
+                0x67, 0x46,
+            ]
+        );
+        let msg = [
+            0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
+            0x17, 0x2a,
+        ];
+        assert_eq!(
+            crypto::aes_cmac(&key, &msg),
+            [
+                0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a,
+                0x28, 0x7c,
+            ]
+        );
+        Ok(())
+    }
+
+    #[test]
+    fn seal_livemac_roundtrip() -> Result {
+        // A sealed CP frame must decrypt back to its content under the IN riv, and its
+        // appended tag must equal a fresh Dl3Cmac over the ciphertext (encrypt-then-MAC).
+        let ks = [
+            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
+            0xee, 0xff,
+        ];
+        let riv = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17];
+        let content = [0xa5u8; 32];
+        let mut hdr = [0u8; 16];
+        hdr[12..16].copy_from_slice(&4u32.to_le_bytes()); // wire_seq = 4
+        let frame = cp::seal_livemac(&ks, &riv, &hdr, &content)?;
+        assert_eq!(frame.len(), 16 + 32 + 16);
+        let ct = &frame[16..16 + 32];
+        assert_eq!(&cp::open_in(&ks, &cp::in_riv(&riv), 4, ct)?[..], &content[..]);
+        assert_eq!(&frame[16 + 32..], &cp::dl3cmac_tag(&ks, &riv, 4, ct)?[..]);
+        Ok(())
+    }
+
+    #[test]
+    fn aux_for_id_constants() {
+        // The CP header `aux` field is a per-inner-id constant, not body_len/4.
+        assert_eq!(cp::aux_for_id(0x14, 48), 0x0a);
+        assert_eq!(cp::aux_for_id(0x15, 32), 0x09);
+        assert_eq!(cp::aux_for_id(0x48, 96), 0x06);
+        // Cursor ids recovered from the cold-ref differential (scripts/verify-cp-seal.py):
+        // body_len/4 (here 0x0c) would be wrong for all three.
+        assert_eq!(cp::aux_for_id(0x1a, 48), 0x04); // cursor move
+        assert_eq!(cp::aux_for_id(0x1b, 48), 0x03); // cursor create
+        assert_eq!(cp::aux_for_id(0x1c, 48), 0x02); // cursor image
+        assert_eq!(cp::aux_for_id(0x99, 40), 10); // unknown id falls back to body_len/4
+    }
+
+    #[test]
+    fn edid_timing_parse_and_validate() {
+        // A well-formed EDID yields the DTD timing; a bad checksum is rejected; a leading
+        // monitor descriptor (pclk 0) does not hide the preferred timing in a later slot.
+        let edid = mk_test_edid(54);
+        let t = cp::timing_from_edid(&edid).expect("valid EDID parses");
+        assert_eq!(t.hactive, 1920);
+        assert_eq!(t.vactive, 1080);
+        assert_eq!(t.refresh_hz, 60);
+        assert_eq!(t.pixel_clock_10khz, 14850);
+
+        let mut bad = edid;
+        bad[127] ^= 0xff;
+        // bad checksum must be rejected
+        assert!(cp::timing_from_edid(&bad).is_none());
+
+        let scanned = mk_test_edid(72); // off54 left as a zero (monitor) descriptor
+        assert_eq!(
+            cp::timing_from_edid(&scanned).expect("scans past off54").hactive,
+            1920
+        );
+    }
+
+    #[test]
+    fn edid_reply_guards() -> Result {
+        // The pre-decrypt guards reject non-EDID frames without touching the cipher.
+        let ks = [0u8; 16];
+        let riv = [0u8; 8];
+        assert!(cp::parse_edid_from_reply(&ks, &riv, &[0u8; 10])?.is_none());
+        let mut wrong_sub = [0u8; 20];
+        wrong_sub[8] = 0x44; // wire sub != 0x45
+        assert!(cp::parse_edid_from_reply(&ks, &riv, &wrong_sub)?.is_none());
+        Ok(())
+    }
+
+    #[test]
+    fn rgb565_packing() {
+        assert_eq!(video::rgb565(0xff, 0x00, 0x00), 0xf800);
+        assert_eq!(video::rgb565(0x00, 0xff, 0x00), 0x07e0);
+        assert_eq!(video::rgb565(0x00, 0x00, 0xff), 0x001f);
+        let _ = EINVAL; // silence unused import on configs without the assert paths
+    }
+
+    #[test]
+    fn cursor_messages_structure() -> Result {
+        // The shared 32-byte cursor layout (recovered byte-exact from the cold-ref session by
+        // scripts/verify-cp-seal.py): marker 0x02 @22, head_id @23, two LE u16 fields @24/@26.
+        // Create (head 0): id=0x1b sub=0x42, fields = w,h.
+        let c = cp::cursor_create(7, 0, 64, 64)?;
+        assert_eq!(c.len(), 32);
+        assert_eq!(&c[0..6], &[0x1b, 0x00, 0x42, 0x00, 0x07, 0x00]); // id, sub, counter (LE)
+        assert_eq!(c[22], 0x02); // marker
+        assert_eq!(c[23], 0); // head id
+        assert_eq!(u16::from_le_bytes([c[24], c[25]]), 64); // width
+        assert_eq!(u16::from_le_bytes([c[26], c[27]]), 64); // height
+
+        // Move (head 1): id=0x1a sub=0x43, marker@22, head@23, X@24, Y@26 (LE).
+        let m = cp::cursor_move(9, 1, 0x0140, 0x00f0)?;
+        assert_eq!(m.len(), 32);
+        assert_eq!(&m[0..4], &[0x1a, 0x00, 0x43, 0x00]); // id, sub
+        assert_eq!(m[22], 0x02); // marker
+        assert_eq!(m[23], 1); // head id
+        assert_eq!(u16::from_le_bytes([m[24], m[25]]), 0x0140); // X
+        assert_eq!(u16::from_le_bytes([m[26], m[27]]), 0x00f0); // Y
+
+        // Image: 32-byte header (inner id 0x401c, the 0x40 bitmap flag) + w*h*4 BGRA at off32;
+        // wrong-size input rejected.
+        let bitmap = KVec::from_elem(0xabu8, 64 * 64 * 4, GFP_KERNEL)?;
+        let img = cp::cursor_image(3, 0, 64, 64, &bitmap)?;
+        assert_eq!(img.len(), 32 + 64 * 64 * 4);
+        assert_eq!(&img[0..4], &[0x1c, 0x40, 0x41, 0x00]); // inner id 0x401c, sub 0x41
+        assert_eq!(img[22], 0x02); // marker
+        assert_eq!(img[32], 0xab); // bitmap begins at off32
+        assert!(cp::cursor_image(3, 0, 64, 64, &[0u8; 16]).is_err()); // wrong bitmap length
+        Ok(())
+    }
+
+    #[test]
+    fn timing_from_drm_mode_1080p60() {
+        // CEA 1920x1080@60: clock 148.5 MHz, h 2008/2052/2200, v 1084/1089/1125.
+        let mut m = bindings::drm_display_mode::default();
+        m.clock = 148_500; // kHz
+        m.hdisplay = 1920;
+        m.hsync_start = 2008;
+        m.hsync_end = 2052;
+        m.htotal = 2200;
+        m.vdisplay = 1080;
+        m.vsync_start = 1084;
+        m.vsync_end = 1089;
+        m.vtotal = 1125;
+        // SAFETY: `m` is a fully-initialised local drm_display_mode.
+        let t = unsafe { cp::timing_from_drm_mode(&m) };
+        assert_eq!(t.hactive, 1920);
+        assert_eq!(t.hblank, 280); // htotal - hdisplay
+        assert_eq!(t.hsync_front, 88); // hsync_start - hdisplay
+        assert_eq!(t.hsync_width, 44); // hsync_end - hsync_start
+        assert_eq!(t.vactive, 1080);
+        assert_eq!(t.vblank, 45); // vtotal - vdisplay
+        assert_eq!(t.vsync_front, 4);
+        assert_eq!(t.vsync_width, 5);
+        assert_eq!(t.pixel_clock_10khz, 14_850); // clock(kHz) / 10
+        assert_eq!(t.refresh_hz, 60); // via drm_mode_vrefresh
+    }
+
+    #[test]
+    fn rotation_pixel_mapping() {
+        use bindings::{
+            DRM_MODE_REFLECT_X, DRM_MODE_ROTATE_0, DRM_MODE_ROTATE_180, DRM_MODE_ROTATE_270,
+            DRM_MODE_ROTATE_90,
+        };
+        // Source 2x3 (sw=2, sh=3). 0deg is identity; 180deg mirrors both axes.
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0, 0, 0, 2, 3), (0, 0));
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0, 1, 2, 2, 3), (1, 2));
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_180, 0, 0, 2, 3), (1, 2));
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_180, 1, 2, 2, 3), (0, 0));
+        // 90deg: output dims are (sh,sw)=(3,2); (dx,dy) -> (dy, sh-1-dx).
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_90, 0, 0, 2, 3), (0, 2));
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_90, 2, 1, 2, 3), (1, 0));
+        // 270deg: (dx,dy) -> (sw-1-dy, dx).
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_270, 0, 0, 2, 3), (1, 0));
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_270, 2, 1, 2, 3), (0, 2));
+        // Reflect-X composes on top of the rotation (here identity): sx -> sw-1-sx.
+        assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0 | DRM_MODE_REFLECT_X, 0, 0, 2, 3), (1, 0));
+    }
+
+    #[test]
+    fn wht_colour_and_quantize() {
+        use video::wht;
+        // Colour transform vs DLM's transform-DC ground truth (validate-transform-encoderio):
+        // white -> Y=16320, achromatic -> Cb=Cr=0, and the reversible luma Y = 64G +
+        // 64*((Cb+Cr)>>2) reproduces every measured colour. Red's Y is 4032 (= 64*((255)>>2)
+        // = 64*63), NOT the old un-floored 16*255 = 4080 (which ran 48 high; see `colour`).
+        assert_eq!(wht::colour(255, 255, 255), (16320, 0, 0));
+        assert_eq!(wht::colour(128, 128, 128), (128 * 64, 0, 0)); // gray: chroma zero
+        assert_eq!(wht::colour(255, 0, 0), (4032, 64 * 255, 0)); // red: Y floored, Cb>0, Cr=0
+        assert_eq!(wht::colour(0, 255, 0), (8128, -64 * 255, -64 * 255)); // green (signed chroma)
+        assert_eq!(wht::colour(0, 0, 255), (4032, 0, 64 * 255)); // blue
+        // The documented ground-truth vector: white Y_DC=16320 quantizes (DC, position 0) to 1020.
+        assert_eq!(wht::quantize(16320, 0), 1020);
+        // AC clamps to the 12-bit signed long-token range.
+        assert_eq!(wht::quantize(1_000_000, 16), 2047);
+        assert_eq!(wht::quantize(-1_000_000, 16), -2048);
+    }
+
+    #[test]
+    fn wht_transform_uniform() {
+        use video::wht;
+        // A uniform block: DC = the per-pixel value, every AC coefficient = 0 (VIDEO.md invariant).
+        let block = [16320i32; wht::BLOCK];
+        let c = wht::transform(&block);
+        assert_eq!(c[0], 16320); // DC = mean = the uniform value
+        assert!(c[1..].iter().all(|&x| x == 0)); // AC all zero
+        // End-to-end: white pixel -> Y plane -> WHT DC -> quantize -> 1020.
+        let (y, _, _) = wht::colour(255, 255, 255);
+        assert_eq!(wht::quantize(wht::transform(&[y; wht::BLOCK])[0], 0), 1020);
+    }
+
+    #[test]
+    fn wht_transform_haar_vectors() {
+        // The 8x8 2-D Haar (Mallat) wavelet, byte-exact-verified against DLM (2026-06-23):
+        // `scripts/wht-transform.py` reproduces these on 320/320 real gradient blocks. Each vector
+        // here is computed by that reference; `Y = 64*gray`. (See the breakthrough writeup.)
+        use video::wht::{transform, DIM, PIXELS};
+        // Build an 8x8 Y block by evaluating a gray-per-(row,col) selector.
+        fn build(gray: impl Fn(usize, usize) -> i32) -> [i32; PIXELS] {
+            let mut b = [0i32; PIXELS];
+            for r in 0..DIM {
+                for c in 0..DIM {
+                    b[r * DIM + c] = 64 * gray(r, c);
+                }
+            }
+            b
+        }
+        // vstripe2 (period-2 vertical, full contrast 0/255) -> level-2 HL band c[4..8] = -2040.
+        let c = transform(&build(|_, c| if (c / 2) & 1 != 0 { 255 } else { 0 }));
+        assert_eq!(&c[4..8], &[-2040, -2040, -2040, -2040]);
+        assert!(c[1..4].iter().all(|&x| x == 0) && c[8..].iter().all(|&x| x == 0));
+        // vstripe4 (period-4 vertical) -> coarse HL c[1] = -8160 (4x the fine band, energy-conserving).
+        let c = transform(&build(|_, c| if (c / 4) & 1 != 0 { 255 } else { 0 }));
+        assert_eq!(c[1], -8160);
+        // hstripe2 (period-2 horizontal) -> level-2 LH band c[8..12] = -2040 (H/V swap of vstripe2).
+        let c = transform(&build(|r, _| if (r / 2) & 1 != 0 { 255 } else { 0 }));
+        assert_eq!(&c[8..12], &[-2040, -2040, -2040, -2040]);
+        // A per-column gradient (gray = 36*col) exercises the DC, coarse-HL and finest band at once.
+        let c = transform(&build(|_, col| 36 * col as i32));
+        assert_eq!(c[0], 8064); // DC = mean(36*0..36*7)*64/64 = 8064
+        assert_eq!(&c[4..8], &[-576, -576, -576, -576]);
+        assert!(c[16..].iter().all(|&x| x == -72)); // finest HL band, uniform per-column ramp
+    }
+
+    #[test]
+    fn wht_vlc_codebook_byte_exact() -> Result {
+        // ★ The recovered LSB-first entropy VLC (dumped from DLM 0x5e68b0), verified byte-exact
+        // against DLM's own captured output (scripts/wht-block-codec.py). Symbol 7 is the AC code
+        // 0b1110000 (LSB-first); four of them pack to the wire's per-block AC unit bytes, and the
+        // final byte is padded with 1-bits (a truncated all-ones code), exactly as the dock emits.
+        use video::wht::Vlc;
+        let mut w = Vlc::new();
+        for _ in 0..4 {
+            w.symbol(7)?;
+        }
+        assert_eq!(&w.finish()?[..], &[0x87, 0xc3, 0xe1, 0xf0]);
+        // The full per-block AC unit `0 0 0 7 7 7 7` (idx1-3 zero, idx4-7 AC) -- matches the live
+        // wire bytes `38 1c 0e ...` captured for vstripe2.
+        let mut w = Vlc::new();
+        for s in [0usize, 0, 0, 7, 7, 7, 7] {
+            w.symbol(s)?;
+        }
+        assert_eq!(&w.finish()?[..4], &[0x38, 0x1c, 0x0e, 0x87]);
+        // Symbol 0 (the 1-bit `0` code) alone -> one byte padded with seven 1-bits.
+        let mut w = Vlc::new();
+        w.symbol(0)?;
+        assert_eq!(&w.finish()?[..], &[0xfe]);
+        Ok(())
+    }
+
+    #[test]
+    fn wht_coeff_magnitude_code() -> Result {
+        // The AC magnitude-code emitter, verified byte-exact vs DLM's per-coefficient wire bits
+        // (q-4/q-8/q-16; scripts/wht-strip-encoder.py reproduces the full q-4 vstripe2 strip).
+        use video::wht::Vlc;
+        // Four q-4 coefficients (category 3, zero offset) == four sym7 -- the per-block AC unit.
+        let mut w = Vlc::new();
+        for _ in 0..4 {
+            w.coeff(-4)?;
+        }
+        assert_eq!(&w.finish()?[..], &[0x87, 0xc3, 0xe1, 0xf0]);
+        // A zero coefficient is the 1-bit symbol 0 -> one byte padded with seven 1-bits.
+        let mut w = Vlc::new();
+        w.coeff(0)?;
+        assert_eq!(&w.finish()?[..], &[0xfe]);
+        // Within-category offset (q-6 = category 3, offset 2) and sign polarity (negative vs +).
+        let mut w = Vlc::new();
+        w.coeff(-6)?;
+        assert_eq!(&w.finish()?[..], &[0x97]);
+        let mut w = Vlc::new();
+        w.coeff(6)?;
+        assert_eq!(&w.finish()?[..], &[0xd7]); // same magnitude, sign bit flipped
+        // Category 5 with offset (q-16) spans two bytes.
+        let mut w = Vlc::new();
+        w.coeff(-16)?;
+        assert_eq!(&w.finish()?[..], &[0x1f, 0xf8]);
+        // Category >= 9 (|q| >= 256) is the unrecovered escape long form -> rejected, not mis-coded.
+        let mut w = Vlc::new();
+        assert!(w.coeff(-256).is_err());
+        Ok(())
+    }
+
+    #[test]
+    fn wht_solid_strip_byte_exact() {
+        // The general solid-colour 64x16 strip encoder, byte-exact vs DLM's wire (14/14 strips of
+        // the codec-grammar-20260623 capture: the full grey sweep c=8/9/10, white, and the 6 solid
+        // primaries/secondaries; scripts/wht-strip-encoder.py). The three per-plane DCs (Cr, Cb, Y)
+        // are escape-coded from bit 368; the strip is zero-padded to its even length with
+        // w18=w1c=tail=L-2. grey128 (Y=8192) and white (Y=16320) differ only in the Y escape bits.
+        use video::wht::solid_strip;
+        let grey128 = [
+            0x01, 0x28, 0, 0, 0, 0, 0, 0, 0, 0, 0x3a, 0, 0x3a, 0, 0, 0, 0xfc, 0x00, 0x7e, 0x00,
+            0x3f, 0x80, 0x1f, 0xc0, 0x0f, 0xe0, 0x07, 0xf0, 0x03, 0xf8, 0x01, 0xfc, 0x00, 0x7e,
+            0x00, 0x3f, 0x80, 0x1f, 0xc0, 0x0f, 0xe0, 0x07, 0xf0, 0x03, 0xf8, 0x01, 0xfc, 0x0f,
+            0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x3a, 0,
+        ];
+        assert_eq!(&*solid_strip(0, 0, 8192, 0, 0).unwrap(), &grey128);
+        // White (Y=16320) -- identical but the Y escape bytes [47:49] are 0xff,0x27 not 0x0f,0x20.
+        let mut white = grey128;
+        white[47] = 0xff;
+        white[48] = 0x27;
+        assert_eq!(&*solid_strip(0, 0, 16320, 0, 0).unwrap(), &white);
+        // X/Y are patched into the header (little-endian) without disturbing the body.
+        let s = solid_strip(0x40, 0x10, 8192, 0, 0).unwrap();
+        assert_eq!(&s[2..6], &[0x40, 0x00, 0x10, 0x00]);
+        assert_eq!(&s[16..47], &grey128[16..47]); // main frame + Y escape unchanged by position
+        // Chroma: red (Y=4032, Cb=64*(255-0)=16320, Cr=0) -- 60-byte strip, plane order (Cr,Cb,Y).
+        let red = [
+            0x01, 0x28, 0, 0, 0, 0, 0, 0, 0, 0, 0x3a, 0, 0x3a, 0, 0, 0, 0xfc, 0x00, 0x7e, 0x00,
+            0x3f, 0x80, 0x1f, 0xc0, 0x0f, 0xe0, 0x07, 0xf0, 0x03, 0xf8, 0x01, 0xfc, 0x00, 0x7e,
+            0x00, 0x3f, 0x80, 0x1f, 0xc0, 0x0f, 0xe0, 0x07, 0xf0, 0x03, 0xf8, 0x01, 0xef, 0xfd,
+            0xff, 0xfb, 0x04, 0, 0, 0, 0, 0, 0, 0x3a, 0,
+        ];
+        assert_eq!(&*solid_strip(0, 0, 4032, 16320, 0).unwrap(), &red);
+        // green (Y=8128, Cb=Cr=-16320) is the longest -- a 64-byte strip (w18=w1c=tail=0x3e=62).
+        let g = solid_strip(0, 0, 8128, -16320, -16320).unwrap();
+        assert_eq!(g.len(), 64);
+        assert_eq!(&g[10..14], &[0x3e, 0, 0x3e, 0]);
+        assert_eq!(&g[62..64], &[0x3e, 0]);
+    }
+
+    #[test]
+    fn wht_ac_strip_byte_exact() {
+        // The uniform AC-strip encoder, byte-exact vs DLM (sig-library-20260623, 25/25 strips). The
+        // significance coder is a LAST-significant-position code (00111110 ++ 5-bit(32-last)); the AC
+        // stream run/magnitude-codes coeffs 1..last with the DLM-matched quantizer (pos3 deadzone).
+        use video::wht::ac_strip;
+        // s_L3HL: c3 = [128, -64, 0..]; last=1 (qAC[1]=-4). 70-byte strip.
+        let mut c = [0i32; 32];
+        c[0] = 128;
+        c[1] = -64;
+        let l3hl = ac_strip(&c, 0x40, 0x10).unwrap();
+        let want = [
+            0x01, 0x28, 0x40, 0x00, 0x10, 0x00, 0, 0, 0, 0, 0x34, 0x00, 0x3c, 0x00, 0, 0, 0x7c, 0x9f,
+            0xef, 0xf3, 0x7d, 0xbe, 0xcf, 0xf7, 0xf9, 0x3e, 0xdf, 0xe7, 0xfb, 0x7c, 0x9f, 0xef, 0xf3,
+            0x7d, 0xbe, 0xcf, 0xf7, 0xf9, 0x3e, 0xdf, 0xe7, 0xfb, 0x3c, 0x04, 0, 0, 0, 0, 0, 0, 0, 0,
+            0x87, 0xc3, 0xe1, 0x70, 0x38, 0x1c, 0x0e, 0x00, 0x87, 0xc3, 0xe1, 0x70, 0x38, 0x1c, 0x0e,
+            0x00, 0x44, 0x00,
+        ];
+        assert_eq!(&*l3hl, &want);
+        // s_L2HL: c3 = [128, 0,0,0, -16,-16,-16,-16, 0..]; last=7 (deadzone pos3 untouched -> last
+        // stays in the L2-HL band). 118-byte strip; spot-check length + w18/w1c framing.
+        let mut c2 = [0i32; 32];
+        c2[0] = 128;
+        for i in 4..8 {
+            c2[i] = -16;
+        }
+        let l2hl = ac_strip(&c2, 0x40, 0x10).unwrap();
+        assert_eq!(l2hl.len(), 118);
+        assert_eq!(&l2hl[10..14], &[0x34, 0x00, 0x54, 0x00]); // w18=52, w1c=84
+        assert_eq!(&l2hl[116..118], &[0x74, 0x00]); // tail = L-2 = 116
+    }
+
+    #[test]
+    fn wht_general_strip_and_frame_forward_hint() -> Result {
+        // The general 16-block strip encoder subsumes the two verified primitives, and the frame
+        // composer writes the forward length-hint tail (tail[k] = L[k+1] - 2).
+        use video::wht::{ac_strip, encode_frame, solid_strip, strip};
+
+        // (a) 16 identical flat grey128 blocks (Y_DC = 64*128 = 8192) -> the SOLID layout, byte-exact
+        // equal to solid_strip (which is itself verified 14/14 vs DLM).
+        let mut flat = [0i32; 32];
+        flat[0] = 8192;
+        let from_general = strip(&[flat; 16], 0x40, 0x10)?;
+        let from_solid = solid_strip(0x40, 0x10, 8192, 0, 0)?;
+        assert_eq!(&*from_general, &*from_solid);
+
+        // (b) 16 identical s_L3HL blocks -> the AC layout, byte-exact equal to ac_strip (verified
+        // 25/25 vs DLM). c3 = [128, -64, 0..]; last = 1.
+        let mut acblk = [0i32; 32];
+        acblk[0] = 128;
+        acblk[1] = -64;
+        assert_eq!(&*strip(&[acblk; 16], 0x40, 0x10)?, &*ac_strip(&acblk, 0x40, 0x10)?);
+
+        // (c) Forward length hint across two differently-sized strips in one 128x16 row: the left
+        // 64-px column is flat grey (small SOLID strip), the right column carries a per-block vertical
+        // edge (larger AC strip). The composer must set strip0's tail to strip1's L-2, and strip1
+        // (the last strip) keeps its own L-2.
+        let mut luma: KVec<u8> = KVec::new();
+        for _y in 0..16 {
+            for x in 0..128usize {
+                let v: u8 = if x < 64 {
+                    128
+                } else if x % 8 < 4 {
+                    120
+                } else {
+                    136
+                };
+                luma.push(v, GFP_KERNEL)?;
+            }
+        }
+        let out = encode_frame(&luma, 128, 16)?;
+        assert_eq!(&out[0..2], &[0x01, 0x28]); // strip0 magic
+        let l0 = (2..out.len() - 1)
+            .find(|&i| out[i] == 0x01 && out[i + 1] == 0x28)
+            .unwrap(); // strip1 start == strip0 length
+        let l1 = out.len() - l0;
+        assert_ne!(l0, l1); // the two strips genuinely differ in size (non-trivial hint)
+        // strip0's tail is the forward hint = strip1's L - 2.
+        assert_eq!(&out[l0 - 2..l0], &((l1 - 2) as u16).to_le_bytes());
+        // strip1 is last -> tail = its own L - 2.
+        assert_eq!(&out[out.len() - 2..], &((l1 - 2) as u16).to_le_bytes());
+        Ok(())
+    }
+
+    #[test]
+    fn wht_magnitude_category() {
+        // Magnitude category = bit_length(|coeff|), verified across the 2026-06-23 value sweep
+        // (q-4 -> cat 3 -> sym7, q-8 -> 4, ... q-128 -> 8).
+        use video::wht::mag_category;
+        assert_eq!(mag_category(0), 0);
+        assert_eq!(mag_category(1), 1);
+        assert_eq!(mag_category(-4), 3);
+        assert_eq!(mag_category(7), 3);
+        assert_eq!(mag_category(-8), 4);
+        assert_eq!(mag_category(16), 5);
+        assert_eq!(mag_category(-128), 8);
+        assert_eq!(mag_category(255), 8);
+    }
+
+    #[test]
+    fn ddc_ci_set_vcp_checksum() {
+        // VESA DDC/CI 1.1 sec 4.4 worked example: Set brightness (VCP 0x10) to 50 (0x0032).
+        // Bytes after the 0x6e write address: 51 84 03 10 00 32, checksum = XOR incl. 0x6e.
+        let p = cp::ddc_ci_set_vcp(cp::VCP_BRIGHTNESS, 50);
+        assert_eq!(&p[..6], &[0x51, 0x84, 0x03, 0x10, 0x00, 0x32]);
+        let want = 0x6e ^ 0x51 ^ 0x84 ^ 0x03 ^ 0x10 ^ 0x00 ^ 0x32;
+        assert_eq!(p[6], want);
+        // The checksum makes the XOR of {dest, source, len, opcode, vcp, hi, lo, chk} zero.
+        assert_eq!(0x6eu8 ^ p.iter().fold(0u8, |a, &b| a ^ b), 0);
+        // Contrast (0x12) and the power VCP (0xd6 = off) carry their codes/values verbatim.
+        assert_eq!(cp::ddc_ci_set_vcp(cp::VCP_CONTRAST, 0x0140)[3..6], [0x12, 0x01, 0x40]);
+        assert_eq!(cp::ddc_ci_set_vcp(cp::VCP_POWER_MODE, cp::POWER_OFF)[3..6], [0xd6, 0x00, 0x04]);
+    }
+
+    #[test]
+    fn ddc_set_vcp_message_structure() -> Result {
+        // CP wrapper: id=0x15 sub=0x22, counter (LE) at off4, I2C slave 0x37 + len 7 at off20,
+        // the 7-byte DDC/CI Set-VCP payload at off22, padded to a 32-byte block.
+        let m = cp::ddc_set_vcp(0x11, cp::VCP_BRIGHTNESS, 75)?;
+        assert_eq!(m.len(), 32);
+        assert_eq!(&m[0..6], &[0x15, 0x00, 0x22, 0x00, 0x11, 0x00]); // id, sub, counter (LE)
+        assert_eq!(&m[20..22], &[0x37, 7]); // monitor DDC/CI I2C slave + payload length
+        assert_eq!(&m[22..29], &cp::ddc_ci_set_vcp(cp::VCP_BRIGHTNESS, 75)); // DDC/CI payload
+        assert_eq!(&m[29..32], &[0, 0, 0]); // block padding
+        Ok(())
+    }
+}
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH v2 07/10] drm/vino: wire the hardware cursor plane
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
                     ` (5 preceding siblings ...)
  2026-07-03  3:02   ` [RFC PATCH v2 06/10] drm/vino: add the DisplayLink DL3 dock driver Mike Lothian
@ 2026-07-03  3:02   ` Mike Lothian
  2026-07-03  3:11     ` sashiko-bot
  2026-07-03  3:02   ` [RFC PATCH v2 08/10] drm/vino: wire CRTC gamma, plane rotation and DDC/CI monitor controls Mike Lothian
                     ` (2 subsequent siblings)
  9 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-07-03  3:02 UTC (permalink / raw)
  To: rust-for-linux
  Cc: dri-devel, David Airlie, Simona Vetter, Thomas Zimmermann,
	Maarten Lankhorst, Maxime Ripard, Danilo Krummrich, Lyude Paul,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	linux-kernel, Mike Lothian

The pre-safe-KMS vino had a hardware cursor (a DRM_PLANE_TYPE_CURSOR plane whose
updates were tunnelled to the dock as the cp::cursor_{create,image,move} CP
messages); the v2 safe-KMS refactor dropped it because the safe KMS layer didn't
expose plane position/hotspot accessors. Those exist now, so re-wire it.

VinoPlane gains an `is_cursor` role (the safe KMS layer allows one plane type per
driver, so one type serves both the primary and cursor planes). probe() creates a
Type::Cursor plane (ARGB8888, max_cursor 64x64) alongside the primary and attaches
it to the CRTC. The cursor plane's atomic_update forwards to the dock over CP:
cursor_move (position, from the new plane crtc_x()/crtc_y()) on every update, and
cursor_create + cursor_image (the w*h*4 BGRA bitmap, read from the framebuffer via
the new Framebuffer::width()/pitch()/vmap()) only when the bitmap changes. Like
scanout it is gated behind CP engagement, so it is inert until the dock's
content-protection channel comes up.

The cp::cursor_* builders and their KUnit tests are unchanged.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
 drivers/gpu/drm/vino/drm_sink.rs | 98 +++++++++++++++++++++++++++++---
 1 file changed, 89 insertions(+), 9 deletions(-)

diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
index 450c7c84a067..fde52336fcd1 100644
--- a/drivers/gpu/drm/vino/drm_sink.rs
+++ b/drivers/gpu/drm/vino/drm_sink.rs
@@ -18,9 +18,10 @@
 //!   wired here). `VinoPlane`/`VinoCrtc` hold their state inline rather than behind a
 //!   pointer-identity lookup table, so adding a head is a second `probe()` call away,
 //!   not a redesign.
-//! - A cursor plane, CRTC gamma LUT, and plane rotation property -- `kernel::drm::kms`
-//!   doesn't yet expose `drm_plane_create_rotation_property` or a second (cursor)
-//!   plane argument shape beyond what `UnregisteredCrtc::new` already threads through.
+//! - CRTC gamma LUT and plane rotation property -- `kernel::drm::kms` doesn't yet expose
+//!   `drm_gamma_lut`/`drm_plane_create_rotation_property`. (The cursor plane *is* wired now:
+//!   `VinoPlane` serves both the primary and a `Type::Cursor` plane, and `atomic_update` forwards
+//!   the cursor bitmap/position to the dock via `cp::cursor_{create,image,move}`.)
 //! - DDC/CI brightness/contrast as connector properties, and the `.detect`/`mode_valid`
 //!   connector hooks (report disconnected until a real EDID arrives; reject
 //!   over-budget modes) -- `DriverConnector` only exposes `get_modes` right now.
@@ -57,6 +58,11 @@
 /// Primary-plane format list (opaque 32bpp scanout).
 static PRIMARY_FORMATS: [u32; 1] = [DRM_FORMAT_XRGB8888];
 
+/// `DRM_FORMAT_ARGB8888` (`fourcc_code('A','R','2','4')`); the dock's cursor bitmap carries alpha.
+const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241;
+/// Cursor-plane format list.
+static CURSOR_FORMATS: [u32; 1] = [DRM_FORMAT_ARGB8888];
+
 /// Per-mode pixel-clock ceiling (kHz) -- about 4K@60 (CEA 594 MHz). With only one head wired
 /// there is no combined-heads budget to enforce (see the module doc); a real ceiling still
 /// needs the connector `mode_valid` hook this port doesn't expose yet, so this constant is
@@ -275,7 +281,7 @@ fn mode_config_info(_dev: &drm::Device<Self, drm::Uninit>) -> Result<ModeConfigI
         Ok(ModeConfigInfo {
             min_resolution: (0, 0),
             max_resolution: (4096, 4096),
-            max_cursor: (0, 0),
+            max_cursor: (64, 64),
             preferred_depth: 32,
             preferred_fourcc: Some(DRM_FORMAT_XRGB8888),
         })
@@ -294,12 +300,21 @@ fn probe(dev: &NewKmsDevice<'_, Self, Probing>) -> Result {
             None,
             plane::Type::Primary,
             None,
-            (),
+            false,
+        )?;
+        let cursor = plane::UnregisteredPlane::<VinoPlane>::new(
+            dev,
+            1,
+            &CURSOR_FORMATS,
+            None,
+            plane::Type::Cursor,
+            None,
+            true,
         )?;
         let crtc_obj = crtc::UnregisteredCrtc::<VinoCrtc>::new(
             dev,
             primary,
-            None::<&plane::UnregisteredPlane<VinoPlane>>,
+            Some(&cursor),
             None,
             (),
         )?;
@@ -381,12 +396,20 @@ fn atomic_disable(commit: CrtcAtomicCommit<'_, Self>) {
     }
 }
 
-// ---- Primary plane / scanout -----------------------------------------------
+// ---- Planes: primary (scanout) + cursor -------------------------------------
+//
+// The safe KMS layer allows one `DriverPlane` type per driver, so `VinoPlane` serves both the
+// primary and cursor planes, told apart by `is_cursor` (from the plane's `Args`).
 
 #[pin_data]
 pub(super) struct VinoPlane {
     #[pin]
     scanout: Mutex<ScanoutState>,
+    /// Whether this is the cursor plane (vs. the primary scanout plane).
+    is_cursor: bool,
+    /// The framebuffer last uploaded to the dock as the cursor bitmap (raw address, `0` = none),
+    /// so a bare cursor move only re-sends the position, not the whole image. Cursor plane only.
+    cursor_last: core::sync::atomic::AtomicUsize,
 }
 
 #[derive(Clone, Default)]
@@ -398,11 +421,11 @@ impl plane::DriverPlaneState for VinoPlaneState {
 
 #[vtable]
 impl plane::DriverPlane for VinoPlane {
-    type Args = ();
+    type Args = bool;
     type Driver = VinoDrmDriver;
     type State = VinoPlaneState;
 
-    fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, _args: ()) -> impl PinInit<Self, Error> {
+    fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, is_cursor: bool) -> impl PinInit<Self, Error> {
         try_pin_init!(VinoPlane {
             scanout <- new_mutex!(ScanoutState {
                 enc: None,
@@ -411,6 +434,8 @@ fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, _args: ()) -> impl PinI
                 dims: (0, 0),
                 hint: 0,
             }),
+            is_cursor,
+            cursor_last: core::sync::atomic::AtomicUsize::new(0),
         })
     }
 
@@ -429,6 +454,36 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
         let plane = commit.plane();
         let data: &VinoDrmData = plane.drm_dev();
         let new = commit.take_new_state();
+
+        // Cursor plane: forward the cursor bitmap/position to the dock over CP (id=0x1b create,
+        // 0x401c image, 0x1a move -- see `cp::cursor_*`). A no-op until CP engages, like scanout.
+        if plane.is_cursor {
+            use core::sync::atomic::Ordering::Relaxed;
+            const HEAD: u8 = 0;
+            match new.framebuffer::<VinoDrmDriver>() {
+                Some(fb) => {
+                    let w = fb.width() as u16;
+                    let h = fb.height() as u16;
+                    // Re-upload the bitmap only when the framebuffer changes; always update the
+                    // position (a bare cursor move must not re-send the whole image).
+                    let key = fb as *const _ as usize;
+                    if plane.cursor_last.swap(key, Relaxed) != key {
+                        let _ = data.send_cp(0x1b, 0, |ctr| super::cp::cursor_create(ctr, HEAD, w, h));
+                        if let Ok(bgra) = read_cursor_bgra(fb, w as usize, h as usize) {
+                            let _ = data
+                                .send_cp(0x401c, 0, |ctr| super::cp::cursor_image(ctr, HEAD, w, h, &bgra));
+                        }
+                    }
+                    let x = new.crtc_x().max(0) as u16;
+                    let y = new.crtc_y().max(0) as u16;
+                    let _ = data.send_cp(0x1a, 0, |ctr| super::cp::cursor_move(ctr, HEAD, x, y));
+                }
+                // Cursor disabled: forget the last bitmap so it re-uploads if it comes back.
+                None => plane.cursor_last.store(0, Relaxed),
+            }
+            return;
+        }
+
         let Some(fb) = new.framebuffer::<VinoDrmDriver>() else { return };
         // The plane's destination geometry mirrors the negotiated mode (the compositor sizes the
         // primary plane 1:1 with the virtual output), so this drives the dynamic scanout
@@ -480,6 +535,31 @@ fn prime_video_eps(dev: &super::usb::Interface<kernel::device::Bound>) {
     }
 }
 
+/// Map the cursor framebuffer and convert it to the `w*h*4` BGRA bitmap the dock expects
+/// (`cp::cursor_image`). The source is XRGB/ARGB8888; each pixel is written out as B, G, R, A.
+fn read_cursor_bgra(
+    fb: &kms::framebuffer::Framebuffer<VinoDrmDriver>,
+    w: usize,
+    h: usize,
+) -> Result<KVec<u8>> {
+    let vmap = fb.vmap()?;
+    let src = vmap.as_ptr();
+    let pitch = fb.pitch(0) as usize;
+    let mut out = KVec::with_capacity(w * h * 4, GFP_KERNEL)?;
+    for dy in 0..h {
+        for dx in 0..w {
+            // SAFETY: `dy*pitch + dx*4 + 3` is within the mapped cursor framebuffer (`pitch*h`
+            // bytes); `dx < w <= pitch/4`, `dy < h`.
+            let px = unsafe { (src.add(dy * pitch + dx * 4) as *const u32).read_unaligned() };
+            out.push((px & 0xff) as u8, GFP_KERNEL)?; // B
+            out.push(((px >> 8) & 0xff) as u8, GFP_KERNEL)?; // G
+            out.push(((px >> 16) & 0xff) as u8, GFP_KERNEL)?; // R
+            out.push(((px >> 24) & 0xff) as u8, GFP_KERNEL)?; // A
+        }
+    }
+    Ok(out)
+}
+
 /// vmap `fb`, encode it, and push one EP08 frame. Split out so `?` can be used.
 fn scanout_one(
     data: &VinoDrmData,
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH v2 08/10] drm/vino: wire CRTC gamma, plane rotation and DDC/CI monitor controls
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
                     ` (6 preceding siblings ...)
  2026-07-03  3:02   ` [RFC PATCH v2 07/10] drm/vino: wire the hardware cursor plane Mike Lothian
@ 2026-07-03  3:02   ` Mike Lothian
  2026-07-03  3:20     ` sashiko-bot
  2026-07-03  3:02   ` [RFC PATCH v2 09/10] drm/vino: two heads, 90/270 rotation, damage clips and connector probe Mike Lothian
  2026-07-03  3:02   ` [RFC PATCH v2 10/10] drm/vino: hrtimer-driven software vblank Mike Lothian
  9 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-07-03  3:02 UTC (permalink / raw)
  To: rust-for-linux
  Cc: dri-devel, David Airlie, Simona Vetter, Thomas Zimmermann,
	Maarten Lankhorst, Maxime Ripard, Danilo Krummrich, Lyude Paul,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	linux-kernel, Mike Lothian

Consume the new gamma/rotation/i2c bindings to finish the KMS feature
set the pre-safe-KMS vino driver had:

  - Gamma: enable a 256-entry GAMMA_LUT on the CRTC; cache the ramp from
    the CRTC atomic hooks (set on enable, cleared on disable) and apply
    it in the scanout RGB conversion so a compositor's colour calibration
    reaches the panel.

  - Rotation: create a rotation property on the primary/cursor plane
    (0/180 plus the X/Y reflections the software scanout can do without a
    resample) and honour the plane state's rotation when reading source
    pixels.

  - DDC/CI: register a virtual i2c adapter on the connector whose
    master_xfer tunnels DDC/CI Set-VCP writes (brightness/contrast/DPMS
    power) to the downstream monitor over the dock's monitor-I2C bridge
    (cp::ddc_forward), matching the macOS/Windows agents' per-display
    controls.

All of this stays inert until the dock engages its content-protection
channel (see docs/BLOCKER.md), like the rest of the scanout path.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
 drivers/gpu/drm/vino/cp.rs       |  22 ++--
 drivers/gpu/drm/vino/drm_sink.rs | 171 ++++++++++++++++++++++++++-----
 drivers/gpu/drm/vino/vino.rs     |  23 ++++-
 3 files changed, 181 insertions(+), 35 deletions(-)

diff --git a/drivers/gpu/drm/vino/cp.rs b/drivers/gpu/drm/vino/cp.rs
index 4d55b96999b0..b84b25ff6e5c 100644
--- a/drivers/gpu/drm/vino/cp.rs
+++ b/drivers/gpu/drm/vino/cp.rs
@@ -151,14 +151,24 @@ pub(super) fn set_mode(counter: u16, t: &Timing) -> Result<KVec<u8>> {
 /// the CP wall), so re-check against a capture once CP engages. The DDC/CI bytes themselves
 /// ([`ddc_ci_set_vcp`]) are standard and verified.
 pub(super) fn ddc_set_vcp(counter: u16, vcp: u8, value: u16) -> Result<KVec<u8>> {
-    let payload = ddc_ci_set_vcp(vcp, value);
-    let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
+    ddc_forward(counter, DDCCI_I2C_ADDR, &ddc_ci_set_vcp(vcp, value))
+}
+
+/// The DDC/CI I2C slave address on the monitor bus.
+pub(super) const DDCCI_I2C_ADDR: u8 = 0x37;
+
+/// CP message tunnelling a raw DDC/CI transaction (`payload`, the bytes a host writes after the
+/// I2C write-address) to the downstream monitor's I2C slave `addr`. Same `id=0x15 sub=0x22`
+/// framing as [`ddc_set_vcp`], with the slave + payload length at off20 and the payload at off22.
+/// This is what the vino I2C adapter forwards for a userspace DDC/CI write (e.g. `ddcutil`).
+pub(super) fn ddc_forward(counter: u16, addr: u8, payload: &[u8]) -> Result<KVec<u8>> {
+    let mut b = KVec::with_capacity(32 + payload.len(), GFP_KERNEL)?;
     header(&mut b, 0x15, 0x22, counter)?;
     pad_to(&mut b, 20)?;
-    // off20: monitor DDC/CI I2C slave (0x37) + DDC/CI payload length.
-    b.extend_from_slice(&[0x37, payload.len() as u8], GFP_KERNEL)?;
-    // off22: the DDC/CI Set-VCP bytes (same off22 convention as the EDID payload).
-    b.extend_from_slice(&payload, GFP_KERNEL)?;
+    // off20: monitor DDC/CI I2C slave + DDC/CI payload length.
+    b.extend_from_slice(&[addr, payload.len() as u8], GFP_KERNEL)?;
+    // off22: the DDC/CI bytes (same off22 convention as the EDID payload).
+    b.extend_from_slice(payload, GFP_KERNEL)?;
     pad_to(&mut b, 32)?;
     Ok(b)
 }
diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
index fde52336fcd1..898cfae8fd4f 100644
--- a/drivers/gpu/drm/vino/drm_sink.rs
+++ b/drivers/gpu/drm/vino/drm_sink.rs
@@ -12,19 +12,20 @@
 //! mode object (`VinoCrtc`/`VinoPlane`/`VinoConnector`/`VinoEncoder`) implements the
 //! matching `Driver*` trait rather than hand-assembling a C vtable.
 //!
-//! Not yet ported from the pre-safe-KMS driver (tracked as follow-up, not fabricated
-//! here since the extension points don't exist yet in `kernel::drm::kms`):
-//! - A second display head (the dock's DL3 protocol supports up to 4; only one is
-//!   wired here). `VinoPlane`/`VinoCrtc` hold their state inline rather than behind a
-//!   pointer-identity lookup table, so adding a head is a second `probe()` call away,
-//!   not a redesign.
-//! - CRTC gamma LUT and plane rotation property -- `kernel::drm::kms` doesn't yet expose
-//!   `drm_gamma_lut`/`drm_plane_create_rotation_property`. (The cursor plane *is* wired now:
-//!   `VinoPlane` serves both the primary and a `Type::Cursor` plane, and `atomic_update` forwards
-//!   the cursor bitmap/position to the dock via `cp::cursor_{create,image,move}`.)
-//! - DDC/CI brightness/contrast as connector properties, and the `.detect`/`mode_valid`
-//!   connector hooks (report disconnected until a real EDID arrives; reject
-//!   over-budget modes) -- `DriverConnector` only exposes `get_modes` right now.
+//! Wired onto the safe KMS layer: primary plane (EP08 scanout), a `Type::Cursor` plane (bitmap +
+//! position forwarded via `cp::cursor_{create,image,move}`), a 256-entry CRTC `GAMMA_LUT` (applied
+//! in the scanout), a primary-plane rotation property (0/180 + reflect, applied per source pixel
+//! via `rot_src`), and a DDC/CI virtual I2C adapter ([`VinoI2c`], tunnelling monitor-control writes
+//! to the dock over CP -- brightness/contrast/etc. via `ddcutil`), alongside the DPMS-power VCP the
+//! CRTC hooks already send.
+//!
+//! Not yet ported (needs `kernel::drm::kms` extension points that don't exist yet; not fabricated):
+//! - A second display head (the DL3 protocol supports up to 4; one is wired here).
+//!   `VinoPlane`/`VinoCrtc` hold their state inline, so a head is a second `probe()` call away.
+//! - 90/270 rotation (swaps width/height, unlike the dimension-preserving rotations above).
+//! - DDC/CI *reads* (Get-VCP) -- need the dock's CP reply path; and brightness/contrast as
+//!   *connector properties* (the I2C adapter above is the interface for now).
+//! - The `.detect`/`mode_valid` connector hooks -- `DriverConnector` only exposes `get_modes`.
 //! - Damage-clip bounded conversion (always converts the full frame).
 //!
 //! None of this is reachable on real hardware yet regardless: the dock never engages
@@ -41,6 +42,7 @@
         plane::{self, PlaneAtomicCommit, RawPlaneState as _},
         KmsDriver, ModeConfigGuard, ModeConfigInfo, ModeObject as _, NewKmsDevice, Probing,
     },
+    i2c,
     error::code::EINVAL,
     prelude::*,
     sync::{aref::ARef, new_mutex, Mutex},
@@ -125,6 +127,12 @@ pub(super) struct VinoDrmData {
     /// can reach its cached-EDID slot without needing DRM's mode-object list. Written once,
     /// during single-threaded probe, before the device is registered; read-only thereafter.
     connector: core::sync::atomic::AtomicPtr<VinoConnector>,
+    /// The CRTC's gamma ramp cached from the atomic hook as three 256-entry 8-bit LUTs
+    /// (`[r; 256] ++ [g; 256] ++ [b; 256]`), or `None` for identity. Cached here (not read from
+    /// the CRTC state) because scanout runs in the plane path; it is `Copy`, so the scanout
+    /// snapshots it under the lock and applies it without holding the lock in the pixel loop.
+    #[pin]
+    gamma: Mutex<Option<[u8; 768]>>,
 }
 
 impl VinoDrmData {
@@ -133,9 +141,32 @@ pub(super) fn new(intf: ARef<super::usb::Interface>) -> impl PinInit<Self, Error
             intf,
             cp_link <- new_mutex!(Option::<CpLink>::None),
             connector: core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()),
+            gamma <- new_mutex!(None),
         })
     }
 
+    /// Cache the CRTC's gamma LUT (from `RawCrtcState::gamma_lut`) for the scanout to apply, or
+    /// clear it (identity) with `None`. Each `drm_color_lut` channel is reduced to 8 bits.
+    pub(super) fn update_gamma(&self, lut: Option<&[bindings::drm_color_lut]>) {
+        let cached = lut.map(|entries| {
+            let mut t = [0u8; 768];
+            for i in 0..256 {
+                // Identity past the end of a short LUT.
+                let e = entries.get(i);
+                t[i] = e.map_or(i as u8, |c| (c.red >> 8) as u8);
+                t[256 + i] = e.map_or(i as u8, |c| (c.green >> 8) as u8);
+                t[512 + i] = e.map_or(i as u8, |c| (c.blue >> 8) as u8);
+            }
+            t
+        });
+        *self.gamma.lock() = cached;
+    }
+
+    /// Snapshot the cached gamma LUT for a scanout pass (`Copy`, so no lock is held afterwards).
+    pub(super) fn gamma_snapshot(&self) -> Option<[u8; 768]> {
+        *self.gamma.lock()
+    }
+
     /// Publish the engaged CP session so the KMS callbacks can send runtime CP messages.
     /// Called once by the bring-up work item after the dock acks (`acks > 0`). `wire_seq`/
     /// `counter` are the next free values past the bring-up CP setup.
@@ -302,6 +333,16 @@ fn probe(dev: &NewKmsDevice<'_, Self, Probing>) -> Result {
             None,
             false,
         )?;
+        // Advertise the rotations vino's re-encode handles 1:1 (dimension-preserving): the
+        // scanout applies them per source pixel via `rot_src`. 90/270 (which swap width/height)
+        // are intentionally omitted.
+        primary.create_rotation_property(
+            bindings::DRM_MODE_ROTATE_0,
+            bindings::DRM_MODE_ROTATE_0
+                | bindings::DRM_MODE_ROTATE_180
+                | bindings::DRM_MODE_REFLECT_X
+                | bindings::DRM_MODE_REFLECT_Y,
+        )?;
         let cursor = plane::UnregisteredPlane::<VinoPlane>::new(
             dev,
             1,
@@ -318,6 +359,8 @@ fn probe(dev: &NewKmsDevice<'_, Self, Probing>) -> Result {
             None,
             (),
         )?;
+        // Advertise a 256-entry GAMMA_LUT; the scanout applies it (cached via the CRTC hooks).
+        crtc_obj.enable_gamma(256);
         let enc = encoder::UnregisteredEncoder::<VinoEncoder>::new(
             dev,
             encoder::Type::Virtual,
@@ -372,6 +415,8 @@ fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, _args: &()) -> impl Pin
     fn atomic_enable(commit: CrtcAtomicCommit<'_, Self>) {
         let data: &VinoDrmData = commit.crtc().drm_dev();
         let new = commit.take_new_state();
+        // Cache the gamma ramp for the scanout to apply.
+        data.update_gamma(new.gamma_lut());
         let timing = super::cp::timing_from_drm_mode(new.mode());
         pr_info!(
             "vino: KMS CRTC enable -- display ON, mode {}x{}@{} (scanout begins)\n",
@@ -391,6 +436,7 @@ fn atomic_enable(commit: CrtcAtomicCommit<'_, Self>) {
     /// no-op until CP engages.
     fn atomic_disable(commit: CrtcAtomicCommit<'_, Self>) {
         let data: &VinoDrmData = commit.crtc().drm_dev();
+        data.update_gamma(None);
         let _ = data.set_vcp(super::cp::VCP_POWER_MODE, super::cp::POWER_OFF);
         pr_info!("vino: KMS CRTC disable -- display OFF (scanout stopped)\n");
     }
@@ -489,6 +535,8 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
         // primary plane 1:1 with the virtual output), so this drives the dynamic scanout
         // resolution.
         let (w, h) = (new.crtc_w() as usize, new.crtc_h() as usize);
+        // Plane rotation/reflection (identity unless the compositor set the rotation property).
+        let rotation = new.rotation();
 
         use core::sync::atomic::Ordering::Relaxed;
         // Throttle: while scanout is failing (dock NAKing because CP isn't engaged), skip the
@@ -499,7 +547,7 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
             super::SCANOUT_SKIP.store(skip - 1, Relaxed);
             return;
         }
-        match scanout_one(data, plane, fb, w, h) {
+        match scanout_one(data, plane, fb, rotation, w, h) {
             Ok(()) => {
                 let n = super::SCANOUT_FAILS.swap(0, Relaxed);
                 super::SCANOUT_SKIP.store(0, Relaxed);
@@ -565,6 +613,7 @@ fn scanout_one(
     data: &VinoDrmData,
     plane: &plane::Plane<VinoPlane>,
     fb: &kms::framebuffer::Framebuffer<VinoDrmDriver>,
+    rotation: u32,
     w: usize,
     h: usize,
 ) -> Result {
@@ -577,7 +626,7 @@ fn scanout_one(
     // The real source stride: GEM dumb buffers pad the pitch (alignment), so it is not necessarily
     // `w * 4` -- read it from the framebuffer rather than assuming.
     let pitch = fb.pitch(0) as usize;
-    encode_and_send(data, plane, vmap.as_ptr(), pitch, w, h)
+    encode_and_send(data, plane, vmap.as_ptr(), pitch, rotation, w, h)
 }
 
 /// Encode the mapped frame with the byte-exact Vino WHT **colour** codec and bulk-write the
@@ -591,15 +640,24 @@ fn encode_and_send_wht(
     plane: &plane::Plane<VinoPlane>,
     vaddr: *const u8,
     pitch: usize,
+    rotation: u32,
     w: usize,
     h: usize,
 ) -> Result {
     let seq0 = plane.scanout.lock().seq;
+    let gamma = data.gamma_snapshot();
     let (frames, next_seq) = super::video::wht::colour_frame_ep08(w, h, seq0, |dx, dy| {
-        // SAFETY: `dy*pitch + dx*4 + 3` is within the mapped source framebuffer (`pitch*h` bytes);
-        // the caller (colour_frame_ep08) only invokes this for `dx < w <= pitch/4`, `dy < h`.
-        let px = unsafe { (vaddr.add(dy * pitch + dx * 4) as *const u32).read_unaligned() };
-        (((px >> 16) & 0xff) as u8, ((px >> 8) & 0xff) as u8, (px & 0xff) as u8)
+        // Map the output pixel back to its source pixel under the plane rotation/reflection.
+        let (sx, sy) = rot_src(rotation, dx, dy, w, h);
+        // SAFETY: `sy*pitch + sx*4 + 3` is within the mapped source framebuffer (`pitch*h` bytes);
+        // `rot_src` returns `sx < w <= pitch/4`, `sy < h`.
+        let px = unsafe { (vaddr.add(sy * pitch + sx * 4) as *const u32).read_unaligned() };
+        apply_gamma(
+            &gamma,
+            ((px >> 16) & 0xff) as u8,
+            ((px >> 8) & 0xff) as u8,
+            (px & 0xff) as u8,
+        )
     })?;
     plane.scanout.lock().seq = next_seq;
 
@@ -620,6 +678,7 @@ fn encode_and_send(
     plane: &plane::Plane<VinoPlane>,
     vaddr: *const u8,
     pitch: usize,
+    rotation: u32,
     w: usize,
     h: usize,
 ) -> Result {
@@ -631,8 +690,9 @@ fn encode_and_send(
         && w % super::video::wht::STRIP_W == 0
         && h % super::video::wht::STRIP_H == 0
     {
-        return encode_and_send_wht(data, plane, vaddr, pitch, w, h);
+        return encode_and_send_wht(data, plane, vaddr, pitch, rotation, w, h);
     }
+    let gamma = data.gamma_snapshot();
     let frame = {
         let mut st = plane.scanout.lock();
         // On the first frame `cur` is freshly zeroed, so the whole buffer must be filled.
@@ -650,10 +710,18 @@ fn encode_and_send(
         let ScanoutState { enc, cur, seq, hint, dims: _ } = &mut *st;
         for dy in 0..h {
             for dx in 0..w {
-                // SAFETY: `dy*pitch + dx*4 + 3` is within the mapped source framebuffer
-                // (`pitch*h` bytes); `dx < w <= pitch/4`, `dy < h`.
-                let px = unsafe { (vaddr.add(dy * pitch + dx * 4) as *const u32).read_unaligned() };
-                let (r, g, b) = ((px >> 16) & 0xff, (px >> 8) & 0xff, px & 0xff);
+                // Map the output pixel back to its source pixel under the plane rotation.
+                let (sx, sy) = rot_src(rotation, dx, dy, w, h);
+                // SAFETY: `sy*pitch + sx*4 + 3` is within the mapped source framebuffer
+                // (`pitch*h` bytes); `rot_src` returns `sx < w <= pitch/4`, `sy < h`.
+                let px = unsafe { (vaddr.add(sy * pitch + sx * 4) as *const u32).read_unaligned() };
+                let (r, g, b) = apply_gamma(
+                    &gamma,
+                    ((px >> 16) & 0xff) as u8,
+                    ((px >> 8) & 0xff) as u8,
+                    (px & 0xff) as u8,
+                );
+                let (r, g, b) = (r as u32, g as u32, b as u32);
                 cur[dy * w + dx] = (((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)) as u16;
             }
         }
@@ -748,13 +816,62 @@ fn get_modes<'a>(
     }
 }
 
+// ---- DDC/CI I2C adapter -----------------------------------------------------
+
+/// vino's DDC/CI virtual I2C bus: a userspace monitor-control tool (`ddcutil`, the desktop
+/// brightness slider via the I2C DDC path) writes a DDC/CI transaction to the monitor's I2C slave
+/// on this adapter, and vino tunnels it to the downstream monitor over the dock's CP channel
+/// (`cp::ddc_forward`, `id=0x15 sub=0x22`) -- the same monitor-I2C bridge vino's DPMS-power VCP
+/// uses. Writes only for now (Get-VCP reads need the CP reply path); a no-op until CP engages.
+pub(super) struct VinoI2c;
+
+impl i2c::BusController for VinoI2c {
+    type Context = ARef<VinoDrmDevice>;
+
+    fn master_xfer(dev: &ARef<VinoDrmDevice>, msgs: &mut [i2c::Msg]) -> Result<usize> {
+        let data: &VinoDrmData = dev;
+        let mut transferred = 0usize;
+        for msg in msgs.iter() {
+            if msg.addr() != super::cp::DDCCI_I2C_ADDR as u16 {
+                continue;
+            }
+            if msg.is_read() {
+                // DDC/CI reads (Get-VCP) require decoding the dock's CP reply -- not wired yet.
+                continue;
+            }
+            if data
+                .send_cp(0x15, 0, |ctr| {
+                    super::cp::ddc_forward(ctr, super::cp::DDCCI_I2C_ADDR, msg.buf())
+                })
+                .is_ok()
+            {
+                transferred += 1;
+            }
+        }
+        Ok(transferred)
+    }
+
+    fn functionality(_dev: &ARef<VinoDrmDevice>) -> u32 {
+        i2c::FUNC_I2C
+    }
+}
+
+/// Apply the cached gamma ramp (three 256-entry 8-bit LUTs) to an `(r, g, b)` pixel, or return it
+/// unchanged when no gamma is programmed.
+#[inline]
+fn apply_gamma(gamma: &Option<[u8; 768]>, r: u8, g: u8, b: u8) -> (u8, u8, u8) {
+    match gamma {
+        Some(t) => (t[r as usize], t[256 + g as usize], t[512 + b as usize]),
+        None => (r, g, b),
+    }
+}
+
 /// Map an output pixel `(dx, dy)` back to its source-framebuffer pixel `(sx, sy)` under a DRM
 /// plane `rotation` bitmask (`DRM_MODE_ROTATE_*` | `DRM_MODE_REFLECT_*`, the values the
 /// standard `drm_plane_create_rotation_property` exposes). `sw`/`sh` are the SOURCE
 /// (framebuffer) dimensions. Rotation is clockwise; reflection is applied in source space
-/// after rotation. Pure and total (saturating), so it is unit-tested directly ahead of the
-/// rotation property itself being wired up (see the module doc).
-#[allow(dead_code)]
+/// after rotation. Pure and total (saturating), so it is unit-tested directly. Applied per source
+/// pixel in [`encode_and_send`]/[`encode_and_send_wht`] for the plane's rotation property.
 pub(super) fn rot_src(rotation: u32, dx: usize, dy: usize, sw: usize, sh: usize) -> (usize, usize) {
     let xmax = sw.saturating_sub(1);
     let ymax = sh.saturating_sub(1);
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index eb4378a747c3..fd8d351dd1d9 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -275,6 +275,9 @@ struct VinoDriver {
     /// interface is unbound (otherwise `Interface::as_bound` in `BringUp::run` would
     /// touch an unbound interface). `None` on the idle sibling interface.
     bringup: Option<Arc<BringUp>>,
+    /// The DDC/CI virtual I2C adapter (control interface only), dropped -> `i2c_del_adapter` on
+    /// disconnect. Tunnels userspace DDC/CI writes to the monitor over the dock CP channel.
+    _i2c: Option<Pin<KBox<kernel::i2c::BusAdapter<drm_sink::VinoI2c>>>>,
 }
 
 /// Deferred bring-up work item: the bring-up sequence run on the system workqueue instead
@@ -1995,7 +1998,7 @@ fn probe<'bound>(
                 return Err(ENODEV);
             }
             dev_info!(cdev, "vino: bound D6000 interface {ifnum} (idle -- control is iface 0)\n");
-            return Ok(Self { _intf: intf.into(), _ddev: None, bringup: None });
+            return Ok(Self { _intf: intf.into(), _ddev: None, bringup: None, _i2c: None });
         }
         dev_info!(cdev, "vino: bound DisplayLink D6000 -- plaintext session bring-up\n");
 
@@ -2057,7 +2060,23 @@ fn probe<'bound>(
             }
         };
 
-        Ok(Self { _intf: intf_ref, _ddev: ddev, bringup })
+        // Register the DDC/CI virtual I2C adapter (control interface only), parented to this USB
+        // device, tunnelling userspace DDC/CI writes to the monitor over CP. Non-fatal.
+        let i2c = ddev.as_ref().and_then(|d| {
+            match kernel::i2c::BusAdapter::<drm_sink::VinoI2c>::new(
+                c"DisplayLink DDC/CI",
+                cdev,
+                d.clone(),
+            ) {
+                Ok(a) => Some(a),
+                Err(e) => {
+                    dev_info!(cdev, "vino: DDC/CI I2C adapter registration failed ({e:?})\n");
+                    None
+                }
+            }
+        });
+
+        Ok(Self { _intf: intf_ref, _ddev: ddev, bringup, _i2c: i2c })
     }
 
     fn disconnect<'bound>(intf: &'bound usb::Interface<Core<'_>>, data: Pin<&Self>) {
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH v2 09/10] drm/vino: two heads, 90/270 rotation, damage clips and connector probe
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
                     ` (7 preceding siblings ...)
  2026-07-03  3:02   ` [RFC PATCH v2 08/10] drm/vino: wire CRTC gamma, plane rotation and DDC/CI monitor controls Mike Lothian
@ 2026-07-03  3:02   ` Mike Lothian
  2026-07-03  3:20     ` sashiko-bot
  2026-07-03  3:02   ` [RFC PATCH v2 10/10] drm/vino: hrtimer-driven software vblank Mike Lothian
  9 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-07-03  3:02 UTC (permalink / raw)
  To: rust-for-linux
  Cc: dri-devel, David Airlie, Simona Vetter, Thomas Zimmermann,
	Maarten Lankhorst, Maxime Ripard, Danilo Krummrich, Lyude Paul,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	linux-kernel, Mike Lothian

Consume the new connector detect()/mode_valid() and plane damage-clip
bindings, and finish the KMS feature set the driver previously stubbed:

  - Two display heads. probe() builds an independent CRTC + primary/cursor
    plane + encoder + connector per head (see HEADS), each pinned to its
    own video endpoint (VIDEO_EPS: head 0 -> EP08, head 1 -> EP0b) via a
    head index carried in VinoPlane. The scanout and cursor CP messages
    route by that head; per-head EDID uses a per-head connector pointer
    array. The CP mode-set has no decoded head field, so a head is
    conveyed on the wire only by which endpoint its frames go to.

  - 90/270 plane rotation. The rotation property now advertises all four
    90-degree rotations plus reflection; the scanout maps each output
    pixel back to its source pixel with rot_src(), and src_dims() swaps
    the source/output dimensions for 90/270 (rot_src itself was already
    correct given source dimensions -- only its callers were feeding it
    output dims).

  - Damage clips. The RLE scanout takes the old+new plane state, merges
    the client's damage clips (RawPlaneState::damage_merged) into one
    rectangle, and re-converts only that region into the shadow the
    encoder diffs against (identity rotation only; the WHT keyframe path
    is unaffected).

  - Connector detect()/mode_valid(). A head reports connected once its
    downstream EDID arrives (no phantom output for an unpopulated head),
    and modes above MAX_HEAD_CLOCK_KHZ (~4K@60) are pruned.

Still inert until the dock engages CP (see docs/BLOCKER.md).

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
 drivers/gpu/drm/vino/drm_sink.rs | 426 +++++++++++++++++++++----------
 drivers/gpu/drm/vino/vino.rs     |   8 +-
 2 files changed, 292 insertions(+), 142 deletions(-)

diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
index 898cfae8fd4f..ee04a5af9f7d 100644
--- a/drivers/gpu/drm/vino/drm_sink.rs
+++ b/drivers/gpu/drm/vino/drm_sink.rs
@@ -2,31 +2,38 @@
 
 //! DRM/KMS sink: register a real `struct drm_device` with an atomic mode-setting
 //! pipeline so the dock appears to userspace as a `card`/`renderD` node that can be
-//! `drmModeSetCrtc`'d. One CRTC driven by a single primary plane
-//! ([`VinoPlane::atomic_update`] -> EP08 scanout), a virtual encoder, and a virtual
-//! connector whose mode list comes from the dock's real EDID (falling back to 1080p),
-//! with GEM-shmem dumb buffers and `drm_gem_fb_create` framebuffers.
+//! `drmModeSetCrtc`'d. Two independent display heads (see [`HEADS`]), each a CRTC driven by a
+//! primary plane ([`VinoPlane::atomic_update`] -> per-head video endpoint scanout), a cursor plane,
+//! a virtual encoder, and a virtual connector whose mode list comes from the dock's real EDID
+//! (falling back to 1080p), with GEM-shmem dumb buffers and `drm_gem_fb_create` framebuffers.
 //!
 //! Built on the safe KMS mode-object layer (`kernel::drm::kms`), not the raw
 //! `bindings::drm_*` C API: `VinoDrmDriver` implements `drm::kms::KmsDriver`, and each
 //! mode object (`VinoCrtc`/`VinoPlane`/`VinoConnector`/`VinoEncoder`) implements the
 //! matching `Driver*` trait rather than hand-assembling a C vtable.
 //!
-//! Wired onto the safe KMS layer: primary plane (EP08 scanout), a `Type::Cursor` plane (bitmap +
-//! position forwarded via `cp::cursor_{create,image,move}`), a 256-entry CRTC `GAMMA_LUT` (applied
-//! in the scanout), a primary-plane rotation property (0/180 + reflect, applied per source pixel
-//! via `rot_src`), and a DDC/CI virtual I2C adapter ([`VinoI2c`], tunnelling monitor-control writes
-//! to the dock over CP -- brightness/contrast/etc. via `ddcutil`), alongside the DPMS-power VCP the
-//! CRTC hooks already send.
+//! Wired onto the safe KMS layer:
+//! - Per-head primary plane scanout to that head's video endpoint ([`VIDEO_EPS`]) and a
+//!   `Type::Cursor` plane (bitmap + position forwarded via `cp::cursor_{create,image,move}` with
+//!   the head as the CP `head` field).
+//! - A 256-entry CRTC `GAMMA_LUT` (applied in the scanout) and a full plane rotation property
+//!   (all four 90-degree rotations plus X/Y reflection), applied per source pixel via `rot_src`
+//!   (90/270 swap the source/output dimensions -- see [`src_dims`]).
+//! - Frame-damage clips: the RLE scanout re-converts only the client's changed rectangles
+//!   (`RawPlaneState::for_each_damage_clip`) for identity rotation.
+//! - Connector `detect()` (connected once the head's EDID arrives) and `mode_valid()` (prune modes
+//!   above [`MAX_HEAD_CLOCK_KHZ`]).
+//! - A DDC/CI virtual I2C adapter ([`VinoI2c`], tunnelling monitor-control writes to the dock over
+//!   CP -- brightness/contrast/etc. via `ddcutil`), alongside the DPMS-power VCP the CRTC hooks
+//!   send.
 //!
-//! Not yet ported (needs `kernel::drm::kms` extension points that don't exist yet; not fabricated):
-//! - A second display head (the DL3 protocol supports up to 4; one is wired here).
-//!   `VinoPlane`/`VinoCrtc` hold their state inline, so a head is a second `probe()` call away.
-//! - 90/270 rotation (swaps width/height, unlike the dimension-preserving rotations above).
+//! Not yet done (needs hardware capture past the CP wall; not fabricated):
+//! - Per-head EDID reads (the bring-up reads head 0's EDID only) and per-head mode-set / DDC
+//!   differentiation on the wire -- the CP mode-set (`id=0x48`) has no decoded head/stream field,
+//!   so the head is conveyed only by which video endpoint its frames go to.
+//! - The WHT keyframe codec ignores damage clips (strip-based); damage applies to the RLE path.
 //! - DDC/CI *reads* (Get-VCP) -- need the dock's CP reply path; and brightness/contrast as
-//!   *connector properties* (the I2C adapter above is the interface for now).
-//! - The `.detect`/`mode_valid` connector hooks -- `DriverConnector` only exposes `get_modes`.
-//! - Damage-clip bounded conversion (always converts the full frame).
+//!   *connector properties* (the I2C adapter is the interface for now).
 //!
 //! None of this is reachable on real hardware yet regardless: the dock never engages
 //! its content-protection channel for vino (see `docs/BLOCKER.md`), so `atomic_update`
@@ -36,9 +43,10 @@
     bindings, drm,
     drm::kms::{
         self,
-        connector::{self, ConnectorGuard},
+        connector::{self, Connector, ConnectorGuard, ModeStatus, Status},
         crtc::{self, CrtcAtomicCommit, RawCrtc as _, RawCrtcState as _},
         encoder,
+        modes::DisplayMode,
         plane::{self, PlaneAtomicCommit, RawPlaneState as _},
         KmsDriver, ModeConfigGuard, ModeConfigInfo, ModeObject as _, NewKmsDevice, Probing,
     },
@@ -65,16 +73,24 @@
 /// Cursor-plane format list.
 static CURSOR_FORMATS: [u32; 1] = [DRM_FORMAT_ARGB8888];
 
-/// Per-mode pixel-clock ceiling (kHz) -- about 4K@60 (CEA 594 MHz). With only one head wired
-/// there is no combined-heads budget to enforce (see the module doc); a real ceiling still
-/// needs the connector `mode_valid` hook this port doesn't expose yet, so this constant is
-/// currently unused wiring for when it does.
-#[allow(dead_code)]
+/// Per-mode pixel-clock ceiling (kHz) -- about 4K@60 (CEA 594 MHz). Modes above this are pruned
+/// by the connector `mode_valid` hook ([`VinoConnector::mode_valid`]).
 const MAX_HEAD_CLOCK_KHZ: i32 = 600_000;
 
-/// The one wired display head's video bulk-OUT endpoint (see the module doc -- only
-/// head 0 is wired for now).
-const VIDEO_EP: u8 = 0x08;
+/// Number of display heads wired. The DL3 protocol supports up to 4; two are wired here (the
+/// common dual-monitor dock), each scanned out to its own video endpoint ([`VIDEO_EPS`]).
+const HEADS: usize = 2;
+
+/// Per-head video bulk-OUT endpoint: head 0 -> EP08, head 1 -> EP0b (captured from DLM driving a
+/// two-monitor dock). The endpoint is the head selector for scanout; the cursor uses the CP `head`
+/// field (see [`VinoPlane::atomic_update`]).
+const VIDEO_EPS: [u8; HEADS] = [0x08, 0x0b];
+
+/// Maximum number of individual frame-damage rectangles re-converted per flip before they are
+/// collapsed into a single bounding box. Bounds the stack array used on the atomic-commit path
+/// (no per-flip allocation); a compositor that reports more clips than this just gets a coarser
+/// (still correct) repaint.
+const MAX_DAMAGE_CLIPS: usize = 16;
 
 /// The DRM driver marker type.
 pub(super) struct VinoDrmDriver;
@@ -123,10 +139,11 @@ pub(super) struct VinoDrmData {
     intf: ARef<super::usb::Interface>,
     #[pin]
     cp_link: Mutex<Option<CpLink>>,
-    /// The device's one connector, stashed by [`KmsDriver::probe`] so [`VinoDrmData::set_edid`]
-    /// can reach its cached-EDID slot without needing DRM's mode-object list. Written once,
+    /// The device's per-head connectors, stashed by [`KmsDriver::probe`] so
+    /// [`VinoDrmData::set_edid`] can reach a head's cached-EDID slot without needing DRM's
+    /// mode-object list. Written once,
     /// during single-threaded probe, before the device is registered; read-only thereafter.
-    connector: core::sync::atomic::AtomicPtr<VinoConnector>,
+    connectors: [core::sync::atomic::AtomicPtr<VinoConnector>; HEADS],
     /// The CRTC's gamma ramp cached from the atomic hook as three 256-entry 8-bit LUTs
     /// (`[r; 256] ++ [g; 256] ++ [b; 256]`), or `None` for identity. Cached here (not read from
     /// the CRTC state) because scanout runs in the plane path; it is `Copy`, so the scanout
@@ -140,7 +157,9 @@ pub(super) fn new(intf: ARef<super::usb::Interface>) -> impl PinInit<Self, Error
         try_pin_init!(Self {
             intf,
             cp_link <- new_mutex!(Option::<CpLink>::None),
-            connector: core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()),
+            connectors: core::array::from_fn(|_| {
+                core::sync::atomic::AtomicPtr::new(core::ptr::null_mut())
+            }),
             gamma <- new_mutex!(None),
         })
     }
@@ -210,12 +229,14 @@ pub(super) fn set_vcp(&self, vcp: u8, value: u16) -> Result {
         self.send_cp(0x15, 0, |ctr| super::cp::ddc_set_vcp(ctr, vcp, value))
     }
 
-    /// Cache the dock's EDID (read during probe) for the connector's `get_modes` to install,
-    /// then fire a hotplug so the compositor re-probes the connector. Only the connector
-    /// itself holds the cached blob (see [`VinoConnector::cached_edid`]); this just forwards
-    /// it there via the pointer [`KmsDriver::probe`] stashed in `self.connector`.
-    pub(super) fn set_edid(&self, dev: &VinoDrmDevice, blob: KVec<u8>) {
-        let ptr = self.connector.load(core::sync::atomic::Ordering::Acquire);
+    /// Cache a head's downstream EDID (read during probe) for that connector's `get_modes` to
+    /// install, then fire a hotplug so the compositor re-probes it. Only the connector itself
+    /// holds the cached blob (see [`VinoConnector::cached_edid`]); this forwards it there via the
+    /// pointer [`KmsDriver::probe`] stashed in `self.connectors[head]`. Out-of-range `head` is a
+    /// no-op.
+    pub(super) fn set_edid(&self, dev: &VinoDrmDevice, head: usize, blob: KVec<u8>) {
+        let Some(slot) = self.connectors.get(head) else { return };
+        let ptr = slot.load(core::sync::atomic::Ordering::Acquire);
         let Some(connector) = (unsafe { ptr.as_ref() }) else { return };
         *connector.cached_edid.lock() = Some(blob);
         dev.hotplug_event();
@@ -319,69 +340,77 @@ fn mode_config_info(_dev: &drm::Device<Self, drm::Uninit>) -> Result<ModeConfigI
     }
 
     fn probe(dev: &NewKmsDevice<'_, Self, Probing>) -> Result {
-        // Order matters: `possible_crtcs` for the plane/encoder is a bitmask of CRTC
-        // *indices*, which only exist once `UnregisteredCrtc::new` runs -- but planes
-        // must exist before the CRTC that references them. With exactly one CRTC ever
-        // created here, its index is always 0, so `possible_crtcs = 1` is correct by
-        // construction rather than needing the CRTC up front.
-        let primary = plane::UnregisteredPlane::<VinoPlane>::new(
-            dev,
-            1,
-            &PRIMARY_FORMATS,
-            None,
-            plane::Type::Primary,
-            None,
-            false,
-        )?;
-        // Advertise the rotations vino's re-encode handles 1:1 (dimension-preserving): the
-        // scanout applies them per source pixel via `rot_src`. 90/270 (which swap width/height)
-        // are intentionally omitted.
-        primary.create_rotation_property(
-            bindings::DRM_MODE_ROTATE_0,
-            bindings::DRM_MODE_ROTATE_0
-                | bindings::DRM_MODE_ROTATE_180
-                | bindings::DRM_MODE_REFLECT_X
-                | bindings::DRM_MODE_REFLECT_Y,
-        )?;
-        let cursor = plane::UnregisteredPlane::<VinoPlane>::new(
-            dev,
-            1,
-            &CURSOR_FORMATS,
-            None,
-            plane::Type::Cursor,
-            None,
-            true,
-        )?;
-        let crtc_obj = crtc::UnregisteredCrtc::<VinoCrtc>::new(
-            dev,
-            primary,
-            Some(&cursor),
-            None,
-            (),
-        )?;
-        // Advertise a 256-entry GAMMA_LUT; the scanout applies it (cached via the CRTC hooks).
-        crtc_obj.enable_gamma(256);
-        let enc = encoder::UnregisteredEncoder::<VinoEncoder>::new(
-            dev,
-            encoder::Type::Virtual,
-            crtc_obj.mask(),
-            0,
-            None,
-            (),
-        )?;
-        let conn =
-            connector::UnregisteredConnector::<VinoConnector>::new(dev, connector::Type::Virtual, ())?;
-        conn.attach_encoder(&*enc)?;
-        // Stash a pointer to our own connector data (not the wrapping `Connector<T>`, which
-        // this crate has no public way to reconstruct from a reference) so
-        // `VinoDrmData::set_edid` can reach it later without walking DRM's mode-object list.
-        // `conn` outlives the device (destroyed only alongside it), so this is valid for as
-        // long as `dev.connector` is read.
         let data: &VinoDrmData = dev;
-        data.connector.store(
-            &**conn as *const VinoConnector as *mut VinoConnector,
-            core::sync::atomic::Ordering::Release,
-        );
+        // Build one independent head (CRTC + primary/cursor plane + encoder + connector) per
+        // wired display, each pinned to its own video endpoint via its head index.
+        for head in 0..HEADS {
+            // `possible_crtcs` for the plane/encoder is a bitmask of CRTC *indices*, which only
+            // exist once `UnregisteredCrtc::new` runs -- but planes must exist before the CRTC
+            // that references them. CRTCs are created here one per head in order, so this head's
+            // CRTC index is `head` and its mask is `1 << head`.
+            let crtc_mask = 1u32 << head;
+            let primary = plane::UnregisteredPlane::<VinoPlane>::new(
+                dev,
+                crtc_mask,
+                &PRIMARY_FORMATS,
+                None,
+                plane::Type::Primary,
+                None,
+                PlaneArgs { head: head as u8, is_cursor: false },
+            )?;
+            // Advertise every rotation vino's re-encode can produce by remapping source pixels
+            // (`rot_src`): the four 90-degree rotations plus the two reflections.
+            primary.create_rotation_property(
+                bindings::DRM_MODE_ROTATE_0,
+                bindings::DRM_MODE_ROTATE_0
+                    | bindings::DRM_MODE_ROTATE_90
+                    | bindings::DRM_MODE_ROTATE_180
+                    | bindings::DRM_MODE_ROTATE_270
+                    | bindings::DRM_MODE_REFLECT_X
+                    | bindings::DRM_MODE_REFLECT_Y,
+            )?;
+            let cursor = plane::UnregisteredPlane::<VinoPlane>::new(
+                dev,
+                crtc_mask,
+                &CURSOR_FORMATS,
+                None,
+                plane::Type::Cursor,
+                None,
+                PlaneArgs { head: head as u8, is_cursor: true },
+            )?;
+            let crtc_obj = crtc::UnregisteredCrtc::<VinoCrtc>::new(
+                dev,
+                primary,
+                Some(&cursor),
+                None,
+                head as u8,
+            )?;
+            // Advertise a 256-entry GAMMA_LUT; the scanout applies it (cached via the CRTC hooks).
+            crtc_obj.enable_gamma(256);
+            let enc = encoder::UnregisteredEncoder::<VinoEncoder>::new(
+                dev,
+                encoder::Type::Virtual,
+                crtc_obj.mask(),
+                0,
+                None,
+                (),
+            )?;
+            let conn = connector::UnregisteredConnector::<VinoConnector>::new(
+                dev,
+                connector::Type::Virtual,
+                (),
+            )?;
+            conn.attach_encoder(&*enc)?;
+            // Stash a pointer to our own connector data (not the wrapping `Connector<T>`, which
+            // this crate has no public way to reconstruct from a reference) so
+            // `VinoDrmData::set_edid` can reach it later without walking DRM's mode-object list.
+            // `conn` outlives the device (destroyed only alongside it), so this is valid for as
+            // long as `dev.connectors[head]` is read.
+            data.connectors[head].store(
+                &**conn as *const VinoConnector as *mut VinoConnector,
+                core::sync::atomic::Ordering::Release,
+            );
+        }
         Ok(())
     }
 }
@@ -389,7 +418,11 @@ fn probe(dev: &NewKmsDevice<'_, Self, Probing>) -> Result {
 // ---- CRTC -----------------------------------------------------------------
 
 #[pin_data]
-pub(super) struct VinoCrtc;
+pub(super) struct VinoCrtc {
+    /// Which display head (0-based) this CRTC drives. Used for diagnostics; the mode-set/DDC CP
+    /// messages this CRTC sends are not yet head-differentiated on the wire (see the module doc).
+    head: u8,
+}
 
 #[derive(Clone, Default)]
 pub(super) struct VinoCrtcState;
@@ -400,26 +433,32 @@ impl crtc::DriverCrtcState for VinoCrtcState {
 
 #[vtable]
 impl crtc::DriverCrtc for VinoCrtc {
-    type Args = ();
+    type Args = u8;
     type Driver = VinoDrmDriver;
     type State = VinoCrtcState;
     type VblankImpl = core::marker::PhantomData<Self>;
 
-    fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, _args: &()) -> impl PinInit<Self, Error> {
-        try_pin_init!(VinoCrtc {})
+    fn new(
+        _device: &drm::Device<Self::Driver, drm::Uninit>,
+        head: &u8,
+    ) -> impl PinInit<Self, Error> {
+        try_pin_init!(VinoCrtc { head: *head })
     }
 
     /// The display is turning on (scanout begins). Pushes a live mode-set CP message for the
     /// negotiated mode and brings the monitor out of DPMS standby -- both no-ops until CP
     /// engages (the wall).
     fn atomic_enable(commit: CrtcAtomicCommit<'_, Self>) {
-        let data: &VinoDrmData = commit.crtc().drm_dev();
+        let crtc = commit.crtc();
+        let head = crtc.head;
+        let data: &VinoDrmData = crtc.drm_dev();
         let new = commit.take_new_state();
         // Cache the gamma ramp for the scanout to apply.
         data.update_gamma(new.gamma_lut());
         let timing = super::cp::timing_from_drm_mode(new.mode());
         pr_info!(
-            "vino: KMS CRTC enable -- display ON, mode {}x{}@{} (scanout begins)\n",
+            "vino: KMS CRTC enable -- head {} display ON, mode {}x{}@{} (scanout begins)\n",
+            head,
             timing.hactive,
             timing.vactive,
             timing.refresh_hz
@@ -435,10 +474,12 @@ fn atomic_enable(commit: CrtcAtomicCommit<'_, Self>) {
     /// against a shadow the dock may have dropped, and blanks the monitor via DDC/CI -- a
     /// no-op until CP engages.
     fn atomic_disable(commit: CrtcAtomicCommit<'_, Self>) {
-        let data: &VinoDrmData = commit.crtc().drm_dev();
+        let crtc = commit.crtc();
+        let head = crtc.head;
+        let data: &VinoDrmData = crtc.drm_dev();
         data.update_gamma(None);
         let _ = data.set_vcp(super::cp::VCP_POWER_MODE, super::cp::POWER_OFF);
-        pr_info!("vino: KMS CRTC disable -- display OFF (scanout stopped)\n");
+        pr_info!("vino: KMS CRTC disable -- head {head} display OFF (scanout stopped)\n");
     }
 }
 
@@ -447,10 +488,21 @@ fn atomic_disable(commit: CrtcAtomicCommit<'_, Self>) {
 // The safe KMS layer allows one `DriverPlane` type per driver, so `VinoPlane` serves both the
 // primary and cursor planes, told apart by `is_cursor` (from the plane's `Args`).
 
+/// Constructor arguments for a [`VinoPlane`]: which head it belongs to and whether it is that
+/// head's cursor plane (vs. its primary scanout plane).
+#[derive(Clone, Copy)]
+pub(super) struct PlaneArgs {
+    head: u8,
+    is_cursor: bool,
+}
+
 #[pin_data]
 pub(super) struct VinoPlane {
     #[pin]
     scanout: Mutex<ScanoutState>,
+    /// Which display head (0-based) this plane belongs to. Selects the scanout video endpoint
+    /// ([`VIDEO_EPS`]) and the cursor CP `head` field.
+    head: u8,
     /// Whether this is the cursor plane (vs. the primary scanout plane).
     is_cursor: bool,
     /// The framebuffer last uploaded to the dock as the cursor bitmap (raw address, `0` = none),
@@ -467,11 +519,14 @@ impl plane::DriverPlaneState for VinoPlaneState {
 
 #[vtable]
 impl plane::DriverPlane for VinoPlane {
-    type Args = bool;
+    type Args = PlaneArgs;
     type Driver = VinoDrmDriver;
     type State = VinoPlaneState;
 
-    fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, is_cursor: bool) -> impl PinInit<Self, Error> {
+    fn new(
+        _device: &drm::Device<Self::Driver, drm::Uninit>,
+        args: PlaneArgs,
+    ) -> impl PinInit<Self, Error> {
         try_pin_init!(VinoPlane {
             scanout <- new_mutex!(ScanoutState {
                 enc: None,
@@ -480,7 +535,8 @@ fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, is_cursor: bool) -> imp
                 dims: (0, 0),
                 hint: 0,
             }),
-            is_cursor,
+            head: args.head,
+            is_cursor: args.is_cursor,
             cursor_last: core::sync::atomic::AtomicUsize::new(0),
         })
     }
@@ -498,14 +554,14 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
             return;
         }
         let plane = commit.plane();
+        let head = plane.head;
         let data: &VinoDrmData = plane.drm_dev();
-        let new = commit.take_new_state();
 
         // Cursor plane: forward the cursor bitmap/position to the dock over CP (id=0x1b create,
         // 0x401c image, 0x1a move -- see `cp::cursor_*`). A no-op until CP engages, like scanout.
         if plane.is_cursor {
             use core::sync::atomic::Ordering::Relaxed;
-            const HEAD: u8 = 0;
+            let new = commit.take_new_state();
             match new.framebuffer::<VinoDrmDriver>() {
                 Some(fb) => {
                     let w = fb.width() as u16;
@@ -514,15 +570,17 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
                     // position (a bare cursor move must not re-send the whole image).
                     let key = fb as *const _ as usize;
                     if plane.cursor_last.swap(key, Relaxed) != key {
-                        let _ = data.send_cp(0x1b, 0, |ctr| super::cp::cursor_create(ctr, HEAD, w, h));
+                        let _ =
+                            data.send_cp(0x1b, 0, |ctr| super::cp::cursor_create(ctr, head, w, h));
                         if let Ok(bgra) = read_cursor_bgra(fb, w as usize, h as usize) {
-                            let _ = data
-                                .send_cp(0x401c, 0, |ctr| super::cp::cursor_image(ctr, HEAD, w, h, &bgra));
+                            let _ = data.send_cp(0x401c, 0, |ctr| {
+                                super::cp::cursor_image(ctr, head, w, h, &bgra)
+                            });
                         }
                     }
                     let x = new.crtc_x().max(0) as u16;
                     let y = new.crtc_y().max(0) as u16;
-                    let _ = data.send_cp(0x1a, 0, |ctr| super::cp::cursor_move(ctr, HEAD, x, y));
+                    let _ = data.send_cp(0x1a, 0, |ctr| super::cp::cursor_move(ctr, head, x, y));
                 }
                 // Cursor disabled: forget the last bitmap so it re-uploads if it comes back.
                 None => plane.cursor_last.store(0, Relaxed),
@@ -530,6 +588,8 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
             return;
         }
 
+        // Primary plane: take both old and new state so the frame-damage clips can be merged.
+        let (old, new) = commit.take_old_new_state();
         let Some(fb) = new.framebuffer::<VinoDrmDriver>() else { return };
         // The plane's destination geometry mirrors the negotiated mode (the compositor sizes the
         // primary plane 1:1 with the virtual output), so this drives the dynamic scanout
@@ -537,6 +597,37 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
         let (w, h) = (new.crtc_w() as usize, new.crtc_h() as usize);
         // Plane rotation/reflection (identity unless the compositor set the rotation property).
         let rotation = new.rotation();
+        // Collect the client's individual frame-damage clips (the rectangles that
+        // `damage_merged()` would collapse into one bounding box), each clamped to the output, so
+        // only the genuinely changed rectangles are re-converted from the source rather than their
+        // whole enclosing box. Only for identity rotation (the clips are in un-rotated source
+        // space; mapping them through 90/270 is not worth it for the throttled fallback path), and
+        // never on the WHT keyframe path -- see `encode_and_send`. A fixed stack array keeps the
+        // atomic-commit path allocation-free; on overflow the clips collapse into one bounding box.
+        // An empty list means "convert the whole output" (used for the rotated/reflected case).
+        let mut clips = [(0usize, 0usize, 0usize, 0usize); MAX_DAMAGE_CLIPS];
+        let mut nclips = 0usize;
+        if rotation & bindings::DRM_MODE_ROTATE_MASK == bindings::DRM_MODE_ROTATE_0
+            && rotation & (bindings::DRM_MODE_REFLECT_X | bindings::DRM_MODE_REFLECT_Y) == 0
+        {
+            new.for_each_damage_clip(old, |r| {
+                let c = (
+                    (r.x1.max(0) as usize).min(w),
+                    (r.y1.max(0) as usize).min(h),
+                    (r.x2.max(0) as usize).min(w),
+                    (r.y2.max(0) as usize).min(h),
+                );
+                if nclips < MAX_DAMAGE_CLIPS {
+                    clips[nclips] = c;
+                    nclips += 1;
+                } else {
+                    // Overflow: collapse everything so far (and `c`) into clips[0]'s bounding box.
+                    let b = clips[0];
+                    clips[0] = (b.0.min(c.0), b.1.min(c.1), b.2.max(c.2), b.3.max(c.3));
+                    nclips = 1;
+                }
+            });
+        }
 
         use core::sync::atomic::Ordering::Relaxed;
         // Throttle: while scanout is failing (dock NAKing because CP isn't engaged), skip the
@@ -547,7 +638,7 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
             super::SCANOUT_SKIP.store(skip - 1, Relaxed);
             return;
         }
-        match scanout_one(data, plane, fb, rotation, w, h) {
+        match scanout_one(data, plane, fb, rotation, &clips[..nclips], w, h) {
             Ok(()) => {
                 let n = super::SCANOUT_FAILS.swap(0, Relaxed);
                 super::SCANOUT_SKIP.store(0, Relaxed);
@@ -608,12 +699,27 @@ fn read_cursor_bgra(
     Ok(out)
 }
 
-/// vmap `fb`, encode it, and push one EP08 frame. Split out so `?` can be used.
+/// Source (framebuffer) dimensions for an output of `ow`x`oh` pixels under plane `rotation`.
+/// The 90/270 rotations swap width and height between the framebuffer and the displayed output;
+/// the others preserve them.
+fn src_dims(rotation: u32, ow: usize, oh: usize) -> (usize, usize) {
+    let rot = rotation & bindings::DRM_MODE_ROTATE_MASK;
+    if rot == bindings::DRM_MODE_ROTATE_90 || rot == bindings::DRM_MODE_ROTATE_270 {
+        (oh, ow)
+    } else {
+        (ow, oh)
+    }
+}
+
+/// vmap `fb`, encode it, and push one video frame to the head's endpoint. Split out so `?` can be
+/// used. `w`/`h` are the OUTPUT (displayed) dimensions; `clips` are the client's changed
+/// rectangles in output space (empty = repaint the whole output).
 fn scanout_one(
     data: &VinoDrmData,
     plane: &plane::Plane<VinoPlane>,
     fb: &kms::framebuffer::Framebuffer<VinoDrmDriver>,
     rotation: u32,
+    clips: &[(usize, usize, usize, usize)],
     w: usize,
     h: usize,
 ) -> Result {
@@ -626,7 +732,7 @@ fn scanout_one(
     // The real source stride: GEM dumb buffers pad the pitch (alignment), so it is not necessarily
     // `w * 4` -- read it from the framebuffer rather than assuming.
     let pitch = fb.pitch(0) as usize;
-    encode_and_send(data, plane, vmap.as_ptr(), pitch, rotation, w, h)
+    encode_and_send(data, plane, vmap.as_ptr(), pitch, rotation, clips, w, h)
 }
 
 /// Encode the mapped frame with the byte-exact Vino WHT **colour** codec and bulk-write the
@@ -646,11 +752,13 @@ fn encode_and_send_wht(
 ) -> Result {
     let seq0 = plane.scanout.lock().seq;
     let gamma = data.gamma_snapshot();
+    // Source dimensions (swapped from the output for 90/270 rotation).
+    let (sw, sh) = src_dims(rotation, w, h);
     let (frames, next_seq) = super::video::wht::colour_frame_ep08(w, h, seq0, |dx, dy| {
         // Map the output pixel back to its source pixel under the plane rotation/reflection.
-        let (sx, sy) = rot_src(rotation, dx, dy, w, h);
-        // SAFETY: `sy*pitch + sx*4 + 3` is within the mapped source framebuffer (`pitch*h` bytes);
-        // `rot_src` returns `sx < w <= pitch/4`, `sy < h`.
+        let (sx, sy) = rot_src(rotation, dx, dy, sw, sh);
+        // SAFETY: `sy*pitch + sx*4 + 3` is within the mapped source framebuffer (`pitch*sh`
+        // bytes); `rot_src` returns `sx < sw <= pitch/4`, `sy < sh`.
         let px = unsafe { (vaddr.add(sy * pitch + sx * 4) as *const u32).read_unaligned() };
         apply_gamma(
             &gamma,
@@ -665,8 +773,9 @@ fn encode_and_send_wht(
     // in `disconnect()`, which first unplugs the DRM device, so it is bound for this push.
     let dev = unsafe { data.intf.as_bound() };
     prime_video_eps(dev);
+    let ep = VIDEO_EPS[plane.head as usize];
     for frame in frames.iter() {
-        dev.bulk_send(VIDEO_EP, frame, super::timeout(), GFP_KERNEL)?;
+        dev.bulk_send(ep, frame, super::timeout(), GFP_KERNEL)?;
     }
     Ok(())
 }
@@ -679,13 +788,14 @@ fn encode_and_send(
     vaddr: *const u8,
     pitch: usize,
     rotation: u32,
+    clips: &[(usize, usize, usize, usize)],
     w: usize,
     h: usize,
 ) -> Result {
     // WHT colour codec path (default off): the byte-exact, bandwidth-efficient DLM-quality
     // codec. Requires a 64x16-aligned mode; for non-aligned geometry (e.g. 1080p, height
     // 1080 % 16 = 8) fall through to the RLE path until partial-strip edge handling is
-    // captured.
+    // captured. The keyframe codec ignores damage clips.
     if super::EP08_WHT_CODEC
         && w % super::video::wht::STRIP_W == 0
         && h % super::video::wht::STRIP_H == 0
@@ -693,6 +803,8 @@ fn encode_and_send(
         return encode_and_send_wht(data, plane, vaddr, pitch, rotation, w, h);
     }
     let gamma = data.gamma_snapshot();
+    // Source dimensions (swapped from the output for 90/270 rotation).
+    let (sw, sh) = src_dims(rotation, w, h);
     let frame = {
         let mut st = plane.scanout.lock();
         // On the first frame `cur` is freshly zeroed, so the whole buffer must be filled.
@@ -708,21 +820,31 @@ fn encode_and_send(
             st.hint = 0;
         }
         let ScanoutState { enc, cur, seq, hint, dims: _ } = &mut *st;
-        for dy in 0..h {
-            for dx in 0..w {
-                // Map the output pixel back to its source pixel under the plane rotation.
-                let (sx, sy) = rot_src(rotation, dx, dy, w, h);
-                // SAFETY: `sy*pitch + sx*4 + 3` is within the mapped source framebuffer
-                // (`pitch*h` bytes); `rot_src` returns `sx < w <= pitch/4`, `sy < h`.
-                let px = unsafe { (vaddr.add(sy * pitch + sx * 4) as *const u32).read_unaligned() };
-                let (r, g, b) = apply_gamma(
-                    &gamma,
-                    ((px >> 16) & 0xff) as u8,
-                    ((px >> 8) & 0xff) as u8,
-                    (px & 0xff) as u8,
-                );
-                let (r, g, b) = (r as u32, g as u32, b as u32);
-                cur[dy * w + dx] = (((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)) as u16;
+        // Re-convert only the client's changed rectangles into `cur`; the rest already holds the
+        // previous frame, which the encoder diffs against. On the first frame there is no valid
+        // previous frame (and on a rotated output there are no usable clips), so convert the whole
+        // output. `clips` are already clamped to the output in `atomic_update`.
+        let full = [(0usize, 0usize, w, h)];
+        let regions: &[(usize, usize, usize, usize)] =
+            if first || clips.is_empty() { &full } else { clips };
+        for &(x0, y0, x1, y1) in regions {
+            for dy in y0..y1 {
+                for dx in x0..x1 {
+                    // Map the output pixel back to its source pixel under the plane rotation.
+                    let (sx, sy) = rot_src(rotation, dx, dy, sw, sh);
+                    // SAFETY: `sy*pitch + sx*4 + 3` is within the mapped source framebuffer
+                    // (`pitch*sh` bytes); `rot_src` returns `sx < sw <= pitch/4`, `sy < sh`.
+                    let px =
+                        unsafe { (vaddr.add(sy * pitch + sx * 4) as *const u32).read_unaligned() };
+                    let (r, g, b) = apply_gamma(
+                        &gamma,
+                        ((px >> 16) & 0xff) as u8,
+                        ((px >> 8) & 0xff) as u8,
+                        (px & 0xff) as u8,
+                    );
+                    let (r, g, b) = (r as u32, g as u32, b as u32);
+                    cur[dy * w + dx] = (((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)) as u16;
+                }
             }
         }
         let s = *seq;
@@ -746,7 +868,7 @@ fn encode_and_send(
     // DRM device, so it is bound for the duration of this push.
     let dev = unsafe { data.intf.as_bound() };
     prime_video_eps(dev);
-    dev.bulk_send(VIDEO_EP, &frame, super::timeout(), GFP_KERNEL)?;
+    dev.bulk_send(VIDEO_EPS[plane.head as usize], &frame, super::timeout(), GFP_KERNEL)?;
     Ok(())
 }
 
@@ -769,7 +891,9 @@ fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, _args: ()) -> impl PinI
 
 #[pin_data]
 pub(super) struct VinoConnector {
-    /// This connector's downstream-monitor EDID (`None` until the CP channel delivers it).
+    /// This connector's downstream-monitor EDID (`None` until the CP channel delivers it). Which
+    /// head a connector belongs to is tracked by its slot in [`VinoDrmData::connectors`], so the
+    /// connector itself needs no head field.
     #[pin]
     cached_edid: Mutex<Option<KVec<u8>>>,
 }
@@ -814,6 +938,28 @@ fn get_modes<'a>(
         connector.set_preferred_mode((FALLBACK_W as u32, FALLBACK_H as u32));
         n
     }
+
+    /// Report the head connected once the dock has delivered this head's downstream EDID (a real
+    /// monitor is attached and described), disconnected until then. A head with no monitor (e.g.
+    /// the second head of a single-monitor dock) stays disconnected rather than advertising a
+    /// phantom output.
+    fn detect(connector: &Connector<Self>, _force: bool) -> Status {
+        if connector.cached_edid.lock().is_some() {
+            Status::Connected
+        } else {
+            Status::Disconnected
+        }
+    }
+
+    /// Prune modes whose pixel clock exceeds a single head's bandwidth ceiling
+    /// ([`MAX_HEAD_CLOCK_KHZ`], ~4K@60).
+    fn mode_valid(_connector: &Connector<Self>, mode: &DisplayMode) -> ModeStatus {
+        if mode.clock() > MAX_HEAD_CLOCK_KHZ {
+            ModeStatus::ClockHigh
+        } else {
+            ModeStatus::Ok
+        }
+    }
 }
 
 // ---- DDC/CI I2C adapter -----------------------------------------------------
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index fd8d351dd1d9..049e8c4cb0f7 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -372,8 +372,12 @@ fn run(this: Arc<BringUp>) {
                             let n = blob.len();
                             let dev: &drm_sink::VinoDrmDevice = d;
                             let data: &drm_sink::VinoDrmData = dev;
-                            data.set_edid(dev, blob);
-                            dev_info!(cdev, "vino: cached dock EDID for connector ({n} bytes)\n");
+                            // The bring-up reads the dock's primary downstream EDID; head 0.
+                            data.set_edid(dev, 0, blob);
+                            dev_info!(
+                                cdev,
+                                "vino: cached dock EDID for head 0 connector ({n} bytes)\n"
+                            );
                         }
                     }
                     Err(e) => dev_info!(cdev, "vino: HDCP AKE incomplete ({e:?}) -- WIP\n"),
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* [RFC PATCH v2 10/10] drm/vino: hrtimer-driven software vblank
  2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
                     ` (8 preceding siblings ...)
  2026-07-03  3:02   ` [RFC PATCH v2 09/10] drm/vino: two heads, 90/270 rotation, damage clips and connector probe Mike Lothian
@ 2026-07-03  3:02   ` Mike Lothian
  2026-07-03  3:21     ` sashiko-bot
  9 siblings, 1 reply; 41+ messages in thread
From: Mike Lothian @ 2026-07-03  3:02 UTC (permalink / raw)
  To: rust-for-linux
  Cc: dri-devel, David Airlie, Simona Vetter, Thomas Zimmermann,
	Maarten Lankhorst, Maxime Ripard, Danilo Krummrich, Lyude Paul,
	Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
	Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
	linux-kernel, Mike Lothian

The atomic helpers were completing every page-flip immediately via
drm_atomic_helper_fake_vblank(), because the CRTC had no vblank support --
so a compositor got no refresh-rate pacing and updates arrived in bursts.

Implement VblankSupport for VinoCrtc backed by a per-CRTC hrtimer that
fires once per frame (from the mode's framedur_ns) and drives
drm_crtc_handle_vblank(). The CRTC now enables/disables vblank around
scanout (drm_crtc_vblank_on/off) and arms the page-flip completion event
to the next vblank in atomic_flush (drm_crtc_arm_vblank_event via the safe
PendingVblankEvent::arm()), so flips are paced to the display's refresh
rate. The timer free-runs once started and is cancelled when the CRTC is
dropped at teardown.

Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
 drivers/gpu/drm/vino/drm_sink.rs | 146 +++++++++++++++++++++++++++++--
 1 file changed, 139 insertions(+), 7 deletions(-)

diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
index ee04a5af9f7d..ce04c8b65461 100644
--- a/drivers/gpu/drm/vino/drm_sink.rs
+++ b/drivers/gpu/drm/vino/drm_sink.rs
@@ -39,21 +39,32 @@
 //! its content-protection channel for vino (see `docs/BLOCKER.md`), so `atomic_update`
 //! never gets past the first `bulk_send`.
 
+use core::sync::atomic::{AtomicBool, AtomicI64, AtomicPtr, Ordering};
 use kernel::{
     bindings, drm,
     drm::kms::{
         self,
         connector::{self, Connector, ConnectorGuard, ModeStatus, Status},
-        crtc::{self, CrtcAtomicCommit, RawCrtc as _, RawCrtcState as _},
+        crtc::{self, AsRawCrtc as _, CrtcAtomicCommit, RawCrtc as _, RawCrtcState as _},
         encoder,
         modes::DisplayMode,
         plane::{self, PlaneAtomicCommit, RawPlaneState as _},
+        vblank::{RawVblankCrtcState as _, VblankGuard, VblankSupport, VblankTimestamp},
         KmsDriver, ModeConfigGuard, ModeConfigInfo, ModeObject as _, NewKmsDevice, Probing,
     },
     i2c,
     error::code::EINVAL,
+    impl_has_hr_timer,
+    interrupt::LocalInterruptDisabled,
     prelude::*,
-    sync::{aref::ARef, new_mutex, Mutex},
+    sync::{aref::ARef, new_mutex, new_spinlock, Arc, ArcBorrow, Mutex, SpinLock},
+    time::{
+        hrtimer::{
+            ArcHrTimerHandle, HrTimer, HrTimerCallback, HrTimerCallbackContext, HrTimerPointer,
+            HrTimerRestart, RelativeMode,
+        },
+        Delta, Monotonic,
+    },
     types::ForLt,
 };
 
@@ -417,11 +428,69 @@ fn probe(dev: &NewKmsDevice<'_, Self, Probing>) -> Result {
 
 // ---- CRTC -----------------------------------------------------------------
 
+/// A software vblank source: an hrtimer that fires once per frame and drives
+/// `drm_crtc_handle_vblank()`, so the atomic helpers pace page-flips against a real vblank
+/// (via `drm_crtc_arm_vblank_event()` in [`VinoCrtc::atomic_flush`]) instead of completing them
+/// immediately with a fake vblank. The timer free-runs once started; `enabled` gates delivery so
+/// DPMS off/on is a flag flip. Cancelled when the owning [`VinoCrtc`] is dropped at teardown.
+#[pin_data]
+pub(super) struct VblankTimer {
+    #[pin]
+    timer: HrTimer<Self>,
+    /// The `drm_crtc` to deliver vblanks to (set when vblank is first enabled).
+    crtc: AtomicPtr<bindings::drm_crtc>,
+    /// One scanout frame in nanoseconds (from the mode's `framedur_ns`).
+    interval_ns: AtomicI64,
+    /// Whether vblanks should currently be delivered (toggled by enable/disable_vblank).
+    enabled: AtomicBool,
+    /// Whether the free-running timer has been started yet.
+    started: AtomicBool,
+}
+
+impl VblankTimer {
+    fn new() -> impl PinInit<Self> {
+        pin_init!(VblankTimer {
+            timer <- HrTimer::new(),
+            crtc: AtomicPtr::new(core::ptr::null_mut()),
+            interval_ns: AtomicI64::new(16_666_666), // ~60 Hz until a mode sets it
+            enabled: AtomicBool::new(false),
+            started: AtomicBool::new(false),
+        })
+    }
+}
+
+impl HrTimerCallback for VblankTimer {
+    type Pointer<'a> = Arc<Self>;
+
+    fn run(this: ArcBorrow<'_, Self>, mut ctx: HrTimerCallbackContext<'_, Self>) -> HrTimerRestart {
+        let crtc = this.crtc.load(Ordering::Relaxed);
+        if !crtc.is_null() && this.enabled.load(Ordering::Relaxed) {
+            // SAFETY: `crtc` is the `drm_crtc` stored in `enable_vblank` while the device is live;
+            // the timer is cancelled (its handle dropped) before the crtc is freed at teardown.
+            unsafe { bindings::drm_crtc_handle_vblank(crtc) };
+        }
+        let interval = this.interval_ns.load(Ordering::Relaxed).max(1_000_000);
+        ctx.forward_now(Delta::from_nanos(interval));
+        HrTimerRestart::Restart
+    }
+}
+
+impl_has_hr_timer! {
+    impl HasHrTimer<Self> for VblankTimer {
+        mode: RelativeMode<Monotonic>, field: self.timer
+    }
+}
+
 #[pin_data]
 pub(super) struct VinoCrtc {
     /// Which display head (0-based) this CRTC drives. Used for diagnostics; the mode-set/DDC CP
     /// messages this CRTC sends are not yet head-differentiated on the wire (see the module doc).
     head: u8,
+    /// The software vblank source for this CRTC.
+    vblank: Arc<VblankTimer>,
+    /// Keeps the timer running; dropping it (at CRTC teardown) cancels the timer.
+    #[pin]
+    vblank_handle: SpinLock<Option<ArcHrTimerHandle<VblankTimer>>>,
 }
 
 #[derive(Clone, Default)]
@@ -436,20 +505,25 @@ impl crtc::DriverCrtc for VinoCrtc {
     type Args = u8;
     type Driver = VinoDrmDriver;
     type State = VinoCrtcState;
-    type VblankImpl = core::marker::PhantomData<Self>;
+    type VblankImpl = Self;
 
     fn new(
         _device: &drm::Device<Self::Driver, drm::Uninit>,
         head: &u8,
     ) -> impl PinInit<Self, Error> {
-        try_pin_init!(VinoCrtc { head: *head })
+        try_pin_init!(VinoCrtc {
+            head: *head,
+            vblank: Arc::pin_init(VblankTimer::new(), GFP_KERNEL)?,
+            vblank_handle <- new_spinlock!(None),
+        })
     }
 
-    /// The display is turning on (scanout begins). Pushes a live mode-set CP message for the
-    /// negotiated mode and brings the monitor out of DPMS standby -- both no-ops until CP
-    /// engages (the wall).
+    /// The display is turning on (scanout begins). Enables vblank pacing, pushes a live mode-set CP
+    /// message for the negotiated mode and brings the monitor out of DPMS standby -- the latter two
+    /// no-ops until CP engages (the wall).
     fn atomic_enable(commit: CrtcAtomicCommit<'_, Self>) {
         let crtc = commit.crtc();
+        crtc.vblank_on();
         let head = crtc.head;
         let data: &VinoDrmData = crtc.drm_dev();
         let new = commit.take_new_state();
@@ -475,12 +549,70 @@ fn atomic_enable(commit: CrtcAtomicCommit<'_, Self>) {
     /// no-op until CP engages.
     fn atomic_disable(commit: CrtcAtomicCommit<'_, Self>) {
         let crtc = commit.crtc();
+        crtc.vblank_off();
         let head = crtc.head;
         let data: &VinoDrmData = crtc.drm_dev();
         data.update_gamma(None);
         let _ = data.set_vcp(super::cp::VCP_POWER_MODE, super::cp::POWER_OFF);
         pr_info!("vino: KMS CRTC disable -- head {head} display OFF (scanout stopped)\n");
     }
+
+    /// Arm the page-flip completion event to be sent by the next vblank tick, so userspace is paced
+    /// to the refresh rate rather than signalled immediately.
+    fn atomic_flush(commit: CrtcAtomicCommit<'_, Self>) {
+        let crtc = commit.crtc();
+        let mut new = commit.take_new_state();
+        if let Some(pending) = new.get_pending_vblank_event() {
+            match crtc.vblank_get() {
+                Ok(vbl_ref) => pending.arm(vbl_ref),
+                // Vblank couldn't be enabled (e.g. mid-teardown): fall back to sending now.
+                Err(_) => pending.send(),
+            }
+        }
+    }
+}
+
+impl VblankSupport for VinoCrtc {
+    type Crtc = VinoCrtc;
+
+    fn enable_vblank(
+        crtc: &crtc::Crtc<Self::Crtc>,
+        vblank_guard: &VblankGuard<'_, Self::Crtc>,
+        _irq: &LocalInterruptDisabled,
+    ) -> Result {
+        let data: &VinoCrtc = crtc;
+        // Track the mode's real frame duration so the tick matches the negotiated refresh rate.
+        let fd = vblank_guard.frame_duration();
+        if fd > 0 {
+            data.vblank.interval_ns.store(fd as i64, Ordering::Relaxed);
+        }
+        data.vblank.crtc.store(crtc.as_raw(), Ordering::Relaxed);
+        data.vblank.enabled.store(true, Ordering::Relaxed);
+        // Start the free-running timer the first time vblank is enabled.
+        if !data.vblank.started.swap(true, Ordering::Relaxed) {
+            let interval = data.vblank.interval_ns.load(Ordering::Relaxed);
+            let handle = data.vblank.clone().start(Delta::from_nanos(interval));
+            *data.vblank_handle.lock() = Some(handle);
+        }
+        Ok(())
+    }
+
+    fn disable_vblank(
+        crtc: &crtc::Crtc<Self::Crtc>,
+        _vblank_guard: &VblankGuard<'_, Self::Crtc>,
+        _irq: &LocalInterruptDisabled,
+    ) {
+        let data: &VinoCrtc = crtc;
+        data.vblank.enabled.store(false, Ordering::Relaxed);
+    }
+
+    fn get_vblank_timestamp(
+        _crtc: &crtc::Crtc<Self::Crtc>,
+        _in_vblank_irq: bool,
+    ) -> Option<VblankTimestamp> {
+        // Let DRM estimate the timestamp from the mode timings.
+        None
+    }
 }
 
 // ---- Planes: primary (scanout) + cursor -------------------------------------
-- 
2.55.0


^ permalink raw reply related	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH v2 07/10] drm/vino: wire the hardware cursor plane
  2026-07-03  3:02   ` [RFC PATCH v2 07/10] drm/vino: wire the hardware cursor plane Mike Lothian
@ 2026-07-03  3:11     ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-07-03  3:11 UTC (permalink / raw)
  To: Mike Lothian; +Cc: dri-devel, ojeda

Thank you for your contribution! Sashiko AI review found 4 potential issue(s) to consider:
- [High] The cursor plane's atomic update callback performs operations that can sleep, including framebuffer vmap and GFP_KERNEL allocations, violating atomic context rules.
- [Medium] The tracking state `cursor_last` is unconditionally updated before the new cursor image is successfully mapped and transmitted, breaking subsequent updates if an error occurs.
- [Medium] The driver does not send commands to the hardware dock to hide the cursor when it is disabled or moved entirely off-screen.
- [Medium] Manual color channel extraction from a natively read 32-bit word produces reversed colors on big-endian architectures.
--

commit c3d3f61b0980905330252aecb30ece0564fb99bc
Author: Mike Lothian <mike@fireburn.co.uk>

drm/vino: wire the hardware cursor plane

This commit re-wires the hardware cursor plane for the vino driver by creating
a Type::Cursor plane. The cursor plane's atomic_update forwards the cursor
bitmap and position to the dock over CP.

> diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
> index 450c7c84a0677..fde52336fcd14 100644
> --- a/drivers/gpu/drm/vino/drm_sink.rs
> +++ b/drivers/gpu/drm/vino/drm_sink.rs

[ ... ]

> @@ -429,6 +454,36 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
>          let plane = commit.plane();
>          let data: &VinoDrmData = plane.drm_dev();
>          let new = commit.take_new_state();
> +
> +        // Cursor plane: forward the cursor bitmap/position to the dock over CP (id=0x1b create,
> +        // 0x401c image, 0x1a move -- see `cp::cursor_*`). A no-op until CP engages, like scanout.
> +        if plane.is_cursor {
> +            use core::sync::atomic::Ordering::Relaxed;
> +            const HEAD: u8 = 0;
> +            match new.framebuffer::<VinoDrmDriver>() {
> +                Some(fb) => {
> +                    let w = fb.width() as u16;
> +                    let h = fb.height() as u16;
> +                    // Re-upload the bitmap only when the framebuffer changes; always update the
> +                    // position (a bare cursor move must not re-send the whole image).
> +                    let key = fb as *const _ as usize;
> +                    if plane.cursor_last.swap(key, Relaxed) != key {

[Severity: Medium]
If read_cursor_bgra() fails, doesn't this unconditional update of
cursor_last mean we'll permanently skip uploading this framebuffer on
subsequent updates, falsely assuming it was already uploaded?

> +                        let _ = data.send_cp(0x1b, 0, |ctr| super::cp::cursor_create(ctr, HEAD, w, h));
> +                        if let Ok(bgra) = read_cursor_bgra(fb, w as usize, h as usize) {

[Severity: High]
Does read_cursor_bgra() sleep in atomic context?

Plane atomic_update callbacks run in atomic context, but read_cursor_bgra()
performs sleepable operations like vmap() and GFP_KERNEL allocations. Will
this trigger lockdep warnings ("scheduling while atomic") or kernel panics?

> +                            let _ = data
> +                                .send_cp(0x401c, 0, |ctr| super::cp::cursor_image(ctr, HEAD, w, h, &bgra));
> +                        }
> +                    }
> +                    let x = new.crtc_x().max(0) as u16;
> +                    let y = new.crtc_y().max(0) as u16;

[Severity: Medium]
Are we leaving a ghost cursor visible when it should be hidden?

By clamping negative coordinates to 0, a cursor moved off-screen to the left
or top will stick to the screen edge instead of moving out of view.

> +                    let _ = data.send_cp(0x1a, 0, |ctr| super::cp::cursor_move(ctr, HEAD, x, y));
> +                }
> +                // Cursor disabled: forget the last bitmap so it re-uploads if it comes back.
> +                None => plane.cursor_last.store(0, Relaxed),

[Severity: Medium]
Similarly, when the cursor is logically disabled (framebuffer is None), this
clears the software state but doesn't send any hardware commands to the dock
to actually hide the cursor. Will the cursor remain visible?

> +            }
> +            return;
> +        }
> +
>          let Some(fb) = new.framebuffer::<VinoDrmDriver>() else { return };

[ ... ]

> @@ -480,6 +535,31 @@ fn prime_video_eps(dev: &super::usb::Interface<kernel::device::Bound>) {
>      }
>  }
>  
> +/// Map the cursor framebuffer and convert it to the `w*h*4` BGRA bitmap the dock expects
> +/// (`cp::cursor_image`). The source is XRGB/ARGB8888; each pixel is written out as B, G, R, A.
> +fn read_cursor_bgra(
> +    fb: &kms::framebuffer::Framebuffer<VinoDrmDriver>,
> +    w: usize,
> +    h: usize,
> +) -> Result<KVec<u8>> {
> +    let vmap = fb.vmap()?;
> +    let src = vmap.as_ptr();
> +    let pitch = fb.pitch(0) as usize;
> +    let mut out = KVec::with_capacity(w * h * 4, GFP_KERNEL)?;
> +    for dy in 0..h {
> +        for dx in 0..w {
> +            // SAFETY: `dy*pitch + dx*4 + 3` is within the mapped cursor framebuffer (`pitch*h`
> +            // bytes); `dx < w <= pitch/4`, `dy < h`.
> +            let px = unsafe { (src.add(dy * pitch + dx * 4) as *const u32).read_unaligned() };
> +            out.push((px & 0xff) as u8, GFP_KERNEL)?; // B
> +            out.push(((px >> 8) & 0xff) as u8, GFP_KERNEL)?; // G
> +            out.push(((px >> 16) & 0xff) as u8, GFP_KERNEL)?; // R
> +            out.push(((px >> 24) & 0xff) as u8, GFP_KERNEL)?; // A

[Severity: Medium]
Will this color extraction logic produce distorted or reversed colors on
big-endian architectures?

The little-endian DRM_FORMAT_ARGB8888 buffer is read as a native u32. On a
big-endian system, extracting the bytes with these shifts will grab the
wrong bytes for each color channel.

> +        }
> +    }
> +    Ok(out)
> +}

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260703030217.2886-1-mike@fireburn.co.uk?part=7

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH v2 03/10] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm sequence
  2026-07-03  3:02   ` [RFC PATCH v2 03/10] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm sequence Mike Lothian
@ 2026-07-03  3:13     ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-07-03  3:13 UTC (permalink / raw)
  To: Mike Lothian; +Cc: ojeda, dri-devel

Thank you for your contribution! Sashiko AI review found 3 potential issue(s) to consider:
- [High] Use of sleeping `GFP_KERNEL` memory allocations and blocking USB operations in atomic KMS callbacks.
- [High] Constant mismatch in `aux_for_id` causes fallback misparsing for `cursor_image` messages.
- [Medium] The `seal` function fails to strip the stale MAC tag and append a fresh one, contradicting its explicit documentation.
--

commit 399a257fc41428dee57be4f93ca9bcdacd1d9785
Author: Mike Lothian <mike@fireburn.co.uk>

drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm sequence

This commit implements the post-AKE control-plane wire format for the vino
driver. It includes the AES-CTR seal, the Dl3Cmac trailer, the stream-open
arm marker, and message builders for mode-set, cursor, and DDC/CI.

> diff --git a/drivers/gpu/drm/vino/cp.rs b/drivers/gpu/drm/vino/cp.rs
> new file mode 100644
> index 0000000000000..4d55b96999b02
> --- /dev/null
> +++ b/drivers/gpu/drm/vino/cp.rs
[ ... ]
> +pub(super) fn cursor_image(
> +    counter: u16,
> +    head: u8,
> +    w: u16,
> +    h: u16,
> +    bgra: &[u8],
> +) -> Result<KVec<u8>> {
> +    // w*h*4 can wrap a 32-bit usize (max ~1.7e10 > u32::MAX), which would let an
> +    // undersized bgra pass the check; compute it with checked arithmetic so an
> +    // overflow is rejected as a mismatch rather than silently bypassing validation.
> +    let expected = (w as usize)
> +        .checked_mul(h as usize)
> +        .and_then(|n| n.checked_mul(4));
> +    if expected != Some(bgra.len()) {
> +        return Err(EINVAL);
> +    }
> +    let mut b = KVec::with_capacity(32 + bgra.len(), GFP_KERNEL)?;

[Severity: High]
Does this allocate memory using a sleeping flag (GFP_KERNEL) while in an
atomic context?

When the compositor requests a frame flip or a cursor update, the DRM core
invokes the VinoPlane::atomic_update callback (which runs in an atomic
context). This atomic callback executes cp::cursor_image, which allocates
memory dynamically using GFP_KERNEL.

> +    cursor_header(&mut b, 0x401c, 0x41, counter, head)?;

[Severity: High]
Will the 0x40 high-byte flag on this cursor image ID cause a mismatch in
aux_for_id()?

In aux_for_id(), we check for 0x1c for the cursor image:

    0x1a => 0x04, // cursor move
    0x1b => 0x03, // cursor create
    0x1c => 0x02, // cursor image
    0x1f => 0x0f,

Because aux_for_id() strictly checks for 0x1c instead of 0x401c, it will fall
through to the default arm and incorrectly return (body_len / 4) as u16,
rather than the expected 0x02 length marker.

> +    pad_to(&mut b, 32)?; // off24..31 zero (no w/h here)
> +    b.extend_from_slice(bgra, GFP_KERNEL)?; // bitmap @ off32
> +    Ok(b)
> +}
[ ... ]
> +pub(super) fn seal(
> +    ks: &[u8; 16],
> +    riv: &[u8; 8],
> +    seq: u32,
> +    inner: &[u8],
> +) -> Result<KVec<u8>> {
> +    // The interactive CP stream: session ks, wire sub 0x24.
> +    seal_stream(ks, riv, 0x24, seq, inner)

[Severity: Medium]
Is the stale MAC tag properly stripped here?

The documentation for seal() explicitly mentions that we should encrypt only
content = inner[..len-16] and append a fresh tag. However, the implementation
delegates to a generic stream encrypter (seal_stream) on the entire inner
buffer, which ignores the requirement to slice off the stale tag and append a
live MAC.

> +}
[ ... ]

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260703030217.2886-1-mike@fireburn.co.uk?part=3

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH v2 05/10] drm/vino: add the DRM/KMS sink, built on the safe KMS mode-object layer
  2026-07-03  3:02   ` [RFC PATCH v2 05/10] drm/vino: add the DRM/KMS sink, built on the safe KMS mode-object layer Mike Lothian
@ 2026-07-03  3:13     ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-07-03  3:13 UTC (permalink / raw)
  To: Mike Lothian; +Cc: dri-devel, ojeda

Thank you for your contribution! Sashiko AI review found 5 potential issue(s) to consider:
- [High] Sleeping in atomic context within KMS atomic callbacks (Subsystem Guide Violation).
- [High] Out-of-bounds kernel memory read in framebuffer scanout due to unvalidated destination dimensions.
- [High] Integer overflow in scanout buffer size calculation on 32-bit architectures leads to out-of-bounds writes.
- [High] Device-specific initialization and recovery states are tracked in global variables, breaking multi-device support.
- [Medium] Incorrect pixel color extraction on big-endian architectures due to native-endian memory reads.
--

commit 717e075a35f786d4dc6a24d62c35ac5c6ff9090a
Author: Mike Lothian <mike@fireburn.co.uk>

drm/vino: add the DRM/KMS sink, built on the safe KMS mode-object layer

This commit introduces the DRM/KMS sink for the vino driver using the safe
KMS mode-object layer. It wires up a CRTC, a primary plane, a virtual
encoder, and a connector to support mode-setting and scanning out
framebuffers to the dock.

> diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
> new file mode 100644
> index 0000000000000..450c7c84a0677
> --- /dev/null
> +++ b/drivers/gpu/drm/vino/drm_sink.rs

[ ... ]

> +    fn atomic_enable(commit: CrtcAtomicCommit<'_, Self>) {
> +        let data: &VinoDrmData = commit.crtc().drm_dev();
> +        let new = commit.take_new_state();
> +        let timing = super::cp::timing_from_drm_mode(new.mode());
> +        pr_info!(
> +            "vino: KMS CRTC enable -- display ON, mode {}x{}@{} (scanout begins)\n",
> +            timing.hactive,
> +            timing.vactive,
> +            timing.refresh_hz
> +        );
> +        if let Err(e) = data.send_cp(0x48, 16, |ctr| super::cp::set_mode(ctr, &timing)) {

[Severity: High]
Does send_cp() perform sleeping operations?

According to DRM subsystem guidelines, CRTC atomic callbacks execute in an
atomic context where sleeping is strictly forbidden.

Since send_cp() acquires a Mutex and performs a blocking USB transfer with
GFP_KERNEL, could this cause a "scheduling while atomic" issue during
display enable?

> +            pr_warn!("vino: runtime mode-set send failed ({e:?})\n");
> +        }
> +        let _ = data.set_vcp(super::cp::VCP_POWER_MODE, super::cp::POWER_ON);
> +    }

[ ... ]

> +    fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {

[Severity: High]
Are we performing sleeping operations in an atomic context here?

The Plane atomic update callback synchronously invokes the frame conversion
and USB scanout pipeline.

This function eventually calls encode_and_send(), which locks a Mutex,
allocates memory with GFP_KERNEL, and blocks on USB bulk transfers. Will
this lead to system instability during mode-sets or page-flips?

> +        if !super::CP_ENGAGED.load(core::sync::atomic::Ordering::SeqCst) {
> +            return;
> +        }
> +        let plane = commit.plane();
> +        let data: &VinoDrmData = plane.drm_dev();
> +        let new = commit.take_new_state();
> +        let Some(fb) = new.framebuffer::<VinoDrmDriver>() else { return };
> +        // The plane's destination geometry mirrors the negotiated mode (the compositor sizes the
> +        // primary plane 1:1 with the virtual output), so this drives the dynamic scanout
> +        // resolution.
> +        let (w, h) = (new.crtc_w() as usize, new.crtc_h() as usize);

[Severity: High]
Could these destination dimensions exceed the bounds of the underlying
source framebuffer?

These values come from the userspace-controlled plane destination size.
Because there is no atomic_check to validate them against the attached
framebuffer size, could the pixel extraction loops further down read out of
bounds memory?

> +
> +        use core::sync::atomic::Ordering::Relaxed;
> +        // Throttle: while scanout is failing (dock NAKing because CP isn't engaged), skip the
> +        // upcoming pageflips set by the backoff below instead of converting+encoding+sending a
> +        // frame the dock will just drop.
> +        let skip = super::SCANOUT_SKIP.load(Relaxed);

[Severity: High]
Can tracking frame backoffs in a global static cause interference between
multiple connected devices?

If multiple docks are in use, they would share these error recovery states
and potentially affect each other's scanout rate.

[ ... ]

> +fn prime_video_eps(dev: &super::usb::Interface<kernel::device::Bound>) {
> +    if !super::EP08_SCANOUT_PRIMED.swap(true, core::sync::atomic::Ordering::SeqCst) {

[Severity: High]
Does using a global static here prevent multiple connected docks from
initializing properly?

If a second dock is connected, it appears it would see this global flag as
true and skip clearing the halts on its video endpoints. Could this state
be tracked per-device instead?

> +        for ep in [0x08u8, 0x0a, 0x0b, 0x0c] {
> +            let _ = dev.clear_halt(ep);
> +        }

[ ... ]

> +fn encode_and_send(
> +    data: &VinoDrmData,
> +    plane: &plane::Plane<VinoPlane>,
> +    vaddr: *const u8,
> +    pitch: usize,
> +    w: usize,
> +    h: usize,
> +) -> Result {

[ ... ]

> +        let first = st.enc.is_none() || st.dims != (w, h);
> +        if first {
> +            st.enc = Some(super::video::Encoder::new(w, h, super::video::Mode::Rle)?);
> +            st.cur = VVec::from_elem(0u16, w * h, GFP_KERNEL)?;

[Severity: High]
Can this size calculation silently overflow on 32-bit architectures?

If userspace passes large values for crtc_w and crtc_h, w * h could wrap
around the 32-bit usize limit.

This would result in an undersized allocation, causing the nested loops
below to write out-of-bounds to the heap.

> +            st.dims = (w, h);
> +            st.hint = 0;
> +        }
> +        let ScanoutState { enc, cur, seq, hint, dims: _ } = &mut *st;
> +        for dy in 0..h {
> +            for dx in 0..w {
> +                // SAFETY: `dy*pitch + dx*4 + 3` is within the mapped source framebuffer
> +                // (`pitch*h` bytes); `dx < w <= pitch/4`, `dy < h`.
> +                let px = unsafe { (vaddr.add(dy * pitch + dx * 4) as *const u32).read_unaligned() };
> +                let (r, g, b) = ((px >> 16) & 0xff, (px >> 8) & 0xff, px & 0xff);

[Severity: Medium]
Will this color channel extraction work correctly on big-endian
architectures?

XRGB8888 has a fixed byte order in memory. By reading it as a native u32
and applying bitwise shifts, the bytes will be loaded in reverse order on a 
big-endian system, which might cause the wrong color channels to be
extracted.

> +                cur[dy * w + dx] = (((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)) as u16;
> +            }
> +        }

[ ... ]

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260703030217.2886-1-mike@fireburn.co.uk?part=5

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH v2 02/10] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE handshake
  2026-07-03  3:02   ` [RFC PATCH v2 02/10] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE handshake Mike Lothian
@ 2026-07-03  3:14     ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-07-03  3:14 UTC (permalink / raw)
  To: Mike Lothian; +Cc: dri-devel, ojeda

Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
- [High] The HDCP OUT message builder `body()` omits a mandatory 2-byte padding at the start of the message body, misaligning the header fields and payload by 2 bytes.
--

commit ff0736a0543094c7762c8587ebbe6bbde5a3fe43
Author: Mike Lothian <mike@fireburn.co.uk>

drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE handshake

This commit introduces a clean-room implementation of the HDCP 2.2 handshake
(AKE/LC/SKE) for the vino driver. It includes cryptographic adapters, protocol
message builders, key derivation routines, and captured plaintext
control-plane skeletons. The implementation builds and parses wire frames
strictly verified against the reference daemon traffic.

> diff --git a/drivers/gpu/drm/vino/ake.rs b/drivers/gpu/drm/vino/ake.rs
> new file mode 100644
> index 0000000000000..38919624cb2f3
> --- /dev/null
> +++ b/drivers/gpu/drm/vino/ake.rs
[ ... ]
> +/// Allocate a body_len-byte zeroed body with the sec 5.1 header filled in
> +/// (sub_size, the 0x0010 marker, hdcp_seq, the 0x30 marker and msg_id).
> +/// The caller writes the payload into body[28..].
> +fn body(body_len: usize, sub_size: u16, hdcp_seq: u32, msg_id: u8) -> Result<KVec<u8>> {
> +    let mut b = KVec::from_elem(0u8, body_len, GFP_KERNEL)?;
> +    b[0..2].copy_from_slice(&sub_size.to_le_bytes());
> +    b[2..4].copy_from_slice(&0x0010u16.to_le_bytes());
> +    b[4..8].copy_from_slice(&hdcp_seq.to_le_bytes());
> +    b[22..26].copy_from_slice(&0x0000_0030u32.to_le_bytes());
> +    b[27] = msg_id;

[Severity: High]
Does this message builder omit a mandatory 2-byte padding at the start of the
payload?

Comparing this against the golden capture (CAP_PLAIN_1080P) in golden.rs, the
48-byte body starts with a 2-byte zero padding at offsets 16-17 (right after
the 16-byte transport header), placing the sub_size at offsets 18-19 (body
offset 2).

By writing sub_size at b[0..2], hdcp_seq at b[4..8], the marker at b[22..26],
and msg_id at b[27], the entire layout appears to be shifted left by 2 bytes
compared to the expected hardware offsets.

Will this misalignment cause the dock to reject the HDCP handshake because
the fields and cryptographic payload are not where the hardware expects them?

> +    Ok(b)
> +}

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260703030217.2886-1-mike@fireburn.co.uk?part=2

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH v2 01/10] drm/vino: add DisplayLink DL3 dock skeleton and protocol framing
  2026-07-03  3:02   ` [RFC PATCH v2 01/10] drm/vino: add DisplayLink DL3 dock skeleton and protocol framing Mike Lothian
@ 2026-07-03  3:15     ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-07-03  3:15 UTC (permalink / raw)
  To: Mike Lothian; +Cc: dri-devel, ojeda

Thank you for your contribution! Sashiko AI review found 3 potential issue(s) to consider:
- [Medium] The `DRM_VINO` Kconfig selects a non-existent symbol `CRYPTO_LIB_RSA`.
- [Medium] Selecting `RUST_DRM_GEM_SHMEM_HELPER` without inheriting its `MMU` dependency.
- [Low] Enabling `CONFIG_DRM_VINO` breaks the kernel build because the module's root source file is missing.
--

commit 06fb786f2554d0076260c52a80817260c6d10437
Author: Mike Lothian <mike@fireburn.co.uk>

drm/vino: add DisplayLink DL3 dock skeleton and protocol framing

This commit introduces the module skeleton for the new DisplayLink DL3 USB
dock driver. It adds Kconfig and Makefile entries and includes standalone
wire framing protocol and randomness wrapper code.

> diff --git a/drivers/gpu/drm/vino/Kconfig b/drivers/gpu/drm/vino/Kconfig
> new file mode 100644
> index 0000000000000..f5fe6670a4886
> --- /dev/null
> +++ b/drivers/gpu/drm/vino/Kconfig
> @@ -0,0 +1,20 @@
> +# SPDX-License-Identifier: GPL-2.0
> +config DRM_VINO
> +	tristate "DisplayLink DL3 (Vino) open driver"
> +	depends on USB
> +	depends on DRM
> +	depends on RUST
> +	select DRM_KMS_HELPER
> +	select RUST_DRM_GEM_SHMEM_HELPER

[Severity: Medium]
Does DRM_VINO also need to depend on MMU? RUST_DRM_GEM_SHMEM_HELPER depends on
DRM and MMU. Selecting it without inheriting the MMU dependency can trigger
unmet direct dependency warnings and potential build failures on MMU-less
architectures.

> +	select CRYPTO_LIB_RSA

[Severity: Medium]
Is CRYPTO_LIB_RSA the correct Kconfig symbol? It appears this symbol does not
exist in the kernel tree, and the correct symbol might be CRYPTO_RSA. Selecting
a non-existent symbol causes the directive to be ignored and can silently leave
crypto dependencies disabled.

> +	help
> +	  Open in-kernel Rust driver for DisplayLink DL3 USB docks (Dell

[ ... ]

> diff --git a/drivers/gpu/drm/vino/Makefile b/drivers/gpu/drm/vino/Makefile
> new file mode 100644
> index 0000000000000..6e39668040f30
> --- /dev/null
> +++ b/drivers/gpu/drm/vino/Makefile
> @@ -0,0 +1,2 @@
> +# SPDX-License-Identifier: GPL-2.0
> +obj-$(CONFIG_DRM_VINO) += vino.o

[Severity: Low]
Could this break the build if CONFIG_DRM_VINO is enabled at this specific
commit? It looks like there is no corresponding vino.rs or vino.c added yet,
which causes a missing target error.

I noticed the missing source file is introduced in the subsequent commit
"drm/vino: add the DisplayLink DL3 dock driver", but this temporary state might
break kernel bisectability.

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260703030217.2886-1-mike@fireburn.co.uk?part=1

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH v2 06/10] drm/vino: add the DisplayLink DL3 dock driver
  2026-07-03  3:02   ` [RFC PATCH v2 06/10] drm/vino: add the DisplayLink DL3 dock driver Mike Lothian
@ 2026-07-03  3:18     ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-07-03  3:18 UTC (permalink / raw)
  To: Mike Lothian; +Cc: ojeda, dri-devel

Thank you for your contribution! Sashiko AI review found 3 potential issue(s) to consider:
- [High] Using global static variables for per-device state breaks support for multiple connected devices and causes state collisions.
- [High] Bypassing the Rust workqueue abstractions to call the raw C `cancel_work_sync` leaks the `Arc` reference transferred to the workqueue.
- [Medium] The async IN queue for the EP84 status endpoint is incorrectly configured with an OUT endpoint address (`0x04`), causing the queue creation to fail.
--

commit d6f1e7eb7413b3d79af598de7aa00c7613895253
Author: Mike Lothian <mike@fireburn.co.uk>
drm/vino: add the DisplayLink DL3 dock driver

This commit adds a new open-source Rust driver for DisplayLink DL3 USB docks,
including the Dell D6000. It introduces the USB bulk and control transfer
handshake, HDCP 2.2 control plane, and registers a DRM/KMS device.

> diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
> new file mode 100644
> index 0000000000000..eb4378a747c35
> --- /dev/null
> +++ b/drivers/gpu/drm/vino/vino.rs
> @@ -0,0 +1,2617 @@
> +// SPDX-License-Identifier: GPL-2.0

[ ... ]

> +/// Set once the dock has actually engaged the CP cipher (`wsub=0x45` acks > 0). EP08 video is
> +/// gated on it: pushing frames at a dock whose CP channel is dead makes it fault and USB-reset.
> +/// NOTE: with the current CP-engagement wall (see the file header) this is never set on real
> +/// hardware -- the dock runs the whole plaintext handshake but never engages the encrypted CP.
> +static CP_ENGAGED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
> +
> +/// One-shot: clear-halt + prime the video endpoints before the first live-scanout EP08 write.
> +static EP08_SCANOUT_PRIMED: core::sync::atomic::AtomicBool =
> +    core::sync::atomic::AtomicBool::new(false);
> +
> +/// Select the live scanout codec. `false` (default) = the RLE/mode-2 path (`video::Encoder`),
> +/// which is what the dock currently runs and which the dock NAKs pre-CP anyway. `true` = the
> +/// byte-exact Vino WHT **colour** codec (`video::wht::colour_frame_ep08`), the bandwidth-efficient
> +/// DLM-quality path. Kept a compile-time flag (default off) so the RLE path stays the live default
> +/// and the switch is testable independently the moment the CP wall falls; the WHT path needs a
> +/// 64x16-aligned mode and falls back to RLE otherwise (see `docs/VIDEO-TODO.md`).
> +const EP08_WHT_CODEC: bool = false;
> +
> +/// Consecutive failed live-scanout frames, for log rate-limiting. Until CP engages, the dock
> +/// NAKs every EP08 write (EPROTO), so without this every compositor pageflip would spam dmesg.
> +static SCANOUT_FAILS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
> +
> +/// Pageflip throttle: number of upcoming pageflips to skip before the next scanout attempt
> +/// (a backoff while the dock NAKs). A single successful frame clears it.
> +static SCANOUT_SKIP: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
> +
> +/// Set once the bring-up work item finishes (AKE/CP attempt done). `detect` only connects the
> +/// live-scanout connector AFTER this, so a compositor enabling the output cannot start EP08
> +/// scanout on top of the still-running AKE on the same USB device.
> +static BRINGUP_COMPLETE: core::sync::atomic::AtomicBool =
> +    core::sync::atomic::AtomicBool::new(false);

[Severity: High]
Does this code support multiple connected devices? It looks like these
global static variables are used to track per-device state. If multiple
DisplayLink D6000 docks are connected, won't they race on these shared flags
during initialization and operation? For instance, one device completing
bring-up could incorrectly flag all devices as ready via BRINGUP_COMPLETE.

[ ... ]

> +        // Open the persistent async EP84 IN reader BEFORE the arm marker and msg0, so
> +        // `EP84_QUEUE_DEPTH` IN transfers are already posted when the dock pushes its post-arm
> +        // reply (DLM's libusb always-pending-IN behaviour). Draining EP84 concurrently stops the
> +        // dock's IN FIFO filling and NAKing our OUT (the sync-bulk deadlock that produced a 100 ms
> +        // msg0 NAK). RAII: dropping the queue at function exit kills+frees the URBs.
> +        let mut ep84_q = match dev.bulk_in_queue(0x04, EP84_QUEUE_DEPTH, EP84_BUF) {

[Severity: Medium]
Is the endpoint address correct here in VinoDriver::send_cp_setup()? It looks
like it's opening an IN queue for endpoint 0x84 but passes 0x04 instead.
Since 0x04 is missing the USB_DIR_IN bit (0x80), won't the queue creation
fail and force the driver to fall back to the synchronous bulk_recv mode?

[ ... ]

> +    fn disconnect<'bound>(intf: &'bound usb::Interface<Core<'_>>, data: Pin<&Self>) {
> +        let dev: &device::Device<Core<'_>> = intf.as_ref();
> +        // Flush the deferred bring-up before the interface is unbound: `cancel_work_sync`
> +        // dequeues it if pending and blocks until it returns if already running, so no
> +        // USB I/O races the unbind (see the `Interface::as_bound` contract in
> +        // `BringUp::run`). Safe to call when the work already finished or never ran.
> +        if let Some(work) = data.bringup.as_ref() {
> +            // SAFETY: `work.work` is a live `Work` field of a pinned, refcounted `BringUp`;
> +            // `raw_get` yields its valid `work_struct`, which `cancel_work_sync` only reads
> +            // and synchronises against. The `Arc` keeps the allocation alive across the call.
> +            unsafe {
> +                let wptr = Work::raw_get(core::ptr::addr_of!(work.work));
> +                bindings::cancel_work_sync(wptr);
> +            }
> +        }

[Severity: High]
Does this direct call to bindings::cancel_work_sync() in
VinoDriver::disconnect() leak the Arc reference transferred to the
workqueue? Since the Rust workqueue enqueue consumes an Arc<BringUp>
that is usually dropped when the callback executes, bypassing the Rust
abstractions to cancel the work means the callback won't run, leaving
the Arc without a mechanism to drop it.

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260703030217.2886-1-mike@fireburn.co.uk?part=6

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH v2 08/10] drm/vino: wire CRTC gamma, plane rotation and DDC/CI monitor controls
  2026-07-03  3:02   ` [RFC PATCH v2 08/10] drm/vino: wire CRTC gamma, plane rotation and DDC/CI monitor controls Mike Lothian
@ 2026-07-03  3:20     ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-07-03  3:20 UTC (permalink / raw)
  To: Mike Lothian; +Cc: dri-devel, ojeda

Thank you for your contribution! Sashiko AI review found 8 potential issue(s) to consider:

New issues:
- [High] The patch introduces a `gamma` field protected by a `kernel::sync::Mutex` and locks it from within DRM atomic commit callbacks (`atomic_enable`, `atomic_disable`, `atomic_update`), which execute in atomic context where sleeping is forbidden.
- [High] The DDC/CI payload length is silently truncated if it exceeds 255 bytes, leading to malformed CP packets.
- [Medium] Dynamic `GAMMA_LUT` property updates are silently ignored while the display is active.
- [Medium] The rotation property is missing on the cursor plane and the rotation is completely ignored when reading cursor pixels.
- [Medium] The DDC/CI I2C adapter's `master_xfer` incorrectly skips unsupported messages and transmission failures instead of returning an error, breaking sequential processing guarantees.
- [Low] The import `i2c` does not follow the kernel vertical import style.

Pre-existing issues:
- [Critical] Missing plane scaling rejection allows userspace to trigger out-of-bounds kernel memory reads during scanout.
- [High] The `ddc_forward` function allocates memory using `GFP_KERNEL` when called from the `atomic_disable` DRM callback, violating atomic context rules.
--

commit efd9ec1e849fe00fb25b5f1bc3faedd48ed3e0f5
Author: Mike Lothian <mike@fireburn.co.uk>

drm/vino: wire CRTC gamma, plane rotation and DDC/CI monitor controls

This commit consumes the new gamma, rotation, and i2c bindings for the vino 
driver. It enables a 256-entry GAMMA_LUT, adds a rotation property on the 
primary/cursor plane, and registers a virtual I2C adapter for DDC/CI controls.

> diff --git a/drivers/gpu/drm/vino/cp.rs b/drivers/gpu/drm/vino/cp.rs
> --- a/drivers/gpu/drm/vino/cp.rs
> +++ b/drivers/gpu/drm/vino/cp.rs

[ ... ]

> @@ -151,14 +151,24 @@ pub(super) fn set_mode(counter: u16, t: &Timing) -> Result<KVec<u8>> {
>  /// the CP wall), so re-check against a capture once CP engages. The DDC/CI bytes themselves
>  /// ([`ddc_ci_set_vcp`]) are standard and verified.
>  pub(super) fn ddc_set_vcp(counter: u16, vcp: u8, value: u16) -> Result<KVec<u8>> {
> -    let payload = ddc_ci_set_vcp(vcp, value);
> -    let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
> +    ddc_forward(counter, DDCCI_I2C_ADDR, &ddc_ci_set_vcp(vcp, value))
> +}
> +
> +/// The DDC/CI I2C slave address on the monitor bus.
> +pub(super) const DDCCI_I2C_ADDR: u8 = 0x37;
> +
> +/// CP message tunnelling a raw DDC/CI transaction (`payload`, the bytes a host writes after the
> +/// I2C write-address) to the downstream monitor's I2C slave `addr`. Same `id=0x15 sub=0x22`
> +/// framing as [`ddc_set_vcp`], with the slave + payload length at off20 and the payload at off22.
> +/// This is what the vino I2C adapter forwards for a userspace DDC/CI write (e.g. `ddcutil`).
> +pub(super) fn ddc_forward(counter: u16, addr: u8, payload: &[u8]) -> Result<KVec<u8>> {
> +    let mut b = KVec::with_capacity(32 + payload.len(), GFP_KERNEL)?;

[Severity: High]
This is a pre-existing issue, but can this GFP_KERNEL allocation cause a
scheduling while atomic panic?

The CRTC atomic_disable callback runs in atomic context where sleeping is
forbidden. It calls set_vcp(), which delegates to cp::ddc_set_vcp() and 
subsequently cp::ddc_forward().

Allocating with GFP_KERNEL here can sleep. Could this allocation use
GFP_ATOMIC instead?

>      header(&mut b, 0x15, 0x22, counter)?;
>      pad_to(&mut b, 20)?;
> -    // off20: monitor DDC/CI I2C slave (0x37) + DDC/CI payload length.
> -    b.extend_from_slice(&[0x37, payload.len() as u8], GFP_KERNEL)?;
> -    // off22: the DDC/CI Set-VCP bytes (same off22 convention as the EDID payload).
> -    b.extend_from_slice(&payload, GFP_KERNEL)?;
> +    // off20: monitor DDC/CI I2C slave + DDC/CI payload length.
> +    b.extend_from_slice(&[addr, payload.len() as u8], GFP_KERNEL)?;

[Severity: High]
Can a payload larger than 255 bytes cause malformed CP packets here?

The payload.len() is cast to u8 without bounds checking, meaning the protocol
packet header receives a truncated length, but the full unbounded payload is 
appended below.

Could this cause the downstream dock's CP parser to read misaligned framing,
potentially hanging the hardware or corrupting the stream?

> +    // off22: the DDC/CI bytes (same off22 convention as the EDID payload).
> +    b.extend_from_slice(payload, GFP_KERNEL)?;
>      pad_to(&mut b, 32)?;
>      Ok(b)
>  }

> diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
> --- a/drivers/gpu/drm/vino/drm_sink.rs
> +++ b/drivers/gpu/drm/vino/drm_sink.rs

[ ... ]

> @@ -41,6 +42,7 @@
>          plane::{self, PlaneAtomicCommit, RawPlaneState as _},
>          KmsDriver, ModeConfigGuard, ModeConfigInfo, ModeObject as _, NewKmsDevice, Probing,
>      },
> +    i2c,
> +    error::code::EINVAL,

[Severity: Low]
Does this import follow the kernel vertical import style for Rust?

The i2c import is placed on the same line as other items instead of being
vertically aligned.

[ ... ]

> @@ -125,6 +127,12 @@ pub(super) struct VinoDrmData {
>      /// can reach its cached-EDID slot without needing DRM's mode-object list. Written once,
>      /// during single-threaded probe, before the device is registered; read-only thereafter.
>      connector: core::sync::atomic::AtomicPtr<VinoConnector>,
> +    /// The CRTC's gamma ramp cached from the atomic hook as three 256-entry 8-bit LUTs
> +    /// (`[r; 256] ++ [g; 256] ++ [b; 256]`), or `None` for identity. Cached here (not read from
> +    /// the CRTC state) because scanout runs in the plane path; it is `Copy`, so the scanout
> +    /// snapshots it under the lock and applies it without holding the lock in the pixel loop.
> +    #[pin]
> +    gamma: Mutex<Option<[u8; 768]>>,
>  }
>  
>  impl VinoDrmData {

[Severity: High]
Does the use of a sleeping Mutex here violate atomic context rules?

The update_gamma() and gamma_snapshot() functions lock this Mutex, but they
are invoked synchronously during the CRTC atomic_enable, atomic_disable,
and plane atomic_update callbacks.

According to DRM subsystem guidelines, these callbacks run in atomic context
during non-blocking commits where sleeping is strictly forbidden. Should this
be a spinlock instead to avoid scheduling while atomic panics?

[ ... ]

> @@ -302,6 +333,16 @@ fn probe(dev: &NewKmsDevice<'_, Self, Probing>) -> Result {
>              None,
>              false,
>          )?;
> +        // Advertise the rotations vino's re-encode handles 1:1 (dimension-preserving): the
> +        // scanout applies them per source pixel via `rot_src`. 90/270 (which swap width/height)
> +        // are intentionally omitted.
> +        primary.create_rotation_property(
> +            bindings::DRM_MODE_ROTATE_0,
> +            bindings::DRM_MODE_ROTATE_0
> +                | bindings::DRM_MODE_ROTATE_180
> +                | bindings::DRM_MODE_REFLECT_X
> +                | bindings::DRM_MODE_REFLECT_Y,
> +        )?;
>          let cursor = plane::UnregisteredPlane::<VinoPlane>::new(
>              dev,
>              1,
>              &CURSOR_FORMATS,
>              None,
>              plane::Type::Cursor,
>              None,
>              PlaneArgs { head: 0, is_cursor: true },
>          )?;

[Severity: Medium]
Is the cursor plane missing the rotation property?

The commit message mentions wiring rotation for the primary/cursor plane, but
create_rotation_property() is not called for the cursor plane during probe.

Additionally, read_cursor_bgra() maps and reads the cursor framebuffer
without applying any rotation transformation.

[ ... ]

> @@ -372,6 +415,8 @@ fn new(_device: &drm::Device<Self::Driver, drm::Uninit>, _args: &()) -> impl Pin
>      fn atomic_enable(commit: CrtcAtomicCommit<'_, Self>) {
>          let data: &VinoDrmData = commit.crtc().drm_dev();
>          let new = commit.take_new_state();
> +        // Cache the gamma ramp for the scanout to apply.
> +        data.update_gamma(new.gamma_lut());

[Severity: Medium]
Are dynamic GAMMA_LUT property updates silently ignored while the display
is active?

The driver caches the GAMMA_LUT value only in the atomic_enable and
atomic_disable CRTC hooks.

If a compositor dynamically updates the gamma LUT, it will trigger
atomic_flush or atomic_update, but skip the enable hook when the CRTC is
already active, potentially missing color calibration changes until a full
modeset occurs.

[ ... ]

> @@ -489,6 +535,8 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
>          // primary plane 1:1 with the virtual output), so this drives the dynamic scanout
>          // resolution.
>          let (w, h) = (new.crtc_w() as usize, new.crtc_h() as usize);

[Severity: Critical]
This isn't a bug introduced by this patch, but does the lack of plane scaling
rejection allow userspace to trigger out-of-bounds kernel memory reads?

VinoPlane lacks an atomic_check function to reject scaling (e.g., using
drm_atomic_helper_check_plane_state with DRM_PLANE_NO_SCALING).

In atomic_update(), the scanout loop uses the destination CRTC geometry
(new.crtc_w(), new.crtc_h()) as the bounds instead of the source framebuffer
dimensions.

If unprivileged userspace issues an atomic commit with crtc_h greater than
fb.height(), the scanout loop will read past the end of the mapped GEM buffer.

[ ... ]

> @@ -748,13 +816,62 @@ fn get_modes<'a>(
>      }
>  }
>  
> +// ---- DDC/CI I2C adapter -----------------------------------------------------
> +
> +/// vino's DDC/CI virtual I2C bus: a userspace monitor-control tool (`ddcutil`, the desktop
> +/// brightness slider via the I2C DDC path) writes a DDC/CI transaction to the monitor's I2C slave
> +/// on this adapter, and vino tunnels it to the downstream monitor over the dock's CP channel
> +/// (`cp::ddc_forward`, `id=0x15 sub=0x22`) -- the same monitor-I2C bridge vino's DPMS-power VCP
> +/// uses. Writes only for now (Get-VCP reads need the CP reply path); a no-op until CP engages.
> +pub(super) struct VinoI2c;
> +
> +impl i2c::BusController for VinoI2c {
> +    type Context = ARef<VinoDrmDevice>;
> +
> +    fn master_xfer(dev: &ARef<VinoDrmDevice>, msgs: &mut [i2c::Msg]) -> Result<usize> {
> +        let data: &VinoDrmData = dev;
> +        let mut transferred = 0usize;
> +        for msg in msgs.iter() {
> +            if msg.addr() != super::cp::DDCCI_I2C_ADDR as u16 {
> +                continue;
> +            }
> +            if msg.is_read() {
> +                // DDC/CI reads (Get-VCP) require decoding the dock's CP reply -- not wired yet.
> +                continue;
> +            }

[Severity: Medium]
Should master_xfer() return an error when encountering unsupported messages
rather than skipping them?

If master_xfer() receives an array of messages where an early message is
unsupported (e.g., a read) and a later message succeeds, it uses continue to
skip the error and returns a positive transferred count.

This violates the I2C subsystem API contract and causes userspace tools like
ddcutil to assume operations succeeded when they actually failed.

> +            if data
> +                .send_cp(0x15, 0, |ctr| {
> +                    super::cp::ddc_forward(ctr, super::cp::DDCCI_I2C_ADDR, msg.buf())
> +                })
> +                .is_ok()
> +            {
> +                transferred += 1;
> +            }
> +        }
> +        Ok(transferred)
> +    }

[ ... ]

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260703030217.2886-1-mike@fireburn.co.uk?part=8

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH v2 09/10] drm/vino: two heads, 90/270 rotation, damage clips and connector probe
  2026-07-03  3:02   ` [RFC PATCH v2 09/10] drm/vino: two heads, 90/270 rotation, damage clips and connector probe Mike Lothian
@ 2026-07-03  3:20     ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-07-03  3:20 UTC (permalink / raw)
  To: Mike Lothian; +Cc: ojeda, dri-devel

Thank you for your contribution! Sashiko AI review found 5 potential issue(s) to consider:

New issues:
- [High] The frame-damage clips overflow fallback logic permanently drops previously accumulated damage clips instead of properly merging them.
- [High] The CRTC gamma LUT state is incorrectly shared globally rather than being per-head, causing independent display heads to overwrite or clear each other's gamma ramps.

Pre-existing issues:
- [High] Kernel out-of-bounds read occurs when userspace requests plane scaling.
- [High] The plane `atomic_update` and CRTC callbacks run in atomic context but acquire sleepable Mutexes and perform GFP_KERNEL allocations.
- [Medium] The cursor plane update logic uses the raw virtual address of the framebuffer as a cache key, risking an ABA problem where a re-uploaded cursor is silently ignored.
--

commit 02346681a0e083ee04b818868a94582e0d21e04e
Author: Mike Lothian <mike@fireburn.co.uk>

    drm/vino: two heads, 90/270 rotation, damage clips and connector probe
    
    Consume the new connector detect()/mode_valid() and plane damage-clip
    bindings, and finish the KMS feature set the driver previously stubbed:

> diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
> index 898cfae8fd4fb..ee04a5af9f7d5 100644
> --- a/drivers/gpu/drm/vino/drm_sink.rs
> +++ b/drivers/gpu/drm/vino/drm_sink.rs
> @@ -435,10 +474,12 @@ fn atomic_enable(commit: CrtcAtomicCommit<'_, Self>) {
>      /// against a shadow the dock may have dropped, and blanks the monitor via DDC/CI -- a
>      /// no-op until CP engages.
>      fn atomic_disable(commit: CrtcAtomicCommit<'_, Self>) {
> -        let data: &VinoDrmData = commit.crtc().drm_dev();
> +        let crtc = commit.crtc();
> +        let head = crtc.head;
> +        let data: &VinoDrmData = crtc.drm_dev();
>          data.update_gamma(None);

[Severity: High]
Since the driver now supports multiple independent display heads, is it safe to
share a single global gamma state?

`data.update_gamma` modifies the shared `gamma` in `VinoDrmData`. This could
inadvertently overwrite or clear the gamma ramps of other connected displays
when one head is updated or disabled.

Should `gamma` be made per-head, similar to the `connectors` array?

Also, this is a pre-existing issue, but doesn't `data.update_gamma` lock a
sleepable Mutex? CRTC callbacks like `atomic_disable` and `atomic_enable` can
run in atomic context during non-blocking atomic commits where sleeping is
forbidden.

[ ... ]

> @@ -498,14 +554,14 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
>              return;
>          }
>          let plane = commit.plane();
> +        let head = plane.head;
>          let data: &VinoDrmData = plane.drm_dev();
> -        let new = commit.take_new_state();
>  
>          // Cursor plane: forward the cursor bitmap/position to the dock over CP (id=0x1b create,
>          // 0x401c image, 0x1a move -- see `cp::cursor_*`). A no-op until CP engages, like scanout.
>          if plane.is_cursor {
>              use core::sync::atomic::Ordering::Relaxed;
> -            const HEAD: u8 = 0;
> +            let new = commit.take_new_state();
>              match new.framebuffer::<VinoDrmDriver>() {
>                  Some(fb) => {
>                      let w = fb.width() as u16;
> @@ -514,15 +570,17 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
>                      // position (a bare cursor move must not re-send the whole image).
>                      let key = fb as *const _ as usize;
>                      if plane.cursor_last.swap(key, Relaxed) != key {

[Severity: Medium]
This is a pre-existing issue, but does using the raw virtual address of the
framebuffer as a cache key risk an ABA problem?

If userspace frees a cursor framebuffer and immediately allocates a new one,
the memory allocator may place it at the same virtual address. This could
cause the new cursor image to be silently ignored because the cache key
hasn't changed.

[ ... ]

> @@ -537,6 +597,37 @@ fn atomic_update(commit: PlaneAtomicCommit<'_, Self>) {
>          let (w, h) = (new.crtc_w() as usize, new.crtc_h() as usize);

[Severity: High]
This is a pre-existing issue, but can this lead to an out-of-bounds read if
userspace requests plane scaling?

The destination dimensions `crtc_w` and `crtc_h` are used to iterate and
calculate source memory offsets. If a compositor provides a small framebuffer
and requests scaling, the offsets could far exceed the bounds of the source
framebuffer.

>          // Plane rotation/reflection (identity unless the compositor set the rotation property).
>          let rotation = new.rotation();
> +        // Collect the client's individual frame-damage clips (the rectangles that
> +        // `damage_merged()` would collapse into one bounding box), each clamped to the output, so
> +        // only the genuinely changed rectangles are re-converted from the source rather than their
> +        // whole enclosing box. Only for identity rotation (the clips are in un-rotated source
> +        // space; mapping them through 90/270 is not worth it for the throttled fallback path), and
> +        // never on the WHT keyframe path -- see `encode_and_send`. A fixed stack array keeps the
> +        // atomic-commit path allocation-free; on overflow the clips collapse into one bounding box.
> +        // An empty list means "convert the whole output" (used for the rotated/reflected case).
> +        let mut clips = [(0usize, 0usize, 0usize, 0usize); MAX_DAMAGE_CLIPS];
> +        let mut nclips = 0usize;
> +        if rotation & bindings::DRM_MODE_ROTATE_MASK == bindings::DRM_MODE_ROTATE_0
> +            && rotation & (bindings::DRM_MODE_REFLECT_X | bindings::DRM_MODE_REFLECT_Y) == 0
> +        {
> +            new.for_each_damage_clip(old, |r| {
> +                let c = (
> +                    (r.x1.max(0) as usize).min(w),
> +                    (r.y1.max(0) as usize).min(h),
> +                    (r.x2.max(0) as usize).min(w),
> +                    (r.y2.max(0) as usize).min(h),
> +                );
> +                if nclips < MAX_DAMAGE_CLIPS {
> +                    clips[nclips] = c;
> +                    nclips += 1;
> +                } else {
> +                    // Overflow: collapse everything so far (and `c`) into clips[0]'s bounding box.
> +                    let b = clips[0];
> +                    clips[0] = (b.0.min(c.0), b.1.min(c.1), b.2.max(c.2), b.3.max(c.3));
> +                    nclips = 1;

[Severity: High]
Does this overflow fallback logic correctly merge the clips?

When `nclips` reaches `MAX_DAMAGE_CLIPS`, it updates `clips[0]` to be the
bounding box of `clips[0]` and `c`. However, this completely discards the
previously accumulated clips from `clips[1]` through `clips[15]`, dropping
them permanently rather than expanding the bounding box to include them.

> +                }
> +            });
> +        }
>  
>          use core::sync::atomic::Ordering::Relaxed;

[ ... ]

> @@ -693,6 +803,8 @@ fn encode_and_send(
>          return encode_and_send_wht(data, plane, vaddr, pitch, rotation, w, h);
>      }
>      let gamma = data.gamma_snapshot();
> +    // Source dimensions (swapped from the output for 90/270 rotation).
> +    let (sw, sh) = src_dims(rotation, w, h);
>      let frame = {
>          let mut st = plane.scanout.lock();

[Severity: High]
This isn't a bug introduced by this patch, but doesn't `atomic_update` run
in atomic context during non-blocking atomic commits? 

Locking a sleepable Mutex like `plane.scanout.lock()` here can cause kernel
warnings and system instability.

[ ... ]

> @@ -746,7 +868,7 @@ fn encode_and_send(
>      // DRM device, so it is bound for the duration of this push.
>      let dev = unsafe { data.intf.as_bound() };
>      prime_video_eps(dev);
> -    dev.bulk_send(VIDEO_EP, &frame, super::timeout(), GFP_KERNEL)?;
> +    dev.bulk_send(VIDEO_EPS[plane.head as usize], &frame, super::timeout(), GFP_KERNEL)?;

[Severity: High]
Similarly, this is a pre-existing issue, but does `dev.bulk_send` perform
allocations using the `GFP_KERNEL` flag? Using `GFP_KERNEL` in atomic context
is unsafe and can sleep.

>      Ok(())
>  }

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260703030217.2886-1-mike@fireburn.co.uk?part=9

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH v2 04/10] drm/vino: add the Vino framebuffer codec
  2026-07-03  3:02   ` [RFC PATCH v2 04/10] drm/vino: add the Vino framebuffer codec Mike Lothian
@ 2026-07-03  3:21     ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-07-03  3:21 UTC (permalink / raw)
  To: Mike Lothian; +Cc: dri-devel, ojeda

Thank you for your contribution! Sashiko AI review found 5 potential issue(s) to consider:
- [High] Sleeping functions called from atomic context in the hot scanout path.
- [High] Large contiguous memory allocations via `KVec` cause page allocator WARNs and fail under load.
- [Medium] `Bits::esc` silently emits malformed unary prefix codes for magnitude categories >= 10, corrupting the bitstream.
- [Medium] Contradictory mathematical rounding for negative values between `quantize_dc` and `quantize_dc_round`.
- [Medium] Documentation for `colour_frame_ep08` contradicts its implementation and the hardware wire format.
--

commit e12de38777f2b0ee034bdb7b4eb4f91d2f8055e5
Author: Mike Lothian <mike@fireburn.co.uk>

drm/vino: add the Vino framebuffer codec

This commit adds the Vino framebuffer codec to the DRM subsystem, including
an RLE fallback encoder and a Walsh-Hadamard transform codec. It also
implements the EP08 wire framing for the display dock.

> diff --git a/drivers/gpu/drm/vino/video.rs b/drivers/gpu/drm/vino/video.rs
> new file mode 100644
> index 0000000000000..c520af19a4fbb
> --- /dev/null
> +++ b/drivers/gpu/drm/vino/video.rs
> @@ -0,0 +1,1229 @@
[ ... ]
> +    pub(crate) fn encode_into(&mut self, cur: &[u16], s: &mut KVec<u8>) -> Result {
> +        s.extend_from_slice(&FRAME_INIT, GFP_KERNEL)?;

[Severity: High]
Is it safe to use GFP_KERNEL allocations here?

Plane atomic update callbacks are atomic context where sleeping functions are
strictly forbidden. However, this encoder path is called during scanout
(VinoPlane::atomic_update() -> scanout_one() -> encode_and_send() ->
encode_into()), leading to repeated allocations with GFP_KERNEL.

Additionally, the outer encode_and_send() and encode_and_send_wht()
functions in drm_sink.rs allocate the outgoing frame buffer using
GFP_KERNEL and perform a blocking USB bulk transfer with GFP_KERNEL.

Does this regression trigger kernel warnings, system instability or deadlocks
when called from atomic context?

> +        for y in 0..self.height {
> +            let row = y * self.width;
> +            let mut x = 0;
> +            while x < self.width {
[ ... ]
> +                let mut p = run_start;
> +                while p < run_end {
> +                    let n = (run_end - p).min(MAX_BLOCK_PIXELS);
> +                    let coord = (((row + p) * 2) & 0xff_ffff) as u32;
> +                    encode_run_into(s, self.mode, coord, &cur[row + p..row + p + n])?;

[Severity: High]
Could this KVec allocation grow too large and cause page allocator failures?

While the shadow buffer correctly uses a vmalloc-backed VVec to avoid kmalloc
limits, the encoded output buffer appended to via encode_run_into() can also
scale linearly with resolution and frame differences. If an uncompressible
frame is encountered (e.g. random noise at 4K resolution), the generated
stream could exceed the kmalloc contiguous memory limit before reaching the
outer 65 KB EOVERFLOW check.

Will this regression cause the page allocator to trigger WARNs and return
-ENOMEM under load, allowing userspace compositors to flood the kernel log
and stall display updates?

> +                    p += n;
> +                }
> +            }
> +        }
[ ... ]
> +    fn quantize_dc(plane: usize, v: i32) -> i32 {
> +        let step = if plane == 0 { 16 } else { 64 };
> +        let q = (((v.unsigned_abs() + 8) * (65536 / step)) >> 16) as i32;

[Severity: Medium]
Does this quantization apply the correct bias for chroma steps?

This function implements magnitude rounding with a hardcoded bias of 8. For
chroma values where step = 64, a negative value like v = -232 will result
in -3 instead of -4.

A newer implementation, quantize_dc_round(), implements Euclidean rounding
using the correct step bias:

    fn quantize_dc_round(plane: usize, v: i32) -> i32 {
        let step = if plane == 0 { 16 } else { 64 };
        (v + step / 2).div_euclid(step)
    }

However, the older quantize_dc() is still used for encoding solid strip DC
planes:

    pub(crate) fn solid_strip(x: u16, y: u16, ydc: i32, cbdc: i32, crdc: i32) -> Result<KVec<u8>> {
        let esc = esc_len(quantize_dc(2, crdc), SOLID_DC_CMAX)

Could this contradictory mathematical rounding act as a regression that causes
decoding errors for negative chroma DCs?

> +        if v < 0 {
> +            -q
> +        } else {
> +            q
> +        }
> +    }
[ ... ]
> +        fn esc(&mut self, v: i32, cmax: u32) -> Result {
> +            if v == 0 {
> +                return self.bit(0);
> +            }
> +            let c = mag_category(v);
> +            let off = v.unsigned_abs() - (1 << (c - 1));
> +            for _ in 0..c {
> +                self.bit(1)?;
> +            }

[Severity: Medium]
Should the number of unary 1 bits emitted be clamped to cmax?

The code limits the unary prefix to cmax by omitting the 0-terminator if
c >= cmax, but the loop unconditionally emits c ones. If a high frequency edge
produces a magnitude category of 10 or higher (while cmax is 9 for AC
coefficients), this will emit 10 or more ones.

A hardware decoder expecting a maximum prefix length of 9 will stop after
reading 9 ones and interpret the 10th one as the magnitude offset, corrupting
the bitstream for the rest of the strip. Can this logic be adjusted to prevent
emitting malformed unary prefix codes?

> +            if c < cmax {
> +                self.bit(0)?;
> +            }
[ ... ]
> +    /// Encode a full width x height 8-bit-RGB frame into the Vino WHT colour EP08 frame(s) --
> +    /// the colour counterpart of the luma encode_frame, and the assembler the live scanout path
> +    /// drives once the CP wall falls. px(x, y) yields the source pixel's (R, G, B); the caller
> +    /// applies any rotation / gamma / format conversion (so this stays a pure codec). The surface is
> +    /// tiled into 64x16 strips in raster order, each built from colour_block + colour_strip.
> +    /// The forward length-hint tail is then chained across the WHOLE frame -- strip k's 2-byte
> +    /// tail is patched to strip k+1's L - 2 (the last strip keeps its own), even across EP08-frame
> +    /// boundaries. Finally the strip stream is split at strip boundaries into <= u16::MAX - 12-byte
> +    /// EP08 frames, each prefixed with a write_ep08_header carrying an incrementing seq from
> +    /// seq0. Returns the ready-to-send frames and the next seq.
> +    ///
> +    /// width/height must be multiples of 64 and 16 (EINVAL otherwise) -- the codec's strip
> +    /// geometry; the live scanout path falls back to RLE for non-aligned modes (see
> +    /// docs/VIDEO-TODO.md). Byte-exact for the recovered colour grammar (chroma sync/DC/AC); the
> +    /// anti-fabrication boundary is the synthetic steepest-chroma edge cases (VIDEO-TODO.md 8/9).
> +    pub(crate) fn colour_frame_ep08(

[Severity: Medium]
Is the documentation for colour_frame_ep08() out of date with its current
implementation?

The docstring claims it chains a forward length-hint tail across the frame and
splits the stream into EP08 frames prefixed with write_ep08_header().

However, the function delegates to frame_records(), which uses a different TLV
record format. The comments in frame_records() explicitly state that the
legacy write_ep08_header() behavior was wrong and caused hardware faults:

    // There is no per-transfer header (the old write_ep08_header sub=0x30
    // over concatenated strips was wrong and made the dock fault).

Could the API documentation be updated to correctly describe the new TLV
record format instead of the obsolete layout?

> +        width: usize,
> +        height: usize,
> +        seq0: u32,
> +        mut px: impl FnMut(usize, usize) -> (u8, u8, u8),
> +    ) -> Result<(KVec<KVec<u8>>, u32)> {

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260703030217.2886-1-mike@fireburn.co.uk?part=4

^ permalink raw reply	[flat|nested] 41+ messages in thread

* Re: [RFC PATCH v2 10/10] drm/vino: hrtimer-driven software vblank
  2026-07-03  3:02   ` [RFC PATCH v2 10/10] drm/vino: hrtimer-driven software vblank Mike Lothian
@ 2026-07-03  3:21     ` sashiko-bot
  0 siblings, 0 replies; 41+ messages in thread
From: sashiko-bot @ 2026-07-03  3:21 UTC (permalink / raw)
  To: Mike Lothian; +Cc: dri-devel, ojeda

Thank you for your contribution! Sashiko AI review found 1 potential issue(s) to consider:
- [Medium] The software vblank hrtimer free-runs indefinitely at ~60Hz even when the display is disabled (DPMS off), causing continuous power drain.
--

commit a169dcaae3d3a68e1d3207b3eca11363159c01a8
Author: Mike Lothian <mike@fireburn.co.uk>

drm/vino: hrtimer-driven software vblank

The atomic helpers were completing every page-flip immediately via
drm_atomic_helper_fake_vblank(), because the CRTC had no vblank support.
This patch implements VblankSupport for VinoCrtc backed by a per-CRTC
hrtimer that fires once per frame to pace page flips.

> diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
> index ee04a5af9f7d5..ce04c8b65461c 100644
> --- a/drivers/gpu/drm/vino/drm_sink.rs
> +++ b/drivers/gpu/drm/vino/drm_sink.rs
[ ... ]
> @@ -417,11 +428,69 @@ fn probe(dev: &NewKmsDevice<'_, Self, Probing>) -> Result {
[ ... ]
> +impl HrTimerCallback for VblankTimer {
> +    type Pointer<'a> = Arc<Self>;
> +
> +    fn run(this: ArcBorrow<'_, Self>, mut ctx: HrTimerCallbackContext<'_, Self>) -> HrTimerRestart {
> +        let crtc = this.crtc.load(Ordering::Relaxed);
> +        if !crtc.is_null() && this.enabled.load(Ordering::Relaxed) {
> +            // SAFETY: `crtc` is the `drm_crtc` stored in `enable_vblank` while the device is live;
> +            // the timer is cancelled (its handle dropped) before the crtc is freed at teardown.
> +            unsafe { bindings::drm_crtc_handle_vblank(crtc) };
> +        }
> +        let interval = this.interval_ns.load(Ordering::Relaxed).max(1_000_000);
> +        ctx.forward_now(Delta::from_nanos(interval));
> +        HrTimerRestart::Restart

[Severity: Medium]
Will this timer continue to free-run indefinitely at ~60Hz even when the
display is disabled?

When the display is turned off, disable_vblank() merely clears the enabled
flag, but VblankTimer::run() unconditionally returns HrTimerRestart::Restart.
Since the timer is only started once in enable_vblank() and is only cancelled
when the CRTC object is dropped, it appears this creates continuous background
wakeups on an idle system, which can prevent the CPU from entering deep sleep
states.

Could the timer self-terminate when the enabled flag is false, and be
restarted by enable_vblank() to avoid draining power during DPMS off?

-- 
Sashiko AI review · https://sashiko.dev/#/patchset/20260703030217.2886-1-mike@fireburn.co.uk?part=10

^ permalink raw reply	[flat|nested] 41+ messages in thread

end of thread, other threads:[~2026-07-03  3:21 UTC | newest]

Thread overview: 41+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
2026-06-17 15:17   ` Miguel Ojeda
2026-06-17 20:11   ` sashiko-bot
2026-06-18 10:39   ` Julian Braha
2026-06-17 15:12 ` [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE Mike Lothian
2026-06-17 16:18   ` Eric Biggers
2026-06-17 20:12   ` sashiko-bot
2026-06-17 15:12 ` [RFC PATCH 3/7] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm Mike Lothian
2026-06-17 20:15   ` sashiko-bot
2026-06-17 15:12 ` [RFC PATCH 4/7] drm/vino: add the Vino (RawRl mode-2) framebuffer codec Mike Lothian
2026-06-17 20:13   ` sashiko-bot
2026-06-17 15:12 ` [RFC PATCH 5/7] drm/vino: register a DRM/KMS device and scan out to EP08 Mike Lothian
2026-06-17 20:22   ` sashiko-bot
2026-06-17 15:12 ` [RFC PATCH 6/7] drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info Mike Lothian
2026-06-17 20:19   ` sashiko-bot
2026-06-17 15:12 ` [RFC PATCH 7/7] drm/vino: add KUnit self-tests for the protocol and crypto paths Mike Lothian
2026-06-17 20:18   ` sashiko-bot
2026-06-17 15:55 ` [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Danilo Krummrich
2026-06-17 16:11   ` Mike Lothian
2026-07-03  3:02 ` [RFC PATCH v2 00/10] drm/vino: DisplayLink DL3 dock driver Mike Lothian
2026-07-03  3:02   ` [RFC PATCH v2 01/10] drm/vino: add DisplayLink DL3 dock skeleton and protocol framing Mike Lothian
2026-07-03  3:15     ` sashiko-bot
2026-07-03  3:02   ` [RFC PATCH v2 02/10] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE handshake Mike Lothian
2026-07-03  3:14     ` sashiko-bot
2026-07-03  3:02   ` [RFC PATCH v2 03/10] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm sequence Mike Lothian
2026-07-03  3:13     ` sashiko-bot
2026-07-03  3:02   ` [RFC PATCH v2 04/10] drm/vino: add the Vino framebuffer codec Mike Lothian
2026-07-03  3:21     ` sashiko-bot
2026-07-03  3:02   ` [RFC PATCH v2 05/10] drm/vino: add the DRM/KMS sink, built on the safe KMS mode-object layer Mike Lothian
2026-07-03  3:13     ` sashiko-bot
2026-07-03  3:02   ` [RFC PATCH v2 06/10] drm/vino: add the DisplayLink DL3 dock driver Mike Lothian
2026-07-03  3:18     ` sashiko-bot
2026-07-03  3:02   ` [RFC PATCH v2 07/10] drm/vino: wire the hardware cursor plane Mike Lothian
2026-07-03  3:11     ` sashiko-bot
2026-07-03  3:02   ` [RFC PATCH v2 08/10] drm/vino: wire CRTC gamma, plane rotation and DDC/CI monitor controls Mike Lothian
2026-07-03  3:20     ` sashiko-bot
2026-07-03  3:02   ` [RFC PATCH v2 09/10] drm/vino: two heads, 90/270 rotation, damage clips and connector probe Mike Lothian
2026-07-03  3:20     ` sashiko-bot
2026-07-03  3:02   ` [RFC PATCH v2 10/10] drm/vino: hrtimer-driven software vblank Mike Lothian
2026-07-03  3:21     ` sashiko-bot

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.