Compare commits

...
Author SHA1 Message Date
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 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
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 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 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 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 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
12 changed files with 366 additions and 100 deletions

No files matched your search

+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;
+7 -2
View File
@@ -20,13 +20,18 @@ 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();
// 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();
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");
+5 -5
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);
}
@@ -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 {
+14 -18
View File
@@ -169,33 +169,29 @@ 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);
}
self.perf
.finish(&horizon_client::logging::game_home(), settings);
let s = &self.settings;
let home = horizon_client::logging::game_home();
self.perf.finish(&horizon_hooks::WorldLeft {
home: &home,
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<()> {
+69
View File
@@ -0,0 +1,69 @@
//! 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);
}
}
/// 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());
}
+102 -66
View File
@@ -3,10 +3,16 @@
//! 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.
//!
//! 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).
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::{Frame, Hooks, Milestone, WorldLeft};
use horizon_telemetry::payload::{Crash, Display, Perf, Settings, System, Timings};
use horizon_telemetry::scrub::Scrubber;
@@ -19,32 +25,70 @@ 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, settings);
}
}
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)
@@ -176,42 +220,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 +255,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;
}
@@ -453,25 +482,30 @@ mod tests {
}
#[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, 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.world_shown(Duration::from_millis(1500));
(0..2000).for_each(|_| session.frame(16.6, || None));
session.finish(&home, Settings::default());
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
allow(
@@ -481,11 +515,11 @@ mod tests {
..Choices::NONE
},
);
(0..2000).for_each(|_| session.frame(16.6, &[]));
(0..2000).for_each(|_| session.frame(16.6, || None));
session.finish(&home, 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,
@@ -518,13 +552,15 @@ 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);
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();
let seen: Vec<_> = perf
.slow_frames
+150 -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,27 @@ 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 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 +73,61 @@ pub trait Hooks: Sync {
}
}
/// 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 and the settings played with.
pub struct WorldLeft<'a> {
pub home: &'a Path,
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 +208,44 @@ 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::tunnel`]: the first registered hook's resolver.
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
all().iter().find_map(|hooks| hooks.tunnel())
@@ -155,6 +271,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 +306,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::{
Frame, Hooks, Milestone, Started, TooLate, WorldLeft, register, tunnel,
};
}
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
+5 -1
View File
@@ -288,7 +288,11 @@ 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
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