Compare commits

..
Author SHA1 Message Date
Ludovic Marie a5a72e59dc Merge branch 'split/telemetry-game' into split/telemetry-bench 2026-11-09 20:46:35 +01:00
Ludovic Marie 3e5616805a Merge branch 'split/telemetry-game' into split/telemetry-bench 2026-11-09 20:38:55 +01:00
Horizon agent b3476f07eb Merge branch 'split/telemetry-game' into split/telemetry-bench
# Conflicts:
#	Cargo.lock
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
#	docs/layers.md
2026-11-09 20:06:59 +01:00
Ludovic Marie 4347a72fe2 Merge branch 'split/telemetry-game' into split/telemetry-bench 2026-11-09 19:43:05 +01:00
Horizon agentandClaude Opus 5.5 cf9c6e9fe0 Cargo.lock: horizon-bench's game part, no engine crate uses horizon-bench
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:40:33 +01:00
Horizon agent 19365846c3 Merge branch 'split/telemetry-game' into split/telemetry-bench
# Conflicts:
#	crates/horizon-bench/src/game.rs
2026-11-09 19:26:44 +01:00
Ludovic Marie a4b9e36ba6 Merge branch 'split/telemetry-game' into split/telemetry-bench 2026-11-09 19:12:04 +01:00
Horizon agent f2fde617b0 Merge branch 'split/telemetry-game' into split/telemetry-bench
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 19:11:33 +01:00
Ludovic Marie bfcf02d4ff Merge branch 'split/telemetry-game' into split/telemetry-bench 2026-11-09 19:07:50 +01:00
Ludovic Marie 59fc7c9870 Merge branch 'split/telemetry-game' into split/telemetry-bench 2026-11-09 18:59:58 +01:00
Horizon agentandClaude Opus 5.5 b05298bec0 The --benchmark driver leaves the engine for horizon-bench (layer plan M5)
The engine offers two hooks, knows_benchmark (asked while the command line is
read) and benchmark (started at the title with a line-based control into the
game's command queue); horizon_bench::game implements them and
horizon-official registers it. horizon-client and horizon-client-lib no longer
depend on horizon-bench. pr_ready tests a crate alone with the features
horizon-official turns on for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:35 +01:00
Ludovic Marie 28a200a0e7 Merge remote-tracking branch 'origin/split/telemetry-probes' into split/telemetry-bench 2026-11-09 18:45:16 +01:00
Horizon agentandClaude Opus 5.5 bb29b70b32 Cargo.lock: horizon-system for horizon-client and horizon-optifine
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:23:16 +01:00
Horizon agentandClaude Opus 5.5 3ad8a18a7b horizon-system: the process probes leave horizon-bench (layer plan M5)
rss_bytes, cpu_seconds and load_average move to horizon-system (engine);
horizon-bench re-exports them for the launcher. horizon-system's cpu() and
ram_bytes() become public and replace horizon-bench's identical copies.
The dedicated server's performance report and cem_expr_bench read them
there, so gameplay no longer uses horizon-bench for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:14:20 +01:00
24 changed files with 324 additions and 212 deletions

No files matched your search

Generated
+5 -3
View File
@@ -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,6 +1794,7 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
@@ -1806,8 +1808,8 @@ dependencies = [
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-system",
"log",
"regex",
]
+9 -1
View File
@@ -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]);
}
keep_for_upload(&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
});
@@ -470,16 +502,12 @@ 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, report: &Report) {
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,
crate::crash::build(),
Payload::Benchmark(data),
);
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}");
}
@@ -489,6 +517,25 @@ fn keep_for_upload(home: &Path, report: &Report) {
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};
@@ -506,18 +553,18 @@ mod tests {
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, &report);
keep_for_upload(&home, "0.2.31", &report);
allow(Choices {
performance: true,
..Choices::NONE
});
keep_for_upload(&home, &report);
keep_for_upload(&home, "0.2.31", &report);
assert!(outbox::pending(&home).is_empty());
allow(Choices {
benchmarks: true,
..Choices::NONE
});
keep_for_upload(&home, &report);
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();
@@ -579,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,
@@ -611,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)
+3 -117
View File
@@ -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);
}
}
+5 -1
View File
@@ -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;
+1 -2
View File
@@ -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.
+4 -3
View File
@@ -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.
-1
View File
@@ -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;
@@ -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"
)
@@ -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))?;
+76 -1
View File
@@ -26,7 +26,7 @@ pub mod tunnel;
use registry::Registry;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::time::Duration;
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
@@ -72,6 +72,17 @@ pub trait Hooks: Sync {
/// 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.
@@ -80,6 +91,29 @@ 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 {
@@ -311,6 +345,21 @@ pub mod engine {
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())
@@ -345,6 +394,16 @@ mod tests {
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()
@@ -378,6 +437,21 @@ mod tests {
// 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(),
@@ -388,6 +462,7 @@ mod tests {
"CoreReady 900",
"TitleShown 1500",
"frame 70 render/terrain 50",
"benchmark ocean {status}",
]
);
assert_eq!(register(&Silent), Err(TooLate));
+1
View File
@@ -16,6 +16,7 @@ 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]
+3 -1
View File
@@ -11,13 +11,15 @@
use horizon_platform::hooks::{self, Hooks};
/// Every Horizon part in the game, in the order its hooks run.
const PARTS: [&dyn Hooks; 3] = [
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
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -47,7 +47,8 @@ pub use horizon_server_files as server_files;
/// in"). The engine's dispatch side is not part of the API.
pub mod hooks {
pub use horizon_hooks::{
Crash, Display, Frame, Gpu, Hooks, Milestone, Started, TooLate, WorldLeft, register, tunnel,
Benchmark, Control, Crash, Display, Frame, Gpu, Hooks, Milestone, Started, TooLate,
WorldLeft, register, tunnel,
};
}
+71 -2
View File
@@ -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);
}
}
}
+1 -1
View File
@@ -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`, `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) |
| 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
+2
View File
@@ -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
+10 -8
View File
@@ -291,14 +291,16 @@ The hooks today: `started` (once, when someone plays: not a dedicated server, a
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)
and `crashed` (each crash, before its report is saved); and `tunnel` (the resolver of `friend:` addresses, Play
with Friends'). Opt-in telemetry (`horizon_telemetry::game`, registered by `horizon-official`) hears 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.
`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
+2
View File
@@ -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):
-2
View File
@@ -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
View File
@@ -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