Compare commits

...
Author SHA1 Message Date
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 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 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 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 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
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
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 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
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 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 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
20 changed files with 303 additions and 194 deletions

No files matched your search

Generated
+2
View File
@@ -1797,6 +1797,7 @@ dependencies = [
"horizon-game",
"horizon-heartbeat",
"horizon-platform",
"horizon-telemetry",
"log",
"winresource",
]
@@ -1970,6 +1971,7 @@ dependencies = [
"getrandom 0.3.4",
"horizon-addresses",
"horizon-platform",
"log",
"ruzstd",
"serde",
"serde_json",
+2 -1
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`).
+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) => {
+56 -1
View File
@@ -311,7 +311,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(&crate::logging::game_home(), &report);
report
.write_to(&setup.game_dir.join("benchmarks"))
.map_err(|e| e.to_string())
@@ -468,10 +468,65 @@ impl RssSampler {
}
}
/// Keeps the report for the launcher to send, if the player allows benchmark uploads: the report
/// as docs/benchmark-report.md defines it, dated to the day (docs/telemetry.md).
fn keep_for_upload(home: &Path, report: &Report) {
use horizon_telemetry::{Item, Payload, Source, outbox};
let Ok(data) = serde_json::to_value(report.for_upload()) else {
return;
};
let item = Item::new(
Source::Game,
crate::crash::build(),
Payload::Benchmark(data),
);
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::*;
#[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, &report);
allow(Choices {
performance: true,
..Choices::NONE
});
keep_for_upload(&home, &report);
assert!(outbox::pending(&home).is_empty());
allow(Choices {
benchmarks: true,
..Choices::NONE
});
keep_for_upload(&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 scenarios() {
assert_eq!(scenes("standard").unwrap().len(), 4);
-1
View File
@@ -140,7 +140,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;
-7
View File
@@ -22,13 +22,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
}
// The process's start, for the startup trace and the start's milestones.
horizon_client::spans::startup::init(std::time::Instant::now());
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Opt-in telemetry's play session plugs in through the hooks until it moves to a Horizon
// crate in horizon-official's parts (layer plan M5). horizon-extras: telemetry is Horizon's
// (docs/layers.md); without it no session is kept.
#[cfg(feature = "horizon-extras")]
let _ = horizon_hooks::register(&horizon_client::telemetry::HOOKS);
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_hooks::engine::seal();
+1 -1
View File
@@ -12664,7 +12664,7 @@ pub fn run_with() -> Result<()> {
// for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::telemetry::version();
let version = horizon_client::crash::build();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
// horizon-extras: the official server is Horizon's (docs/forking.md).
#[cfg(feature = "horizon-extras")]
@@ -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
@@ -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);
@@ -178,12 +178,16 @@ impl Game<'_> {
}
// 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);
}
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".
+12
View File
@@ -58,6 +58,18 @@ impl Session {
}
}
/// 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());
+66 -1
View File
@@ -65,6 +65,13 @@ pub trait Hooks: Sync {
/// 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<'_>) {}
/// 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.
@@ -73,6 +80,46 @@ pub trait Hooks: Sync {
}
}
/// 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 {
@@ -115,9 +162,14 @@ impl<'a> Frame<'a> {
}
}
/// What [`Hooks::world_left`] gets: the game's home folder and the settings played with.
/// 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.
@@ -246,6 +298,19 @@ pub mod engine {
}
}
/// [`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::tunnel`]: the first registered hook's resolver.
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
all().iter().find_map(|hooks| hooks.tunnel())
+1
View File
@@ -15,6 +15,7 @@ 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"] }
log = "0.4"
[build-dependencies]
+3 -1
View File
@@ -11,11 +11,13 @@
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; 3] = [
// The anonymous "I'm playing" ping.
&horizon_heartbeat::HOOKS,
// Play with Friends: `friend:` addresses join through its tunnel.
&horizon_friends::game::HOOKS,
// Opt-in telemetry: the play session's summary and crash reports, kept for the launcher.
&horizon_telemetry::game::HOOKS,
];
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
+1 -1
View File
@@ -47,7 +47,7 @@ pub use horizon_server_files as server_files;
/// in"). The engine's dispatch side is not part of the API.
pub mod hooks {
pub use horizon_hooks::{
Frame, Hooks, Milestone, Started, TooLate, WorldLeft, register, tunnel,
Crash, Display, Frame, Gpu, Hooks, Milestone, Started, TooLate, WorldLeft, register, tunnel,
};
}
+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,22 +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.
//!
//! The play session's summary is built from the game's hooks ([`HOOKS`], `horizon_hooks`), as a
//! Horizon crate does: it leaves the engine for the Horizon layer when the official executable
//! ships (layer plan M5, docs/layers.md).
//! 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::{LazyLock, Mutex};
use std::time::Duration;
use horizon_hooks::{Frame, Hooks, Milestone, WorldLeft};
use horizon_platform::hooks::{self, Frame, Hooks, Milestone, WorldLeft};
use horizon_telemetry::payload::{Crash, Display, Perf, Settings, System, Timings};
use horizon_telemetry::scrub::Scrubber;
use horizon_telemetry::{Item, Payload, Source, outbox};
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;
@@ -30,7 +29,7 @@ static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
/// The screen the window opened on.
static DISPLAY: Mutex<Option<Display>> = Mutex::new(None);
/// The game's hooks, for `horizon_hooks::register`.
/// The game's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: Telemetry = Telemetry {
session: LazyLock::new(|| Mutex::new(Session::default())),
};
@@ -58,7 +57,7 @@ impl Hooks for Telemetry {
/// A player who allows performance statistics also says what their slow frames spent their
/// time in.
fn wants_frame_steps(&self, home: &Path) -> bool {
horizon_telemetry::Consent::load(home).allows(horizon_telemetry::Kind::Perf)
crate::Consent::load(home).allows(crate::Kind::Perf)
}
fn world_shown(&self, since_opening: Duration) {
@@ -80,7 +79,21 @@ impl Hooks for Telemetry {
fullscreen: left.fullscreen,
graphics: left.graphics.to_owned(),
};
self.session().finish(left.home, settings);
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);
}
}
@@ -94,54 +107,32 @@ fn readiness_ms(mark: &AtomicU32) -> Option<u32> {
(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" => {
@@ -156,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()
@@ -167,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 {
@@ -189,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)]
@@ -220,7 +197,7 @@ pub struct Session {
/// The start was measured: only the first session carries it.
title_measured: bool,
to_world_ms: Option<u32>,
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
slow_frames: Vec<crate::payload::SlowFrames>,
}
impl Session {
@@ -244,7 +221,7 @@ impl Session {
/// 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 horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
use crate::payload::{SLOW_FRAME_MS, SlowFrames};
if ms >= SLOW_FRAME_MS {
let (mut path, span_ms) = slowest_step().unwrap_or_default();
// A path too long for the item keeps its last steps.
@@ -265,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(),
@@ -284,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}");
}
@@ -333,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!(
@@ -351,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(),
@@ -374,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");
};
@@ -396,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());
@@ -413,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");
@@ -421,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,
@@ -466,7 +428,7 @@ mod tests {
..Choices::NONE
},
);
keep_crash(&home, &report());
keep_crash(&report.crash(&home));
assert!(outbox::pending(&home).is_empty());
allow(
&home,
@@ -475,7 +437,7 @@ 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);
@@ -495,7 +457,7 @@ mod tests {
let mut session = Session::default();
session.title_shown(Duration::from_millis(3100));
assert_eq!(session.to_title_ms, Some(3100));
session.finish(&home, Settings::default());
session.finish(&home, "dev", None, Settings::default());
session.title_shown(Duration::from_millis(9000));
assert_eq!(session.to_title_ms, None);
}
@@ -506,7 +468,7 @@ mod tests {
let mut session = Session::default();
session.world_shown(Duration::from_millis(1500));
(0..2000).for_each(|_| session.frame(16.6, || None));
session.finish(&home, Settings::default());
session.finish(&home, "dev", None, Settings::default());
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
allow(
&home,
@@ -516,13 +478,15 @@ mod tests {
},
);
(0..2000).for_each(|_| session.frame(16.6, || None));
session.finish(&home, Settings::default());
session.finish(&home, "dev", None, Settings::default());
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
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()
@@ -545,23 +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,
};
let culprit = |spans: &[Span]| crate::spans::culprit(spans);
// 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();
let ticking = [span("tick", 10.0, 0)];
(0..4000).for_each(|_| session.frame(16.6, || culprit(&ticking)));
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
session.frame(320.0, || culprit(&frozen));
session.frame(900.0, || culprit(&frozen));
session.frame(260.0, || culprit(&[]));
session.frame(249.0, || culprit(&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;
+1 -1
View File
@@ -65,7 +65,7 @@ command above to inspect the selection rather than relying on historical counts.
| 36 | Audio and music (38) | `R/audio.rs`, `R/sound_engine.rs`, `R/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle |
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) |
| 38 | Command parsing (34) | `C/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); `cargo test -p horizon-command` | command_parse (corpus `tools/command_parse_corpus.py`) |
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `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) | `L/benchmark.rs`, `crates/horizon-telemetry/src/game.rs`, `tools/perf_bench.py`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
| 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
+9 -8
View File
@@ -290,14 +290,15 @@ The hooks today: `started` (once, when someone plays: not a dedicated server, a
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
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) and `world_left` (the session's end, with its settings); and `tunnel` (the resolver of
`friend:` addresses, Play with Friends'). Opt-in telemetry's performance summary (`telemetry.rs`) is built from
these. **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.
(every frame played), `world_left` (the session's end, with its settings), `display` (the screen the window is on)
and `crashed` (each crash, before its report is saved); and `tunnel` (the resolver of `friend:` addresses, Play
with Friends'). Opt-in telemetry (`horizon_telemetry::game`, registered by `horizon-official`) hears the game
through these only. **A hook you need and don't find:** add a method with an empty default to `Hooks` and a
function in `horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data
or a type the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a
draw context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension
point that belongs to one subsystem (an input source, the compositor) lives in the engine crate that owns it, with
the same shape: registered before the game runs, read-only after.
**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