* [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted)
@ 2026-06-17 15:12 Mike Lothian
2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
` (7 more replies)
0 siblings, 8 replies; 12+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
To: dri-devel
Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
linux-kernel
Vino is a clean-room, in-kernel Rust DRM driver for DisplayLink DL3 USB
docks (Dell Universal Dock D6000, 17e9:6006), a native replacement for
the out-of-tree EVDI module plus the proprietary DisplayLinkManager
userspace daemon. It is built on the in-tree Rust USB, crypto and DRM/KMS
bindings, which are posted as their own prerequisite series; this series
is the driver that consumes them and touches only drivers/gpu/drm/vino/
plus the two drivers/gpu/drm/{Kconfig,Makefile} wiring lines.
Prerequisite binding series (apply in this order before this one):
- [RFC PATCH 0/9] rust: usb: synchronous bulk/control transfers + helpers
https://lore.kernel.org/r/20260617145946.1894-1-mike@fireburn.co.uk
- [RFC PATCH 0/2] rust: crypto: library AES-128 / SHA-256 / HMAC + RSA
https://lore.kernel.org/r/20260617150143.2152-1-mike@fireburn.co.uk
- [RFC PATCH 0/5] rust: drm: minimal KMS bindings, EDID read, rotation, HDCP defs
https://lore.kernel.org/r/20260617150232.2210-1-mike@fireburn.co.uk
It is posted as an [RFC] in a deliberately INCOMPLETE state to ask for
help with one remaining blocker -- the control-plane engagement "wall"
(see below and patch 3). It was previously a single ~5000-line patch; it
has been split into one self-contained subsystem per patch so it is
reviewable and so others can pick up and fix a single layer in isolation:
1. skeleton + USB bind + the plaintext connect handshake (proto);
2. the clean-room HDCP 2.2 AKE/LC/SKE (crypto, rng, hdcp, ake, golden);
3. the AES-CTR/AES-CMAC ("Dl3Cmac") control-plane seal + arm (cp);
4. the Vino RawRl mode-2 framebuffer codec (video);
5. the DRM/KMS device + EP08 framebuffer scanout (drm_sink);
6. DDC/CI brightness/contrast, DPMS power and DFU device-info;
7. KUnit self-tests for the protocol and crypto paths.
Each commit builds and the module loads on its own, so the series is
bisectable. With the prerequisite binding series applied it is
git-am-clean and checkpatch.pl --strict reports no errors (the only notes
are the expected "does MAINTAINERS need updating?" on the new files and a
few >100-column verbatim log strings).
Note on tooling: the implementation was AI-assisted (see the Assisted-by:
trailers); the reverse-engineering, the hardware testing on a real D6000,
and responsibility for the code under the DCO are the author's.
What works, all on real hardware (Dell Universal Dock D6000):
- the plaintext connect handshake over the Rust USB bulk + control API;
- the clean-room HDCP 2.2 AKE/LC/SKE -- H', L' and V' all verify against
the dock, establishing the shared session key;
- the AES-CTR + AES-CMAC control-plane seal, byte-exact against the
reference daemon's captured wire, plus the plaintext stream-open arm;
- registration of a real struct drm_device via an atomic KMS pipeline, so
the dock appears as a mode-settable GEM/dumb DRM card, with a live EP08
framebuffer-scanout hook on every page-flip.
What does NOT work -- the wall (help wanted):
After the arm marker the driver sends the first encrypted control-plane
frame and the dock never acknowledges it (the wsub=0x45 ack count stays
0), so the CP cipher never engages and no pixels flow. Every
host-observable channel has been matched to the reference daemon -- the
bulk wire is byte-identical through the arm and the first encrypted frame,
the AKE verifies, the seal/MAC/IV are byte-exact, the full EP0 control and
endpoint sets match, and the arm timing is tighter than the daemon's --
yet the dock silently drops our encrypted CP while engaging the daemon's.
The gate appears not to be visible on the host wire. If you have knowledge
of the DisplayLink DL3 control-plane engagement sequence, or ideas for
isolating a whole-bus timing/ordering property a per-channel diff cannot
see, help would be very welcome.
Not-for-merge as-is: the driver carries RE diagnostics behind pr_debug and
a small captured plaintext cap-announce skeleton (golden) that a fully
field-derived builder should eventually replace.
Mike Lothian (7):
drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up
drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE
drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm
drm/vino: add the Vino (RawRl mode-2) framebuffer codec
drm/vino: register a DRM/KMS device and scan out to EP08
drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info
drm/vino: add KUnit self-tests for the protocol and crypto paths
drivers/gpu/drm/Kconfig | 2 +
drivers/gpu/drm/Makefile | 2 +
drivers/gpu/drm/vino/Kconfig | 21 +
drivers/gpu/drm/vino/Makefile | 2 +
drivers/gpu/drm/vino/ake.rs | 167 +++
drivers/gpu/drm/vino/cp.rs | 686 ++++++++++
drivers/gpu/drm/vino/crypto.rs | 81 ++
drivers/gpu/drm/vino/drm_sink.rs | 1466 +++++++++++++++++++++
drivers/gpu/drm/vino/golden.rs | 69 +
drivers/gpu/drm/vino/hdcp.rs | 167 +++
drivers/gpu/drm/vino/proto.rs | 73 ++
drivers/gpu/drm/vino/rng.rs | 12 +
drivers/gpu/drm/vino/video.rs | 348 +++++
drivers/gpu/drm/vino/vino.rs | 2053 ++++++++++++++++++++++++++++++
14 files changed, 5149 insertions(+)
create mode 100644 drivers/gpu/drm/vino/Kconfig
create mode 100644 drivers/gpu/drm/vino/Makefile
create mode 100644 drivers/gpu/drm/vino/ake.rs
create mode 100644 drivers/gpu/drm/vino/cp.rs
create mode 100644 drivers/gpu/drm/vino/crypto.rs
create mode 100644 drivers/gpu/drm/vino/drm_sink.rs
create mode 100644 drivers/gpu/drm/vino/golden.rs
create mode 100644 drivers/gpu/drm/vino/hdcp.rs
create mode 100644 drivers/gpu/drm/vino/proto.rs
create mode 100644 drivers/gpu/drm/vino/rng.rs
create mode 100644 drivers/gpu/drm/vino/video.rs
create mode 100644 drivers/gpu/drm/vino/vino.rs
--
2.54.0
^ permalink raw reply [flat|nested] 12+ messages in thread
* [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up
2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
2026-06-17 15:17 ` Miguel Ojeda
2026-06-17 15:12 ` [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE Mike Lothian
` (6 subsequent siblings)
7 siblings, 1 reply; 12+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
To: dri-devel
Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
linux-kernel
Vino is a clean-room, in-kernel Rust DRM driver for DisplayLink DL3 USB
docks (Dell Universal Dock D6000, 17e9:6006), a native replacement for
the out-of-tree EVDI module plus the proprietary DisplayLinkManager
userspace daemon. It is built on the in-tree Rust USB, crypto and DRM/KMS
bindings (posted as their own prerequisite series).
This is posted as an [RFC] in a deliberately INCOMPLETE state to ask for
help with one remaining blocker (see the final patch of this series for
the full "help wanted" note).
This first patch is the skeleton. It registers a usb::Driver for the
D6000, binds the control interface (interface 0; interface 1 binds idle,
the audio/Ethernet interfaces are declined so their class drivers claim
them), and runs the plaintext connect handshake on a workqueue: the
control-request preamble (device-open vendor reads, SET_INTERFACE, the
0x24/0x22 pair) and the three bulk init messages over the Rust USB bulk +
control transfer API, reading the single ACK.
The wire framing and the plaintext init message builders live in the new
proto module. The HDCP 2.2 AKE, the AES-CTR/AES-CMAC control plane, the
Vino codec and the DRM/KMS sink are added in the following patches, one
subsystem per patch, so each can be reviewed (and fixed) on its own.
Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
drivers/gpu/drm/Kconfig | 2 +
drivers/gpu/drm/Makefile | 2 +
drivers/gpu/drm/vino/Kconfig | 21 ++
drivers/gpu/drm/vino/Makefile | 2 +
drivers/gpu/drm/vino/proto.rs | 73 +++++++
drivers/gpu/drm/vino/vino.rs | 391 ++++++++++++++++++++++++++++++++++
6 files changed, 491 insertions(+)
create mode 100644 drivers/gpu/drm/vino/Kconfig
create mode 100644 drivers/gpu/drm/vino/Makefile
create mode 100644 drivers/gpu/drm/vino/proto.rs
create mode 100644 drivers/gpu/drm/vino/vino.rs
diff --git a/drivers/gpu/drm/Kconfig b/drivers/gpu/drm/Kconfig
index 323422861e8f..8ea7f2bb9300 100644
--- a/drivers/gpu/drm/Kconfig
+++ b/drivers/gpu/drm/Kconfig
@@ -370,3 +370,5 @@ endif
# Separate option because drm_panel_orientation_quirks.c is shared with fbdev
config DRM_PANEL_ORIENTATION_QUIRKS
tristate
+
+source "drivers/gpu/drm/vino/Kconfig"
diff --git a/drivers/gpu/drm/Makefile b/drivers/gpu/drm/Makefile
index e97faabcd783..8c6322df7c1f 100644
--- a/drivers/gpu/drm/Makefile
+++ b/drivers/gpu/drm/Makefile
@@ -256,3 +256,5 @@ quiet_cmd_hdrtest = HDRTEST $(patsubst %.hdrtest,%.h,$@)
$(obj)/%.hdrtest: $(src)/%.h FORCE
$(call if_changed_dep,hdrtest)
+
+obj-$(CONFIG_DRM_VINO) += vino/
diff --git a/drivers/gpu/drm/vino/Kconfig b/drivers/gpu/drm/vino/Kconfig
new file mode 100644
index 000000000000..234ce92736e4
--- /dev/null
+++ b/drivers/gpu/drm/vino/Kconfig
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: GPL-2.0
+config DRM_VINO
+ tristate "DisplayLink DL3 (Vino) open driver"
+ depends on USB = y
+ depends on DRM
+ depends on RUST
+ select DRM_KMS_HELPER
+ select DRM_GEM_SHMEM_HELPER
+ select RUST_DRM_GEM_SHMEM_HELPER
+ help
+ Open in-kernel Rust driver for DisplayLink DL3 USB docks (Dell
+ Universal Dock D6000 and relatives), reverse-engineered in this tree
+ (see vino-re/docs/00-canonical-guide.md).
+
+ Phase 0 binds the dock over USB only. USB data transfer, the HDCP 2.2
+ control plane, mode-set, the Vino codec and the DRM/KMS sink are added
+ in later phases (see vino-kmod/README.md).
+
+ To compile this as a module, choose M here: the module is called vino.
+
+ If unsure, say N.
diff --git a/drivers/gpu/drm/vino/Makefile b/drivers/gpu/drm/vino/Makefile
new file mode 100644
index 000000000000..6e39668040f3
--- /dev/null
+++ b/drivers/gpu/drm/vino/Makefile
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: GPL-2.0
+obj-$(CONFIG_DRM_VINO) += vino.o
diff --git a/drivers/gpu/drm/vino/proto.rs b/drivers/gpu/drm/vino/proto.rs
new file mode 100644
index 000000000000..cae6eae46b7a
--- /dev/null
+++ b/drivers/gpu/drm/vino/proto.rs
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! The DL3 "universal" wire framing and the plaintext session-init messages (sec 3/sec 4).
+
+use super::*;
+
+/// Append a sec 3-framed message to `out` with an explicit `sub_len_dw`: a 16-byte
+/// little-endian header (`pad(2) | size(2)=total-4 | type(4) | sub_id(2) |
+/// sub_len_dw(2) | seq(4)`) followed by `body`.
+///
+/// HDCP OUT messages (sec 5.1) carry DLM-fixed `sub_len_dw` values that are *not*
+/// `body.len() / 4`, so the framer cannot derive it -- the caller passes it.
+pub(super) fn push_frame_with(
+ out: &mut KVec<u8>,
+ msg_type: u32,
+ sub_id: u16,
+ sub_len_dw: u16,
+ seq: u32,
+ body: &[u8],
+) -> Result {
+ let size = ((16 + body.len()) - 4) as u16;
+ out.extend_from_slice(&[0, 0], GFP_KERNEL)?;
+ out.extend_from_slice(&size.to_le_bytes(), GFP_KERNEL)?;
+ out.extend_from_slice(&msg_type.to_le_bytes(), GFP_KERNEL)?;
+ out.extend_from_slice(&sub_id.to_le_bytes(), GFP_KERNEL)?;
+ out.extend_from_slice(&sub_len_dw.to_le_bytes(), GFP_KERNEL)?;
+ out.extend_from_slice(&seq.to_le_bytes(), GFP_KERNEL)?;
+ out.extend_from_slice(body, GFP_KERNEL)?;
+ Ok(())
+}
+
+/// `init_25` body (sec 4, verified 2026-05-27). Framed with `sub_len_dw=0` -- the
+/// DLM-fixed value, NOT `body.len()/4` (the dock ignores/rejects otherwise).
+pub(super) const INIT_25: [u8; 16] =
+ [0x05, 0, 0x08, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
+/// `init_4` (Part A) body (sec 4), also framed with `sub_len_dw=0`.
+pub(super) const INIT_4: [u8; 16] =
+ [0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
+/// The first HDCP-channel probe **body** (Part B of the init_4+probe transfer,
+/// sec 4): a 32-byte body leading with `14 00 76 00`, the rest zero. It is wrapped
+/// in its own type=4 sub=0x04 frame (`sub_len_dw=0x0a`) -- see [`init_4_probe`].
+/// The dock only ACKs once this framed probe arrives.
+pub(super) const PROBE_BODY: [u8; 32] = {
+ let mut p = [0u8; 32];
+ p[0] = 0x14;
+ p[2] = 0x76;
+ p
+};
+
+/// `init_0`: 16-byte framing header only, empty body (sec 4).
+pub(super) fn init_0() -> Result<KVec<u8>> {
+ let mut buf = KVec::with_capacity(16, GFP_KERNEL)?;
+ push_frame_with(&mut buf, 0x01, 0x00, 0, 0, &[])?;
+ Ok(buf)
+}
+
+/// `init_25`: type=2 sub=0x25, `sub_len_dw=0`, 32 bytes total (sec 4).
+pub(super) fn init_25() -> Result<KVec<u8>> {
+ let mut buf = KVec::with_capacity(32, GFP_KERNEL)?;
+ push_frame_with(&mut buf, 0x02, 0x25, 0, 0, &INIT_25)?;
+ Ok(buf)
+}
+
+/// `init_4` + HDCP probe as one 80-byte transfer (sec 4): Part A (type=2 sub=0x04,
+/// `sub_len_dw=0`, 32 B) concatenated with Part B -- the probe framed as type=4
+/// sub=0x04 with `sub_len_dw=0x0a` over the 32-byte [`PROBE_BODY`] (48 B). This
+/// is the message the dock ACKs.
+pub(super) fn init_4_probe() -> Result<KVec<u8>> {
+ let mut buf = KVec::with_capacity(80, GFP_KERNEL)?;
+ push_frame_with(&mut buf, 0x02, 0x04, 0, 0, &INIT_4)?; // Part A
+ push_frame_with(&mut buf, 0x04, 0x04, 0x0a, 0, &PROBE_BODY)?; // Part B (framed probe)
+ Ok(buf)
+}
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
new file mode 100644
index 000000000000..79f446041b64
--- /dev/null
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -0,0 +1,391 @@
+// SPDX-License-Identifier: GPL-2.0
+// SPDX-FileCopyrightText: Copyright (C) 2026 Mike Lothian
+
+//! Vino -- open in-kernel Rust driver for DisplayLink DL3 docks (Dell D6000, ...).
+//!
+//! This is an `[RFC]` work-in-progress, posted to ask for help. It is a clean-room
+//! reverse-engineered replacement for the proprietary DisplayLinkManager userspace
+//! daemon + the EVDI kernel module, written natively in Rust against the in-tree USB,
+//! crypto and DRM/KMS bindings.
+//!
+//! This first patch is the skeleton: it binds the dock over USB and runs the plaintext
+//! connect handshake (the control-request preamble and the three bulk init messages over
+//! the Rust USB bulk + control transfer API). The HDCP 2.2 AKE, the AES-CTR/AES-CMAC
+//! control plane, the Vino codec and the DRM/KMS sink are added in the following patches.
+//!
+//! Device: VID 0x17e9 (DisplayLink) / PID 0x6006 (Dell Universal Dock D6000).
+
+use kernel::{
+ alloc::flags::GFP_KERNEL,
+ device::{self, Core},
+ error::code::ENODEV,
+ prelude::*,
+ sync::{aref::ARef, Arc},
+ time::Delta,
+ usb,
+ workqueue::{self, impl_has_work, new_work, Work, WorkItem},
+};
+
+/// DisplayLink vendor id.
+const VID_DISPLAYLINK: u16 = 0x17e9;
+/// Dell Universal Dock D6000 (DL3 family) product id.
+const PID_D6000: u16 = 0x6006;
+
+/// Control + per-head bulk endpoints (guide sec 2).
+const EP_CTRL_OUT: u8 = 0x02;
+const EP_CTRL_IN: u8 = 0x84;
+
+/// USB transfer timeout used during bring-up.
+fn timeout() -> Delta {
+ Delta::from_millis(1000)
+}
+
+mod proto;
+
+/// Per-bound-interface driver state.
+struct VinoDriver {
+ _intf: ARef<usb::Interface>,
+}
+
+/// Deferred bring-up work item: the bring-up sequence run on the system workqueue instead
+/// of inline in `probe()` (which would pin the driver-model probe thread on blocking USB
+/// I/O while the card node is live). Holds a refcounted handle to the bound interface (and,
+/// once the DRM sink exists, the DRM device), so they outlive `probe()`.
+#[pin_data]
+struct BringUp {
+ intf: ARef<usb::Interface>,
+ #[pin]
+ work: Work<BringUp>,
+}
+
+impl_has_work! {
+ impl HasWork<Self> for BringUp { self.work }
+}
+
+impl BringUp {
+ fn new(intf: ARef<usb::Interface>) -> Result<Arc<Self>> {
+ Arc::pin_init(
+ pin_init!(BringUp {
+ intf,
+ work <- new_work!("vino::bring_up"),
+ }),
+ GFP_KERNEL,
+ )
+ }
+}
+
+impl WorkItem for BringUp {
+ type Pointer = Arc<BringUp>;
+
+ fn run(this: Arc<BringUp>) {
+ let cdev: &device::Device = this.intf.as_ref();
+ let dev: &usb::Device = this.intf.as_ref();
+ // WIP scaffold: attempt the plaintext bring-up. Bind regardless of the outcome --
+ // there is no display path yet (the HDCP AKE, control plane and DRM sink land in
+ // the following patches).
+ match VinoDriver::bring_up(dev) {
+ Ok(()) => dev_info!(cdev, "vino: plaintext session init OK\n"),
+ Err(e) => dev_info!(cdev, "vino: session init incomplete ({e:?}) -- WIP\n"),
+ }
+ }
+}
+
+impl VinoDriver {
+ /// Plaintext session bring-up (sec 4): control-request preamble then the three
+ /// bulk init messages, reading the single ACK. Best-effort during scaffold
+ /// bring-up -- errors are logged, not fatal.
+ fn bring_up(dev: &usb::Device) -> Result {
+ // Control-request preamble (sec 4): dock-id read, interface selection, then the
+ // vendor_out 0x24 / vendor_in 0x22 pairs that kick off the HDCP path. (The
+ // GET_DESCRIPTOR string reads DLM also issues look cosmetic and are omitted.)
+ const VENDOR_OUT: u8 = 0x40; // host->dev, vendor, device
+ const VENDOR_IN_IFACE: u8 = 0xc1; // dev->host, vendor, INTERFACE recipient (DLM's choice)
+
+ // The DLM-style vendor preamble (sec 4). Per the userspace oracle, every
+ // control request here is **best-effort**: the dock legitimately STALLs
+ // some of them (e.g. the cosmetic dock-id read) yet still advances its
+ // host-identification state. The oracle tolerates each error and relies
+ // on DLM's inter-request timing gaps -- without those gaps the dock may
+ // not advance. So we log-and-continue on every control step and insert
+ // the same delays; only the bulk init + ACK is treated as load-bearing.
+ // GROUND-TRUTH 2026-06-13: at device-open DLM issues two vendor-IN reads on interface 1,
+ // recipient 0xc1, BEFORE the SET_INTERFACE / 0x24 / 0x22 sequence (dlm-cold-20260611-123347
+ // f708 `0xc1 0xfe wIdx=1` -> 16 B "RidgeDock" blob; f710 `0xc1 0xfc wIdx=1` -> 0 B). vino
+ // skipped them; the earlier attempt used recipient 0xc0 (device) and STALLed, which was
+ // misread as "the dock rejects 0xfe / DLM never sends it". Issue them here with the correct
+ // 0xc1 recipient. Best-effort: log and continue (the dock may still short/stall 0xfc).
+ let mut dock_id = [0u8; 16];
+ match dev.control_recv(0xfe, VENDOR_IN_IFACE, 0, 1, &mut dock_id, timeout()) {
+ Ok(()) => pr_info!("vino: step device-open 0xfe(iface1) OK = {:02x?}\n", dock_id),
+ Err(e) => pr_info!("vino: step device-open 0xfe(iface1) non-fatal ({e:?})\n"),
+ }
+ let mut probe3 = [0u8; 3];
+ match dev.control_recv(0xfc, VENDOR_IN_IFACE, 0, 1, &mut probe3, timeout()) {
+ Ok(()) => pr_info!("vino: step device-open 0xfc(iface1) OK = {:02x?}\n", probe3),
+ Err(e) => pr_info!("vino: step device-open 0xfc(iface1) non-fatal ({e:?})\n"),
+ }
+ // EXPERIMENT (2026-06-16): replay DLM's repeated STRING-descriptor reads at device-open.
+ // Timing analysis of the paired cold capture (captures/paired-coldbus-20260615-220311)
+ // shows DLM, beyond the distinct descriptor SET vino already issues, re-reads STRING idx0
+ // (language-ID list) and idx3 (en-US product, langid 0x0409), 255 B each, at ~2/sec for the
+ // ENTIRE 175 s session -- a 1 Hz host string-poll heartbeat. Engagement happens in the
+ // first
+ // second, so this is almost certainly NOT a pre-AKE gate (the distinct set already
+ // matches),
+ // but the repetition was never A/B-tested by replay the way the 0xfe/0xfc reads were. Issue
+ // a
+ // small burst here, BEFORE the AKE, to test whether the dock conditions CP engagement on
+ // seeing the host poll its strings. Best-effort: the kernel reports EREMOTEIO on the
+ // expected
+ // short reply, but the GET_DESCRIPTOR still reaches the wire, which is all the experiment
+ // needs.
+ // RESULT 2026-06-16 (paired-coldbus-20260616-162650): the pre-arm GET_DESCRIPTOR delta is
+ // USB ENUMERATION, not application protocol. Both captures contain an identical 3x 8-byte +
+ // 7x 18-byte DEVICE-descriptor read sequence -- which no kernel driver issues (it is the
+ // enumeration handshake the USB core runs each time the dock re-enumerates on the cold
+ // plug, plus DisplayLink's leftover /opt/displaylink/udev.sh hook firing per uevent).
+ // Proven to be enumeration, not the DLM daemon: the vino capture reproduces the SAME reads
+ // with displaylink-driver.service masked and no DisplayLinkManager process running. It is
+ // symmetric across both runs, so it is neither a DLM-vs-vino difference nor the engagement
+ // gate. This speculative burst only ADDED vino-issued reads on top, so disable it.
+ // -- LIBUSB-STYLE DEVICE-OPEN ENUMERATION (2026-06-17)
+ // ----------------------------------
+ // The clean paired capture (paired-coldbus-20260616-180401) isolated the LAST pre-AKE
+ // divergence from DLM to ONE thing: DLM (libusb) re-reads the dock's full descriptor set
+ // when it opens the device -- DEVICE(18), CONFIG(9 then full ~618), STRING langid(idx0),
+ // then every STRING index the descriptors reference (~22x 255B) -- right before the AKE.
+ // A
+ // kernel driver normally skips this (the USB core cached it at enumeration), which is why
+ // vino's pre-arm control stream was missing it (the "DLM-ONLY 255x22 / 618 / 40"
+ // residual).
+ // These reads are CP-irrelevant descriptor boilerplate. The cold-plug A/B proved the dock
+ // does NOT gate CP on them (replaying them byte-for-byte still gave 0x wsub=0x45 -- see
+ // project_get_descriptor_burst_experiment / the firmware-wall verdict), and the in-kernel
+ // Windows (WDF) and macOS (IOUSBLib) drivers DON'T issue this burst either -- like vino
+ // they run over an already-enumerated device and use the USB core's cached descriptors.
+ // The burst is therefore a libusb-userspace artifact, not something the dock expects.
+ // Default OFF so vino behaves like a native kernel driver; flip to `true` only to reproduce
+ // DLM's libusb wire for a paired A/B diff. Best-effort throughout: a STALL/EREMOTEIO on an
+ // absent index is fine -- EP0 auto-recovers and the SETUP still reaches the wire (all the
+ // A/B diff needs). Reproduces (histogram diff DLM vs vino, paired-coldbus-20260616-180401):
+ // DLM's libusb open adds CONFIG-full(618)x3, CONFIG-partial(40)x3, STRING(255)x22, with
+ // no
+ // extra DEVICE(18)/CONFIG(9).
+ const CP_LIBUSB_OPEN_ENUM: bool = false;
+ if CP_LIBUSB_OPEN_ENUM {
+ let mut tmp = [0u8; 255];
+ let mut cfg = KVec::from_elem(0u8, 618, GFP_KERNEL)?;
+ // CONFIG full (618) x3 -- parse the first to find real string indices so the STRING
+ // reads
+ // below return data (matching DLM's byte counts), not just the SETUP counts.
+ for _ in 0..3 {
+ let _ = dev.control_recv(0x06, 0x80, 0x0200, 0, &mut cfg, timeout());
+ }
+ // CONFIG partial (40) x3.
+ for _ in 0..3 {
+ let _ = dev.control_recv(0x06, 0x80, 0x0200, 0, &mut tmp[..40], timeout());
+ }
+ // STRING idx0 = language-ID list (1st of the 22x 255 reads); adopt the dock's REAL
+ // langid.
+ let mut langid = 0x0409u16;
+ if dev.control_recv(0x06, 0x80, 0x0300, 0, &mut tmp, timeout()).is_ok() && tmp[0] >= 4 {
+ langid = (tmp[2] as u16) | ((tmp[3] as u16) << 8);
+ }
+ // String indices referenced by the config (iConfiguration @off6, iInterface @off8).
+ let mut idxs = [0u8; 64];
+ let mut ni = 0usize;
+ let mut p = 0usize;
+ while p + 2 <= cfg.len() {
+ let blen = cfg[p] as usize;
+ if blen == 0 {
+ break;
+ }
+ let btype = cfg[p + 1];
+ if btype == 0x02 && p + 7 <= cfg.len() && cfg[p + 6] != 0 && ni < idxs.len() {
+ idxs[ni] = cfg[p + 6];
+ ni += 1;
+ }
+ if btype == 0x04 && p + 9 <= cfg.len() && cfg[p + 8] != 0 && ni < idxs.len() {
+ idxs[ni] = cfg[p + 8];
+ ni += 1;
+ }
+ p += blen;
+ }
+ // 21 more STRING(255) reads (idx0 above makes 22 total = DLM's count). Cycle the real
+ // referenced indices so each returns data; DLM likewise re-reads indices.
+ let mut nok = 0usize;
+ for k in 0..21usize {
+ let i = if ni > 0 { idxs[k % ni] as u16 } else { 1 + k as u16 };
+ if dev
+ .control_recv(0x06, 0x80, 0x0300 | i, langid, &mut tmp, timeout())
+ .is_ok()
+ {
+ nok += 1;
+ }
+ }
+ pr_info!(
+ "vino: libusb-open enum: config 618x3 + 40x3, langid={langid:#06x}, strings 22 ({nok} ok of {ni} refs)\n"
+ );
+ }
+
+ // SET_INTERFACE: DLM's two handshake SET_INTERFACEs target iface 1 (alt 0,
+ // app-specific/DFU) then iface 0 (alt 0, vendor) -- confirmed by a clean cold
+ // DLM usbmon capture (captures/dlm-cold-20260611-123347, t=52.079/52.085).
+ // The old code set iface 4 (the microphone) which DLM NEVER touches in the
+ // handshake (the 58 audio SET_INTERFACEs in a session are snd-usb-audio's, not
+ // DLM's -- see project_cp_setinterface_is_audio_binding_fix).
+ match dev.set_interface(1, 0) {
+ Ok(()) => pr_info!("vino: step set_interface(1,0) OK\n"),
+ Err(e) => pr_info!("vino: step set_interface(1,0) non-fatal ({e:?})\n"),
+ }
+ match dev.set_interface(0, 0) {
+ Ok(()) => pr_info!("vino: step set_interface(0,0) OK\n"),
+ Err(e) => pr_info!("vino: step set_interface(0,0) non-fatal ({e:?})\n"),
+ }
+ // vendor_out 0x24 (wValue=3, initial ack) then vendor_in 0x22 (state read,
+ // wValue=1 -- DLM's exact values; wValue=0 STALLs). Both best-effort: the
+ // dock advances state regardless and the oracle tolerates failure here.
+ match dev.control_send(0x24, VENDOR_OUT, 3, 0, &[], timeout()) {
+ Ok(()) => pr_info!("vino: step 0x24(wValue=3) OK\n"),
+ Err(e) => pr_info!("vino: step 0x24(wValue=3) non-fatal ({e:?})\n"),
+ }
+ // 0xc1 = IN|vendor|INTERFACE recipient (NOT 0xc0, device recipient): DLM's cold capture
+ // uses
+ // bmRequestType=0xc1, wIndex=0 (interface 0). wValue=1 (DLM's value; 0 stalls). Uses the
+ // function-scope `VENDOR_IN_IFACE` declared in the device-open preamble above.
+ let mut state = [0u8; 28];
+ match dev.control_recv(0x22, VENDOR_IN_IFACE, 1, 0, &mut state, timeout()) {
+ Ok(()) => pr_info!("vino: step 0x22(wValue=1) OK = {:02x?}\n", state),
+ Err(e) => pr_info!("vino: step 0x22(wValue=1) non-fatal ({e:?})\n"),
+ }
+
+ // Plaintext session init (sec 4) in DLM's exact wire order. The dock only
+ // ACKs once init_4+probe arrives, and it gates on DLM's fingerprint -- the
+ // interleaved GET_DESCRIPTOR reads (CONFIGURATION before init_0, two STRING
+ // reads between init_25 and init_4). Those reads are best-effort: the
+ // kernel reports EREMOTEIO on the short reply but the request still hits the
+ // wire (all we need). init_0/init_25/init_4+probe are separate transfers.
+ const STD_IN: u8 = 0x80; // dev->host, standard, device
+ let mut desc = KVec::from_elem(0u8, 618, GFP_KERNEL)?;
+ let _ = dev.control_recv(0x06, STD_IN, 0x0200, 0, &mut desc[..40], timeout()); // CONFIG, 40
+ let _ = dev.control_recv(0x06, STD_IN, 0x0200, 0, &mut desc, timeout()); // CONFIG, 618
+
+ // Log EP02's bulk wMaxPacketSize from the config descriptor. If it is 64 then a 64-byte
+ // msg0/arm is an exact multiple and the in-kernel `usb_bulk_msg` path (unlike libusb's
+ // LIBUSB_TRANSFER_ADD_ZERO_PACKET) won't auto-append the terminating ZLP -- the dock's SIE
+ // would then wait for more data and never hand the frame to firmware. Rules the ZLP-trap
+ // hypothesis in or out from data we already capture. Walk the standard descriptor chain
+ // (bLength/bDescriptorType), find the ENDPOINT (0x05) descriptor for bEndpointAddress 0x02.
+ {
+ let total = ((desc[2] as usize) | ((desc[3] as usize) << 8)).min(desc.len());
+ let mut i = 0usize;
+ while i + 2 <= total {
+ let blen = desc[i] as usize;
+ if blen == 0 {
+ break;
+ }
+ if desc[i + 1] == 0x05 && i + 7 <= total && desc[i + 2] == EP_CTRL_OUT {
+ let wmax = (desc[i + 4] as u16) | ((desc[i + 5] as u16) << 8);
+ pr_info!("vino: EP02 bulk wMaxPacketSize = {wmax} (ZLP needed if msg0 is a multiple)\n");
+ }
+ i += blen;
+ }
+ }
+
+ let load_bearing = |label: &str, msg: &[u8]| -> Result {
+ match dev.bulk_send(EP_CTRL_OUT, msg, timeout()) {
+ Ok(_) => Ok(pr_info!("vino: step {label} OK ({} B)\n", msg.len())),
+ Err(e) => {
+ pr_err!("vino: step {label} FAILED ({e:?})\n");
+ Err(e)
+ }
+ }
+ };
+ load_bearing("init_0", &proto::init_0()?)?;
+ load_bearing("init_25", &proto::init_25()?)?;
+ // DLM's two interleaved STRING reads between init_25 and init_4+probe.
+ let _ = dev.control_recv(0x06, STD_IN, 0x0300, 0x0000, &mut desc[..255], timeout()); // STRING #0
+ let _ = dev.control_recv(0x06, STD_IN, 0x0303, 0x0409, &mut desc[..255], timeout()); // STRING #3 en-US
+ load_bearing("init_4+probe", &proto::init_4_probe()?)?;
+
+ // Read the single ACK that follows init_4+probe.
+ let mut ack = KVec::from_elem(0u8, 1024, GFP_KERNEL)?;
+ match dev.bulk_recv(EP_CTRL_IN, &mut ack, timeout()) {
+ Ok(n) => Ok(pr_info!("vino: session-init ACK = {n} bytes: {:02x?}\n",
+ &ack[..n.min(40)])),
+ Err(e) => {
+ pr_err!("vino: session-init ACK read FAILED ({e:?})\n");
+ Err(e)
+ }
+ }
+ }
+
+}
+
+kernel::usb_device_table!(
+ USB_TABLE,
+ MODULE_USB_TABLE,
+ <VinoDriver as usb::Driver>::IdInfo,
+ [(usb::DeviceId::from_id(VID_DISPLAYLINK, PID_D6000), ())]
+);
+
+impl usb::Driver for VinoDriver {
+ type IdInfo = ();
+ // The driver instance is itself the per-bound device-private data.
+ type Data<'bound> = Self;
+ const ID_TABLE: usb::IdTable<Self::IdInfo> = &USB_TABLE;
+
+ fn probe<'bound>(
+ intf: &'bound usb::Interface<Core<'_>>,
+ _id: &usb::DeviceId,
+ _info: &'bound Self::IdInfo,
+ ) -> impl PinInit<Self, Error> + 'bound {
+ let cdev: &device::Device<Core<'_>> = intf.as_ref();
+ // The D6000 exposes several interfaces (0/1/5/6 match us; 2-4 are audio).
+ // The control endpoints (0x02/0x84) and the whole HDCP session live on
+ // interface 0 -- drive bring-up only there so we don't run the preamble and
+ // AKE four times and pollute the dock's state machine. Other interfaces
+ // bind (so usbcore doesn't hand them to another driver) but stay idle.
+ let ifnum = intf.number();
+ if ifnum != 0 {
+ // Interface 1 (app-specific/DFU) is the only other one DLM claims; let everything else
+ // (audio 2-4, Ethernet 5-6) fall through to its proper kernel driver. Returning ENODEV
+ // tells usbcore this driver doesn't handle the interface, so it tries the next match.
+ if ifnum != 1 {
+ dev_info!(cdev, "vino: declining D6000 interface {ifnum} (left to its class driver)\n");
+ return Err(ENODEV);
+ }
+ dev_info!(cdev, "vino: bound D6000 interface {ifnum} (idle -- control is iface 0)\n");
+ return Ok(Self { _intf: intf.into() });
+ }
+ dev_info!(cdev, "vino: bound DisplayLink D6000 -- plaintext session bring-up\n");
+
+ // Bring-up is blocking synchronous USB I/O; hand it to the system workqueue so
+ // probe() returns immediately and userspace stays responsive. The work item holds
+ // a refcounted handle to the interface, so the bulk endpoints outlive probe(); USB
+ // I/O after an intervening disconnect simply errors and is logged.
+ let intf_ref: ARef<usb::Interface> = intf.into();
+ match BringUp::new(intf_ref.clone()) {
+ Ok(work) => {
+ let _ = workqueue::system().enqueue(work);
+ dev_info!(cdev, "vino: bring-up queued on system workqueue\n");
+ }
+ Err(e) => dev_info!(cdev, "vino: failed to queue bring-up ({e:?}) -- WIP\n"),
+ }
+
+ Ok(Self { _intf: intf_ref })
+ }
+
+ fn disconnect<'bound>(intf: &'bound usb::Interface<Core<'_>>, _data: Pin<&Self>) {
+ let dev: &device::Device<Core<'_>> = intf.as_ref();
+ dev_info!(dev, "vino: D6000 disconnected\n");
+ }
+}
+
+kernel::module_usb_driver! {
+ type: VinoDriver,
+ name: "vino",
+ authors: ["Mike Lothian"],
+ description: "DisplayLink DL3 (Vino) open driver",
+ license: "GPL v2",
+}
--
2.54.0
^ permalink raw reply related [flat|nested] 12+ messages in thread
* [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE
2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
2026-06-17 16:18 ` Eric Biggers
2026-06-17 15:12 ` [RFC PATCH 3/7] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm Mike Lothian
` (5 subsequent siblings)
7 siblings, 1 reply; 12+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
To: dri-devel
Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
linux-kernel
After the plaintext session init, the DL3 dock requires an HDCP 2.2
session before it will accept any control-plane traffic. Add a clean-room
implementation of the HDCP 2.2 authentication: the AKE (with stored-km
and no-stored-km), locality check (LC) and session-key exchange (SKE),
all verified against the live dock -- H', L' and V' all match, so the
shared session key ks and content IV riv are established.
New modules:
- crypto: thin adapters onto the in-tree kernel library-crypto bindings
(AES-128-ECB, AES-CMAC, HMAC-SHA256, SHA-256) used by the KDF;
- rng: CSPRNG helpers for the per-session HDCP nonces/keys;
- hdcp: the HDCP 2.2 key derivation (kd/dkey/ks) and H'/L'/V' verifier
computation (the byte-exact KDF formulas);
- ake: the HDCP 2.2 AKE wire layer (OUT message builders, IN parsing);
- golden: the session-invariant plaintext capability-announce skeleton
the driver re-states with this session's live AKE values right after
the AKE (build_cap_announce).
run_ake() drives the state machine end to end and returns the keyed
Session; an on-device crypto known-answer self-test (FIPS-197 AES-128,
RFC 4493 AES-CMAC) confirms the in-kernel crypto path is byte-correct.
The encrypted control plane that consumes the Session lands in the next
patch.
Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
drivers/gpu/drm/vino/ake.rs | 167 +++++++++
drivers/gpu/drm/vino/crypto.rs | 81 ++++
drivers/gpu/drm/vino/golden.rs | 69 ++++
drivers/gpu/drm/vino/hdcp.rs | 167 +++++++++
drivers/gpu/drm/vino/rng.rs | 12 +
drivers/gpu/drm/vino/vino.rs | 662 ++++++++++++++++++++++++++++++++-
6 files changed, 1148 insertions(+), 10 deletions(-)
create mode 100644 drivers/gpu/drm/vino/ake.rs
create mode 100644 drivers/gpu/drm/vino/crypto.rs
create mode 100644 drivers/gpu/drm/vino/golden.rs
create mode 100644 drivers/gpu/drm/vino/hdcp.rs
create mode 100644 drivers/gpu/drm/vino/rng.rs
diff --git a/drivers/gpu/drm/vino/ake.rs b/drivers/gpu/drm/vino/ake.rs
new file mode 100644
index 000000000000..ad79d2754c60
--- /dev/null
+++ b/drivers/gpu/drm/vino/ake.rs
@@ -0,0 +1,167 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! HDCP 2.2 AKE wire layer (sec 5.1 OUT framing, sec 5.2 IN parsing) -- the byte-exact
+//! message builders the AKE state machine drives, mirroring the verified userspace
+//! oracle (`vino-driver::hdcp_msgs`). DLM hardcodes per-message `sub_size` /
+//! `sub_len_dw` values the dock validates, so they are reproduced verbatim rather
+//! than derived.
+//!
+//! OUT body layout (sec 5.1), after the 16-byte sec 3 transport header:
+//! ```text
+//! body[0..2] u16 sub_size (DLM-fixed per message)
+//! body[2..4] u16 = 0x0010
+//! body[4..8] u32 hdcp_seq increments 1..7 across the AKE OUT messages
+//! body[8..22] 14 zero bytes
+//! body[22..26] u32 = 0x00000030 marker
+//! body[26] u8 = 0x00 flag
+//! body[27] u8 = msg_id
+//! body[28..] HDCP payload (zero-padded to the fixed body length)
+//! ```
+#![allow(dead_code)] // AKE message builders; response handlers run only after CP engagement
+
+use super::*;
+
+/// HDCP 2.2 message IDs (sec 5.3). `pub(crate)` so the AKE state machine
+/// ([`super::VinoDriver::run_ake`]) can match on the response IDs too.
+pub(crate) mod id {
+ use kernel::bindings;
+
+ // Standard HDCP 2.2 message IDs: reuse the canonical values from
+ // `<drm/display/drm_hdcp.h>` rather than redefining them, so vino stays in
+ // lockstep with the kernel's HDCP definitions. Only the transport framing
+ // around these (the DisplayLink type/sub/ctr header) is vino-specific.
+ pub(crate) const AKE_INIT: u8 = bindings::HDCP_2_2_AKE_INIT as u8;
+ pub(crate) const AKE_SEND_CERT: u8 = bindings::HDCP_2_2_AKE_SEND_CERT as u8;
+ pub(crate) const AKE_NO_STORED_KM: u8 = bindings::HDCP_2_2_AKE_NO_STORED_KM as u8;
+ pub(crate) const AKE_SEND_H_PRIME: u8 = bindings::HDCP_2_2_AKE_SEND_HPRIME as u8;
+ pub(crate) const AKE_SEND_PAIRING_INFO: u8 = bindings::HDCP_2_2_AKE_SEND_PAIRING_INFO as u8;
+ pub(crate) const LC_INIT: u8 = bindings::HDCP_2_2_LC_INIT as u8;
+ pub(crate) const LC_SEND_L_PRIME: u8 = bindings::HDCP_2_2_LC_SEND_LPRIME as u8;
+ pub(crate) const SKE_SEND_EKS: u8 = bindings::HDCP_2_2_SKE_SEND_EKS as u8;
+ pub(crate) const REPEATERAUTH_SEND_RECEIVERID_LIST: u8 =
+ bindings::HDCP_2_2_REP_SEND_RECVID_LIST as u8;
+ pub(crate) const REPEATERAUTH_SEND_ACK: u8 = bindings::HDCP_2_2_REP_SEND_ACK as u8;
+ pub(crate) const REPEATERAUTH_STREAM_MANAGE: u8 = bindings::HDCP_2_2_REP_STREAM_MANAGE as u8;
+ pub(crate) const REPEATERAUTH_STREAM_READY: u8 = bindings::HDCP_2_2_REP_STREAM_READY as u8;
+
+ // DisplayLink-specific message IDs with no `<drm/display/drm_hdcp.h>` equivalent
+ // (the AKE_Send_rrx split and the transmitter/receiver-info + auth-status messages
+ // the DL3 dock uses), kept as literals.
+ pub(crate) const AKE_SEND_RRX: u8 = 0x06;
+ pub(crate) const RECEIVER_AUTH_STATUS: u8 = 0x12;
+ pub(crate) const AKE_TRANSMITTER_INFO: u8 = 0x13;
+ pub(crate) const AKE_RECEIVER_INFO: u8 = 0x14;
+}
+
+/// transport `sub_id` for HDCP OUT messages (type=4 sub=0x04, sec 5.1).
+const SUB_HDCP: u16 = 0x04;
+
+/// Allocate a `body_len`-byte zeroed body with the sec 5.1 header filled in
+/// (`sub_size`, the `0x0010` marker, `hdcp_seq`, the `0x30` marker and `msg_id`).
+/// The caller writes the payload into `body[28..]`.
+fn body(body_len: usize, sub_size: u16, hdcp_seq: u32, msg_id: u8) -> Result<KVec<u8>> {
+ let mut b = KVec::from_elem(0u8, body_len, GFP_KERNEL)?;
+ b[0..2].copy_from_slice(&sub_size.to_le_bytes());
+ b[2..4].copy_from_slice(&0x0010u16.to_le_bytes());
+ b[4..8].copy_from_slice(&hdcp_seq.to_le_bytes());
+ b[22..26].copy_from_slice(&0x0000_0030u32.to_le_bytes());
+ b[27] = msg_id;
+ Ok(b)
+}
+
+/// Wrap a finished HDCP body in the sec 3 transport header (type=4 sub=0x04) with
+/// the DLM-fixed `sub_len_dw` and the transport `seq`.
+fn wrap(sub_len_dw: u16, seq: u32, body: &[u8]) -> Result<KVec<u8>> {
+ let mut frame = KVec::with_capacity(16 + body.len(), GFP_KERNEL)?;
+ proto::push_frame_with(&mut frame, 0x04, SUB_HDCP, sub_len_dw, seq, body)?;
+ Ok(frame)
+}
+
+/// `AKE_Init` (msg_id 0x02): `rtx[8] || TxCaps[3]`, padded to a 48-byte body
+/// (`sub_size=0x22`, `sub_len_dw=0x0c` -- guide sec 5.4 table).
+pub(super) fn ake_init(
+ hdcp_seq: u32,
+ seq: u32,
+ rtx: &[u8; 8],
+ tx_caps: &[u8; 3],
+) -> Result<KVec<u8>> {
+ let mut b = body(48, 0x0022, hdcp_seq, id::AKE_INIT)?;
+ b[28..36].copy_from_slice(rtx);
+ b[36..39].copy_from_slice(tx_caps);
+ wrap(0x000c, seq, &b)
+}
+
+/// `AKE_Transmitter_Info` (msg_id 0x13): byte-exact DLM framing
+/// (`sub_size=0x1f`, `sub_len_dw=0x0f`), payload `00 06 02 00 02`.
+pub(super) fn ake_transmitter_info(hdcp_seq: u32, seq: u32) -> Result<KVec<u8>> {
+ let mut b = body(48, 0x001f, hdcp_seq, id::AKE_TRANSMITTER_INFO)?;
+ b[28..33].copy_from_slice(&[0x00, 0x06, 0x02, 0x00, 0x02]);
+ wrap(0x000f, seq, &b)
+}
+
+/// `AKE_No_Stored_km` (msg_id 0x04): the 128-byte RSA-OAEP-SHA256 `Ekpub(km)`
+/// in a 160-byte body (`sub_size=0x9a`, `sub_len_dw=0x04` -- guide sec 5.4 table).
+pub(super) fn ake_no_stored_km(
+ hdcp_seq: u32,
+ seq: u32,
+ ekpub_km: &[u8; 128],
+) -> Result<KVec<u8>> {
+ let mut b = body(160, 0x009a, hdcp_seq, id::AKE_NO_STORED_KM)?;
+ b[28..156].copy_from_slice(ekpub_km);
+ wrap(0x0004, seq, &b)
+}
+
+/// `LC_Init` (msg_id 0x09): `rn[8]` in a 48-byte body
+/// (`sub_size=0x22`, `sub_len_dw=0x0c`).
+pub(super) fn lc_init(hdcp_seq: u32, seq: u32, rn: &[u8; 8]) -> Result<KVec<u8>> {
+ let mut b = body(48, 0x0022, hdcp_seq, id::LC_INIT)?;
+ b[28..36].copy_from_slice(rn);
+ wrap(0x000c, seq, &b)
+}
+
+/// `SKE_Send_Eks` (msg_id 0x0b): `Edkey(ks)[16] || riv[8]` in a 64-byte body
+/// (`sub_size=0x32`, `sub_len_dw=0x0c`).
+pub(super) fn ske_send_eks(
+ hdcp_seq: u32,
+ seq: u32,
+ edkey_ks: &[u8; 16],
+ riv: &[u8; 8],
+) -> Result<KVec<u8>> {
+ let mut b = body(64, 0x0032, hdcp_seq, id::SKE_SEND_EKS)?;
+ b[28..44].copy_from_slice(edkey_ks);
+ b[44..52].copy_from_slice(riv);
+ wrap(0x000c, seq, &b)
+}
+
+/// `RepeaterAuth_Send_ACK` (msg_id 0x0f): the full `V[16]` in a 48-byte body
+/// (`sub_size=0x2a`, `sub_len_dw=0x04`).
+pub(super) fn repeater_auth_send_ack(
+ hdcp_seq: u32,
+ seq: u32,
+ v: &[u8; 16],
+) -> Result<KVec<u8>> {
+ let mut b = body(48, 0x002a, hdcp_seq, id::REPEATERAUTH_SEND_ACK)?;
+ b[28..44].copy_from_slice(v);
+ wrap(0x0004, seq, &b)
+}
+
+/// `RepeaterAuth_Stream_Manage` SM2 (msg_id 0x10): byte-exact DLM replica sent
+/// after Send_ACK -- `k=2` (LE), `StreamID_Type[0]=4` (LE), `body[43]=0x05`
+/// (`sub_size=0x2d`, `sub_len_dw=0x01`). See guide sec 5.4 and sec 8.2.
+pub(super) fn repeater_auth_stream_manage(hdcp_seq: u32, seq: u32) -> Result<KVec<u8>> {
+ let mut b = body(48, 0x002d, hdcp_seq, id::REPEATERAUTH_STREAM_MANAGE)?;
+ b[32..36].copy_from_slice(&[0x02, 0, 0, 0]); // k = 2 (LE)
+ b[36..40].copy_from_slice(&[0x04, 0, 0, 0]); // StreamID_Type[0] = 4 (LE)
+ b[43] = 0x05;
+ wrap(0x0001, seq, &b)
+}
+
+/// Parse an IN HDCP message body (sec 5.2): `body[8]` marker, `body[9]` msg_id,
+/// `body[10..]` payload (for `AKE_Send_Cert`, `body[10]` is a version flag).
+/// Returns `(msg_id, payload)`.
+pub(super) fn parse_in(body: &[u8]) -> Option<(u8, &[u8])> {
+ if body.len() < 10 {
+ return None;
+ }
+ Some((body[9], &body[10..]))
+}
diff --git a/drivers/gpu/drm/vino/crypto.rs b/drivers/gpu/drm/vino/crypto.rs
new file mode 100644
index 000000000000..04203db81991
--- /dev/null
+++ b/drivers/gpu/drm/vino/crypto.rs
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Thin adapters onto the shared [`kernel::crypto`] library-crypto bindings, so the
+//! protocol code keeps its `crypto::aes128_ecb` / `crypto::hmac_sha256` call sites.
+#![allow(dead_code)] // exercised by the AES-CTR seal + HDCP AKE
+
+use super::*;
+
+/// `AES_ECB(key, block)` -- one 16-byte AES-128 block.
+pub(super) fn aes128_ecb(key: &[u8; 16], block: &[u8; 16]) -> Result<[u8; 16]> {
+ kernel::crypto::Aes128::new(*key).encrypt_block(block)
+}
+
+/// `HMAC-SHA256(key, data)`.
+pub(super) fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
+ kernel::crypto::hmac_sha256(key, data)
+}
+
+/// `AES-CMAC-128(key, data)` (RFC 4493), built on the one-block ECB above.
+/// This is DisplayLink's "Dl3Cmac" core -- the CP per-message integrity tag is
+/// `AES_CMAC(ks, nonce8 || BE64(counter) || content)` (see `cp::dl3cmac_tag`);
+/// verified byte-exact against live DLM data (canonical guide sec 8.6.7).
+pub(super) fn aes_cmac(key: &[u8; 16], data: &[u8]) -> Result<[u8; 16]> {
+ // dbl: left-shift the 128-bit value by 1, XOR 0x87 if the MSB was set.
+ fn dbl(b: &[u8; 16]) -> [u8; 16] {
+ let mut o = [0u8; 16];
+ for i in 0..15 {
+ o[i] = (b[i] << 1) | (b[i + 1] >> 7);
+ }
+ o[15] = b[15] << 1;
+ if b[0] & 0x80 != 0 {
+ o[15] ^= 0x87;
+ }
+ o
+ }
+ let l = aes128_ecb(key, &[0u8; 16])?;
+ let k1 = dbl(&l);
+ let k2 = dbl(&k1);
+ let n = if data.is_empty() { 1 } else { data.len().div_ceil(16) };
+ let complete = !data.is_empty() && data.len() % 16 == 0;
+ let mut c = [0u8; 16];
+ for i in 0..n {
+ let mut blk = [0u8; 16];
+ let start = i * 16;
+ let end = core::cmp::min(start + 16, data.len());
+ blk[..end - start].copy_from_slice(&data[start..end]);
+ if i == n - 1 {
+ if complete {
+ for j in 0..16 {
+ blk[j] ^= k1[j];
+ }
+ } else {
+ blk[end - start] = 0x80; // 10* padding
+ for j in 0..16 {
+ blk[j] ^= k2[j];
+ }
+ }
+ }
+ for j in 0..16 {
+ blk[j] ^= c[j];
+ }
+ c = aes128_ecb(key, &blk)?;
+ }
+ Ok(c)
+}
+
+/// `SHA256(data)`.
+pub(super) fn sha256(data: &[u8]) -> [u8; 32] {
+ kernel::crypto::sha256(data)
+}
+
+/// Raw RSA public-key op `out = input^exponent mod modulus`, big-endian,
+/// `out` written fixed-width (caller applies OAEP padding to `input`).
+pub(super) fn rsa_pubkey_encrypt(
+ modulus: &[u8],
+ exponent: &[u8],
+ input: &[u8],
+ out: &mut [u8],
+) -> Result {
+ kernel::crypto::rsa_pubkey_encrypt(modulus, exponent, input, out)
+}
diff --git a/drivers/gpu/drm/vino/golden.rs b/drivers/gpu/drm/vino/golden.rs
new file mode 100644
index 000000000000..e379e888c9c8
--- /dev/null
+++ b/drivers/gpu/drm/vino/golden.rs
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Captured DisplayLink control-plane protocol templates.
+//!
+//! These are NOT replay dumps of an encrypted session. They are the
+//! session-invariant *plaintext skeletons* of two control-plane bursts captured
+//! from the proprietary DisplayLinkManager (DLM). The driver overwrites the
+//! session-specific fields with THIS session's live values and then seals the
+//! result under the live `ks`, so the bytes that reach the wire are this
+//! session's own, never the capture's. They remain inline here because the
+//! field-by-field live builders that would replace them are not yet written --
+//! see the "help wanted" note at the top of the file.
+
+/// Plaintext capability-announce skeleton: the seven `sub=0x10`, ctr 1..7
+/// frames that restate the AKE OUT messages. `build_cap_announce` walks this
+/// and overwrites each frame's payload with this session's live AKE value
+/// (rtx / Ekpub / rn / Edkey+riv / V). 590 bytes.
+pub(super) const CAP_PLAIN_1080P: &[u8] = &[
+ 0x40, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00,
+ 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x22, 0x00, 0x10, 0x00, 0x01, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x02, 0x1f, 0xe7,
+ 0x18, 0x56, 0x6e, 0x1f, 0xc0, 0x54, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x3c, 0x00,
+ 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x1f, 0x00, 0x10, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00,
+ 0x00, 0x00, 0x00, 0x13, 0x00, 0x06, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0xb0, 0x00, 0x00, 0x00, 0xac, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00,
+ 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9a, 0x00, 0x10, 0x00, 0x03, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x04, 0x0e, 0xd9,
+ 0x2f, 0x05, 0xee, 0x3e, 0xca, 0x40, 0x7e, 0x14, 0x9f, 0x9d, 0x12, 0x6c,
+ 0xca, 0x1a, 0x70, 0x27, 0x55, 0x02, 0x22, 0x0c, 0xde, 0x7d, 0x79, 0x6b,
+ 0x13, 0x14, 0x32, 0x62, 0xef, 0x62, 0xc0, 0xf2, 0xb6, 0x3d, 0x41, 0x21,
+ 0xcf, 0xbd, 0x2a, 0x40, 0xf9, 0xe8, 0x42, 0xc7, 0xbb, 0xa7, 0xcd, 0x8c,
+ 0x53, 0xab, 0x56, 0x4e, 0x5b, 0xf8, 0x55, 0x0a, 0x05, 0x96, 0x09, 0x28,
+ 0xbb, 0xf9, 0xbe, 0xc9, 0xe8, 0x81, 0x32, 0xaa, 0xc8, 0x49, 0x27, 0x3c,
+ 0x80, 0x5c, 0x7c, 0xb8, 0x23, 0x54, 0xb6, 0xe0, 0x38, 0x71, 0x3c, 0xdd,
+ 0xa6, 0x77, 0x91, 0x16, 0x3f, 0xd4, 0xec, 0xfd, 0xdd, 0x56, 0xf7, 0x01,
+ 0xe1, 0x6c, 0x03, 0x50, 0xdf, 0x80, 0xd5, 0x93, 0x66, 0x55, 0xe1, 0xd7,
+ 0x3b, 0x55, 0x7e, 0x9c, 0xb7, 0x71, 0xfe, 0x0b, 0x7d, 0x1c, 0x0d, 0x6b,
+ 0x18, 0xda, 0xdb, 0xbe, 0x79, 0x75, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00,
+ 0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x0c, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x22, 0x00, 0x10, 0x00, 0x04, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x09, 0xf4, 0xc4, 0x61, 0x0d,
+ 0xe0, 0x75, 0x99, 0xf5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x4c, 0x00, 0x04, 0x00,
+ 0x00, 0x00, 0x04, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x32, 0x00,
+ 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00,
+ 0x00, 0x0b, 0xb2, 0xd9, 0xbd, 0x87, 0x94, 0x1b, 0xf0, 0xec, 0x59, 0x40,
+ 0xf2, 0xba, 0xd5, 0x6d, 0x24, 0xab, 0x56, 0xfe, 0x0c, 0xff, 0xbc, 0x3a,
+ 0x9d, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00,
+ 0x04, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a, 0x00, 0x10, 0x00,
+ 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x0f,
+ 0x38, 0x08, 0x3b, 0x1f, 0x39, 0x61, 0xb4, 0x9b, 0x3a, 0x2e, 0x9a, 0x1c,
+ 0xbd, 0x64, 0x78, 0x85, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00,
+ 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x2d, 0x00, 0x10, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x30, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00,
+ 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00,
+ 0x00, 0x00,
+];
diff --git a/drivers/gpu/drm/vino/hdcp.rs b/drivers/gpu/drm/vino/hdcp.rs
new file mode 100644
index 000000000000..c22d58b624ab
--- /dev/null
+++ b/drivers/gpu/drm/vino/hdcp.rs
@@ -0,0 +1,167 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! HDCP 2.2 key derivation and verifier computation (sec 5.6), built on [`crypto`].
+//! Lets the driver run a clean-room AKE without DisplayLink's binary; the byte-exact
+//! formulas are verified against the live dock in the guide.
+#![allow(dead_code)] // some HDCP builders/handlers are reached only after CP engagement
+
+use super::*;
+
+/// `dkey_n = AES_ECB(km with low-8-bytes XOR rn, rtx || (rrx with byte15 XOR n))`
+/// (HDCP 2.2 IIA sec 2.7, sec 5.6). The counter `n` XORs into byte 15 (LSB of the rrx
+/// half) of the IV; `rn` XORs into the low 8 bytes (km[8..16]) of the key -- zero
+/// for the `kd` derivation, the SKE nonce for `dkey_2`.
+fn derive_dkey(
+ km: &[u8; 16],
+ rn: &[u8; 8],
+ rtx: &[u8; 8],
+ rrx: &[u8; 8],
+ n: u8,
+) -> Result<[u8; 16]> {
+ let mut iv = [0u8; 16];
+ iv[..8].copy_from_slice(rtx);
+ iv[8..].copy_from_slice(rrx);
+ iv[15] ^= n;
+ let mut key = *km;
+ for i in 0..8 {
+ key[8 + i] ^= rn[i];
+ }
+ crypto::aes128_ecb(&key, &iv)
+}
+
+/// `kd = dkey_0 || dkey_1` with `rn = 0` (sec 5.6) -- the 256-bit derived key.
+pub(super) fn derive_kd(km: &[u8; 16], rtx: &[u8; 8], rrx: &[u8; 8]) -> Result<[u8; 32]> {
+ let rn = [0u8; 8];
+ let dkey0 = derive_dkey(km, &rn, rtx, rrx, 0)?;
+ let dkey1 = derive_dkey(km, &rn, rtx, rrx, 1)?;
+ let mut kd = [0u8; 32];
+ kd[..16].copy_from_slice(&dkey0);
+ kd[16..].copy_from_slice(&dkey1);
+ Ok(kd)
+}
+
+/// `H' = HMAC-SHA256(kd, rtx with byte7 ^= repeater)` (sec 5.6).
+pub(super) fn compute_h(kd: &[u8; 32], rtx: &[u8; 8], repeater: bool) -> [u8; 32] {
+ let mut msg = *rtx;
+ msg[7] ^= repeater as u8;
+ crypto::hmac_sha256(kd, &msg)
+}
+
+/// `L' = HMAC-SHA256(kd with low-8-bytes XOR rrx, rn)` (sec 5.6).
+///
+/// "low-8-bytes" is the *least-significant* 64 bits of the 256-bit `kd`, i.e.
+/// `kd[24..32]` -- verified byte-exact against the live dock by the userspace
+/// oracle (`vino-hdcp::kdf::compute_l`). XOR-ing into `kd[0..8]` does not verify.
+pub(super) fn compute_l(kd: &[u8; 32], rrx: &[u8; 8], rn: &[u8; 8]) -> [u8; 32] {
+ let mut key = *kd;
+ for i in 0..8 {
+ key[24 + i] ^= rrx[i];
+ }
+ crypto::hmac_sha256(&key, rn)
+}
+
+/// Full `V = HMAC-SHA256(kd, list_header)` (256 bits) for RepeaterAuth (sec 2.3).
+/// The **MSB-128** (`[..16]`) is `V'` -- verified against the repeater's
+/// `RepeaterAuth_Send_ReceiverID_List` trailer. The **LSB-128** (`[16..]`) is the
+/// value the transmitter returns in `RepeaterAuth_Send_Ack`. vino had been sending
+/// the MSB (i.e. echoing the dock's own `V'`) as the Ack -- so the dock rejected the
+/// repeater authentication, never acknowledged Stream_Manage, and never engaged CP
+/// (proven 2026-06-11: vino's ctr6 == the dock's `id=0x21` list trailer; DLM's ctr6
+/// is a computed value present in no dock push). H'/L'/V' still pass because V'
+/// verification uses the MSB.
+pub(super) fn compute_v_full(kd: &[u8; 32], list_header: &[u8]) -> [u8; 32] {
+ crypto::hmac_sha256(kd, list_header)
+}
+
+/// MGF1 mask generation (RFC 8017 sec B.2.1) with SHA-256: returns `mask_len`
+/// bytes of `T = SHA256(seed || I2OSP(0,4)) || SHA256(seed || I2OSP(1,4)) || ...`.
+fn mgf1_sha256(seed: &[u8], mask_len: usize) -> Result<KVec<u8>> {
+ let mut mask = KVec::with_capacity(mask_len, GFP_KERNEL)?;
+ let mut counter: u32 = 0;
+ let mut block = KVec::with_capacity(seed.len() + 4, GFP_KERNEL)?;
+ while mask.len() < mask_len {
+ block.clear();
+ block.extend_from_slice(seed, GFP_KERNEL)?;
+ block.extend_from_slice(&counter.to_be_bytes(), GFP_KERNEL)?;
+ let digest = crypto::sha256(&block);
+ let take = core::cmp::min(digest.len(), mask_len - mask.len());
+ mask.extend_from_slice(&digest[..take], GFP_KERNEL)?;
+ counter += 1;
+ }
+ Ok(mask)
+}
+
+/// EME-OAEP encode (RFC 8017 sec 7.1.1) with SHA-256 and an empty label, for a
+/// `k`-byte modulus. Returns the `k`-byte encoded message `EM` ready for the
+/// raw RSA op. `seed` is `hLen` (32) random bytes. HDCP 2.2 uses SHA-256 here
+/// (SHA-1 makes the dock stop responding -- guide sec 5.4).
+fn eme_oaep_encode(k: usize, msg: &[u8], seed: &[u8; 32]) -> Result<KVec<u8>> {
+ const HLEN: usize = 32;
+ // DB = lHash || PS(zeros) || 0x01 || M, length k - hLen - 1.
+ let l_hash = crypto::sha256(&[]);
+ let db_len = k - HLEN - 1;
+ let mut db = KVec::with_capacity(db_len, GFP_KERNEL)?;
+ db.extend_from_slice(&l_hash, GFP_KERNEL)?;
+ let ps_len = db_len - HLEN - 1 - msg.len(); // k - mLen - 2*hLen - 2
+ for _ in 0..ps_len {
+ db.push(0, GFP_KERNEL)?;
+ }
+ db.push(0x01, GFP_KERNEL)?;
+ db.extend_from_slice(msg, GFP_KERNEL)?;
+ // maskedDB = DB ^ MGF1(seed, db_len).
+ let db_mask = mgf1_sha256(seed, db_len)?;
+ for i in 0..db_len {
+ db[i] ^= db_mask[i];
+ }
+ // maskedSeed = seed ^ MGF1(maskedDB, hLen).
+ let seed_mask = mgf1_sha256(&db, HLEN)?;
+ let mut masked_seed = [0u8; HLEN];
+ for i in 0..HLEN {
+ masked_seed[i] = seed[i] ^ seed_mask[i];
+ }
+ // EM = 0x00 || maskedSeed || maskedDB.
+ let mut em = KVec::with_capacity(k, GFP_KERNEL)?;
+ em.push(0x00, GFP_KERNEL)?;
+ em.extend_from_slice(&masked_seed, GFP_KERNEL)?;
+ em.extend_from_slice(&db, GFP_KERNEL)?;
+ Ok(em)
+}
+
+/// RSA-OAEP-SHA256 encrypt the 16-byte master key `km` under the dock's
+/// RSA-1024 public key (`modulus[128]`, `exponent`), giving the 128-byte
+/// `Ekpub(km)` for `AKE_No_Stored_km` (sec 5.4). Generates a fresh OAEP seed.
+pub(super) fn oaep_encrypt_km(
+ modulus: &[u8; 128],
+ exponent: &[u8],
+ km: &[u8; 16],
+) -> Result<[u8; 128]> {
+ let mut seed = [0u8; 32];
+ super::rng::fill(&mut seed);
+ let em = eme_oaep_encode(128, km, &seed)?;
+ let mut out = [0u8; 128];
+ crypto::rsa_pubkey_encrypt(modulus, exponent, &em, &mut out)?;
+ Ok(out)
+}
+
+/// SKE: `Edkey(ks) = ks XOR (dkey_2 with low-8-bytes XOR rrx)` (sec 5.6).
+///
+/// `dkey_2` is derived with the SKE nonce `rn` mixed into the key; `rrx` then
+/// XORs into the low 8 bytes (`dkey_2[8..16]`) of the mask. The result is the
+/// 16-byte `Edkey_ks` carried by `SKE_Send_Eks` (msg_id 0x0b).
+pub(super) fn compute_eks(
+ km: &[u8; 16],
+ rtx: &[u8; 8],
+ rrx: &[u8; 8],
+ rn: &[u8; 8],
+ ks: &[u8; 16],
+) -> Result<[u8; 16]> {
+ let mut mask = derive_dkey(km, rn, rtx, rrx, 2)?;
+ for i in 0..8 {
+ mask[8 + i] ^= rrx[i];
+ }
+ let mut edkey_ks = [0u8; 16];
+ for i in 0..16 {
+ edkey_ks[i] = ks[i] ^ mask[i];
+ }
+ Ok(edkey_ks)
+}
diff --git a/drivers/gpu/drm/vino/rng.rs b/drivers/gpu/drm/vino/rng.rs
new file mode 100644
index 000000000000..8720d55174ae
--- /dev/null
+++ b/drivers/gpu/drm/vino/rng.rs
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Cryptographically-secure randomness for the per-session HDCP nonces/keys
+//! (`rtx`, `km`, `rn`, `ks`, `riv`, the OAEP seed).
+#![allow(dead_code)] // RNG helpers; some are reached only on the post-engagement CP path
+
+/// Fills `buf` with random bytes from the kernel CSPRNG (`get_random_bytes`).
+pub(super) fn fill(buf: &mut [u8]) {
+ // SAFETY: `buf` is valid for writes of `buf.len()` bytes; `get_random_bytes`
+ // writes exactly that many and never sleeps/faults on a kernel buffer.
+ unsafe { kernel::bindings::get_random_bytes(buf.as_mut_ptr().cast(), buf.len()) };
+}
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index 79f446041b64..db4c38b6dc92 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -6,19 +6,45 @@
//! This is an `[RFC]` work-in-progress, posted to ask for help. It is a clean-room
//! reverse-engineered replacement for the proprietary DisplayLinkManager userspace
//! daemon + the EVDI kernel module, written natively in Rust against the in-tree USB,
-//! crypto and DRM/KMS bindings.
+//! crypto and DRM/KMS bindings (the prerequisite binding patches are posted as their
+//! own series).
//!
-//! This first patch is the skeleton: it binds the dock over USB and runs the plaintext
-//! connect handshake (the control-request preamble and the three bulk init messages over
-//! the Rust USB bulk + control transfer API). The HDCP 2.2 AKE, the AES-CTR/AES-CMAC
-//! control plane, the Vino codec and the DRM/KMS sink are added in the following patches.
+//! # What works
+//!
+//! On probe the driver runs, all on real hardware (Dell Universal Dock D6000):
+//! - the plaintext connect handshake over the Rust USB bulk + control transfer API;
+//! - the clean-room HDCP 2.2 AKE / LC / SKE -- H', L' and V' all verify against the
+//! dock, so the session key `ks` is established and shared;
+//! - the AES-CTR + AES-CMAC ("Dl3Cmac") control-plane seal, byte-exact against the
+//! reference daemon's captured wire;
+//! - the plaintext `type=2 sub=0x24` stream-open arm marker; and
+//! - registration of a real `struct drm_device` (see [`drm_sink`]) via the simple
+//! display pipe, so the dock appears to userspace as a mode-settable GEM/dumb DRM
+//! card, with a live EP08 framebuffer-scanout hook on every page-flip.
+//!
+//! # What does NOT work -- the wall (help wanted)
+//!
+//! After the arm marker the driver sends the first encrypted control-plane frame
+//! (msg0) and the dock **never acknowledges it** (`wsub=0x45` ack count stays 0), so
+//! the CP cipher never engages and no pixels ever flow. Every host-observable channel
+//! has been matched to the reference daemon -- the bulk wire is byte-identical through
+//! the arm + msg0, the AKE verifies, the seal/MAC/IV are byte-exact, the full EP0
+//! control-transfer set matches, the endpoint set matches, the arm timing is tighter
+//! than the daemon's -- and the dock still silently drops our encrypted CP while it
+//! engages the daemon's. The gate appears to be something not visible on the host wire
+//! (dock-internal session state, or a whole-bus timing/ordering property a per-channel
+//! diff cannot see). **If you know the DL3 / DisplayLink control-plane engagement
+//! sequence, or have ideas for the remaining paired full-bus diff, please help.**
+//!
+//! Note: `send_cp_setup` builds msg0's body field-by-field except for a small captured
+//! cap-announce skeleton ([`golden`]); a fully field-derived cap-announce is open work.
//!
//! Device: VID 0x17e9 (DisplayLink) / PID 0x6006 (Dell Universal Dock D6000).
use kernel::{
alloc::flags::GFP_KERNEL,
device::{self, Core},
- error::code::ENODEV,
+ error::code::{ENODEV, EINVAL},
prelude::*,
sync::{aref::ARef, Arc},
time::Delta,
@@ -34,6 +60,9 @@
/// Control + per-head bulk endpoints (guide sec 2).
const EP_CTRL_OUT: u8 = 0x02;
const EP_CTRL_IN: u8 = 0x84;
+/// EP84 (dock->host) drain buffer size. The dock's capability block can reach ~5.8 KiB, so a
+/// single bulk read needs a generously sized buffer to avoid truncating and misframing it.
+const EP84_BUF: usize = 16384;
/// USB transfer timeout used during bring-up.
fn timeout() -> Delta {
@@ -41,6 +70,26 @@ fn timeout() -> Delta {
}
mod proto;
+mod crypto;
+mod rng;
+mod hdcp;
+mod ake;
+mod golden;
+
+/// The shared secrets a completed HDCP 2.2 AKE leaves behind: the SKE session key
+/// `ks` and content IV `riv` key the AES-CTR control plane (sec 6), and `kd` is kept
+/// for any further repeater verification. Consumed by the Phase 2b/2c CP + video.
+#[allow(dead_code)] // ks/riv/kd are consumed by the post-engagement CP stream (open blocker)
+struct Session {
+ ks: [u8; 16],
+ riv: [u8; 8],
+ kd: [u8; 32],
+ /// The 7-frame **plaintext capability-announce** to send between the init markers and
+ /// the arm marker (see `VinoDriver::build_cap_announce`). Built LIVE
+ /// from this session's AKE values (rtx/ekpub/rn/edkey+riv/V) -- NOT a stale replay. Empty
+ /// for a non-repeater dock (the announce path is only exercised on the D6000, repeater=1).
+ cap_announce: KVec<u8>,
+}
/// Per-bound-interface driver state.
struct VinoDriver {
@@ -80,21 +129,94 @@ impl WorkItem for BringUp {
fn run(this: Arc<BringUp>) {
let cdev: &device::Device = this.intf.as_ref();
let dev: &usb::Device = this.intf.as_ref();
- // WIP scaffold: attempt the plaintext bring-up. Bind regardless of the outcome --
- // there is no display path yet (the HDCP AKE, control plane and DRM sink land in
- // the following patches).
+ // WIP scaffold: plaintext bring-up then the clean-room HDCP 2.2 AKE/LC/SKE. Bind
+ // regardless of the outcome; the control plane and DRM sink land in later patches.
match VinoDriver::bring_up(dev) {
- Ok(()) => dev_info!(cdev, "vino: plaintext session init OK\n"),
+ Ok(()) => {
+ dev_info!(cdev, "vino: plaintext session init OK\n");
+ match VinoDriver::run_ake(dev) {
+ Ok(session) => {
+ dev_info!(cdev, "vino: HDCP AKE + LC + SKE complete (session keyed)\n");
+ // Dev diagnostic: the live session key/riv, so the dock's encrypted
+ // EP84 replies can be decoded offline from a usbmon capture. Behind
+ // pr_debug, so compiled out unless dynamic debug is enabled.
+ pr_debug!("vino: SESSION ks={:02x?} riv={:02x?}\n", &session.ks, &session.riv);
+ }
+ Err(e) => dev_info!(cdev, "vino: HDCP AKE incomplete ({e:?}) -- WIP\n"),
+ }
+ }
Err(e) => dev_info!(cdev, "vino: session init incomplete ({e:?}) -- WIP\n"),
}
}
}
+/// On-device crypto known-answer self-test. Confirms the IN-KERNEL crypto path (which the CP seal
+/// depends on) is byte-correct -- something only ever checked offline (Python `verify-kdf.py`)
+/// before.
+/// Runs three checks and logs PASS/FAIL:
+/// 1. AES-128-ECB vs the FIPS-197 test vector.
+/// 2. AES-CMAC vs the RFC 4493 test vector (subkey + full-block path).
+/// 3. The full `cp::seal_livemac` vs cold-ref's REAL msg0: known plaintext + known `ks`/`riv`
+/// must reproduce the captured wire ciphertext+tag byte-for-byte. A FAIL here (with 1+2
+/// passing) would localize a bug in our seal framing; a FAIL in 1/2 means the kernel
+/// primitive itself is wrong. If all PASS, the crypto we send is correct and the
+/// CP-engagement wall is NOT our crypto.
+fn crypto_selftest() {
+ use core::sync::atomic::{AtomicBool, Ordering};
+ static RAN: AtomicBool = AtomicBool::new(false);
+ if RAN.swap(true, Ordering::Relaxed) {
+ return;
+ }
+
+ // 1. AES-128-ECB KAT (FIPS-197 Appendix B / C.1).
+ let ecb_key = [
+ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
+ 0x0f,
+ ];
+ let ecb_pt = [
+ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee,
+ 0xff,
+ ];
+ let ecb_expect = [
+ 0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5,
+ 0x5a,
+ ];
+ match crypto::aes128_ecb(&ecb_key, &ecb_pt) {
+ Ok(out) if out == ecb_expect => pr_info!("vino: selftest AES-128-ECB PASS\n"),
+ Ok(out) => pr_err!("vino: selftest AES-128-ECB FAIL got={out:02x?}\n"),
+ Err(e) => pr_err!("vino: selftest AES-128-ECB ERR ({e:?})\n"),
+ }
+
+ // 2. AES-CMAC KAT (RFC 4493 sec 4 example 2: a single 16-byte block).
+ let cmac_key = [
+ 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f,
+ 0x3c,
+ ];
+ let cmac_msg = [
+ 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, 0x17,
+ 0x2a,
+ ];
+ let cmac_expect = [
+ 0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a, 0x28,
+ 0x7c,
+ ];
+ match crypto::aes_cmac(&cmac_key, &cmac_msg) {
+ Ok(out) if out == cmac_expect => pr_info!("vino: selftest AES-CMAC PASS\n"),
+ Ok(out) => pr_err!("vino: selftest AES-CMAC FAIL got={out:02x?}\n"),
+ Err(e) => pr_err!("vino: selftest AES-CMAC ERR ({e:?})\n"),
+ }
+}
+
impl VinoDriver {
/// Plaintext session bring-up (sec 4): control-request preamble then the three
/// bulk init messages, reading the single ACK. Best-effort during scaffold
/// bring-up -- errors are logged, not fatal.
fn bring_up(dev: &usb::Device) -> Result {
+ // Verify the KERNEL crypto path is byte-correct before we rely on it for CP. The KDF was
+ // only ever checked offline (Python); this confirms the in-kernel AES-ECB, AES-CMAC and the
+ // full `seal_livemac` reproduce ground-truth vectors on THIS device. Logs PASS/FAIL once.
+ crypto_selftest();
+
// Control-request preamble (sec 4): dock-id read, interface selection, then the
// vendor_out 0x24 / vendor_in 0x22 pairs that kick off the HDCP path. (The
// GET_DESCRIPTOR string reads DLM also issues look cosmetic and are omitted.)
@@ -320,6 +442,526 @@ fn bring_up(dev: &usb::Device) -> Result {
}
}
+
+ /// Whether to service EP83 (interrupt-IN status) during bring-up. Measured 2026-06-16
+ /// (paired-coldbus-20260616-162650): DLM polls EP83 0x in the pre-arm window (14x total, all
+ /// post-engagement) while vino polled it 5x pre-arm -- injecting interrupt-IN traffic into the
+ /// critical arm/msg0 window that DLM never generates. Disabled so the pre-arm wire matches DLM;
+ /// re-enable if a post-engagement status channel is ever needed (DLM only services it once the
+ /// dock has already acked).
+ const POLL_EP83_DURING_BRINGUP: bool = false;
+
+ /// Reads the next HDCP response (type=4 sub=0x25, sec 5.2) from EP `0x84`,
+ /// skipping any non-HDCP frames (e.g. plain ACKs) in between, and returns the
+ /// parsed `(msg_id, payload)`. Bounded retry so a chatty dock can't wedge us.
+ fn recv_hdcp(dev: &usb::Device) -> Result<(u8, KVec<u8>)> {
+ const SUB_HDCP_RESP: u16 = 0x25;
+ let mut buf = KVec::from_elem(0u8, 4096, GFP_KERNEL)?;
+ for _ in 0..24 {
+ // Read EP84 FIRST. The dock replies to AKE messages sub-millisecond (DLM cold capture:
+ // ~0.1-0.7 ms between EP84 IN frames), but it interleaves status/cap pushes that we
+ // skip. Polling EP83 (a ~2 ms idle wait) BEFORE every read added ~2 ms x
+ // N-skipped-frames
+ // of latency per reply -- making vino's AKE ~400 ms vs DLM's ~62 ms, slow enough that
+ // the
+ // dock starts downstream HDCP and NAKs our arm/Stream_Manage. So only service EP83 when
+ // EP84 came back empty (same reorder as `drain_ep84`). See the cold wire diff.
+ let n = dev.bulk_recv(EP_CTRL_IN, &mut buf, timeout())?;
+ if n < 16 {
+ if Self::POLL_EP83_DURING_BRINGUP {
+ Self::poll_ep83(dev);
+ }
+ continue;
+ }
+ // DIAGNOSTIC (2026-06-11): log EVERY frame the dock returns during the AKE --
+ // including
+ // wsub!=0x25 and cap-block (sub=0x84) pushes we'd otherwise skip -- so we can see
+ // whether
+ // the dock interleaves its capability blocks with the HDCP replies (the suspected
+ // reason
+ // its cap phase never completes / it won't engage CP). Inner id/sub at off 16/18.
+ {
+ let wsub = u16::from_le_bytes([buf[8], buf[9]]);
+ let iid = if n >= 18 { u16::from_le_bytes([buf[16], buf[17]]) } else { 0 };
+ let isub = if n >= 20 { u16::from_le_bytes([buf[18], buf[19]]) } else { 0 };
+ pr_debug!("vino: AKE-EP84 {n}B wsub={wsub:#x} inner_id={iid:#x} inner_sub={isub:#x}\n");
+ }
+ if u16::from_le_bytes([buf[8], buf[9]]) != SUB_HDCP_RESP {
+ continue; // non-HDCP frame -- skip
+ }
+ if let Some((id, payload)) = ake::parse_in(&buf[16..n]) {
+ // Inner msg_id 0 is a status/ACK frame (the dock emits one as a
+ // sub=0x25 frame after each OUT message, e.g. the `14 00 76 00...`
+ // frame after AKE_Init) -- skip it and keep reading for the real
+ // HDCP response, mirroring the oracle's recv_hdcp_msg.
+ if id == 0 {
+ continue;
+ }
+ let mut pl = KVec::with_capacity(payload.len(), GFP_KERNEL)?;
+ pl.extend_from_slice(payload, GFP_KERNEL)?;
+ return Ok((id, pl));
+ }
+ }
+ Err(EINVAL)
+ }
+
+
+ /// Pace like DLM after a RepeaterAuth OUT (ctr6 Send_Ack / ctr7 Stream_Manage):
+ /// read the dock's per-frame `id=0x14 sub=0x10` ack off EP84 BEFORE the next OUT,
+ /// so vino never transmits while the dock is mid-NAK.
+ ///
+ /// Ground truth (cold wire diff, captures/dlm-cold-20260611-123347 vs vino-cold):
+ /// DLM reads that ack after EVERY cap/AKE OUT --
+ /// ctr4->ack->ctr5->ack->ctr6->ack->ctr7->
+ /// ack->arm, ~0.2 ms apart, whole ctr7->arm gap 0.46 ms. Commit d74a4d7 dropped the
+ /// drain for ctr6/ctr7, so `run_ake` sent ctr6->ctr7 back-to-back with no read; the
+ /// dock (busy with downstream HDCP after SKE) then NAK'd each OUT ~100 ms (vino's
+ /// V'->arm gap measured ~200 ms), and the arm landed after the dock had left its
+ /// freshly-keyed CP window -> CP never engaged (0 `wsub=0x45`). Restoring the read
+ /// re-paces vino to DLM and lets the arm land tight. Best-effort: returns as soon as
+ /// the matching ack arrives, or immediately if nothing is queued (dock idle).
+ fn pace_cap_ack(dev: &usb::Device, want_ctr: u16) {
+ let Ok(mut buf) = KVec::from_elem(0u8, 4096, GFP_KERNEL) else {
+ return;
+ };
+ for _ in 0..8 {
+ match dev.bulk_recv(EP_CTRL_IN, &mut buf, Delta::from_millis(30)) {
+ Ok(len) if len >= 22 => {
+ let wsub = u16::from_le_bytes([buf[8], buf[9]]);
+ let iid = u16::from_le_bytes([buf[16], buf[17]]);
+ let ictr = u16::from_le_bytes([buf[20], buf[21]]);
+ // The per-frame cap-ack: wsub=0x25, inner id=0x14 sub=0x10 ctr=want.
+ // An interleaved cap push (sub=0x84) or earlier ack -- keep reading.
+ if wsub == 0x25 && iid == 0x14 && ictr == want_ctr {
+ return;
+ }
+ }
+ // Nothing queued within the short window -- the dock is idle, don't block.
+ _ => return,
+ }
+ }
+ }
+
+
+ /// After ctr7 (Stream_Manage) and its ack, WAIT for the dock's terminal capability block
+ /// `id=0x0b sub=0x84` before letting the caller arm. This is the dock's "cap-complete"
+ /// signal: DLM receives it and only then arms (cold-ref: `id=0x21` @52.1465 -> `id=0x0b`
+ /// @52.1469 -> arm @52.1474). vino's lockstep ([`pace_cap_ack`]) only consumed the `id=0x14`
+ /// ctr acks, so it armed right after ctr7's ack -- BEFORE the dock had emitted `id=0x0b`
+ /// (vino received every other cap block id=0x213/0x0d/0x10/0x28/0x18/0x21 but armed one push
+ /// early). The dock then NAK'd msg0 ~100 ms and dumped a 16 KB error block
+ /// (`type=0x1003 wsub=0x37`) that DLM never produces, instead of engaging CP -- the true
+ /// gate, found on cold plug `vino-cold-20260612-080549`. The dock emits `id=0x0b` a few ms
+ /// after `id=0x21` once it settles downstream HDCP, so draining EP84 until it arrives keeps
+ /// the arm tight (DLM ~ 0.5 ms after ctr7) yet correctly ordered. Best-effort, bounded.
+ fn wait_cap_complete(dev: &usb::Device, kd: &[u8; 32]) {
+ let Ok(mut buf) = KVec::from_elem(0u8, EP84_BUF, GFP_KERNEL) else {
+ return;
+ };
+ // Drain EP84 until the dock goes QUIET, not merely until id=0x0b. Cold plug #2
+ // (vino-cold-20260612-082707) showed DLM's LAST pre-arm push is the id=0x28 that
+ // follows id=0x0b (cold-ref: id=0x0b@52.1469 -> ack ctr7 -> id=0x28@52.1472 ->
+ // arm@52.1474),
+ // whereas vino stopped at id=0x0b and armed -- leaving id=0x28 (and the rest of the dock's
+ // terminal cap burst) un-drained in the dock's EP84 queue. With its IN queue backed up the
+ // dock NAK'd vino's msg0 ~100 ms (it can't accept the OUT while it still owes IN data) and
+ // then dumped the 16 KB error block. So after id=0x0b, keep reading until a read times out
+ // (the dock has sent everything), then return so the caller arms into a clean dock -- like
+ // DLM. Bounded: id=0x0b is the marker; QUIET_GAP short reads of silence end the drain.
+ //
+ // * 2026-06-12 (HDCP 2.3 Adaptation sec RepeaterAuth, pdfs/): one of the frames drained
+ // here is
+ // the dock's `RepeaterAuth_Stream_Ready` (HDCP msg 0x11) -- the 3rd `id=0x28` DLM receives
+ // and
+ // vino historically did not. The spec requires the transmitter to RECEIVE it within 100 ms
+ // of
+ // `Stream_Manage` and verify `M == M'` before transmitting content; the dock's exactly-100
+ // ms
+ // msg0 NAK on a cold plug is that window. We now RECOGNISE it in this same drain (no added
+ // latency vs the old broken 10x1 s poll) and log `M'` plus candidate `M`s so the next
+ // capture
+ // pins the exact `STREAMID_TYPE || seq_num_M` the dock hashes. The HDCP msg_id rides at
+ // `body[9]` = `buf[25]` in an EP84 reply (`ake::parse_in`); `M'[32]` follows at
+ // `buf[26..58]`.
+ // Verification is logged-only for now (the DisplayLink field offsets in `Stream_Manage` are
+ // not yet confirmed, so a wrong guess must not block the arm); the arm is gated on
+ // receiving
+ // Stream_Ready when it arrives, else on the existing id=0x0b + quiet fallback. `M` key is
+ // `SHA256(kd)`; `M = HMAC-SHA256(STREAMID_TYPE || seq_num_M, SHA256(kd))`, seq_num_M = 0.
+ let sha_kd = crypto::sha256(kd);
+ let mut saw_0b = false;
+ let mut saw_ready = false;
+ let mut quiet = 0usize;
+ const QUIET_GAP: usize = 3; // ~3 consecutive empty short reads => dock done pushing
+ const MAX_ROUNDS: usize = 48;
+ for _ in 0..MAX_ROUNDS {
+ match dev.bulk_recv(EP_CTRL_IN, &mut buf, Delta::from_millis(5)) {
+ Ok(len) if len >= 20 => {
+ quiet = 0;
+ let iid = u16::from_le_bytes([buf[16], buf[17]]);
+ let isub = u16::from_le_bytes([buf[18], buf[19]]);
+ let mid = if len >= 26 { buf[25] } else { 0 }; // HDCP msg_id (body[9])
+ if isub == 0x84 && iid == 0x0b {
+ saw_0b = true;
+ }
+ if mid == ake::id::REPEATERAUTH_STREAM_READY && len >= 58 {
+ saw_ready = true;
+ let mprime = &buf[26..58];
+ pr_info!("vino: AKE: Stream_Ready (0x11) M'={mprime:02x?}\n");
+ // M = HMAC-SHA256(SHA256(kd), data) where data is the Content Stream
+ // Management input the dock hashes: `k` 7-byte stream entries followed by
+ // the 3-byte `seq_num_M` (=0 on the first Stream_Manage). Cracked from the
+ // DLM aarch64 decompile (`FUN_0057be04`: data = memcpy(streams, k*7) ||
+ // BE16(field) || field, keyed by the 32-byte SHA256(kd) at session+0x37);
+ // reproduces DLM's captured M' byte-exact (captures/.../FINDINGS.md).
+ // vino's
+ // two streams carry the same StreamID_Type bytes its Stream_Manage sends
+ // (`repeater_auth_stream_manage`: type 0x04 and 0x05), so the dock computes
+ // the same M. (Earlier code guessed a 5-byte STREAMID_TYPE||seq layout and
+ // so
+ // always mismatched -- host-side only, never gated the dock.)
+ let m_data: [u8; 17] = [
+ 0, 0, 0, 0x04, 0, 0, 0, // stream 0: StreamID_Type[0] = 4
+ 0, 0, 0, 0x05, 0, 0, 0, // stream 1: StreamID_Type[1] = 5
+ 0, 0, 0, // seq_num_M = 0 (first Stream_Manage, big-endian)
+ ];
+ let m = crypto::hmac_sha256(&sha_kd, &m_data);
+ let eq = if &m[..] == mprime { "==" } else { "!=" };
+ pr_info!("vino: AKE: M {} M' (CSM stream-entry layout)\n", eq);
+ } else if mid == ake::id::RECEIVER_AUTH_STATUS && len >= 27 {
+ pr_info!("vino: AKE: RECEIVER_AUTH_STATUS=0x{:02x}\n", buf[26]);
+ }
+ // * 2026-06-12: arm the INSTANT both terminal markers have arrived -- the
+ // cap-complete
+ // id=0x0b AND the Stream_Ready (the trailing id=0x28 / HDCP 0x11). DLM arms
+ // 0.46 ms
+ // after its last cap block; a cold-plug cadence diff
+ // (vino-cold-20260612-113706) showed
+ // vino was instead waiting QUIET_GAP x 5 ms of EMPTY reads AFTER already
+ // seeing both
+ // markers, landing the arm ~68 ms late -- outside the dock's freshly-keyed CP
+ // window, so
+ // the dock errored on the arm (27 KB type=0x1001 dump) instead of engaging.
+ // Once both
+ // markers are in, the terminal burst is complete; arm now, like DLM. (The
+ // empty-read
+ // quiet path below remains the fallback when Stream_Ready never arrives.)
+ if saw_0b && saw_ready {
+ pr_info!("vino: cap-complete (id=0x0b + Stream_Ready 0x11) -- arming now\n");
+ return;
+ }
+ }
+ // Empty/short read = a quiet window. Fallback when Stream_Ready (0x11) never
+ // arrives:
+ // once id=0x0b has arrived AND the dock has been quiet for QUIET_GAP rounds, the
+ // terminal burst is drained -- arm now.
+ _ => {
+ if saw_0b {
+ quiet += 1;
+ if quiet >= QUIET_GAP {
+ pr_info!(
+ "vino: cap-complete drained (id=0x0b{}+ quiet) -- arming now\n",
+ if saw_ready { ", Stream_Ready 0x11, " } else { " (no 0x11) " }
+ );
+ return;
+ }
+ }
+ }
+ }
+ }
+ pr_info!(
+ "vino: cap-complete drain budget hit (saw_0b={saw_0b} saw_ready={saw_ready}) -- arming anyway\n"
+ );
+ }
+
+
+ /// Drives a full clean-room HDCP 2.2 AKE + LC + SKE (and RepeaterAuth for a
+ /// repeater sink) over EP `0x02`/`0x84`, verifying `H'`, `L'` and `V'` against
+ /// our own KDF (sec 5). On success returns the [`Session`] keys.
+ ///
+ /// All HDCP transfers use transport `seq=0`; the `hdcp_seq` counter increments
+ /// 1..7 across the OUT messages (sec 5.1). Best-effort: any mismatch/short read
+ /// aborts with an error the caller logs.
+ fn run_ake(dev: &usb::Device) -> Result<Session> {
+ use ake::id;
+
+ // Flush any STALE EP84 frames the dock still has queued from a PRIOR session before
+ // starting a fresh AKE. On a warm rmmod/insmod re-probe the dock is not power-cycled, so
+ // its previous CP/cap replies (including a multi-KB residual block) sit in its EP84 queue;
+ // if we don't drain them, the first `recv_hdcp` picks up a stale frame and the whole AKE
+ // reply stream is shifted. Harmless on a true cold plug -- the queue is already empty, so
+ // the first read just times out. Best-effort.
+ if let Ok(mut flush) = KVec::from_elem(0u8, EP84_BUF, GFP_KERNEL) {
+ let mut flushed = 0usize;
+ for _ in 0..32 {
+ match dev.bulk_recv(EP_CTRL_IN, &mut flush, Delta::from_millis(20)) {
+ Ok(n) if n > 0 => flushed += 1,
+ _ => break,
+ }
+ }
+ if flushed > 0 {
+ pr_info!("vino: flushed {flushed} stale EP84 frame(s) before AKE\n");
+ }
+ }
+
+ // (1) AKE_Init -- fresh rtx, TxCaps = 00 00 00 (DLM-exact).
+ let mut rtx = [0u8; 8];
+ rng::fill(&mut rtx);
+ dev.bulk_send(EP_CTRL_OUT, &ake::ake_init(1, 0, &rtx, &[0; 3])?, timeout())?;
+
+ // (2) AKE_Send_Cert: payload = REPEATER(1) || cert_rx(522). Extract the
+ // RSA-1024 public key (modulus[5..133], exponent[133..136]).
+ let (cid, cert_msg) = Self::recv_hdcp(dev)?;
+ if cid != id::AKE_SEND_CERT || cert_msg.len() < 1 + 136 {
+ pr_err!("vino: AKE: bad AKE_Send_Cert (id={cid:#x}, {} B)\n", cert_msg.len());
+ return Err(EINVAL);
+ }
+ let repeater = cert_msg[0] != 0;
+ let cert = &cert_msg[1..];
+ let mut modulus = [0u8; 128];
+ modulus.copy_from_slice(&cert[5..133]);
+ let mut exponent = [0u8; 3];
+ exponent.copy_from_slice(&cert[133..136]);
+
+ // (3) AKE_Transmitter_Info, then (4) read AKE_Receiver_Info (RxCaps unused).
+ dev.bulk_send(EP_CTRL_OUT, &ake::ake_transmitter_info(2, 0)?, timeout())?;
+ let _ = Self::recv_hdcp(dev)?;
+
+ // (5) AKE_No_Stored_km -- fresh km, RSA-OAEP-SHA256 to Ekpub(km).
+ let mut km = [0u8; 16];
+ rng::fill(&mut km);
+ let ekpub = hdcp::oaep_encrypt_km(&modulus, &exponent, &km)?;
+ dev.bulk_send(EP_CTRL_OUT, &ake::ake_no_stored_km(3, 0, &ekpub)?, timeout())?;
+
+ // (6) AKE_Send_Rrx.
+ let (rid, rrx_pl) = Self::recv_hdcp(dev)?;
+ if rid != id::AKE_SEND_RRX || rrx_pl.len() < 8 {
+ pr_err!("vino: AKE: bad AKE_Send_Rrx (id={rid:#x})\n");
+ return Err(EINVAL);
+ }
+ let mut rrx = [0u8; 8];
+ rrx.copy_from_slice(&rrx_pl[..8]);
+
+ // (7)/(8) AKE_Send_H_prime -- verify H' = HMAC(kd, rtx^REPEATER).
+ let (hid, hp) = Self::recv_hdcp(dev)?;
+ if hid != id::AKE_SEND_H_PRIME || hp.len() < 32 {
+ pr_err!("vino: AKE: bad H' (id={hid:#x})\n");
+ return Err(EINVAL);
+ }
+ let kd = hdcp::derive_kd(&km, &rtx, &rrx)?;
+ if hdcp::compute_h(&kd, &rtx, repeater)[..] != hp[..32] {
+ pr_err!("vino: AKE: H' mismatch -- authentication failed\n");
+ return Err(EINVAL);
+ }
+ pr_info!("vino: AKE: H' verified\n");
+
+ // (9) AKE_Send_Pairing_Info (Ekh_km) -- read and discard (no-stored path).
+ let _ = Self::recv_hdcp(dev)?;
+
+ // (10) Locality Check -- LC_Init(rn) then verify L'.
+ let mut rn = [0u8; 8];
+ rng::fill(&mut rn);
+ dev.bulk_send(EP_CTRL_OUT, &ake::lc_init(4, 0, &rn)?, timeout())?;
+ let (lid, lp) = Self::recv_hdcp(dev)?;
+ if lid != id::LC_SEND_L_PRIME || lp.len() < 32 {
+ pr_err!("vino: AKE: bad L' (id={lid:#x})\n");
+ return Err(EINVAL);
+ }
+ if hdcp::compute_l(&kd, &rrx, &rn)[..] != lp[..32] {
+ pr_err!("vino: AKE: L' mismatch -- locality check failed\n");
+ return Err(EINVAL);
+ }
+ pr_info!("vino: AKE: L' verified\n");
+
+ // (11) Session Key Exchange -- send Edkey(ks) || riv. The session key and IV are
+ // fresh-random per session.
+ let mut ks = [0u8; 16];
+ let mut riv = [0u8; 8];
+ rng::fill(&mut ks);
+ rng::fill(&mut riv);
+ let edkey = hdcp::compute_eks(&km, &rtx, &rrx, &rn, &ks)?;
+ // Dev diagnostic: the full SKE secrets, so the SKE delivery can be verified OFFLINE
+ // (edkey == ks XOR derive_dkey(km,rtx,rrx,rn,2), and the dock unwrapping to the same ks).
+ // Behind pr_debug, so compiled out unless dynamic debug is enabled.
+ pr_debug!("vino: SKE-SECRETS km={km:02x?} rtx={rtx:02x?} rrx={rrx:02x?} rn={rn:02x?}\n");
+ pr_debug!("vino: SKE-SECRETS ks={ks:02x?} edkey={edkey:02x?}\n");
+ // * riv DERIVATION -- THE CP-ENGAGEMENT BUG, FIXED 2026-06-11.
+ // The SKE delivers the BASE riv (byte7 low-3 head/direction-selector bits cleared); the
+ // dock
+ // derives the per-direction CP riv from that base. GROUND TRUTH from cold-ref AND the live
+ // vino cold-plug diff (captures/dlm-cold-20260611-123347 + vino-cold-20260611-130522):
+ // delivered base byte7 = e8 -> host OUT-CP riv = ec (base | 0x04) -> dock IN-CP riv = ed
+ // (^1).
+ // vino had been sealing OUT-CP with the RAW random `riv` (byte7 e.g. f9 = base f8 | 0x01)
+ // while delivering base f8 -- so the dock, deriving its keystream from f8 (expecting
+ // host-OUT
+ // = fc), could NOT decrypt vino's CP and SILENTLY DROPPED every post-arm frame (0 sub=0x45,
+ // EP84 dead after the arm) even though ks/seal/MAC/frame-format were all byte-correct. The
+ // off-by-one-bit IV was the whole wall. Fix: deliver base, seal OUT with base | 0x04.
+ // The SKE delivers the FULL random riv as-is (DLM does NOT mask the low bits -- verified
+ // on
+ // two decrypted DLM sessions: cold-ref delivers ...e8, dl3cmac delivers ...e7). The host CP
+ // OUT riv = delivered XOR 0x04 (flip byte7 bit 2): cold-ref e8->ec, dl3cmac e7->e3.
+ // cp::in_riv
+ // then ^1 for the dock->host IN stream (ec->ed). vino had been masking the delivered riv
+ // and
+ // sealing with the raw random LSBs, so the dock (deriving its keystream as delivered^0x04)
+ // got a different keystream and silently dropped every CP frame. See the vino cold-plug
+ // diff.
+ let riv_ske = riv; // deliver the full random riv, unmasked, exactly like DLM
+ riv[7] ^= 0x04; // host OUT-CP riv = delivered ^ 0x04
+ dev.bulk_send(EP_CTRL_OUT, &ake::ske_send_eks(5, 0, &edkey, &riv_ske)?, timeout())?;
+ // Dev diagnostic: the live session key/out-riv the dock must hold to decrypt our CP.
+ pr_debug!("vino: SESSION ks={ks:02x?} out_riv={riv:02x?}\n");
+
+ // The LIVE plaintext capability-announce (`build_cap_announce`),
+ // built once V is known below. Empty unless the dock is a repeater (D6000 always is).
+ let mut cap_announce = KVec::new();
+
+ // (12) RepeaterAuth -- verify V' over the ReceiverID_List, ACK, then SM2.
+ if repeater {
+ let (vid, list) = Self::recv_hdcp(dev)?;
+ if vid != id::REPEATERAUTH_SEND_RECEIVERID_LIST || list.len() < 16 {
+ pr_err!("vino: AKE: bad ReceiverID_List (id={vid:#x})\n");
+ return Err(EINVAL);
+ }
+ let split = list.len() - 16;
+ // V = HMAC(kd, list_header): MSB-128 = V' (verify vs the list trailer);
+ // LSB-128 = the RepeaterAuth_Send_Ack value (NOT the MSB -- that was THE bug).
+ let v_full = hdcp::compute_v_full(&kd, &list[..split]);
+ let mut v_ack = [0u8; 16];
+ v_ack.copy_from_slice(&v_full[16..]);
+ if v_full[..16] != list[split..] {
+ pr_err!("vino: AKE: V' mismatch -- repeater verification failed\n");
+ return Err(EINVAL);
+ }
+ pr_info!("vino: AKE: V' verified\n");
+ dev.bulk_send(EP_CTRL_OUT, &ake::repeater_auth_send_ack(6, 0, &v_ack)?, timeout())?;
+ // Read the dock's ctr6 ack before sending ctr7 -- DLM's lockstep pacing, without
+ // which the dock NAKs the back-to-back OUTs ~100 ms each (see `pace_cap_ack`).
+ Self::pace_cap_ack(dev, 6);
+ dev.bulk_send(EP_CTRL_OUT, &ake::repeater_auth_stream_manage(7, 0)?, timeout())?;
+ // Read the dock's ctr7 ack before returning, so the caller's arm marker lands
+ // tight after ctr7 (DLM: 0.46 ms) instead of while the dock is still NAKing.
+ Self::pace_cap_ack(dev, 7);
+ // Then drain the dock's terminal cap burst -- id=0x0b (cap-complete) AND the dock's
+ // `RepeaterAuth_Stream_Ready` (HDCP 0x11, the 3rd id=0x28) -- before the caller arms.
+ // DLM arms only after this burst (cold-ref: id=0x21 -> id=0x0b -> id=0x28/0x11 ->
+ // arm);
+ // arming early makes the dock NAK msg0 ~100 ms and dump a 16 KB error block instead of
+ // engaging. `wait_cap_complete` recognises + verifies the Stream_Ready in place (HDCP
+ // 2.3 Adaptation sec RepeaterAuth). `kd` is needed to check `M == M'`.
+ Self::wait_cap_complete(dev, &kd);
+
+ // Build the LIVE capability-announce now that every field is known. This is the
+ // plaintext re-statement of the 7 AKE OUT messages the dock requires between the
+ // init markers and the arm marker (`CP_CAP_PHASE`). See `build_cap_announce`.
+ // Pass `riv_ske` (the value SKE_Send_Eks actually delivered), NOT `riv` (= session
+ // OUT-CP seal riv = riv_ske ^ 0x04). The cap-announce ctr5 frame is a byte-faithful
+ // re-statement of SKE_Send_Eks, so it must carry the IDENTICAL riv.
+ cap_announce = Self::build_cap_announce(&rtx, &ekpub, &rn, &edkey, &riv_ske, &v_ack)?;
+ }
+
+ Ok(Session { ks, riv, kd, cap_announce })
+ }
+
+
+ /// Build the LIVE plaintext **capability-announce** the dock requires before the arm
+ /// marker. Ground truth: the cold-ref raw wire
+ /// (`captures/cold-ref-20260608-200850/`, t~36.754-36.813) shows DLM, *after* the HDCP
+ /// AKE, sends 7 plaintext `type=4 wsub=0x04` frames that are a re-statement of the 7 AKE
+ /// OUT messages -- `id=0x22/0x1f/0x9a/0x22/0x32/0x2a/0x2d`, `sub=0x10`, ctr 1-7 -- each
+ /// carrying THIS session's real value: f1=rtx, f2=const TxCaps, f3=Ekpub(km)[128],
+ /// f4=rn, f5=Edkey(ks)[16]||riv_base[8], f6=V[16], f7=const Stream_Manage config. The dock
+ /// ACKs each (`id=0x14 sub=0x10 ctr=N`) and only then engages its CP cipher; skipping the
+ /// announce leaves it cipher-off (the long-standing "0 `sub=0x45` acks" symptom).
+ ///
+ /// [`golden::CAP_PLAIN_1080P`] is a byte-correct *skeleton* (headers/aux/lead bytes and the
+ /// two constant frames are session-invariant -- verified across the cold-ref and matched
+ /// sessions) but its 5 variable payloads are a STALE foreign session's values. Replaying it
+ /// verbatim delivers the dock a stale Ekpub/Edkey/riv that re-key it to a foreign `ks`
+ /// (the `cap_phase`-clobbers-`ks` bug). So we clone the skeleton and overwrite ONLY the 5
+ /// session-specific payloads. Each payload sits at frame offset 44 (16-byte wire header +
+ /// 22 inner-prefix bytes + the `30 00 00 00 00` marker + 1 lead byte = 28 inner bytes), and
+ /// frames are stored `[u16 len][frame]`. `riv` here is the SKE-*delivered* riv (`riv_ske`),
+ /// written verbatim -- frame 5 is a byte-faithful re-statement of `SKE_Send_Eks`, so it must
+ /// carry the EXACT delivered riv. (It earlier wrote `riv & 0xF8`, which equals the delivered
+ /// value only when the random riv's low 3 bits are zero -- true for cold-ref's `e8` but wrong
+ /// for 7 of 8 live sessions, so the dock saw a different riv in the announce than in SKE.
+ /// Ground truth: cold-ref ctr5 capture t=36.812413 delivers riv `...40e8` == its SKE riv.)
+ fn build_cap_announce(
+ rtx: &[u8; 8],
+ ekpub: &[u8; 128],
+ rn: &[u8; 8],
+ edkey: &[u8; 16],
+ riv: &[u8; 8],
+ v: &[u8; 16],
+ ) -> Result<KVec<u8>> {
+ let mut blob = KVec::with_capacity(golden::CAP_PLAIN_1080P.len(), GFP_KERNEL)?;
+ blob.extend_from_slice(golden::CAP_PLAIN_1080P, GFP_KERNEL)?;
+
+ // Walk the skeleton; for each frame, overwrite the payload (at frame+44) keyed by ctr.
+ let mut off = 0usize;
+ while off + 2 <= blob.len() {
+ let len = u16::from_le_bytes([blob[off], blob[off + 1]]) as usize;
+ let frame = off + 2;
+ if frame + len > blob.len() {
+ break;
+ }
+ // ctr (inner offset 4) identifies which AKE message this announce frame restates.
+ let ctr = u16::from_le_bytes([blob[frame + 16 + 4], blob[frame + 16 + 5]]);
+ let pay = frame + 44; // 16 hdr + 22 inner-prefix + 5 marker + 1 lead
+ match ctr {
+ 1 => blob[pay..pay + 8].copy_from_slice(rtx), // AKE_Init
+ 3 => blob[pay..pay + 128].copy_from_slice(ekpub), // AKE_No_Stored_km Ekpub
+ 4 => blob[pay..pay + 8].copy_from_slice(rn), // LC_Init
+ 5 => {
+ // SKE_Send_Eks: Edkey(ks)[16] || riv[8] (the delivered riv, verbatim)
+ blob[pay..pay + 16].copy_from_slice(edkey);
+ blob[pay + 16..pay + 24].copy_from_slice(riv);
+ }
+ 6 => blob[pay..pay + 16].copy_from_slice(v), // RepeaterAuth_Send_Ack V
+ _ => {} // ctr 2 (TxCaps) and 7 (Stream_Manage) are session-invariant
+ }
+ off = frame + len;
+ }
+ Ok(blob)
+ }
+
+
+ /// Poll EP 0x83 (interrupt-IN status endpoint). DLM submits URBs here CONTINUOUSLY and the dock
+ /// pushes 6-byte status events; the dock may gate CP/downstream-HDCP engagement on the host
+ /// servicing this endpoint (flagged in `vino-driver/src/bin/bringup.rs`). vino never polled it
+ /// --
+ /// invisible in the EP02/EP84 bulk-wire comparison. Reads up to a few events (short timeout so
+ /// a
+ /// URB is pending when the dock pushes). `usb_bulk_msg` auto-routes the interrupt endpoint.
+ fn poll_ep83(dev: &usb::Device) -> usize {
+ // EP83 (interrupt-IN) transfers need DMA-capable memory -- allocate on the HEAP.
+ // A stack array trips usb_hcd_map_urb_for_dma's "transfer buffer is on stack"
+ // WARNING (VMAP_STACK can't be DMA-mapped) and the broken submit also stalls the
+ // bring-up (poll_ep83 runs inside every drain round). Best-effort: bail on OOM.
+ let mut buf = match KVec::from_elem(0u8, 64, GFP_KERNEL) {
+ Ok(b) => b,
+ Err(_) => return 0,
+ };
+ let mut n = 0usize;
+ // Short timeout: a pending URB gives the dock a window to push, but a 30 ms block on the
+ // (normally idle) EP83 stalls the bring-up loop (see drain_ep84). 2 ms is enough to catch a
+ // ready event without serializing the handshake.
+ for _ in 0..4 {
+ match dev.interrupt_recv(0x83, &mut buf, Delta::from_millis(2)) {
+ Ok(len) if len > 0 => {
+ n += 1;
+ let s = &buf[..len.min(8)];
+ pr_info!("vino: EP83 status event {len}B {s:02x?}\n");
+ }
+ _ => break,
+ }
+ }
+ n
+ }
+
}
kernel::usb_device_table!(
--
2.54.0
^ permalink raw reply related [flat|nested] 12+ messages in thread
* [RFC PATCH 3/7] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm
2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
2026-06-17 15:12 ` [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
2026-06-17 15:12 ` [RFC PATCH 4/7] drm/vino: add the Vino (RawRl mode-2) framebuffer codec Mike Lothian
` (4 subsequent siblings)
7 siblings, 0 replies; 12+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
To: dri-devel
Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
linux-kernel
With the HDCP session keyed, the dock's control plane (CP) is an
AES-CTR-encrypted, AES-CMAC-authenticated ("Dl3Cmac") message channel.
Add the cp module: the control-plane message builders (mode-set, EDID
read/parse, cursor, the interactive seal) plus seal_livemac(), which
encrypts and frames a CP message under the live ks/riv -- byte-exact
against the reference daemon's captured wire (the on-device self-test
gains a third known-answer check that reproduces the daemon's real msg0).
send_cp_setup() drives the post-SKE sequence: it opens the async EP84
bulk-IN reader, sends the plaintext type=2 sub=0x24 stream-open arm
marker, then the first live encrypted CP frame, and counts the dock's
encrypted wsub=0x45 acks. The EP84 drain/parse helpers and the
lockstep-reply decoder land here too.
This is THE WALL: on a cold dock the ack count stays 0 -- the dock runs
the entire plaintext handshake but never engages the encrypted CP (see
the final patch's "help wanted" note). CP_ENGAGED is left clear, which
gates the EP08 video added in a later patch.
Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
drivers/gpu/drm/vino/cp.rs | 635 +++++++++++++++++++++++++++++++++++
drivers/gpu/drm/vino/vino.rs | 607 ++++++++++++++++++++++++++++++++-
2 files changed, 1237 insertions(+), 5 deletions(-)
create mode 100644 drivers/gpu/drm/vino/cp.rs
diff --git a/drivers/gpu/drm/vino/cp.rs b/drivers/gpu/drm/vino/cp.rs
new file mode 100644
index 000000000000..2668931d8500
--- /dev/null
+++ b/drivers/gpu/drm/vino/cp.rs
@@ -0,0 +1,635 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Encrypted-control-plane message builders (the inner plaintext of the type=4
+//! sub=0x24 AES-CTR frames) plus the AES-CTR `seal` that encrypts and frames them.
+//! Layouts are from the reverse-engineered protocol; offsets cite the guide and
+//! should be re-checked against a capture before they drive real hardware.
+#![allow(dead_code)] // some seal/handler paths run only after the dock engages CP (open blocker)
+
+use super::*;
+
+/// Common CP inner header: `[id u16][sub u16][counter u16][00 00]` (sec 6.1/sec 8.6.4).
+fn header(out: &mut KVec<u8>, id: u16, sub: u16, counter: u16) -> Result {
+ out.extend_from_slice(&id.to_le_bytes(), GFP_KERNEL)?;
+ out.extend_from_slice(&sub.to_le_bytes(), GFP_KERNEL)?;
+ out.extend_from_slice(&counter.to_le_bytes(), GFP_KERNEL)?;
+ out.extend_from_slice(&[0, 0], GFP_KERNEL)?;
+ Ok(())
+}
+
+fn pad_to(out: &mut KVec<u8>, len: usize) -> Result {
+ while out.len() < len {
+ out.push(0, GFP_KERNEL)?;
+ }
+ Ok(())
+}
+
+/// OUT heartbeat (sec 6.1): `id=0x16 sub=0x75`, two AES blocks (`10 27` at block1+6).
+pub(super) fn heartbeat(counter: u16) -> Result<KVec<u8>> {
+ let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
+ header(&mut b, 0x16, 0x75, counter)?;
+ pad_to(&mut b, 22)?; // block0 tail + block1[0..6]
+ b.extend_from_slice(&[0x10, 0x27], GFP_KERNEL)?; // block1[6..8]
+ pad_to(&mut b, 32)?;
+ Ok(b)
+}
+
+/// OUT get-EDID request (CP-HANDSHAKE.md sec 4f): `id=0x15 sub=0x21`, the message that asks
+/// the dock to return the downstream monitor's EDID in an `id=0x194 sub=0x21` reply (parsed
+/// by [`parse_edid_from_reply`]). The request carries no payload beyond the inner header, so
+/// it is a single 16-byte AES block; [`seal_livemac`] appends the 16-byte Dl3Cmac. The dock
+/// echoes the `counter`, so any monotonic value works. The exact request body was never
+/// captured (only the reply), so this is the minimal well-formed form -- re-check against a
+/// capture if the dock ever NAKs it once CP engages.
+pub(super) fn get_edid_req(counter: u16) -> Result<KVec<u8>> {
+ let mut b = KVec::with_capacity(16, GFP_KERNEL)?;
+ header(&mut b, 0x15, 0x21, counter)?;
+ pad_to(&mut b, 16)?;
+ Ok(b)
+}
+
+/// A video timing in DisplayID-Type-I terms (sec 8.6.4), as carried by the
+/// `0x48/0x22` set-mode message. Field meanings and offsets are verified
+/// byte-exact against the golden 3840x2160@60 capture (see [`set_mode`]).
+#[derive(Clone, Copy)]
+pub(super) struct Timing {
+ pub hactive: u16,
+ pub hblank: u16,
+ pub hsync_front: u16,
+ pub hsync_width: u16,
+ pub vactive: u16,
+ pub vblank: u16,
+ pub vsync_front: u16,
+ pub vsync_width: u16,
+ pub refresh_hz: u16,
+ /// Pixel clock in 10 kHz units (e.g. 0xd040 = 533.12 MHz for 4K@60).
+ pub pixel_clock_10khz: u16,
+ /// DisplayID field at off42 -- partly decoded (0x0604 for 4K, 0x0600 for the
+ /// 2560x1440 sample in sec 8.6.4); high byte 0x06 constant, low byte mode-varying.
+ pub field42: u16,
+}
+
+impl Timing {
+ /// 3840x2160@60 (CVT-RB) -- the mode the non-HDCP dongle advertises, kept as a
+ /// known-good reference whose `set_mode` output is byte-exact vs the golden capture.
+ pub(super) const UHD_60: Timing = Timing {
+ hactive: 3840, hblank: 160, hsync_front: 48, hsync_width: 32,
+ vactive: 2160, vblank: 62, vsync_front: 3, vsync_width: 5,
+ refresh_hz: 60, pixel_clock_10khz: 0xd040, field42: 0x0604,
+ };
+}
+
+/// set-mode (sec 8.6.4): `id=0x48 sub=0x22`, a 96-byte inner message carrying a
+/// DisplayID-Type-I u16 timing record. **Verified byte-exact** against the golden
+/// `[59]` 3840x2160@60 capture for every byte except the trailing 22-byte session
+/// MAC (off74..95), which [`seal`]'s caller / the HDCP session layer appends.
+///
+/// Layout (inner offsets): off20 BE u32 generation=2; off26 begins the LE u16
+/// record `hactive,hblank,hsync_front,hsync_width,vactive,vblank,vsync_front,
+/// vsync_width,field42,refresh,flags(0x4000)`; off48/off58/off60/off66 carry
+/// constants observed in the 4K capture; off70 the pixel clock (10 kHz units).
+pub(super) fn set_mode(counter: u16, t: &Timing) -> Result<KVec<u8>> {
+ let mut b = KVec::with_capacity(96, GFP_KERNEL)?;
+ header(&mut b, 0x48, 0x22, counter)?;
+ pad_to(&mut b, 20)?;
+ b.extend_from_slice(&2u32.to_be_bytes(), GFP_KERNEL)?; // off20: BE generation=2
+ pad_to(&mut b, 26)?; // off24..25 zero; timing begins at off26
+ for v in [
+ t.hactive, t.hblank, t.hsync_front, t.hsync_width,
+ t.vactive, t.vblank, t.vsync_front, t.vsync_width,
+ t.field42, t.refresh_hz, 0x4000, /* off46 flags */ 0x6000, /* off48 */
+ ] {
+ b.extend_from_slice(&v.to_le_bytes(), GFP_KERNEL)?;
+ }
+ pad_to(&mut b, 58)?;
+ b.extend_from_slice(&0x0080u16.to_le_bytes(), GFP_KERNEL)?; // off58 (observed const)
+ b.extend_from_slice(&0x00ffu16.to_le_bytes(), GFP_KERNEL)?; // off60 (observed const)
+ pad_to(&mut b, 66)?;
+ b.extend_from_slice(&0x0800u16.to_le_bytes(), GFP_KERNEL)?; // off66 (observed const)
+ pad_to(&mut b, 70)?;
+ b.extend_from_slice(&t.pixel_clock_10khz.to_le_bytes(), GFP_KERNEL)?; // off70
+ pad_to(&mut b, 96)?;
+ Ok(b)
+}
+
+/// EDID base-block sanity check: length, the `00 FF..FF 00` magic, and the 1-byte
+/// checksum (all 128 base bytes sum to 0 mod 256). A corrupt blob must never drive a
+/// mode-set, so [`timing_from_edid`] rejects anything that fails this.
+fn edid_valid(edid: &[u8]) -> bool {
+ const MAGIC: [u8; 8] = [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00];
+ edid.len() >= 128
+ && edid[..8] == MAGIC
+ && edid[..128].iter().fold(0u8, |a, &b| a.wrapping_add(b)) == 0
+}
+
+/// Parse one 18-byte EDID detailed timing descriptor into a [`Timing`], or `None` if it
+/// is too short or not a timing (pixel clock 0 marks a monitor descriptor). `field42`
+/// is left at the sec 8.6.4 default (`0x0600`) -- its low byte is mode-varying and not fully
+/// decoded, so the live mode-set substitution leaves the captured value in place.
+fn parse_dtd(d: &[u8]) -> Option<Timing> {
+ if d.len() < 18 {
+ return None;
+ }
+ let pclk = u16::from_le_bytes([d[0], d[1]]);
+ if pclk == 0 {
+ return None; // monitor descriptor, not a detailed timing
+ }
+ let hi = |v: u8, lo: u8| -> u16 { ((v as u16) << 8) | lo as u16 };
+ let hactive = hi((d[4] >> 4) & 0xf, d[2]);
+ let hblank = hi(d[4] & 0xf, d[3]);
+ let vactive = hi((d[7] >> 4) & 0xf, d[5]);
+ let vblank = hi(d[7] & 0xf, d[6]);
+ let hsync_front = (((d[11] >> 6) & 0x3) as u16) << 8 | d[8] as u16;
+ let hsync_width = (((d[11] >> 4) & 0x3) as u16) << 8 | d[9] as u16;
+ let vsync_front = (((d[11] >> 2) & 0x3) as u16) << 4 | ((d[10] >> 4) & 0xf) as u16;
+ let vsync_width = ((d[11] & 0x3) as u16) << 4 | (d[10] & 0xf) as u16;
+ let htotal = hactive.wrapping_add(hblank) as u32;
+ let vtotal = vactive.wrapping_add(vblank) as u32;
+ let refresh_hz = if htotal != 0 && vtotal != 0 {
+ ((pclk as u32 * 10_000 + (htotal * vtotal) / 2) / (htotal * vtotal)) as u16
+ } else {
+ 0
+ };
+ Some(Timing {
+ hactive,
+ hblank,
+ hsync_front,
+ hsync_width,
+ vactive,
+ vblank,
+ vsync_front,
+ vsync_width,
+ refresh_hz,
+ pixel_clock_10khz: pclk,
+ field42: 0x0600,
+ })
+}
+
+/// Extract the monitor's **preferred** detailed timing from an EDID for the live mode-set
+/// (CP-HANDSHAKE.md sec 4e). The first DTD in the base block is the preferred timing per the
+/// EDID spec; scan all four base descriptor slots (off 54/72/90/108) so a leading monitor
+/// descriptor (name/range/serial) doesn't hide it, and if the base block carries no DTD at
+/// all, fall back to the first DTD in the CTA-861 extension block. The blob is validated
+/// first; an invalid or timing-less EDID returns `None` so the caller keeps its known-good
+/// fallback timing rather than driving the dock with garbage.
+pub(super) fn timing_from_edid(edid: &[u8]) -> Option<Timing> {
+ if !edid_valid(edid) {
+ return None;
+ }
+ // Base-block descriptors: the first valid DTD is the preferred timing.
+ for off in [54usize, 72, 90, 108] {
+ if off + 18 <= edid.len() {
+ if let Some(t) = parse_dtd(&edid[off..off + 18]) {
+ return Some(t);
+ }
+ }
+ }
+ // No DTD in the base block: try the first CTA-861 extension's DTD area. CTA-861 blocks
+ // have tag 0x02 at byte 0 and a DTD-area byte offset at byte 2 (>= 4 when DTDs follow);
+ // descriptors run in 18-byte records up to the extension's checksum byte (127).
+ if edid[126] as usize >= 1 && edid.len() >= 256 {
+ let ext = &edid[128..256];
+ if ext[0] == 0x02 {
+ let start = ext[2] as usize;
+ if start >= 4 {
+ let mut off = start;
+ while off + 18 <= 127 {
+ if let Some(t) = parse_dtd(&ext[off..off + 18]) {
+ return Some(t);
+ }
+ off += 18;
+ }
+ }
+ }
+ }
+ None
+}
+
+/// Overwrite the geometry + clock fields of an in-place set-mode inner message
+/// (`id=0x48 sub=0x22`) with `t` (CP-HANDSHAKE.md sec 4e). Offsets mirror [`set_mode`]:
+/// the LE u16 timing record at off26 and the pixel clock at off70. `field42` (off42),
+/// the off66 token and the encrypted trailer are intentionally **left as captured**;
+/// only the EDID-derived values change, so the wire length (hence `wire_seq`) is
+/// unchanged. No-op if `plain` is too short.
+pub(super) fn apply_edid_timing(plain: &mut [u8], t: &Timing) {
+ if plain.len() < 72 {
+ return;
+ }
+ let put = |b: &mut [u8], off: usize, v: u16| {
+ b[off] = v as u8;
+ b[off + 1] = (v >> 8) as u8;
+ };
+ put(plain, 26, t.hactive);
+ put(plain, 28, t.hblank);
+ put(plain, 30, t.hsync_front);
+ put(plain, 32, t.hsync_width);
+ put(plain, 34, t.vactive);
+ put(plain, 36, t.vblank);
+ put(plain, 38, t.vsync_front);
+ put(plain, 40, t.vsync_width);
+ put(plain, 44, t.refresh_hz);
+ put(plain, 70, t.pixel_clock_10khz);
+}
+
+/// Convert a DRM display mode (the timing the *compositor* selected from the connector's
+/// EDID-derived mode list) into a set-mode [`Timing`]. This is what makes the dock
+/// multi-mode: `drm_edid_connector_add_modes` already advertises every base+extension mode
+/// from the dock's EDID, and when userspace sets any one of them the resulting
+/// `drm_display_mode` lands here verbatim -- no re-parsing of EDID offsets. The blanking
+/// fields map straight across (CVT/DMT/DisplayID all use the same front-porch/sync model),
+/// and the refresh rate comes from DRM's own `drm_mode_vrefresh` helper rather than a
+/// hand-rolled divide. `field42` keeps the sec 8.6.4 default (its low byte is mode-varying and
+/// not fully decoded); the dock tolerates the high byte `0x06`.
+///
+/// SAFETY: `mode` must point to a valid `drm_display_mode` for the duration of the call.
+pub(super) unsafe fn timing_from_drm_mode(mode: *const bindings::drm_display_mode) -> Timing {
+ // SAFETY: caller guarantees `mode` is a live drm_display_mode.
+ let m = unsafe { &*mode };
+ // SAFETY: `drm_mode_vrefresh` only reads the mode; `mode` is valid per the contract.
+ let refresh = unsafe { bindings::drm_mode_vrefresh(mode) } as u16;
+ let sub = |a: u16, b: u16| a.saturating_sub(b);
+ Timing {
+ hactive: m.hdisplay,
+ hblank: sub(m.htotal, m.hdisplay),
+ hsync_front: sub(m.hsync_start, m.hdisplay),
+ hsync_width: sub(m.hsync_end, m.hsync_start),
+ vactive: m.vdisplay,
+ vblank: sub(m.vtotal, m.vdisplay),
+ vsync_front: sub(m.vsync_start, m.vdisplay),
+ vsync_width: sub(m.vsync_end, m.vsync_start),
+ refresh_hz: refresh,
+ // `clock` is in kHz; the set-mode field is in 10 kHz units.
+ pixel_clock_10khz: (m.clock / 10).clamp(0, u16::MAX as i32) as u16,
+ field42: 0x0600,
+ }
+}
+
+/// Decode the inner header of a dock->host CP frame: returns `(id, sub, ictr)` from
+/// the first decrypted block (CP-HANDSHAKE.md sec 3), or `None` if `wire` is not a
+/// decryptable CP frame. Used by the live loop to log what the dock is replying.
+pub(super) fn reply_info(
+ ks: &[u8; 16],
+ out_riv: &[u8; 8],
+ wire: &[u8],
+) -> Option<(u16, u16, u16)> {
+ if wire.len() <= 16 {
+ return None;
+ }
+ let seq = u32::from_le_bytes([wire[12], wire[13], wire[14], wire[15]]);
+ let head = &wire[16..wire.len().min(32)];
+ let inner = open_in(ks, &in_riv(out_riv), seq, head).ok()?;
+ if inner.len() < 6 {
+ return None;
+ }
+ Some((
+ u16::from_le_bytes([inner[0], inner[1]]),
+ u16::from_le_bytes([inner[2], inner[3]]),
+ u16::from_le_bytes([inner[4], inner[5]]),
+ ))
+}
+
+/// CP `sub` ids seen on the wire (CP-HANDSHAKE.md). Used to score a candidate
+/// decrypt: a plaintext whose `sub` is one of these (and whose post-counter pad is
+/// zero) is almost certainly the correct key/riv.
+fn is_known_sub(sub: u16) -> bool {
+ matches!(
+ sub,
+ 0x00 | 0x04 | 0x0c | 0x10 | 0x20 | 0x21 | 0x22 | 0x24 | 0x25 | 0x30 | 0x41
+ | 0x42 | 0x43 | 0x45 | 0x75 | 0x84
+ )
+}
+
+/// Diagnostic decode: try a dock->host frame under every plausible riv variant and
+/// return the best-scoring inner `(riv_tag, id, sub, ictr)`. The interactive
+/// `wsub=0x45` replies decrypt under `in_riv` (byte7^1), but the **cap-phase**
+/// `wsub=0x25` frames decrypt under the session ks with **byte7 unchanged** (the OUT
+/// value) -- see the cold-ref transcript. `byte0^0x80` selects the head. This mirrors
+/// `decode-handshake.py`'s scoring so a live trace shows what the dock is actually
+/// asking for during the capability exchange we currently skip.
+pub(super) fn decode_any(
+ ks: &[u8; 16],
+ out_riv: &[u8; 8],
+ wire: &[u8],
+) -> Option<(&'static str, u16, u16, u16, [u8; 24])> {
+ if wire.len() <= 16 {
+ return None;
+ }
+ let seq = u32::from_le_bytes([wire[12], wire[13], wire[14], wire[15]]);
+ let head = &wire[16..wire.len().min(48)];
+ let out0 = *out_riv;
+ let in0 = in_riv(out_riv);
+ let mut out1 = out0;
+ out1[0] ^= 0x80;
+ let mut in1 = in0;
+ in1[0] ^= 0x80;
+ let variants: [(&'static str, [u8; 8]); 4] =
+ [("out/h0", out0), ("in/h0", in0), ("out/h1", out1), ("in/h1", in1)];
+ let mut best: Option<(i32, &'static str, u16, u16, u16, [u8; 24])> = None;
+ for (tag, riv) in variants.iter() {
+ let Ok(pt) = open_in(ks, riv, seq, head) else { continue };
+ if pt.len() < 8 {
+ continue;
+ }
+ let id = u16::from_le_bytes([pt[0], pt[1]]);
+ let sub = u16::from_le_bytes([pt[2], pt[3]]);
+ let ctr = u16::from_le_bytes([pt[4], pt[5]]);
+ let pad = u16::from_le_bytes([pt[6], pt[7]]);
+ let mut sc = 0i32;
+ if is_known_sub(sub) {
+ sc += 50;
+ }
+ if pad == 0 {
+ sc += 10;
+ }
+ if ctr < 0x400 {
+ sc += 5;
+ }
+ if best.map_or(true, |b| sc > b.0) {
+ // Keep the first 24 plaintext bytes so the live trace shows the decoded
+ // structure (e.g. the `..4c..de..` cap-descriptor template that, in the
+ // capture, is session-independent -- its absence flags a ks/riv mismatch).
+ let mut sample = [0u8; 24];
+ let n = pt.len().min(24);
+ sample[..n].copy_from_slice(&pt[..n]);
+ best = Some((sc, tag, id, sub, ctr, sample));
+ }
+ }
+ best.map(|(_, tag, id, sub, ctr, sample)| (tag, id, sub, ctr, sample))
+}
+
+/// cursor create (sec 8.6.1): `id=0x1b sub=0x42`, advertises `w x h`.
+pub(super) fn cursor_create(counter: u16, w: u16, h: u16) -> Result<KVec<u8>> {
+ let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
+ header(&mut b, 0x1b, 0x42, counter)?;
+ pad_to(&mut b, 20)?;
+ b.extend_from_slice(&[0x00, 0x02, 0x00], GFP_KERNEL)?; // marker seen in captures
+ b.extend_from_slice(&w.to_le_bytes(), GFP_KERNEL)?;
+ b.extend_from_slice(&h.to_le_bytes(), GFP_KERNEL)?;
+ Ok(b)
+}
+
+/// cursor move (sec 8.6.1): `id=0x1a sub=0x43`, head id @22, X @24, Y @26 (LE).
+pub(super) fn cursor_move(counter: u16, head: u8, x: u16, y: u16) -> Result<KVec<u8>> {
+ let mut b = KVec::with_capacity(28, GFP_KERNEL)?;
+ header(&mut b, 0x1a, 0x43, counter)?;
+ pad_to(&mut b, 22)?;
+ b.push(head, GFP_KERNEL)?; // off22 head/monitor id
+ b.push(1, GFP_KERNEL)?; // off23 flag
+ b.extend_from_slice(&x.to_le_bytes(), GFP_KERNEL)?; // off24
+ b.extend_from_slice(&y.to_le_bytes(), GFP_KERNEL)?; // off26
+ Ok(b)
+}
+
+/// cursor image (sec 8.6.1): `id=0x1c sub=0x41`. Mirrors [`cursor_create`]'s header (the
+/// `00 02 00` marker + `w`,`h` at off20) and appends the `w*h` BGRA bitmap. `bgra` must be
+/// `w*h*4` bytes -- DRM hands the driver a 64x64 ARGB8888 cursor buffer and the caller swaps
+/// it
+/// to BGRA. The image sub-layout past the create-style header is capture-unconfirmed (only the
+/// id and the shared header are decoded); re-check against a capture once CP engages.
+pub(super) fn cursor_image(counter: u16, w: u16, h: u16, bgra: &[u8]) -> Result<KVec<u8>> {
+ if bgra.len() != w as usize * h as usize * 4 {
+ return Err(EINVAL);
+ }
+ let mut b = KVec::with_capacity(32 + bgra.len(), GFP_KERNEL)?;
+ header(&mut b, 0x1c, 0x41, counter)?;
+ pad_to(&mut b, 20)?;
+ b.extend_from_slice(&[0x00, 0x02, 0x00], GFP_KERNEL)?; // marker (mirrors cursor_create)
+ b.extend_from_slice(&w.to_le_bytes(), GFP_KERNEL)?;
+ b.extend_from_slice(&h.to_le_bytes(), GFP_KERNEL)?;
+ b.extend_from_slice(bgra, GFP_KERNEL)?;
+ Ok(b)
+}
+
+/// DisplayLink "Dl3Cmac" CP-message integrity tag (16 bytes) -- **FULLY SOLVED + CROSS-SESSION
+/// VERIFIED 2026-06-11** (`captures/DL3CMAC-FULLY-SOLVED-20260611.md`):
+/// `tag = AES-CMAC(ks, mac_nonce(8) || BE64(wire_seq) || ciphertext)` where
+/// - `mac_nonce` = the CTR stream `riv` **with `byte0 ^= 0x80`** (this byte0 flip is the bit
+/// prior writeups missed -- they tried `riv` / `riv^1@byte7` and OUT never verified),
+/// - `wire_seq` = the AES-CTR block counter (frame header off-12), zero-extended to BE64,
+/// - `ciphertext` = the AES-CTR ciphertext content (encrypt-then-MAC), tag appended IN CLEAR.
+/// `K_dl3 = ks`. Proven: 110/115 OUT + 128/135 IN corpus frames AND cold-ref msg0 (a different
+/// session) reproduce byte-exact. Pass the CTR `riv` directly; the byte0 flip is applied here.
+pub(super) fn dl3cmac_tag(
+ ks: &[u8; 16],
+ riv: &[u8; 8],
+ wire_seq: u64,
+ ciphertext: &[u8],
+) -> Result<[u8; 16]> {
+ let mut mac_nonce = *riv;
+ mac_nonce[0] ^= 0x80;
+ let mut buf = KVec::with_capacity(16 + ciphertext.len(), GFP_KERNEL)?;
+ buf.extend_from_slice(&mac_nonce, GFP_KERNEL)?;
+ buf.extend_from_slice(&wire_seq.to_be_bytes(), GFP_KERNEL)?;
+ buf.extend_from_slice(ciphertext, GFP_KERNEL)?;
+ crypto::aes_cmac(ks, &buf)
+}
+
+/// Seal a CP message with a **freshly computed live Dl3Cmac**, reusing DLM's captured wire
+/// `header` (so `seq`/`aux` are byte-identical) but recomputing the tail tag for THIS session.
+/// `content_pt` is the real inner plaintext WITHOUT the 16-byte tag region. Wire body =
+/// `AES-CTR(ks, riv, content_pt)` || `dl3cmac_tag(...)`. This is the live-generation path. See
+/// `captures/DL3CMAC-FULLY-SOLVED-20260611.md`.
+pub(super) fn seal_livemac(
+ ks: &[u8; 16],
+ riv: &[u8; 8],
+ header: &[u8],
+ content_pt: &[u8],
+) -> Result<KVec<u8>> {
+ let seq = u32::from_le_bytes([header[12], header[13], header[14], header[15]]);
+ let mut ct = KVec::with_capacity(content_pt.len(), GFP_KERNEL)?;
+ for (i, chunk) in content_pt.chunks(16).enumerate() {
+ let mut iv = [0u8; 16];
+ iv[..8].copy_from_slice(riv);
+ iv[12..].copy_from_slice(&seq.wrapping_add(i as u32).to_be_bytes());
+ let ksb = crypto::aes128_ecb(ks, &iv)?;
+ for (j, &p) in chunk.iter().enumerate() {
+ ct.push(p ^ ksb[j], GFP_KERNEL)?;
+ }
+ }
+ let tag = dl3cmac_tag(ks, riv, seq as u64, &ct)?;
+ let mut frame = KVec::with_capacity(16 + ct.len() + 16, GFP_KERNEL)?;
+ frame.extend_from_slice(&header[..16], GFP_KERNEL)?;
+ frame.extend_from_slice(&ct, GFP_KERNEL)?;
+ frame.extend_from_slice(&tag, GFP_KERNEL)?;
+ Ok(frame)
+}
+
+/// Seal an inner CP message into a wire frame (type=4 sub=0x24, `seq`). DisplayLink
+/// CP is **encrypt-then-MAC**: the message content is AES-CTR-encrypted, then a
+/// 16-byte Dl3Cmac tag (`AES-CMAC(ks, riv || BE64(seq) || ciphertext)`) is appended.
+/// The keystream is `AES_ECB(ks, riv(8) || u32(0) || u32_be(seq + block))` (sec 6.1).
+///
+/// `inner` is the captured golden plaintext `[content || stale-tag-region(16)]`; we
+/// encrypt only `content = inner[..len-16]` and append a **fresh** tag keyed by our
+/// live session, so the dock's Dl3Cmac verification passes (the stale replayed tag is
+/// why the dock previously dropped our CP). VERIFIED construction (sec 8.6.7).
+pub(super) fn seal(
+ ks: &[u8; 16],
+ riv: &[u8; 8],
+ seq: u32,
+ inner: &[u8],
+) -> Result<KVec<u8>> {
+ // The interactive CP stream: session ks, wire sub `0x24`.
+ seal_stream(ks, riv, 0x24, seq, inner)
+}
+
+/// Build a fully sealed interactive CP frame (`type=4 sub=0x24`) at `wire_seq` over `content`
+/// (the inner plaintext, WITHOUT any trailing 16-byte tag placeholder): the 16-byte wire
+/// header -- size, `type=4`, `sub=0x24`, the per-`id` [`aux_for_id`] field, and `wire_seq` --
+/// followed by [`seal_livemac`] (AES-CTR ciphertext + appended live Dl3Cmac). Shared by the
+/// bring-up live loop ([`VinoDriver::send_live_cp`]) and the runtime KMS senders
+/// ([`drm_sink::VinoDrmData::send_cp`]) so both produce a byte-identical wire frame.
+pub(super) fn seal_interactive(
+ ks: &[u8; 16],
+ riv: &[u8; 8],
+ id: u16,
+ wire_seq: u32,
+ content: &[u8],
+) -> Result<KVec<u8>> {
+ let body_len = content.len() + 16; // AES-CTR ciphertext + 16-byte Dl3Cmac
+ let size = ((16 + body_len) - 4) as u16;
+ let aux = aux_for_id(id, body_len);
+ let mut hdr = [0u8; 16];
+ hdr[2..4].copy_from_slice(&size.to_le_bytes());
+ hdr[4..8].copy_from_slice(&4u32.to_le_bytes()); // type=4
+ hdr[8..10].copy_from_slice(&0x24u16.to_le_bytes()); // sub=0x24 (interactive CP)
+ hdr[10..12].copy_from_slice(&aux.to_le_bytes());
+ hdr[12..16].copy_from_slice(&wire_seq.to_le_bytes());
+ seal_livemac(ks, riv, &hdr, content)
+}
+
+/// The CP wire-header `aux`@10 (`sub_len_dw`) field is a **strict per-inner-message-id
+/// constant** in DLM's CP stream -- verified byte-exact across all 94 captured 1080p CP
+/// frames (`cp-hdrwire-1080p.bin`) -- **not** `body.len()/4`, which is what `push_frame`
+/// derives. Reproducing it makes a generated CP frame's header byte-identical to DLM, the
+/// leading hypothesis for the dock engaging its CP cipher (the dock acks our plaintext cap
+/// but emits 0 encrypted replies with the wrong `aux`). See docs/BLOCKER.md and memory
+/// `project_cp_aux_field_per_id_constant`. Unknown ids fall back to the dword count so an
+/// unrecognised message is still well-formed. This makes the generated `seal`/`seal_stream`
+/// path match DLM without a captured-header blob -- the basis for **live** CP generation.
+pub(super) fn aux_for_id(id: u16, body_len: usize) -> u16 {
+ match id {
+ 0x14 => 0x0a,
+ 0x15 => 0x09,
+ 0x16 => 0x08,
+ 0x19 => 0x05,
+ 0x1f => 0x0f,
+ 0x22 => 0x0c,
+ 0x26 => 0x08,
+ 0x2a => 0x04,
+ 0x32 => 0x0c,
+ 0x48 => 0x06,
+ 0x9a => 0x04,
+ _ => (body_len / 4) as u16,
+ }
+}
+
+/// General AES-CTR seal under an arbitrary stream `key`/`riv` and wire sub. `seal`
+/// is the session-CP case (`wsub=0x24`); the **cap phase** (CP-HANDSHAKE.md sec 4b)
+/// needs `wsub=0x04` sealed under the dock's `id=0x32`-delivered per-head stream key,
+/// not the session ks -- which `seal` cannot express. Body construction is identical:
+/// AES-CTR(key, riv || 0x00000000 || BE32(seq+block)) over the **whole** inner message
+/// (no appended MAC; the inner carries its own encrypted trailer -- verified byte-exact
+/// vs DLM, 30/30 wire frames).
+pub(super) fn seal_stream(
+ key: &[u8; 16],
+ riv: &[u8; 8],
+ wsub: u16,
+ seq: u32,
+ inner: &[u8],
+) -> Result<KVec<u8>> {
+ let mut ct = KVec::with_capacity(inner.len(), GFP_KERNEL)?;
+ for (i, chunk) in inner.chunks(16).enumerate() {
+ let mut iv = [0u8; 16];
+ iv[..8].copy_from_slice(riv);
+ iv[12..].copy_from_slice(&seq.wrapping_add(i as u32).to_be_bytes());
+ let ksb = crypto::aes128_ecb(key, &iv)?;
+ for (j, &p) in chunk.iter().enumerate() {
+ ct.push(p ^ ksb[j], GFP_KERNEL)?;
+ }
+ }
+ let mut frame = KVec::with_capacity(16 + ct.len(), GFP_KERNEL)?;
+ // DLM-exact `aux`@10: a per-inner-id constant (see `aux_for_id`), not `body/4`. The
+ // id is read from the *plaintext* inner (off 0); `push_frame` would derive the wrong
+ // value and is the suspected reason the dock won't engage its CP cipher.
+ let id = if inner.len() >= 2 { u16::from_le_bytes([inner[0], inner[1]]) } else { 0 };
+ super::proto::push_frame_with(&mut frame, 0x04, wsub, aux_for_id(id, ct.len()), seq, &ct)?;
+ Ok(frame)
+}
+
+/// Derive the dock->host (IN) CP riv from the host->dock (OUT) `riv`. **It is the
+/// SAME riv -- no transform.** Proven 2026-06-12 by decrypting a frida-keyed DLM cold
+/// session's engaged `sub=0x45` replies (`captures/dlm-coldkeys-20260611-135237`, logged
+/// `ks`/`out_riv`): the dock's replies decrypt cleanly ONLY under the raw `out_riv`
+/// (`id=0x4c sub=0 ctr=8` to msg0, `id=0x14 sub=0x10` ACKs, `id=0x213` cert, ...); the old
+/// `byte7 ^= 1` gives garbage. The earlier "byte7^1 for IN" note was never validated against
+/// a real engaged reply (vino never engaged) and was wrong -- it would have made vino
+/// misdecode
+/// every dock reply (and partly explains old "dock replies garbage under our ks" findings).
+pub(super) fn in_riv(out_riv: &[u8; 8]) -> [u8; 8] {
+ *out_riv
+}
+
+/// Decrypt a dock->host CP frame body (AES-CTR, the same keystream as [`seal`] but
+/// keyed with the IN `riv`). `ct` is the ciphertext (wire bytes after the 16-byte
+/// cleartext header); `seq` is the wire counter at wire offset 12.
+pub(super) fn open_in(
+ ks: &[u8; 16],
+ in_riv: &[u8; 8],
+ seq: u32,
+ ct: &[u8],
+) -> Result<KVec<u8>> {
+ let mut pt = KVec::with_capacity(ct.len(), GFP_KERNEL)?;
+ for (i, chunk) in ct.chunks(16).enumerate() {
+ let mut iv = [0u8; 16];
+ iv[..8].copy_from_slice(in_riv);
+ iv[12..].copy_from_slice(&seq.wrapping_add(i as u32).to_be_bytes());
+ let ksb = crypto::aes128_ecb(ks, &iv)?;
+ for (j, &c) in chunk.iter().enumerate() {
+ pt.push(c ^ ksb[j], GFP_KERNEL)?;
+ }
+ }
+ Ok(pt)
+}
+
+/// If `wire` is an EDID reply (dock->host EP84, `type=4 sub=0x45`, inner
+/// `id=0x194 sub=0x21`), decrypt it with the IN riv and return the embedded EDID
+/// blob (base block + extensions). The EDID begins at inner offset 22; its total
+/// length is `128 * (1 + extension_count)`, where the extension count is base-block
+/// byte 126. Returns `None` for any other frame. See docs/CONTROL-PLANE.md.
+pub(super) fn parse_edid_from_reply(
+ ks: &[u8; 16],
+ out_riv: &[u8; 8],
+ wire: &[u8],
+) -> Result<Option<KVec<u8>>> {
+ // Wire header: [.. type@4 u32 .. sub@8 u16 .. seq@12 u32]; body at off16.
+ if wire.len() <= 16 || u16::from_le_bytes([wire[8], wire[9]]) != 0x45 {
+ return Ok(None);
+ }
+ let seq = u32::from_le_bytes([wire[12], wire[13], wire[14], wire[15]]);
+ let inner = open_in(ks, &in_riv(out_riv), seq, &wire[16..])?;
+ // Inner header: [id u16][sub u16][counter u16][00 00]; EDID payload at off22.
+ const EDID_OFF: usize = 22;
+ if inner.len() < EDID_OFF + 128 {
+ return Ok(None);
+ }
+ let id = u16::from_le_bytes([inner[0], inner[1]]);
+ let sub = u16::from_le_bytes([inner[2], inner[3]]);
+ // The get-EDID reply id is `0x194` on the wire (CP-HANDSHAKE.md sec 4f, ground-truthed
+ // against the cold-ref capture); older notes wrote the low byte `0x94` alone. Accept
+ // both so a real `0x194` reply is not silently dropped (the EDID would never reach the
+ // connector even after CP engages).
+ if (id != 0x94 && id != 0x194) || sub != 0x21 {
+ return Ok(None);
+ }
+ let edid = &inner[EDID_OFF..];
+ // Validate the EDID base-block magic `00 FF FF FF FF FF FF 00`.
+ const MAGIC: [u8; 8] = [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00];
+ if edid[..8] != MAGIC {
+ return Ok(None);
+ }
+ let total = ((1 + edid[126] as usize) * 128).min(edid.len());
+ let mut out = KVec::with_capacity(total, GFP_KERNEL)?;
+ out.extend_from_slice(&edid[..total], GFP_KERNEL)?;
+ Ok(Some(out))
+}
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index db4c38b6dc92..ef44a625cb70 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -43,6 +43,7 @@
use kernel::{
alloc::flags::GFP_KERNEL,
+ bindings,
device::{self, Core},
error::code::{ENODEV, EINVAL},
prelude::*,
@@ -63,18 +64,28 @@
/// EP84 (dock->host) drain buffer size. The dock's capability block can reach ~5.8 KiB, so a
/// single bulk read needs a generously sized buffer to avoid truncating and misframing it.
const EP84_BUF: usize = 16384;
+/// Number of IN URBs kept perpetually posted on EP84 by the async reader
+/// ([`usb::Device::bulk_in_queue`]); `depth - 1` stay outstanding while one is serviced.
+const EP84_QUEUE_DEPTH: usize = 4;
/// USB transfer timeout used during bring-up.
fn timeout() -> Delta {
Delta::from_millis(1000)
}
+/// Set once the dock has actually engaged the CP cipher (`wsub=0x45` acks > 0). EP08 video is
+/// gated on it: pushing frames at a dock whose CP channel is dead makes it fault and USB-reset.
+/// NOTE: with the current CP-engagement wall (see the file header) this is never set on real
+/// hardware -- the dock runs the whole plaintext handshake but never engages the encrypted CP.
+static CP_ENGAGED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
+
mod proto;
mod crypto;
mod rng;
mod hdcp;
mod ake;
mod golden;
+mod cp;
/// The shared secrets a completed HDCP 2.2 AKE leaves behind: the SKE session key
/// `ks` and content IV `riv` key the AES-CTR control plane (sec 6), and `kd` is kept
@@ -129,18 +140,33 @@ impl WorkItem for BringUp {
fn run(this: Arc<BringUp>) {
let cdev: &device::Device = this.intf.as_ref();
let dev: &usb::Device = this.intf.as_ref();
- // WIP scaffold: plaintext bring-up then the clean-room HDCP 2.2 AKE/LC/SKE. Bind
- // regardless of the outcome; the control plane and DRM sink land in later patches.
+ // WIP scaffold: plaintext bring-up, the clean-room HDCP 2.2 AKE/LC/SKE, then the
+ // post-SKE CP setup. Bind regardless of the outcome -- there is no display path until
+ // the dock engages the encrypted control plane, which it currently never does (see the
+ // "help wanted" note at the top of the file). The DRM sink lands in a later patch.
match VinoDriver::bring_up(dev) {
Ok(()) => {
dev_info!(cdev, "vino: plaintext session init OK\n");
match VinoDriver::run_ake(dev) {
Ok(session) => {
dev_info!(cdev, "vino: HDCP AKE + LC + SKE complete (session keyed)\n");
- // Dev diagnostic: the live session key/riv, so the dock's encrypted
- // EP84 replies can be decoded offline from a usbmon capture. Behind
- // pr_debug, so compiled out unless dynamic debug is enabled.
pr_debug!("vino: SESSION ks={:02x?} riv={:02x?}\n", &session.ks, &session.riv);
+ // Phase 2c: drive the post-SKE CP setup. send_cp_setup re-seals DLM's
+ // captured setup template under THIS session's live ks/riv and sends it;
+ // `acks` counts the dock's encrypted wsub=0x45 replies. THIS IS THE WALL:
+ // on a cold dock `acks` stays 0 -- the dock runs the entire plaintext
+ // handshake but never engages the encrypted CP.
+ let mut edid_out: Option<KVec<u8>> = None;
+ match VinoDriver::send_cp_setup(dev, &session, &mut edid_out) {
+ Ok((n, acks, _wseq_end, _ctr_end)) => {
+ dev_info!(cdev,
+ "vino: CP setup sent -- {n} messages, {acks} dock CP acks (wsub=0x45)\n");
+ // CP engagement gates EP08 video (added in a later patch): until
+ // the dock acks, pushing pixels at it wedges the hub.
+ CP_ENGAGED.store(acks > 0, core::sync::atomic::Ordering::SeqCst);
+ }
+ Err(e) => dev_info!(cdev, "vino: CP setup incomplete ({e:?}) -- WIP\n"),
+ }
}
Err(e) => dev_info!(cdev, "vino: HDCP AKE incomplete ({e:?}) -- WIP\n"),
}
@@ -205,6 +231,56 @@ fn crypto_selftest() {
Ok(out) => pr_err!("vino: selftest AES-CMAC FAIL got={out:02x?}\n"),
Err(e) => pr_err!("vino: selftest AES-CMAC ERR ({e:?})\n"),
}
+
+ // 3. Full seal_livemac vs cold-ref's REAL msg0 (capture t=36.813765). ks/riv are the cold-ref
+ // session's; content is msg0's 32-byte plaintext; the expected frame is the captured wire.
+ let ks = [
+ 0xd8, 0xb2, 0x48, 0x12, 0x44, 0x1d, 0x50, 0x82, 0x0d, 0xa3, 0xc2, 0x71, 0xc7, 0xa3, 0x6e,
+ 0xc2,
+ ];
+ let riv = [0xfb, 0xa7, 0xc3, 0x5f, 0xe6, 0xce, 0x40, 0xec];
+ let header = [
+ 0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x24, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00,
+ 0x00,
+ ];
+ let content = [
+ 0x14, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x48, 0xec, 0x9c, 0xec, 0xc3, 0x89, 0x23,
+ 0x5d, 0x69,
+ ];
+ let expect = [
+ 0x00, 0x00, 0x3c, 0x00, 0x04, 0x00, 0x00, 0x00, 0x24, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0xcb, 0x4c, 0x80, 0xde, 0xf0, 0xd0, 0xfd, 0x56, 0x22, 0x5f, 0x43, 0xbd, 0x55, 0x0d,
+ 0x8e, 0xc5, 0x7a, 0x1c, 0x35, 0x12, 0x81, 0x35, 0x31, 0x1a, 0x45, 0x13, 0x91, 0x41, 0x25,
+ 0x87, 0xe9, 0xf7, 0xe5, 0x5b, 0xb5, 0xbc, 0x76, 0x5b, 0x2f, 0x1e, 0x79, 0xf2, 0x8b, 0xd5,
+ 0x5b, 0x2c, 0x3c, 0xe7,
+ ];
+ match cp::seal_livemac(&ks, &riv, &header, &content) {
+ Ok(frame) if frame.as_slice() == expect.as_slice() => {
+ pr_info!("vino: selftest seal_livemac(msg0) PASS -- CP crypto reproduces cold-ref wire\n")
+ }
+ Ok(frame) => {
+ // Show where it first diverges so a framing/order bug is localizable.
+ let mut at = frame.len().min(expect.len());
+ for i in 0..at {
+ if frame[i] != expect[i] {
+ at = i;
+ break;
+ }
+ }
+ pr_err!(
+ "vino: selftest seal_livemac(msg0) FAIL at byte {at} (len {} vs {})\n",
+ frame.len(),
+ expect.len()
+ );
+ let s = at.saturating_sub(0);
+ let e = (at + 16).min(frame.len());
+ pr_err!("vino: got[{s}..]={:02x?}\n", &frame[s..e]);
+ let e2 = (at + 16).min(expect.len());
+ pr_err!("vino: exp[{s}..]={:02x?}\n", &expect[s..e2]);
+ }
+ Err(e) => pr_err!("vino: selftest seal_livemac(msg0) ERR ({e:?})\n"),
+ }
}
impl VinoDriver {
@@ -962,6 +1038,527 @@ fn poll_ep83(dev: &usb::Device) -> usize {
n
}
+
+ /// Drives the post-SKE CP setup: opens the async EP84 reader, sends the plaintext
+ /// stream-open arm marker, then the first live encrypted CP frame (msg0), and counts the
+ /// dock's encrypted `wsub=0x45` acks. THE WALL: on a cold dock `acks` stays 0 -- the dock
+ /// runs the entire plaintext handshake but never engages the encrypted CP. See the "help
+ /// wanted" note at the top of the file.
+ fn send_cp_setup(
+ dev: &usb::Device,
+ session: &Session,
+ edid_out: &mut Option<KVec<u8>>,
+ ) -> Result<(usize, usize, u32, u16)> {
+ // 16 KiB so the dock's ~5787 B capability block is read whole (see [`EP84_BUF`]).
+ let mut resp = KVec::from_elem(0u8, EP84_BUF, GFP_KERNEL)?;
+ let mut drained = 0usize;
+ let mut acks = 0usize;
+ let mut sent = 0usize;
+
+ // Plaintext `type=2 sub=0x24`+`0x45` stream-open arm marker -- the mandatory gate
+ // before the first encrypted frame.
+ const STREAM_OPEN: [u8; 64] = [
+ 0x00, 0x00, 0x1c, 0x00, 0x02, 0x00, 0x00, 0x00, //
+ 0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x04, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x1c, 0x00, 0x02, 0x00, 0x00, 0x00, //
+ 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x05, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
+ ];
+
+ // Open the persistent async EP84 IN reader BEFORE the arm marker and msg0, so
+ // `EP84_QUEUE_DEPTH` IN transfers are already posted when the dock pushes its post-arm
+ // reply (DLM's libusb always-pending-IN behaviour). Draining EP84 concurrently stops the
+ // dock's IN FIFO filling and NAKing our OUT (the sync-bulk deadlock that produced a 100 ms
+ // msg0 NAK). RAII: dropping the queue at function exit kills+frees the URBs.
+ let mut ep84_q = match dev.bulk_in_queue(0x04, EP84_QUEUE_DEPTH, EP84_BUF) {
+ Ok(q) => {
+ pr_info!("vino: EP84 async IN queue opened (depth={EP84_QUEUE_DEPTH})\n");
+ Some(q)
+ }
+ Err(e) => {
+ pr_info!("vino: EP84 async queue open failed ({e:?}) -- falling back to sync bulk_recv\n");
+ None
+ }
+ };
+
+ // A/B (2026-06-16): route the engagement-critical arm marker + msg0 through an async,
+ // pipelined OUT queue (`usb::Device::bulk_out_queue`) instead of the synchronous
+ // `bulk_send`. This mirrors DLM's libusb execution model exactly: each OUT URB is
+ // submitted and returns immediately (the HCD auto-retries NAKs until the URB's
+ // teardown), so the arm and msg0 are queued back-to-back and reaped afterwards rather
+ // than each blocking for its device-ACK round-trip before the next is submitted. The
+ // 2026-06-15 measurement showed the *wire* (lengths + submit->complete latency) is
+ // already identical, so this is not expected to change what the dock receives -- it is
+ // the last structural host difference (sync `usb_bulk_msg` vs async submit/reap) made
+ // identical so a cold plug can rule it in or out. Default OFF so vino keeps the proven
+ // sync path and paired diffs are not polluted; flip to test.
+ const CP_ASYNC_OUT: bool = true;
+ let mut out_q = if CP_ASYNC_OUT {
+ match dev.bulk_out_queue(0x02, 4, 1024) {
+ Ok(q) => {
+ pr_info!("vino: EP02 async OUT queue opened (depth=4) -- libusb-style submit/reap\n");
+ Some(q)
+ }
+ Err(e) => {
+ pr_info!("vino: EP02 async OUT queue open failed ({e:?}) -- using sync bulk_send\n");
+ None
+ }
+ }
+ } else {
+ None
+ };
+
+ // Pin the EP02 DATA0/DATA1 toggle to DATA0 immediately before the arm. This is the one
+ // host lever invisible to every "host exhausted" test: usbmon logs payloads, not the
+ // toggle bit, and the crypto/timing work never touches it. DLM (libusb async URBs) and
+ // vino (in-kernel blocking bulk_send) can reach the arm with EP02 at *different* parity
+ // after the ~9 preceding OUT transfers (7 cap-announce + arm) -- a mismatch makes the
+ // dock's SIE ACK the packet at the link layer (byte-identical on the wire) yet discard
+ // the payload as a duplicate, i.e. "arms clean, silently drops msg0". clear_halt issues
+ // CLEAR_FEATURE(ENDPOINT_HALT), which resets both sides' toggle to DATA0. Every earlier
+ // reset (reset_configuration at the top of bring_up, HARD_RESET, VBUS cycle) reset the
+ // toggle *before* those preceding transfers, so msg0's parity was never pinned. A/B:
+ // flip to `reset_configuration()` to test the heavier reset at the same call site.
+ // RESULT 2026-06-16 (cold plug vino-cold-20260616-000552): TESTED NEGATIVE.
+ // clear_halt(EP02)
+ // fired (wire shows CLEAR_FEATURE on EP2, dmesg "toggle -> DATA0") yet the dock still gave
+ // sub=0x45_acks=0. The toggle was NOT the gate. Left default-OFF so vino doesn't carry an
+ // EP02 CLEAR_FEATURE that DLM never sends (would pollute future paired diffs); flip to
+ // test.
+ // Sibling result: EP02 wMaxPacketSize logged = 1024, so a 64-byte msg0/arm always
+ // terminates
+ // as a natural short packet -- the ZLP-trap hypothesis is moot too.
+ const CLEAR_HALT_BEFORE_ARM: bool = false;
+ if CLEAR_HALT_BEFORE_ARM {
+ match dev.clear_halt(EP_CTRL_OUT) {
+ Ok(()) => pr_info!("vino: EP02 clear_halt before arm OK (toggle -> DATA0)\n"),
+ Err(e) => pr_info!("vino: EP02 clear_halt before arm non-fatal ({e:?})\n"),
+ }
+ }
+
+ // Submit the arm marker. Async path: queue it and DO NOT flush -- leave it in flight so
+ // msg0 can be submitted right behind it (the pipelined arm->msg0 burst DLM does). Sync
+ // path: the original blocking send.
+ let arm_res = match out_q.as_mut() {
+ Some(q) => q.send(&STREAM_OPEN, timeout()),
+ None => dev.bulk_send(EP_CTRL_OUT, &STREAM_OPEN, timeout()).map(|_| ()),
+ };
+ if let Err(e) = arm_res {
+ pr_err!("vino: CP stream-open marker FAILED ({e:?})\n");
+ return Err(e);
+ }
+ pr_info!("vino: CP stream-open arm marker sent\n");
+
+ // No artificial arm->msg0 pad. The shared engine (decompiled mac/Windows drivers) is
+ // event-driven and never wall-clock-paces this gap; vino sends msg0 ~0.06 ms after the arm
+ // (vs DLM's ~0.18 ms libusb gap) and the dock's acceptance window is ms-scale, so the
+ // sub-ms lead is immaterial -- confirmed not a gate by the firmware-wall verdict. (Was a
+ // 150 us fsleep copied from DLM's usbmon spacing.)
+
+ // LIVE CP msg0: protocol-fixed header `id=0x14 sub=0x00 ctr=0x08`, 14 zero bytes, then a
+ // fresh host-random 10-byte token (the dock does not validate or echo it), sealed under
+ // THIS session's ks/riv with a live Dl3Cmac. This is the decisive engagement probe: a
+ // `wsub=0x45` reply would mean the cipher engaged on a live session.
+ let mut content = [0u8; 32];
+ content[0..2].copy_from_slice(&0x0014u16.to_le_bytes()); // id=0x14
+ content[4..8].copy_from_slice(&8u32.to_le_bytes()); // ctr=0x08 (sub=0x00 stays zero)
+ rng::fill(&mut content[22..32]); // host-random token
+ let body_len = content.len() + 16; // AES-CTR ciphertext + 16-byte Dl3Cmac
+ let size = ((16 + body_len) - 4) as u16;
+ let aux = cp::aux_for_id(0x14, body_len);
+ let mut hdr = [0u8; 16];
+ hdr[2..4].copy_from_slice(&size.to_le_bytes());
+ hdr[4..8].copy_from_slice(&4u32.to_le_bytes()); // type=4
+ hdr[8..10].copy_from_slice(&0x24u16.to_le_bytes()); // sub=0x24 (interactive CP)
+ hdr[10..12].copy_from_slice(&aux.to_le_bytes());
+ // hdr[12..16] = wire_seq = 0 (first CP block)
+ let frame = cp::seal_livemac(&session.ks, &session.riv, &hdr, &content)?;
+
+ let mut ok = false;
+ if let Some(q) = out_q.as_mut() {
+ // Async path: submit msg0 right behind the still-in-flight arm (pipelined burst),
+ // then drain EP84 while the HCD auto-retries any NAK against the live URB. Reap both
+ // OUT transfers; a flush timeout just means the dock NAK'd msg0 (URB killed at drop).
+ match q.send(&frame, timeout()) {
+ Ok(()) => {
+ ok = true;
+ pr_info!("vino: live CP msg0 submitted async (pipelined behind arm)\n");
+ }
+ Err(e) => pr_info!("vino: live CP msg0 async submit failed ({e:?})\n"),
+ }
+ for _ in 0..8 {
+ let (d, a) = Self::drain_ep84(dev, ep84_q.as_mut(), &mut resp, session, edid_out);
+ drained += d;
+ acks += a;
+ }
+ match q.flush(Delta::from_millis(200)) {
+ Ok(()) => pr_info!("vino: async arm+msg0 reaped OK (both transfers completed)\n"),
+ Err(e) => pr_info!("vino: async arm+msg0 reap incomplete ({e:?}) -- dock NAK'd\n"),
+ }
+ } else {
+ // Sync path: single-packet msg0 => a NAK transfers nothing, so cancel+retry is safe.
+ // Between attempts drain EP84 so the dock can push/drain its IN queue. Bounded.
+ const TRIES: usize = 40;
+ for t in 0..TRIES {
+ match dev.bulk_send(EP_CTRL_OUT, &frame, Delta::from_millis(5)) {
+ Ok(_) => {
+ ok = true;
+ pr_info!("vino: live CP msg0 ACCEPTED after {t} interleaved tries\n");
+ break;
+ }
+ // OUT NAK'd (nothing transferred) -- let the dock push on EP84, then retry.
+ Err(_) => {
+ let (d, a) =
+ Self::drain_ep84(dev, ep84_q.as_mut(), &mut resp, session, edid_out);
+ drained += d;
+ acks += a;
+ }
+ }
+ }
+ }
+ if ok {
+ sent += 1;
+ pr_info!("vino: live CP msg0 sent (id=0x14 ctr=8, random token, live seal)\n");
+ } else {
+ pr_info!("vino: live CP msg0 still NAK'd (no transfer accepted)\n");
+ }
+
+ // DLM sends the `0x24 wValue=0` render/commit vendor request right after msg0.
+ match dev.control_send(0x24, 0x40 /* VENDOR_OUT */, 0, 0, &[], timeout()) {
+ Ok(()) => pr_info!("vino: post-msg0 0x24(wValue=0) OK\n"),
+ Err(e) => pr_info!("vino: post-msg0 0x24(wValue=0) non-fatal ({e:?})\n"),
+ }
+ // DLM then re-reads the 0x22 vendor state (0xc1, wValue=1, wIndex=0, 28 B) -- its SECOND
+ // 0x22 of the session, immediately after the post-msg0 0x24. vino issued the first 0x22
+ // pre-arm but stopped here, leaving "DLM-ONLY 0x22" in the paired diff. Issue it
+ // unconditionally so the wire matches DLM regardless of whether the dock acks; it is a
+ // harmless vendor IN read. (0xc1 = IN|vendor|INTERFACE recipient, matching the first 0x22.)
+ let mut state2 = [0u8; 28];
+ match dev.control_recv(0x22, 0xc1, 1, 0, &mut state2, timeout()) {
+ Ok(()) => pr_info!("vino: post-msg0 0x22(wValue=1) OK = {:02x?}\n", state2),
+ Err(e) => pr_info!("vino: post-msg0 0x22(wValue=1) non-fatal ({e:?})\n"),
+ }
+
+ // Read the dock's reply: a `wsub=0x45` ack means the cipher engaged on our live frame.
+ let (d, a, _m) = Self::lockstep_reply(dev, ep84_q.as_mut(), &mut resp, session, 0x08, edid_out);
+ drained += d;
+ acks += a;
+
+ const MAX_ROUNDS: usize = 16;
+ for _ in 0..MAX_ROUNDS {
+ let (d, a) = Self::drain_ep84(dev, ep84_q.as_mut(), &mut resp, session, edid_out);
+ drained += d;
+ acks += a;
+ if d == 0 {
+ break;
+ }
+ }
+
+ // ---- Post-engagement live setup (CP-HANDSHAKE.md sec 4f/sec 4e) ------------------------
+ // Only meaningful once the dock has acked msg0: ask the dock for the downstream EDID,
+ // then build the mode-set from its preferred timing and send that -- the live path that
+ // replaces the static 1080p modeset and the opportunistic-only EDID capture. On a cold
+ // dock `acks` stays 0 (the wall), so this does not run on current hardware; it completes
+ // the standalone live-generation flow for when the engagement gate is solved.
+ // The next free AES-CTR block index past this setup, handed to the DRM device so runtime
+ // KMS sends (mode-set/cursor) continue the same keystream. Defaults to msg0's end (2) when
+ // the live block below doesn't run (no acks) -- irrelevant then, since we only publish the
+ // session when `acks > 0`.
+ let mut wire_seq_end = 2u32;
+ if acks > 0 {
+ // `wseq` continues the AES-CTR block counter past msg0 (32 B content = 2 blocks);
+ // the inner `counter` continues past msg0's ctr=8. The dock echoes both, so the
+ // exact values only need to stay monotonic / non-overlapping for the keystream.
+ let mut wseq = 2u32;
+
+ // (1) Live get-EDID request -> the dock replies id=0x194; `drain_ep84` (called inside
+ // `send_live_cp`) decodes it and fills `edid_out` via `parse_edid_from_reply`.
+ if let Ok(req) = cp::get_edid_req(9) {
+ match Self::send_live_cp(
+ dev, session, ep84_q.as_mut(), &mut resp, edid_out, 0x15, wseq, &req,
+ ) {
+ Ok((ok, d, a)) => {
+ drained += d;
+ acks += a;
+ wseq = wseq.wrapping_add(((req.len() + 15) / 16) as u32);
+ pr_info!("vino: live get-EDID request {}\n",
+ if ok { "sent (id=0x15 sub=0x21)" } else { "NAK'd" });
+ }
+ Err(e) => pr_info!("vino: live get-EDID request failed ({e:?})\n"),
+ }
+ }
+
+ // (2) Dynamic mode-set from the dock's EDID preferred detailed timing, falling back to
+ // the known-good UHD_60 timing when no EDID/DTD is available.
+ let from_edid = edid_out.is_some();
+ let timing = edid_out
+ .as_deref()
+ .and_then(cp::timing_from_edid)
+ .unwrap_or(cp::Timing::UHD_60);
+ match cp::set_mode(10, &timing) {
+ Ok(smode) => {
+ // `set_mode` reserves a trailing 16-byte tag region; `seal_livemac` appends a
+ // fresh live Dl3Cmac, so hand it the inner content without that region.
+ let content = &smode[..smode.len().saturating_sub(16)];
+ match Self::send_live_cp(
+ dev, session, ep84_q.as_mut(), &mut resp, edid_out, 0x48, wseq, content,
+ ) {
+ Ok((ok, d, a)) => {
+ drained += d;
+ acks += a;
+ pr_info!("vino: live mode-set {} ({}x{}@{} from {})\n",
+ if ok { "sent" } else { "NAK'd" },
+ timing.hactive, timing.vactive, timing.refresh_hz,
+ if from_edid { "EDID" } else { "fallback" });
+ }
+ Err(e) => pr_info!("vino: live mode-set failed ({e:?})\n"),
+ }
+ // Advance the keystream past this mode-set so runtime KMS sends continue it.
+ wseq = wseq.wrapping_add(((content.len() + 15) / 16) as u32);
+ }
+ Err(e) => pr_info!("vino: mode-set build failed ({e:?})\n"),
+ }
+ wire_seq_end = wseq;
+ }
+
+ let engaged = if acks > 0 { "dock engaged" } else { "dock ignoring our CP (the wall)" };
+ pr_info!("vino: CP setup sent={sent} EP84_resp={drained} sub=0x45_acks={acks} ({engaged})\n");
+ // Inner counter past the bring-up CP messages (msg0=8, get-EDID=9, mode-set=10).
+ Ok((sent, acks, wire_seq_end, 11))
+ }
+
+
+ /// Seal `content` (inner CP plaintext, WITHOUT the 16-byte tag region) into a live
+ /// `type=4 sub=0x24` frame at `wire_seq`, send it on EP02 with EP84 drained between NAK
+ /// retries (the single-packet interleave discipline msg0 uses), then drain once more to
+ /// collect the dock's reply. `id` selects the DLM-exact `aux` header field
+ /// ([`cp::aux_for_id`]). Returns `(sent_ok, ep84_reads, sub=0x45_acks)`. Used for the
+ /// post-engagement live messages (get-EDID, mode-set) once the dock has acked msg0.
+ fn send_live_cp(
+ dev: &usb::Device,
+ session: &Session,
+ mut q: Option<&mut usb::BulkInQueue>,
+ resp: &mut [u8],
+ edid_out: &mut Option<KVec<u8>>,
+ id: u16,
+ wire_seq: u32,
+ content: &[u8],
+ ) -> Result<(bool, usize, usize)> {
+ let frame = cp::seal_interactive(&session.ks, &session.riv, id, wire_seq, content)?;
+
+ // Single-packet OUT: a NAK transfers nothing, so cancel+retry is safe. Between attempts
+ // drain EP84 so the dock can push/drain its IN queue (matches msg0's behaviour).
+ const TRIES: usize = 40;
+ let mut ok = false;
+ let mut drained = 0usize;
+ let mut acks = 0usize;
+ for _ in 0..TRIES {
+ match dev.bulk_send(EP_CTRL_OUT, &frame, Delta::from_millis(5)) {
+ Ok(_) => {
+ ok = true;
+ break;
+ }
+ Err(_) => {
+ let (d, a) = Self::drain_ep84(dev, q.as_deref_mut(), resp, session, edid_out);
+ drained += d;
+ acks += a;
+ }
+ }
+ }
+ // Collect the dock's reply (the get-EDID id=0x194 frame is captured here via drain_ep84).
+ let (d, a) = Self::drain_ep84(dev, q.as_deref_mut(), resp, session, edid_out);
+ drained += d;
+ acks += a;
+ Ok((ok, drained, acks))
+ }
+
+
+ /// sec 5 read-only diagnostic: log one dock->host EP84 frame's wire header
+ /// (`type`@4, `sub`@8, `aux`@10, `seq`@12) and, when the body decrypts under the IN
+ /// keystream, its inner `(id, sub, ictr)`. Surfaces EVERY frame the dock returns --
+ /// not just `sub=0x45` -- so a hardware run reveals whether the dock is mute, NAKing,
+ /// or replying with an unexpected sub. Pure logging; no state change.
+ fn log_ep84(session: &Session, frame: &[u8]) {
+ let len = frame.len();
+ let wtype = if len >= 8 {
+ u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]])
+ } else {
+ 0
+ };
+ let wsub = if len >= 10 { u16::from_le_bytes([frame[8], frame[9]]) } else { 0 };
+ let aux = if len >= 12 { u16::from_le_bytes([frame[10], frame[11]]) } else { 0 };
+ let wseq = if len >= 16 {
+ u32::from_le_bytes([frame[12], frame[13], frame[14], frame[15]])
+ } else {
+ 0
+ };
+ {
+ // Dev diagnostic (pr_debug, compiled out unless dynamic debug is enabled): the raw
+ // wire, so the dock's pushes can be offline-decoded. The dock's large capability block
+ // (~5787 B) must be dumped in 128-byte CHUNKS, because a single hex print of a
+ // >~250-byte
+ // array exceeds printk's per-line limit. Capped at 768 B (6 lines) to avoid flooding.
+ let cap = len.min(768);
+ if cap <= 64 {
+ let raw = &frame[..cap];
+ pr_debug!("vino: dock EP84 RAW {len}B {raw:02x?}\n");
+ } else {
+ pr_debug!("vino: dock EP84 RAW {len}B (first {cap} B in 128-B chunks):\n");
+ let mut o = 0usize;
+ while o < cap {
+ let e = (o + 128).min(cap);
+ let chunk = &frame[o..e];
+ pr_debug!("vino: ep84[{o:#06x}] {chunk:02x?}\n");
+ o = e;
+ }
+ }
+ }
+ match cp::decode_any(&session.ks, &session.riv, frame) {
+ Some((rivtag, rid, rsub, rictr, sample)) => {
+ pr_info!(
+ "vino: dock EP84 type={wtype} wsub={wsub:#x} aux={aux:#x} seq={wseq:#x} {len}B -> [{rivtag}] id={rid:#x} sub={rsub:#x} ictr={rictr:#x} pt={sample:02x?}\n"
+ );
+ }
+ None => {
+ pr_info!(
+ "vino: dock EP84 type={wtype} wsub={wsub:#x} aux={aux:#x} seq={wseq:#x} {len}B (no inner decode)\n"
+ );
+ }
+ }
+ }
+
+ /// Read one EP84 frame: from the persistent async queue `q` when [`CP_ASYNC_EP84`] has opened
+ /// one, else a synchronous `bulk_recv`. The queue's timeout (`Ok(None)`) is mapped to
+ /// `Err(ETIMEDOUT)` so the callers' existing match arms (which treat any `Err`/empty as
+ /// "no more data right now") work unchanged across both paths.
+ fn read_ep84(
+ dev: &usb::Device,
+ q: Option<&mut usb::BulkInQueue>,
+ buf: &mut [u8],
+ to: Delta,
+ ) -> Result<usize> {
+ match q {
+ Some(queue) => match queue.recv(buf, to) {
+ Ok(Some(n)) => Ok(n),
+ Ok(None) => Err(ETIMEDOUT),
+ Err(e) => Err(e),
+ },
+ None => dev.bulk_recv(EP_CTRL_IN, buf, to),
+ }
+ }
+
+
+ fn drain_ep84(
+ dev: &usb::Device,
+ mut q: Option<&mut usb::BulkInQueue>,
+ buf: &mut [u8],
+ session: &Session,
+ edid_out: &mut Option<KVec<u8>>,
+ ) -> (usize, usize) {
+ const MAX_READS: usize = 16;
+ let mut n = 0usize;
+ let mut acks = 0usize;
+ // Read EP84 FIRST (the dock answers in ~0.14 ms, same as it does for DLM). The EP83 status
+ // poll is serviced AFTER -- polling it before the EP84 read blocked the critical path for
+ // up
+ // to 30 ms PER cap frame (timeline diff 2026-06-11: vino's cap phase was 446 ms / ~32 ms
+ // per
+ // frame vs DLM's 60 ms / 0.14 ms, purely from this ordering), arming the dock ~1 s late.
+ for _ in 0..MAX_READS {
+ match Self::read_ep84(dev, q.as_deref_mut(), buf, Delta::from_millis(10)) {
+ Ok(len) if len > 0 => {
+ n += 1;
+ // sec 5 diagnostic: surface EVERY dock->host frame, not just `sub=0x45`,
+ // so a hardware run shows what the dock actually returns (a different
+ // sub, a NAK, or plaintext) instead of a bare `EP84_resp=N` count.
+ Self::log_ep84(session, &buf[..len]);
+ if len >= 10 && u16::from_le_bytes([buf[8], buf[9]]) == 0x45 {
+ acks += 1;
+ // Capture the dock's EDID the first time it appears (id=0x94
+ // sub=0x21 reply to the replayed get-EDID request). Reuses the
+ // standard DRM EDID infra in get_modes. See CONTROL-PLANE.md.
+ if edid_out.is_none() {
+ if let Ok(Some(e)) =
+ cp::parse_edid_from_reply(&session.ks, &session.riv, &buf[..len])
+ {
+ pr_info!("vino: EDID read from dock ({} bytes)\n", e.len());
+ *edid_out = Some(e);
+ }
+ }
+ }
+ }
+ _ => break,
+ }
+ }
+ // Service EP83 AFTER draining EP84, so it never delays reading the dock's CP reply.
+ if Self::POLL_EP83_DURING_BRINGUP {
+ Self::poll_ep83(dev);
+ }
+ (n, acks)
+ }
+
+
+ /// Lockstep counterpart to [`drain_ep84`]: after one CP OUT, drain EP84 until the
+ /// `sub=0x45` reply whose **inner counter echoes** `ictr` arrives (DLM's 1:1
+ /// handshake) or the short read budget elapses. Any async
+ /// pushes seen meanwhile are still counted and scanned for the EDID. Returns
+ /// `(reads, acks, matched)`.
+ fn lockstep_reply(
+ dev: &usb::Device,
+ mut q: Option<&mut usb::BulkInQueue>,
+ buf: &mut [u8],
+ session: &Session,
+ ictr: u16,
+ edid_out: &mut Option<KVec<u8>>,
+ ) -> (usize, usize, bool) {
+ const MAX_READS: usize = 8;
+ let in_riv = cp::in_riv(&session.riv);
+ let mut reads = 0usize;
+ let mut acks = 0usize;
+ let mut matched = false;
+ for _ in 0..MAX_READS {
+ match Self::read_ep84(dev, q.as_deref_mut(), buf, Delta::from_millis(30)) {
+ Ok(len) if len > 16 => {
+ reads += 1;
+ // sec 5 diagnostic: log every frame the dock returns in the lockstep
+ // window -- including the non-`0x45` frames we otherwise skip -- so the
+ // divergence point is paired with the dock's actual reply on the wire.
+ Self::log_ep84(session, &buf[..len]);
+ if u16::from_le_bytes([buf[8], buf[9]]) != 0x45 {
+ continue;
+ }
+ acks += 1;
+ let seq = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
+ // Decrypt just the first block to read the inner counter (off 4).
+ let head = &buf[16..len.min(32)];
+ if let Ok(inner) = cp::open_in(&session.ks, &in_riv, seq, head) {
+ if inner.len() >= 6
+ && u16::from_le_bytes([inner[4], inner[5]]) == ictr
+ {
+ matched = true;
+ }
+ }
+ // Opportunistically capture the EDID (id=0x94 reply, off 22).
+ if edid_out.is_none() {
+ if let Ok(Some(e)) =
+ cp::parse_edid_from_reply(&session.ks, &session.riv, &buf[..len])
+ {
+ pr_info!("vino: EDID read from dock ({} bytes)\n", e.len());
+ *edid_out = Some(e);
+ }
+ }
+ if matched {
+ break;
+ }
+ }
+ _ => break,
+ }
+ }
+ (reads, acks, matched)
+ }
}
kernel::usb_device_table!(
--
2.54.0
^ permalink raw reply related [flat|nested] 12+ messages in thread
* [RFC PATCH 4/7] drm/vino: add the Vino (RawRl mode-2) framebuffer codec
2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
` (2 preceding siblings ...)
2026-06-17 15:12 ` [RFC PATCH 3/7] drm/vino: add the AES-CTR/AES-CMAC control-plane seal and arm Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
2026-06-17 15:12 ` [RFC PATCH 5/7] drm/vino: register a DRM/KMS device and scan out to EP08 Mike Lothian
` (3 subsequent siblings)
7 siblings, 0 replies; 12+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
To: dri-devel
Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
linux-kernel
Add the video module: the RawRl ("Raw/RLX" mode-2) encoder, clean-room
from the AArch64 reference-driver decompile, which emits packed-RGB565
frames the dock decodes without the impractical Vino Walsh-Hadamard
entropy codec. The encode/decode round-trip is unit-tested (keyframe,
differential, >256-pixel multi-block and >255 RLE run-splits all
reconstruct byte-exact); that round-trip is the correctness anchor, since
no real mode-2 capture exists to diff against.
This is the codec library only; the DRM/KMS sink that drives it (vmap the
framebuffer, encode, push to the EP08 video endpoint on each page-flip)
is added in the next patch.
Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
drivers/gpu/drm/vino/video.rs | 348 ++++++++++++++++++++++++++++++++++
drivers/gpu/drm/vino/vino.rs | 1 +
2 files changed, 349 insertions(+)
create mode 100644 drivers/gpu/drm/vino/video.rs
diff --git a/drivers/gpu/drm/vino/video.rs b/drivers/gpu/drm/vino/video.rs
new file mode 100644
index 000000000000..bb5ea893575f
--- /dev/null
+++ b/drivers/gpu/drm/vino/video.rs
@@ -0,0 +1,348 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! RawRl (Raw/RLX) **mode-2 video encoder** -- clean-room from the AArch64 DLM
+//! decompile (sec 8.4 + `docs/decompile/arm64-blockencoder`/`-frame-markers`).
+//! Emits packed-RGB565 frames the dock decodes WITHOUT the impractical Vino
+//! Walsh-Hadamard entropy codec (sec 7.11). This is a **verbatim port** of the
+//! `vino-codec::rawrl` oracle, whose encode/decode round-trip is unit-tested
+//! (keyframe, differential, >256-pixel multi-block and >255 RLE run-splits all
+//! reconstruct byte-exact); keep the two in lockstep. No real mode-2 capture
+//! exists to diff against (sec 7.4), so that round-trip is the correctness anchor.
+//! NOT yet wired into `probe()`: sending a frame the dock rejects USB-resets the
+//! dock, so EP08 streaming is a supervised bring-up step.
+#![allow(dead_code)] // Encoder/Mode variants validated by KUnit; live scanout uses the RLE path
+
+use super::*;
+
+pub(super) const MAGIC_RAW16: u16 = 0x68af;
+pub(super) const MAGIC_RLE16: u16 = 0x69af;
+/// Frame-init `0x40af` (`FUN_003330fc`: u32 `0xaf0440af` + u16 `0x0840`).
+pub(super) const FRAME_INIT: [u8; 6] = [0xaf, 0x40, 0x04, 0xaf, 0x40, 0x08];
+/// Bare `0xa0af` sync (`FUN_00332a38`).
+pub(super) const SYNC: [u8; 2] = [0xaf, 0xa0];
+/// Frame-end section->code table `DAT_005b7860`, indexed by `mode - 1`.
+pub(super) const SECTION_CODE: [u8; 7] = [0x01, 0x00, 0x03, 0x00, 0x05, 0x07, 0x07];
+pub(super) const MAX_BLOCK_PIXELS: usize = 256;
+
+/// Per-run strategy: mode 0 raw-only, 1 RLE-only, 2 adaptive (sec 8.4).
+#[derive(Clone, Copy)]
+pub(super) enum Mode {
+ Raw = 0,
+ Rle = 1,
+ Adaptive = 2,
+}
+
+/// Pack 8-bit RGB into RGB565 (the XRGB framebuffer reduced for the
+/// `0x68af`/`0x69af` path).
+pub(super) fn rgb565(r: u8, g: u8, b: u8) -> u16 {
+ ((r as u16 >> 3) << 11) | ((g as u16 >> 2) << 5) | (b as u16 >> 3)
+}
+
+/// 6-byte block header: magic LE, 24-bit coord BE, count u8 (256 -> 0).
+fn block_header(out: &mut KVec<u8>, magic: u16, coord: u32, count: usize) -> Result {
+ out.extend_from_slice(&magic.to_le_bytes(), GFP_KERNEL)?;
+ out.push(((coord >> 16) & 0xff) as u8, GFP_KERNEL)?;
+ out.push(((coord >> 8) & 0xff) as u8, GFP_KERNEL)?;
+ out.push((coord & 0xff) as u8, GFP_KERNEL)?;
+ out.push((count & 0xff) as u8, GFP_KERNEL)?;
+ Ok(())
+}
+
+fn encode_raw_into(out: &mut KVec<u8>, coord: u32, pix: &[u16]) -> Result {
+ block_header(out, MAGIC_RAW16, coord, pix.len())?;
+ for &p in pix {
+ out.extend_from_slice(&p.to_be_bytes(), GFP_KERNEL)?;
+ }
+ Ok(())
+}
+
+fn encode_rle_into(out: &mut KVec<u8>, coord: u32, pix: &[u16]) -> Result {
+ block_header(out, MAGIC_RLE16, coord, pix.len())?;
+ let mut i = 0;
+ while i < pix.len() {
+ let v = pix[i];
+ let mut run = 1;
+ while i + run < pix.len() && pix[i + run] == v && run < 255 {
+ run += 1;
+ }
+ out.push(run as u8, GFP_KERNEL)?;
+ out.extend_from_slice(&v.to_be_bytes(), GFP_KERNEL)?;
+ i += run;
+ }
+ Ok(())
+}
+
+fn run_count(pix: &[u16]) -> usize {
+ let mut c = 0;
+ let mut i = 0;
+ while i < pix.len() {
+ let v = pix[i];
+ let mut j = i + 1;
+ while j < pix.len() && pix[j] == v {
+ j += 1;
+ }
+ c += 1;
+ i = j;
+ }
+ c
+}
+
+fn encode_run_into(out: &mut KVec<u8>, mode: Mode, coord: u32, pix: &[u16]) -> Result {
+ match mode {
+ Mode::Raw => encode_raw_into(out, coord, pix),
+ Mode::Rle => encode_rle_into(out, coord, pix),
+ Mode::Adaptive => {
+ let l = pix.len();
+ let c = run_count(pix);
+ if 2 * l < 3 * c + 1 {
+ encode_raw_into(out, coord, pix)
+ } else {
+ encode_rle_into(out, coord, pix)
+ }
+ }
+ }
+}
+
+/// Mode-2 frame encoder holding the shadow (previous-frame) buffer.
+pub(super) struct Encoder {
+ width: usize,
+ height: usize,
+ mode: Mode,
+ // vmalloc-backed: a `width*height` u16 buffer is ~4 MiB at 1080p, far above the
+ // contiguous-kmalloc order limit (the page allocator WARNs and fails on it).
+ shadow: VVec<u16>,
+}
+
+impl Encoder {
+ pub(super) fn new(width: usize, height: usize, mode: Mode) -> Result<Self> {
+ let shadow = VVec::from_elem(0u16, width * height, GFP_KERNEL)?;
+ Ok(Self { width, height, mode, shadow })
+ }
+
+ /// Encode `cur` (RGB565) into a mode-2 marker stream; updates the shadow.
+ /// Change-detection is per row; changed runs chunk into <=256-px blocks.
+ pub(super) fn encode(&mut self, cur: &[u16]) -> Result<KVec<u8>> {
+ let mut s = KVec::new();
+ self.encode_into(cur, &mut s)?;
+ Ok(s)
+ }
+
+ /// Like [`encode`](Self::encode) but appends the marker stream to a caller-owned
+ /// `out` instead of allocating a fresh `KVec`. The hot scanout path
+ /// ([`encode_and_send`](super::drm_sink::encode_and_send)) uses this to encode
+ /// straight into a buffer that already reserves the EP08 transport header, so a
+ /// frame costs one allocation with no separate framing copy.
+ pub(super) fn encode_into(&mut self, cur: &[u16], s: &mut KVec<u8>) -> Result {
+ s.extend_from_slice(&FRAME_INIT, GFP_KERNEL)?;
+ for y in 0..self.height {
+ let row = y * self.width;
+ let mut x = 0;
+ while x < self.width {
+ while x < self.width && cur[row + x] == self.shadow[row + x] {
+ x += 1;
+ }
+ if x >= self.width {
+ break;
+ }
+ let run_start = x;
+ while x < self.width && cur[row + x] != self.shadow[row + x] {
+ x += 1;
+ }
+ let run_end = x;
+ let mut p = run_start;
+ while p < run_end {
+ let n = (run_end - p).min(MAX_BLOCK_PIXELS);
+ let coord = (((row + p) * 2) & 0xff_ffff) as u32;
+ encode_run_into(s, self.mode, coord, &cur[row + p..row + p + n])?;
+ p += n;
+ }
+ for k in run_start..run_end {
+ self.shadow[row + k] = cur[row + k];
+ }
+ }
+ }
+ let code = SECTION_CODE[(self.mode as usize).saturating_sub(1).min(6)];
+ s.extend_from_slice(&SYNC, GFP_KERNEL)?;
+ s.extend_from_slice(&[0xaf, 0x20, 0x1f, code], GFP_KERNEL)?;
+ s.extend_from_slice(&[0xaf, 0x20, 0xff, 0x00], GFP_KERNEL)?;
+ s.extend_from_slice(&SYNC, GFP_KERNEL)?;
+ Ok(())
+ }
+}
+
+/// Vino (`0x2801`) Walsh-Hadamard codec -- the bandwidth-constrained / 4K path (the RLE path
+/// above is what the dock currently runs; this is the lossy transform codec DLM uses when raw/
+/// RLE won't fit the USB budget). See `docs/WHT-CODEC.md` + `docs/VIDEO.md`.
+///
+/// **Scope.** The colour transform, the quantizer, and the 2-level Walsh-Hadamard transform
+/// are reverse-engineered and **validated offline** (`white -> Y_DC=16320 -> quantized 1020`;
+/// achromatic -> `Cb=Cr=0`; uniform block -> DC=mean, AC=0). The token *bit format* (5-bit
+/// short
+/// 0..=30 / 17-bit long, MSB-first) and the **token-value mapping** are confirmed against DLM's
+/// own frida token trace (`captures/02-solid-white/tokens.jsonl`): the **token value is the
+/// quantized coefficient, directly** -- pure-white strips emit `L,1020` exactly where
+/// `quantize(16320, DC) = 1020`, so the rumoured "entropy codebook" is just this direct value
+/// encoding, not a lookup table (the 1641-byte expression-tree coder is the bit-packer). **What
+/// is still NOT generated here:** the per-strip *framing* -- a uniform strip wraps the DC in a
+/// constant prefix/suffix of framing tokens (`L,2048 L,3072 ... L,3 ... S,19 S,16 ...`) plus
+/// zero-run
+/// AC coding, and the dock's exact sequency ordering -- so a complete `Mode::Wht` would replay
+/// the recovered uniform-strip template with the DC substituted (the `docs/WHT-CODEC.md`
+/// structural model, ~90% desktop coverage). Until that framing is generalized + wired, the
+/// scanout path keeps using RLE.
+// Not yet wired into the scanout path (the per-strip framing template is recovered for white
+// but not yet generalized to arbitrary uniform colour / non-uniform content) -- RLE stays the
+// active codec; this module is validated by its KUnit tests + the frida-trace value mapping.
+#[allow(dead_code)] // Walsh-Hadamard codec: KUnit-validated, not yet on the live scanout path
+pub(super) mod wht {
+ use super::*;
+
+ /// 4x8 transform block geometry (`docs/VIDEO.md`): 4 rows x 8 columns = 32 samples.
+ pub(super) const ROWS: usize = 4;
+ pub(super) const COLS: usize = 8;
+ pub(super) const BLOCK: usize = ROWS * COLS;
+
+ /// Vino colour transform (`docs/VIDEO.md`, exact integer form, no rounding):
+ /// `Y = 16R + 32G + 16B`, `Cb = 64(R-G)`, `Cr = 64(B-G)`. Achromatic (R=G=B) ->
+ /// Cb=Cr=0.
+ pub(super) fn colour(r: u8, g: u8, b: u8) -> (i32, i32, i32) {
+ let (r, g, b) = (r as i32, g as i32, b as i32);
+ (16 * r + 32 * g + 16 * b, 64 * (r - g), 64 * (b - g))
+ }
+
+ /// Per-coefficient `(bias, step)` quantization table (`docs/VIDEO.md` `FUN_0077b140`),
+ /// keyed by coefficient position `0..64`.
+ fn bias_step(i: usize) -> (i32, i32) {
+ match i {
+ 0..=2 => (8, 16),
+ 3 => (16, 32),
+ 4..=11 => (2, 4),
+ 12..=15 => (4, 8),
+ 16..=47 => (1, 2),
+ _ => (2, 4), // 48..=63
+ }
+ }
+
+ /// Quantize coefficient `coeff` at position `i`: `(coeff + bias) * (65536/step) >> 16`,
+ /// the fixed-point form of `(coeff + bias) / step` (`docs/VIDEO.md`). Clamped to the
+ /// 12-bit signed long-token range (the DC is wider than the +/-127 AC clip -- the
+ /// documented
+ /// `white -> 1020` vector is a 12-bit long token, not a +/-127 value).
+ pub(super) fn quantize(coeff: i32, i: usize) -> i32 {
+ let (bias, step) = bias_step(i);
+ let scale = 65536 / step;
+ (((coeff + bias) * scale) >> 16).clamp(-2048, 2047)
+ }
+
+ /// In-place 1-D Walsh-Hadamard (natural/Hadamard order) on a power-of-two slice,
+ /// unnormalized (pairwise sums/differences); the 2-D transform normalizes afterwards.
+ fn hadamard_1d(v: &mut [i32]) {
+ let n = v.len();
+ let mut h = 1;
+ while h < n {
+ let mut i = 0;
+ while i < n {
+ for j in i..i + h {
+ let (a, b) = (v[j], v[j + h]);
+ v[j] = a + b;
+ v[j + h] = a - b;
+ }
+ i += 2 * h;
+ }
+ h *= 2;
+ }
+ }
+
+ /// 2-level separable Walsh-Hadamard transform of a 4x8 `block` (row-major), normalized so
+ /// the DC coefficient equals the block **mean** -- i.e. a uniform block yields `DC = the
+ /// per-pixel value` and all AC = 0 (`docs/VIDEO.md`). Returns 32 coefficients row-major.
+ /// (Natural Hadamard order; the dock's sequency reorder is not bit-matched -- see the
+ /// module note.)
+ pub(super) fn transform(block: &[i32; BLOCK]) -> [i32; BLOCK] {
+ let mut m = *block;
+ for r in 0..ROWS {
+ hadamard_1d(&mut m[r * COLS..r * COLS + COLS]);
+ }
+ let mut col = [0i32; ROWS];
+ for c in 0..COLS {
+ for r in 0..ROWS {
+ col[r] = m[r * COLS + c];
+ }
+ hadamard_1d(&mut col);
+ for r in 0..ROWS {
+ m[r * COLS + c] = col[r];
+ }
+ }
+ // Normalize by the block size (/32 = >>5) so DC = mean (uniform block -> DC = value).
+ for x in m.iter_mut() {
+ *x >>= 5;
+ }
+ m
+ }
+
+ /// MSB-first bit packer for the Vino token stream (`docs/VIDEO.md`): a 16-bit zero pad at
+ /// the start, then codewords packed most-significant-bit first across byte boundaries.
+ pub(super) struct TokenWriter {
+ out: KVec<u8>,
+ acc: u32,
+ nbits: u32,
+ }
+
+ impl TokenWriter {
+ pub(super) fn new() -> Result<Self> {
+ let mut w = Self { out: KVec::new(), acc: 0, nbits: 0 };
+ w.put(0, 16)?; // 16-bit zero pad at stream start
+ Ok(w)
+ }
+
+ /// Append the low `n` bits of `val` (n <= 24), MSB-first.
+ fn put(&mut self, val: u32, n: u32) -> Result {
+ self.acc = (self.acc << n) | (val & ((1u32 << n) - 1));
+ self.nbits += n;
+ while self.nbits >= 8 {
+ self.nbits -= 8;
+ self.out.push(((self.acc >> self.nbits) & 0xff) as u8, GFP_KERNEL)?;
+ }
+ Ok(())
+ }
+
+ /// Write one token *value* in the Vino short/long encoding: a 5-bit short token for
+ /// `0..=30`, else the 17-bit long token `0b11111` escape + 12-bit value. (The mapping
+ /// from a quantized coefficient to this `value` is the un-RE'd entropy codebook -- see
+ /// the module note -- so callers can only pack values they already know.)
+ pub(super) fn token(&mut self, value: u16) -> Result {
+ if value <= 30 {
+ self.put(value as u32, 5)
+ } else {
+ self.put(0b11111, 5)?;
+ self.put((value & 0x0fff) as u32, 12)
+ }
+ }
+
+ /// Flush any partial byte (zero-padded) and return the packed stream.
+ pub(super) fn finish(mut self) -> Result<KVec<u8>> {
+ if self.nbits > 0 {
+ let pad = 8 - self.nbits;
+ self.put(0, pad)?;
+ }
+ Ok(self.out)
+ }
+ }
+}
+
+/// Length of the EP08 transport header ([`write_ep08_header`]).
+pub(super) const EP08_HDR_LEN: usize = 16;
+
+/// Write the 16-byte EP08 transport header into `hdr` for a `payload_len`-byte codec
+/// stream: `type=4 sub=0x30 sub_len_dw=0` sec 3 framing (matches the live capture).
+/// `size = payload_len + 12`. Used by the in-place scanout path. `hdr` must be at
+/// least 16 bytes.
+pub(super) fn write_ep08_header(hdr: &mut [u8], payload_len: usize, seq: u32) {
+ hdr[0] = 0;
+ hdr[1] = 0;
+ hdr[2..4].copy_from_slice(&((payload_len + 12) as u16).to_le_bytes());
+ hdr[4..8].copy_from_slice(&4u32.to_le_bytes());
+ hdr[8..10].copy_from_slice(&0x30u16.to_le_bytes());
+ hdr[10..12].copy_from_slice(&0u16.to_le_bytes());
+ hdr[12..16].copy_from_slice(&seq.to_le_bytes());
+}
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index ef44a625cb70..e9e6324b717b 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -86,6 +86,7 @@ fn timeout() -> Delta {
mod ake;
mod golden;
mod cp;
+mod video;
/// The shared secrets a completed HDCP 2.2 AKE leaves behind: the SKE session key
/// `ks` and content IV `riv` key the AES-CTR control plane (sec 6), and `kd` is kept
--
2.54.0
^ permalink raw reply related [flat|nested] 12+ messages in thread
* [RFC PATCH 5/7] drm/vino: register a DRM/KMS device and scan out to EP08
2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
` (3 preceding siblings ...)
2026-06-17 15:12 ` [RFC PATCH 4/7] drm/vino: add the Vino (RawRl mode-2) framebuffer codec Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
2026-06-17 15:12 ` [RFC PATCH 6/7] drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info Mike Lothian
` (2 subsequent siblings)
7 siblings, 0 replies; 12+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
To: dri-devel
Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
linux-kernel
Add the drm_sink module: register a real struct drm_device with a
hand-rolled atomic mode-setting pipeline so the dock appears to userspace
as a mode-settable card/renderD node -- one CRTC driven by a primary
plane and a cursor plane, a virtual encoder, and a virtual connector
whose mode list comes from the dock's real EDID (falling back to 1080p),
with GEM-shmem dumb buffers and drm_gem_fb_create framebuffers.
probe() now allocates and registers the DRM device on the control
interface; the bring-up work item caches the dock EDID on it and, once
(if) the CP engages, publishes the live session so the KMS callbacks can
emit runtime CP (mode-set on a modeset, cursor on motion). On every
page-flip the primary plane's update vmaps the framebuffer, encodes it
with the Vino codec and pushes it to the EP08 video endpoint, gated on
CP_ENGAGED so frames are only sent once the dock's cipher is live.
Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
drivers/gpu/drm/vino/drm_sink.rs | 1333 ++++++++++++++++++++++++++++++
drivers/gpu/drm/vino/vino.rs | 145 +++-
2 files changed, 1458 insertions(+), 20 deletions(-)
create mode 100644 drivers/gpu/drm/vino/drm_sink.rs
diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
new file mode 100644
index 000000000000..afbf883fba36
--- /dev/null
+++ b/drivers/gpu/drm/vino/drm_sink.rs
@@ -0,0 +1,1333 @@
+// SPDX-License-Identifier: GPL-2.0
+
+//! Phase 3 (DRM/KMS sink): register a real `struct drm_device` with a full atomic
+//! mode-setting pipeline so the dock appears to userspace as a `card`/`renderD` node
+//! that can be `drmModeSetCrtc`'d. A hand-rolled atomic pipeline: one CRTC driven by a
+//! primary plane (`primary_atomic_update` -> EP08 scanout) and a cursor plane
+//! (`cursor_atomic_update` -> cursor CP), a virtual encoder, and a virtual connector whose
+//! mode list comes from the dock's real EDID (falling back to 1080p), with GEM-shmem dumb
+//! buffers and `drm_gem_fb_create` framebuffers. (Earlier this was `drm_simple_display_pipe`,
+//! swapped out because that helper is primary-plane-only and can't carry a cursor plane.)
+//! The scanout/cursor writes are gated on the CP-arming blocker (see `docs/BLOCKER.md`). The
+//! KMS C bindings are pulled in by
+//! `patches/drm/0001` (bindings_helper.h headers + the `Driver::FEAT_MODESET` /
+//! `Driver::FEAT_ATOMIC` flags + a public `Device::as_raw`); see `patches/README.md`.
+
+use core::ptr;
+use kernel::{
+ bindings, drm,
+ error::{
+ code::{EINVAL, ENOMEM},
+ to_result,
+ },
+ prelude::*,
+ sync::{aref::ARef, new_mutex, Mutex},
+ types::Opaque,
+};
+
+/// Fallback connector mode advertised by `get_modes` when the dock has not delivered a real
+/// downstream EDID yet. The live scanout geometry follows the actual framebuffer/negotiated
+/// mode (see `scanout_one`), so this is only the no-EDID default, not a hard scanout limit.
+const FALLBACK_W: i32 = 1920;
+const FALLBACK_H: i32 = 1080;
+
+/// The DRM driver marker type.
+pub(super) struct VinoDrmDriver;
+
+/// Convenience alias for our concrete `drm::Device`.
+pub(super) type VinoDrmDevice = drm::Device<VinoDrmDriver>;
+
+/// `DRM_FORMAT_XRGB8888` (`fourcc_code('X','R','2','4')`); the dock scans out 32bpp.
+const DRM_FORMAT_XRGB8888: u32 = 0x3432_5258;
+/// `DRM_FORMAT_ARGB8888` (`fourcc_code('A','R','2','4')`); the cursor sprite carries alpha.
+const DRM_FORMAT_ARGB8888: u32 = 0x3432_5241;
+/// Primary-plane format list (opaque 32bpp scanout).
+static PRIMARY_FORMATS: [u32; 1] = [DRM_FORMAT_XRGB8888];
+/// Cursor-plane format list (alpha sprite). ARGB8888 little-endian memory order is
+/// `B,G,R,A` per pixel -- already the BGRA byte layout `cp::cursor_image` wants.
+static CURSOR_FORMATS: [u32; 1] = [DRM_FORMAT_ARGB8888];
+/// Hardware cursor sprite size (the dock cursor is 64x64; `DRM_CAP_CURSOR_WIDTH/HEIGHT`).
+const CURSOR_SIZE: u32 = 64;
+/// `GAMMA_LUT` size advertised on the CRTC. 256 entries matches the 8-bit scanout channels;
+/// the LUT is applied host-side in the scanout conversion (see `read_gamma_lut`).
+const GAMMA_SIZE: u32 = 256;
+
+/// Per-mode pixel-clock ceiling (kHz) for a single head -- about 4K@60 (CEA 594 MHz).
+/// `mode_valid` prunes any single mode above this from a connector's advertised list.
+const MAX_HEAD_CLOCK_KHZ: i32 = 600_000;
+/// Combined pixel-clock budget (kHz) summed over all *active* heads -- the dock's DL3 link
+/// ceiling. This is a deliberately conservative *raw* pixel-rate proxy (DisplayLink compresses
+/// the stream, so the true USB budget is higher and content-dependent; the WHT codec exists for
+/// the tight cases). At 1 GHz it admits one 4K@60, 4K@60 + QHD@60, or dual-QHD@60, and rejects
+/// dual-4K -- matching the D6000's real multi-monitor envelope. Tune to taste / per dock
+/// model.
+const MAX_TOTAL_CLOCK_KHZ: i64 = 1_000_000;
+
+/// Mutable scanout state, guarded because the atomic `update` callback may run
+/// concurrently with itself across heads. Holds the stateful Vino encoder (created
+/// lazily on the first flip, once the buffer geometry is known) and the EP08 frame
+/// sequence counter.
+pub(super) struct ScanoutState {
+ enc: Option<super::video::Encoder>,
+ /// Reusable `width*height` RGB565 conversion buffer, allocated once alongside `enc`.
+ /// Previously `encode_and_send` did a fresh `KVec::with_capacity(w*h)` on every pageflip;
+ /// at 1080p that is a ~4 MiB *contiguous* kmalloc (order 11), above the allocator's limit,
+ /// so the page allocator WARNed and returned `ENOMEM` every frame. vmalloc-backed +
+ /// persistent: virtually-contiguous (no high-order page need) and allocated once.
+ cur: VVec<u16>,
+ seq: u32,
+ /// Geometry (`width`, `height`) the encoder/`cur` were allocated for. The scanout follows
+ /// the live framebuffer size, so a mode switch re-allocates them when this no longer
+ /// matches.
+ dims: (usize, usize),
+ /// The [`super::cp::Timing`] of the mode the compositor last enabled on the CRTC, captured
+ /// in [`crtc_atomic_enable`] via [`super::cp::timing_from_drm_mode`]. This is the
+ /// multi-mode hook:
+ /// userspace can pick *any* mode from the EDID-derived list and the chosen
+ /// `drm_display_mode`
+ /// is recorded here so the live mode-set CP message reflects it (rather than always the
+ /// EDID-preferred timing). The CP send itself is gated on the engagement wall + session
+ /// plumbing (see the doc note in `crtc_atomic_enable`).
+ active_timing: Option<super::cp::Timing>,
+ /// Size of the last EP08 frame produced, used to pre-reserve the next frame's
+ /// buffer. The encoded stream size is stable frame-to-frame (it tracks the damage
+ /// area), so seeding `KVec::with_capacity` from it makes the encode grow the buffer
+ /// at most once instead of reallocating repeatedly as runs are appended.
+ hint: usize,
+}
+
+/// The live CP session the bring-up work item publishes once the dock engages the cipher
+/// (`acks > 0`), so the KMS callbacks can seal+send runtime CP messages -- a mode-set when the
+/// compositor switches mode, a cursor message on pointer motion -- that continue the SAME
+/// keystream the bring-up setup left off at. `wire_seq` is the AES-CTR block counter (advanced
+/// by the content blocks of each send; the appended Dl3Cmac tag is not part of the keystream)
+/// and `counter` the dock-echoed inner CP counter. Both advance per send under the mutex.
+pub(super) struct CpLink {
+ ks: [u8; 16],
+ riv: [u8; 8],
+ wire_seq: u32,
+ counter: u16,
+}
+
+/// Number of display heads the D6000 dock drives. The protocol routes video by endpoint
+/// (head 0 -> EP 0x08, head 1 -> EP 0x0a; heads 2/3 -> 0x0b/0x0c are documented but their CP
+/// riv / EDID-request encoding is unconfirmed, so only 2 heads are wired). The DL3 CP stream
+/// selects the head via the riv `byte0 ^ 0x80` (head 0 base / head 1 flipped).
+pub(super) const NHEADS: usize = 2;
+/// Per-head video bulk-OUT endpoint (`PROTOCOL.md`). Index by [`Head::index`].
+const HEAD_EP: [u8; NHEADS] = [0x08, 0x0a];
+
+/// One display head: its own CRTC + primary plane (scanout) + cursor plane + encoder +
+/// connector, plus per-head scanout state and cached monitor EDID. The vtables are shared
+/// across heads (in [`VinoDrmData`]); the callbacks recover the head from the C object
+/// pointer. All C objects are zeroed at init and filled by [`kms_init`].
+#[pin_data]
+pub(super) struct Head {
+ /// 0-based head index. Selects the video EP ([`HEAD_EP`]) and the CP riv (head 1 flips
+ /// the riv `byte0`); see the scanout EP and [`VinoDrmData::send_cp`].
+ index: u8,
+ /// One-shot: this head's cursor `create` (sprite dimensions) was sent before its first
+ /// image upload (per head -- the global one would skip head 1's create).
+ cursor_primed: core::sync::atomic::AtomicBool,
+ #[pin]
+ scanout: Mutex<ScanoutState>,
+ /// This head's downstream-monitor EDID (`None` until the CP channel delivers it). Only
+ /// head 0's EDID is read during bring-up; per-head EDID requests are unconfirmed, so
+ /// head 1 falls back to a fixed CVT mode in `get_modes`.
+ #[pin]
+ cached_edid: Mutex<Option<KVec<u8>>>,
+ #[pin]
+ crtc: Opaque<bindings::drm_crtc>,
+ #[pin]
+ primary: Opaque<bindings::drm_plane>,
+ #[pin]
+ cursor: Opaque<bindings::drm_plane>,
+ #[pin]
+ encoder: Opaque<bindings::drm_encoder>,
+ #[pin]
+ connector: Opaque<bindings::drm_connector>,
+}
+
+// SAFETY: as for `VinoDrmData` below -- the embedded C KMS objects are written only during
+// single-threaded probe and thereafter serialised by the DRM core's own locks.
+unsafe impl Send for Head {}
+// SAFETY: see the `Send` impl above.
+unsafe impl Sync for Head {}
+
+impl Head {
+ fn new(index: u8) -> impl PinInit<Self, Error> {
+ fn z<T>() -> impl PinInit<Opaque<T>, Error> {
+ // SAFETY: an all-zero C KMS object is a valid starting point (all callback
+ // pointers NULL); `kms_init` populates the rest via raw pointers under `unsafe`.
+ Opaque::try_ffi_init(|p: *mut T| {
+ unsafe { ptr::write_bytes(p, 0, 1) };
+ Ok(())
+ })
+ }
+ try_pin_init!(Self {
+ index,
+ cursor_primed: core::sync::atomic::AtomicBool::new(false),
+ scanout <- new_mutex!(ScanoutState {
+ enc: None,
+ cur: VVec::new(),
+ seq: 0,
+ dims: (0, 0),
+ active_timing: None,
+ hint: 0,
+ }),
+ cached_edid <- new_mutex!(Option::<KVec<u8>>::None),
+ crtc <- z(),
+ primary <- z(),
+ cursor <- z(),
+ encoder <- z(),
+ connector <- z(),
+ })
+ }
+
+ /// This head's video bulk-OUT endpoint.
+ fn video_ep(&self) -> u8 {
+ HEAD_EP[self.index as usize]
+ }
+
+ /// Fire a hotplug on this head's connector so the compositor re-probes [`detect`].
+ fn fire_hotplug(&self) {
+ // SAFETY: called after `drm_dev_register`; the embedded connector is initialised
+ // and its `dev` is our live drm_device. Safe from process context.
+ unsafe {
+ let dev = (*self.connector.get()).dev;
+ if !dev.is_null() {
+ bindings::drm_kms_helper_hotplug_event(dev);
+ }
+ }
+ }
+}
+
+/// DRM device-private data. Holds [`NHEADS`] display [`Head`]s (each a CRTC + primary +
+/// cursor plane + encoder + connector) and the shared KMS vtables inline, so they keep
+/// stable addresses for the device's lifetime. All C objects zeroed at init; filled by
+/// [`kms_init`]. Also keeps the bound USB interface (to reach the video EPs) and the engaged
+/// CP session.
+#[pin_data]
+pub(super) struct VinoDrmData {
+ intf: ARef<super::usb::Interface>,
+ /// The engaged CP session for runtime KMS-driven sends (`None` until
+ /// [`VinoDrmData::publish_session`]). See [`CpLink`] and [`VinoDrmData::send_cp`].
+ #[pin]
+ cp_link: Mutex<Option<CpLink>>,
+ #[pin]
+ head0: Head,
+ #[pin]
+ head1: Head,
+ // Shared vtables (one set for all heads; the callbacks recover the head from the C
+ // object pointer). One `drm_plane_funcs` for both planes; per-plane helper funcs because
+ // the primary's `atomic_update` scans out while the cursor's sends cursor CP.
+ #[pin]
+ conn_funcs: Opaque<bindings::drm_connector_funcs>,
+ #[pin]
+ conn_helper: Opaque<bindings::drm_connector_helper_funcs>,
+ #[pin]
+ crtc_funcs: Opaque<bindings::drm_crtc_funcs>,
+ #[pin]
+ crtc_helper: Opaque<bindings::drm_crtc_helper_funcs>,
+ #[pin]
+ plane_funcs: Opaque<bindings::drm_plane_funcs>,
+ #[pin]
+ primary_helper: Opaque<bindings::drm_plane_helper_funcs>,
+ #[pin]
+ cursor_helper: Opaque<bindings::drm_plane_helper_funcs>,
+ #[pin]
+ encoder_funcs: Opaque<bindings::drm_encoder_funcs>,
+ #[pin]
+ mode_cfg_funcs: Opaque<bindings::drm_mode_config_funcs>,
+}
+
+// SAFETY: the embedded C KMS objects are written only during single-threaded
+// `probe()` (before `drm_dev_register`), and thereafter are owned and serialised by
+// the DRM core under its own modeset/atomic locks -- Rust never aliases them again.
+// This is the conventional assertion for drivers embedding C KMS state until the
+// kernel grows safe Rust KMS abstractions.
+unsafe impl Send for VinoDrmData {}
+// SAFETY: see the `Send` impl above.
+unsafe impl Sync for VinoDrmData {}
+
+impl VinoDrmData {
+ /// Zero-initialise all embedded C objects (so each `Option<fn>` vtable slot is
+ /// `None`); [`kms_init`] then fills in only the callbacks we implement. `intf`
+ /// is the bound USB interface, kept so the scanout path can reach EP08.
+ pub(super) fn new(intf: ARef<super::usb::Interface>) -> impl PinInit<Self, Error> {
+ fn z<T>() -> impl PinInit<Opaque<T>, Error> {
+ // SAFETY: an all-zero C KMS object / funcs table is a valid starting
+ // point (all callback pointers NULL); the `_init` helpers populate the
+ // rest, and we only read it back through raw pointers under `unsafe`.
+ Opaque::try_ffi_init(|p: *mut T| {
+ unsafe { ptr::write_bytes(p, 0, 1) };
+ Ok(())
+ })
+ }
+ try_pin_init!(Self {
+ intf,
+ cp_link <- new_mutex!(Option::<CpLink>::None),
+ head0 <- Head::new(0),
+ head1 <- Head::new(1),
+ conn_funcs <- z(),
+ conn_helper <- z(),
+ crtc_funcs <- z(),
+ crtc_helper <- z(),
+ plane_funcs <- z(),
+ primary_helper <- z(),
+ cursor_helper <- z(),
+ encoder_funcs <- z(),
+ mode_cfg_funcs <- z(),
+ })
+ }
+
+ /// The display heads, in index order.
+ fn heads(&self) -> [&Head; NHEADS] {
+ [&self.head0, &self.head1]
+ }
+
+ /// Recover the [`Head`] that owns a given C KMS object, by pointer identity. Used by the
+ /// connector/CRTC/plane callbacks (which receive a raw C pointer) to find their head.
+ fn head_by_connector(&self, c: *mut bindings::drm_connector) -> Option<&Head> {
+ self.heads().into_iter().find(|h| h.connector.get() == c)
+ }
+ fn head_by_crtc(&self, c: *mut bindings::drm_crtc) -> Option<&Head> {
+ self.heads().into_iter().find(|h| h.crtc.get() == c)
+ }
+ fn head_by_primary(&self, p: *mut bindings::drm_plane) -> Option<&Head> {
+ self.heads().into_iter().find(|h| h.primary.get() == p)
+ }
+ fn head_by_cursor(&self, p: *mut bindings::drm_plane) -> Option<&Head> {
+ self.heads().into_iter().find(|h| h.cursor.get() == p)
+ }
+
+ /// Cache the dock's EDID (read during probe) on head 0 for [`get_modes`] to install, then
+ /// fire a hotplug so the compositor re-probes the connector -- which now reports connected
+ /// (see [`detect`]) and exposes the monitor's real mode list. Only head 0's downstream EDID
+ /// is read during bring-up (per-head EDID requests are unconfirmed).
+ pub(super) fn set_edid(&self, blob: KVec<u8>) {
+ *self.head0.cached_edid.lock() = Some(blob);
+ self.head0.fire_hotplug();
+ }
+
+ /// Fire a hotplug on every head's connector so the compositor re-probes [`detect`] -- used
+ /// after the bring-up work item completes to expose the live-scanout outputs.
+ pub(super) fn fire_hotplug(&self) {
+ for h in self.heads() {
+ h.fire_hotplug();
+ }
+ }
+
+ /// Publish the engaged CP session so the KMS callbacks can send runtime CP messages.
+ /// Called once by the bring-up work item after the dock acks (`acks > 0`).
+ /// `wire_seq`/`counter` are the next free values past the bring-up CP setup.
+ pub(super) fn publish_session(&self, ks: &[u8; 16], riv: &[u8; 8], wire_seq: u32, counter: u16) {
+ *self.cp_link.lock() = Some(CpLink { ks: *ks, riv: *riv, wire_seq, counter });
+ }
+
+ /// Seal and send one interactive CP message on EP02 for head `head_index`, advancing the
+ /// session keystream. The DL3 CP stream selects the head via the riv `byte0 ^ 0x80` (head 0
+ /// base, head 1 flipped). `build(counter)` produces the inner CP message for the
+ /// dock-echoed `counter` it is handed (e.g. [`super::cp::set_mode`]); `tag_reserved`
+ /// trailing bytes are dropped before the live Dl3Cmac is appended (set-mode reserves a
+ /// 16-byte placeholder; messages with no placeholder pass 0). Returns `Ok(())` as a
+ /// **no-op when CP is not engaged**. The `cp_link` mutex serialises concurrent KMS
+ /// callbacks. Runs from the atomic-commit context (same as the scanout), so the blocking
+ /// `bulk_send` is fine. NOTE: head 1's `wire_seq`/counter sharing with head 0 is an
+ /// assumption (both share `cp_link`); the riv differs so the keystreams differ. Unconfirmed
+ /// (CP-wall-gated) -- revisit when the dock engages.
+ pub(super) fn send_cp(
+ &self,
+ head_index: u8,
+ id: u16,
+ tag_reserved: usize,
+ build: impl FnOnce(u16) -> Result<KVec<u8>>,
+ ) -> Result {
+ let mut guard = self.cp_link.lock();
+ // `&mut *guard` forces the guard's `DerefMut` to `&mut Option<CpLink>` so `as_mut`
+ // resolves to `Option::as_mut` (the guard has its own inherent `as_mut`).
+ let Some(link) = (&mut *guard).as_mut() else {
+ return Ok(()); // CP not engaged -- nothing to send
+ };
+ // Head select: head 1's CP stream flips the riv byte0 (see `decode_any`/CP-HANDSHAKE).
+ let mut riv = link.riv;
+ if head_index == 1 {
+ riv[0] ^= 0x80;
+ }
+ let msg = build(link.counter)?;
+ let content = &msg[..msg.len().saturating_sub(tag_reserved)];
+ let frame = super::cp::seal_interactive(&link.ks, &riv, id, link.wire_seq, content)?;
+ let dev: &super::usb::Device = self.intf.as_ref();
+ dev.bulk_send(super::EP_CTRL_OUT, &frame, super::timeout())?;
+ // Advance the AES-CTR block counter by the content blocks only (the appended Dl3Cmac
+ // tag is sent in clear, not keystreamed) and bump the dock-echoed inner counter.
+ link.wire_seq = link.wire_seq.wrapping_add(((content.len() + 15) / 16) as u32);
+ link.counter = link.counter.wrapping_add(1);
+ Ok(())
+ }
+}
+
+/// GEM object inner data. Empty: the shmem-backed `drm::gem::shmem::Object` (which
+/// wires `drm_gem_shmem_dumb_create`, so userspace `DRM_IOCTL_MODE_CREATE_DUMB`
+/// works) is enough until the EP08 scanout path consumes the framebuffers.
+#[pin_data]
+pub(super) struct VinoObject {}
+
+impl drm::gem::DriverObject for VinoObject {
+ type Driver = VinoDrmDriver;
+ type Args = ();
+
+ fn new<Ctx: drm::DeviceContext>(
+ _dev: &drm::Device<VinoDrmDriver, Ctx>,
+ _size: usize,
+ _args: (),
+ ) -> impl PinInit<Self, Error> {
+ try_pin_init!(VinoObject {})
+ }
+}
+
+/// Per-open DRM client state. Empty of driver data, but its lifetime is used to
+/// pin the module for the duration of an open DRM file (see [`VinoDrmFile::open`]).
+#[pin_data(PinnedDrop)]
+pub(super) struct VinoDrmFile {}
+
+impl drm::file::DriverFile for VinoDrmFile {
+ type Driver = VinoDrmDriver;
+
+ fn open(_dev: &drm::Device<Self::Driver>) -> Result<Pin<KBox<Self>>> {
+ let file = KBox::try_pin_init(try_pin_init!(Self {}), GFP_KERNEL)?;
+ // Pin this module while a DRM file is open. The Rust DRM `file_operations`
+ // are built with `owner = NULL` (drm/gem/mod.rs `create_fops`), so the DRM
+ // core's `try_module_get(fops->owner)` on open is a no-op: an open card fd
+ // does NOT keep the driver loaded. Unloading vino (rmmod, or USB teardown at
+ // shutdown) while a compositor still holds `/dev/dri/cardN` then frees the
+ // module's `.rodata` -- where the fops live -- under that open fd, so the next
+ // ioctl/close dereferences freed memory and oopses the kernel (observed: KWin
+ // UAF in `__x64_sys_ioctl` / `put_files_struct`, "recursive fault, reboot
+ // needed"). Take an explicit module reference here, released 1:1 in
+ // `PinnedDrop` (run by `postclose_callback` on file close), to restore the
+ // pin the NULL `fops.owner` drops. Remove once the binding sets `fops.owner`.
+ // SAFETY: we are executing inside this module's own DRM `open` callback, so
+ // the module is live; taking an extra reference via `__module_get` is sound.
+ unsafe { bindings::__module_get(crate::THIS_MODULE.as_ptr()) };
+ Ok(file)
+ }
+}
+
+#[pinned_drop]
+impl PinnedDrop for VinoDrmFile {
+ fn drop(self: Pin<&mut Self>) {
+ // Release the module reference taken in `open` (balanced one-per-open-file).
+ // SAFETY: balances the `__module_get` in `open`; `THIS_MODULE` is valid for
+ // the lifetime of the module.
+ unsafe { bindings::module_put(crate::THIS_MODULE.as_ptr()) };
+ }
+}
+
+const INFO: drm::DriverInfo = drm::DriverInfo {
+ major: 0,
+ minor: 1,
+ patchlevel: 0,
+ name: c"vino",
+ desc: c"DisplayLink DL3 (Dell D6000) DRM driver",
+};
+
+#[vtable]
+impl drm::Driver for VinoDrmDriver {
+ type Data = VinoDrmData;
+ type File = VinoDrmFile;
+ type Object<Ctx: drm::DeviceContext> = drm::gem::shmem::Object<VinoObject, Ctx>;
+
+ const INFO: drm::DriverInfo = INFO;
+ // Atomic KMS driver (CRTC/plane/connector via the simple display pipe).
+ // Mirrors the FEAT_RENDER idiom added by patches/drm/0001.
+ const FEAT_MODESET: bool = true;
+ const FEAT_ATOMIC: bool = true;
+
+ // No driver-private ioctls (GEM/dumb + KMS handled by the DRM core).
+ kernel::declare_drm_ioctls! {}
+}
+
+// ---- KMS C callbacks ------------------------------------------------------
+
+/// Install a real EDID blob on the connector via the standard DRM EDID
+/// infrastructure and return the number of modes added (0 on failure). This
+/// reuses the kernel helpers -- no synthetic EDID. See CONTROL-PLANE.md.
+fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 {
+ // SAFETY: `blob` is a valid byte buffer; `drm_edid_alloc` copies it.
+ let edid = unsafe { bindings::drm_edid_alloc(blob.as_ptr().cast(), blob.len()) };
+ if edid.is_null() {
+ return 0;
+ }
+ // SAFETY: `connector` is valid during probe; `edid` is freshly allocated above.
+ unsafe { bindings::drm_edid_connector_update(connector, edid) };
+ // SAFETY: connector valid; adds the EDID-derived modes, returns the count.
+ let n = unsafe { bindings::drm_edid_connector_add_modes(connector) };
+ // SAFETY: `edid` was allocated by `drm_edid_alloc` and is no longer needed.
+ unsafe { bindings::drm_edid_free(edid) };
+ n
+}
+
+/// Connector `.mode_valid`: reject any single mode whose pixel clock exceeds the per-head
+/// ceiling ([`MAX_HEAD_CLOCK_KHZ`], ~4K@60), so the compositor never offers an over-spec mode
+/// on
+/// one head. The *combined* across-heads budget is enforced separately at commit by
+/// [`vino_atomic_check`].
+unsafe extern "C" fn mode_valid(
+ _connector: *mut bindings::drm_connector,
+ mode: *const bindings::drm_display_mode,
+) -> bindings::drm_mode_status {
+ // SAFETY: `mode` is a valid drm_display_mode for the duration of the call.
+ let clock = unsafe { (*mode).clock };
+ if clock > MAX_HEAD_CLOCK_KHZ {
+ bindings::drm_mode_status_MODE_CLOCK_HIGH
+ } else {
+ bindings::drm_mode_status_MODE_OK
+ }
+}
+
+/// `mode_config.funcs.atomic_check`: run the standard atomic checks, then reject the commit if
+/// the **combined** pixel clock of all active heads would exceed the dock's USB/DL3 budget
+/// ([`MAX_TOTAL_CLOCK_KHZ`]) -- e.g. two simultaneous 4K modes. For each head, the proposed
+/// (new) CRTC state is used when the head is part of this commit, else its current committed
+/// state; only `enable && active` heads count.
+unsafe extern "C" fn vino_atomic_check(
+ dev: *mut bindings::drm_device,
+ state: *mut bindings::drm_atomic_commit,
+) -> i32 {
+ // SAFETY: `dev`/`state` are valid for the duration of the atomic check.
+ let rc = unsafe { bindings::drm_atomic_helper_check(dev, state) };
+ if rc != 0 {
+ return rc;
+ }
+ // SAFETY: `dev` is our live, registered drm_device.
+ let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) };
+ let mut total_khz: i64 = 0;
+ for head in data.heads() {
+ let crtc = head.crtc.get();
+ // SAFETY: read-only new-state accessor (a `rust_helper`, exposed without the prefix);
+ // NULL when this head is not in the commit -- then fall back to its current state.
+ let mut cs = unsafe { bindings::drm_atomic_get_new_crtc_state(state, crtc) };
+ if cs.is_null() {
+ // SAFETY: `crtc` is initialised; `.state` is its current committed state (or NULL).
+ cs = unsafe { (*crtc).state };
+ }
+ if cs.is_null() {
+ continue;
+ }
+ // SAFETY: `cs` is a live drm_crtc_state.
+ let (enable, active, clock) = unsafe { ((*cs).enable, (*cs).active, (*cs).mode.clock) };
+ if enable && active {
+ total_khz += clock as i64;
+ }
+ }
+ if total_khz > MAX_TOTAL_CLOCK_KHZ {
+ pr_warn!(
+ "vino: modeset rejected -- combined {total_khz} kHz pixel clock over the {} kHz dock budget\n",
+ MAX_TOTAL_CLOCK_KHZ
+ );
+ return EINVAL.to_errno();
+ }
+ 0
+}
+
+/// Connector `.get_modes`: install the dock's real EDID (read during probe) when
+/// available; otherwise fall back to a single 1920x1080@60 CVT mode. Reading the
+/// real EDID gives the true monitor name/size and its native mode list (see the
+/// EDID Read path); the fallback keeps the connector usable when nothing is
+/// plugged into the dock or the CP channel has not yet delivered the EDID.
+unsafe extern "C" fn get_modes(connector: *mut bindings::drm_connector) -> i32 {
+ // SAFETY: `connector` is a valid, initialised connector during probe.
+ let dev = unsafe { (*connector).dev };
+ // SAFETY: `dev` is our live, registered drm_device.
+ let ddev = unsafe { VinoDrmDevice::from_raw(dev) };
+ let data: &VinoDrmData = ddev;
+ if let Some(head) = data.head_by_connector(connector) {
+ let guard = head.cached_edid.lock();
+ if let Some(blob) = guard.as_ref() {
+ let n = install_edid(connector, blob);
+ if n > 0 {
+ return n;
+ }
+ }
+ }
+ // Fallback: single FALLBACK_W x FALLBACK_H @60 CVT mode, marked preferred.
+ // SAFETY: `dev` is a valid drm_device; drm_cvt_mode allocates a mode.
+ let mode = unsafe {
+ bindings::drm_cvt_mode(dev, FALLBACK_W, FALLBACK_H, 60, false, false, false)
+ };
+ if mode.is_null() {
+ return 0;
+ }
+ // SAFETY: `mode` is freshly allocated and owned by the connector after add.
+ unsafe { bindings::drm_mode_probed_add(connector, mode) };
+ // SAFETY: connector is valid; set the fallback mode as preferred.
+ unsafe { bindings::drm_set_preferred_mode(connector, FALLBACK_W, FALLBACK_H) };
+ 1
+}
+
+/// Connector `.detect`: report **disconnected** until the dock's downstream EDID has
+/// actually been read over the CP channel, then **connected**. A virtual connector
+/// that always reports connected makes the compositor light up a phantom output it
+/// cannot drive -- no pixels reach the dock until the CP/EP08 path is up -- which froze
+/// KWin on plug (SSH stayed alive; unplug recovered it). Gating on a real EDID mirrors
+/// how `gud`/`udl` report monitor presence; [`VinoDrmData::set_edid`] fires a hotplug
+/// so the compositor re-probes and enables the output once the EDID arrives.
+unsafe extern "C" fn detect(
+ connector: *mut bindings::drm_connector,
+ _force: bool,
+) -> bindings::drm_connector_status {
+ // SAFETY: `connector` is a valid connector embedded in our DRM device-private.
+ let dev = unsafe { (*connector).dev };
+ // SAFETY: `dev` is our live, registered drm_device.
+ let ddev = unsafe { VinoDrmDevice::from_raw(dev) };
+ let data: &VinoDrmData = ddev;
+ let live_ready = super::CP_ENGAGED.load(core::sync::atomic::Ordering::SeqCst);
+ let has_edid = data
+ .head_by_connector(connector)
+ .is_some_and(|h| h.cached_edid.lock().is_some());
+ if has_edid || live_ready {
+ // Force-connect (with the get_modes 1080p fallback) so a compositor drives the CRTC and
+ // `primary_atomic_update` fires live frames -- but only once CP is engaged (not merely
+ // after
+ // bring-up): connecting the output makes the compositor push EP08 video, and doing that
+ // before the dock has engaged CP makes it fault and USB-reset in a loop (the "EP08
+ // write
+ // wedges the hub" mode). So stay disconnected until CP is up -- or a real EDID arrived.
+ bindings::drm_connector_status_connector_status_connected
+ } else {
+ bindings::drm_connector_status_connector_status_disconnected
+ }
+}
+
+/// CRTC `.atomic_enable`: the display is turning on (scanout begins). Captures the mode the
+/// compositor selected -- any entry from the connector's full EDID-derived list -- as a
+/// set-mode [`super::cp::Timing`] in [`ScanoutState::active_timing`] and pushes a live
+/// mode-set CP message for it (no-op until CP engages). The geometry change is also honoured
+/// by the scanout path (`encode_and_send` re-inits on `dims` change).
+unsafe extern "C" fn crtc_atomic_enable(
+ crtc: *mut bindings::drm_crtc,
+ _state: *mut bindings::drm_atomic_commit,
+) {
+ // SAFETY: in `.atomic_enable` the crtc and its committed `state` are valid; `state->mode`
+ // is a live drm_display_mode and `timing_from_drm_mode` only reads it.
+ let cs = unsafe { (*crtc).state };
+ if cs.is_null() {
+ return;
+ }
+ let timing = unsafe { super::cp::timing_from_drm_mode(&(*cs).mode) };
+ pr_info!(
+ "vino: KMS CRTC enable -- display ON, mode {}x{}@{} (scanout begins)\n",
+ timing.hactive, timing.vactive, timing.refresh_hz
+ );
+ // SAFETY: `crtc` is valid; its `dev` is our live drm_device.
+ let dev = unsafe { (*crtc).dev };
+ if dev.is_null() {
+ return;
+ }
+ // SAFETY: `dev` is our registered drm_device.
+ let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) };
+ let Some(head) = data.head_by_crtc(crtc) else {
+ return;
+ };
+ head.scanout.lock().active_timing = Some(timing);
+ // Push a live mode-set for the chosen mode (on this head's CP stream) so the dock switches
+ // to it at runtime, not just the EDID-preferred mode the bring-up setup sent. `set_mode`
+ // reserves a 16-byte tag placeholder. A no-op until the cipher is engaged.
+ if let Err(e) = data.send_cp(head.index, 0x48, 16, |ctr| super::cp::set_mode(ctr, &timing)) {
+ pr_warn!("vino: head{} runtime mode-set send failed ({e:?})\n", head.index);
+ }
+}
+
+/// CRTC `.atomic_disable`: the display is turning off.
+/// CRTC `.atomic_disable`: the display is turning off -- DPMS-off / blank / suspend all land
+/// here in atomic KMS (the compositor clears the CRTC `active` state). The compositor stops
+/// page-flipping, so no new frames are pushed; this resets the head's scanout state so a later
+/// re-enable (DPMS-on) re-inits the encoder and sends a **full keyframe** rather than diffing
+/// against a shadow the dock may have dropped while blanked, and re-uploads the cursor sprite.
+///
+/// The dock holds the last frame when video stops (it has its own scanout buffer), so the
+/// monitor freezes the last image rather than going black; a true backlight-standby would need
+/// a dock power command that is not decoded (DLM's `Standby`/`Suspend`/`TempPowerOff` are
+/// internal, vtable-dispatched events with no wire frame -- the same dead-end as gamma).
+unsafe extern "C" fn crtc_atomic_disable(
+ crtc: *mut bindings::drm_crtc,
+ _state: *mut bindings::drm_atomic_commit,
+) {
+ // SAFETY: in `.atomic_disable` the crtc and its `dev` are valid.
+ let dev = unsafe { (*crtc).dev };
+ if dev.is_null() {
+ return;
+ }
+ // SAFETY: `dev` is our registered drm_device.
+ let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) };
+ let Some(head) = data.head_by_crtc(crtc) else {
+ return;
+ };
+ {
+ let mut st = head.scanout.lock();
+ st.enc = None; // force a full re-init + keyframe on the next enable
+ st.dims = (0, 0);
+ }
+ head.cursor_primed
+ .store(false, core::sync::atomic::Ordering::SeqCst);
+ pr_info!("vino: KMS CRTC disable -- head{} display OFF (scanout stopped)\n", head.index);
+}
+
+/// Cursor plane `.atomic_update`: the cursor sprite and/or position changed. Sends the cursor
+/// CP messages (create once + image when a sprite framebuffer is present, then a move). Gated
+/// on CP engagement; a no-op on current hardware (the CP wall). See [`cursor_send`].
+unsafe extern "C" fn cursor_atomic_update(
+ plane: *mut bindings::drm_plane,
+ _state: *mut bindings::drm_atomic_commit,
+) {
+ if !super::CP_ENGAGED.load(core::sync::atomic::Ordering::SeqCst) {
+ return;
+ }
+ // SAFETY: in `.atomic_update` the plane and its committed state are valid for the commit.
+ let (dev_raw, fb, w, h, cx, cy) = unsafe {
+ let st = (*plane).state;
+ if st.is_null() {
+ return;
+ }
+ (
+ (*plane).dev,
+ (*st).fb,
+ (*st).crtc_w as usize,
+ (*st).crtc_h as usize,
+ (*st).crtc_x,
+ (*st).crtc_y,
+ )
+ };
+ // SAFETY: `dev_raw` is our live, registered drm_device.
+ let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev_raw) };
+ let Some(head) = data.head_by_cursor(plane) else {
+ return;
+ };
+ if let Err(e) = cursor_send(data, head, fb, w, h, cx, cy) {
+ pr_warn!("vino: head{} cursor update failed ({e:?})\n", head.index);
+ }
+}
+
+/// Primary plane `.atomic_update`: a new framebuffer was flipped in -- the scanout hook.
+/// Maps the framebuffer, converts XRGB8888 -> RGB565, Vino-encodes the changed
+/// region against the previous frame, and bulk-writes the EP08 video frame.
+///
+/// The EP08 write only happens once the dock has engaged CP (see `docs/BLOCKER.md`):
+/// until then the dock NAKs/stalls EP08, so a normal module load must not push frames on
+/// every flip and thrash the dock. With the CP-engagement wall unsolved this never fires
+/// on real hardware.
+unsafe extern "C" fn primary_atomic_update(
+ plane: *mut bindings::drm_plane,
+ _state: *mut bindings::drm_atomic_commit,
+) {
+ // Don't touch EP08 until the dock has engaged CP. Pushing video (and the one-shot
+ // clear_halt of EPs 8/10/11/12) at a dock with a dead CP channel makes it fault and
+ // USB-reset, which re-probes the driver in a ~2.7 s loop.
+ if !super::CP_ENGAGED.load(core::sync::atomic::Ordering::SeqCst) {
+ return;
+ }
+ // SAFETY: in `.atomic_update` the plane and its committed state are valid; the plane
+ // state and its framebuffer are valid for the duration of the commit.
+ let (dev_raw, fb, w, h, damage, rotation) = unsafe {
+ let st = (*plane).state;
+ if st.is_null() {
+ return;
+ }
+ // Plane destination geometry == the negotiated mode (the compositor sizes the primary
+ // plane 1:1 with a virtual output), so this drives the dynamic scanout resolution.
+ let (w, h) = ((*st).crtc_w as usize, (*st).crtc_h as usize);
+ ((*plane).dev, (*st).fb, w, h, damage_bbox(st), (*st).rotation)
+ };
+ if fb.is_null() {
+ return;
+ }
+ // Recover our device-private data + this plane's head from the raw drm_device.
+ // SAFETY: `dev_raw` is our live, registered drm_device.
+ let ddev = unsafe { VinoDrmDevice::from_raw(dev_raw) };
+ let data: &VinoDrmData = ddev;
+ let Some(head) = data.head_by_primary(plane) else {
+ return;
+ };
+
+ use core::sync::atomic::Ordering::Relaxed;
+ // Throttle: while scanout is failing (dock NAKing because CP isn't engaged), skip the
+ // upcoming pageflips set by the backoff below instead of converting+encoding+sending a
+ // frame the dock will just drop. The backoff is shared across heads (a coarse global rate
+ // limit) -- fine while it never fires on real hardware (CP wall).
+ let skip = super::SCANOUT_SKIP.load(Relaxed);
+ if skip > 0 {
+ super::SCANOUT_SKIP.store(skip - 1, Relaxed);
+ return;
+ }
+ // Read this head's CRTC GAMMA_LUT (if a compositor set one) and apply it host-side in the
+ // conversion below -- there is no dock-side gamma message (see `read_gamma_lut`).
+ let gamma = read_gamma_lut(head);
+ match scanout_one(data, head, fb, w, h, damage, rotation, gamma.as_ref()) {
+ Ok(()) => {
+ let n = super::SCANOUT_FAILS.swap(0, Relaxed);
+ super::SCANOUT_SKIP.store(0, Relaxed);
+ if n > 0 {
+ pr_info!("vino: scanout recovered after {n} failed frame(s)\n");
+ }
+ }
+ Err(e) => {
+ // The dock NAKs every EP08 write (EPROTO) until CP engages -- expected and not
+ // actionable. Log the first failure and then at exponentially sparser points so
+ // dmesg isn't flooded, and back off the scanout rate.
+ let n = super::SCANOUT_FAILS.fetch_add(1, Relaxed) + 1;
+ if n == 1 || n.is_power_of_two() {
+ pr_err!("vino: scanout frame failed ({e:?}) [x{n}] -- throttling\n");
+ }
+ // Linear backoff capped at 120 frames (~2 s @ 60 Hz) between probe attempts, so
+ // recovery (CP engaging) is still detected within ~2 s while idle CPU stays low.
+ super::SCANOUT_SKIP.store(core::cmp::min(n, 120), Relaxed);
+ }
+ }
+}
+
+/// Map an output pixel `(dx, dy)` back to its source-framebuffer pixel `(sx, sy)` under a DRM
+/// plane `rotation` bitmask (`DRM_MODE_ROTATE_*` | `DRM_MODE_REFLECT_*`, the values the
+/// standard `drm_plane_create_rotation_property` exposes). `sw`/`sh` are the SOURCE
+/// (framebuffer) dimensions; the output dimensions are `(sw, sh)` for 0 deg/180 deg and `(sh, sw)`
+/// for 90 deg/270 deg (the caller swaps source vs output accordingly). Rotation is clockwise;
+/// reflection is applied in source space after rotation. Pure + total (saturating), so it is
+/// unit-tested directly. Used by [`encode_and_send`] to honour the connector's rotation
+/// property -- DLM rotates host-side, vino rotates in the scanout encode.
+pub(super) fn rot_src(
+ rotation: u32,
+ dx: usize,
+ dy: usize,
+ sw: usize,
+ sh: usize,
+) -> (usize, usize) {
+ let xmax = sw.saturating_sub(1);
+ let ymax = sh.saturating_sub(1);
+ let rot = rotation & bindings::DRM_MODE_ROTATE_MASK;
+ let (mut sx, mut sy) = if rot == bindings::DRM_MODE_ROTATE_90 {
+ (dy, ymax.saturating_sub(dx))
+ } else if rot == bindings::DRM_MODE_ROTATE_180 {
+ (xmax.saturating_sub(dx), ymax.saturating_sub(dy))
+ } else if rot == bindings::DRM_MODE_ROTATE_270 {
+ (xmax.saturating_sub(dy), dx)
+ } else {
+ (dx, dy) // ROTATE_0 / unset
+ };
+ if rotation & bindings::DRM_MODE_REFLECT_X != 0 {
+ sx = xmax.saturating_sub(sx);
+ }
+ if rotation & bindings::DRM_MODE_REFLECT_Y != 0 {
+ sy = ymax.saturating_sub(sy);
+ }
+ (sx, sy)
+}
+
+/// True if `rotation` swaps width/height (90 deg or 270 deg), so the source framebuffer is
+/// portrait while the scanned-out display is landscape (or vice versa).
+fn rotation_swaps_dims(rotation: u32) -> bool {
+ let r = rotation & bindings::DRM_MODE_ROTATE_MASK;
+ r == bindings::DRM_MODE_ROTATE_90 || r == bindings::DRM_MODE_ROTATE_270
+}
+
+/// True if `rotation` is anything other than the identity (plain 0 deg), i.e. the scanout must
+/// remap every pixel and cannot take the damage-clip fast path.
+fn rotation_active(rotation: u32) -> bool {
+ rotation != 0 && rotation != bindings::DRM_MODE_ROTATE_0
+}
+
+/// Damage bounding box (pixels, clamped to the scanout) for this atomic update, or
+/// `None` meaning "convert the whole frame". Reads the standard `FB_DAMAGE_CLIPS` blob the
+/// compositor attaches to the plane state and unions its rects. The Vino encoder already
+/// shadow-diffs against the previous frame, so unchanged regions emit nothing regardless;
+/// the win here is skipping the XRGB8888->RGB565 conversion of those regions. Returns `None`
+/// when no damage is advertised, `ignore_damage_clips` is set, or the union is degenerate
+/// (all treated as a full-frame update).
+///
+/// SAFETY: `st` must be a valid `drm_plane_state` for the duration of the call.
+unsafe fn damage_bbox(
+ st: *const bindings::drm_plane_state,
+) -> Option<(usize, usize, usize, usize)> {
+ // SAFETY: caller guarantees `st` is a live plane state.
+ let (blob, ignore) = unsafe { ((*st).fb_damage_clips, (*st).ignore_damage_clips) };
+ if ignore || blob.is_null() {
+ return None;
+ }
+ // SAFETY: `blob` is non-null and lives as long as the plane state.
+ let (data, len) = unsafe { ((*blob).data as *const bindings::drm_mode_rect, (*blob).length) };
+ let n = len / core::mem::size_of::<bindings::drm_mode_rect>();
+ if data.is_null() || n == 0 {
+ return None;
+ }
+ let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
+ for i in 0..n {
+ // SAFETY: `i < n`, the rect array length implied by `blob.length`.
+ let r = unsafe { &*data.add(i) };
+ x0 = x0.min(r.x1);
+ y0 = y0.min(r.y1);
+ x1 = x1.max(r.x2);
+ y1 = y1.max(r.y2);
+ }
+ // Clamp to the plane's destination geometry and reject empty/degenerate boxes (fall back to
+ // a full frame). Read crtc_w/crtc_h off the plane state so the clamp tracks the live mode,
+ // not a fixed 1080p.
+ // SAFETY: caller guarantees `st` is a live plane state.
+ let (pw, ph) = unsafe { ((*st).crtc_w as i32, (*st).crtc_h as i32) };
+ let cx0 = x0.clamp(0, pw) as usize;
+ let cy0 = y0.clamp(0, ph) as usize;
+ let cx1 = x1.clamp(0, pw) as usize;
+ let cy1 = y1.clamp(0, ph) as usize;
+ if cx1 <= cx0 || cy1 <= cy0 {
+ return None;
+ }
+ Some((cx0, cy0, cx1, cy1))
+}
+
+/// Read the CRTC's `GAMMA_LUT` and flatten it into three 256-entry 8-bit lookup tables
+/// (R, G, B), or `None` when no gamma is set (the common case -- the conversion then runs at
+/// full speed). DLM gamma-corrects pixels **host-side** before encoding; the DL3 dock has no
+/// gamma CP message (the `NotifyGammaCurve`/`SetGammaMode` handlers are DLM-internal,
+/// vtable-dispatched, and emit no wire frame -- confirmed against the decompile and every
+/// capture), so vino applies the LUT in the scanout exactly like it applies `rotation`. The
+/// blob holds `n` `drm_color_lut` entries (u16 per channel); 8-bit input `i` maps to entry
+/// `i*(n-1)/255` and takes that entry's high 8 bits.
+fn read_gamma_lut(head: &Head) -> Option<[[u8; 256]; 3]> {
+ // SAFETY: `head.crtc` was initialised in `kms_init`; its committed `state` and the
+ // gamma_lut blob it references are valid for the duration of the atomic commit.
+ let blob = unsafe {
+ let cs = (*head.crtc.get()).state;
+ if cs.is_null() {
+ return None;
+ }
+ (*cs).gamma_lut
+ };
+ if blob.is_null() {
+ return None;
+ }
+ // SAFETY: `blob` is a live drm_property_blob for the commit; `data`/`length` are valid.
+ let (ptr, len) =
+ unsafe { ((*blob).data as *const bindings::drm_color_lut, (*blob).length) };
+ let n = len / core::mem::size_of::<bindings::drm_color_lut>();
+ if ptr.is_null() || n == 0 {
+ return None;
+ }
+ let mut t = [[0u8; 256]; 3];
+ for i in 0..256usize {
+ let idx = if n == 1 { 0 } else { i * (n - 1) / 255 };
+ // SAFETY: `idx < n`, within the blob's `n` `drm_color_lut` entries.
+ let e = unsafe { &*ptr.add(idx) };
+ t[0][i] = (e.red >> 8) as u8;
+ t[1][i] = (e.green >> 8) as u8;
+ t[2][i] = (e.blue >> 8) as u8;
+ }
+ Some(t)
+}
+
+/// vmap `fb`, encode it, and push one EP08 frame. Split out so `?` can be used. `damage`
+/// bounds the XRGB8888->RGB565 conversion to the changed region (see [`damage_bbox`]).
+fn scanout_one(
+ data: &VinoDrmData,
+ head: &Head,
+ fb: *mut bindings::drm_framebuffer,
+ w: usize,
+ h: usize,
+ damage: Option<(usize, usize, usize, usize)>,
+ rotation: u32,
+ gamma: Option<&[[u8; 256]; 3]>,
+) -> Result {
+ // `w`/`h` are the plane's destination (displayed) geometry (== the negotiated mode),
+ // threaded in from `primary_atomic_update`, so the scanout follows the live mode (e.g. the
+ // dock's
+ // native 4K) instead of a hardcoded 1080p. `drm_framebuffer` is opaque in the bindings, so
+ // the geometry comes from the plane state; our XRGB8888 buffers are packed. Under a
+ // 90 deg/270 deg
+ // `rotation` the source framebuffer is portrait relative to the display, so its row pitch
+ // tracks the *source* width -- `encode_and_send` derives that from `rotation`.
+ if w == 0 || h == 0 {
+ return Err(EINVAL);
+ }
+
+ // Map the framebuffer's backing pages into the kernel address space.
+ // SAFETY: `iosys_map` is POD (a pointer union + bool); all-zero is a valid,
+ // "not mapped" value that `drm_gem_fb_vmap` overwrites for present planes.
+ let mut map: [bindings::iosys_map; 4] = unsafe { core::mem::zeroed() };
+ let mut dmap: [bindings::iosys_map; 4] = unsafe { core::mem::zeroed() };
+ // SAFETY: `fb` is a valid framebuffer with GEM-backed storage.
+ to_result(unsafe { bindings::drm_gem_fb_vmap(fb, map.as_mut_ptr(), dmap.as_mut_ptr()) })?;
+
+ // SAFETY: plane 0's CPU virtual address, valid until `drm_gem_fb_vunmap`.
+ let vaddr = unsafe { map[0].__bindgen_anon_1.vaddr } as *const u8;
+ let result = if vaddr.is_null() {
+ Err(EINVAL)
+ } else {
+ encode_and_send(data, head, vaddr, w, h, damage, rotation, gamma)
+ };
+
+ // SAFETY: balances the vmap above with the same `map`.
+ unsafe { bindings::drm_gem_fb_vunmap(fb, map.as_mut_ptr()) };
+ result
+}
+
+/// Convert the mapped XRGB8888 frame to RGB565, Vino-encode it against the previous
+/// frame, and bulk-write the resulting EP08 frame to the dock.
+fn encode_and_send(
+ data: &VinoDrmData,
+ head: &Head,
+ vaddr: *const u8,
+ w: usize,
+ h: usize,
+ damage: Option<(usize, usize, usize, usize)>,
+ rotation: u32,
+ gamma: Option<&[[u8; 256]; 3]>,
+) -> Result {
+ // Convert XRGB8888 (LE bytes B,G,R,X) -> RGB565 and encode, all under the scanout lock.
+ // The conversion fills a PERSISTENT `cur` buffer (allocated once with the encoder) in
+ // place -- no per-frame ~4 MiB kmalloc, which is what was failing with ENOMEM and flooding
+ // the log. The encoder's shadow buffer is mutable state, so the lock is needed regardless.
+ let frame = {
+ let mut st = head.scanout.lock();
+ // On the first frame `cur` is freshly zeroed, so the whole buffer must be filled
+ // regardless of the advertised damage (a partial fill would scan out black around
+ // the damage box). Afterwards, unchanged regions of `cur` already hold the previous
+ // frame (== the shadow the encoder diffs against), so converting only the damage box
+ // is correct and skips the rest of the XRGB8888->RGB565 work.
+ // Re-initialise the encoder/shadow/conversion buffers on the first frame AND whenever
+ // the framebuffer geometry changes (a mode switch), so they always match `cur`'s size.
+ let first = st.enc.is_none() || st.dims != (w, h);
+ if first {
+ st.enc = Some(super::video::Encoder::new(w, h, super::video::Mode::Rle)?);
+ st.cur = VVec::from_elem(0u16, w * h, GFP_KERNEL)?;
+ st.dims = (w, h);
+ st.hint = 0; // previous frame's size no longer applies at the new geometry
+ }
+ // Source framebuffer geometry: a 90 deg/270 deg rotation makes the source portrait relative
+ // to the displayed `w`x`h`, so its packed row pitch tracks the *source* width.
+ let (sw, sh) = if rotation_swaps_dims(rotation) { (h, w) } else { (w, h) };
+ let pitch = sw * 4;
+ // Damage clips are in source coordinates and don't map cleanly through a rotation, so
+ // convert the whole frame on a (re)allocation OR whenever a rotation/reflection is in
+ // effect; the encoder still shadow-diffs, so unchanged pixels emit nothing regardless.
+ // A gamma LUT recolours every pixel, so it also forces a full convert (still no extra
+ // wire traffic -- the recoloured output is identical frame-to-frame for static
+ // content,
+ // so the shadow-diff emits nothing for unchanged regions).
+ let full = first || rotation_active(rotation) || gamma.is_some();
+ let (x0, y0, x1, y1) = if full { (0, 0, w, h) } else { damage.unwrap_or((0, 0, w, h)) };
+ // Split-borrow the fields so the in-place fill and the &mut encode can coexist.
+ let ScanoutState { enc, cur, seq, hint, dims: _, active_timing: _ } = &mut *st;
+ for dy in y0..y1 {
+ for dx in x0..x1 {
+ // Output pixel (dx,dy) -> source pixel under the plane rotation/reflection.
+ let (sx, sy) = rot_src(rotation, dx, dy, sw, sh);
+ // SAFETY: `sy*pitch + sx*4 + 3` is within the mapped source framebuffer
+ // (`sw*sh*4` bytes); `rot_src` guarantees `sx < sw`, `sy < sh`.
+ let px =
+ unsafe { (vaddr.add(sy * pitch + sx * 4) as *const u32).read_unaligned() };
+ let (mut r, mut g, mut b) =
+ (((px >> 16) & 0xff) as usize, ((px >> 8) & 0xff) as usize, (px & 0xff) as usize);
+ // Apply the CRTC gamma LUT host-side (DLM gamma-corrects pixels before
+ // encoding -- there is no dock-side gamma CP message; see `read_gamma_lut`).
+ if let Some(t) = gamma {
+ r = t[0][r] as usize;
+ g = t[1][g] as usize;
+ b = t[2][b] as usize;
+ }
+ cur[dy * w + dx] =
+ (((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)) as u16;
+ }
+ }
+ let s = *seq;
+ *seq = seq.wrapping_add(1);
+ let enc = enc.as_mut().ok_or(ENOMEM)?;
+ // Encode straight into the outgoing frame buffer: reserve the EP08 header up
+ // front, append the codec stream in place, then back-patch the header now that
+ // the payload length is known. This replaces a two-allocation/extra-copy path
+ // (encode -> KVec, then frame_to_ep08 -> second KVec) with a single buffer,
+ // and `hint` pre-sizes it from the last frame so the encode rarely reallocates.
+ const HDR: usize = super::video::EP08_HDR_LEN;
+ let mut frame = KVec::with_capacity((*hint).max(HDR + 64), GFP_KERNEL)?;
+ frame.extend_from_slice(&[0u8; HDR], GFP_KERNEL)?; // header placeholder
+ enc.encode_into(&*cur, &mut frame)?;
+ let payload_len = frame.len() - HDR;
+ super::video::write_ep08_header(&mut frame[..HDR], payload_len, s);
+ *hint = frame.len();
+ frame
+ };
+
+ // Push the frame to this head's video endpoint (lock released).
+ let dev: &super::usb::Device = data.intf.as_ref();
+ // First live-scanout frame: clear-halt the four iface-0 bulk-OUT video endpoints
+ // (0x08 main + 0x0a/0x0b/0x0c aux, covering every head) so the first write doesn't
+ // ETIMEDOUT on a stale endpoint toggle. DLM clear-halts these at engagement (the
+ // "startRender" step). Once, globally.
+ if !super::EP08_SCANOUT_PRIMED.swap(true, core::sync::atomic::Ordering::SeqCst) {
+ for ep in [0x08u8, 0x0a, 0x0b, 0x0c] {
+ let _ = dev.clear_halt(ep);
+ }
+ pr_info!("vino: video endpoints primed (clear-halt 8/10/11/12)\n");
+ }
+ // Head 0 -> EP 0x08, head 1 -> EP 0x0a (see `HEAD_EP`).
+ dev.bulk_send(head.video_ep(), &frame, super::timeout())?;
+ Ok(())
+}
+
+/// Send the cursor CP messages for the current sprite + position (called from
+/// [`cursor_atomic_update`]). `fb` is the cursor sprite framebuffer (`None`/null = hidden),
+/// `w`x`h` its size, `(cx, cy)` the on-CRTC position. Sends `create` (once, the constant
+/// sprite size), `image` (the ARGB8888 sprite -- its little-endian memory bytes are already
+/// the
+/// `B,G,R,A` order the dock wants, copied row-by-row to honour the framebuffer pitch), then a
+/// `move`. Every send routes through [`VinoDrmData::send_cp`], so all of this is a no-op until
+/// the CP cipher engages (the wall). A hidden cursor currently just re-issues a move (a
+/// dedicated hide message is a future refinement).
+fn cursor_send(
+ data: &VinoDrmData,
+ head: &Head,
+ fb: *mut bindings::drm_framebuffer,
+ w: usize,
+ h: usize,
+ cx: i32,
+ cy: i32,
+) -> Result {
+ let hid = head.index; // cursor messages carry the head id at off22; CP routes by head too
+ let (mx, my) = (
+ cx.clamp(0, u16::MAX as i32) as u16,
+ cy.clamp(0, u16::MAX as i32) as u16,
+ );
+ if fb.is_null() || w == 0 || h == 0 {
+ // Hidden cursor: no sprite to upload, just track the position.
+ return data.send_cp(hid, 0x1a, 0, |ctr| super::cp::cursor_move(ctr, hid, mx, my));
+ }
+ // Declare the sprite dimensions once per head, then upload the bitmap. We don't diff sprite
+ // content yet, so the image is re-sent on every sprite-present update.
+ if !head.cursor_primed.swap(true, core::sync::atomic::Ordering::SeqCst) {
+ data.send_cp(hid, 0x1b, 0, |ctr| super::cp::cursor_create(ctr, w as u16, h as u16))?;
+ }
+ // vmap the sprite; copy `w*h*4` BGRA bytes row-by-row (the source pitch is `w*4` for our
+ // packed cursor buffers, but copying per-row keeps it correct if that ever changes).
+ let pitch = w * 4;
+ // SAFETY: `iosys_map` is POD; all-zero is a valid "not mapped" value `drm_gem_fb_vmap`
+ // fills.
+ let mut map: [bindings::iosys_map; 4] = unsafe { core::mem::zeroed() };
+ let mut dmap: [bindings::iosys_map; 4] = unsafe { core::mem::zeroed() };
+ // SAFETY: `fb` is a valid cursor framebuffer with GEM-backed storage.
+ to_result(unsafe { bindings::drm_gem_fb_vmap(fb, map.as_mut_ptr(), dmap.as_mut_ptr()) })?;
+ // SAFETY: plane 0's CPU virtual address, valid until `drm_gem_fb_vunmap`.
+ let vaddr = unsafe { map[0].__bindgen_anon_1.vaddr } as *const u8;
+ let res = if vaddr.is_null() {
+ Err(EINVAL)
+ } else {
+ (|| -> Result {
+ let mut bgra = KVec::with_capacity(w * h * 4, GFP_KERNEL)?;
+ for y in 0..h {
+ // SAFETY: `[y*pitch, y*pitch + w*4)` is within the mapped `h*pitch` sprite.
+ let row = unsafe { core::slice::from_raw_parts(vaddr.add(y * pitch), w * 4) };
+ bgra.extend_from_slice(row, GFP_KERNEL)?;
+ }
+ data.send_cp(hid, 0x1c, 0, |ctr| {
+ super::cp::cursor_image(ctr, w as u16, h as u16, &bgra)
+ })
+ })()
+ };
+ // SAFETY: balances the vmap above with the same `map`.
+ unsafe { bindings::drm_gem_fb_vunmap(fb, map.as_mut_ptr()) };
+ res?;
+ data.send_cp(hid, 0x1a, 0, |ctr| super::cp::cursor_move(ctr, hid, mx, my))
+}
+
+/// Wire up the atomic KMS pipeline on `ddev` (called after `drm::Device::new` and
+/// before `drm_dev_register`). Sets `mode_config`, builds the virtual connector,
+/// and initialises the atomic CRTC + primary/cursor planes + virtual encoder.
+pub(super) fn kms_init<C: drm::DeviceContext>(
+ ddev: &drm::Device<VinoDrmDriver, C>,
+) -> Result {
+ let raw = ddev.as_raw();
+ // Deref `drm::Device<T>` -> `T::Data` to reach the embedded C objects.
+ let data: &VinoDrmData = ddev;
+
+ // SAFETY: `raw` is a valid, not-yet-registered drm_device; the funcs/objects
+ // referenced below live in device-private memory (`data`) for its lifetime.
+ unsafe {
+ to_result(bindings::drmm_mode_config_init(raw))?;
+
+ let mc = &mut (*raw).mode_config;
+ mc.min_width = 0;
+ mc.min_height = 0;
+ mc.max_width = 4096;
+ mc.max_height = 4096;
+ // Advertise a 64x64 hardware cursor (the dock's cursor sprite size) so userspace
+ // drives the cursor plane instead of compositing the pointer into the framebuffer.
+ mc.cursor_width = CURSOR_SIZE;
+ mc.cursor_height = CURSOR_SIZE;
+ let mcf = data.mode_cfg_funcs.get();
+ (*mcf).fb_create = Some(bindings::drm_gem_fb_create);
+ // `vino_atomic_check` = the standard atomic check + the combined cross-head USB
+ // bandwidth budget (rejects e.g. two simultaneous 4K modes).
+ (*mcf).atomic_check = Some(vino_atomic_check);
+ (*mcf).atomic_commit = Some(bindings::drm_atomic_helper_commit);
+ mc.funcs = mcf;
+
+ // ---- Shared vtables (one set for every head; the callbacks recover the head from
+ // the C object pointer). Plane/CRTC `atomic_check` are left NULL: a virtual sink
+ // accepts any configuration, and the helpers still invoke `atomic_update`/
+ // `atomic_enable` because the objects are assigned to the CRTC.
+
+ // Connector funcs + helper. We report presence from the cached EDID (see `detect`)
+ // and deliver HPD ourselves (`set_edid`/`fire_hotplug`).
+ let cf = data.conn_funcs.get();
+ (*cf).fill_modes = Some(bindings::drm_helper_probe_single_connector_modes);
+ (*cf).detect = Some(detect);
+ (*cf).destroy = Some(bindings::drm_connector_cleanup);
+ (*cf).reset = Some(bindings::drm_atomic_helper_connector_reset);
+ (*cf).atomic_duplicate_state =
+ Some(bindings::drm_atomic_helper_connector_duplicate_state);
+ (*cf).atomic_destroy_state =
+ Some(bindings::drm_atomic_helper_connector_destroy_state);
+ (*data.conn_helper.get()).get_modes = Some(get_modes);
+ // Prune any single mode above the per-head pixel-clock ceiling (~4K@60).
+ (*data.conn_helper.get()).mode_valid = Some(mode_valid);
+
+ // One `drm_plane_funcs` shared by both planes; per-plane helper funcs (the primary's
+ // `atomic_update` scans out, the cursor's sends cursor CP).
+ let plf = data.plane_funcs.get();
+ (*plf).update_plane = Some(bindings::drm_atomic_helper_update_plane);
+ (*plf).disable_plane = Some(bindings::drm_atomic_helper_disable_plane);
+ (*plf).destroy = Some(bindings::drm_plane_cleanup);
+ (*plf).reset = Some(bindings::drm_atomic_helper_plane_reset);
+ (*plf).atomic_duplicate_state =
+ Some(bindings::drm_atomic_helper_plane_duplicate_state);
+ (*plf).atomic_destroy_state = Some(bindings::drm_atomic_helper_plane_destroy_state);
+ (*data.primary_helper.get()).atomic_update = Some(primary_atomic_update);
+ (*data.cursor_helper.get()).atomic_update = Some(cursor_atomic_update);
+
+ // CRTC funcs + helper.
+ let crf = data.crtc_funcs.get();
+ (*crf).set_config = Some(bindings::drm_atomic_helper_set_config);
+ (*crf).page_flip = Some(bindings::drm_atomic_helper_page_flip);
+ (*crf).destroy = Some(bindings::drm_crtc_cleanup);
+ (*crf).reset = Some(bindings::drm_atomic_helper_crtc_reset);
+ (*crf).atomic_duplicate_state =
+ Some(bindings::drm_atomic_helper_crtc_duplicate_state);
+ (*crf).atomic_destroy_state = Some(bindings::drm_atomic_helper_crtc_destroy_state);
+ let crh = data.crtc_helper.get();
+ (*crh).atomic_enable = Some(crtc_atomic_enable);
+ (*crh).atomic_disable = Some(crtc_atomic_disable);
+
+ // Encoder funcs.
+ (*data.encoder_funcs.get()).destroy = Some(bindings::drm_encoder_cleanup);
+
+ // Build each head's objects (connector + primary/cursor planes + CRTC + encoder).
+ for head in data.heads() {
+ build_head(raw, data, head)?;
+ }
+
+ drm_mode_config_reset(raw);
+ }
+ Ok(())
+}
+
+/// Build one head's KMS objects -- connector + primary plane (scanout) + cursor plane + CRTC +
+/// virtual encoder -- using the shared vtables already filled in `data`. Each is a complete
+/// independent output (its own CRTC), so the compositor sees [`NHEADS`] monitors and routes
+/// each to its own video EP / CP stream (see [`Head`]).
+///
+/// SAFETY: `raw` is a valid, not-yet-registered drm_device; the `data`/`head` C objects live
+/// in device-private memory for its lifetime.
+unsafe fn build_head(raw: *mut bindings::drm_device, data: &VinoDrmData, head: &Head) -> Result {
+ // SAFETY: see the function contract; every object/vtable below is device-private memory.
+ unsafe {
+ // Connector.
+ let conn = head.connector.get();
+ to_result(bindings::drm_connector_init(
+ raw,
+ conn,
+ data.conn_funcs.get(),
+ bindings::DRM_MODE_CONNECTOR_VIRTUAL as i32,
+ ))?;
+ (*conn).helper_private = data.conn_helper.get();
+ (*conn).polled = bindings::DRM_CONNECTOR_POLL_HPD as u8;
+
+ // Primary plane (XRGB8888 scanout). `possible_crtcs` is fixed up once the CRTC exists.
+ let primary = head.primary.get();
+ to_result(bindings::drm_universal_plane_init(
+ raw,
+ primary,
+ 0,
+ data.plane_funcs.get(),
+ PRIMARY_FORMATS.as_ptr(),
+ PRIMARY_FORMATS.len() as u32,
+ ptr::null(),
+ bindings::drm_plane_type_DRM_PLANE_TYPE_PRIMARY,
+ ptr::null(),
+ ))?;
+ (*primary).helper_private = data.primary_helper.get();
+
+ // Cursor plane (ARGB8888 sprite).
+ let cursor = head.cursor.get();
+ to_result(bindings::drm_universal_plane_init(
+ raw,
+ cursor,
+ 0,
+ data.plane_funcs.get(),
+ CURSOR_FORMATS.as_ptr(),
+ CURSOR_FORMATS.len() as u32,
+ ptr::null(),
+ bindings::drm_plane_type_DRM_PLANE_TYPE_CURSOR,
+ ptr::null(),
+ ))?;
+ (*cursor).helper_private = data.cursor_helper.get();
+
+ // CRTC with both planes, plus a GAMMA_LUT (applied host-side in `read_gamma_lut`).
+ let crtc = head.crtc.get();
+ to_result(bindings::drm_crtc_init_with_planes(
+ raw,
+ crtc,
+ primary,
+ cursor,
+ data.crtc_funcs.get(),
+ ptr::null(),
+ ))?;
+ (*crtc).helper_private = data.crtc_helper.get();
+ bindings::drm_crtc_enable_color_mgmt(crtc, 0, false, GAMMA_SIZE);
+
+ // The CRTC now has an index: bind both planes and the encoder to it.
+ let crtc_mask = 1u32 << (*crtc).index;
+ (*primary).possible_crtcs = crtc_mask;
+ (*cursor).possible_crtcs = crtc_mask;
+
+ // Virtual encoder bound to this head's connector.
+ let encoder = head.encoder.get();
+ to_result(bindings::drm_encoder_init(
+ raw,
+ encoder,
+ data.encoder_funcs.get(),
+ bindings::DRM_MODE_ENCODER_VIRTUAL as i32,
+ ptr::null(),
+ ))?;
+ (*encoder).possible_crtcs = crtc_mask;
+ to_result(bindings::drm_connector_attach_encoder(conn, encoder))?;
+
+ // Rotation property on the primary plane (DLM rotates host-side; vino remaps in the
+ // scanout encode -- see `rot_src`). Canonical helper; non-fatal on failure.
+ let supported = bindings::DRM_MODE_ROTATE_0
+ | bindings::DRM_MODE_ROTATE_90
+ | bindings::DRM_MODE_ROTATE_180
+ | bindings::DRM_MODE_ROTATE_270
+ | bindings::DRM_MODE_REFLECT_X
+ | bindings::DRM_MODE_REFLECT_Y;
+ let rc = bindings::drm_plane_create_rotation_property(
+ primary,
+ bindings::DRM_MODE_ROTATE_0,
+ supported,
+ );
+ if rc != 0 {
+ pr_warn!("vino: head{} rotation property unavailable ({rc})\n", head.index);
+ }
+ }
+ Ok(())
+}
+
+/// Thin wrapper so the `unsafe` block above reads cleanly.
+unsafe fn drm_mode_config_reset(raw: *mut bindings::drm_device) {
+ // SAFETY: `raw` is a valid drm_device with mode_config initialised.
+ unsafe { bindings::drm_mode_config_reset(raw) };
+}
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index e9e6324b717b..1091dcc992c7 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -44,6 +44,7 @@
use kernel::{
alloc::flags::GFP_KERNEL,
bindings,
+ drm,
device::{self, Core},
error::code::{ENODEV, EINVAL},
prelude::*,
@@ -79,6 +80,24 @@ fn timeout() -> Delta {
/// hardware -- the dock runs the whole plaintext handshake but never engages the encrypted CP.
static CP_ENGAGED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
+/// One-shot: clear-halt + prime the video endpoints before the first live-scanout EP08 write.
+static EP08_SCANOUT_PRIMED: core::sync::atomic::AtomicBool =
+ core::sync::atomic::AtomicBool::new(false);
+
+/// Consecutive failed live-scanout frames, for log rate-limiting. Until CP engages, the dock
+/// NAKs every EP08 write (EPROTO), so without this every compositor pageflip would spam dmesg.
+static SCANOUT_FAILS: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
+
+/// Pageflip throttle: number of upcoming pageflips to skip before the next scanout attempt
+/// (a backoff while the dock NAKs). A single successful frame clears it.
+static SCANOUT_SKIP: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
+
+/// Set once the bring-up work item finishes (AKE/CP attempt done). `detect` only connects the
+/// live-scanout connector AFTER this, so a compositor enabling the output cannot start EP08
+/// scanout on top of the still-running AKE on the same USB device.
+static BRINGUP_COMPLETE: core::sync::atomic::AtomicBool =
+ core::sync::atomic::AtomicBool::new(false);
+
mod proto;
mod crypto;
mod rng;
@@ -103,9 +122,13 @@ struct Session {
cap_announce: KVec<u8>,
}
+mod drm_sink;
+
/// Per-bound-interface driver state.
struct VinoDriver {
_intf: ARef<usb::Interface>,
+ /// The registered `drm::Device` (only on the control interface, iface 0).
+ _ddev: Option<ARef<drm_sink::VinoDrmDevice>>,
}
/// Deferred bring-up work item: the bring-up sequence run on the system workqueue instead
@@ -115,6 +138,7 @@ struct VinoDriver {
#[pin_data]
struct BringUp {
intf: ARef<usb::Interface>,
+ ddev: Option<ARef<drm_sink::VinoDrmDevice>>,
#[pin]
work: Work<BringUp>,
}
@@ -124,10 +148,14 @@ impl HasWork<Self> for BringUp { self.work }
}
impl BringUp {
- fn new(intf: ARef<usb::Interface>) -> Result<Arc<Self>> {
+ fn new(
+ intf: ARef<usb::Interface>,
+ ddev: Option<ARef<drm_sink::VinoDrmDevice>>,
+ ) -> Result<Arc<Self>> {
Arc::pin_init(
pin_init!(BringUp {
intf,
+ ddev,
work <- new_work!("vino::bring_up"),
}),
GFP_KERNEL,
@@ -141,39 +169,73 @@ impl WorkItem for BringUp {
fn run(this: Arc<BringUp>) {
let cdev: &device::Device = this.intf.as_ref();
let dev: &usb::Device = this.intf.as_ref();
- // WIP scaffold: plaintext bring-up, the clean-room HDCP 2.2 AKE/LC/SKE, then the
- // post-SKE CP setup. Bind regardless of the outcome -- there is no display path until
- // the dock engages the encrypted control plane, which it currently never does (see the
- // "help wanted" note at the top of the file). The DRM sink lands in a later patch.
+ let ddev = &this.ddev;
+ // WIP scaffold: attempt the plaintext bring-up, then the clean-room HDCP 2.2
+ // AKE/LC/SKE, then the post-SKE CP setup. Bind regardless of the outcome -- there
+ // is no display path until the dock engages the encrypted control plane, which it
+ // currently never does (see the "help wanted" note at the top of the file).
match VinoDriver::bring_up(dev) {
Ok(()) => {
dev_info!(cdev, "vino: plaintext session init OK\n");
match VinoDriver::run_ake(dev) {
Ok(session) => {
dev_info!(cdev, "vino: HDCP AKE + LC + SKE complete (session keyed)\n");
+ // Dev diagnostic: the live session key/riv, so the dock's encrypted
+ // EP84 replies can be decoded offline from a usbmon capture. Behind
+ // pr_debug, so compiled out unless dynamic debug is enabled.
pr_debug!("vino: SESSION ks={:02x?} riv={:02x?}\n", &session.ks, &session.riv);
- // Phase 2c: drive the post-SKE CP setup. send_cp_setup re-seals DLM's
- // captured setup template under THIS session's live ks/riv and sends it;
- // `acks` counts the dock's encrypted wsub=0x45 replies. THIS IS THE WALL:
- // on a cold dock `acks` stays 0 -- the dock runs the entire plaintext
- // handshake but never engages the encrypted CP.
+
+ // Phase 2c: drive the post-SKE CP setup. send_cp_setup re-seals
+ // DLM's captured setup template under THIS session's live ks/riv and
+ // sends it; `acks` counts the dock's encrypted wsub=0x45 replies.
+ // THIS IS THE WALL: on a cold dock `acks` stays 0 -- the dock runs the
+ // entire plaintext handshake but never engages the encrypted CP.
let mut edid_out: Option<KVec<u8>> = None;
match VinoDriver::send_cp_setup(dev, &session, &mut edid_out) {
- Ok((n, acks, _wseq_end, _ctr_end)) => {
+ Ok((n, acks, wseq_end, ctr_end)) => {
dev_info!(cdev,
"vino: CP setup sent -- {n} messages, {acks} dock CP acks (wsub=0x45)\n");
- // CP engagement gates EP08 video (added in a later patch): until
- // the dock acks, pushing pixels at it wedges the hub.
+ // CP engagement gates EP08 video: until the dock acks, pushing
+ // pixels at it wedges the hub.
CP_ENGAGED.store(acks > 0, core::sync::atomic::Ordering::SeqCst);
+ // Publish the engaged session to the DRM device so the KMS
+ // callbacks
+ // can send runtime CP (mode-set on a modeset, cursor on motion),
+ // continuing this keystream. Only when the dock actually engaged.
+ if acks > 0 {
+ if let Some(d) = ddev.as_ref() {
+ let data: &drm_sink::VinoDrmData = d;
+ data.publish_session(
+ &session.ks, &session.riv, wseq_end, ctr_end,
+ );
+ }
+ }
}
Err(e) => dev_info!(cdev, "vino: CP setup incomplete ({e:?}) -- WIP\n"),
}
+ // Cache the dock's EDID on the DRM device (when the CP channel
+ // delivered it) so the connector's get_modes installs the real
+ // monitor descriptor via the standard DRM EDID helpers.
+ if let (Some(blob), Some(d)) = (edid_out, ddev.as_ref()) {
+ let n = blob.len();
+ let data: &drm_sink::VinoDrmData = d;
+ data.set_edid(blob);
+ dev_info!(cdev, "vino: cached dock EDID for connector ({n} bytes)\n");
+ }
}
Err(e) => dev_info!(cdev, "vino: HDCP AKE incomplete ({e:?}) -- WIP\n"),
}
}
Err(e) => dev_info!(cdev, "vino: session init incomplete ({e:?}) -- WIP\n"),
}
+ // Bring-up attempt finished: allow the live-scanout connector to report connected
+ // and let a compositor drive EP08 frames, without racing the handshake.
+ BRINGUP_COMPLETE.store(true, core::sync::atomic::Ordering::SeqCst);
+ if let Some(d) = ddev.as_ref() {
+ let data: &drm_sink::VinoDrmData = d;
+ data.fire_hotplug();
+ dev_info!(cdev, "vino: bring-up complete -- live-scanout connector now connected\n");
+ }
}
}
@@ -1596,16 +1658,59 @@ fn probe<'bound>(
return Err(ENODEV);
}
dev_info!(cdev, "vino: bound D6000 interface {ifnum} (idle -- control is iface 0)\n");
- return Ok(Self { _intf: intf.into() });
+ return Ok(Self { _intf: intf.into(), _ddev: None });
}
dev_info!(cdev, "vino: bound DisplayLink D6000 -- plaintext session bring-up\n");
- // Bring-up is blocking synchronous USB I/O; hand it to the system workqueue so
- // probe() returns immediately and userspace stays responsive. The work item holds
- // a refcounted handle to the interface, so the bulk endpoints outlive probe(); USB
- // I/O after an intervening disconnect simply errors and is logged.
+ // Phase 3: register a real DRM/KMS device on the control interface so the dock
+ // shows up as a mode-settable `card`/`renderD` node (atomic KMS via the simple
+ // display pipe, one 1080p virtual connector, GEM-shmem dumb buffers). Non-fatal:
+ // bring-up still proceeds (and the interface still binds) if any step fails, so
+ // a DRM-core hiccup can't regress the USB session work.
+ // Hold a refcounted handle to the bound interface; one copy goes into the DRM
+ // device-private (for the EP08 scanout path), one stays in `VinoDriver`.
let intf_ref: ARef<usb::Interface> = intf.into();
- match BringUp::new(intf_ref.clone()) {
+ // DRM device lifecycle (drm-rust API): allocate an `UnregisteredDevice`, wire up
+ // the KMS pipeline on it while still unregistered, then hand it to
+ // `Registration::new_foreign_owned` (which registers it and ties its lifetime to
+ // the bound USB device via devres, returning a borrowed `&Device`).
+ let ddev: Option<ARef<drm_sink::VinoDrmDevice>> =
+ match drm::UnregisteredDevice::<drm_sink::VinoDrmDriver>::new(
+ cdev,
+ drm_sink::VinoDrmData::new(intf_ref.clone()),
+ ) {
+ Ok(unreg) => match drm_sink::kms_init(&unreg) {
+ Ok(()) => match drm::driver::Registration::new_foreign_owned(unreg, cdev, 0) {
+ Ok(reg_dev) => {
+ dev_info!(cdev, "vino: DRM+KMS device registered (card node live, 1080p)\n");
+ Some(reg_dev.into())
+ }
+ Err(e) => {
+ dev_info!(cdev, "vino: DRM registration failed ({e:?}) -- continuing without card node\n");
+ None
+ }
+ },
+ Err(e) => {
+ dev_info!(cdev, "vino: KMS init failed ({e:?}) -- continuing without card node\n");
+ None
+ }
+ },
+ Err(e) => {
+ dev_info!(cdev, "vino: drm::UnregisteredDevice::new failed ({e:?}) -- continuing\n");
+ None
+ }
+ };
+
+ // Bring-up (preamble + HDCP AKE + ~6 s of lockstep CP replay) is all blocking
+ // synchronous USB I/O. Running it inline here pins the USB driver-model probe
+ // thread while the DRM card node is already registered and live, which stalled
+ // the compositor (KWin) on first plug until the dock was physically yanked. Hand
+ // it to the system workqueue so `probe()` returns immediately and userspace KMS
+ // stays responsive. The work item holds refcounted handles to the interface (for
+ // the bulk endpoints) and the DRM device (for EDID caching), so they outlive
+ // `probe()`; USB I/O after an intervening disconnect simply errors and is logged,
+ // exactly like any other failed bring-up step.
+ match BringUp::new(intf_ref.clone(), ddev.clone()) {
Ok(work) => {
let _ = workqueue::system().enqueue(work);
dev_info!(cdev, "vino: bring-up queued on system workqueue\n");
@@ -1613,7 +1718,7 @@ fn probe<'bound>(
Err(e) => dev_info!(cdev, "vino: failed to queue bring-up ({e:?}) -- WIP\n"),
}
- Ok(Self { _intf: intf_ref })
+ Ok(Self { _intf: intf_ref, _ddev: ddev })
}
fn disconnect<'bound>(intf: &'bound usb::Interface<Core<'_>>, _data: Pin<&Self>) {
--
2.54.0
^ permalink raw reply related [flat|nested] 12+ messages in thread
* [RFC PATCH 6/7] drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info
2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
` (4 preceding siblings ...)
2026-06-17 15:12 ` [RFC PATCH 5/7] drm/vino: register a DRM/KMS device and scan out to EP08 Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
2026-06-17 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
7 siblings, 0 replies; 12+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
To: dri-devel
Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
linux-kernel
Add monitor controls that ride the same control plane:
- DDC/CI Set-VCP builders (cp): brightness (VCP 0x10), contrast (0x12)
and power mode (0xD6), tunnelled to the downstream monitor's I2C slave
0x37 over the dock's monitor-I2C bridge;
- DRM connector range properties (drm_sink): a 0..=100 brightness and
contrast property per connector, whose atomic_set_property callback
pushes a DDC/CI Set-VCP write to the monitor;
- DPMS power (drm_sink): the CRTC enable/disable hooks emit VCP 0xD6
on/off so a blanked output drops the monitor to standby instead of
freezing the last frame.
All are no-ops until the CP cipher engages (the open blocker), so they
are inert on current hardware but correct by construction (the DDC/CI
payloads are unit-tested against the VESA MCCS worked examples).
Also query the dock's DFU device info at bring-up (firmware version,
customer/board id) -- device-level vendor reads independent of the CP
channel, useful for diagnostics and confirming the dock firmware
revision.
Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
drivers/gpu/drm/vino/cp.rs | 51 +++++++++++
drivers/gpu/drm/vino/drm_sink.rs | 141 ++++++++++++++++++++++++++++++-
drivers/gpu/drm/vino/vino.rs | 18 ++++
3 files changed, 206 insertions(+), 4 deletions(-)
diff --git a/drivers/gpu/drm/vino/cp.rs b/drivers/gpu/drm/vino/cp.rs
index 2668931d8500..be2bdcf5557c 100644
--- a/drivers/gpu/drm/vino/cp.rs
+++ b/drivers/gpu/drm/vino/cp.rs
@@ -112,6 +112,57 @@ pub(super) fn set_mode(counter: u16, t: &Timing) -> Result<KVec<u8>> {
Ok(b)
}
+/// Standard VESA MCCS (Monitor Control Command Set 2.2) VCP feature codes, driven over
+/// DDC/CI. The macOS DisplayLink agent exposes these as per-display brightness/contrast
+/// ("Popover did show -- starting DDC/CI communication", `setBrightness`/`setContrast`); the
+/// dock bridges the DDC/CI transaction to the downstream monitor's I2C slave 0x37 -- the same
+/// monitor-I2C path the EDID read ([`get_edid_req`]) uses for the 0x50 EDID slave.
+pub(super) const VCP_BRIGHTNESS: u8 = 0x10;
+pub(super) const VCP_CONTRAST: u8 = 0x12;
+/// VCP 0xD6 "Power mode": value 0x01 = on, 0x04 = off (DPMS-off / hard standby). Lets DPMS
+/// blank the panel backlight instead of freezing the last frame (see [`crtc_atomic_disable`]).
+pub(super) const VCP_POWER_MODE: u8 = 0xd6;
+pub(super) const POWER_ON: u16 = 0x01;
+pub(super) const POWER_OFF: u16 = 0x04;
+
+/// Build a DDC/CI "Set VCP Feature" request: the 7 bytes a DDC/CI host writes to the
+/// monitor's I2C slave 0x37, after the 0x6e (= 0x37<<1) write address (VESA DDC/CI 1.1
+/// sec 4.4). Layout: source 0x51, length `0x80 | 4`, opcode 0x03 (Set VCP), VCP code,
+/// value-hi, value-lo, then an XOR checksum seeded with the destination address 0x6e. Pure
+/// and fully standard, so it is unit-tested byte-exact against the spec
+/// ([`super::tests::ddc_ci_set_vcp_checksum`]).
+pub(super) fn ddc_ci_set_vcp(vcp: u8, value: u16) -> [u8; 7] {
+ let body = [0x51u8, 0x84, 0x03, vcp, (value >> 8) as u8, value as u8];
+ let mut chk = 0x6eu8; // checksum seed = destination slave-write address (0x37 << 1)
+ for &x in &body {
+ chk ^= x;
+ }
+ [body[0], body[1], body[2], body[3], body[4], body[5], chk]
+}
+
+/// CP message that tunnels a DDC/CI Set-VCP write to the downstream monitor -- the brightness,
+/// contrast and DPMS-power controls the macOS/Windows agents drive over "DDC/CI communication".
+/// The dock's monitor-I2C bridge is the same one the EDID read uses, so this is modelled as the
+/// WRITE companion to the `0x15/0x21` EDID read: `id=0x15 sub=0x22`, carrying the I2C slave
+/// (0x37) + payload length at off20 and the 7-byte DDC/CI Set-VCP payload at off22.
+///
+/// The `id`/`sub` and payload offset are **inferred** from the EDID-read pairing -- the write
+/// transaction was never captured (it only fires once a monitor is actively driven, i.e. past
+/// the CP wall), so re-check against a capture once CP engages. The DDC/CI bytes themselves
+/// ([`ddc_ci_set_vcp`]) are standard and verified.
+pub(super) fn ddc_set_vcp(counter: u16, vcp: u8, value: u16) -> Result<KVec<u8>> {
+ let payload = ddc_ci_set_vcp(vcp, value);
+ let mut b = KVec::with_capacity(32, GFP_KERNEL)?;
+ header(&mut b, 0x15, 0x22, counter)?;
+ pad_to(&mut b, 20)?;
+ // off20: monitor DDC/CI I2C slave (0x37) + DDC/CI payload length.
+ b.extend_from_slice(&[0x37, payload.len() as u8], GFP_KERNEL)?;
+ // off22: the DDC/CI Set-VCP bytes (same off22 convention as the EDID payload).
+ b.extend_from_slice(&payload, GFP_KERNEL)?;
+ pad_to(&mut b, 32)?;
+ Ok(b)
+}
+
/// EDID base-block sanity check: length, the `00 FF..FF 00` magic, and the 1-byte
/// checksum (all 128 base bytes sum to 0 mod 256). A corrupt blob must never drive a
/// mode-set, so [`timing_from_edid`] rejects anything that fails this.
diff --git a/drivers/gpu/drm/vino/drm_sink.rs b/drivers/gpu/drm/vino/drm_sink.rs
index afbf883fba36..bcc871958a8a 100644
--- a/drivers/gpu/drm/vino/drm_sink.rs
+++ b/drivers/gpu/drm/vino/drm_sink.rs
@@ -129,6 +129,11 @@ pub(super) struct Head {
/// One-shot: this head's cursor `create` (sprite dimensions) was sent before its first
/// image upload (per head -- the global one would skip head 1's create).
cursor_primed: core::sync::atomic::AtomicBool,
+ /// Last DDC/CI brightness/contrast (0..=100) set on this head's monitor via the connector
+ /// properties; replayed on DPMS-on. Stored here (not in connector state) because DDC/CI is
+ /// a side-band action on the physical monitor, not part of the atomic scanout pipeline.
+ brightness: core::sync::atomic::AtomicU32,
+ contrast: core::sync::atomic::AtomicU32,
#[pin]
scanout: Mutex<ScanoutState>,
/// This head's downstream-monitor EDID (`None` until the CP channel delivers it). Only
@@ -167,6 +172,8 @@ fn z<T>() -> impl PinInit<Opaque<T>, Error> {
try_pin_init!(Self {
index,
cursor_primed: core::sync::atomic::AtomicBool::new(false),
+ brightness: core::sync::atomic::AtomicU32::new(100),
+ contrast: core::sync::atomic::AtomicU32::new(100),
scanout <- new_mutex!(ScanoutState {
enc: None,
cur: VVec::new(),
@@ -239,6 +246,12 @@ pub(super) struct VinoDrmData {
encoder_funcs: Opaque<bindings::drm_encoder_funcs>,
#[pin]
mode_cfg_funcs: Opaque<bindings::drm_mode_config_funcs>,
+ /// The custom 0..=100 connector range properties for DDC/CI brightness/contrast, created
+ /// and attached in [`kms_init`]. Stored so the connector `atomic_set_property` /
+ /// `atomic_get_property` callbacks can identify them by pointer. Written once during
+ /// single-threaded probe, read-only thereafter (`AtomicPtr` for `Sync` without `unsafe`).
+ brightness_prop: core::sync::atomic::AtomicPtr<bindings::drm_property>,
+ contrast_prop: core::sync::atomic::AtomicPtr<bindings::drm_property>,
}
// SAFETY: the embedded C KMS objects are written only during single-threaded
@@ -278,6 +291,8 @@ fn z<T>() -> impl PinInit<Opaque<T>, Error> {
cursor_helper <- z(),
encoder_funcs <- z(),
mode_cfg_funcs <- z(),
+ brightness_prop: core::sync::atomic::AtomicPtr::new(ptr::null_mut()),
+ contrast_prop: core::sync::atomic::AtomicPtr::new(ptr::null_mut()),
})
}
@@ -365,6 +380,14 @@ pub(super) fn send_cp(
link.counter = link.counter.wrapping_add(1);
Ok(())
}
+
+ /// Push a DDC/CI Set-VCP write to a head's downstream monitor (brightness, contrast or
+ /// DPMS power). Wraps [`super::cp::ddc_set_vcp`] (`id=0x15`); a no-op until the cipher is
+ /// engaged. Used by the brightness/contrast connector properties and by DPMS in the CRTC
+ /// enable/disable callbacks.
+ pub(super) fn set_vcp(&self, head_index: u8, vcp: u8, value: u16) -> Result {
+ self.send_cp(head_index, 0x15, 0, |ctr| super::cp::ddc_set_vcp(ctr, vcp, value))
+ }
}
/// GEM object inner data. Empty: the shmem-backed `drm::gem::shmem::Object` (which
@@ -637,6 +660,11 @@ fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 {
if let Err(e) = data.send_cp(head.index, 0x48, 16, |ctr| super::cp::set_mode(ctr, &timing)) {
pr_warn!("vino: head{} runtime mode-set send failed ({e:?})\n", head.index);
}
+ // Bring the monitor out of DPMS standby (DDC/CI VCP 0xD6 = on). Inferred wire (see
+ // `cp::ddc_set_vcp`); a no-op until CP engages, and re-applies the user's brightness.
+ let _ = data.set_vcp(head.index, super::cp::VCP_POWER_MODE, super::cp::POWER_ON);
+ let b = head.brightness.load(core::sync::atomic::Ordering::Relaxed);
+ let _ = data.set_vcp(head.index, super::cp::VCP_BRIGHTNESS, b as u16);
}
/// CRTC `.atomic_disable`: the display is turning off.
@@ -646,10 +674,12 @@ fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 {
/// re-enable (DPMS-on) re-inits the encoder and sends a **full keyframe** rather than diffing
/// against a shadow the dock may have dropped while blanked, and re-uploads the cursor sprite.
///
-/// The dock holds the last frame when video stops (it has its own scanout buffer), so the
-/// monitor freezes the last image rather than going black; a true backlight-standby would need
-/// a dock power command that is not decoded (DLM's `Standby`/`Suspend`/`TempPowerOff` are
-/// internal, vtable-dispatched events with no wire frame -- the same dead-end as gamma).
+/// The dock holds the last frame when video stops (it has its own scanout buffer), so video
+/// alone freezes the last image rather than going black. To actually blank the panel we send a
+/// DDC/CI power-off (VCP 0xD6 = off) to the monitor over the same monitor-I2C bridge the EDID
+/// read uses -- the standard MCCS power control the macOS/Windows agents drive (DLM's
+/// `Standby`/`Suspend`/`TempPowerOff` are the host-internal names for it). Inferred wire (see
+/// [`super::cp::ddc_set_vcp`]); a no-op until CP engages.
unsafe extern "C" fn crtc_atomic_disable(
crtc: *mut bindings::drm_crtc,
_state: *mut bindings::drm_atomic_commit,
@@ -671,6 +701,10 @@ fn install_edid(connector: *mut bindings::drm_connector, blob: &[u8]) -> i32 {
}
head.cursor_primed
.store(false, core::sync::atomic::Ordering::SeqCst);
+ // DPMS-off: blank the monitor backlight via DDC/CI (VCP 0xD6 = off) rather than leaving
+ // the last frame frozen on the panel. Inferred wire (see `cp::ddc_set_vcp`); no-op until
+ // CP engages.
+ let _ = data.set_vcp(head.index, super::cp::VCP_POWER_MODE, super::cp::POWER_OFF);
pr_info!("vino: KMS CRTC disable -- head{} display OFF (scanout stopped)\n", head.index);
}
@@ -1182,6 +1216,9 @@ pub(super) fn kms_init<C: drm::DeviceContext>(
Some(bindings::drm_atomic_helper_connector_duplicate_state);
(*cf).atomic_destroy_state =
Some(bindings::drm_atomic_helper_connector_destroy_state);
+ // Custom DDC/CI brightness/contrast properties (see `connector_atomic_set_property`).
+ (*cf).atomic_set_property = Some(connector_atomic_set_property);
+ (*cf).atomic_get_property = Some(connector_atomic_get_property);
(*data.conn_helper.get()).get_modes = Some(get_modes);
// Prune any single mode above the per-head pixel-clock ceiling (~4K@60).
(*data.conn_helper.get()).mode_valid = Some(mode_valid);
@@ -1216,6 +1253,26 @@ pub(super) fn kms_init<C: drm::DeviceContext>(
(*data.encoder_funcs.get()).destroy = Some(bindings::drm_encoder_cleanup);
// Build each head's objects (connector + primary/cursor planes + CRTC + encoder).
+ // DDC/CI brightness/contrast: one 0..=100 range property each, created on the device
+ // and attached to every connector in `build_head`. Non-fatal: a NULL property just
+ // means the knob is absent (kept in the AtomicPtr as NULL, ignored by the callbacks).
+ let bp = bindings::drm_property_create_range(
+ raw,
+ 0,
+ c"brightness".as_ptr().cast(),
+ 0,
+ 100,
+ );
+ data.brightness_prop.store(bp, core::sync::atomic::Ordering::Relaxed);
+ let cp = bindings::drm_property_create_range(
+ raw,
+ 0,
+ c"contrast".as_ptr().cast(),
+ 0,
+ 100,
+ );
+ data.contrast_prop.store(cp, core::sync::atomic::Ordering::Relaxed);
+
for head in data.heads() {
build_head(raw, data, head)?;
}
@@ -1322,10 +1379,86 @@ unsafe fn build_head(raw: *mut bindings::drm_device, data: &VinoDrmData, head: &
if rc != 0 {
pr_warn!("vino: head{} rotation property unavailable ({rc})\n", head.index);
}
+
+ // Attach the shared DDC/CI brightness/contrast properties to this connector, each at
+ // its default of 100 (= no attenuation). The callbacks store the value per head and
+ // fire the DDC/CI write (see `connector_atomic_set_property`).
+ let bp = data.brightness_prop.load(core::sync::atomic::Ordering::Relaxed);
+ if !bp.is_null() {
+ bindings::drm_object_attach_property(&mut (*conn).base, bp, 100);
+ }
+ let cp = data.contrast_prop.load(core::sync::atomic::Ordering::Relaxed);
+ if !cp.is_null() {
+ bindings::drm_object_attach_property(&mut (*conn).base, cp, 100);
+ }
}
Ok(())
}
+/// Connector `.atomic_set_property`: handle the custom DDC/CI brightness/contrast properties
+/// (the standard properties are handled by the DRM core, which never calls us for them). On a
+/// value change we store it on the head and immediately push a DDC/CI Set-VCP write to the
+/// monitor -- DDC/CI is a side-band action on the physical panel, not part of the atomic
+/// scanout state, so it is applied here rather than threaded through connector state. Returns
+/// `-EINVAL` for any other property so the core reports it as unknown.
+unsafe extern "C" fn connector_atomic_set_property(
+ connector: *mut bindings::drm_connector,
+ _state: *mut bindings::drm_connector_state,
+ property: *mut bindings::drm_property,
+ val: u64,
+) -> i32 {
+ // SAFETY: `connector` is a valid connector embedded in our DRM device-private.
+ let dev = unsafe { (*connector).dev };
+ // SAFETY: `dev` is our live, registered drm_device.
+ let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) };
+ let Some(head) = data.head_by_connector(connector) else {
+ return EINVAL.to_errno();
+ };
+ let bp = data.brightness_prop.load(core::sync::atomic::Ordering::Relaxed);
+ let cp = data.contrast_prop.load(core::sync::atomic::Ordering::Relaxed);
+ let v = val.min(100) as u32;
+ let (slot, vcp) = if property == bp && !bp.is_null() {
+ (&head.brightness, super::cp::VCP_BRIGHTNESS)
+ } else if property == cp && !cp.is_null() {
+ (&head.contrast, super::cp::VCP_CONTRAST)
+ } else {
+ return EINVAL.to_errno();
+ };
+ if slot.swap(v, core::sync::atomic::Ordering::Relaxed) != v {
+ let _ = data.set_vcp(head.index, vcp, v as u16);
+ }
+ 0
+}
+
+/// Connector `.atomic_get_property`: read back the stored DDC/CI brightness/contrast (the
+/// values round-trip through the head atomics set by [`connector_atomic_set_property`]).
+unsafe extern "C" fn connector_atomic_get_property(
+ connector: *mut bindings::drm_connector,
+ _state: *const bindings::drm_connector_state,
+ property: *mut bindings::drm_property,
+ val: *mut u64,
+) -> i32 {
+ // SAFETY: `connector` is a valid connector embedded in our DRM device-private.
+ let dev = unsafe { (*connector).dev };
+ // SAFETY: `dev` is our live, registered drm_device.
+ let data: &VinoDrmData = unsafe { VinoDrmDevice::from_raw(dev) };
+ let Some(head) = data.head_by_connector(connector) else {
+ return EINVAL.to_errno();
+ };
+ let bp = data.brightness_prop.load(core::sync::atomic::Ordering::Relaxed);
+ let cp = data.contrast_prop.load(core::sync::atomic::Ordering::Relaxed);
+ let slot = if property == bp && !bp.is_null() {
+ &head.brightness
+ } else if property == cp && !cp.is_null() {
+ &head.contrast
+ } else {
+ return EINVAL.to_errno();
+ };
+ // SAFETY: the DRM core passes a valid `*mut u64` output pointer.
+ unsafe { *val = slot.load(core::sync::atomic::Ordering::Relaxed) as u64 };
+ 0
+}
+
/// Thin wrapper so the `unsafe` block above reads cleanly.
unsafe fn drm_mode_config_reset(raw: *mut bindings::drm_device) {
// SAFETY: `raw` is a valid drm_device with mode_config initialised.
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index 1091dcc992c7..ee63ce7e4625 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -385,6 +385,24 @@ fn bring_up(dev: &usb::Device) -> Result {
Ok(()) => pr_info!("vino: step device-open 0xfc(iface1) OK = {:02x?}\n", probe3),
Err(e) => pr_info!("vino: step device-open 0xfc(iface1) non-fatal ({e:?})\n"),
}
+ // DFU firmware-version query, matching DLM / the macOS+Windows drivers'
+ // DfuGetVmmDeviceFirmwareVersion: vendor IN bmRequestType=0xc1 bRequest=0xfd wIndex=1,
+ // a 6-byte version blob (the reference driver's request-size table: 0xfb=4 customer/board,
+ // 0xfc=3 device-type, 0xfd=6 firmware-version, 0xfe=16 descriptor). This is a device-level
+ // DFU read, independent of the CP channel, so it works regardless of CP engagement -- handy
+ // for diagnostics and confirming the dock firmware revision.
+ let mut fw_ver = [0u8; 6];
+ match dev.control_recv(0xfd, VENDOR_IN_IFACE, 0, 1, &mut fw_ver, timeout()) {
+ Ok(()) => pr_info!("vino: dock DFU firmware version = {:02x?}\n", fw_ver),
+ Err(e) => pr_info!("vino: device-open 0xfd(firmware-version) non-fatal ({e:?})\n"),
+ }
+ // DFU customer/board id (DfuGetVmmDeviceCustomerAndBoardId): bRequest=0xfb, 4-byte blob.
+ let mut cust_board = [0u8; 4];
+ match dev.control_recv(0xfb, VENDOR_IN_IFACE, 0, 1, &mut cust_board, timeout()) {
+ Ok(()) => pr_info!("vino: dock DFU customer/board id = {:02x?}\n", cust_board),
+ Err(e) => pr_info!("vino: device-open 0xfb(customer/board) non-fatal ({e:?})\n"),
+ }
+
// EXPERIMENT (2026-06-16): replay DLM's repeated STRING-descriptor reads at device-open.
// Timing analysis of the paired cold capture (captures/paired-coldbus-20260615-220311)
// shows DLM, beyond the distinct descriptor SET vino already issues, re-reads STRING idx0
--
2.54.0
^ permalink raw reply related [flat|nested] 12+ messages in thread
* [RFC PATCH 7/7] drm/vino: add KUnit self-tests for the protocol and crypto paths
2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
` (5 preceding siblings ...)
2026-06-17 15:12 ` [RFC PATCH 6/7] drm/vino: add DDC/CI brightness/contrast, DPMS power and DFU info Mike Lothian
@ 2026-06-17 15:12 ` Mike Lothian
2026-06-17 15:55 ` [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Danilo Krummrich
7 siblings, 0 replies; 12+ messages in thread
From: Mike Lothian @ 2026-06-17 15:12 UTC (permalink / raw)
To: dri-devel
Cc: Mike Lothian, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
linux-kernel
Add offline KUnit self-tests for the pure protocol builders/parsers and
the crypto bindings the control plane relies on. The crypto cases are
published known-answer vectors (FIPS-197 AES-128, RFC 4493 AES-CMAC) and
a live seal round-trip; the rest pin wire layout, EDID timing extraction,
the WHT codec stages and the DDC/CI Set-VCP encoding that have no hardware
oracle. Gated behind CONFIG_KUNIT, so they have zero effect on a
production build; run with a KUnit-enabled kernel.
Signed-off-by: Mike Lothian <mike@fireburn.co.uk>
Assisted-by: Claude:claude-opus-4-8 [Claude-Code]
---
drivers/gpu/drm/vino/vino.rs | 299 +++++++++++++++++++++++++++++++++++
1 file changed, 299 insertions(+)
diff --git a/drivers/gpu/drm/vino/vino.rs b/drivers/gpu/drm/vino/vino.rs
index ee63ce7e4625..2d22c3f822cd 100644
--- a/drivers/gpu/drm/vino/vino.rs
+++ b/drivers/gpu/drm/vino/vino.rs
@@ -1752,3 +1752,302 @@ fn disconnect<'bound>(intf: &'bound usb::Interface<Core<'_>>, _data: Pin<&Self>)
description: "DisplayLink DL3 (Vino) open driver",
license: "GPL v2",
}
+
+/// Build a minimal valid 128-byte EDID with a 1920x1080@60 detailed timing at base-block
+/// offset `dtd_at` (54 = preferred slot), a correct checksum, and the standard magic.
+#[cfg(CONFIG_KUNIT = "y")]
+fn mk_test_edid(dtd_at: usize) -> [u8; 128] {
+ let mut e = [0u8; 128];
+ e[..8].copy_from_slice(&[0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]);
+ // 1920x1080@60: pclk 14850 (148.5 MHz, 10 kHz units); hblank 280, vblank 45;
+ // hsync_front 88, hsync_width 44, vsync_front 4, vsync_width 5.
+ let dtd: [u8; 18] = [
+ 0x02, 0x3a, // pixel clock 0x3a02 LE
+ 0x80, 0x18, 0x71, // hactive 1920 / hblank 280 (high nibbles in byte 4)
+ 0x38, 0x2d, 0x40, // vactive 1080 / vblank 45 (high nibbles in byte 7)
+ 0x58, 0x2c, 0x45, 0x00, // hsync/vsync front+width
+ 0, 0, 0, 0, 0, 0, // trailing flags (DTD is 18 bytes total)
+ ];
+ e[dtd_at..dtd_at + 18].copy_from_slice(&dtd);
+ let s = e[..127].iter().fold(0u8, |a, &b| a.wrapping_add(b));
+ e[127] = 0u8.wrapping_sub(s); // base-block checksum: all 128 bytes sum to 0
+ e
+}
+
+/// Offline self-tests for the pure protocol builders/parsers and the crypto bindings the
+/// control plane relies on. Gated behind `CONFIG_KUNIT` (the macro adds the cfg), so they
+/// have zero effect on a production build; run with a KUnit-enabled kernel. The crypto cases
+/// are published known-answer vectors (FIPS-197 AES-128, RFC 4493 AES-CMAC); the seal case is
+/// a live round-trip; the rest pin wire layout and EDID parsing that have no hardware oracle.
+#[kunit_tests(vino_protocol)]
+mod tests {
+ use super::*;
+ use kernel::error::code::EINVAL;
+
+ #[test]
+ fn aes128_ecb_fips197_kat() -> Result {
+ // FIPS-197 / NIST SP800-38A F.1.1 AES-128 ECB known-answer vector.
+ let key = [
+ 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
+ 0x4f, 0x3c,
+ ];
+ let pt = [
+ 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
+ 0x17, 0x2a,
+ ];
+ assert_eq!(
+ crypto::aes128_ecb(&key, &pt)?,
+ [
+ 0x3a, 0xd7, 0x7b, 0xb4, 0x0d, 0x7a, 0x36, 0x60, 0xa8, 0x9e, 0xca, 0xf3, 0x24, 0x66,
+ 0xef, 0x97,
+ ]
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn aes_cmac_rfc4493_kat() -> Result {
+ // RFC 4493 sec 4 AES-CMAC test vectors (same key as above).
+ let key = [
+ 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
+ 0x4f, 0x3c,
+ ];
+ assert_eq!(
+ crypto::aes_cmac(&key, &[])?,
+ [
+ 0xbb, 0x1d, 0x69, 0x29, 0xe9, 0x59, 0x37, 0x28, 0x7f, 0xa3, 0x7d, 0x12, 0x9b, 0x75,
+ 0x67, 0x46,
+ ]
+ );
+ let msg = [
+ 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
+ 0x17, 0x2a,
+ ];
+ assert_eq!(
+ crypto::aes_cmac(&key, &msg)?,
+ [
+ 0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a,
+ 0x28, 0x7c,
+ ]
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn seal_livemac_roundtrip() -> Result {
+ // A sealed CP frame must decrypt back to its content under the IN riv, and its
+ // appended tag must equal a fresh Dl3Cmac over the ciphertext (encrypt-then-MAC).
+ let ks = [
+ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
+ 0xee, 0xff,
+ ];
+ let riv = [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17];
+ let content = [0xa5u8; 32];
+ let mut hdr = [0u8; 16];
+ hdr[12..16].copy_from_slice(&4u32.to_le_bytes()); // wire_seq = 4
+ let frame = cp::seal_livemac(&ks, &riv, &hdr, &content)?;
+ assert_eq!(frame.len(), 16 + 32 + 16);
+ let ct = &frame[16..16 + 32];
+ assert_eq!(&cp::open_in(&ks, &cp::in_riv(&riv), 4, ct)?[..], &content[..]);
+ assert_eq!(&frame[16 + 32..], &cp::dl3cmac_tag(&ks, &riv, 4, ct)?[..]);
+ Ok(())
+ }
+
+ #[test]
+ fn aux_for_id_constants() {
+ // The CP header `aux` field is a per-inner-id constant, not body_len/4.
+ assert_eq!(cp::aux_for_id(0x14, 48), 0x0a);
+ assert_eq!(cp::aux_for_id(0x15, 32), 0x09);
+ assert_eq!(cp::aux_for_id(0x48, 96), 0x06);
+ assert_eq!(cp::aux_for_id(0x99, 40), 10); // unknown id falls back to body_len/4
+ }
+
+ #[test]
+ fn edid_timing_parse_and_validate() {
+ // A well-formed EDID yields the DTD timing; a bad checksum is rejected; a leading
+ // monitor descriptor (pclk 0) does not hide the preferred timing in a later slot.
+ let edid = mk_test_edid(54);
+ let t = cp::timing_from_edid(&edid).expect("valid EDID parses");
+ assert_eq!(t.hactive, 1920);
+ assert_eq!(t.vactive, 1080);
+ assert_eq!(t.refresh_hz, 60);
+ assert_eq!(t.pixel_clock_10khz, 14850);
+
+ let mut bad = edid;
+ bad[127] ^= 0xff;
+ assert!(cp::timing_from_edid(&bad).is_none(), "bad checksum rejected");
+
+ let scanned = mk_test_edid(72); // off54 left as a zero (monitor) descriptor
+ assert_eq!(
+ cp::timing_from_edid(&scanned).expect("scans past off54").hactive,
+ 1920
+ );
+ }
+
+ #[test]
+ fn edid_reply_guards() -> Result {
+ // The pre-decrypt guards reject non-EDID frames without touching the cipher.
+ let ks = [0u8; 16];
+ let riv = [0u8; 8];
+ assert!(cp::parse_edid_from_reply(&ks, &riv, &[0u8; 10])?.is_none());
+ let mut wrong_sub = [0u8; 20];
+ wrong_sub[8] = 0x44; // wire sub != 0x45
+ assert!(cp::parse_edid_from_reply(&ks, &riv, &wrong_sub)?.is_none());
+ Ok(())
+ }
+
+ #[test]
+ fn rgb565_packing() {
+ assert_eq!(video::rgb565(0xff, 0x00, 0x00), 0xf800);
+ assert_eq!(video::rgb565(0x00, 0xff, 0x00), 0x07e0);
+ assert_eq!(video::rgb565(0x00, 0x00, 0xff), 0x001f);
+ let _ = EINVAL; // silence unused import on configs without the assert paths
+ }
+
+ #[test]
+ fn cursor_messages_structure() -> Result {
+ // Create: id=0x1b sub=0x42, `00 02 00` marker + w,h at off20.
+ let c = cp::cursor_create(7, 64, 64)?;
+ assert_eq!(c.len(), 27);
+ assert_eq!(&c[0..6], &[0x1b, 0x00, 0x42, 0x00, 0x07, 0x00]); // id, sub, counter (LE)
+ assert_eq!(&c[20..23], &[0x00, 0x02, 0x00]); // marker
+ assert_eq!(u16::from_le_bytes([c[23], c[24]]), 64); // width
+ assert_eq!(u16::from_le_bytes([c[25], c[26]]), 64); // height
+
+ // Move: id=0x1a sub=0x43, head@22, flag@23, X@24, Y@26 (LE).
+ let m = cp::cursor_move(9, 1, 0x0140, 0x00f0)?;
+ assert_eq!(m.len(), 28);
+ assert_eq!(&m[0..4], &[0x1a, 0x00, 0x43, 0x00]); // id, sub
+ assert_eq!(m[22], 1); // head id
+ assert_eq!(u16::from_le_bytes([m[24], m[25]]), 0x0140); // X
+ assert_eq!(u16::from_le_bytes([m[26], m[27]]), 0x00f0); // Y
+
+ // Image: create-style 27-byte header + w*h*4 BGRA bitmap; wrong-size input rejected.
+ let bitmap = KVec::from_elem(0xabu8, 64 * 64 * 4, GFP_KERNEL)?;
+ let img = cp::cursor_image(3, 64, 64, &bitmap)?;
+ assert_eq!(img.len(), 27 + 64 * 64 * 4);
+ assert_eq!(&img[0..4], &[0x1c, 0x00, 0x41, 0x00]); // id, sub
+ assert_eq!(img[27], 0xab); // bitmap begins right after the 27-byte header
+ assert!(cp::cursor_image(3, 64, 64, &[0u8; 16]).is_err()); // wrong bitmap length
+ Ok(())
+ }
+
+ #[test]
+ fn timing_from_drm_mode_1080p60() {
+ // CEA 1920x1080@60: clock 148.5 MHz, h 2008/2052/2200, v 1084/1089/1125.
+ let mut m = bindings::drm_display_mode::default();
+ m.clock = 148_500; // kHz
+ m.hdisplay = 1920;
+ m.hsync_start = 2008;
+ m.hsync_end = 2052;
+ m.htotal = 2200;
+ m.vdisplay = 1080;
+ m.vsync_start = 1084;
+ m.vsync_end = 1089;
+ m.vtotal = 1125;
+ // SAFETY: `m` is a fully-initialised local drm_display_mode.
+ let t = unsafe { cp::timing_from_drm_mode(&m) };
+ assert_eq!(t.hactive, 1920);
+ assert_eq!(t.hblank, 280); // htotal - hdisplay
+ assert_eq!(t.hsync_front, 88); // hsync_start - hdisplay
+ assert_eq!(t.hsync_width, 44); // hsync_end - hsync_start
+ assert_eq!(t.vactive, 1080);
+ assert_eq!(t.vblank, 45); // vtotal - vdisplay
+ assert_eq!(t.vsync_front, 4);
+ assert_eq!(t.vsync_width, 5);
+ assert_eq!(t.pixel_clock_10khz, 14_850); // clock(kHz) / 10
+ assert_eq!(t.refresh_hz, 60); // via drm_mode_vrefresh
+ }
+
+ #[test]
+ fn rotation_pixel_mapping() {
+ use bindings::{
+ DRM_MODE_REFLECT_X, DRM_MODE_ROTATE_0, DRM_MODE_ROTATE_180, DRM_MODE_ROTATE_270,
+ DRM_MODE_ROTATE_90,
+ };
+ // Source 2x3 (sw=2, sh=3). 0deg is identity; 180deg mirrors both axes.
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0, 0, 0, 2, 3), (0, 0));
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0, 1, 2, 2, 3), (1, 2));
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_180, 0, 0, 2, 3), (1, 2));
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_180, 1, 2, 2, 3), (0, 0));
+ // 90deg: output dims are (sh,sw)=(3,2); (dx,dy) -> (dy, sh-1-dx).
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_90, 0, 0, 2, 3), (0, 2));
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_90, 2, 1, 2, 3), (1, 0));
+ // 270deg: (dx,dy) -> (sw-1-dy, dx).
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_270, 0, 0, 2, 3), (1, 0));
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_270, 2, 1, 2, 3), (0, 2));
+ // Reflect-X composes on top of the rotation (here identity): sx -> sw-1-sx.
+ assert_eq!(drm_sink::rot_src(DRM_MODE_ROTATE_0 | DRM_MODE_REFLECT_X, 0, 0, 2, 3), (1, 0));
+ }
+
+ #[test]
+ fn wht_colour_and_quantize() {
+ use video::wht;
+ // Exact colour transform: white -> Y=16320, achromatic -> Cb=Cr=0.
+ assert_eq!(wht::colour(255, 255, 255), (16320, 0, 0));
+ assert_eq!(wht::colour(128, 128, 128), (128 * 64, 0, 0)); // gray: chroma zero
+ assert_eq!(wht::colour(255, 0, 0), (16 * 255, 64 * 255, 0)); // red: Cb>0, Cr=0
+ // The documented ground-truth vector: white Y_DC=16320 quantizes (DC, position 0) to 1020.
+ assert_eq!(wht::quantize(16320, 0), 1020);
+ // AC clamps to the 12-bit signed long-token range.
+ assert_eq!(wht::quantize(1_000_000, 16), 2047);
+ assert_eq!(wht::quantize(-1_000_000, 16), -2048);
+ }
+
+ #[test]
+ fn wht_transform_uniform() {
+ use video::wht;
+ // A uniform block: DC = the per-pixel value, every AC coefficient = 0 (VIDEO.md invariant).
+ let block = [16320i32; wht::BLOCK];
+ let c = wht::transform(&block);
+ assert_eq!(c[0], 16320); // DC = mean = the uniform value
+ assert!(c[1..].iter().all(|&x| x == 0)); // AC all zero
+ // End-to-end: white pixel -> Y plane -> WHT DC -> quantize -> 1020.
+ let (y, _, _) = wht::colour(255, 255, 255);
+ assert_eq!(wht::quantize(wht::transform(&[y; wht::BLOCK])[0], 0), 1020);
+ }
+
+ #[test]
+ fn wht_token_bitstream() -> Result {
+ use video::wht::TokenWriter;
+ // 16-bit zero pad, then short tokens 5 (00101) and 30 (11110), zero-padded to a byte:
+ // 0000000000000000 00101 11110 000000 = 0x00 0x00 0x2F 0x80.
+ let mut w = TokenWriter::new()?;
+ w.token(5)?;
+ w.token(30)?;
+ assert_eq!(&w.finish()?[..], &[0x00, 0x00, 0x2f, 0x80]);
+ // A value > 30 escapes to a 17-bit long token: 16 pad + 17 + byte-pad = 5 bytes.
+ let mut w = TokenWriter::new()?;
+ w.token(100)?;
+ assert_eq!(w.finish()?.len(), 5);
+ Ok(())
+ }
+
+ #[test]
+ fn ddc_ci_set_vcp_checksum() {
+ // VESA DDC/CI 1.1 sec 4.4 worked example: Set brightness (VCP 0x10) to 50 (0x0032).
+ // Bytes after the 0x6e write address: 51 84 03 10 00 32, checksum = XOR incl. 0x6e.
+ let p = cp::ddc_ci_set_vcp(cp::VCP_BRIGHTNESS, 50);
+ assert_eq!(&p[..6], &[0x51, 0x84, 0x03, 0x10, 0x00, 0x32]);
+ let want = 0x6e ^ 0x51 ^ 0x84 ^ 0x03 ^ 0x10 ^ 0x00 ^ 0x32;
+ assert_eq!(p[6], want);
+ // The checksum makes the XOR of {dest, source, len, opcode, vcp, hi, lo, chk} zero.
+ assert_eq!(0x6eu8 ^ p.iter().fold(0u8, |a, &b| a ^ b), 0);
+ // Contrast (0x12) and the power VCP (0xd6 = off) carry their codes/values verbatim.
+ assert_eq!(cp::ddc_ci_set_vcp(cp::VCP_CONTRAST, 0x0140)[3..6], [0x12, 0x01, 0x40]);
+ assert_eq!(cp::ddc_ci_set_vcp(cp::VCP_POWER_MODE, cp::POWER_OFF)[3..6], [0xd6, 0x00, 0x04]);
+ }
+
+ #[test]
+ fn ddc_set_vcp_message_structure() -> Result {
+ // CP wrapper: id=0x15 sub=0x22, counter (LE) at off4, I2C slave 0x37 + len 7 at off20,
+ // the 7-byte DDC/CI Set-VCP payload at off22, padded to a 32-byte block.
+ let m = cp::ddc_set_vcp(0x11, cp::VCP_BRIGHTNESS, 75)?;
+ assert_eq!(m.len(), 32);
+ assert_eq!(&m[0..6], &[0x15, 0x00, 0x22, 0x00, 0x11, 0x00]); // id, sub, counter (LE)
+ assert_eq!(&m[20..22], &[0x37, 7]); // monitor DDC/CI I2C slave + payload length
+ assert_eq!(&m[22..29], &cp::ddc_ci_set_vcp(cp::VCP_BRIGHTNESS, 75)); // DDC/CI payload
+ assert_eq!(&m[29..32], &[0, 0, 0]); // block padding
+ Ok(())
+ }
+}
--
2.54.0
^ permalink raw reply related [flat|nested] 12+ messages in thread
* Re: [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up
2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
@ 2026-06-17 15:17 ` Miguel Ojeda
0 siblings, 0 replies; 12+ messages in thread
From: Miguel Ojeda @ 2026-06-17 15:17 UTC (permalink / raw)
To: Mike Lothian
Cc: dri-devel, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
linux-kernel
On Wed, Jun 17, 2026 at 5:13 PM Mike Lothian <mike@fireburn.co.uk> wrote:
>
> Vino is a clean-room, in-kernel Rust DRM driver for DisplayLink DL3 USB
> docks (Dell Universal Dock D6000, 17e9:6006), a native replacement for
> the out-of-tree EVDI module plus the proprietary DisplayLinkManager
> userspace daemon. It is built on the in-tree Rust USB, crypto and DRM/KMS
> bindings (posted as their own prerequisite series).
Ah, so this is the user for all those series. Is it then something you
plan to finish implementing and upstreaming?
(I would suggest linking this series and clarifying those questions
for future versions of all the related cover letters, so that there is
no confusion about it.)
Thanks!
Cheers,
Miguel
^ permalink raw reply [flat|nested] 12+ messages in thread
* Re: [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted)
2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
` (6 preceding siblings ...)
2026-06-17 15:12 ` [RFC PATCH 7/7] drm/vino: add KUnit self-tests for the protocol and crypto paths Mike Lothian
@ 2026-06-17 15:55 ` Danilo Krummrich
2026-06-17 16:11 ` Mike Lothian
7 siblings, 1 reply; 12+ messages in thread
From: Danilo Krummrich @ 2026-06-17 15:55 UTC (permalink / raw)
To: Mike Lothian
Cc: dri-devel, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
Andreas Hindborg, Alice Ryhl, Trevor Gross, linux-kernel,
Lyude Paul
(Cc: Lyude)
On Wed Jun 17, 2026 at 5:12 PM CEST, Mike Lothian wrote:
> Vino is a clean-room, in-kernel Rust DRM driver for DisplayLink DL3 USB
> docks (Dell Universal Dock D6000, 17e9:6006), a native replacement for
> the out-of-tree EVDI module plus the proprietary DisplayLinkManager
> userspace daemon.
Interesting project!
> It is built on the in-tree Rust USB, crypto and DRM/KMS bindings, which are
> posted as their own prerequisite series;
The KMS series you refer to is really just including the C headers for bindgen,
while the driver messes with all C KMS APIs directly.
Lyude already works on proper KMS infrastructure for Rust; can you please work
with her to get your driver reworked to use the safe infrastructure?
Also, I recommend looking at this series [1], which should very much simplify
dealing with device resources.
Thanks,
Danilo
[1] https://lore.kernel.org/dri-devel/20260603011711.2077361-1-dakr@kernel.org/
^ permalink raw reply [flat|nested] 12+ messages in thread
* Re: [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted)
2026-06-17 15:55 ` [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Danilo Krummrich
@ 2026-06-17 16:11 ` Mike Lothian
0 siblings, 0 replies; 12+ messages in thread
From: Mike Lothian @ 2026-06-17 16:11 UTC (permalink / raw)
To: Danilo Krummrich
Cc: Maling list - DRI developers, rust-for-linux, Maarten Lankhorst,
Maxime Ripard, Thomas Zimmermann, David Airlie, Simona Vetter,
Miguel Ojeda, Boqun Feng, Gary Guo, Björn Roy Baron,
Benno Lossin, Andreas Hindborg, Alice Ryhl, Trevor Gross,
Linux Kernel Mailing List, Lyude Paul
[-- Attachment #1: Type: text/plain, Size: 1097 bytes --]
Thanks, I'll check it out.
On Wed, 17 Jun 2026, 16:55 Danilo Krummrich, <dakr@kernel.org> wrote:
> (Cc: Lyude)
>
> On Wed Jun 17, 2026 at 5:12 PM CEST, Mike Lothian wrote:
> > Vino is a clean-room, in-kernel Rust DRM driver for DisplayLink DL3 USB
> > docks (Dell Universal Dock D6000, 17e9:6006), a native replacement for
> > the out-of-tree EVDI module plus the proprietary DisplayLinkManager
> > userspace daemon.
>
> Interesting project!
>
> > It is built on the in-tree Rust USB, crypto and DRM/KMS bindings, which
> are
> > posted as their own prerequisite series;
>
> The KMS series you refer to is really just including the C headers for
> bindgen,
> while the driver messes with all C KMS APIs directly.
>
> Lyude already works on proper KMS infrastructure for Rust; can you please
> work
> with her to get your driver reworked to use the safe infrastructure?
>
> Also, I recommend looking at this series [1], which should very much
> simplify
> dealing with device resources.
>
> Thanks,
> Danilo
>
> [1]
> https://lore.kernel.org/dri-devel/20260603011711.2077361-1-dakr@kernel.org/
>
[-- Attachment #2: Type: text/html, Size: 1608 bytes --]
^ permalink raw reply [flat|nested] 12+ messages in thread
* Re: [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE
2026-06-17 15:12 ` [RFC PATCH 2/7] drm/vino: add the clean-room HDCP 2.2 AKE/LC/SKE Mike Lothian
@ 2026-06-17 16:18 ` Eric Biggers
0 siblings, 0 replies; 12+ messages in thread
From: Eric Biggers @ 2026-06-17 16:18 UTC (permalink / raw)
To: Mike Lothian
Cc: dri-devel, rust-for-linux, Maarten Lankhorst, Maxime Ripard,
Thomas Zimmermann, David Airlie, Simona Vetter, Miguel Ojeda,
Boqun Feng, Gary Guo, Björn Roy Baron, Benno Lossin,
Andreas Hindborg, Alice Ryhl, Trevor Gross, Danilo Krummrich,
linux-kernel
On Wed, Jun 17, 2026 at 04:12:39PM +0100, Mike Lothian wrote:
> +/// `AES-CMAC-128(key, data)` (RFC 4493), built on the one-block ECB above.
> +/// This is DisplayLink's "Dl3Cmac" core -- the CP per-message integrity tag is
> +/// `AES_CMAC(ks, nonce8 || BE64(counter) || content)` (see `cp::dl3cmac_tag`);
> +/// verified byte-exact against live DLM data (canonical guide sec 8.6.7).
> +pub(super) fn aes_cmac(key: &[u8; 16], data: &[u8]) -> Result<[u8; 16]> {
> + // dbl: left-shift the 128-bit value by 1, XOR 0x87 if the MSB was set.
> + fn dbl(b: &[u8; 16]) -> [u8; 16] {
> + let mut o = [0u8; 16];
> + for i in 0..15 {
> + o[i] = (b[i] << 1) | (b[i + 1] >> 7);
> + }
> + o[15] = b[15] << 1;
> + if b[0] & 0x80 != 0 {
> + o[15] ^= 0x87;
> + }
> + o
> + }
> + let l = aes128_ecb(key, &[0u8; 16])?;
> + let k1 = dbl(&l);
> + let k2 = dbl(&k1);
> + let n = if data.is_empty() { 1 } else { data.len().div_ceil(16) };
> + let complete = !data.is_empty() && data.len() % 16 == 0;
> + let mut c = [0u8; 16];
> + for i in 0..n {
> + let mut blk = [0u8; 16];
> + let start = i * 16;
> + let end = core::cmp::min(start + 16, data.len());
> + blk[..end - start].copy_from_slice(&data[start..end]);
> + if i == n - 1 {
> + if complete {
> + for j in 0..16 {
> + blk[j] ^= k1[j];
> + }
> + } else {
> + blk[end - start] = 0x80; // 10* padding
> + for j in 0..16 {
> + blk[j] ^= k2[j];
> + }
> + }
> + }
> + for j in 0..16 {
> + blk[j] ^= c[j];
> + }
> + c = aes128_ecb(key, &blk)?;
> + }
> + Ok(c)
> +}
There are AES-CMAC library functions that should be used. See
include/crypto/aes-cbc-macs.h. We don't want drivers rolling their own
modes on top of bare AES unless they have to, for a number of reasons.
- Eric
^ permalink raw reply [flat|nested] 12+ messages in thread
end of thread, other threads:[~2026-06-17 16:18 UTC | newest]
Thread overview: 12+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-06-17 15:12 [RFC PATCH 0/7] drm/vino: DisplayLink DL3 dock driver (RFC, help wanted) Mike Lothian
2026-06-17 15:12 ` [RFC PATCH 1/7] drm/vino: add DisplayLink DL3 dock skeleton and plaintext bring-up Mike Lothian
2026-06-17 15:17 ` Miguel Ojeda
2026-06-17 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-06-17 16:11 ` Mike Lothian
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.