mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 02:29:50 -10:00
Compare commits
29
Commits
No files matched your search
Generated
+1
@@ -1395,6 +1395,7 @@ dependencies = [
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-telemetry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -8,3 +8,5 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
@@ -16,8 +16,22 @@ pub struct Hardware {
|
||||
pub cpu: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_threads: Option<usize>,
|
||||
/// Physical cores.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_cores: Option<u16>,
|
||||
/// Base and top boost clock, where the OS gives them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_base_mhz: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_boost_mhz: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ram_mb: Option<u64>,
|
||||
/// The drive under the game folder: `nvme`, `ssd` or `hdd`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub storage: Option<String>,
|
||||
/// `laptop` or `desktop`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub form: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gpu: Option<Gpu>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -33,6 +47,9 @@ pub struct Gpu {
|
||||
pub backend: String,
|
||||
/// `integrated`, `discrete`, `virtual`, `cpu`, `other`.
|
||||
pub kind: String,
|
||||
/// Its own memory in MiB (rounded to 256), for a discrete GPU whose driver says.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vram_mb: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -47,17 +64,50 @@ pub struct Display {
|
||||
|
||||
impl Hardware {
|
||||
pub fn detect() -> Hardware {
|
||||
let probes = probes();
|
||||
Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
display: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
|
||||
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
|
||||
Hardware {
|
||||
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
|
||||
..Hardware::detect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the telemetry probes read (`horizon_telemetry::system`).
|
||||
struct Probes {
|
||||
cores: Option<u16>,
|
||||
base: Option<u32>,
|
||||
boost: Option<u32>,
|
||||
form: Option<String>,
|
||||
}
|
||||
|
||||
fn probes() -> Probes {
|
||||
let s = horizon_telemetry::payload::System::detect();
|
||||
let some = |n: u32| (n > 0).then_some(n);
|
||||
Probes {
|
||||
cores: (s.cores > 0).then_some(s.cores),
|
||||
base: some(s.cpu_base_mhz),
|
||||
boost: some(s.cpu_boost_mhz),
|
||||
form: (!s.form.is_empty()).then_some(s.form),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
@@ -82,25 +132,7 @@ pub fn load_average() -> Option<f64> {
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let batt = output("pmset", &["-g", "batt"])?;
|
||||
return Some(if batt.contains("'Battery Power'") {
|
||||
"battery"
|
||||
} else {
|
||||
"ac"
|
||||
});
|
||||
}
|
||||
let mut on_battery = None;
|
||||
for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
|
||||
let read =
|
||||
|name: &str| std::fs::read_to_string(entry.path().join(name)).unwrap_or_default();
|
||||
match read("type").trim() {
|
||||
"Mains" if read("online").trim() == "1" => return Some("ac"),
|
||||
"Battery" => on_battery = Some("battery"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
on_battery
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
|
||||
@@ -140,7 +140,22 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
driver: text(&renderer["driver"]),
|
||||
backend: text(&renderer["backend"]).to_lowercase(),
|
||||
kind: text(&renderer["device_type"]),
|
||||
vram_mb: None,
|
||||
});
|
||||
if let Some(gpu) = report.hardware.gpu.as_mut() {
|
||||
let vendor = horizon_telemetry::system::gpu_vendor(&gpu.name, 0);
|
||||
let vendor = if gpu.vendor.is_empty() {
|
||||
vendor
|
||||
} else {
|
||||
gpu.vendor.as_str()
|
||||
}
|
||||
.to_owned();
|
||||
if gpu.kind.contains("iscrete") {
|
||||
gpu.vram_mb = horizon_telemetry::system::vram_mb(&vendor);
|
||||
}
|
||||
}
|
||||
report.hardware.storage =
|
||||
horizon_telemetry::system::storage(&setup.game_dir).map(str::to_owned);
|
||||
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
|
||||
|
||||
replace_world(&setup.game_dir)?;
|
||||
@@ -224,6 +239,10 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
|
||||
let status = control.status();
|
||||
let renderer = &status["renderer"];
|
||||
// On Wayland the window is on a screen only once it has been shown: ask again.
|
||||
if report.hardware.display.is_none() {
|
||||
report.hardware.display = serde_json::from_value(status["display"].clone()).ok();
|
||||
}
|
||||
let c = &mut report.conditions;
|
||||
c.scenario = Some(setup.scenario.clone());
|
||||
c.seed = Some(SEED.to_string());
|
||||
|
||||
@@ -32,16 +32,18 @@ pub enum Group {
|
||||
Move,
|
||||
Act,
|
||||
Screens,
|
||||
Multiplayer,
|
||||
World,
|
||||
Scripting,
|
||||
}
|
||||
impl Group {
|
||||
pub const ALL: [Group; 7] = [
|
||||
pub const ALL: [Group; 8] = [
|
||||
Group::Session,
|
||||
Group::Observe,
|
||||
Group::Move,
|
||||
Group::Act,
|
||||
Group::Screens,
|
||||
Group::Multiplayer,
|
||||
Group::World,
|
||||
Group::Scripting,
|
||||
];
|
||||
@@ -52,6 +54,7 @@ impl Group {
|
||||
Group::Move => "Move and look",
|
||||
Group::Act => "Act on the world",
|
||||
Group::Screens => "Screens, menus and chat",
|
||||
Group::Multiplayer => "Multiplayer, LAN and friends",
|
||||
Group::World => "World and cheats",
|
||||
Group::Scripting => "Scripting and benchmarks",
|
||||
}
|
||||
@@ -117,6 +120,50 @@ const OVER: Param = flag(
|
||||
"turn with mouse motion spread over this many seconds (eased), as a player would",
|
||||
);
|
||||
const FACE: Ty = Ty::Enum(&["down", "up", "north", "south", "west", "east"]);
|
||||
const BOX: [Param; 6] = [
|
||||
p("x1", Ty::Int, "corner X"),
|
||||
p("y1", Ty::Int, "corner Y"),
|
||||
p("z1", Ty::Int, "corner Z"),
|
||||
p("x2", Ty::Int, "opposite X"),
|
||||
p("y2", Ty::Int, "opposite Y"),
|
||||
p("z2", Ty::Int, "opposite Z"),
|
||||
];
|
||||
const PAGES: Ty = Ty::Enum(&[
|
||||
"main",
|
||||
"video",
|
||||
"sound",
|
||||
"controls",
|
||||
"keybinds",
|
||||
"controller",
|
||||
"controller-binds",
|
||||
"mouse",
|
||||
"chat",
|
||||
"accessibility",
|
||||
"language",
|
||||
"font",
|
||||
"packs",
|
||||
"mods",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"extras",
|
||||
"horizon",
|
||||
"shader-packs",
|
||||
"agent",
|
||||
]);
|
||||
|
||||
/// `--confirm`, required by destructive commands: the player agreed.
|
||||
const CONFIRM: Param = Param {
|
||||
name: "confirm",
|
||||
ty: Ty::Bool,
|
||||
required: true,
|
||||
flag: true,
|
||||
help: "true once the player agreed: it cannot be undone",
|
||||
};
|
||||
|
||||
/// MCP tools that delete or overwrite the player's things (`destructiveHint`).
|
||||
pub const DESTRUCTIVE: &[&str] = &["world-delete", "fill", "setblock"];
|
||||
/// MCP tools that reach other players or the network (`openWorldHint`): only when the player asks.
|
||||
pub const OPEN_WORLD: &[&str] = &["connect", "lan", "friends", "chat", "command"];
|
||||
|
||||
macro_rules! cmd {
|
||||
($name:expr, $group:ident, $usage:expr, $summary:expr, $details:expr, [$($ex:expr),* $(,)?], $params:expr, mcp: $mcp:expr, local: $local:expr $(, wire: $wire:expr)?) => {
|
||||
@@ -164,7 +211,19 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[flag("lines", Ty::Int, "how many lines of latest.log (default 100)"), flag("crash", Ty::Bool, "the newest crash report instead")],
|
||||
mcp: true, local: true),
|
||||
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
|
||||
"", ["horizon-ctl status"], &[], mcp: false, local: false),
|
||||
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
|
||||
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
|
||||
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
|
||||
"Needs the title screen (no world open).", ["horizon-ctl world-rename Test \"My Base\""],
|
||||
&[p("folder", Ty::Str, "world folder from worlds"), p("name", Ty::Rest, "the new name")], mcp: true, local: false),
|
||||
cmd!("world-backup", Session, "world-backup FOLDER", "zip a world into backups/, as Edit World's Make Backup does",
|
||||
"Needs the title screen. Replies once the zip is written, with its size.", ["horizon-ctl world-backup Test"],
|
||||
&[p("folder", Ty::Str, "world folder from worlds")], mcp: true, local: false),
|
||||
cmd!("world-delete", Session, "world-delete FOLDER --confirm", "delete a world for good, as Select World's Delete does",
|
||||
"Needs the title screen and --confirm: ask the player first, it cannot be undone (world-backup keeps a copy).",
|
||||
["horizon-ctl world-delete Test --confirm"],
|
||||
&[p("folder", Ty::Str, "world folder from worlds"), CONFIRM], mcp: true, local: false),
|
||||
cmd!("open", Session, "open WORLD [SEED] [creative]", "open or create a world from the title screen",
|
||||
"Opens saves/WORLD, creating it with SEED (and creative mode) when it doesn't exist.",
|
||||
["horizon-ctl open Test 12345", "horizon-ctl open Build 0 creative"],
|
||||
@@ -190,10 +249,10 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("light", Observe, "light X1 Y1 Z1 X2 Y2 Z2", "the stored sky and block light of a box",
|
||||
"At most 32768 blocks per request. One hex digit per block in `sky` and `block`, x fastest, then z, then y; `.` where no chunk is loaded.",
|
||||
["horizon-ctl light 0 64 0 15 70 15"],
|
||||
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
|
||||
&BOX, mcp: true, local: false),
|
||||
cmd!("blocks", Observe, "blocks X1 Y1 Z1 X2 Y2 Z2", "the non-air blocks of a box, with counts per block",
|
||||
"At most 32768 blocks per request.", ["horizon-ctl blocks 0 60 0 15 70 15"],
|
||||
&[p("x1", Ty::Int, "corner X"), p("y1", Ty::Int, "corner Y"), p("z1", Ty::Int, "corner Z"), p("x2", Ty::Int, "opposite X"), p("y2", Ty::Int, "opposite Y"), p("z2", Ty::Int, "opposite Z")], mcp: true, local: false),
|
||||
&BOX, mcp: true, local: false),
|
||||
cmd!("find-block", Observe, "find-block BLOCK [RADIUS]", "the nearest loaded blocks of a kind",
|
||||
"Searches a cube of RADIUS (default 32, at most 64) around the player; returns the count and the 16 nearest.",
|
||||
["horizon-ctl find-block oak_log", "horizon-ctl find-block diamond_ore 48"],
|
||||
@@ -264,12 +323,15 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
"down holds the key until key ... up, release or stop; wait does not release it. move, goto, jump, break and the other actions release the movement keys when they end.",
|
||||
["horizon-ctl key space tap"], &[p("key", Ty::Str, "key name"), p("action", Ty::Enum(&["down", "up", "tap", "repeat"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("mouse", Act, "mouse left|right|middle down|up|click", "press or release a mouse button in the world", "",
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: false, local: false),
|
||||
["horizon-ctl mouse right click"], &[p("button", Ty::Enum(&["left", "right", "middle"]), "button"), p("action", Ty::Enum(&["down", "up", "click"]), "what to do")], mcp: true, local: false),
|
||||
cmd!("interact-entity", Act, "interact-entity ID", "right-click an entity: trade with a villager, mount a horse, ride a boat, shear a sheep",
|
||||
"Faces the entity (from entities), right-clicks it with the held item, waits two ticks and replies with the screen.",
|
||||
["horizon-ctl interact-entity 42"], &[p("id", Ty::Int, "entity id from entities")], mcp: true, local: true),
|
||||
cmd!("pad", Act, "pad BUTTON down|up|tap | pad stick left|right X Y | pad trigger left|right V | pad release | pad status", "a virtual controller: buttons, sticks and triggers as a real pad would send them",
|
||||
"Buttons by position: south east west north lb rb lt rt select start ls rs up down left right (south is Xbox A / PlayStation cross). Sticks run -1..1 with up positive and stay tilted until centred; triggers 0..1. Merged with any real controller; status lists the connected ones and what is held.",
|
||||
["horizon-ctl pad south tap", "horizon-ctl pad stick left 0 1", "horizon-ctl pad trigger right 1", "horizon-ctl pad status"],
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: false, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: false, local: false),
|
||||
&[p("what", Ty::Str, "a button name, stick, trigger, release or status"), opt("a", Ty::Str, "down|up|tap, or left|right"), opt("b", Ty::Num, "stick X or trigger value"), opt("c", Ty::Num, "stick Y")], mcp: true, local: false),
|
||||
cmd!("release", Act, "release", "release every key and mouse button", "", ["horizon-ctl release"], &[], mcp: true, local: false),
|
||||
cmd!("stop", Act, "stop", "cancel the running action (goto, break...) and release every key", "", ["horizon-ctl stop"], &[], mcp: true, local: false),
|
||||
// Screens
|
||||
cmd!("open-inventory", Screens, "open-inventory", "open the inventory screen (crafting grid 100-103, result 200)", "",
|
||||
@@ -317,7 +379,31 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("respawn", Screens, "respawn", "respawn from the death screen", "", ["horizon-ctl respawn"], &[], mcp: true, local: false),
|
||||
cmd!("pause", Screens, "pause", "open the pause menu", "", ["horizon-ctl pause"], &[], mcp: true, local: false),
|
||||
cmd!("resume", Screens, "resume", "leave the pause menu", "", ["horizon-ctl resume"], &[], mcp: true, local: false),
|
||||
cmd!("options", Screens, "options [PAGE]", "open the options screen, or one of its pages (Horizon's too)",
|
||||
"Then ui lists its widgets and click, set and type change them, as with the mouse. options-set changes an options.txt value without the screen.",
|
||||
["horizon-ctl options video", "horizon-ctl options horizon"], &[opt("page", PAGES, "the page (default: the main options)")], mcp: true, local: false),
|
||||
cmd!("book", Screens, "book [next|back|take|done]", "the open book or lectern: its page text, or press one of its buttons",
|
||||
"", ["horizon-ctl book", "horizon-ctl book next"], &[opt("action", Ty::Enum(&["next", "back", "take", "done"]), "a button to press")], mcp: true, local: false),
|
||||
cmd!("signedit", Screens, "signedit X Y Z [front|back]", "open the sign editor of the sign at X Y Z (then type and close)",
|
||||
"", ["horizon-ctl signedit 3 64 1"], &[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("side", Ty::Enum(&["front", "back"]), "which side")], mcp: true, local: false),
|
||||
cmd!("mount-menu", Screens, "mount-menu", "open the inventory of the horse, donkey, mule or llama being ridden",
|
||||
"As the inventory key does while riding; also opens the nearest one's.", ["horizon-ctl mount-menu"], &[], mcp: true, local: false),
|
||||
cmd!("chat-log", Screens, "chat-log [N]", "the newest N chat messages", "", ["horizon-ctl chat-log 5"], &[], mcp: false, local: false),
|
||||
// Multiplayer
|
||||
cmd!("connect", Multiplayer, "connect ADDRESS [NAME]", "join a multiplayer server, or a friend's world by its invite code, from the title screen",
|
||||
"ADDRESS is HOST[:PORT], or friend:CODE for an invite code. Only when the player asks: it reaches the network.",
|
||||
["horizon-ctl connect play.example.net", "horizon-ctl connect localhost:25565", "horizon-ctl connect friend:0B33K-Q405Q-..."],
|
||||
&[p("address", Ty::Str, "HOST[:PORT] or friend:CODE"), opt("name", Ty::Str, "player name when offline")], mcp: true, local: false),
|
||||
cmd!("disconnect", Multiplayer, "disconnect", "leave the server, back to the server list", "", ["horizon-ctl disconnect"], &[], mcp: true, local: false),
|
||||
cmd!("servers", Multiplayer, "servers", "the Multiplayer screen's server list: name and address",
|
||||
"Add, edit and remove them on the Multiplayer screen (click-button Multiplayer, then ui).", ["horizon-ctl servers"], &[], mcp: true, local: false),
|
||||
cmd!("lan", Multiplayer, "lan open [PORT] [commands] [force] | lan close | lan status", "open the world to LAN (Open to LAN), stop, or see the guests",
|
||||
"PORT 0 or none picks one; commands lets guests use cheats; force forces the game mode. Only when the player asks.",
|
||||
["horizon-ctl lan open", "horizon-ctl lan status", "horizon-ctl lan close"],
|
||||
&[p("action", Ty::Enum(&["open", "close", "status"]), "what to do"), opt("port", Ty::Int, "port for open"), opt("commands", Ty::Bool, "allow cheats"), opt("force", Ty::Bool, "force the game mode")], mcp: true, local: false),
|
||||
cmd!("friends", Multiplayer, "friends host|stop|status", "Play with Friends: share the open world behind an invite code, stop, or see who joined",
|
||||
"status gives the code: share it only when the player asks. Friends join with connect friend:CODE.",
|
||||
["horizon-ctl friends host", "horizon-ctl friends status"], &[p("action", Ty::Enum(&["host", "stop", "status"]), "what to do")], mcp: true, local: false),
|
||||
// World
|
||||
cmd!("give", World, "give ITEM [COUNT]", "add items to the inventory", "", ["horizon-ctl give oak_log 16"],
|
||||
&[p("item", Ty::Str, "item id"), opt("count", Ty::Int, "how many (default 1)")], mcp: true, local: false),
|
||||
@@ -325,8 +411,20 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[p("ticks", Ty::Int, "day time")], mcp: true, local: false),
|
||||
cmd!("setblock", World, "setblock X Y Z BLOCK[props]", "set a block (with neighbour updates)", "", ["horizon-ctl setblock 0 64 0 stone"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], p("block", Ty::Str, "block state such as oak_stairs[facing=east]")], mcp: true, local: false),
|
||||
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob", "", ["horizon-ctl summon cow"], &[p("kind", Ty::Str, "mob type")], mcp: true, local: false),
|
||||
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect", "", ["horizon-ctl effect speed 30 1"], &[], mcp: false, local: false),
|
||||
cmd!("summon", World, "summon KIND [X Y Z] [baby]", "spawn a mob (in front of the player without a position)", "Replies with its id.", ["horizon-ctl summon cow", "horizon-ctl summon pig 10 64 3 baby"],
|
||||
&[p("kind", Ty::Str, "mob type"), opt("x", Ty::Num, "X"), opt("y", Ty::Num, "Y"), opt("z", Ty::Num, "Z"), opt("baby", Ty::Bool, "a baby")], mcp: true, local: false),
|
||||
cmd!("effect", World, "effect ID SECONDS [AMPLIFIER] | effect clear", "give a status effect, or clear them all", "", ["horizon-ctl effect speed 30 1", "horizon-ctl effect clear"],
|
||||
&[p("id", Ty::Str, "effect id such as speed, or clear"), opt("seconds", Ty::Num, "duration"), opt("amplifier", Ty::Int, "level - 1 (default 0)")], mcp: true, local: false),
|
||||
cmd!("effects", World, "effects", "the player's active status effects", "", ["horizon-ctl effects"], &[], mcp: true, local: false),
|
||||
cmd!("xp", World, "xp LEVELS", "add experience levels (negative removes)", "", ["horizon-ctl xp 30"], &[p("levels", Ty::Int, "levels to add")], mcp: true, local: false),
|
||||
cmd!("enchant", World, "enchant ID LEVEL", "enchant the held item (a book stores it)", "", ["horizon-ctl enchant sharpness 5"],
|
||||
&[p("id", Ty::Str, "enchantment id"), p("level", Ty::Int, "level")], mcp: true, local: false),
|
||||
cmd!("potion", World, "potion ITEM POTION", "give a potion item", "", ["horizon-ctl potion splash_potion healing"],
|
||||
&[p("item", Ty::Enum(&["potion", "splash_potion", "lingering_potion", "tipped_arrow"]), "which item"), p("potion", Ty::Str, "potion id")], mcp: true, local: false),
|
||||
cmd!("fill", World, "fill X1 Y1 Z1 X2 Y2 Z2 BLOCK", "set a box of loaded blocks", "", ["horizon-ctl fill 0 64 0 4 64 4 glass"],
|
||||
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
|
||||
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
|
||||
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
|
||||
@@ -340,8 +438,8 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("mcp", Scripting, "mcp", "serve the Model Context Protocol on stdin/stdout, one tool per command",
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[], mcp: false, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[], mcp: false, local: false),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
|
||||
@@ -366,8 +464,9 @@ pub fn overview() -> String {
|
||||
}
|
||||
out.push_str(
|
||||
"\nEvery reply is one JSON line. Errors are {\"error\": \"...\"}; the exit status is 0 on\n\
|
||||
success, 1 when the game replied with an error, 2 for a usage error and 3 when the\n\
|
||||
game cannot be reached or started. Actions (goto, break, place...) block until done.\n\
|
||||
success, 1 when the game replied with an error, 2 for a usage error, 3 when the\n\
|
||||
game cannot be reached or started and 4 while No AI is on (the launcher's settings).\n\
|
||||
Actions (goto, break, place...) block until done.\n\
|
||||
horizon-ctl help --game lists the running game's own commands, test hooks included.\n\
|
||||
The socket is $HORIZON_CONTROL, else $XDG_RUNTIME_DIR/horizon-control.sock (on Windows\n\
|
||||
%TEMP%\\horizon-control.json, a localhost TCP port with a token; any path ending in .json\n\
|
||||
@@ -402,6 +501,73 @@ pub fn detail(c: &Cmd) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The generated reference, docs/horizon-ctl-commands.md (`horizon-ctl help --markdown`).
|
||||
pub fn markdown() -> String {
|
||||
let cell = |s: &str| s.replace('|', "\\|");
|
||||
let mut out = String::from(
|
||||
"<!-- Generated by `horizon-ctl help --markdown` from the command table in \
|
||||
crates/horizon-client/src/bin/horizon-ctl/commands.rs: edit the table, then regenerate. -->\n\
|
||||
# horizon-ctl commands\n\n\
|
||||
Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launcher's \
|
||||
`horizon-launcher mcp` offers the same tools as `game_<tool>`, all but `launch`) and its \
|
||||
arguments. Positional arguments come first, in order; `--flags` after them. \
|
||||
`horizon-ctl help --game` lists the game's own commands, test hooks included. \
|
||||
Design rules and the full inventory: [agent-control.md](agent-control.md).\n",
|
||||
);
|
||||
for group in Group::ALL {
|
||||
out.push_str(&format!(
|
||||
"\n## {}\n\n| Command | MCP tool | What it does | Arguments |\n| --- | --- | --- | --- |\n",
|
||||
group.title()
|
||||
));
|
||||
for c in COMMANDS.iter().filter(|c| c.group == group) {
|
||||
let tool = if c.mcp {
|
||||
let mut t = format!("`{}`", tool_name(c));
|
||||
if DESTRUCTIVE.contains(&c.name) {
|
||||
t.push_str(" (destructive)");
|
||||
} else if OPEN_WORLD.contains(&c.name) {
|
||||
t.push_str(" (network)");
|
||||
}
|
||||
t
|
||||
} else {
|
||||
"CLI only".to_owned()
|
||||
};
|
||||
let args: Vec<String> = c
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let ty = match p.ty {
|
||||
Ty::Int => "integer".to_owned(),
|
||||
Ty::Num => "number".to_owned(),
|
||||
Ty::Str => "string".to_owned(),
|
||||
Ty::Rest => "text".to_owned(),
|
||||
Ty::Bool => "boolean".to_owned(),
|
||||
Ty::Enum(v) => v.join(", "),
|
||||
};
|
||||
let name = if p.flag {
|
||||
format!("--{}", p.name)
|
||||
} else {
|
||||
p.name.to_owned()
|
||||
};
|
||||
let need = if p.required { ", required" } else { "" };
|
||||
format!("`{name}` ({ty}{need}): {}", cell(p.help))
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&format!(
|
||||
"| `{}` | {} | {} | {} |\n",
|
||||
cell(c.usage),
|
||||
tool,
|
||||
cell(&capitalize(c.summary)),
|
||||
if args.is_empty() {
|
||||
"none".to_owned()
|
||||
} else {
|
||||
args.join("<br>")
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
chars
|
||||
@@ -640,6 +806,43 @@ mod tests {
|
||||
assert_eq!(tool_name(find("look-at-entity").unwrap()), "look_at_entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_generated_reference_is_up_to_date() {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../docs/horizon-ctl-commands.md"
|
||||
);
|
||||
let on_disk = std::fs::read_to_string(path).unwrap_or_default();
|
||||
assert!(
|
||||
on_disk == markdown(),
|
||||
"docs/horizon-ctl-commands.md is out of date: run `horizon-ctl help --markdown > docs/horizon-ctl-commands.md`"
|
||||
);
|
||||
for c in COMMANDS {
|
||||
assert!(
|
||||
on_disk.contains(&format!("`{}", c.usage.replace('|', "\\|"))),
|
||||
"{} undocumented",
|
||||
c.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructive_and_network_tools_exist() {
|
||||
for name in DESTRUCTIVE.iter().chain(OPEN_WORLD) {
|
||||
assert!(find(name).is_some_and(|c| c.mcp), "{name}");
|
||||
}
|
||||
let delete = find("world-delete").unwrap();
|
||||
assert_eq!(
|
||||
line_for(delete, &json!({ "folder": "A", "confirm": true })).unwrap(),
|
||||
["world-delete", "A", "--confirm"]
|
||||
);
|
||||
let lan = find("lan").unwrap();
|
||||
assert_eq!(
|
||||
line_for(lan, &json!({ "action": "open", "commands": true })).unwrap(),
|
||||
["lan", "open", "commands"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_mentions_every_command() {
|
||||
let text = overview();
|
||||
|
||||
@@ -20,6 +20,8 @@ use serde_json::{Value, json};
|
||||
const GAME_ERROR: u8 = 1;
|
||||
const USAGE: u8 = 2;
|
||||
const UNREACHABLE: u8 = 3;
|
||||
/// No AI is on (`horizon_core::no_ai`): agent control is off.
|
||||
const DENIED: u8 = 4;
|
||||
|
||||
/// A failure, with the exit status it maps to.
|
||||
#[derive(Debug)]
|
||||
@@ -34,6 +36,13 @@ impl Failure {
|
||||
status: USAGE,
|
||||
}
|
||||
}
|
||||
/// No AI is on in the game's home.
|
||||
fn denied() -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": horizon_core::no_ai::REFUSAL, "kind": "denied" }),
|
||||
status: DENIED,
|
||||
}
|
||||
}
|
||||
fn unreachable(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
reply: json!({ "error": message.into(), "kind": "connection" }),
|
||||
@@ -98,6 +107,8 @@ impl Client {
|
||||
pub struct Session {
|
||||
pub socket: PathBuf,
|
||||
pub timeout: Option<Duration>,
|
||||
/// The game's home, where No AI is read (`horizon_core::no_ai`).
|
||||
pub home: PathBuf,
|
||||
client: Option<Client>,
|
||||
}
|
||||
|
||||
@@ -106,9 +117,14 @@ impl Session {
|
||||
Self {
|
||||
socket,
|
||||
timeout,
|
||||
home: horizon_client::logging::game_home(),
|
||||
client: None,
|
||||
}
|
||||
}
|
||||
/// A failure while No AI is on: every command refuses (help aside).
|
||||
pub fn allowed(&self) -> Result<(), Failure> {
|
||||
horizon_core::no_ai::check(&self.home).map_err(|_| Failure::denied())
|
||||
}
|
||||
fn client(&mut self) -> Result<&mut Client, Failure> {
|
||||
if self.client.is_none() {
|
||||
self.client = Some(Client::connect(&self.socket, self.timeout)?);
|
||||
@@ -137,6 +153,7 @@ impl Session {
|
||||
let Some(name) = words.first().map(String::as_str) else {
|
||||
return Err(Failure::usage("no command; try horizon-ctl help"));
|
||||
};
|
||||
self.allowed()?;
|
||||
if words.iter().skip(1).any(|w| w == "--help" || w == "-h") {
|
||||
return Err(Failure::usage(
|
||||
"use horizon-ctl COMMAND --help outside scripts",
|
||||
@@ -174,6 +191,18 @@ impl Session {
|
||||
ready["launch"] = reply;
|
||||
Ok(ready)
|
||||
}
|
||||
"interact-entity" => {
|
||||
let id = match words {
|
||||
[_, id] if id.parse::<i64>().is_ok() => id,
|
||||
_ => return Err(Failure::usage(format!("usage: {}", usage(name)))),
|
||||
};
|
||||
self.game(&format!("look-at entity {id}"))?;
|
||||
self.game("mouse right click")?;
|
||||
self.game("wait 2")?;
|
||||
let mut screen = self.game("screen")?;
|
||||
screen["ok"] = json!(true);
|
||||
Ok(screen)
|
||||
}
|
||||
"hold" => {
|
||||
let (key, seconds) = match words {
|
||||
[_, key, _] => (key.clone(), number(2, "SECONDS")?),
|
||||
@@ -183,7 +212,7 @@ impl Session {
|
||||
thread::sleep(Duration::from_secs_f64(seconds.max(0.0)));
|
||||
self.game(&format!("key {key} up"))
|
||||
}
|
||||
"logs" => logs(&horizon_client::logging::game_home(), &words[1..]),
|
||||
"logs" => logs(&self.home, &words[1..]),
|
||||
"wait-ready" => {
|
||||
let limit = if words.len() > 1 {
|
||||
number(1, "SECONDS")?
|
||||
@@ -417,6 +446,10 @@ fn main() -> ExitCode {
|
||||
print!("{}", commands::overview());
|
||||
0
|
||||
}
|
||||
Some("help") if args.get(1).is_some_and(|a| a == "--markdown") => {
|
||||
print!("{}", commands::markdown());
|
||||
0
|
||||
}
|
||||
Some("help") if args.get(1).is_none_or(|a| a != "--game") => match args.get(1) {
|
||||
None => {
|
||||
print!("{}", commands::overview());
|
||||
@@ -451,6 +484,10 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("mcp") if session.allowed().is_err() => {
|
||||
eprintln!("horizon-ctl mcp: {}", horizon_core::no_ai::REFUSAL);
|
||||
DENIED
|
||||
}
|
||||
Some("mcp") => match mcp::serve(&mut session) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
@@ -581,6 +618,27 @@ mod tests {
|
||||
assert_eq!(f.status, USAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ai_refuses_every_command() {
|
||||
let home = std::env::temp_dir().join(format!("horizon-ctl-no-ai-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-nobody.sock"), None);
|
||||
s.home = home.clone();
|
||||
assert_eq!(
|
||||
s.run(&["state".to_owned()]).unwrap_err().status,
|
||||
UNREACHABLE
|
||||
);
|
||||
horizon_core::no_ai::set(&home, true).unwrap();
|
||||
for words in [vec!["state"], vec!["logs"], vec!["launch", "--world", "A"]] {
|
||||
let words: Vec<String> = words.into_iter().map(str::to_owned).collect();
|
||||
let f = s.run(&words).unwrap_err();
|
||||
assert_eq!(f.status, DENIED, "{words:?}");
|
||||
assert_eq!(f.reply["kind"], "denied");
|
||||
assert_eq!(f.reply["error"], horizon_core::no_ai::REFUSAL);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_paths_become_absolute() {
|
||||
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
|
||||
|
||||
@@ -23,7 +23,13 @@ fn tools() -> Vec<Value> {
|
||||
schema = json!({ "type": "object", "properties": {} });
|
||||
description = "Capture the game window and return it as a PNG image.".into();
|
||||
}
|
||||
json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema })
|
||||
let mut tool = json!({ "name": commands::tool_name(c), "description": description, "inputSchema": schema });
|
||||
if commands::DESTRUCTIVE.contains(&c.name) {
|
||||
tool["annotations"] = json!({ "destructiveHint": true });
|
||||
} else if commands::OPEN_WORLD.contains(&c.name) {
|
||||
tool["annotations"] = json!({ "openWorldHint": true });
|
||||
}
|
||||
tool
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -107,6 +107,8 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
|
||||
pub no_ai: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -135,6 +137,7 @@ impl Default for Extras {
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
no_ai: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +201,7 @@ impl Extras {
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +209,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -234,6 +238,8 @@ impl Extras {
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
self.sort_key,
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
|
||||
@@ -126,6 +126,7 @@ mod remote_vehicles;
|
||||
mod render_control;
|
||||
mod render_tick;
|
||||
mod respawn_anchor;
|
||||
mod saves_control;
|
||||
mod saving;
|
||||
mod scoreboard;
|
||||
mod screens;
|
||||
@@ -275,10 +276,9 @@ struct Options {
|
||||
benchmark: Option<String>,
|
||||
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
|
||||
dev_mods: Vec<PathBuf>,
|
||||
/// `--server-thread` or `HORIZON_SERVER_THREAD=1`: single player runs on an
|
||||
/// integrated server thread (game/integrated.rs), as vanilla's does. Off by
|
||||
/// default again until a server's players can board, ride and hit
|
||||
/// vehicles (#1222); `--no-server-thread` keeps it off.
|
||||
/// Single player runs on an integrated server thread (game/integrated.rs),
|
||||
/// as vanilla's does: the default. `--no-server-thread` or
|
||||
/// `HORIZON_SERVER_THREAD=0` runs the world in the frame instead.
|
||||
server_thread: bool,
|
||||
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
|
||||
integrated: bool,
|
||||
@@ -335,7 +335,7 @@ impl Options {
|
||||
dev_mods: Vec::new(),
|
||||
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
|
||||
// itself, without the event loop: it stays in the frame.
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1")
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
|
||||
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
|
||||
integrated: false,
|
||||
};
|
||||
@@ -345,7 +345,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --server-thread Run single player's world on its server thread (no vehicles yet)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --no-server-thread Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -7717,6 +7717,10 @@ impl Game<'_> {
|
||||
fullscreen: s.fullscreen,
|
||||
graphics: s.graphics_preset.clone(),
|
||||
};
|
||||
// Again now: on Wayland the window is on a screen only once it has been shown.
|
||||
if let Some(window) = &self.window {
|
||||
horizon_client::telemetry::note_display(window);
|
||||
}
|
||||
self.perf
|
||||
.finish(&horizon_client::logging::game_home(), settings);
|
||||
}
|
||||
@@ -10515,6 +10519,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
Ok(r) => {
|
||||
self.renderer = Some(r);
|
||||
@@ -12439,7 +12444,13 @@ fn start_game<'a>(
|
||||
game.mods.dev = game.options.dev_mods.clone();
|
||||
game.start_mods();
|
||||
}
|
||||
if let Some(path) = &game.options.control {
|
||||
if game.options.control.is_some() && game.settings.extras.no_ai {
|
||||
// No AI turns agent control off: the socket is never opened.
|
||||
log::warn!(
|
||||
"Horizon: control socket not opened: {}",
|
||||
horizon_core::no_ai::REFUSAL
|
||||
);
|
||||
} else if let Some(path) = &game.options.control {
|
||||
let server = horizon_client::control::Server::start(path)
|
||||
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
|
||||
log::info!("Horizon: control socket {}", server.path().display());
|
||||
@@ -12606,6 +12617,7 @@ impl Loading {
|
||||
return;
|
||||
}
|
||||
};
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
match loading_renderer(
|
||||
window.clone(),
|
||||
|
||||
@@ -165,6 +165,9 @@ impl Game<'_> {
|
||||
if let Some(value) = self.events_command(&words, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.saves_control(&words, line, reply)? {
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(value) = self.options_control(&words, line)? {
|
||||
return Ok(Some(value));
|
||||
}
|
||||
@@ -192,6 +195,7 @@ impl Game<'_> {
|
||||
.chain(super::ui_control::HELP)
|
||||
.chain(super::options::CONTROL_HELP)
|
||||
.chain(super::events::HELP)
|
||||
.chain(super::saves_control::HELP)
|
||||
.chain(super::pad::HELP)
|
||||
.collect::<Vec<_>>() }))),
|
||||
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
|
||||
@@ -906,7 +910,7 @@ impl Game<'_> {
|
||||
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
|
||||
"dirty_light": self.world.dirty_light.len(),
|
||||
"renderer": renderer,
|
||||
"display": self.window.as_ref().and_then(|w| w.current_monitor()).map(|m| json!({
|
||||
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
|
||||
"width": m.size().width,
|
||||
"height": m.size().height,
|
||||
"scale": m.scale_factor(),
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`): it was the
|
||||
//! default for one day, but a server's players can't board, ride or hit
|
||||
//! boats and minecarts yet (#1222), and single player's window is one of
|
||||
//! them here.
|
||||
//! On by default. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` runs
|
||||
//! the world in the frame instead (the escape hatch while the network
|
||||
//! player's side catches up with the in-frame one).
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
lan::{LanSettings, Owner},
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Worlds and the multiplayer server list from the control socket: what the Select World, Edit
|
||||
//! World and Multiplayer screens do, by world folder. Changing a world needs the title screen
|
||||
//! (no world open), as those screens do; deleting one needs `--confirm`.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, title};
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::PathBuf, sync::mpsc::Sender, time::UNIX_EPOCH};
|
||||
|
||||
pub(super) const HELP: &[&str] = &[
|
||||
"worlds the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"world-rename FOLDER NAME rename a world (Edit World; its folder stays)",
|
||||
"world-backup FOLDER zip a world into backups/ (Edit World > Make Backup)",
|
||||
"world-delete FOLDER --confirm delete a world for good (Select World > Delete)",
|
||||
"servers the multiplayer server list (servers.dat): name and address",
|
||||
];
|
||||
|
||||
fn saves() -> Result<PathBuf> {
|
||||
Ok(std::env::current_dir()?.join("saves"))
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Some(reply) for these commands (Some(None): the reply comes later), None for others.
|
||||
pub(super) fn saves_control(
|
||||
&mut self,
|
||||
words: &[&str],
|
||||
line: &str,
|
||||
reply: &Sender<String>,
|
||||
) -> Result<Option<Option<Value>>> {
|
||||
let folder = || {
|
||||
words
|
||||
.get(1)
|
||||
.copied()
|
||||
.ok_or_else(|| format!("{} FOLDER: the folder from worlds", words[0]))
|
||||
};
|
||||
let on_title = || {
|
||||
if self.title.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{} needs the title screen: save and leave the world first (pause, then Save and Quit to Title)",
|
||||
words[0]
|
||||
))
|
||||
}
|
||||
};
|
||||
let value = match words[0] {
|
||||
"worlds" => {
|
||||
let worlds: Vec<Value> = title::list_worlds(&saves()?)
|
||||
.iter()
|
||||
.map(|w| {
|
||||
json!({
|
||||
"folder": w.folder,
|
||||
"name": w.name,
|
||||
"mode": if w.creative { "creative" } else { "survival" },
|
||||
"last_played": w.modified.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64),
|
||||
"commands": w.commands,
|
||||
"version": w.version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "worlds": worlds })
|
||||
}
|
||||
"world-rename" => {
|
||||
on_title()?;
|
||||
let folder = folder()?;
|
||||
let name = line
|
||||
.trim_start()
|
||||
.strip_prefix("world-rename")
|
||||
.and_then(|rest| rest.trim_start().strip_prefix(folder))
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty())
|
||||
.ok_or("world-rename FOLDER NAME: missing the new name")?;
|
||||
title::world_management::rename(&saves()?, folder, name)?;
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.worlds_changed();
|
||||
}
|
||||
json!({ "ok": true, "folder": folder, "name": name })
|
||||
}
|
||||
"world-delete" => {
|
||||
on_title()?;
|
||||
let folder = folder()?;
|
||||
if !words.contains(&"--confirm") {
|
||||
return Err(format!(
|
||||
"world-delete deletes {folder} for good: ask the player, then add --confirm"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
title::world_management::delete(&saves()?, folder)?;
|
||||
if let Some(title) = self.title.as_mut() {
|
||||
title.worlds_changed();
|
||||
}
|
||||
json!({ "ok": true, "deleted": folder })
|
||||
}
|
||||
"world-backup" => {
|
||||
on_title()?;
|
||||
let folder = folder()?.to_owned();
|
||||
let saves = saves()?;
|
||||
let backups = title::world_management::backups_path(&saves);
|
||||
// A big world takes a while to zip: off the game thread, as Make Backup does.
|
||||
let reply = reply.clone();
|
||||
std::thread::spawn(move || {
|
||||
let answer = match title::world_management::backup(&saves, &folder) {
|
||||
Ok(bytes) => {
|
||||
json!({ "ok": true, "folder": folder, "backups": backups, "bytes": bytes })
|
||||
}
|
||||
Err(e) => json!({ "error": format!("backup of {folder} failed: {e}") }),
|
||||
};
|
||||
let _ = reply.send(answer.to_string());
|
||||
});
|
||||
return Ok(Some(None));
|
||||
}
|
||||
"servers" => {
|
||||
let file = horizon_client::multiplayer::servers_file(&saves()?);
|
||||
let servers: Vec<Value> = horizon_protocol::servers::load(&file)
|
||||
.iter()
|
||||
.map(|s| json!({ "name": s.name, "address": s.ip }))
|
||||
.collect();
|
||||
json!({ "servers": servers })
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(Some(Some(value)))
|
||||
}
|
||||
}
|
||||
@@ -694,13 +694,15 @@ fn describe_adapter(adapter: &wgpu::Adapter) {
|
||||
info.backend,
|
||||
info.device_type
|
||||
);
|
||||
crate::telemetry::set_gpu(
|
||||
&info.name,
|
||||
format!("{} {}", info.driver, info.driver_info)
|
||||
crate::telemetry::set_gpu(crate::telemetry::Gpu {
|
||||
name: info.name.clone(),
|
||||
vendor: horizon_telemetry::system::gpu_vendor(&info.name, info.vendor).to_owned(),
|
||||
kind: crate::telemetry::gpu_kind(&format!("{:?}", info.device_type)).to_owned(),
|
||||
driver: format!("{} {}", info.driver, info.driver_info)
|
||||
.trim()
|
||||
.to_owned(),
|
||||
format!("{:?}", info.backend),
|
||||
);
|
||||
backend: format!("{:?}", info.backend),
|
||||
});
|
||||
crate::crash::set_detail(
|
||||
"Graphics Adapter",
|
||||
format!(
|
||||
|
||||
@@ -84,11 +84,13 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
use crate::atlas_sources::{self, SpriteOrigin};
|
||||
let have: std::collections::HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let textures = atlas_sources::list_textures(root);
|
||||
let load = |texture: &str, directory: &str| {
|
||||
decode(&atlas_sources::texture_path(root, texture, directory)).ok()
|
||||
};
|
||||
let mut out: Vec<PackedImage> = Vec::new();
|
||||
let mut added = std::collections::HashSet::new();
|
||||
// Each new name with its origins in the atlases' order: the first that
|
||||
// loads is the sprite. They load at once (a content pack's atlases list
|
||||
// tens of thousands of files), and come out in the order a load of one
|
||||
// after another would give.
|
||||
let mut names: Vec<(String, Vec<(usize, SpriteOrigin)>)> = Vec::new();
|
||||
let mut slots = std::collections::HashMap::new();
|
||||
let mut order = 0;
|
||||
for (atlas, sources) in atlas_sources::atlases(root) {
|
||||
// The other atlases' renamed files (GUI sprites, particles...) are
|
||||
// looked up by their texture paths here.
|
||||
@@ -98,18 +100,33 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
continue;
|
||||
}
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(&id).to_owned();
|
||||
if have.contains(name.as_str()) || added.contains(&name) {
|
||||
if have.contains(name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let image = match &origin {
|
||||
// A vanilla file is packed under its own name already: only a
|
||||
// renamed one is new. Other namespaces' files (content mods) are
|
||||
// never in the walked directories.
|
||||
if let SpriteOrigin::File(texture) = &origin
|
||||
&& texture.strip_prefix("minecraft:") == Some(name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let slot = *slots.entry(name.clone()).or_insert_with(|| {
|
||||
names.push((name, Vec::new()));
|
||||
names.len() - 1
|
||||
});
|
||||
names[slot].1.push((order, origin));
|
||||
order += 1;
|
||||
}
|
||||
}
|
||||
let load = |texture: &str, directory: &str| {
|
||||
decode(&atlas_sources::texture_path(root, texture, directory)).ok()
|
||||
};
|
||||
let first = |(name, origins): &(String, Vec<(usize, SpriteOrigin)>)| {
|
||||
origins.iter().find_map(|(order, origin)| {
|
||||
let image = match origin {
|
||||
SpriteOrigin::File(texture) => {
|
||||
let path = texture.strip_prefix("minecraft:").unwrap_or(texture);
|
||||
// A vanilla file is packed under its own name already:
|
||||
// only a renamed one is new. Other namespaces' files
|
||||
// (content mods) are never in the walked directories.
|
||||
if texture.starts_with("minecraft:") && path == name {
|
||||
continue;
|
||||
}
|
||||
load(texture, "textures").map(|(w, h, p)| {
|
||||
// Animated strips keep their first frame, like the packed files.
|
||||
if h > w && (path.starts_with("block/") || path.starts_with("item/")) {
|
||||
@@ -121,13 +138,15 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
}
|
||||
generated => atlas_sources::generate(generated, &load),
|
||||
};
|
||||
if let Some((w, h, pixels)) = image {
|
||||
added.insert(name.clone());
|
||||
out.push((name, w, h, pixels));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels)))
|
||||
})
|
||||
};
|
||||
let mut out: Vec<_> = crate::par::par_map(&names, first)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
out.sort_unstable_by_key(|(order, _)| *order);
|
||||
out.into_iter().map(|(_, image)| image).collect()
|
||||
}
|
||||
impl HudAtlas {
|
||||
/// A font glyph's atlas cell (8x8 GUI pixels) and advance.
|
||||
|
||||
@@ -132,6 +132,7 @@ pub mod osk;
|
||||
pub mod overlay;
|
||||
pub mod pack_screen;
|
||||
pub mod packs;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod patrol;
|
||||
pub mod perf_overlay;
|
||||
|
||||
@@ -1028,6 +1028,66 @@ impl TagMerge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Threads rewriting the tables at once. Each holds a whole table as a tree
|
||||
/// (gameplay-facts.json's is over a gigabyte with ATM10's mods), so more
|
||||
/// would mostly add memory.
|
||||
const TABLE_THREADS: usize = 3;
|
||||
|
||||
/// Rewrites the base's per-state tables for the mods' states into the
|
||||
/// overlay. They are independent: [`TABLE_THREADS`] threads take them,
|
||||
/// the largest first.
|
||||
fn tables(base: &Path, plan: &Plan, overlay: &Overlay<'_>) -> Result<()> {
|
||||
let mut jobs: Vec<(&str, u64)> = JSON_TABLES
|
||||
.iter()
|
||||
.chain(&NBT_TABLES)
|
||||
.filter_map(|name| Some((*name, fs::metadata(base.join(name)).ok()?.len())))
|
||||
.collect();
|
||||
jobs.sort_by_key(|(_, size)| std::cmp::Reverse(*size));
|
||||
// Their directories become real here, so the threads only write files.
|
||||
for (name, _) in &jobs {
|
||||
if let Some(parent) = Path::new(name).parent() {
|
||||
overlay.dir(parent)?;
|
||||
}
|
||||
}
|
||||
let table = |name: &str| -> Result<()> {
|
||||
let path = base.join(name);
|
||||
let bytes = if JSON_TABLES.contains(&name) {
|
||||
let mut doc = read_json(&path)?;
|
||||
plan.json(name, &mut doc)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
serde_json::to_vec(&doc)?
|
||||
} else {
|
||||
let (mut named, _) = nbt::read_named(&fs::read(&path)?, Limits::tables())
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
plan.nbt(&mut named.tag);
|
||||
nbt::write_named(&named, Limits::tables()).map_err(|e| format!("{name}: {e}"))?
|
||||
};
|
||||
overlay.write(name, &bytes)
|
||||
};
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = (0..threads.min(TABLE_THREADS))
|
||||
.map(|_| {
|
||||
scope.spawn(|| -> std::result::Result<(), String> {
|
||||
loop {
|
||||
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let Some((name, _)) = jobs.get(i) else {
|
||||
return Ok(());
|
||||
};
|
||||
table(name).map_err(|e| e.to_string())?;
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers.into_iter().try_for_each(|w| {
|
||||
w.join()
|
||||
.unwrap_or_else(|_| Err("a table thread panicked".into()))
|
||||
})
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the content root for `mods` over `base` in `out`. Returns the
|
||||
/// warnings for the log: the shared tags a mod replaced.
|
||||
pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>> {
|
||||
@@ -1047,28 +1107,7 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
let base = &std::path::absolute(base)?;
|
||||
let plan = Plan::new(base, mods)?;
|
||||
let overlay = Overlay::new(base, out)?;
|
||||
for name in JSON_TABLES {
|
||||
let path = base.join(name);
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let mut doc = read_json(&path)?;
|
||||
plan.json(name, &mut doc)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
overlay.write(name, &serde_json::to_vec(&doc)?)?;
|
||||
}
|
||||
for name in NBT_TABLES {
|
||||
let path = base.join(name);
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let (mut named, _) = nbt::read_named(&fs::read(&path)?, Limits::tables())
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
plan.nbt(&mut named.tag);
|
||||
let bytes =
|
||||
nbt::write_named(&named, Limits::tables()).map_err(|e| format!("{name}: {e}"))?;
|
||||
overlay.write(name, &bytes)?;
|
||||
}
|
||||
tables(base, &plan, &overlay)?;
|
||||
// Mod files, and every tag addition gathered per tag file.
|
||||
let mut tags: BTreeMap<String, TagMerge> = BTreeMap::new();
|
||||
let mut warnings = Vec::new();
|
||||
@@ -1076,8 +1115,9 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
let mut additions: BTreeMap<&str, Vec<Addition>> = BTreeMap::new();
|
||||
for m in mods {
|
||||
let declarations = format!("data/{}/horizon/", m.id);
|
||||
let mut reader = m.content.reader()?;
|
||||
for file in &m.content.files {
|
||||
let bytes = m.content.file(file)?;
|
||||
let bytes = reader.read(file)?;
|
||||
let is_tag = file.starts_with("data/") && file.contains("/tags/");
|
||||
if is_tag && file.ends_with(".json") {
|
||||
let doc: J = serde_json::from_slice(&bytes).map_err(|e| format!("{file}: {e}"))?;
|
||||
|
||||
@@ -10,10 +10,12 @@ pub(crate) struct ParsedModels {
|
||||
impl ParsedModels {
|
||||
/// Every `models/**.json` of a namespace directory, by identifier.
|
||||
pub(crate) fn load(root: &Path) -> Self {
|
||||
let mut out = BTreeMap::new();
|
||||
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
|
||||
return Self { models: out };
|
||||
return Self::default();
|
||||
};
|
||||
// Listed first, then read and parsed at once: a content pack has tens
|
||||
// of thousands of models.
|
||||
let mut files = Vec::new();
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("models");
|
||||
@@ -24,20 +26,33 @@ impl ParsedModels {
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if path.is_dir() {
|
||||
// The entry's own type saves a stat per file; a link
|
||||
// (content roots link into the base) is followed.
|
||||
let is_dir = match e.file_type() {
|
||||
Ok(t) if !t.is_symlink() => t.is_dir(),
|
||||
_ => path.is_dir(),
|
||||
};
|
||||
if is_dir {
|
||||
stack.push(path);
|
||||
} else if path.extension().is_some_and(|x| x == "json")
|
||||
&& let Ok(rel) = path.strip_prefix(&base)
|
||||
&& let Ok(text) = fs::read_to_string(&path)
|
||||
&& let Ok(model) = CuboidModel::from_json(&text)
|
||||
{
|
||||
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
|
||||
out.insert(format!("{namespace}:{rel}"), Arc::new(model));
|
||||
files.push((format!("{namespace}:{rel}"), path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { models: out }
|
||||
let parsed = crate::par::par_map(&files, |(_, path)| {
|
||||
let text = fs::read_to_string(path).ok()?;
|
||||
CuboidModel::from_json(&text).ok().map(Arc::new)
|
||||
});
|
||||
let models = files
|
||||
.into_iter()
|
||||
.zip(parsed)
|
||||
.filter_map(|((id, _), model)| Some((id, model?)))
|
||||
.collect();
|
||||
Self { models }
|
||||
}
|
||||
|
||||
/// Blocks require every referenced resource, including parents. Items instead
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Loading many small files at once: startup reads tens of thousands of
|
||||
//! textures and models with a content pack, and one at a time it waits on
|
||||
//! each open and read in turn.
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// `f` over `items` on the machine's threads, results in `items`' order.
|
||||
/// Threads take the next item as they finish one, so a run of slow items
|
||||
/// doesn't hold back the others.
|
||||
pub fn par_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map_or(1, |n| n.get())
|
||||
.min(items.len());
|
||||
if threads <= 1 {
|
||||
return items.iter().map(f).collect();
|
||||
}
|
||||
let next = AtomicUsize::new(0);
|
||||
let mut out: Vec<(usize, R)> = std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = (0..threads)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
let mut done = Vec::new();
|
||||
loop {
|
||||
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(item) = items.get(i) else {
|
||||
return done;
|
||||
};
|
||||
done.push((i, f(item)));
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
workers
|
||||
.into_iter()
|
||||
.flat_map(|w| w.join().unwrap_or_else(|e| std::panic::resume_unwind(e)))
|
||||
.collect()
|
||||
});
|
||||
out.sort_unstable_by_key(|(i, _)| *i);
|
||||
out.into_iter().map(|(_, r)| r).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::par_map;
|
||||
|
||||
#[test]
|
||||
fn results_keep_the_items_order() {
|
||||
let items: Vec<u32> = (0..1000).collect();
|
||||
assert_eq!(
|
||||
par_map(&items, |x| x * 2),
|
||||
items.iter().map(|x| x * 2).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(par_map(&[] as &[u32], |x| *x).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Instant;
|
||||
|
||||
use horizon_telemetry::payload::{Crash, Perf, Settings, System, Timings};
|
||||
use horizon_telemetry::payload::{Crash, Display, Perf, Settings, System, Timings};
|
||||
use horizon_telemetry::scrub::Scrubber;
|
||||
use horizon_telemetry::{Item, Payload, Source, outbox};
|
||||
|
||||
@@ -25,8 +25,21 @@ static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||
static UI_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static CORE_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
/// The GPU's name, driver and backend, once the renderer has its adapter.
|
||||
static GPU: Mutex<Option<(String, String, String)>> = Mutex::new(None);
|
||||
/// The GPU, once the renderer has its adapter: name, maker, kind, driver, backend, its own memory.
|
||||
static GPU: Mutex<Option<Gpu>> = Mutex::new(None);
|
||||
/// The screen the window opened on.
|
||||
static DISPLAY: Mutex<Option<Display>> = Mutex::new(None);
|
||||
|
||||
/// The adapter as the renderer found it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Gpu {
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
/// `integrated`, `discrete`, `virtual`, `cpu` or `other`.
|
||||
pub kind: String,
|
||||
pub driver: String,
|
||||
pub backend: String,
|
||||
}
|
||||
|
||||
/// When the game started, for the time to the title screen. Call first thing in `main`.
|
||||
pub fn mark_start() {
|
||||
@@ -61,18 +74,73 @@ fn readiness_ms(mark: &AtomicU32) -> Option<u32> {
|
||||
(ms != u32::MAX).then_some(ms)
|
||||
}
|
||||
|
||||
pub fn set_gpu(name: &str, driver: String, backend: String) {
|
||||
*GPU.lock().unwrap_or_else(|e| e.into_inner()) = Some((name.to_owned(), driver, backend));
|
||||
pub fn set_gpu(gpu: Gpu) {
|
||||
*GPU.lock().unwrap_or_else(|e| e.into_inner()) = Some(gpu);
|
||||
}
|
||||
|
||||
/// This machine, with the GPU once the renderer knows it.
|
||||
pub fn system() -> System {
|
||||
let mut system = System::detect();
|
||||
if let Some((gpu, driver, backend)) = GPU.lock().unwrap_or_else(|e| e.into_inner()).clone() {
|
||||
system.gpu = gpu;
|
||||
system.driver = driver;
|
||||
system.backend = backend;
|
||||
/// 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,
|
||||
});
|
||||
}
|
||||
|
||||
/// The screen `window` is on, else the primary one, else the only one: right after the window
|
||||
/// is made, Wayland doesn't say yet which screen it is on.
|
||||
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
|
||||
let only = || {
|
||||
let mut all = window.available_monitors();
|
||||
all.next().filter(|_| all.next().is_none())
|
||||
};
|
||||
window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
.or_else(only)
|
||||
}
|
||||
|
||||
/// Keeps [`monitor`]'s size, refresh rate and scale for the items' `system.display`.
|
||||
pub fn note_display(window: &winit::window::Window) {
|
||||
if let Some(m) = monitor(window) {
|
||||
set_display(
|
||||
m.size().width,
|
||||
m.size().height,
|
||||
m.refresh_rate_millihertz().map_or(0, |r| r.div_ceil(1000)),
|
||||
m.scale_factor(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// wgpu's device type in the words items use.
|
||||
pub fn gpu_kind(device_type: &str) -> &'static str {
|
||||
match device_type {
|
||||
"IntegratedGpu" => "integrated",
|
||||
"DiscreteGpu" => "discrete",
|
||||
"VirtualGpu" => "virtual",
|
||||
"Cpu" => "cpu",
|
||||
_ => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// This machine, with the GPU and the screen once the renderer knows them. The drive is the one
|
||||
/// under the game folder (the current folder).
|
||||
pub fn system() -> System {
|
||||
let home = std::env::current_dir().ok();
|
||||
let mut system = System::detect_in(home.as_deref());
|
||||
if let Some(gpu) = GPU.lock().unwrap_or_else(|e| e.into_inner()).clone() {
|
||||
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.display = DISPLAY.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
system
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::{
|
||||
mod create_world;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod world_management;
|
||||
pub mod world_management;
|
||||
pub use create_world::{DIFFICULTIES, WorldOptions};
|
||||
|
||||
/// SelectWorldScreen's `HeaderAndFooterLayout(8 + 9 + 8 + 20 + 4, 60)`.
|
||||
@@ -500,6 +500,14 @@ impl Title {
|
||||
}
|
||||
}
|
||||
/// The worlds, newest first, cached for two seconds (drawn every frame).
|
||||
/// Saves changed behind the screens' back (agent control renamed or deleted a world): the
|
||||
/// list shows them again.
|
||||
pub fn worlds_changed(&mut self) {
|
||||
if matches!(self.mode, Mode::Select { .. }) {
|
||||
self.return_to_list(None);
|
||||
}
|
||||
*self.recent.borrow_mut() = None;
|
||||
}
|
||||
pub fn recent_worlds(&self) -> Vec<WorldEntry> {
|
||||
let mut recent = self.recent.borrow_mut();
|
||||
match &*recent {
|
||||
|
||||
@@ -23,7 +23,7 @@ fn world_path(saves: &Path, folder: &str) -> crate::Result<PathBuf> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub(super) fn rename(saves: &Path, folder: &str, name: &str) -> crate::Result<()> {
|
||||
pub fn rename(saves: &Path, folder: &str, name: &str) -> crate::Result<()> {
|
||||
let path = world_path(saves, folder)?.join("level.dat");
|
||||
let mut root = read_dat(&path).ok_or("Could not read level.dat")?;
|
||||
let Some(Tag::Compound(data)) = root.get_mut("Data") else {
|
||||
@@ -45,7 +45,7 @@ pub(super) fn reset_icon(saves: &Path, folder: &str) -> crate::Result<()> {
|
||||
/// file briefly held by a virus scanner, say) is retried up to five times,
|
||||
/// half a second apart, as vanilla does. Only the named folder directly in
|
||||
/// `saves` is touched: symbolic links inside it are removed, never followed.
|
||||
pub(super) fn delete(saves: &Path, folder: &str) -> crate::Result<()> {
|
||||
pub fn delete(saves: &Path, folder: &str) -> crate::Result<()> {
|
||||
let world = world_path(saves, folder)?;
|
||||
let lock = crate::save::SessionLock::acquire(&world)?;
|
||||
let lock_path = world.join("session.lock");
|
||||
@@ -86,7 +86,7 @@ fn remove_contents(world: &Path, lock: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn backups_path(saves: &Path) -> PathBuf {
|
||||
pub fn backups_path(saves: &Path) -> PathBuf {
|
||||
saves.parent().unwrap_or(saves).join("backups")
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ pub(super) fn open_folder(path: &Path) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn backup(saves: &Path, folder: &str) -> crate::Result<u64> {
|
||||
pub fn backup(saves: &Path, folder: &str) -> crate::Result<u64> {
|
||||
let world = world_path(saves, folder)?;
|
||||
let backups = backups_path(saves);
|
||||
fs::create_dir_all(&backups)?;
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod carving;
|
||||
pub mod density;
|
||||
pub mod geometry;
|
||||
pub mod jmath;
|
||||
pub mod no_ai;
|
||||
pub mod noise;
|
||||
pub mod paletted;
|
||||
pub mod position;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! The "No AI" switch: one setting that hides and turns off every AI feature of Horizon (the
|
||||
//! companion, agent control through the control socket, `horizon-ctl`, its MCP server and the
|
||||
//! launcher's). The launcher and the game both read and write it here, so they always agree.
|
||||
//!
|
||||
//! It is the `noAi` line of the game's `horizon-extras.txt`, in the game's home (the folder
|
||||
//! with `options.txt`). Missing means off: AI features are available. See docs/agent-control.md.
|
||||
use std::{fs, io, path::Path};
|
||||
|
||||
/// The game's extras file, beside `options.txt`.
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
/// The key No AI is kept under there.
|
||||
pub const KEY: &str = "noAi";
|
||||
/// The one line every AI surface answers with while No AI is on.
|
||||
pub const REFUSAL: &str = "AI features are off: turn off No AI in the launcher's settings";
|
||||
|
||||
/// Whether `text` (an extras file) says No AI.
|
||||
pub fn parse(text: &str) -> bool {
|
||||
text.lines().any(|line| {
|
||||
line.split_once(':')
|
||||
.is_some_and(|(k, v)| k.trim() == KEY && v.trim() == "true")
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether No AI is on for the game whose home is `home`.
|
||||
pub fn is_on(home: &Path) -> bool {
|
||||
fs::read_to_string(home.join(FILE)).is_ok_and(|text| parse(&text))
|
||||
}
|
||||
|
||||
/// `text` with its `noAi` line set to `on`, every other line kept as it was.
|
||||
pub fn with(text: &str, on: bool) -> String {
|
||||
let mut out: String = text
|
||||
.lines()
|
||||
.filter(|line| line.split_once(':').is_none_or(|(k, _)| k.trim() != KEY))
|
||||
.flat_map(|line| [line, "\n"])
|
||||
.collect();
|
||||
out.push_str(&format!("{KEY}:{on}\n"));
|
||||
out
|
||||
}
|
||||
|
||||
/// Turns No AI on or off for the game in `home`, keeping the file's other settings. Turning it
|
||||
/// off where there is no file writes nothing (off is the default).
|
||||
pub fn set(home: &Path, on: bool) -> io::Result<()> {
|
||||
let path = home.join(FILE);
|
||||
let text = match fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
if !on {
|
||||
return Ok(());
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
fs::create_dir_all(home)?;
|
||||
fs::write(path, with(&text, on))
|
||||
}
|
||||
|
||||
/// `Err(REFUSAL)` while No AI is on in `home`.
|
||||
pub fn check(home: &Path) -> Result<(), &'static str> {
|
||||
if is_on(home) { Err(REFUSAL) } else { Ok(()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_and_writes_the_extras_line_keeping_the_others() {
|
||||
assert!(!parse(""));
|
||||
assert!(!parse("noAi:false\nzoom:true\n"));
|
||||
assert!(parse("zoom:true\nnoAi: true\n"));
|
||||
assert_eq!(
|
||||
with("zoom:true\nnoAi:false\nfps:true", true),
|
||||
"zoom:true\nfps:true\nnoAi:true\n"
|
||||
);
|
||||
assert_eq!(with("", false), "noAi:false\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_writes_only_when_needed() {
|
||||
let home = std::env::temp_dir().join(format!("horizon-no-ai-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&home);
|
||||
set(&home, false).unwrap();
|
||||
assert!(!home.join(FILE).exists(), "off needs no file");
|
||||
assert!(check(&home).is_ok());
|
||||
set(&home, true).unwrap();
|
||||
assert!(is_on(&home));
|
||||
assert_eq!(check(&home), Err(REFUSAL));
|
||||
fs::write(home.join(FILE), "fps:true\nnoAi:true\n").unwrap();
|
||||
set(&home, false).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(home.join(FILE)).unwrap(),
|
||||
"fps:true\nnoAi:false\n"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&home);
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,33 @@ pub struct Content {
|
||||
pub namespaces: Vec<String>,
|
||||
}
|
||||
|
||||
/// Reads a mod's content files ([`Content::reader`]).
|
||||
pub enum FileReader<'a> {
|
||||
Dir(&'a Path),
|
||||
Zip(Box<zip::ZipArchive<std::fs::File>>),
|
||||
}
|
||||
impl FileReader<'_> {
|
||||
/// One content file's bytes.
|
||||
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, String> {
|
||||
check_path(name)?;
|
||||
let reader: Box<dyn Read + '_> = match self {
|
||||
Self::Dir(root) => {
|
||||
Box::new(std::fs::File::open(root.join(name)).map_err(|e| format!("{name}: {e}"))?)
|
||||
}
|
||||
Self::Zip(zip) => Box::new(zip.by_name(name).map_err(|e| format!("{name}: {e}"))?),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
reader
|
||||
.take(MAX_FILE + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
if out.len() as u64 > MAX_FILE {
|
||||
return Err(format!("{name} is over {} MiB", MAX_FILE >> 20));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why two content mods cannot be on together: a namespace both own (one's
|
||||
/// id or listed namespace is the other's), which would mix their files.
|
||||
/// `a` and `b` are (mod id, owned namespaces).
|
||||
@@ -222,29 +249,16 @@ impl Content {
|
||||
|
||||
/// One content file's bytes.
|
||||
pub fn file(&self, name: &str) -> Result<Vec<u8>, String> {
|
||||
check_path(name)?;
|
||||
let mut out = Vec::new();
|
||||
match &self.source {
|
||||
Source::Dir(root) => {
|
||||
let file =
|
||||
std::fs::File::open(root.join(name)).map_err(|e| format!("{name}: {e}"))?;
|
||||
file.take(MAX_FILE + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
}
|
||||
Source::Zip(path) => {
|
||||
let mut zip = open_zip(path)?;
|
||||
let entry = zip.by_name(name).map_err(|e| format!("{name}: {e}"))?;
|
||||
entry
|
||||
.take(MAX_FILE + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
if out.len() as u64 > MAX_FILE {
|
||||
return Err(format!("{name} is over {} MiB", MAX_FILE >> 20));
|
||||
}
|
||||
Ok(out)
|
||||
self.reader()?.read(name)
|
||||
}
|
||||
|
||||
/// A reader of its content files. A packaged mod's archive is opened
|
||||
/// once for all of them (opening it reads its whole index).
|
||||
pub fn reader(&self) -> Result<FileReader<'_>, String> {
|
||||
Ok(match &self.source {
|
||||
Source::Dir(root) => FileReader::Dir(root),
|
||||
Source::Zip(path) => FileReader::Zip(Box::new(open_zip(path)?)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where content may go, and the declarations.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! The hardware classes the public output breaks results down by. Coarse on purpose: a class
|
||||
//! holds many machines, and a published cell still needs `MIN_INSTALLS` different installs.
|
||||
//! `website/scripts/benchmarks.mjs` has the same functions, tested on the same fixtures.
|
||||
//!
|
||||
//! The raw values (the exact memory, the driver, the screen) stay in the database for the
|
||||
//! retention period and are never published.
|
||||
use horizon_telemetry::payload::System;
|
||||
use serde_json::Value;
|
||||
|
||||
/// The dimensions, in the order [`of_report`] and [`of_system`] return them.
|
||||
pub const DIMENSIONS: [&str; 5] = ["ram", "vram", "storage", "os", "gpu_kind"];
|
||||
|
||||
pub type Classes = [Option<&'static str>; 5];
|
||||
|
||||
/// Installed memory in GB (a little under the size on the box: some is reserved).
|
||||
pub fn ram(gb: f64) -> Option<&'static str> {
|
||||
Some(match gb {
|
||||
g if g.is_nan() || g <= 0.0 => return None,
|
||||
g if g <= 5.0 => "4 GB or less",
|
||||
g if g <= 10.0 => "8 GB",
|
||||
g if g <= 20.0 => "16 GB",
|
||||
g if g <= 40.0 => "32 GB",
|
||||
_ => "64 GB or more",
|
||||
})
|
||||
}
|
||||
|
||||
/// A GPU's own memory in MiB; an integrated GPU shares the system's.
|
||||
pub fn vram(mb: f64, kind: &str) -> Option<&'static str> {
|
||||
if gpu_kind(kind) == Some("integrated") {
|
||||
return Some("shared");
|
||||
}
|
||||
Some(match mb {
|
||||
m if m.is_nan() || m <= 0.0 => return None,
|
||||
m if m <= 2560.0 => "2 GB or less",
|
||||
m if m <= 4608.0 => "4 GB",
|
||||
m if m <= 6656.0 => "6 GB",
|
||||
m if m <= 8704.0 => "8 GB",
|
||||
m if m <= 12800.0 => "12 GB",
|
||||
m if m <= 16896.0 => "16 GB",
|
||||
_ => "more than 16 GB",
|
||||
})
|
||||
}
|
||||
|
||||
pub fn storage(s: &str) -> Option<&'static str> {
|
||||
["nvme", "ssd", "hdd"].into_iter().find(|k| *k == s)
|
||||
}
|
||||
|
||||
pub fn os(s: &str) -> Option<&'static str> {
|
||||
["linux", "macos", "windows"].into_iter().find(|k| *k == s)
|
||||
}
|
||||
|
||||
/// `integrated` or `discrete`, from either wording (`IntegratedGpu`, as wgpu says it, too).
|
||||
pub fn gpu_kind(s: &str) -> Option<&'static str> {
|
||||
match s {
|
||||
"integrated" | "IntegratedGpu" => Some("integrated"),
|
||||
"discrete" | "DiscreteGpu" => Some("discrete"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A benchmark report's classes (`docs/benchmark-report.md`'s `hardware`).
|
||||
pub fn of_report(report: &Value) -> Classes {
|
||||
let hw = &report["hardware"];
|
||||
let kind = hw["gpu"]["kind"].as_str().unwrap_or("");
|
||||
[
|
||||
hw["ram_mb"].as_f64().and_then(|mb| ram(mb / 1024.0)),
|
||||
vram(hw["gpu"]["vram_mb"].as_f64().unwrap_or(0.0), kind),
|
||||
storage(hw["storage"].as_str().unwrap_or("")),
|
||||
os(hw["os"].as_str().unwrap_or("")),
|
||||
gpu_kind(kind),
|
||||
]
|
||||
}
|
||||
|
||||
/// A telemetry item's machine's classes.
|
||||
pub fn of_system(s: &System) -> Classes {
|
||||
[
|
||||
ram(f64::from(s.ram_gb)),
|
||||
vram(f64::from(s.vram_mb), &s.gpu_kind),
|
||||
storage(&s.storage),
|
||||
os(&s.os),
|
||||
gpu_kind(&s.gpu_kind),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classes_are_coarse() {
|
||||
assert_eq!(ram(15.6), Some("16 GB"));
|
||||
assert_eq!(ram(31.0), Some("32 GB"));
|
||||
assert_eq!(ram(64.0), Some("64 GB or more"));
|
||||
assert_eq!(ram(0.0), None);
|
||||
assert_eq!(vram(16376.0, "discrete"), Some("16 GB"));
|
||||
assert_eq!(vram(8192.0, "DiscreteGpu"), Some("8 GB"));
|
||||
assert_eq!(vram(0.0, "integrated"), Some("shared"));
|
||||
assert_eq!(vram(0.0, ""), None);
|
||||
assert_eq!(storage("nvme"), Some("nvme"));
|
||||
assert_eq!(storage("tape"), None);
|
||||
assert_eq!(gpu_kind("virtual"), None);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use horizon_telemetry::batch;
|
||||
|
||||
pub mod classes;
|
||||
pub mod live;
|
||||
pub mod store;
|
||||
pub mod summary;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The database: one SQLite file, kept small.
|
||||
//!
|
||||
//! - `systems`: each distinct machine description once. Rows point at it, so a CPU and GPU
|
||||
//! name isn't stored again with every session.
|
||||
//! name isn't stored again with every session. `extra` holds the later hardware fields
|
||||
//! (cores, clocks, GPU maker and kind, VRAM, drive, screen, power, form) as JSON.
|
||||
//! - `perf`: one row per play session, in columns. A frame or tick summary is an 80-byte blob
|
||||
//! ([`pack`]), not JSON.
|
||||
//! - `reports`: crash and bug reports and benchmark uploads, their `data` zstd-compressed.
|
||||
@@ -17,6 +18,7 @@ use horizon_telemetry::Kind;
|
||||
use horizon_telemetry::batch::Batch;
|
||||
use horizon_telemetry::payload::{BUCKET_EDGES_MS, Payload, Summary, System};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const BUCKETS: usize = BUCKET_EDGES_MS.len() + 1;
|
||||
|
||||
@@ -27,8 +29,8 @@ CREATE TABLE IF NOT EXISTS systems (
|
||||
id INTEGER PRIMARY KEY,
|
||||
os TEXT NOT NULL, os_version TEXT NOT NULL, arch TEXT NOT NULL, cpu TEXT NOT NULL,
|
||||
threads INTEGER NOT NULL, ram_gb INTEGER NOT NULL,
|
||||
gpu TEXT NOT NULL, driver TEXT NOT NULL, backend TEXT NOT NULL,
|
||||
UNIQUE (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend)
|
||||
gpu TEXT NOT NULL, driver TEXT NOT NULL, backend TEXT NOT NULL, extra TEXT NOT NULL DEFAULT '{}',
|
||||
UNIQUE (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend, extra)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS perf (
|
||||
install BLOB NOT NULL, day INTEGER NOT NULL, version TEXT NOT NULL, system INTEGER NOT NULL,
|
||||
@@ -126,8 +128,9 @@ pub struct Store {
|
||||
|
||||
impl Store {
|
||||
pub fn open(path: &str) -> rusqlite::Result<Self> {
|
||||
let db = Connection::open(path)?;
|
||||
let mut db = Connection::open(path)?;
|
||||
db.execute_batch(SCHEMA)?;
|
||||
migrate(&mut db)?;
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
@@ -357,14 +360,91 @@ impl Store {
|
||||
})?
|
||||
.collect::<Result<_, _>>()?
|
||||
};
|
||||
let breakdowns = self.breakdowns(from, to)?;
|
||||
Ok(serde_json::json!({
|
||||
"from_day": from, "to_day": to, "min_install_days": k,
|
||||
"bucket_edges_ms": BUCKET_EDGES_MS.map(|e| (f64::from(e) * 10.0).round() / 10.0),
|
||||
"totals": { "sessions": sessions, "hours": (hours * 10.0).round() / 10.0 },
|
||||
"groups": out, "crashes_by_version": crashes,
|
||||
"groups": out, "crashes_by_version": crashes, "breakdowns": breakdowns,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Play sessions in `from..=to` per hardware class (`classes`): for each RAM, VRAM, drive,
|
||||
/// OS and GPU kind class with at least `MIN_INSTALLS` different installs, its installs,
|
||||
/// sessions, hours and mean p50 and p99 frame times. From the raw rows, so only within the
|
||||
/// retention period.
|
||||
fn breakdowns(&self, from: u32, to: u32) -> rusqlite::Result<serde_json::Value> {
|
||||
#[derive(Default)]
|
||||
struct Cell {
|
||||
installs: HashSet<Vec<u8>>,
|
||||
sessions: u64,
|
||||
seconds: u64,
|
||||
p50_sum: f64,
|
||||
p99_sum: f64,
|
||||
}
|
||||
let mut cells: [HashMap<&'static str, Cell>; 5] = Default::default();
|
||||
let mut q = self.db.prepare(
|
||||
"SELECT perf.install, perf.seconds, perf.frames, systems.os, systems.ram_gb, systems.extra
|
||||
FROM perf JOIN systems ON systems.id = perf.system WHERE perf.day BETWEEN ?1 AND ?2",
|
||||
)?;
|
||||
let mut rows = q.query([from, to])?;
|
||||
while let Some(r) = rows.next()? {
|
||||
let Some(frames) = unpack(&r.get::<_, Vec<u8>>(2)?) else {
|
||||
continue;
|
||||
};
|
||||
let extra: Extra = serde_json::from_str(&r.get::<_, String>(5)?).unwrap_or_default();
|
||||
let system = System {
|
||||
os: r.get(3)?,
|
||||
ram_gb: r.get(4)?,
|
||||
gpu_kind: extra.gpu_kind,
|
||||
vram_mb: extra.vram_mb,
|
||||
storage: extra.storage,
|
||||
..System::default()
|
||||
};
|
||||
let install: Vec<u8> = r.get(0)?;
|
||||
let seconds: u64 = r.get(1)?;
|
||||
for (dim, class) in crate::classes::of_system(&system).into_iter().enumerate() {
|
||||
let Some(class) = class else { continue };
|
||||
let c = cells[dim].entry(class).or_default();
|
||||
c.installs.insert(install.clone());
|
||||
c.sessions += 1;
|
||||
c.seconds += seconds;
|
||||
c.p50_sum += f64::from(frames.p50_ms);
|
||||
c.p99_sum += f64::from(frames.p99_ms);
|
||||
}
|
||||
}
|
||||
let mut out = serde_json::Map::new();
|
||||
for (dim, by_class) in crate::classes::DIMENSIONS.iter().zip(cells) {
|
||||
let mut list: Vec<(&str, Cell)> = by_class
|
||||
.into_iter()
|
||||
.filter(|(_, c)| c.installs.len() >= crate::summary::MIN_INSTALLS)
|
||||
.collect();
|
||||
list.sort_by(|a, b| {
|
||||
b.1.installs
|
||||
.len()
|
||||
.cmp(&a.1.installs.len())
|
||||
.then(a.0.cmp(b.0))
|
||||
});
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let avg = |sum: f64, n: u64| (sum / n as f64 * 10.0).round() / 10.0;
|
||||
out.insert(
|
||||
dim.to_string(),
|
||||
list.into_iter()
|
||||
.map(|(class, c)| {
|
||||
serde_json::json!({
|
||||
"class": class, "installs": c.installs.len(), "sessions": c.sessions,
|
||||
"hours": (c.seconds as f64 / 360.0).round() / 10.0,
|
||||
"p50_ms": avg(c.p50_sum, c.sessions), "p99_ms": avg(c.p99_sum, c.sessions),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
Ok(serde_json::Value::Object(out))
|
||||
}
|
||||
|
||||
/// Every benchmark upload received since `from_day`, with its install (to count installs, never shown)
|
||||
/// and release, for `summary`.
|
||||
pub fn benchmark_uploads(
|
||||
@@ -398,15 +478,74 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// The hardware fields after the first nine, as `systems.extra` keeps them.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Extra {
|
||||
pub cores: u16,
|
||||
pub cpu_base_mhz: u32,
|
||||
pub cpu_boost_mhz: u32,
|
||||
pub gpu_vendor: String,
|
||||
pub gpu_kind: String,
|
||||
pub vram_mb: u32,
|
||||
pub storage: String,
|
||||
pub display: Option<horizon_telemetry::payload::Display>,
|
||||
pub power: String,
|
||||
pub form: String,
|
||||
}
|
||||
|
||||
impl Extra {
|
||||
fn of(s: &System) -> Self {
|
||||
Self {
|
||||
cores: s.cores,
|
||||
cpu_base_mhz: s.cpu_base_mhz,
|
||||
cpu_boost_mhz: s.cpu_boost_mhz,
|
||||
gpu_vendor: s.gpu_vendor.clone(),
|
||||
gpu_kind: s.gpu_kind.clone(),
|
||||
vram_mb: s.vram_mb,
|
||||
storage: s.storage.clone(),
|
||||
display: s.display.clone(),
|
||||
power: s.power.clone(),
|
||||
form: s.form.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Databases from before `systems.extra`: the table rebuilt with it (ids kept), once.
|
||||
fn migrate(db: &mut Connection) -> rusqlite::Result<()> {
|
||||
let has_extra = db
|
||||
.prepare("SELECT 1 FROM pragma_table_info('systems') WHERE name = 'extra'")?
|
||||
.exists([])?;
|
||||
if has_extra {
|
||||
return Ok(());
|
||||
}
|
||||
let tx = db.transaction()?;
|
||||
tx.execute_batch(
|
||||
"CREATE TABLE systems_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
os TEXT NOT NULL, os_version TEXT NOT NULL, arch TEXT NOT NULL, cpu TEXT NOT NULL,
|
||||
threads INTEGER NOT NULL, ram_gb INTEGER NOT NULL,
|
||||
gpu TEXT NOT NULL, driver TEXT NOT NULL, backend TEXT NOT NULL, extra TEXT NOT NULL DEFAULT '{}',
|
||||
UNIQUE (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend, extra)
|
||||
);
|
||||
INSERT INTO systems_new (id, os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend)
|
||||
SELECT id, os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend FROM systems;
|
||||
DROP TABLE systems;
|
||||
ALTER TABLE systems_new RENAME TO systems;",
|
||||
)?;
|
||||
tx.commit()
|
||||
}
|
||||
|
||||
fn system_id(tx: &rusqlite::Transaction, s: &System) -> rusqlite::Result<i64> {
|
||||
let extra = serde_json::to_string(&Extra::of(s)).unwrap_or_else(|_| "{}".into());
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO systems (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![s.os, s.os_version, s.arch, s.cpu, s.threads, s.ram_gb, s.gpu, s.driver, s.backend],
|
||||
"INSERT OR IGNORE INTO systems (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend, extra)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![s.os, s.os_version, s.arch, s.cpu, s.threads, s.ram_gb, s.gpu, s.driver, s.backend, extra],
|
||||
)?;
|
||||
tx.query_row(
|
||||
"SELECT id FROM systems WHERE os = ?1 AND os_version = ?2 AND arch = ?3 AND cpu = ?4
|
||||
AND threads = ?5 AND ram_gb = ?6 AND gpu = ?7 AND driver = ?8 AND backend = ?9",
|
||||
AND threads = ?5 AND ram_gb = ?6 AND gpu = ?7 AND driver = ?8 AND backend = ?9 AND extra = ?10",
|
||||
params![
|
||||
s.os,
|
||||
s.os_version,
|
||||
@@ -416,7 +555,8 @@ fn system_id(tx: &rusqlite::Transaction, s: &System) -> rusqlite::Result<i64> {
|
||||
s.ram_gb,
|
||||
s.gpu,
|
||||
s.driver,
|
||||
s.backend
|
||||
s.backend,
|
||||
extra
|
||||
],
|
||||
|r| r.get(0),
|
||||
)
|
||||
@@ -432,3 +572,65 @@ pub fn inflate(body: &[u8]) -> serde_json::Value {
|
||||
.and_then(|_| serde_json::from_slice(&json).ok())
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A database from before `systems.extra` opens, keeps its machines' ids and takes new ones.
|
||||
#[test]
|
||||
fn systems_from_before_extra_are_kept() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"horizon-telemetry-migrate-{}.sqlite",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
{
|
||||
let db = Connection::open(&path).unwrap();
|
||||
db.execute_batch(
|
||||
"CREATE TABLE systems (
|
||||
id INTEGER PRIMARY KEY,
|
||||
os TEXT NOT NULL, os_version TEXT NOT NULL, arch TEXT NOT NULL, cpu TEXT NOT NULL,
|
||||
threads INTEGER NOT NULL, ram_gb INTEGER NOT NULL,
|
||||
gpu TEXT NOT NULL, driver TEXT NOT NULL, backend TEXT NOT NULL,
|
||||
UNIQUE (os, os_version, arch, cpu, threads, ram_gb, gpu, driver, backend)
|
||||
);
|
||||
INSERT INTO systems VALUES (7, 'linux', 'Fedora 43', 'x86_64', 'Ryzen', 32, 32, 'RTX 4070', '570', 'Vulkan');",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let path_str = path.to_str().unwrap();
|
||||
let mut store = Store::open(path_str).unwrap();
|
||||
let old: (String, String) = store
|
||||
.db
|
||||
.query_row("SELECT gpu, extra FROM systems WHERE id = 7", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(old, ("RTX 4070".to_owned(), "{}".to_owned()));
|
||||
let s = System {
|
||||
os: "linux".into(),
|
||||
gpu: "RTX 4070".into(),
|
||||
vram_mb: 12288,
|
||||
gpu_kind: "discrete".into(),
|
||||
storage: "nvme".into(),
|
||||
..System::default()
|
||||
};
|
||||
let tx = store.db.transaction().unwrap();
|
||||
let id = system_id(&tx, &s).unwrap();
|
||||
assert_eq!(system_id(&tx, &s).unwrap(), id, "the same machine once");
|
||||
let extra: String = tx
|
||||
.query_row("SELECT extra FROM systems WHERE id = ?1", [id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
let back: Extra = serde_json::from_str(&extra).unwrap();
|
||||
assert_eq!((back.vram_mb, back.storage.as_str()), (12288, "nvme"));
|
||||
drop(store);
|
||||
Store::open(path_str).unwrap(); // a second open doesn't rebuild again
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{path_str}{suffix}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,9 @@ pub fn rejection(report: &Value) -> Option<&'static str> {
|
||||
if report.get("kind").and_then(Value::as_str) != Some("game") {
|
||||
return Some("not a game run");
|
||||
}
|
||||
game(&report["app"])?;
|
||||
if game(&report["app"]).is_none() {
|
||||
return Some("not Horizon or plain vanilla");
|
||||
}
|
||||
let c = &report["conditions"];
|
||||
let scenario = c.get("scenario").and_then(Value::as_str).unwrap_or("");
|
||||
if !(scenario == "standard" || scenario == "flight" || scenario.starts_with("perf_bench:")) {
|
||||
@@ -190,6 +192,8 @@ fn newest_first(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
#[derive(Default)]
|
||||
struct Group {
|
||||
installs: HashMap<Vec<u8>, Vec<[Option<f64>; 7]>>,
|
||||
/// Per dimension and class, the installs in it.
|
||||
classes: [BTreeMap<&'static str, BTreeSet<Vec<u8>>>; 5],
|
||||
reports: usize,
|
||||
versions: BTreeSet<String>,
|
||||
}
|
||||
@@ -219,6 +223,14 @@ pub fn groups(uploads: &[Upload], min_installs: usize) -> Vec<Value> {
|
||||
.entry(u.install.clone())
|
||||
.or_default()
|
||||
.push(figures(r));
|
||||
for (dim, class) in crate::classes::of_report(r).into_iter().enumerate() {
|
||||
if let Some(class) = class {
|
||||
g.classes[dim]
|
||||
.entry(class)
|
||||
.or_default()
|
||||
.insert(u.install.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<(usize, Value)> = by
|
||||
.into_iter()
|
||||
@@ -243,11 +255,13 @@ pub fn groups(uploads: &[Upload], min_installs: usize) -> Vec<Value> {
|
||||
versions.sort_by(|a, b| newest_first(a, b));
|
||||
versions.truncate(3);
|
||||
let installs = g.installs.len();
|
||||
let breakdowns = breakdowns(&g.classes, min_installs);
|
||||
(
|
||||
installs,
|
||||
json!({
|
||||
"gpu": gpu, "cpu": cpu, "render_distance": rd, "game": game, "source": "players",
|
||||
"installs": installs, "reports": g.reports, "versions": versions, "medians": medians,
|
||||
"breakdowns": breakdowns,
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -257,6 +271,33 @@ pub fn groups(uploads: &[Upload], min_installs: usize) -> Vec<Value> {
|
||||
out.into_iter().map(|(_, v)| v).collect()
|
||||
}
|
||||
|
||||
/// Per dimension, the classes with at least `min_installs` installs, most first:
|
||||
/// `{"ram": [{"class": "32 GB", "installs": 3}], …}`. A dimension with none is left out.
|
||||
pub fn breakdowns(
|
||||
classes: &[BTreeMap<&'static str, BTreeSet<Vec<u8>>>; 5],
|
||||
min_installs: usize,
|
||||
) -> Value {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (dim, by_class) in crate::classes::DIMENSIONS.iter().zip(classes) {
|
||||
let mut cells: Vec<(&str, usize)> = by_class
|
||||
.iter()
|
||||
.map(|(c, set)| (*c, set.len()))
|
||||
.filter(|(_, n)| *n >= min_installs)
|
||||
.collect();
|
||||
cells.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
|
||||
if !cells.is_empty() {
|
||||
out.insert(
|
||||
dim.to_string(),
|
||||
cells
|
||||
.into_iter()
|
||||
.map(|(class, installs)| json!({"class": class, "installs": installs}))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// The whole answer; `generated` is an RFC 3339 UTC time.
|
||||
pub fn summary(uploads: &[Upload], min_installs: usize, generated: &str) -> Value {
|
||||
json!({
|
||||
|
||||
@@ -157,6 +157,45 @@ async fn totals_show_only_groups_with_enough_installs() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Sessions split by hardware class; a class shows only with three installs, and never a raw value.
|
||||
#[tokio::test]
|
||||
async fn totals_break_down_by_hardware_class() {
|
||||
let s = server(Config {
|
||||
min_install_days: 1,
|
||||
..generous()
|
||||
});
|
||||
for n in 0..4 {
|
||||
let mut item = perf("NVIDIA GeForce RTX 4080", 12);
|
||||
if let Payload::Perf(p) = &mut item.payload {
|
||||
p.system.ram_gb = if n == 3 { 16 } else { 31 };
|
||||
p.system.vram_mb = 16376;
|
||||
p.system.gpu_kind = "discrete".into();
|
||||
p.system.storage = if n == 3 { "hdd" } else { "nvme" }.into();
|
||||
}
|
||||
assert_eq!(
|
||||
s.post(&install(n), vec![item]).await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let totals = s.aggregate().await;
|
||||
let b = &totals["breakdowns"];
|
||||
assert_eq!(
|
||||
b["ram"],
|
||||
serde_json::json!([{"class": "32 GB", "installs": 3, "sessions": 3, "hours": 1.5, "p50_ms": b["ram"][0]["p50_ms"], "p99_ms": b["ram"][0]["p99_ms"]}]),
|
||||
"16 GB has one install: {totals}"
|
||||
);
|
||||
assert!(b["ram"][0]["p50_ms"].as_f64().unwrap() > 8.0);
|
||||
assert_eq!(b["vram"][0]["class"], "16 GB");
|
||||
assert_eq!(b["vram"][0]["installs"], 4);
|
||||
assert_eq!(b["storage"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(b["os"][0]["class"], "linux");
|
||||
assert_eq!(b["gpu_kind"][0]["class"], "discrete");
|
||||
let text = totals.to_string();
|
||||
for raw in ["16376", "570.86", "Fedora", "hdd", &install(0)] {
|
||||
assert!(!text.contains(raw), "{raw} in {text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_batches_are_refused() {
|
||||
let s = server(generous());
|
||||
|
||||
@@ -148,6 +148,7 @@ async fn uploads_come_back_grouped_and_anonymous() {
|
||||
assert_eq!(origin.as_deref(), Some("https://playhorizon.dev"));
|
||||
let s: Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(s["groups"], expected()["groups"]);
|
||||
assert_eq!(s["groups"][0]["breakdowns"]["vram"][0]["installs"], 3);
|
||||
let generated = s["generated"].as_str().unwrap();
|
||||
assert!(
|
||||
generated.len() == 20 && generated.ends_with('Z'),
|
||||
@@ -160,6 +161,9 @@ async fn uploads_come_back_grouped_and_anonymous() {
|
||||
"secret-driver",
|
||||
"20394",
|
||||
"ram_mb",
|
||||
"vram_mb",
|
||||
"16376",
|
||||
"32768",
|
||||
] {
|
||||
assert!(!body.contains(secret), "{secret} in {body}");
|
||||
}
|
||||
|
||||
@@ -30,8 +30,9 @@ pub use payload::{Item, Kind, Payload, Source};
|
||||
pub const SCHEMA: u32 = 1;
|
||||
|
||||
/// What players agree to. Bumped when what is collected changes: the launcher then asks again,
|
||||
/// and until the player answers nothing new is kept or sent.
|
||||
pub const CONSENT_VERSION: u32 = 1;
|
||||
/// and until the player answers nothing new is kept or sent. 2: the machine's full hardware
|
||||
/// (cores, clocks, VRAM, drive, screen, power, form), 2026-10-23.
|
||||
pub const CONSENT_VERSION: u32 = 2;
|
||||
|
||||
/// The ingest server (`crates/horizon-telemetry-server`, on Railway). `HORIZON_TELEMETRY_URL`
|
||||
/// overrides it (a local server); `HORIZON_TELEMETRY_URL=off` sends nothing at all, no ping
|
||||
|
||||
@@ -129,8 +129,9 @@ impl Item {
|
||||
}
|
||||
}
|
||||
|
||||
/// The machine, as coarse as is useful: no serial numbers, names or exact memory.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
/// The machine, as coarse as is useful: no serial numbers, names or exact memory. Every field
|
||||
/// explains performance; `docs/telemetry.md` lists them. Empty or zero: not known.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct System {
|
||||
/// `linux`, `macos` or `windows`.
|
||||
@@ -140,12 +141,42 @@ pub struct System {
|
||||
pub arch: String,
|
||||
pub cpu: String,
|
||||
pub threads: u16,
|
||||
/// Physical cores.
|
||||
pub cores: u16,
|
||||
/// The CPU's base and top boost clock, where the OS says.
|
||||
pub cpu_base_mhz: u32,
|
||||
pub cpu_boost_mhz: u32,
|
||||
/// Installed memory, rounded to the GiB.
|
||||
pub ram_gb: u16,
|
||||
pub gpu: String,
|
||||
/// `nvidia`, `amd`, `intel`, `apple`…
|
||||
pub gpu_vendor: String,
|
||||
/// `integrated`, `discrete`, `virtual` or `cpu`.
|
||||
pub gpu_kind: String,
|
||||
/// The GPU's own memory, rounded to 256 MiB (0 for a GPU that shares the system's).
|
||||
pub vram_mb: u32,
|
||||
pub driver: String,
|
||||
/// `Metal`, `Vulkan`, `Dx12`…
|
||||
pub backend: String,
|
||||
/// The drive under the game folder: `nvme`, `ssd` or `hdd`.
|
||||
pub storage: String,
|
||||
/// The screen the game's window is on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display: Option<Display>,
|
||||
/// `ac` or `battery`, when the game started.
|
||||
pub power: String,
|
||||
/// `laptop` or `desktop`.
|
||||
pub form: String,
|
||||
}
|
||||
|
||||
/// A screen: its size in physical pixels, refresh rate and scale.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Display {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: u32,
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
/// The settings that change performance.
|
||||
@@ -190,7 +221,7 @@ pub struct Perf {
|
||||
|
||||
/// A crash, scrubbed: the error and its stack, the thread, and the details that say what the
|
||||
/// game was doing (graphics adapter, settings), never its log, world or server.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Crash {
|
||||
pub system: System,
|
||||
@@ -202,7 +233,7 @@ pub struct Crash {
|
||||
}
|
||||
|
||||
/// A bug report the player wrote, with the log only if they chose to add it after reading it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Bug {
|
||||
pub system: System,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
//! The machine as an item describes it ([`System`]): what the OS says, read the same way on
|
||||
//! every start. The game adds the GPU, its driver and the graphics backend.
|
||||
//! every start. The game adds the GPU (its name, maker, kind, driver and backend, then
|
||||
//! [`vram_mb`] for it) and the screen.
|
||||
//!
|
||||
//! Only what explains performance: no serial number, host name, user name or path is read into
|
||||
//! an item. The slower probes (a command to run) are read once per process.
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::payload::System;
|
||||
|
||||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
@@ -74,20 +81,422 @@ fn ram_bytes() -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical cores.
|
||||
fn cores() -> Option<u16> {
|
||||
if cfg!(target_os = "linux") {
|
||||
cores_from_cpuinfo(&read("/proc/cpuinfo")?)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "hw.physicalcpu"])?
|
||||
.parse()
|
||||
.ok()
|
||||
} else if cfg!(windows) {
|
||||
powershell("(Get-CimInstance Win32_Processor | Measure-Object NumberOfCores -Sum).Sum")?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinct (package, core) pairs; a listing without them (ARM boards) counts its processors.
|
||||
fn cores_from_cpuinfo(text: &str) -> Option<u16> {
|
||||
let mut pairs = std::collections::HashSet::new();
|
||||
let (mut package, mut processors) = (String::new(), 0u16);
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
match key.trim() {
|
||||
"processor" => processors += 1,
|
||||
"physical id" => package = value.trim().to_owned(),
|
||||
"core id" => {
|
||||
pairs.insert((package.clone(), value.trim().to_owned()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let n = if pairs.is_empty() {
|
||||
processors
|
||||
} else {
|
||||
pairs.len() as u16
|
||||
};
|
||||
(n > 0).then_some(n)
|
||||
}
|
||||
|
||||
/// The base and top boost clocks in MHz, where the OS gives them (not on Apple silicon).
|
||||
fn cpu_mhz() -> (Option<u32>, Option<u32>) {
|
||||
let khz = |path: &str| {
|
||||
read(path)
|
||||
.and_then(|t| t.trim().parse::<u32>().ok())
|
||||
.map(|k| k / 1000)
|
||||
};
|
||||
if cfg!(target_os = "linux") {
|
||||
let base = khz("/sys/devices/system/cpu/cpu0/cpufreq/base_frequency").or_else(|| {
|
||||
read("/sys/devices/system/cpu/cpu0/acpi_cppc/nominal_freq")
|
||||
.and_then(|t| t.trim().parse().ok())
|
||||
});
|
||||
(
|
||||
base,
|
||||
khz("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq"),
|
||||
)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let hz = |key: &str| {
|
||||
command_output("sysctl", &["-n", key])
|
||||
.and_then(|t| t.parse::<u64>().ok())
|
||||
.map(|hz| (hz / 1_000_000) as u32)
|
||||
};
|
||||
(hz("hw.cpufrequency"), hz("hw.cpufrequency_max"))
|
||||
} else if cfg!(windows) {
|
||||
let base =
|
||||
powershell("(Get-CimInstance Win32_Processor | Select-Object -First 1).MaxClockSpeed")
|
||||
.and_then(|t| t.parse().ok());
|
||||
(base, None)
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn powershell(command: &str) -> Option<String> {
|
||||
command_output("powershell", &["-NoProfile", "-Command", command])
|
||||
}
|
||||
|
||||
/// `nvme`, `ssd` or `hdd` for the drive `path` is on, where it can be read.
|
||||
pub fn storage(path: &Path) -> Option<&'static str> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let source = mount_source(&read("/proc/self/mountinfo")?, path)?;
|
||||
let name = source.strip_prefix("/dev/")?;
|
||||
// /dev/mapper/x and /dev/dm-0 (LUKS, LVM) sit on the disks in their slaves/.
|
||||
let name = std::fs::canonicalize(format!("/dev/{name}"))
|
||||
.ok()
|
||||
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
.unwrap_or_else(|| name.to_owned());
|
||||
linux_disk_kind(&name, 0)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let df = command_output("df", &[&path.to_string_lossy()])?;
|
||||
let device = df.lines().nth(1)?.split_whitespace().next()?.to_owned();
|
||||
let info = command_output("diskutil", &["info", &device])?;
|
||||
disk_kind_from_diskutil(&info)
|
||||
} else if cfg!(windows) {
|
||||
let letter = path.to_string_lossy().chars().next()?.to_ascii_uppercase();
|
||||
if !letter.is_ascii_alphabetic() {
|
||||
return None;
|
||||
}
|
||||
let out = powershell(&format!(
|
||||
"$d = (Get-Partition -DriveLetter {letter}).DiskNumber; \
|
||||
$p = Get-PhysicalDisk | Where-Object DeviceId -eq $d; \"$($p.BusType) $($p.MediaType)\""
|
||||
))?;
|
||||
let out = out.to_ascii_lowercase();
|
||||
if out.contains("nvme") {
|
||||
Some("nvme")
|
||||
} else if out.contains("ssd") {
|
||||
Some("ssd")
|
||||
} else if out.contains("hdd") {
|
||||
Some("hdd")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The device mounted at the longest mount point above `path`, from `/proc/self/mountinfo`.
|
||||
fn mount_source(mountinfo: &str, path: &Path) -> Option<String> {
|
||||
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned());
|
||||
mountinfo
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
let point = fields.get(4)?;
|
||||
let dash = fields.iter().position(|f| *f == "-")?;
|
||||
let source = fields.get(dash + 2)?;
|
||||
path.starts_with(point)
|
||||
.then(|| (point.len(), source.to_string()))
|
||||
})
|
||||
.max_by_key(|(len, _)| *len)
|
||||
.map(|(_, source)| source)
|
||||
}
|
||||
|
||||
fn linux_disk_kind(name: &str, depth: u8) -> Option<&'static str> {
|
||||
if depth > 4 {
|
||||
return None;
|
||||
}
|
||||
let class = Path::new("/sys/class/block").join(name);
|
||||
// A mapper device: the first disk under it.
|
||||
if let Ok(slaves) = std::fs::read_dir(class.join("slaves"))
|
||||
&& let Some(slave) = slaves.flatten().next()
|
||||
{
|
||||
return linux_disk_kind(&slave.file_name().to_string_lossy(), depth + 1);
|
||||
}
|
||||
// A partition: its disk is the parent directory.
|
||||
let device = std::fs::canonicalize(&class).ok()?;
|
||||
let disk = if device.join("partition").exists() {
|
||||
device.parent()?.to_owned()
|
||||
} else {
|
||||
device
|
||||
};
|
||||
let disk_name = disk.file_name()?.to_string_lossy();
|
||||
if disk_name.starts_with("nvme") {
|
||||
return Some("nvme");
|
||||
}
|
||||
match std::fs::read_to_string(disk.join("queue/rotational"))
|
||||
.ok()?
|
||||
.trim()
|
||||
{
|
||||
"0" => Some("ssd"),
|
||||
"1" => Some("hdd"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn disk_kind_from_diskutil(info: &str) -> Option<&'static str> {
|
||||
let solid = field_spaced(info, "Solid State")?;
|
||||
if solid != "Yes" {
|
||||
return Some("hdd");
|
||||
}
|
||||
let protocol = field_spaced(info, "Protocol").unwrap_or_default();
|
||||
Some(
|
||||
if ["PCI-Express", "Apple Fabric", "NVMe"]
|
||||
.iter()
|
||||
.any(|p| protocol.contains(p))
|
||||
{
|
||||
"nvme"
|
||||
} else {
|
||||
"ssd"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// ` Key: value` listings (diskutil, system_profiler).
|
||||
fn field_spaced(text: &str, key: &str) -> Option<String> {
|
||||
text.lines().find_map(|l| {
|
||||
let (k, v) = l.split_once(':')?;
|
||||
(k.trim() == key).then(|| v.trim().to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read.
|
||||
pub fn power() -> Option<&'static str> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let batt = command_output("pmset", &["-g", "batt"])?;
|
||||
return Some(if batt.contains("'Battery Power'") {
|
||||
"battery"
|
||||
} else {
|
||||
"ac"
|
||||
});
|
||||
}
|
||||
if cfg!(windows) {
|
||||
// BatteryStatus 1: discharging.
|
||||
let status =
|
||||
powershell("(Get-CimInstance Win32_Battery | Select-Object -First 1).BatteryStatus")?;
|
||||
return Some(if status.trim() == "1" {
|
||||
"battery"
|
||||
} else {
|
||||
"ac"
|
||||
});
|
||||
}
|
||||
let mut on_battery = None;
|
||||
for entry in std::fs::read_dir("/sys/class/power_supply").ok()?.flatten() {
|
||||
let read =
|
||||
|name: &str| std::fs::read_to_string(entry.path().join(name)).unwrap_or_default();
|
||||
match read("type").trim() {
|
||||
"Mains" if read("online").trim() == "1" => return Some("ac"),
|
||||
"Battery" => on_battery = Some("battery"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
on_battery
|
||||
}
|
||||
|
||||
/// `laptop` or `desktop`: the firmware's chassis type, else whether there is a battery.
|
||||
fn form() -> Option<&'static str> {
|
||||
if cfg!(target_os = "linux") {
|
||||
if let Some(kind) =
|
||||
read("/sys/class/dmi/id/chassis_type").and_then(|t| t.trim().parse::<u8>().ok())
|
||||
{
|
||||
match kind {
|
||||
8 | 9 | 10 | 14 | 30 | 31 | 32 => return Some("laptop"),
|
||||
3..=7 | 13 | 15 | 16 | 35 | 36 => return Some("desktop"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let battery = std::fs::read_dir("/sys/class/power_supply")
|
||||
.ok()?
|
||||
.flatten()
|
||||
.any(|e| {
|
||||
std::fs::read_to_string(e.path().join("type")).is_ok_and(|t| t.trim() == "Battery")
|
||||
});
|
||||
Some(if battery { "laptop" } else { "desktop" })
|
||||
} else if cfg!(target_os = "macos") {
|
||||
let batt = command_output("pmset", &["-g", "batt"])?;
|
||||
Some(if batt.contains("InternalBattery") {
|
||||
"laptop"
|
||||
} else {
|
||||
"desktop"
|
||||
})
|
||||
} else if cfg!(windows) {
|
||||
let n = powershell("@(Get-CimInstance Win32_Battery).Count")?;
|
||||
Some(if n.trim() == "0" { "desktop" } else { "laptop" })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The dedicated memory of the GPU from `vendor` (`nvidia`, `amd`, `intel`, `apple`), in MiB
|
||||
/// rounded to 256, where the driver says; read once. `None` for a GPU sharing the system's.
|
||||
pub fn vram_mb(vendor: &str) -> Option<u32> {
|
||||
static CACHE: OnceLock<std::sync::Mutex<std::collections::HashMap<String, Option<u32>>>> =
|
||||
OnceLock::new();
|
||||
let cache = CACHE.get_or_init(Default::default);
|
||||
let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*cache.entry(vendor.to_ascii_lowercase()).or_insert_with(|| {
|
||||
read_vram_mb(&vendor.to_ascii_lowercase()).map(|mb| mb.div_ceil(256) * 256)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_vram_mb(vendor: &str) -> Option<u32> {
|
||||
if cfg!(target_os = "linux") {
|
||||
if vendor.contains("nvidia") {
|
||||
return command_output(
|
||||
"nvidia-smi",
|
||||
&["--query-gpu=memory.total", "--format=csv,noheader,nounits"],
|
||||
)?
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().parse::<u32>().ok())
|
||||
.max();
|
||||
}
|
||||
// amdgpu (and Intel's discrete xe/i915 cards) in sysfs.
|
||||
std::fs::read_dir("/sys/class/drm")
|
||||
.ok()?
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let dev = e.path().join("device");
|
||||
let bytes = read(&dev.join("mem_info_vram_total").to_string_lossy())
|
||||
.or_else(|| read(&dev.join("lmem_total_bytes").to_string_lossy()))?;
|
||||
bytes.trim().parse::<u64>().ok().map(|b| (b >> 20) as u32)
|
||||
})
|
||||
.filter(|mb| *mb >= 512)
|
||||
.max()
|
||||
} else if cfg!(target_os = "macos") {
|
||||
// Apple silicon shares the system's memory: no "VRAM" line.
|
||||
let text = command_output("system_profiler", &["SPDisplaysDataType"])?;
|
||||
text.lines()
|
||||
.filter_map(|l| {
|
||||
let (k, v) = l.split_once(':')?;
|
||||
k.trim().starts_with("VRAM").then(|| size_mb(v))?
|
||||
})
|
||||
.max()
|
||||
} else if cfg!(windows) {
|
||||
// AdapterRAM stops at 4 GB; the driver's registry value doesn't.
|
||||
let out = powershell(
|
||||
"Get-ItemProperty 'HKLM:\\SYSTEM\\ControlSet001\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\0*' \
|
||||
-ErrorAction SilentlyContinue | ForEach-Object { \"$($_.DriverDesc)|$($_.'HardwareInformation.qwMemorySize')\" }",
|
||||
)?;
|
||||
out.lines()
|
||||
.filter_map(|l| {
|
||||
let (name, bytes) = l.split_once('|')?;
|
||||
let ok = vendor.is_empty()
|
||||
|| name.to_ascii_lowercase().contains(vendor)
|
||||
|| (vendor == "nvidia" && name.contains("GeForce"))
|
||||
|| (vendor == "amd" && name.contains("Radeon"));
|
||||
ok.then(|| bytes.trim().parse::<u64>().ok().map(|b| (b >> 20) as u32))?
|
||||
})
|
||||
.max()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// "8 GB", "1536 MB" -> MiB.
|
||||
fn size_mb(text: &str) -> Option<u32> {
|
||||
let mut parts = text.split_whitespace();
|
||||
let n: f64 = parts.next()?.parse().ok()?;
|
||||
match parts.next()? {
|
||||
"GB" => Some((n * 1024.0) as u32),
|
||||
"MB" => Some(n as u32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What doesn't change while the game runs, read once.
|
||||
struct Fixed {
|
||||
cores: u16,
|
||||
base: u32,
|
||||
boost: u32,
|
||||
form: &'static str,
|
||||
}
|
||||
|
||||
fn fixed() -> &'static Fixed {
|
||||
static FIXED: OnceLock<Fixed> = OnceLock::new();
|
||||
FIXED.get_or_init(|| {
|
||||
let (base, boost) = cpu_mhz();
|
||||
Fixed {
|
||||
cores: cores().unwrap_or(0),
|
||||
base: base.unwrap_or(0),
|
||||
boost: boost.unwrap_or(0),
|
||||
form: form().unwrap_or(""),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl System {
|
||||
/// This machine, without the GPU.
|
||||
pub fn detect() -> Self {
|
||||
/// This machine, without the GPU and the screen. `home` is the game folder, for the kind of
|
||||
/// drive it is on.
|
||||
pub fn detect_in(home: Option<&Path>) -> Self {
|
||||
const GIB: f64 = (1u64 << 30) as f64;
|
||||
static STORAGE: OnceLock<&'static str> = OnceLock::new();
|
||||
let fixed = fixed();
|
||||
Self {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version().unwrap_or_default(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu().unwrap_or_default(),
|
||||
threads: std::thread::available_parallelism().map_or(0, |n| n.get() as u16),
|
||||
cores: fixed.cores,
|
||||
cpu_base_mhz: fixed.base,
|
||||
cpu_boost_mhz: fixed.boost,
|
||||
ram_gb: ram_bytes().map_or(0, |b| (b as f64 / GIB).round() as u16),
|
||||
storage: home
|
||||
.map(|h| *STORAGE.get_or_init(|| storage(h).unwrap_or("")))
|
||||
.unwrap_or("")
|
||||
.to_owned(),
|
||||
power: power().unwrap_or("").to_owned(),
|
||||
form: fixed.form.to_owned(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`System::detect_in`] without the drive.
|
||||
pub fn detect() -> Self {
|
||||
Self::detect_in(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// The GPU's maker from its name and the vendor ID wgpu reports (0: none, as on Metal).
|
||||
pub fn gpu_vendor(name: &str, vendor_id: u32) -> &'static str {
|
||||
match vendor_id {
|
||||
0x10de => return "nvidia",
|
||||
0x1002 | 0x1022 => return "amd",
|
||||
0x8086 => return "intel",
|
||||
0x106b => return "apple",
|
||||
0x5143 => return "qualcomm",
|
||||
_ => {}
|
||||
}
|
||||
let name = name.to_ascii_lowercase();
|
||||
[
|
||||
("apple", "apple"),
|
||||
("nvidia", "nvidia"),
|
||||
("geforce", "nvidia"),
|
||||
("radeon", "amd"),
|
||||
("amd", "amd"),
|
||||
("intel", "intel"),
|
||||
("adreno", "qualcomm"),
|
||||
("llvmpipe", "software"),
|
||||
("lavapipe", "software"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|(needle, _)| name.contains(needle))
|
||||
.map_or("", |(_, v)| v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -114,11 +523,80 @@ mod tests {
|
||||
assert_eq!(field("NAME=x", "PRETTY_NAME"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cores_are_counted_once_per_package() {
|
||||
let two_cores_four_threads = "processor : 0\nphysical id : 0\ncore id : 0\n\nprocessor : 1\nphysical id : 0\ncore id : 1\n\nprocessor : 2\nphysical id : 0\ncore id : 0\n\nprocessor : 3\nphysical id : 0\ncore id : 1\n";
|
||||
assert_eq!(cores_from_cpuinfo(two_cores_four_threads), Some(2));
|
||||
assert_eq!(
|
||||
cores_from_cpuinfo("processor : 0\nprocessor : 1\n"),
|
||||
Some(2)
|
||||
);
|
||||
assert_eq!(cores_from_cpuinfo(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_game_folders_mount_is_found() {
|
||||
let mountinfo = "22 1 259:2 / / rw - ext4 /dev/nvme0n1p2 rw\n30 22 8:1 / /mnt/games rw - ext4 /dev/sda1 rw\n";
|
||||
assert_eq!(
|
||||
mount_source(mountinfo, Path::new("/home/x/.horizon")).as_deref(),
|
||||
Some("/dev/nvme0n1p2")
|
||||
);
|
||||
assert_eq!(
|
||||
mount_source(mountinfo, Path::new("/mnt/games/horizon")).as_deref(),
|
||||
Some("/dev/sda1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diskutil_and_sizes_are_read() {
|
||||
let info = " Device Node: /dev/disk3s1\n Protocol: Apple Fabric\n Solid State: Yes\n";
|
||||
assert_eq!(disk_kind_from_diskutil(info), Some("nvme"));
|
||||
assert_eq!(
|
||||
disk_kind_from_diskutil(" Solid State: No\n"),
|
||||
Some("hdd")
|
||||
);
|
||||
assert_eq!(
|
||||
disk_kind_from_diskutil(" Protocol: SATA\n Solid State: Yes\n"),
|
||||
Some("ssd")
|
||||
);
|
||||
assert_eq!(size_mb(" 8 GB"), Some(8192));
|
||||
assert_eq!(size_mb(" 1536 MB"), Some(1536));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_makers() {
|
||||
assert_eq!(gpu_vendor("NVIDIA GeForce RTX 4080", 0x10de), "nvidia");
|
||||
assert_eq!(gpu_vendor("Apple M4 Pro", 0), "apple");
|
||||
assert_eq!(gpu_vendor("AMD Radeon RX 7800 XT", 0), "amd");
|
||||
assert_eq!(
|
||||
gpu_vendor("Mesa Intel(R) HD Graphics 620 (KBL GT2)", 0x8086),
|
||||
"intel"
|
||||
);
|
||||
assert_eq!(gpu_vendor("llvmpipe (LLVM 19)", 0x10005), "software");
|
||||
}
|
||||
|
||||
/// `cargo test -p horizon-telemetry -- --ignored --nocapture what_this_machine_sends`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn what_this_machine_sends() {
|
||||
let home = std::env::current_dir().unwrap();
|
||||
let mut system = System::detect_in(Some(&home));
|
||||
system.vram_mb = vram_mb("nvidia").or_else(|| vram_mb("amd")).unwrap_or(0);
|
||||
println!("{}", serde_json::to_string_pretty(&system).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_machine_is_described() {
|
||||
let system = System::detect();
|
||||
assert_eq!(system.os, std::env::consts::OS);
|
||||
assert!(system.threads > 0);
|
||||
assert!(system.gpu.is_empty(), "the game fills the GPU in");
|
||||
assert!(system.cores == 0 || system.cores <= system.threads);
|
||||
assert!(["", "ac", "battery"].contains(&system.power.as_str()));
|
||||
assert!(["", "laptop", "desktop"].contains(&system.form.as_str()));
|
||||
let dir = std::env::temp_dir();
|
||||
assert!(
|
||||
["", "nvme", "ssd", "hdd"].contains(&System::detect_in(Some(&dir)).storage.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ kilobytes whatever the frame rate. Measured numbers are rounded to 3 decimals.
|
||||
"app": { "name": "horizon-client", "version": "0.21.0", "commit": "e96e1571" },
|
||||
"hardware": {
|
||||
"os": "macos", "os_version": "27.0", "arch": "aarch64",
|
||||
"cpu": "Apple M4 Pro", "cpu_threads": 14, "ram_mb": 65536,
|
||||
"cpu": "Apple M4 Pro", "cpu_threads": 14, "cpu_cores": 14, "ram_mb": 65536, "storage": "nvme", "form": "desktop",
|
||||
"gpu": { "name": "Apple M4 Pro", "vendor": "apple", "driver": "", "backend": "metal", "kind": "integrated" },
|
||||
"display": { "width": 2560, "height": 1440, "scale": 2.0, "refresh_hz": 60 }
|
||||
},
|
||||
@@ -58,9 +58,12 @@ one (histogram edges included) makes it 2.
|
||||
optionally `commit` (Horizon builds) and `mods` (`[{ "name", "version" }]`, vanilla with mods).
|
||||
|
||||
**`hardware`.** `os` (`macos`, `linux`, `windows`), `os_version`, `arch`, `cpu` (brand string),
|
||||
`cpu_threads`, `ram_mb`; `gpu` (`name`, `vendor`, `driver`, `backend`: `metal`, `vulkan`, `dx12`, `gl`;
|
||||
`kind`: `integrated`, `discrete`, `virtual`, `cpu`, `other`) and `display` (the screen the window was on:
|
||||
`width`, `height` in physical pixels, `scale`, `refresh_hz`) when known. Any field can be missing.
|
||||
`cpu_threads`, `cpu_cores` (physical), `cpu_base_mhz` and `cpu_boost_mhz` (where the OS gives them; not on
|
||||
Apple silicon), `ram_mb`, `storage` (the drive under the game folder: `nvme`, `ssd`, `hdd`), `form` (`laptop`,
|
||||
`desktop`); `gpu` (`name`, `vendor`, `driver`, `backend`: `metal`, `vulkan`, `dx12`, `gl`;
|
||||
`kind`: `integrated`, `discrete`, `virtual`, `cpu`, `other`; `vram_mb`, a discrete GPU's own memory) and
|
||||
`display` (the screen the window was on: `width`, `height` in physical pixels, `scale`, `refresh_hz`) when
|
||||
known. Any field can be missing. On mains or battery is `conditions.power`.
|
||||
|
||||
**`conditions`.** What has to match for two reports to be compared, all optional: `scenario`, `seed`,
|
||||
`render_distance`, `simulation_distance`, `window` (`[width, height]` actually rendered), `vsync`,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<!-- Generated by `horizon-ctl help --markdown` from the command table in crates/horizon-client/src/bin/horizon-ctl/commands.rs: edit the table, then regenerate. -->
|
||||
# horizon-ctl commands
|
||||
|
||||
Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launcher's `horizon-launcher mcp` offers the same tools as `game_<tool>`, all but `launch`) and its arguments. Positional arguments come first, in order; `--flags` after them. `horizon-ctl help --game` lists the game's own commands, test hooks included. Design rules and the full inventory: [agent-control.md](agent-control.md).
|
||||
|
||||
## Session
|
||||
|
||||
| Command | MCP tool | What it does | Arguments |
|
||||
| --- | --- | --- | --- |
|
||||
| `launch [--world NAME] [--seed N] [--creative] [--version TAG] [--game PATH] [--wait] [GAME ARGS...]` | `launch` | Start Horizon with the control socket and wait until it answers | `--world` (string): world folder to open or create<br>`--seed` (string): seed for a new world<br>`--creative` (boolean): creative mode for a new world<br>`--version` (string): an installed launcher version tag such as v2026.10.13<br>`--game` (string): path to a horizon-client binary<br>`--wait` (boolean): also wait until the world is ready (default in MCP)<br>`--offscreen` (boolean): render without showing a window (screenshots still work): locked or headless sessions |
|
||||
| `wait-for screen NAME\|widget ID\|loaded\|chat TEXT\|event KIND [--timeout S] [--since SEQ]` | `wait_for` | Wait until the game reaches a state, then reply | `--timeout` (number): seconds (default 30)<br>`--since` (integer): chat and event: count from this event seq<br>`condition` (screen, widget, loaded, chat, event, required): what to wait for<br>`value` (text): the screen name, widget id, chat text or event kind |
|
||||
| `wait-ready [SECONDS]` | `wait_ready` | Wait until the player has spawned and no chunks are pending | `seconds` (number): how long to wait (default 120) |
|
||||
| `logs [--lines N] [--crash]` | `logs` | The end of the game's log, or its newest crash report | `--lines` (integer): how many lines of latest.log (default 100)<br>`--crash` (boolean): the newest crash report instead |
|
||||
| `status` | `status` | Game, renderer and streaming state (the scripting socket's original summary) | none |
|
||||
| `worlds` | `worlds` | The saved worlds, newest first: folder, name, mode, last played, version | none |
|
||||
| `world-rename FOLDER NAME` | `world_rename` | Rename a world, as Edit World does (its folder stays) | `folder` (string, required): world folder from worlds<br>`name` (text, required): the new name |
|
||||
| `world-backup FOLDER` | `world_backup` | Zip a world into backups/, as Edit World's Make Backup does | `folder` (string, required): world folder from worlds |
|
||||
| `world-delete FOLDER --confirm` | `world_delete` (destructive) | Delete a world for good, as Select World's Delete does | `folder` (string, required): world folder from worlds<br>`--confirm` (boolean, required): true once the player agreed: it cannot be undone |
|
||||
| `open WORLD [SEED] [creative]` | `open` | Open or create a world from the title screen | `world` (string, required): world folder<br>`seed` (integer): seed for a new world<br>`mode` (creative): creative for a creative world |
|
||||
| `save` | `save` | Save the world | none |
|
||||
| `quit` | `quit` | Save and exit the game | none |
|
||||
| `screenshot PATH` | `screenshot` | Write the next rendered frame as a PNG | none |
|
||||
|
||||
## Observe (JSON state)
|
||||
|
||||
| Command | MCP tool | What it does | Arguments |
|
||||
| --- | --- | --- | --- |
|
||||
| `events [--since SEQ]` | `events` | What happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds | `--since` (integer): only events after this seq |
|
||||
| `state` | `state` | Player, world, screen and crosshair target in one reply | none |
|
||||
| `looking-at` | `looking_at` | The block or entity under the crosshair | none |
|
||||
| `entities [RADIUS] [TYPE]` | `entities` | Mobs, dropped items and experience orbs near the player, nearest first | `radius` (number): search radius in blocks (default 32)<br>`type` (string): entity type filter |
|
||||
| `block X Y Z` | `block` | The block state at a position | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z |
|
||||
| `light X1 Y1 Z1 X2 Y2 Z2` | `light` | The stored sky and block light of a box | `x1` (integer, required): corner X<br>`y1` (integer, required): corner Y<br>`z1` (integer, required): corner Z<br>`x2` (integer, required): opposite X<br>`y2` (integer, required): opposite Y<br>`z2` (integer, required): opposite Z |
|
||||
| `blocks X1 Y1 Z1 X2 Y2 Z2` | `blocks` | The non-air blocks of a box, with counts per block | `x1` (integer, required): corner X<br>`y1` (integer, required): corner Y<br>`z1` (integer, required): corner Z<br>`x2` (integer, required): opposite X<br>`y2` (integer, required): opposite Y<br>`z2` (integer, required): opposite Z |
|
||||
| `find-block BLOCK [RADIUS]` | `find_block` | The nearest loaded blocks of a kind | `block` (string, required): block id such as oak_log<br>`radius` (integer): search radius (default 32) |
|
||||
| `inventory` | `inventory` | Hotbar, main inventory, armor, offhand, carried stack and item counts | none |
|
||||
| `screen` | `screen` | The open screen or title menu, its buttons and its slots | none |
|
||||
| `messages [N]` | `messages` | Recent chat and system messages, the title and the action bar | `n` (integer): how many chat lines (default 20) |
|
||||
| `menu` | CLI only | The open menu's slots and carried stack, with station details | none |
|
||||
|
||||
## Move and look
|
||||
|
||||
| Command | MCP tool | What it does | Arguments |
|
||||
| --- | --- | --- | --- |
|
||||
| `move forward\|back\|left\|right SECONDS [--sprint] [--sneak] [--jump]` | `move` | Walk in a direction relative to the view for a time | `direction` (forward, back, left, right, required): direction relative to the view<br>`seconds` (number, required): how long to walk<br>`--sprint` (boolean): sprint<br>`--sneak` (boolean): sneak<br>`--jump` (boolean): keep jumping |
|
||||
| `goto X Y Z [--near R] [--sprint] [--direct] [--timeout S]` | `goto` | Walk to a position, finding a path through loaded terrain | `x` (number, required): X coordinate<br>`y` (number, required): Y coordinate<br>`z` (number, required): Z coordinate<br>`--near` (number): arrive within this many blocks (default 0.6)<br>`--sprint` (boolean): sprint<br>`--direct` (boolean): walk straight when no path is found<br>`--timeout` (number): give up after this many seconds (default 60) |
|
||||
| `jump` | `jump` | Jump once and reply when landed | none |
|
||||
| `look YAW PITCH` | `look` | Set the view direction in degrees | `yaw` (number, required): yaw in degrees<br>`pitch` (number, required): pitch in degrees (-90 up .. 90 down) |
|
||||
| `turn DYAW DPITCH [--over S]` | `turn` | Rotate the view by an amount | `dyaw` (number, required): yaw change in degrees<br>`dpitch` (number, required): pitch change in degrees<br>`--over` (number): turn with mouse motion spread over this many seconds (eased), as a player would |
|
||||
| `mouse-move DX DY` | `mouse_move` | Move the mouse: relative motion that turns the view like a player's mouse | `dx` (number, required): horizontal motion (positive turns right)<br>`dy` (number, required): vertical motion (positive looks down) |
|
||||
| `look-at X Y Z \| look-at block X Y Z [FACE] \| look-at entity ID [--over S]` | `look_at` | Turn to face a point, a block (a visible face of it) or an entity | `x` (number, required): X coordinate<br>`y` (number, required): Y coordinate<br>`z` (number, required): Z coordinate<br>`--over` (number): turn with mouse motion spread over this many seconds (eased), as a player would |
|
||||
| `look-at-block X Y Z [FACE] [--over S]` | `look_at_block` | Aim the crosshair at a block (same as look-at block) | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`face` (down, up, north, south, west, east): which face to aim at<br>`--over` (number): turn with mouse motion spread over this many seconds (eased), as a player would |
|
||||
| `look-at-entity ID [--over S]` | `look_at_entity` | Face an entity (same as look-at entity) | `id` (integer, required): entity id from entities<br>`--over` (number): turn with mouse motion spread over this many seconds (eased), as a player would |
|
||||
| `tp X Y Z` | `tp` | Teleport the player | `x` (number, required): X coordinate<br>`y` (number, required): Y coordinate<br>`z` (number, required): Z coordinate |
|
||||
|
||||
## Act on the world
|
||||
|
||||
| Command | MCP tool | What it does | Arguments |
|
||||
| --- | --- | --- | --- |
|
||||
| `break X Y Z [--timeout S]` | `break` | Mine a block, holding the attack button until it breaks | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`--timeout` (number): give up after this many seconds (default 30) |
|
||||
| `place X Y Z [--item ITEM]` | `place` | Place the held item (or ITEM from the hotbar) at an empty position | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`--item` (string): hotbar item to select first |
|
||||
| `interact X Y Z [FACE]` | `interact` | Right-click a block: open a chest or crafting table, press a button, open a door | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`face` (down, up, north, south, west, east): which face to click |
|
||||
| `use-item [--hold SECONDS]` | `use_item` | Right-click with the held item: eat, drink, throw, draw a bow | `--hold` (number): hold the button this long |
|
||||
| `attack [ID]` | `attack` | Swing at what the crosshair targets (after facing entity ID) | `id` (integer): entity id to face first |
|
||||
| `select SLOT\|ITEM` | `select` | Select a hotbar slot 1-9, or the hotbar slot holding ITEM | `slot` (string, required): 1-9 or an item id |
|
||||
| `drop [all]` | `drop` | Drop one of the held item, or the whole stack | `all` (boolean): drop the whole stack |
|
||||
| `hold KEY SECONDS` | CLI only | Press a key, wait, release it | `key` (string, required): key name<br>`seconds` (number, required): how long |
|
||||
| `key KEY down\|up\|tap\|repeat` | `key` | Press, release or auto-repeat a key (w a s d space shift ctrl e q esc f3 1-9 arrows ...) | `key` (string, required): key name<br>`action` (down, up, tap, repeat, required): what to do |
|
||||
| `mouse left\|right\|middle down\|up\|click` | `mouse` | Press or release a mouse button in the world | `button` (left, right, middle, required): button<br>`action` (down, up, click, required): what to do |
|
||||
| `interact-entity ID` | `interact_entity` | Right-click an entity: trade with a villager, mount a horse, ride a boat, shear a sheep | `id` (integer, required): entity id from entities |
|
||||
| `pad BUTTON down\|up\|tap \| pad stick left\|right X Y \| pad trigger left\|right V \| pad release \| pad status` | `pad` | A virtual controller: buttons, sticks and triggers as a real pad would send them | `what` (string, required): a button name, stick, trigger, release or status<br>`a` (string): down\|up\|tap, or left\|right<br>`b` (number): stick X or trigger value<br>`c` (number): stick Y |
|
||||
| `release` | `release` | Release every key and mouse button | none |
|
||||
| `stop` | `stop` | Cancel the running action (goto, break...) and release every key | none |
|
||||
|
||||
## Screens, menus and chat
|
||||
|
||||
| Command | MCP tool | What it does | Arguments |
|
||||
| --- | --- | --- | --- |
|
||||
| `open-inventory` | `open_inventory` | Open the inventory screen (crafting grid 100-103, result 200) | none |
|
||||
| `click-slot SLOT [right] [shift]` | `click_slot` | Click a slot of the open inventory or menu | `slot` (integer, required): slot id<br>`right` (boolean): right-click<br>`shift` (boolean): shift-click |
|
||||
| `close` | `close` | Close the open menu and return to the game | none |
|
||||
| `ui` | `ui` | Every widget of the open screen: id, kind, label, bounds, value | none |
|
||||
| `click ID [right\|middle] [shift]` | `click` | Click a widget of ui by its id, as the mouse would | `id` (string, required): widget id from ui<br>`right` (boolean): right-click<br>`middle` (boolean): middle-click<br>`shift` (boolean): shift-click (slots) |
|
||||
| `set ID VALUE` | `set` | Move a slider of ui to VALUE (0..1), as a click on its track | `id` (string, required): slider id from ui<br>`value` (number, required): 0..1 along the track |
|
||||
| `options-get [KEY...]` | `options_get` | The game's options (options.txt) by key, with their type and range or choices | `keys` (text): option keys (default: all) |
|
||||
| `options-set KEY VALUE` | `options_set` | Set one option, apply it at once and save options.txt, as the options screen does | `key` (string, required): option key from options-get<br>`value` (text, required): the new value |
|
||||
| `click-button LABEL` | `click_button` | Click a button of the open screen or title menu by its label | `label` (text, required): button label |
|
||||
| `click-at X Y [right]` | `click_at` | Click at GUI pixel X Y of the window (screen gives button rectangles) | `x` (number, required): GUI x<br>`y` (number, required): GUI y<br>`right` (boolean): right-click |
|
||||
| `drag-at [right] X Y X Y...` | `drag_at` | Press at the first GUI point, move through the others, release at the last | `points` (text, required): right, then GUI x y pairs |
|
||||
| `scroll-at X Y STEPS` | `scroll_at` | Turn the mouse wheel at GUI pixel X Y (positive scrolls up) | `x` (number, required): GUI x<br>`y` (number, required): GUI y<br>`steps` (number, required): wheel steps |
|
||||
| `type [--field name\|seed] [--replace] TEXT` | `type` | Type into the focused text field (chat box, world name, anvil, sign...) | `--field` (name, seed): create-world field to type into<br>`--replace` (boolean): clear the field first<br>`text` (text, required): text to type |
|
||||
| `chat TEXT` | `chat` (network) | Send a chat line (or a /command) as if typed | `text` (text, required): message |
|
||||
| `command TEXT` | `command` (network) | Run a command (the leading / is optional) | `command` (text, required): command line |
|
||||
| `respawn` | `respawn` | Respawn from the death screen | none |
|
||||
| `pause` | `pause` | Open the pause menu | none |
|
||||
| `resume` | `resume` | Leave the pause menu | none |
|
||||
| `options [PAGE]` | `options` | Open the options screen, or one of its pages (Horizon's too) | `page` (main, video, sound, controls, keybinds, controller, controller-binds, mouse, chat, accessibility, language, font, packs, mods, graphics, graphics-advanced, extras, horizon, shader-packs, agent): the page (default: the main options) |
|
||||
| `book [next\|back\|take\|done]` | `book` | The open book or lectern: its page text, or press one of its buttons | `action` (next, back, take, done): a button to press |
|
||||
| `signedit X Y Z [front\|back]` | `signedit` | Open the sign editor of the sign at X Y Z (then type and close) | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`side` (front, back): which side |
|
||||
| `mount-menu` | `mount_menu` | Open the inventory of the horse, donkey, mule or llama being ridden | none |
|
||||
| `chat-log [N]` | CLI only | The newest N chat messages | none |
|
||||
|
||||
## Multiplayer, LAN and friends
|
||||
|
||||
| Command | MCP tool | What it does | Arguments |
|
||||
| --- | --- | --- | --- |
|
||||
| `connect ADDRESS [NAME]` | `connect` (network) | Join a multiplayer server, or a friend's world by its invite code, from the title screen | `address` (string, required): HOST[:PORT] or friend:CODE<br>`name` (string): player name when offline |
|
||||
| `disconnect` | `disconnect` | Leave the server, back to the server list | none |
|
||||
| `servers` | `servers` | The Multiplayer screen's server list: name and address | none |
|
||||
| `lan open [PORT] [commands] [force] \| lan close \| lan status` | `lan` (network) | Open the world to LAN (Open to LAN), stop, or see the guests | `action` (open, close, status, required): what to do<br>`port` (integer): port for open<br>`commands` (boolean): allow cheats<br>`force` (boolean): force the game mode |
|
||||
| `friends host\|stop\|status` | `friends` (network) | Play with Friends: share the open world behind an invite code, stop, or see who joined | `action` (host, stop, status, required): what to do |
|
||||
|
||||
## World and cheats
|
||||
|
||||
| Command | MCP tool | What it does | Arguments |
|
||||
| --- | --- | --- | --- |
|
||||
| `give ITEM [COUNT]` | `give` | Add items to the inventory | `item` (string, required): item id<br>`count` (integer): how many (default 1) |
|
||||
| `time TICKS` | `time` | Set the time of day (0 sunrise, 6000 noon, 13000 night) | `ticks` (integer, required): day time |
|
||||
| `setblock X Y Z BLOCK[props]` | `setblock` (destructive) | Set a block (with neighbour updates) | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`block` (string, required): block state such as oak_stairs[facing=east] |
|
||||
| `summon KIND [X Y Z] [baby]` | `summon` | Spawn a mob (in front of the player without a position) | `kind` (string, required): mob type<br>`x` (number): X<br>`y` (number): Y<br>`z` (number): Z<br>`baby` (boolean): a baby |
|
||||
| `effect ID SECONDS [AMPLIFIER] \| effect clear` | `effect` | Give a status effect, or clear them all | `id` (string, required): effect id such as speed, or clear<br>`seconds` (number): duration<br>`amplifier` (integer): level - 1 (default 0) |
|
||||
| `effects` | `effects` | The player's active status effects | none |
|
||||
| `xp LEVELS` | `xp` | Add experience levels (negative removes) | `levels` (integer, required): levels to add |
|
||||
| `enchant ID LEVEL` | `enchant` | Enchant the held item (a book stores it) | `id` (string, required): enchantment id<br>`level` (integer, required): level |
|
||||
| `potion ITEM POTION` | `potion` | Give a potion item | `item` (potion, splash_potion, lingering_potion, tipped_arrow, required): which item<br>`potion` (string, required): potion id |
|
||||
| `fill X1 Y1 Z1 X2 Y2 Z2 BLOCK` | `fill` (destructive) | Set a box of loaded blocks | `x1` (integer, required): corner X<br>`y1` (integer, required): corner Y<br>`z1` (integer, required): corner Z<br>`x2` (integer, required): opposite X<br>`y2` (integer, required): opposite Y<br>`z2` (integer, required): opposite Z<br>`block` (string, required): block state |
|
||||
| `container X Y Z [ITEM COUNT]` | `container` | List a container's items, or add items to it | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`item` (string): item to add<br>`count` (integer): how many |
|
||||
| `render-distance N` | CLI only | Chunks, 2..64 | `chunks` (integer, required): render distance |
|
||||
| `wait TICKS` | `wait` | Wait until TICKS game ticks have passed (20 per second) | `ticks` (integer, required): game ticks |
|
||||
|
||||
## Scripting and benchmarks
|
||||
|
||||
| Command | MCP tool | What it does | Arguments |
|
||||
| --- | --- | --- | --- |
|
||||
| `script [FILE] [--keep-going]` | CLI only | Run commands from FILE (or stdin), one per line | none |
|
||||
| `sleep SECONDS` | CLI only | Wait (in scripts) | none |
|
||||
| `mcp` | CLI only | Serve the Model Context Protocol on stdin/stdout, one tool per command | none |
|
||||
| `bench SECONDS` | `bench` | Reset frame stats, wait, reply with the summary | `seconds` (number, required): how long to measure |
|
||||
| `stats [reset]` | `stats` | Frame-time summary since the last reset | `reset` (boolean): start counting again |
|
||||
| `fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]` | CLI only | Fly the camera along a line, then reply with the frame summary | none |
|
||||
+33
-4
@@ -9,7 +9,7 @@ The one exception is the anonymous ["I'm playing" ping](#the-im-playing-ping) be
|
||||
`telemetry/consent.json` in the game folder (`HORIZON_HOME`, else `horizon` in the platform data folder):
|
||||
|
||||
```json
|
||||
{ "version": 1, "answered": 1792060800, "choices": { "performance": true, "crashes": true, "bugs": false, "benchmarks": false }, "install_id": "3f2c…" }
|
||||
{ "version": 2, "answered": 1792060800, "choices": { "performance": true, "crashes": true, "bugs": false, "benchmarks": false }, "install_id": "3f2c…" }
|
||||
```
|
||||
|
||||
- `version` is the consent version the player answered (`CONSENT_VERSION`). It goes up when what is collected changes. The launcher then asks again, and until the player answers nothing new is kept or sent.
|
||||
@@ -34,7 +34,24 @@ Every item has `day` (days since 1970, UTC: no finer time), `source` (`launcher`
|
||||
| `bug` | bug reports | `system`, the player's `text` (up to 4000 characters), and the `log` only if they add it after reading it | 80 KiB | 5 |
|
||||
| `benchmark` | benchmark uploads | the benchmark report as `docs/benchmark-report.md` defines it, as it is, with its own schema field | 128 KiB | 10 |
|
||||
|
||||
`system` is the OS and its version, the architecture, the CPU model and thread count, installed memory rounded to the GiB, and the GPU, driver and graphics backend.
|
||||
`system` describes the machine, with what can explain its performance. A field the OS doesn't give is empty or 0:
|
||||
|
||||
| Field | What | Example |
|
||||
|---|---|---|
|
||||
| `os`, `os_version`, `arch` | the OS, its version, the CPU architecture | `linux`, `Fedora Linux 43`, `x86_64` |
|
||||
| `cpu`, `threads`, `cores` | the CPU model, its threads and physical cores | `13th Gen Intel(R) Core(TM) i7-13700KF`, 24, 16 |
|
||||
| `cpu_base_mhz`, `cpu_boost_mhz` | base and boost clock, where the OS gives them (not on Apple silicon) | 3400, 5400 |
|
||||
| `ram_gb` | installed memory, rounded to the GiB | 32 |
|
||||
| `gpu`, `gpu_vendor`, `gpu_kind` | the GPU the game draws with, its maker, `integrated` or `discrete` | `NVIDIA GeForce RTX 4080`, `nvidia`, `discrete` |
|
||||
| `vram_mb` | a discrete GPU's own memory, rounded up to 256 MiB | 16384 |
|
||||
| `driver`, `backend` | the driver version and the graphics API (`Vulkan`, `Metal`, `Dx12`) | `580.95.05`, `Vulkan` |
|
||||
| `storage` | the drive under the game folder: `nvme`, `ssd` or `hdd` | `nvme` |
|
||||
| `display` | the screen the window opened on: `width`, `height` (physical pixels), `refresh_hz`, `scale` | 2560, 1440, 165, 1.0 |
|
||||
| `power`, `form` | on mains or battery (`ac`, `battery`), `laptop` or `desktop` | `ac`, `desktop` |
|
||||
|
||||
The launcher's own items (bug reports sent from it) have no GPU or screen: only the game knows them. The memory given to the game is not a setting in Horizon (no Java heap): `peak_ram_mb` is what it used. Benchmark reports carry the same facts in their `hardware` (`docs/benchmark-report.md`).
|
||||
|
||||
No serial number, host name, user name, MAC address or path is read. Settings → Data collection → "View what would be sent" and `horizon-launcher data show` print every field as it would leave this machine.
|
||||
|
||||
A summary (`frames`, `ticks`) is the count, the 50th, 95th and 99th percentile and the maximum in milliseconds (to the tenth), and 14 bucket counts: under 4 ms, 4–6, 6–8, 8–10, 10–12, 12–14, 14–16.7, 16.7–20, 20–25, 25–33.3, 33.3–50, 50–100, 100–250, and 250 ms and more. Raw frame times never leave: `Timings` records them in tenths of a millisecond (20 KB however long the session) and only its summary is kept. A perf item is under 1 KiB.
|
||||
|
||||
@@ -75,8 +92,20 @@ The launcher reads it when it opens and every minute after, and shows "N playing
|
||||
|
||||
- `POST /v1/batch`
|
||||
- `DELETE /v1/installs/<install_id>`: deletes everything sent under that ID.
|
||||
- `GET /v1/aggregate`: the anonymous totals the website's results page reads.
|
||||
- `GET /v1/benchmarks/summary`: players' benchmark uploads grouped by GPU, CPU, render distance and game for playhorizon.dev/benchmarks. A group shows only with at least 3 different installs, and only its medians and counts: no install ID, day, OS version, driver, memory or screen. Uploads are never published one by one.
|
||||
- `GET /v1/aggregate`: the anonymous totals the website's results page reads. Its `breakdowns` split the play sessions of the period by hardware class (below), each class with its installs, sessions, hours and mean p50 and p99 frame times.
|
||||
- `GET /v1/benchmarks/summary`: players' benchmark uploads grouped by GPU, CPU, render distance and game for playhorizon.dev/benchmarks. A group shows only with at least 3 different installs, and only its medians and counts: no install ID, day, OS version, driver, exact memory or screen. Uploads are never published one by one. Each group's `breakdowns` count its installs per hardware class.
|
||||
- `POST /v1/live` and `GET /v1/live`: the "I'm playing" ping and the live count.
|
||||
|
||||
Public output never holds a raw hardware value, only these classes (`src/classes.rs`, the same in `website/scripts/benchmarks.mjs`), and a class is published only with at least 3 different installs:
|
||||
|
||||
| Breakdown | Classes |
|
||||
|---|---|
|
||||
| `ram` | 4 GB or less, 8 GB, 16 GB, 32 GB, 64 GB or more |
|
||||
| `vram` | shared (integrated GPU), 2 GB or less, 4, 6, 8, 12, 16 GB, more than 16 GB |
|
||||
| `storage` | nvme, ssd, hdd |
|
||||
| `os` | linux, macos, windows |
|
||||
| `gpu_kind` | integrated, discrete |
|
||||
|
||||
The driver, OS version, clocks, screen and power are kept raw for the 180 days of retention, to explain a slow group, and never published.
|
||||
|
||||
`crates/horizon-telemetry-server` is the server, at `https://telemetry.playhorizon.dev` on Railway (one SQLite file on a volume, region `europe-west4`). Its [README](../crates/horizon-telemetry-server/README.md) covers storage (about 190 bytes per play session), retention (180 days of raw rows, anonymous daily totals kept), rate limits and hosting.
|
||||
Generated
+1
@@ -2633,6 +2633,7 @@ dependencies = [
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-telemetry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+24
-16
@@ -174,27 +174,35 @@ scripts and tests).
|
||||
|
||||
## AI agents (MCP)
|
||||
|
||||
`horizon-launcher mcp` is a Model Context Protocol server over stdio. To use it from Claude Code:
|
||||
`horizon-launcher mcp` is a Model Context Protocol server over stdio. `horizon-launcher
|
||||
agent-config` prints the config for Claude Code, Codex, Cursor and any MCP client, and the
|
||||
command that adds it; for Claude Code:
|
||||
|
||||
claude mcp add horizon -- horizon-launcher mcp
|
||||
claude mcp add --scope user horizon -- horizon-launcher mcp
|
||||
|
||||
(with the full path to the binary if it is not on your `PATH`). Its tools: `list_versions`,
|
||||
`install_version`, `remove_version`, `prepare_resources`, `launch_client`, `start_server`,
|
||||
`server_command`, `server_log`, `stop_server`, `patch_notes`, `links`, `update_channel`, `logs`,
|
||||
`check_updates`, `accounts`, `sign_in` (returns the link and code for the player; the sign-in
|
||||
finishes in the background), `sign_out`, `select_account`, and for data collection
|
||||
`data_collection`, `set_data_collection`, `data_preview`, `delete_my_data` and `report_bug`
|
||||
(only with the player's say-so).
|
||||
A server started through MCP stops (cleanly, with `stop`) when the session ends. Starting one
|
||||
needs `accept_eula`, which an agent should only pass with the player's consent.
|
||||
(with the full path to the binary if it is not on your `PATH`). Every CLI command is a tool of the
|
||||
same name and arguments, generated from the CLI's definition (`src/cli_tools.rs`): `versions`,
|
||||
`install`, `remove`, `prepare`, `settings`, `mods_search`, `mods_install`, `modpack_import`,
|
||||
`servers_create`, `data_status`... A call runs that command with `--json` and returns its
|
||||
output. Tools that delete something (`remove`, `mods_remove`, `modpack_remove`,
|
||||
`servers_forget`, `account_sign_out`, `data_delete`) need `confirm: true`. The session runs a few
|
||||
itself, under the CLI's names: `play`, `server` with `server_command`, `server_log` and
|
||||
`server_stop`, `account_list` and `account_sign_in` (returns the link and code for the player;
|
||||
the sign-in finishes in the background). A server started through MCP stops (cleanly, with
|
||||
`stop`) when the session ends. Starting one needs `accept_eula`, which an agent should only pass
|
||||
with the player's consent.
|
||||
|
||||
The same server also plays the game: `launch_client` starts it with a local control socket
|
||||
(`world`, `creative` and `offscreen` are optional), and every `horizon-ctl` tool of the selected
|
||||
The same server also plays the game: `play` starts it with a local control socket (`world`,
|
||||
`creative`, `join` and `offscreen` are optional), and every `horizon-ctl` tool of the selected
|
||||
version is offered as `game_<name>`: `game_state`, `game_ui` (every widget of the open screen),
|
||||
`game_click`, `game_goto`, `game_break`, `game_inventory`, `game_screenshot` (an image) and the
|
||||
rest of [horizon-ctl's commands](../docs/agent-control.md). The launcher runs that version's
|
||||
`horizon-ctl mcp` and passes the calls through; while no game runs, they answer with an error
|
||||
saying to call `launch_client`.
|
||||
rest of [horizon-ctl's commands](../docs/horizon-ctl-commands.md). The launcher runs that
|
||||
version's `horizon-ctl mcp` and passes the calls through; while no game runs, they answer with an
|
||||
error saying to call `play`.
|
||||
|
||||
`horizon-launcher no-ai on` (the Settings page's No AI switch) hides and turns off every AI
|
||||
feature: `mcp` and `agent-config` refuse, the game opens no control socket and `horizon-ctl`
|
||||
refuses. Every other command keeps working.
|
||||
|
||||
The window, the command line and the MCP server all call the same core, `src/backend.rs`.
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Connecting an AI agent (docs/agent-control.md): the launcher's MCP server, `horizon-launcher
|
||||
//! mcp`, as each common client wants it configured. `horizon-launcher agent-config` prints it and
|
||||
//! the Settings page's "Connect an AI agent" shows it. Nothing here runs a client's command: the
|
||||
//! window runs one only when the player clicks its button, and shows it first.
|
||||
use serde_json::{json, Value};
|
||||
use std::path::Path;
|
||||
|
||||
/// The name the server is added under in every client.
|
||||
pub const SERVER_NAME: &str = "horizon";
|
||||
|
||||
pub struct Client {
|
||||
/// `claude-code`, `codex`, `cursor` or `generic`.
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
/// Where its configuration lives.
|
||||
pub file: &'static str,
|
||||
/// What goes in that file.
|
||||
pub config: String,
|
||||
/// The client's own command that adds the server, when it has a CLI: (program, arguments).
|
||||
pub add: Option<(&'static str, Vec<String>)>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// The add command as a shell line, to show and to copy.
|
||||
pub fn add_line(&self) -> Option<String> {
|
||||
self.add
|
||||
.as_ref()
|
||||
.map(|(program, args)| shell_line(program, args))
|
||||
}
|
||||
}
|
||||
|
||||
/// The `mcpServers` JSON most clients read (Claude Code's .mcp.json, Cursor, Claude Desktop...).
|
||||
fn mcp_servers_json(exe: &Path) -> String {
|
||||
let config = json!({ "mcpServers": { SERVER_NAME: { "command": exe, "args": ["mcp"] } } });
|
||||
serde_json::to_string_pretty(&config).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A TOML basic string.
|
||||
fn toml_string(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
/// The clients, for the launcher at `exe`.
|
||||
pub fn clients(exe: &Path) -> Vec<Client> {
|
||||
let exe_text = exe.display().to_string();
|
||||
let add = |args: &[&str]| -> Vec<String> {
|
||||
let mut words: Vec<String> = args.iter().map(|s| (*s).to_owned()).collect();
|
||||
words.extend([exe_text.clone(), "mcp".into()]);
|
||||
words
|
||||
};
|
||||
vec![
|
||||
Client {
|
||||
id: "claude-code",
|
||||
name: "Claude Code",
|
||||
file: "~/.claude.json (or .mcp.json in a project)",
|
||||
config: mcp_servers_json(exe),
|
||||
add: Some((
|
||||
"claude",
|
||||
add(&["mcp", "add", "--scope", "user", SERVER_NAME, "--"]),
|
||||
)),
|
||||
},
|
||||
Client {
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
file: "~/.codex/config.toml",
|
||||
config: format!(
|
||||
"[mcp_servers.{SERVER_NAME}]\ncommand = {}\nargs = [\"mcp\"]\n",
|
||||
toml_string(&exe_text)
|
||||
),
|
||||
add: Some(("codex", add(&["mcp", "add", SERVER_NAME, "--"]))),
|
||||
},
|
||||
Client {
|
||||
id: "cursor",
|
||||
name: "Cursor",
|
||||
file: "~/.cursor/mcp.json",
|
||||
config: mcp_servers_json(exe),
|
||||
add: None,
|
||||
},
|
||||
Client {
|
||||
id: "generic",
|
||||
name: "Any MCP client",
|
||||
file: "its MCP settings (a stdio server)",
|
||||
config: mcp_servers_json(exe),
|
||||
add: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Where a client's CLI is: on the PATH, else in the folders installers use (a launcher started
|
||||
/// from the desktop gets a short PATH on macOS). The bare name when it is nowhere.
|
||||
pub fn locate(program: &str) -> std::path::PathBuf {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let names: Vec<String> = if cfg!(windows) {
|
||||
["exe", "cmd"]
|
||||
.iter()
|
||||
.map(|e| format!("{program}.{e}"))
|
||||
.collect()
|
||||
} else {
|
||||
vec![program.to_owned()]
|
||||
};
|
||||
let extra = [
|
||||
home.join(".local/bin"),
|
||||
home.join(".claude/local"),
|
||||
home.join(".npm-global/bin"),
|
||||
home.join(".bun/bin"),
|
||||
home.join(".cargo/bin"),
|
||||
"/opt/homebrew/bin".into(),
|
||||
"/usr/local/bin".into(),
|
||||
home.join("AppData/Roaming/npm"),
|
||||
];
|
||||
std::env::var_os("PATH")
|
||||
.map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(extra)
|
||||
.flat_map(|dir| names.iter().map(move |n| dir.join(n)))
|
||||
.find(|p| p.is_file())
|
||||
.unwrap_or_else(|| program.into())
|
||||
}
|
||||
|
||||
/// Starts `exe mcp` as a client would, lists its tools and returns how many answered: the
|
||||
/// Settings page's "Test the connection".
|
||||
pub fn self_test(exe: &Path) -> Result<usize, String> {
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
let mut child = Command::new(exe)
|
||||
.arg("mcp")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not start {}: {e}", exe.display()))?;
|
||||
let lines = [
|
||||
json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "horizon-launcher-test", "version": "1" } } }),
|
||||
json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }),
|
||||
json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }),
|
||||
];
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
for line in &lines {
|
||||
writeln!(stdin, "{line}").map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Dropping stdin ends the session: the server answers, then exits.
|
||||
}
|
||||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_owned());
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
|
||||
.find(|r| r["id"] == 2)
|
||||
.and_then(|r| r["result"]["tools"].as_array().map(Vec::len))
|
||||
.ok_or_else(|| "The MCP server did not list its tools.".into())
|
||||
}
|
||||
|
||||
/// `program args...` quoted for a POSIX shell where a word needs it.
|
||||
pub fn shell_line(program: &str, args: &[String]) -> String {
|
||||
let quote = |w: &str| {
|
||||
if !w.is_empty()
|
||||
&& w.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "-_./:=@".contains(c))
|
||||
{
|
||||
w.to_owned()
|
||||
} else {
|
||||
format!("'{}'", w.replace('\'', "'\\''"))
|
||||
}
|
||||
};
|
||||
std::iter::once(program)
|
||||
.chain(args.iter().map(String::as_str))
|
||||
.map(quote)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Everything `agent-config --json` prints.
|
||||
pub fn json(exe: &Path) -> Value {
|
||||
json!({
|
||||
"server": { "name": SERVER_NAME, "command": exe, "args": ["mcp"] },
|
||||
"clients": clients(exe).iter().map(|c| json!({
|
||||
"id": c.id, "name": c.name, "file": c.file, "config": c.config, "add": c.add_line(),
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn configs_name_the_launcher_and_quote_its_path() {
|
||||
let exe = Path::new("/Applications/Horizon Launcher.app/Contents/MacOS/horizon-launcher");
|
||||
let all = clients(exe);
|
||||
let claude = &all[0];
|
||||
assert_eq!(
|
||||
claude.add_line().unwrap(),
|
||||
"claude mcp add --scope user horizon -- '/Applications/Horizon Launcher.app/Contents/MacOS/horizon-launcher' mcp"
|
||||
);
|
||||
let parsed: Value = serde_json::from_str(&claude.config).unwrap();
|
||||
assert_eq!(parsed["mcpServers"]["horizon"]["args"], json!(["mcp"]));
|
||||
assert_eq!(
|
||||
parsed["mcpServers"]["horizon"]["command"],
|
||||
exe.display().to_string()
|
||||
);
|
||||
let codex = &all[1];
|
||||
assert!(codex
|
||||
.config
|
||||
.contains("command = \"/Applications/Horizon Launcher.app"));
|
||||
assert_eq!(
|
||||
toml_string(r#"C:\Horizon\"x".exe"#),
|
||||
r#""C:\\Horizon\\\"x\".exe""#
|
||||
);
|
||||
assert!(all.iter().any(|c| c.id == "cursor" && c.add.is_none()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Settings' AI rows: the No AI switch (`horizon_core::no_ai`, shared with the game) and "Connect
|
||||
//! an AI agent", the ready-made MCP config of each common client with Copy buttons, a one-click
|
||||
//! add where the client has a CLI (run only on that click, its command shown first), and a test
|
||||
//! that starts the MCP server and lists its tools. With No AI on, only the switch shows.
|
||||
use super::*;
|
||||
use horizon_launcher::agents;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct AgentsUi {
|
||||
/// The client shown, an index into `agents::clients`.
|
||||
client: usize,
|
||||
/// Which Copy button just copied.
|
||||
copied: Option<&'static str>,
|
||||
/// A command or test running.
|
||||
busy: bool,
|
||||
/// What the last add or test said, and whether it went well.
|
||||
status: Option<(bool, String)>,
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
/// The No AI row: always shown, so it can be turned off again.
|
||||
pub(crate) fn no_ai_row(&mut self, n: usize, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
let on = backend::no_ai();
|
||||
let toggle =
|
||||
switch("no-ai", on, &t).on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.agents.status = backend::set_no_ai(!on).err().map(|e| (false, e));
|
||||
cx.notify();
|
||||
}));
|
||||
index_row(
|
||||
n,
|
||||
"No AI",
|
||||
Some(
|
||||
div()
|
||||
.child(if on {
|
||||
"On: every AI feature is hidden and off. The game opens no control socket, and horizon-ctl and the MCP servers refuse."
|
||||
} else {
|
||||
"Hides and turns off every AI feature: agent control of the launcher and the game, horizon-ctl and the MCP servers."
|
||||
})
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(toggle.into_any_element()),
|
||||
&t,
|
||||
)
|
||||
}
|
||||
|
||||
/// "Connect an AI agent"; None while No AI is on.
|
||||
pub(crate) fn agents_section(&mut self, cx: &mut Context<Self>) -> Option<Div> {
|
||||
if backend::no_ai() {
|
||||
return None;
|
||||
}
|
||||
let t = self.theme;
|
||||
let exe = backend::exe_path();
|
||||
let clients = agents::clients(exe);
|
||||
let pick = self.agents.client.min(clients.len() - 1);
|
||||
let client = &clients[pick];
|
||||
let this = cx.entity().downgrade();
|
||||
let options: Vec<(usize, &'static str)> = clients
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| (i, c.name))
|
||||
.collect();
|
||||
let picker = segments("agent-client", &options, pick, &t, move |i, _, cx| {
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.agents.client = i;
|
||||
this.agents.copied = None;
|
||||
this.agents.status = None;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
let copy = |id: &'static str, text: String, cx: &mut Context<Self>| {
|
||||
let copied = self.agents.copied == Some(id);
|
||||
button(
|
||||
id,
|
||||
if copied { "Copied" } else { "Copy" },
|
||||
Kind::Secondary,
|
||||
&t,
|
||||
)
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone()));
|
||||
this.agents.copied = Some(id);
|
||||
cx.notify();
|
||||
}))
|
||||
};
|
||||
let code = |s: &str| {
|
||||
div()
|
||||
.p(px(10.))
|
||||
.bg(t.well)
|
||||
.border_2()
|
||||
.border_color(t.outline)
|
||||
.font_family(theme::MONO)
|
||||
.text_size(px(12.))
|
||||
.line_height(px(18.))
|
||||
.text_color(t.ink)
|
||||
.children(s.lines().map(|l| div().child(l.to_owned())))
|
||||
};
|
||||
let mut section = plate(&t)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(12.))
|
||||
.p(px(24.))
|
||||
.text_size(px(theme::UI.px))
|
||||
.line_height(px(21.))
|
||||
.child(text(theme::HEADING).text_color(t.ink).child("Connect an AI agent"))
|
||||
.child(div().max_w(px(640.)).text_color(t.muted).child(
|
||||
"Your own agent (Claude Code, Codex, Cursor or any MCP client) can drive the launcher and the game it starts, with the same commands as the terminal. It runs on your computer and talks to Horizon there; it asks before deleting anything.",
|
||||
))
|
||||
.child(picker);
|
||||
if let Some((program, args)) = client.add.clone() {
|
||||
let line = client.add_line().unwrap_or_default();
|
||||
let label = format!("Add to {}", client.name);
|
||||
let busy = self.agents.busy;
|
||||
let add = button(
|
||||
"agent-add",
|
||||
if busy { "Running…".to_owned() } else { label },
|
||||
Kind::Primary,
|
||||
&t,
|
||||
)
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
if this.agents.busy {
|
||||
return;
|
||||
}
|
||||
this.agents.busy = true;
|
||||
this.agents.status = None;
|
||||
let (program, args) = (program, args.clone());
|
||||
let task = cx
|
||||
.background_executor()
|
||||
.spawn(async move { run_add(program, &args) });
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = task.await;
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.agents.busy = false;
|
||||
this.agents.status = Some(result);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
cx.notify();
|
||||
}));
|
||||
section = section
|
||||
.child(div().text_color(t.muted).child(format!(
|
||||
"{} adds it with this command (it runs only when you click):",
|
||||
client.name
|
||||
)))
|
||||
.child(code(&line))
|
||||
.child(div().flex().gap(px(8.)).child(add).child(copy(
|
||||
"agent-copy-command",
|
||||
line.clone(),
|
||||
cx,
|
||||
)));
|
||||
}
|
||||
section = section
|
||||
.child(div().text_color(t.muted).child(format!(
|
||||
"{} {}:",
|
||||
if client.add.is_some() { "Or put this in" } else { "Put this in" },
|
||||
client.file
|
||||
)))
|
||||
.child(code(&client.config))
|
||||
.child(div().flex().gap(px(8.)).child(copy("agent-copy-config", client.config.clone(), cx)).child(
|
||||
button("agent-test", "Test the connection", Kind::Secondary, &t).on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
if this.agents.busy {
|
||||
return;
|
||||
}
|
||||
this.agents.busy = true;
|
||||
this.agents.status = None;
|
||||
let exe = backend::exe_path().to_path_buf();
|
||||
let task = cx.background_executor().spawn(async move {
|
||||
match agents::self_test(&exe) {
|
||||
Ok(n) => (true, format!("The MCP server answered with {n} tools. Now ask your agent: \"Say hello from Horizon\".")),
|
||||
Err(e) => (false, format!("The MCP server did not answer: {e}")),
|
||||
}
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = task.await;
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
this.agents.busy = false;
|
||||
this.agents.status = Some(result);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
));
|
||||
if let Some((ok, message)) = &self.agents.status {
|
||||
section = section.child(
|
||||
div()
|
||||
.text_color(if *ok { t.ink } else { t.danger })
|
||||
.child(message.clone()),
|
||||
);
|
||||
}
|
||||
Some(
|
||||
section.child(
|
||||
div()
|
||||
.id("agent-docs")
|
||||
.text_color(t.accent)
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.opacity(0.8))
|
||||
.child("What an agent can do, and the commands")
|
||||
.on_click(|_, _, cx| cx.open_url(backend::AGENT_DOCS_URL)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a client's add command; (went well, what it said).
|
||||
fn run_add(program: &str, args: &[String]) -> (bool, String) {
|
||||
match std::process::Command::new(agents::locate(program))
|
||||
.args(args)
|
||||
.output()
|
||||
{
|
||||
Ok(out) if out.status.success() => (
|
||||
true,
|
||||
format!(
|
||||
"Added. Restart {program} if it is open, then ask it: \"Say hello from Horizon\"."
|
||||
),
|
||||
),
|
||||
Ok(out) => (
|
||||
false,
|
||||
format!(
|
||||
"{program} said: {}",
|
||||
String::from_utf8_lossy(if out.stderr.is_empty() {
|
||||
&out.stdout
|
||||
} else {
|
||||
&out.stderr
|
||||
})
|
||||
.trim()
|
||||
),
|
||||
),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (
|
||||
false,
|
||||
format!(
|
||||
"{program} is not installed here (or not on the PATH): copy the config instead."
|
||||
),
|
||||
),
|
||||
Err(e) => (false, format!("Could not run {program}: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
pub const REPO: &str = "ludovic111/horizon";
|
||||
pub const README_URL: &str = "https://github.com/ludovic111/horizon/blob/main/launcher/README.md";
|
||||
/// The players' page on connecting an AI agent (website/docs/agents.md).
|
||||
pub const AGENT_DOCS_URL: &str = "https://playhorizon.dev/docs/agents";
|
||||
/// This launcher's version: the release workflow sets `HORIZON_LAUNCHER_VERSION` from the
|
||||
/// tag it builds (no version-bump commits); other builds say Cargo.toml's.
|
||||
pub const VERSION: &str = version_or(option_env!("HORIZON_LAUNCHER_VERSION"));
|
||||
@@ -1269,6 +1271,17 @@ pub fn game_home() -> PathBuf {
|
||||
.join("horizon")
|
||||
}
|
||||
|
||||
/// Whether No AI is on: every AI feature hidden and off (`horizon_core::no_ai`, kept in the
|
||||
/// game's `horizon-extras.txt` so the game reads the same switch).
|
||||
pub fn no_ai() -> bool {
|
||||
horizon_core::no_ai::is_on(&game_home())
|
||||
}
|
||||
|
||||
/// Turns No AI on or off for the launcher and the game.
|
||||
pub fn set_no_ai(on: bool) -> Result<(), String> {
|
||||
horizon_core::no_ai::set(&game_home(), on).map_err(|e| format!("Could not save No AI: {e}"))
|
||||
}
|
||||
|
||||
// ----- Logs and crash reports ---------------------------------------------------------------------
|
||||
|
||||
/// The game's logs (`latest.log` and the day archives), as it writes them in its home.
|
||||
|
||||
+183
-3
@@ -253,6 +253,12 @@ enum Cmd {
|
||||
Play {
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
/// Open or create this world (a folder under saves) at once
|
||||
#[arg(long)]
|
||||
world: Option<String>,
|
||||
/// Creative mode for a new world
|
||||
#[arg(long)]
|
||||
creative: bool,
|
||||
/// Join a friend's world with its invite code
|
||||
#[arg(long)]
|
||||
join: Option<String>,
|
||||
@@ -288,6 +294,16 @@ enum Cmd {
|
||||
#[arg(value_parser = ["stable", "alpha"])]
|
||||
channel: Option<String>,
|
||||
},
|
||||
/// Show the launcher's settings, or set one (the Settings page): selected-version TAG,
|
||||
/// minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions
|
||||
/// on|off, opening-animation on|off
|
||||
Settings {
|
||||
#[arg(value_parser = SETTING_KEYS)]
|
||||
key: Option<String>,
|
||||
/// The new value (with KEY)
|
||||
#[arg(requires = "key")]
|
||||
value: Option<String>,
|
||||
},
|
||||
/// Show or set the extra arguments the game starts with (for example --server-thread);
|
||||
/// quotes group a word with spaces in it
|
||||
GameArgs {
|
||||
@@ -343,8 +359,26 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
action: Option<ServersCmd>,
|
||||
},
|
||||
/// Serve the Model Context Protocol on stdin and stdout, for AI agents
|
||||
/// Serve the Model Context Protocol on stdin and stdout, for AI agents (refused while No AI
|
||||
/// is on)
|
||||
Mcp,
|
||||
/// How to connect an AI agent (Claude Code, Codex, Cursor, any MCP client) to `mcp`: the
|
||||
/// config to paste and the command that adds it (refused while No AI is on)
|
||||
AgentConfig {
|
||||
/// Only this client
|
||||
#[arg(long, value_parser = ["claude-code", "codex", "cursor", "generic"])]
|
||||
client: Option<String>,
|
||||
/// Start the MCP server as a client would and say whether it answered
|
||||
#[arg(long)]
|
||||
test: bool,
|
||||
},
|
||||
/// Show or set No AI: on hides and turns off every AI feature (the companion, agent control,
|
||||
/// horizon-ctl and both MCP servers); off, the default, makes them available
|
||||
NoAi {
|
||||
/// Omit to show the current state
|
||||
#[arg(value_parser = ["on", "off"])]
|
||||
state: Option<String>,
|
||||
},
|
||||
/// Measure the launcher: starts (the first is the cold one), page switching, idle CPU and
|
||||
/// memory, the release list fetch; writes a benchmark report to benchmarks/ in its data folder
|
||||
Benchmark {
|
||||
@@ -354,11 +388,75 @@ enum Cmd {
|
||||
},
|
||||
}
|
||||
|
||||
/// The CLI's definition, for the MCP tools generated from it (cli_tools.rs).
|
||||
pub fn command() -> clap::Command {
|
||||
<Cli as clap::CommandFactory>::command()
|
||||
}
|
||||
|
||||
/// The commands that exist for AI agents: hidden and refused while No AI is on. Every other
|
||||
/// command stays: the launcher, packagers and scripts need them, and none drives the game.
|
||||
pub const AI_COMMANDS: [&str; 2] = ["mcp", "agent-config"];
|
||||
|
||||
/// The keys `settings` sets.
|
||||
const SETTING_KEYS: [&str; 5] = [
|
||||
"selected-version",
|
||||
"minecraft-dir",
|
||||
"server-dir",
|
||||
"install-new-versions",
|
||||
"opening-animation",
|
||||
];
|
||||
|
||||
fn on_off(value: &str) -> Result<bool, String> {
|
||||
match value {
|
||||
"on" | "true" => Ok(true),
|
||||
"off" | "false" => Ok(false),
|
||||
_ => Err(format!("{value}: say on or off")),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_setting(s: &mut backend::Settings, key: &str, value: &str) -> Result<(), String> {
|
||||
let folder = |value: &str| (value != "default").then(|| PathBuf::from(value));
|
||||
match key {
|
||||
"selected-version" => s.selected = Some(backend::resolve_version(Some(value))?),
|
||||
"minecraft-dir" => s.minecraft_dir = folder(value),
|
||||
"server-dir" => s.server_dir = folder(value),
|
||||
"install-new-versions" => s.install_new_versions = Some(on_off(value)?),
|
||||
"opening-animation" => s.skip_opening = !on_off(value)?,
|
||||
_ => return Err(format!("No setting {key}.")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every launcher setting, as `settings` shows them.
|
||||
fn settings_json(s: &backend::Settings) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"selected_version": backend::resolve_version(None).ok(),
|
||||
"minecraft_dir": s.minecraft_dir,
|
||||
"server_dir": backend::server_dir(None),
|
||||
"install_new_versions": s.install_new_versions.unwrap_or(true),
|
||||
"opening_animation": !s.skip_opening,
|
||||
"update_channel": s.update_channel.name(),
|
||||
"game_args": s.game_args,
|
||||
"desktop_entry": s.desktop_entry.unwrap_or(true),
|
||||
"no_ai": backend::no_ai(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs a subcommand and returns the exit code, or None when the window should open.
|
||||
pub fn run() -> Option<i32> {
|
||||
let cli = Cli::parse();
|
||||
use clap::FromArgMatches;
|
||||
let no_ai = backend::no_ai();
|
||||
let mut definition = command();
|
||||
if no_ai {
|
||||
// Hidden from the help too; a call still gets the refusal below.
|
||||
for name in AI_COMMANDS {
|
||||
definition = definition.mut_subcommand(name, |c| c.hide(true));
|
||||
}
|
||||
}
|
||||
let cli = Cli::from_arg_matches(&definition.get_matches()).unwrap_or_else(|e| e.exit());
|
||||
let command = cli.command?;
|
||||
let result = match command {
|
||||
Cmd::Mcp | Cmd::AgentConfig { .. } if no_ai => Err(horizon_core::no_ai::REFUSAL.into()),
|
||||
Cmd::Mcp => crate::mcp::serve(),
|
||||
command => execute(command, cli.json),
|
||||
};
|
||||
@@ -496,11 +594,23 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
}
|
||||
println!("{tag} is prepared.");
|
||||
}
|
||||
Cmd::Play { version, join, arg } => {
|
||||
Cmd::Play {
|
||||
version,
|
||||
world,
|
||||
creative,
|
||||
join,
|
||||
arg,
|
||||
} => {
|
||||
let mut cmd = backend::client(&backend::resolve_version(version.as_deref())?)?;
|
||||
if let Some(code) = join {
|
||||
backend::join_friend(&mut cmd, &code)?;
|
||||
}
|
||||
if let Some(world) = world {
|
||||
cmd.arg("--world").arg(world);
|
||||
}
|
||||
if creative {
|
||||
cmd.arg("--creative");
|
||||
}
|
||||
cmd.args(arg);
|
||||
attached(cmd)?
|
||||
}
|
||||
@@ -553,6 +663,76 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::NoAi { state } => {
|
||||
if let Some(state) = state {
|
||||
backend::set_no_ai(state == "on")?;
|
||||
}
|
||||
let on = backend::no_ai();
|
||||
if as_json {
|
||||
json(serde_json::json!({ "no_ai": on }));
|
||||
} else if on {
|
||||
println!("No AI is on: every AI feature is hidden and off.");
|
||||
} else {
|
||||
println!("No AI is off: AI features are available.");
|
||||
}
|
||||
}
|
||||
Cmd::AgentConfig { test: true, .. } => {
|
||||
let tools = horizon_launcher::agents::self_test(backend::exe_path())?;
|
||||
if as_json {
|
||||
json(serde_json::json!({ "answered": true, "tools": tools }));
|
||||
} else {
|
||||
println!("The MCP server answered with {tools} tools.");
|
||||
}
|
||||
}
|
||||
Cmd::AgentConfig { client, .. } => {
|
||||
use horizon_launcher::agents;
|
||||
let exe = backend::exe_path();
|
||||
if as_json {
|
||||
let mut all = agents::json(exe);
|
||||
if let Some(id) = &client {
|
||||
all["clients"] = all["clients"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| c["id"] == id.as_str())
|
||||
.cloned()
|
||||
.collect();
|
||||
}
|
||||
json(all);
|
||||
return Ok(());
|
||||
}
|
||||
for c in agents::clients(exe)
|
||||
.iter()
|
||||
.filter(|c| client.as_ref().is_none_or(|id| id == c.id))
|
||||
{
|
||||
println!("{}", c.name);
|
||||
if let Some(line) = c.add_line() {
|
||||
println!(" Add it with: {line}");
|
||||
}
|
||||
println!(" Or put this in {}:", c.file);
|
||||
for line in c.config.lines() {
|
||||
println!(" {line}");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
println!("Then ask your agent to say hello: it can call the links tool, or play.");
|
||||
}
|
||||
Cmd::Settings { key, value } => {
|
||||
let mut settings = backend::Settings::load();
|
||||
if let Some(key) = key {
|
||||
let value = value.ok_or("settings KEY VALUE: the value is missing")?;
|
||||
set_setting(&mut settings, &key, &value)?;
|
||||
settings.save();
|
||||
}
|
||||
let all = settings_json(&settings);
|
||||
if as_json {
|
||||
json(all);
|
||||
} else {
|
||||
for (key, value) in all.as_object().into_iter().flatten() {
|
||||
println!("{key}: {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::UpdateChannel { channel } => {
|
||||
let mut settings = backend::Settings::load();
|
||||
if let Some(channel) = channel.as_deref().and_then(backend::UpdateChannel::parse) {
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Every launcher CLI command as an MCP tool, generated from the CLI's own definition (clap) so the
|
||||
//! two can't drift: the tool `mods_search` is `horizon-launcher mods search`, its arguments are the
|
||||
//! command's (snake_case), and a call runs that command with `--json` as a child process and
|
||||
//! returns what it printed. A few commands keep a process or a sign-in going for the session;
|
||||
//! mcp.rs runs those itself under the same names (`play`, `server`, `account_sign_in`...).
|
||||
use clap::{ArgAction, Command};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
/// CLI commands that are not generated tools: the MCP server itself; No AI, the player's switch
|
||||
/// and never an agent's; and the ones mcp.rs runs in the session.
|
||||
const NOT_TOOLS: &[&str] = &[
|
||||
"mcp",
|
||||
"agent-config",
|
||||
"no-ai",
|
||||
"play",
|
||||
"server",
|
||||
"account sign-in",
|
||||
"account list",
|
||||
];
|
||||
|
||||
/// Commands that delete the player's things: their tools need `confirm: true`.
|
||||
pub const DESTRUCTIVE: &[&str] = &[
|
||||
"remove",
|
||||
"modpack remove",
|
||||
"mods remove",
|
||||
"servers forget",
|
||||
"account sign-out",
|
||||
"data delete",
|
||||
];
|
||||
|
||||
/// Commands that send something or reach other people: only when the player asks.
|
||||
pub const OPEN_WORLD: &[&str] = &[
|
||||
"data allow",
|
||||
"data send",
|
||||
"data bug",
|
||||
"data benchmark",
|
||||
"servers create",
|
||||
];
|
||||
|
||||
/// A generated tool: its name, the CLI words it runs and the clap command behind them.
|
||||
pub struct Tool {
|
||||
pub name: String,
|
||||
pub path: Vec<String>,
|
||||
command: Command,
|
||||
}
|
||||
|
||||
/// The tool name of CLI words: `mods search` gives `mods_search`.
|
||||
pub fn tool_name(path: &[String]) -> String {
|
||||
path.join("_").replace('-', "_")
|
||||
}
|
||||
|
||||
/// Every generated tool, in the CLI's order.
|
||||
pub fn all() -> Vec<Tool> {
|
||||
let mut out = Vec::new();
|
||||
walk(&crate::cli::command(), &mut Vec::new(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(cmd: &Command, path: &mut Vec<String>, out: &mut Vec<Tool>) {
|
||||
for sub in cmd.get_subcommands() {
|
||||
path.push(sub.get_name().to_owned());
|
||||
if sub.has_subcommands() {
|
||||
walk(sub, path, out);
|
||||
} else if !NOT_TOOLS.contains(&path.join(" ").as_str()) {
|
||||
out.push(Tool {
|
||||
name: tool_name(path),
|
||||
path: path.clone(),
|
||||
command: sub.clone(),
|
||||
});
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
fn words(&self) -> String {
|
||||
self.path.join(" ")
|
||||
}
|
||||
pub fn destructive(&self) -> bool {
|
||||
DESTRUCTIVE.contains(&self.words().as_str())
|
||||
}
|
||||
|
||||
/// The tools/list entry.
|
||||
pub fn describe(&self) -> Value {
|
||||
let mut props = Map::new();
|
||||
let mut required = Vec::new();
|
||||
for arg in self.command.get_arguments().filter(|a| shown(a)) {
|
||||
let id = arg.get_id().as_str();
|
||||
let mut help = arg.get_help().map(ToString::to_string).unwrap_or_default();
|
||||
let defaults: Vec<String> = arg
|
||||
.get_default_values()
|
||||
.iter()
|
||||
.map(|d| d.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
if !defaults.is_empty() {
|
||||
help = format!("{help} (default {})", defaults.join(", "))
|
||||
.trim_start()
|
||||
.to_owned();
|
||||
}
|
||||
let choices: Vec<String> = arg
|
||||
.get_possible_values()
|
||||
.iter()
|
||||
.map(|v| v.get_name().to_owned())
|
||||
.collect();
|
||||
let one = if !choices.is_empty() {
|
||||
json!({ "type": "string", "enum": choices })
|
||||
} else if defaults.first().is_some_and(|d| d.parse::<i64>().is_ok()) {
|
||||
json!({ "type": "integer" })
|
||||
} else {
|
||||
json!({ "type": "string" })
|
||||
};
|
||||
let mut schema = if matches!(arg.get_action(), ArgAction::SetTrue) {
|
||||
json!({ "type": "boolean" })
|
||||
} else if many(arg) {
|
||||
json!({ "type": "array", "items": one })
|
||||
} else {
|
||||
one
|
||||
};
|
||||
if !help.is_empty() {
|
||||
schema["description"] = help.into();
|
||||
}
|
||||
props.insert(id.to_owned(), schema);
|
||||
if arg.is_required_set() {
|
||||
required.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
if self.destructive() {
|
||||
props.insert(
|
||||
"confirm".into(),
|
||||
json!({ "type": "boolean", "description": "true once the player agreed: it cannot be undone" }),
|
||||
);
|
||||
required.push("confirm".into());
|
||||
}
|
||||
let about = self
|
||||
.command
|
||||
.get_about()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_default();
|
||||
let mut tool = json!({
|
||||
"name": self.name,
|
||||
"description": format!("{about}. CLI: horizon-launcher {}", self.words()),
|
||||
"inputSchema": { "type": "object", "properties": props, "required": required },
|
||||
});
|
||||
if self.destructive() {
|
||||
tool["annotations"] = json!({ "destructiveHint": true });
|
||||
} else if OPEN_WORLD.contains(&self.words().as_str()) {
|
||||
tool["annotations"] = json!({ "openWorldHint": true });
|
||||
}
|
||||
tool
|
||||
}
|
||||
|
||||
/// The CLI words for a call's arguments.
|
||||
pub fn argv(&self, args: &Value) -> Result<Vec<String>, String> {
|
||||
if self.destructive() && args.get("confirm").and_then(Value::as_bool) != Some(true) {
|
||||
return Err(format!(
|
||||
"{} cannot be undone: ask the player, then call it with confirm: true",
|
||||
self.name
|
||||
));
|
||||
}
|
||||
let text = |v: &Value| match v {
|
||||
Value::String(s) => Ok(s.clone()),
|
||||
Value::Number(n) => Ok(n.to_string()),
|
||||
Value::Bool(b) => Ok(b.to_string()),
|
||||
other => Err(format!("{other} is not a value")),
|
||||
};
|
||||
let mut words = self.path.clone();
|
||||
let mut positional = Vec::new();
|
||||
let mut last = Vec::new();
|
||||
for arg in self.command.get_arguments().filter(|a| shown(a)) {
|
||||
let id = arg.get_id().as_str();
|
||||
let Some(value) = args.get(id).filter(|v| !v.is_null()) else {
|
||||
if arg.is_required_set() {
|
||||
return Err(format!("{id} is required"));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let values = match value {
|
||||
Value::Array(items) => items.iter().map(text).collect::<Result<Vec<_>, _>>()?,
|
||||
v => vec![text(v)?],
|
||||
};
|
||||
if matches!(arg.get_action(), ArgAction::SetTrue) {
|
||||
if value.as_bool().ok_or(format!("{id} is true or false"))? {
|
||||
words.push(format!("--{}", arg.get_long().unwrap_or(id)));
|
||||
}
|
||||
} else if arg.is_positional() {
|
||||
let into = if arg.is_last_set() {
|
||||
&mut last
|
||||
} else {
|
||||
&mut positional
|
||||
};
|
||||
into.push((arg.get_index().unwrap_or(0), values));
|
||||
} else {
|
||||
let long = arg.get_long().unwrap_or(id);
|
||||
for v in values {
|
||||
// `=` keeps a value that starts with a dash (game arguments) a value.
|
||||
words.push(format!("--{long}={v}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
positional.sort_by_key(|(index, _)| *index);
|
||||
words.extend(positional.into_iter().flat_map(|(_, v)| v));
|
||||
if !last.is_empty() {
|
||||
words.push("--".into());
|
||||
words.extend(last.into_iter().flat_map(|(_, v)| v));
|
||||
}
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
/// Runs the command as `horizon-launcher --json ...` and returns what it printed.
|
||||
pub fn call(&self, args: &Value) -> Result<String, String> {
|
||||
let words = self.argv(args)?;
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let out = std::process::Command::new(exe)
|
||||
.arg("--json")
|
||||
.args(&words)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.map_err(|e| format!("Could not run horizon-launcher: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||
let text = match (stdout.is_empty(), stderr.is_empty()) {
|
||||
(false, true) => stdout,
|
||||
(true, false) => stderr,
|
||||
(true, true) => "Done.".into(),
|
||||
(false, false) => format!("{stdout}\n{stderr}"),
|
||||
};
|
||||
if out.status.success() {
|
||||
Ok(text)
|
||||
} else {
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an argument is part of a tool (not --help, --version or the global --json).
|
||||
fn shown(arg: &clap::Arg) -> bool {
|
||||
!arg.is_hide_set() && !matches!(arg.get_id().as_str(), "help" | "version" | "json")
|
||||
}
|
||||
|
||||
/// Whether an argument takes several values (a list).
|
||||
fn many(arg: &clap::Arg) -> bool {
|
||||
matches!(arg.get_action(), ArgAction::Append)
|
||||
|| arg.get_num_args().is_some_and(|n| n.max_values() > 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tool(name: &str) -> Tool {
|
||||
all().into_iter().find(|t| t.name == name).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_cli_command_is_a_tool_named_after_its_words() {
|
||||
let names: Vec<String> = all().into_iter().map(|t| t.name).collect();
|
||||
for want in [
|
||||
"versions",
|
||||
"install",
|
||||
"remove",
|
||||
"prepare",
|
||||
"notes",
|
||||
"update",
|
||||
"update_channel",
|
||||
"game_args",
|
||||
"settings",
|
||||
"logs",
|
||||
"mods_search",
|
||||
"mods_install",
|
||||
"modpack_import",
|
||||
"servers_create",
|
||||
"servers_run",
|
||||
"data_status",
|
||||
"data_bug",
|
||||
"account_select",
|
||||
"benchmark",
|
||||
] {
|
||||
assert!(names.iter().any(|n| n == want), "{want} missing: {names:?}");
|
||||
}
|
||||
for not in ["mcp", "no_ai", "play", "server", "account_sign_in"] {
|
||||
assert!(!names.iter().any(|n| n == not), "{not}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_become_cli_words() {
|
||||
let search = tool("mods_search");
|
||||
let schema = search.describe();
|
||||
assert_eq!(schema["inputSchema"]["properties"]["text"]["type"], "array");
|
||||
let kinds = &schema["inputSchema"]["properties"]["kind"]["enum"];
|
||||
assert!(
|
||||
kinds.as_array().unwrap().contains(&json!("shader")),
|
||||
"{kinds}"
|
||||
);
|
||||
assert_eq!(
|
||||
search
|
||||
.argv(&json!({ "text": ["fresh", "animations"], "kind": "shader", "everything": true }))
|
||||
.unwrap(),
|
||||
["mods", "search", "--kind=shader", "--everything", "fresh", "animations"]
|
||||
);
|
||||
let run = tool("servers_run");
|
||||
assert_eq!(
|
||||
run.argv(&json!({ "server": "My server", "args": ["status"] }))
|
||||
.unwrap(),
|
||||
["servers", "run", "My server", "--", "status"]
|
||||
);
|
||||
assert!(run
|
||||
.argv(&json!({ "server": "x" }))
|
||||
.unwrap_err()
|
||||
.contains("args is required"));
|
||||
let logs = tool("logs").describe();
|
||||
assert_eq!(
|
||||
logs["inputSchema"]["properties"]["lines"]["type"],
|
||||
"integer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destructive_tools_need_confirm() {
|
||||
let remove = tool("remove");
|
||||
assert_eq!(remove.describe()["annotations"]["destructiveHint"], true);
|
||||
assert!(remove
|
||||
.argv(&json!({ "tag": "v1" }))
|
||||
.unwrap_err()
|
||||
.contains("confirm"));
|
||||
assert_eq!(
|
||||
remove
|
||||
.argv(&json!({ "tag": "v1", "confirm": true }))
|
||||
.unwrap(),
|
||||
["remove", "v1"]
|
||||
);
|
||||
for words in DESTRUCTIVE.iter().chain(OPEN_WORLD) {
|
||||
assert!(
|
||||
all().iter().any(|t| t.words() == *words),
|
||||
"{words} is not a tool"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The running game's tools in the launcher's MCP, so an agent needs one server
|
||||
//! (docs/agent-control.md): the selected version's `horizon-ctl mcp` runs as a child, its tools
|
||||
//! are offered as `game_<name>`, and calls go to it unchanged. `launch_client` starts the game
|
||||
//! are offered as `game_<name>`, and calls go to it unchanged. `play` starts the game
|
||||
//! with the control socket that child talks to.
|
||||
use horizon_launcher::backend;
|
||||
use serde_json::{json, Value};
|
||||
@@ -10,7 +10,7 @@ use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
|
||||
pub const PREFIX: &str = "game_";
|
||||
|
||||
/// horizon-ctl tools the launcher covers itself: `launch_client` starts the game.
|
||||
/// horizon-ctl tools the launcher covers itself: `play` starts the game.
|
||||
const HIDDEN: &[&str] = &["launch"];
|
||||
|
||||
const CTL: &str = if cfg!(windows) {
|
||||
@@ -19,7 +19,7 @@ const CTL: &str = if cfg!(windows) {
|
||||
"horizon-ctl"
|
||||
};
|
||||
|
||||
/// The control endpoint of a game started by `launch_client`: a Unix socket, or on Windows the
|
||||
/// The control endpoint of a game started by `play`: a Unix socket, or on Windows the
|
||||
/// file naming a localhost port and its token.
|
||||
pub fn socket_path() -> PathBuf {
|
||||
backend::data_dir().join(if cfg!(unix) {
|
||||
@@ -151,7 +151,7 @@ fn prefixed(tools: &Value) -> Vec<Value> {
|
||||
let mut t = t.clone();
|
||||
t["name"] = format!("{PREFIX}{}", t["name"].as_str().unwrap_or_default()).into();
|
||||
t["description"] = format!(
|
||||
"In the running game (start it with launch_client): {}",
|
||||
"In the running game (start it with play): {}",
|
||||
t["description"].as_str().unwrap_or_default()
|
||||
)
|
||||
.into();
|
||||
@@ -173,7 +173,7 @@ fn explain_unreachable(mut result: Value) -> Value {
|
||||
});
|
||||
if unreachable {
|
||||
result["content"] = json!([{ "type": "text", "text": json!({
|
||||
"error": "The game is not running under agent control. Start it with launch_client, then game_wait_ready.",
|
||||
"error": "The game is not running under agent control. Start it with play, then game_wait_ready.",
|
||||
"kind": "state",
|
||||
}).to_string() }]);
|
||||
}
|
||||
@@ -209,7 +209,7 @@ mod tests {
|
||||
assert!(result["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("launch_client"));
|
||||
.contains("with play"));
|
||||
let other = json!({ "content": [{ "type": "text", "text": "{\"error\":\"no world\"}" }], "isError": true });
|
||||
assert_eq!(explain_unreachable(other.clone()), other);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The launcher's core, shared by the GUI, the CLI and the MCP server.
|
||||
pub mod account;
|
||||
pub mod agents;
|
||||
pub mod backend;
|
||||
pub mod browser;
|
||||
pub mod desktop;
|
||||
|
||||
+8
-31
@@ -1,9 +1,11 @@
|
||||
//! Horizon launcher: installs Horizon builds, prepares them from the player's own Minecraft,
|
||||
//! and starts the client or a dedicated server.
|
||||
|
||||
mod agents_page;
|
||||
mod art;
|
||||
mod blocks;
|
||||
mod cli;
|
||||
mod cli_tools;
|
||||
mod collection;
|
||||
mod cull;
|
||||
mod game_tools;
|
||||
@@ -179,6 +181,8 @@ struct Launcher {
|
||||
args_focus: FocusHandle,
|
||||
/// Opt-in data collection: the player's answer and Settings' section (`collection`).
|
||||
collection: collection::Collection,
|
||||
/// Settings' "Connect an AI agent" (`agents_page`).
|
||||
agents: agents_page::AgentsUi,
|
||||
/// How many play Horizon now, while the server answers ("N playing now" on the Play page).
|
||||
playing: Option<u64>,
|
||||
tx: Sender<Msg>,
|
||||
@@ -306,6 +310,7 @@ impl Launcher {
|
||||
friend_focus: cx.focus_handle(),
|
||||
joining: None,
|
||||
collection: collection::Collection::load(),
|
||||
agents: agents_page::AgentsUi::default(),
|
||||
playing: None,
|
||||
tx,
|
||||
rx,
|
||||
@@ -2432,36 +2437,6 @@ impl Launcher {
|
||||
};
|
||||
let mono = |s: String| div().font_family(theme::MONO).child(s).into_any_element();
|
||||
let plain = |s: &str| div().child(s.to_string()).into_any_element();
|
||||
let agents = plate(&t)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(10.))
|
||||
.p(px(24.))
|
||||
.text_size(px(theme::UI.px))
|
||||
.line_height(px(21.))
|
||||
.child(
|
||||
text(theme::HEADING)
|
||||
.text_color(t.ink)
|
||||
.child("Use from the command line or AI agents"),
|
||||
)
|
||||
.child(div().max_w(px(600.)).text_color(t.muted).child(
|
||||
"The launcher is also a command-line tool and an MCP server. Everything here works from a terminal, or from an agent such as Claude Code:",
|
||||
))
|
||||
.child(
|
||||
text(theme::CAPTION)
|
||||
.font_family(theme::MONO)
|
||||
.text_color(t.ink)
|
||||
.child("claude mcp add horizon -- horizon-launcher mcp"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("readme")
|
||||
.text_color(t.accent)
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.opacity(0.8))
|
||||
.child("Commands and tools, in the README")
|
||||
.on_click(|_, _, cx| cx.open_url(backend::README_URL)),
|
||||
);
|
||||
let (accounts, n) = self.account_rows(cx);
|
||||
let appearance = segments(
|
||||
"appearance",
|
||||
@@ -2545,6 +2520,8 @@ impl Launcher {
|
||||
))
|
||||
.child(group("Game", "gamepad"))
|
||||
.child(self.game_args_row(n + 4, cx))
|
||||
.child(group("AI", "terminal"))
|
||||
.child(self.no_ai_row(n + 4, cx))
|
||||
.child(group("About", "info-box"))
|
||||
.child(index_row(n + 5, "Horizon", Some(plain(&horizon)), horizon_action, &t))
|
||||
.child(index_row(
|
||||
@@ -2594,7 +2571,7 @@ impl Launcher {
|
||||
}),
|
||||
)
|
||||
.child(self.collection_section(cx))
|
||||
.child(agents)
|
||||
.children(self.agents_section(cx))
|
||||
}
|
||||
|
||||
/// Settings' Game arguments row: the extra arguments the game starts with, kept in
|
||||
|
||||
+44
-226
@@ -2,10 +2,10 @@
|
||||
//! message per line), so AI agents can install, prepare and run Horizon. Every tool goes through
|
||||
//! `backend`. A server started here lives as long as the session. The running game's own tools
|
||||
//! come from its `horizon-ctl` as `game_*` (game_tools.rs).
|
||||
use crate::cli_tools;
|
||||
use crate::game_tools::{self, GameTools};
|
||||
use horizon_launcher::account::{self, Accounts, SignIn, SignInState};
|
||||
use horizon_launcher::backend::{self, Job, Logged};
|
||||
use horizon_launcher::telemetry;
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, Write};
|
||||
use std::path::PathBuf;
|
||||
@@ -21,55 +21,38 @@ struct Session {
|
||||
game: Option<GameTools>,
|
||||
}
|
||||
|
||||
fn tools() -> Value {
|
||||
/// The tools the session runs itself, under their CLI names: they keep a process (the game, a
|
||||
/// server) or a sign-in going between calls. Every other CLI command is generated (cli_tools.rs).
|
||||
fn session_tools() -> Value {
|
||||
let version = json!({ "type": "string", "description": "An installed version tag such as v2026.10.13. Default: the selected version." });
|
||||
let none = json!({ "type": "object", "properties": {} });
|
||||
json!([
|
||||
{ "name": "list_versions", "description": "List Horizon releases on GitHub (newest first) and the versions installed here, with which is selected and which are prepared.",
|
||||
"inputSchema": { "type": "object", "properties": { "installed_only": { "type": "boolean", "description": "Skip GitHub and list only installed versions." } } } },
|
||||
{ "name": "install_version", "description": "Download and install a Horizon release for this platform. Takes a minute or two.",
|
||||
"inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "description": "A release tag such as v2026.10.13, or \"latest\"." } }, "required": ["tag"] } },
|
||||
{ "name": "remove_version", "description": "Remove an installed Horizon version and its prepared resources.",
|
||||
"inputSchema": { "type": "object", "properties": { "tag": { "type": "string" } }, "required": ["tag"] } },
|
||||
{ "name": "prepare_resources", "description": "Prepare a version's resources from the player's own Minecraft install (needs the Minecraft release that version is built for, played once in the official launcher, and its Java; versions before horizon-prepare also need Python 3.10+). Can take several minutes; returns the end of the log.",
|
||||
"inputSchema": { "type": "object", "properties": { "version": version, "minecraft_dir": { "type": "string", "description": "The game folder of the official launcher, Prism Launcher, MultiMC, the Modrinth App, ATLauncher or GDLauncher. Default: the launcher's setting, then every launcher's default folder." } } } },
|
||||
{ "name": "launch_client", "description": "Start the Horizon client (a game window on the player's screen) under agent control: the game_* tools then play it. Returns once it has started; game_wait_ready waits for the world.",
|
||||
{ "name": "play", "description": "Start the Horizon client (a game window on the player's screen) under agent control: the game_* tools then play it. Returns once it has started; game_wait_ready waits for the world. CLI: horizon-launcher play",
|
||||
"inputSchema": { "type": "object", "properties": { "version": version,
|
||||
"world": { "type": "string", "description": "Open or create this world (a folder under saves) at once." },
|
||||
"creative": { "type": "boolean", "description": "Creative mode for a new world." },
|
||||
"join": { "type": "string", "description": "Join a friend's world with its invite code. Only when the player asks." },
|
||||
"offscreen": { "type": "boolean", "description": "Render without showing a window (game_screenshot still works)." },
|
||||
"args": { "type": "array", "items": { "type": "string" }, "description": "Extra game arguments, passed through as-is (for example \"--server-thread\")." } } } },
|
||||
{ "name": "start_server", "description": "Start a Horizon dedicated server. It runs until stop_server or the end of this MCP session. Requires agreeing to the Minecraft EULA.",
|
||||
"arg": { "type": "array", "items": { "type": "string" }, "description": "Extra game arguments, passed through as-is (for example \"--server-thread\")." } } } },
|
||||
{ "name": "server", "description": "Start a Horizon dedicated server. It runs until server_stop or the end of this MCP session. Requires agreeing to the Minecraft EULA. CLI: horizon-launcher server",
|
||||
"inputSchema": { "type": "object", "properties": { "version": version, "dir": { "type": "string", "description": "The server's folder. Default: the launcher's server folder." }, "accept_eula": { "type": "boolean", "description": "Agree to the Minecraft EULA (https://aka.ms/MinecraftEULA) on the player's behalf. Only with their consent." } } } },
|
||||
{ "name": "server_command", "description": "Send a console command to the running server (for example \"list\" or \"say hello\"), and return the lines it printed in reply.",
|
||||
{ "name": "server_command", "description": "Send a console command to the server started with server (for example \"list\" or \"say hello\"), and return the lines it printed in reply.",
|
||||
"inputSchema": { "type": "object", "properties": { "command": { "type": "string" } }, "required": ["command"] } },
|
||||
{ "name": "server_log", "description": "The most recent lines of the server's console.",
|
||||
"inputSchema": { "type": "object", "properties": { "lines": { "type": "integer", "minimum": 1, "maximum": 2000, "description": "How many lines (default 50)." } } } },
|
||||
{ "name": "stop_server", "description": "Stop the running server cleanly (it saves the world), killing it if it does not stop within 30 seconds.", "inputSchema": none },
|
||||
{ "name": "patch_notes", "description": "A release's patch notes as markdown.",
|
||||
"inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "description": "Default: the newest release." } } } },
|
||||
{ "name": "links", "description": "The Horizon links: the Discord invite, the GitHub Sponsors page and the website (an empty one is not offered).", "inputSchema": none },
|
||||
{ "name": "update_channel", "description": "Show or set which Horizon releases install by themselves. \"stable\" (the default) is a tested build about once a day; \"alpha\" is every release as soon as it is built, several times a day, and can break. Only when the player asks to change it.",
|
||||
"inputSchema": { "type": "object", "properties": { "channel": { "type": "string", "enum": ["stable", "alpha"], "description": "Omit to show the current channel." } } } },
|
||||
{ "name": "logs", "description": "Read the game's log: the last lines of logs/latest.log in the game folder, or with crash the newest crash report (what the game wrote when it last crashed). Read-only.",
|
||||
"inputSchema": { "type": "object", "properties": { "lines": { "type": "integer", "minimum": 1, "maximum": 5000, "description": "How many lines of latest.log (default 100)." }, "crash": { "type": "boolean", "description": "Return the newest crash report instead." } } } },
|
||||
{ "name": "accounts", "description": "The Microsoft accounts signed in to the launcher (names and UUIDs, never tokens), which one the game plays as (none = offline), and how a sign-in started with sign_in is going.", "inputSchema": none },
|
||||
{ "name": "sign_in", "description": "Start signing in a Microsoft account: returns a link and a code the player enters in their browser. The sign-in finishes in the background; check it with accounts. Only when the player asks to sign in.", "inputSchema": none },
|
||||
{ "name": "sign_out", "description": "Sign an account out of the launcher (its tokens leave the keychain).",
|
||||
"inputSchema": { "type": "object", "properties": { "account": { "type": "string", "description": "Its name or UUID." } }, "required": ["account"] } },
|
||||
{ "name": "select_account", "description": "Choose the account the game plays as, or \"offline\" to play without one.",
|
||||
"inputSchema": { "type": "object", "properties": { "account": { "type": "string", "description": "A name or UUID, or \"offline\"." } }, "required": ["account"] } },
|
||||
{ "name": "check_updates", "description": "Check GitHub for a Horizon release newer than every installed version, and for a newer launcher.", "inputSchema": none },
|
||||
{ "name": "data_collection", "description": "The player's opt-in data collection: which kinds they allow (performance, crashes, bugs, benchmarks; all off until they answer), the install ID, how many items wait, and the data server. Read-only.", "inputSchema": none },
|
||||
{ "name": "set_data_collection", "description": "Turn kinds of data collection on or off. Only when the player asks: it records their consent.",
|
||||
"inputSchema": { "type": "object", "properties": { "kinds": { "type": "array", "items": { "type": "string", "enum": ["performance", "crashes", "bugs", "benchmarks", "all"] } }, "on": { "type": "boolean" } }, "required": ["kinds", "on"] } },
|
||||
{ "name": "data_preview", "description": "Exactly what would be sent (the JSON), or an example of each kind when nothing waits. Sends nothing.", "inputSchema": none },
|
||||
{ "name": "delete_my_data", "description": "Ask the server to delete everything sent from this install, remove what waits, and take a new install ID. Only when the player asks.", "inputSchema": none },
|
||||
{ "name": "report_bug", "description": "Send a bug report in the player's words, only once they have agreed to send it (bug reports must be allowed). With include_log the end of the game's log goes too, scrubbed and without chat: show it to the player first (the logs tool). Returns exactly what was kept.",
|
||||
"inputSchema": { "type": "object", "properties": { "text": { "type": "string" }, "include_log": { "type": "boolean" } }, "required": ["text"] } },
|
||||
{ "name": "server_stop", "description": "Stop the server started with server cleanly (it saves the world), killing it if it does not stop within 30 seconds.", "inputSchema": none },
|
||||
{ "name": "account_list", "description": "The Microsoft accounts signed in to the launcher (names and UUIDs, never tokens), which one the game plays as (none = offline), and how a sign-in started with account_sign_in is going. CLI: horizon-launcher account list", "inputSchema": none },
|
||||
{ "name": "account_sign_in", "description": "Start signing in a Microsoft account: returns a link and a code the player enters in their browser. The sign-in finishes in the background; check it with account_list. Only when the player asks to sign in. CLI: horizon-launcher account sign-in", "inputSchema": none },
|
||||
])
|
||||
}
|
||||
|
||||
/// Every launcher tool: the session's and the generated ones.
|
||||
fn tools() -> Vec<Value> {
|
||||
let mut list = session_tools().as_array().cloned().unwrap_or_default();
|
||||
list.extend(cli_tools::all().iter().map(cli_tools::Tool::describe));
|
||||
list
|
||||
}
|
||||
|
||||
fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
|
||||
args.get(key)
|
||||
.and_then(Value::as_str)
|
||||
@@ -78,81 +61,8 @@ fn arg_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
|
||||
|
||||
impl Session {
|
||||
fn call(&mut self, name: &str, args: &Value) -> Result<String, String> {
|
||||
if matches!(name, "list_versions" | "check_updates") {
|
||||
backend::reuse_prepared_everywhere();
|
||||
}
|
||||
match name {
|
||||
"list_versions" => {
|
||||
let installed = backend::installed();
|
||||
let mut out = json!({
|
||||
"installed": installed.iter().map(|t| json!({ "tag": t, "prepared": backend::is_prepared(t), "sounds": backend::has_sounds(t) })).collect::<Vec<_>>(),
|
||||
"selected": backend::resolve_version(None).ok(),
|
||||
});
|
||||
if !args
|
||||
.get("installed_only")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
out["releases"] = backend::releases()?
|
||||
.iter()
|
||||
.map(|r| json!({ "tag": r.tag, "name": r.name, "date": r.date, "channel": backend::UpdateChannel::of(r).name(), "available_here": r.asset.is_some() }))
|
||||
.collect();
|
||||
}
|
||||
Ok(serde_json::to_string_pretty(&out).unwrap())
|
||||
}
|
||||
"install_version" => {
|
||||
let tag = backend::install_tag(
|
||||
arg_str(args, "tag").ok_or("tag is required")?,
|
||||
false,
|
||||
|_| {},
|
||||
)?;
|
||||
Ok(if backend::is_prepared(&tag) {
|
||||
format!("Installed {tag}. It is now the selected version, prepared already from another installed version.")
|
||||
} else {
|
||||
format!("Installed {tag}. It is now the selected version; prepare it before playing.")
|
||||
})
|
||||
}
|
||||
"remove_version" => {
|
||||
let tag = arg_str(args, "tag").ok_or("tag is required")?;
|
||||
backend::remove_version(tag)?;
|
||||
Ok(format!("Removed {tag}."))
|
||||
}
|
||||
"prepare_resources" => {
|
||||
let tag = backend::resolve_version(arg_str(args, "version"))?;
|
||||
let minecraft =
|
||||
backend::minecraft_dir(arg_str(args, "minecraft_dir").map(PathBuf::from))?;
|
||||
let (preparer, minecraft) = backend::preparer(&tag, &minecraft, &|_| {})?;
|
||||
let sound = std::cell::RefCell::new(Vec::new());
|
||||
backend::sounds(&tag, &minecraft, &|line| sound.borrow_mut().push(line));
|
||||
let (result, tail) = match preparer {
|
||||
backend::Preparer::Process(cmd) => {
|
||||
let run = Logged::start(Job::Prepare, cmd)?;
|
||||
let result = run.wait();
|
||||
(result, run.tail(20).join("\n"))
|
||||
}
|
||||
backend::Preparer::InProcess(options) => {
|
||||
let lines = std::cell::RefCell::new(std::collections::VecDeque::new());
|
||||
let result = backend::prepare_in_process(&options, &|line| {
|
||||
let mut lines = lines.borrow_mut();
|
||||
lines.push_back(line);
|
||||
if lines.len() > 20 {
|
||||
lines.pop_front();
|
||||
}
|
||||
});
|
||||
let tail = lines
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(result, tail)
|
||||
}
|
||||
};
|
||||
let tail = format!("{}\n{tail}", sound.into_inner().join("\n"));
|
||||
result
|
||||
.map(|_| format!("{tag} is prepared.\n{tail}"))
|
||||
.map_err(|e| format!("{e}\n{tail}"))
|
||||
}
|
||||
"launch_client" => {
|
||||
"play" => {
|
||||
if self.client.as_ref().is_some_and(|c| c.finished().is_none()) {
|
||||
return Err("The client is already running.".into());
|
||||
}
|
||||
@@ -167,7 +77,10 @@ impl Session {
|
||||
cmd.arg(format!("--{flag}"));
|
||||
}
|
||||
}
|
||||
if let Some(extra) = args.get("args").and_then(Value::as_array) {
|
||||
if let Some(code) = arg_str(args, "join") {
|
||||
backend::join_friend(&mut cmd, code)?;
|
||||
}
|
||||
if let Some(extra) = args.get("arg").and_then(Value::as_array) {
|
||||
cmd.args(extra.iter().filter_map(Value::as_str));
|
||||
}
|
||||
let client = Logged::start(Job::Client, cmd)?;
|
||||
@@ -194,7 +107,7 @@ impl Session {
|
||||
"Started the {tag} client. The game_* tools play it; game_wait_ready waits until the world is ready."
|
||||
))
|
||||
}
|
||||
"start_server" => {
|
||||
"server" => {
|
||||
if self.server.as_ref().is_some_and(|s| s.finished().is_none()) {
|
||||
return Err("A server is already running. Stop it first.".into());
|
||||
}
|
||||
@@ -249,7 +162,7 @@ impl Session {
|
||||
server.tail(lines).join("\n")
|
||||
))
|
||||
}
|
||||
"stop_server" => {
|
||||
"server_stop" => {
|
||||
let mut server = self
|
||||
.server
|
||||
.take()
|
||||
@@ -260,85 +173,7 @@ impl Session {
|
||||
server.tail(5).join("\n")
|
||||
))
|
||||
}
|
||||
"patch_notes" => {
|
||||
let r = backend::release_notes(arg_str(args, "tag"))?;
|
||||
Ok(format!(
|
||||
"# {} ({}, {})\n\n{}",
|
||||
if r.name.is_empty() { &r.tag } else { &r.name },
|
||||
r.tag,
|
||||
r.date,
|
||||
r.body
|
||||
))
|
||||
}
|
||||
"links" => Ok(serde_json::to_string_pretty(&backend::links()).unwrap()),
|
||||
"logs" => {
|
||||
let lines = args.get("lines").and_then(Value::as_u64).unwrap_or(100);
|
||||
let crash = args.get("crash").and_then(Value::as_bool).unwrap_or(false);
|
||||
let (path, text) = backend::read_logs(lines.clamp(1, 5000) as usize, crash)?;
|
||||
Ok(format!("{}\n\n{text}", path.display()))
|
||||
}
|
||||
"update_channel" => {
|
||||
let mut settings = backend::Settings::load();
|
||||
if let Some(name) = arg_str(args, "channel") {
|
||||
settings.update_channel = backend::UpdateChannel::parse(name)
|
||||
.ok_or("channel is \"stable\" or \"alpha\"")?;
|
||||
settings.save();
|
||||
}
|
||||
Ok(format!(
|
||||
"Update channel: {}",
|
||||
settings.update_channel.name()
|
||||
))
|
||||
}
|
||||
"data_collection" => Ok(serde_json::to_string_pretty(&telemetry::status_json())
|
||||
.map_err(|e| e.to_string())?),
|
||||
"set_data_collection" => {
|
||||
let kinds: Vec<String> = args
|
||||
.get("kinds")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let on = args
|
||||
.get("on")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or("on is required")?;
|
||||
telemetry::set(&telemetry::parse_kinds(&kinds)?, on)?;
|
||||
Ok(serde_json::to_string_pretty(&telemetry::status_json())
|
||||
.map_err(|e| e.to_string())?)
|
||||
}
|
||||
"data_preview" => {
|
||||
let (what, text) = telemetry::preview();
|
||||
Ok(format!("{what}\n{text}"))
|
||||
}
|
||||
"delete_my_data" => telemetry::delete_my_data(),
|
||||
"report_bug" => {
|
||||
let text = arg_str(args, "text").ok_or("text is required")?;
|
||||
let with_log = args
|
||||
.get("include_log")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let item = telemetry::bug_report(text, with_log)?;
|
||||
let done = telemetry::submit(&item)?;
|
||||
Ok(format!(
|
||||
"{done}\n{}",
|
||||
serde_json::to_string_pretty(&item).map_err(|e| e.to_string())?
|
||||
))
|
||||
}
|
||||
"check_updates" => {
|
||||
let u = backend::check_updates()?;
|
||||
Ok(serde_json::to_string_pretty(&json!({
|
||||
"horizon_update": u.horizon.as_ref().map(|r| &r.tag),
|
||||
"installed": backend::installed(),
|
||||
"launcher_update": u.launcher.as_ref().and_then(|r| backend::launcher_version(&r.tag)),
|
||||
"launcher_version": backend::VERSION,
|
||||
}))
|
||||
.unwrap())
|
||||
}
|
||||
"accounts" => {
|
||||
"account_list" => {
|
||||
let accounts = Accounts::load();
|
||||
let sign_in = self.sign_in.as_ref().map(|s| match s.state() {
|
||||
SignInState::Starting => json!({ "state": "starting" }),
|
||||
@@ -355,7 +190,7 @@ impl Session {
|
||||
}))
|
||||
.unwrap())
|
||||
}
|
||||
"sign_in" => {
|
||||
"account_sign_in" => {
|
||||
account::client_id().ok_or(account::NOT_CONFIGURED)?;
|
||||
if let Some(s) = self.sign_in.take() {
|
||||
s.cancel();
|
||||
@@ -380,29 +215,10 @@ impl Session {
|
||||
_ => Err("Microsoft did not answer. Try again.".into()),
|
||||
}
|
||||
}
|
||||
"sign_out" | "select_account" => {
|
||||
let who = arg_str(args, "account").ok_or("account is required")?;
|
||||
let mut accounts = Accounts::load();
|
||||
if name == "select_account" && who.eq_ignore_ascii_case("offline") {
|
||||
accounts.selected = None;
|
||||
accounts.save()?;
|
||||
return Ok("The game plays offline.".into());
|
||||
}
|
||||
let a = accounts
|
||||
.find(who)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("No signed-in account {who}."))?;
|
||||
if name == "sign_out" {
|
||||
accounts.remove(account::store(), &a.uuid)?;
|
||||
accounts.save()?;
|
||||
Ok(format!("Signed out {}.", a.name))
|
||||
} else {
|
||||
accounts.selected = Some(a.uuid);
|
||||
accounts.save()?;
|
||||
Ok(format!("The game plays as {}.", a.name))
|
||||
}
|
||||
}
|
||||
_ => Err(format!("Unknown tool {name}")),
|
||||
_ => match cli_tools::all().into_iter().find(|t| t.name == name) {
|
||||
Some(tool) => tool.call(args),
|
||||
None => Err(format!("Unknown tool {name}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +235,7 @@ impl Session {
|
||||
fn running_server(&mut self) -> Result<&mut Logged, String> {
|
||||
match &mut self.server {
|
||||
Some(s) if s.finished().is_none() => Ok(s),
|
||||
_ => Err("No server is running. Start one with start_server.".into()),
|
||||
_ => Err("No server is running. Start one with server.".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,22 +245,29 @@ impl Session {
|
||||
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
|
||||
"capabilities": { "tools": {} },
|
||||
"serverInfo": { "name": "horizon-launcher", "version": backend::VERSION },
|
||||
"instructions": "Manage Horizon, a Rust reimplementation of Minecraft Java Edition: install versions, prepare them from the player's own Minecraft, start the client or a dedicated server. Once launch_client started the game, the game_* tools play it: game_state, game_ui (every widget of the open screen), game_click, game_goto, game_screenshot...",
|
||||
"instructions": "Manage Horizon, a Rust reimplementation of Minecraft Java Edition: install versions, prepare them from the player's own Minecraft, start the client or a dedicated server. Every tool is a horizon-launcher CLI command of the same name (mods_search is horizon-launcher mods search). Once play started the game, the game_* tools play it: game_state, game_ui (every widget of the open screen), game_click, game_goto, game_screenshot...",
|
||||
})),
|
||||
"ping" => Ok(json!({})),
|
||||
"tools/list" => {
|
||||
let mut list = tools().as_array().cloned().unwrap_or_default();
|
||||
let mut list = tools();
|
||||
if let Some(game) = self.game() {
|
||||
list.extend(game.tools().iter().cloned());
|
||||
}
|
||||
Ok(json!({ "tools": list }))
|
||||
}
|
||||
// No AI turned on during the session: nothing more runs.
|
||||
"tools/call" if backend::no_ai() => {
|
||||
Err((-32000, horizon_core::no_ai::REFUSAL.to_string()))
|
||||
}
|
||||
"tools/call" => {
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or((-32602, "name is required".to_string()))?;
|
||||
if name.starts_with(game_tools::PREFIX) {
|
||||
// The launcher's own tools first: game_args is one, not a game tool.
|
||||
if name.starts_with(game_tools::PREFIX)
|
||||
&& !tools().iter().any(|t| t["name"] == name)
|
||||
{
|
||||
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
let game = self.game().ok_or((
|
||||
-32602,
|
||||
@@ -455,12 +278,7 @@ impl Session {
|
||||
}
|
||||
return game.call(name, &args).map_err(|e| (-32603, e));
|
||||
}
|
||||
if !tools()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|t| t["name"] == name)
|
||||
{
|
||||
if !tools().iter().any(|t| t["name"] == name) {
|
||||
return Err((-32602, format!("Unknown tool {name}")));
|
||||
}
|
||||
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
|
||||
@@ -17,7 +17,7 @@ pub const SWITCHES: [(Kind, &str, &str); 4] = [
|
||||
(
|
||||
Kind::Perf,
|
||||
"Performance statistics",
|
||||
"After each session: frame-time percentiles and a 14-bucket histogram, tick times, peak memory and startup times, with your OS, CPU, GPU, driver, memory size and the settings that change performance.",
|
||||
"After each session: frame-time percentiles and a 14-bucket histogram, tick times, peak memory and startup times, with your machine (OS, CPU and its cores and clocks, memory size, GPU, its memory and driver, drive type, screen size and refresh rate, laptop or desktop, on battery or not) and the settings that change performance.",
|
||||
),
|
||||
(
|
||||
Kind::Crash,
|
||||
@@ -96,7 +96,7 @@ pub fn preview() -> (String, String) {
|
||||
);
|
||||
return (what, upload::preview(&home));
|
||||
}
|
||||
let system = System::detect();
|
||||
let system = with_example_gpu(System::detect_in(Some(&home)));
|
||||
let examples = vec![
|
||||
Item::new(
|
||||
Source::Game,
|
||||
@@ -137,11 +137,29 @@ pub fn preview() -> (String, String) {
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
(
|
||||
"Nothing is waiting. One example of each kind, from this machine (a benchmark upload is the report itself, see docs/benchmark-report.md):".into(),
|
||||
"Nothing is waiting. One example of each kind, from this machine; the game fills in the GPU and screen it uses where these say example (a benchmark upload is the report itself, see docs/benchmark-report.md):".into(),
|
||||
json,
|
||||
)
|
||||
}
|
||||
|
||||
/// What only the game knows (the GPU it draws with, the screen its window is on), as an
|
||||
/// example: the launcher draws with neither.
|
||||
pub fn with_example_gpu(mut system: System) -> System {
|
||||
system.gpu = "example: the GPU the game draws with".into();
|
||||
system.gpu_vendor = "nvidia".into();
|
||||
system.gpu_kind = "discrete".into();
|
||||
system.vram_mb = 8192;
|
||||
system.driver = "example: 570.86".into();
|
||||
system.backend = "Vulkan".into();
|
||||
system.display = Some(horizon_telemetry::payload::Display {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
scale: 1.0,
|
||||
});
|
||||
system
|
||||
}
|
||||
|
||||
/// "Delete my data": the server deletes everything sent under this install's ID, the waiting
|
||||
/// items go, and the install gets a new ID. If the server can't be reached the ID is kept, so
|
||||
/// the deletion can be asked again.
|
||||
@@ -203,7 +221,7 @@ pub fn bug_report(text: &str, with_log: bool) -> Result<Item, String> {
|
||||
Source::Launcher,
|
||||
backend::VERSION,
|
||||
Payload::Bug(Bug {
|
||||
system: System::detect(),
|
||||
system: System::detect_in(Some(&home())),
|
||||
text: scrubber().scrub(text.trim()),
|
||||
log: with_log.then(log_for_report).flatten(),
|
||||
}),
|
||||
@@ -342,6 +360,20 @@ mod tests {
|
||||
assert!(names(&["everything"]).is_err());
|
||||
}
|
||||
|
||||
/// "View what would be sent" and `data show` list every hardware field.
|
||||
#[test]
|
||||
fn the_example_shows_every_hardware_field() {
|
||||
let json = serde_json::to_string(&with_example_gpu(System::detect())).unwrap();
|
||||
let fields = "os os_version arch cpu threads cores cpu_base_mhz cpu_boost_mhz ram_gb gpu \
|
||||
gpu_vendor gpu_kind vram_mb driver backend storage width height refresh_hz scale power form";
|
||||
for field in fields.split_whitespace() {
|
||||
assert!(
|
||||
json.contains(&format!("\"{field}\":")),
|
||||
"{field} missing from {json}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_has_a_switch() {
|
||||
assert!(Kind::ALL.iter().all(|&k| !label(k).is_empty()));
|
||||
|
||||
@@ -50,7 +50,7 @@ if __name__ == "__main__":
|
||||
class WaitLoadedTests(unittest.TestCase):
|
||||
"""wait_loaded on a scripted game with a fake clock: one status per 50 ms poll."""
|
||||
|
||||
def run_script(self, statuses):
|
||||
def run_script(self, statuses, min_chunks=0):
|
||||
import tools.perf_bench as pb
|
||||
clock = [0.0]
|
||||
it = iter(statuses)
|
||||
@@ -62,7 +62,7 @@ class WaitLoadedTests(unittest.TestCase):
|
||||
pb.time.time = lambda: clock[0]
|
||||
pb.time.sleep = lambda s: clock.__setitem__(0, clock[0] + s)
|
||||
try:
|
||||
return pb.wait_loaded(control, lambda p: True, 60)
|
||||
return pb.wait_loaded(control, lambda p: True, 60, min_chunks)
|
||||
finally:
|
||||
pb.time.time, pb.time.sleep = real_time, real_sleep
|
||||
|
||||
@@ -86,3 +86,15 @@ class WaitLoadedTests(unittest.TestCase):
|
||||
busy = [self.status(901, 1000 * i) for i in range(20)]
|
||||
quiet = [self.status(901, 20000 + i) for i in range(40)]
|
||||
self.assertAlmostEqual(self.run_script(busy + quiet), 1.0, places=6)
|
||||
|
||||
def test_a_quiet_start_is_not_a_load(self):
|
||||
# Right after a teleport Horizon reports steady counts and nothing queued for 1.5 s before it loads:
|
||||
# with a target the clock runs on until the chunks are there and the meshing has gone quiet.
|
||||
idle = [self.status(3, 0, stages=0) for _ in range(30)]
|
||||
loading = [self.status(30 * (i + 1), 500 * i, stages=500) for i in range(20)]
|
||||
tail = [self.status(600, 10000, stages=0) for _ in range(60)]
|
||||
took = self.run_script(idle + loading + tail, min_chunks=500)
|
||||
self.assertGreater(took, 2.4)
|
||||
self.assertLess(took, 4.0)
|
||||
# Without one (the old call), the quiet start ends the wait at once.
|
||||
self.assertLess(self.run_script(idle + loading + tail), 1.6)
|
||||
+119
-8
@@ -11,7 +11,9 @@ states (wait-for) and reads the event log; the consume scenario eats and drinks
|
||||
golden apple, milk, honey, chorus fruit) and checks their effects, remainders and teleport; the
|
||||
pick scenario middle-clicks slots (AbstractContainerScreen's cloning): a full stack in creative, from
|
||||
the hotbar and from an item tab, and nothing in survival; the suggestions scenario checks
|
||||
slash-command Tab completion on single player's connected client after a fresh launch.
|
||||
slash-command Tab completion on single player's connected client after a fresh launch; the no-ai
|
||||
scenario turns No AI on in a scratch game home and checks that horizon-ctl, its MCP server and the
|
||||
game's control socket all refuse.
|
||||
|
||||
python3 tools/agent_control_e2e.py # both modes
|
||||
python3 tools/agent_control_e2e.py --mode mcp # one of them
|
||||
@@ -52,10 +54,13 @@ POSITIONAL = {
|
||||
'click_button': ['label'], 'type': ['text'], 'wait': ['ticks'], 'wait_ready': ['seconds'],
|
||||
'command': ['command'], 'look_at_block': ['x', 'y', 'z'], 'click': ['id'], 'set': ['id', 'value'],
|
||||
'give': ['item', 'count'], 'options_get': ['keys'], 'options_set': ['key', 'value'],
|
||||
'wait_for': ['condition', 'value'],
|
||||
'wait_for': ['condition', 'value'], 'open': ['world', 'seed', 'mode'], 'select': ['slot'],
|
||||
'summon': ['kind', 'x', 'y', 'z'], 'interact_entity': ['id'], 'effect': ['id', 'seconds', 'amplifier'],
|
||||
'xp': ['levels'], 'options': ['page'], 'lan': ['action'], 'world_backup': ['folder'],
|
||||
'world_rename': ['folder', 'name'], 'world_delete': ['folder'],
|
||||
}
|
||||
# Booleans the game takes as bare words.
|
||||
BARE = {'click_slot': ['right', 'shift'], 'click': ['right', 'middle', 'shift']}
|
||||
BARE = {'click_slot': ['right', 'shift'], 'click': ['right', 'middle', 'shift'], 'summon': ['baby']}
|
||||
|
||||
|
||||
class Failed(Exception):
|
||||
@@ -567,17 +572,123 @@ def suggestions_scenario(d, out):
|
||||
return {'completions': len(cases)}
|
||||
|
||||
|
||||
def manage_scenario(d, out):
|
||||
# The commands that close the audit's gaps: status (F3), entities by right-click, effects and xp,
|
||||
# LAN status, options pages by name, then from the title screen the worlds list, Make Backup,
|
||||
# rename and a delete that needs the player's confirmation.
|
||||
d('open', world='Manage', seed=7)
|
||||
d('wait_for', condition='loaded', timeout=400)
|
||||
assert d('status')['player'] is not None
|
||||
d('give', item='bucket')
|
||||
d('select', slot='bucket')
|
||||
pos = d('state')['player']['position']
|
||||
cow = d('summon', kind='cow', x=pos[0], y=pos[1], z=pos[2] + 2)['id']
|
||||
d('wait', ticks=5)
|
||||
d('interact_entity', id=cow)
|
||||
items = {s.get('item') for s in d('inventory')['hotbar'] if s}
|
||||
assert 'minecraft:milk_bucket' in items, items
|
||||
d('effect', id='speed', seconds=30, amplifier=1)
|
||||
effects = json.dumps(d('effects'))
|
||||
assert 'speed' in effects, effects
|
||||
d('xp', levels=5)
|
||||
assert d('lan', action='status') is not None
|
||||
d('options', page='horizon')
|
||||
assert 'graphics' in widgets(d), widgets(d).keys()
|
||||
d('close')
|
||||
d('pause')
|
||||
d('click', id='save-and-quit-to-title')
|
||||
d('wait_for', condition='screen', value='title', timeout=60)
|
||||
worlds = {w['folder']: w for w in d('worlds')['worlds']}
|
||||
assert worlds['Manage']['mode'] == 'survival', worlds
|
||||
assert d('world_backup', folder='Manage')['bytes'] > 0
|
||||
assert (d.cwd / 'backups').is_dir() and any((d.cwd / 'backups').glob('*_Manage.zip'))
|
||||
d('world_rename', folder='Manage', name='Renamed Base')
|
||||
assert {w['folder']: w for w in d('worlds')['worlds']}['Manage']['name'] == 'Renamed Base'
|
||||
try:
|
||||
d('world_delete', folder='Manage')
|
||||
raise AssertionError('deleted without --confirm')
|
||||
except Failed as e:
|
||||
assert 'confirm' in str(e), e
|
||||
assert (d.cwd / 'saves/Manage').is_dir()
|
||||
d('world_delete', folder='Manage', confirm=True)
|
||||
assert not (d.cwd / 'saves/Manage').exists()
|
||||
assert 'Manage' not in {w['folder'] for w in d('worlds')['worlds']}
|
||||
assert d('servers')['servers'] == []
|
||||
return {'worlds': len(worlds)}
|
||||
|
||||
|
||||
SCENARIOS = {'survival': scenario, 'ui': ui_scenario, 'options': options_scenario, 'events': events_scenario,
|
||||
'consume': consume_scenario, 'pick': pick_scenario, 'suggestions': suggestions_scenario}
|
||||
'consume': consume_scenario, 'pick': pick_scenario, 'suggestions': suggestions_scenario,
|
||||
'manage': manage_scenario}
|
||||
|
||||
|
||||
def run(mode, ctl, out, resources, which='survival'):
|
||||
REFUSAL = "AI features are off: turn off No AI in the launcher's settings"
|
||||
|
||||
|
||||
def scratch(mode, resources):
|
||||
"""A scratch game home with the prepared resources, and the game's environment for it."""
|
||||
work = Path(tempfile.mkdtemp(prefix=f'hzac-{mode}-'))
|
||||
(work / '.local').mkdir()
|
||||
(work / '.local/horizon-client').mkdir()
|
||||
os.symlink(resources, work / '.local/horizon-client' / lab.VERSION)
|
||||
# horizon-ctl reads No AI in the game home: this one, not the player's.
|
||||
env = dict(game_env(os.environ.get('VSCREEN', 'agent-control')), HORIZON_HOME=str(work))
|
||||
return work, env
|
||||
|
||||
|
||||
def no_ai_check(mode, ctl, resources):
|
||||
"""No AI on: horizon-ctl refuses (exit 4, kind denied), so does its MCP server, and the game
|
||||
started with --control-socket never opens it."""
|
||||
work, env = scratch(mode, resources)
|
||||
socket = work / 'c.sock'
|
||||
try:
|
||||
(work / 'horizon-extras.txt').write_text('fps:true\nnoAi:true\n')
|
||||
if mode == 'cli':
|
||||
for words in (['state'], ['launch', '--world', 'NoAi'], ['logs']):
|
||||
r = subprocess.run([str(ctl), '--socket', str(socket), *words], cwd=work, env=env,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
reply = json.loads(r.stdout.strip().splitlines()[-1])
|
||||
log(mode, words, reply)
|
||||
assert r.returncode == 4 and reply == {'error': REFUSAL, 'kind': 'denied'}, (r.returncode, reply)
|
||||
client = ctl.with_name('horizon-client')
|
||||
args = [str(client), '--control-socket', str(socket), '--world', 'NoAi', '--world-frames', '30']
|
||||
if sys.platform == 'darwin':
|
||||
args.append('--offscreen')
|
||||
r = subprocess.run(args, cwd=work, env=env, capture_output=True, text=True, timeout=600)
|
||||
text = r.stdout + r.stderr + ''.join(
|
||||
p.read_text(errors='replace') for p in (work / 'logs').glob('latest.log'))
|
||||
assert r.returncode == 0, f'game exit {r.returncode}: {text[-2000:]}'
|
||||
assert 'control socket not opened' in text and REFUSAL in text, text[-2000:]
|
||||
assert not socket.exists(), 'the game opened its control socket under No AI'
|
||||
else:
|
||||
r = subprocess.run([str(ctl), '--socket', str(socket), 'mcp'], cwd=work, env=env, input='',
|
||||
capture_output=True, text=True, timeout=60)
|
||||
log(mode, ['mcp'], {'exit': r.returncode, 'stderr': r.stderr.strip()})
|
||||
assert r.returncode == 4 and r.stderr.strip() == f'horizon-ctl mcp: {REFUSAL}', r
|
||||
# Turned on during a session: the next call refuses.
|
||||
(work / 'horizon-extras.txt').write_text('noAi:false\n')
|
||||
m = Mcp(ctl, socket, work, env)
|
||||
(work / 'horizon-extras.txt').write_text('noAi:true\n')
|
||||
try:
|
||||
m('state')
|
||||
raise AssertionError('state ran under No AI')
|
||||
except Failed as e:
|
||||
assert REFUSAL in str(e) and 'denied' in str(e), e
|
||||
m.close()
|
||||
print(f'[{mode}] PASS: no-ai')
|
||||
return True
|
||||
except (Failed, AssertionError, json.JSONDecodeError, IndexError, subprocess.TimeoutExpired) as e:
|
||||
print(f'[{mode}] FAIL: no-ai: {e!r}')
|
||||
return False
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
def run(mode, ctl, out, resources, which='survival'):
|
||||
if which == 'no-ai':
|
||||
return no_ai_check(mode, ctl, resources)
|
||||
work, env = scratch(mode, resources)
|
||||
socket = work / 'c.sock'
|
||||
env = game_env(os.environ.get('VSCREEN', 'agent-control'))
|
||||
driver = (Cli if mode == 'cli' else Mcp)(ctl, socket, work, env)
|
||||
started = time.time()
|
||||
try:
|
||||
@@ -604,7 +715,7 @@ def run(mode, ctl, out, resources, which='survival'):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--mode', choices=['cli', 'mcp', 'both'], default='both')
|
||||
parser.add_argument('--scenario', choices=[*SCENARIOS, 'all'], default='all')
|
||||
parser.add_argument('--scenario', choices=[*SCENARIOS, 'no-ai', 'all'], default='all')
|
||||
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
|
||||
parser.add_argument('--out', default=str(ROOT / '.local/agent-control-e2e'), help='screenshots go here')
|
||||
args = parser.parse_args()
|
||||
@@ -622,7 +733,7 @@ def main():
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
modes = ['cli', 'mcp'] if args.mode == 'both' else [args.mode]
|
||||
scenarios = list(SCENARIOS) if args.scenario == 'all' else [args.scenario]
|
||||
scenarios = [*SCENARIOS, 'no-ai'] if args.scenario == 'all' else [args.scenario]
|
||||
results = {f'{m}/{s}': run(m, bins / 'horizon-ctl', out, resources, s) for m in modes for s in scenarios}
|
||||
print(json.dumps(results))
|
||||
sys.exit(0 if all(results.values()) else 1)
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
|
||||
A scratch launcher home gets one "installed" version made of this checkout's builds
|
||||
(horizon-client and horizon-ctl) and the prepared resources in .local/horizon-client. An MCP
|
||||
client then talks to `horizon-launcher mcp` alone: the game tools are listed, a call before the
|
||||
game runs says to launch it, launch_client starts it under control, and game_wait_ready,
|
||||
game_state, game_ui and game_screenshot play it until game_quit.
|
||||
client then talks to `horizon-launcher mcp` alone: every CLI command is listed as a tool of the same
|
||||
name (versions, settings, mods_search...), a destructive one needs confirm, the game tools are
|
||||
listed, a call before the game runs says to start it, play starts it under control, and
|
||||
game_wait_ready, game_state, game_ui and game_screenshot play it until game_quit. Then No AI is turned on with
|
||||
`horizon-launcher no-ai on`: `horizon-launcher mcp` refuses to start, and a session already running
|
||||
refuses its next call.
|
||||
|
||||
python3 tools/launcher_mcp_e2e.py --bin-dir target/fast --launcher launcher/target/debug/horizon-launcher
|
||||
|
||||
@@ -68,14 +71,27 @@ def scenario(m, out):
|
||||
'clientInfo': {'name': 'launcher_mcp_e2e', 'version': '1'}})
|
||||
assert init['serverInfo']['name'] == 'horizon-launcher', init
|
||||
tools = {t['name'] for t in m.rpc('tools/list', {})['tools']}
|
||||
for name in ['launch_client', 'game_state', 'game_ui', 'game_click', 'game_screenshot']:
|
||||
for name in ['play', 'server', 'versions', 'settings', 'mods_search', 'modpack_list', 'servers_list',
|
||||
'data_status', 'account_list', 'game_args', 'game_state', 'game_ui', 'game_click',
|
||||
'game_screenshot']:
|
||||
assert name in tools, f'{name} missing from {sorted(tools)}'
|
||||
assert 'game_launch' not in tools, 'launch_client covers horizon-ctl launch'
|
||||
for name in ['game_launch', 'mcp', 'no_ai', 'agent_config']:
|
||||
assert name not in tools, f'{name} is a tool'
|
||||
|
||||
# Generated from the CLI: the same command, its JSON output.
|
||||
result, texts, _ = m.call('versions', installed=True)
|
||||
assert json.loads(texts[-1])['installed'] == [TAG], texts
|
||||
result, texts, _ = m.call('settings', key='opening-animation', value='off')
|
||||
assert json.loads(texts[-1])['opening_animation'] is False, texts
|
||||
result, texts, _ = m.call('game_args')
|
||||
assert 'game_args' in json.loads(texts[-1]), texts
|
||||
result, texts, _ = m.call('remove', tag=TAG)
|
||||
assert result['isError'] and 'confirm' in texts[-1], texts
|
||||
|
||||
result, texts, _ = m.call('game_state')
|
||||
assert result['isError'] and 'launch_client' in texts[-1], texts
|
||||
assert result['isError'] and 'with play' in texts[-1], texts
|
||||
|
||||
result, texts, _ = m.call('launch_client', world='Agent', creative=True,
|
||||
result, texts, _ = m.call('play', world='Agent', creative=True,
|
||||
offscreen=sys.platform == 'darwin')
|
||||
assert not result['isError'], texts
|
||||
result, texts, _ = m.call('game_wait_ready', seconds=300)
|
||||
@@ -96,6 +112,42 @@ def scenario(m, out):
|
||||
return {'tools': len(tools)}
|
||||
|
||||
|
||||
REFUSAL = "AI features are off: turn off No AI in the launcher's settings"
|
||||
|
||||
|
||||
def no_ai_scenario(launcher, env, home):
|
||||
run = lambda *a: subprocess.run([str(launcher), *a], env=env, input='', capture_output=True, text=True,
|
||||
timeout=60)
|
||||
assert json.loads(run('no-ai', '--json').stdout) == {'no_ai': False}
|
||||
config = json.loads(run('agent-config', '--json').stdout)
|
||||
assert config['server']['args'] == ['mcp'] and len(config['clients']) == 4, config
|
||||
assert 'agent-config' in run('--help').stdout
|
||||
test = json.loads(run('agent-config', '--test', '--json').stdout)
|
||||
assert test['answered'] and test['tools'] > 100, test
|
||||
m = Mcp(launcher, env)
|
||||
m.rpc('initialize', {'protocolVersion': '2025-06-18', 'capabilities': {},
|
||||
'clientInfo': {'name': 'launcher_mcp_e2e', 'version': '1'}})
|
||||
r = run('no-ai', 'on')
|
||||
assert r.returncode == 0, r
|
||||
assert 'noAi:true' in (home / 'horizon-extras.txt').read_text()
|
||||
try:
|
||||
m.call('links')
|
||||
raise AssertionError('links ran under No AI')
|
||||
except Failed as e:
|
||||
assert REFUSAL in str(e), e
|
||||
m.close()
|
||||
for command in ('mcp', 'agent-config'):
|
||||
r = run(command)
|
||||
print(f'[launcher-mcp] {command} under No AI -> exit {r.returncode}: {r.stderr.strip()}')
|
||||
assert r.returncode == 1 and r.stderr.strip() == f'error: {REFUSAL}', r
|
||||
assert 'agent-config' not in run('--help').stdout and ' mcp ' not in run('--help').stdout
|
||||
# What the launcher needs keeps working.
|
||||
assert run('versions', '--installed', '--json').returncode == 0
|
||||
assert run('no-ai', 'off').returncode == 0
|
||||
assert json.loads(run('no-ai', '--json').stdout) == {'no_ai': False}
|
||||
return {'no_ai': 'refused'}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--bin-dir', required=True, help='horizon-client and horizon-ctl built from this checkout')
|
||||
@@ -120,6 +172,7 @@ def main():
|
||||
m = Mcp(Path(args.launcher).resolve(), env)
|
||||
try:
|
||||
summary = scenario(m, out)
|
||||
summary.update(no_ai_scenario(Path(args.launcher).resolve(), env, work / 'home'))
|
||||
print(f'[launcher-mcp] PASS: {summary}')
|
||||
ok = True
|
||||
except (Failed, AssertionError, KeyError, json.JSONDecodeError) as e:
|
||||
|
||||
+18
-3
@@ -307,7 +307,12 @@ def poll(control, check, limit, interval=0.05):
|
||||
QUIET_MESHES_PER_S = 100
|
||||
|
||||
|
||||
def wait_loaded(control, near, limit):
|
||||
def view_chunks(distance):
|
||||
"""80% of the view circle (pi x RD^2): Horizon keeps 637 columns at RD 12 and 3725 at RD 32."""
|
||||
return int(0.8 * math.pi * distance ** 2)
|
||||
|
||||
|
||||
def wait_loaded(control, near, limit, min_chunks=0):
|
||||
"""Waits until the player is spawned (near(position)), nothing is queued, the
|
||||
resident and generated chunk counts have not changed for a second, as the
|
||||
vanilla driver does, and the last sections are drawn; returns when the last
|
||||
@@ -320,6 +325,10 @@ def wait_loaded(control, near, limit):
|
||||
already taken, and sections waiting for their neighbours' light.)"""
|
||||
deadline = time.time() + limit
|
||||
last, changed, previous, meshing = None, time.time(), None, time.time()
|
||||
# Right after a teleport (and at spawn) Horizon reports nothing queued and steady counts for over a second
|
||||
# before it starts loading: the counts must change once, or stay full for 5 s, and the resident chunks reach
|
||||
# min_chunks, before "steady" means loaded.
|
||||
first, moved, began = None, False, time.time()
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
s = control("status", timeout=10)
|
||||
@@ -331,6 +340,12 @@ def wait_loaded(control, near, limit):
|
||||
last, changed, previous, meshing = None, now, None, now
|
||||
else:
|
||||
counts = (s["chunks"], s["generated_chunks"])
|
||||
first = first or counts
|
||||
moved = moved or counts != first or (s["chunks"] >= min_chunks > 0 and now - began > 5)
|
||||
if min_chunks and (not moved or s["chunks"] < min_chunks):
|
||||
last, changed, previous, meshing = None, now, None, now
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
if counts != last:
|
||||
last, changed = counts, now
|
||||
stages = s.get("streaming")
|
||||
@@ -393,7 +408,7 @@ def run_horizon(args, distance):
|
||||
result["renderer"] = title["renderer"]
|
||||
result["startup"] = {"title_s": at - started}
|
||||
control(f"open {WORLD} {SEED} creative")
|
||||
result["startup"]["world_s"] = wait_loaded(control, lambda p: True, args.timeout) - started
|
||||
result["startup"]["world_s"] = wait_loaded(control, lambda p: True, args.timeout, view_chunks(distance)) - started
|
||||
for name, start, end, pitch in scene_list(args):
|
||||
control("time 6000")
|
||||
meshed = control("status")["meshed_sections"]
|
||||
@@ -403,7 +418,7 @@ def run_horizon(args, distance):
|
||||
control("fly {0} {1} {2} {0} {1} {2} 0.1 {3}".format(*start, pitch))
|
||||
control(f"look {yaw(start, end):.3f} {pitch}")
|
||||
near = lambda p: sum((a - b) ** 2 for a, b in zip(p, start)) < 4.0
|
||||
scene = {"load_s": wait_loaded(control, near, args.timeout) - tp}
|
||||
scene = {"load_s": wait_loaded(control, near, args.timeout, view_chunks(distance)) - tp}
|
||||
# Section meshes built while loading and while flying (Horizon only).
|
||||
loaded_meshes = control("status")["meshed_sections"]
|
||||
scene["meshes_load"] = loaded_meshes - meshed
|
||||
|
||||
+2
-2
@@ -238,8 +238,8 @@ const siteFooter = ` <footer class="footer">
|
||||
|
||||
const fixed = (x, n) => Number(x).toFixed(n);
|
||||
const specTable =
|
||||
`<table class="bench-table spec"><thead><tr><th scope="col">Graphics card</th><th scope="col">FP32 TFLOPS</th><th scope="col">Bandwidth, GB/s</th></tr></thead><tbody>` +
|
||||
GPUS.map((g) => `<tr><th scope="row">${escape(g.name)}</th><td class="num">${fixed(g.tflops, 1)}</td><td class="num">${g.gbps}</td></tr>`).join("") +
|
||||
`<table class="bench-table spec"><thead><tr><th scope="col">Graphics card</th><th scope="col">FP32 TFLOPS</th><th scope="col">Bandwidth, GB/s</th><th scope="col">Memory, GB</th></tr></thead><tbody>` +
|
||||
GPUS.map((g) => `<tr><th scope="row">${escape(g.name)}</th><td class="num">${fixed(g.tflops, 1)}</td><td class="num">${g.gbps}</td><td class="num">${g.vram_gb || "shared"}</td></tr>`).join("") +
|
||||
`</tbody></table><table class="bench-table spec"><thead><tr><th scope="col">Processor</th><th scope="col">Boost, GHz</th><th scope="col">Generation factor</th><th scope="col">Single-thread score</th></tr></thead><tbody>` +
|
||||
CPUS.map((c) => `<tr><th scope="row">${escape(c.name)}</th><td class="num">${fixed(c.ghz, 2)}</td><td class="num">${fixed(c.ipc, 2)}</td><td class="num">${fixed(singleThread(c), 2)}</td></tr>`).join("") +
|
||||
`</tbody></table>`;
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-23T00:36:08Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "0.1.0",
|
||||
"commit": "07e71797"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 17.934,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 10795.933,
|
||||
"stdev": 13944.902,
|
||||
"min": 2694.75,
|
||||
"max": 26898.014,
|
||||
"ci95": 34643.852
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 17972.549,
|
||||
"stdev": 13965.287,
|
||||
"min": 6319.925,
|
||||
"max": 33452.436,
|
||||
"ci95": 34694.496
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1260.016,
|
||||
"stdev": 503.052,
|
||||
"min": 679.248,
|
||||
"max": 1560.056,
|
||||
"ci95": 1249.753
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 9182.667,
|
||||
"stdev": 24.705,
|
||||
"min": 9160.0,
|
||||
"max": 9209.0,
|
||||
"ci95": 61.375
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2593.701,
|
||||
"stdev": 266.92,
|
||||
"min": 2285.61,
|
||||
"max": 2755.256,
|
||||
"ci95": 663.12
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 253.998,
|
||||
"stdev": 45.489,
|
||||
"min": 203.519,
|
||||
"max": 291.815,
|
||||
"ci95": 113.011
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 129.195,
|
||||
"stdev": 39.949,
|
||||
"min": 99.233,
|
||||
"max": 174.551,
|
||||
"ci95": 99.247
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 982.0,
|
||||
"stdev": 0.0,
|
||||
"min": 982.0,
|
||||
"max": 982.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1591.886,
|
||||
"stdev": 579.89,
|
||||
"min": 950.688,
|
||||
"max": 2079.581,
|
||||
"ci95": 1440.643
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 10278.667,
|
||||
"stdev": 9.292,
|
||||
"min": 10271.0,
|
||||
"max": 10289.0,
|
||||
"ci95": 23.083
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 3426.658,
|
||||
"stdev": 200.328,
|
||||
"min": 3202.184,
|
||||
"max": 3587.268,
|
||||
"ci95": 497.683
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 327.656,
|
||||
"stdev": 39.232,
|
||||
"min": 283.548,
|
||||
"max": 358.656,
|
||||
"ci95": 97.466
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 141.195,
|
||||
"stdev": 12.058,
|
||||
"min": 127.735,
|
||||
"max": 151.011,
|
||||
"ci95": 29.956
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1323.333,
|
||||
"stdev": 295.603,
|
||||
"min": 982.0,
|
||||
"max": 1494.0,
|
||||
"ci95": 734.379
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2659.926,
|
||||
"stdev": 2159.489,
|
||||
"min": 1288.28,
|
||||
"max": 5149.172,
|
||||
"ci95": 5364.902
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 9016.0,
|
||||
"stdev": 517.249,
|
||||
"min": 8623.0,
|
||||
"max": 9602.0,
|
||||
"ci95": 1285.023
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 3277.475,
|
||||
"stdev": 71.523,
|
||||
"min": 3210.907,
|
||||
"max": 3353.092,
|
||||
"ci95": 177.688
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 387.862,
|
||||
"stdev": 43.664,
|
||||
"min": 341.534,
|
||||
"max": 428.255,
|
||||
"ci95": 108.476
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 173.188,
|
||||
"stdev": 61.528,
|
||||
"min": 104.34,
|
||||
"max": 222.8,
|
||||
"ci95": 152.857
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1238.0,
|
||||
"stdev": 0.0,
|
||||
"min": 1238.0,
|
||||
"max": 1238.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 1675.173,
|
||||
"stdev": 449.734,
|
||||
"min": 1361.858,
|
||||
"max": 2190.49,
|
||||
"ci95": 1117.293
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 10546.0,
|
||||
"stdev": 34.699,
|
||||
"min": 10516.0,
|
||||
"max": 10584.0,
|
||||
"ci95": 86.203
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2672.531,
|
||||
"stdev": 87.428,
|
||||
"min": 2572.733,
|
||||
"max": 2735.621,
|
||||
"ci95": 217.202
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 275.049,
|
||||
"stdev": 9.403,
|
||||
"min": 264.82,
|
||||
"max": 283.318,
|
||||
"ci95": 23.361
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 123.382,
|
||||
"stdev": 2.627,
|
||||
"min": 121.404,
|
||||
"max": 126.363,
|
||||
"ci95": 6.527
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1238.0,
|
||||
"stdev": 0.0,
|
||||
"min": 1238.0,
|
||||
"max": 1238.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1414.479,
|
||||
"stdev": 24.614,
|
||||
"min": 1386.086,
|
||||
"max": 1429.77,
|
||||
"ci95": 61.148
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1202.283,
|
||||
"stdev": 33.096,
|
||||
"min": 1169.059,
|
||||
"max": 1235.25,
|
||||
"ci95": 82.223
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 33063,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2755.256,
|
||||
"low_1pct_fps": 266.659,
|
||||
"low_01pct_fps": 99.233,
|
||||
"p50_ms": 0.306,
|
||||
"p95_ms": 0.459,
|
||||
"p99_ms": 1.268,
|
||||
"p999_ms": 4.771,
|
||||
"max_ms": 124.422,
|
||||
"cpu_p50_ms": 0.271,
|
||||
"gpu_p50_ms": 0.065,
|
||||
"histogram": [
|
||||
32542,
|
||||
268,
|
||||
30,
|
||||
153,
|
||||
44,
|
||||
15,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 27428,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2285.61,
|
||||
"low_1pct_fps": 203.519,
|
||||
"low_01pct_fps": 113.802,
|
||||
"p50_ms": 0.336,
|
||||
"p95_ms": 0.715,
|
||||
"p99_ms": 2.315,
|
||||
"p999_ms": 7.476,
|
||||
"max_ms": 13.28,
|
||||
"cpu_p50_ms": 0.299,
|
||||
"gpu_p50_ms": 0.066,
|
||||
"histogram": [
|
||||
26734,
|
||||
400,
|
||||
37,
|
||||
94,
|
||||
67,
|
||||
35,
|
||||
23,
|
||||
24,
|
||||
10,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 32883,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2740.237,
|
||||
"low_1pct_fps": 291.815,
|
||||
"low_01pct_fps": 174.551,
|
||||
"p50_ms": 0.31,
|
||||
"p95_ms": 0.437,
|
||||
"p99_ms": 1.399,
|
||||
"p999_ms": 5.007,
|
||||
"max_ms": 9.523,
|
||||
"cpu_p50_ms": 0.275,
|
||||
"gpu_p50_ms": 0.066,
|
||||
"histogram": [
|
||||
32316,
|
||||
314,
|
||||
13,
|
||||
161,
|
||||
46,
|
||||
25,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 41887,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3490.521,
|
||||
"low_1pct_fps": 340.763,
|
||||
"low_01pct_fps": 144.838,
|
||||
"p50_ms": 0.242,
|
||||
"p95_ms": 0.38,
|
||||
"p99_ms": 1.172,
|
||||
"p999_ms": 5.439,
|
||||
"max_ms": 9.908,
|
||||
"cpu_p50_ms": 0.21,
|
||||
"gpu_p50_ms": 0.084,
|
||||
"histogram": [
|
||||
41255,
|
||||
377,
|
||||
76,
|
||||
110,
|
||||
21,
|
||||
16,
|
||||
17,
|
||||
7,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 38443,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 3202.184,
|
||||
"low_1pct_fps": 283.548,
|
||||
"low_01pct_fps": 127.735,
|
||||
"p50_ms": 0.248,
|
||||
"p95_ms": 0.49,
|
||||
"p99_ms": 1.533,
|
||||
"p999_ms": 6.44,
|
||||
"max_ms": 13.381,
|
||||
"cpu_p50_ms": 0.216,
|
||||
"gpu_p50_ms": 0.084,
|
||||
"histogram": [
|
||||
37784,
|
||||
375,
|
||||
85,
|
||||
95,
|
||||
33,
|
||||
26,
|
||||
19,
|
||||
14,
|
||||
10,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 43048,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3587.268,
|
||||
"low_1pct_fps": 358.656,
|
||||
"low_01pct_fps": 151.011,
|
||||
"p50_ms": 0.241,
|
||||
"p95_ms": 0.318,
|
||||
"p99_ms": 1.122,
|
||||
"p999_ms": 4.817,
|
||||
"max_ms": 10.326,
|
||||
"cpu_p50_ms": 0.209,
|
||||
"gpu_p50_ms": 0.084,
|
||||
"histogram": [
|
||||
42442,
|
||||
354,
|
||||
87,
|
||||
95,
|
||||
31,
|
||||
14,
|
||||
10,
|
||||
8,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 38531,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3210.907,
|
||||
"low_1pct_fps": 341.534,
|
||||
"low_01pct_fps": 104.34,
|
||||
"p50_ms": 0.265,
|
||||
"p95_ms": 0.44,
|
||||
"p99_ms": 1.171,
|
||||
"p999_ms": 4.04,
|
||||
"max_ms": 169.171,
|
||||
"cpu_p50_ms": 0.232,
|
||||
"gpu_p50_ms": 0.082,
|
||||
"histogram": [
|
||||
37966,
|
||||
321,
|
||||
156,
|
||||
54,
|
||||
16,
|
||||
9,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 40238,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3353.092,
|
||||
"low_1pct_fps": 428.255,
|
||||
"low_01pct_fps": 222.8,
|
||||
"p50_ms": 0.262,
|
||||
"p95_ms": 0.385,
|
||||
"p99_ms": 1.199,
|
||||
"p999_ms": 3.403,
|
||||
"max_ms": 9.156,
|
||||
"cpu_p50_ms": 0.23,
|
||||
"gpu_p50_ms": 0.081,
|
||||
"histogram": [
|
||||
39659,
|
||||
325,
|
||||
186,
|
||||
49,
|
||||
11,
|
||||
3,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 39222,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3268.426,
|
||||
"low_1pct_fps": 393.798,
|
||||
"low_01pct_fps": 192.424,
|
||||
"p50_ms": 0.265,
|
||||
"p95_ms": 0.415,
|
||||
"p99_ms": 1.317,
|
||||
"p999_ms": 3.623,
|
||||
"max_ms": 10.651,
|
||||
"cpu_p50_ms": 0.232,
|
||||
"gpu_p50_ms": 0.081,
|
||||
"histogram": [
|
||||
38610,
|
||||
357,
|
||||
169,
|
||||
59,
|
||||
12,
|
||||
7,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 30873,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2572.733,
|
||||
"low_1pct_fps": 264.82,
|
||||
"low_01pct_fps": 122.378,
|
||||
"p50_ms": 0.326,
|
||||
"p95_ms": 0.605,
|
||||
"p99_ms": 1.424,
|
||||
"p999_ms": 6.393,
|
||||
"max_ms": 14.698,
|
||||
"cpu_p50_ms": 0.291,
|
||||
"gpu_p50_ms": 0.082,
|
||||
"histogram": [
|
||||
30329,
|
||||
300,
|
||||
11,
|
||||
155,
|
||||
37,
|
||||
10,
|
||||
10,
|
||||
13,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 32828,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2735.621,
|
||||
"low_1pct_fps": 283.318,
|
||||
"low_01pct_fps": 121.404,
|
||||
"p50_ms": 0.32,
|
||||
"p95_ms": 0.496,
|
||||
"p99_ms": 1.264,
|
||||
"p999_ms": 5.842,
|
||||
"max_ms": 17.335,
|
||||
"cpu_p50_ms": 0.286,
|
||||
"gpu_p50_ms": 0.082,
|
||||
"histogram": [
|
||||
32256,
|
||||
320,
|
||||
50,
|
||||
130,
|
||||
27,
|
||||
15,
|
||||
9,
|
||||
7,
|
||||
9,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 32511,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2709.239,
|
||||
"low_1pct_fps": 277.009,
|
||||
"low_01pct_fps": 126.363,
|
||||
"p50_ms": 0.319,
|
||||
"p95_ms": 0.536,
|
||||
"p99_ms": 1.362,
|
||||
"p999_ms": 5.537,
|
||||
"max_ms": 15.451,
|
||||
"cpu_p50_ms": 0.285,
|
||||
"gpu_p50_ms": 0.082,
|
||||
"histogram": [
|
||||
31962,
|
||||
290,
|
||||
26,
|
||||
173,
|
||||
18,
|
||||
16,
|
||||
10,
|
||||
6,
|
||||
4,
|
||||
4,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-desktop"
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-23T00:43:34Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "0.1.0",
|
||||
"commit": "07e71797"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 22.354,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 3223.157,
|
||||
"stdev": 1895.181,
|
||||
"min": 1770.597,
|
||||
"max": 5366.923,
|
||||
"ci95": 4708.272
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 8999.144,
|
||||
"stdev": 1593.439,
|
||||
"min": 7467.122,
|
||||
"max": 10647.611,
|
||||
"ci95": 3958.641
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2702.493,
|
||||
"stdev": 332.199,
|
||||
"min": 2494.684,
|
||||
"max": 3085.624,
|
||||
"ci95": 825.294
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 42899.667,
|
||||
"stdev": 866.726,
|
||||
"min": 41974.0,
|
||||
"max": 43692.0,
|
||||
"ci95": 2153.241
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 685.508,
|
||||
"stdev": 151.759,
|
||||
"min": 512.479,
|
||||
"max": 796.037,
|
||||
"ci95": 377.022
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 93.031,
|
||||
"stdev": 31.096,
|
||||
"min": 57.146,
|
||||
"max": 112.055,
|
||||
"ci95": 77.253
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 60.993,
|
||||
"stdev": 17.294,
|
||||
"min": 41.024,
|
||||
"max": 71.142,
|
||||
"ci95": 42.965
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2390.0,
|
||||
"stdev": 0.0,
|
||||
"min": 2390.0,
|
||||
"max": 2390.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 4823.42,
|
||||
"stdev": 395.462,
|
||||
"min": 4545.561,
|
||||
"max": 5276.174,
|
||||
"ci95": 982.461
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 69214.333,
|
||||
"stdev": 243.052,
|
||||
"min": 68957.0,
|
||||
"max": 69440.0,
|
||||
"ci95": 603.824
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 590.362,
|
||||
"stdev": 195.262,
|
||||
"min": 373.61,
|
||||
"max": 752.506,
|
||||
"ci95": 485.096
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 75.679,
|
||||
"stdev": 34.507,
|
||||
"min": 41.158,
|
||||
"max": 110.171,
|
||||
"ci95": 85.726
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 41.943,
|
||||
"stdev": 22.598,
|
||||
"min": 22.254,
|
||||
"max": 66.617,
|
||||
"ci95": 56.141
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2646.0,
|
||||
"stdev": 0.0,
|
||||
"min": 2646.0,
|
||||
"max": 2646.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3628.996,
|
||||
"stdev": 953.676,
|
||||
"min": 3030.638,
|
||||
"max": 4728.784,
|
||||
"ci95": 2369.254
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 66732.667,
|
||||
"stdev": 470.761,
|
||||
"min": 66379.0,
|
||||
"max": 67267.0,
|
||||
"ci95": 1169.531
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 867.7,
|
||||
"stdev": 24.924,
|
||||
"min": 840.039,
|
||||
"max": 888.411,
|
||||
"ci95": 61.919
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 113.015,
|
||||
"stdev": 5.586,
|
||||
"min": 106.569,
|
||||
"max": 116.454,
|
||||
"ci95": 13.878
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 64.662,
|
||||
"stdev": 4.205,
|
||||
"min": 60.245,
|
||||
"max": 68.618,
|
||||
"ci95": 10.448
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2646.0,
|
||||
"stdev": 0.0,
|
||||
"min": 2646.0,
|
||||
"max": 2646.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 7552.085,
|
||||
"stdev": 1200.747,
|
||||
"min": 6352.523,
|
||||
"max": 8754.014,
|
||||
"ci95": 2983.062
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 75906.0,
|
||||
"stdev": 159.69,
|
||||
"min": 75725.0,
|
||||
"max": 76027.0,
|
||||
"ci95": 396.725
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 544.171,
|
||||
"stdev": 109.268,
|
||||
"min": 419.998,
|
||||
"max": 625.628,
|
||||
"ci95": 271.458
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 73.334,
|
||||
"stdev": 15.459,
|
||||
"min": 56.192,
|
||||
"max": 86.217,
|
||||
"ci95": 38.405
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 44.998,
|
||||
"stdev": 6.524,
|
||||
"min": 37.602,
|
||||
"max": 49.935,
|
||||
"ci95": 16.207
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 3243.333,
|
||||
"stdev": 147.802,
|
||||
"min": 3158.0,
|
||||
"max": 3414.0,
|
||||
"ci95": 367.189
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1956.853,
|
||||
"stdev": 83.778,
|
||||
"min": 1868.5,
|
||||
"max": 2035.148,
|
||||
"ci95": 208.133
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1646.435,
|
||||
"stdev": 30.998,
|
||||
"min": 1617.857,
|
||||
"max": 1679.389,
|
||||
"ci95": 77.01
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 9552,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 796.037,
|
||||
"low_1pct_fps": 112.055,
|
||||
"low_01pct_fps": 70.812,
|
||||
"p50_ms": 0.975,
|
||||
"p95_ms": 2.218,
|
||||
"p99_ms": 6.816,
|
||||
"p999_ms": 12.22,
|
||||
"max_ms": 17.3,
|
||||
"cpu_p50_ms": 0.933,
|
||||
"gpu_p50_ms": 0.247,
|
||||
"histogram": [
|
||||
5935,
|
||||
2999,
|
||||
296,
|
||||
57,
|
||||
16,
|
||||
93,
|
||||
68,
|
||||
44,
|
||||
23,
|
||||
11,
|
||||
6,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 6150,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 512.479,
|
||||
"low_1pct_fps": 57.146,
|
||||
"low_01pct_fps": 41.024,
|
||||
"p50_ms": 1.162,
|
||||
"p95_ms": 5.201,
|
||||
"p99_ms": 14.081,
|
||||
"p999_ms": 21.346,
|
||||
"max_ms": 34.138,
|
||||
"cpu_p50_ms": 1.106,
|
||||
"gpu_p50_ms": 0.264,
|
||||
"histogram": [
|
||||
2098,
|
||||
2525,
|
||||
744,
|
||||
369,
|
||||
87,
|
||||
86,
|
||||
41,
|
||||
41,
|
||||
46,
|
||||
29,
|
||||
21,
|
||||
33,
|
||||
20,
|
||||
8,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 8981,
|
||||
"seconds": 12.007,
|
||||
"avg_fps": 748.007,
|
||||
"low_1pct_fps": 109.891,
|
||||
"low_01pct_fps": 71.142,
|
||||
"p50_ms": 1.021,
|
||||
"p95_ms": 2.555,
|
||||
"p99_ms": 7.187,
|
||||
"p999_ms": 12.828,
|
||||
"max_ms": 16.902,
|
||||
"cpu_p50_ms": 0.975,
|
||||
"gpu_p50_ms": 0.263,
|
||||
"histogram": [
|
||||
4071,
|
||||
4237,
|
||||
311,
|
||||
93,
|
||||
10,
|
||||
61,
|
||||
94,
|
||||
55,
|
||||
30,
|
||||
7,
|
||||
9,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 9031,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 752.506,
|
||||
"low_1pct_fps": 110.171,
|
||||
"low_01pct_fps": 66.617,
|
||||
"p50_ms": 1.047,
|
||||
"p95_ms": 2.413,
|
||||
"p99_ms": 6.6,
|
||||
"p999_ms": 13.248,
|
||||
"max_ms": 16.878,
|
||||
"cpu_p50_ms": 1.005,
|
||||
"gpu_p50_ms": 0.398,
|
||||
"histogram": [
|
||||
1109,
|
||||
7289,
|
||||
301,
|
||||
62,
|
||||
69,
|
||||
80,
|
||||
44,
|
||||
36,
|
||||
20,
|
||||
6,
|
||||
8,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 7740,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 644.97,
|
||||
"low_1pct_fps": 75.707,
|
||||
"low_01pct_fps": 36.957,
|
||||
"p50_ms": 1.091,
|
||||
"p95_ms": 3.344,
|
||||
"p99_ms": 8.483,
|
||||
"p999_ms": 23.855,
|
||||
"max_ms": 29.382,
|
||||
"cpu_p50_ms": 1.046,
|
||||
"gpu_p50_ms": 0.4,
|
||||
"histogram": [
|
||||
304,
|
||||
6528,
|
||||
456,
|
||||
139,
|
||||
55,
|
||||
72,
|
||||
51,
|
||||
53,
|
||||
40,
|
||||
13,
|
||||
7,
|
||||
8,
|
||||
1,
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 4484,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 373.61,
|
||||
"low_1pct_fps": 41.158,
|
||||
"low_01pct_fps": 22.254,
|
||||
"p50_ms": 1.526,
|
||||
"p95_ms": 7.517,
|
||||
"p99_ms": 18.1,
|
||||
"p999_ms": 33.482,
|
||||
"max_ms": 58.633,
|
||||
"cpu_p50_ms": 1.445,
|
||||
"gpu_p50_ms": 0.414,
|
||||
"histogram": [
|
||||
4,
|
||||
2841,
|
||||
562,
|
||||
454,
|
||||
169,
|
||||
140,
|
||||
65,
|
||||
63,
|
||||
39,
|
||||
30,
|
||||
29,
|
||||
32,
|
||||
30,
|
||||
14,
|
||||
7,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 10661,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 888.411,
|
||||
"low_1pct_fps": 116.454,
|
||||
"low_01pct_fps": 68.618,
|
||||
"p50_ms": 0.878,
|
||||
"p95_ms": 2.165,
|
||||
"p99_ms": 5.268,
|
||||
"p999_ms": 13.015,
|
||||
"max_ms": 18.852,
|
||||
"cpu_p50_ms": 0.837,
|
||||
"gpu_p50_ms": 0.35,
|
||||
"histogram": [
|
||||
8240,
|
||||
1821,
|
||||
246,
|
||||
135,
|
||||
98,
|
||||
39,
|
||||
25,
|
||||
8,
|
||||
18,
|
||||
11,
|
||||
16,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 10496,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 874.651,
|
||||
"low_1pct_fps": 116.021,
|
||||
"low_01pct_fps": 60.245,
|
||||
"p50_ms": 0.864,
|
||||
"p95_ms": 2.203,
|
||||
"p99_ms": 5.482,
|
||||
"p999_ms": 12.557,
|
||||
"max_ms": 20.239,
|
||||
"cpu_p50_ms": 0.821,
|
||||
"gpu_p50_ms": 0.353,
|
||||
"histogram": [
|
||||
7610,
|
||||
2286,
|
||||
236,
|
||||
124,
|
||||
98,
|
||||
56,
|
||||
21,
|
||||
25,
|
||||
15,
|
||||
13,
|
||||
4,
|
||||
2,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 10080,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 840.039,
|
||||
"low_1pct_fps": 106.569,
|
||||
"low_01pct_fps": 65.123,
|
||||
"p50_ms": 0.889,
|
||||
"p95_ms": 2.576,
|
||||
"p99_ms": 5.746,
|
||||
"p999_ms": 13.975,
|
||||
"max_ms": 17.531,
|
||||
"cpu_p50_ms": 0.845,
|
||||
"gpu_p50_ms": 0.351,
|
||||
"histogram": [
|
||||
7336,
|
||||
1986,
|
||||
372,
|
||||
136,
|
||||
117,
|
||||
44,
|
||||
14,
|
||||
19,
|
||||
21,
|
||||
16,
|
||||
9,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 7043,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 586.888,
|
||||
"low_1pct_fps": 77.592,
|
||||
"low_01pct_fps": 47.456,
|
||||
"p50_ms": 1.203,
|
||||
"p95_ms": 3.879,
|
||||
"p99_ms": 8.496,
|
||||
"p999_ms": 17.959,
|
||||
"max_ms": 23.68,
|
||||
"cpu_p50_ms": 1.159,
|
||||
"gpu_p50_ms": 0.356,
|
||||
"histogram": [
|
||||
0,
|
||||
5932,
|
||||
573,
|
||||
213,
|
||||
48,
|
||||
93,
|
||||
64,
|
||||
45,
|
||||
27,
|
||||
18,
|
||||
6,
|
||||
10,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 7508,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 625.628,
|
||||
"low_1pct_fps": 86.217,
|
||||
"low_01pct_fps": 49.935,
|
||||
"p50_ms": 1.2,
|
||||
"p95_ms": 3.217,
|
||||
"p99_ms": 7.725,
|
||||
"p999_ms": 16.759,
|
||||
"max_ms": 22.613,
|
||||
"cpu_p50_ms": 1.157,
|
||||
"gpu_p50_ms": 0.361,
|
||||
"histogram": [
|
||||
0,
|
||||
6649,
|
||||
448,
|
||||
117,
|
||||
26,
|
||||
70,
|
||||
93,
|
||||
40,
|
||||
25,
|
||||
14,
|
||||
10,
|
||||
7,
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 5040,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 419.998,
|
||||
"low_1pct_fps": 56.192,
|
||||
"low_01pct_fps": 37.602,
|
||||
"p50_ms": 1.71,
|
||||
"p95_ms": 6.335,
|
||||
"p99_ms": 12.398,
|
||||
"p999_ms": 23.227,
|
||||
"max_ms": 32.253,
|
||||
"cpu_p50_ms": 1.634,
|
||||
"gpu_p50_ms": 0.429,
|
||||
"histogram": [
|
||||
0,
|
||||
3163,
|
||||
1077,
|
||||
371,
|
||||
104,
|
||||
58,
|
||||
48,
|
||||
71,
|
||||
47,
|
||||
41,
|
||||
17,
|
||||
16,
|
||||
17,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-desktop"
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-23T00:52:47Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "NVIDIA",
|
||||
"driver": "1.4.329 NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 25.007,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 12177.771,
|
||||
"stdev": 8960.751,
|
||||
"min": 6852.719,
|
||||
"max": 22523.26,
|
||||
"ci95": 22261.536
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 30469.771,
|
||||
"stdev": 9525.862,
|
||||
"min": 23924.719,
|
||||
"max": 41398.26,
|
||||
"ci95": 23665.464
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8002.915,
|
||||
"stdev": 2750.518,
|
||||
"min": 5499.832,
|
||||
"max": 10947.467,
|
||||
"ci95": 6833.218
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2481.889,
|
||||
"stdev": 303.157,
|
||||
"min": 2203.739,
|
||||
"max": 2805.025,
|
||||
"ci95": 753.144
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 385.31,
|
||||
"stdev": 241.771,
|
||||
"min": 129.951,
|
||||
"max": 610.699,
|
||||
"ci95": 600.641
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 107.075,
|
||||
"stdev": 81.437,
|
||||
"min": 16.766,
|
||||
"max": 174.928,
|
||||
"ci95": 202.318
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 44.667,
|
||||
"stdev": 41.789,
|
||||
"min": 6.0,
|
||||
"max": 89.0,
|
||||
"ci95": 103.818
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 9653.503,
|
||||
"stdev": 3442.183,
|
||||
"min": 7186.472,
|
||||
"max": 13585.896,
|
||||
"ci95": 8551.546
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2367.898,
|
||||
"stdev": 266.822,
|
||||
"min": 2176.324,
|
||||
"max": 2672.655,
|
||||
"ci95": 662.876
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 208.784,
|
||||
"stdev": 102.377,
|
||||
"min": 91.127,
|
||||
"max": 277.551,
|
||||
"ci95": 254.34
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 56.84,
|
||||
"stdev": 59.634,
|
||||
"min": 10.785,
|
||||
"max": 124.2,
|
||||
"ci95": 148.151
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 44.667,
|
||||
"stdev": 41.789,
|
||||
"min": 6.0,
|
||||
"max": 89.0,
|
||||
"ci95": 103.818
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 10441.986,
|
||||
"stdev": 2957.968,
|
||||
"min": 7966.98,
|
||||
"max": 13717.953,
|
||||
"ci95": 7348.594
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2727.058,
|
||||
"stdev": 257.941,
|
||||
"min": 2429.545,
|
||||
"max": 2887.978,
|
||||
"ci95": 640.812
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 246.78,
|
||||
"stdev": 35.091,
|
||||
"min": 211.805,
|
||||
"max": 281.986,
|
||||
"ci95": 87.178
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 55.43,
|
||||
"stdev": 45.634,
|
||||
"min": 26.317,
|
||||
"max": 108.023,
|
||||
"ci95": 113.369
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 44.667,
|
||||
"stdev": 41.789,
|
||||
"min": 6.0,
|
||||
"max": 89.0,
|
||||
"ci95": 103.818
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 15248.98,
|
||||
"stdev": 1927.895,
|
||||
"min": 13668.264,
|
||||
"max": 17396.829,
|
||||
"ci95": 4789.544
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2824.349,
|
||||
"stdev": 222.395,
|
||||
"min": 2568.693,
|
||||
"max": 2973.143,
|
||||
"ci95": 552.505
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 407.099,
|
||||
"stdev": 174.268,
|
||||
"min": 207.655,
|
||||
"max": 529.972,
|
||||
"ci95": 432.941
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 69.832,
|
||||
"stdev": 35.341,
|
||||
"min": 29.188,
|
||||
"max": 93.326,
|
||||
"ci95": 87.8
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 44.667,
|
||||
"stdev": 41.789,
|
||||
"min": 6.0,
|
||||
"max": 89.0,
|
||||
"ci95": 103.818
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3078.311,
|
||||
"stdev": 77.595,
|
||||
"min": 3022.469,
|
||||
"max": 3166.914,
|
||||
"ci95": 192.773
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2882.664,
|
||||
"stdev": 183.911,
|
||||
"min": 2768.117,
|
||||
"max": 3094.801,
|
||||
"ci95": 456.898
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 29243,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2436.904,
|
||||
"low_1pct_fps": 415.279,
|
||||
"low_01pct_fps": 129.532,
|
||||
"p50_ms": 0.354,
|
||||
"p95_ms": 0.691,
|
||||
"p99_ms": 1.231,
|
||||
"p999_ms": 3.925,
|
||||
"max_ms": 17.066,
|
||||
"histogram": [
|
||||
28752,
|
||||
394,
|
||||
53,
|
||||
20,
|
||||
6,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 26445,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2203.739,
|
||||
"low_1pct_fps": 129.951,
|
||||
"low_01pct_fps": 16.766,
|
||||
"p50_ms": 0.33,
|
||||
"p95_ms": 0.712,
|
||||
"p99_ms": 1.185,
|
||||
"p999_ms": 4.919,
|
||||
"max_ms": 460.22,
|
||||
"histogram": [
|
||||
26007,
|
||||
352,
|
||||
38,
|
||||
16,
|
||||
6,
|
||||
6,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 33661,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2805.025,
|
||||
"low_1pct_fps": 610.699,
|
||||
"low_01pct_fps": 174.928,
|
||||
"p50_ms": 0.315,
|
||||
"p95_ms": 0.5,
|
||||
"p99_ms": 0.95,
|
||||
"p999_ms": 2.426,
|
||||
"max_ms": 12.54,
|
||||
"histogram": [
|
||||
33371,
|
||||
251,
|
||||
9,
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
10,
|
||||
4,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 32072,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2672.655,
|
||||
"low_1pct_fps": 257.674,
|
||||
"low_01pct_fps": 35.534,
|
||||
"p50_ms": 0.336,
|
||||
"p95_ms": 0.37,
|
||||
"p99_ms": 0.797,
|
||||
"p999_ms": 2.624,
|
||||
"max_ms": 735.482,
|
||||
"histogram": [
|
||||
31895,
|
||||
137,
|
||||
11,
|
||||
14,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 27011,
|
||||
"seconds": 12.411,
|
||||
"avg_fps": 2176.324,
|
||||
"low_1pct_fps": 91.127,
|
||||
"low_01pct_fps": 10.785,
|
||||
"p50_ms": 0.346,
|
||||
"p95_ms": 0.401,
|
||||
"p99_ms": 0.934,
|
||||
"p999_ms": 3.871,
|
||||
"max_ms": 818.414,
|
||||
"histogram": [
|
||||
26776,
|
||||
162,
|
||||
31,
|
||||
17,
|
||||
5,
|
||||
3,
|
||||
2,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 27058,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 2254.715,
|
||||
"low_1pct_fps": 277.551,
|
||||
"low_01pct_fps": 124.2,
|
||||
"p50_ms": 0.367,
|
||||
"p95_ms": 0.761,
|
||||
"p99_ms": 2.117,
|
||||
"p999_ms": 5.229,
|
||||
"max_ms": 18.327,
|
||||
"histogram": [
|
||||
26114,
|
||||
645,
|
||||
153,
|
||||
91,
|
||||
23,
|
||||
15,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 34656,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2887.978,
|
||||
"low_1pct_fps": 246.55,
|
||||
"low_01pct_fps": 31.951,
|
||||
"p50_ms": 0.301,
|
||||
"p95_ms": 0.374,
|
||||
"p99_ms": 0.744,
|
||||
"p999_ms": 1.84,
|
||||
"max_ms": 529.481,
|
||||
"histogram": [
|
||||
34514,
|
||||
111,
|
||||
10,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 34364,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2863.651,
|
||||
"low_1pct_fps": 211.805,
|
||||
"low_01pct_fps": 26.317,
|
||||
"p50_ms": 0.299,
|
||||
"p95_ms": 0.354,
|
||||
"p99_ms": 0.726,
|
||||
"p999_ms": 2.066,
|
||||
"max_ms": 406.779,
|
||||
"histogram": [
|
||||
34238,
|
||||
91,
|
||||
8,
|
||||
9,
|
||||
4,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 29155,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2429.545,
|
||||
"low_1pct_fps": 281.986,
|
||||
"low_01pct_fps": 108.023,
|
||||
"p50_ms": 0.328,
|
||||
"p95_ms": 0.694,
|
||||
"p99_ms": 1.869,
|
||||
"p999_ms": 5.426,
|
||||
"max_ms": 21.079,
|
||||
"histogram": [
|
||||
28371,
|
||||
532,
|
||||
131,
|
||||
52,
|
||||
30,
|
||||
19,
|
||||
3,
|
||||
6,
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 35678,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2973.143,
|
||||
"low_1pct_fps": 529.972,
|
||||
"low_01pct_fps": 93.326,
|
||||
"p50_ms": 0.316,
|
||||
"p95_ms": 0.353,
|
||||
"p99_ms": 0.67,
|
||||
"p999_ms": 2.332,
|
||||
"max_ms": 142.274,
|
||||
"histogram": [
|
||||
35573,
|
||||
63,
|
||||
14,
|
||||
8,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
6,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 30825,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2568.693,
|
||||
"low_1pct_fps": 207.655,
|
||||
"low_01pct_fps": 29.188,
|
||||
"p50_ms": 0.329,
|
||||
"p95_ms": 0.457,
|
||||
"p99_ms": 0.904,
|
||||
"p999_ms": 4.074,
|
||||
"max_ms": 296.278,
|
||||
"histogram": [
|
||||
30573,
|
||||
169,
|
||||
34,
|
||||
20,
|
||||
8,
|
||||
0,
|
||||
1,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 35175,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2931.211,
|
||||
"low_1pct_fps": 483.67,
|
||||
"low_01pct_fps": 86.982,
|
||||
"p50_ms": 0.32,
|
||||
"p95_ms": 0.366,
|
||||
"p99_ms": 0.701,
|
||||
"p999_ms": 3.252,
|
||||
"max_ms": 186.727,
|
||||
"histogram": [
|
||||
35054,
|
||||
67,
|
||||
17,
|
||||
17,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
4,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-desktop"
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-10-23T01:07:26Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "NVIDIA",
|
||||
"driver": "1.4.329 NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 21.652,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 8940.211,
|
||||
"stdev": 2530.108,
|
||||
"min": 6019.207,
|
||||
"max": 10448.156,
|
||||
"ci95": 6285.645
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 58553.544,
|
||||
"stdev": 16445.393,
|
||||
"min": 39635.207,
|
||||
"max": 69435.156,
|
||||
"ci95": 40855.919
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19818.416,
|
||||
"stdev": 5189.664,
|
||||
"min": 14041.61,
|
||||
"max": 24086.671,
|
||||
"ci95": 12892.88
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 881.018,
|
||||
"stdev": 156.975,
|
||||
"min": 710.499,
|
||||
"max": 1019.508,
|
||||
"ci95": 389.978
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 110.182,
|
||||
"stdev": 38.396,
|
||||
"min": 83.662,
|
||||
"max": 154.211,
|
||||
"ci95": 95.388
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 42.667,
|
||||
"stdev": 23.983,
|
||||
"min": 19.55,
|
||||
"max": 67.431,
|
||||
"ci95": 59.582
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 66.333,
|
||||
"stdev": 51.859,
|
||||
"min": 7.0,
|
||||
"max": 103.0,
|
||||
"ci95": 128.835
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 28927.436,
|
||||
"stdev": 5464.979,
|
||||
"min": 22671.485,
|
||||
"max": 32771.861,
|
||||
"ci95": 13576.855
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1072.17,
|
||||
"stdev": 7.49,
|
||||
"min": 1067.189,
|
||||
"max": 1080.784,
|
||||
"ci95": 18.609
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 164.583,
|
||||
"stdev": 1.587,
|
||||
"min": 162.781,
|
||||
"max": 165.773,
|
||||
"ci95": 3.943
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 75.682,
|
||||
"stdev": 1.798,
|
||||
"min": 73.791,
|
||||
"max": 77.37,
|
||||
"ci95": 4.467
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 66.333,
|
||||
"stdev": 51.859,
|
||||
"min": 7.0,
|
||||
"max": 103.0,
|
||||
"ci95": 128.835
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19245.588,
|
||||
"stdev": 3584.123,
|
||||
"min": 16135.104,
|
||||
"max": 23165.065,
|
||||
"ci95": 8904.175
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 893.83,
|
||||
"stdev": 538.989,
|
||||
"min": 273.707,
|
||||
"max": 1249.655,
|
||||
"ci95": 1339.03
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 141.621,
|
||||
"stdev": 90.353,
|
||||
"min": 40.103,
|
||||
"max": 213.218,
|
||||
"ci95": 224.467
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 56.775,
|
||||
"stdev": 31.752,
|
||||
"min": 20.453,
|
||||
"max": 79.261,
|
||||
"ci95": 78.883
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 66.333,
|
||||
"stdev": 51.859,
|
||||
"min": 7.0,
|
||||
"max": 103.0,
|
||||
"ci95": 128.835
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 59282.69,
|
||||
"stdev": 12376.117,
|
||||
"min": 50806.144,
|
||||
"max": 73484.868,
|
||||
"ci95": 30746.461
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 550.795,
|
||||
"stdev": 248.553,
|
||||
"min": 264.54,
|
||||
"max": 711.867,
|
||||
"ci95": 617.489
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 33.217,
|
||||
"stdev": 15.316,
|
||||
"min": 15.734,
|
||||
"max": 44.271,
|
||||
"ci95": 38.051
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 5.325,
|
||||
"stdev": 1.985,
|
||||
"min": 3.177,
|
||||
"max": 7.092,
|
||||
"ci95": 4.932
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 66.333,
|
||||
"stdev": 51.859,
|
||||
"min": 7.0,
|
||||
"max": 103.0,
|
||||
"ci95": 128.835
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3483.918,
|
||||
"stdev": 54.851,
|
||||
"min": 3420.879,
|
||||
"max": 3520.746,
|
||||
"ci95": 136.268
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3418.959,
|
||||
"stdev": 50.107,
|
||||
"min": 3363.992,
|
||||
"max": 3462.084,
|
||||
"ci95": 124.482
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 10957,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 913.047,
|
||||
"low_1pct_fps": 92.672,
|
||||
"low_01pct_fps": 19.55,
|
||||
"p50_ms": 0.804,
|
||||
"p95_ms": 2.202,
|
||||
"p99_ms": 4.333,
|
||||
"p999_ms": 13.43,
|
||||
"max_ms": 410.29,
|
||||
"histogram": [
|
||||
7884,
|
||||
2368,
|
||||
440,
|
||||
147,
|
||||
39,
|
||||
26,
|
||||
15,
|
||||
15,
|
||||
3,
|
||||
7,
|
||||
3,
|
||||
8,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 8526,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 710.499,
|
||||
"low_1pct_fps": 83.662,
|
||||
"low_01pct_fps": 41.019,
|
||||
"p50_ms": 0.892,
|
||||
"p95_ms": 3.72,
|
||||
"p99_ms": 7.437,
|
||||
"p999_ms": 20.294,
|
||||
"max_ms": 30.234,
|
||||
"histogram": [
|
||||
4933,
|
||||
2204,
|
||||
727,
|
||||
332,
|
||||
133,
|
||||
67,
|
||||
38,
|
||||
29,
|
||||
20,
|
||||
19,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
5,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 12236,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 1019.508,
|
||||
"low_1pct_fps": 154.211,
|
||||
"low_01pct_fps": 67.431,
|
||||
"p50_ms": 0.803,
|
||||
"p95_ms": 1.825,
|
||||
"p99_ms": 3.764,
|
||||
"p999_ms": 10.283,
|
||||
"max_ms": 23.801,
|
||||
"histogram": [
|
||||
9657,
|
||||
2078,
|
||||
286,
|
||||
118,
|
||||
27,
|
||||
26,
|
||||
13,
|
||||
10,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 12823,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1068.537,
|
||||
"low_1pct_fps": 165.773,
|
||||
"low_01pct_fps": 77.37,
|
||||
"p50_ms": 0.842,
|
||||
"p95_ms": 1.298,
|
||||
"p99_ms": 3.415,
|
||||
"p999_ms": 10.457,
|
||||
"max_ms": 18.103,
|
||||
"histogram": [
|
||||
11615,
|
||||
909,
|
||||
137,
|
||||
66,
|
||||
30,
|
||||
20,
|
||||
13,
|
||||
16,
|
||||
3,
|
||||
7,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 12970,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1080.784,
|
||||
"low_1pct_fps": 165.196,
|
||||
"low_01pct_fps": 75.886,
|
||||
"p50_ms": 0.841,
|
||||
"p95_ms": 1.246,
|
||||
"p99_ms": 3.428,
|
||||
"p999_ms": 9.769,
|
||||
"max_ms": 30.26,
|
||||
"histogram": [
|
||||
11881,
|
||||
845,
|
||||
93,
|
||||
62,
|
||||
27,
|
||||
15,
|
||||
16,
|
||||
9,
|
||||
11,
|
||||
6,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 12807,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1067.189,
|
||||
"low_1pct_fps": 162.781,
|
||||
"low_01pct_fps": 73.791,
|
||||
"p50_ms": 0.842,
|
||||
"p95_ms": 1.31,
|
||||
"p99_ms": 3.393,
|
||||
"p999_ms": 11.153,
|
||||
"max_ms": 19.229,
|
||||
"histogram": [
|
||||
11611,
|
||||
933,
|
||||
108,
|
||||
62,
|
||||
24,
|
||||
28,
|
||||
13,
|
||||
6,
|
||||
7,
|
||||
6,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 3285,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 273.707,
|
||||
"low_1pct_fps": 40.103,
|
||||
"low_01pct_fps": 20.453,
|
||||
"p50_ms": 3.047,
|
||||
"p95_ms": 7.993,
|
||||
"p99_ms": 14.596,
|
||||
"p999_ms": 36.882,
|
||||
"max_ms": 71.876,
|
||||
"histogram": [
|
||||
210,
|
||||
775,
|
||||
629,
|
||||
685,
|
||||
337,
|
||||
266,
|
||||
123,
|
||||
116,
|
||||
51,
|
||||
37,
|
||||
18,
|
||||
11,
|
||||
7,
|
||||
8,
|
||||
6,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 13898,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1158.128,
|
||||
"low_1pct_fps": 171.541,
|
||||
"low_01pct_fps": 70.612,
|
||||
"p50_ms": 0.716,
|
||||
"p95_ms": 1.492,
|
||||
"p99_ms": 3.27,
|
||||
"p999_ms": 9.156,
|
||||
"max_ms": 21.227,
|
||||
"histogram": [
|
||||
12221,
|
||||
1268,
|
||||
244,
|
||||
83,
|
||||
23,
|
||||
20,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 14996,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1249.655,
|
||||
"low_1pct_fps": 213.218,
|
||||
"low_01pct_fps": 79.261,
|
||||
"p50_ms": 0.713,
|
||||
"p95_ms": 1.166,
|
||||
"p99_ms": 2.533,
|
||||
"p999_ms": 8.971,
|
||||
"max_ms": 18.592,
|
||||
"histogram": [
|
||||
13939,
|
||||
816,
|
||||
131,
|
||||
58,
|
||||
17,
|
||||
8,
|
||||
8,
|
||||
4,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 3175,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 264.54,
|
||||
"low_1pct_fps": 15.734,
|
||||
"low_01pct_fps": 3.177,
|
||||
"p50_ms": 2.117,
|
||||
"p95_ms": 9.913,
|
||||
"p99_ms": 20.257,
|
||||
"p999_ms": 48.141,
|
||||
"max_ms": 619.805,
|
||||
"histogram": [
|
||||
319,
|
||||
1186,
|
||||
482,
|
||||
399,
|
||||
211,
|
||||
166,
|
||||
102,
|
||||
83,
|
||||
71,
|
||||
48,
|
||||
30,
|
||||
30,
|
||||
16,
|
||||
16,
|
||||
6,
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 8543,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 711.867,
|
||||
"low_1pct_fps": 39.646,
|
||||
"low_01pct_fps": 5.706,
|
||||
"p50_ms": 1.033,
|
||||
"p95_ms": 2.025,
|
||||
"p99_ms": 5.49,
|
||||
"p999_ms": 17.993,
|
||||
"max_ms": 365.508,
|
||||
"histogram": [
|
||||
2186,
|
||||
5922,
|
||||
181,
|
||||
98,
|
||||
43,
|
||||
43,
|
||||
22,
|
||||
17,
|
||||
12,
|
||||
5,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 8112,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 675.978,
|
||||
"low_1pct_fps": 44.271,
|
||||
"low_01pct_fps": 7.092,
|
||||
"p50_ms": 1.043,
|
||||
"p95_ms": 2.629,
|
||||
"p99_ms": 5.842,
|
||||
"p999_ms": 16.976,
|
||||
"max_ms": 258.087,
|
||||
"histogram": [
|
||||
1494,
|
||||
5923,
|
||||
365,
|
||||
141,
|
||||
57,
|
||||
61,
|
||||
24,
|
||||
19,
|
||||
6,
|
||||
5,
|
||||
6,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-desktop"
|
||||
}
|
||||
@@ -42,5 +42,32 @@
|
||||
"thinkpad/vanilla-thinkpad-r1-rd12.json",
|
||||
"thinkpad/vanilla-thinkpad-r2-rd12.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"machine": "Desktop (RTX 4080, i7-13700KF)",
|
||||
"reports": [
|
||||
"desktop/horizon-desktop-rd12.json"
|
||||
],
|
||||
"version": "v2026.10.22.13",
|
||||
"note": "Linux (NixOS) with the NVIDIA 595 driver. The desktop is in daily use, so both games drew to a separate GPU compositor (a headless sway rendering on the RTX 4080) in a 1280x720 window, never on its screen: Horizon through Wayland, vanilla through Xwayland, both on Vulkan. Other jobs were running during the runs (1-minute load average 18 to 25 on 24 threads), which costs both games, chunk loading most."
|
||||
},
|
||||
{
|
||||
"machine": "Desktop (RTX 4080, i7-13700KF)",
|
||||
"reports": [
|
||||
"desktop/horizon-desktop-rd32.json"
|
||||
],
|
||||
"version": "v2026.10.22.13"
|
||||
},
|
||||
{
|
||||
"machine": "Desktop (RTX 4080, i7-13700KF)",
|
||||
"reports": [
|
||||
"desktop/vanilla-desktop-rd12.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"machine": "Desktop (RTX 4080, i7-13700KF)",
|
||||
"reports": [
|
||||
"desktop/vanilla-desktop-rd32.json"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"format": "horizon-benchmark-summary",
|
||||
"version": 1,
|
||||
"generated": "2026-10-22T20:35:03Z",
|
||||
"generated": "2026-10-23T01:09:54Z",
|
||||
"min_installs": 3,
|
||||
"groups": [
|
||||
{
|
||||
@@ -313,6 +313,211 @@
|
||||
"chunk_load_ms": 54778
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"cpu": "13th Gen Intel Core i7-13700KF",
|
||||
"render_distance": 12,
|
||||
"game": "horizon",
|
||||
"source": "us",
|
||||
"machine": "Desktop (RTX 4080, i7-13700KF)",
|
||||
"measured": "2026-10-23",
|
||||
"version": "v2026.10.22.13",
|
||||
"backend": "vulkan",
|
||||
"os": "linux",
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"repeats": 3,
|
||||
"load_avg_1m": 17.934,
|
||||
"note": "Linux (NixOS) with the NVIDIA 595 driver. The desktop is in daily use, so both games drew to a separate GPU compositor (a headless sway rendering on the RTX 4080) in a 1280x720 window, never on its screen: Horizon through Wayland, vanilla through Xwayland, both on Vulkan. Other jobs were running during the runs (1-minute load average 18 to 25 on 24 threads), which costs both games, chunk loading most.",
|
||||
"medians": {
|
||||
"avg_fps": 2993,
|
||||
"low_1pct_fps": 311,
|
||||
"p95_ms": 0.5,
|
||||
"world_ms": 17973,
|
||||
"chunk_load_ms": 1797,
|
||||
"rss_peak_mb": 1414,
|
||||
"rss_steady_mb": 1202,
|
||||
"cpu_p50_ms": 0.3,
|
||||
"gpu_p50_ms": 0.1
|
||||
},
|
||||
"scenes": {
|
||||
"village": {
|
||||
"avg_fps": 2594,
|
||||
"low_1pct_fps": 254,
|
||||
"chunk_load_ms": 1260
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 3427,
|
||||
"low_1pct_fps": 328,
|
||||
"chunk_load_ms": 1592
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 3277,
|
||||
"low_1pct_fps": 388,
|
||||
"chunk_load_ms": 2660
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 2673,
|
||||
"low_1pct_fps": 275,
|
||||
"chunk_load_ms": 1675
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"cpu": "13th Gen Intel Core i7-13700KF",
|
||||
"render_distance": 32,
|
||||
"game": "horizon",
|
||||
"source": "us",
|
||||
"machine": "Desktop (RTX 4080, i7-13700KF)",
|
||||
"measured": "2026-10-23",
|
||||
"version": "v2026.10.22.13",
|
||||
"backend": "vulkan",
|
||||
"os": "linux",
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"repeats": 3,
|
||||
"load_avg_1m": 22.354,
|
||||
"medians": {
|
||||
"avg_fps": 672,
|
||||
"low_1pct_fps": 89,
|
||||
"p95_ms": 3.6,
|
||||
"world_ms": 8999,
|
||||
"chunk_load_ms": 4677,
|
||||
"rss_peak_mb": 1957,
|
||||
"rss_steady_mb": 1646,
|
||||
"cpu_p50_ms": 1.1,
|
||||
"gpu_p50_ms": 0.3
|
||||
},
|
||||
"scenes": {
|
||||
"village": {
|
||||
"avg_fps": 686,
|
||||
"low_1pct_fps": 93,
|
||||
"chunk_load_ms": 2702
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 590,
|
||||
"low_1pct_fps": 76,
|
||||
"chunk_load_ms": 4823
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 868,
|
||||
"low_1pct_fps": 113,
|
||||
"chunk_load_ms": 3629
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 544,
|
||||
"low_1pct_fps": 73,
|
||||
"chunk_load_ms": 7552
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"cpu": "13th Gen Intel Core i7-13700KF",
|
||||
"render_distance": 12,
|
||||
"game": "vanilla",
|
||||
"source": "us",
|
||||
"machine": "Desktop (RTX 4080, i7-13700KF)",
|
||||
"measured": "2026-10-23",
|
||||
"version": "26.3",
|
||||
"backend": "vulkan",
|
||||
"os": "linux",
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"repeats": 3,
|
||||
"load_avg_1m": 25.007,
|
||||
"medians": {
|
||||
"avg_fps": 2600,
|
||||
"low_1pct_fps": 312,
|
||||
"p95_ms": 0.5,
|
||||
"world_ms": 30470,
|
||||
"chunk_load_ms": 10837,
|
||||
"rss_peak_mb": 3078,
|
||||
"rss_steady_mb": 2883,
|
||||
"cpu_p50_ms": null,
|
||||
"gpu_p50_ms": null
|
||||
},
|
||||
"scenes": {
|
||||
"village": {
|
||||
"avg_fps": 2482,
|
||||
"low_1pct_fps": 385,
|
||||
"chunk_load_ms": 8003
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 2368,
|
||||
"low_1pct_fps": 209,
|
||||
"chunk_load_ms": 9654
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 2727,
|
||||
"low_1pct_fps": 247,
|
||||
"chunk_load_ms": 10442
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 2824,
|
||||
"low_1pct_fps": 407,
|
||||
"chunk_load_ms": 15249
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"gpu": "NVIDIA GeForce RTX 4080",
|
||||
"cpu": "13th Gen Intel Core i7-13700KF",
|
||||
"render_distance": 32,
|
||||
"game": "vanilla",
|
||||
"source": "us",
|
||||
"machine": "Desktop (RTX 4080, i7-13700KF)",
|
||||
"measured": "2026-10-23",
|
||||
"version": "26.3",
|
||||
"backend": "vulkan",
|
||||
"os": "linux",
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"repeats": 3,
|
||||
"load_avg_1m": 21.652,
|
||||
"medians": {
|
||||
"avg_fps": 849,
|
||||
"low_1pct_fps": 112,
|
||||
"p95_ms": 3.1,
|
||||
"world_ms": 58554,
|
||||
"chunk_load_ms": 31819,
|
||||
"rss_peak_mb": 3484,
|
||||
"rss_steady_mb": 3419,
|
||||
"cpu_p50_ms": null,
|
||||
"gpu_p50_ms": null
|
||||
},
|
||||
"scenes": {
|
||||
"village": {
|
||||
"avg_fps": 881,
|
||||
"low_1pct_fps": 110,
|
||||
"chunk_load_ms": 19818
|
||||
},
|
||||
"forest": {
|
||||
"avg_fps": 1072,
|
||||
"low_1pct_fps": 165,
|
||||
"chunk_load_ms": 28927
|
||||
},
|
||||
"ocean": {
|
||||
"avg_fps": 894,
|
||||
"low_1pct_fps": 142,
|
||||
"chunk_load_ms": 19246
|
||||
},
|
||||
"mountain": {
|
||||
"avg_fps": 551,
|
||||
"low_1pct_fps": 33,
|
||||
"chunk_load_ms": 59283
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,9 @@
|
||||
// players' `benchmark` items as the server stores them ({ install_id, day, version, data: <report> } per line).
|
||||
// Players' results are grouped by GPU, CPU, render distance and game, and a group is published only with at least
|
||||
// MIN_INSTALLS installs, so nobody can be picked out. install_id is used to count installs and to weigh each install
|
||||
// once, and never leaves this script; neither do the day, OS version, driver, RAM or screen.
|
||||
// once, and never leaves this script; neither do the day, OS version, driver, the exact RAM and VRAM, or the screen.
|
||||
// A group also says how its installs split by hardware class (RAM, VRAM, drive, OS, integrated or discrete GPU),
|
||||
// each class only with at least MIN_INSTALLS installs. The classes are the server's (src/classes.rs).
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -34,6 +36,43 @@ export function round(field, x) {
|
||||
const trim = (s) => (typeof s === "string" ? s.replace(/\(R\)|\(TM\)|\(tm\)/g, "").replace(/\s+/g, " ").trim() : "");
|
||||
const gameOf = (app) => (app?.name === "horizon-client" ? "horizon" : app?.name === "minecraft" && !(app.mods || []).some((m) => m.name !== "fabric-loader") ? "vanilla" : null);
|
||||
|
||||
// --- Hardware classes: coarse, so that a class holds many machines ---------------------------
|
||||
|
||||
export const DIMENSIONS = ["ram", "vram", "storage", "os", "gpu_kind"];
|
||||
export function ramClass(gb) {
|
||||
if (!(gb > 0)) return null;
|
||||
return gb <= 5 ? "4 GB or less" : gb <= 10 ? "8 GB" : gb <= 20 ? "16 GB" : gb <= 40 ? "32 GB" : "64 GB or more";
|
||||
}
|
||||
export function gpuKindClass(s) {
|
||||
return s === "integrated" || s === "IntegratedGpu" ? "integrated" : s === "discrete" || s === "DiscreteGpu" ? "discrete" : null;
|
||||
}
|
||||
// A GPU's own memory in MiB; an integrated GPU shares the system's.
|
||||
export function vramClass(mb, kind) {
|
||||
if (gpuKindClass(kind) === "integrated") return "shared";
|
||||
if (!(mb > 0)) return null;
|
||||
for (const [max, name] of [[2560, "2 GB or less"], [4608, "4 GB"], [6656, "6 GB"], [8704, "8 GB"], [12800, "12 GB"], [16896, "16 GB"]]) if (mb <= max) return name;
|
||||
return "more than 16 GB";
|
||||
}
|
||||
const oneOf = (list) => (s) => (list.includes(s) ? s : null);
|
||||
export const storageClass = oneOf(["nvme", "ssd", "hdd"]);
|
||||
export const osClass = oneOf(["linux", "macos", "windows"]);
|
||||
// A report's classes, in DIMENSIONS order.
|
||||
export function classesOf(report) {
|
||||
const hw = report?.hardware || {};
|
||||
const kind = hw.gpu?.kind;
|
||||
return [ramClass(finite(hw.ram_mb) ? hw.ram_mb / 1024 : 0), vramClass(hw.gpu?.vram_mb, kind), storageClass(hw.storage), osClass(hw.os), gpuKindClass(kind)];
|
||||
}
|
||||
// Per dimension, the classes with at least minInstalls installs, most first; dimensions without one are left out.
|
||||
export function breakdowns(classes, minInstalls) {
|
||||
const out = {};
|
||||
DIMENSIONS.forEach((dim, i) => {
|
||||
const cells = [...classes[i]].map(([c, set]) => ({ class: c, installs: set.size })).filter((c) => c.installs >= minInstalls);
|
||||
cells.sort((a, b) => b.installs - a.installs || (a.class < b.class ? -1 : a.class > b.class ? 1 : 0));
|
||||
if (cells.length) out[dim] = cells;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Why a report can't be compared with the others, or null when it can: the standard flight over the four scenes,
|
||||
// VSync off, no frame cap, drawn on a screen.
|
||||
export function rejection(report) {
|
||||
@@ -82,12 +121,17 @@ export function aggregatePlayers(items, { minInstalls = MIN_INSTALLS } = {}) {
|
||||
const report = item?.data;
|
||||
if (!item?.install_id || rejection(report)) continue;
|
||||
const key = JSON.stringify([trim(report.hardware.gpu.name), trim(report.hardware.cpu), report.conditions.render_distance, gameOf(report.app)]);
|
||||
if (!groups.has(key)) groups.set(key, { installs: new Map(), reports: 0, versions: new Set() });
|
||||
if (!groups.has(key)) groups.set(key, { installs: new Map(), reports: 0, versions: new Set(), classes: DIMENSIONS.map(() => new Map()) });
|
||||
const g = groups.get(key);
|
||||
g.reports++;
|
||||
if (item.version) g.versions.add(item.version);
|
||||
if (!g.installs.has(item.install_id)) g.installs.set(item.install_id, []);
|
||||
g.installs.get(item.install_id).push(figures(report));
|
||||
classesOf(report).forEach((c, i) => {
|
||||
if (!c) return;
|
||||
if (!g.classes[i].has(c)) g.classes[i].set(c, new Set());
|
||||
g.classes[i].get(c).add(item.install_id);
|
||||
});
|
||||
}
|
||||
const out = [];
|
||||
for (const [key, g] of groups) {
|
||||
@@ -95,7 +139,7 @@ export function aggregatePlayers(items, { minInstalls = MIN_INSTALLS } = {}) {
|
||||
const [gpu, cpu, render_distance, game] = JSON.parse(key);
|
||||
const perInstall = [...g.installs.values()].map((runs) => Object.fromEntries(FIELDS.map((f) => [f, median(runs.map((r) => r[f]))])));
|
||||
const medians = Object.fromEntries(FIELDS.map((f) => [f, round(f, median(perInstall.map((r) => r[f])))]));
|
||||
out.push({ gpu, cpu, render_distance, game, source: "players", installs: g.installs.size, reports: g.reports, versions: [...g.versions].sort(byVersionDesc).slice(0, 3), medians });
|
||||
out.push({ gpu, cpu, render_distance, game, source: "players", installs: g.installs.size, reports: g.reports, versions: [...g.versions].sort(byVersionDesc).slice(0, 3), medians, breakdowns: breakdowns(g.classes, minInstalls) });
|
||||
}
|
||||
// Most installs first, then by GPU, CPU, render distance and game, compared as plain strings (as the server does).
|
||||
const cmp = (x, y) => (x < y ? -1 : x > y ? 1 : 0);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { aggregatePlayers, median, ourRows, rejection, round, summary } from "./benchmarks.mjs";
|
||||
import { aggregatePlayers, median, osClass, ourRows, ramClass, rejection, round, storageClass, summary, vramClass, gpuKindClass } from "./benchmarks.mjs";
|
||||
|
||||
const fixture = (name) => new URL(`./fixtures/benchmarks/${name}`, import.meta.url);
|
||||
const items = readFileSync(fixture("items.jsonl"), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
||||
@@ -17,7 +17,7 @@ test("players' items give the expected groups", () => {
|
||||
|
||||
test("nothing that identifies an install is published", () => {
|
||||
const text = JSON.stringify(aggregatePlayers(items, { minInstalls: 1 }));
|
||||
for (const secret of ["a1", "a2", "install_id", "secret-os-version", "secret-driver", "20394", "ram_mb", "2560"]) assert.ok(!text.includes(secret), secret);
|
||||
for (const secret of ["a1", "a2", "install_id", "secret-os-version", "secret-driver", "20394", "ram_mb", "2560", "16376", "32768", "vram_mb"]) assert.ok(!text.includes(secret), secret);
|
||||
});
|
||||
|
||||
test("a group needs three installs, not three runs", () => {
|
||||
@@ -26,6 +26,30 @@ test("a group needs three installs, not three runs", () => {
|
||||
assert.equal(aggregatePlayers(items, { minInstalls: 2 }).length, 2);
|
||||
});
|
||||
|
||||
test("hardware classes are coarse, as the server's", () => {
|
||||
assert.equal(ramClass(15.6), "16 GB");
|
||||
assert.equal(ramClass(31), "32 GB");
|
||||
assert.equal(ramClass(64), "64 GB or more");
|
||||
assert.equal(ramClass(0), null);
|
||||
assert.equal(vramClass(16376, "discrete"), "16 GB");
|
||||
assert.equal(vramClass(8192, "DiscreteGpu"), "8 GB");
|
||||
assert.equal(vramClass(0, "integrated"), "shared");
|
||||
assert.equal(vramClass(0, ""), null);
|
||||
assert.equal(storageClass("nvme"), "nvme");
|
||||
assert.equal(storageClass("tape"), null);
|
||||
assert.equal(osClass("windows"), "windows");
|
||||
assert.equal(gpuKindClass("virtual"), null);
|
||||
});
|
||||
|
||||
test("a breakdown cell needs three installs too", () => {
|
||||
const [group] = aggregatePlayers(items);
|
||||
assert.equal(group.breakdowns.ram, undefined); // 32 GB has two installs, 16 GB one
|
||||
assert.equal(group.breakdowns.storage, undefined);
|
||||
const [all] = aggregatePlayers(items, { minInstalls: 1 });
|
||||
assert.deepEqual(all.breakdowns.ram, [{ class: "32 GB", installs: 2 }, { class: "16 GB", installs: 1 }]);
|
||||
assert.deepEqual(all.breakdowns.storage, [{ class: "nvme", installs: 2 }, { class: "ssd", installs: 1 }]);
|
||||
});
|
||||
|
||||
test("only comparable runs count", () => {
|
||||
const reasons = items.map((i) => rejection(i.data));
|
||||
assert.deepEqual(reasons.filter(Boolean), ["VSync on or unknown", "offscreen", "frame rate capped", "not every scene", "not the standard flight", "not a version 1 benchmark report"]);
|
||||
|
||||
@@ -7,4 +7,7 @@ Shared by the website's aggregator test (`website/scripts/benchmarks.test.mjs`)
|
||||
RTX 4080 + i7-13700KF at render distance 12 has three installs (one with two runs); the same install at 32,
|
||||
and the RTX 3060 group, have fewer than three and are left out. The other runs are rejected: VSync on,
|
||||
offscreen, a frame cap, a missing scene, a one-scene scenario, a report of another version, no install ID.
|
||||
- Breakdowns: the RTX 4080 group's three installs share a VRAM class (16 GB), the OS and a discrete GPU, so
|
||||
those cells are published; a3 has 16 GB of RAM and an SSD where a1 and a2 have 32 GB and NVMe, so neither the
|
||||
RAM nor the drive cells reach three installs and those dimensions are left out.
|
||||
- `expected.json`: `min_installs` and `groups` of the summary (`generated` varies).
|
||||
@@ -10,7 +10,12 @@
|
||||
"installs": 3,
|
||||
"reports": 4,
|
||||
"versions": ["v0.2.1", "v0.2.0"],
|
||||
"medians": { "avg_fps": 515, "low_1pct_fps": 120, "p95_ms": 3.5, "world_ms": 4500, "chunk_load_ms": 1400, "rss_peak_mb": 1450, "rss_steady_mb": 1250 }
|
||||
"medians": { "avg_fps": 515, "low_1pct_fps": 120, "p95_ms": 3.5, "world_ms": 4500, "chunk_load_ms": 1400, "rss_peak_mb": 1450, "rss_steady_mb": 1250 },
|
||||
"breakdowns": {
|
||||
"vram": [{ "class": "16 GB", "installs": 3 }],
|
||||
"os": [{ "class": "linux", "installs": 3 }],
|
||||
"gpu_kind": [{ "class": "discrete", "installs": 3 }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
{"install_id":"a1","day":20394,"version":"v0.2.0","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":5000,"stdev":null,"min":5000,"max":5000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1400},"memory.rss_steady_mb":{"n":1,"mean":1200},"chunks.village.load_ms":{"n":1,"mean":1500},"chunks.forest.load_ms":{"n":1,"mean":1500},"chunks.ocean.load_ms":{"n":1,"mean":1500},"chunks.mountain.load_ms":{"n":1,"mean":1500}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":400,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":410,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":420,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":430,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a1","day":20395,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":4000,"stdev":null,"min":4000,"max":4000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1500},"memory.rss_steady_mb":{"n":1,"mean":1300},"chunks.village.load_ms":{"n":1,"mean":1300},"chunks.forest.load_ms":{"n":1,"mean":1300},"chunks.ocean.load_ms":{"n":1,"mean":1300},"chunks.mountain.load_ms":{"n":1,"mean":1300}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":600,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":610,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":620,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":630,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a2","day":20395,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":6000,"stdev":null,"min":6000,"max":6000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":1700},"chunks.forest.load_ms":{"n":1,"mean":1700},"chunks.ocean.load_ms":{"n":1,"mean":1700},"chunks.mountain.load_ms":{"n":1,"mean":1700}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":300,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":310,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":320,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":330,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a3","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":3000,"stdev":null,"min":3000,"max":3000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1600},"memory.rss_steady_mb":{"n":1,"mean":1400},"chunks.village.load_ms":{"n":1,"mean":1100},"chunks.forest.load_ms":{"n":1,"mean":1100},"chunks.ocean.load_ms":{"n":1,"mean":1100},"chunks.mountain.load_ms":{"n":1,"mean":1100}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":700,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":710,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":720,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":730,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a4","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":true,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a5","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"offscreen":true,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a6","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"fps_cap":60,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a7","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a8","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"village","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a1","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":32,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":9000,"stdev":null,"min":9000,"max":9000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":2600},"memory.rss_steady_mb":{"n":1,"mean":2300},"chunks.village.load_ms":{"n":1,"mean":6000},"chunks.forest.load_ms":{"n":1,"mean":6000},"chunks.ocean.load_ms":{"n":1,"mean":6000},"chunks.mountain.load_ms":{"n":1,"mean":6000}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":120,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":130,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":140,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":150,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"b1","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":7000,"stdev":null,"min":7000,"max":7000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":2500},"chunks.forest.load_ms":{"n":1,"mean":2500},"chunks.ocean.load_ms":{"n":1,"mean":2500},"chunks.mountain.load_ms":{"n":1,"mean":2500}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":250,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":260,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":270,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":280,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"b2","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":7100,"stdev":null,"min":7100,"max":7100,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":2400},"chunks.forest.load_ms":{"n":1,"mean":2400},"chunks.ocean.load_ms":{"n":1,"mean":2400},"chunks.mountain.load_ms":{"n":1,"mean":2400}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":260,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":270,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":280,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":290,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"b3","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":2,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":7100,"stdev":null,"min":7100,"max":7100,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":2400},"chunks.forest.load_ms":{"n":1,"mean":2400},"chunks.ocean.load_ms":{"n":1,"mean":2400},"chunks.mountain.load_ms":{"n":1,"mean":2400}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":260,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":270,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":280,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":290,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"day":20396,"version":"v0.2.1","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":1,"stdev":null,"min":1,"max":1,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1},"memory.rss_steady_mb":{"n":1,"mean":1},"chunks.village.load_ms":{"n":1,"mean":1},"chunks.forest.load_ms":{"n":1,"mean":1},"chunks.ocean.load_ms":{"n":1,"mean":1},"chunks.mountain.load_ms":{"n":1,"mean":1}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":1,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":11,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":21,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":31,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a1","day":20394,"version":"v0.2.0","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":16376},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":5000,"stdev":null,"min":5000,"max":5000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1400},"memory.rss_steady_mb":{"n":1,"mean":1200},"chunks.village.load_ms":{"n":1,"mean":1500},"chunks.forest.load_ms":{"n":1,"mean":1500},"chunks.ocean.load_ms":{"n":1,"mean":1500},"chunks.mountain.load_ms":{"n":1,"mean":1500}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":400,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":410,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":420,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":430,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a1","day":20395,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":16376},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":4000,"stdev":null,"min":4000,"max":4000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1500},"memory.rss_steady_mb":{"n":1,"mean":1300},"chunks.village.load_ms":{"n":1,"mean":1300},"chunks.forest.load_ms":{"n":1,"mean":1300},"chunks.ocean.load_ms":{"n":1,"mean":1300},"chunks.mountain.load_ms":{"n":1,"mean":1300}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":600,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":610,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":620,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":630,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a2","day":20395,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":16376},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":6000,"stdev":null,"min":6000,"max":6000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":1700},"chunks.forest.load_ms":{"n":1,"mean":1700},"chunks.ocean.load_ms":{"n":1,"mean":1700},"chunks.mountain.load_ms":{"n":1,"mean":1700}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":300,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":310,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":320,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":330,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a3","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":16384,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":16376},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"ssd"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":3000,"stdev":null,"min":3000,"max":3000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1600},"memory.rss_steady_mb":{"n":1,"mean":1400},"chunks.village.load_ms":{"n":1,"mean":1100},"chunks.forest.load_ms":{"n":1,"mean":1100},"chunks.ocean.load_ms":{"n":1,"mean":1100},"chunks.mountain.load_ms":{"n":1,"mean":1100}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":700,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":710,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":720,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":730,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a4","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":16376},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":true,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a5","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":16376},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"offscreen":true,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a6","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":16376},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"fps_cap":60,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a7","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":16376},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a8","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":16376},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"village","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"a1","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":16376},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":32,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":9000,"stdev":null,"min":9000,"max":9000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":2600},"memory.rss_steady_mb":{"n":1,"mean":2300},"chunks.village.load_ms":{"n":1,"mean":6000},"chunks.forest.load_ms":{"n":1,"mean":6000},"chunks.ocean.load_ms":{"n":1,"mean":6000},"chunks.mountain.load_ms":{"n":1,"mean":6000}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":120,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":130,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":140,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":150,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"b1","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":12288},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":7000,"stdev":null,"min":7000,"max":7000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":2500},"chunks.forest.load_ms":{"n":1,"mean":2500},"chunks.ocean.load_ms":{"n":1,"mean":2500},"chunks.mountain.load_ms":{"n":1,"mean":2500}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":250,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":260,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":270,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":280,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"b2","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":12288},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":7100,"stdev":null,"min":7100,"max":7100,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":2400},"chunks.forest.load_ms":{"n":1,"mean":2400},"chunks.ocean.load_ms":{"n":1,"mean":2400},"chunks.mountain.load_ms":{"n":1,"mean":2400}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":260,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":270,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":280,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":290,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"install_id":"b3","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":2,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":12288},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":7100,"stdev":null,"min":7100,"max":7100,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":2400},"chunks.forest.load_ms":{"n":1,"mean":2400},"chunks.ocean.load_ms":{"n":1,"mean":2400},"chunks.mountain.load_ms":{"n":1,"mean":2400}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":260,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":270,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":280,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":290,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
{"day":20396,"version":"v0.2.1","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete","vram_mb":12288},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165},"storage":"nvme"},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":1,"stdev":null,"min":1,"max":1,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1},"memory.rss_steady_mb":{"n":1,"mean":1},"chunks.village.load_ms":{"n":1,"mean":1},"chunks.forest.load_ms":{"n":1,"mean":1},"chunks.ocean.load_ms":{"n":1,"mean":1},"chunks.mountain.load_ms":{"n":1,"mean":1}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":1,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":11,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":21,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":31,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}}
|
||||
+34
-1
@@ -117,6 +117,39 @@ export function machinesHtml(summary) {
|
||||
return [...by].map(([name, rows]) => machineHtml(name, rows)).join("\n");
|
||||
}
|
||||
|
||||
// The hardware breakdowns (the server's classes), in words.
|
||||
export const DIMENSIONS = [
|
||||
{ key: "ram", label: "Memory", word: (c) => `${c} RAM` },
|
||||
{ key: "vram", label: "Graphics memory", word: (c) => (c === "shared" ? "shared graphics memory" : `${c} graphics memory`) },
|
||||
{ key: "storage", label: "Drive", word: (c) => ({ nvme: "NVMe", ssd: "SSD", hdd: "hard disk" })[c] || c },
|
||||
{ key: "os", label: "System", word: (c) => ({ linux: "Linux", macos: "macOS", windows: "Windows" })[c] || c },
|
||||
{ key: "gpu_kind", label: "Graphics", word: (c) => (c === "integrated" ? "integrated GPU" : c === "discrete" ? "separate GPU" : c) },
|
||||
];
|
||||
|
||||
// A group's breakdowns in one line: "32 GB RAM (3) · NVMe (3) · Linux (3)". Empty without any.
|
||||
export function breakdownLine(breakdowns) {
|
||||
return DIMENSIONS.flatMap((d) => (breakdowns?.[d.key] || []).map((c) => `${d.word(c.class)} (${c.installs})`)).join(" · ");
|
||||
}
|
||||
|
||||
// GET /v1/aggregate's breakdowns: players' play sessions per hardware class. Empty without any.
|
||||
export function hardwareHtml(aggregate) {
|
||||
const b = aggregate?.breakdowns || {};
|
||||
const dims = DIMENSIONS.filter((d) => b[d.key]?.length);
|
||||
if (!dims.length) return "";
|
||||
const rows = dims
|
||||
.flatMap((d) =>
|
||||
b[d.key].map(
|
||||
(c, i) =>
|
||||
`<tr>${i === 0 ? `<th scope="rowgroup" rowspan="${b[d.key].length}">${escape(d.label)}</th>` : ""}<th scope="row">${escape(d.word(c.class))}</th><td class="num">${c.installs}</td><td class="num">${Math.round(c.hours).toLocaleString("en")}</td><td class="num">${format("ms", c.p50_ms)}</td><td class="num">${format("ms", c.p99_ms)}</td></tr>`,
|
||||
),
|
||||
)
|
||||
.join("");
|
||||
return `<div class="table-wrap"><table class="bench-table players-hardware">
|
||||
<thead><tr><th scope="col">By</th><th scope="col">Class</th><th scope="col">Installs</th><th scope="col">Hours played</th><th scope="col">Frame time, median</th><th scope="col">Frame time, 99th percentile</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
// Players' groups: one row each, at least `min_installs` installs.
|
||||
export function playersHtml(summary) {
|
||||
const groups = (summary?.groups || []).filter((g) => g.source === "players");
|
||||
@@ -124,7 +157,7 @@ export function playersHtml(summary) {
|
||||
return `<p class="empty">No player results yet. A GPU and CPU pair shows here once at least ${summary?.min_installs || 3} different installs have shared a benchmark with it.</p>`;
|
||||
const rows = groups
|
||||
.map(
|
||||
(g) => `<tr><th scope="row">${escape(g.gpu)}<span class="metric-hint">${escape(g.cpu)}</span></th><td class="num">${g.render_distance}</td><td>${g.game === "vanilla" ? "Java" : "Horizon"}</td>${["avg_fps", "low_1pct_fps", "p95_ms", "world_ms", "rss_steady_mb"].map((k) => `<td class="num">${format(METRICS.find((m) => m.key === k).kind, g.medians?.[k])}</td>`).join("")}<td><span class="badge badge-players">From ${g.installs} players</span></td></tr>`,
|
||||
(g) => `<tr><th scope="row">${escape(g.gpu)}<span class="metric-hint">${escape(g.cpu)}</span></th><td class="num">${g.render_distance}</td><td>${g.game === "vanilla" ? "Java" : "Horizon"}</td>${["avg_fps", "low_1pct_fps", "p95_ms", "world_ms", "rss_steady_mb"].map((k) => `<td class="num">${format(METRICS.find((m) => m.key === k).kind, g.medians?.[k])}</td>`).join("")}<td><span class="badge badge-players">From ${g.installs} players</span>${breakdownLine(g.breakdowns) ? `<span class="metric-hint">${escape(breakdownLine(g.breakdowns))}</span>` : ""}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
return `<div class="table-wrap"><table class="bench-table players-table">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// The Benchmarks page: players' results from the telemetry server (the baked summary when it doesn't answer)
|
||||
// and the hardware picker (a measurement when there is one, else an estimate from src/estimate.js).
|
||||
import { escape } from "./markdown.js";
|
||||
import { METRICS, cell, compareRows, format, playersHtml } from "./bench.js";
|
||||
import { estimate, measured } from "./estimate.js";
|
||||
import { METRICS, cell, compareRows, format, hardwareHtml, playersHtml } from "./bench.js";
|
||||
import { estimate, measured, memoryNotes } from "./estimate.js";
|
||||
import { findCpu, findGpu } from "./hardware.js";
|
||||
import { setUpMenu, setUpTheme } from "./chrome.js";
|
||||
|
||||
@@ -27,6 +27,20 @@ async function live() {
|
||||
}
|
||||
}
|
||||
|
||||
// Players' play sessions per hardware class (last 30 days); the section stays hidden until there are some.
|
||||
async function hardware() {
|
||||
try {
|
||||
const res = await fetch(`${TELEMETRY}/v1/aggregate?days=30`, { signal: AbortSignal.timeout(3000) });
|
||||
if (!res.ok) return;
|
||||
const html = hardwareHtml(await res.json());
|
||||
if (!html) return;
|
||||
document.getElementById("players-hardware-table").innerHTML = html;
|
||||
document.getElementById("players-hardware").hidden = false;
|
||||
} catch {
|
||||
// Nothing to show.
|
||||
}
|
||||
}
|
||||
|
||||
const picker = document.getElementById("picker");
|
||||
const out = document.getElementById("pick-result");
|
||||
|
||||
@@ -57,19 +71,25 @@ function render() {
|
||||
const gpu = findGpu(picker.gpu.value);
|
||||
const cpu = findCpu(picker.cpu.value);
|
||||
const rd = Number(picker.rd.value);
|
||||
const ramGb = Number(picker.ram.value) || null;
|
||||
if (!gpu || !cpu) return;
|
||||
// Horizon's and Java's notes, each once (the players' hardware is the same for both).
|
||||
const notesHtml = (...lists) => {
|
||||
const notes = [...new Set(lists.flat())];
|
||||
return notes.length ? `<ul class="pick-notes">${notes.map((n) => `<li>${escape(n)}</li>`).join("")}</ul>` : "";
|
||||
};
|
||||
const mh = measured(groups, gpu, cpu, rd, "horizon");
|
||||
const mj = measured(groups, gpu, cpu, rd, "vanilla");
|
||||
if (mh.length || mj.length) {
|
||||
const h = mh[0];
|
||||
const j = mj[0];
|
||||
out.innerHTML = `<p class="pick-head">${[h, j].filter(Boolean).map(sourceBadge).join(" ")}</p>${measuredTable(h, j)}${
|
||||
out.innerHTML = `<p class="pick-head">${[h, j].filter(Boolean).map(sourceBadge).join(" ")}</p>${measuredTable(h, j)}${notesHtml(...[h, j].filter(Boolean).map((row) => memoryNotes(row, gpu, ramGb)))}${
|
||||
h && h.source === "us" && h.measured ? `<p class="meta">Measured ${escape(h.measured)} with Horizon ${escape(h.version)}. The full table is under <a href="#measured">Measured by us</a>.</p>` : ""
|
||||
}`;
|
||||
return;
|
||||
}
|
||||
const h = estimate(groups, gpu, cpu, rd, "horizon");
|
||||
const j = estimate(groups, gpu, cpu, rd, "vanilla");
|
||||
const h = estimate(groups, gpu, cpu, rd, "horizon", { ramGb });
|
||||
const j = estimate(groups, gpu, cpu, rd, "vanilla", { ramGb });
|
||||
if (!h && !j) {
|
||||
out.innerHTML = `<p class="empty">Nothing measured at render distance ${rd} to estimate from yet.</p>`;
|
||||
return;
|
||||
@@ -77,7 +97,7 @@ function render() {
|
||||
const base = (h || j).base;
|
||||
const f = (h || j).factors;
|
||||
const times = (x) => `${x >= 1 ? x.toFixed(x >= 10 ? 0 : 1) : x.toFixed(2)}x`;
|
||||
out.innerHTML = `<p class="pick-head"><span class="badge badge-estimate">Estimate</span> From ${escape(base.source === "us" ? base.machine : `${base.gpu} with ${base.cpu}`)} (${escape(base.gpu)}, ${escape(base.cpu)}): GPU factor ${times(f.gpu)}, CPU factor ${times(f.cpu)}. <a href="#method">How</a></p>${estimateTable(h, j)}<p class="meta">Ranges, not measurements: the real figure depends on drivers, the graphics API and cooling too. Memory is the measured machine's.</p>`;
|
||||
out.innerHTML = `<p class="pick-head"><span class="badge badge-estimate">Estimate</span> From ${escape(base.source === "us" ? base.machine : `${base.gpu} with ${base.cpu}`)} (${escape(base.gpu)}, ${escape(base.cpu)}): GPU factor ${times(f.gpu)}, CPU factor ${times(f.cpu)}. <a href="#method">How</a></p>${estimateTable(h, j)}${notesHtml(h?.notes || [], j?.notes || [])}<p class="meta">Ranges, not measurements: the real figure depends on drivers, the graphics API and cooling too. Memory is the measured machine's.</p>`;
|
||||
}
|
||||
|
||||
setUpTheme();
|
||||
@@ -85,3 +105,4 @@ setUpMenu();
|
||||
picker.addEventListener("change", render);
|
||||
render();
|
||||
live();
|
||||
hardware();
|
||||
@@ -37,17 +37,15 @@
|
||||
<div class="machines">
|
||||
{{MACHINES}}
|
||||
</div>
|
||||
<div class="older-runs prose">
|
||||
<p><strong>Not yet measured against Java: the desktop (RTX 4080, Core i7-13700KF).</strong> It is in daily use; the comparison runs on it once it is free. The last figures from it are Horizon alone, from an older build (2026-10-13, offscreen, <code>tools/graphics_bench.py</code>): over the village, 2,409 FPS at render distance 8, 1,041 at 16 and 48 at 32, where that build still stuttered while loading. They are not comparable with the tables above; the picker estimates the RTX 4080 from the measured machines instead.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bench-section" id="your-hardware" aria-labelledby="pick-title">
|
||||
<h2 class="title" id="pick-title">Your hardware</h2>
|
||||
<p class="lede">Pick a graphics card and a processor. If we or at least three players have measured that pair, you see what was measured; otherwise an estimate from the nearest measured machine, as a range.</p>
|
||||
<p class="lede">Pick a graphics card, a processor and, if you know it, your memory. If we or at least three players have measured that pair, you see what was measured; otherwise an estimate from the nearest measured machine, as a range.</p>
|
||||
<form class="picker" id="picker" aria-describedby="pick-title">
|
||||
<label><span>Graphics card</span><select name="gpu" id="pick-gpu">{{GPU_OPTIONS}}</select></label>
|
||||
<label><span>Processor</span><select name="cpu" id="pick-cpu">{{CPU_OPTIONS}}</select></label>
|
||||
<label><span>Memory</span><select name="ram" id="pick-ram"><option value="" selected>Not sure</option><option value="8">8 GB</option><option value="16">16 GB</option><option value="32">32 GB</option><option value="64">64 GB or more</option></select></label>
|
||||
<fieldset class="picker-rd"><legend>Render distance</legend>
|
||||
<label><input type="radio" name="rd" value="12" checked> 12</label>
|
||||
<label><input type="radio" name="rd" value="32"> 32</label>
|
||||
@@ -58,13 +56,20 @@
|
||||
|
||||
<section class="bench-section" id="players" aria-labelledby="players-title">
|
||||
<h2 class="title" id="players-title">From players</h2>
|
||||
<p class="lede">Players who allow benchmark uploads in the launcher's data collection settings share the report of <code>horizon-client --benchmark standard</code>. Results are grouped by graphics card, processor and render distance, and a group shows only once <strong>at least 3 different installs</strong> are in it, so no one can be picked out. Only medians are published: no install ID, date, driver, memory size or screen.</p>
|
||||
<p class="lede">Players who allow benchmark uploads in the launcher's data collection settings share the report of <code>horizon-client --benchmark standard</code>. Results are grouped by graphics card, processor and render distance, and a group shows only once <strong>at least 3 different installs</strong> are in it, so no one can be picked out. Only medians are published, with how the group's installs split by memory, graphics memory, drive and system in coarse classes (each class only with at least 3 installs too): no install ID, date, driver, exact memory or screen.</p>
|
||||
<p class="meta" data-players-source>{{PLAYERS_SOURCE}}</p>
|
||||
<div id="players-table">
|
||||
{{PLAYERS}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bench-section" id="players-hardware" aria-labelledby="players-hardware-title" hidden>
|
||||
<h2 class="title" id="players-hardware-title">Players' hardware</h2>
|
||||
<p class="lede">Every play session players with performance statistics on sent in the last 30 days, split by memory, graphics memory, drive, system and integrated or separate graphics. A class shows only with at least 3 different installs. Frame times are the sessions' average median and 99th percentile, at every render distance and setting players chose, so classes differ by more than their hardware.</p>
|
||||
<p class="meta">Live from the telemetry server.</p>
|
||||
<div id="players-hardware-table"></div>
|
||||
</section>
|
||||
|
||||
<section class="bench-section prose-section" id="method" aria-labelledby="method-title">
|
||||
<h2 class="title" id="method-title">How the estimates work</h2>
|
||||
<div class="prose">
|
||||
@@ -74,6 +79,7 @@
|
||||
<li><strong>CPU factor</strong>: the ratio of the two processors' single-thread scores: top boost clock times a rough factor per chip generation (Zen 4 = 1).</li>
|
||||
<li><strong>Frame rate</strong>: when the measured run split each frame into CPU and GPU time, the slower side is scaled by its factor; otherwise by the geometric mean of both. Whatever the split, the real figure lies between the measured one scaled by the smaller and by the larger factor; we widen that by 15% for the roughness of the table, and show it as the range.</li>
|
||||
<li><strong>Load times</strong> are CPU work (generating, lighting and meshing chunks), scaled by the CPU factor. <strong>Memory</strong> is not scaled: it follows the world and the settings, not the speed.</li>
|
||||
<li><strong>Memory size</strong>: when players' results say how much memory and graphics memory their machines have, a measured group whose players have neither yours counts as further away, and the estimate says what they had. With your memory picked, it also says when the game's peak use is a large share of it.</li>
|
||||
</ul>
|
||||
<p>Estimates ignore drivers, the graphics API, thermal limits and the operating system, which can matter as much as the chip. Treat them as a rough idea, and run <code>horizon-client --benchmark standard</code> on your own machine for the real figure.</p>
|
||||
</div>
|
||||
|
||||
+38
-5
@@ -9,7 +9,10 @@
|
||||
// the roughness of the table: that is the range shown.
|
||||
// - World and chunk load times are CPU work (generation, lighting, meshing): scaled by the CPU factor.
|
||||
// - Memory is not scaled: it depends on the world and settings, not on speed.
|
||||
import { findCpu, findGpu, singleThread } from "./hardware.js";
|
||||
// - Memory size: players' groups say how their installs split by RAM and by the GPU's own memory (breakdowns). A
|
||||
// base whose players have neither the picked RAM nor the picked GPU's memory class counts as further away, and
|
||||
// the estimate says what the base's players had, and when the game's peak memory is a large share of the RAM.
|
||||
import { findCpu, findGpu, ramClass, singleThread, vramClass } from "./hardware.js";
|
||||
|
||||
const MARGIN = 0.15;
|
||||
|
||||
@@ -20,6 +23,35 @@ export function factors(fromGpu, fromCpu, toGpu, toCpu) {
|
||||
}
|
||||
|
||||
const distance = (f) => Math.abs(Math.log(f.gpu)) + Math.abs(Math.log(f.cpu));
|
||||
// Added to the distance per memory class (RAM, VRAM) the base's players don't have: about a 20% speed gap.
|
||||
const MEMORY_MISMATCH = 0.2;
|
||||
|
||||
// The classes a row's installs have in a breakdown, or null when it has none (our own rows, older summaries).
|
||||
const classesIn = (row, dim) => (row.breakdowns?.[dim]?.length ? row.breakdowns[dim].map((c) => c.class) : null);
|
||||
|
||||
function mismatch(row, gpu, ramGb) {
|
||||
let d = 0;
|
||||
const vram = classesIn(row, "vram");
|
||||
if (vram && !vram.includes(vramClass(gpu))) d += MEMORY_MISMATCH;
|
||||
const ram = classesIn(row, "ram");
|
||||
if (ram && ramGb && !ram.includes(ramClass(ramGb))) d += MEMORY_MISMATCH;
|
||||
return d;
|
||||
}
|
||||
|
||||
// What to say about memory: the base's players' RAM and VRAM when they differ from the pick, and the game's peak
|
||||
// memory against the picked RAM.
|
||||
export function memoryNotes(row, gpu, ramGb) {
|
||||
const notes = [];
|
||||
const list = (dim) => row.breakdowns[dim].map((c) => `${c.class} (${c.installs})`).join(", ");
|
||||
const vram = classesIn(row, "vram");
|
||||
if (vram && gpu && !vram.includes(vramClass(gpu))) notes.push(`The measured players' graphics cards have ${list("vram")} of their own memory; this one has ${gpu.vram_gb ? `${gpu.vram_gb} GB` : "none (it shares the system's)"}.`);
|
||||
const ram = classesIn(row, "ram");
|
||||
if (ram && ramGb && !ram.includes(ramClass(ramGb))) notes.push(`The measured players have ${list("ram")} of memory, not ${ramGb} GB.`);
|
||||
const peak = row.medians?.rss_peak_mb;
|
||||
if (ramGb && peak && peak / 1024 > ramGb * 0.4)
|
||||
notes.push(`${row.game === "vanilla" ? "Java" : "Horizon"} used up to ${(peak / 1024).toFixed(1)} GB here: with ${ramGb} GB, close other programs, or the system may swap and stutter.`);
|
||||
return notes;
|
||||
}
|
||||
|
||||
// The rows (benchmarks.json groups) usable as a base: a known GPU and CPU.
|
||||
function known(rows) {
|
||||
@@ -34,19 +66,19 @@ export function measured(rows, gpu, cpu, rd, game) {
|
||||
}
|
||||
|
||||
// The nearest measured machine for a target GPU and CPU, for one game and render distance.
|
||||
export function nearest(rows, gpu, cpu, rd, game) {
|
||||
export function nearest(rows, gpu, cpu, rd, game, { ramGb } = {}) {
|
||||
let best = null;
|
||||
for (const r of known(rows)) {
|
||||
if (r.row.render_distance !== rd || r.row.game !== game) continue;
|
||||
const f = factors(r.gpu, r.cpu, gpu, cpu);
|
||||
const d = distance(f);
|
||||
const d = distance(f) + mismatch(r.row, gpu, ramGb);
|
||||
if (!best || d < best.d) best = { ...r, f, d };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function estimate(rows, gpu, cpu, rd, game) {
|
||||
const base = nearest(rows, gpu, cpu, rd, game);
|
||||
export function estimate(rows, gpu, cpu, rd, game, { ramGb } = {}) {
|
||||
const base = nearest(rows, gpu, cpu, rd, game, { ramGb });
|
||||
if (!base) return null;
|
||||
const m = base.row.medians;
|
||||
const { gpu: g, cpu: c } = base.f;
|
||||
@@ -73,5 +105,6 @@ export function estimate(rows, gpu, cpu, rd, game) {
|
||||
chunk_load_ms: time(m.chunk_load_ms, c),
|
||||
rss_steady_mb: m.rss_steady_mb == null ? null : { value: m.rss_steady_mb },
|
||||
rss_peak_mb: m.rss_peak_mb == null ? null : { value: m.rss_peak_mb },
|
||||
notes: memoryNotes(base.row, gpu, ramGb),
|
||||
};
|
||||
}
|
||||
@@ -62,3 +62,40 @@ test("a release title of issue numbers falls back to its notes", async () => {
|
||||
assert.equal(parts(r).line, "Single player runs in the frame again and 1 more");
|
||||
assert.equal(parts({ ...r, name: "Horizon 2026.10.22.05: Hoppers, mob tick, server tick" }).line, "Hoppers, mob tick, server tick");
|
||||
});
|
||||
|
||||
test("players' memory classes steer the base and the notes", async () => {
|
||||
const { memoryNotes } = await import("./estimate.js");
|
||||
const { ramClass, vramClass } = await import("./hardware.js");
|
||||
assert.equal(vramClass(findGpu("NVIDIA GeForce RTX 4080")), "16 GB");
|
||||
assert.equal(vramClass(findGpu("NVIDIA GeForce RTX 3080")), "12 GB");
|
||||
assert.equal(vramClass(findGpu("Apple M4 Pro")), "shared");
|
||||
assert.equal(ramClass(16), "16 GB");
|
||||
// Two equally near groups: the one whose players have the picked GPU's memory class wins.
|
||||
const players = (vram, ram, fps) =>
|
||||
row("NVIDIA GeForce RTX 4070", "AMD Ryzen 5 7600X", { avg_fps: fps, rss_peak_mb: 3600 }, { source: "players", installs: 3, breakdowns: { vram: [{ class: vram, installs: 3 }], ram: [{ class: ram, installs: 3 }] } });
|
||||
const a = players("8 GB", "16 GB", 400);
|
||||
const b = { ...players("12 GB", "32 GB", 500), cpu: "AMD Ryzen 7 7800X3D" };
|
||||
const gpu = findGpu("NVIDIA GeForce RTX 4070 Ti");
|
||||
const cpu = findCpu("AMD Ryzen 5 7600X");
|
||||
assert.equal(nearest([a, b], gpu, cpu, 12, "horizon").row, b, "12 GB like the 4070 Ti, though the CPU differs");
|
||||
assert.equal(nearest([a, b], gpu, cpu, 12, "horizon", { ramGb: 16 }).row, a, "both classes count");
|
||||
assert.deepEqual(memoryNotes(b, gpu, 32), []);
|
||||
const notes = memoryNotes(a, gpu, 8);
|
||||
assert.equal(notes.length, 3);
|
||||
assert.match(notes[0], /8 GB \(3\) of their own memory; this one has 12 GB/);
|
||||
assert.match(notes[1], /16 GB \(3\) of memory, not 8 GB/);
|
||||
assert.match(notes[2], /Horizon used up to 3\.5 GB/);
|
||||
// Our own rows have no breakdowns: only the peak memory note.
|
||||
assert.deepEqual(memoryNotes(mac, gpu, 32), []);
|
||||
assert.equal(estimate([a], gpu, cpu, 12, "horizon", { ramGb: 8 }).notes.length, 3);
|
||||
});
|
||||
|
||||
test("breakdowns read as words, and the hardware table shows only with data", async () => {
|
||||
const { breakdownLine, hardwareHtml } = await import("./bench.js");
|
||||
assert.equal(breakdownLine({ ram: [{ class: "32 GB", installs: 3 }], storage: [{ class: "nvme", installs: 4 }], gpu_kind: [{ class: "discrete", installs: 3 }] }), "32 GB RAM (3) · NVMe (4) · separate GPU (3)");
|
||||
assert.equal(breakdownLine(undefined), "");
|
||||
assert.equal(hardwareHtml({ breakdowns: {} }), "");
|
||||
const html = hardwareHtml({ breakdowns: { os: [{ class: "linux", installs: 5, sessions: 9, hours: 4.5, p50_ms: 6.2, p99_ms: 14 }] } });
|
||||
assert.match(html, /Linux/);
|
||||
assert.match(html, /6\.2 ms/);
|
||||
});
|
||||
+47
-32
@@ -1,42 +1,43 @@
|
||||
// The hardware the Benchmarks page can estimate for, from specifications only (our own table, no benchmark
|
||||
// database): each GPU's FP32 shader throughput in TFLOPS and its memory bandwidth in GB/s as its maker states them,
|
||||
// its own memory in GB (vram_gb; 0 for a GPU that shares the system's: integrated, Apple silicon),
|
||||
// and each CPU's single-thread score = its top boost clock (GHz) x our rough per-generation factor (instructions per
|
||||
// clock, Zen 4 = 1). The factors are round numbers, so estimates are ranges, and the page says so.
|
||||
// `match` finds a reported name (the GPU and CPU strings in benchmark reports) in this table.
|
||||
|
||||
export const GPUS = [
|
||||
{ name: "NVIDIA GeForce RTX 4090", tflops: 82.6, gbps: 1008, match: /RTX 4090/i },
|
||||
{ name: "NVIDIA GeForce RTX 4080", tflops: 48.7, gbps: 717, match: /RTX 4080(?! SUPER)/i },
|
||||
{ name: "NVIDIA GeForce RTX 4070 Ti", tflops: 40.1, gbps: 504, match: /RTX 4070 Ti/i },
|
||||
{ name: "NVIDIA GeForce RTX 4070", tflops: 29.1, gbps: 504, match: /RTX 4070(?! Ti)/i },
|
||||
{ name: "NVIDIA GeForce RTX 4060 Ti", tflops: 22.1, gbps: 288, match: /RTX 4060 Ti/i },
|
||||
{ name: "NVIDIA GeForce RTX 4060", tflops: 15.1, gbps: 272, match: /RTX 4060(?! Ti)/i },
|
||||
{ name: "NVIDIA GeForce RTX 3080", tflops: 29.8, gbps: 760, match: /RTX 3080(?! Ti)/i },
|
||||
{ name: "NVIDIA GeForce RTX 3070", tflops: 20.3, gbps: 448, match: /RTX 3070(?! Ti)/i },
|
||||
{ name: "NVIDIA GeForce RTX 3060", tflops: 12.7, gbps: 360, match: /RTX 3060(?! Ti)/i },
|
||||
{ name: "NVIDIA GeForce RTX 3050", tflops: 9.1, gbps: 224, match: /RTX 3050/i },
|
||||
{ name: "NVIDIA GeForce RTX 2060", tflops: 6.5, gbps: 336, match: /RTX 2060(?! SUPER)/i },
|
||||
{ name: "NVIDIA GeForce GTX 1660 SUPER", tflops: 5.0, gbps: 336, match: /GTX 1660 SUPER/i },
|
||||
{ name: "NVIDIA GeForce GTX 1060 6GB", tflops: 4.4, gbps: 192, match: /GTX 1060/i },
|
||||
{ name: "NVIDIA GeForce GTX 1050 Ti", tflops: 2.1, gbps: 112, match: /GTX 1050 Ti/i },
|
||||
{ name: "AMD Radeon RX 7900 XTX", tflops: 61.4, gbps: 960, match: /RX 7900 XTX/i },
|
||||
{ name: "AMD Radeon RX 7800 XT", tflops: 37.3, gbps: 624, match: /RX 7800 XT/i },
|
||||
{ name: "AMD Radeon RX 7600", tflops: 21.8, gbps: 288, match: /RX 7600(?! XT)/i },
|
||||
{ name: "AMD Radeon RX 6700 XT", tflops: 13.2, gbps: 384, match: /RX 6700 XT/i },
|
||||
{ name: "AMD Radeon RX 6600", tflops: 8.9, gbps: 224, match: /RX 6600(?! XT)/i },
|
||||
{ name: "AMD Radeon RX 580", tflops: 6.2, gbps: 256, match: /RX 580/i },
|
||||
{ name: "AMD Radeon 780M (integrated)", tflops: 8.6, gbps: 89, match: /780M/i },
|
||||
{ name: "Steam Deck (AMD Van Gogh)", tflops: 1.6, gbps: 88, match: /VanGogh|Van Gogh|Custom GPU 0405/i },
|
||||
{ name: "Intel Arc A770", tflops: 19.7, gbps: 560, match: /Arc.*A770/i },
|
||||
{ name: "Intel Iris Xe Graphics (96 EU)", tflops: 2.1, gbps: 68, match: /Iris\(?R?\)? Xe/i },
|
||||
{ name: "Intel HD Graphics 620", tflops: 0.4, gbps: 34, match: /\bU?HD Graphics 620\b/i },
|
||||
{ name: "Apple M1", tflops: 2.6, gbps: 68, match: /^Apple M1$/i },
|
||||
{ name: "Apple M2", tflops: 3.6, gbps: 100, match: /^Apple M2$/i },
|
||||
{ name: "Apple M3", tflops: 4.1, gbps: 100, match: /^Apple M3$/i },
|
||||
{ name: "Apple M4", tflops: 4.3, gbps: 120, match: /^Apple M4$/i },
|
||||
{ name: "Apple M1 Pro (16-core GPU)", tflops: 5.2, gbps: 200, match: /^Apple M1 Pro$/i },
|
||||
{ name: "Apple M4 Pro (20-core GPU)", tflops: 8.5, gbps: 273, match: /^Apple M4 Pro$/i },
|
||||
{ name: "Apple M4 Max (40-core GPU)", tflops: 17.0, gbps: 546, match: /^Apple M4 Max$/i },
|
||||
{ name: "NVIDIA GeForce RTX 4090", tflops: 82.6, gbps: 1008, vram_gb: 24, match: /RTX 4090/i },
|
||||
{ name: "NVIDIA GeForce RTX 4080", tflops: 48.7, gbps: 717, vram_gb: 16, match: /RTX 4080(?! SUPER)/i },
|
||||
{ name: "NVIDIA GeForce RTX 4070 Ti", tflops: 40.1, gbps: 504, vram_gb: 12, match: /RTX 4070 Ti/i },
|
||||
{ name: "NVIDIA GeForce RTX 4070", tflops: 29.1, gbps: 504, vram_gb: 12, match: /RTX 4070(?! Ti)/i },
|
||||
{ name: "NVIDIA GeForce RTX 4060 Ti", tflops: 22.1, gbps: 288, vram_gb: 8, match: /RTX 4060 Ti/i },
|
||||
{ name: "NVIDIA GeForce RTX 4060", tflops: 15.1, gbps: 272, vram_gb: 8, match: /RTX 4060(?! Ti)/i },
|
||||
{ name: "NVIDIA GeForce RTX 3080", tflops: 29.8, gbps: 760, vram_gb: 10, match: /RTX 3080(?! Ti)/i },
|
||||
{ name: "NVIDIA GeForce RTX 3070", tflops: 20.3, gbps: 448, vram_gb: 8, match: /RTX 3070(?! Ti)/i },
|
||||
{ name: "NVIDIA GeForce RTX 3060", tflops: 12.7, gbps: 360, vram_gb: 12, match: /RTX 3060(?! Ti)/i },
|
||||
{ name: "NVIDIA GeForce RTX 3050", tflops: 9.1, gbps: 224, vram_gb: 8, match: /RTX 3050/i },
|
||||
{ name: "NVIDIA GeForce RTX 2060", tflops: 6.5, gbps: 336, vram_gb: 6, match: /RTX 2060(?! SUPER)/i },
|
||||
{ name: "NVIDIA GeForce GTX 1660 SUPER", tflops: 5.0, gbps: 336, vram_gb: 6, match: /GTX 1660 SUPER/i },
|
||||
{ name: "NVIDIA GeForce GTX 1060 6GB", tflops: 4.4, gbps: 192, vram_gb: 6, match: /GTX 1060/i },
|
||||
{ name: "NVIDIA GeForce GTX 1050 Ti", tflops: 2.1, gbps: 112, vram_gb: 4, match: /GTX 1050 Ti/i },
|
||||
{ name: "AMD Radeon RX 7900 XTX", tflops: 61.4, gbps: 960, vram_gb: 24, match: /RX 7900 XTX/i },
|
||||
{ name: "AMD Radeon RX 7800 XT", tflops: 37.3, gbps: 624, vram_gb: 16, match: /RX 7800 XT/i },
|
||||
{ name: "AMD Radeon RX 7600", tflops: 21.8, gbps: 288, vram_gb: 8, match: /RX 7600(?! XT)/i },
|
||||
{ name: "AMD Radeon RX 6700 XT", tflops: 13.2, gbps: 384, vram_gb: 12, match: /RX 6700 XT/i },
|
||||
{ name: "AMD Radeon RX 6600", tflops: 8.9, gbps: 224, vram_gb: 8, match: /RX 6600(?! XT)/i },
|
||||
{ name: "AMD Radeon RX 580", tflops: 6.2, gbps: 256, vram_gb: 8, match: /RX 580/i },
|
||||
{ name: "AMD Radeon 780M (integrated)", tflops: 8.6, gbps: 89, vram_gb: 0, match: /780M/i },
|
||||
{ name: "Steam Deck (AMD Van Gogh)", tflops: 1.6, gbps: 88, vram_gb: 0, match: /VanGogh|Van Gogh|Custom GPU 0405/i },
|
||||
{ name: "Intel Arc A770", tflops: 19.7, gbps: 560, vram_gb: 16, match: /Arc.*A770/i },
|
||||
{ name: "Intel Iris Xe Graphics (96 EU)", tflops: 2.1, gbps: 68, vram_gb: 0, match: /Iris\(?R?\)? Xe/i },
|
||||
{ name: "Intel HD Graphics 620", tflops: 0.4, gbps: 34, vram_gb: 0, match: /\bU?HD Graphics 620\b/i },
|
||||
{ name: "Apple M1", tflops: 2.6, gbps: 68, vram_gb: 0, match: /^Apple M1$/i },
|
||||
{ name: "Apple M2", tflops: 3.6, gbps: 100, vram_gb: 0, match: /^Apple M2$/i },
|
||||
{ name: "Apple M3", tflops: 4.1, gbps: 100, vram_gb: 0, match: /^Apple M3$/i },
|
||||
{ name: "Apple M4", tflops: 4.3, gbps: 120, vram_gb: 0, match: /^Apple M4$/i },
|
||||
{ name: "Apple M1 Pro (16-core GPU)", tflops: 5.2, gbps: 200, vram_gb: 0, match: /^Apple M1 Pro$/i },
|
||||
{ name: "Apple M4 Pro (20-core GPU)", tflops: 8.5, gbps: 273, vram_gb: 0, match: /^Apple M4 Pro$/i },
|
||||
{ name: "Apple M4 Max (40-core GPU)", tflops: 17.0, gbps: 546, vram_gb: 0, match: /^Apple M4 Max$/i },
|
||||
];
|
||||
|
||||
// ghz: top single-core boost clock; ipc: our per-generation factor (Zen 4 = 1).
|
||||
@@ -71,3 +72,17 @@ export const CPUS = [
|
||||
export const singleThread = (cpu) => cpu.ghz * cpu.ipc;
|
||||
export const findGpu = (name) => GPUS.find((g) => g.name === name || g.match.test(name || "")) || null;
|
||||
export const findCpu = (name) => CPUS.find((c) => c.name === name || c.match.test(name || "")) || null;
|
||||
|
||||
// The telemetry server's hardware classes (crates/horizon-telemetry-server/src/classes.rs), which players'
|
||||
// results are broken down by: memory and a GPU's own memory in coarse steps.
|
||||
export function ramClass(gb) {
|
||||
if (!(gb > 0)) return null;
|
||||
return gb <= 5 ? "4 GB or less" : gb <= 10 ? "8 GB" : gb <= 20 ? "16 GB" : gb <= 40 ? "32 GB" : "64 GB or more";
|
||||
}
|
||||
export function vramClass(gpu) {
|
||||
if (!gpu) return null;
|
||||
if (!gpu.vram_gb) return "shared";
|
||||
const mb = gpu.vram_gb * 1024;
|
||||
for (const [max, name] of [[2560, "2 GB or less"], [4608, "4 GB"], [6656, "6 GB"], [8704, "8 GB"], [12800, "12 GB"], [16896, "16 GB"]]) if (mb <= max) return name;
|
||||
return "more than 16 GB";
|
||||
}
|
||||
@@ -26,9 +26,19 @@
|
||||
<li><strong>Performance statistics</strong>, after each play session of a minute or more. These are the frame-time and tick-time percentiles and a 14-bucket histogram (never a recording of each frame), the peak memory, how long the game took to start and to load the world, and the settings that change performance (render and simulation distance, frame limit, VSync, fullscreen, graphics mode).</li>
|
||||
<li><strong>Crash reports</strong>, when the game crashes. They hold the error, where it happened in Horizon's code, your graphics adapter and settings. They hold no log, no world, no server and no resource packs.</li>
|
||||
<li><strong>Bug reports</strong> that you write and send yourself. The end of the game's log goes with one only if you add it after reading it, and lines of chat are removed first.</li>
|
||||
<li><strong>Benchmark uploads</strong> of the benchmarks you run. They are shown publicly on the <a href="/benchmarks">Benchmarks page</a>, only as medians of groups of at least 3 different installs with the same graphics card, processor and render distance; never one by one, and never with your install ID, the day, your OS version, driver, memory or screen.</li>
|
||||
<li><strong>Benchmark uploads</strong> of the benchmarks you run. They are shown publicly on the <a href="/benchmarks">Benchmarks page</a>, only as medians of groups of at least 3 different installs with the same graphics card, processor and render distance; never one by one, and never with your install ID, the day, your OS version, driver, exact memory or screen.</li>
|
||||
</ul>
|
||||
<p>Each one also carries the Horizon version, the day it was made (not the time), and a description of your machine: the operating system and version, the CPU model and thread count, the memory rounded to the gigabyte, and the GPU, its driver and the graphics backend. It also carries a <strong>random install ID</strong>. The launcher makes that ID the first time you allow something; it says nothing about you and exists only so that your data can be deleted.</p>
|
||||
<p>Each one also carries the Horizon version, the day it was made (not the time), and a description of your machine, because that is what explains how fast the game runs on it:</p>
|
||||
<ul>
|
||||
<li>the operating system, its version and the processor architecture;</li>
|
||||
<li>the processor model, its cores and threads, and its base and boost clock where the system gives them;</li>
|
||||
<li>the memory, rounded to the gigabyte;</li>
|
||||
<li>the graphics card the game draws with: its model, maker, whether it is built into the processor or separate, its own memory, its driver version and the graphics interface (Vulkan, Metal, DirectX 12);</li>
|
||||
<li>the kind of drive the game folder is on (NVMe, SSD or hard disk);</li>
|
||||
<li>the screen the game opened on: its size in pixels, refresh rate and scaling;</li>
|
||||
<li>whether the computer is a laptop or a desktop, and whether it runs on battery.</li>
|
||||
</ul>
|
||||
<p>No serial number, computer name, user name, network address or file path is read. Each item also carries a <strong>random install ID</strong>. The launcher makes that ID the first time you allow something; it says nothing about you and exists only so that your data can be deleted.</p>
|
||||
<p>Never sent: your Microsoft or Minecraft account name or UUID, world names, server or LAN addresses, chat, and your user name or home folder in file paths. Before anything is kept, those are replaced with placeholders, along with e-mail and IP addresses. The server does not store the IP address requests come from.</p>
|
||||
<p>The launcher shows exactly what would be sent: Settings → Data collection → View what would be sent, or <code>horizon-launcher data show</code>. Every field is listed in <a href="https://github.com/ludovic111/horizon/blob/main/docs/telemetry.md">docs/telemetry.md</a>.</p>
|
||||
|
||||
@@ -44,7 +54,7 @@
|
||||
<p>For the switches: your consent (Article 6(1)(a) of the EU's General Data Protection Regulation). It is given kind by kind, recorded on your computer with the version of the question you answered and when, and withdrawn at any time with the same switches. Withdrawing deletes what was waiting to be sent. When what is collected changes, the launcher asks again, and nothing new is sent until you answer. The ping holds nothing that identifies you; the address it comes from is used only to limit how often one address may send, kept in memory for an hour at most and never stored.</p>
|
||||
|
||||
<h2>Where and how long</h2>
|
||||
<p>Items wait on your computer (<code>telemetry/</code> in the game folder) and are sent compressed when the launcher starts or closes, to Horizon's own server, <code>telemetry.playhorizon.dev</code>, hosted by <a href="https://railway.com">Railway</a> in the European Union (Amsterdam, the Netherlands). The server keeps them for <strong>180 days</strong>, then deletes them. What stays after that are anonymous totals per day, Horizon version, operating system, GPU maker and render distance (for example "120 sessions on macOS with Apple GPUs at render distance 12"). They hold no install ID, and groups too small to be anonymous are never shown.</p>
|
||||
<p>Items wait on your computer (<code>telemetry/</code> in the game folder) and are sent compressed when the launcher starts or closes, to Horizon's own server, <code>telemetry.playhorizon.dev</code>, hosted by <a href="https://railway.com">Railway</a> in the European Union (Amsterdam, the Netherlands). The server keeps them for <strong>180 days</strong>, then deletes them. What stays after that are anonymous totals per day, Horizon version, operating system, GPU maker and render distance (for example "120 sessions on macOS with Apple GPUs at render distance 12"). They hold no install ID, and groups too small to be anonymous are never shown. Published results also show how players split by memory size (in steps: 8, 16, 32 GB…), graphics memory, drive type, operating system and built-in or separate graphics, each class only once it holds at least 3 different installs; the exact values, the driver, the clocks and the screen are never published.</p>
|
||||
|
||||
<h2>Deleting your data</h2>
|
||||
<p>Settings → Data collection → Delete my data, or <code>horizon-launcher data delete</code>. The server deletes everything sent under your install ID, your share of the totals is recomputed, anything waiting on your computer is removed, and you get a new install ID. You can also turn everything off, or delete the <code>telemetry/</code> folder.</p>
|
||||
|
||||
@@ -723,6 +723,8 @@ code { border-radius: 0; background: var(--well); box-shadow: 0 0 0 1px var(--ou
|
||||
.pick-result { margin-top: 16px; padding: 20px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); }
|
||||
.pick-head { margin-bottom: 12px; font-size: var(--text-caption); color: var(--ink-soft); line-height: 1.8; }
|
||||
.pick-result .meta { margin-top: 12px; }
|
||||
.pick-notes { margin: 12px 0 0; padding-left: 20px; font-size: var(--text-caption); color: var(--ink-soft); line-height: 1.6; }
|
||||
.players-hardware th[scope="rowgroup"] { vertical-align: top; }
|
||||
.empty { color: var(--muted); }
|
||||
.prose-section .prose { max-width: 80ch; }
|
||||
.spec-table { margin-top: 24px; }
|
||||
@@ -740,4 +742,3 @@ code { border-radius: 0; background: var(--well); box-shadow: 0 0 0 1px var(--ou
|
||||
.release-detail { padding-left: 0; margin-left: 0; }
|
||||
.machine { padding: 16px; }
|
||||
}
|
||||
.older-runs { margin-top: 16px; max-width: 90ch; padding: 16px 20px; background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); font-size: var(--text-caption); }
|
||||
Reference in new issue
Block a user