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 1a86761edf Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 20:46:34 +01:00
Horizon agentandClaude Opus 5.5 60e38c57b5 crash::gpu instead of gfx::gpu: gfx.rs is over 3000 lines and may not grow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:46:33 +01:00
Ludovic Marie 3e5616805a Merge branch 'split/telemetry-game' into split/telemetry-bench 2026-11-09 20:38:55 +01:00
Ludovic Marie 320186c089 Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 20:38:53 +01:00
Ludovic Marie 4ffac208e1 Merge branch 'split/telemetry-hooks' into split/telemetry-crash 2026-11-09 20:38:52 +01:00
Horizon agent 40f82d0e99 Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-telemetry-server/Cargo.lock
2026-11-09 20:38:46 +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
Horizon agent 8d9b80c420 Merge branch 'split/telemetry-crash' into split/telemetry-game
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-official/Cargo.toml
#	crates/horizon-official/src/lib.rs
#	docs/layers.md
2026-11-09 20:06:36 +01:00
Horizon agent 2b3b2fe7e5 Merge branch 'split/telemetry-hooks' into split/telemetry-crash
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
#	docs/layers.md
2026-11-09 20:06:14 +01:00
Horizon agent 7433b49bef Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
#	docs/layers.md
2026-11-09 20:05:34 +01:00
Ludovic Marie 4347a72fe2 Merge branch 'split/telemetry-game' into split/telemetry-bench 2026-11-09 19:43:05 +01:00
Horizon agent edeb6978b9 Merge branch 'split/telemetry-crash' into split/telemetry-game
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:43:02 +01:00
Ludovic Marie 5176dc5a75 Merge branch 'split/telemetry-hooks' into split/telemetry-crash 2026-11-09 19:42:47 +01:00
Horizon agent 9f5ed29593 Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:42:41 +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
Horizon agentandClaude Opus 5.5 afdfc8bfa1 rustfmt; Cargo.lock: horizon-official uses horizon-telemetry's game part
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:26:20 +01:00
Ludovic Marie 13c366a85b Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 19:26:19 +01:00
Ludovic Marie 763bc981f0 Merge branch 'split/telemetry-hooks' into split/telemetry-crash 2026-11-09 19:26:18 +01:00
Horizon agentandClaude Opus 5.5 b7bf458117 horizon-telemetry-server's Cargo.lock: horizon-platform uses horizon-core (stale on main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:26:15 +01:00
Ludovic Marie a4b9e36ba6 Merge branch 'split/telemetry-game' into split/telemetry-bench 2026-11-09 19:12:04 +01:00
Ludovic Marie 74f52d0782 Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 19:12:04 +01:00
Horizon agentandClaude Opus 5.5 23f0abf4d2 horizon-client-render: one horizon-hooks dependency after main's settings store
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:11:56 +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 4958be7ce0 Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 19:11:19 +01:00
Ludovic Marie 9d70e670c8 Merge branch 'split/telemetry-hooks' into split/telemetry-crash 2026-11-09 19:11:12 +01:00
Horizon agent 5062adfb0c Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 19:11:07 +01:00
Ludovic Marie bfcf02d4ff Merge branch 'split/telemetry-game' into split/telemetry-bench 2026-11-09 19:07:50 +01:00
Horizon agentandClaude Opus 5.5 195ecc898e Telemetry test: the slow step's name is 'static
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:06:18 +01:00
Ludovic Marie 59fc7c9870 Merge branch 'split/telemetry-game' into split/telemetry-bench 2026-11-09 18:59:58 +01:00
Horizon agent 7aa6f0fda0 Merge branch 'split/telemetry-crash' into split/telemetry-game
# Conflicts:
#	crates/horizon-telemetry/src/game.rs
2026-11-09 18:59:06 +01:00
Horizon agentandClaude Opus 5.5 63408fc7f0 rustfmt; Cargo.lock: horizon-client-render uses horizon-hooks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:17 +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 agent 5aca6c42ee Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 18:39:07 +01:00
Horizon agent 4abfa15d97 Merge branch 'split/telemetry-hooks' into split/telemetry-crash 2026-11-09 18:39:06 +01:00
Horizon agentandClaude Opus 5.5 37bdbf9def spans::startup::since_start in the startup module; rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:39:04 +01:00
Horizon agentandClaude Opus 5.5 dd6ad03eea Telemetry's game part leaves the engine for horizon-telemetry (layer plan M5)
telemetry.rs moves to horizon_telemetry::game (feature "game"), using only
the platform's hooks, and horizon-official registers it among its PARTS;
horizon-client's entry no longer registers it. The benchmark upload
(keep_benchmark) stays with the in-game benchmark as benchmark.rs's
keep_for_upload, with its test, until the benchmark leaves too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:27:20 +01:00
Horizon agentandClaude Opus 5.5 67d691b1e3 Telemetry hears crashes and the screen through the hooks (layer plan M5)
horizon-hooks gains crashed (the report's parts, the build and the GPU) and
display (the screen the window is on), and WorldLeft carries the build and
the GPU. crash.rs tells the hooks directly, replacing M3's on_report list
(telemetry was its only user); gfx::gpu() and crash::build() give the plain
data. telemetry.rs no longer reads the renderer, the crash module or a winit
window: everything it needs arrives through its hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:24:34 +01:00
Horizon agent 9a0d252abf Merge branch 'split/telemetry-display' into split/telemetry-crash 2026-11-09 18:22:36 +01:00
Horizon agentandClaude Opus 5.5 55b78015fe docs/layers.md: the hooks paragraph rewrapped
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:59:57 +01:00
Horizon agentandClaude Opus 5.5 cb1198a38d Telemetry's hooks register only with horizon-extras
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:59:46 +01:00
Horizon agentandClaude Opus 5.5 17b50d7959 Telemetry's play session hears the game through the hooks (layer plan M5)
horizon-hooks gains the play session's hooks: startup (core-ready, rest-ready,
the title's first frame, once each), wants_frame_steps, world_shown, frame and
world_left, with plain data (Frame, WorldLeft, Milestone). The engine's
play_session::Session marks the startup trace and dispatches them; Game and
the startup menu hold it instead of telemetry's Session.

telemetry.rs implements them (HOOKS, registered by entry.rs like the ping):
the consent check for frame steps, the settings' encoding and the start's
timings move there, so gameplay no longer names horizon_telemetry for the
perf session.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:59:39 +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
68 changed files with 1753 additions and 2253 deletions

No files matched your search

Generated
+8 -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",
@@ -1449,6 +1450,7 @@ dependencies = [
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
@@ -1460,6 +1462,7 @@ dependencies = [
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1521,7 +1524,6 @@ dependencies = [
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
@@ -1792,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",
]
@@ -1804,8 +1808,8 @@ dependencies = [
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-system",
"log",
"regex",
]
@@ -1969,6 +1973,7 @@ dependencies = [
"getrandom 0.3.4",
"horizon-addresses",
"horizon-platform",
"log",
"ruzstd",
"serde",
"serde_json",
+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]);
}
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)
+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.
+2 -5
View File
@@ -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`).
@@ -76,10 +77,6 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
# the variable.
horizon-extras = []
[lints]
workspace = true
+2 -2
View File
@@ -382,8 +382,8 @@ impl RenderAssets {
};
// After the connected tiles: a tile identical to a block shares its layer, and the
// overlays skip shared layers.
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
if draws {
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
}
sprites.save_disk_cache();
if draws {
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
}
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
let rel = format!("assets/{namespace}/{directory}/{path}.png");
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
let stays = rel
.split('/')
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
root.join(if stays { &rel } else { NOWHERE })
root.join(if horizon_optifine::paths::valid(&rel) {
&rel
} else {
NOWHERE
})
}
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
+32 -20
View File
@@ -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) => {
@@ -112,11 +112,7 @@ fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
};
Row::new(id, lang::get(label), value)
}
added::Row::Button {
label,
active: true,
..
} => Row::new(id, lang::get(label), Value::Press),
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
added::Row::Key { label, key } => {
let key = crate::keybinds::display_name(&key).plain();
Row::new(id, lang::get(label), Value::Read(key))
@@ -494,7 +490,9 @@ mod tests {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::button("test.elsewhere.button"));
rows.push(added::Row::Button {
label: "test.elsewhere.button",
});
}
fn act(&self, _: usize, _: Act) {}
}
@@ -1,79 +0,0 @@
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
//! extra, docs/layers.md), the device is opened exactly as before.
/// The ray query feature.
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
fn requested() -> bool {
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
}
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
/// build.
fn asked(var: Option<&str>) -> bool {
cfg!(feature = "horizon-extras") && var == Some("1")
}
/// The features to request on `adapter`: ray queries, when asked for and offered.
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
let features = pick(requested(), adapter.features());
if requested() {
let info = adapter.get_info();
if features.is_empty() {
log::warn!(
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
queries; it stays off",
info.name,
info.backend
);
} else {
log::info!(
"Horizon renderer: ray queries on ({} on {:?})",
info.name,
info.backend
);
}
}
features
}
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
if requested {
offered & RAY_QUERY
} else {
wgpu::Features::empty()
}
}
/// The device's opt-in to `features`' experimental ones.
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
if features.contains(RAY_QUERY) {
horizon_hdr::experimental_features()
} else {
wgpu::ExperimentalFeatures::disabled()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_an_extras_build_asks_for_ray_tracing() {
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
assert!(!asked(None));
assert!(!asked(Some("0")));
}
#[test]
fn ray_queries_only_when_asked_and_offered() {
let all = wgpu::Features::all();
assert_eq!(pick(true, all), RAY_QUERY);
assert!(pick(false, all).is_empty());
assert!(pick(true, all - RAY_QUERY).is_empty());
assert!(!experimental(wgpu::Features::empty()).is_enabled());
}
}
@@ -5,9 +5,6 @@ use super::{
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
timing,
};
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
#[path = "ray_query.rs"]
mod ray_query;
use std::sync::Arc;
use winit::window::Window;
@@ -137,7 +134,6 @@ pub(super) async fn open_adapter(
let features = timing::GpuTimer::features(adapter)
| cull::Cull::features(adapter)
| oit::features(adapter)
| ray_query::features(adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
@@ -170,7 +166,6 @@ pub(super) async fn open_adapter(
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
memory_hints: wgpu::MemoryHints::MemoryUsage,
experimental_features: ray_query::experimental(features),
..Default::default()
};
#[cfg(not(target_os = "macos"))]
@@ -1,7 +1,6 @@
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
//! terrain fragment: native mods' (`<ns>:emissive/...`) and a pack's emissive block textures
//! (OptiFine's `block/stone_e.png` beside `block/stone.png`, the suffix from the pack formats,
//! [`crate::pack_hooks`]). No extra draw calls or world light changes.
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
use crate::sprites::Sprites;
use std::{collections::BTreeMap, path::Path};
@@ -36,12 +35,14 @@ fn tile_file(name: &str) -> Option<&str> {
name.strip_suffix(".png")
}
/// A pack's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each `<ns>:block/<name>`, or connected-texture tile
/// `assets/<ns>/.../<name>.png`, whose `<name><suffix>.png` sits beside it, the overlay is loaded
/// too and paired with it, blocks first, then tiles, each by name, at most [`MAX`] - [`NATIVE`]
/// (native mods keep their share of the table). A pack without such files adds nothing.
pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(String, String)> {
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// the table). A pack without such files adds nothing.
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
let suffix = horizon_optifine::emissive::suffix(root);
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
// base would glow on every block that shares it, so it gets no overlay.
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
@@ -55,8 +56,8 @@ pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(S
sharing[layer] == 1
&& (name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(suffix)))
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
})
.map(|(name, _)| name.clone())
.collect();
@@ -109,7 +110,7 @@ pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(S
pub const CUTOUT: u32 = 1 << 31;
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_overlays`] loads),
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
pub fn table<'a>(
layers: &BTreeMap<String, u32>,
@@ -203,7 +204,7 @@ mod tests {
// Items draw from the HUD atlas, not the terrain: no pair.
sprites.insert(&root, "minecraft:item/stick").unwrap();
// glowstone isn't loaded: its overlay pairs with nothing.
let pairs = load_overlays(&root, "_e", &mut sprites);
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
[(
@@ -214,6 +215,9 @@ mod tests {
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
// The pack's own suffix.
let props = root.join("assets/minecraft/optifine/emissive.properties");
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
// Connected-texture tiles pair with the overlay beside them.
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
png(&tiles.join("0.png"), 230);
@@ -230,7 +234,7 @@ mod tests {
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
.unwrap();
}
let pairs = load_overlays(&root, "_glow", &mut sprites);
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
// Blocks first, then tiles.
@@ -162,38 +162,10 @@ impl OptionsUi {
};
let mut rows = Vec::new();
registered.rows(&mut rows);
let mut out = Vec::new();
// The row a half-width button started, waiting for the next to sit beside it.
let mut half_open = false;
for (r, row) in rows.into_iter().enumerate() {
match row {
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
added::Row::Text { text, .. } => {
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
}
added::Row::Space(_) => continue,
added::Row::Button {
label,
active,
half: true,
..
} => {
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
if !active {
half = inactive(half);
}
match out.last_mut() {
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
_ => out.push(Row::Buttons(half, None)),
}
half_open = !half_open;
continue;
}
row => out.push(self.added_row(page, r, row)),
}
half_open = false;
}
out
rows.into_iter()
.enumerate()
.map(|(r, row)| self.added_row(page, r, row))
.collect()
}
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
@@ -205,10 +177,7 @@ impl OptionsUi {
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
added::Row::Button { label, active, .. } => {
let wide = button(0.0, 0.0, 310.0, tr(label), act);
Row::Buttons(if active { wide } else { inactive(wide) }, None)
}
added::Row::Button { label } => wide(tr(label)),
added::Row::Key { label, key } => {
let mut message = keybinds::display_name(&key);
if self.selected_key == Some(PAGE_KEY)
@@ -492,84 +461,4 @@ mod tests {
Some(tr("test.sample.switch.tooltip").plain())
);
}
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
struct Shaped(Mutex<Vec<usize>>);
impl added::Page for Shaped {
fn id(&self) -> &'static str {
"test.shaped"
}
fn title(&self) -> &'static str {
"test.shaped.title"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
rows.push(added::Row::text("grey").quiet());
rows.push(added::Row::Space(6));
rows.push(added::Row::button("test.shaped.copy").half());
rows.push(added::Row::button("test.shaped.stop").half().active(false));
rows.push(added::Row::button("test.shaped.help").footer());
}
fn act(&self, row: usize, _: added::Act) {
self.0.lock().unwrap().push(row);
}
}
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
#[test]
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
let mut s = Settings::default();
TEST_PAGES.set(Some(&SHAPED_PAGES));
let ui = OptionsUi::default();
let rows = ui.added_rows(0);
// The two texts as notes (space shows nothing here), the halves on one row, the
// footer button below them (the settings shell has no footer of its own).
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
panic!("the halves sit side by side: {:?}", rows.len());
};
assert_eq!((copy.w, stop.w), (150.0, 150.0));
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
assert_eq!(rows.len(), 4);
let mut ui = ui;
ui.show(Page::Added(0));
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
let stop = widgets
.iter()
.find(|w| {
matches!(
&w.kind,
WKind::Button {
act: Act::PageRow(0, 4),
..
}
)
})
.expect("Stop is drawn");
let atlas = super::super::tests::atlas();
ui.click(
&mut s,
&atlas,
stop.x + 1.0,
stop.y + 1.0,
(480.0, 240.0),
[480, 240],
false,
false,
);
assert_eq!(
*SHAPED.0.lock().unwrap(),
[3, 5],
"the inactive half heard nothing"
);
}
}
@@ -20,10 +20,6 @@ impl PackFormats for Formats {
Some(colors::read(root))
}
fn emissive_suffix(&self, root: &Path) -> Option<String> {
Some(horizon_optifine::emissive::suffix(root))
}
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
let sky = custom_sky::CustomSky::load(root);
(!sky.is_empty()).then(|| Arc::new(sky) as _)
@@ -15,12 +15,6 @@ pub trait PackFormats: Send + Sync {
None
}
/// The name ending of the overlay's emissive textures (`stone_e.png` beside `stone.png`),
/// if its format has them.
fn emissive_suffix(&self, _root: &Path) -> Option<String> {
None
}
/// The sky layers the overlay draws over the vanilla sky, if it has any.
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
None
+7 -2
View File
@@ -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 {
+11 -6
View File
@@ -39,6 +39,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -66,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.
@@ -117,14 +120,16 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord"]
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
friends = ["dep:horizon-friends"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
+1 -2
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;
@@ -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;
+2 -8
View File
@@ -20,17 +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);
// Crates' text in the player's language (`horizon_hooks::tr`).
horizon_hooks::engine::set_language(|key| {
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
});
// 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");
+30 -21
View File
@@ -143,8 +143,6 @@ mod enderman_hurt_teleport_tests;
mod enderman_placement_tests;
mod entity;
mod entity_culling;
#[cfg(test)]
mod entity_inside_tests;
mod entity_sounds;
mod event_loop;
mod events;
@@ -162,6 +160,17 @@ mod fov;
mod foxes;
mod frame_geometry;
mod frames;
#[cfg(feature = "friends")]
mod friends;
mod held_items;
#[cfg(test)]
mod setblock_order_tests;
// Without Play with Friends: nothing shared, no Friends button (game/friends_off.rs).
#[cfg(test)]
mod entity_inside_tests;
#[cfg(not(feature = "friends"))]
#[path = "game/friends_off.rs"]
mod friends;
mod functions;
mod game_mode;
mod game_tests;
@@ -172,7 +181,6 @@ mod golems;
mod growth;
mod hand;
mod headless;
mod held_items;
mod hud_editor;
mod hud_room;
mod ime;
@@ -349,7 +357,6 @@ mod pad;
mod pad_actions;
mod pad_craft;
mod pad_menu;
mod page_screen;
mod paintings;
mod pandas;
mod particle_culling;
@@ -413,10 +420,7 @@ mod seats;
mod server_links;
mod server_packs;
mod server_travel;
#[cfg(test)]
mod setblock_order_tests;
mod shader_packs;
mod share_view;
mod sheep_dye;
mod shelf;
mod shield;
@@ -645,9 +649,9 @@ enum Screen {
Advancements,
/// WorldOptionsScreen (game/lan_screen.rs).
WorldOptions,
/// The page a crate registered at `Place::PauseFriends` (Play with Friends in the official
/// build), from the pause menu's Friends icon, drawn as its own screen (game/page_screen.rs).
AddedPage,
/// Play with Friends, from the pause menu's Friends button
/// (game/friends.rs, game/friends/screen.rs).
Friends,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
/// CommandBlockEditScreen (game/command_blocks.rs).
@@ -691,7 +695,7 @@ impl Screen {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world_options",
Screen::AddedPage => "friends",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::CommandBlock => "command_block",
Screen::BlockEditor => "block_editor",
@@ -2797,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.
@@ -2899,6 +2903,9 @@ struct Game<'a> {
level_shapes: horizon_client::chunk::LevelShapes,
/// This game is that server thread's (game/integrated.rs).
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
#[cfg(feature = "friends")]
friends: Option<friends::Sharing>,
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
social: social::Social,
world_options: Option<lan_screen::WorldOptions>,
@@ -5730,6 +5737,7 @@ impl Game<'_> {
if let Some(log) = self.world.block_log.as_mut() {
log.extend(held);
}
self.tick_friends();
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
self.announce_sleep_status();
@@ -6233,7 +6241,7 @@ impl Game<'_> {
Screen::Advancements => self.draw_advancements(&mut batch),
Screen::Mod => {}
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::AddedPage => self.draw_added_page(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::Social => self.draw_social_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
@@ -11006,8 +11014,8 @@ impl Game<'_> {
Screen::WorldOptions if button == MouseButton::Left => {
self.world_options_release()
}
Screen::AddedPage if pressed && button == MouseButton::Left => {
self.added_page_click()
Screen::Friends if pressed && button == MouseButton::Left => {
self.friends_screen_click()
}
Screen::Social if pressed && button == MouseButton::Left => {
self.social_click()
@@ -11405,7 +11413,6 @@ impl Game<'_> {
// the world's own ticks.
horizon_client::social::set_presence_status(self.presence_status());
horizon_client::social::tick();
self.share_tick();
// Minecraft.tick: ClientTickEvent.Pre before any of it.
self.native_client_tick_pre();
if self.title.is_none() && !self.pause {
@@ -12173,6 +12180,8 @@ impl<'a> Game<'a> {
test_defs_snapshot: false,
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
#[cfg(feature = "friends")]
friends: None,
social: Default::default(),
debug_keys: Default::default(),
debug_charts: Default::default(),
@@ -12626,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);
}
@@ -12655,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")]
+1 -1
View File
@@ -479,7 +479,7 @@ impl Game<'_> {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::AddedPage => "friends",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
+7 -10
View File
@@ -17,19 +17,16 @@ const PROTOCOL: &str = "0.1.0";
/// The command groups the game serves, as `help` chains them: what `hello` reports as its features.
const FEATURES: &[&str] = &[
"game", "station", "blocks", "lan", "render", "sculk", "agent", "ui", "options", "events",
"saves", "pad", "studio",
"game", "station", "blocks", "lan", "friends", "render", "sculk", "agent", "ui", "options",
"events", "saves", "pad", "studio",
];
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves
/// (the engine's, then those of the crates that answer commands, `Hooks::control_commands`).
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves.
fn hello() -> Value {
let groups = horizon_hooks::engine::control_commands().map(|(group, _)| group);
let features: Vec<&str> = FEATURES.iter().copied().chain(groups).collect();
json!({
"protocol": PROTOCOL,
"release": horizon_core::release::VERSION,
"features": features,
"features": FEATURES,
})
}
@@ -403,7 +400,7 @@ impl Game<'_> {
if let Some(value) = self.lan_control(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.hooks_control(&words)? {
if let Some(value) = self.friends_control(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.studio_control(&words)? {
@@ -420,7 +417,7 @@ impl Game<'_> {
.chain(super::station_control::HELP)
.chain(super::blockctl::HELP)
.chain(super::lan::HELP)
.chain(horizon_hooks::engine::control_commands().flat_map(|(_, help)| help))
.chain(super::friends::HELP)
.chain(super::render_control::HELP)
.chain(super::sculk::HELP)
.chain(super::agent::HELP)
@@ -1373,7 +1370,7 @@ impl Game<'_> {
Screen::Stats => "stats",
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::AddedPage => "friends",
Screen::Friends => "friends",
Screen::Social => "social",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
+1 -1
View File
@@ -123,7 +123,7 @@ fn screen_ids_are_distinct_and_stable() {
Screen::Stats,
Screen::Advancements,
Screen::WorldOptions,
Screen::AddedPage,
Screen::Friends,
Screen::Social,
Screen::CommandBlock,
Screen::BlockEditor,
@@ -523,7 +523,7 @@ fn debug_options_interrupts(screen: Screen, playing_overlay: bool) -> bool {
| Screen::EmoteWheel
| Screen::Stats
| Screen::Advancements
| Screen::AddedPage
| Screen::Friends
| Screen::Social
| Screen::ServerLinks
| Screen::ConfirmLink
@@ -584,7 +584,7 @@ mod tests {
Screen::GameModeSwitcher,
Screen::Stats,
Screen::Advancements,
Screen::AddedPage,
Screen::Friends,
Screen::Social,
Screen::ServerLinks,
Screen::ConfirmLink,
@@ -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!(
+339
View File
@@ -0,0 +1,339 @@
//! Play with friends, host side: "Start Sharing" on the Play with Friends
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
//! it isn't (with Open to LAN's defaults) and shares that port behind an
//! invite code (horizon-friends). The code holds this computer's addresses
//! and the key, so friends join with no server of ours; a relay the player
//! configured adds a short code and a fallback. The integrated server is the
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address. The joining side is horizon-friends' (`game`), registered by
//! horizon-official.
use super::{Game, lan::LanSettings};
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::time::{Duration, Instant};
mod screen;
use screen::{self as friends_screen, Action, FriendsView};
pub use screen::{TITLE, title};
pub(super) struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
opened_lan: bool,
code: Option<String>,
/// Wrong codes tried so far.
wrong: u32,
notice: Option<String>,
copied: Option<Instant>,
/// Sessions carried directly and through the relay so far.
paths: (u32, u32),
}
pub const HELP: &[&str] = &[
"friends host share the open world behind an invite code",
"friends stop stop sharing",
"friends status the code, how friends reach this host, the guests",
];
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
fn stun_servers() -> Vec<String> {
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
return stun::servers_from_env();
}
let dir = std::env::current_dir().unwrap_or_default();
match horizon_client::ui_theme::preference(&dir, "friendsStun") {
Some(list) => stun::parse_servers(&list),
None => stun::servers_from_env(),
}
}
fn env_flag(name: &str, default: bool) -> bool {
std::env::var(name).map_or(default, |v| v != "0")
}
impl Game<'_> {
/// Start sharing the open world.
pub(super) fn friends_start(&mut self) -> Result<()> {
if self.friends.is_some() {
return Ok(());
}
// Single player on its server thread shares that server, as Open to
// LAN publishes it; another server can't be shared.
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
return Err("open a singleplayer world first".into());
}
let opened_lan = self.lan_port().is_none();
if opened_lan {
// ShareToLanScreen's defaults, as the world options screen offers them.
self.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: false,
force_game_mode: true,
})?;
}
let port = self.lan_port().ok_or("the world isn't open to LAN")?;
let host = match Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
}) {
Ok(h) => h,
Err(e) => {
if opened_lan {
self.lan_close(true);
}
return Err(e.into());
}
};
self.friends = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
Ok(())
}
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
pub(super) fn friends_stop(&mut self) {
let Some(mut sharing) = self.friends.take() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
self.lan_close(true);
} else {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
)));
}
}
/// The LAN server is closing: the code goes with it.
pub(super) fn friends_lan_closed(&mut self) {
if let Some(mut sharing) = self.friends.take() {
sharing.host.stop();
}
}
/// New codes and sessions, once a tick and while the pause menu is drawn.
pub(super) fn tick_friends(&mut self) {
let Some(sharing) = self.friends.as_mut() else {
return;
};
let mut announce = false;
let mut stopped = None;
for event in sharing.host.poll() {
match event {
HostEvent::Code(code) => {
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
sharing.notice = Some(lang::get_or(
"horizon.friends.short_code_changed",
"Someone kept trying a wrong short code, so it changed.",
));
sharing.wrong = 0;
}
announce = sharing.code.is_none();
sharing.code = Some(code);
}
HostEvent::WrongCode { count } => {
sharing.wrong = count;
sharing.notice = Some(lang::get_or(
"horizon.friends.wrong_code",
"Someone tried a wrong code.",
));
}
HostEvent::Connected { direct: true, .. } => sharing.paths.0 += 1,
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
HostEvent::Disconnected { .. } => {}
HostEvent::RelayStopped => {
let text = lang::get_or(
"horizon.friends.relay_stopped",
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
);
sharing.notice = Some(text.clone());
stopped = Some(text);
}
}
}
if let Some(text) = stopped {
self.system_message(Text::literal(text));
}
if announce {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
)));
}
}
pub(super) fn friends_copy(&mut self) {
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
return;
};
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&code);
if let Some(s) = self.friends.as_mut() {
s.copied = Some(Instant::now());
}
}
/// Play with Friends. The world is paused under it in singleplayer, so
/// new codes and sessions are read while it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
self.tick_friends();
let view = self.friends_view();
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", friends_screen::TITLE)
});
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
}
pub(super) fn friends_screen_click(&mut self) {
let view = self.friends_view();
let Some(action) =
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
else {
return;
};
self.audio.play_button_click();
match action {
Action::Share => {
if let Err(e) = self.friends_start() {
self.system_message(Text::literal(
lang::get_or(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e.to_string(), 1),
));
}
}
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the folder buttons open their folders.
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_uri(url);
}
}
Action::Done => self.screen = super::Screen::Paused,
}
}
/// Whether the pause menu offers Play with Friends (a singleplayer world).
pub(super) fn friends_shown(&self) -> bool {
self.friends_view().shown
}
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = match &self.lan {
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
// On the server thread: the other players the server lists.
None if self.integrated.is_some() => {
let me = self.options.player_name();
let mut names = self.remote_online_names().unwrap_or_default();
names.retain(|n| *n != me);
names
}
None => Vec::new(),
};
let Some(s) = &self.friends else {
return FriendsView {
shown: self.remote.is_none() || self.integrated.is_some(),
..FriendsView::default()
};
};
let state = s.host.state();
let mut status = if s.code.is_none() {
lang::get_or(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
FriendsView {
shown: true,
sharing: true,
code: s.code.clone(),
status,
reachable,
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
guests,
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
}
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
use serde_json::json;
if words[0] != "friends" {
return Ok(None);
}
match words.get(1).copied() {
Some("host") => {
self.friends_start()?;
Ok(Some(json!({ "ok": true, "lan_port": self.lan_port() })))
}
Some("stop") => {
self.friends_stop();
Ok(Some(json!({ "ok": true })))
}
Some("status") => {
self.tick_friends();
let view = self.friends_view();
let state = self.friends.as_ref().map(|s| s.host.state());
Ok(Some(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": self.friends.as_ref().map_or(0, |s| s.host.sessions()),
"direct": self.friends.as_ref().map_or(0, |s| s.paths.0),
"relayed": self.friends.as_ref().map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": self.lan_port(),
})))
}
_ => Err("friends host|stop|status".into()),
}
}
}
@@ -0,0 +1,340 @@
//! Play with Friends: Horizon's own sharing of the open world behind an
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
//! a screen to vanilla: the menu background, a centred title and text,
//! vanilla's buttons and Done in the footer.
use horizon_client::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
ui,
};
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
pub const TITLE: &str = "Play with Friends";
/// A string of this screen: the language table's, or the English text.
fn tr(key: &str, english: &str) -> String {
horizon_client::lang::get_or(key, english)
}
/// The title in the current language.
pub fn title() -> String {
tr("horizon.friends.title", TITLE)
}
/// What the screen shows (game/friends.rs fills it).
#[derive(Clone, Debug, Default)]
pub struct FriendsView {
/// A singleplayer world (not a server): sharing is offered.
pub shown: bool,
pub sharing: bool,
pub code: Option<String>,
/// Who can join and what to do when friends on the internet can't (or,
/// before the code, what the game is doing).
pub status: String,
/// Friends on the internet can reach the host.
pub reachable: bool,
/// Where the troubleshooting is, when they can't.
pub help: Option<String>,
/// The players in the world besides the host.
pub guests: Vec<String>,
pub notice: Option<String>,
/// The code was just copied.
pub copied: bool,
}
/// A button's action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Share,
Copy,
Stop,
/// Open the troubleshooting page.
Help,
Done,
}
fn grey() -> u32 {
rgba(160, 160, 160, 255)
}
const WHITE: u32 = u32::MAX;
fn hint() -> String {
tr(
"horizon.friends.hint",
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
)
}
struct Button {
x: f32,
y: f32,
w: f32,
label: String,
action: Action,
active: bool,
}
/// The centred lines (text, colour, y) and the buttons.
fn layout(
atlas: &HudAtlas,
(w, h): (f32, f32),
view: &FriendsView,
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
let width = (w - 40.0).min(310.0);
let cx = (w / 2.0).floor();
let mut lines = Vec::new();
let mut y = 40.0;
let add = |lines: &mut Vec<(String, u32, f32)>, text: &str, color: u32, y: &mut f32| {
for line in wrap(text, width, atlas) {
lines.push((line, color, *y));
*y += 10.0;
}
};
let mut buttons = Vec::new();
if view.sharing {
lines.push((
tr("horizon.multiplayer.invite_code", "Invite Code"),
grey(),
y,
));
y += 12.0;
match &view.code {
Some(code) => {
for line in code_lines(atlas, code, width) {
lines.push((line, WHITE, y));
y += 10.0;
}
}
None => add(&mut lines, &view.status, WHITE, &mut y),
}
y += 6.0;
buttons.push(Button {
x: cx - 154.0,
y,
w: 150.0,
label: if view.copied {
tr("horizon.friends.copied", "Copied")
} else {
tr("horizon.friends.copy_code", "Copy Code")
},
action: Action::Copy,
active: view.code.is_some(),
});
buttons.push(Button {
x: cx + 4.0,
y,
w: 150.0,
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
action: Action::Stop,
active: true,
});
y += 30.0;
if view.code.is_some() {
// Who can join: grey when friends anywhere can, white (it asks
// for something) when only the LAN and VPNs can.
let tint = if view.reachable { grey() } else { WHITE };
add(&mut lines, &view.status, tint, &mut y);
y += 4.0;
}
let here = if view.guests.is_empty() {
tr("horizon.friends.none_yet", "No friends here yet.")
} else {
tr("horizon.friends.here_now", "Here now: %s").replacen(
"%s",
&view.guests.join(", "),
1,
)
};
add(&mut lines, &here, grey(), &mut y);
if let Some(notice) = &view.notice {
add(&mut lines, notice, WHITE, &mut y);
}
// Where friends paste the code, while it fits above the footer.
let hint = hint();
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
add(&mut lines, &hint, grey(), &mut y);
}
} else {
add(
&mut lines,
&tr(
"horizon.friends.share_intro",
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
),
WHITE,
&mut y,
);
y += 4.0;
add(&mut lines, &hint(), grey(), &mut y);
buttons.push(Button {
x: cx - 100.0,
y: y + 8.0,
w: 200.0,
label: tr("horizon.friends.start_sharing", "Start Sharing"),
action: Action::Share,
active: view.shown,
});
}
// HeaderAndFooterLayout's footer: Done, beside Troubleshooting when
// friends on the internet can't reach the host.
if view.sharing && view.help.is_some() {
buttons.push(Button {
x: cx - 154.0,
y: h - 27.0,
w: 150.0,
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
action: Action::Help,
active: true,
});
buttons.push(Button {
x: cx + 4.0,
y: h - 27.0,
w: 150.0,
label: "gui.done".to_owned(),
action: Action::Done,
active: true,
});
} else {
buttons.push(Button {
x: cx - 100.0,
y: h - 27.0,
w: 200.0,
label: "gui.done".to_owned(),
action: Action::Done,
active: true,
});
}
(lines, buttons)
}
/// The code's groups packed into lines of at most `width`.
fn code_lines(atlas: &HudAtlas, code: &str, width: f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for group in code.split('-') {
match lines.last_mut() {
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
l.push('-');
l.push_str(group);
}
_ => lines.push(group.to_owned()),
}
}
lines
}
fn label(key: &str) -> Text {
if key.starts_with("gui.") {
Text::translate(key, &[])
} else {
Text::literal(key)
}
}
pub fn draw(
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
view: &FriendsView,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let title = Text::literal(title());
let tw = atlas.font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
let (lines, buttons) = layout(atlas, (w, h), view);
for (line, color, y) in lines {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if matches!(b.action, Action::Done) {
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
}
ui::button(
batch,
atlas,
b.x,
b.y,
b.w,
20.0,
&label(&b.label),
b.active,
hover,
);
}
}
/// The button under a click, if it is active.
pub fn click(
atlas: &HudAtlas,
size: (f32, f32),
cursor: (f32, f32),
view: &FriendsView,
) -> Option<Action> {
layout(atlas, size, view)
.1
.into_iter()
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
.map(|b| b.action)
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
#[test]
fn share_then_copy_and_stop() {
let atlas = atlas();
let size = (427.0, 240.0);
let idle = FriendsView {
shown: true,
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &idle);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert_eq!(
click(&atlas, size, (share.x + 1.0, share.y + 1.0), &idle),
Some(Action::Share)
);
let sharing = FriendsView {
shown: true,
sharing: true,
status: "Finding how friends can reach you…".into(),
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &sharing);
let copy = buttons.iter().find(|b| b.action == Action::Copy).unwrap();
assert!(!copy.active, "nothing to copy before the code");
let with_code = FriendsView {
code: Some("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0".into()),
status: "Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address).".into(),
help: Some("https://example.invalid/help".into()),
..sharing
};
let (lines, buttons) = layout(&atlas, size, &with_code);
assert!(
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
"the text stays above the footer"
);
for action in [Action::Copy, Action::Stop, Action::Help, Action::Done] {
let b = buttons.iter().find(|b| b.action == action).unwrap();
assert_eq!(
click(&atlas, size, (b.x + 1.0, b.y + 1.0), &with_code),
Some(action)
);
}
}
#[test]
fn a_server_offers_no_sharing() {
let atlas = atlas();
let view = FriendsView::default();
let (_, buttons) = layout(&atlas, (427.0, 240.0), &view);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert!(!share.active);
}
}
@@ -0,0 +1,38 @@
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
use super::{Game, Screen};
use horizon_client::{Result, hud::HudBatch};
pub const HELP: &[&str] = &[];
/// The pause menu's Friends icon is vanilla's, drawn inactive: its name for the widget tree.
pub const TITLE: &str = "Play with Friends";
pub fn title() -> String {
horizon_client::lang::get_or("horizon.friends.title", TITLE)
}
impl Game<'_> {
pub(super) fn friends_lan_closed(&mut self) {}
pub(super) fn tick_friends(&mut self) {}
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
self.screen = Screen::Paused;
}
pub(super) fn friends_screen_click(&mut self) {}
pub(super) fn friends_shown(&self) -> bool {
false
}
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
if words[0] != "friends" {
return Ok(None);
}
Err("this build has no Play with Friends (the `friends` feature)".into())
}
}
+2 -2
View File
@@ -1489,7 +1489,7 @@ impl Game<'_> {
None => self.unpublish(),
};
if closed {
self.lan_closed_hooks();
self.friends_lan_closed();
}
if closed && announce && self.integrated.is_some() {
self.system_message(Text::translate(
@@ -1499,7 +1499,7 @@ impl Game<'_> {
}
return;
}
self.lan_closed_hooks();
self.friends_lan_closed();
self.native_players_leaving();
let Some(mut host) = self.lan.take() else {
return;
@@ -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))?;
@@ -1,330 +0,0 @@
//! A page drawn as its own screen (`horizon_hooks::pages::Place::PauseFriends`, what the pause
//! menu's vanilla Friends icon opens), the way a mod adds a screen to vanilla: the menu
//! background over the paused world, the title centred at the top, the rows centred down the
//! middle, footer buttons beside Done at the bottom (`HeaderAndFooterLayout`).
use super::{Game, Screen};
use horizon_client::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
ui,
};
use horizon_hooks::pages::{self, Act, Page, Place, Row};
/// What a click on the screen hit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Hit {
/// The page's row with this index.
Row(usize),
Done,
}
struct Button {
x: f32,
y: f32,
w: f32,
label: &'static str,
hit: Hit,
active: bool,
}
fn grey() -> u32 {
rgba(160, 160, 160, 255)
}
const WHITE: u32 = u32::MAX;
/// The centred lines (text, colour, y) and the buttons.
fn layout(
atlas: &HudAtlas,
(w, h): (f32, f32),
rows: &[Row],
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
let width = (w - 40.0).min(310.0);
let cx = (w / 2.0).floor();
let mut lines = Vec::new();
let mut buttons = Vec::new();
let mut footer = Vec::new();
let mut y = 40.0;
// A half-width button waiting for the next to sit beside it.
let mut half_open = false;
for (r, row) in rows.iter().enumerate() {
let beside = std::mem::take(&mut half_open);
let (text, quiet, groups) = match row {
Row::Text {
text,
quiet,
groups,
..
} => (text.clone(), *quiet, *groups),
Row::Note(text) => (text.clone(), true, false),
Row::Heading(key) => (horizon_client::lang::get(key), true, false),
Row::Space(px) => {
y += f32::from(*px);
continue;
}
Row::Button {
label,
active,
footer: true,
..
} => {
footer.push((r, *label, *active));
continue;
}
Row::Button {
label,
active,
half,
..
} => {
let (x, w) = match (*half, beside) {
(true, true) => {
y -= 30.0;
(cx + 4.0, 150.0)
}
(true, false) => {
half_open = true;
(cx - 154.0, 150.0)
}
(false, _) => (cx - 100.0, 200.0),
};
buttons.push(Button {
x,
y,
w,
label,
hit: Hit::Row(r),
active: *active,
});
y += 30.0;
continue;
}
// Settings rows belong in the options shell.
_ => continue,
};
let wrapped = if groups {
groups_lines(atlas, &text, width)
} else {
wrap(&text, width, atlas)
};
// Text that would run into the footer is left out.
if y + 10.0 * wrapped.len() as f32 > h - 33.0 {
continue;
}
let color = if quiet { grey() } else { WHITE };
for line in wrapped {
lines.push((line, color, y));
y += 10.0;
}
}
// The footer: the page's buttons, then Done; Done alone is wider.
footer.push((usize::MAX, "gui.done", true));
let (each, gap) = if footer.len() == 1 {
(200.0, 0.0)
} else {
(150.0, 8.0)
};
let total = footer.len() as f32 * (each + gap) - gap;
let mut x = cx - total / 2.0;
for (r, label, active) in footer {
let hit = if r == usize::MAX {
Hit::Done
} else {
Hit::Row(r)
};
buttons.push(Button {
x,
y: h - 27.0,
w: each,
label,
hit,
active,
});
x += each + gap;
}
(lines, buttons)
}
/// `-`-separated groups packed into lines of at most `width`, never broken inside a group.
fn groups_lines(atlas: &HudAtlas, text: &str, width: f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for group in text.split('-') {
match lines.last_mut() {
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
l.push('-');
l.push_str(group);
}
_ => lines.push(group.to_owned()),
}
}
lines
}
fn draw(batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), title: &str, rows: &[Row]) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let title = Text::translate(title, &[]);
let tw = atlas.font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
let (lines, buttons) = layout(atlas, (w, h), rows);
for (line, color, y) in lines {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if b.hit == Hit::Done {
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
}
let label = Text::translate(b.label, &[]);
ui::button(batch, atlas, b.x, b.y, b.w, 20.0, &label, b.active, hover);
}
}
/// The active button under a click.
fn click(atlas: &HudAtlas, size: (f32, f32), cursor: (f32, f32), rows: &[Row]) -> Option<Hit> {
layout(atlas, size, rows)
.1
.into_iter()
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
.map(|b| b.hit)
}
/// The page the Friends icon opens: the first registered there.
fn friends_page() -> Option<&'static dyn Page> {
pages::engine::all()
.iter()
.copied()
.find(|page| page.place() == Place::PauseFriends)
}
/// What the Friends icon and its screen are named, for its tooltip, the narrator, controller
/// focus and the control socket's widget tree: the page's title, else vanilla's tooltip.
pub(super) fn friends_name() -> String {
friends_page().map_or_else(
|| horizon_client::lang::get("gui.friends.open"),
|page| horizon_client::lang::get(page.title()),
)
}
impl Game<'_> {
/// Whether the pause menu's Friends icon is active: a page is registered for it and this
/// window plays a singleplayer world (another server's world can't be shared).
pub(super) fn friends_shown(&self) -> bool {
self.can_share() && friends_page().is_some()
}
/// The Friends icon's page. Its rows are asked each time it is drawn.
pub(super) fn draw_added_page(&mut self, batch: &mut HudBatch) {
let Some(page) = friends_page() else {
// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
self.screen = Screen::Paused;
return;
};
let mut rows = Vec::new();
page.rows(&mut rows);
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", friends_name())
});
draw(batch, &self.gui.atlas, self.cursor, page.title(), &rows);
}
pub(super) fn added_page_click(&mut self) {
let Some(page) = friends_page() else {
return;
};
let mut rows = Vec::new();
page.rows(&mut rows);
let Some(hit) = click(&self.gui.atlas, self.gui_size(), self.cursor, &rows) else {
return;
};
self.audio.play_button_click();
match hit {
Hit::Row(r) => page.act(r, Act::Press),
Hit::Done => self.screen = Screen::Paused,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
fn at(buttons: &[Button], hit: Hit) -> &Button {
buttons.iter().find(|b| b.hit == hit).unwrap()
}
#[test]
fn halves_sit_side_by_side_and_footer_buttons_beside_done() {
let atlas = atlas();
let size = (427.0, 240.0);
let rows = [
Row::text("Invite Code").quiet(),
Row::Space(2),
Row::text("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0").groups(),
Row::Space(6),
Row::button("copy").half().active(false),
Row::button("stop").half(),
Row::text("Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address)."),
Row::button("help").footer(),
];
let (lines, buttons) = layout(&atlas, size, &rows);
let (copy, stop) = (at(&buttons, Hit::Row(4)), at(&buttons, Hit::Row(5)));
// 213 is the middle: two 150-wide halves 8 apart around it, as vanilla's pairs.
assert_eq!(
(copy.x, copy.w, stop.x, stop.w),
(59.0, 150.0, 217.0, 150.0)
);
assert_eq!(copy.y, stop.y);
let (help, done) = (at(&buttons, Hit::Row(7)), at(&buttons, Hit::Done));
assert_eq!(
(help.x, done.x, help.y, done.y),
(59.0, 217.0, 213.0, 213.0)
);
// The code breaks between its groups only.
let code: Vec<_> = lines
.iter()
.filter(|(_, c, _)| *c == WHITE)
.map(|(l, ..)| l)
.collect();
assert!(code[0].starts_with("0B33K-") && code.iter().all(|l| !l.ends_with('-')));
assert!(
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
"above the footer"
);
// An inactive button takes no click; Done alone is 200 wide.
assert_eq!(
click(&atlas, size, (copy.x + 1.0, copy.y + 1.0), &rows),
None
);
assert_eq!(
click(&atlas, size, (stop.x + 1.0, stop.y + 1.0), &rows),
Some(Hit::Row(5))
);
let (_, alone) = layout(&atlas, size, &[Row::button("share")]);
assert_eq!(at(&alone, Hit::Done).w, 200.0);
assert_eq!(at(&alone, Hit::Row(0)).x, 113.0);
}
#[test]
fn text_that_would_reach_the_footer_is_left_out() {
let atlas = atlas();
let rows = [
Row::Space(150),
Row::text("first"),
Row::text("too low").quiet(),
];
let (lines, _) = layout(&atlas, (427.0, 240.0), &rows);
// 40 + 150 = 190: one line fits above 207, the next (200..210) doesn't.
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].0, "first");
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ impl Game<'_> {
self.death_confirm_answer(false)?;
}
}
Screen::Stats | Screen::AddedPage => self.screen = Screen::Paused,
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
Screen::Social => self.close_social(),
Screen::ServerLinks => self.screen = Screen::Paused,
// ConfirmScreen.keyPressed: Escape answers no; both return to
+1 -1
View File
@@ -1569,7 +1569,7 @@ impl Game<'_> {
self.left = false;
self.right = false;
// The shared world is closing: its invite code ends with it.
self.lan_closed_hooks();
self.friends_lan_closed();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -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()
+8 -9
View File
@@ -73,7 +73,7 @@ impl Screen {
| Self::Stats
| Self::Advancements
| Self::WorldOptions
| Self::AddedPage
| Self::Friends
| Self::ServerLinks
| Self::ConfirmLink
| Self::DialogWarning
@@ -92,7 +92,7 @@ impl Screen {
| Self::Stats
| Self::Advancements
| Self::WorldOptions
| Self::AddedPage
| Self::Friends
| Self::Social
| Self::ServerLinks
| Self::ConfirmLink
@@ -191,8 +191,8 @@ fn pause_buttons(
}
/// The icon row: report bugs, feedback, friends, player reporting.
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens the page registered at
/// `Place::PauseFriends` (Play with Friends in the official build); the first two open their link through
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
/// Play with Friends; the first two open their link through
/// `ConfirmLinkScreen`; player reporting opens `SocialInteractionsScreen`.
const FRIENDS_ICON: usize = 2;
/// `CommonLinks.SNAPSHOT_BUGS_FEEDBACK`, behind `PauseScreen.REPORT_BUGS`.
@@ -312,7 +312,6 @@ impl Game<'_> {
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
let friends = self.friends_shown();
let friends_name = super::page_screen::friends_name();
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -374,7 +373,7 @@ impl Game<'_> {
// The icon has no label: name it for controller focus and the
// control socket's widget tree.
if i == FRIENDS_ICON {
horizon_client::widgets::record(&friends_name, [x, y, 20.0, 20.0], active);
horizon_client::widgets::record(super::friends::TITLE, [x, y, 20.0, 20.0], active);
}
ui::button(
batch,
@@ -411,7 +410,7 @@ impl Game<'_> {
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
if hover {
tooltip = Some(if i == FRIENDS_ICON {
Text::literal(friends_name.clone())
Text::literal(super::friends::title())
} else {
Text::translate(ICON_LABELS[i], &[])
});
@@ -439,7 +438,7 @@ impl Game<'_> {
3 => self.open_stats(),
4 => self.open_advancements(true),
9 => self.open_world_options(),
10 => self.screen = Screen::AddedPage,
10 => self.screen = Screen::Friends,
// DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS).
11 => {
self.links_ui.scroll = 0.0;
@@ -994,7 +993,7 @@ mod tests {
Screen::Stats,
Screen::Advancements,
Screen::WorldOptions,
Screen::AddedPage,
Screen::Friends,
Screen::ServerLinks,
Screen::ConfirmLink,
Screen::DialogWarning,
@@ -1,109 +0,0 @@
//! The window's world as `horizon_hooks::share` shows it to the crates that share it past the
//! LAN (Play with Friends), and where the engine calls `Hooks::share` and `Hooks::lan_closed`.
use super::{Game, lan::LanSettings};
use horizon_client::text::Text;
use horizon_hooks::share::{LanOptions, ShareView};
/// The window's own player: not the integrated server's thread, not a splitscreen guest (it
/// plays P1's world and can't share it; its leaving doesn't close it).
fn shares(game: &Game<'_>) -> bool {
!game.serving() && !game.options.split_guest
}
impl Game<'_> {
/// `Hooks::share`, once a client tick (`Minecraft.runTick`, so also while paused).
pub(super) fn share_tick(&mut self) {
if shares(self) {
horizon_hooks::engine::share(&mut World(self));
}
}
/// `Hooks::lan_closed`: the world stopped being open to LAN, or the player left it.
pub(super) fn lan_closed_hooks(&self) {
if shares(self) {
horizon_hooks::engine::lan_closed();
}
}
/// `Hooks::control`: a `horizon-ctl` command a crate answers, with this window's world.
pub(super) fn hooks_control(
&mut self,
words: &[&str],
) -> horizon_client::Result<Option<serde_json::Value>> {
match horizon_hooks::engine::control(words, &mut World(self)) {
None => Ok(None),
Some(Ok(json)) => Ok(Some(serde_json::from_str(&json)?)),
Some(Err(why)) => Err(why.into()),
}
}
/// A singleplayer world runs on this window's server thread, so it can be shared, as Open
/// to LAN publishes it; another server can't be.
pub(super) fn can_share(&self) -> bool {
self.title.is_none() && (self.remote.is_none() || self.integrated.is_some())
}
}
pub(super) struct World<'a, 'g>(&'a mut Game<'g>);
impl ShareView for World<'_, '_> {
fn can_share(&self) -> bool {
self.0.can_share()
}
fn lan_port(&self) -> Option<u16> {
self.0.lan_port()
}
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String> {
self.0
.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: options.guest_commands,
force_game_mode: options.force_game_mode,
})
.map_err(|e| e.to_string())?;
self.0
.lan_port()
.ok_or_else(|| "the world isn't open to LAN".to_owned())
}
fn close_lan(&mut self) {
self.0.lan_close(true);
}
fn players(&self, each: &mut dyn FnMut(&str)) {
let g = &self.0;
if let Some(host) = &g.lan {
for guest in &host.hub.guests {
each(&guest.name);
}
} else if g.integrated.is_some() {
// On the server thread: the other players the server lists.
let me = g.options.player_name();
for name in g.remote_online_names().unwrap_or_default() {
if name != me {
each(&name);
}
}
}
}
fn chat(&mut self, text: &str) {
self.0.system_message(Text::literal(text));
}
fn copy(&mut self, text: &str) {
use horizon_client::clipboard::Clipboard;
self.0.clipboard.set(text);
}
fn open_url(&mut self, url: &str) {
// The system browser, as the folder buttons open their folders.
super::packs::open_uri(url);
}
fn tr(&self, key: &str) -> Option<String> {
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
}
}
@@ -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);
+19 -19
View File
@@ -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<()> {
+81
View File
@@ -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());
}
+3 -24
View File
@@ -1,10 +1,7 @@
//! Play with Friends in the game (the `game` feature). Joining: the engine's `friend:`
//! Play with Friends in the game, joining side (the `game` feature): the engine's `friend:`
//! addresses (`horizon_platform::hooks::tunnel`) read as invite codes, and joined through this
//! crate's tunnel. Hosting ([`share`]): the pause menu's Friends page shares the open world
//! behind an invite code. `horizon-official` registers [`HOOKS`] and [`PAGE`]; the game's own
//! executable has neither, so there no invite code joins and nothing is shared.
mod share;
//! crate's tunnel. `horizon-official` registers [`HOOKS`]; the game's own executable has none, so
//! there no invite code joins.
use crate::{
PathMode,
guest::{self, GuestConfig},
@@ -13,12 +10,10 @@ use crate::{
use horizon_platform::{
hooks::{
Hooks,
share::ShareView,
tunnel::{Opened, Progress, Resolver},
},
version::{PROTOCOL_VERSION, VANILLA_VERSION},
};
pub use share::PAGE;
use std::net::SocketAddr;
/// What `horizon-official` registers: Play with Friends' hooks.
@@ -30,22 +25,6 @@ impl Hooks for Friends {
fn tunnel(&self) -> Option<&dyn Resolver> {
Some(&Invites)
}
fn share(&self, world: &mut dyn ShareView) {
share::HOOKS.share(world);
}
fn lan_closed(&self) {
share::HOOKS.lan_closed();
}
fn control_commands(&self) -> Option<(&'static str, &'static [&'static str])> {
share::HOOKS.control_commands()
}
fn control(&self, words: &[&str], world: &mut dyn ShareView) -> Option<Result<String, String>> {
share::HOOKS.control(words, world)
}
}
struct Invites;
-790
View File
@@ -1,790 +0,0 @@
//! Play with friends, host side: "Start Sharing" on the Play with Friends
//! page (`PAGE`, what the pause menu's Friends icon opens) opens the world to LAN if
//! it isn't (with Open to LAN's defaults) and shares that port behind an
//! invite code (horizon-friends). The code holds this computer's addresses
//! and the key, so friends join with no server of ours; a relay the player
//! configured adds a short code and a fallback. The integrated server is the
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address. The joining side is [`super`]'s resolver; horizon-official registers
//! both ([`super::HOOKS`], [`PAGE`]).
//!
//! It sees the game only through the platform's hooks (the `ShareView` its `Hooks::share` tick
//! gets, `Hooks::lan_closed`, `Hooks::control` for `horizon-ctl friends`, its page, `tr`) and
//! keeps what it shares in its own statics.
use crate::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_platform::{
hooks::{
Hooks,
share::{LanOptions, ShareView},
},
pages::{Act, Page, Place, Row},
version::{PROTOCOL_VERSION, VANILLA_VERSION},
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
opened_lan: bool,
code: Option<String>,
/// Wrong codes tried so far.
wrong: u32,
notice: Option<String>,
copied: Option<Instant>,
/// Sessions carried directly and through the relay so far.
paths: (u32, u32),
}
/// The world shared, if one is. The game has one window player who can share (the engine
/// calls the hooks for it only), so one is all there is.
static SHARING: Mutex<Option<Sharing>> = Mutex::new(None);
/// Whether [`SHARING`] holds one: a tick with nothing shared is this one load.
static ON: AtomicBool = AtomicBool::new(false);
/// What the page left for the next [`Hooks::share`] tick, which has the game's view.
#[derive(Default)]
struct PageState {
/// Chat lines (new codes, the relay stopping), said in order.
chat: Vec<String>,
/// A button pressed on the page.
pressed: Option<Action>,
/// The page was drawn since the last tick: its guests are read again.
open: bool,
/// The other players, as the last tick while the page was open read them.
guests: Vec<String>,
/// The action of each row the page last showed.
acts: Vec<Option<Action>>,
}
static PAGE_STATE: Mutex<PageState> = Mutex::new(PageState {
chat: Vec::new(),
pressed: None,
open: false,
guests: Vec::new(),
acts: Vec::new(),
});
/// Whether [`PAGE_STATE`] has something for the tick.
static WORK: AtomicBool = AtomicBool::new(false);
fn page() -> MutexGuard<'static, PageState> {
PAGE_STATE.lock().unwrap_or_else(PoisonError::into_inner)
}
/// A button of the page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Action {
Share,
Copy,
Stop,
/// Open the troubleshooting page.
Help,
}
fn sharing() -> MutexGuard<'static, Option<Sharing>> {
SHARING.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Ends the sharing, if there is one. The lock is let go before the caller touches the
/// game: closing LAN calls [`Hooks::lan_closed`], which takes it again.
fn take() -> Option<Sharing> {
let taken = sharing().take();
ON.store(false, Ordering::Relaxed);
taken
}
/// The host side's hooks, which [`super::HOOKS`] passes on.
pub(super) struct Share;
pub(super) static HOOKS: Share = Share;
impl Hooks for Share {
fn control_commands(&self) -> Option<(&'static str, &'static [&'static str])> {
Some(("friends", HELP))
}
/// `horizon-ctl friends host|stop|status`.
fn control(
&self,
words: &[&str],
world: &mut dyn ShareView,
) -> Option<std::result::Result<String, String>> {
(words.first() == Some(&"friends"))
.then(|| control(words.get(1).copied(), world).map(|answer| answer.to_string()))
}
/// What the page left for the game: chat lines, a button's press, the guests while it is
/// open. With nothing left, one relaxed load.
fn share(&self, world: &mut dyn ShareView) {
if !WORK.swap(false, Ordering::Relaxed) {
return;
}
let (lines, pressed, open) = {
let mut page = page();
let open = std::mem::take(&mut page.open);
(std::mem::take(&mut page.chat), page.pressed.take(), open)
};
for line in lines {
world.chat(&line);
}
if let Some(action) = pressed {
press(action, world);
}
if open {
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
page().guests = guests;
}
}
/// The LAN server is closing: the code goes with it.
fn lan_closed(&self) {
if ON.load(Ordering::Relaxed)
&& let Some(mut sharing) = take()
{
sharing.host.stop();
}
}
}
const HELP: &[&str] = &[
"friends host share the open world behind an invite code",
"friends stop stop sharing",
"friends status the code, how friends reach this host, the guests",
];
/// A string in the player's language, or the English one.
fn tr(key: &str, english: &str) -> String {
horizon_platform::hooks::tr(key).unwrap_or_else(|| english.to_owned())
}
/// One of the player's Horizon preferences, `key:value` lines in `horizon.txt` beside
/// `options.txt` in the game folder (the working directory), where the game keeps its own.
fn preference(key: &str) -> Option<String> {
let text = std::fs::read_to_string(std::env::current_dir().ok()?.join("horizon.txt")).ok()?;
text.lines().find_map(|l| {
let (k, v) = l.split_once(':')?;
(k.trim() == key).then(|| v.trim().to_owned())
})
}
/// The relay the player set on Join a Friend (`friendsRelay`), else `HORIZON_RELAY_URL`. None
/// by default.
fn relay() -> Option<String> {
preference("friendsRelay")
.filter(|r| !r.is_empty())
.or_else(crate::relay_url)
}
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
fn stun_servers() -> Vec<String> {
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
return stun::servers_from_env();
}
match preference("friendsStun") {
Some(list) => stun::parse_servers(&list),
None => stun::servers_from_env(),
}
}
fn env_flag(name: &str, default: bool) -> bool {
std::env::var(name).map_or(default, |v| v != "0")
}
/// Start sharing the open world.
fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
if ON.load(Ordering::Relaxed) {
return Ok(());
}
if !world.can_share() {
return Err("open a singleplayer world first".into());
}
let (port, opened_lan) = match world.lan_port() {
Some(port) => (port, false),
// ShareToLanScreen's defaults, as the world options screen offers them.
None => (world.open_lan(LanOptions::default())?, true),
};
let host = Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: VANILLA_VERSION.to_owned(),
relay: relay(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
});
let host = match host {
Ok(h) => h,
Err(e) => {
if opened_lan {
world.close_lan();
}
return Err(e.to_string());
}
};
*sharing() = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
ON.store(true, Ordering::Relaxed);
Ok(())
}
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
fn stop(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(mut sharing) = take() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
world.close_lan();
} else {
let text = tr(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
);
world.chat(&text);
}
}
/// New codes and sessions, while the page is drawn and when `friends status` asks. Its chat
/// lines wait for the next tick ([`Hooks::share`]), or for [`say`].
fn poll() {
if !ON.load(Ordering::Relaxed) {
return;
}
let mut announce = false;
let mut stopped = None;
{
let mut guard = sharing();
let Some(sharing) = guard.as_mut() else {
return;
};
for event in sharing.host.poll() {
match event {
HostEvent::Code(code) => {
if sharing.code.is_some() && sharing.wrong >= crate::host::MAX_WRONG {
sharing.notice = Some(tr(
"horizon.friends.short_code_changed",
"Someone kept trying a wrong short code, so it changed.",
));
sharing.wrong = 0;
}
announce = sharing.code.is_none();
sharing.code = Some(code);
}
HostEvent::WrongCode { count } => {
sharing.wrong = count;
sharing.notice = Some(tr(
"horizon.friends.wrong_code",
"Someone tried a wrong code.",
));
}
HostEvent::Connected { direct: true, .. } => sharing.paths.0 += 1,
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
HostEvent::Disconnected { .. } => {}
HostEvent::RelayStopped => {
let text = tr(
"horizon.friends.relay_stopped",
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
);
sharing.notice = Some(text.clone());
stopped = Some(text);
}
}
}
}
let mut page = page();
page.chat.extend(stopped);
if announce {
page.chat.push(tr(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
));
}
if !page.chat.is_empty() {
WORK.store(true, Ordering::Relaxed);
}
}
/// The chat lines waiting, said now.
fn say(world: &mut dyn ShareView) {
let lines = std::mem::take(&mut page().chat);
for line in lines {
world.chat(&line);
}
}
/// A button of the page, on the tick after its press.
fn press(action: Action, world: &mut dyn ShareView) {
match action {
Action::Share => {
if let Err(e) = start(world) {
let text = tr(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e, 1);
world.chat(&text);
}
}
Action::Copy => copy(world),
Action::Stop => stop(world),
// The system browser, as the folder buttons open their folders.
Action::Help => world.open_url(reach::HELP_URL),
}
}
fn copy(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(code) = sharing().as_ref().and_then(|s| s.code.clone()) else {
return;
};
world.copy(&code);
if let Some(s) = sharing().as_mut() {
s.copied = Some(Instant::now());
}
}
/// What Play with Friends shows.
#[derive(Clone, Debug, Default)]
struct View {
sharing: bool,
code: Option<String>,
/// Who can join and what to do when friends on the internet can't (or,
/// before the code, what the game is doing).
status: String,
/// Friends on the internet can reach the host.
reachable: bool,
/// The players in the world besides the host.
guests: Vec<String>,
notice: Option<String>,
/// The code was just copied.
copied: bool,
}
/// What Play with Friends shows; `guests` is asked only while sharing.
fn view(guests: impl FnOnce() -> Vec<String>) -> View {
let guard = sharing();
let Some(s) = guard.as_ref() else {
return View::default();
};
let state = s.host.state();
let mut status = if s.code.is_none() {
tr(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
View {
sharing: true,
code: s.code.clone(),
status,
reachable,
guests: guests(),
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
}
/// The page's rows for `view`, each with the button's action: the layout today's Friends
/// screen had (the engine draws it, `game/page_screen.rs`).
fn rows(view: &View, rows: &mut Vec<Row>, acts: &mut Vec<Option<Action>>) {
let mut row = |row: Row, act: Option<Action>| {
rows.push(row);
acts.push(act);
};
let hint = || {
tr(
"horizon.friends.hint",
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
)
};
if !view.sharing {
row(
Row::text(tr(
"horizon.friends.share_intro",
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
)),
None,
);
row(Row::Space(4), None);
row(Row::text(hint()).quiet(), None);
row(Row::Space(8), None);
row(
Row::button("horizon.friends.start_sharing"),
Some(Action::Share),
);
return;
}
row(
Row::text(tr("horizon.multiplayer.invite_code", "Invite Code")).quiet(),
None,
);
row(Row::Space(2), None);
match &view.code {
Some(code) => row(Row::text(code.clone()).groups(), None),
None => row(Row::text(view.status.clone()), None),
}
row(Row::Space(6), None);
let copy = if view.copied {
"horizon.friends.copied"
} else {
"horizon.friends.copy_code"
};
row(
Row::button(copy).half().active(view.code.is_some()),
Some(Action::Copy),
);
row(
Row::button("horizon.friends.stop_sharing").half(),
Some(Action::Stop),
);
if view.code.is_some() {
// Who can join: grey when friends anywhere can, white (it asks
// for something) when only the LAN and VPNs can.
let status = Row::text(view.status.clone());
row(
if view.reachable {
status.quiet()
} else {
status
},
None,
);
row(Row::Space(4), None);
}
let here = if view.guests.is_empty() {
tr("horizon.friends.none_yet", "No friends here yet.")
} else {
tr("horizon.friends.here_now", "Here now: %s").replacen("%s", &view.guests.join(", "), 1)
};
row(Row::text(here).quiet(), None);
if let Some(notice) = &view.notice {
row(Row::text(notice.clone()), None);
}
// Where friends paste the code, while it fits above the footer.
row(Row::text(hint()).quiet(), None);
// Troubleshooting beside Done when friends on the internet can't reach the host.
if !view.reachable && view.code.is_some() {
row(
Row::button("horizon.friends.troubleshooting").footer(),
Some(Action::Help),
);
}
}
/// Play with Friends' page: what the pause menu's Friends icon opens. Its rows are read when
/// it is drawn; a press waits for the next tick, which has the game's view.
pub struct FriendsPage;
pub static PAGE: FriendsPage = FriendsPage;
impl Page for FriendsPage {
fn id(&self) -> &'static str {
"horizon_friends.share"
}
fn title(&self) -> &'static str {
"horizon.friends.title"
}
fn place(&self) -> Place {
Place::PauseFriends
}
fn rows(&self, out: &mut Vec<Row>) {
// The world is paused under the page in singleplayer, so new codes and sessions are
// read while it is drawn.
poll();
let guests = std::mem::take(&mut page().guests);
let view = view(|| guests);
let mut acts = Vec::new();
rows(&view, out, &mut acts);
let mut page = page();
page.guests = view.guests;
page.acts = acts;
page.open = true;
WORK.store(true, Ordering::Relaxed);
}
fn act(&self, row: usize, act: Act) {
let mut page = page();
if act == Act::Press
&& let Some(Some(action)) = page.acts.get(row).copied()
{
page.pressed = Some(action);
WORK.store(true, Ordering::Relaxed);
}
}
}
/// `horizon-ctl friends host|stop|status`.
fn control(
word: Option<&str>,
world: &mut dyn ShareView,
) -> std::result::Result<serde_json::Value, String> {
use serde_json::json;
match word {
Some("host") => {
start(world)?;
Ok(json!({ "ok": true, "lan_port": world.lan_port() }))
}
Some("stop") => {
stop(world);
Ok(json!({ "ok": true }))
}
Some("status") => {
// A splitscreen guest asking hears nothing of P1's sharing, and its chat stays its own.
let view = if world.can_share() {
poll();
say(world);
view(|| {
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
guests
})
} else {
View::default()
};
let guard = sharing();
let sharing = guard.as_ref().filter(|_| view.sharing);
let state = sharing.map(|s| s.host.state());
Ok(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": sharing.map_or(0, |s| s.host.sessions()),
"direct": sharing.map_or(0, |s| s.paths.0),
"relayed": sharing.map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": world.lan_port(),
}))
}
_ => Err("friends host|stop|status".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A world as the engine shows it, recording what the crate did to it.
#[derive(Default)]
struct Fake {
can_share: bool,
lan: Option<u16>,
refuse_lan: bool,
did: Vec<String>,
}
impl ShareView for Fake {
fn can_share(&self) -> bool {
self.can_share
}
fn lan_port(&self) -> Option<u16> {
self.lan
}
fn open_lan(&mut self, options: LanOptions) -> std::result::Result<u16, String> {
self.did.push(format!("open_lan {options:?}"));
if self.refuse_lan {
return Err("no free port".into());
}
self.lan = Some(25565);
Ok(25565)
}
fn close_lan(&mut self) {
self.did.push("close_lan".into());
self.lan = None;
}
fn players(&self, each: &mut dyn FnMut(&str)) {
each("Alex");
}
fn chat(&mut self, text: &str) {
self.did.push(format!("chat {text}"));
}
fn copy(&mut self, text: &str) {
self.did.push(format!("copy {text}"));
}
fn open_url(&mut self, url: &str) {
self.did.push(format!("open_url {url}"));
}
fn tr(&self, _key: &str) -> Option<String> {
None
}
}
#[test]
fn a_world_that_cannot_be_shared_is_left_alone() {
// Another server, or a splitscreen guest in P1's world.
let mut guest = Fake {
lan: Some(25565),
..Fake::default()
};
assert_eq!(
control(Some("host"), &mut guest).unwrap_err(),
"open a singleplayer world first"
);
stop(&mut guest);
copy(&mut guest);
assert!(guest.did.is_empty(), "{:?}", guest.did);
// horizon-ctl reaches the same through the hook; other commands aren't its.
let status = HOOKS
.control(&["friends", "status"], &mut guest)
.unwrap()
.unwrap();
let status: serde_json::Value = serde_json::from_str(&status).unwrap();
assert_eq!(status["sharing"], false);
assert!(HOOKS.control(&["lan", "status"], &mut guest).is_none());
assert!(guest.did.is_empty(), "{:?}", guest.did);
assert!(!ON.load(Ordering::Relaxed));
}
#[test]
fn sharing_opens_lan_as_open_to_lan_does_and_a_refusal_shares_nothing() {
let mut world = Fake {
can_share: true,
refuse_lan: true,
..Fake::default()
};
assert_eq!(start(&mut world).unwrap_err(), "no free port");
assert_eq!(
world.did,
["open_lan LanOptions { guest_commands: false, force_game_mode: true }"]
);
// Offered, not shared: the guests aren't asked for.
let view = view(|| unreachable!("the guests of a world not shared"));
assert!(!view.sharing && view.guests.is_empty());
assert!(!ON.load(Ordering::Relaxed));
}
#[test]
fn the_page_shows_todays_screen_and_a_press_runs_on_the_next_tick() {
// Sharing, with a code friends on the internet can't reach yet.
let shared = View {
sharing: true,
code: Some("K7QM2-AAAAA".into()),
status: "Friends on your network can join.".into(),
..View::default()
};
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(&shared, &mut out, &mut acts);
let buttons: Vec<_> = out
.iter()
.zip(&acts)
.filter_map(|(row, act)| match row {
Row::Button {
label,
active,
half,
footer,
..
} => Some((*label, *active, *half, *footer, *act)),
_ => None,
})
.collect();
assert_eq!(
buttons,
[
(
"horizon.friends.copy_code",
true,
true,
false,
Some(Action::Copy)
),
(
"horizon.friends.stop_sharing",
true,
true,
false,
Some(Action::Stop)
),
(
"horizon.friends.troubleshooting",
true,
false,
true,
Some(Action::Help)
),
]
);
assert!(out.iter().any(
|row| matches!(row, Row::Text { text, groups: true, .. } if text == "K7QM2-AAAAA")
));
// Before the code there is nothing to copy.
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(
&View {
code: None,
..shared
},
&mut out,
&mut acts,
);
assert!(out.iter().any(|row| matches!(
row,
Row::Button {
label: "horizon.friends.copy_code",
active: false,
..
}
)));
// Not sharing: Start Sharing. Its press waits for the tick, which has the world.
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(&View::default(), &mut out, &mut acts);
let share = acts.iter().position(|a| *a == Some(Action::Share)).unwrap();
page().acts = acts;
PAGE.act(share, Act::Press);
let mut world = Fake {
can_share: true,
refuse_lan: true,
..Fake::default()
};
HOOKS.share(&mut world);
assert_eq!(
world.did,
[
"open_lan LanOptions { guest_commands: false, force_game_mode: true }",
"chat Couldn't share the world: no free port",
]
);
// Pressed once, run once.
HOOKS.share(&mut world);
assert_eq!(world.did.len(), 2);
}
}
-9
View File
@@ -47,15 +47,6 @@ pub fn open_vulkan_device(
Some(unsafe { adapter.create_device_from_hal(open, desc) })
}
/// wgpu's opt-in to its `EXPERIMENTAL_*` features, which the crates that forbid unsafe code
/// can't give: hardware ray queries, asked for only when the player turns ray tracing on
/// (`gfx/ray_query.rs` in horizon-client-render).
pub fn experimental_features() -> wgpu::ExperimentalFeatures {
// Safety: wgpu warns that its experimental features may have bugs that reach undefined
// behaviour through safe calls. Horizon accepts that only on request, for ray queries.
unsafe { wgpu::ExperimentalFeatures::enabled() }
}
/// Tells the compositor, through `VK_EXT_hdr_metadata`, that the swapchain's
/// content is BT.709, peaks at `peak` nits and is no darker than `black`. Needs a
/// device from `open_vulkan_device` and a configured surface; does nothing otherwise.
+273 -52
View File
@@ -21,11 +21,12 @@ pub mod hud;
pub mod pages;
mod registry;
pub mod settings;
pub mod share;
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
@@ -43,39 +44,176 @@ 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.
fn tunnel(&self) -> Option<&dyn tunnel::Resolver> {
None
}
}
/// Once a client tick (20 a second, also while the game is paused or on the title screen),
/// with the window's world ([`share`]). Only the window's own player calls it.
fn share(&self, _world: &mut dyn share::ShareView) {}
/// 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>;
/// The window's world stopped being open to LAN: Open to LAN's stop, or the player left the
/// world. Called at that moment, by the window's own player only.
fn lan_closed(&self) {}
/// 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 `horizon-ctl` commands this crate answers in the game: their group, as `hello` lists
/// it, and the lines `help` adds for them.
fn control_commands(&self) -> Option<(&'static str, &'static [&'static str])> {
None
/// 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 }
}
/// A `horizon-ctl` command (its words) on the window's game: `Some` with the answer as JSON
/// text, or why it failed, when it is this crate's. The first hook that answers wins; the
/// hooks are asked before most of the engine's own commands.
fn control(
&self,
_words: &[&str],
_world: &mut dyn share::ShareView,
) -> Option<Result<String, String>> {
None
/// 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> {
@@ -111,15 +249,6 @@ pub fn register(hooks: &'static dyn Hooks) -> Result<(), TooLate> {
}
/// The registered hooks, sealing the list on the first call.
/// The game's translations, which it installs at start ([`engine::set_language`]).
static LANGUAGE: std::sync::OnceLock<fn(&str) -> Option<String>> = std::sync::OnceLock::new();
/// The text of a translation key in the player's language, if the game has it. For text a crate
/// makes outside a view, such as a page's rows. None before the game installs its languages.
pub fn tr(key: &str) -> Option<String> {
LANGUAGE.get().and_then(|language| language(key))
}
fn all() -> &'static [&'static dyn Hooks] {
HOOKS.all()
}
@@ -137,11 +266,6 @@ pub mod engine {
crate::hud::engine::all();
}
/// Where [`crate::tr`] reads translations from; the first call wins.
pub fn set_language(language: fn(&str) -> Option<String>) {
let _ = LANGUAGE.set(language);
}
/// [`Hooks::started`].
pub fn started(game: &Started<'_>) {
for hooks in all() {
@@ -170,33 +294,72 @@ pub mod engine {
}
}
/// [`Hooks::share`].
pub fn share(world: &mut dyn share::ShareView) {
for hooks in all() {
hooks.share(world);
/// 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::lan_closed`].
pub fn lan_closed() {
/// [`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.lan_closed();
hooks.world_shown(since_opening);
}
}
/// [`Hooks::control`]: the first hook's answer.
pub fn control(
words: &[&str],
world: &mut dyn share::ShareView,
) -> Option<Result<String, String>> {
all().iter().find_map(|hooks| hooks.control(words, world))
/// [`Hooks::frame`].
pub fn frame(frame: &Frame<'_>) {
for hooks in all() {
hooks.frame(frame);
}
}
/// [`Hooks::control_commands`] of every hook, in registration order.
pub fn control_commands() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
all().iter().filter_map(|hooks| hooks.control_commands())
/// [`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())
@@ -222,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.
@@ -239,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));
}
+2 -88
View File
@@ -19,12 +19,6 @@ pub enum Place {
/// start a group of their own after the page's. A [`Row::Heading`] starts another group. A
/// [`Row::Key`] shows its key there, without changing it: give a page of its own to a key.
Extras,
/// What the pause menu's Friends icon opens (vanilla's `CommonButtons.friends`): the icon is
/// active while a page is registered here and the world can be shared (singleplayer). The
/// first page registered here is drawn as its own screen over the paused world, as a mod's
/// screen is: the title at the top, the rows centred down the middle, footer buttons beside
/// Done at the bottom. Text that would reach the footer is left out.
PauseFriends,
}
/// One page. Its values live in the crate (statics, often its settings [`crate::settings::Store`]);
@@ -67,93 +61,13 @@ pub enum Row {
value: f32,
text: Label,
},
/// A button that does something ([`Row::button`]). An inactive one is drawn greyed and
/// never pressed; two half-width ones in a row sit side by side; a footer one sits at the
/// bottom beside Done on a page drawn as its own screen, and with the others elsewhere.
/// Horizon Settings' Extras shows only active ones.
#[non_exhaustive]
Button {
label: &'static str,
active: bool,
half: bool,
footer: bool,
},
/// Text shown as it is, wrapped to the page's width ([`Row::text`]): white, or grey when
/// `quiet` (secondary); `groups` breaks it only between its `-`-separated groups (a code).
#[non_exhaustive]
Text {
text: String,
quiet: bool,
groups: bool,
},
/// Empty space, in GUI pixels, on a page drawn as its own screen; nothing elsewhere.
Space(u8),
/// A button that does something.
Button { label: &'static str },
/// A key binding, as Key Binds shows one: `key` is its key name (`key.keyboard.z`,
/// `key.mouse.left`). A press waits for the next key or mouse button ([`Act::Bind`]).
Key { label: &'static str, key: String },
}
impl Row {
/// An active, full-width button in the page's body.
pub fn button(label: &'static str) -> Self {
Self::Button {
label,
active: true,
half: false,
footer: false,
}
}
/// White text in the page's body, wrapped at spaces.
pub fn text(text: impl Into<String>) -> Self {
Self::Text {
text: text.into(),
quiet: false,
groups: false,
}
}
/// A button greyed out (`false`): drawn, never pressed.
pub fn active(mut self, on: bool) -> Self {
if let Self::Button { active, .. } = &mut self {
*active = on;
}
self
}
/// A button half as wide: two in a row sit side by side.
pub fn half(mut self) -> Self {
if let Self::Button { half, .. } = &mut self {
*half = true;
}
self
}
/// A button in the footer, beside Done, on a page drawn as its own screen.
pub fn footer(mut self) -> Self {
if let Self::Button { footer, .. } = &mut self {
*footer = true;
}
self
}
/// Text in grey: secondary.
pub fn quiet(mut self) -> Self {
if let Self::Text { quiet, .. } = &mut self {
*quiet = true;
}
self
}
/// Text broken only between its `-`-separated groups, never inside one (a code).
pub fn groups(mut self) -> Self {
if let Self::Text { groups, .. } = &mut self {
*groups = true;
}
self
}
}
/// A value shown on a row: a translation key, or text shown as it is (a number, a name).
#[derive(Clone, Debug, PartialEq)]
pub enum Label {
-53
View File
@@ -1,53 +0,0 @@
//! Sharing the open world past the LAN (Play with Friends' host side): what the engine lets a
//! crate do with the world a window plays. The engine implements [`ShareView`] and hands it to
//! [`Hooks::share`](crate::Hooks::share) once a client tick; [`Hooks::lan_closed`](crate::Hooks::lan_closed)
//! says the world stopped being open to LAN.
//!
//! Only the window's own player calls them (a splitscreen guest can't share P1's world, and
//! its leaving doesn't close it), never the integrated server's thread or a dedicated server.
/// How [`ShareView::open_lan`] opens the world, as Open to LAN's screen does. The default is
/// what `ShareToLanScreen` offers: no commands for guests, the world's game mode forced on them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct LanOptions {
/// Guests may run commands (`ShareToLanScreen`'s "Allow Cheats").
pub guest_commands: bool,
/// Guests play in the world's game mode.
pub force_game_mode: bool,
}
impl Default for LanOptions {
fn default() -> Self {
Self {
guest_commands: false,
force_game_mode: true,
}
}
}
/// The window's world, as a crate sharing it sees it. Nothing here allocates per call unless a
/// method says so.
pub trait ShareView {
/// A singleplayer world runs on this window's server thread, so it can be shared. False on
/// the title screen and on another server.
fn can_share(&self) -> bool;
/// The port the world is open to LAN on, if it is.
fn lan_port(&self) -> Option<u16>;
/// Opens the world to LAN on a free port, as Open to LAN does; the port, or why not.
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String>;
/// Closes the world to LAN, saying so in chat as Open to LAN's stop does.
fn close_lan(&mut self);
/// The names of the other players in the world (not this one's). For a screen or a command,
/// not every tick.
fn players(&self, each: &mut dyn FnMut(&str));
/// A line in this player's chat, as the game's own system messages; `text` is already in
/// the player's language ([`ShareView::tr`]).
fn chat(&mut self, text: &str);
/// Puts `text` on the system clipboard.
fn copy(&mut self, text: &str);
/// Opens `url` in the system's browser.
fn open_url(&mut self, url: &str);
/// The translation of `key` in the player's language, if the game has one.
fn tr(&self, key: &str) -> Option<String>;
}
+2
View File
@@ -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]
+7 -18
View File
@@ -8,24 +8,18 @@
//! crate.
#![forbid(unsafe_code)]
use horizon_platform::{
hooks::{self, Hooks},
pages::{self, Page},
};
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, and the open world is
// shared behind an invite code.
// Play with Friends: `friend:` addresses join through its tunnel.
&horizon_friends::game::HOOKS,
];
/// The pages Horizon's parts add to the game's menus.
const PAGES: [&dyn Page; 1] = [
// Play with Friends, what the pause menu's Friends icon opens.
&horizon_friends::game::PAGE,
// 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
@@ -37,9 +31,4 @@ pub fn register() {
log::warn!("Horizon: an in-game part did not register: {e}");
}
}
for page in PAGES {
if let Err(e) = pages::register(page) {
log::warn!("Horizon: a page did not register: {e}");
}
}
}
+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,
+4 -1
View File
@@ -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, share, tr, 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
+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);
}
}
}
+4
View File
@@ -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()
+2
View File
@@ -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;
+2 -2
View File
@@ -60,12 +60,12 @@ command above to inspect the selection rather than relying on historical counts.
| 31 | Particles and client effects (46) | `R/particles.rs`, `G/render_tick.rs`, `G/level_particles.rs`, `L/animate_tick.rs` | particle_providers, block_particles, entity_particles, emitter_particles, marker_particles |
| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, forced_end, end_podium, end_gateway |
| 33 | Protocol and packets (44) | `crates/horizon-protocol/src/` (`packets/play.rs`, `entity_data.rs`, `connection.rs`) | dedicated_server, online_mode, secure_chat; `cargo test -p horizon-protocol` |
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/page_screen.rs` (its screen), `crates/horizon-friends/` (`src/game.rs` joining, `src/game/share.rs` hosting), `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/friends.rs` + `G/friends/` (screen, tunnel), `crates/horizon-friends/`, `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
| 35 | Dispensers (43) | `G/dispenser.rs`, `G/dispense_fixture.rs` (the harness side) | dispenser |
| 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
+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
+1 -1
View File
@@ -25,7 +25,7 @@ What changes in that build:
| The official server added to the server list, the withdrawn partner server taken out | `official_servers.rs` | the module and its call at startup (`game.rs`, `fn run`) |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
| Play with Friends | crate `horizon-friends` (`game`): hosting in `src/game/share.rs` (its page, drawn by the engine's `game/page_screen.rs`), joining in `src/game.rs` | only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
Credits & Attribution then says where the official build is (`horizon.plain.official`).
+17 -22
View File
@@ -288,15 +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), `tunnel` (the resolver of `friend:` addresses, Play with Friends'), `share` (each client tick, the window's world
through `share::ShareView`: open it to LAN, its players, chat, the clipboard) and `lan_closed` (it stopped being open
to LAN), `control_commands` and `control` (the `horizon-ctl` commands a crate answers in the game, with that view).
**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
@@ -367,7 +371,7 @@ pub trait Store: Sync {
A page lives in the options screen's shell, and its entry sits on one of the game's menus.
```rust
pub enum Place { HorizonSettings, Title, Pause, Extras, PauseFriends }
pub enum Place { HorizonSettings, Title, Pause }
pub trait Page: Sync {
fn id(&self) -> &'static str; // stable, for horizon-ctl and the page stack
fn title(&self) -> &'static str; // translation key
@@ -382,10 +386,8 @@ pub enum Row {
Toggle { label: &'static str, on: bool },
Cycle { label: &'static str, value: Label },
Slider { label: &'static str, value: f32, text: Label }, // value in 0..=1
Button { label: &'static str, active: bool, half: bool, footer: bool }, // Row::button(..).half()...
Button { label: &'static str },
Key { label: &'static str, key: String }, // a binding: `key.keyboard.z`
Text { text: String, quiet: bool, groups: bool }, // Row::text(..): white or grey, wrapped
Space(u8), // GUI pixels, on a page drawn as a screen
}
pub enum Label { Key(&'static str), Value(String) }
pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
@@ -395,13 +397,6 @@ pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
button on the title screen or the pause menu that opens the page. These buttons exist only when something
registered, so the vanilla menus keep vanilla's look. The page is drawn with the same widgets, tooltips,
narration, controller focus and `horizon-ctl` ids as the engine's pages.
- `PauseFriends`: what the pause menu's vanilla Friends icon opens. The icon is active while a page is registered
there and the world can be shared; the page is drawn as its own screen over the paused world (title, rows down the
middle, footer buttons beside Done), the way Play with Friends' screen always looked.
- `Button` and `Text` are built with `Row::button` and `Row::text` and their options: `.active(false)` greys a
button, `.half()` puts two side by side, `.footer()` sets one beside Done on a page drawn as its own screen,
`.quiet()` greys text, `.groups()` breaks it only between `-`-separated groups (an invite code). In the options
shell, text is notes and space is nothing.
- Rows are rebuilt when the page is drawn or acted on, like the engine's own pages: no cost while no page is
open.
- The engine keeps native mods' pages (`Page::NativeMod`) as they are. Both kinds sit in one module that
@@ -483,8 +478,8 @@ All three run in CI's verify job and in `tools/pr_ready.py`.
| Crate | Uses, against the rule |
|---|---|
| `horizon-client` | platform: `horizon-anticheat`, `-chat-filter`, `-dualsense`, `-hdr`, `-mod-api-gen`, `-mods`, `-optifine`, `-shader-api`, `-shaders`, `-theme`; Horizon: `horizon-bench`, `-cosmetics`, `-telemetry` (`horizon-friends` left through `Hooks::tunnel`, `share`, `control` and its page, #14167) |
| `horizon-client-lib` | the same, plus `horizon-mcp`, minus `horizon-mod-api-gen` |
| `horizon-client` | platform: `horizon-anticheat`, `-chat-filter`, `-dualsense`, `-hdr`, `-mod-api-gen`, `-mods`, `-optifine`, `-shader-api`, `-shaders`, `-theme`; Horizon: `horizon-bench`, `-cosmetics`, `-friends`, `-telemetry` |
| `horizon-client-lib` | the same, plus `horizon-mcp`, minus `horizon-mod-api-gen` and `horizon-friends` (left through `Hooks::tunnel`) |
| `horizon-client-render` | platform: `horizon-hdr`, `-mod-api-gen`, `-optifine`, `-polytone`, `-shader-api`, `-shaders`, `-theme`; Horizon: `horizon-bench`, `-cosmetics`, `-telemetry` |
| `horizon-client-input` | platform: `horizon-dualsense`, `horizon-theme` |
+4 -7
View File
@@ -26,7 +26,7 @@ that finds the port takes the session.
## How it works
**Hosting.** The pause menu's Friends button (vanilla's icon, beside Player Reporting) opens **Play
with Friends** (horizon-friends' page at `Place::PauseFriends`, `src/game/share.rs`, drawn by the engine's `game/page_screen.rs`); its **Start Sharing** opens the world to LAN if it isn't (Open to
with Friends** (`game/friends/screen.rs`); its **Start Sharing** opens the world to LAN if it isn't (Open to
LAN's defaults), then the host's game, on its own:
1. listens on its **direct port**, TCP 25580 by default (the same port every time, so a port forwarded
@@ -54,12 +54,9 @@ router that opened the port, or with a forwarded port, that's it: no server took
reads codes and opens the tunnel, which the join (`net.rs`) then reaches on a loopback port like any server.
horizon-friends' `game` side offers it, and the official executable (`horizon-official`) registers it;
horizon-client's own executable has none, so there a `friend:` address is refused. Hosting needs no hook: the
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. Hosting sees the
game only through `horizon_hooks`: `Hooks::share` each client tick with a `share::ShareView` (open the world to
LAN, its players, chat, the clipboard, a link), `Hooks::lan_closed`, its page (`pages::Place::PauseFriends`, what
the pause menu's Friends icon opens; a press runs on the next tick) and `tr`. It keeps what it shares in its own
statics, and answers `horizon-ctl friends` through `Hooks::control`. Both sides are horizon-friends' `game`
feature, which only the official executable registers: horizon-client's own executable shares nothing (#14167).
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. It still lives in
horizon-client (`game/friends.rs`, `game/friends/screen.rs`, the `friends` feature, which comes with
`horizon-extras`) until the pause menu and `horizon-ctl` offer entries to crates above the engine.
**Codes.** Crockford base32 (`0-9 A-Z` without `I L O U`; input ignores case, spaces and dashes and reads
`O` as `0`, `I`/`L` as `1`) in groups of five, with a 16-bit checksum so a typo is caught before
+1 -1
View File
@@ -427,7 +427,7 @@ game built in `Game::new`, the integrated server on its own thread. On that base
**Binaries.** The release archives hold `horizon-client`, `horizon-ctl`, `horizon-prepare` and
`horizon-server`. On Linux the release also has `horizon-server-linux-x86_64.tar.gz`: `horizon-server`,
`horizon-prepare` and `horizon-engine`, the game built with `--no-default-features` (no sound, no
controllers, no Discord presence: the `discord` feature; nor Play with Friends or the "I'm
controllers, no Discord presence or Play with Friends: the `discord` and `friends` features; nor the "I'm
playing" ping, which only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library;
CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else
+2 -3
View File
@@ -172,9 +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")
# Play with Friends in the game is horizon-friends' `game` side, which only horizon-official turns on.
self.assertEqual(pr_ready.mod_features(["horizon-friends", "horizon-mods"]),
" --features horizon-mods/index,horizon-friends/game")
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):
+1 -2
View File
@@ -81,10 +81,10 @@
},
"allowed": [
"horizon-client -> horizon-anticheat",
"horizon-client -> horizon-bench",
"horizon-client -> horizon-chat-filter",
"horizon-client -> horizon-cosmetics",
"horizon-client -> horizon-dualsense",
"horizon-client -> horizon-friends",
"horizon-client -> horizon-hdr",
"horizon-client -> horizon-mod-api-gen",
"horizon-client -> horizon-mods",
@@ -95,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",
+8 -7
View File
@@ -333,15 +333,16 @@ 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,
horizon-friends' game."""
if crates is None:
return ""
# horizon-official turns on horizon-friends' `game` side (Play with Friends in the game).
wanted = {"horizon-mods": "horizon-mods/index", "horizon-friends": "horizon-friends/game"}
features = [f for crate, f in wanted.items() if crate in crates]
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 ""