diff --git a/apps/nesdoctor/README.md b/apps/nesdoctor/README.md index 37431e40..7ba796d5 100644 --- a/apps/nesdoctor/README.md +++ b/apps/nesdoctor/README.md @@ -61,6 +61,8 @@ always a router setting rather than a line you need to upgrade. | `--yes` | Take the defaults — for a second run, not a first | | `--json ` | Where to write the full report | | `--quiet` | Only the summary line, for scripting | +| `--no-open` | Do not offer to open a browser; just print the link | +| `--submit-url` | Where the submit link points (default `https://doctor.nestri.io`) | ## Verdicts @@ -73,14 +75,30 @@ always a router setting rather than a line you need to upgrade. | `CLIENT` | Not a host. A complete answer, and what most machines are | | `UNKNOWN` | A blocking check could not be run. An unknown is not a no | +## Sending it back + +At the end it prints a link, lists in plain English what the link contains, and +opens it when you press Enter. That is the whole submission — no account, no +form, no email client. + +The link is built from **readable query parameters** rather than an encoded +blob. A blob would be shorter and would let us send more; it would also mean you +cannot read what you are sending, which is the one thing this program has going +for it. + +If you would rather not click a link we wrote, the short line is printed too and +put on your clipboard. + ## What it deliberately does not tell you - **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. +- **The host's `virglrenderer` and Mesa are not checked at all**, on purpose. + The box carries its own inside the image it runs in, so the host's copies are + not on the path — and a row that could only ever say "present, patch state + unknown" told prospective hosts their machine was wrong when it was not. ## Building diff --git a/apps/nesdoctor/src/ask.rs b/apps/nesdoctor/src/ask.rs index 5e228291..146f180e 100644 --- a/apps/nesdoctor/src/ask.rs +++ b/apps/nesdoctor/src/ask.rs @@ -22,6 +22,12 @@ use serde::Serialize; #[derive(Debug, Serialize, Default)] pub struct Answers { + /// The one question that could change the roadmap, so it is asked first and + /// of everybody. Answer `remote` is a product with no capacity model, no + /// library cost, no peak and no trough — and it is what the people we can + /// currently reach are already doing for themselves. That is the + /// uncomfortable possibility, which is the reason to ask rather than not. + pub want: Option, /// USERS.md 7, roughly: is this machine a host, a client, or both? pub role: Option, /// USERS.md 6: cash or credit. Only asked of a machine that could host. @@ -47,11 +53,25 @@ pub struct Ctx { pub fn run(ctx: &Ctx) -> Answers { let mut a = Answers::default(); - println!("\n\x1b[1mFive questions, maximum. Enter skips any of them.\x1b[0m"); + println!("\n\x1b[1mFour or five questions, and Enter skips any of them.\x1b[0m"); println!("\x1b[2mNothing here is sent anywhere. You will see the exact line before you\x1b[0m"); println!("\x1b[2mshare it, and you can edit or discard it.\x1b[0m\n"); - // 1 — role. Asked of everyone, because it decides what the rest means. + // 1 — the roadmap question. First because it is the one whose answer we + // would most regret not having, and because a respondent who quits after + // one question should have answered this one. + a.want = choose( + "If Nestri could only do one of these well, which would you want?", + &[ + ("cloud", "Play my games on your hardware, somewhere near me"), + ("remote", "Reach my own gaming PC from anywhere"), + ("both", "Both, equally"), + ("watch", "Neither — just having a look"), + ], + ); + a.asked += 1; + + // 2 — role. Asked of everyone, because it decides what the rest means. a.role = choose( "What is this machine for?", &[ @@ -63,7 +83,7 @@ pub fn run(ctx: &Ctx) -> Answers { ); a.asked += 1; - // 2 — cash or credit, only where it is not a hypothetical. Asking someone + // 3 — cash or credit, only where it is not a hypothetical. Asking someone // whose machine cannot host what they would charge for it produces noise. if ctx.capable && matches!(a.role.as_deref(), Some("host") | Some("both") | None) { a.share_for = choose( @@ -79,7 +99,7 @@ pub fn run(ctx: &Ctx) -> Answers { a.asked += 1; } - // 3 — current spend. The factual version of willingness to pay. + // 4 — current spend. The factual version of willingness to pay. a.pays_today = choose( "What do you pay a month for gaming right now, all in?", &[ @@ -92,7 +112,7 @@ pub fn run(ctx: &Ctx) -> Answers { ); a.asked += 1; - // 4 — a client machine may still have a host behind it. This converts a + // 5 — a client machine may still have a host behind it. This converts a // respondent who is not a candidate into a supply data point. if !ctx.is_linux { a.other_linux = choose( @@ -106,7 +126,7 @@ pub fn run(ctx: &Ctx) -> Answers { a.asked += 1; } - // 5 — the consent gate. Last, so that by now the person has seen what this + // The consent gate — not counted as one of the five. Last, so that by now the person has seen what this // program is and what it prints. if ctx.steam_present { println!(); @@ -120,7 +140,7 @@ pub fn run(ctx: &Ctx) -> Answers { println!(" \x1b[2mIt is read locally. Titles never appear in the shareable line — only"); println!(" 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); + a.steam_consent = yes_no("May I read it?", true); } if !ctx.could_host && ctx.is_linux { diff --git a/apps/nesdoctor/src/hostreq.rs b/apps/nesdoctor/src/hostreq.rs index d2f42a09..5e41a832 100644 --- a/apps/nesdoctor/src/hostreq.rs +++ b/apps/nesdoctor/src/hostreq.rs @@ -10,9 +10,12 @@ //! - **`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. +//! - **The renderer is not checked at all, on purpose.** It used to be, and the +//! row could only ever say "present, patch state unknown" — which is a row +//! that cannot pass. The box now carries its own virglrenderer and Mesa +//! inside the image it runs in, so the host's copies are not on the path and +//! asking about them told a prospective host their machine was wrong when it +//! was not. //! - **Nothing here is measured under load.** A host that passes every row can //! still fail on block I/O, which is the real density ceiling and needs a //! benchmark rather than a probe. @@ -189,34 +192,6 @@ pub fn probe(sys: &SysInfo) -> HostReport { 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`. diff --git a/apps/nesdoctor/src/main.rs b/apps/nesdoctor/src/main.rs index d47754f4..6e5f50d4 100644 --- a/apps/nesdoctor/src/main.rs +++ b/apps/nesdoctor/src/main.rs @@ -80,6 +80,14 @@ struct Args { /// Print only the summary line. #[arg(long)] quiet: bool, + + /// Where the submit link points. Override to test against a local worker. + #[arg(long, default_value = "https://doctor.nestri.io")] + submit_url: String, + + /// Do not offer to open a browser; just print the link. + #[arg(long)] + no_open: bool, } fn main() { @@ -183,9 +191,6 @@ fn main() { 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, @@ -196,6 +201,9 @@ fn main() { verdict: v, region_hint: region.clone(), }; + let line = report::summary_line(&full); + + // --- the full report, locally ----------------------------------------- let wrote = serde_json::to_string_pretty(&full) .ok() .and_then(|j| std::fs::write(&args.json, j).ok().map(|_| ())) @@ -206,62 +214,74 @@ fn main() { return; } - let clip = report::to_clipboard(&line); + let url = report::submit_url(&args.submit_url, &full); println!(); - println!("\x1b[1m─── Copy this ───────────────────────────────────────────────────\x1b[0m"); + println!("\x1b[1m─── One keystroke and we are done ───────────────────────────────\x1b[0m"); println!(); - println!("\x1b[1;97;44m {line} \x1b[0m"); - println!(); - match clip { - Some(tool) => println!( - "\x1b[32m ✓ Already on your clipboard\x1b[0m \x1b[2m(via {tool}) — just paste it.\x1b[0m" - ), - None => println!( - "\x1b[2m Select the line above to copy it. (Install wl-clipboard or xclip and\x1b[0m\n\x1b[2m this happens by itself next time.)\x1b[0m" - ), + println!("\x1b[2m Everything above goes to us through this link. It contains:\x1b[0m"); + for item in report::submit_contents(&steamr, &answers) { + println!("\x1b[2m · {item}\x1b[0m"); } println!(); - println!( - "\x1b[2m Every field is above: no hostname, no IP, no username, no game titles,\x1b[0m" - ); - println!("\x1b[2m no paths. A size band rather than a size, hours rather than dates.\x1b[0m"); + println!("\x1b[2m {}\x1b[0m", args.submit_url); println!(); - println!("\x1b[1m → Paste it into the thread you got this from.\x1b[0m"); - println!( - "\x1b[2m It is the only way we find out what the machines on the other end are,\x1b[0m" - ); - println!("\x1b[2m and right now we genuinely have no idea.\x1b[0m"); + + let opened = if args.no_open || !interactive { + false + } else { + print!( + "\x1b[1;97;44m Press Enter to send it \x1b[0m\x1b[2m (or Ctrl-C to send nothing) \x1b[0m" + ); + let _ = std::io::stdout().flush(); + let mut s = String::new(); + let _ = std::io::stdin().read_line(&mut s); + println!(); + report::open_in_browser(&url) + }; + + if opened { + println!("\x1b[32m ✓ Opened in your browser. That is it — thank you.\x1b[0m"); + println!( + "\x1b[2m If the page did not load, the link is below and it still works later.\x1b[0m" + ); + } else { + println!("\x1b[1m Open this to send it:\x1b[0m"); + } + println!(); + println!("\x1b[4;36m{url}\x1b[0m"); + println!(); + + // The clipboard line stays as the offline path: a headless host, a machine + // with no browser, or somebody who would rather paste into a channel than + // click a link we wrote. + let clip = report::to_clipboard(&line); + println!("\x1b[2m Prefer to paste it yourself? The short version:\x1b[0m"); + println!(); + println!(" {line}"); + if let Some(tool) = clip { + println!("\x1b[2m (also on your clipboard, via {tool})\x1b[0m"); + } if wrote { println!(); - println!("\x1b[1mAnd if you feel like being properly helpful\x1b[0m"); + println!("\x1b[1m And if you feel like being properly helpful\x1b[0m"); println!( - "\x1b[2m {} has the long version: every check with its reason, the full\x1b[0m", + "\x1b[2m {} has the long version — every check with its reason, the\x1b[0m", args.json.display() ); println!( - "\x1b[2m latency series, and — if you said yes to Steam — your installed titles\x1b[0m" - ); - println!("\x1b[2m with their sizes and launch times.\x1b[0m"); - println!(); - println!( - "\x1b[2m That file is more useful to us than the line by a long way: it is what\x1b[0m" + "\x1b[2m full latency series, and your installed titles with sizes and launch\x1b[0m" ); println!( - "\x1b[2m lets us size a game library properly and see which requirement actually\x1b[0m" + "\x1b[2m times. It is more useful to us than anything above, because it is what\x1b[0m" ); println!( - "\x1b[2m stops people. Have a look through it — it is plain JSON — and send it\x1b[0m" + "\x1b[2m lets us size a real game library. Have a read and send it along if\x1b[0m" ); - println!( - "\x1b[2m along if nothing in there bothers you. Entirely optional, and the\x1b[0m" - ); - println!("\x1b[2m line above is already plenty.\x1b[0m"); + println!("\x1b[2m nothing in there bothers you.\x1b[0m"); } println!(); - println!("\x1b[2mThanks. Genuinely — this is the part we cannot do on our own.\x1b[0m"); - println!(); } fn banner() { diff --git a/apps/nesdoctor/src/report.rs b/apps/nesdoctor/src/report.rs index 0f754278..184d7edf 100644 --- a/apps/nesdoctor/src/report.rs +++ b/apps/nesdoctor/src/report.rs @@ -130,15 +130,19 @@ pub fn verdict(sys: &SysInfo, host: &HostReport, net: &NetReport) -> Verdict { } /// The line to paste. Pipe-separated fields, `k=v` inside, stable key order. -pub fn summary_line( - sys: &SysInfo, - host: &HostReport, - net: &NetReport, - steam: &SteamReport, - answers: &Answers, - verdict: Verdict, - region: &Option, -) -> String { +/// Both renderers take the assembled report rather than seven arguments: the +/// set of things they need is exactly [`Full`], and keeping them in step with +/// it is the point. +pub fn summary_line(f_: &Full) -> String { + let (sys, host, net, steam, answers, verdict, region) = ( + f_.sys, + f_.host, + f_.net, + f_.steam, + f_.answers, + f_.verdict, + &f_.region_hint, + ); let mut f: Vec = Vec::new(); f.push(format!("nesdoctor {VERSION}")); f.push(format!("{}/{}", sys.os, sys.arch)); @@ -351,3 +355,219 @@ pub fn to_clipboard(line: &str) -> Option<&'static str> { } None } + +// ------------------------------------------------------------------ submit --- + +/// Percent-encode everything that is not unreserved. Small enough to write. +fn enc(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +/// The URL that submits this run. +/// +/// Query parameters rather than an opaque blob, deliberately. A base64 payload +/// would be shorter and would let us send more, and it would also mean the +/// person clicking cannot read what they are sending — which is the one thing +/// this program has going for it. Readable parameters are self-documenting, and +/// the length is nowhere near a browser limit. +/// +/// Carries more than the clipboard line does, because it is not something +/// anyone has to eyeball in a chat window: every check individually, the full +/// latency triple, the 24-hour launch histogram, and the five largest titles. +pub fn submit_url(base: &str, f_: &Full) -> String { + let (sys, host, net, steam, answers, verdict, region) = ( + f_.sys, + f_.host, + f_.net, + f_.steam, + f_.answers, + f_.verdict, + &f_.region_hint, + ); + let mut q: Vec = Vec::new(); + let mut put = |k: &str, v: String| q.push(format!("{k}={}", enc(&v))); + + put("v", VERSION.to_string()); + put("os", format!("{}/{}", sys.os, sys.arch)); + if let Some(rel) = &sys.release { + put("rel", rel.clone()); + } + if let Some(g) = sys + .gpus + .iter() + .find(|g| g.render_node.is_some()) + .or_else(|| sys.gpus.first()) + { + put("gpu", g.name.clone()); + } + if sys.gpus.len() > 1 { + put("gpus", sys.gpus.len().to_string()); + } + put("cpu", sys.cpu_threads.to_string()); + if let Some(m) = &sys.cpu_model { + put("cpumodel", m.clone()); + } + if let Some(r) = sys.ram_gib { + put("ram", format!("{r:.0}")); + } + + // Every check, individually — the aggregate verdict hides which single + // requirement stops people, which is the thing worth knowing. + for c in &host.checks { + // Prefixed: the `gpu` check id would otherwise overwrite the GPU model + // parameter, and last-writer-wins in a query string is a silent loss. + put( + &format!("ck_{}", c.id), + match c.state { + State::Pass => "y", + State::Fail => "n", + State::Unknown => "?", + } + .to_string(), + ); + } + + if let Some(u) = net.upstream_mbps { + put("up", format!("{u:.1}")); + } + if let Some(v) = net.idle_rtt_ms { + put("rtt", format!("{v:.0}")); + } + if let Some(v) = net.loaded_rtt_ms { + put("rttload", format!("{v:.0}")); + } + if let Some(v) = net.loaded_rtt_p95_ms { + put("rttp95", format!("{v:.0}")); + } + if let Some(v) = net.bloat_ms { + put("bloat", format!("{v:.0}")); + } + if let Some(g) = net.grade { + put("grade", g.to_string()); + } + if let Some(r) = region { + put("edge", r.clone()); + } + + if let Some(d) = sys.disks.first() { + put("disk", format!("{:.0}", d.free_gib)); + if let Some(fs) = &d.fs { + put("diskfs", fs.clone()); + } + } + put("disks", sys.disks.len().to_string()); + if let (Some(h), Some(s)) = (sys.powered_hours_per_day, sys.powered_span_days) { + put("powered", format!("{h:.1}")); + put("span", format!("{s:.0}")); + } + + if steam.found { + put("titles", steam.titles.to_string()); + put("gib", format!("{:.0}", steam::gib(steam.bytes_on_disk))); + if steam.launch_samples > 0 { + put( + "hours", + steam + .launch_hours + .iter() + .map(u32::to_string) + .collect::>() + .join(","), + ); + put("n", steam.launch_samples.to_string()); + } + if let Some((a, b)) = steam.peak_window { + put("peak", format!("{a}-{b}")); + } + if !steam.largest.is_empty() { + // Whether the title distribution has a head decides whether a depot + // cache is worth building at all, and it cannot be seen from counts. + put( + "top", + steam + .largest + .iter() + .map(|(n, _)| n.as_str()) + .collect::>() + .join("~"), + ); + } + } + + for (k, v) in [ + ("want", &answers.want), + ("role", &answers.role), + ("share", &answers.share_for), + ("pays", &answers.pays_today), + ("otherlinux", &answers.other_linux), + ] { + if let Some(v) = v { + put(k, v.clone()); + } + } + put("verdict", verdict.tag().to_string()); + + format!("{}/?{}", base.trim_end_matches('/'), q.join("&")) +} + +/// Plain English list of what the submit URL contains, printed before it opens. +/// +/// The URL is readable, but it is also 800 characters long and nobody reads +/// 800 characters. This is the honest summary of it. +pub fn submit_contents(steam: &SteamReport, answers: &Answers) -> Vec<&'static str> { + let mut v = vec![ + "this machine's OS, CPU, RAM and GPU model", + "which host requirements passed and which did not", + "the network figures you just saw", + "free disk space, and how long this machine tends to stay on", + ]; + if steam.found && steam.titles > 0 { + v.push("how many games are installed, their total size, and your five largest"); + if steam.launch_samples > 0 { + v.push("the hour-of-day histogram above — hours, never dates"); + } + } + if answers.want.is_some() + || answers.role.is_some() + || answers.share_for.is_some() + || answers.pays_today.is_some() + { + v.push("your answers to the questions"); + } + v.push("no hostname, no IP address, no username, no file paths"); + v +} + +/// Hand a URL to whatever the desktop uses to open links. +pub fn open_in_browser(url: &str) -> bool { + use std::process::{Command, Stdio}; + let attempts: [(&str, &[&str]); 4] = [ + ("xdg-open", &[]), + ("open", &[]), // macOS + ("cmd", &["/C", "start", ""]), // Windows + ("wslview", &[]), // WSL, where xdg-open is often absent + ]; + for (cmd, args) in attempts { + if Command::new(cmd) + .args(args) + .arg(url) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + return true; + } + } + false +} diff --git a/nesdoctor.json b/nesdoctor.json new file mode 100644 index 00000000..6c8b3185 --- /dev/null +++ b/nesdoctor.json @@ -0,0 +1,163 @@ +{ + "nesdoctor": "0.1.0", + "sys": { + "os": "linux", + "arch": "x86_64", + "release": "CachyOS", + "kernel": "7.1.8-1-cachyos", + "cpu_model": "AMD Ryzen 5 7530U with Radeon Graphics", + "cpu_threads": 12, + "ram_gib": 13.496604919433594, + "gpus": [ + { + "name": "AMD Barcelo", + "vendor": "AMD", + "render_node": "/dev/dri/renderD128" + } + ], + "disks": [ + { + "mount": "/", + "fs": "btrfs", + "source": "/dev/nvme0n1p2", + "free_gib": 90.39772033691406 + }, + { + "mount": "/tmp", + "fs": "tmpfs", + "source": "tmpfs", + "free_gib": 3.8342933654785156 + } + ], + "uptime_hours": 12.97486111111111, + "powered_hours_per_day": null, + "powered_span_days": 2.0136990167476854 + }, + "host": { + "checks": [ + { + "id": "kvm", + "what": "/dev/kvm present and openable", + "state": "pass", + "detail": "yes", + "blocking": true + }, + { + "id": "gpu", + "what": "an AMD or Intel GPU with a DRM render node", + "state": "pass", + "detail": "AMD Barcelo at /dev/dri/renderD128", + "blocking": true + }, + { + "id": "vkvideo", + "what": "VK_KHR_video_encode_queue plus a codec extension", + "state": "pass", + "detail": "extensions present. Note: presence is not proof — a working extension list with a broken path has happened here before, so this row is a necessary and not a sufficient condition.", + "blocking": true + }, + { + "id": "virgl", + "what": "libvirglrenderer with DRM native context, patched", + "state": "unknown", + "detail": "libvirglrenderer.so.1.11.0 found. Whether it carries the native-context patches cannot be told from outside — the contract calls this the requirement most likely to be silently wrong, so we report presence only.", + "blocking": false + }, + { + "id": "content-store", + "what": "a ZFS pool for game datasets", + "state": "fail", + "detail": "no ZFS mount found. One dataset per game, cloned per player, is the whole of the content store — no other filesystem gives clones and send/recv.", + "blocking": false + }, + { + "id": "box-store", + "what": "ext4 or xfs, not /, for box images (O_DIRECT)", + "state": "fail", + "detail": "none found. A box image must be openable O_DIRECT or the box has no storage bound at all — ZFS ignores the flag, and a warm page cache let a capped guest read at 13.3 GB/s against a 20 MB/s cap. Games are hundreds of GiB, so / is not an option either.", + "blocking": false + }, + { + "id": "cgroup-io", + "what": "the io cgroup controller available", + "state": "pass", + "detail": "present at the root", + "blocking": false + }, + { + "id": "virtiofsd", + "what": "virtiofsd, for shared directories into the guest", + "state": "pass", + "detail": "/usr/bin/virtiofsd", + "blocking": false + } + ], + "could_host": true, + "unknowns": 0 + }, + "net": { + "idle_rtt_ms": null, + "loaded_rtt_ms": null, + "loaded_rtt_p95_ms": null, + "bloat_ms": null, + "upstream_mbps": null, + "grade": null, + "note": "skipped with --no-net" + }, + "steam": { + "found": true, + "roots": [ + "/home/wanjohiryan/.steam/steam", + "/home/wanjohiryan/.local/share/Steam" + ], + "titles": 1, + "bytes_on_disk": 45003193272, + "largest": [ + [ + "Control Ultimate Edition", + 45003193272 + ] + ], + "launch_hours": [ + 8, + 4, + 2, + 8, + 0, + 4, + 0, + 4, + 2, + 0, + 0, + 0, + 4, + 6, + 2, + 0, + 8, + 2, + 2, + 2, + 2, + 8, + 6, + 0 + ], + "launch_samples": 74, + "peak_window": [ + 20, + 3 + ] + }, + "answers": { + "role": "play", + "share_for": null, + "pays_today": "1-9", + "other_linux": null, + "steam_consent": true, + "asked": 2 + }, + "verdict": "unknown", + "region_hint": null +} \ No newline at end of file