Files
netris-nestri/apps/nesdoctor/src/display.rs
Wanjohi 3544f857ff feat(nesdoctor): read the display, and offer early access
Two things, both of which every response collected without them is a response
we cannot go back for -- since a submission carries nothing that identifies
anyone, there is no second chance to ask.

## The display and decode probe

This is the readable half of the client capability probe our build order
already specifies -- GPU, decoder, display -- and its stated purpose is
attribution: told only that a stream "looks bad", the cheapest available
explanation is that our reconstruction ratio was too aggressive, so without
this we would lower the ratio and pay density for somebody else's window
manager.

  presentation path   x11 · bspwm
  eDP-1               1920x1200 @ 60 Hz, 8-bit
  Vulkan decode       h264, h265
  VA-API decode       h264, h265, vp9

Session type, compositor, and whether we are under XWayland -- which is exactly
the objection raised against our own A/B rounds, now recorded automatically
rather than argued about. A bare window manager sets none of the XDG variables,
so bspwm and thirteen others are matched from the process list; a report that
cannot name bspwm cannot answer the challenge that named it.

From EDID, parsed here rather than shelled out to: native mode, refresh,
colour bit depth, which HDR transfer functions the panel accepts, BT.2020
colorimetry, and 4:2:0 chroma. The CTA-861 extension blocks are where all the
colour capability lives -- base EDID says nothing about any of it.

That decides real choices. Whether 10-bit is worth sending, whether BT.2020 is
worth encoding, which codec to reach for. Every one of those has so far been
decided against the one panel in this room -- which this now reports as 8-bit,
meaning the 10-bit work cannot be validated on it at all.

EDID is untrusted binary from a device node. Every read is bounds-checked and
every field optional: monitors ship broken EDIDs and docks synthesise worse
ones, so a bad panel costs one field rather than the run. Three tests, one of
which truncates the block mid-extension and asserts that no colour capability
is invented. The colorimetry byte offset was wrong the first time and the test
caught it, which is the argument for the test.

Present mode, tearing and fractional scaling need a real window and swapchain,
so they are absent and said to be absent rather than guessed.

## Early access

An optional email, asked last, after the verdict has printed -- so nobody types
an address before seeing what this said about their machine. Blank skips it.

The offer branches on the verdict, because telling someone with no KVM and a
grade-F uplink that we liked what their machine can do is a lie, and this
program's only real asset is that it does not flatter anyone. A host-capable
machine gets the host offer; everyone else gets early access as a player, which
is a true offer too.

It is the one identifying thing collected here, so: it appears in the
pre-submit disclosure with everything else, and the promise elsewhere had to be
reworded -- "no username, no identifiers" stopped being true the moment this
field existed, and leaving the old line standing would have been the dishonest
option. Validation is deliberately loose; arguing with somebody about their own
address over a regex loses the response outright.

Version to 0.2.0.
2026-09-02 14:58:40 +03:00

460 lines
16 KiB
Rust

