Compare commits

...
Author SHA1 Message Date
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 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 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 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
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
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 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
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 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 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
16 changed files with 574 additions and 199 deletions

No files matched your search

+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) => {
+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 {
+1
View File
@@ -110,6 +110,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;
+8 -4
View File
@@ -20,13 +20,17 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::options_ui::HORIZON_EXTRAS
.store(false, std::sync::atomic::Ordering::Relaxed);
}
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// The process's start, for the startup trace and the start's milestones.
horizon_client::spans::startup::init(std::time::Instant::now());
// Opt-in telemetry (the play session's summary, each crash report, whatever the mode) 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 nothing 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();
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");
+6 -6
View File
@@ -2801,7 +2801,7 @@ struct Game<'a> {
agent_last: Option<Instant>,
frame_stats: horizon_client::control::FrameStats,
/// This play session's frame and tick times, for opt-in performance statistics.
perf: horizon_client::telemetry::Session,
perf: horizon_client::play_session::Session,
/// Control replies waiting for a rendered frame or a benchmark deadline.
pending_screenshot: Option<std::sync::mpsc::Sender<String>>,
/// `screenshot-sequence` while it runs.
@@ -12635,11 +12635,11 @@ pub fn run_with() -> Result<()> {
options.spikes = Some(dedicated::SLOW_TICK_MS);
}
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
// A player who allows performance statistics also says what their slow frames spent their
// time in: this thread, which draws the frames, times its steps (wall time only).
// A hook that wants to know what slow frames spent their time in (a player who allows
// performance statistics): this thread, which draws the frames, times its steps (wall time
// only).
if options.execution_role.is_client()
&& horizon_telemetry::Consent::load(&horizon_client::logging::game_home())
.allows(horizon_telemetry::Kind::Perf)
&& horizon_hooks::engine::wants_frame_steps(&horizon_client::logging::game_home())
{
horizon_client::spans::record_here(true);
}
@@ -12664,7 +12664,7 @@ pub fn run_with() -> Result<()> {
// for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::telemetry::version();
let version = horizon_client::crash::build();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
// horizon-extras: the official server is Horizon's (docs/forking.md).
#[cfg(feature = "horizon-extras")]
@@ -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
@@ -487,7 +487,7 @@ pub(super) fn load_resources(
};
let resources = Arc::new(resources);
if menu {
horizon_client::telemetry::mark_core_ready();
horizon_client::play_session::core_ready();
log::info!(
"Horizon: core-ready after {} ms",
started.elapsed().as_millis()
@@ -526,7 +526,7 @@ pub(super) fn load_resources(
.set(rest)
.map_err(|_| "world resources already loaded".to_owned())?;
progress.set(9);
horizon_client::telemetry::mark_rest_ready();
horizon_client::play_session::rest_ready();
log::info!(
"Horizon: rest-ready after {} ms",
started.elapsed().as_millis()
@@ -53,7 +53,7 @@ pub(super) struct StartupMenu {
pub reload_packs: bool,
/// Browse mods' worker (`mod_browser`).
mod_jobs: super::mod_browser::Jobs,
pub perf: horizon_client::telemetry::Session,
pub perf: horizon_client::play_session::Session,
}
impl StartupMenu {
@@ -304,7 +304,7 @@ impl ApplicationHandler for Game<'_> {
};
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
drop(window_step);
horizon_client::telemetry::note_display(&window);
horizon_client::play_session::note_display(&window);
match self.new_renderer(window.clone()) {
Ok(r) => {
self.renderer = Some(r);
+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());
}
+184 -133
View File
@@ -3,10 +3,15 @@
//! 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.
//!
//! It hears the game only through its hooks ([`HOOKS`], `horizon_hooks`), as a Horizon crate does:
//! it leaves the engine for the Horizon layer next (layer plan M5, docs/layers.md).
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_hooks::{self as hooks, Frame, Hooks, Milestone, WorldLeft};
use horizon_telemetry::payload::{Crash, Display, Perf, Settings, System, Timings};
use horizon_telemetry::scrub::Scrubber;
@@ -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_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 {
horizon_telemetry::Consent::load(home).allows(horizon_telemetry::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),
_ => 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,8 +180,8 @@ 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}");
}
}
@@ -159,7 +194,11 @@ pub fn keep_benchmark(home: &Path, report: &horizon_bench::Report) {
};
if let Err(e) = outbox::push(
home,
&Item::new(Source::Game, version(), Payload::Benchmark(data)),
&Item::new(
Source::Game,
crate::crash::build(),
Payload::Benchmark(data),
),
) {
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
}
@@ -176,42 +215,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>,
}
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]) {
/// 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 horizon_telemetry::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 +250,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 +260,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 +279,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) {
pub fn finish(
&mut self,
home: &Path,
version: &str,
gpu: Option<&hooks::Gpu>,
settings: Settings,
) {
if horizon_telemetry::Consent::load(home).allows(horizon_telemetry::Kind::Perf)
&& let Some(perf) = self.summary(settings)
&& 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}");
}
@@ -322,16 +352,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 +392,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 +414,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 +425,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");
@@ -428,7 +469,8 @@ mod tests {
#[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 +479,7 @@ mod tests {
..Choices::NONE
},
);
keep_crash(&home, &report());
keep_crash(&report.crash(&home));
assert!(outbox::pending(&home).is_empty());
allow(
&home,
@@ -446,33 +488,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 +528,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()
@@ -518,14 +567,16 @@ mod tests {
cpu_ms: ms,
depth,
};
let culprit = |spans: &[Span]| crate::spans::culprit(spans);
let mut session = Session::default();
(0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)]));
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, &frozen);
session.frame(900.0, &frozen);
session.frame(260.0, &[]);
session.frame(249.0, &frozen);
let perf = session.summary(Settings::default()).unwrap();
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(None, Settings::default()).unwrap();
let seen: Vec<_> = perf
.slow_frames
.iter()
+215 -2
View File
@@ -24,7 +24,9 @@ pub mod settings;
pub mod tunnel;
use registry::Registry;
use std::sync::atomic::{AtomicBool, Ordering};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::time::Duration;
/// What a crate above the engine hears from the game. Every method has an empty default:
/// implement only those you need. Hooks run on the game's thread (the render thread) unless a
@@ -42,6 +44,34 @@ 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<'_>) {}
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
/// offers one is used. Asked on the network thread when joining and on the game thread when
/// an address is shown, never per frame.
@@ -50,6 +80,106 @@ 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 {
/// The resources the menus need are loaded.
CoreReady,
/// The rest (what a world needs) too.
RestReady,
/// The title screen drew its first frame.
TitleShown,
}
impl Milestone {
fn bit(self) -> u8 {
match self {
Self::CoreReady => 1,
Self::RestReady => 2,
Self::TitleShown => 4,
}
}
}
/// What [`Hooks::frame`] gets.
pub struct Frame<'a> {
/// How long the frame took.
pub ms: f32,
slowest_step: &'a dyn Fn() -> Option<(String, f32)>,
}
impl<'a> Frame<'a> {
/// `slowest_step` gives [`Frame::slowest_step`], only when asked.
pub fn new(ms: f32, slowest_step: &'a dyn Fn() -> Option<(String, f32)>) -> Self {
Self { ms, slowest_step }
}
/// The step the frame spent the most time in (`render/terrain`) and its milliseconds, when
/// the steps were timed ([`Hooks::wants_frame_steps`]). Worked out on each call: ask only
/// for a slow frame.
pub fn slowest_step(&self) -> Option<(String, f32)> {
(self.slowest_step)()
}
}
/// What [`Hooks::world_left`] gets: the game's home folder, the build, the adapter and the
/// settings played with.
pub struct WorldLeft<'a> {
pub home: &'a Path,
/// As [`Crash::version`].
pub version: &'a str,
/// The renderer's adapter.
pub gpu: Option<&'a Gpu>,
pub render_distance: i32,
pub simulation_distance: i32,
/// `None`: unlimited.
pub max_fps: Option<u32>,
pub vsync: bool,
pub fullscreen: bool,
/// The graphics preset's name.
pub graphics: &'a str,
}
/// What [`Hooks::started`] gets.
#[non_exhaustive]
pub struct Started<'a> {
@@ -130,6 +260,57 @@ pub mod engine {
}
}
/// The milestones already said.
static MILESTONES: AtomicU8 = AtomicU8::new(0);
/// [`Hooks::startup`], the first time `milestone` is reached; later calls do nothing.
pub fn startup(milestone: Milestone, since_start: Duration) {
if MILESTONES.fetch_or(milestone.bit(), Ordering::Relaxed) & milestone.bit() == 0 {
for hooks in all() {
hooks.startup(milestone, since_start);
}
}
}
/// [`Hooks::wants_frame_steps`]: whether any hook wants them.
pub fn wants_frame_steps(home: &Path) -> bool {
all().iter().any(|h| h.wants_frame_steps(home))
}
/// [`Hooks::world_shown`].
pub fn world_shown(since_opening: Duration) {
for hooks in all() {
hooks.world_shown(since_opening);
}
}
/// [`Hooks::frame`].
pub fn frame(frame: &Frame<'_>) {
for hooks in all() {
hooks.frame(frame);
}
}
/// [`Hooks::world_left`].
pub fn world_left(left: &WorldLeft<'_>) {
for hooks in all() {
hooks.world_left(left);
}
}
/// [`Hooks::display`].
pub fn display(display: &Display) {
for hooks in all() {
hooks.display(display);
}
}
/// [`Hooks::crashed`].
pub fn crashed(crash: &Crash<'_>) {
for hooks in all() {
hooks.crashed(crash);
}
}
/// [`Hooks::tunnel`]: the first registered hook's resolver.
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
all().iter().find_map(|hooks| hooks.tunnel())
@@ -155,6 +336,24 @@ 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 frame(&self, frame: &Frame<'_>) {
let step = frame
.slowest_step()
.map(|(name, ms)| format!(" {name} {ms}"));
self.0
.lock()
.unwrap()
.push(format!("frame {}{}", frame.ms, step.unwrap_or_default()));
}
}
/// Implements nothing: the defaults do nothing.
@@ -172,10 +371,24 @@ 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))));
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",
]
);
assert_eq!(register(&Silent), Err(TooLate));
}
+3 -1
View File
@@ -46,7 +46,9 @@ pub use horizon_server_files as server_files;
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
/// in"). The engine's dispatch side is not part of the API.
pub mod hooks {
pub use horizon_hooks::{Hooks, Started, TooLate, register, tunnel};
pub use horizon_hooks::{
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
+11 -6
View File
@@ -288,12 +288,17 @@ build pays nothing.
The hooks today: `started` (once, when someone plays: not a dedicated server, a benchmark or an automated run),
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
world saved) and `tunnel` (the resolver of `friend:` addresses, Play with Friends'). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
that belongs to one subsystem (an input source, the compositor) lives in the engine crate that owns it, with the
same shape: registered before the game runs, read-only after.
world saved), and the play session's: `startup` (core-ready, rest-ready, the title's first frame, once each),
`wants_frame_steps` (asked once: time the render thread's steps), `world_shown` (a world's first frame), `frame`
(every frame played), `world_left` (the session's end, with its settings), `display` (the screen the window is on)
and `crashed` (each crash, before its report is saved); and `tunnel` (the resolver of `friend:` addresses, Play
with Friends'). Opt-in telemetry (`telemetry.rs`) 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