mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a86761edf | ||
|
|
60e38c57b5 | ||
|
|
320186c089 | ||
|
|
4ffac208e1 | ||
|
|
8d9b80c420 | ||
|
|
2b3b2fe7e5 | ||
|
|
edeb6978b9 | ||
|
|
5176dc5a75 | ||
|
|
afdfc8bfa1 | ||
|
|
13c366a85b | ||
|
|
763bc981f0 | ||
|
|
74f52d0782 | ||
|
|
23f0abf4d2 | ||
|
|
4958be7ce0 | ||
|
|
9d70e670c8 | ||
|
|
195ecc898e | ||
|
|
7aa6f0fda0 | ||
|
|
63408fc7f0 | ||
|
|
5aca6c42ee | ||
|
|
4abfa15d97 | ||
|
|
dd6ad03eea | ||
|
|
67d691b1e3 | ||
|
|
9a0d252abf |
No files matched your search
Generated
+2
@@ -1797,6 +1797,7 @@ dependencies = [
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-platform",
|
||||
"horizon-telemetry",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
@@ -1970,6 +1971,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -311,7 +311,7 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
if let Ok(meta) = std::env::current_exe().and_then(std::fs::metadata) {
|
||||
report.metric("game.binary_bytes", &[meta.len() as f64]);
|
||||
}
|
||||
crate::telemetry::keep_benchmark(&crate::logging::game_home(), &report);
|
||||
keep_for_upload(&crate::logging::game_home(), &report);
|
||||
report
|
||||
.write_to(&setup.game_dir.join("benchmarks"))
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -468,10 +468,65 @@ impl RssSampler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the report for the launcher to send, if the player allows benchmark uploads: the report
|
||||
/// as docs/benchmark-report.md defines it, dated to the day (docs/telemetry.md).
|
||||
fn keep_for_upload(home: &Path, report: &Report) {
|
||||
use horizon_telemetry::{Item, Payload, Source, outbox};
|
||||
let Ok(data) = serde_json::to_value(report.for_upload()) else {
|
||||
return;
|
||||
};
|
||||
let item = Item::new(
|
||||
Source::Game,
|
||||
crate::crash::build(),
|
||||
Payload::Benchmark(data),
|
||||
);
|
||||
if let Err(e) = outbox::push(home, &item) {
|
||||
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reports_kept_for_upload_need_their_own_consent_and_go_dated_to_the_day() {
|
||||
use horizon_telemetry::{Choices, Consent, outbox};
|
||||
let home = std::env::temp_dir().join(format!(
|
||||
"horizon-client-benchmark-upload-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
let allow = |choices: Choices| {
|
||||
let mut consent = Consent::load(&home);
|
||||
consent.answer(choices);
|
||||
consent.save(&home).unwrap();
|
||||
};
|
||||
let mut report = Report::new(Kind::Game, Default::default());
|
||||
report.created = "2026-10-15T14:03:27Z".into();
|
||||
report.label = Some("my machine".into());
|
||||
keep_for_upload(&home, &report);
|
||||
allow(Choices {
|
||||
performance: true,
|
||||
..Choices::NONE
|
||||
});
|
||||
keep_for_upload(&home, &report);
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(Choices {
|
||||
benchmarks: true,
|
||||
..Choices::NONE
|
||||
});
|
||||
keep_for_upload(&home, &report);
|
||||
let pending = outbox::pending(&home);
|
||||
assert_eq!(pending.len(), 1);
|
||||
let text = serde_json::to_string(&pending[0].1).unwrap();
|
||||
assert!(
|
||||
text.contains("2026-10-15T00:00:00Z") && !text.contains("my machine"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenarios() {
|
||||
assert_eq!(scenes("standard").unwrap().len(), 4);
|
||||
|
||||
@@ -140,7 +140,6 @@ pub mod stations;
|
||||
pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod studio;
|
||||
pub mod telemetry;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
|
||||
@@ -22,13 +22,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
}
|
||||
// The process's start, for the startup trace and the start's milestones.
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// Opt-in telemetry's play session plugs in through the hooks until it moves to a Horizon
|
||||
// crate in horizon-official's parts (layer plan M5). horizon-extras: telemetry is Horizon's
|
||||
// (docs/layers.md); without it no session is kept.
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
let _ = horizon_hooks::register(&horizon_client::telemetry::HOOKS);
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
horizon_hooks::engine::seal();
|
||||
|
||||
@@ -12664,7 +12664,7 @@ pub fn run_with() -> Result<()> {
|
||||
// for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that
|
||||
// horizon-ctl drives: those start in a source checkout and wrote the list there.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
let version = horizon_client::telemetry::version();
|
||||
let version = horizon_client::crash::build();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
|
||||
// horizon-extras: the official server is Horizon's (docs/forking.md).
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
|
||||
@@ -123,7 +123,7 @@ impl Loading {
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
horizon_client::play_session::note_display(&window);
|
||||
// The renderer takes seconds to build and winit's window sits white
|
||||
// until the first frame presents, so paint the boot screen on it and
|
||||
// keep repainting it as the console's lines arrive; the wgpu frames
|
||||
|
||||
@@ -304,7 +304,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
};
|
||||
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
horizon_client::play_session::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
Ok(r) => {
|
||||
self.renderer = Some(r);
|
||||
|
||||
@@ -178,12 +178,16 @@ impl Game<'_> {
|
||||
}
|
||||
// Again now: on Wayland the window is on a screen only once it has been shown.
|
||||
if let Some(window) = &self.window {
|
||||
horizon_client::telemetry::note_display(window);
|
||||
horizon_client::play_session::note_display(window);
|
||||
}
|
||||
let s = &self.settings;
|
||||
let home = horizon_client::logging::game_home();
|
||||
let version = horizon_client::crash::build();
|
||||
let gpu = horizon_client::crash::gpu();
|
||||
self.perf.finish(&horizon_hooks::WorldLeft {
|
||||
home: &home,
|
||||
version: &version,
|
||||
gpu: gpu.as_ref(),
|
||||
render_distance: s.render_distance,
|
||||
simulation_distance: s.simulation_distance,
|
||||
// 260 is the slider's "unlimited".
|
||||
|
||||
@@ -58,6 +58,18 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells the hooks the screen `window` is on ([`crate::window::display`]).
|
||||
pub fn note_display(window: &winit::window::Window) {
|
||||
if let Some(d) = crate::window::display(window) {
|
||||
engine::display(&horizon_hooks::Display {
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
refresh_hz: d.refresh_hz,
|
||||
scale: d.scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The resources the menus need are loaded.
|
||||
pub fn core_ready() {
|
||||
engine::startup(Milestone::CoreReady, startup::since_start());
|
||||
|
||||
@@ -65,6 +65,13 @@ pub trait Hooks: Sync {
|
||||
/// session, with the settings it was played with.
|
||||
fn world_left(&self, _left: &WorldLeft<'_>) {}
|
||||
|
||||
/// The screen the window is on: when the window opens, and again as a session ends (on
|
||||
/// Wayland a window is on a screen only once it has been shown).
|
||||
fn display(&self, _display: &Display) {}
|
||||
|
||||
/// The game crashed, once, before its report is saved in `crash-reports/`. Called on the
|
||||
/// thread that crashed, whatever the mode (a dedicated server too).
|
||||
fn crashed(&self, _crash: &Crash<'_>) {}
|
||||
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
|
||||
/// offers one is used. Asked on the network thread when joining and on the game thread when
|
||||
/// an address is shown, never per frame.
|
||||
@@ -73,6 +80,46 @@ pub trait Hooks: Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphics adapter the renderer runs on.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Gpu {
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
/// `integrated`, `discrete`, `virtual`, `cpu` or `other`.
|
||||
pub kind: String,
|
||||
pub driver: String,
|
||||
/// `metal`, `vulkan`, `dx12` or `gl`.
|
||||
pub backend: String,
|
||||
}
|
||||
|
||||
/// The screen a window is on ([`Hooks::display`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Display {
|
||||
/// Physical pixels.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// `None` where the OS doesn't say.
|
||||
pub refresh_hz: Option<u32>,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// What [`Hooks::crashed`] gets: the crash report's parts.
|
||||
pub struct Crash<'a> {
|
||||
/// The game's home folder.
|
||||
pub home: &'a Path,
|
||||
/// The build: the release (`v0.2.3`), or `dev <crate version>` for a build from source.
|
||||
pub version: &'a str,
|
||||
/// The error's message.
|
||||
pub error: &'a str,
|
||||
/// Where it happened and how it got there: one frame a line.
|
||||
pub stack: &'a str,
|
||||
pub thread: &'a str,
|
||||
/// The report's details (`Horizon Version`, `World`, `Server`...), as written in it.
|
||||
pub details: &'a [(String, String)],
|
||||
/// The renderer's adapter, once it has one.
|
||||
pub gpu: Option<&'a Gpu>,
|
||||
}
|
||||
|
||||
/// A moment of the game's start ([`Hooks::startup`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Milestone {
|
||||
@@ -115,9 +162,14 @@ impl<'a> Frame<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Hooks::world_left`] gets: the game's home folder and the settings played with.
|
||||
/// What [`Hooks::world_left`] gets: the game's home folder, the build, the adapter and the
|
||||
/// settings played with.
|
||||
pub struct WorldLeft<'a> {
|
||||
pub home: &'a Path,
|
||||
/// As [`Crash::version`].
|
||||
pub version: &'a str,
|
||||
/// The renderer's adapter.
|
||||
pub gpu: Option<&'a Gpu>,
|
||||
pub render_distance: i32,
|
||||
pub simulation_distance: i32,
|
||||
/// `None`: unlimited.
|
||||
@@ -246,6 +298,19 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::display`].
|
||||
pub fn display(display: &Display) {
|
||||
for hooks in all() {
|
||||
hooks.display(display);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::crashed`].
|
||||
pub fn crashed(crash: &Crash<'_>) {
|
||||
for hooks in all() {
|
||||
hooks.crashed(crash);
|
||||
}
|
||||
}
|
||||
/// [`Hooks::tunnel`]: the first registered hook's resolver.
|
||||
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
|
||||
all().iter().find_map(|hooks| hooks.tunnel())
|
||||
|
||||
@@ -15,6 +15,7 @@ horizon-game = { path = "../horizon-game" }
|
||||
horizon-platform = { path = "../horizon-platform" }
|
||||
horizon-heartbeat = { path = "../horizon-heartbeat" }
|
||||
horizon-friends = { path = "../horizon-friends", features = ["game"] }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["game"] }
|
||||
log = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
use horizon_platform::hooks::{self, Hooks};
|
||||
|
||||
/// Every Horizon part in the game, in the order its hooks run.
|
||||
const PARTS: [&dyn Hooks; 2] = [
|
||||
const PARTS: [&dyn Hooks; 3] = [
|
||||
// The anonymous "I'm playing" ping.
|
||||
&horizon_heartbeat::HOOKS,
|
||||
// Play with Friends: `friend:` addresses join through its tunnel.
|
||||
&horizon_friends::game::HOOKS,
|
||||
// Opt-in telemetry: the play session's summary and crash reports, kept for the launcher.
|
||||
&horizon_telemetry::game::HOOKS,
|
||||
];
|
||||
|
||||
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
|
||||
|
||||
@@ -47,7 +47,7 @@ pub use horizon_server_files as server_files;
|
||||
/// in"). The engine's dispatch side is not part of the API.
|
||||
pub mod hooks {
|
||||
pub use horizon_hooks::{
|
||||
Frame, Hooks, Milestone, Started, TooLate, WorldLeft, register, tunnel,
|
||||
Crash, Display, Frame, Gpu, Hooks, Milestone, Started, TooLate, WorldLeft, register, tunnel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,16 @@ ruzstd = "0.9"
|
||||
getrandom = "0.3"
|
||||
# Sending, for the launcher (and the game's "I'm playing" ping); the ingest server builds without it.
|
||||
ureq = { version = "2", default-features = false, features = ["tls"], optional = true }
|
||||
log = { version = "0.4", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["upload", "live"]
|
||||
upload = ["dep:ureq"]
|
||||
# The "I'm playing" ping and reading the live count (`live`), for the game and the launcher.
|
||||
live = ["dep:ureq"]
|
||||
# The game's part (`game`): the play session's summary and crash reports, kept through the
|
||||
# platform's hooks. The official executable turns it on.
|
||||
game = ["dep:log"]
|
||||
|
||||
[[test]]
|
||||
name = "consent_gating"
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
//! The game's part in opt-in data collection (`horizon_telemetry`, docs/telemetry.md). When the
|
||||
//! player allows it, the game keeps a scrubbed crash report, or a play session's performance
|
||||
//! The game's part in opt-in data collection (docs/telemetry.md), behind the `game` feature. When
|
||||
//! the player allows it, the game keeps a scrubbed crash report, or a play session's performance
|
||||
//! summary, in the outbox. It never sends those itself: the launcher does, at its next start (the
|
||||
//! game's only sending is the anonymous "I'm playing" ping, `heartbeat.rs`).
|
||||
//! Without the player's yes for that kind nothing is kept, and crash-reports/ stays local.
|
||||
//! game's only sending is the anonymous "I'm playing" ping, `horizon-heartbeat`). Without the
|
||||
//! player's yes for that kind nothing is kept, and crash-reports/ stays local.
|
||||
//!
|
||||
//! The play session's summary is built from the game's hooks ([`HOOKS`], `horizon_hooks`), as a
|
||||
//! Horizon crate does: it leaves the engine for the Horizon layer when the official executable
|
||||
//! ships (layer plan M5, docs/layers.md).
|
||||
//! It hears the game only through the platform's hooks ([`HOOKS`], `horizon_platform::hooks`);
|
||||
//! the official executable registers it (`horizon_official::PARTS`).
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use horizon_hooks::{Frame, Hooks, Milestone, WorldLeft};
|
||||
use horizon_platform::hooks::{self, Frame, Hooks, Milestone, WorldLeft};
|
||||
|
||||
use horizon_telemetry::payload::{Crash, Display, Perf, Settings, System, Timings};
|
||||
use horizon_telemetry::scrub::Scrubber;
|
||||
use horizon_telemetry::{Item, Payload, Source, outbox};
|
||||
use crate::payload::{Crash, Display, Perf, Settings, System, Timings};
|
||||
use crate::scrub::Scrubber;
|
||||
use crate::{Item, Payload, Source, outbox};
|
||||
|
||||
/// A session shorter than this says too little to keep.
|
||||
const MIN_SECONDS: f32 = 60.0;
|
||||
@@ -30,7 +29,7 @@ static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
/// The screen the window opened on.
|
||||
static DISPLAY: Mutex<Option<Display>> = Mutex::new(None);
|
||||
|
||||
/// The game's hooks, for `horizon_hooks::register`.
|
||||
/// The game's hooks, for `horizon_platform::hooks::register`.
|
||||
pub static HOOKS: Telemetry = Telemetry {
|
||||
session: LazyLock::new(|| Mutex::new(Session::default())),
|
||||
};
|
||||
@@ -58,7 +57,7 @@ impl Hooks for Telemetry {
|
||||
/// A player who allows performance statistics also says what their slow frames spent their
|
||||
/// time in.
|
||||
fn wants_frame_steps(&self, home: &Path) -> bool {
|
||||
horizon_telemetry::Consent::load(home).allows(horizon_telemetry::Kind::Perf)
|
||||
crate::Consent::load(home).allows(crate::Kind::Perf)
|
||||
}
|
||||
|
||||
fn world_shown(&self, since_opening: Duration) {
|
||||
@@ -80,7 +79,21 @@ impl Hooks for Telemetry {
|
||||
fullscreen: left.fullscreen,
|
||||
graphics: left.graphics.to_owned(),
|
||||
};
|
||||
self.session().finish(left.home, settings);
|
||||
self.session()
|
||||
.finish(left.home, left.version, left.gpu, settings);
|
||||
}
|
||||
|
||||
fn display(&self, display: &hooks::Display) {
|
||||
*DISPLAY.lock().unwrap_or_else(|e| e.into_inner()) = Some(Display {
|
||||
width: display.width,
|
||||
height: display.height,
|
||||
refresh_hz: display.refresh_hz.unwrap_or(0),
|
||||
scale: display.scale as f32,
|
||||
});
|
||||
}
|
||||
|
||||
fn crashed(&self, crash: &hooks::Crash<'_>) {
|
||||
keep_crash(crash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,54 +107,32 @@ fn readiness_ms(mark: &AtomicU32) -> Option<u32> {
|
||||
(ms != u32::MAX).then_some(ms)
|
||||
}
|
||||
|
||||
/// The screen the window is on: size in physical pixels, refresh rate, scale.
|
||||
pub fn set_display(width: u32, height: u32, refresh_hz: u32, scale: f64) {
|
||||
*DISPLAY.lock().unwrap_or_else(|e| e.into_inner()) = Some(Display {
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
scale: scale as f32,
|
||||
});
|
||||
}
|
||||
|
||||
/// Keeps the screen `window` is on ([`crate::window::display`]) for the items' `system.display`.
|
||||
pub fn note_display(window: &winit::window::Window) {
|
||||
if let Some(d) = crate::window::display(window) {
|
||||
set_display(d.width, d.height, d.refresh_hz.unwrap_or(0), d.scale);
|
||||
}
|
||||
}
|
||||
|
||||
/// This machine, with the GPU and the screen once the renderer knows them. The drive is the one
|
||||
/// This machine, with the GPU once the renderer has one and the screen. The drive is the one
|
||||
/// under the game folder (the current folder).
|
||||
pub fn system() -> System {
|
||||
pub fn system(gpu: Option<&hooks::Gpu>) -> System {
|
||||
let home = std::env::current_dir().ok();
|
||||
let mut system = System::detect_in(home.as_deref());
|
||||
if let Some(gpu) = crate::gfx::adapter() {
|
||||
if let Some(gpu) = gpu {
|
||||
system.vram_mb = match gpu.kind.as_str() {
|
||||
"discrete" => horizon_telemetry::system::vram_mb(&gpu.vendor).unwrap_or(0),
|
||||
"discrete" => crate::system::vram_mb(&gpu.vendor).unwrap_or(0),
|
||||
_ => 0,
|
||||
};
|
||||
system.gpu = gpu.name;
|
||||
system.gpu_vendor = gpu.vendor;
|
||||
system.gpu_kind = gpu.kind;
|
||||
system.driver = gpu.driver;
|
||||
system.backend = gpu.backend;
|
||||
system.gpu = gpu.name.clone();
|
||||
system.gpu_vendor = gpu.vendor.clone();
|
||||
system.gpu_kind = gpu.kind.clone();
|
||||
system.driver = gpu.driver.clone();
|
||||
system.backend = gpu.backend.clone();
|
||||
}
|
||||
system.display = DISPLAY.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
system
|
||||
}
|
||||
|
||||
/// The release (`v0.2.3`), or the client's crate version for a build from source.
|
||||
pub fn version() -> String {
|
||||
crate::crash::release_tag().unwrap_or_else(|| format!("dev {}", env!("CARGO_PKG_VERSION")))
|
||||
}
|
||||
|
||||
/// What a crash report becomes when it may leave: the error and stack, the thread, and the
|
||||
/// details in [`CRASH_DETAILS`], with the world's name and folder and the server's address
|
||||
/// scrubbed wherever they appear. No log lines.
|
||||
pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
pub fn crash_item(report: &hooks::Crash<'_>) -> Item {
|
||||
let mut scrubber = Scrubber::new();
|
||||
for (key, value) in &report.details {
|
||||
for (key, value) in report.details {
|
||||
match key.as_str() {
|
||||
// `Name (saves/folder)`, as game.rs sets it.
|
||||
"World" => {
|
||||
@@ -156,10 +147,10 @@ pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
}
|
||||
}
|
||||
let crash = Crash {
|
||||
system: system(),
|
||||
error: scrubber.scrub(&report.error),
|
||||
stack: scrubber.scrub(&report.stack),
|
||||
thread: report.thread.clone(),
|
||||
system: system(report.gpu),
|
||||
error: scrubber.scrub(report.error),
|
||||
stack: scrubber.scrub(report.stack),
|
||||
thread: report.thread.to_owned(),
|
||||
details: report
|
||||
.details
|
||||
.iter()
|
||||
@@ -167,7 +158,7 @@ pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
.map(|(k, v)| (k.clone(), scrubber.scrub(v)))
|
||||
.collect(),
|
||||
};
|
||||
let mut item = Item::new(Source::Game, version(), Payload::Crash(crash));
|
||||
let mut item = Item::new(Source::Game, report.version, Payload::Crash(crash));
|
||||
// A huge stack keeps its top, where the error is.
|
||||
while item.validate().is_err() {
|
||||
let Payload::Crash(crash) = &mut item.payload else {
|
||||
@@ -189,26 +180,12 @@ pub fn crash_item(report: &crate::crash::Report) -> Item {
|
||||
}
|
||||
|
||||
/// Keeps the crash for the launcher to send, if the player allows crash reports.
|
||||
pub fn keep_crash(home: &Path, report: &crate::crash::Report) {
|
||||
if let Err(e) = outbox::push(home, &crash_item(report)) {
|
||||
pub fn keep_crash(report: &hooks::Crash<'_>) {
|
||||
if let Err(e) = outbox::push(report.home, &crash_item(report)) {
|
||||
log::warn!("Horizon: could not keep the crash report to send: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps a benchmark report (`--benchmark`) for the launcher to send, if the player allows
|
||||
/// benchmark uploads: the report as docs/benchmark-report.md defines it, dated to the day.
|
||||
pub fn keep_benchmark(home: &Path, report: &horizon_bench::Report) {
|
||||
let Ok(data) = serde_json::to_value(report.for_upload()) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = outbox::push(
|
||||
home,
|
||||
&Item::new(Source::Game, version(), Payload::Benchmark(data)),
|
||||
) {
|
||||
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One play session's performance, from entering a world to leaving it: frame and tick
|
||||
/// times as [`Timings`] (fixed size whatever the length), never the frames themselves.
|
||||
#[derive(Default)]
|
||||
@@ -220,7 +197,7 @@ pub struct Session {
|
||||
/// The start was measured: only the first session carries it.
|
||||
title_measured: bool,
|
||||
to_world_ms: Option<u32>,
|
||||
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
|
||||
slow_frames: Vec<crate::payload::SlowFrames>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -244,7 +221,7 @@ impl Session {
|
||||
/// more counts under the step it spent most of its time in (`slowest_step`, the game's
|
||||
/// `spans::culprit`).
|
||||
pub fn frame(&mut self, ms: f32, slowest_step: impl FnOnce() -> Option<(String, f32)>) {
|
||||
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
|
||||
use crate::payload::{SLOW_FRAME_MS, SlowFrames};
|
||||
if ms >= SLOW_FRAME_MS {
|
||||
let (mut path, span_ms) = slowest_step().unwrap_or_default();
|
||||
// A path too long for the item keeps its last steps.
|
||||
@@ -265,10 +242,10 @@ impl Session {
|
||||
}
|
||||
|
||||
/// The session's summary, if it was long enough to say something.
|
||||
pub fn summary(&self, settings: Settings) -> Option<Perf> {
|
||||
pub fn summary(&self, gpu: Option<&hooks::Gpu>, settings: Settings) -> Option<Perf> {
|
||||
let seconds = self.played_ms / 1000.0;
|
||||
(seconds >= MIN_SECONDS).then(|| Perf {
|
||||
system: system(),
|
||||
system: system(gpu),
|
||||
settings,
|
||||
seconds: seconds as u32,
|
||||
frames: self.frames.summary(),
|
||||
@@ -284,11 +261,17 @@ impl Session {
|
||||
|
||||
/// Keeps the session's summary for the launcher to send, if the player allows performance
|
||||
/// statistics, and starts the next session (the time to the title goes with the first).
|
||||
pub fn finish(&mut self, home: &Path, settings: Settings) {
|
||||
if horizon_telemetry::Consent::load(home).allows(horizon_telemetry::Kind::Perf)
|
||||
&& let Some(perf) = self.summary(settings)
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
home: &Path,
|
||||
version: &str,
|
||||
gpu: Option<&hooks::Gpu>,
|
||||
settings: Settings,
|
||||
) {
|
||||
if crate::Consent::load(home).allows(crate::Kind::Perf)
|
||||
&& let Some(perf) = self.summary(gpu, settings)
|
||||
{
|
||||
let item = Item::new(Source::Game, version(), Payload::Perf(perf));
|
||||
let item = Item::new(Source::Game, version, Payload::Perf(perf));
|
||||
if let Err(e) = outbox::push(home, &item) {
|
||||
log::warn!("Horizon: could not keep the session's performance summary: {e}");
|
||||
}
|
||||
@@ -333,7 +316,7 @@ fn peak_ram_mb() -> u32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_telemetry::{Choices, Consent, Kind};
|
||||
use crate::{Choices, Consent, Kind};
|
||||
|
||||
fn home(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
@@ -351,16 +334,33 @@ mod tests {
|
||||
consent.save(home).unwrap();
|
||||
}
|
||||
|
||||
fn report() -> crate::crash::Report {
|
||||
crate::crash::Report {
|
||||
time: chrono::Local::now(),
|
||||
description: "Unexpected error".into(),
|
||||
/// A crash report's parts, as `crash.rs` gives them to the hooks.
|
||||
struct Report {
|
||||
error: String,
|
||||
stack: String,
|
||||
details: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn crash<'a>(&'a self, home: &'a Path) -> hooks::Crash<'a> {
|
||||
hooks::Crash {
|
||||
home,
|
||||
version: "v2026.10.15",
|
||||
error: &self.error,
|
||||
stack: &self.stack,
|
||||
thread: "Render thread",
|
||||
details: &self.details,
|
||||
gpu: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report() -> Report {
|
||||
Report {
|
||||
error:
|
||||
"panicked at src/save.rs:10:5:\nno level.dat in /Users/ana/horizon/saves/Ana's Base"
|
||||
.into(),
|
||||
stack: "horizon_client::save::load (crates/horizon-client/src/save.rs:10:5)\n".into(),
|
||||
thread: "Render thread".into(),
|
||||
side: "client",
|
||||
details: vec![
|
||||
(
|
||||
"Horizon Version".into(),
|
||||
@@ -374,13 +374,13 @@ mod tests {
|
||||
("Server".into(), "play.example.net:25565".into()),
|
||||
("Resource Packs".into(), "Ana's pack".into()),
|
||||
],
|
||||
log: vec!["[CHAT] <Ana> hi".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crash_keeps_only_what_says_what_happened() {
|
||||
let item = crash_item(&report());
|
||||
let report = report();
|
||||
let item = crash_item(&report.crash(Path::new("home")));
|
||||
let Payload::Crash(crash) = &item.payload else {
|
||||
panic!("a crash item");
|
||||
};
|
||||
@@ -396,13 +396,7 @@ mod tests {
|
||||
)]
|
||||
);
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
for private in [
|
||||
"Castle Ana",
|
||||
"Ana's Base",
|
||||
"example.net",
|
||||
"Ana's pack",
|
||||
"CHAT",
|
||||
] {
|
||||
for private in ["Castle Ana", "Ana's Base", "example.net", "Ana's pack"] {
|
||||
assert!(!json.contains(private), "{private} in {json}");
|
||||
}
|
||||
assert!(item.validate().is_ok());
|
||||
@@ -413,7 +407,7 @@ mod tests {
|
||||
let mut report = report();
|
||||
report.stack =
|
||||
"horizon_client::game::deep (crates/horizon-client/src/game.rs:1:1)\n".repeat(2000);
|
||||
let item = crash_item(&report);
|
||||
let item = crash_item(&report.crash(Path::new("home")));
|
||||
assert!(item.validate().is_ok());
|
||||
let Payload::Crash(crash) = item.payload else {
|
||||
panic!("a crash item");
|
||||
@@ -421,43 +415,11 @@ mod tests {
|
||||
assert!(crash.stack.lines().count() > 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn benchmark_reports_need_their_own_consent_and_go_dated_to_the_day() {
|
||||
let home = home("benchmark");
|
||||
let mut report = horizon_bench::Report::new(horizon_bench::Kind::Game, Default::default());
|
||||
report.created = "2026-10-15T14:03:27Z".into();
|
||||
report.label = Some("my machine".into());
|
||||
keep_benchmark(&home, &report);
|
||||
allow(
|
||||
&home,
|
||||
Choices {
|
||||
performance: true,
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_benchmark(&home, &report);
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(
|
||||
&home,
|
||||
Choices {
|
||||
benchmarks: true,
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_benchmark(&home, &report);
|
||||
let pending = outbox::pending(&home);
|
||||
assert_eq!(pending.len(), 1);
|
||||
let text = serde_json::to_string(&pending[0].1).unwrap();
|
||||
assert!(
|
||||
text.contains("2026-10-15T00:00:00Z") && !text.contains("my machine"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashes_stay_local_without_consent() {
|
||||
let home = home("crash");
|
||||
keep_crash(&home, &report());
|
||||
let report = report();
|
||||
keep_crash(&report.crash(&home));
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(
|
||||
&home,
|
||||
@@ -466,7 +428,7 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_crash(&home, &report());
|
||||
keep_crash(&report.crash(&home));
|
||||
assert!(outbox::pending(&home).is_empty());
|
||||
allow(
|
||||
&home,
|
||||
@@ -475,7 +437,7 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
keep_crash(&home, &report());
|
||||
keep_crash(&report.crash(&home));
|
||||
let pending = outbox::pending(&home);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].1.kind(), Kind::Crash);
|
||||
@@ -495,7 +457,7 @@ mod tests {
|
||||
let mut session = Session::default();
|
||||
session.title_shown(Duration::from_millis(3100));
|
||||
assert_eq!(session.to_title_ms, Some(3100));
|
||||
session.finish(&home, Settings::default());
|
||||
session.finish(&home, "dev", None, Settings::default());
|
||||
session.title_shown(Duration::from_millis(9000));
|
||||
assert_eq!(session.to_title_ms, None);
|
||||
}
|
||||
@@ -506,7 +468,7 @@ mod tests {
|
||||
let mut session = Session::default();
|
||||
session.world_shown(Duration::from_millis(1500));
|
||||
(0..2000).for_each(|_| session.frame(16.6, || None));
|
||||
session.finish(&home, Settings::default());
|
||||
session.finish(&home, "dev", None, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
|
||||
allow(
|
||||
&home,
|
||||
@@ -516,13 +478,15 @@ mod tests {
|
||||
},
|
||||
);
|
||||
(0..2000).for_each(|_| session.frame(16.6, || None));
|
||||
session.finish(&home, Settings::default());
|
||||
session.finish(&home, "dev", None, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
|
||||
session.world_shown(Duration::from_millis(1500));
|
||||
(0..4000).for_each(|_| session.frame(16.6, || None));
|
||||
(0..1200).for_each(|_| session.tick(4.0));
|
||||
session.finish(
|
||||
&home,
|
||||
"dev",
|
||||
None,
|
||||
Settings {
|
||||
render_distance: 12,
|
||||
..Settings::default()
|
||||
@@ -545,23 +509,15 @@ mod tests {
|
||||
/// span counts under none. Faster frames leave no entry.
|
||||
#[test]
|
||||
fn slow_frames_are_put_down_to_their_step() {
|
||||
use crate::spans::Span;
|
||||
let span = |name, ms, depth| Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth,
|
||||
};
|
||||
let culprit = |spans: &[Span]| crate::spans::culprit(spans);
|
||||
// What the game's `spans::culprit` names for each frame.
|
||||
let step = |path: &'static str, ms: f32| move || Some((path.to_owned(), ms));
|
||||
let mut session = Session::default();
|
||||
let ticking = [span("tick", 10.0, 0)];
|
||||
(0..4000).for_each(|_| session.frame(16.6, || culprit(&ticking)));
|
||||
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
|
||||
session.frame(320.0, || culprit(&frozen));
|
||||
session.frame(900.0, || culprit(&frozen));
|
||||
session.frame(260.0, || culprit(&[]));
|
||||
session.frame(249.0, || culprit(&frozen));
|
||||
let perf = session.summary(Settings::default()).unwrap();
|
||||
(0..4000).for_each(|_| session.frame(16.6, step("tick", 10.0)));
|
||||
session.frame(320.0, step("renderer/r_uploads", 280.0));
|
||||
session.frame(900.0, step("renderer/r_uploads", 280.0));
|
||||
session.frame(260.0, || None);
|
||||
session.frame(249.0, step("renderer/r_uploads", 280.0));
|
||||
let perf = session.summary(None, Settings::default()).unwrap();
|
||||
let seen: Vec<_> = perf
|
||||
.slow_frames
|
||||
.iter()
|
||||
@@ -15,6 +15,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub mod batch;
|
||||
pub mod consent;
|
||||
#[cfg(feature = "game")]
|
||||
pub mod game;
|
||||
pub mod live;
|
||||
pub mod outbox;
|
||||
pub mod payload;
|
||||
|
||||
@@ -65,7 +65,7 @@ command above to inspect the selection rather than relying on historical counts.
|
||||
| 36 | Audio and music (38) | `R/audio.rs`, `R/sound_engine.rs`, `R/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle |
|
||||
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) |
|
||||
| 38 | Command parsing (34) | `C/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); `cargo test -p horizon-command` | command_parse (corpus `tools/command_parse_corpus.py`) |
|
||||
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `L/telemetry.rs`, `tools/perf_bench.py`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
|
||||
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `crates/horizon-telemetry/src/game.rs`, `tools/perf_bench.py`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
|
||||
| 40 | Explosions and TNT (31) | `G/explosion.rs`, `G/tnt.rs` | lan_e2e blast; no dedicated harness yet |
|
||||
|
||||
Controller glyphs (`R/pad_glyph.rs`, drawn by the gameplay guide, notices, splitscreen prompts and declared
|
||||
|
||||
+9
-8
@@ -290,14 +290,15 @@ The hooks today: `started` (once, when someone plays: not a dedicated server, a
|
||||
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
|
||||
world saved), and the play session's: `startup` (core-ready, rest-ready, the title's first frame, once each),
|
||||
`wants_frame_steps` (asked once: time the render thread's steps), `world_shown` (a world's first frame), `frame`
|
||||
(every frame played) and `world_left` (the session's end, with its settings); and `tunnel` (the resolver of
|
||||
`friend:` addresses, Play with Friends'). Opt-in telemetry's performance summary (`telemetry.rs`) is built from
|
||||
these. **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
|
||||
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
|
||||
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
|
||||
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
|
||||
that belongs to one subsystem (an input source, the compositor) lives in the engine crate that owns it, with the
|
||||
same shape: registered before the game runs, read-only after.
|
||||
(every frame played), `world_left` (the session's end, with its settings), `display` (the screen the window is on)
|
||||
and `crashed` (each crash, before its report is saved); and `tunnel` (the resolver of `friend:` addresses, Play
|
||||
with Friends'). Opt-in telemetry (`horizon_telemetry::game`, registered by `horizon-official`) hears the game
|
||||
through these only. **A hook you need and don't find:** add a method with an empty default to `Hooks` and a
|
||||
function in `horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data
|
||||
or a type the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a
|
||||
draw context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension
|
||||
point that belongs to one subsystem (an input source, the compositor) lives in the engine crate that owns it, with
|
||||
the same shape: registered before the game runs, read-only after.
|
||||
|
||||
**What players get.** The release builds (`game-build.yml`, so the nightly and the release; `tools/ci/build-musl.sh`)
|
||||
build `horizon-official` and ship it as `horizon-client`, the name the launcher and players know. The launcher's
|
||||
|
||||
Reference in new issue
Block a user