mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
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.
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2462,7 +2462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nesdoctor"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nesdoctor"
|
||||
version = "0.1.2"
|
||||
version = "0.2.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
@@ -101,6 +101,33 @@ always a router setting rather than a line you need to upgrade.
|
||||
| `CLIENT` | Not a host. A complete answer, and what most machines are |
|
||||
| `UNKNOWN` | A blocking check could not be run. An unknown is not a no |
|
||||
|
||||
## Your display, and why we ask
|
||||
|
||||
```
|
||||
presentation path x11 · bspwm
|
||||
eDP-1 1920x1200 @ 60 Hz, 8-bit
|
||||
Vulkan decode h264, h265
|
||||
VA-API decode h264, h265, vp9
|
||||
```
|
||||
|
||||
Read from your monitor's EDID and your session, not guessed. Resolution,
|
||||
refresh, **colour depth**, which HDR transfer functions the panel accepts,
|
||||
whether it takes BT.2020, whether it takes 4:2:0 chroma — plus what your
|
||||
hardware can decode.
|
||||
|
||||
This decides real choices on our side: whether 10-bit is worth sending, whether
|
||||
BT.2020 is worth encoding, which codec to reach for. Every one of those had
|
||||
been decided against the single panel in one room.
|
||||
|
||||
Its other use is **attribution**. Told only that a stream "looks bad", the
|
||||
cheapest explanation is always that we compressed it too hard — so without
|
||||
knowing that a compositor is rescaling the picture, we would turn down our own
|
||||
quality to pay for somebody else's window manager.
|
||||
|
||||
**Present mode, tearing and fractional scaling are not here.** They need a real
|
||||
window and a swapchain, so they belong in the client, and are reported as
|
||||
unknown rather than guessed.
|
||||
|
||||
## Sending it back
|
||||
|
||||
At the end it prints a link, lists in plain English what the link contains, and
|
||||
@@ -122,6 +149,11 @@ for it.
|
||||
If you would rather not click a link we wrote, the short line is printed too and
|
||||
put on your clipboard.
|
||||
|
||||
At the very end it offers to take an **email address** — optional, blank skips
|
||||
it — so we can come to you when there is something to try. That is the only
|
||||
identifying thing this program collects, it is asked last, and it appears in the
|
||||
disclosure list like everything else so you see it before it is sent.
|
||||
|
||||
## What it deliberately does not tell you
|
||||
|
||||
- **A pass is not a promise.** Every check is a *necessary* condition. Nothing
|
||||
|
||||
@@ -38,9 +38,24 @@ pub struct Answers {
|
||||
pub other_linux: Option<String>,
|
||||
/// Consent gate, not a survey question.
|
||||
pub steam_consent: bool,
|
||||
/// Optional, and the only field in this program that identifies a person.
|
||||
/// Blank unless they typed one.
|
||||
pub email: Option<String>,
|
||||
pub asked: usize,
|
||||
}
|
||||
|
||||
/// What to offer at the end, which depends on what the machine turned out to
|
||||
/// be. The wording matters: telling someone with a grade-F line and no KVM that
|
||||
/// we liked what their machine can do is a lie, and a tool whose whole argument
|
||||
/// is that it does not flatter you cannot afford one.
|
||||
pub enum Offer {
|
||||
/// The machine could host, or could with setup. These are the people we
|
||||
/// most want to talk to.
|
||||
Host,
|
||||
/// Everyone else. Early access as a player is a real offer too.
|
||||
Player,
|
||||
}
|
||||
|
||||
pub struct Ctx {
|
||||
/// Whether the blocking host checks passed.
|
||||
pub could_host: bool,
|
||||
@@ -202,3 +217,113 @@ fn read_line() -> String {
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Offer early access, and take an email only if they want to give one.
|
||||
///
|
||||
/// This is the one identifying thing the program will ever collect, and it is
|
||||
/// the reason the wording around it is careful:
|
||||
///
|
||||
/// - **It is asked last**, after the verdict, so nobody types an address before
|
||||
/// seeing what the tool actually does.
|
||||
/// - **Blank skips it**, and the prompt says so.
|
||||
/// - **It is listed in the pre-submit disclosure** like everything else, and the
|
||||
/// promise elsewhere had to be reworded, because "no username, no
|
||||
/// identifiers" stops being true the moment this field exists. Quietly
|
||||
/// leaving the old promise up would have been the dishonest option.
|
||||
/// - **The offer is branched on the verdict.** Saying "we liked what your
|
||||
/// machine can do" to a machine that cannot host is a lie, and this program's
|
||||
/// only real asset is that it does not flatter anyone.
|
||||
pub fn offer_early_access(offer: Offer) -> Option<String> {
|
||||
println!();
|
||||
match offer {
|
||||
Offer::Host => {
|
||||
println!("\x1b[1;32m We would like to talk to you.\x1b[0m");
|
||||
println!(
|
||||
"\x1b[2m Machines that can actually host are rare — most results are clients —\x1b[0m"
|
||||
);
|
||||
println!(
|
||||
"\x1b[2m and yours is one. If you leave an email we will put you in the\x1b[0m"
|
||||
);
|
||||
println!(
|
||||
"\x1b[2m first group to try Nestri, and ask you first when there is\x1b[0m"
|
||||
);
|
||||
println!("\x1b[2m something to try.\x1b[0m");
|
||||
}
|
||||
Offer::Player => {
|
||||
println!("\x1b[1m Want to be among the first to try Nestri?\x1b[0m");
|
||||
println!(
|
||||
"\x1b[2m Leave an email and we will come to you when there is something\x1b[0m"
|
||||
);
|
||||
println!("\x1b[2m worth playing. Nothing else — no newsletter, no list.\x1b[0m");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!("\x1b[2m This is the only thing here that identifies you, it is entirely\x1b[0m");
|
||||
println!(
|
||||
"\x1b[2m optional, and it goes in the link with everything else so you will\x1b[0m"
|
||||
);
|
||||
println!("\x1b[2m see it before it is sent.\x1b[0m");
|
||||
|
||||
loop {
|
||||
print!("\x1b[1m email\x1b[0m \x1b[2m(Enter to skip)\x1b[0m > ");
|
||||
let _ = std::io::stdout().flush();
|
||||
let line = read_line();
|
||||
let e = line.trim();
|
||||
if e.is_empty() {
|
||||
println!(" \x1b[2mskipped\x1b[0m");
|
||||
return None;
|
||||
}
|
||||
if plausible_email(e) {
|
||||
println!(" \x1b[32m✓\x1b[0m");
|
||||
return Some(e.to_string());
|
||||
}
|
||||
println!(
|
||||
" \x1b[2mthat does not look like an address — try again, or Enter to skip\x1b[0m"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliberately loose. Rejecting a valid address is worse than accepting a junk
|
||||
/// one: a bounce costs us nothing, and arguing with somebody about their own
|
||||
/// email address over a regex is how you lose the response entirely.
|
||||
fn plausible_email(s: &str) -> bool {
|
||||
if s.len() > 254 || s.contains(char::is_whitespace) {
|
||||
return false;
|
||||
}
|
||||
let Some((local, domain)) = s.split_once('@') else {
|
||||
return false;
|
||||
};
|
||||
!local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::plausible_email;
|
||||
|
||||
#[test]
|
||||
fn accepts_real_addresses() {
|
||||
for e in [
|
||||
"a@b.co",
|
||||
"first.last+tag@sub.example.com",
|
||||
"someone@example.co.uk",
|
||||
"x_y-z@example.io",
|
||||
] {
|
||||
assert!(plausible_email(e), "{e} should be accepted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_the_obvious() {
|
||||
for e in [
|
||||
"",
|
||||
"nope",
|
||||
"@example.com",
|
||||
"a@b",
|
||||
"a@.com",
|
||||
"a@b.",
|
||||
"a b@c.com",
|
||||
] {
|
||||
assert!(!plausible_email(e), "{e} should be rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
459
apps/nesdoctor/src/display.rs
Normal file
459
apps/nesdoctor/src/display.rs
Normal file
@@ -0,0 +1,459 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
//! `fq_codel` can.
|
||||
|
||||
mod ask;
|
||||
mod display;
|
||||
mod hostreq;
|
||||
mod net;
|
||||
mod report;
|
||||
@@ -115,6 +116,11 @@ fn main() {
|
||||
}
|
||||
|
||||
// --- can it host ------------------------------------------------------
|
||||
let disp = display::probe();
|
||||
if !args.quiet {
|
||||
print_display(&disp);
|
||||
}
|
||||
|
||||
let host = hostreq::probe(&sys);
|
||||
if !args.quiet {
|
||||
report::print_checks(&host);
|
||||
@@ -165,7 +171,7 @@ fn main() {
|
||||
// is the only reason anything runs this way.
|
||||
let interactive = !args.quiet && std::io::stdin().is_terminal();
|
||||
|
||||
let answers = if args.yes || !interactive {
|
||||
let mut answers = if args.yes || !interactive {
|
||||
ask::Answers {
|
||||
// `--yes` is a deliberate consent; a pipe is not consent to read
|
||||
// somebody's library.
|
||||
@@ -198,9 +204,24 @@ fn main() {
|
||||
report::print_verdict(v, &netr);
|
||||
}
|
||||
|
||||
// The early-access offer comes after the verdict has been printed, so the
|
||||
// wording can be true and so nobody types an address before seeing what
|
||||
// this thing actually said about their machine. Before the link is built,
|
||||
// so what they typed goes in it and they see it there.
|
||||
if interactive && !args.yes {
|
||||
let offer = match v {
|
||||
report::Verdict::HostReady
|
||||
| report::Verdict::HostReadyLocalOnly
|
||||
| report::Verdict::HostFixable => ask::Offer::Host,
|
||||
_ => ask::Offer::Player,
|
||||
};
|
||||
answers.email = ask::offer_early_access(offer);
|
||||
}
|
||||
|
||||
let full = report::Full {
|
||||
nesdoctor: report::VERSION,
|
||||
sys: &sys,
|
||||
display: &disp,
|
||||
host: &host,
|
||||
net: &netr,
|
||||
steam: &steamr,
|
||||
@@ -474,3 +495,68 @@ fn print_steam(s: &steam::SteamReport) {
|
||||
}
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
fn print_display(d: &display::DisplayReport) {
|
||||
println!("\n\x1b[1mDisplay and decode\x1b[0m");
|
||||
let mut path = Vec::new();
|
||||
if let Some(s) = &d.session {
|
||||
path.push(s.clone());
|
||||
}
|
||||
if let Some(c) = &d.compositor {
|
||||
path.push(c.clone());
|
||||
}
|
||||
if d.xwayland {
|
||||
path.push("XWayland".into());
|
||||
}
|
||||
println!(
|
||||
" presentation path {}",
|
||||
if path.is_empty() {
|
||||
"unknown (headless?)".to_string()
|
||||
} else {
|
||||
path.join(" · ")
|
||||
}
|
||||
);
|
||||
|
||||
for o in &d.outputs {
|
||||
let geom = match (o.width, o.height, o.refresh_hz) {
|
||||
(Some(w), Some(h), Some(r)) => format!("{w}x{h} @ {r:.0} Hz"),
|
||||
(Some(w), Some(h), None) => format!("{w}x{h}"),
|
||||
_ => "unknown mode".into(),
|
||||
};
|
||||
println!(
|
||||
" {:<19} {geom}{}",
|
||||
o.name.clone().unwrap_or_else(|| "display".into()),
|
||||
o.bit_depth
|
||||
.map(|b| format!(", {b}-bit"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let mut caps = Vec::new();
|
||||
if !o.eotf.is_empty() {
|
||||
caps.push(format!("HDR: {}", o.eotf.join(", ")));
|
||||
}
|
||||
if !o.bt2020.is_empty() {
|
||||
caps.push(format!("BT.2020: {}", o.bt2020.join(", ")));
|
||||
}
|
||||
if o.ycbcr420 {
|
||||
caps.push("4:2:0".into());
|
||||
}
|
||||
if !caps.is_empty() {
|
||||
println!(" \x1b[2m {}\x1b[0m", caps.join(" · "));
|
||||
}
|
||||
}
|
||||
|
||||
let dec = |label: &str, v: &[&str]| {
|
||||
if !v.is_empty() {
|
||||
println!(" {label:<19} {}", v.join(", "));
|
||||
}
|
||||
};
|
||||
dec("Vulkan decode", &d.decode.vulkan);
|
||||
dec("VA-API decode", &d.decode.vaapi);
|
||||
if d.decode.vulkan.is_empty() && d.decode.vaapi.is_empty() {
|
||||
println!(" \x1b[2mdecode could not be determined here\x1b[0m");
|
||||
}
|
||||
println!(
|
||||
" \x1b[2mpresent mode and tearing need a real window, so they are not in this\x1b[0m"
|
||||
);
|
||||
println!(" \x1b[2mreport — they belong in the client.\x1b[0m");
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub struct Full<'a> {
|
||||
pub nesdoctor: &'static str,
|
||||
pub sys: &'a SysInfo,
|
||||
pub display: &'a crate::display::DisplayReport,
|
||||
pub host: &'a HostReport,
|
||||
pub net: &'a NetReport,
|
||||
pub steam: &'a SteamReport,
|
||||
@@ -544,6 +545,59 @@ pub fn submit_url(base: &str, f_: &Full) -> String {
|
||||
put(k, v.clone());
|
||||
}
|
||||
}
|
||||
// Display and decode. These are what decide the colour and codec choices
|
||||
// on the wire -- until now every one of them was made against the single
|
||||
// panel in one room.
|
||||
let d = f_.display;
|
||||
if let Some(s) = &d.session {
|
||||
put("session", s.clone());
|
||||
}
|
||||
if let Some(c) = &d.compositor {
|
||||
put("wm", c.clone());
|
||||
}
|
||||
if d.xwayland {
|
||||
put("xwayland", "1".into());
|
||||
}
|
||||
if let Some(o) = d.outputs.first() {
|
||||
if let (Some(w), Some(h)) = (o.width, o.height) {
|
||||
put("mode", format!("{w}x{h}"));
|
||||
}
|
||||
if let Some(r) = o.refresh_hz {
|
||||
put("hz", format!("{r:.0}"));
|
||||
}
|
||||
if let Some(b) = o.bit_depth {
|
||||
put("bpc", b.to_string());
|
||||
}
|
||||
if !o.eotf.is_empty() {
|
||||
put("eotf", o.eotf.join(","));
|
||||
}
|
||||
if !o.bt2020.is_empty() {
|
||||
put("bt2020", o.bt2020.join(","));
|
||||
}
|
||||
put(
|
||||
"chroma",
|
||||
match (o.ycbcr420, o.ycbcr444) {
|
||||
(true, true) => "420+444",
|
||||
(true, false) => "420",
|
||||
(false, true) => "444",
|
||||
(false, false) => "-",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if d.outputs.len() > 1 {
|
||||
put("outputs", d.outputs.len().to_string());
|
||||
}
|
||||
if !d.decode.vulkan.is_empty() {
|
||||
put("vkdec", d.decode.vulkan.join(","));
|
||||
}
|
||||
if !d.decode.vaapi.is_empty() {
|
||||
put("vadec", d.decode.vaapi.join(","));
|
||||
}
|
||||
|
||||
if let Some(e) = &answers.email {
|
||||
put("email", e.clone());
|
||||
}
|
||||
put("verdict", verdict.tag().to_string());
|
||||
|
||||
format!("{}/?{}", base.trim_end_matches('/'), q.join("&"))
|
||||
@@ -556,6 +610,7 @@ pub fn submit_url(base: &str, f_: &Full) -> String {
|
||||
pub fn submit_contents(steam: &SteamReport, answers: &Answers) -> Vec<&'static str> {
|
||||
let mut v = vec![
|
||||
"this machine's OS, CPU, RAM and GPU model",
|
||||
"your display's resolution, refresh rate, colour depth and HDR support",
|
||||
"which host requirements passed and which did not",
|
||||
"the network figures you just saw",
|
||||
"free disk space, and how long this machine tends to stay on",
|
||||
@@ -573,7 +628,13 @@ pub fn submit_contents(steam: &SteamReport, answers: &Answers) -> Vec<&'static s
|
||||
{
|
||||
v.push("your answers to the questions");
|
||||
}
|
||||
v.push("no hostname, no IP address, no username, no file paths");
|
||||
// Reworded when the email field landed. The old line said "no username, no
|
||||
// identifiers", which stopped being true, and leaving it up would have been
|
||||
// the dishonest option.
|
||||
if answers.email.is_some() {
|
||||
v.push("the email address you just typed — the only identifying thing here");
|
||||
}
|
||||
v.push("no hostname, no IP address, no file paths, and nothing about your account");
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user