mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5a72e59dc | ||
|
|
1a86761edf | ||
|
|
60e38c57b5 | ||
|
|
3e5616805a | ||
|
|
320186c089 | ||
|
|
4ffac208e1 | ||
|
|
40f82d0e99 | ||
|
|
b3476f07eb | ||
|
|
8d9b80c420 | ||
|
|
2b3b2fe7e5 | ||
|
|
7433b49bef | ||
|
|
4347a72fe2 | ||
|
|
edeb6978b9 | ||
|
|
5176dc5a75 | ||
|
|
9f5ed29593 | ||
|
|
cf9c6e9fe0 | ||
|
|
19365846c3 | ||
|
|
afdfc8bfa1 | ||
|
|
13c366a85b | ||
|
|
763bc981f0 | ||
|
|
b7bf458117 | ||
|
|
a4b9e36ba6 | ||
|
|
74f52d0782 | ||
|
|
23f0abf4d2 | ||
|
|
f2fde617b0 | ||
|
|
4958be7ce0 | ||
|
|
9d70e670c8 | ||
|
|
5062adfb0c | ||
|
|
bfcf02d4ff | ||
|
|
195ecc898e | ||
|
|
59fc7c9870 | ||
|
|
7aa6f0fda0 | ||
|
|
63408fc7f0 | ||
|
|
b05298bec0 | ||
|
|
28a200a0e7 | ||
|
|
5aca6c42ee | ||
|
|
4abfa15d97 | ||
|
|
37bdbf9def | ||
|
|
dd6ad03eea | ||
|
|
67d691b1e3 | ||
|
|
9a0d252abf | ||
|
|
55b78015fe | ||
|
|
cb1198a38d | ||
|
|
17b50d7959 | ||
|
|
bb29b70b32 | ||
|
|
3ad8a18a7b |
No files matched your search
Generated
+7
-3
@@ -1401,7 +1401,9 @@ dependencies = [
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-platform",
|
||||
"horizon-telemetry",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1439,7 +1441,6 @@ dependencies = [
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
@@ -1461,6 +1462,7 @@ dependencies = [
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1522,7 +1524,6 @@ dependencies = [
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
@@ -1793,10 +1794,12 @@ dependencies = [
|
||||
name = "horizon-official"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-platform",
|
||||
"horizon-telemetry",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
@@ -1805,8 +1808,8 @@ dependencies = [
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"horizon-system",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
@@ -1970,6 +1973,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license = "MIT"
|
||||
description = "The benchmark report format (docs/benchmark-report.md), shared by the game, the launcher and the upload"
|
||||
@@ -11,3 +11,11 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
# The game's part (`game`): the hooks it plugs into and the probes, from the platform's API.
|
||||
horizon-platform = { path = "../horizon-platform", optional = true }
|
||||
log = { version = "0.4", optional = true }
|
||||
|
||||
[features]
|
||||
# The game's `--benchmark` driver, started through the platform's hooks. The official executable
|
||||
# turns it on.
|
||||
game = ["dep:horizon-platform", "dep:log"]
|
||||
@@ -5,16 +5,54 @@
|
||||
//!
|
||||
//! The scenes, the world (seed 1234, creative) and every step and figure match `tools/perf_bench.py`,
|
||||
//! so reports from the two can be compared.
|
||||
//!
|
||||
//! Behind the `game` feature: the game starts it through the platform's hooks ([`HOOKS`],
|
||||
//! `horizon_platform::hooks`), which the official executable registers (`horizon_official::PARTS`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{self, Sender};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use horizon_bench::{App, Frames, Kind, Report};
|
||||
use horizon_platform::hooks::{self, Hooks};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::control::Request;
|
||||
use crate::{App, Frames, Kind, Report};
|
||||
|
||||
/// The benchmark's hooks, for `horizon_platform::hooks::register`.
|
||||
pub static HOOKS: Benchmark = Benchmark;
|
||||
|
||||
/// Runs `--benchmark SCENARIO`.
|
||||
pub struct Benchmark;
|
||||
|
||||
impl Hooks for Benchmark {
|
||||
fn knows_benchmark(&self, scenario: &str) -> bool {
|
||||
scenes(scenario).is_some()
|
||||
}
|
||||
|
||||
fn benchmark(&self, run: &hooks::Benchmark<'_>, control: hooks::Control) {
|
||||
start(
|
||||
control,
|
||||
Setup {
|
||||
scenario: run.scenario.to_owned(),
|
||||
started: run.started,
|
||||
app: App {
|
||||
name: "horizon-client".into(),
|
||||
version: run.version.to_owned(),
|
||||
commit: option_env!("HORIZON_COMMIT").map(str::to_owned),
|
||||
mods: Vec::new(),
|
||||
},
|
||||
simulation_distance: run.simulation_distance,
|
||||
vsync: false,
|
||||
fps_cap: None,
|
||||
offscreen: run.offscreen,
|
||||
game_dir: run.game_dir.to_owned(),
|
||||
home: run.home.to_owned(),
|
||||
build: run.build.to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub const SEED: i64 = 1234;
|
||||
/// The world the benchmark creates under `saves/`, replaced on every run.
|
||||
@@ -73,14 +111,18 @@ pub struct Setup {
|
||||
pub fps_cap: Option<u32>,
|
||||
pub offscreen: bool,
|
||||
pub game_dir: PathBuf,
|
||||
/// The game's home folder: the telemetry outbox.
|
||||
pub home: PathBuf,
|
||||
/// The build, as an upload names it (`horizon_platform::hooks::Crash::version`).
|
||||
pub build: String,
|
||||
}
|
||||
|
||||
/// Starts the driver; the game quits when it is done, and the report's path is printed.
|
||||
pub fn start(requests: Sender<Request>, setup: Setup) {
|
||||
pub fn start(control: hooks::Control, setup: Setup) {
|
||||
thread::Builder::new()
|
||||
.name("horizon-benchmark".into())
|
||||
.spawn(move || {
|
||||
let control = Control(requests);
|
||||
let control = Control(control);
|
||||
match run(&control, setup) {
|
||||
Ok(path) => println!("Horizon: benchmark report {}", path.display()),
|
||||
Err(e) => eprintln!("Horizon: benchmark failed: {e}"),
|
||||
@@ -90,21 +132,12 @@ pub fn start(requests: Sender<Request>, setup: Setup) {
|
||||
.expect("spawn the benchmark thread");
|
||||
}
|
||||
|
||||
struct Control(Sender<Request>);
|
||||
struct Control(hooks::Control);
|
||||
|
||||
impl Control {
|
||||
fn call(&self, line: &str) -> Result<Value, String> {
|
||||
let (reply, response) = mpsc::channel();
|
||||
self.0
|
||||
.send(Request {
|
||||
line: line.to_owned(),
|
||||
reply,
|
||||
})
|
||||
.map_err(|_| "the game has exited".to_owned())?;
|
||||
// Flights and loads reply when they finish.
|
||||
let text = response
|
||||
.recv_timeout(LOAD_LIMIT)
|
||||
.map_err(|_| format!("{line}: no reply"))?;
|
||||
let text = (self.0)(line, LOAD_LIMIT).ok_or_else(|| format!("{line}: no reply"))?;
|
||||
let value: Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
|
||||
match value.get("error").and_then(Value::as_str) {
|
||||
Some(e) => Err(format!("{line}: {e}")),
|
||||
@@ -154,8 +187,8 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
|
||||
let mut report = Report::new(Kind::Game, setup.app.clone());
|
||||
let pid = std::process::id();
|
||||
report.conditions.load_avg_1m = horizon_bench::load_average();
|
||||
report.conditions.power = horizon_bench::power_source().map(str::to_owned);
|
||||
report.conditions.load_avg_1m = crate::load_average();
|
||||
report.conditions.power = crate::power_source().map(str::to_owned);
|
||||
let rss = RssSampler::start(pid);
|
||||
|
||||
let title = poll(control, LOAD_LIMIT, |s| {
|
||||
@@ -163,7 +196,7 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
})?;
|
||||
report.metric("startup.title_ms", &[ms(setup.started.elapsed())]);
|
||||
let renderer = &title["renderer"];
|
||||
report.hardware.gpu = Some(horizon_bench::Gpu {
|
||||
report.hardware.gpu = Some(crate::Gpu {
|
||||
name: text(&renderer["gpu"]),
|
||||
vendor: match text(&renderer["vendor"]) {
|
||||
// Metal reports no vendor ID.
|
||||
@@ -176,7 +209,7 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
vram_mb: None,
|
||||
});
|
||||
if let Some(gpu) = report.hardware.gpu.as_mut() {
|
||||
let vendor = horizon_telemetry::system::gpu_vendor(&gpu.name, 0);
|
||||
let vendor = horizon_platform::system::gpu_vendor(&gpu.name, 0);
|
||||
let vendor = if gpu.vendor.is_empty() {
|
||||
vendor
|
||||
} else {
|
||||
@@ -184,11 +217,10 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
}
|
||||
.to_owned();
|
||||
if gpu.kind.contains("iscrete") {
|
||||
gpu.vram_mb = horizon_telemetry::system::vram_mb(&vendor);
|
||||
gpu.vram_mb = horizon_platform::system::vram_mb(&vendor);
|
||||
}
|
||||
}
|
||||
report.hardware.storage =
|
||||
horizon_telemetry::system::storage(&setup.game_dir).map(str::to_owned);
|
||||
report.hardware.storage = horizon_platform::system::storage(&setup.game_dir).map(str::to_owned);
|
||||
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
|
||||
|
||||
replace_world(&setup.game_dir)?;
|
||||
@@ -311,7 +343,7 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
if let Ok(meta) = std::env::current_exe().and_then(std::fs::metadata) {
|
||||
report.metric("game.binary_bytes", &[meta.len() as f64]);
|
||||
}
|
||||
crate::telemetry::keep_benchmark(&crate::logging::game_home(), &report);
|
||||
keep_for_upload(&setup.home, &setup.build, &report);
|
||||
report
|
||||
.write_to(&setup.game_dir.join("benchmarks"))
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -456,7 +488,7 @@ impl RssSampler {
|
||||
while let Err(mpsc::RecvTimeoutError::Timeout) =
|
||||
stopped.recv_timeout(Duration::from_millis(250))
|
||||
{
|
||||
samples.extend(horizon_bench::rss_bytes(pid).map(|b| (Instant::now(), b)));
|
||||
samples.extend(crate::rss_bytes(pid).map(|b| (Instant::now(), b)));
|
||||
}
|
||||
samples
|
||||
});
|
||||
@@ -468,10 +500,80 @@ impl RssSampler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the report for the launcher to send, if the player allows benchmark uploads: the report
|
||||
/// as docs/benchmark-report.md defines it, dated to the day (docs/telemetry.md).
|
||||
fn keep_for_upload(home: &Path, build: &str, report: &Report) {
|
||||
use horizon_telemetry::{Item, Payload, Source, outbox};
|
||||
let Ok(data) = serde_json::to_value(report.for_upload()) else {
|
||||
return;
|
||||
};
|
||||
let item = Item::new(Source::Game, build.to_owned(), Payload::Benchmark(data));
|
||||
if let Err(e) = outbox::push(home, &item) {
|
||||
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A control line and where its reply goes, as the game's command queue takes them.
|
||||
struct Request {
|
||||
line: String,
|
||||
reply: Sender<String>,
|
||||
}
|
||||
|
||||
/// A control that hands each line to `requests`, as the game does.
|
||||
fn control(requests: Sender<Request>) -> Control {
|
||||
Control(Box::new(move |line, wait| {
|
||||
let (reply, response) = mpsc::channel();
|
||||
let request = Request {
|
||||
line: line.to_owned(),
|
||||
reply,
|
||||
};
|
||||
requests.send(request).ok()?;
|
||||
response.recv_timeout(wait).ok()
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_kept_for_upload_need_their_own_consent_and_go_dated_to_the_day() {
|
||||
use horizon_telemetry::{Choices, Consent, outbox};
|
||||
let home = std::env::temp_dir().join(format!(
|
||||
"horizon-client-benchmark-upload-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
let allow = |choices: Choices| {
|
||||
let mut consent = Consent::load(&home);
|
||||
consent.answer(choices);
|
||||
consent.save(&home).unwrap();
|
||||
};
|
||||
let mut report = Report::new(Kind::Game, Default::default());
|
||||
report.created = "2026-10-15T14:03:27Z".into();
|
||||
report.label = Some("my machine".into());
|
||||
keep_for_upload(&home, "0.2.31", &report);
|
||||
allow(Choices {
|
||||
performance: true,
|
||||
..Choices::NONE
|
||||
});
|
||||
keep_for_upload(&home, "0.2.31", &report);
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(Choices {
|
||||
benchmarks: true,
|
||||
..Choices::NONE
|
||||
});
|
||||
keep_for_upload(&home, "0.2.31", &report);
|
||||
let pending = outbox::pending(&home);
|
||||
assert_eq!(pending.len(), 1);
|
||||
let text = serde_json::to_string(&pending[0].1).unwrap();
|
||||
assert!(
|
||||
text.contains("2026-10-15T00:00:00Z") && !text.contains("my machine"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenarios() {
|
||||
assert_eq!(scenes("standard").unwrap().len(), 4);
|
||||
@@ -524,7 +626,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
});
|
||||
let control = Control(tx);
|
||||
let control = control(tx);
|
||||
wait_loaded(&control, |_| true).expect("the scene loads");
|
||||
assert!(
|
||||
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
|
||||
@@ -556,15 +658,15 @@ mod tests {
|
||||
}
|
||||
lines
|
||||
});
|
||||
let hardware = horizon_bench::Hardware {
|
||||
gpu: Some(horizon_bench::Gpu {
|
||||
let hardware = crate::Hardware {
|
||||
gpu: Some(crate::Gpu {
|
||||
backend: backend.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut report = Report::with_hardware(Kind::Game, App::default(), hardware);
|
||||
let control = Control(tx);
|
||||
let control = control(tx);
|
||||
let timed = time_passes(&control, "forest", &mut report).expect("the wait runs");
|
||||
drop(control);
|
||||
(timed, server.join().unwrap(), report.metrics)
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -68,12 +69,12 @@ impl Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu: horizon_telemetry::system::cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
@@ -109,84 +110,16 @@ fn probes() -> Probes {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cpu() -> Option<String> {
|
||||
output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_version() -> Option<String> {
|
||||
let release = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
@@ -199,22 +132,6 @@ fn os_version() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb = key_value(&info, "MemTotal", ':')?
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
@@ -228,39 +145,11 @@ fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let out = output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?;
|
||||
out.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn os_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn cpu() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -274,9 +163,6 @@ mod tests {
|
||||
assert!(hw.cpu.is_some(), "{hw:?}");
|
||||
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
|
||||
assert!(hw.os_version.is_some(), "{hw:?}");
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,13 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "game")]
|
||||
pub mod game;
|
||||
mod hardware;
|
||||
|
||||
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
|
||||
pub use hardware::{Display, Gpu, Hardware, power_source};
|
||||
/// The process probes, from the platform (`horizon-system`).
|
||||
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
@@ -71,9 +71,8 @@ mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
|
||||
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
|
||||
# The official server's address (multiplayer.rs) until it moves to the Horizon layer.
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
|
||||
@@ -7,7 +7,8 @@ rust-version.workspace = true
|
||||
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
|
||||
|
||||
[dependencies]
|
||||
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
|
||||
# The crates above the engine: their settings stores (docs/layers.md, "Platform registries"),
|
||||
# and the hooks that hear each crash (`crash.rs`).
|
||||
horizon-hooks = { path = "../horizon-hooks" }
|
||||
horizon-cosmetics = { path = "../horizon-cosmetics" }
|
||||
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
|
||||
|
||||
@@ -204,6 +204,23 @@ impl Report {
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer's adapter ([`crate::gfx::adapter`]), for the hooks above the engine.
|
||||
pub fn gpu() -> Option<horizon_hooks::Gpu> {
|
||||
crate::gfx::adapter().map(|a| horizon_hooks::Gpu {
|
||||
name: a.name,
|
||||
vendor: a.vendor,
|
||||
kind: a.kind,
|
||||
driver: a.driver,
|
||||
backend: a.backend,
|
||||
})
|
||||
}
|
||||
|
||||
/// The build, for what leaves the machine: the release (`v0.2.3`), or `dev <crate version>` for a
|
||||
/// build from source.
|
||||
pub fn build() -> String {
|
||||
release_tag().unwrap_or_else(|| format!("dev {}", env!("CARGO_PKG_VERSION")))
|
||||
}
|
||||
|
||||
/// The release this binary is (`v0.2.3`); a nightly's label (`Nightly 2026-10-28 · 0c02067`); for a
|
||||
/// release from before 0.2, its tag and the client's crate version; for a build from source, that
|
||||
/// version alone.
|
||||
@@ -383,19 +400,20 @@ fn thread_name(side: &str) -> String {
|
||||
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
|
||||
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
|
||||
|
||||
/// What runs on each report before it is saved ([`on_report`]).
|
||||
pub type ReportHook = fn(&Path, &Report);
|
||||
|
||||
static REPORT_HOOKS: std::sync::RwLock<Vec<ReportHook>> = std::sync::RwLock::new(Vec::new());
|
||||
|
||||
/// Runs `hook` with every crash report, before it is saved: the game folder and the report. An
|
||||
/// extension point for what lives above the engine (Horizon's opt-in telemetry keeps the
|
||||
/// report). A hook runs inside the crash handler: it must not panic or block.
|
||||
pub fn on_report(hook: ReportHook) {
|
||||
REPORT_HOOKS
|
||||
.write()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(hook);
|
||||
/// The hooks above the engine hear the report before it is saved (`Hooks::crashed`; Horizon's
|
||||
/// opt-in telemetry keeps it). They run inside the crash handler: they must not panic or block.
|
||||
fn tell_hooks(home: &Path, report: &Report) {
|
||||
let version = build();
|
||||
let gpu = gpu();
|
||||
horizon_hooks::engine::crashed(&horizon_hooks::Crash {
|
||||
home,
|
||||
version: &version,
|
||||
error: &report.error,
|
||||
stack: &report.stack,
|
||||
thread: &report.thread,
|
||||
details: &report.details,
|
||||
gpu: gpu.as_ref(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Writes the report and tells where it is; the log gets the error too.
|
||||
@@ -418,13 +436,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
),
|
||||
);
|
||||
crate::logging::flush();
|
||||
for hook in REPORT_HOOKS
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
{
|
||||
hook(home, &report);
|
||||
}
|
||||
tell_hooks(home, &report);
|
||||
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
|
||||
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
|
||||
Ok(path) => {
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn enabled() -> bool {
|
||||
}
|
||||
/// Record this thread's spans even with spans off, their wall time only (no CPU clock read):
|
||||
/// what the render thread's slow frames spent their time in, for the opt-in performance
|
||||
/// statistics (`telemetry::Session::frame`). The thread takes them every frame ([`take`]).
|
||||
/// statistics (`play_session::Session::frame`). The thread takes them every frame ([`take`]).
|
||||
pub fn record_here(on: bool) {
|
||||
HERE.with(|h| h.set(on));
|
||||
}
|
||||
@@ -466,7 +466,7 @@ pub mod startup {
|
||||
Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
@@ -489,6 +489,11 @@ pub mod startup {
|
||||
ON.store(sink.is_some(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The time since the process started ([`init`]'s origin; zero before it).
|
||||
pub fn since_start() -> Duration {
|
||||
ORIGIN.get().map_or(Duration::ZERO, Instant::elapsed)
|
||||
}
|
||||
|
||||
/// Whether a trace is being written.
|
||||
#[inline]
|
||||
pub fn on() -> bool {
|
||||
|
||||
@@ -68,10 +68,11 @@ log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md). The
|
||||
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
|
||||
# The official server's address (multiplayer.rs) until it moves to the Horizon layer. Telemetry,
|
||||
# the "I'm playing" ping and the benchmark are Horizon parts the official executable registers.
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
# The machine and process probes (the dedicated server's performance report).
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
|
||||
@@ -33,7 +33,6 @@ pub mod attribute_updates;
|
||||
pub mod auto_jump;
|
||||
pub mod banner;
|
||||
pub mod beehive;
|
||||
pub mod benchmark;
|
||||
pub mod block_entity_item;
|
||||
pub mod block_pattern;
|
||||
pub mod block_transform;
|
||||
@@ -110,6 +109,7 @@ pub mod osk;
|
||||
pub mod patrol;
|
||||
pub mod perf_overlay;
|
||||
pub mod pick_item;
|
||||
pub mod play_session;
|
||||
pub mod player_sync;
|
||||
pub mod portal_sections;
|
||||
pub mod pose;
|
||||
@@ -139,7 +139,6 @@ pub mod stations;
|
||||
pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod studio;
|
||||
pub mod telemetry;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
|
||||
@@ -20,13 +20,11 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_client::options_ui::HORIZON_EXTRAS
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
horizon_client::telemetry::mark_start();
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// The process's start, for the startup trace and the start's milestones.
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
horizon_hooks::engine::seal();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let game_args = args.iter().take_while(|a| *a != "--dedicated");
|
||||
|
||||
@@ -2801,7 +2801,7 @@ struct Game<'a> {
|
||||
agent_last: Option<Instant>,
|
||||
frame_stats: horizon_client::control::FrameStats,
|
||||
/// This play session's frame and tick times, for opt-in performance statistics.
|
||||
perf: horizon_client::telemetry::Session,
|
||||
perf: horizon_client::play_session::Session,
|
||||
/// Control replies waiting for a rendered frame or a benchmark deadline.
|
||||
pending_screenshot: Option<std::sync::mpsc::Sender<String>>,
|
||||
/// `screenshot-sequence` while it runs.
|
||||
@@ -12635,11 +12635,11 @@ pub fn run_with() -> Result<()> {
|
||||
options.spikes = Some(dedicated::SLOW_TICK_MS);
|
||||
}
|
||||
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
|
||||
// A player who allows performance statistics also says what their slow frames spent their
|
||||
// time in: this thread, which draws the frames, times its steps (wall time only).
|
||||
// A hook that wants to know what slow frames spent their time in (a player who allows
|
||||
// performance statistics): this thread, which draws the frames, times its steps (wall time
|
||||
// only).
|
||||
if options.execution_role.is_client()
|
||||
&& horizon_telemetry::Consent::load(&horizon_client::logging::game_home())
|
||||
.allows(horizon_telemetry::Kind::Perf)
|
||||
&& horizon_hooks::engine::wants_frame_steps(&horizon_client::logging::game_home())
|
||||
{
|
||||
horizon_client::spans::record_here(true);
|
||||
}
|
||||
@@ -12664,7 +12664,7 @@ pub fn run_with() -> Result<()> {
|
||||
// for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that
|
||||
// horizon-ctl drives: those start in a source checkout and wrote the list there.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
let version = horizon_client::telemetry::version();
|
||||
let version = horizon_client::crash::build();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
|
||||
// horizon-extras: the official server is Horizon's (docs/forking.md).
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Recording {
|
||||
Self {
|
||||
seconds,
|
||||
started: Instant::now(),
|
||||
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
|
||||
cpu_s: horizon_system::cpu_seconds(std::process::id()),
|
||||
spans_were_on,
|
||||
ticks: Vec::with_capacity(seconds as usize * 20),
|
||||
sampled_ticks: 0,
|
||||
@@ -363,7 +363,7 @@ impl Game<'_> {
|
||||
let costs = tick_profile::take();
|
||||
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
|
||||
let jobs = job_stats::take();
|
||||
let cpu = horizon_bench::cpu_seconds(std::process::id())
|
||||
let cpu = horizon_system::cpu_seconds(std::process::id())
|
||||
.zip(r.cpu_s)
|
||||
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
@@ -382,7 +382,7 @@ impl Game<'_> {
|
||||
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
|
||||
"players": players,
|
||||
"levels": self.report_levels(),
|
||||
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"memory": { "rss_mb": horizon_system::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"process": process_limits(),
|
||||
"cpu_cores_used": cpu,
|
||||
"config": {
|
||||
@@ -395,9 +395,12 @@ impl Game<'_> {
|
||||
// The machine's description runs commands: off the tick.
|
||||
std::thread::spawn(move || {
|
||||
let mut report = report;
|
||||
let hw = horizon_bench::Hardware::detect();
|
||||
report["machine"] = json!({
|
||||
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
|
||||
"os": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"cpu": horizon_system::cpu(),
|
||||
"cpu_threads": std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
"ram_mb": horizon_system::ram_bytes().map(|b| b >> 20),
|
||||
});
|
||||
match write_report(Path::new("reports"), &report, &text) {
|
||||
Ok(path) => log::info!(
|
||||
|
||||
@@ -324,7 +324,7 @@ impl Options {
|
||||
"--player-dir" => {}
|
||||
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
|
||||
"--benchmark" => {
|
||||
if horizon_client::benchmark::scenes(&value).is_none() {
|
||||
if !horizon_hooks::engine::knows_benchmark(&value) {
|
||||
return Err(format!(
|
||||
"unknown benchmark {value}: standard, village, forest, ocean or mountain"
|
||||
)
|
||||
|
||||
@@ -123,7 +123,7 @@ impl Loading {
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
horizon_client::play_session::note_display(&window);
|
||||
// The renderer takes seconds to build and winit's window sits white
|
||||
// until the first frame presents, so paint the boot screen on it and
|
||||
// keep repainting it as the console's lines arrive; the wgpu frames
|
||||
|
||||
@@ -314,27 +314,31 @@ pub(super) fn start_game<'a>(
|
||||
game.preload_registries();
|
||||
game.start_agent_control()?;
|
||||
if let Some(scenario) = game.options.benchmark.clone() {
|
||||
let control = game
|
||||
// A part above the engine runs it (docs/benchmark.md), driving the game through the
|
||||
// command queue horizon-ctl uses.
|
||||
let requests = game
|
||||
.control
|
||||
.get_or_insert_with(horizon_client::control::Server::local);
|
||||
horizon_client::benchmark::start(
|
||||
control.sender(),
|
||||
horizon_client::benchmark::Setup {
|
||||
scenario,
|
||||
started,
|
||||
app: horizon_bench::App {
|
||||
name: "horizon-client".into(),
|
||||
version: horizon_client::crash::version(),
|
||||
commit: option_env!("HORIZON_COMMIT").map(str::to_owned),
|
||||
mods: Vec::new(),
|
||||
},
|
||||
simulation_distance: game.settings.simulation_distance as u32,
|
||||
vsync: false,
|
||||
fps_cap: None,
|
||||
offscreen: game.options.offscreen,
|
||||
game_dir: std::env::current_dir()?,
|
||||
},
|
||||
);
|
||||
.get_or_insert_with(horizon_client::control::Server::local)
|
||||
.sender();
|
||||
let control: horizon_hooks::Control = Box::new(move |line, wait| {
|
||||
let (reply, response) = std::sync::mpsc::channel();
|
||||
let line = line.to_owned();
|
||||
requests
|
||||
.send(horizon_client::control::Request { line, reply })
|
||||
.ok()?;
|
||||
response.recv_timeout(wait).ok()
|
||||
});
|
||||
let run = horizon_hooks::Benchmark {
|
||||
scenario: &scenario,
|
||||
started,
|
||||
home: &horizon_client::logging::game_home(),
|
||||
game_dir: &std::env::current_dir()?,
|
||||
version: &horizon_client::crash::version(),
|
||||
build: &horizon_client::crash::build(),
|
||||
simulation_distance: game.settings.simulation_distance as u32,
|
||||
offscreen: game.options.offscreen,
|
||||
};
|
||||
horizon_hooks::engine::benchmark(&run, control);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_NATIVE_CLIENT_PARTICLES_FIXTURE") {
|
||||
game.run_native_client_particles_fixture(std::path::Path::new(&directory))?;
|
||||
|
||||
@@ -487,7 +487,7 @@ pub(super) fn load_resources(
|
||||
};
|
||||
let resources = Arc::new(resources);
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_core_ready();
|
||||
horizon_client::play_session::core_ready();
|
||||
log::info!(
|
||||
"Horizon: core-ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
@@ -526,7 +526,7 @@ pub(super) fn load_resources(
|
||||
.set(rest)
|
||||
.map_err(|_| "world resources already loaded".to_owned())?;
|
||||
progress.set(9);
|
||||
horizon_client::telemetry::mark_rest_ready();
|
||||
horizon_client::play_session::rest_ready();
|
||||
log::info!(
|
||||
"Horizon: rest-ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
|
||||
@@ -53,7 +53,7 @@ pub(super) struct StartupMenu {
|
||||
pub reload_packs: bool,
|
||||
/// Browse mods' worker (`mod_browser`).
|
||||
mod_jobs: super::mod_browser::Jobs,
|
||||
pub perf: horizon_client::telemetry::Session,
|
||||
pub perf: horizon_client::play_session::Session,
|
||||
}
|
||||
|
||||
impl StartupMenu {
|
||||
|
||||
@@ -304,7 +304,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
horizon_client::play_session::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
Ok(r) => {
|
||||
self.renderer = Some(r);
|
||||
|
||||
@@ -169,33 +169,33 @@ impl Game<'_> {
|
||||
/// Leaving the game with a world open (window closed, Quit, `quit`):
|
||||
/// menu items go back (Player.closeContainer), then the world is saved,
|
||||
/// once.
|
||||
/// Keeps this play session's performance summary for the launcher to send, if the player
|
||||
/// allows performance statistics (`horizon_client::telemetry`), and starts the next one.
|
||||
/// Ends this play session for the hooks (`horizon_client::play_session`): opt-in telemetry
|
||||
/// keeps its performance summary for the launcher to send, if the player allows it.
|
||||
pub(super) fn finish_perf(&mut self) {
|
||||
// One session per computer: P1's (a splitscreen player's would count it again).
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
let s = &self.settings;
|
||||
let settings = horizon_telemetry::payload::Settings {
|
||||
render_distance: s.render_distance.clamp(0, 255) as u8,
|
||||
simulation_distance: s.simulation_distance.clamp(0, 255) as u8,
|
||||
// 260 is the slider's "unlimited".
|
||||
max_fps: if s.max_fps >= 260 {
|
||||
0
|
||||
} else {
|
||||
s.max_fps.min(u32::from(u16::MAX)) as u16
|
||||
},
|
||||
vsync: s.vsync,
|
||||
fullscreen: s.fullscreen,
|
||||
graphics: s.graphics_preset.clone(),
|
||||
};
|
||||
// Again now: on Wayland the window is on a screen only once it has been shown.
|
||||
if let Some(window) = &self.window {
|
||||
horizon_client::telemetry::note_display(window);
|
||||
horizon_client::play_session::note_display(window);
|
||||
}
|
||||
self.perf
|
||||
.finish(&horizon_client::logging::game_home(), settings);
|
||||
let s = &self.settings;
|
||||
let home = horizon_client::logging::game_home();
|
||||
let version = horizon_client::crash::build();
|
||||
let gpu = horizon_client::crash::gpu();
|
||||
self.perf.finish(&horizon_hooks::WorldLeft {
|
||||
home: &home,
|
||||
version: &version,
|
||||
gpu: gpu.as_ref(),
|
||||
render_distance: s.render_distance,
|
||||
simulation_distance: s.simulation_distance,
|
||||
// 260 is the slider's "unlimited".
|
||||
max_fps: (s.max_fps < 260).then_some(s.max_fps),
|
||||
vsync: s.vsync,
|
||||
fullscreen: s.fullscreen,
|
||||
graphics: &s.graphics_preset,
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn save_for_exit(&mut self) -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! A play session as the hooks above the engine hear it (`horizon_hooks`): the title screen's
|
||||
//! first frame, a world opening and its first frame, the frames played, and leaving the world.
|
||||
//! It also marks those moments in the startup trace (`spans::startup`). Opt-in telemetry's
|
||||
//! performance summary is built from these hooks (docs/telemetry.md).
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use horizon_hooks::{Frame, Milestone, WorldLeft, engine};
|
||||
|
||||
use crate::spans::{self, startup};
|
||||
|
||||
/// One window's session: moved from the startup menu to the game with the window.
|
||||
#[derive(Default)]
|
||||
pub struct Session {
|
||||
/// When the world being opened started to, until its first frame.
|
||||
opening: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The title screen drew a frame: the first time, how long the start took.
|
||||
pub fn title_shown(&mut self) {
|
||||
static SHOWN: AtomicBool = AtomicBool::new(false);
|
||||
if !SHOWN.swap(true, Ordering::Relaxed) {
|
||||
startup::mark("title.shown");
|
||||
// The frame itself: the menu bundle can be ready seconds before the renderer that
|
||||
// draws it (pipelines compiling on a slow driver).
|
||||
engine::startup(Milestone::TitleShown, startup::since_start());
|
||||
}
|
||||
}
|
||||
|
||||
/// A world starts loading.
|
||||
pub fn world_opening(&mut self) {
|
||||
if self.opening.is_none() {
|
||||
startup::arm("world.opening");
|
||||
}
|
||||
self.opening.get_or_insert_with(Instant::now);
|
||||
}
|
||||
|
||||
/// An entry queued during startup was cancelled before opening a world.
|
||||
pub fn cancel_world_opening(&mut self) {
|
||||
self.opening = None;
|
||||
}
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
|
||||
/// recorded in it ([`spans::take_raw`]): the step it spent most of its time in is
|
||||
/// [`spans::culprit`].
|
||||
pub fn frame(&mut self, ms: f32, frame_spans: &[spans::Span]) {
|
||||
if let Some(opening) = self.opening.take() {
|
||||
startup::mark("world.first_frame");
|
||||
engine::world_shown(opening.elapsed());
|
||||
}
|
||||
engine::frame(&Frame::new(ms, &|| spans::culprit(frame_spans)));
|
||||
}
|
||||
|
||||
/// The player left the world: the session ends.
|
||||
pub fn finish(&mut self, left: &WorldLeft<'_>) {
|
||||
engine::world_left(left);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells the hooks the screen `window` is on ([`crate::window::display`]).
|
||||
pub fn note_display(window: &winit::window::Window) {
|
||||
if let Some(d) = crate::window::display(window) {
|
||||
engine::display(&horizon_hooks::Display {
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
refresh_hz: d.refresh_hz,
|
||||
scale: d.scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The resources the menus need are loaded.
|
||||
pub fn core_ready() {
|
||||
engine::startup(Milestone::CoreReady, startup::since_start());
|
||||
}
|
||||
|
||||
/// What a world needs is loaded too.
|
||||
pub fn rest_ready() {
|
||||
engine::startup(Milestone::RestReady, startup::since_start());
|
||||
}
|
||||
@@ -24,7 +24,9 @@ pub mod settings;
|
||||
pub mod tunnel;
|
||||
|
||||
use registry::Registry;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// What a crate above the engine hears from the game. Every method has an empty default:
|
||||
/// implement only those you need. Hooks run on the game's thread (the render thread) unless a
|
||||
@@ -42,6 +44,45 @@ pub trait Hooks: Sync {
|
||||
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
|
||||
fn shutdown(&self) {}
|
||||
|
||||
/// A moment of the game's start, once each, with the time since the process started.
|
||||
fn startup(&self, _milestone: Milestone, _since_start: Duration) {}
|
||||
|
||||
/// Whether this hook wants the render thread to time its steps in every frame, for
|
||||
/// [`Frame::slowest_step`]. Asked once as the game starts (not on a dedicated server);
|
||||
/// `home` is the game's home folder. Timing the steps costs microseconds a frame.
|
||||
fn wants_frame_steps(&self, _home: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// A world's first frame was drawn: the time since it started opening (single player, a
|
||||
/// server, LAN). Every time a world is entered.
|
||||
fn world_shown(&self, _since_opening: Duration) {}
|
||||
|
||||
/// A frame drawn while playing: not in the menus or while a world loads. Every frame.
|
||||
fn frame(&self, _frame: &Frame<'_>) {}
|
||||
|
||||
/// The player left a world (to the title screen, or by quitting): the end of a play
|
||||
/// session, with the settings it was played with.
|
||||
fn world_left(&self, _left: &WorldLeft<'_>) {}
|
||||
|
||||
/// The screen the window is on: when the window opens, and again as a session ends (on
|
||||
/// Wayland a window is on a screen only once it has been shown).
|
||||
fn display(&self, _display: &Display) {}
|
||||
|
||||
/// The game crashed, once, before its report is saved in `crash-reports/`. Called on the
|
||||
/// thread that crashed, whatever the mode (a dedicated server too).
|
||||
fn crashed(&self, _crash: &Crash<'_>) {}
|
||||
|
||||
/// Whether this crate runs the benchmark `--benchmark SCENARIO` names. Asked while the command
|
||||
/// line is read; a scenario no hook knows is refused there.
|
||||
fn knows_benchmark(&self, _scenario: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Starts the benchmark [`Hooks::knows_benchmark`] accepted, once the title screen loads: it
|
||||
/// drives the game through `control` on a thread of its own and quits the game when done.
|
||||
/// Only the first hook that knows the scenario is called.
|
||||
fn benchmark(&self, _run: &Benchmark<'_>, _control: Control) {}
|
||||
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
|
||||
/// offers one is used. Asked on the network thread when joining and on the game thread when
|
||||
/// an address is shown, never per frame.
|
||||
@@ -50,6 +91,129 @@ pub trait Hooks: Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// One command line into the game's agent control (docs/agent-control.md), answered with its
|
||||
/// JSON reply, as `horizon-ctl` would send it. `None`: the game has exited, or gave no reply
|
||||
/// within the time given.
|
||||
pub type Control = Box<dyn Fn(&str, Duration) -> Option<String> + Send>;
|
||||
|
||||
/// What [`Hooks::benchmark`] gets.
|
||||
pub struct Benchmark<'a> {
|
||||
pub scenario: &'a str,
|
||||
/// When the process started.
|
||||
pub started: Instant,
|
||||
/// The game's home folder (logs, crash reports, telemetry).
|
||||
pub home: &'a Path,
|
||||
/// The game folder (saves, options).
|
||||
pub game_dir: &'a Path,
|
||||
/// The release, as the game's log names it: a nightly's label, the tag, or the crate version
|
||||
/// for a build from source.
|
||||
pub version: &'a str,
|
||||
/// As [`Crash::version`].
|
||||
pub build: &'a str,
|
||||
pub simulation_distance: u32,
|
||||
pub offscreen: bool,
|
||||
}
|
||||
|
||||
/// The graphics adapter the renderer runs on.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Gpu {
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
/// `integrated`, `discrete`, `virtual`, `cpu` or `other`.
|
||||
pub kind: String,
|
||||
pub driver: String,
|
||||
/// `metal`, `vulkan`, `dx12` or `gl`.
|
||||
pub backend: String,
|
||||
}
|
||||
|
||||
/// The screen a window is on ([`Hooks::display`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Display {
|
||||
/// Physical pixels.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// `None` where the OS doesn't say.
|
||||
pub refresh_hz: Option<u32>,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// What [`Hooks::crashed`] gets: the crash report's parts.
|
||||
pub struct Crash<'a> {
|
||||
/// The game's home folder.
|
||||
pub home: &'a Path,
|
||||
/// The build: the release (`v0.2.3`), or `dev <crate version>` for a build from source.
|
||||
pub version: &'a str,
|
||||
/// The error's message.
|
||||
pub error: &'a str,
|
||||
/// Where it happened and how it got there: one frame a line.
|
||||
pub stack: &'a str,
|
||||
pub thread: &'a str,
|
||||
/// The report's details (`Horizon Version`, `World`, `Server`...), as written in it.
|
||||
pub details: &'a [(String, String)],
|
||||
/// The renderer's adapter, once it has one.
|
||||
pub gpu: Option<&'a Gpu>,
|
||||
}
|
||||
|
||||
/// A moment of the game's start ([`Hooks::startup`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Milestone {
|
||||
/// The resources the menus need are loaded.
|
||||
CoreReady,
|
||||
/// The rest (what a world needs) too.
|
||||
RestReady,
|
||||
/// The title screen drew its first frame.
|
||||
TitleShown,
|
||||
}
|
||||
|
||||
impl Milestone {
|
||||
fn bit(self) -> u8 {
|
||||
match self {
|
||||
Self::CoreReady => 1,
|
||||
Self::RestReady => 2,
|
||||
Self::TitleShown => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Hooks::frame`] gets.
|
||||
pub struct Frame<'a> {
|
||||
/// How long the frame took.
|
||||
pub ms: f32,
|
||||
slowest_step: &'a dyn Fn() -> Option<(String, f32)>,
|
||||
}
|
||||
|
||||
impl<'a> Frame<'a> {
|
||||
/// `slowest_step` gives [`Frame::slowest_step`], only when asked.
|
||||
pub fn new(ms: f32, slowest_step: &'a dyn Fn() -> Option<(String, f32)>) -> Self {
|
||||
Self { ms, slowest_step }
|
||||
}
|
||||
|
||||
/// The step the frame spent the most time in (`render/terrain`) and its milliseconds, when
|
||||
/// the steps were timed ([`Hooks::wants_frame_steps`]). Worked out on each call: ask only
|
||||
/// for a slow frame.
|
||||
pub fn slowest_step(&self) -> Option<(String, f32)> {
|
||||
(self.slowest_step)()
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Hooks::world_left`] gets: the game's home folder, the build, the adapter and the
|
||||
/// settings played with.
|
||||
pub struct WorldLeft<'a> {
|
||||
pub home: &'a Path,
|
||||
/// As [`Crash::version`].
|
||||
pub version: &'a str,
|
||||
/// The renderer's adapter.
|
||||
pub gpu: Option<&'a Gpu>,
|
||||
pub render_distance: i32,
|
||||
pub simulation_distance: i32,
|
||||
/// `None`: unlimited.
|
||||
pub max_fps: Option<u32>,
|
||||
pub vsync: bool,
|
||||
pub fullscreen: bool,
|
||||
/// The graphics preset's name.
|
||||
pub graphics: &'a str,
|
||||
}
|
||||
|
||||
/// What [`Hooks::started`] gets.
|
||||
#[non_exhaustive]
|
||||
pub struct Started<'a> {
|
||||
@@ -130,6 +294,72 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The milestones already said.
|
||||
static MILESTONES: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// [`Hooks::startup`], the first time `milestone` is reached; later calls do nothing.
|
||||
pub fn startup(milestone: Milestone, since_start: Duration) {
|
||||
if MILESTONES.fetch_or(milestone.bit(), Ordering::Relaxed) & milestone.bit() == 0 {
|
||||
for hooks in all() {
|
||||
hooks.startup(milestone, since_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::wants_frame_steps`]: whether any hook wants them.
|
||||
pub fn wants_frame_steps(home: &Path) -> bool {
|
||||
all().iter().any(|h| h.wants_frame_steps(home))
|
||||
}
|
||||
|
||||
/// [`Hooks::world_shown`].
|
||||
pub fn world_shown(since_opening: Duration) {
|
||||
for hooks in all() {
|
||||
hooks.world_shown(since_opening);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::frame`].
|
||||
pub fn frame(frame: &Frame<'_>) {
|
||||
for hooks in all() {
|
||||
hooks.frame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::world_left`].
|
||||
pub fn world_left(left: &WorldLeft<'_>) {
|
||||
for hooks in all() {
|
||||
hooks.world_left(left);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::display`].
|
||||
pub fn display(display: &Display) {
|
||||
for hooks in all() {
|
||||
hooks.display(display);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::crashed`].
|
||||
pub fn crashed(crash: &Crash<'_>) {
|
||||
for hooks in all() {
|
||||
hooks.crashed(crash);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::knows_benchmark`]: whether any hook does.
|
||||
pub fn knows_benchmark(scenario: &str) -> bool {
|
||||
all().iter().any(|h| h.knows_benchmark(scenario))
|
||||
}
|
||||
|
||||
/// [`Hooks::benchmark`]: starts the run on the first hook that knows the scenario. False when
|
||||
/// none does.
|
||||
pub fn benchmark(run: &Benchmark<'_>, control: Control) -> bool {
|
||||
let Some(hooks) = all().iter().find(|h| h.knows_benchmark(run.scenario)) else {
|
||||
return false;
|
||||
};
|
||||
hooks.benchmark(run, control);
|
||||
true
|
||||
}
|
||||
/// [`Hooks::tunnel`]: the first registered hook's resolver.
|
||||
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
|
||||
all().iter().find_map(|hooks| hooks.tunnel())
|
||||
@@ -155,6 +385,34 @@ mod tests {
|
||||
fn published(&self, open: bool) {
|
||||
self.0.lock().unwrap().push(format!("published {open}"));
|
||||
}
|
||||
fn startup(&self, milestone: Milestone, since_start: Duration) {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("{milestone:?} {}", since_start.as_millis()));
|
||||
}
|
||||
fn wants_frame_steps(&self, _home: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn knows_benchmark(&self, scenario: &str) -> bool {
|
||||
scenario == "ocean"
|
||||
}
|
||||
fn benchmark(&self, run: &Benchmark<'_>, control: Control) {
|
||||
let reply = control("status", Duration::from_secs(1)).unwrap_or_default();
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("benchmark {} {reply}", run.scenario));
|
||||
}
|
||||
fn frame(&self, frame: &Frame<'_>) {
|
||||
let step = frame
|
||||
.slowest_step()
|
||||
.map(|(name, ms)| format!(" {name} {ms}"));
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("frame {}{}", frame.ms, step.unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements nothing: the defaults do nothing.
|
||||
@@ -172,10 +430,40 @@ mod tests {
|
||||
for open in [false, true, true, true, false, false] {
|
||||
engine::published(open);
|
||||
}
|
||||
// Each milestone once: the first time it is reached.
|
||||
engine::startup(Milestone::CoreReady, Duration::from_millis(900));
|
||||
engine::startup(Milestone::TitleShown, Duration::from_millis(1500));
|
||||
engine::startup(Milestone::CoreReady, Duration::from_millis(4000));
|
||||
// Any hook that wants the steps timed has them.
|
||||
assert!(engine::wants_frame_steps(Path::new("home")));
|
||||
engine::frame(&Frame::new(70.0, &|| Some(("render/terrain".into(), 50.0))));
|
||||
// A benchmark runs on the hook that knows it, and on none when nobody does.
|
||||
let run = |scenario| Benchmark {
|
||||
scenario,
|
||||
started: Instant::now(),
|
||||
home: Path::new("home"),
|
||||
game_dir: Path::new("game"),
|
||||
version: "v0.2.31",
|
||||
build: "v0.2.31",
|
||||
simulation_distance: 12,
|
||||
offscreen: true,
|
||||
};
|
||||
let control = || -> Control { Box::new(|line, _| Some(format!("{{{line}}}"))) };
|
||||
assert!(engine::knows_benchmark("ocean") && !engine::knows_benchmark("nether"));
|
||||
assert!(engine::benchmark(&run("ocean"), control()));
|
||||
assert!(!engine::benchmark(&run("nether"), control()));
|
||||
engine::shutdown();
|
||||
assert_eq!(
|
||||
*RECORDER.0.lock().unwrap(),
|
||||
["started 0.2.31", "published true", "published false"]
|
||||
[
|
||||
"started 0.2.31",
|
||||
"published true",
|
||||
"published false",
|
||||
"CoreReady 900",
|
||||
"TitleShown 1500",
|
||||
"frame 70 render/terrain 50",
|
||||
"benchmark ocean {status}",
|
||||
]
|
||||
);
|
||||
assert_eq!(register(&Silent), Err(TooLate));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ horizon-game = { path = "../horizon-game" }
|
||||
horizon-platform = { path = "../horizon-platform" }
|
||||
horizon-heartbeat = { path = "../horizon-heartbeat" }
|
||||
horizon-friends = { path = "../horizon-friends", features = ["game"] }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["game"] }
|
||||
horizon-bench = { path = "../horizon-bench", features = ["game"] }
|
||||
log = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
use horizon_platform::hooks::{self, Hooks};
|
||||
|
||||
/// Every Horizon part in the game, in the order its hooks run.
|
||||
const PARTS: [&dyn Hooks; 2] = [
|
||||
const PARTS: [&dyn Hooks; 4] = [
|
||||
// The anonymous "I'm playing" ping.
|
||||
&horizon_heartbeat::HOOKS,
|
||||
// Play with Friends: `friend:` addresses join through its tunnel.
|
||||
&horizon_friends::game::HOOKS,
|
||||
// Opt-in telemetry: the play session's summary and crash reports, kept for the launcher.
|
||||
&horizon_telemetry::game::HOOKS,
|
||||
// `--benchmark SCENARIO`: the built-in benchmark (docs/benchmark.md).
|
||||
&horizon_bench::game::HOOKS,
|
||||
];
|
||||
|
||||
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
|
||||
|
||||
@@ -13,7 +13,7 @@ regex = { version = "1", default-features = false, features = ["std", "unicode-p
|
||||
|
||||
[dev-dependencies]
|
||||
# `cpu_seconds` for cem_expr_bench.
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -101,7 +101,7 @@ fn main() {
|
||||
let mut slots = vec![0.0f32; NAMES.len()];
|
||||
let frames = 2_000_000u32;
|
||||
let pid = std::process::id();
|
||||
let cpu = horizon_bench::cpu_seconds(pid);
|
||||
let cpu = horizon_system::cpu_seconds(pid);
|
||||
let start = Instant::now();
|
||||
for frame in 0..frames {
|
||||
// An entity walking in and out of water.
|
||||
@@ -125,7 +125,7 @@ fn main() {
|
||||
evals / elapsed / 1e6,
|
||||
elapsed * 1e6 / f64::from(frames),
|
||||
);
|
||||
match cpu.zip(horizon_bench::cpu_seconds(pid)) {
|
||||
match cpu.zip(horizon_system::cpu_seconds(pid)) {
|
||||
Some((before, after)) => println!(
|
||||
" cpu time {:.1} ns per expression, {:.2} us per entity frame",
|
||||
(after - before) * 1e9 / evals,
|
||||
|
||||
@@ -46,7 +46,10 @@ pub use horizon_server_files as server_files;
|
||||
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
|
||||
/// in"). The engine's dispatch side is not part of the API.
|
||||
pub mod hooks {
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, tunnel};
|
||||
pub use horizon_hooks::{
|
||||
Benchmark, Control, Crash, Display, Frame, Gpu, Hooks, Milestone, Started, TooLate,
|
||||
WorldLeft, register, tunnel,
|
||||
};
|
||||
}
|
||||
|
||||
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
|
||||
|
||||
@@ -229,7 +229,8 @@ fn os_version() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn cpu() -> Option<String> {
|
||||
/// The CPU's name (`Apple M4 Pro`, `AMD Ryzen 7 7700X 8-Core Processor`).
|
||||
pub fn cpu() -> Option<String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
read("/proc/cpuinfo").and_then(|t| linux_cpu_name(&t))
|
||||
} else if cfg!(target_os = "macos") {
|
||||
@@ -281,7 +282,8 @@ fn processor_name_from_reg_query(query: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
/// Installed memory, in bytes.
|
||||
pub fn ram_bytes() -> Option<u64> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let kib = read("/proc/meminfo").and_then(|t| field(&t, "MemTotal"))?;
|
||||
kib.split_whitespace()
|
||||
@@ -740,6 +742,64 @@ impl Machine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
command_output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return command_output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = command_output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
/// The GPU's maker from its name and the vendor ID wgpu reports (0: none, as on Metal).
|
||||
pub fn gpu_vendor(name: &str, vendor_id: u32) -> &'static str {
|
||||
match vendor_id {
|
||||
@@ -1005,4 +1065,13 @@ mod tests {
|
||||
let dir = std::env::temp_dir();
|
||||
assert!(["", "nvme", "ssd", "hdd"].contains(&Machine::detect_in(Some(&dir)).storage));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_process_is_measured() {
|
||||
if cfg!(any(target_os = "macos", target_os = "linux")) {
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,16 @@ ruzstd = "0.9"
|
||||
getrandom = "0.3"
|
||||
# Sending, for the launcher (and the game's "I'm playing" ping); the ingest server builds without it.
|
||||
ureq = { version = "2", default-features = false, features = ["tls"], optional = true }
|
||||
log = { version = "0.4", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["upload", "live"]
|
||||
upload = ["dep:ureq"]
|
||||
# The "I'm playing" ping and reading the live count (`live`), for the game and the launcher.
|
||||
live = ["dep:ureq"]
|
||||
# The game's part (`game`): the play session's summary and crash reports, kept through the
|
||||
# platform's hooks. The official executable turns it on.
|
||||
game = ["dep:log"]
|
||||
|
||||
[[test]]
|
||||
name = "consent_gating"
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
//! The game's part in opt-in data collection (`horizon_telemetry`, docs/telemetry.md). When the
|
||||
//! player allows it, the game keeps a scrubbed crash report, or a play session's performance
|
||||
//! The game's part in opt-in data collection (docs/telemetry.md), behind the `game` feature. When
|
||||
//! the player allows it, the game keeps a scrubbed crash report, or a play session's performance
|
||||
//! summary, in the outbox. It never sends those itself: the launcher does, at its next start (the
|
||||
//! game's only sending is the anonymous "I'm playing" ping, `heartbeat.rs`).
|
||||
//! Without the player's yes for that kind nothing is kept, and crash-reports/ stays local.
|
||||
//! game's only sending is the anonymous "I'm playing" ping, `horizon-heartbeat`). Without the
|
||||
//! player's yes for that kind nothing is kept, and crash-reports/ stays local.
|
||||
//!
|
||||
//! It hears the game only through the platform's hooks ([`HOOKS`], `horizon_platform::hooks`);
|
||||
//! the official executable registers it (`horizon_official::PARTS`).
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Instant;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use horizon_telemetry::payload::{Crash, Display, Perf, Settings, System, Timings};
|
||||
use horizon_telemetry::scrub::Scrubber;
|
||||
use horizon_telemetry::{Item, Payload, Source, outbox};
|
||||
use horizon_platform::hooks::{self, Frame, Hooks, Milestone, WorldLeft};
|
||||
|
||||
use crate::payload::{Crash, Display, Perf, Settings, System, Timings};
|
||||
use crate::scrub::Scrubber;
|
||||
use crate::{Item, Payload, Source, outbox};
|
||||
|
||||
/// A session shorter than this says too little to keep.
|
||||
const MIN_SECONDS: f32 = 60.0;
|
||||
@@ -19,85 +24,115 @@ const MIN_SECONDS: f32 = 60.0;
|
||||
/// world, the server or the resource packs.
|
||||
const CRASH_DETAILS: [&str; 3] = ["Horizon Version", "Graphics Adapter", "Render Settings"];
|
||||
|
||||
static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||
static CORE_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
/// The screen the window opened on.
|
||||
static DISPLAY: Mutex<Option<Display>> = Mutex::new(None);
|
||||
|
||||
/// When the game started, for the time to the title screen. Call first thing in `main`.
|
||||
pub fn mark_start() {
|
||||
STARTED.get_or_init(Instant::now);
|
||||
/// The game's hooks, for `horizon_platform::hooks::register`.
|
||||
pub static HOOKS: Telemetry = Telemetry {
|
||||
session: LazyLock::new(|| Mutex::new(Session::default())),
|
||||
};
|
||||
|
||||
/// The play session's performance, as the game's hooks tell it.
|
||||
pub struct Telemetry {
|
||||
session: LazyLock<Mutex<Session>>,
|
||||
}
|
||||
|
||||
pub fn mark_core_ready() {
|
||||
mark_ready(&CORE_READY_MS);
|
||||
}
|
||||
|
||||
pub fn mark_rest_ready() {
|
||||
mark_ready(&REST_READY_MS);
|
||||
}
|
||||
|
||||
fn mark_ready(mark: &AtomicU32) {
|
||||
if let Some(started) = STARTED.get() {
|
||||
let ms = started.elapsed().as_millis().min(u128::from(u32::MAX - 1)) as u32;
|
||||
let _ = mark.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
|
||||
impl Telemetry {
|
||||
fn session(&self) -> std::sync::MutexGuard<'_, Session> {
|
||||
self.session.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
impl Hooks for Telemetry {
|
||||
fn startup(&self, milestone: Milestone, since_start: Duration) {
|
||||
match milestone {
|
||||
Milestone::CoreReady => mark_ready(&CORE_READY_MS, since_start),
|
||||
Milestone::RestReady => mark_ready(&REST_READY_MS, since_start),
|
||||
Milestone::TitleShown => self.session().title_shown(since_start),
|
||||
}
|
||||
}
|
||||
|
||||
/// A player who allows performance statistics also says what their slow frames spent their
|
||||
/// time in.
|
||||
fn wants_frame_steps(&self, home: &Path) -> bool {
|
||||
crate::Consent::load(home).allows(crate::Kind::Perf)
|
||||
}
|
||||
|
||||
fn world_shown(&self, since_opening: Duration) {
|
||||
self.session().world_shown(since_opening);
|
||||
}
|
||||
|
||||
fn frame(&self, frame: &Frame<'_>) {
|
||||
self.session().frame(frame.ms, || frame.slowest_step());
|
||||
}
|
||||
|
||||
fn world_left(&self, left: &WorldLeft<'_>) {
|
||||
let settings = Settings {
|
||||
render_distance: left.render_distance.clamp(0, 255) as u8,
|
||||
simulation_distance: left.simulation_distance.clamp(0, 255) as u8,
|
||||
max_fps: left
|
||||
.max_fps
|
||||
.map_or(0, |f| f.min(u32::from(u16::MAX)) as u16),
|
||||
vsync: left.vsync,
|
||||
fullscreen: left.fullscreen,
|
||||
graphics: left.graphics.to_owned(),
|
||||
};
|
||||
self.session()
|
||||
.finish(left.home, left.version, left.gpu, settings);
|
||||
}
|
||||
|
||||
fn display(&self, display: &hooks::Display) {
|
||||
*DISPLAY.lock().unwrap_or_else(|e| e.into_inner()) = Some(Display {
|
||||
width: display.width,
|
||||
height: display.height,
|
||||
refresh_hz: display.refresh_hz.unwrap_or(0),
|
||||
scale: display.scale as f32,
|
||||
});
|
||||
}
|
||||
|
||||
fn crashed(&self, crash: &hooks::Crash<'_>) {
|
||||
keep_crash(crash);
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_ready(mark: &AtomicU32, since_start: Duration) {
|
||||
let ms = since_start.as_millis().min(u128::from(u32::MAX - 1)) as u32;
|
||||
let _ = mark.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn readiness_ms(mark: &AtomicU32) -> Option<u32> {
|
||||
let ms = mark.load(Ordering::Relaxed);
|
||||
(ms != u32::MAX).then_some(ms)
|
||||
}
|
||||
|
||||
/// The screen the window is on: size in physical pixels, refresh rate, scale.
|
||||
pub fn set_display(width: u32, height: u32, refresh_hz: u32, scale: f64) {
|
||||
*DISPLAY.lock().unwrap_or_else(|e| e.into_inner()) = Some(Display {
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
scale: scale as f32,
|
||||
});
|
||||
}
|
||||
|
||||
/// Keeps the screen `window` is on ([`crate::window::display`]) for the items' `system.display`.
|
||||
pub fn note_display(window: &winit::window::Window) {
|
||||
if let Some(d) = crate::window::display(window) {
|
||||
set_display(d.width, d.height, d.refresh_hz.unwrap_or(0), d.scale);
|
||||
}
|
||||
}
|
||||
|
||||
/// This machine, with the GPU and the screen once the renderer knows them. The drive is the one
|
||||
/// This machine, with the GPU once the renderer has one and the screen. The drive is the one
|
||||
/// under the game folder (the current folder).
|
||||
pub fn system() -> System {
|
||||
pub fn system(gpu: Option<&hooks::Gpu>) -> System {
|
||||
let home = std::env::current_dir().ok();
|
||||
let mut system = System::detect_in(home.as_deref());
|
||||
if let Some(gpu) = crate::gfx::adapter() {
|
||||
if let Some(gpu) = gpu {
|
||||
system.vram_mb = match gpu.kind.as_str() {
|
||||
"discrete" => horizon_telemetry::system::vram_mb(&gpu.vendor).unwrap_or(0),
|
||||
"discrete" => crate::system::vram_mb(&gpu.vendor).unwrap_or(0),
|
||||
_ => 0,
|
||||
};
|
||||
system.gpu = gpu.name;
|
||||
system.gpu_vendor = gpu.vendor;
|
||||
system.gpu_kind = gpu.kind;
|
||||
system.driver = gpu.driver;
|
||||
system.backend = gpu.backend;
|
||||
system.gpu = gpu.name.clone();
|
||||
system.gpu_vendor = gpu.vendor.clone();
|
||||
system.gpu_kind = gpu.kind.clone();
|
||||
system.driver = gpu.driver.clone();
|
||||
system.backend = gpu.backend.clone();
|
||||
}
|
||||
system.display = DISPLAY.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
system
|
||||
}
|
||||
|
||||
/// The release (`v0.2.3`), or the client's crate version for a build from source.
|
||||
pub fn version() -> String {
|
||||
crate::crash::release_tag().unwrap_or_else(|| format!("dev {}", env!("CARGO_PKG_VERSION")))
|
||||
}
|
||||
|
||||
/// What a crash report becomes when it may leave: the error and stack, the thread, and the
|
||||
/// details in [`CRASH_DETAILS`], with the world's name and folder and the server's address
|
||||
/// scrubbed wherever they appear. No log lines.
|
||||
pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
pub fn crash_item(report: &hooks::Crash<'_>) -> Item {
|
||||
let mut scrubber = Scrubber::new();
|
||||
for (key, value) in &report.details {
|
||||
for (key, value) in report.details {
|
||||
match key.as_str() {
|
||||
// `Name (saves/folder)`, as game.rs sets it.
|
||||
"World" => {
|
||||
@@ -112,10 +147,10 @@ pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
}
|
||||
}
|
||||
let crash = Crash {
|
||||
system: system(),
|
||||
error: scrubber.scrub(&report.error),
|
||||
stack: scrubber.scrub(&report.stack),
|
||||
thread: report.thread.clone(),
|
||||
system: system(report.gpu),
|
||||
error: scrubber.scrub(report.error),
|
||||
stack: scrubber.scrub(report.stack),
|
||||
thread: report.thread.to_owned(),
|
||||
details: report
|
||||
.details
|
||||
.iter()
|
||||
@@ -123,7 +158,7 @@ pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
.map(|(k, v)| (k.clone(), scrubber.scrub(v)))
|
||||
.collect(),
|
||||
};
|
||||
let mut item = Item::new(Source::Game, version(), Payload::Crash(crash));
|
||||
let mut item = Item::new(Source::Game, report.version, Payload::Crash(crash));
|
||||
// A huge stack keeps its top, where the error is.
|
||||
while item.validate().is_err() {
|
||||
let Payload::Crash(crash) = &mut item.payload else {
|
||||
@@ -145,26 +180,12 @@ pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
}
|
||||
|
||||
/// Keeps the crash for the launcher to send, if the player allows crash reports.
|
||||
pub fn keep_crash(home: &Path, report: &crate::crash::Report) {
|
||||
if let Err(e) = outbox::push(home, &crash_item(report)) {
|
||||
pub fn keep_crash(report: &hooks::Crash<'_>) {
|
||||
if let Err(e) = outbox::push(report.home, &crash_item(report)) {
|
||||
log::warn!("Horizon: could not keep the crash report to send: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps a benchmark report (`--benchmark`) for the launcher to send, if the player allows
|
||||
/// benchmark uploads: the report as docs/benchmark-report.md defines it, dated to the day.
|
||||
pub fn keep_benchmark(home: &Path, report: &horizon_bench::Report) {
|
||||
let Ok(data) = serde_json::to_value(report.for_upload()) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = outbox::push(
|
||||
home,
|
||||
&Item::new(Source::Game, version(), Payload::Benchmark(data)),
|
||||
) {
|
||||
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One play session's performance, from entering a world to leaving it: frame and tick
|
||||
/// times as [`Timings`] (fixed size whatever the length), never the frames themselves.
|
||||
#[derive(Default)]
|
||||
@@ -176,42 +197,33 @@ pub struct Session {
|
||||
/// The start was measured: only the first session carries it.
|
||||
title_measured: bool,
|
||||
to_world_ms: Option<u32>,
|
||||
opening: Option<Instant>,
|
||||
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
|
||||
slow_frames: Vec<crate::payload::SlowFrames>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The title screen drew a frame: the first time, how long the start took.
|
||||
pub fn title_shown(&mut self) {
|
||||
/// The title screen drew its first frame, `since_start` after the game started.
|
||||
pub fn title_shown(&mut self, since_start: Duration) {
|
||||
if !self.title_measured {
|
||||
self.title_measured = true;
|
||||
crate::spans::startup::mark("title.shown");
|
||||
// The frame itself: the menu bundle can be ready seconds before
|
||||
// the renderer that draws it (pipelines compiling on a slow driver).
|
||||
self.to_title_ms = STARTED.get().map(|s| s.elapsed().as_millis() as u32);
|
||||
self.to_title_ms = Some(since_start.as_millis().min(u128::from(u32::MAX)) as u32);
|
||||
}
|
||||
}
|
||||
|
||||
/// A world starts loading.
|
||||
pub fn world_opening(&mut self) {
|
||||
if self.opening.is_none() {
|
||||
crate::spans::startup::arm("world.opening");
|
||||
/// A world's first frame, `since_opening` after it started opening: the session's first
|
||||
/// world counts.
|
||||
pub fn world_shown(&mut self, since_opening: Duration) {
|
||||
if self.to_world_ms.is_none() {
|
||||
self.to_world_ms = Some(since_opening.as_millis().min(u128::from(u32::MAX)) as u32);
|
||||
}
|
||||
self.opening.get_or_insert_with(Instant::now);
|
||||
}
|
||||
|
||||
/// An entry queued during startup was cancelled before opening a world.
|
||||
pub fn cancel_world_opening(&mut self) {
|
||||
self.opening = None;
|
||||
}
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
|
||||
/// recorded in it ([`crate::spans::take_raw`]): a frame of `SLOW_FRAME_MS` or more counts
|
||||
/// under the step it spent most of its time in ([`crate::spans::culprit`]).
|
||||
pub fn frame(&mut self, ms: f32, spans: &[crate::spans::Span]) {
|
||||
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
|
||||
/// A frame drawn while playing (not in menus or loading): a frame of `SLOW_FRAME_MS` or
|
||||
/// more counts under the step it spent most of its time in (`slowest_step`, the game's
|
||||
/// `spans::culprit`).
|
||||
pub fn frame(&mut self, ms: f32, slowest_step: impl FnOnce() -> Option<(String, f32)>) {
|
||||
use crate::payload::{SLOW_FRAME_MS, SlowFrames};
|
||||
if ms >= SLOW_FRAME_MS {
|
||||
let (mut path, span_ms) = crate::spans::culprit(spans).unwrap_or_default();
|
||||
let (mut path, span_ms) = slowest_step().unwrap_or_default();
|
||||
// A path too long for the item keeps its last steps.
|
||||
while path.len() > SlowFrames::MAX_NAME
|
||||
&& let Some((_, rest)) = path.split_once('/')
|
||||
@@ -220,12 +232,6 @@ impl Session {
|
||||
}
|
||||
SlowFrames::record(&mut self.slow_frames, ms, &path, span_ms);
|
||||
}
|
||||
if let Some(opening) = self.opening.take() {
|
||||
crate::spans::startup::mark("world.first_frame");
|
||||
if self.to_world_ms.is_none() {
|
||||
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
|
||||
}
|
||||
}
|
||||
self.frames.record(ms);
|
||||
self.played_ms += ms;
|
||||
}
|
||||
@@ -236,10 +242,10 @@ impl Session {
|
||||
}
|
||||
|
||||
/// The session's summary, if it was long enough to say something.
|
||||
pub fn summary(&self, settings: Settings) -> Option<Perf> {
|
||||
pub fn summary(&self, gpu: Option<&hooks::Gpu>, settings: Settings) -> Option<Perf> {
|
||||
let seconds = self.played_ms / 1000.0;
|
||||
(seconds >= MIN_SECONDS).then(|| Perf {
|
||||
system: system(),
|
||||
system: system(gpu),
|
||||
settings,
|
||||
seconds: seconds as u32,
|
||||
frames: self.frames.summary(),
|
||||
@@ -255,11 +261,17 @@ impl Session {
|
||||
|
||||
/// Keeps the session's summary for the launcher to send, if the player allows performance
|
||||
/// statistics, and starts the next session (the time to the title goes with the first).
|
||||
pub fn finish(&mut self, home: &Path, settings: Settings) {
|
||||
if horizon_telemetry::Consent::load(home).allows(horizon_telemetry::Kind::Perf)
|
||||
&& let Some(perf) = self.summary(settings)
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
home: &Path,
|
||||
version: &str,
|
||||
gpu: Option<&hooks::Gpu>,
|
||||
settings: Settings,
|
||||
) {
|
||||
if crate::Consent::load(home).allows(crate::Kind::Perf)
|
||||
&& let Some(perf) = self.summary(gpu, settings)
|
||||
{
|
||||
let item = Item::new(Source::Game, version(), Payload::Perf(perf));
|
||||
let item = Item::new(Source::Game, version, Payload::Perf(perf));
|
||||
if let Err(e) = outbox::push(home, &item) {
|
||||
log::warn!("Horizon: could not keep the session's performance summary: {e}");
|
||||
}
|
||||
@@ -304,7 +316,7 @@ fn peak_ram_mb() -> u32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_telemetry::{Choices, Consent, Kind};
|
||||
use crate::{Choices, Consent, Kind};
|
||||
|
||||
fn home(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
@@ -322,16 +334,33 @@ mod tests {
|
||||
consent.save(home).unwrap();
|
||||
}
|
||||
|
||||
fn report() -> crate::crash::Report {
|
||||
crate::crash::Report {
|
||||
time: chrono::Local::now(),
|
||||
description: "Unexpected error".into(),
|
||||
/// A crash report's parts, as `crash.rs` gives them to the hooks.
|
||||
struct Report {
|
||||
error: String,
|
||||
stack: String,
|
||||
details: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn crash<'a>(&'a self, home: &'a Path) -> hooks::Crash<'a> {
|
||||
hooks::Crash {
|
||||
home,
|
||||
version: "v2026.10.15",
|
||||
error: &self.error,
|
||||
stack: &self.stack,
|
||||
thread: "Render thread",
|
||||
details: &self.details,
|
||||
gpu: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report() -> Report {
|
||||
Report {
|
||||
error:
|
||||
"panicked at src/save.rs:10:5:\nno level.dat in /Users/ana/horizon/saves/Ana's Base"
|
||||
.into(),
|
||||
stack: "horizon_client::save::load (crates/horizon-client/src/save.rs:10:5)\n".into(),
|
||||
thread: "Render thread".into(),
|
||||
side: "client",
|
||||
details: vec![
|
||||
(
|
||||
"Horizon Version".into(),
|
||||
@@ -345,13 +374,13 @@ mod tests {
|
||||
("Server".into(), "play.example.net:25565".into()),
|
||||
("Resource Packs".into(), "Ana's pack".into()),
|
||||
],
|
||||
log: vec!["[CHAT] <Ana> hi".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crash_keeps_only_what_says_what_happened() {
|
||||
let item = crash_item(&report());
|
||||
let report = report();
|
||||
let item = crash_item(&report.crash(Path::new("home")));
|
||||
let Payload::Crash(crash) = &item.payload else {
|
||||
panic!("a crash item");
|
||||
};
|
||||
@@ -367,13 +396,7 @@ mod tests {
|
||||
)]
|
||||
);
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
for private in [
|
||||
"Castle Ana",
|
||||
"Ana's Base",
|
||||
"example.net",
|
||||
"Ana's pack",
|
||||
"CHAT",
|
||||
] {
|
||||
for private in ["Castle Ana", "Ana's Base", "example.net", "Ana's pack"] {
|
||||
assert!(!json.contains(private), "{private} in {json}");
|
||||
}
|
||||
assert!(item.validate().is_ok());
|
||||
@@ -384,7 +407,7 @@ mod tests {
|
||||
let mut report = report();
|
||||
report.stack =
|
||||
"horizon_client::game::deep (crates/horizon-client/src/game.rs:1:1)\n".repeat(2000);
|
||||
let item = crash_item(&report);
|
||||
let item = crash_item(&report.crash(Path::new("home")));
|
||||
assert!(item.validate().is_ok());
|
||||
let Payload::Crash(crash) = item.payload else {
|
||||
panic!("a crash item");
|
||||
@@ -392,43 +415,11 @@ mod tests {
|
||||
assert!(crash.stack.lines().count() > 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn benchmark_reports_need_their_own_consent_and_go_dated_to_the_day() {
|
||||
let home = home("benchmark");
|
||||
let mut report = horizon_bench::Report::new(horizon_bench::Kind::Game, Default::default());
|
||||
report.created = "2026-10-15T14:03:27Z".into();
|
||||
report.label = Some("my machine".into());
|
||||
keep_benchmark(&home, &report);
|
||||
allow(
|
||||
&home,
|
||||
Choices {
|
||||
performance: true,
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_benchmark(&home, &report);
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(
|
||||
&home,
|
||||
Choices {
|
||||
benchmarks: true,
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_benchmark(&home, &report);
|
||||
let pending = outbox::pending(&home);
|
||||
assert_eq!(pending.len(), 1);
|
||||
let text = serde_json::to_string(&pending[0].1).unwrap();
|
||||
assert!(
|
||||
text.contains("2026-10-15T00:00:00Z") && !text.contains("my machine"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashes_stay_local_without_consent() {
|
||||
let home = home("crash");
|
||||
keep_crash(&home, &report());
|
||||
let report = report();
|
||||
keep_crash(&report.crash(&home));
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(
|
||||
&home,
|
||||
@@ -437,7 +428,7 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_crash(&home, &report());
|
||||
keep_crash(&report.crash(&home));
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(
|
||||
&home,
|
||||
@@ -446,33 +437,38 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_crash(&home, &report());
|
||||
keep_crash(&report.crash(&home));
|
||||
let pending = outbox::pending(&home);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].1.kind(), Kind::Crash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_startup_entry_does_not_time_menu_browsing() {
|
||||
fn the_time_to_a_world_is_the_sessions_first() {
|
||||
let mut session = Session::default();
|
||||
session.world_opening();
|
||||
assert!(session.opening.is_some());
|
||||
session.cancel_world_opening();
|
||||
assert!(session.opening.is_none());
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_none());
|
||||
session.world_opening();
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_some());
|
||||
session.world_shown(Duration::from_millis(4200));
|
||||
session.world_shown(Duration::from_millis(900));
|
||||
assert_eq!(session.to_world_ms, Some(4200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_time_to_the_title_goes_with_the_first_session_only() {
|
||||
let home = home("title");
|
||||
let mut session = Session::default();
|
||||
session.title_shown(Duration::from_millis(3100));
|
||||
assert_eq!(session.to_title_ms, Some(3100));
|
||||
session.finish(&home, "dev", None, Settings::default());
|
||||
session.title_shown(Duration::from_millis(9000));
|
||||
assert_eq!(session.to_title_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_is_kept_only_with_consent_and_once_long_enough() {
|
||||
let home = home("session");
|
||||
let mut session = Session::default();
|
||||
session.world_opening();
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
session.world_shown(Duration::from_millis(1500));
|
||||
(0..2000).for_each(|_| session.frame(16.6, || None));
|
||||
session.finish(&home, "dev", None, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
|
||||
allow(
|
||||
&home,
|
||||
@@ -481,14 +477,16 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
(0..2000).for_each(|_| session.frame(16.6, || None));
|
||||
session.finish(&home, "dev", None, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
|
||||
session.world_opening();
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.world_shown(Duration::from_millis(1500));
|
||||
(0..4000).for_each(|_| session.frame(16.6, || None));
|
||||
(0..1200).for_each(|_| session.tick(4.0));
|
||||
session.finish(
|
||||
&home,
|
||||
"dev",
|
||||
None,
|
||||
Settings {
|
||||
render_distance: 12,
|
||||
..Settings::default()
|
||||
@@ -511,21 +509,15 @@ mod tests {
|
||||
/// span counts under none. Faster frames leave no entry.
|
||||
#[test]
|
||||
fn slow_frames_are_put_down_to_their_step() {
|
||||
use crate::spans::Span;
|
||||
let span = |name, ms, depth| Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth,
|
||||
};
|
||||
// What the game's `spans::culprit` names for each frame.
|
||||
let step = |path: &'static str, ms: f32| move || Some((path.to_owned(), ms));
|
||||
let mut session = Session::default();
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)]));
|
||||
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
|
||||
session.frame(320.0, &frozen);
|
||||
session.frame(900.0, &frozen);
|
||||
session.frame(260.0, &[]);
|
||||
session.frame(249.0, &frozen);
|
||||
let perf = session.summary(Settings::default()).unwrap();
|
||||
(0..4000).for_each(|_| session.frame(16.6, step("tick", 10.0)));
|
||||
session.frame(320.0, step("renderer/r_uploads", 280.0));
|
||||
session.frame(900.0, step("renderer/r_uploads", 280.0));
|
||||
session.frame(260.0, || None);
|
||||
session.frame(249.0, step("renderer/r_uploads", 280.0));
|
||||
let perf = session.summary(None, Settings::default()).unwrap();
|
||||
let seen: Vec<_> = perf
|
||||
.slow_frames
|
||||
.iter()
|
||||
@@ -15,6 +15,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub mod batch;
|
||||
pub mod consent;
|
||||
#[cfg(feature = "game")]
|
||||
pub mod game;
|
||||
pub mod live;
|
||||
pub mod outbox;
|
||||
pub mod payload;
|
||||
|
||||
@@ -65,7 +65,7 @@ command above to inspect the selection rather than relying on historical counts.
|
||||
| 36 | Audio and music (38) | `R/audio.rs`, `R/sound_engine.rs`, `R/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle |
|
||||
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) |
|
||||
| 38 | Command parsing (34) | `C/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); `cargo test -p horizon-command` | command_parse (corpus `tools/command_parse_corpus.py`) |
|
||||
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `L/telemetry.rs`, `tools/perf_bench.py`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
|
||||
| 39 | Benchmarks and telemetry (34) | `crates/horizon-bench/src/game.rs`, `crates/horizon-telemetry/src/game.rs`, `tools/perf_bench.py`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
|
||||
| 40 | Explosions and TNT (31) | `G/explosion.rs`, `G/tnt.rs` | lan_e2e blast; no dedicated harness yet |
|
||||
|
||||
Controller glyphs (`R/pad_glyph.rs`, drawn by the gameplay guide, notices, splitscreen prompts and declared
|
||||
|
||||
@@ -42,6 +42,8 @@ horizon-client --benchmark standard --render-distance 16
|
||||
|
||||
It starts at the title screen, creates the world `horizon-benchmark` (seed 1234, creative; the next run replaces it, and it never touches a world it didn't create), flies the four scenes as above, writes a report to `benchmarks/` in the game folder and quits. `--benchmark village` (or `forest`, `ocean`, `mountain`) flies one scene. For the run only, VSync is off, the frame rate unlimited and the game doesn't pause or slow down without focus; your options are not changed. Your other settings (render distance unless given, graphics, window size) are used as they are, and stored in the report, so compare reports with the same conditions.
|
||||
|
||||
The run is a part of the official build (`horizon-official`, which the releases ship as `horizon-client`; [docs/layers.md](layers.md)). From source: `cargo run --release -p horizon-official -- --benchmark standard`; horizon-client's own executable and horizon-plain refuse `--benchmark`.
|
||||
|
||||
The report is the [benchmark report format](benchmark-report.md): startup to the title screen and to in-world, each scene's chunk load time, chunks generated and section meshes built, every flight's frame figures and histogram, resident and GPU memory, the binary's size, and the machine (OS, CPU, RAM, GPU, driver, backend, display), the load average before the run and whether it ran on battery. If you allowed benchmark uploads (the launcher's data collection choices, [docs/telemetry.md](telemetry.md)), the same report, dated to the day and without local fields, is kept for the launcher to send; otherwise it stays on your machine.
|
||||
|
||||
## Reproduce it
|
||||
|
||||
+13
-6
@@ -288,12 +288,19 @@ build pays nothing.
|
||||
|
||||
The hooks today: `started` (once, when someone plays: not a dedicated server, a benchmark or an automated run),
|
||||
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
|
||||
world saved) and `tunnel` (the resolver of `friend:` addresses, Play with Friends'). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
|
||||
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
|
||||
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
|
||||
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
|
||||
that belongs to one subsystem (an input source, the compositor) lives in the engine crate that owns it, with the
|
||||
same shape: registered before the game runs, read-only after.
|
||||
world saved), and the play session's: `startup` (core-ready, rest-ready, the title's first frame, once each),
|
||||
`wants_frame_steps` (asked once: time the render thread's steps), `world_shown` (a world's first frame), `frame`
|
||||
(every frame played), `world_left` (the session's end, with its settings), `display` (the screen the window is on)
|
||||
`crashed` (each crash, before its report is saved), and `knows_benchmark` and `benchmark` (`--benchmark SCENARIO`:
|
||||
the part that knows the scenario drives the game through horizon-ctl's command queue); and `tunnel` (the resolver
|
||||
of `friend:` addresses, Play with Friends'). Opt-in telemetry (`horizon_telemetry::game`) and the benchmark
|
||||
(`horizon_bench::game`), both registered by `horizon-official`, hear the game through these only. **A hook you need
|
||||
and don't find:** add a method with an empty default to `Hooks` and a function in `horizon_hooks::engine` that the
|
||||
engine calls where vanilla does the matching thing; give it plain data or a type the engine owns, never one from
|
||||
above. A hook that needs engine types the platform doesn't expose yet (a draw context for HUD and chat overlays, a
|
||||
settings page) comes with that type's platform API (M6). An extension point that belongs to one subsystem (an input
|
||||
source, the compositor) lives in the engine crate that owns it, with the same shape: registered before the game
|
||||
runs, read-only after.
|
||||
|
||||
**What players get.** The release builds (`game-build.yml`, so the nightly and the release; `tools/ci/build-musl.sh`)
|
||||
build `horizon-official` and ship it as `horizon-client`, the name the launcher and players know. The launcher's
|
||||
|
||||
@@ -172,6 +172,8 @@ class PrReadyTests(unittest.TestCase):
|
||||
self.assertEqual(pr_ready.mod_features(["horizon-client"]), "")
|
||||
self.assertEqual(pr_ready.mod_features(["horizon-mods"]),
|
||||
" --features horizon-mods/index")
|
||||
self.assertEqual(pr_ready.mod_features(["horizon-bench", "horizon-telemetry"]),
|
||||
" --features horizon-telemetry/game,horizon-bench/game")
|
||||
self.assertEqual(pr_ready.mod_features(None), MODS)
|
||||
|
||||
def test_mod_api_data_inputs_build_and_test_the_consuming_crates(self):
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
},
|
||||
"allowed": [
|
||||
"horizon-client -> horizon-anticheat",
|
||||
"horizon-client -> horizon-bench",
|
||||
"horizon-client -> horizon-chat-filter",
|
||||
"horizon-client -> horizon-cosmetics",
|
||||
"horizon-client -> horizon-dualsense",
|
||||
@@ -96,7 +95,6 @@
|
||||
"horizon-client -> horizon-theme",
|
||||
"horizon-client-input -> horizon-theme",
|
||||
"horizon-client-lib -> horizon-anticheat",
|
||||
"horizon-client-lib -> horizon-bench",
|
||||
"horizon-client-lib -> horizon-chat-filter",
|
||||
"horizon-client-lib -> horizon-hdr",
|
||||
"horizon-client-lib -> horizon-mcp",
|
||||
|
||||
+9
-2
@@ -333,10 +333,17 @@ def mark_old_rust(steps, warning):
|
||||
steps.insert(steps.index(clippy[0]), rust)
|
||||
|
||||
|
||||
# The features another workspace package turns on for these crates: horizon-mods' index, and the game parts
|
||||
# horizon-official runs (telemetry's and the benchmark's `game`).
|
||||
WORKSPACE_FEATURES = {"horizon-mods": "horizon-mods/index", "horizon-telemetry": "horizon-telemetry/game",
|
||||
"horizon-bench": "horizon-bench/game"}
|
||||
|
||||
|
||||
def mod_features(crates):
|
||||
"""The features CI's workspace-wide test run gives these crates. Cargo unifies features over the packages it
|
||||
builds, so a crate tested alone needs the ones the workspace would have turned on for it: horizon-mods' index."""
|
||||
return " --features horizon-mods/index" if crates is not None and "horizon-mods" in crates else ""
|
||||
builds, so a crate tested alone needs the ones the workspace would have turned on for it (WORKSPACE_FEATURES)."""
|
||||
features = [f for crate, f in WORKSPACE_FEATURES.items() if crates is not None and crate in crates]
|
||||
return f" --features {','.join(features)}" if features else ""
|
||||
|
||||
|
||||
# What horizon-client's full content-root tests load (mod_content_tests.rs). They take minutes on prepared data, so
|
||||
|
||||
Reference in new issue
Block a user