//! What the client's display and decoder can actually accept.
//!
//! This is the readable half of the capability probe our build order specifies
//! — *GPU, decoder, display* — and its stated purpose is **attribution**. Told
//! only that a stream "looks bad", the cheapest available explanation is that
//! our reconstruction ratio was too aggressive; so without this we would lower
//! the ratio and pay density for somebody else's window manager.
//!
//! It also answers the colour question directly. Whether to send BT.709 or
//! BT.2020, limited or full range, 8-bit or 10-bit, 4:2:0 or 4:4:4 is decided
//! by what is on the other end — and until now we had no way to know, so every
//! choice was made against the one panel in this room.
//!
//! # What is readable here, and what is not
//!
//! | | |
//! |---|---|
//! | session type, compositor, desktop | environment, exactly what a client sees |
//! | native resolution, refresh, bit depth | EDID |
//! | HDR transfer functions, BT.2020, 4:2:0 | EDID CTA-861 extension blocks |
//! | hardware decode profiles | `vulkaninfo` / `vainfo` |
//! | **present mode, tearing, fractional scale** | **needs a real surface — not here** |
//!
//! The last row is the honest gap: those require a window and a swapchain, so
//! they belong in the client and are reported as unknown rather than guessed.
//!
//! EDID is untrusted binary from a device node. Every read here is
//! bounds-checked and every field is optional; a monitor that reports nonsense
//! costs one field.
use std::fs;
use serde::Serialize;
use crate::sys::{self};
#[derive(Debug, Serialize, Default)]
pub struct DisplayReport {
/// `wayland`, `x11`, `windows`, `macos`, or none if headless.
pub session: Option<String>,
pub compositor: Option<String>,
pub desktop: Option<String>,
/// True when the session is X11 while a Wayland socket also exists —
/// meaning the client is running under XWayland, which is its own
/// presentation path and was the exact challenge raised against our A/B
/// rounds.
pub xwayland: bool,
pub outputs: Vec<Output>,
pub decode: Decode,
}
#[derive(Debug, Serialize, Default, Clone)]
pub struct Output {
pub name: Option<String>,
/// Native (preferred) mode from the first detailed timing descriptor.
pub width: Option<u32>,
pub height: Option<u32>,
pub refresh_hz: Option<f64>,
/// Bits per colour channel, as the panel declares it. 8 or 10 is the
/// question that decides whether sending 10-bit is worth anything.
pub bit_depth: Option<u8>,
/// Transfer functions the panel accepts: `sdr`, `hdr-traditional`, `pq`,
/// `hlg`. Empty means SDR only, or an EDID too old to say.
pub eotf: Vec<&'static str>,
/// BT.2020 colorimetry, in the forms CTA-861 distinguishes.
pub bt2020: Vec<&'static str>,
/// The panel accepts 4:2:0 chroma, which is what we encode.
pub ycbcr420: bool,
pub ycbcr444: bool,
}
#[derive(Debug, Serialize, Default)]
pub struct Decode {
/// Vulkan Video decode, which is what the client would prefer.
pub vulkan: Vec<&'static str>,
/// VA-API profiles, the fallback that is far more widely present.
pub vaapi: Vec<&'static str>,
}
pub fn probe() -> DisplayReport {
let mut r = DisplayReport {
session: session_type(),
compositor: std::env::var("XDG_SESSION_DESKTOP").ok(),
desktop: std::env::var("XDG_CURRENT_DESKTOP").ok(),
xwayland: std::env::var_os("WAYLAND_DISPLAY").is_some()
&& std::env::var("XDG_SESSION_TYPE").as_deref() == Ok("x11"),
outputs: outputs(),
decode: decode(),
};
// A compositor name is more useful than the generic desktop string, and on
// a bare window manager neither is set — so fall back to what is running.
if r.compositor.is_none() {
r.compositor = wm_hint();
}
r
}
fn session_type() -> Option<String> {
if cfg!(windows) {
return Some("windows".into());
}
if cfg!(target_os = "macos") {
return Some("macos".into());
}
if let Ok(t) = std::env::var("XDG_SESSION_TYPE")
&& !t.is_empty()
{
return Some(t);
}
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
return Some("wayland".into());
}
if std::env::var_os("DISPLAY").is_some() {
return Some("x11".into());
}
None
}
/// Name a bare window manager, which sets none of the XDG variables.
///
/// Worth the ugliness: the challenge to our own measurements was specifically
/// *"a 1080p panel under bspwm and Xorg"*, and a report that cannot name bspwm
/// cannot answer it.
fn wm_hint() -> Option<String> {
const WMS: [&str; 14] = [
"bspwm",
"i3",
"sway",
"hyprland",
"river",
"dwm",
"awesome",
"xmonad",
"openbox",
"qtile",
"herbstluftwm",
"spectrwm",
"leftwm",
"niri",
];
let out = sys::sh("ps", &["-eo", "comm="])?;
let running: Vec<&str> = out.lines().map(str::trim).collect();
WMS.iter()
.find(|w| running.iter().any(|p| p == *w))
.map(|w| (*w).to_string())
}
// -------------------------------------------------------------------- EDID ---
fn outputs() -> Vec<Output> {
#[cfg(target_os = "linux")]
{
let mut out = Vec::new();
let Ok(entries) = fs::read_dir("/sys/class/drm") else {
return out;
};
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
for p in paths {
// Connectors are `cardN-<CONNECTOR>`; a connected one has an EDID.
let name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if !name.contains('-') {
continue;
}
let status = fs::read_to_string(p.join("status")).unwrap_or_default();
if status.trim() != "connected" {
continue;
}
let Ok(edid) = fs::read(p.join("edid")) else {
continue;
};
if let Some(mut o) = parse_edid(&edid) {
// The connector name (`card0-DP-1`) is more useful than nothing
// when the panel declares no product name.
o.name = o
.name
.or_else(|| name.split_once('-').map(|(_, c)| c.to_string()));
out.push(o);
}
}
out
}
#[cfg(windows)]
{
// WMI exposes raw EDID, which is the same bytes and the same parser.
let raw = sys::ps(
"Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorRawEEdidV1Block \
-ErrorAction SilentlyContinue | ForEach-Object { ($_.BlockContent -join ',') }",
)
.unwrap_or_default();
raw.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| {
let bytes: Vec<u8> = l
.split(',')
.filter_map(|n| n.trim().parse::<u8>().ok())
.collect();
parse_edid(&bytes)
})
.collect()
}
#[cfg(not(any(target_os = "linux", windows)))]
Vec::new()
}
/// Parse the parts of an EDID that decide how we should encode.
///
/// Deliberately partial. Everything is bounds-checked and optional because this
/// is untrusted binary from a device node: monitors ship broken EDIDs, docks
/// and KVM switches synthesise worse ones, and one bad panel must cost a field
/// rather than the run.
fn parse_edid(b: &[u8]) -> Option<Output> {
// Header, which is how we know this is an EDID at all.
if b.len() < 128 || b[0..8] != [0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00] {
return None;
}
let mut o = Output::default();
// Byte 0x14: video input definition. Bit 7 set means digital, and then
// bits 4-6 carry the bit depth -- the field that decides whether sending
// 10-bit buys anything at all.
if b[0x14] & 0x80 != 0 {
o.bit_depth = match (b[0x14] >> 4) & 0x07 {
1 => Some(6),
2 => Some(8),
3 => Some(10),
4 => Some(12),
5 => Some(14),
6 => Some(16),
_ => None, // 0 is "undefined", 7 is reserved
};
}
// The four 18-byte descriptors at 0x36. The first is the preferred timing;
// a descriptor whose first two bytes are zero is a text block instead.
for i in 0..4 {
let d = &b[0x36 + i * 18..0x36 + i * 18 + 18];
if d[0] == 0 && d[1] == 0 {
// 0xFC is the monitor's product name.
if d[3] == 0xFC {
let s: String = d[5..18]
.iter()
.take_while(|&&c| c != 0x0A)
.map(|&c| c as char)
.collect();
let s = s.trim().to_string();
if !s.is_empty() {
o.name = Some(s);
}
}
continue;
}
if o.width.is_some() {
continue; // first detailed timing only
}
let clock_khz = (d[0] as u32 | ((d[1] as u32) << 8)) * 10;
let h_active = d[2] as u32 | (((d[4] as u32) & 0xF0) << 4);
let h_blank = d[3] as u32 | (((d[4] as u32) & 0x0F) << 8);
let v_active = d[5] as u32 | (((d[7] as u32) & 0xF0) << 4);
let v_blank = d[6] as u32 | (((d[7] as u32) & 0x0F) << 8);
let total = (h_active + h_blank) as u64 * (v_active + v_blank) as u64;
if h_active > 0 && v_active > 0 {
o.width = Some(h_active);
o.height = Some(v_active);
if total > 0 && clock_khz > 0 {
o.refresh_hz = Some((clock_khz as f64 * 1000.0) / total as f64);
}
}
}
// CTA-861 extension blocks carry the colour capabilities: HDR transfer
// functions, BT.2020, and 4:2:0 chroma. Base EDID says nothing about any
// of them.
let ext_count = b[0x7E] as usize;
for n in 0..ext_count {
let start = 128 * (n + 1);
if b.len() < start + 128 {
break;
}
parse_cta(&b[start..start + 128], &mut o);
}
Some(o)
}
fn parse_cta(e: &[u8], o: &mut Output) {
if e[0] != 0x02 {
return; // not a CTA-861 block
}
// Byte 3 flags: bit 5 = YCbCr 4:2:2, bit 4 = YCbCr 4:4:4.
o.ycbcr444 |= e[3] & 0x20 != 0;
// The data block collection runs from byte 4 to the DTD offset in byte 2.
let end = (e[2] as usize).clamp(4, 128);
let mut i = 4usize;
while i < end {
let tag = e[i] >> 5;
let len = (e[i] & 0x1F) as usize;
if len == 0 || i + len >= 128 {
break;
}
let body = &e[i + 1..i + 1 + len];
if tag == 7 && !body.is_empty() {
match body[0] {
// Colorimetry data block.
5 if body.len() >= 2 => {
let f = body[1];
for (bit, name) in [(5, "bt2020-cycc"), (6, "bt2020-ycc"), (7, "bt2020-rgb")] {
if f & (1 << bit) != 0 {
o.bt2020.push(name);
}
}
}
// HDR static metadata: which transfer functions the panel takes.
6 if body.len() >= 2 => {
let f = body[1];
for (bit, name) in [(0, "sdr"), (1, "hdr-traditional"), (2, "pq"), (3, "hlg")] {
if f & (1 << bit) != 0 {
o.eotf.push(name);
}
}
}
// Either 4:2:0 block means the panel accepts 4:2:0 chroma,
// which is what we encode.
14 | 15 => o.ycbcr420 = true,
_ => {}
}
}
i += 1 + len;
}
}
// ------------------------------------------------------------------ decode ---
fn decode() -> Decode {
let mut d = Decode::default();
// Vulkan Video decode. Same caveat as the encode check on the host side:
// an advertised extension is necessary and not sufficient.
if let Some(vk) = sys::sh("vulkaninfo", &[]) {
for (needle, name) in [
("VK_KHR_video_decode_h264", "h264"),
("VK_KHR_video_decode_h265", "h265"),
("VK_KHR_video_decode_av1", "av1"),
] {
if vk.contains(needle) {
d.vulkan.push(name);
}
}
}
// VA-API, which is far more widely present than Vulkan Video and is what a
// client would actually fall back to.
if let Some(va) = sys::sh("vainfo", &[]) {
for (needle, name) in [
("VAProfileH264", "h264"),
("VAProfileHEVC", "h265"),
("VAProfileAV1", "av1"),
("VAProfileVP9", "vp9"),
] {
if va.contains(needle) && va.contains("VLD") {
d.vaapi.push(name);
}
}
}
d
}
#[cfg(test)]
mod tests {
use super::*;
/// A synthetic EDID 1.4: digital input, 10 bits per channel, one detailed
/// timing for 2560x1440 at ~60 Hz, and a CTA block declaring PQ, HLG,
/// BT.2020 RGB and 4:2:0.
fn synthetic() -> Vec<u8> {
let mut b = vec![0u8; 256];
b[0..8].copy_from_slice(&[0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00]);
b[0x14] = 0x80 | (3 << 4); // digital, 10 bpc
b[0x7E] = 1; // one extension
// 2560x1440: pixel clock 241.5 MHz, htotal 2720, vtotal 1481.
let d = 0x36;
let clock = 24_150u32; // in 10 kHz units
b[d] = (clock & 0xFF) as u8;
b[d + 1] = (clock >> 8) as u8;
b[d + 2] = (2560 & 0xFF) as u8;
b[d + 3] = (160 & 0xFF) as u8; // hblank 160 -> htotal 2720
// High nibbles of h_active and h_blank. Written out rather than
// `| (160 >> 8)`, which is zero here and which clippy is right to
// object to -- the shape of the field is the documentation.
b[d + 4] = ((2560u32 >> 8) << 4) as u8;
b[d + 5] = (1440 & 0xFF) as u8;
b[d + 6] = 41; // vblank 41 -> vtotal 1481
b[d + 7] = ((1440u32 >> 8) << 4) as u8;
// CTA-861 extension.
let e = 128;
b[e] = 0x02;
b[e + 1] = 3;
b[e + 3] = 0x20; // YCbCr 4:4:4
let mut i = e + 4;
// Colorimetry: BT.2020 RGB (bit 7). Relative to the tag byte the
// layout is [ext tag, colorimetry flags, metadata profiles] -- getting
// that off by one is what made this test fail the first time, which is
// a good argument for the test existing.
b[i] = (7 << 5) | 3;
b[i + 1] = 5;
b[i + 2] = 1 << 7;
b[i + 3] = 0;
i += 4;
// HDR static metadata: SDR + PQ + HLG.
b[i] = (7 << 5) | 3;
b[i + 1] = 6;
b[i + 2] = 0b0000_1101;
i += 4;
// 4:2:0 capability map.
b[i] = (7 << 5) | 2;
b[i + 1] = 15;
b[i + 2] = 0;
i += 3;
b[e + 2] = (i - e) as u8; // DTD offset ends the data block collection
b
}
#[test]
fn reads_depth_geometry_and_colour() {
let o = parse_edid(&synthetic()).expect("should parse");
assert_eq!(o.bit_depth, Some(10));
assert_eq!((o.width, o.height), (Some(2560), Some(1440)));
let hz = o.refresh_hz.expect("refresh");
assert!((hz - 59.95).abs() < 0.5, "refresh was {hz}");
assert!(o.eotf.contains(&"pq"), "eotf: {:?}", o.eotf);
assert!(o.eotf.contains(&"hlg"));
assert!(o.bt2020.contains(&"bt2020-rgb"), "bt2020: {:?}", o.bt2020);
assert!(o.ycbcr420);
assert!(o.ycbcr444);
}
/// Garbage must cost a field, never the run.
#[test]
fn rejects_non_edid() {
assert!(parse_edid(&[]).is_none());
assert!(parse_edid(&[0u8; 128]).is_none());
assert!(parse_edid(&vec![0xABu8; 300]).is_none());
}
#[test]
fn truncated_extension_is_survivable() {
let mut b = synthetic();
b.truncate(200); // claims an extension it does not have in full
let o = parse_edid(&b).expect("base block still parses");
assert_eq!(o.bit_depth, Some(10));
assert!(o.eotf.is_empty(), "no colour data should be invented");
}
}