mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60e38c57b5 | ||
|
|
4ffac208e1 | ||
|
|
40f82d0e99 | ||
|
|
2b3b2fe7e5 | ||
|
|
7433b49bef | ||
|
|
5176dc5a75 | ||
|
|
9f5ed29593 | ||
|
|
763bc981f0 | ||
|
|
b7bf458117 | ||
|
|
23f0abf4d2 | ||
|
|
9d70e670c8 | ||
|
|
5062adfb0c | ||
|
|
63408fc7f0 | ||
|
|
4abfa15d97 | ||
|
|
37bdbf9def | ||
|
|
67d691b1e3 | ||
|
|
9a0d252abf | ||
|
|
55b78015fe | ||
|
|
cb1198a38d | ||
|
|
17b50d7959 |
No files matched your search
@@ -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`).
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! A play session as the hooks above the engine hear it (`horizon_hooks`): the title screen's
|
||||
//! first frame, a world opening and its first frame, the frames played, and leaving the world.
|
||||
//! It also marks those moments in the startup trace (`spans::startup`). Opt-in telemetry's
|
||||
//! performance summary is built from these hooks (docs/telemetry.md).
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use horizon_hooks::{Frame, Milestone, WorldLeft, engine};
|
||||
|
||||
use crate::spans::{self, startup};
|
||||
|
||||
/// One window's session: moved from the startup menu to the game with the window.
|
||||
#[derive(Default)]
|
||||
pub struct Session {
|
||||
/// When the world being opened started to, until its first frame.
|
||||
opening: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The title screen drew a frame: the first time, how long the start took.
|
||||
pub fn title_shown(&mut self) {
|
||||
static SHOWN: AtomicBool = AtomicBool::new(false);
|
||||
if !SHOWN.swap(true, Ordering::Relaxed) {
|
||||
startup::mark("title.shown");
|
||||
// The frame itself: the menu bundle can be ready seconds before the renderer that
|
||||
// draws it (pipelines compiling on a slow driver).
|
||||
engine::startup(Milestone::TitleShown, startup::since_start());
|
||||
}
|
||||
}
|
||||
|
||||
/// A world starts loading.
|
||||
pub fn world_opening(&mut self) {
|
||||
if self.opening.is_none() {
|
||||
startup::arm("world.opening");
|
||||
}
|
||||
self.opening.get_or_insert_with(Instant::now);
|
||||
}
|
||||
|
||||
/// An entry queued during startup was cancelled before opening a world.
|
||||
pub fn cancel_world_opening(&mut self) {
|
||||
self.opening = None;
|
||||
}
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
|
||||
/// recorded in it ([`spans::take_raw`]): the step it spent most of its time in is
|
||||
/// [`spans::culprit`].
|
||||
pub fn frame(&mut self, ms: f32, frame_spans: &[spans::Span]) {
|
||||
if let Some(opening) = self.opening.take() {
|
||||
startup::mark("world.first_frame");
|
||||
engine::world_shown(opening.elapsed());
|
||||
}
|
||||
engine::frame(&Frame::new(ms, &|| spans::culprit(frame_spans)));
|
||||
}
|
||||
|
||||
/// The player left the world: the session ends.
|
||||
pub fn finish(&mut self, left: &WorldLeft<'_>) {
|
||||
engine::world_left(left);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells the hooks the screen `window` is on ([`crate::window::display`]).
|
||||
pub fn note_display(window: &winit::window::Window) {
|
||||
if let Some(d) = crate::window::display(window) {
|
||||
engine::display(&horizon_hooks::Display {
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
refresh_hz: d.refresh_hz,
|
||||
scale: d.scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The resources the menus need are loaded.
|
||||
pub fn core_ready() {
|
||||
engine::startup(Milestone::CoreReady, startup::since_start());
|
||||
}
|
||||
|
||||
/// What a world needs is loaded too.
|
||||
pub fn rest_ready() {
|
||||
engine::startup(Milestone::RestReady, startup::since_start());
|
||||
}
|
||||
@@ -3,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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user