feat(nesdoctor): close the loop with a submit link, and three corrections

The run used to end by asking someone to copy a line and paste it "wherever you
got this from". That is not a loop, it is a hope. At a few hundred people in one
quiet channel, with one shot and no chance to say "please run it again, we made
a mistake", the drop-off between seeing output and pasting it somewhere is
where the whole exercise dies.

So the run now ends with a link, a plain-English list of what the link
contains, and Enter to open it. No account, no form, no mail client, no
assumption about being logged into anything.

  · Query parameters, not an encoded blob. A blob would be shorter and would
    let us send more; it would also mean the person clicking cannot read what
    they are sending, which is the one thing this program has going for it.
  · It carries more than the clipboard line did, because nobody has to eyeball
    it in a chat window: every requirement check individually rather than only
    the aggregate verdict -- which single requirement stops people is the thing
    worth knowing -- plus the full latency triple, the 24-hour launch
    histogram, and the five largest titles.
  · `--submit-url` points it at a local worker; `--no-open` prints only.
  · The clipboard line stays as the offline path: a headless host, a machine
    with no browser, or somebody who would rather not click a link we wrote.

Three corrections
  1. Steam consent now defaults to yes. This is a questionnaire and the person
     running it knows that; defaulting to no framed our own instrument as
     something to be wary of, and the yes/no still asks.
  2. The virglrenderer check is gone. It could only ever report "present,
     patch state unknown" -- a row incapable of passing -- and the box now
     carries its own virglrenderer and Mesa inside the image it runs in, so the
     host's copies are not on the path. It was telling prospective hosts their
     machine was wrong when it was not.
  3. The roadmap question -- cloud, or reach my own machine from anywhere -- is
     now question one, of everybody. It was going to be a channel poll, and the
     channel is no longer the only way in. It is asked first because a
     respondent who quits after one question should have answered that one.

Two collisions found while wiring it up: the `gpu` check id overwrote the GPU
model parameter (last-writer-wins in a query string is a silent loss), so check
ids are prefixed `ck_`; and both renderers now take the assembled report rather
than seven positional arguments that had to be kept in step by hand.
This commit is contained in:
Wanjohi
2026-09-02 12:01:45 +03:00
parent 55b72fc4af
commit a76cb2aa6a
6 changed files with 504 additions and 88 deletions

View File

@@ -61,6 +61,8 @@ always a router setting rather than a line you need to upgrade.
| `--yes` | Take the defaults — for a second run, not a first | | `--yes` | Take the defaults — for a second run, not a first |
| `--json <PATH>` | Where to write the full report | | `--json <PATH>` | Where to write the full report |
| `--quiet` | Only the summary line, for scripting | | `--quiet` | Only the summary line, for scripting |
| `--no-open` | Do not offer to open a browser; just print the link |
| `--submit-url` | Where the submit link points (default `https://doctor.nestri.io`) |
## Verdicts ## Verdicts
@@ -73,14 +75,30 @@ always a router setting rather than a line you need to upgrade.
| `CLIENT` | Not a host. A complete answer, and what most machines are | | `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 | | `UNKNOWN` | A blocking check could not be run. An unknown is not a no |
## Sending it back
At the end it prints a link, lists in plain English what the link contains, and
opens it when you press Enter. That is the whole submission — no account, no
form, no email client.
The link is built from **readable query parameters** rather than an encoded
blob. A blob would be shorter and would let us send more; it would also mean you
cannot read what you are sending, which is the one thing this program has going
for it.
If you would rather not click a link we wrote, the short line is printed too and
put on your clipboard.
## What it deliberately does not tell you ## What it deliberately does not tell you
- **A pass is not a promise.** Every check is a *necessary* condition. Nothing - **A pass is not a promise.** Every check is a *necessary* condition. Nothing
here runs under load, so a machine that passes can still fail on block I/O. here runs under load, so a machine that passes can still fail on block I/O.
- **`vulkaninfo` reporting the encode extension is not proof the path works.** - **`vulkaninfo` reporting the encode extension is not proof the path works.**
We have had a correct extension list over a broken path before. We have had a correct extension list over a broken path before.
- **Whether `libvirglrenderer` carries the native-context patches cannot be - **The host's `virglrenderer` and Mesa are not checked at all**, on purpose.
determined from outside**, so that row reports presence only. The box carries its own inside the image it runs in, so the host's copies are
not on the path — and a row that could only ever say "present, patch state
unknown" told prospective hosts their machine was wrong when it was not.
## Building ## Building

View File

@@ -22,6 +22,12 @@ use serde::Serialize;
#[derive(Debug, Serialize, Default)] #[derive(Debug, Serialize, Default)]
pub struct Answers { pub struct Answers {
/// The one question that could change the roadmap, so it is asked first and
/// of everybody. Answer `remote` is a product with no capacity model, no
/// library cost, no peak and no trough — and it is what the people we can
/// currently reach are already doing for themselves. That is the
/// uncomfortable possibility, which is the reason to ask rather than not.
pub want: Option<String>,
/// USERS.md 7, roughly: is this machine a host, a client, or both? /// USERS.md 7, roughly: is this machine a host, a client, or both?
pub role: Option<String>, pub role: Option<String>,
/// USERS.md 6: cash or credit. Only asked of a machine that could host. /// USERS.md 6: cash or credit. Only asked of a machine that could host.
@@ -47,11 +53,25 @@ pub struct Ctx {
pub fn run(ctx: &Ctx) -> Answers { pub fn run(ctx: &Ctx) -> Answers {
let mut a = Answers::default(); let mut a = Answers::default();
println!("\n\x1b[1mFive questions, maximum. Enter skips any of them.\x1b[0m"); println!("\n\x1b[1mFour or five questions, and Enter skips any of them.\x1b[0m");
println!("\x1b[2mNothing here is sent anywhere. You will see the exact line before you\x1b[0m"); println!("\x1b[2mNothing here is sent anywhere. You will see the exact line before you\x1b[0m");
println!("\x1b[2mshare it, and you can edit or discard it.\x1b[0m\n"); println!("\x1b[2mshare it, and you can edit or discard it.\x1b[0m\n");
// 1 — role. Asked of everyone, because it decides what the rest means. // 1 — the roadmap question. First because it is the one whose answer we
// would most regret not having, and because a respondent who quits after
// one question should have answered this one.
a.want = choose(
"If Nestri could only do one of these well, which would you want?",
&[
("cloud", "Play my games on your hardware, somewhere near me"),
("remote", "Reach my own gaming PC from anywhere"),
("both", "Both, equally"),
("watch", "Neither — just having a look"),
],
);
a.asked += 1;
// 2 — role. Asked of everyone, because it decides what the rest means.
a.role = choose( a.role = choose(
"What is this machine for?", "What is this machine for?",
&[ &[
@@ -63,7 +83,7 @@ pub fn run(ctx: &Ctx) -> Answers {
); );
a.asked += 1; a.asked += 1;
// 2 — cash or credit, only where it is not a hypothetical. Asking someone // 3 — cash or credit, only where it is not a hypothetical. Asking someone
// whose machine cannot host what they would charge for it produces noise. // whose machine cannot host what they would charge for it produces noise.
if ctx.capable && matches!(a.role.as_deref(), Some("host") | Some("both") | None) { if ctx.capable && matches!(a.role.as_deref(), Some("host") | Some("both") | None) {
a.share_for = choose( a.share_for = choose(
@@ -79,7 +99,7 @@ pub fn run(ctx: &Ctx) -> Answers {
a.asked += 1; a.asked += 1;
} }
// 3 — current spend. The factual version of willingness to pay. // 4 — current spend. The factual version of willingness to pay.
a.pays_today = choose( a.pays_today = choose(
"What do you pay a month for gaming right now, all in?", "What do you pay a month for gaming right now, all in?",
&[ &[
@@ -92,7 +112,7 @@ pub fn run(ctx: &Ctx) -> Answers {
); );
a.asked += 1; a.asked += 1;
// 4 — a client machine may still have a host behind it. This converts a // 5 — a client machine may still have a host behind it. This converts a
// respondent who is not a candidate into a supply data point. // respondent who is not a candidate into a supply data point.
if !ctx.is_linux { if !ctx.is_linux {
a.other_linux = choose( a.other_linux = choose(
@@ -106,7 +126,7 @@ pub fn run(ctx: &Ctx) -> Answers {
a.asked += 1; a.asked += 1;
} }
// 5 — the consent gate. Last, so that by now the person has seen what this // The consent gate — not counted as one of the five. Last, so that by now the person has seen what this
// program is and what it prints. // program is and what it prints.
if ctx.steam_present { if ctx.steam_present {
println!(); println!();
@@ -120,7 +140,7 @@ pub fn run(ctx: &Ctx) -> Answers {
println!(" \x1b[2mIt is read locally. Titles never appear in the shareable line — only"); println!(" \x1b[2mIt is read locally. Titles never appear in the shareable line — only");
println!(" a count, a size band, and an hour histogram. You will see all of it.\x1b[0m"); println!(" a count, a size band, and an hour histogram. You will see all of it.\x1b[0m");
println!(); println!();
a.steam_consent = yes_no("May I read it?", false); a.steam_consent = yes_no("May I read it?", true);
} }
if !ctx.could_host && ctx.is_linux { if !ctx.could_host && ctx.is_linux {

View File

@@ -10,9 +10,12 @@
//! - **`vulkaninfo` is not sufficient by itself.** The contract says so twice, //! - **`vulkaninfo` is not sufficient by itself.** The contract says so twice,
//! we have had the case that proves it: extension present, path still broken. So the encode row reports what the extension list says //! we have had the case that proves it: extension present, path still broken. So the encode row reports what the extension list says
//! and labels it as such. //! and labels it as such.
//! - **The renderer's patch state is not checkable from outside.** The contract //! - **The renderer is not checked at all, on purpose.** It used to be, and the
//! calls it "the requirement most likely to be silently wrong". We report the //! row could only ever say "present, patch state unknown" — which is a row
//! library's presence and version and say nothing about the patch. //! that cannot pass. The box now carries its own virglrenderer and Mesa
//! inside the image it runs in, so the host's copies are not on the path and
//! asking about them told a prospective host their machine was wrong when it
//! was not.
//! - **Nothing here is measured under load.** A host that passes every row can //! - **Nothing here is measured under load.** A host that passes every row can
//! still fail on block I/O, which is the real density ceiling and needs a //! still fail on block I/O, which is the real density ceiling and needs a
//! benchmark rather than a probe. //! benchmark rather than a probe.
@@ -189,34 +192,6 @@ pub fn probe(sys: &SysInfo) -> HostReport {
blocking: true, blocking: true,
}); });
// --- virglrenderer ----------------------------------------------------
let virgl = ["/usr/lib", "/usr/lib64", "/usr/lib/x86_64-linux-gnu"]
.iter()
.flat_map(|d| std::fs::read_dir(d).ok())
.flatten()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.find(|n| n.starts_with("libvirglrenderer.so"));
c.push(Check {
id: "virgl",
what: "libvirglrenderer with DRM native context, patched",
state: match &virgl {
Some(_) => State::Unknown,
None => State::Fail,
},
detail: match &virgl {
Some(n) => format!(
"{n} found. Whether it carries the native-context patches cannot be told from \
outside — the contract calls this the requirement most likely to be silently \
wrong, so we report presence only."
),
None => "not found. The host supplies the native context, not the guest.".into(),
},
// Not blocking, because unknown-vs-missing is the whole point and the
// patched build is something we would ship anyway.
blocking: false,
});
// --- two stores ------------------------------------------------------- // --- two stores -------------------------------------------------------
// ZFS for content, direct-I/O-capable for box images, and not the same // ZFS for content, direct-I/O-capable for box images, and not the same
// filesystem, because ZFS ignores `O_DIRECT`. // filesystem, because ZFS ignores `O_DIRECT`.

View File

@@ -80,6 +80,14 @@ struct Args {
/// Print only the summary line. /// Print only the summary line.
#[arg(long)] #[arg(long)]
quiet: bool, quiet: bool,
/// Where the submit link points. Override to test against a local worker.
#[arg(long, default_value = "https://doctor.nestri.io")]
submit_url: String,
/// Do not offer to open a browser; just print the link.
#[arg(long)]
no_open: bool,
} }
fn main() { fn main() {
@@ -183,9 +191,6 @@ fn main() {
report::print_verdict(v, &netr); report::print_verdict(v, &netr);
} }
let line = report::summary_line(&sys, &host, &netr, &steamr, &answers, v, &region);
// --- the full report, locally -----------------------------------------
let full = report::Full { let full = report::Full {
nesdoctor: report::VERSION, nesdoctor: report::VERSION,
sys: &sys, sys: &sys,
@@ -196,6 +201,9 @@ fn main() {
verdict: v, verdict: v,
region_hint: region.clone(), region_hint: region.clone(),
}; };
let line = report::summary_line(&full);
// --- the full report, locally -----------------------------------------
let wrote = serde_json::to_string_pretty(&full) let wrote = serde_json::to_string_pretty(&full)
.ok() .ok()
.and_then(|j| std::fs::write(&args.json, j).ok().map(|_| ())) .and_then(|j| std::fs::write(&args.json, j).ok().map(|_| ()))
@@ -206,62 +214,74 @@ fn main() {
return; return;
} }
let clip = report::to_clipboard(&line); let url = report::submit_url(&args.submit_url, &full);
println!(); println!();
println!("\x1b[1m─── Copy this ───────────────────────────────────────────────────\x1b[0m"); println!("\x1b[1m─── One keystroke and we are done ───────────────────────────────\x1b[0m");
println!(); println!();
println!("\x1b[1;97;44m {line} \x1b[0m"); println!("\x1b[2m Everything above goes to us through this link. It contains:\x1b[0m");
println!(); for item in report::submit_contents(&steamr, &answers) {
match clip { println!("\x1b[2m · {item}\x1b[0m");
Some(tool) => println!(
"\x1b[32m ✓ Already on your clipboard\x1b[0m \x1b[2m(via {tool}) — just paste it.\x1b[0m"
),
None => println!(
"\x1b[2m Select the line above to copy it. (Install wl-clipboard or xclip and\x1b[0m\n\x1b[2m this happens by itself next time.)\x1b[0m"
),
} }
println!(); println!();
println!( println!("\x1b[2m {}\x1b[0m", args.submit_url);
"\x1b[2m Every field is above: no hostname, no IP, no username, no game titles,\x1b[0m"
);
println!("\x1b[2m no paths. A size band rather than a size, hours rather than dates.\x1b[0m");
println!(); println!();
println!("\x1b[1m → Paste it into the thread you got this from.\x1b[0m");
println!( let opened = if args.no_open || !interactive {
"\x1b[2m It is the only way we find out what the machines on the other end are,\x1b[0m" false
} else {
print!(
"\x1b[1;97;44m Press Enter to send it \x1b[0m\x1b[2m (or Ctrl-C to send nothing) \x1b[0m"
); );
println!("\x1b[2m and right now we genuinely have no idea.\x1b[0m"); let _ = std::io::stdout().flush();
let mut s = String::new();
let _ = std::io::stdin().read_line(&mut s);
println!();
report::open_in_browser(&url)
};
if opened {
println!("\x1b[32m ✓ Opened in your browser. That is it — thank you.\x1b[0m");
println!(
"\x1b[2m If the page did not load, the link is below and it still works later.\x1b[0m"
);
} else {
println!("\x1b[1m Open this to send it:\x1b[0m");
}
println!();
println!("\x1b[4;36m{url}\x1b[0m");
println!();
// The clipboard line stays as the offline path: a headless host, a machine
// with no browser, or somebody who would rather paste into a channel than
// click a link we wrote.
let clip = report::to_clipboard(&line);
println!("\x1b[2m Prefer to paste it yourself? The short version:\x1b[0m");
println!();
println!(" {line}");
if let Some(tool) = clip {
println!("\x1b[2m (also on your clipboard, via {tool})\x1b[0m");
}
if wrote { if wrote {
println!(); println!();
println!("\x1b[1mAnd if you feel like being properly helpful\x1b[0m"); println!("\x1b[1m And if you feel like being properly helpful\x1b[0m");
println!( println!(
"\x1b[2m {} has the long version: every check with its reason, the full\x1b[0m", "\x1b[2m {} has the long version every check with its reason, the\x1b[0m",
args.json.display() args.json.display()
); );
println!( println!(
"\x1b[2m latency series, and — if you said yes to Steam — your installed titles\x1b[0m" "\x1b[2m full latency series, and your installed titles with sizes and launch\x1b[0m"
);
println!("\x1b[2m with their sizes and launch times.\x1b[0m");
println!();
println!(
"\x1b[2m That file is more useful to us than the line by a long way: it is what\x1b[0m"
); );
println!( println!(
"\x1b[2m lets us size a game library properly and see which requirement actually\x1b[0m" "\x1b[2m times. It is more useful to us than anything above, because it is what\x1b[0m"
); );
println!( println!(
"\x1b[2m stops people. Have a look through it — it is plain JSON — and send it\x1b[0m" "\x1b[2m lets us size a real game library. Have a read and send it along if\x1b[0m"
); );
println!( println!("\x1b[2m nothing in there bothers you.\x1b[0m");
"\x1b[2m along if nothing in there bothers you. Entirely optional, and the\x1b[0m"
);
println!("\x1b[2m line above is already plenty.\x1b[0m");
} }
println!(); println!();
println!("\x1b[2mThanks. Genuinely — this is the part we cannot do on our own.\x1b[0m");
println!();
} }
fn banner() { fn banner() {

View File

@@ -130,15 +130,19 @@ pub fn verdict(sys: &SysInfo, host: &HostReport, net: &NetReport) -> Verdict {
} }
/// The line to paste. Pipe-separated fields, `k=v` inside, stable key order. /// The line to paste. Pipe-separated fields, `k=v` inside, stable key order.
pub fn summary_line( /// Both renderers take the assembled report rather than seven arguments: the
sys: &SysInfo, /// set of things they need is exactly [`Full`], and keeping them in step with
host: &HostReport, /// it is the point.
net: &NetReport, pub fn summary_line(f_: &Full) -> String {
steam: &SteamReport, let (sys, host, net, steam, answers, verdict, region) = (
answers: &Answers, f_.sys,
verdict: Verdict, f_.host,
region: &Option<String>, f_.net,
) -> String { f_.steam,
f_.answers,
f_.verdict,
&f_.region_hint,
);
let mut f: Vec<String> = Vec::new(); let mut f: Vec<String> = Vec::new();
f.push(format!("nesdoctor {VERSION}")); f.push(format!("nesdoctor {VERSION}"));
f.push(format!("{}/{}", sys.os, sys.arch)); f.push(format!("{}/{}", sys.os, sys.arch));
@@ -351,3 +355,219 @@ pub fn to_clipboard(line: &str) -> Option<&'static str> {
} }
None None
} }
// ------------------------------------------------------------------ submit ---
/// Percent-encode everything that is not unreserved. Small enough to write.
fn enc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// The URL that submits this run.
///
/// Query parameters rather than an opaque blob, deliberately. A base64 payload
/// would be shorter and would let us send more, and it would also mean the
/// person clicking cannot read what they are sending — which is the one thing
/// this program has going for it. Readable parameters are self-documenting, and
/// the length is nowhere near a browser limit.
///
/// Carries more than the clipboard line does, because it is not something
/// anyone has to eyeball in a chat window: every check individually, the full
/// latency triple, the 24-hour launch histogram, and the five largest titles.
pub fn submit_url(base: &str, f_: &Full) -> String {
let (sys, host, net, steam, answers, verdict, region) = (
f_.sys,
f_.host,
f_.net,
f_.steam,
f_.answers,
f_.verdict,
&f_.region_hint,
);
let mut q: Vec<String> = Vec::new();
let mut put = |k: &str, v: String| q.push(format!("{k}={}", enc(&v)));
put("v", VERSION.to_string());
put("os", format!("{}/{}", sys.os, sys.arch));
if let Some(rel) = &sys.release {
put("rel", rel.clone());
}
if let Some(g) = sys
.gpus
.iter()
.find(|g| g.render_node.is_some())
.or_else(|| sys.gpus.first())
{
put("gpu", g.name.clone());
}
if sys.gpus.len() > 1 {
put("gpus", sys.gpus.len().to_string());
}
put("cpu", sys.cpu_threads.to_string());
if let Some(m) = &sys.cpu_model {
put("cpumodel", m.clone());
}
if let Some(r) = sys.ram_gib {
put("ram", format!("{r:.0}"));
}
// Every check, individually — the aggregate verdict hides which single
// requirement stops people, which is the thing worth knowing.
for c in &host.checks {
// Prefixed: the `gpu` check id would otherwise overwrite the GPU model
// parameter, and last-writer-wins in a query string is a silent loss.
put(
&format!("ck_{}", c.id),
match c.state {
State::Pass => "y",
State::Fail => "n",
State::Unknown => "?",
}
.to_string(),
);
}
if let Some(u) = net.upstream_mbps {
put("up", format!("{u:.1}"));
}
if let Some(v) = net.idle_rtt_ms {
put("rtt", format!("{v:.0}"));
}
if let Some(v) = net.loaded_rtt_ms {
put("rttload", format!("{v:.0}"));
}
if let Some(v) = net.loaded_rtt_p95_ms {
put("rttp95", format!("{v:.0}"));
}
if let Some(v) = net.bloat_ms {
put("bloat", format!("{v:.0}"));
}
if let Some(g) = net.grade {
put("grade", g.to_string());
}
if let Some(r) = region {
put("edge", r.clone());
}
if let Some(d) = sys.disks.first() {
put("disk", format!("{:.0}", d.free_gib));
if let Some(fs) = &d.fs {
put("diskfs", fs.clone());
}
}
put("disks", sys.disks.len().to_string());
if let (Some(h), Some(s)) = (sys.powered_hours_per_day, sys.powered_span_days) {
put("powered", format!("{h:.1}"));
put("span", format!("{s:.0}"));
}
if steam.found {
put("titles", steam.titles.to_string());
put("gib", format!("{:.0}", steam::gib(steam.bytes_on_disk)));
if steam.launch_samples > 0 {
put(
"hours",
steam
.launch_hours
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(","),
);
put("n", steam.launch_samples.to_string());
}
if let Some((a, b)) = steam.peak_window {
put("peak", format!("{a}-{b}"));
}
if !steam.largest.is_empty() {
// Whether the title distribution has a head decides whether a depot
// cache is worth building at all, and it cannot be seen from counts.
put(
"top",
steam
.largest
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>()
.join("~"),
);
}
}
for (k, v) in [
("want", &answers.want),
("role", &answers.role),
("share", &answers.share_for),
("pays", &answers.pays_today),
("otherlinux", &answers.other_linux),
] {
if let Some(v) = v {
put(k, v.clone());
}
}
put("verdict", verdict.tag().to_string());
format!("{}/?{}", base.trim_end_matches('/'), q.join("&"))
}
/// Plain English list of what the submit URL contains, printed before it opens.
///
/// The URL is readable, but it is also 800 characters long and nobody reads
/// 800 characters. This is the honest summary of it.
pub fn submit_contents(steam: &SteamReport, answers: &Answers) -> Vec<&'static str> {
let mut v = vec![
"this machine's OS, CPU, RAM and GPU model",
"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",
];
if steam.found && steam.titles > 0 {
v.push("how many games are installed, their total size, and your five largest");
if steam.launch_samples > 0 {
v.push("the hour-of-day histogram above — hours, never dates");
}
}
if answers.want.is_some()
|| answers.role.is_some()
|| answers.share_for.is_some()
|| answers.pays_today.is_some()
{
v.push("your answers to the questions");
}
v.push("no hostname, no IP address, no username, no file paths");
v
}
/// Hand a URL to whatever the desktop uses to open links.
pub fn open_in_browser(url: &str) -> bool {
use std::process::{Command, Stdio};
let attempts: [(&str, &[&str]); 4] = [
("xdg-open", &[]),
("open", &[]), // macOS
("cmd", &["/C", "start", ""]), // Windows
("wslview", &[]), // WSL, where xdg-open is often absent
];
for (cmd, args) in attempts {
if Command::new(cmd)
.args(args)
.arg(url)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return true;
}
}
false
}

163
nesdoctor.json Normal file
View File

@@ -0,0 +1,163 @@
{
"nesdoctor": "0.1.0",
"sys": {
"os": "linux",
"arch": "x86_64",
"release": "CachyOS",
"kernel": "7.1.8-1-cachyos",
"cpu_model": "AMD Ryzen 5 7530U with Radeon Graphics",
"cpu_threads": 12,
"ram_gib": 13.496604919433594,
"gpus": [
{
"name": "AMD Barcelo",
"vendor": "AMD",
"render_node": "/dev/dri/renderD128"
}
],
"disks": [
{
"mount": "/",
"fs": "btrfs",
"source": "/dev/nvme0n1p2",
"free_gib": 90.39772033691406
},
{
"mount": "/tmp",
"fs": "tmpfs",
"source": "tmpfs",
"free_gib": 3.8342933654785156
}
],
"uptime_hours": 12.97486111111111,
"powered_hours_per_day": null,
"powered_span_days": 2.0136990167476854
},
"host": {
"checks": [
{
"id": "kvm",
"what": "/dev/kvm present and openable",
"state": "pass",
"detail": "yes",
"blocking": true
},
{
"id": "gpu",
"what": "an AMD or Intel GPU with a DRM render node",
"state": "pass",
"detail": "AMD Barcelo at /dev/dri/renderD128",
"blocking": true
},
{
"id": "vkvideo",
"what": "VK_KHR_video_encode_queue plus a codec extension",
"state": "pass",
"detail": "extensions present. Note: presence is not proof — a working extension list with a broken path has happened here before, so this row is a necessary and not a sufficient condition.",
"blocking": true
},
{
"id": "virgl",
"what": "libvirglrenderer with DRM native context, patched",
"state": "unknown",
"detail": "libvirglrenderer.so.1.11.0 found. Whether it carries the native-context patches cannot be told from outside — the contract calls this the requirement most likely to be silently wrong, so we report presence only.",
"blocking": false
},
{
"id": "content-store",
"what": "a ZFS pool for game datasets",
"state": "fail",
"detail": "no ZFS mount found. One dataset per game, cloned per player, is the whole of the content store — no other filesystem gives clones and send/recv.",
"blocking": false
},
{
"id": "box-store",
"what": "ext4 or xfs, not /, for box images (O_DIRECT)",
"state": "fail",
"detail": "none found. A box image must be openable O_DIRECT or the box has no storage bound at all — ZFS ignores the flag, and a warm page cache let a capped guest read at 13.3 GB/s against a 20 MB/s cap. Games are hundreds of GiB, so / is not an option either.",
"blocking": false
},
{
"id": "cgroup-io",
"what": "the io cgroup controller available",
"state": "pass",
"detail": "present at the root",
"blocking": false
},
{
"id": "virtiofsd",
"what": "virtiofsd, for shared directories into the guest",
"state": "pass",
"detail": "/usr/bin/virtiofsd",
"blocking": false
}
],
"could_host": true,
"unknowns": 0
},
"net": {
"idle_rtt_ms": null,
"loaded_rtt_ms": null,
"loaded_rtt_p95_ms": null,
"bloat_ms": null,
"upstream_mbps": null,
"grade": null,
"note": "skipped with --no-net"
},
"steam": {
"found": true,
"roots": [
"/home/wanjohiryan/.steam/steam",
"/home/wanjohiryan/.local/share/Steam"
],
"titles": 1,
"bytes_on_disk": 45003193272,
"largest": [
[
"Control Ultimate Edition",
45003193272
]
],
"launch_hours": [
8,
4,
2,
8,
0,
4,
0,
4,
2,
0,
0,
0,
4,
6,
2,
0,
8,
2,
2,
2,
2,
8,
6,
0
],
"launch_samples": 74,
"peak_window": [
20,
3
]
},
"answers": {
"role": "play",
"share_for": null,
"pays_today": "1-9",
"other_linux": null,
"steam_consent": true,
"asked": 2
},
"verdict": "unknown",
"region_hint": null
}