Rust for Linux List
 help / color / mirror / Atom feed
From: Mike Lothian <mike@fireburn.co.uk>
To: rust-for-linux@vger.kernel.org
Cc: dri-devel@lists.freedesktop.org,
	"David Airlie" <airlied@gmail.com>,
	"Simona Vetter" <simona@ffwll.ch>,
	"Thomas Zimmermann" <tzimmermann@suse.de>,
	"Maarten Lankhorst" <maarten.lankhorst@linux.intel.com>,
	"Maxime Ripard" <mripard@kernel.org>,
	"Danilo Krummrich" <dakr@kernel.org>,
	"Lyude Paul" <lyude@redhat.com>,
	"Miguel Ojeda" <ojeda@kernel.org>,
	"Boqun Feng" <boqun@kernel.org>, "Gary Guo" <gary@garyguo.net>,
	"Björn Roy Baron" <bjorn3_gh@protonmail.com>,
	"Benno Lossin" <lossin@kernel.org>,
	"Andreas Hindborg" <a.hindborg@kernel.org>,
	"Alice Ryhl" <aliceryhl@google.com>,
	"Trevor Gross" <tmgross@umich.edu>,
	linux-kernel@vger.kernel.org,
	"Mike Lothian" <mike@fireburn.co.uk>
Subject: [RFC PATCH v2 06/10] drm/vino: add the DisplayLink DL3 dock driver
Date: Fri,  3 Jul 2026 04:02:11 +0100	[thread overview]
Message-ID: <20260703030217.2886-7-mike@fireburn.co.uk> (raw)
In-Reply-To: <20260703030217.2886-1-mike@fireburn.co.uk>

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


  parent reply	other threads:[~2026-07-03  3:02 UTC|newest]

Thread overview: 23+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
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-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 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 ` [RFC PATCH 4/7] drm/vino: add the Vino (RawRl mode-2) framebuffer codec Mike Lothian
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 ` [RFC PATCH 6/7] drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info Mike Lothian
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 ` [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
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   ` [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   ` [RFC PATCH v2 04/10] drm/vino: add the Vino framebuffer codec Mike Lothian
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 [this message]
2026-07-03  3:02   ` [RFC PATCH v2 07/10] drm/vino: wire the hardware cursor plane Mike Lothian
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   ` [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

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260703030217.2886-7-mike@fireburn.co.uk \
    --to=mike@fireburn.co.uk \
    --cc=a.hindborg@kernel.org \
    --cc=airlied@gmail.com \
    --cc=aliceryhl@google.com \
    --cc=bjorn3_gh@protonmail.com \
    --cc=boqun@kernel.org \
    --cc=dakr@kernel.org \
    --cc=dri-devel@lists.freedesktop.org \
    --cc=gary@garyguo.net \
    --cc=linux-kernel@vger.kernel.org \
    --cc=lossin@kernel.org \
    --cc=lyude@redhat.com \
    --cc=maarten.lankhorst@linux.intel.com \
    --cc=mripard@kernel.org \
    --cc=ojeda@kernel.org \
    --cc=rust-for-linux@vger.kernel.org \
    --cc=simona@ffwll.ch \
    --cc=tmgross@umich.edu \
    --cc=tzimmermann@suse.de \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox