mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(nesdoctor): a host readiness checker that measures instead of asking
The first executable form of our host requirements. Until now a machine was
qualified by a human reading a table of hard requirements, and a requirement
nothing can check is one that is silently optional.
It also replaces a form. Every field we wanted from a prospective host --
upstream, latency under load, spare disk, hours powered, library size, play
hours -- is measurable, and most of them cannot be answered honestly by a
human anyway: almost nobody knows their real upstream and essentially nobody
has seen their own bufferbloat figure. What is left for the questions is only
what a machine cannot know: intent, and what someone already pays.
What it does
- Checks every hard requirement: /dev/kvm, an AMD or Intel GPU with a DRM
render node, VK_KHR_video_encode_queue plus a codec, virglrenderer, the
two stores, the io cgroup controller, virtiofsd. Pass/fail/unknown, and
unknown is never collapsed into fail -- a machine we could not ask is not
a machine that failed.
- Measures upstream and, the point of the whole thing, added latency under
load. Grade bands come from the frame budget rather than convention: the
network allowance is ~40 ms because render, encode, decode, display and
jitter buffer have already spent ~58 ms.
- Reads Steam, only with an explicit yes, for library size and shape and an
hour-of-day histogram of launches -- one sample per title, which is a real
distribution obtained without asking.
- Asks at most five questions, branched, all skippable.
No server
Nothing is uploaded and no telemetry endpoint exists. The network test talks
to Cloudflare's public sink and to 1.1.1.1, neither of which is ours. Output
is a line on the terminal that the person may choose to paste. The line
carries no hostname, IP, username, game title or path -- a size band rather
than a size, hours rather than dates. The long version stays in a local
JSON file.
Three bugs found by running it, all of which would have produced wrong data
- vulkaninfo --summary lists ZERO VK_KHR_video entries where full vulkaninfo
lists five on the same machine. Preferring the summary reported "not
advertised" on a card that advertises it -- a false negative on the check
most likely to disqualify a host.
- btrfs subvolumes were counted as separate disks: /, /home and /srv each
reported 91 GiB of one 91 GiB device. Deduped by backing device, which the
two-stores check needs anyway since it wants separate devices.
- Proton and the Steam Linux Runtimes are installed like games and are not
games. Five of eight entries on the test machine, so the title count was
5x too high and the library-shape question was corrupted.
And one finding about the development connection, now encoded as a verdict:
178 ms idle RTT, served from Johannesburg. That machine passes every other
check and cannot host for a European player, because it is distance and no
upgrade shortens it. HOST-READY-LOCAL exists for exactly that case -- and it
is the coverage argument from the other end: somewhere with no nearby edge is
somewhere a local host is the only option anyone has.
Dependencies are four, three of them serde/clap/anyhow. The VDF parser, every
platform probe and the text wrapping are in-tree: a binary handed to strangers
has a dependency tree that is part of its interface.
This commit is contained in:
25
apps/nesdoctor/Cargo.toml
Normal file
25
apps/nesdoctor/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "nesdoctor"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "nesdoctor"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Dependencies are deliberately few. This binary is handed to strangers and
|
||||
# asked to be trusted with a look at their machine, so the dependency tree is
|
||||
# part of the interface: a reviewer has to be able to read all of it. Anything
|
||||
# that can be done with `std` is done with `std` -- the VDF parser and every
|
||||
# platform probe shell out or read files rather than pulling a crate.
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
# The one non-trivial dependency, and only for the throughput half of the
|
||||
# network test. rustls rather than native-tls so there is no OpenSSL to find.
|
||||
ureq = "3"
|
||||
92
apps/nesdoctor/README.md
Normal file
92
apps/nesdoctor/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# nesdoctor
|
||||
|
||||
**Is this machine any good — as a Nestri host, or as a client?**
|
||||
|
||||
```
|
||||
nesdoctor
|
||||
```
|
||||
|
||||
Checks every hard requirement for running a Nestri box, measures what your
|
||||
connection actually does when it is busy, and asks at most five questions.
|
||||
|
||||
## Nothing is uploaded
|
||||
|
||||
There is no server. No telemetry endpoint exists, and there is no build of this
|
||||
program that reports home — the network test talks to Cloudflare's public
|
||||
speed-test sink and to `1.1.1.1`, neither of which is ours.
|
||||
|
||||
The output is a line on your terminal. If you want us to have it, you paste it
|
||||
somewhere. If you do not, we never had it. That is a property of the design
|
||||
rather than a promise about our intentions: there is nothing to switch on later.
|
||||
|
||||
The shareable line carries **no hostname, no IP, no username, no game titles and
|
||||
no file paths** — a size band rather than a size, and an hour histogram rather
|
||||
than timestamps. The long version does include titles and paths, and it stays in
|
||||
`nesdoctor.json` on your disk.
|
||||
|
||||
Reading your Steam library needs an explicit yes, and the question is asked last,
|
||||
after you have seen what this program does.
|
||||
|
||||
## The number worth running it for
|
||||
|
||||
Everybody knows their download speed. Almost nobody has seen **how much latency
|
||||
their connection adds when it is busy**, and for anything interactive that is the
|
||||
figure that decides it:
|
||||
|
||||
```
|
||||
upstream 28 Mbps
|
||||
latency, idle 179 ms
|
||||
latency, loaded 198 ms
|
||||
added under load +19 ms grade B
|
||||
```
|
||||
|
||||
A 500 Mbps uplink that queues for 300 ms under load cannot carry a game. A
|
||||
25 Mbps one with `fq_codel` or CAKE can. If your grade is C or F it is almost
|
||||
always a router setting rather than a line you need to upgrade.
|
||||
|
||||
## Options
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `--no-net` | Skip the network test (it uploads ~100 MB) |
|
||||
| `--no-steam` | Never look at Steam, and do not ask |
|
||||
| `--yes` | Take the defaults — for a second run, not a first |
|
||||
| `--json <PATH>` | Where to write the full report |
|
||||
| `--quiet` | Only the summary line, for scripting |
|
||||
|
||||
## Verdicts
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `HOST-READY` | Passes everything, and close enough to the network to serve others |
|
||||
| `HOST-READY-LOCAL` | Passes everything, but far enough out that it can only serve players nearby |
|
||||
| `HOST-NET` | Good machine; the connection is in the way |
|
||||
| `HOST-FIXABLE` | Nothing is a hardware limit — what is missing can be installed |
|
||||
| `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 |
|
||||
|
||||
## What it deliberately does not tell you
|
||||
|
||||
- **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.
|
||||
- **`vulkaninfo` reporting the encode extension is not proof the path works.**
|
||||
We have had a correct extension list over a broken path before.
|
||||
- **Whether `libvirglrenderer` carries the native-context patches cannot be
|
||||
determined from outside**, so that row reports presence only.
|
||||
|
||||
## Building
|
||||
|
||||
```
|
||||
cargo build --release -p nesdoctor
|
||||
```
|
||||
|
||||
Four dependencies, three of them `serde`/`clap`/`anyhow`. Everything that could
|
||||
be done with `std` is: the VDF parser, the platform probes and the text wrapping
|
||||
are all in-tree, because a binary handed to strangers has a dependency tree that
|
||||
is part of its interface.
|
||||
|
||||
A static build, for a release someone downloads rather than compiles:
|
||||
|
||||
```
|
||||
cargo build --release -p nesdoctor --target x86_64-unknown-linux-musl
|
||||
```
|
||||
182
apps/nesdoctor/src/ask.rs
Normal file
182
apps/nesdoctor/src/ask.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
//! The five questions.
|
||||
//!
|
||||
//! Hard cap at five, branched on what was found, and every one of them either
|
||||
//! tells us something no probe can or gates something. Three rules:
|
||||
//!
|
||||
//! - **Never ask what can be measured.** No question asks about upstream, hours
|
||||
//! powered, library size, or play schedule — those are probed. What is left is
|
||||
//! only what a machine cannot know: intent, and what someone already pays.
|
||||
//! - **Ask about the present, not intentions about the future.** Hence *"what do
|
||||
//! you pay today"* rather than *"what would you pay"* — the first is a fact
|
||||
//! and the second is a ceiling.
|
||||
//! - **Do not explain why we are asking before they answer**, because a
|
||||
//! cooperative respondent will help by giving the answer that appears wanted.
|
||||
//!
|
||||
//! Everything is skippable with Enter. A skipped answer is recorded as skipped
|
||||
//! rather than as a default, because "did not say" and "said no" are different
|
||||
//! data and collapsing them is how a survey lies.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub struct Answers {
|
||||
/// USERS.md 7, roughly: is this machine a host, a client, or both?
|
||||
pub role: Option<String>,
|
||||
/// USERS.md 6: cash or credit. Only asked of a machine that could host.
|
||||
pub share_for: Option<String>,
|
||||
/// USERS.md 5, in its factual form: current spend, not willingness to pay.
|
||||
pub pays_today: Option<String>,
|
||||
/// Asked only of a non-Linux machine: is there a Linux box behind it?
|
||||
pub other_linux: Option<String>,
|
||||
/// Consent gate, not a survey question.
|
||||
pub steam_consent: bool,
|
||||
pub asked: usize,
|
||||
}
|
||||
|
||||
pub struct Ctx {
|
||||
/// Whether the blocking host checks passed.
|
||||
pub could_host: bool,
|
||||
/// Whether the machine is plausibly capable regardless of software setup —
|
||||
/// used to decide if the cash-or-credit question is worth asking at all.
|
||||
pub capable: bool,
|
||||
pub is_linux: bool,
|
||||
pub steam_present: bool,
|
||||
}
|
||||
|
||||
pub fn run(ctx: &Ctx) -> Answers {
|
||||
let mut a = Answers::default();
|
||||
println!("\n\x1b[1mFive questions, maximum. 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[2mshare it, and you can edit or discard it.\x1b[0m\n");
|
||||
|
||||
// 1 — role. Asked of everyone, because it decides what the rest means.
|
||||
a.role = choose(
|
||||
"What is this machine for?",
|
||||
&[
|
||||
("play", "Playing games on"),
|
||||
("host", "Hosting games for other people"),
|
||||
("both", "Both"),
|
||||
("look", "Just having a look"),
|
||||
],
|
||||
);
|
||||
a.asked += 1;
|
||||
|
||||
// 2 — cash or credit, only where it is not a hypothetical. Asking someone
|
||||
// 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) {
|
||||
a.share_for = choose(
|
||||
"If this machine served another player while you were not using it, \
|
||||
would you rather have",
|
||||
&[
|
||||
("credit", "Credit off my own subscription"),
|
||||
("cash", "Cash"),
|
||||
("either", "Either, I don't mind"),
|
||||
("no", "Neither — I would not share it"),
|
||||
],
|
||||
);
|
||||
a.asked += 1;
|
||||
}
|
||||
|
||||
// 3 — current spend. The factual version of willingness to pay.
|
||||
a.pays_today = choose(
|
||||
"What do you pay a month for gaming right now, all in?",
|
||||
&[
|
||||
("0", "Nothing"),
|
||||
("1-9", "Under 10"),
|
||||
("10-19", "10 to 19"),
|
||||
("20-39", "20 to 39"),
|
||||
("40+", "40 or more"),
|
||||
],
|
||||
);
|
||||
a.asked += 1;
|
||||
|
||||
// 4 — a client machine may still have a host behind it. This converts a
|
||||
// respondent who is not a candidate into a supply data point.
|
||||
if !ctx.is_linux {
|
||||
a.other_linux = choose(
|
||||
"Do you have another machine — a Linux one — that could host?",
|
||||
&[
|
||||
("yes", "Yes"),
|
||||
("could", "No, but I could set one up"),
|
||||
("no", "No"),
|
||||
],
|
||||
);
|
||||
a.asked += 1;
|
||||
}
|
||||
|
||||
// 5 — the consent gate. Last, so that by now the person has seen what this
|
||||
// program is and what it prints.
|
||||
if ctx.steam_present {
|
||||
println!();
|
||||
println!("\x1b[1mOne permission.\x1b[0m Steam keeps, on this disk, the size of each game you");
|
||||
println!("have installed and the time you last launched it. Reading it answers three");
|
||||
println!("things we would otherwise have to ask you badly: how big a library is, what");
|
||||
println!("shape it has, and what hours you actually play.");
|
||||
println!();
|
||||
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.steam_consent = yes_no("May I read it?", false);
|
||||
}
|
||||
|
||||
if !ctx.could_host && ctx.is_linux {
|
||||
println!(
|
||||
"\n\x1b[2m(Skipped the hosting question — the checks above say this machine cannot \
|
||||
host yet.)\x1b[0m"
|
||||
);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// A numbered single choice. Returns the stable key, not the label, so the
|
||||
/// wording can change without breaking comparisons across runs.
|
||||
fn choose(question: &str, options: &[(&str, &str)]) -> Option<String> {
|
||||
println!("\x1b[1m{question}\x1b[0m");
|
||||
for (i, (_, label)) in options.iter().enumerate() {
|
||||
println!(" {}) {}", i + 1, label);
|
||||
}
|
||||
loop {
|
||||
print!(" > ");
|
||||
let _ = std::io::stdout().flush();
|
||||
let line = read_line();
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
println!(" \x1b[2mskipped\x1b[0m\n");
|
||||
return None;
|
||||
}
|
||||
match line.parse::<usize>() {
|
||||
Ok(n) if n >= 1 && n <= options.len() => {
|
||||
println!();
|
||||
return Some(options[n - 1].0.to_string());
|
||||
}
|
||||
_ => println!(" \x1b[2m1–{}, or Enter to skip\x1b[0m", options.len()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn yes_no(question: &str, default_yes: bool) -> bool {
|
||||
let hint = if default_yes { "[Y/n]" } else { "[y/N]" };
|
||||
loop {
|
||||
print!("\x1b[1m{question}\x1b[0m {hint} ");
|
||||
let _ = std::io::stdout().flush();
|
||||
let line = read_line();
|
||||
match line.trim().to_ascii_lowercase().as_str() {
|
||||
"y" | "yes" => return true,
|
||||
"n" | "no" => return false,
|
||||
"" => return default_yes,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_line() -> String {
|
||||
let mut s = String::new();
|
||||
// EOF (a piped stdin, or Ctrl-D) reads as a skip rather than as an error:
|
||||
// this binary should still produce a report when run non-interactively.
|
||||
if std::io::stdin().lock().read_line(&mut s).is_err() {
|
||||
return String::new();
|
||||
}
|
||||
s
|
||||
}
|
||||
334
apps/nesdoctor/src/hostreq.rs
Normal file
334
apps/nesdoctor/src/hostreq.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
//! Can this machine run a box?
|
||||
//!
|
||||
//! One check per hard requirement for running a box, in the order our host
|
||||
//! requirements list them. This is the first thing that has ever *executed* that
|
||||
//! list — until now a host was qualified by a human reading a table, and a
|
||||
//! requirement nothing can check is a requirement that is silently optional.
|
||||
//!
|
||||
//! Three deliberate limits, stated because a green result here is not a promise:
|
||||
//!
|
||||
//! - **`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
|
||||
//! and labels it as such.
|
||||
//! - **The renderer's patch state is not checkable from outside.** The contract
|
||||
//! calls it "the requirement most likely to be silently wrong". We report the
|
||||
//! library's presence and version and say nothing about the patch.
|
||||
//! - **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
|
||||
//! benchmark rather than a probe.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::sys::{self, SysInfo};
|
||||
|
||||
/// A single requirement's outcome.
|
||||
///
|
||||
/// `Unknown` is a first-class result and is never collapsed into `Fail`. The
|
||||
/// difference matters: a failed check is a machine that cannot host, an unknown
|
||||
/// one is a machine we could not ask, and reporting the second as the first is
|
||||
/// how you lose a capable host to a missing `lspci`.
|
||||
#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum State {
|
||||
Pass,
|
||||
Fail,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Check {
|
||||
pub id: &'static str,
|
||||
pub what: &'static str,
|
||||
pub state: State,
|
||||
pub detail: String,
|
||||
/// True when a `Fail` here means the machine cannot host at all.
|
||||
pub blocking: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HostReport {
|
||||
pub checks: Vec<Check>,
|
||||
/// `true` only when no blocking check failed.
|
||||
pub could_host: bool,
|
||||
/// Blocking checks we could not determine. A host with these is a
|
||||
/// *maybe*, and saying so is the point.
|
||||
pub unknowns: usize,
|
||||
}
|
||||
|
||||
pub fn probe(sys: &SysInfo) -> HostReport {
|
||||
let mut c: Vec<Check> = Vec::new();
|
||||
|
||||
// Not Linux: every row below is meaningless, and pretending otherwise
|
||||
// produces a page of red for a machine that was never a candidate. A
|
||||
// Windows box is a *client*, which is a perfectly good thing to be.
|
||||
if sys.os != "linux" {
|
||||
c.push(Check {
|
||||
id: "os",
|
||||
what: "Linux with KVM",
|
||||
state: State::Fail,
|
||||
detail: format!(
|
||||
"this is {}. A host must be Linux; nesbox is a microVM hypervisor. \
|
||||
As a client, this machine is fine and nothing below applies.",
|
||||
sys.os
|
||||
),
|
||||
blocking: true,
|
||||
});
|
||||
return HostReport {
|
||||
could_host: false,
|
||||
unknowns: 0,
|
||||
checks: c,
|
||||
};
|
||||
}
|
||||
|
||||
// --- KVM -------------------------------------------------------------
|
||||
let kvm = sys::exists("/dev/kvm");
|
||||
let kvm_rw = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open("/dev/kvm")
|
||||
.is_ok();
|
||||
c.push(Check {
|
||||
id: "kvm",
|
||||
what: "/dev/kvm present and openable",
|
||||
state: if kvm_rw {
|
||||
State::Pass
|
||||
} else if kvm {
|
||||
State::Fail
|
||||
} else {
|
||||
State::Fail
|
||||
},
|
||||
detail: match (kvm, kvm_rw) {
|
||||
(true, true) => "yes".into(),
|
||||
(true, false) => {
|
||||
"present but not openable — you are not in the `kvm` group, or virtualisation \
|
||||
is disabled in firmware"
|
||||
.into()
|
||||
}
|
||||
_ => "missing — enable SVM/VT-x in firmware, or this is a VM without nested virt".into(),
|
||||
},
|
||||
blocking: true,
|
||||
});
|
||||
|
||||
// --- GPU vendor and render node --------------------------------------
|
||||
let usable: Vec<_> = sys
|
||||
.gpus
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
g.render_node.is_some() && matches!(g.vendor.as_deref(), Some("AMD") | Some("Intel"))
|
||||
})
|
||||
.collect();
|
||||
let nvidia_only = !sys.gpus.is_empty()
|
||||
&& sys
|
||||
.gpus
|
||||
.iter()
|
||||
.all(|g| g.vendor.as_deref() == Some("NVIDIA"));
|
||||
c.push(Check {
|
||||
id: "gpu",
|
||||
what: "an AMD or Intel GPU with a DRM render node",
|
||||
state: if !usable.is_empty() {
|
||||
State::Pass
|
||||
} else {
|
||||
State::Fail
|
||||
},
|
||||
detail: if let Some(g) = usable.first() {
|
||||
format!("{} at {}", g.name, g.render_node.clone().unwrap_or_default())
|
||||
} else if nvidia_only {
|
||||
"NVIDIA only. Nvidia needs virtio-nvgpu, which is not funded — so this card \
|
||||
cannot host today. It is a fine client."
|
||||
.into()
|
||||
} else if sys.gpus.is_empty() {
|
||||
"no GPU found under /sys/class/drm".into()
|
||||
} else {
|
||||
format!(
|
||||
"found {} but none with both a supported vendor and a render node",
|
||||
sys.gpus.len()
|
||||
)
|
||||
},
|
||||
blocking: true,
|
||||
});
|
||||
|
||||
// --- Vulkan Video encode ---------------------------------------------
|
||||
// The contract is explicit that this is stricter than "has a hardware
|
||||
// encoder": VA-API and NVENC are far more common than this extension, so a
|
||||
// machine can encode well and still fail.
|
||||
// `--summary` is NOT usable here and the order used to be the other way
|
||||
// round. Measured on the development laptop 2026-09-02: `vulkaninfo
|
||||
// --summary` lists **zero** `VK_KHR_video*` entries while full `vulkaninfo`
|
||||
// lists five on the same machine — so preferring the summary reported "not
|
||||
// advertised" on a card that advertises it, which is a false negative on
|
||||
// the one check most likely to disqualify a host.
|
||||
let vk = sys::sh("vulkaninfo", &[]).unwrap_or_default();
|
||||
let has_encode_q = vk.contains("VK_KHR_video_encode_queue");
|
||||
let has_codec = vk.contains("VK_KHR_video_encode_h264")
|
||||
|| vk.contains("VK_KHR_video_encode_h265")
|
||||
|| vk.contains("VK_KHR_video_encode_av1");
|
||||
c.push(Check {
|
||||
id: "vkvideo",
|
||||
what: "VK_KHR_video_encode_queue plus a codec extension",
|
||||
state: if vk.is_empty() {
|
||||
State::Unknown
|
||||
} else if has_encode_q && has_codec {
|
||||
State::Pass
|
||||
} else {
|
||||
State::Fail
|
||||
},
|
||||
detail: if vk.is_empty() {
|
||||
"vulkaninfo not installed, so this could not be checked. Install `vulkan-tools`.".into()
|
||||
} else if has_encode_q && has_codec {
|
||||
"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."
|
||||
.into()
|
||||
} else if has_encode_q {
|
||||
"encode queue present but no codec extension found".into()
|
||||
} else {
|
||||
"not advertised. This is stricter than 'has a hardware encoder': VA-API and NVENC \
|
||||
are much more common than this extension."
|
||||
.into()
|
||||
},
|
||||
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 -------------------------------------------------------
|
||||
// ZFS for content, direct-I/O-capable for box images, and not the same
|
||||
// filesystem, because ZFS ignores `O_DIRECT`.
|
||||
let zfs_mounts: Vec<&crate::sys::Disk> = sys
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|d| d.fs.as_deref() == Some("zfs"))
|
||||
.collect();
|
||||
c.push(Check {
|
||||
id: "content-store",
|
||||
what: "a ZFS pool for game datasets",
|
||||
state: if zfs_mounts.is_empty() {
|
||||
State::Fail
|
||||
} else {
|
||||
State::Pass
|
||||
},
|
||||
detail: if let Some(d) = zfs_mounts.first() {
|
||||
format!("{} ({:.0} GiB free)", d.mount, d.free_gib)
|
||||
} else {
|
||||
"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."
|
||||
.into()
|
||||
},
|
||||
blocking: false,
|
||||
});
|
||||
|
||||
// "Its own device" is part of the requirement, not a nicety: separate
|
||||
// devices keep a box's disk latency out of a game download's write path.
|
||||
let root_dev = sys
|
||||
.disks
|
||||
.iter()
|
||||
.find(|d| d.mount == "/")
|
||||
.and_then(|d| d.source.clone());
|
||||
let box_store = sys.disks.iter().find(|d| {
|
||||
matches!(d.fs.as_deref(), Some("ext4") | Some("xfs"))
|
||||
&& d.mount != "/"
|
||||
&& d.source.is_some()
|
||||
&& d.source != root_dev
|
||||
&& d.free_gib >= 64.0
|
||||
});
|
||||
c.push(Check {
|
||||
id: "box-store",
|
||||
what: "ext4 or xfs, not /, for box images (O_DIRECT)",
|
||||
state: match box_store {
|
||||
Some(_) => State::Pass,
|
||||
None => State::Fail,
|
||||
},
|
||||
detail: match box_store {
|
||||
Some(d) => format!(
|
||||
"{} on {} ({:.0} GiB free)",
|
||||
d.mount,
|
||||
d.fs.clone().unwrap_or_default(),
|
||||
d.free_gib
|
||||
),
|
||||
None => "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."
|
||||
.into(),
|
||||
},
|
||||
blocking: false,
|
||||
});
|
||||
|
||||
// --- cgroup io delegation --------------------------------------------
|
||||
// `io.max` is applied per box, and a user session gets `cpu memory pids`
|
||||
// but not `io` — so an unprivileged bound has nothing to attach to.
|
||||
let io_ctrl = std::fs::read_to_string("/sys/fs/cgroup/cgroup.controllers")
|
||||
.map(|s| s.split_whitespace().any(|w| w == "io"))
|
||||
.unwrap_or(false);
|
||||
c.push(Check {
|
||||
id: "cgroup-io",
|
||||
what: "the io cgroup controller available",
|
||||
state: if io_ctrl { State::Pass } else { State::Fail },
|
||||
detail: if io_ctrl {
|
||||
"present at the root".into()
|
||||
} else {
|
||||
"not available. Without it a per-box io.max silently has nothing to attach to."
|
||||
.into()
|
||||
},
|
||||
blocking: false,
|
||||
});
|
||||
|
||||
// --- virtiofsd --------------------------------------------------------
|
||||
let virtiofsd = ["/usr/bin/virtiofsd", "/usr/libexec/virtiofsd", "/usr/lib/virtiofsd"]
|
||||
.iter()
|
||||
.find(|p| sys::exists(p));
|
||||
c.push(Check {
|
||||
id: "virtiofsd",
|
||||
what: "virtiofsd, for shared directories into the guest",
|
||||
state: if virtiofsd.is_some() {
|
||||
State::Pass
|
||||
} else {
|
||||
State::Unknown
|
||||
},
|
||||
detail: match virtiofsd {
|
||||
Some(p) => (*p).to_string(),
|
||||
None => "not found in the usual places; it may still be packaged elsewhere".into(),
|
||||
},
|
||||
blocking: false,
|
||||
});
|
||||
|
||||
let unknowns = c
|
||||
.iter()
|
||||
.filter(|k| k.blocking && k.state == State::Unknown)
|
||||
.count();
|
||||
let could_host = !c
|
||||
.iter()
|
||||
.any(|k| k.blocking && k.state == State::Fail);
|
||||
|
||||
HostReport {
|
||||
checks: c,
|
||||
could_host,
|
||||
unknowns,
|
||||
}
|
||||
}
|
||||
349
apps/nesdoctor/src/main.rs
Normal file
349
apps/nesdoctor/src/main.rs
Normal file
@@ -0,0 +1,349 @@
|
||||
//! nesdoctor — is this machine any good, as a Nestri host or as a client?
|
||||
//!
|
||||
//! # What it is
|
||||
//!
|
||||
//! The first executable form of our host requirements. Until now a host was
|
||||
//! qualified by a human reading a table, and a requirement nothing can check is
|
||||
//! one that is silently optional. It is also the qualification anyone offering a
|
||||
//! machine has to pass — and the check that decides it is loaded latency, not
|
||||
//! throughput.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```text
|
||||
//! nesdoctor [OPTIONS]
|
||||
//!
|
||||
//! --no-net Skip the network test (it uploads ~100 MB)
|
||||
//! --no-steam Never look at Steam, and do not ask
|
||||
//! --yes Accept the prompts; still prints, still saves nothing remote
|
||||
//! --json <PATH> Where to write the full report [default: ./nesdoctor.json]
|
||||
//! --quiet Only the summary line, for scripting
|
||||
//! ```
|
||||
//!
|
||||
//! # What it does not do
|
||||
//!
|
||||
//! **It has no server.** Nothing is uploaded, no telemetry endpoint exists, and
|
||||
//! there is no build of this program that reports home — the network test talks
|
||||
//! to Cloudflare's public speed-test sink and to `1.1.1.1`, neither of which is
|
||||
//! ours. The output is a line on your terminal. If you want us to have it, you
|
||||
//! paste it somewhere; if you do not, we never had it.
|
||||
//!
|
||||
//! That is a design decision and not a promise about our intentions: there is
|
||||
//! nothing to trust, because there is nothing to switch on later.
|
||||
//!
|
||||
//! # Why anyone would run it
|
||||
//!
|
||||
//! Because of one number almost nobody has ever seen: how much latency their
|
||||
//! own connection adds when it is busy. Throughput is the figure everybody
|
||||
//! knows and it is the wrong one — a 500 Mbps uplink that queues for 300 ms
|
||||
//! under load cannot carry an interactive stream, and a 25 Mbps one with
|
||||
//! `fq_codel` can.
|
||||
|
||||
mod ask;
|
||||
mod hostreq;
|
||||
mod net;
|
||||
mod report;
|
||||
mod steam;
|
||||
mod sys;
|
||||
mod vdf;
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "nesdoctor",
|
||||
about = "Is this machine any good, as a Nestri host or as a client?",
|
||||
version
|
||||
)]
|
||||
struct Args {
|
||||
/// Skip the network test. It uploads roughly 100 MB to a public speed-test
|
||||
/// sink; on a metered connection that is worth knowing about up front.
|
||||
#[arg(long)]
|
||||
no_net: bool,
|
||||
|
||||
/// Never read Steam, and do not ask about it.
|
||||
#[arg(long)]
|
||||
no_steam: bool,
|
||||
|
||||
/// Answer the prompts affirmatively and take the defaults. Intended for a
|
||||
/// second run, not a first one — the questions are the point.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
|
||||
/// Where to write the full report.
|
||||
#[arg(long, default_value = "nesdoctor.json")]
|
||||
json: PathBuf,
|
||||
|
||||
/// Print only the summary line.
|
||||
#[arg(long)]
|
||||
quiet: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
if !args.quiet {
|
||||
banner();
|
||||
}
|
||||
|
||||
// --- what the machine is ---------------------------------------------
|
||||
if !args.quiet {
|
||||
net::tick(" reading hardware…");
|
||||
}
|
||||
let sys = sys::probe();
|
||||
if !args.quiet {
|
||||
println!(" done");
|
||||
print_sys(&sys);
|
||||
}
|
||||
|
||||
// --- can it host ------------------------------------------------------
|
||||
let host = hostreq::probe(&sys);
|
||||
if !args.quiet {
|
||||
report::print_checks(&host);
|
||||
}
|
||||
|
||||
// --- the network ------------------------------------------------------
|
||||
let netr = if args.no_net {
|
||||
net::NetReport::unmeasured("skipped with --no-net")
|
||||
} else {
|
||||
if !args.quiet {
|
||||
println!("\n\x1b[1mNetwork\x1b[0m");
|
||||
println!(
|
||||
"\x1b[2m About 20 seconds. This saturates your upload on purpose — that is the\x1b[0m"
|
||||
);
|
||||
println!(
|
||||
"\x1b[2m only way to see the number that matters — so expect a video call to\x1b[0m"
|
||||
);
|
||||
println!("\x1b[2m stutter while it runs. Roughly 100 MB of upload.\x1b[0m");
|
||||
net::tick(" measuring…");
|
||||
}
|
||||
let r = net::run();
|
||||
if !args.quiet {
|
||||
println!(" done");
|
||||
print_net(&r);
|
||||
}
|
||||
r
|
||||
};
|
||||
|
||||
let region = if args.no_net {
|
||||
None
|
||||
} else {
|
||||
net::region_hint()
|
||||
};
|
||||
|
||||
// --- the questions ----------------------------------------------------
|
||||
let steam_present = !args.no_steam && steam::present();
|
||||
let capable = sys.os == "linux"
|
||||
&& sys
|
||||
.gpus
|
||||
.iter()
|
||||
.any(|g| matches!(g.vendor.as_deref(), Some("AMD") | Some("Intel")));
|
||||
|
||||
let answers = if args.yes {
|
||||
ask::Answers {
|
||||
steam_consent: steam_present,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
ask::run(&ask::Ctx {
|
||||
could_host: host.could_host,
|
||||
capable,
|
||||
is_linux: sys.os == "linux",
|
||||
steam_present,
|
||||
})
|
||||
};
|
||||
|
||||
// --- Steam, only with a yes -------------------------------------------
|
||||
let steamr = if answers.steam_consent {
|
||||
let r = steam::read();
|
||||
if !args.quiet {
|
||||
print_steam(&r);
|
||||
}
|
||||
r
|
||||
} else {
|
||||
steam::SteamReport::default()
|
||||
};
|
||||
|
||||
// --- verdict ----------------------------------------------------------
|
||||
let v = report::verdict(&sys, &host, &netr);
|
||||
if !args.quiet {
|
||||
report::print_verdict(v, &netr);
|
||||
}
|
||||
|
||||
let line = report::summary_line(&sys, &host, &netr, &steamr, &answers, v, ®ion);
|
||||
|
||||
// --- the full report, locally -----------------------------------------
|
||||
let full = report::Full {
|
||||
nesdoctor: report::VERSION,
|
||||
sys: &sys,
|
||||
host: &host,
|
||||
net: &netr,
|
||||
steam: &steamr,
|
||||
answers: &answers,
|
||||
verdict: v,
|
||||
region_hint: region.clone(),
|
||||
};
|
||||
let wrote = serde_json::to_string_pretty(&full)
|
||||
.ok()
|
||||
.and_then(|j| std::fs::write(&args.json, j).ok().map(|_| ()))
|
||||
.is_some();
|
||||
|
||||
if args.quiet {
|
||||
println!("{line}");
|
||||
return;
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("\x1b[1mThe line to share, if you want to\x1b[0m");
|
||||
println!(
|
||||
"\x1b[2m No hostname, no IP, no username, no game titles, no paths. A size band\x1b[0m"
|
||||
);
|
||||
println!("\x1b[2m rather than a size, and hours rather than dates. Read it yourself:\x1b[0m");
|
||||
println!();
|
||||
println!(" {line}");
|
||||
println!();
|
||||
if wrote {
|
||||
println!(
|
||||
"\x1b[2mThe long version — which does include titles and paths — is in {} and\x1b[0m",
|
||||
args.json.display()
|
||||
);
|
||||
println!("\x1b[2mgoes nowhere unless you send it.\x1b[0m");
|
||||
}
|
||||
println!();
|
||||
println!("\x1b[2mIf you are willing: paste that line into the thread you got this from.\x1b[0m");
|
||||
println!("\x1b[2mIt is the only way we learn what the machines on the other end are.\x1b[0m");
|
||||
}
|
||||
|
||||
fn banner() {
|
||||
println!();
|
||||
println!("\x1b[1mnesdoctor {}\x1b[0m", report::VERSION);
|
||||
println!("\x1b[2mChecks whether this machine can host a Nestri box, measures what your\x1b[0m");
|
||||
println!("\x1b[2mconnection actually does under load, and asks at most five questions.\x1b[0m");
|
||||
println!();
|
||||
println!("\x1b[2mNothing is uploaded. There is no server to upload to — the output is a\x1b[0m");
|
||||
println!("\x1b[2mline on your terminal that you may choose to paste somewhere.\x1b[0m");
|
||||
println!();
|
||||
}
|
||||
|
||||
fn print_sys(s: &sys::SysInfo) {
|
||||
println!("\n\x1b[1mMachine\x1b[0m");
|
||||
println!(
|
||||
" {} {} · {}",
|
||||
s.os,
|
||||
s.arch,
|
||||
s.release.clone().unwrap_or_else(|| "unknown".into())
|
||||
);
|
||||
if let Some(c) = &s.cpu_model {
|
||||
println!(
|
||||
" {c} · {} threads · {} RAM",
|
||||
s.cpu_threads,
|
||||
s.ram_gib.map_or("?".into(), |g| format!("{g:.0} GiB"))
|
||||
);
|
||||
}
|
||||
for g in &s.gpus {
|
||||
println!(
|
||||
" {}{}",
|
||||
g.name,
|
||||
g.render_node
|
||||
.as_ref()
|
||||
.map(|r| format!(" · {r}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
for d in s.disks.iter().take(3) {
|
||||
println!(
|
||||
" {} · {} · {:.0} GiB free",
|
||||
d.mount,
|
||||
d.fs.clone().unwrap_or_else(|| "?".into()),
|
||||
d.free_gib
|
||||
);
|
||||
}
|
||||
match (s.powered_hours_per_day, s.powered_span_days) {
|
||||
(Some(h), Some(days)) => println!(
|
||||
" powered {h:.1} h/day, averaged over {days:.0} days of boot history\n \
|
||||
\x1b[2m(measured, not asked — it counts powered rather than idle, so read it as \
|
||||
'always on' vs 'evenings')\x1b[0m"
|
||||
),
|
||||
_ => println!(" \x1b[2mno boot history available, so hours-powered is unknown\x1b[0m"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_net(n: &net::NetReport) {
|
||||
let f = |o: Option<f64>, unit: &str| {
|
||||
o.map(|v| format!("{v:.0}{unit}"))
|
||||
.unwrap_or_else(|| "—".into())
|
||||
};
|
||||
println!(" upstream {}", f(n.upstream_mbps, " Mbps"));
|
||||
println!(" latency, idle {}", f(n.idle_rtt_ms, " ms"));
|
||||
if n.idle_rtt_ms.is_some_and(|r| r > 60.0) {
|
||||
println!(
|
||||
" \x1b[2m That is the round trip to the *nearest* major network, so it is a\x1b[0m"
|
||||
);
|
||||
println!(
|
||||
" \x1b[2m floor on what any player sees. It is distance, not a fault.\x1b[0m"
|
||||
);
|
||||
}
|
||||
println!(" latency, loaded {}", f(n.loaded_rtt_ms, " ms"));
|
||||
println!(" latency, loaded p95 {}", f(n.loaded_rtt_p95_ms, " ms"));
|
||||
match (n.bloat_ms, n.grade) {
|
||||
(Some(b), Some(g)) => {
|
||||
let colour = match g {
|
||||
"A" => "32",
|
||||
"B" => "32",
|
||||
"C" => "33",
|
||||
_ => "31",
|
||||
};
|
||||
println!(" \x1b[1madded under load \x1b[{colour}m+{b:.0} ms grade {g}\x1b[0m");
|
||||
println!(
|
||||
" \x1b[2m A/B is fine, C is marginal, F cannot carry an interactive stream.\x1b[0m"
|
||||
);
|
||||
println!(
|
||||
" \x1b[2m The whole network allowance is about 40 ms, because render, encode,\x1b[0m"
|
||||
);
|
||||
println!(
|
||||
" \x1b[2m decode, display and the jitter buffer have already spent ~58 ms.\x1b[0m"
|
||||
);
|
||||
}
|
||||
_ => println!(" added under load —"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_steam(s: &steam::SteamReport) {
|
||||
if !s.found {
|
||||
return;
|
||||
}
|
||||
println!("\n\x1b[1mSteam\x1b[0m");
|
||||
println!(
|
||||
" {} titles installed · {:.0} GiB on disk",
|
||||
s.titles,
|
||||
steam::gib(s.bytes_on_disk)
|
||||
);
|
||||
for (name, bytes) in &s.largest {
|
||||
println!(" {:>6.0} GiB {}", steam::gib(*bytes), name);
|
||||
}
|
||||
if s.launch_samples > 0 {
|
||||
println!(
|
||||
"\n When you launch games — {} samples, one per title, local time:",
|
||||
s.launch_samples
|
||||
);
|
||||
println!(" {}", steam::sparkline(&s.launch_hours));
|
||||
println!(" \x1b[2m0h 6h 12h 18h 23h\x1b[0m");
|
||||
if let Some((a, b)) = s.peak_window {
|
||||
println!(
|
||||
" Half of your launches fall between \x1b[1m{a:02}:00 and {b:02}:59\x1b[0m."
|
||||
);
|
||||
let width = if b >= a { b - a + 1 } else { 24 - a + b + 1 };
|
||||
if width <= 6 {
|
||||
println!(
|
||||
" \x1b[2mThat is a narrow window, which is what a peak looks like.\x1b[0m"
|
||||
);
|
||||
} else {
|
||||
println!(" \x1b[2mThat is spread out — no strong peak.\x1b[0m");
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
264
apps/nesdoctor/src/net.rs
Normal file
264
apps/nesdoctor/src/net.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
//! The network test, and the reason anybody runs this.
|
||||
//!
|
||||
//! Two numbers, and the second is the one that matters:
|
||||
//!
|
||||
//! 1. **Sustained upstream**, because a session is video going the unusual way
|
||||
//! down a domestic line.
|
||||
//! 2. **Added latency under load** — bufferbloat. A saturated consumer uplink
|
||||
//! adds 100–500 ms of queueing delay unless the router runs `fq_codel` or
|
||||
//! CAKE, and against a click-to-photon budget that has already spent ~58 ms
|
||||
//! on render, encode, decode, display and jitter buffer, **that is more than
|
||||
//! the entire remaining allowance.**
|
||||
//!
|
||||
//! So a peer with 500 Mbps up and no queue management is unusable, and one with
|
||||
//! 25 Mbps and CAKE is fine. Throughput is the number everyone volunteers and
|
||||
//! it is the wrong one. This is also why the test is worth running for its own
|
||||
//! sake: almost nobody has ever seen their own figure.
|
||||
//!
|
||||
//! Method: measure TCP connect time (one round trip, no privileges, no ICMP)
|
||||
//! to a fixed anycast address; then saturate the uplink with parallel HTTPS
|
||||
//! uploads and measure the same thing again. The difference is the queue.
|
||||
|
||||
use std::io::Write;
|
||||
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Cloudflare's speed-test sink. Chosen because it is anycast (so the path is
|
||||
/// short from anywhere, which keeps this a test of the *access* link rather
|
||||
/// than of the distance to us), it discards uploads, and it is not ours — we
|
||||
/// have no server to keep alive and no data arrives anywhere we control.
|
||||
const UPLOAD_URL: &str = "https://speed.cloudflare.com/__up";
|
||||
|
||||
/// Latency target. `1.1.1.1:443` answers a TCP handshake from essentially
|
||||
/// everywhere and is anycast for the same reason as above.
|
||||
const PROBE_ADDR: &str = "1.1.1.1:443";
|
||||
|
||||
/// How long to hold the uplink saturated. Long enough for a queue to fill —
|
||||
/// a short burst measures nothing, because bufferbloat is a steady-state
|
||||
/// property — and short enough not to ruin someone's evening.
|
||||
const LOAD_SECONDS: u64 = 8;
|
||||
const STREAMS: usize = 3;
|
||||
const CHUNK: usize = 1 << 20; // 1 MiB per write
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct NetReport {
|
||||
pub idle_rtt_ms: Option<f64>,
|
||||
pub loaded_rtt_ms: Option<f64>,
|
||||
pub loaded_rtt_p95_ms: Option<f64>,
|
||||
/// Loaded minus idle: the queue, in milliseconds.
|
||||
pub bloat_ms: Option<f64>,
|
||||
pub upstream_mbps: Option<f64>,
|
||||
/// A, B, C or F. See [`grade`].
|
||||
pub grade: Option<&'static str>,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
impl NetReport {
|
||||
/// A report with no numbers, carrying the reason. Used both for `--no-net`
|
||||
/// and for a genuine failure, because the caller has to render the same
|
||||
/// "we do not know" either way.
|
||||
pub fn unmeasured(note: &str) -> Self {
|
||||
Self::unavailable(note)
|
||||
}
|
||||
|
||||
fn unavailable(note: &str) -> Self {
|
||||
Self {
|
||||
idle_rtt_ms: None,
|
||||
loaded_rtt_ms: None,
|
||||
loaded_rtt_p95_ms: None,
|
||||
bloat_ms: None,
|
||||
upstream_mbps: None,
|
||||
grade: None,
|
||||
note: note.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bufferbloat grade, on the added-latency thresholds that matter to us rather
|
||||
/// than to a generic speed test.
|
||||
///
|
||||
/// The bands come from the frame budget, not from convention: ~40 ms of RTT is
|
||||
/// the whole network allowance for a 100 ms click-to-photon target, so 30 ms of
|
||||
/// *added* delay has already eaten most of it.
|
||||
fn grade(bloat_ms: f64) -> &'static str {
|
||||
match bloat_ms {
|
||||
b if b < 15.0 => "A",
|
||||
b if b < 30.0 => "B",
|
||||
b if b < 80.0 => "C",
|
||||
_ => "F",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() -> NetReport {
|
||||
let Some(addr) = resolve(PROBE_ADDR) else {
|
||||
return NetReport::unavailable("could not resolve the latency probe address — offline?");
|
||||
};
|
||||
|
||||
// --- idle baseline ---------------------------------------------------
|
||||
let idle = sample_rtt(addr, 12, Duration::from_millis(120));
|
||||
let Some(idle_p50) = percentile(&idle, 0.50) else {
|
||||
return NetReport::unavailable(
|
||||
"no TCP handshake completed to 1.1.1.1:443 — a firewall may block it, so the \
|
||||
latency half could not run",
|
||||
);
|
||||
};
|
||||
|
||||
// --- saturate, and measure again -------------------------------------
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let sent = Arc::new(AtomicU64::new(0));
|
||||
let started = Instant::now();
|
||||
|
||||
let uploaders: Vec<_> = (0..STREAMS)
|
||||
.map(|_| {
|
||||
let stop = Arc::clone(&stop);
|
||||
let sent = Arc::clone(&sent);
|
||||
std::thread::spawn(move || upload_until(&stop, &sent))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Give the queue a moment to actually fill before sampling: measuring from
|
||||
// t=0 averages in the unloaded state and understates the bloat.
|
||||
std::thread::sleep(Duration::from_millis(1500));
|
||||
let loaded = sample_rtt(
|
||||
addr,
|
||||
(LOAD_SECONDS as usize - 2) * 4,
|
||||
Duration::from_millis(250),
|
||||
);
|
||||
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
let mut any_upload_ok = false;
|
||||
for h in uploaders {
|
||||
any_upload_ok |= h.join().unwrap_or(false);
|
||||
}
|
||||
|
||||
let elapsed = started.elapsed().as_secs_f64() - 1.5;
|
||||
let bytes = sent.load(Ordering::Relaxed) as f64;
|
||||
let upstream_mbps = (any_upload_ok && elapsed > 1.0 && bytes > 0.0)
|
||||
.then(|| bytes * 8.0 / elapsed / 1_000_000.0);
|
||||
|
||||
let loaded_p50 = percentile(&loaded, 0.50);
|
||||
let loaded_p95 = percentile(&loaded, 0.95);
|
||||
let bloat = loaded_p50.map(|l| (l - idle_p50).max(0.0));
|
||||
|
||||
let note = match (&upstream_mbps, &bloat) {
|
||||
(None, _) => "upstream could not be measured (the upload sink was unreachable), so the \
|
||||
latency figures below are not under a known load and should be ignored"
|
||||
.to_string(),
|
||||
(Some(_), Some(b)) if *b >= 80.0 => {
|
||||
"your uplink queues badly under load. This is a router setting, not a line \
|
||||
problem: fq_codel or CAKE on the upload direction usually fixes it entirely."
|
||||
.to_string()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
NetReport {
|
||||
idle_rtt_ms: Some(idle_p50),
|
||||
loaded_rtt_ms: loaded_p50,
|
||||
loaded_rtt_p95_ms: loaded_p95,
|
||||
bloat_ms: bloat,
|
||||
upstream_mbps,
|
||||
grade: bloat.map(grade),
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(s: &str) -> Option<SocketAddr> {
|
||||
s.to_socket_addrs().ok()?.next()
|
||||
}
|
||||
|
||||
/// `n` TCP handshakes, spaced by `gap`, in milliseconds. Failures are dropped
|
||||
/// rather than recorded as a large value: a refused connection is not a slow
|
||||
/// one, and averaging the two produces a number that means nothing.
|
||||
fn sample_rtt(addr: SocketAddr, n: usize, gap: Duration) -> Vec<f64> {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let t = Instant::now();
|
||||
if TcpStream::connect_timeout(&addr, Duration::from_secs(2)).is_ok() {
|
||||
out.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
std::thread::sleep(gap);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn percentile(v: &[f64], p: f64) -> Option<f64> {
|
||||
if v.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut s = v.to_vec();
|
||||
s.sort_by(f64::total_cmp);
|
||||
let i = ((s.len() as f64 - 1.0) * p).round() as usize;
|
||||
Some(s[i])
|
||||
}
|
||||
|
||||
/// Upload until told to stop, counting bytes. Returns whether anything was
|
||||
/// accepted at all, so the caller can distinguish "slow" from "blocked".
|
||||
///
|
||||
/// The body is generated rather than read from anywhere, and is
|
||||
/// incompressible-enough zeroes; the sink discards it.
|
||||
fn upload_until(stop: &AtomicBool, sent: &AtomicU64) -> bool {
|
||||
let mut ok = false;
|
||||
let chunk = vec![0u8; CHUNK];
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
// One request per 8 MiB rather than one endless request: a long-lived
|
||||
// POST can be buffered by an intermediary, which would make the timing
|
||||
// a measure of the proxy rather than of the line.
|
||||
let body: Vec<u8> = chunk.repeat(8);
|
||||
let n = body.len() as u64;
|
||||
match ureq::post(UPLOAD_URL)
|
||||
.header("content-type", "application/octet-stream")
|
||||
.send(&body[..])
|
||||
{
|
||||
Ok(_) => {
|
||||
ok = true;
|
||||
sent.fetch_add(n, Ordering::Relaxed);
|
||||
}
|
||||
Err(_) => {
|
||||
// One failure is a hiccup; the loop exits on the flag anyway.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// Best-effort public-facing region, from Cloudflare's trace endpoint.
|
||||
///
|
||||
/// This answers "roughly where are you" without asking it, and it is coarse on
|
||||
/// purpose: the IATA code of the edge that served the request, which
|
||||
/// is a latency radius rather than a location. No IP address is retained and
|
||||
/// none goes into the summary line.
|
||||
pub fn region_hint() -> Option<String> {
|
||||
let body = ureq::get("https://speed.cloudflare.com/cdn-cgi/trace")
|
||||
.call()
|
||||
.ok()?
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.ok()?;
|
||||
let mut loc = None;
|
||||
let mut colo = None;
|
||||
for line in body.lines() {
|
||||
match line.split_once('=') {
|
||||
Some(("loc", v)) => loc = Some(v.to_string()),
|
||||
Some(("colo", v)) => colo = Some(v.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match (loc, colo) {
|
||||
(Some(l), Some(c)) => Some(format!("{l}/{c}")),
|
||||
(Some(l), None) => Some(l),
|
||||
(None, c) => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a fixed prompt to the terminal and flush. Here rather than in `ask`
|
||||
/// because the network test narrates while it runs.
|
||||
pub fn tick(msg: &str) {
|
||||
print!("{msg}");
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
312
apps/nesdoctor/src/report.rs
Normal file
312
apps/nesdoctor/src/report.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
//! The verdict, the printed report, and the one line somebody pastes.
|
||||
//!
|
||||
//! Two audiences and they want different things. The person running this wants
|
||||
//! to know whether their machine is any good and what to fix. We want the
|
||||
//! distribution. The summary line is the only thing that crosses over, and it
|
||||
//! is built to be legible to both: a human can read it, and it parses.
|
||||
//!
|
||||
//! # What is not in the line
|
||||
//!
|
||||
//! No IP address, no hostname, no username, no game titles, no file paths, no
|
||||
//! machine identifier of any kind. A size *band* rather than a size, and an
|
||||
//! hour histogram rather than timestamps. The full JSON — which does contain
|
||||
//! titles and paths — stays on the local disk, and the person is told where.
|
||||
//!
|
||||
//! That is not politeness. A line that people are comfortable pasting in public
|
||||
//! is a line that gets pasted, and one that quietly carries their hostname gets
|
||||
//! screenshotted once and then never again.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::ask::Answers;
|
||||
use crate::hostreq::{HostReport, State};
|
||||
use crate::net::NetReport;
|
||||
use crate::steam::{self, SteamReport};
|
||||
use crate::sys::SysInfo;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Full<'a> {
|
||||
pub nesdoctor: &'static str,
|
||||
pub sys: &'a SysInfo,
|
||||
pub host: &'a HostReport,
|
||||
pub net: &'a NetReport,
|
||||
pub steam: &'a SteamReport,
|
||||
pub answers: &'a Answers,
|
||||
pub verdict: Verdict,
|
||||
pub region_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// What this machine is, in one word, plus why.
|
||||
#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Verdict {
|
||||
/// Passes every blocking host check, and the uplink is good enough.
|
||||
HostReady,
|
||||
/// Hardware and software are fine; the network is the problem.
|
||||
HostBlockedByNetwork,
|
||||
/// Every check passes, the uplink is clean — and the machine is a long way
|
||||
/// from the rest of the internet, so it can only usefully serve players
|
||||
/// near it. Which is the most interesting result this tool produces.
|
||||
HostReadyLocalOnly,
|
||||
/// Could host with setup work — nothing missing that cannot be installed.
|
||||
HostFixable,
|
||||
/// Cannot host. Usually the GPU vendor or the OS.
|
||||
ClientOnly,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Verdict {
|
||||
pub fn tag(self) -> &'static str {
|
||||
match self {
|
||||
Verdict::HostReady => "HOST-READY",
|
||||
Verdict::HostReadyLocalOnly => "HOST-READY-LOCAL",
|
||||
Verdict::HostBlockedByNetwork => "HOST-NET",
|
||||
Verdict::HostFixable => "HOST-FIXABLE",
|
||||
Verdict::ClientOnly => "CLIENT",
|
||||
Verdict::Unknown => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The uplink thresholds a host has to clear.
|
||||
///
|
||||
/// Both matter and the second matters more: 1080p60 needs 10–15 Mbps, and
|
||||
/// almost every fibre peer clears that, but added latency under load is what
|
||||
/// actually disqualifies a machine. See `net::grade`.
|
||||
const MIN_UP_MBPS: f64 = 15.0;
|
||||
const MAX_BLOAT_MS: f64 = 30.0;
|
||||
|
||||
/// Idle round trip to the nearest anycast edge, above which this machine can
|
||||
/// only serve players close to it.
|
||||
///
|
||||
/// This is a *floor* on what any player will see, not a measure of the machine.
|
||||
/// The network allowance is ~40 ms in total, so a host already spending 178 ms
|
||||
/// to reach Cloudflare's nearest point of presence cannot serve anyone who is
|
||||
/// not on roughly its own local networks — and no upgrade changes that, because
|
||||
/// it is distance.
|
||||
///
|
||||
/// Measured on the development connection 2026-09-02: 178 ms idle, served from
|
||||
/// Johannesburg. That machine passes every other check and still cannot host
|
||||
/// for a European player — which is the coverage argument seen from the other
|
||||
/// end: the places with no nearby edge are the places a local host is the only
|
||||
/// option anyone has.
|
||||
const FAR_RTT_MS: f64 = 60.0;
|
||||
|
||||
pub fn verdict(sys: &SysInfo, host: &HostReport, net: &NetReport) -> Verdict {
|
||||
// A machine that cannot host is a client, and that is a complete answer —
|
||||
// not a failure. Most respondents will land here and the wording matters.
|
||||
if !host.could_host {
|
||||
// Distinguish "wrong hardware" from "missing setup": an AMD or Intel
|
||||
// card with a render node and KVM is a fixable machine.
|
||||
let fixable = sys.os == "linux"
|
||||
&& host
|
||||
.checks
|
||||
.iter()
|
||||
.filter(|c| c.blocking && c.state == State::Fail)
|
||||
.all(|c| c.id == "vkvideo");
|
||||
return if fixable {
|
||||
Verdict::HostFixable
|
||||
} else {
|
||||
Verdict::ClientOnly
|
||||
};
|
||||
}
|
||||
if host.unknowns > 0 {
|
||||
return Verdict::Unknown;
|
||||
}
|
||||
match (net.upstream_mbps, net.bloat_ms) {
|
||||
(Some(up), Some(bloat)) => {
|
||||
if up < MIN_UP_MBPS || bloat > MAX_BLOAT_MS {
|
||||
Verdict::HostBlockedByNetwork
|
||||
} else if net.idle_rtt_ms.is_some_and(|r| r > FAR_RTT_MS) {
|
||||
Verdict::HostReadyLocalOnly
|
||||
} else {
|
||||
Verdict::HostReady
|
||||
}
|
||||
}
|
||||
_ => Verdict::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// The line to paste. Pipe-separated fields, `k=v` inside, stable key order.
|
||||
pub fn summary_line(
|
||||
sys: &SysInfo,
|
||||
host: &HostReport,
|
||||
net: &NetReport,
|
||||
steam: &SteamReport,
|
||||
answers: &Answers,
|
||||
verdict: Verdict,
|
||||
region: &Option<String>,
|
||||
) -> String {
|
||||
let mut f: Vec<String> = Vec::new();
|
||||
f.push(format!("nesdoctor {VERSION}"));
|
||||
f.push(format!("{}/{}", sys.os, sys.arch));
|
||||
|
||||
let gpu = sys
|
||||
.gpus
|
||||
.iter()
|
||||
.find(|g| g.render_node.is_some())
|
||||
.or_else(|| sys.gpus.first());
|
||||
f.push(format!(
|
||||
"gpu={}",
|
||||
gpu.map(|g| g.name.as_str()).unwrap_or("unknown")
|
||||
));
|
||||
|
||||
if let Some(r) = sys.ram_gib {
|
||||
f.push(format!("cpu={}t ram={r:.0}G", sys.cpu_threads));
|
||||
}
|
||||
|
||||
if sys.os == "linux" {
|
||||
let st = |id: &str| {
|
||||
host.checks
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| match c.state {
|
||||
State::Pass => "y",
|
||||
State::Fail => "n",
|
||||
State::Unknown => "?",
|
||||
})
|
||||
.unwrap_or("-")
|
||||
};
|
||||
f.push(format!(
|
||||
"kvm={} venc={} zfs={} boxfs={} io={}",
|
||||
st("kvm"),
|
||||
st("vkvideo"),
|
||||
st("content-store"),
|
||||
st("box-store"),
|
||||
st("cgroup-io")
|
||||
));
|
||||
}
|
||||
|
||||
match (net.upstream_mbps, net.bloat_ms, net.grade) {
|
||||
(Some(up), Some(b), Some(g)) => f.push(format!(
|
||||
"up={up:.0}Mbps rtt={}ms bloat=+{b:.0}ms grade={g}",
|
||||
net.idle_rtt_ms.map_or("?".into(), |r| format!("{r:.0}"))
|
||||
)),
|
||||
_ => f.push("net=unmeasured".into()),
|
||||
}
|
||||
|
||||
if let Some(d) = sys.disks.first() {
|
||||
f.push(format!("disk={:.0}G", d.free_gib));
|
||||
}
|
||||
if let (Some(h), Some(days)) = (sys.powered_hours_per_day, sys.powered_span_days) {
|
||||
f.push(format!("powered={h:.0}h/d over {days:.0}d"));
|
||||
}
|
||||
if let Some(r) = region {
|
||||
f.push(format!("edge={r}"));
|
||||
}
|
||||
|
||||
if steam.found && steam.titles > 0 {
|
||||
f.push(format!(
|
||||
"steam={} titles/{}",
|
||||
steam.titles,
|
||||
steam::size_band(steam.bytes_on_disk)
|
||||
));
|
||||
if let Some((s, e)) = steam.peak_window {
|
||||
f.push(format!("plays={s:02}-{e:02}h n={}", steam.launch_samples));
|
||||
}
|
||||
}
|
||||
|
||||
let a = |o: &Option<String>| o.clone().unwrap_or_else(|| "-".into());
|
||||
f.push(format!(
|
||||
"role={} share={} pays={}",
|
||||
a(&answers.role),
|
||||
a(&answers.share_for),
|
||||
a(&answers.pays_today)
|
||||
));
|
||||
if let Some(o) = &answers.other_linux {
|
||||
f.push(format!("otherlinux={o}"));
|
||||
}
|
||||
|
||||
f.push(verdict.tag().to_string());
|
||||
f.join(" | ")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ output ---
|
||||
|
||||
pub fn print_checks(host: &HostReport) {
|
||||
println!("\n\x1b[1mCan this machine run a Nestri box?\x1b[0m");
|
||||
for c in &host.checks {
|
||||
let (mark, colour) = match c.state {
|
||||
State::Pass => ("ok ", "32"),
|
||||
State::Fail => ("no ", "31"),
|
||||
State::Unknown => ("? ", "33"),
|
||||
};
|
||||
println!(" \x1b[{colour}m{mark}\x1b[0m {}", c.what);
|
||||
if !c.detail.is_empty() {
|
||||
for line in wrap(&c.detail, 68) {
|
||||
println!(" \x1b[2m{line}\x1b[0m");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_verdict(v: Verdict, net: &NetReport) {
|
||||
println!();
|
||||
let (colour, headline, body) = match v {
|
||||
Verdict::HostReady => (
|
||||
"32",
|
||||
"This machine could host.",
|
||||
"Every hard requirement passes and the uplink is good enough. That is rarer \
|
||||
than it sounds — most machines fail on the encode extension or on queueing.",
|
||||
),
|
||||
Verdict::HostBlockedByNetwork => (
|
||||
"33",
|
||||
"Good machine, the network is in the way.",
|
||||
"The hardware and the software are fine. See the uplink figures above — if the \
|
||||
problem is added latency rather than throughput, it is a router setting and not \
|
||||
a line you need to upgrade.",
|
||||
),
|
||||
Verdict::HostReadyLocalOnly => (
|
||||
"32",
|
||||
"This machine could host — for players near you.",
|
||||
"Every requirement passes and your uplink queues cleanly. But the idle round trip to the nearest major network is already most of the latency budget, and that is distance rather than a fault: no upgrade shortens it. So this machine is a good host for people on your side of the world and cannot be one for anybody else. If you are somewhere without a cloud gaming edge, that is not a consolation prize — it is the only way anyone there gets a playable stream.",
|
||||
),
|
||||
Verdict::HostFixable => (
|
||||
"33",
|
||||
"This machine could host, with some setup.",
|
||||
"Nothing here is a hardware limit — what is missing can be installed.",
|
||||
),
|
||||
Verdict::ClientOnly => (
|
||||
"36",
|
||||
"This is a client, not a host.",
|
||||
"Which is a complete answer and not a failure: most machines are clients, and \
|
||||
the thing you would actually use Nestri for works fine from here.",
|
||||
),
|
||||
Verdict::Unknown => (
|
||||
"33",
|
||||
"Inconclusive.",
|
||||
"One or more checks could not be run rather than failing. The report says which; \
|
||||
an unknown is not a no.",
|
||||
),
|
||||
};
|
||||
println!("\x1b[1;{colour}m{headline}\x1b[0m");
|
||||
for line in wrap(body, 72) {
|
||||
println!("\x1b[2m{line}\x1b[0m");
|
||||
}
|
||||
if !net.note.is_empty() {
|
||||
println!();
|
||||
for line in wrap(&net.note, 72) {
|
||||
println!("\x1b[33m{line}\x1b[0m");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap on whitespace. Twelve lines rather than a dependency, per `Cargo.toml`.
|
||||
fn wrap(s: &str, width: usize) -> Vec<String> {
|
||||
let mut out = vec![String::new()];
|
||||
for word in s.split_whitespace() {
|
||||
let cur = out.last_mut().unwrap();
|
||||
if !cur.is_empty() && cur.chars().count() + 1 + word.chars().count() > width {
|
||||
out.push(word.to_string());
|
||||
} else {
|
||||
if !cur.is_empty() {
|
||||
cur.push(' ');
|
||||
}
|
||||
cur.push_str(word);
|
||||
}
|
||||
}
|
||||
out.retain(|l| !l.is_empty());
|
||||
out
|
||||
}
|
||||
301
apps/nesdoctor/src/steam.rs
Normal file
301
apps/nesdoctor/src/steam.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
//! What Steam already knows, read locally and only with permission.
|
||||
//!
|
||||
//! Three things we would otherwise have to ask about are answerable from files
|
||||
//! on disk, and all three are ones people answer badly when asked:
|
||||
//!
|
||||
//! | question | what we read |
|
||||
//! |---|---|
|
||||
//! | *what do you play?* | `appmanifest_*.acf` — title and size on disk |
|
||||
//! | *how big is a library?* | the sum of those sizes, which is the content store's cost |
|
||||
//! | *when do you play?* | `localconfig.vdf` — `LastPlayed` per title, as an hour-of-day histogram |
|
||||
//!
|
||||
//! The third is the interesting one. Steam keeps one `LastPlayed` timestamp per
|
||||
//! title, so a library of eighty games is **eighty samples of what hour this
|
||||
//! person launches a game at** — a real distribution, taken without asking, and
|
||||
//! the thing a demand trough is made of. It is biased toward whatever
|
||||
//! they played most recently and it is not a schedule; it is a sample, and it is
|
||||
//! reported as one.
|
||||
//!
|
||||
//! # This is somebody's private library
|
||||
//!
|
||||
//! Nothing here runs without an explicit yes, nothing leaves the machine, and
|
||||
//! the summary line carries **counts and hours, never titles**. The full JSON
|
||||
//! stays in a local file the caller is told the path of. Reading a game library
|
||||
//! is not a neutral act and the code is arranged so that a reader can confirm
|
||||
//! that in one pass.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::vdf;
|
||||
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub struct SteamReport {
|
||||
pub found: bool,
|
||||
pub roots: Vec<String>,
|
||||
pub titles: usize,
|
||||
pub bytes_on_disk: u64,
|
||||
/// The five largest, by size on disk. Included because library *shape* is
|
||||
/// what decides whether a depot cache has a head to cache.
|
||||
pub largest: Vec<(String, u64)>,
|
||||
/// Count of `LastPlayed` timestamps falling in each local hour, 0–23.
|
||||
pub launch_hours: [u32; 24],
|
||||
pub launch_samples: usize,
|
||||
/// Hours covering half of all launches, contiguous and wrapping — the
|
||||
/// "evening peak" if there is one.
|
||||
pub peak_window: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Proton builds, runtimes and redistributables are installed like games and
|
||||
/// are not games.
|
||||
///
|
||||
/// Measured 2026-09-02: on a machine with one real title, five of the eight
|
||||
/// entries were runtimes — so counting them inflates the title count by 5x and
|
||||
/// corrupts the library-shape question this is here to answer. Matching on the
|
||||
/// name is imperfect and is the honest trade: a title genuinely called
|
||||
/// "Proton …" would be dropped, and no real title is.
|
||||
fn is_runtime(title: &str) -> bool {
|
||||
const PREFIXES: [&str; 5] = [
|
||||
"Proton",
|
||||
"Steam Linux Runtime",
|
||||
"Steamworks Common Redistributables",
|
||||
"Steam Deck",
|
||||
"SteamVR",
|
||||
];
|
||||
PREFIXES.iter().any(|p| title.starts_with(p))
|
||||
}
|
||||
|
||||
/// Where Steam might be. Checked in order; all hits are used, because a library
|
||||
/// is routinely split across drives.
|
||||
fn candidate_roots() -> Vec<PathBuf> {
|
||||
let home = std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.map(PathBuf::from);
|
||||
let mut v = Vec::new();
|
||||
if let Some(h) = home {
|
||||
v.push(h.join(".steam/steam"));
|
||||
v.push(h.join(".local/share/Steam"));
|
||||
// Flatpak keeps its own home.
|
||||
v.push(h.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"));
|
||||
v.push(h.join("Library/Application Support/Steam")); // macOS
|
||||
}
|
||||
v.push(PathBuf::from(r"C:\Program Files (x86)\Steam"));
|
||||
v.push(PathBuf::from("/usr/lib/steam"));
|
||||
v.into_iter().filter(|p| p.join("steamapps").is_dir()).collect()
|
||||
}
|
||||
|
||||
/// True when there is anything to ask about. Called *before* consent so the
|
||||
/// question is not asked of someone with no Steam install.
|
||||
pub fn present() -> bool {
|
||||
!candidate_roots().is_empty()
|
||||
}
|
||||
|
||||
pub fn read() -> SteamReport {
|
||||
let roots = candidate_roots();
|
||||
if roots.is_empty() {
|
||||
return SteamReport::default();
|
||||
}
|
||||
|
||||
let mut r = SteamReport {
|
||||
found: true,
|
||||
roots: roots.iter().map(|p| p.display().to_string()).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Library folders can live on other drives; `libraryfolders.vdf` lists
|
||||
// them, and skipping it undercounts a split library badly.
|
||||
let mut app_dirs: Vec<PathBuf> = roots.iter().map(|p| p.join("steamapps")).collect();
|
||||
for root in &roots {
|
||||
let lf = root.join("steamapps/libraryfolders.vdf");
|
||||
if let Ok(txt) = fs::read_to_string(&lf) {
|
||||
let doc = vdf::parse(&txt);
|
||||
if let Some(folders) = doc.get(&["libraryfolders"]).and_then(vdf::Value::as_node) {
|
||||
for (_, entry) in folders {
|
||||
if let Some(p) = entry.get(&["path"]).and_then(vdf::Value::as_str) {
|
||||
let d = PathBuf::from(p).join("steamapps");
|
||||
if d.is_dir() {
|
||||
app_dirs.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app_dirs.sort();
|
||||
app_dirs.dedup();
|
||||
|
||||
let mut by_size: Vec<(String, u64)> = Vec::new();
|
||||
for dir in &app_dirs {
|
||||
let Ok(entries) = fs::read_dir(dir) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if !(name.starts_with("appmanifest_") && name.ends_with(".acf")) {
|
||||
continue;
|
||||
}
|
||||
let Ok(txt) = fs::read_to_string(e.path()) else {
|
||||
continue;
|
||||
};
|
||||
let doc = vdf::parse(&txt);
|
||||
let title = doc
|
||||
.get(&["AppState", "name"])
|
||||
.and_then(vdf::Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let size = doc
|
||||
.get(&["AppState", "SizeOnDisk"])
|
||||
.and_then(vdf::Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
if is_runtime(&title) {
|
||||
continue;
|
||||
}
|
||||
by_size.push((title, size));
|
||||
}
|
||||
}
|
||||
// A split library can list the same appid twice; dedupe by title.
|
||||
by_size.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
by_size.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
r.titles = by_size.len();
|
||||
r.bytes_on_disk = by_size.iter().map(|(_, s)| s).sum();
|
||||
by_size.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
r.largest = by_size.into_iter().take(5).collect();
|
||||
|
||||
// --- when do they play -----------------------------------------------
|
||||
for root in &roots {
|
||||
let Ok(users) = fs::read_dir(root.join("userdata")) else {
|
||||
continue;
|
||||
};
|
||||
for u in users.flatten() {
|
||||
let cfg = u.path().join("config/localconfig.vdf");
|
||||
let Ok(txt) = fs::read_to_string(&cfg) else {
|
||||
continue;
|
||||
};
|
||||
let doc = vdf::parse(&txt);
|
||||
let Some(apps) = doc
|
||||
.get(&[
|
||||
"UserLocalConfigStore",
|
||||
"Software",
|
||||
"Valve",
|
||||
"Steam",
|
||||
"apps",
|
||||
])
|
||||
.and_then(vdf::Value::as_node)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (_, app) in apps {
|
||||
let Some(ts) = app.get(&["LastPlayed"]).and_then(vdf::Value::as_u64) else {
|
||||
continue;
|
||||
};
|
||||
if ts == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(h) = local_hour(ts) {
|
||||
r.launch_hours[h as usize] += 1;
|
||||
r.launch_samples += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.peak_window = peak_window(&r.launch_hours, r.launch_samples);
|
||||
r
|
||||
}
|
||||
|
||||
/// Hour of day, in the machine's local time, for a Unix timestamp.
|
||||
///
|
||||
/// Done with the offset the OS reports rather than a timezone crate: we need
|
||||
/// the hour a person launched a game in their own reckoning, and one integer
|
||||
/// offset is enough for that. DST at the boundary shifts a sample by an hour,
|
||||
/// which is inside the resolution this is reported at.
|
||||
fn local_hour(unix: u64) -> Option<u32> {
|
||||
let offset = utc_offset_seconds()?;
|
||||
let local = unix as i64 + offset;
|
||||
Some((local.rem_euclid(86_400) / 3600) as u32)
|
||||
}
|
||||
|
||||
fn utc_offset_seconds() -> Option<i64> {
|
||||
// `date +%z` gives `+0200`. Present on every unix; PowerShell for Windows.
|
||||
let z = crate::sys::sh("date", &["+%z"]).or_else(|| {
|
||||
crate::sys::ps("(Get-TimeZone).BaseUtcOffset.TotalSeconds")
|
||||
.and_then(|s| s.trim().parse::<f64>().ok())
|
||||
.map(|s| format!("{:+05}", (s as i64 / 3600) * 100))
|
||||
})?;
|
||||
let z = z.trim();
|
||||
let sign = if z.starts_with('-') { -1 } else { 1 };
|
||||
let digits: String = z.chars().filter(char::is_ascii_digit).collect();
|
||||
if digits.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let h: i64 = digits[0..2].parse().ok()?;
|
||||
let m: i64 = digits[2..4].parse().ok()?;
|
||||
Some(sign * (h * 3600 + m * 60))
|
||||
}
|
||||
|
||||
/// The shortest contiguous, wrapping run of hours holding at least half the
|
||||
/// launches. That is the honest form of "when do you play": if it is four hours
|
||||
/// wide there is an evening peak, and if it takes fourteen there is not.
|
||||
fn peak_window(hours: &[u32; 24], total: usize) -> Option<(u32, u32)> {
|
||||
if total < 8 {
|
||||
return None; // too few samples to claim a shape
|
||||
}
|
||||
let half = (total as f64 / 2.0).ceil() as u32;
|
||||
let mut best: Option<(u32, u32, u32)> = None; // width, start, end
|
||||
for start in 0..24u32 {
|
||||
let mut sum = 0;
|
||||
for w in 1..=24u32 {
|
||||
sum += hours[((start + w - 1) % 24) as usize];
|
||||
if sum >= half {
|
||||
let cand = (w, start, (start + w - 1) % 24);
|
||||
if best.is_none_or(|b| w < b.0) {
|
||||
best = Some(cand);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, s, e)| (s, e))
|
||||
}
|
||||
|
||||
/// GiB, for display.
|
||||
pub fn gib(bytes: u64) -> f64 {
|
||||
bytes as f64 / 1_073_741_824.0
|
||||
}
|
||||
|
||||
/// A one-line sparkline of the launch-hour histogram.
|
||||
///
|
||||
/// Worth the twenty lines: it is the part of the output people screenshot, and
|
||||
/// it is the only place someone sees their own play schedule as a shape.
|
||||
pub fn sparkline(hours: &[u32; 24]) -> String {
|
||||
const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
let max = *hours.iter().max().unwrap_or(&0);
|
||||
if max == 0 {
|
||||
return String::new();
|
||||
}
|
||||
hours
|
||||
.iter()
|
||||
.map(|&h| {
|
||||
if h == 0 {
|
||||
' '
|
||||
} else {
|
||||
BARS[((h as f64 / max as f64) * 7.0).round() as usize]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Titles are never put in the paste line, so this is what goes instead:
|
||||
/// a coarse size band, which is all a content-store estimate needs.
|
||||
pub fn size_band(bytes: u64) -> &'static str {
|
||||
match gib(bytes) {
|
||||
g if g < 100.0 => "<100G",
|
||||
g if g < 500.0 => "100-500G",
|
||||
g if g < 1500.0 => "0.5-1.5T",
|
||||
_ => ">1.5T",
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn debug_map(_m: &BTreeMap<String, vdf::Value>) {}
|
||||
465
apps/nesdoctor/src/sys.rs
Normal file
465
apps/nesdoctor/src/sys.rs
Normal file
@@ -0,0 +1,465 @@
|
||||
//! What the machine is: OS, CPU, memory, GPU, disk, and how long it stays on.
|
||||
//!
|
||||
//! Everything here is read from files or from a command that ships with the OS.
|
||||
//! No crate is used to describe hardware, because a wrong answer from a
|
||||
//! dependency is indistinguishable from a wrong answer from us, and this output
|
||||
//! is what a host-capacity decision would rest on: hosts are
|
||||
//! customer-supplied and heterogeneous, so an unlabelled capacity number is a
|
||||
//! wrong one.
|
||||
//!
|
||||
//! Every probe degrades to `None` rather than failing the run. A missing
|
||||
//! `lspci` costs one field.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SysInfo {
|
||||
pub os: &'static str,
|
||||
pub arch: &'static str,
|
||||
pub release: Option<String>,
|
||||
pub kernel: Option<String>,
|
||||
pub cpu_model: Option<String>,
|
||||
pub cpu_threads: usize,
|
||||
pub ram_gib: Option<f64>,
|
||||
pub gpus: Vec<Gpu>,
|
||||
/// Mounts with usable free space, largest first.
|
||||
pub disks: Vec<Disk>,
|
||||
pub uptime_hours: Option<f64>,
|
||||
/// Mean hours per day the machine was powered, from boot history. See
|
||||
/// [`powered`]. `None` where the history is not readable.
|
||||
pub powered_hours_per_day: Option<f64>,
|
||||
/// Days the boot history spans, so the reader can judge the above.
|
||||
pub powered_span_days: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Gpu {
|
||||
pub name: String,
|
||||
pub vendor: Option<String>,
|
||||
/// The DRM render node, where one exists. Linux only, and a hard
|
||||
/// requirement in `contracts/host-requirements.md`: a card without one
|
||||
/// cannot host, however good it is.
|
||||
pub render_node: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Disk {
|
||||
pub mount: String,
|
||||
pub fs: Option<String>,
|
||||
/// The backing device. Kept because btrfs and ZFS present many mount
|
||||
/// points on one device: without this, three subvolumes of one 91 GiB disk
|
||||
/// read as 273 GiB of capacity, and the two-stores check (which wants
|
||||
/// *separate devices*) cannot be answered at all.
|
||||
pub source: Option<String>,
|
||||
pub free_gib: f64,
|
||||
}
|
||||
|
||||
pub fn probe() -> SysInfo {
|
||||
let (powered_hours_per_day, powered_span_days) = powered();
|
||||
SysInfo {
|
||||
os: std::env::consts::OS,
|
||||
arch: std::env::consts::ARCH,
|
||||
release: release(),
|
||||
kernel: kernel(),
|
||||
cpu_model: cpu_model(),
|
||||
cpu_threads: std::thread::available_parallelism().map_or(0, |n| n.get()),
|
||||
ram_gib: ram_gib(),
|
||||
gpus: gpus(),
|
||||
disks: disks(),
|
||||
uptime_hours: uptime_hours(),
|
||||
powered_hours_per_day,
|
||||
powered_span_days,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- identity ---
|
||||
|
||||
fn release() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
return kv_line(&fs::read_to_string("/etc/os-release").ok()?, "PRETTY_NAME");
|
||||
#[cfg(windows)]
|
||||
return ps("(Get-CimInstance Win32_OperatingSystem).Caption");
|
||||
#[cfg(target_os = "macos")]
|
||||
return sh("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"));
|
||||
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
|
||||
return None;
|
||||
}
|
||||
|
||||
fn kernel() -> Option<String> {
|
||||
if cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
sh("uname", &["-r"])
|
||||
}
|
||||
|
||||
fn cpu_model() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
return fs::read_to_string("/proc/cpuinfo")
|
||||
.ok()?
|
||||
.lines()
|
||||
.find(|l| l.starts_with("model name"))
|
||||
.and_then(|l| l.split_once(':'))
|
||||
.map(|(_, v)| v.trim().to_string());
|
||||
#[cfg(windows)]
|
||||
return ps("(Get-CimInstance Win32_Processor).Name");
|
||||
#[cfg(target_os = "macos")]
|
||||
return sh("sysctl", &["-n", "machdep.cpu.brand_string"]);
|
||||
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
|
||||
return None;
|
||||
}
|
||||
|
||||
fn ram_gib() -> Option<f64> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let txt = fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb: f64 = txt
|
||||
.lines()
|
||||
.find(|l| l.starts_with("MemTotal:"))?
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse()
|
||||
.ok()?;
|
||||
return Some(kb / 1048576.0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
return Some(
|
||||
ps("(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory")?
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()?
|
||||
/ 1073741824.0,
|
||||
);
|
||||
#[cfg(target_os = "macos")]
|
||||
return Some(sh("sysctl", &["-n", "hw.memsize"])?.trim().parse::<f64>().ok()? / 1073741824.0);
|
||||
#[cfg(not(any(target_os = "linux", windows, target_os = "macos")))]
|
||||
return None;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- gpu ---
|
||||
|
||||
/// PCI vendor ids as they appear in `/sys/.../vendor`.
|
||||
fn vendor_name(id: &str) -> Option<&'static str> {
|
||||
match id.trim().trim_start_matches("0x") {
|
||||
"1002" => Some("AMD"),
|
||||
"8086" => Some("Intel"),
|
||||
"10de" => Some("NVIDIA"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn gpus() -> Vec<Gpu> {
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux_gpus();
|
||||
#[cfg(windows)]
|
||||
return ps("Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }")
|
||||
.map(|s| {
|
||||
s.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| {
|
||||
let up = l.to_uppercase();
|
||||
Gpu {
|
||||
name: l.to_string(),
|
||||
vendor: ["AMD", "NVIDIA", "INTEL"]
|
||||
.into_iter()
|
||||
.find(|v| up.contains(v))
|
||||
.map(str::to_string),
|
||||
render_node: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
/// Walk `/sys/class/drm` for cards and pair each with its render node.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_gpus() -> Vec<Gpu> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(entries) = fs::read_dir("/sys/class/drm") else {
|
||||
return out;
|
||||
};
|
||||
let all: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
|
||||
|
||||
let mut cards: Vec<&PathBuf> = all
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("card") && !n.contains('-'))
|
||||
})
|
||||
.collect();
|
||||
cards.sort();
|
||||
|
||||
let lspci = sh("lspci", &["-mm"]).unwrap_or_default();
|
||||
|
||||
for card in cards {
|
||||
let dev = card.join("device");
|
||||
let real = fs::canonicalize(&dev).ok();
|
||||
let vendor = fs::read_to_string(dev.join("vendor"))
|
||||
.ok()
|
||||
.and_then(|v| vendor_name(&v))
|
||||
.map(str::to_string);
|
||||
|
||||
// The PCI slot is the symlink target's basename; lspci -mm keys on the
|
||||
// bus:device.function part of it.
|
||||
let slot = real
|
||||
.as_ref()
|
||||
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
.unwrap_or_default();
|
||||
let bdf = slot.splitn(2, ':').nth(1).unwrap_or(&slot).to_string();
|
||||
|
||||
let name = lspci
|
||||
.lines()
|
||||
.find(|l| l.starts_with(&bdf))
|
||||
// lspci -mm quotes each field; index 5 is the device name.
|
||||
.and_then(|l| l.split('"').nth(5).map(str::to_string))
|
||||
.or_else(|| {
|
||||
fs::read_to_string(dev.join("device")).ok().map(|d| {
|
||||
format!(
|
||||
"{} device {}",
|
||||
vendor.clone().unwrap_or_else(|| "unknown".into()),
|
||||
d.trim()
|
||||
)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "unknown GPU".into());
|
||||
|
||||
let render_node = all
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("renderD"))
|
||||
})
|
||||
.find(|p| fs::canonicalize(p.join("device")).ok() == real)
|
||||
.and_then(|p| {
|
||||
p.file_name()
|
||||
.map(|n| format!("/dev/dri/{}", n.to_string_lossy()))
|
||||
});
|
||||
|
||||
// 0041 requires a *recorded* gpu_model per host, so prefer a name that
|
||||
// identifies the part. lspci gives the codename alone ("Barcelo"),
|
||||
// which is thin on its own.
|
||||
let name = match &vendor {
|
||||
Some(v) if !name.to_uppercase().contains(&v.to_uppercase()) => format!("{v} {name}"),
|
||||
_ => name,
|
||||
};
|
||||
out.push(Gpu {
|
||||
name,
|
||||
vendor,
|
||||
render_node,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- disk ---
|
||||
|
||||
fn disks() -> Vec<Disk> {
|
||||
let mut out = Vec::new();
|
||||
#[cfg(unix)]
|
||||
if let Some(txt) = sh("df", &["-Pk"]) {
|
||||
// -P for POSIX output and -k for a unit that does not move under
|
||||
// locale. Both matter, because this is parsed.
|
||||
for line in txt.lines().skip(1) {
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
if f.len() < 6 {
|
||||
continue;
|
||||
}
|
||||
let Ok(avail_kb) = f[3].parse::<f64>() else {
|
||||
continue;
|
||||
};
|
||||
let source = f[0].to_string();
|
||||
let mount = f[5..].join(" ");
|
||||
// Pseudo-filesystems are noise, and tmpfs free space is RAM.
|
||||
if ["/dev", "/sys", "/proc", "/run", "/boot", "/snap"]
|
||||
.iter()
|
||||
.any(|p| mount.starts_with(p))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(Disk {
|
||||
fs: fs_type(&mount),
|
||||
mount,
|
||||
source: Some(source),
|
||||
free_gib: avail_kb / 1048576.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Some(txt) =
|
||||
ps("Get-PSDrive -PSProvider FileSystem | ForEach-Object { \"$($_.Name)|$($_.Free)\" }")
|
||||
{
|
||||
for line in txt.lines() {
|
||||
if let Some((name, free)) = line.split_once('|') {
|
||||
if let Ok(b) = free.trim().parse::<f64>() {
|
||||
out.push(Disk {
|
||||
mount: format!("{}:", name.trim()),
|
||||
fs: None,
|
||||
source: None,
|
||||
free_gib: b / 1073741824.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.free_gib.total_cmp(&a.free_gib));
|
||||
out.dedup_by(|a, b| a.mount == b.mount);
|
||||
// One entry per backing device. Measured 2026-09-02: this laptop reported
|
||||
// /, /home and /srv at 91 GiB each — three btrfs subvolumes of one device,
|
||||
// counted three times.
|
||||
out.dedup_by(|a, b| a.source.is_some() && a.source == b.source);
|
||||
out
|
||||
}
|
||||
|
||||
/// Filesystem type for a mount point.
|
||||
///
|
||||
/// `hostreq` needs this in both directions: ZFS is *required* for the content
|
||||
/// store and *disqualifying* for the box store, because it ignores `O_DIRECT`
|
||||
/// ignores `O_DIRECT`.
|
||||
pub fn fs_type(mount: &str) -> Option<String> {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return None;
|
||||
}
|
||||
// Last match wins: a later mount shadows an earlier one on the same point.
|
||||
fs::read_to_string("/proc/mounts")
|
||||
.ok()?
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut f = l.split_whitespace();
|
||||
let _src = f.next()?;
|
||||
let mnt = f.next()?;
|
||||
let ty = f.next()?;
|
||||
(mnt == mount).then(|| ty.to_string())
|
||||
})
|
||||
.next_back()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ powered ---
|
||||
|
||||
fn uptime_hours() -> Option<f64> {
|
||||
#[cfg(target_os = "linux")]
|
||||
return Some(
|
||||
fs::read_to_string("/proc/uptime")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse::<f64>()
|
||||
.ok()?
|
||||
/ 3600.0,
|
||||
);
|
||||
#[cfg(windows)]
|
||||
return ps(
|
||||
"[int]((Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime).TotalSeconds",
|
||||
)?
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.map(|s| s / 3600.0);
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
return None;
|
||||
}
|
||||
|
||||
/// Mean hours per day the machine was powered, and the span that covers.
|
||||
///
|
||||
/// This exists so no question has to ask *"how many hours is this machine
|
||||
/// on?"* — which is exactly the kind of question nobody can answer about
|
||||
/// themselves, so it should never be asked.
|
||||
///
|
||||
/// Method: `journalctl --list-boots -o json` gives a `first_entry` and
|
||||
/// `last_entry` microsecond timestamp per boot. Summing `last − first` gives
|
||||
/// time powered; `max(last) − min(first)` gives the wall-clock span. The ratio
|
||||
/// is the answer, and it needs no date parsing at all — only integers.
|
||||
///
|
||||
/// It is a **coarse** instrument and is reported as one: it measures powered,
|
||||
/// not idle, and a machine that suspends looks powered-off. It answers "always
|
||||
/// on" versus "a few hours in the evening", which is the only resolution the
|
||||
/// availability question needs at this stage.
|
||||
pub fn powered() -> (Option<f64>, Option<f64>) {
|
||||
let Some(txt) = sh("journalctl", &["--list-boots", "-o", "json", "--no-pager"]) else {
|
||||
return (None, None);
|
||||
};
|
||||
let mut up_us: u128 = 0;
|
||||
let (mut lo, mut hi) = (u128::MAX, 0u128);
|
||||
let mut boots = 0usize;
|
||||
|
||||
// Deliberately not a JSON parse: the shape is flat and stable, and pulling
|
||||
// the whole document through serde_json to read two integers per record
|
||||
// buys nothing.
|
||||
for first in txt.split("\"first_entry\":").skip(1) {
|
||||
let Some(a) = read_int(first) else { continue };
|
||||
let Some(rest) = first.split_once("\"last_entry\":") else {
|
||||
continue;
|
||||
};
|
||||
let Some(b) = read_int(rest.1) else { continue };
|
||||
if b <= a {
|
||||
continue;
|
||||
}
|
||||
up_us += b - a;
|
||||
lo = lo.min(a);
|
||||
hi = hi.max(b);
|
||||
boots += 1;
|
||||
}
|
||||
if boots < 2 || hi <= lo {
|
||||
return (None, None);
|
||||
}
|
||||
let span_days = (hi - lo) as f64 / 86_400_000_000.0;
|
||||
// Under three days this is one or two boots and says nothing about a
|
||||
// habit. Reporting it anyway invites someone to read "13 h/day" off two
|
||||
// days of history, so report the span with no rate instead.
|
||||
if span_days < 3.0 {
|
||||
return (None, Some(span_days));
|
||||
}
|
||||
let up_hours = up_us as f64 / 3_600_000_000.0;
|
||||
(Some(up_hours / span_days), Some(span_days))
|
||||
}
|
||||
|
||||
fn read_int(s: &str) -> Option<u128> {
|
||||
let s = s.trim_start().trim_start_matches('"');
|
||||
let digits: String = s.chars().take_while(char::is_ascii_digit).collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- shell ---
|
||||
|
||||
/// Run a command, return trimmed stdout, `None` on any failure.
|
||||
pub fn sh(cmd: &str, args: &[&str]) -> Option<String> {
|
||||
let out = Command::new(cmd).args(args).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
/// PowerShell, for the Windows probes. `-NoProfile` so a user's profile script
|
||||
/// cannot change what we read.
|
||||
#[allow(dead_code)]
|
||||
pub fn ps(script: &str) -> Option<String> {
|
||||
if !cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
sh(
|
||||
"powershell",
|
||||
&["-NoProfile", "-NonInteractive", "-Command", script],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn exists(p: &str) -> bool {
|
||||
Path::new(p).exists()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn kv_line(txt: &str, key: &str) -> Option<String> {
|
||||
txt.lines()
|
||||
.find(|l| l.starts_with(&format!("{key}=")))
|
||||
.and_then(|l| l.split_once('='))
|
||||
.map(|(_, v)| v.trim().trim_matches('"').to_string())
|
||||
}
|
||||
224
apps/nesdoctor/src/vdf.rs
Normal file
224
apps/nesdoctor/src/vdf.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
//! A minimal reader for Valve's KeyValues text format (VDF).
|
||||
//!
|
||||
//! Steam writes several files we want in this format, and all of them are the
|
||||
//! simple textual dialect: `"key" "value"` for leaves, `"key" { ... }` for
|
||||
//! nodes, `//` to end of line for comments. There is a binary dialect and there
|
||||
//! are `#include` and conditional (`[$WIN32]`) forms; **none of them appear in
|
||||
//! the three files this crate reads**, so none of them are implemented.
|
||||
//!
|
||||
//! Written by hand rather than taken from a crate for the reason in
|
||||
//! `Cargo.toml`: this code runs on a stranger's machine over their private game
|
||||
//! library, and the whole dependency tree has to be reviewable.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Value {
|
||||
Str(String),
|
||||
Node(BTreeMap<String, Value>),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// Walk a path of keys, case-insensitively.
|
||||
///
|
||||
/// Steam is inconsistent about capitalisation across versions and
|
||||
/// platforms — `apps` vs `Apps`, `LastPlayed` vs `lastplayed` — and a
|
||||
/// case-sensitive lookup here silently returns nothing on some installs,
|
||||
/// which reads as "this user plays no games" rather than as a bug.
|
||||
pub fn get(&self, path: &[&str]) -> Option<&Value> {
|
||||
let mut cur = self;
|
||||
for want in path {
|
||||
let Value::Node(map) = cur else { return None };
|
||||
cur = map
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(want))
|
||||
.map(|(_, v)| v)?;
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Value::Str(s) => Some(s),
|
||||
Value::Node(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_node(&self) -> Option<&BTreeMap<String, Value>> {
|
||||
match self {
|
||||
Value::Node(m) => Some(m),
|
||||
Value::Str(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A leaf read as an integer, tolerating the quoted decimals Steam writes.
|
||||
pub fn as_u64(&self) -> Option<u64> {
|
||||
self.as_str()?.trim().parse().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a whole VDF document into its root node.
|
||||
///
|
||||
/// Malformed input yields whatever was read before the problem rather than an
|
||||
/// error. That is deliberate: a truncated `localconfig.vdf` (Steam was killed
|
||||
/// mid-write) should cost us one field, not the whole run.
|
||||
pub fn parse(input: &str) -> Value {
|
||||
let mut p = Parser {
|
||||
b: input.as_bytes(),
|
||||
i: 0,
|
||||
};
|
||||
Value::Node(p.node(true))
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
b: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn node(&mut self, top: bool) -> BTreeMap<String, Value> {
|
||||
let mut out = BTreeMap::new();
|
||||
loop {
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
None => return out,
|
||||
Some(b'}') => {
|
||||
if !top {
|
||||
self.i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Some(key) = self.token() else { return out };
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b'{') => {
|
||||
self.i += 1;
|
||||
out.insert(key, Value::Node(self.node(false)));
|
||||
}
|
||||
None => return out,
|
||||
_ => match self.token() {
|
||||
Some(v) => {
|
||||
out.insert(key, Value::Str(v));
|
||||
}
|
||||
None => return out,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.b.get(self.i).copied()
|
||||
}
|
||||
|
||||
/// Skip whitespace and `//` comments.
|
||||
fn ws(&mut self) {
|
||||
loop {
|
||||
while matches!(self.peek(), Some(c) if c.is_ascii_whitespace()) {
|
||||
self.i += 1;
|
||||
}
|
||||
if self.b[self.i..].starts_with(b"//") {
|
||||
while !matches!(self.peek(), None | Some(b'\n')) {
|
||||
self.i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// One quoted or bare token. Handles `\\` and `\"`; Steam emits both in
|
||||
/// Windows paths inside `libraryfolders.vdf`.
|
||||
fn token(&mut self) -> Option<String> {
|
||||
match self.peek()? {
|
||||
b'"' => {
|
||||
self.i += 1;
|
||||
let mut s = String::new();
|
||||
loop {
|
||||
match self.peek()? {
|
||||
b'"' => {
|
||||
self.i += 1;
|
||||
return Some(s);
|
||||
}
|
||||
b'\\' => {
|
||||
self.i += 1;
|
||||
match self.peek()? {
|
||||
b'n' => s.push('\n'),
|
||||
b't' => s.push('\t'),
|
||||
c => s.push(c as char),
|
||||
}
|
||||
self.i += 1;
|
||||
}
|
||||
c => {
|
||||
// Push bytes and let String::from_utf8_lossy-style
|
||||
// recovery happen naturally: paths may be non-UTF-8
|
||||
// on Windows, and one bad path must not lose the file.
|
||||
s.push(c as char);
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b'{' | b'}' => None,
|
||||
_ => {
|
||||
let start = self.i;
|
||||
while matches!(self.peek(), Some(c) if !c.is_ascii_whitespace() && c != b'{' && c != b'}')
|
||||
{
|
||||
self.i += 1;
|
||||
}
|
||||
if self.i == start {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&self.b[start..self.i]).into_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn leaves_nodes_and_comments() {
|
||||
let v = parse(
|
||||
r#"
|
||||
// a comment
|
||||
"AppState"
|
||||
{
|
||||
"appid" "730"
|
||||
"name" "Counter-Strike 2"
|
||||
"SizeOnDisk" "38654705664"
|
||||
"nested" { "a" "1" }
|
||||
}
|
||||
"#,
|
||||
);
|
||||
assert_eq!(
|
||||
v.get(&["AppState", "name"]).unwrap().as_str(),
|
||||
Some("Counter-Strike 2")
|
||||
);
|
||||
assert_eq!(
|
||||
v.get(&["appstate", "sizeondisk"]).unwrap().as_u64(),
|
||||
Some(38654705664)
|
||||
);
|
||||
assert_eq!(v.get(&["AppState", "nested", "a"]).unwrap().as_u64(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_windows_path() {
|
||||
let v = parse(r#" "libraryfolders" { "0" { "path" "D:\\SteamLibrary" } } "#);
|
||||
assert_eq!(
|
||||
v.get(&["libraryfolders", "0", "path"]).unwrap().as_str(),
|
||||
Some(r"D:\SteamLibrary")
|
||||
);
|
||||
}
|
||||
|
||||
/// Truncation must cost one field, not the document.
|
||||
#[test]
|
||||
fn truncated_input_keeps_what_it_read() {
|
||||
let v = parse(r#" "a" "1" "b" { "c" "2" "#);
|
||||
assert_eq!(v.get(&["a"]).unwrap().as_u64(), Some(1));
|
||||
assert_eq!(v.get(&["b", "c"]).unwrap().as_u64(), Some(2));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user