mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40f82d0e99 | ||
|
|
7433b49bef | ||
|
|
9f5ed29593 | ||
|
|
b7bf458117 | ||
|
|
5062adfb0c | ||
|
|
37bdbf9def | ||
|
|
55b78015fe | ||
|
|
cb1198a38d | ||
|
|
17b50d7959 |
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user