Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 4e22c1ad9a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:27:08 +01:00
Horizon agentandClaude Opus 5.5 e1aa435188 horizon-discord: the Discord switch leaves the engine's Extras (layer plan M5)
The crate keeps its switch: a settings store for its discordPresence line of
horizon-extras.txt (players keep their choice) and a row in the Extras page's
Multiplayer group, which horizon-official registers. The engine loses
Extras::discord_presence, Item::DiscordPresence and the Horizon Settings
button; the hooks lose Activity::Hidden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:19:29 +01:00
Horizon agentandClaude Opus 5.5 5385996153 Merge #14107 (split/official-servers-crate) into split/discord-crate
Stacks the Discord crate on the official-servers crate: horizon-official
registers four parts; horizon-client-lib has no horizon-extras feature left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:08:57 +01:00
Horizon agent 115f5235b9 Merge remote-tracking branch 'origin/main' into split/official-servers-crate
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-official/Cargo.toml
#	crates/horizon-official/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:24:48 +01:00
Horizon agentandClaude Opus 5.5 cef35e64e7 horizon-hooks: std's Mutex by its path after the merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:32:42 +01:00
Horizon agent 3a9b05d21d Merge remote-tracking branch 'origin/main' into split/discord-crate
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-client/Cargo.toml
2026-11-09 19:31:04 +01:00
Horizon agent 0f0ca381d4 Merge remote-tracking branch 'origin/main' into split/official-servers-crate
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 19:14:23 +01:00
Horizon agentandClaude Opus 5.5 8ac6a79035 rustfmt, Cargo.lock: horizon-discord
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:19:44 +01:00
Horizon agentandClaude Opus 5.5 153e0d20a4 horizon-discord: the hook's Client by its path
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:18:32 +01:00
Horizon agentandClaude Opus 5.5 01343707af horizon-discord: Discord Rich Presence leaves the engine for a Horizon crate
Layer plan M5: discord_presence.rs moves to the new Horizon crate
horizon-discord (file move), which the official executable registers on two
new platform hooks: Hooks::client_started (every game window, automated
runs included, never a dedicated server) and Hooks::activity (what the
player does, dispatched on change only, P1's). game/discord.rs becomes the
engine's game/activity.rs, which reports Activity (Hidden while the Discord
switch is off, until the switch moves to the settings store). The discord
Cargo feature goes: horizon-client's own executable has no presence.
discord_presence_e2e.py runs the official executable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:11:42 +01:00
Horizon agentandClaude Opus 5.5 466201a864 Cargo.lock: horizon-official-servers after the merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:06:31 +01:00
Horizon agent ddb4f9df58 Merge branch 'split/official-servers-module' into split/official-servers-crate
# Conflicts:
#	Cargo.lock
#	docs/forking.md
2026-11-09 18:02:48 +01:00
Horizon agentandClaude Opus 5.5 8e2780a8f0 Cargo.lock: horizon-official-servers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:15:07 +01:00
Horizon agentandClaude Opus 5.5 2cd4ec9888 horizon-official-servers: the official server leaves the engine for a Horizon crate
Layer plan M5: official_servers.rs moves (pure file move plus its hook) to
the new Horizon crate horizon-official-servers, which the official
executable registers through the platform's hooks: Hooks::started now gets
the game folder (Started::game_dir), and horizon-platform re-exports
servers.dat as horizon_platform::servers. Its addresses come from
horizon-addresses: the withdrawn partner's joins the list file, so its
allowed entry in tools/addresses.json goes. horizon-client's own executable
(plain builds, harnesses) no longer touches servers.dat at startup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:24 +01:00
70 changed files with 1057 additions and 1488 deletions

No files matched your search

Generated
+20 -2
View File
@@ -1650,6 +1650,15 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
dependencies = [
"horizon-platform",
"log",
"serde_json",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
@@ -1793,15 +1802,25 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-discord",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-platform",
"horizon-telemetry",
"log",
"winresource",
]
[[package]]
name = "horizon-official-servers"
version = "0.1.0"
dependencies = [
"horizon-addresses",
"horizon-platform",
"log",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
@@ -1971,7 +1990,6 @@ dependencies = [
"getrandom 0.3.4",
"horizon-addresses",
"horizon-platform",
"log",
"ruzstd",
"serde",
"serde_json",
+2
View File
@@ -34,6 +34,8 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
+4
View File
@@ -15,6 +15,10 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// Afri-Network, a partner server the game added once below the official server (#9299) until
/// the partnership ended: kept so the game can take back out the entry it added.
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
+1 -6
View File
@@ -109,17 +109,12 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers", "horizon-extras"]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
# server in the list (`official_servers`).
horizon-extras = ["discord"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
+1 -2
View File
@@ -7,8 +7,7 @@ rust-version.workspace = true
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
# The crates above the engine: their settings stores (docs/layers.md, "Platform registries"),
# and the hooks that hear each crash (`crash.rs`).
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
+20 -32
View File
@@ -204,23 +204,6 @@ impl Report {
}
}
/// The renderer's adapter ([`crate::gfx::adapter`]), for the hooks above the engine.
pub fn gpu() -> Option<horizon_hooks::Gpu> {
crate::gfx::adapter().map(|a| horizon_hooks::Gpu {
name: a.name,
vendor: a.vendor,
kind: a.kind,
driver: a.driver,
backend: a.backend,
})
}
/// The build, for what leaves the machine: the release (`v0.2.3`), or `dev <crate version>` for a
/// build from source.
pub fn build() -> String {
release_tag().unwrap_or_else(|| format!("dev {}", env!("CARGO_PKG_VERSION")))
}
/// The release this binary is (`v0.2.3`); a nightly's label (`Nightly 2026-10-28 · 0c02067`); for a
/// release from before 0.2, its tag and the client's crate version; for a build from source, that
/// version alone.
@@ -400,20 +383,19 @@ fn thread_name(side: &str) -> String {
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
/// The hooks above the engine hear the report before it is saved (`Hooks::crashed`; Horizon's
/// opt-in telemetry keeps it). They run inside the crash handler: they must not panic or block.
fn tell_hooks(home: &Path, report: &Report) {
let version = build();
let gpu = gpu();
horizon_hooks::engine::crashed(&horizon_hooks::Crash {
home,
version: &version,
error: &report.error,
stack: &report.stack,
thread: &report.thread,
details: &report.details,
gpu: gpu.as_ref(),
});
/// What runs on each report before it is saved ([`on_report`]).
pub type ReportHook = fn(&Path, &Report);
static REPORT_HOOKS: std::sync::RwLock<Vec<ReportHook>> = std::sync::RwLock::new(Vec::new());
/// Runs `hook` with every crash report, before it is saved: the game folder and the report. An
/// extension point for what lives above the engine (Horizon's opt-in telemetry keeps the
/// report). A hook runs inside the crash handler: it must not panic or block.
pub fn on_report(hook: ReportHook) {
REPORT_HOOKS
.write()
.unwrap_or_else(|e| e.into_inner())
.push(hook);
}
/// Writes the report and tells where it is; the log gets the error too.
@@ -436,7 +418,13 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
),
);
crate::logging::flush();
tell_hooks(home, &report);
for hook in REPORT_HOOKS
.read()
.unwrap_or_else(|e| e.into_inner())
.iter()
{
hook(home, &report);
}
// A player of a shared game folder (`--player-dir`) finds the report beside its own log.
match report.save(crate::logging::player_dir_override().unwrap_or(home)) {
Ok(path) => {
+6 -23
View File
@@ -2,7 +2,7 @@
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
//! (No AI, Agent Control) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
@@ -145,8 +145,6 @@ pub struct Extras {
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
@@ -233,7 +231,6 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
@@ -338,7 +335,6 @@ impl Extras {
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
@@ -376,7 +372,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -418,7 +414,6 @@ impl Extras {
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
@@ -613,8 +608,6 @@ pub enum Item {
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
@@ -691,7 +684,6 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
@@ -724,7 +716,6 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
@@ -768,7 +759,6 @@ impl Item {
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
@@ -792,10 +782,9 @@ impl Item {
ChatIndicator => ChatAnimation,
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
return None;
}
})
@@ -844,9 +833,6 @@ impl Item {
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
@@ -927,7 +913,6 @@ impl Item {
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
@@ -986,7 +971,6 @@ impl Item {
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
@@ -1104,7 +1088,7 @@ mod tests {
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles && !d.pregen);
assert!(!d.music_page && d.music_off.is_empty());
assert!(d.discord_presence && !d.wheel_cycles_settings);
assert!(!d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -1143,7 +1127,6 @@ mod tests {
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
+4 -4
View File
@@ -231,8 +231,8 @@ pub struct FogInputs<'a> {
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
/// The packs' `environment` fog settings (Polytone's).
pub environment: crate::pack_palette::Environment,
/// The packs' Polytone `environment` fog settings.
pub environment: horizon_polytone::colors::Environment,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -581,7 +581,7 @@ mod tests {
let tables = FogTables {
water_fog_end: vec![1.0],
};
let environment = crate::pack_palette::Environment {
let environment = horizon_polytone::colors::Environment {
powder_snow_fog: Some(0xFF0000),
blindness_fog_distance: 40.0,
water_fog_brightening: 0.0,
@@ -613,7 +613,7 @@ mod tests {
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: crate::pack_palette::Environment,
environment: horizon_polytone::colors::Environment,
) -> FogInputs<'a> {
FogInputs {
environment,
+5 -8
View File
@@ -13664,7 +13664,6 @@ mod tests {
Some(Reported {
paper_white: 240,
peak: 998,
peak_guessed: true,
})
);
assert_eq!(
@@ -13672,7 +13671,6 @@ mod tests {
Some(Reported {
paper_white: 480,
peak: 480,
peak_guessed: true,
})
);
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
@@ -13681,7 +13679,6 @@ mod tests {
Some(Reported {
paper_white: 456,
peak: 456,
peak_guessed: true,
})
);
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
@@ -15372,9 +15369,11 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
});
}
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
/// white level the brightness slider sets), which is HDR output's scale there: SDR
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
/// the settings' ranges. The OS's figures often come from the display's EDID and may
/// run optimistic; the player's own value always wins.
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
let luminance = info.luminance?;
let usable = |n: f32| n.is_finite() && n > 0.0;
@@ -15386,7 +15385,6 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
// Windows' SDR brightness slider can set white above the reported peak (480 against
// a calibrated 380): the highlights then stay at white instead of dropping below it.
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
peak_guessed: true,
})
}
@@ -15422,7 +15420,6 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
crate::graphics::Reported {
paper_white: 203,
peak: peak.clamp(200, 10_000),
peak_guessed: false,
}
}
@@ -300,7 +300,6 @@ pub(crate) mod tests {
luminance: Some(Reported {
paper_white: 400,
peak: 1000,
peak_guessed: false,
}),
hdr: system,
},
@@ -651,7 +650,6 @@ pub(crate) mod tests {
window_reading.luminance = Some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
});
assert_eq!(window_output(window_reading).nits[..2], [300.0, 800.0]);
assert_eq!(
@@ -549,7 +549,6 @@ mod tests {
reading.luminance = Some(Reported {
paper_white: 250,
peak: 600,
peak_guessed: false,
});
// 150 nits under the reported paper white of 250: 0.6 of it.
settings.hdr_ui_nits = 150;
@@ -585,7 +584,6 @@ mod tests {
reading.luminance = Some(Reported {
paper_white,
peak: 1000,
peak_guessed: false,
});
let next = candidate(&settings, caps, reading, &published);
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
@@ -731,7 +729,6 @@ mod tests {
luminance: (hdr == Some(true)).then_some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
}),
hdr,
};
@@ -30,22 +30,6 @@ fn marker_driver(text: &str) -> &str {
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
pub peak_guessed: bool,
}
/// Whether HDR is set up for the display, and whether the player kept the system's setup.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HdrSetup {
/// The system says nothing: the player calibrates.
Unknown,
/// The system says, but only guesses the peak: the player calibrates.
Guessed,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
@@ -1223,15 +1207,6 @@ impl Graphics {
n => n,
}
}
/// How HDR is set up for the display the system `reported`.
pub fn hdr_setup(&self, reported: Option<Reported>) -> HdrSetup {
match reported {
None => HdrSetup::Unknown,
Some(_) if self.hdr_peak != 0 || self.hdr_paper_white != 0 => HdrSetup::Changed,
Some(r) if r.peak_guessed => HdrSetup::Guessed,
Some(_) => HdrSetup::Automatic,
}
}
/// The black level, in nits. Not the system's: files from before saved 0 for that
/// (Auto), and a reported black would lift every display's shadows by default.
pub fn black_level_nits(&self) -> f32 {
@@ -1345,7 +1320,6 @@ mod tests {
let reported = Some(Reported {
paper_white: 300,
peak: 1000,
peak_guessed: false,
});
assert_eq!(g.ui_nits(reported), 300);
g.hdr_paper_white = 120;
@@ -1354,33 +1328,6 @@ mod tests {
assert_eq!((g.ui_nits(None), g.ui_nits(reported)), (400, 400));
}
#[test]
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
let wayland = Reported {
paper_white: 203,
peak: 1015,
peak_guessed: false,
};
let windows = Reported {
paper_white: 400,
peak: 1000,
peak_guessed: true,
};
let mut g = Graphics::default();
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
// Auto still takes the guess, over the 1000-nit default.
assert_eq!(g.peak_nits(Some(windows)), 1000);
assert_eq!(g.paper_white_nits(Some(windows)), 400);
for (peak, paper_white) in [(800, 0), (0, 240)] {
(g.hdr_peak, g.hdr_paper_white) = (peak, paper_white);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
}
}
#[test]
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
+23 -11
View File
@@ -8,8 +8,7 @@ use crate::{
gfx::EffectiveOutput,
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode,
Preset, Upscaling,
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
},
graphics_style::Cost,
lang,
@@ -676,9 +675,28 @@ const LOD_STEPS: [u32; 24] = [
crate::lod::MAX_CHUNKS,
];
/// Whether the system set HDR up for the display, and whether the player kept it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Setup {
/// The system says nothing: the player calibrates.
Unknown,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// `out`: the output of the renderer the settings are shown for, with what its display reports.
fn setup(s: &Settings, out: &EffectiveOutput) -> Setup {
s.horizon.hdr_setup(out.reported)
setup_of(&s.horizon, out.reported.is_some())
}
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
if !reported {
Setup::Unknown
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
Setup::Automatic
} else {
Setup::Changed
}
}
/// What the HDR page says while HDR output is held back on this window's graphics driver
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
@@ -696,19 +714,13 @@ fn description(
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
"Your display's reported peak is often too high: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
"Already set up for your display. Only calibrate if HDR looks wrong."
}
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
{
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::GpuCulling), _) if s.horizon.gpu_culling == GpuCullingMode::Auto => {
@@ -932,7 +944,7 @@ impl H {
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
H::Hdr => labelled("HDR", g.hdr.label()),
H::HdrCalibrate => Text::literal(lang::get(match setup(s, out) {
Setup::Unknown | Setup::Guessed => "Calibrate...",
Setup::Unknown => "Calibrate...",
Setup::Automatic => "Calibrate Anyway...",
Setup::Changed => "Use Automatic Setup",
})),
@@ -120,86 +120,14 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
assert_eq!(label(H::HdrCalibrate, &silent), "Calibrate...");
}
/// The setup rows' label and description for each kind of report: none, a compositor's
/// (Wayland), and Windows', whose peak is the display's own guess.
#[test]
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
let trusted = offering(true, Some(true));
let mut guessed = trusted;
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
peak_guessed: true,
..r
});
let rows = |s: &Settings, out: &EffectiveOutput| {
[H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite].map(|h| {
let item = Item::Horizon(h);
(item.label(s, out).plain(), description(item, s, out, "-"))
})
};
let calibrate = "Calibrate...";
let set_up = "Set up for your display. Leave on Auto unless it looks wrong.";
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let none = (
calibrate.into(),
"Your display didn't say how bright it gets: calibrate to set it up.",
);
assert_eq!(
rows(&s, &NONE),
[
none.clone(),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (203)".into(), "-"),
]
);
assert_eq!(
rows(&s, &guessed),
[
(
calibrate.into(),
"Your display's reported peak is often too high: calibrate to set it up."
),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (400)".into(), "-"),
]
);
assert_eq!(
rows(&s, &trusted),
[
(
"Calibrate Anyway...".into(),
"Already set up for your display. Only calibrate if HDR looks wrong."
),
("Peak Brightness: Auto (1000)".into(), set_up),
("Paper White: Auto (400)".into(), set_up),
]
);
// Calibrated or set by hand, either report offers its automatic setup back.
s.horizon.hdr_peak = 800;
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
let changed = |peak| {
[
(
"Use Automatic Setup".into(),
"Puts Peak Brightness and Paper White back to your display's setup.",
),
("Peak Brightness: 800".into(), peak),
("Paper White: Auto (400)".into(), set_up),
]
};
assert_eq!(rows(&s, &guessed), changed("-"));
assert_eq!(rows(&s, &trusted), changed(set_up));
assert_eq!(rows(&s, &NONE)[0], none);
let mut ui = ui();
ui.screen.output = guessed;
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
assert_eq!(press(&mut ui, &mut s, at(calibrate)), [CLICK, CHANGED]);
assert_eq!((s.horizon.hdr_peak, s.horizon.hdr_paper_white), (0, 0));
assert_eq!(
press(&mut ui, &mut s, at(calibrate)),
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
);
fn hdr_setup_follows_what_the_system_reported() {
let mut g = crate::graphics::Graphics::default();
assert_eq!(setup_of(&g, false), Setup::Unknown);
assert_eq!(setup_of(&g, true), Setup::Automatic);
g.hdr_paper_white = 80;
assert_eq!(setup_of(&g, true), Setup::Changed);
assert_eq!(setup_of(&g, false), Setup::Unknown);
}
/// UI Brightness reads as Paper White does, in nits apart from it: Auto shows the paper white
+1
View File
@@ -67,6 +67,7 @@ mod jei_sprites;
pub mod jigsaw_block;
pub mod keypad;
pub mod terrain_model;
pub use horizon_polytone::colors as polytone_colors;
pub use horizon_polytone::creative_tabs as polytone_creative_tabs;
pub use horizon_world::json_folder;
pub mod level_loading;
+2 -2
View File
@@ -2593,7 +2593,8 @@ impl Lod {
self.returned_at = std::time::Instant::now();
let nodes = &self.plan.nodes;
self.mesh_samples.retain(|key| nodes.contains(key));
// Queued tiles the view left are not sampled; a running one is dropped (`finish_sample`).
// Queued tiles the view left are not sampled (a running one is
// dropped when it finishes, `finish_sample`).
drop_queued_samples(&self.shared, &mut self.pending, |key| {
self.plan.nodes.contains(key)
});
@@ -2679,7 +2680,6 @@ impl Lod {
}
if let Some((columns, mask)) = self.kept.take(&key) {
self.ready.insert(key, columns);
self.sync_sample(key);
if let Some(mask) = mask {
self.detail_masks.insert(key, mask);
}
@@ -124,21 +124,15 @@ impl OptionsUi {
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai)
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
)
.collect();
if s.extras.no_ai {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
} else {
Row::Buttons(
open("horizon.pages.open.agent", Page::Agent),
Some(self.extra_button(s, crate::extras::Item::NoAi)),
)
},
];
self.added_entries(&mut rows);
rows
}
@@ -1,67 +0,0 @@
//! Polytone's custom colours (`horizon_polytone::colors`, `colors.json` or `color.properties`)
//! as the engine's pack colours ([`crate::pack_palette::PackColors`]).
use std::path::Path;
use horizon_polytone::colors::PackColors;
use crate::block_entity_render::DYES;
use crate::pack_palette::{self, Environment};
/// The colours of every namespace's file under the pack overlay at `root`.
pub fn read(root: &Path) -> pack_palette::PackColors {
let colors = PackColors::read(root);
let by_dye = |f: &dyn Fn(&str) -> Option<u32>| std::array::from_fn(|i| f(DYES[i].0));
let env = colors.env;
pack_palette::PackColors {
dye_diffuse: by_dye(&|name| colors.dye(name, "diffuse")),
dye_text: by_dye(&|name| colors.dye(name, "text")),
sheep: by_dye(&|name| colors.sheep(name)),
redstone: std::array::from_fn(|i| colors.redstone(i as u8)),
effects: colors.effects(),
entity_shadows: colors.entity_shadows(),
xp_bar: colors.xp_bar(),
env: Environment {
powder_snow_fog: env.powder_snow_fog,
blindness_fog_distance: env.blindness_fog_distance,
darkness_fog_distance: env.darkness_fog_distance,
boss_fog_distance: env.boss_fog_distance,
water_fog_brightening: env.water_fog_brightening,
water_fog_brightening_time: env.water_fog_brightening_time,
horizon_height: env.horizon_height,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pack_palette::{diffuse, redstone, sheep_wool, text};
#[test]
fn a_pack_recolours_only_what_it_names() {
// Dyes no other test draws: the table is process-wide.
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
let polytone = dir.join("assets/obs/polytone");
std::fs::create_dir_all(&polytone).unwrap();
std::fs::write(
polytone.join("colors.json"),
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
)
.unwrap();
crate::pack_palette::set(&read(&dir));
let (cyan, pink, purple) = (9, 6, 10);
assert_eq!(diffuse(cyan), 0x00ff00);
assert_eq!(text(cyan), 0x0000ff);
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
assert_eq!(sheep_wool(cyan as u8), 0x107575);
assert_eq!(diffuse(purple), 0x8932B8);
assert_eq!(text(purple), 0xA020F0);
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
crate::pack_palette::set(&Default::default());
assert_eq!(redstone(7), None);
assert_eq!(diffuse(cyan), 0x169C9C);
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -1,146 +0,0 @@
//! Polytone's item modifiers' durability bar colours, on the engine's
//! [`crate::pack_palette::ItemBars`].
use std::path::Path;
use std::sync::Arc;
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
struct DamageBar {
targets: horizon_polytone::Targets,
row: Vec<u32>,
}
/// The packs' damage bars, the last read first.
struct Bars(Vec<DamageBar>);
impl crate::pack_palette::ItemBars for Bars {
fn color(&self, item: &str, damage: f32) -> Option<u32> {
let bar = self
.0
.iter()
.rev()
.find(|b| b.targets.matches(item, |_| false))?;
let last = (bar.row.len() - 1) as f32;
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
}
}
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read. `None` without any.
pub fn read(root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
let mut bars = Vec::new();
for namespace in std::fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten()
{
let base = namespace.path().join("polytone/item_modifiers");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Some(bar) = item_bar(root, &base, &path) {
bars.push(bar);
}
}
}
}
(!bars.is_empty()).then(|| Arc::new(Bars(bars)) as _)
}
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
let name = path.file_name()?.to_str()?;
let row = |png: &Path| {
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
let row: Vec<u32> = rgba[..width as usize * 4]
.as_chunks::<4>()
.0
.iter()
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
.collect();
(!row.is_empty()).then_some(row)
};
if let Some(stem) = name.strip_suffix("_bar.png") {
// A json of the same name owns its png.
if path.with_file_name(format!("{stem}.json")).exists() {
return None;
}
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
return Some(DamageBar {
targets,
row: row(path)?,
});
}
let stem = name.strip_suffix(".json")?;
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
if json.get("bar_color")?.as_str()? != "damage" {
return None;
}
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
.into_iter()
.map(|png| path.with_file_name(png))
.find(|png| png.exists())?;
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
let targets =
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
Some(DamageBar {
targets,
row: row(&png)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::fs::File::create(path).unwrap();
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
encoder.set_color(png::ColorType::Rgba);
let data: Vec<u8> = pixels
.iter()
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&data)
.unwrap();
}
#[test]
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
std::fs::write(
modifiers.join("tools.json"),
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
)
.unwrap();
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
let bars = read(&dir).unwrap();
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
assert_eq!(
bars.color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
Some(0xFF0000)
);
assert_eq!(
bars.color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
Some(0xFFFF00)
);
assert_eq!(bars.color("minecraft:iron_axe", 0.9), Some(0x040506));
assert_eq!(bars.color("minecraft:diamond_shovel", 0.5), None);
assert!(read(&dir.join("none")).is_none());
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -3,9 +3,7 @@
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
//! moves to a platform crate unchanged, and nothing else in the crate names it.
mod colors;
mod custom_sky;
mod item_bars;
use std::path::Path;
use std::sync::Arc;
@@ -16,18 +14,10 @@ use crate::pack_hooks::PackFormats;
pub struct Formats;
impl PackFormats for Formats {
fn colors(&self, root: &Path) -> Option<crate::pack_palette::PackColors> {
Some(colors::read(root))
}
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
let sky = custom_sky::CustomSky::load(root);
(!sky.is_empty()).then(|| Arc::new(sky) as _)
}
fn item_bars(&self, root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
item_bars::read(root)
}
}
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
@@ -10,20 +10,10 @@ use std::sync::{Arc, OnceLock};
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
/// (its folder at `root`); the default is "nothing in this format".
pub trait PackFormats: Send + Sync {
/// The colours the overlay replaces where vanilla hardcodes them.
fn colors(&self, _root: &Path) -> Option<crate::pack_palette::PackColors> {
None
}
/// The sky layers the overlay draws over the vanilla sky, if it has any.
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
None
}
/// The durability bar colours the overlay sets by item, if any.
fn item_bars(&self, _root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
None
}
}
/// No format beyond vanilla's.
+184 -113
View File
@@ -1,87 +1,14 @@
//! The colours a pack replaces where vanilla hardcodes them (Polytone's `colors.json`, read by the
//! platform's pack formats, [`crate::pack_hooks`]): dye, sheep and redstone colours, for drawing
//! only (the pack recolours collars, tropical fish, banners, wool, sign text and redstone wire and
//! dust, while gameplay keeps vanilla's `DyeColor`), mob effect colours, the experience number's,
//! entity shadows, the effect fogs' settings and durability bar colours. Lock-free: the draw paths
//! and the mesher's workers read the dye, sheep and redstone tables per face.
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
use std::path::Path;
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::sync::{Arc, RwLock};
use crate::block_entity_render::DYES;
use crate::signs::DYE_TEXT;
/// The fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Environment {
/// The fog colour inside powder snow (RGB).
pub powder_snow_fog: Option<u32>,
/// The fog ends of Blindness (5), Darkness (15) and bosses that add world fog (96).
pub blindness_fog_distance: f32,
pub darkness_fog_distance: f32,
pub boss_fog_distance: f32,
/// How much the underwater fog colour washes out (1 vanilla, 0 none).
pub water_fog_brightening: f32,
/// The ticks under water it takes (vanilla's 600).
pub water_fog_brightening_time: f32,
/// Below it the sky draws its dark disc (vanilla's 63; flat worlds keep their floor).
pub horizon_height: f32,
}
impl Default for Environment {
fn default() -> Self {
Self {
powder_snow_fog: None,
blindness_fog_distance: 5.0,
darkness_fog_distance: 15.0,
boss_fog_distance: 96.0,
water_fog_brightening: 1.0,
water_fog_brightening_time: 600.0,
horizon_height: 63.0,
}
}
}
/// The packs' colours; `None` (and the defaults) where they keep vanilla's.
#[derive(Clone, Debug, PartialEq)]
pub struct PackColors {
/// By dye id (`DyeColor` order): `getTextureDiffuseColor`, `getTextColor`, the sheep's wool.
pub dye_diffuse: [Option<u32>; 16],
pub dye_text: [Option<u32>; 16],
pub sheep: [Option<u32>; 16],
/// `RedStoneWireBlock.COLORS` by power.
pub redstone: [Option<u32>; 16],
/// Mob effects' colours, by effect id without vanilla's namespace.
pub effects: Vec<(String, u32)>,
/// False hides every entity's round shadow, whatever the video option says.
pub entity_shadows: bool,
/// The experience level number's colour and outline.
pub xp_bar: [Option<u32>; 2],
/// The effect fogs' settings: the fog reads them every frame.
pub env: Environment,
}
impl Default for PackColors {
fn default() -> Self {
Self {
dye_diffuse: [None; 16],
dye_text: [None; 16],
sheep: [None; 16],
redstone: [None; 16],
effects: Vec::new(),
entity_shadows: true,
xp_bar: [None; 2],
env: Environment::default(),
}
}
}
impl PackColors {
/// Whether entities draw their round shadow, as far as the packs say.
pub fn entity_shadows(&self) -> bool {
self.entity_shadows
}
}
use horizon_polytone::colors::PackColors;
/// 0 is unset, else `1 << 32 | rgb`.
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
@@ -89,30 +16,6 @@ static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
/// The colours of the pack overlay at `root`, from the installed pack formats; their dye, sheep
/// and redstone colours go to the draw paths' tables ([`set`]).
pub fn read(root: &Path) -> PackColors {
let formats = crate::pack_hooks::formats();
let colors = formats.colors(root).unwrap_or_default();
set(&colors);
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = formats.item_bars(root);
colors
}
/// The packs' durability bar colours by item (Polytone's item modifiers' `bar_color`).
pub trait ItemBars: Send + Sync {
/// The bar colour (RGB) of `item` at this damage (0 to 1), when a pack sets one.
fn color(&self, item: &str, damage: f32) -> Option<u32>;
}
static ITEM_BARS: RwLock<Option<Arc<dyn ItemBars>>> = RwLock::new(None);
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
bars.as_ref()?.color(item, damage)
}
/// Takes the colours of the packs just loaded (vanilla's where they set none).
pub fn set(colors: &PackColors) {
let store = |table: &[AtomicU64; 16], i: usize, color: Option<u32>| {
@@ -121,39 +24,129 @@ pub fn set(colors: &PackColors) {
Relaxed,
);
};
for i in 0..16 {
store(&DIFFUSE, i, colors.dye_diffuse[i]);
store(&TEXT, i, colors.dye_text[i]);
store(&SHEEP, i, colors.sheep[i]);
store(&REDSTONE, i, colors.redstone[i]);
for (i, (name, _)) in DYES.iter().enumerate() {
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
store(&TEXT, i, colors.dye(name, "text"));
store(&SHEEP, i, colors.sheep(name));
store(&REDSTONE, i, colors.redstone(i as u8));
}
}
fn get(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
struct DamageBar {
targets: horizon_polytone::Targets,
row: Vec<u32>,
}
static ITEM_BARS: RwLock<Vec<DamageBar>> = RwLock::new(Vec::new());
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read.
pub fn set_item_bars(root: &Path) {
let mut bars = Vec::new();
for namespace in std::fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten()
{
let base = namespace.path().join("polytone/item_modifiers");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Some(bar) = item_bar(root, &base, &path) {
bars.push(bar);
}
}
}
}
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = bars;
}
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
let name = path.file_name()?.to_str()?;
let row = |png: &Path| {
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
let row: Vec<u32> = rgba[..width as usize * 4]
.as_chunks::<4>()
.0
.iter()
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
.collect();
(!row.is_empty()).then_some(row)
};
if let Some(stem) = name.strip_suffix("_bar.png") {
// A json of the same name owns its png.
if path.with_file_name(format!("{stem}.json")).exists() {
return None;
}
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
return Some(DamageBar {
targets,
row: row(path)?,
});
}
let stem = name.strip_suffix(".json")?;
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
if json.get("bar_color")?.as_str()? != "damage" {
return None;
}
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
.into_iter()
.map(|png| path.with_file_name(png))
.find(|png| png.exists())?;
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
let targets =
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
Some(DamageBar {
targets,
row: row(&png)?,
})
}
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
let bar = bars
.iter()
.rev()
.find(|b| b.targets.matches(item, |_| false))?;
let last = (bar.row.len() - 1) as f32;
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
}
fn read(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
let v = table[i & 15].load(Relaxed);
(v != 0).then_some(v as u32)
}
/// `DyeColor.getTextureDiffuseColor` (RGB) as the packs draw it.
pub fn diffuse(dye: usize) -> u32 {
get(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
read(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
}
/// `DyeColor.getTextColor` (RGB) as the packs draw it.
pub fn text(dye: usize) -> u32 {
get(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
}
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
pub fn redstone(power: u8) -> Option<u32> {
get(&REDSTONE, usize::from(power))
read(&REDSTONE, usize::from(power))
}
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
pub fn sheep_wool(dye: u8) -> u32 {
let i = usize::from(dye & 15);
if let Some(color) = get(&SHEEP, i) {
if let Some(color) = read(&SHEEP, i) {
return color;
}
if i == 0 {
@@ -163,3 +156,81 @@ pub fn sheep_wool(dye: u8) -> u32 {
let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255);
f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pack_recolours_only_what_it_names() {
// Dyes no other test draws: the table is process-wide.
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
let polytone = dir.join("assets/obs/polytone");
std::fs::create_dir_all(&polytone).unwrap();
std::fs::write(
polytone.join("colors.json"),
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
)
.unwrap();
set(&PackColors::read(&dir));
let (cyan, pink, purple) = (9, 6, 10);
assert_eq!(diffuse(cyan), 0x00ff00);
assert_eq!(text(cyan), 0x0000ff);
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
assert_eq!(sheep_wool(cyan as u8), 0x107575);
assert_eq!(diffuse(purple), 0x8932B8);
assert_eq!(text(purple), 0xA020F0);
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
set(&PackColors::default());
assert_eq!(redstone(7), None);
assert_eq!(diffuse(cyan), 0x169C9C);
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
std::fs::remove_dir_all(&dir).ok();
}
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::fs::File::create(path).unwrap();
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
encoder.set_color(png::ColorType::Rgba);
let data: Vec<u8> = pixels
.iter()
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&data)
.unwrap();
}
#[test]
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
std::fs::write(
modifiers.join("tools.json"),
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
)
.unwrap();
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
set_item_bars(&dir);
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
assert_eq!(
item_bar_color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
Some(0xFF0000)
);
assert_eq!(
item_bar_color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
Some(0xFFFF00)
);
assert_eq!(item_bar_color("minecraft:iron_axe", 0.9), Some(0x040506));
assert_eq!(item_bar_color("minecraft:diamond_shovel", 0.5), None);
set_item_bars(&dir.join("none"));
assert_eq!(item_bar_color("minecraft:diamond_pickaxe", 0.5), None);
std::fs::remove_dir_all(&dir).ok();
}
}
+2 -7
View File
@@ -51,7 +51,7 @@ pub fn enabled() -> bool {
}
/// Record this thread's spans even with spans off, their wall time only (no CPU clock read):
/// what the render thread's slow frames spent their time in, for the opt-in performance
/// statistics (`play_session::Session::frame`). The thread takes them every frame ([`take`]).
/// statistics (`telemetry::Session::frame`). The thread takes them every frame ([`take`]).
pub fn record_here(on: bool) {
HERE.with(|h| h.set(on));
}
@@ -466,7 +466,7 @@ pub mod startup {
Mutex, OnceLock,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::{Duration, Instant},
time::Instant,
};
static ON: AtomicBool = AtomicBool::new(false);
@@ -489,11 +489,6 @@ pub mod startup {
ON.store(sink.is_some(), Ordering::Relaxed);
}
/// The time since the process started ([`init`]'s origin; zero before it).
pub fn since_start() -> Duration {
ORIGIN.get().map_or(Duration::ZERO, Instant::elapsed)
}
/// Whether a trace is being written.
#[inline]
pub fn on() -> bool {
+2 -5
View File
@@ -119,13 +119,10 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
horizon-extras = ["friends"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
friends = ["dep:horizon-friends"]
@@ -441,7 +441,6 @@
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
"Your display's reported peak is often too high: calibrate to set it up.": "Your display's reported peak is often too high: calibrate to set it up.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
+1 -56
View File
@@ -311,7 +311,7 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
if let Ok(meta) = std::env::current_exe().and_then(std::fs::metadata) {
report.metric("game.binary_bytes", &[meta.len() as f64]);
}
keep_for_upload(&crate::logging::game_home(), &report);
crate::telemetry::keep_benchmark(&crate::logging::game_home(), &report);
report
.write_to(&setup.game_dir.join("benchmarks"))
.map_err(|e| e.to_string())
@@ -468,65 +468,10 @@ impl RssSampler {
}
}
/// Keeps the report for the launcher to send, if the player allows benchmark uploads: the report
/// as docs/benchmark-report.md defines it, dated to the day (docs/telemetry.md).
fn keep_for_upload(home: &Path, report: &Report) {
use horizon_telemetry::{Item, Payload, Source, outbox};
let Ok(data) = serde_json::to_value(report.for_upload()) else {
return;
};
let item = Item::new(
Source::Game,
crate::crash::build(),
Payload::Benchmark(data),
);
if let Err(e) = outbox::push(home, &item) {
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_kept_for_upload_need_their_own_consent_and_go_dated_to_the_day() {
use horizon_telemetry::{Choices, Consent, outbox};
let home = std::env::temp_dir().join(format!(
"horizon-client-benchmark-upload-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let allow = |choices: Choices| {
let mut consent = Consent::load(&home);
consent.answer(choices);
consent.save(&home).unwrap();
};
let mut report = Report::new(Kind::Game, Default::default());
report.created = "2026-10-15T14:03:27Z".into();
report.label = Some("my machine".into());
keep_for_upload(&home, &report);
allow(Choices {
performance: true,
..Choices::NONE
});
keep_for_upload(&home, &report);
assert!(outbox::pending(&home).is_empty());
allow(Choices {
benchmarks: true,
..Choices::NONE
});
keep_for_upload(&home, &report);
let pending = outbox::pending(&home);
assert_eq!(pending.len(), 1);
let text = serde_json::to_string(&pending[0].1).unwrap();
assert!(
text.contains("2026-10-15T00:00:00Z") && !text.contains("my machine"),
"{text}"
);
}
#[test]
fn scenarios() {
assert_eq!(scenes("standard").unwrap().len(), 4);
+6 -10
View File
@@ -21,11 +21,11 @@ pub use horizon_client_render::{
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
polytone_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
providers, pvp, pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask,
server_terrain, settings, shader_options, shader_pack_list, signs, skins, social, sound_engine,
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
streaming, structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
@@ -58,8 +58,6 @@ pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
#[cfg(feature = "discord")]
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
@@ -102,15 +100,12 @@ pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
#[cfg(feature = "horizon-extras")]
pub mod official_servers;
pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod play_session;
pub mod player_sync;
pub mod portal_sections;
pub mod pose;
@@ -140,6 +135,7 @@ pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod studio;
pub mod telemetry;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
+3 -3
View File
@@ -2666,9 +2666,9 @@ pub fn mob_from_nbt_with_random(
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Shulker {
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
m.extra.color = number(tag.get("Color")).map_or(16, |c| c.clamp(0, 16) as u8);
m.anim.v[0] = m.extra.counter as f32 * 0.01;
m.anim.vo[0] = m.anim.v[0];
// Shulker.setPos: square on its block.
+4 -2
View File
@@ -20,11 +20,13 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::options_ui::HORIZON_EXTRAS
.store(false, std::sync::atomic::Ordering::Relaxed);
}
// The process's start, for the startup trace and the start's milestones.
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_hooks::engine::seal();
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
+14 -25
View File
@@ -49,6 +49,7 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
}
}
mod activity;
mod advancements;
mod advancements_screen;
mod agent;
@@ -128,8 +129,6 @@ mod decorated_pot;
mod dedicated;
mod deflect;
mod dialogs;
#[cfg(feature = "discord")]
mod discord;
mod dispense_fixture;
mod dispenser;
mod dragon;
@@ -2028,7 +2027,7 @@ pub struct PackResources {
/// The packs' Polytone post chains (#11486), when a pack has some.
polytone_post: Option<Arc<horizon_client::polytone_post::PolytonePost>>,
/// The packs' Polytone custom colours (`polytone/colors.json`).
polytone_colors: Arc<horizon_client::pack_palette::PackColors>,
polytone_colors: Arc<horizon_client::polytone_colors::PackColors>,
}
/// A quick craft drag as the screen draws it: each dragged slot's count and
/// whether the slot caps it (None: hidden), and what stays in hand.
@@ -2801,7 +2800,7 @@ struct Game<'a> {
agent_last: Option<Instant>,
frame_stats: horizon_client::control::FrameStats,
/// This play session's frame and tick times, for opt-in performance statistics.
perf: horizon_client::play_session::Session,
perf: horizon_client::telemetry::Session,
/// Control replies waiting for a rendered frame or a benchmark deadline.
pending_screenshot: Option<std::sync::mpsc::Sender<String>>,
/// `screenshot-sequence` while it runs.
@@ -6142,7 +6141,7 @@ impl Game<'_> {
let atlas = &g.gui.atlas;
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
let draws = hud_layout::experience_colored(
g.pack.polytone_colors.xp_bar,
g.pack.polytone_colors.xp_bar(),
wi,
hi,
g.experience.progress,
@@ -11404,8 +11403,7 @@ impl Game<'_> {
self.heal_hold_latches();
// For the hooks above the engine (the "I'm playing" ping): said when it changes.
horizon_hooks::engine::published(self.is_published());
#[cfg(feature = "discord")]
self.update_discord_presence();
self.report_activity();
let steps = self.clock.advance(elapsed);
for _ in 0..steps {
let started = Instant::now();
@@ -12635,11 +12633,11 @@ pub fn run_with() -> Result<()> {
options.spikes = Some(dedicated::SLOW_TICK_MS);
}
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
// A hook that wants to know what slow frames spent their time in (a player who allows
// performance statistics): this thread, which draws the frames, times its steps (wall time
// only).
// A player who allows performance statistics also says what their slow frames spent their
// time in: this thread, which draws the frames, times its steps (wall time only).
if options.execution_role.is_client()
&& horizon_hooks::engine::wants_frame_steps(&horizon_client::logging::game_home())
&& horizon_telemetry::Consent::load(&horizon_client::logging::game_home())
.allows(horizon_telemetry::Kind::Perf)
{
horizon_client::spans::record_here(true);
}
@@ -12664,11 +12662,9 @@ pub fn run_with() -> Result<()> {
// for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::crash::build();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
// horizon-extras: the official server is Horizon's (docs/forking.md).
#[cfg(feature = "horizon-extras")]
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
let version = horizon_client::telemetry::version();
let game_dir = horizon_client::logging::player_dir();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
@@ -12696,15 +12692,8 @@ pub fn run_with() -> Result<()> {
if options.execution_role.is_client() {
narrator::start(&early_settings);
}
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
#[cfg(feature = "discord")]
if options.dedicated.is_none() {
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
}
// A game window's hooks (Discord's presence): never for a server.
activity::client_started(&options);
// Snapshots of derived tables live in the game folder, never beside the
// shared resources.
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
@@ -0,0 +1,68 @@
//! What the player is doing, for the hooks above the engine (`Hooks::activity`: the Horizon
//! layer's Discord presence shows it).
use super::*;
use horizon_client::command::args::GameMode;
use horizon_hooks::{Activity, Client, Dimension, Play};
/// At startup, for a game window (never a dedicated server's): the player's first activity, then
/// `Hooks::client_started`.
pub(super) fn client_started(options: &Options) {
if options.dedicated.is_some() {
return;
}
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_hooks::engine::activity(Activity::Menus);
horizon_hooks::engine::client_started(&Client::new(automated));
}
impl Game<'_> {
/// Each frame: only a change reaches the hooks.
pub(super) fn report_activity(&self) {
// One status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
horizon_hooks::engine::activity(self.activity());
}
fn activity(&self) -> Activity {
if self.title.is_some() {
return Activity::Menus;
}
let max = horizon_client::lan::MAX_PLAYERS;
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
max,
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
max,
},
_ => Play::Singleplayer,
}
} else if self.remote.is_some() {
Play::Multiplayer
} else {
Play::Singleplayer
};
Activity::World {
play,
dimension: match self.dim {
Dim::Overworld => Dimension::Overworld,
Dim::Nether => Dimension::Nether,
Dim::End => Dimension::End,
},
mode: match self.mode {
GameMode::Survival => horizon_hooks::GameMode::Survival,
GameMode::Creative => horizon_hooks::GameMode::Creative,
GameMode::Adventure => horizon_hooks::GameMode::Adventure,
GameMode::Spectator => horizon_hooks::GameMode::Spectator,
},
}
}
}
-49
View File
@@ -1,49 +0,0 @@
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
use super::*;
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
impl Game<'_> {
/// Each frame: only a change reaches the presence thread.
pub(super) fn update_discord_presence(&self) {
// One Discord status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
presence::set(self.discord_status());
}
fn discord_status(&self) -> Status {
if !self.settings.extras.discord_presence {
return Status::Off;
}
if self.title.is_some() {
return Status::Menus;
}
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
max: horizon_client::lan::MAX_PLAYERS,
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
max: horizon_client::lan::MAX_PLAYERS,
},
_ => Play::Singleplayer,
}
} else if self.remote.is_some() {
Play::Multiplayer
} else {
Play::Singleplayer
};
Status::World {
play,
dimension: match self.dim {
Dim::Overworld => Dimension::Overworld,
Dim::Nether => Dimension::Nether,
Dim::End => Dimension::End,
},
mode: self.mode,
}
}
}
+1 -1
View File
@@ -1820,7 +1820,7 @@ impl Game<'_> {
// from carries those, as `save_guest` gives them on leaving. The map
// stays their one owner.
let live = self.guest_attachments.get(&uuid).cloned();
let g = playerdata::with_warden(&mut host.hub.guests[i], self.warden_tracker_tag());
let g = &mut host.hub.guests[i];
let held = live.map(|a| std::mem::replace(&mut g.attachments, a));
playerdata::store(g, &mut self.level, vehicle);
if let Some(held) = held {
@@ -34,8 +34,6 @@ pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
narrator::stop();
leave_fullscreen_after(&result, &mut game.settings);
result?;
#[cfg(feature = "discord")]
horizon_client::discord_presence::stop();
write_report(&game)?;
if let Some(e) = game.error.take() {
return Err(e);
@@ -123,7 +121,7 @@ impl Loading {
};
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
drop(window_step);
horizon_client::play_session::note_display(&window);
horizon_client::telemetry::note_display(&window);
// The renderer takes seconds to build and winit's window sits white
// until the first frame presents, so paint the boot screen on it and
// keep repainting it as the console's lines arrive; the wgpu frames
+1 -2
View File
@@ -11496,8 +11496,7 @@ impl Remote {
pub(super) fn listed_player_count(&self) -> usize {
self.listed.len()
}
/// Players in the server's player list, this one included (for Discord).
#[cfg(feature = "discord")]
/// Players in the server's player list, this one included (for the activity hooks).
pub(super) fn player_count(&self) -> usize {
self.players.len()
}
@@ -487,7 +487,7 @@ pub(super) fn load_resources(
};
let resources = Arc::new(resources);
if menu {
horizon_client::play_session::core_ready();
horizon_client::telemetry::mark_core_ready();
log::info!(
"Horizon: core-ready after {} ms",
started.elapsed().as_millis()
@@ -526,7 +526,7 @@ pub(super) fn load_resources(
.set(rest)
.map_err(|_| "world resources already loaded".to_owned())?;
progress.set(9);
horizon_client::play_session::rest_ready();
horizon_client::telemetry::mark_rest_ready();
log::info!(
"Horizon: rest-ready after {} ms",
started.elapsed().as_millis()
@@ -306,8 +306,6 @@ pub(super) fn run_loading(
App::Loading(loading) => leave_fullscreen_after(&result, &mut loading.settings),
}
result?;
#[cfg(feature = "discord")]
horizon_client::discord_presence::stop();
match app {
App::Ready(mut split) => {
write_report(&split.host)?;
@@ -292,8 +292,10 @@ fn pack_particles(
}
/// The packs' custom colours; their dye and sheep colours go to the draw paths' table.
fn read_colors(root: &std::path::Path) -> horizon_client::pack_palette::PackColors {
let colors = horizon_client::pack_palette::read(root);
horizon_client::brewing::set_pack_colors(colors.effects.clone());
fn read_colors(root: &std::path::Path) -> horizon_client::polytone_colors::PackColors {
let colors = horizon_client::polytone_colors::PackColors::read(root);
horizon_client::pack_palette::set(&colors);
horizon_client::pack_palette::set_item_bars(root);
horizon_client::brewing::set_pack_colors(colors.effects());
colors
}
+4 -11
View File
@@ -359,19 +359,12 @@ impl Game<'_> {
self.sculk.tracker = WardenSpawnTracker::from_nbt(tag);
}
}
/// The tracker as saved, once it has been loaded.
pub(super) fn warden_tracker_tag(&self) -> Option<horizon_world::nbt::Tag> {
self.sculk
.tracker_loaded
.then(|| self.sculk.tracker.to_nbt())
}
/// Write the tracker into the player data being saved. With a LAN owner,
/// `store_owner` rewrites that from the owner's guest, so it puts the
/// tracker there as well (`playerdata::with_warden`).
/// Write the tracker into the player data being saved.
pub(super) fn save_warden_tracker(&mut self) {
let Some(tag) = self.warden_tracker_tag() else {
if !self.sculk.tracker_loaded {
return;
};
}
let tag = self.sculk.tracker.to_nbt();
let extra = self
.level
.player_extra
@@ -53,7 +53,7 @@ pub(super) struct StartupMenu {
pub reload_packs: bool,
/// Browse mods' worker (`mod_browser`).
mod_jobs: super::mod_browser::Jobs,
pub perf: horizon_client::play_session::Session,
pub perf: horizon_client::telemetry::Session,
}
impl StartupMenu {
@@ -304,7 +304,7 @@ impl ApplicationHandler for Game<'_> {
};
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
drop(window_step);
horizon_client::play_session::note_display(&window);
horizon_client::telemetry::note_display(&window);
match self.new_renderer(window.clone()) {
Ok(r) => {
self.renderer = Some(r);
+18 -18
View File
@@ -169,33 +169,33 @@ impl Game<'_> {
/// Leaving the game with a world open (window closed, Quit, `quit`):
/// menu items go back (Player.closeContainer), then the world is saved,
/// once.
/// Ends this play session for the hooks (`horizon_client::play_session`): opt-in telemetry
/// keeps its performance summary for the launcher to send, if the player allows it.
/// Keeps this play session's performance summary for the launcher to send, if the player
/// allows performance statistics (`horizon_client::telemetry`), and starts the next one.
pub(super) fn finish_perf(&mut self) {
// One session per computer: P1's (a splitscreen player's would count it again).
if self.options.split_guest {
return;
}
// Again now: on Wayland the window is on a screen only once it has been shown.
if let Some(window) = &self.window {
horizon_client::play_session::note_display(window);
}
let s = &self.settings;
let home = horizon_client::logging::game_home();
let version = horizon_client::crash::build();
let gpu = horizon_client::crash::gpu();
self.perf.finish(&horizon_hooks::WorldLeft {
home: &home,
version: &version,
gpu: gpu.as_ref(),
render_distance: s.render_distance,
simulation_distance: s.simulation_distance,
let settings = horizon_telemetry::payload::Settings {
render_distance: s.render_distance.clamp(0, 255) as u8,
simulation_distance: s.simulation_distance.clamp(0, 255) as u8,
// 260 is the slider's "unlimited".
max_fps: (s.max_fps < 260).then_some(s.max_fps),
max_fps: if s.max_fps >= 260 {
0
} else {
s.max_fps.min(u32::from(u16::MAX)) as u16
},
vsync: s.vsync,
fullscreen: s.fullscreen,
graphics: &s.graphics_preset,
});
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);
}
pub(super) fn save_for_exit(&mut self) -> Result<()> {
@@ -53,27 +53,6 @@ pub fn exists(world: &Path, uuid: u128) -> bool {
path(world, uuid).is_file()
}
/// The key `ServerPlayer.addAdditionalSaveData` stores the `WardenSpawnTracker` under.
pub const WARDEN_TRACKER_KEY: &str = "warden_spawn_tracker";
/// Put a player's `warden_spawn_tracker` into the compound `store` writes from
/// (`guest.saved`), so a save keeps it rather than the older one loaded with
/// the file (the LAN owner's tracker lives in `Game`, `save_warden_tracker`).
/// Other saved keys are left as they are; no tracker (not loaded yet) changes
/// nothing.
pub fn with_warden(guest: &mut Guest, tracker: Option<Tag>) -> &mut Guest {
let Some(tracker) = tracker else {
return guest;
};
let saved = guest
.saved
.get_or_insert_with(|| Tag::Compound(Default::default()));
if let Tag::Compound(map) = saved {
map.insert(WARDEN_TRACKER_KEY.into(), tracker);
}
guest
}
/// `PlayerDataStorage.load`: a guest's saved state, if they have been here
/// before. The player's file, else its `.dat_old` (what a save cut off
/// between its two renames leaves, `write_dat`).
@@ -672,42 +651,6 @@ mod tests {
)
}
/// #9965: the owner's warden warning level and cooldown reach the file on
/// a save, instead of the ones the file was loaded with.
#[test]
fn a_stored_warden_tracker_replaces_the_loaded_one() {
let mut g = guest();
g.saved = Some(Tag::compound(vec![
(
"warden_spawn_tracker",
Tag::compound(vec![
("warning_level", Tag::Int(2)),
("cooldown_ticks", Tag::Int(77)),
("ticks_since_last_warning", Tag::Int(5)),
]),
),
("unrelated_key", Tag::Int(9)),
]));
let tracker = crate::sculk::WardenSpawnTracker {
ticks_since_last_warning: 0,
warning_level: 3,
cooldown_ticks: 200,
};
with_warden(&mut g, Some(tracker.to_nbt()));
let (tag, _) = round_trip(&g);
let mut back = guest();
let mut level = Level::default();
level.read_player(&tag);
level.player_extra = Some(tag);
apply(&mut back, &level);
let saved = back.saved.as_ref().unwrap();
let read =
crate::sculk::WardenSpawnTracker::from_nbt(saved.get("warden_spawn_tracker").unwrap());
assert_eq!((read.warning_level, read.cooldown_ticks), (3, 200));
assert_eq!(read.ticks_since_last_warning, 0);
assert!(matches!(saved.get("unrelated_key"), Some(Tag::Int(9))));
}
#[test]
fn a_new_player_faces_the_world_spawn_angle() {
let mut g = guest();
+3 -48
View File
@@ -855,11 +855,9 @@ pub fn texture(m: &Mob) -> String {
"red",
"black",
];
// Shulker.getColor: the saved byte is signed; 16 and above are undyed, and
// DyeColor.byId sends a negative id to white (OutOfBoundsStrategy.ZERO).
match m.extra.color as i8 {
16.. => "entity/shulker/shulker".into(),
c => format!("entity/shulker/shulker_{}", COLORS[c.max(0) as usize]),
match COLORS.get(m.extra.color as usize) {
Some(c) => format!("entity/shulker/shulker_{c}"),
None => "entity/shulker/shulker".into(),
}
}
/// `attachFace.getOpposite().getRotation()` by attach face: the open
@@ -1146,46 +1144,3 @@ mod tests {
assert!((h - 1.0).abs() < 1e-9, "{h}");
}
}
#[cfg(test)]
mod saved_bytes_tests {
use super::*;
/// `Shulker.readAdditionalSaveData`: `AttachFace` is
/// `Direction.LEGACY_ID_CODEC` (`from3DDataValue` wraps the id through
/// `abs(id % 6)`), while `Peek` and `Color` are raw `getByteOr` bytes. The
/// load clamped all three to their setters' ranges instead.
#[test]
fn a_shulker_reads_its_face_and_lid_bytes_like_vanilla() {
use crate::entity_nbt::mob_from_nbt;
use horizon_world::nbt::Tag;
let mut kind = crate::mobs::sensor_scan_tests::kind();
let k = std::sync::Arc::make_mut(&mut kind);
k.id = "minecraft:shulker".into();
k.species = Species::Shulker;
let types = [("minecraft:shulker".into(), kind)].into();
let tag = Tag::compound([
("id", Tag::String("minecraft:shulker".into())),
(
"Pos",
Tag::List(vec![
Tag::Double(0.0f64.to_bits()),
Tag::Double(64.0f64.to_bits()),
Tag::Double(0.0f64.to_bits()),
]),
),
("AttachFace", Tag::Byte(7)),
("Peek", Tag::Byte(-1)),
("Color", Tag::Byte(-1)),
]);
let m = mob_from_nbt(&tag, &types, 1, None).unwrap();
assert_eq!(m.extra.variant3, 1, "abs(7 % 6) is up");
assert_eq!(m.extra.counter, -1, "the peek byte is kept");
assert_eq!(m.extra.color, 255, "the color byte is kept");
assert_eq!(
texture(&m),
"entity/shulker/shulker_white",
"DyeColor.byId(-1)"
);
}
}
+17 -15
View File
@@ -2148,16 +2148,18 @@ impl MotionWorld<'_, '_, '_> {
let gravity = living_motion::effective_gravity(self)?;
self.player.velocity = fall_flying_movement(v, self.player.pitch, self.player.yaw, gravity);
let movement = self.player.velocity;
let before = self.player.position;
self.move_self(movement)?;
// LivingEntity.handleFallFlyingCollisions: the fall-damage sound plays
// on both sides (a glider's own travel is client-side, and the sound
// must reach its own ears, #8906), only the server hurts, and it
// compares the horizontal speed before `updateFallFlyingMovement`
// with the delta `Entity.move` leaves (the collided axes zeroed).
// compares the delta before the move with the one after it, which
// `Entity.move` leaves as the movement it resolved. `move_entity` does
// not write that back, so the distance actually travelled stands in.
if self.player.collision.horizontal {
let v = self.player.velocity;
let after_h = (v.x * v.x + v.z * v.z).sqrt();
let dmg = fly_into_wall_damage(move_h, after_h);
let travelled = self.player.position.subtract(before);
let new_h = (travelled.x * travelled.x + travelled.z * travelled.z).sqrt();
let dmg = fly_into_wall_damage(move_h, new_h);
if dmg > 0.0 {
let first = self.player.events.len();
// getFallDamageSound((int) dmg)
@@ -3146,7 +3148,6 @@ mod tests {
assert_eq!(fly_into_wall_damage(0.3, 0.0), 0.0);
assert!((fly_into_wall_damage(0.5, 0.1) - 1.0).abs() < 1e-5);
assert!((fly_into_wall_damage(1.2, 0.0) - 9.0).abs() < 1e-5);
assert!(fly_into_wall_damage(0.01, 0.0) < 0.0);
}
use super::*;
@@ -4194,16 +4195,17 @@ mod tests {
health: &resources.health,
};
w.travel_fall_flying().unwrap();
let is_fall = |e: &_| {
matches!(
assert!(
w.player.events.iter().any(|e| matches!(
e,
MotionEvent::Fall(crate::landing::FallEvent::Sound { .. })
)
};
assert_eq!(w.player.events.iter().filter(|e| is_fall(e)).count(), 1);
// The sound must play only once on impact, not repeat on subsequent ticks (#12304):
w.travel_fall_flying().unwrap();
assert_eq!(w.player.events.iter().filter(|e| is_fall(e)).count(), 1);
MotionEvent::Fall(crate::landing::FallEvent::Sound {
sound: crate::landing::SceneSound::SmallFall,
..
})
)),
"{:?}",
w.player.events
);
assert!(
!w.player
.hurt_log
-81
View File
@@ -1,81 +0,0 @@
//! A play session as the hooks above the engine hear it (`horizon_hooks`): the title screen's
//! first frame, a world opening and its first frame, the frames played, and leaving the world.
//! It also marks those moments in the startup trace (`spans::startup`). Opt-in telemetry's
//! performance summary is built from these hooks (docs/telemetry.md).
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use horizon_hooks::{Frame, Milestone, WorldLeft, engine};
use crate::spans::{self, startup};
/// One window's session: moved from the startup menu to the game with the window.
#[derive(Default)]
pub struct Session {
/// When the world being opened started to, until its first frame.
opening: Option<Instant>,
}
impl Session {
/// The title screen drew a frame: the first time, how long the start took.
pub fn title_shown(&mut self) {
static SHOWN: AtomicBool = AtomicBool::new(false);
if !SHOWN.swap(true, Ordering::Relaxed) {
startup::mark("title.shown");
// The frame itself: the menu bundle can be ready seconds before the renderer that
// draws it (pipelines compiling on a slow driver).
engine::startup(Milestone::TitleShown, startup::since_start());
}
}
/// A world starts loading.
pub fn world_opening(&mut self) {
if self.opening.is_none() {
startup::arm("world.opening");
}
self.opening.get_or_insert_with(Instant::now);
}
/// An entry queued during startup was cancelled before opening a world.
pub fn cancel_world_opening(&mut self) {
self.opening = None;
}
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
/// recorded in it ([`spans::take_raw`]): the step it spent most of its time in is
/// [`spans::culprit`].
pub fn frame(&mut self, ms: f32, frame_spans: &[spans::Span]) {
if let Some(opening) = self.opening.take() {
startup::mark("world.first_frame");
engine::world_shown(opening.elapsed());
}
engine::frame(&Frame::new(ms, &|| spans::culprit(frame_spans)));
}
/// The player left the world: the session ends.
pub fn finish(&mut self, left: &WorldLeft<'_>) {
engine::world_left(left);
}
}
/// Tells the hooks the screen `window` is on ([`crate::window::display`]).
pub fn note_display(window: &winit::window::Window) {
if let Some(d) = crate::window::display(window) {
engine::display(&horizon_hooks::Display {
width: d.width,
height: d.height,
refresh_hz: d.refresh_hz,
scale: d.scale,
});
}
}
/// The resources the menus need are loaded.
pub fn core_ready() {
engine::startup(Milestone::CoreReady, startup::since_start());
}
/// What a world needs is loaded too.
pub fn rest_ready() {
engine::startup(Milestone::RestReady, startup::since_start());
}
@@ -1,21 +1,16 @@
//! The game's part in opt-in data collection (docs/telemetry.md), behind the `game` feature. When
//! the player allows it, the game keeps a scrubbed crash report, or a play session's performance
//! The game's part in opt-in data collection (`horizon_telemetry`, docs/telemetry.md). When the
//! player allows it, the game keeps a scrubbed crash report, or a play session's performance
//! summary, in the outbox. It never sends those itself: the launcher does, at its next start (the
//! game's only sending is the anonymous "I'm playing" ping, `horizon-heartbeat`). Without the
//! player's yes for that kind nothing is kept, and crash-reports/ stays local.
//!
//! It hears the game only through the platform's hooks ([`HOOKS`], `horizon_platform::hooks`);
//! the official executable registers it (`horizon_official::PARTS`).
//! game's only sending is the anonymous "I'm playing" ping, `heartbeat.rs`).
//! Without the player's yes for that kind nothing is kept, and crash-reports/ stays local.
use std::path::Path;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
use horizon_platform::hooks::{self, Frame, Hooks, Milestone, WorldLeft};
use crate::payload::{Crash, Display, Perf, Settings, System, Timings};
use crate::scrub::Scrubber;
use crate::{Item, Payload, Source, outbox};
use horizon_telemetry::payload::{Crash, Display, Perf, Settings, System, Timings};
use horizon_telemetry::scrub::Scrubber;
use horizon_telemetry::{Item, Payload, Source, outbox};
/// A session shorter than this says too little to keep.
const MIN_SECONDS: f32 = 60.0;
@@ -24,82 +19,30 @@ const MIN_SECONDS: f32 = 60.0;
/// world, the server or the resource packs.
const CRASH_DETAILS: [&str; 3] = ["Horizon Version", "Graphics Adapter", "Render Settings"];
static STARTED: OnceLock<Instant> = OnceLock::new();
static CORE_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
/// The screen the window opened on.
static DISPLAY: Mutex<Option<Display>> = Mutex::new(None);
/// The game's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: Telemetry = Telemetry {
session: LazyLock::new(|| Mutex::new(Session::default())),
};
/// The play session's performance, as the game's hooks tell it.
pub struct Telemetry {
session: LazyLock<Mutex<Session>>,
/// When the game started, for the time to the title screen. Call first thing in `main`.
pub fn mark_start() {
STARTED.get_or_init(Instant::now);
}
impl Telemetry {
fn session(&self) -> std::sync::MutexGuard<'_, Session> {
self.session.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn mark_core_ready() {
mark_ready(&CORE_READY_MS);
}
impl Hooks for Telemetry {
fn startup(&self, milestone: Milestone, since_start: Duration) {
match milestone {
Milestone::CoreReady => mark_ready(&CORE_READY_MS, since_start),
Milestone::RestReady => mark_ready(&REST_READY_MS, since_start),
Milestone::TitleShown => self.session().title_shown(since_start),
}
}
/// A player who allows performance statistics also says what their slow frames spent their
/// time in.
fn wants_frame_steps(&self, home: &Path) -> bool {
crate::Consent::load(home).allows(crate::Kind::Perf)
}
fn world_shown(&self, since_opening: Duration) {
self.session().world_shown(since_opening);
}
fn frame(&self, frame: &Frame<'_>) {
self.session().frame(frame.ms, || frame.slowest_step());
}
fn world_left(&self, left: &WorldLeft<'_>) {
let settings = Settings {
render_distance: left.render_distance.clamp(0, 255) as u8,
simulation_distance: left.simulation_distance.clamp(0, 255) as u8,
max_fps: left
.max_fps
.map_or(0, |f| f.min(u32::from(u16::MAX)) as u16),
vsync: left.vsync,
fullscreen: left.fullscreen,
graphics: left.graphics.to_owned(),
};
self.session()
.finish(left.home, left.version, left.gpu, settings);
}
fn display(&self, display: &hooks::Display) {
*DISPLAY.lock().unwrap_or_else(|e| e.into_inner()) = Some(Display {
width: display.width,
height: display.height,
refresh_hz: display.refresh_hz.unwrap_or(0),
scale: display.scale as f32,
});
}
fn crashed(&self, crash: &hooks::Crash<'_>) {
keep_crash(crash);
}
pub fn mark_rest_ready() {
mark_ready(&REST_READY_MS);
}
fn mark_ready(mark: &AtomicU32, since_start: Duration) {
let ms = since_start.as_millis().min(u128::from(u32::MAX - 1)) as u32;
let _ = mark.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
fn mark_ready(mark: &AtomicU32) {
if let Some(started) = STARTED.get() {
let ms = started.elapsed().as_millis().min(u128::from(u32::MAX - 1)) as u32;
let _ = mark.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
}
}
fn readiness_ms(mark: &AtomicU32) -> Option<u32> {
@@ -107,32 +50,54 @@ fn readiness_ms(mark: &AtomicU32) -> Option<u32> {
(ms != u32::MAX).then_some(ms)
}
/// This machine, with the GPU once the renderer has one and the screen. The drive is the one
/// The screen the window is on: size in physical pixels, refresh rate, scale.
pub fn set_display(width: u32, height: u32, refresh_hz: u32, scale: f64) {
*DISPLAY.lock().unwrap_or_else(|e| e.into_inner()) = Some(Display {
width,
height,
refresh_hz,
scale: scale as f32,
});
}
/// Keeps the screen `window` is on ([`crate::window::display`]) for the items' `system.display`.
pub fn note_display(window: &winit::window::Window) {
if let Some(d) = crate::window::display(window) {
set_display(d.width, d.height, d.refresh_hz.unwrap_or(0), d.scale);
}
}
/// This machine, with the GPU and the screen once the renderer knows them. The drive is the one
/// under the game folder (the current folder).
pub fn system(gpu: Option<&hooks::Gpu>) -> System {
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 {
if let Some(gpu) = crate::gfx::adapter() {
system.vram_mb = match gpu.kind.as_str() {
"discrete" => crate::system::vram_mb(&gpu.vendor).unwrap_or(0),
"discrete" => horizon_telemetry::system::vram_mb(&gpu.vendor).unwrap_or(0),
_ => 0,
};
system.gpu = gpu.name.clone();
system.gpu_vendor = gpu.vendor.clone();
system.gpu_kind = gpu.kind.clone();
system.driver = gpu.driver.clone();
system.backend = gpu.backend.clone();
system.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
}
/// The release (`v0.2.3`), or the client's crate version for a build from source.
pub fn version() -> String {
crate::crash::release_tag().unwrap_or_else(|| format!("dev {}", env!("CARGO_PKG_VERSION")))
}
/// What a crash report becomes when it may leave: the error and stack, the thread, and the
/// details in [`CRASH_DETAILS`], with the world's name and folder and the server's address
/// scrubbed wherever they appear. No log lines.
pub fn crash_item(report: &hooks::Crash<'_>) -> Item {
pub fn crash_item(report: &crate::crash::Report) -> Item {
let mut scrubber = Scrubber::new();
for (key, value) in report.details {
for (key, value) in &report.details {
match key.as_str() {
// `Name (saves/folder)`, as game.rs sets it.
"World" => {
@@ -147,10 +112,10 @@ pub fn crash_item(report: &hooks::Crash<'_>) -> Item {
}
}
let crash = Crash {
system: system(report.gpu),
error: scrubber.scrub(report.error),
stack: scrubber.scrub(report.stack),
thread: report.thread.to_owned(),
system: system(),
error: scrubber.scrub(&report.error),
stack: scrubber.scrub(&report.stack),
thread: report.thread.clone(),
details: report
.details
.iter()
@@ -158,7 +123,7 @@ pub fn crash_item(report: &hooks::Crash<'_>) -> Item {
.map(|(k, v)| (k.clone(), scrubber.scrub(v)))
.collect(),
};
let mut item = Item::new(Source::Game, report.version, Payload::Crash(crash));
let mut item = Item::new(Source::Game, version(), Payload::Crash(crash));
// A huge stack keeps its top, where the error is.
while item.validate().is_err() {
let Payload::Crash(crash) = &mut item.payload else {
@@ -180,12 +145,26 @@ pub fn crash_item(report: &hooks::Crash<'_>) -> Item {
}
/// Keeps the crash for the launcher to send, if the player allows crash reports.
pub fn keep_crash(report: &hooks::Crash<'_>) {
if let Err(e) = outbox::push(report.home, &crash_item(report)) {
pub fn keep_crash(home: &Path, report: &crate::crash::Report) {
if let Err(e) = outbox::push(home, &crash_item(report)) {
log::warn!("Horizon: could not keep the crash report to send: {e}");
}
}
/// Keeps a benchmark report (`--benchmark`) for the launcher to send, if the player allows
/// benchmark uploads: the report as docs/benchmark-report.md defines it, dated to the day.
pub fn keep_benchmark(home: &Path, report: &horizon_bench::Report) {
let Ok(data) = serde_json::to_value(report.for_upload()) else {
return;
};
if let Err(e) = outbox::push(
home,
&Item::new(Source::Game, version(), Payload::Benchmark(data)),
) {
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
}
}
/// One play session's performance, from entering a world to leaving it: frame and tick
/// times as [`Timings`] (fixed size whatever the length), never the frames themselves.
#[derive(Default)]
@@ -197,33 +176,42 @@ pub struct Session {
/// The start was measured: only the first session carries it.
title_measured: bool,
to_world_ms: Option<u32>,
slow_frames: Vec<crate::payload::SlowFrames>,
opening: Option<Instant>,
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
}
impl Session {
/// The title screen drew its first frame, `since_start` after the game started.
pub fn title_shown(&mut self, since_start: Duration) {
/// The title screen drew a frame: the first time, how long the start took.
pub fn title_shown(&mut self) {
if !self.title_measured {
self.title_measured = true;
self.to_title_ms = Some(since_start.as_millis().min(u128::from(u32::MAX)) as u32);
crate::spans::startup::mark("title.shown");
// The frame itself: the menu bundle can be ready seconds before
// the renderer that draws it (pipelines compiling on a slow driver).
self.to_title_ms = STARTED.get().map(|s| s.elapsed().as_millis() as u32);
}
}
/// A world's first frame, `since_opening` after it started opening: the session's first
/// world counts.
pub fn world_shown(&mut self, since_opening: Duration) {
if self.to_world_ms.is_none() {
self.to_world_ms = Some(since_opening.as_millis().min(u128::from(u32::MAX)) as u32);
/// A world starts loading.
pub fn world_opening(&mut self) {
if self.opening.is_none() {
crate::spans::startup::arm("world.opening");
}
self.opening.get_or_insert_with(Instant::now);
}
/// A frame drawn while playing (not in menus or loading): a frame of `SLOW_FRAME_MS` or
/// more counts under the step it spent most of its time in (`slowest_step`, the game's
/// `spans::culprit`).
pub fn frame(&mut self, ms: f32, slowest_step: impl FnOnce() -> Option<(String, f32)>) {
use crate::payload::{SLOW_FRAME_MS, SlowFrames};
/// An entry queued during startup was cancelled before opening a world.
pub fn cancel_world_opening(&mut self) {
self.opening = None;
}
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
/// recorded in it ([`crate::spans::take_raw`]): a frame of `SLOW_FRAME_MS` or more counts
/// under the step it spent most of its time in ([`crate::spans::culprit`]).
pub fn frame(&mut self, ms: f32, spans: &[crate::spans::Span]) {
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
if ms >= SLOW_FRAME_MS {
let (mut path, span_ms) = slowest_step().unwrap_or_default();
let (mut path, span_ms) = crate::spans::culprit(spans).unwrap_or_default();
// A path too long for the item keeps its last steps.
while path.len() > SlowFrames::MAX_NAME
&& let Some((_, rest)) = path.split_once('/')
@@ -232,6 +220,12 @@ impl Session {
}
SlowFrames::record(&mut self.slow_frames, ms, &path, span_ms);
}
if let Some(opening) = self.opening.take() {
crate::spans::startup::mark("world.first_frame");
if self.to_world_ms.is_none() {
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
}
}
self.frames.record(ms);
self.played_ms += ms;
}
@@ -242,10 +236,10 @@ impl Session {
}
/// The session's summary, if it was long enough to say something.
pub fn summary(&self, gpu: Option<&hooks::Gpu>, settings: Settings) -> Option<Perf> {
pub fn summary(&self, settings: Settings) -> Option<Perf> {
let seconds = self.played_ms / 1000.0;
(seconds >= MIN_SECONDS).then(|| Perf {
system: system(gpu),
system: system(),
settings,
seconds: seconds as u32,
frames: self.frames.summary(),
@@ -261,17 +255,11 @@ impl Session {
/// Keeps the session's summary for the launcher to send, if the player allows performance
/// statistics, and starts the next session (the time to the title goes with the first).
pub fn finish(
&mut self,
home: &Path,
version: &str,
gpu: Option<&hooks::Gpu>,
settings: Settings,
) {
if crate::Consent::load(home).allows(crate::Kind::Perf)
&& let Some(perf) = self.summary(gpu, settings)
pub fn finish(&mut self, home: &Path, settings: Settings) {
if horizon_telemetry::Consent::load(home).allows(horizon_telemetry::Kind::Perf)
&& let Some(perf) = self.summary(settings)
{
let item = Item::new(Source::Game, version, Payload::Perf(perf));
let item = Item::new(Source::Game, version(), Payload::Perf(perf));
if let Err(e) = outbox::push(home, &item) {
log::warn!("Horizon: could not keep the session's performance summary: {e}");
}
@@ -316,7 +304,7 @@ fn peak_ram_mb() -> u32 {
#[cfg(test)]
mod tests {
use super::*;
use crate::{Choices, Consent, Kind};
use horizon_telemetry::{Choices, Consent, Kind};
fn home(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
@@ -334,33 +322,16 @@ mod tests {
consent.save(home).unwrap();
}
/// A crash report's parts, as `crash.rs` gives them to the hooks.
struct Report {
error: String,
stack: String,
details: Vec<(String, String)>,
}
impl Report {
fn crash<'a>(&'a self, home: &'a Path) -> hooks::Crash<'a> {
hooks::Crash {
home,
version: "v2026.10.15",
error: &self.error,
stack: &self.stack,
thread: "Render thread",
details: &self.details,
gpu: None,
}
}
}
fn report() -> Report {
Report {
fn report() -> crate::crash::Report {
crate::crash::Report {
time: chrono::Local::now(),
description: "Unexpected error".into(),
error:
"panicked at src/save.rs:10:5:\nno level.dat in /Users/ana/horizon/saves/Ana's Base"
.into(),
stack: "horizon_client::save::load (crates/horizon-client/src/save.rs:10:5)\n".into(),
thread: "Render thread".into(),
side: "client",
details: vec![
(
"Horizon Version".into(),
@@ -374,13 +345,13 @@ mod tests {
("Server".into(), "play.example.net:25565".into()),
("Resource Packs".into(), "Ana's pack".into()),
],
log: vec!["[CHAT] <Ana> hi".into()],
}
}
#[test]
fn a_crash_keeps_only_what_says_what_happened() {
let report = report();
let item = crash_item(&report.crash(Path::new("home")));
let item = crash_item(&report());
let Payload::Crash(crash) = &item.payload else {
panic!("a crash item");
};
@@ -396,7 +367,13 @@ mod tests {
)]
);
let json = serde_json::to_string(&item).unwrap();
for private in ["Castle Ana", "Ana's Base", "example.net", "Ana's pack"] {
for private in [
"Castle Ana",
"Ana's Base",
"example.net",
"Ana's pack",
"CHAT",
] {
assert!(!json.contains(private), "{private} in {json}");
}
assert!(item.validate().is_ok());
@@ -407,7 +384,7 @@ mod tests {
let mut report = report();
report.stack =
"horizon_client::game::deep (crates/horizon-client/src/game.rs:1:1)\n".repeat(2000);
let item = crash_item(&report.crash(Path::new("home")));
let item = crash_item(&report);
assert!(item.validate().is_ok());
let Payload::Crash(crash) = item.payload else {
panic!("a crash item");
@@ -415,11 +392,43 @@ mod tests {
assert!(crash.stack.lines().count() > 100);
}
#[test]
fn benchmark_reports_need_their_own_consent_and_go_dated_to_the_day() {
let home = home("benchmark");
let mut report = horizon_bench::Report::new(horizon_bench::Kind::Game, Default::default());
report.created = "2026-10-15T14:03:27Z".into();
report.label = Some("my machine".into());
keep_benchmark(&home, &report);
allow(
&home,
Choices {
performance: true,
..Choices::NONE
},
);
keep_benchmark(&home, &report);
assert!(outbox::pending(&home).is_empty());
allow(
&home,
Choices {
benchmarks: true,
..Choices::NONE
},
);
keep_benchmark(&home, &report);
let pending = outbox::pending(&home);
assert_eq!(pending.len(), 1);
let text = serde_json::to_string(&pending[0].1).unwrap();
assert!(
text.contains("2026-10-15T00:00:00Z") && !text.contains("my machine"),
"{text}"
);
}
#[test]
fn crashes_stay_local_without_consent() {
let home = home("crash");
let report = report();
keep_crash(&report.crash(&home));
keep_crash(&home, &report());
assert!(outbox::pending(&home).is_empty());
allow(
&home,
@@ -428,7 +437,7 @@ mod tests {
..Choices::NONE
},
);
keep_crash(&report.crash(&home));
keep_crash(&home, &report());
assert!(outbox::pending(&home).is_empty());
allow(
&home,
@@ -437,38 +446,33 @@ mod tests {
..Choices::NONE
},
);
keep_crash(&report.crash(&home));
keep_crash(&home, &report());
let pending = outbox::pending(&home);
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].1.kind(), Kind::Crash);
}
#[test]
fn the_time_to_a_world_is_the_sessions_first() {
fn cancelled_startup_entry_does_not_time_menu_browsing() {
let mut session = Session::default();
session.world_shown(Duration::from_millis(4200));
session.world_shown(Duration::from_millis(900));
assert_eq!(session.to_world_ms, Some(4200));
}
#[test]
fn the_time_to_the_title_goes_with_the_first_session_only() {
let home = home("title");
let mut session = Session::default();
session.title_shown(Duration::from_millis(3100));
assert_eq!(session.to_title_ms, Some(3100));
session.finish(&home, "dev", None, Settings::default());
session.title_shown(Duration::from_millis(9000));
assert_eq!(session.to_title_ms, None);
session.world_opening();
assert!(session.opening.is_some());
session.cancel_world_opening();
assert!(session.opening.is_none());
session.frame(16.0, &[]);
assert!(session.to_world_ms.is_none());
session.world_opening();
session.frame(16.0, &[]);
assert!(session.to_world_ms.is_some());
}
#[test]
fn a_session_is_kept_only_with_consent_and_once_long_enough() {
let home = home("session");
let mut session = Session::default();
session.world_shown(Duration::from_millis(1500));
(0..2000).for_each(|_| session.frame(16.6, || None));
session.finish(&home, "dev", None, Settings::default());
session.world_opening();
(0..2000).for_each(|_| session.frame(16.6, &[]));
session.finish(&home, Settings::default());
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
allow(
&home,
@@ -477,16 +481,14 @@ mod tests {
..Choices::NONE
},
);
(0..2000).for_each(|_| session.frame(16.6, || None));
session.finish(&home, "dev", None, Settings::default());
(0..2000).for_each(|_| session.frame(16.6, &[]));
session.finish(&home, Settings::default());
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
session.world_shown(Duration::from_millis(1500));
(0..4000).for_each(|_| session.frame(16.6, || None));
session.world_opening();
(0..4000).for_each(|_| session.frame(16.6, &[]));
(0..1200).for_each(|_| session.tick(4.0));
session.finish(
&home,
"dev",
None,
Settings {
render_distance: 12,
..Settings::default()
@@ -509,15 +511,21 @@ mod tests {
/// span counts under none. Faster frames leave no entry.
#[test]
fn slow_frames_are_put_down_to_their_step() {
// What the game's `spans::culprit` names for each frame.
let step = |path: &'static str, ms: f32| move || Some((path.to_owned(), ms));
use crate::spans::Span;
let span = |name, ms, depth| Span {
name,
ms,
cpu_ms: ms,
depth,
};
let mut session = Session::default();
(0..4000).for_each(|_| session.frame(16.6, step("tick", 10.0)));
session.frame(320.0, step("renderer/r_uploads", 280.0));
session.frame(900.0, step("renderer/r_uploads", 280.0));
session.frame(260.0, || None);
session.frame(249.0, step("renderer/r_uploads", 280.0));
let perf = session.summary(None, Settings::default()).unwrap();
(0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)]));
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
session.frame(320.0, &frozen);
session.frame(900.0, &frozen);
session.frame(260.0, &[]);
session.frame(249.0, &frozen);
let perf = session.summary(Settings::default()).unwrap();
let seen: Vec<_> = perf
.slow_frames
.iter()
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "horizon-discord"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Discord Rich Presence: \"Playing Horizon\" on the player's Discord profile (docs/native-client.md)"
[dependencies]
horizon-platform = { path = "../horizon-platform" }
log = "0.4"
serde_json = "1.0.151"
[lints]
workspace = true
@@ -1,21 +1,30 @@
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
//! length u32 LE + JSON). The game says what the player does on each change ([`set`]), which wakes the thread,
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
//! or with `HORIZON_NO_DISCORD=1`. It uses nothing else of the game (docs/layers.md: Horizon layer):
//! `game/discord.rs` says what the player does.
//! or with `HORIZON_NO_DISCORD=1`.
//!
//! A Horizon crate (docs/layers.md): the game tells it what the player does through the
//! platform's hooks ([`HOOKS`]: `client_started`, `activity`, `shutdown`), which the official
//! executable (`horizon-official`) registers with the switch: its line of `horizon-extras.txt`
//! ([`SETTING`]) and its row in Extras' Multiplayer group ([`SECTION`]). horizon-client's own
//! executable has no presence.
#![forbid(unsafe_code)]
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Condvar, Mutex, OnceLock};
use std::thread::Thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use horizon_command::args::GameMode;
use horizon_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
use horizon_platform::pages::{Act, Label, Page, Place, Row};
use horizon_platform::settings::Store;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
@@ -40,24 +49,134 @@ const CLOSE: u32 = 2;
const PING: u32 = 3;
const PONG: u32 = 4;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dimension {
Overworld,
Nether,
End,
/// The presence's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: Presence = Presence;
pub struct Presence;
impl Hooks for Presence {
/// Starts the presence thread with the game's window (never for a dedicated server).
fn client_started(&self, client: &hooks::Client) {
start(client.automated);
}
fn activity(&self, now: Activity) {
set(match now {
Activity::Menus => Status::Menus,
Activity::World {
play,
dimension,
mode,
} => Status::World {
play,
dimension,
mode,
},
// Anything a later game says that this crate doesn't know yet.
_ => Status::Off,
});
}
/// On quit: the activity is cleared.
fn shutdown(&self) {
stop();
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Play {
Singleplayer,
/// A server or someone else's LAN world. Its address and name are never shown.
Multiplayer,
/// This world is open to LAN or friends; `players` counts the host, `max` is how many
/// may join.
Hosting {
players: usize,
max: usize,
},
/// The switch's line in `horizon-extras.txt`, where the engine's Extras kept it before.
const KEY: &str = "discordPresence";
/// The switch's row, and what it says on hover.
const LABEL: &str = "horizon.extras.discord_presence";
const ABOUT: &str = "horizon.extras.discord_presence.tooltip";
/// The switch (on by default): the player's own settings' line, the game's first player's. A
/// splitscreen player's settings file loads after the first player's and keeps it.
static ON: AtomicBool = AtomicBool::new(true);
/// Whether the player's settings were loaded once: the later loads are splitscreen players'.
static LOADED: AtomicBool = AtomicBool::new(false);
/// Turns the presence on or off; off clears the activity at once.
pub fn set_enabled(on: bool) {
ON.store(on, Ordering::Relaxed);
let mut shared = lock();
let status = shown(shared.game);
let changed = shared.wanted.update(status, unix_now());
drop(shared);
if let Some(thread) = THREAD.get().filter(|_| changed) {
thread.unpark();
}
}
pub fn enabled() -> bool {
ON.load(Ordering::Relaxed)
}
/// What Discord is told for the game's `status`: nothing with the switch off.
fn shown(status: Status) -> Status {
if enabled() { status } else { Status::Off }
}
/// The switch's line of `horizon-extras.txt`, for `horizon_platform::settings::register`.
pub static SETTING: Setting = Setting;
pub struct Setting;
impl Store for Setting {
fn file(&self) -> &'static str {
"horizon-extras.txt"
}
fn owns(&self, key: &str) -> bool {
key == KEY
}
fn load(&self, _key: &str, value: &str) {
// The first player's settings load first; the first load is theirs.
if LOADED.swap(true, Ordering::Relaxed) {
return;
}
match value {
"true" => set_enabled(true),
"false" => set_enabled(false),
_ => {}
}
}
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
line(KEY, if enabled() { "true" } else { "false" });
}
fn is_default(&self) -> bool {
enabled()
}
}
/// The switch's row in the Extras page's Multiplayer group, for
/// `horizon_platform::pages::register`.
pub static SECTION: Section = Section;
pub struct Section;
impl Page for Section {
fn id(&self) -> &'static str {
"horizon-discord.extras"
}
fn title(&self) -> &'static str {
"horizon.extras.header.multiplayer"
}
fn place(&self) -> Place {
Place::Extras
}
fn rows(&self, rows: &mut Vec<Row>) {
rows.push(Row::Toggle {
label: LABEL,
on: enabled(),
});
}
fn act(&self, row: usize, act: Act) {
if row == 0 && matches!(act, Act::Press | Act::PressBack) {
set_enabled(!enabled());
}
}
fn tooltip(&self, _row: usize) -> Option<Label> {
Some(Label::Key(ABOUT))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -97,11 +216,15 @@ impl Wanted {
}
struct Shared {
/// What the game said last.
game: Status,
/// What Discord is to show: the game's, or nothing with the switch off.
wanted: Wanted,
stop: bool,
stopped: bool,
}
static SHARED: Mutex<Shared> = Mutex::new(Shared {
game: Status::Menus,
wanted: Wanted {
status: Status::Menus,
since: None,
@@ -114,7 +237,8 @@ static THREAD: OnceLock<Thread> = OnceLock::new();
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
pub fn start(enabled: bool, automated: bool) {
/// The status [`set`] gave before, the switch included, is the first one sent.
pub fn start(automated: bool) {
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
if THREAD.get().is_some()
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
@@ -122,9 +246,6 @@ pub fn start(enabled: bool, automated: bool) {
{
return;
}
if !enabled {
lock().wanted.status = Status::Off;
}
match std::thread::Builder::new()
.name("Discord presence".into())
.spawn(run)
@@ -136,12 +257,13 @@ pub fn start(enabled: bool, automated: bool) {
}
}
/// What the player does now; only a change wakes the thread. Free without [`start`].
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
pub fn set(status: Status) {
let Some(thread) = THREAD.get() else {
return;
};
if lock().wanted.update(status, unix_now()) {
let mut shared = lock();
shared.game = status;
let changed = shared.wanted.update(shown(status), unix_now());
drop(shared);
if let Some(thread) = THREAD.get().filter(|_| changed) {
thread.unpark();
}
}
@@ -582,6 +704,39 @@ mod tests {
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
#[cfg(unix)]
/// The switch as the player's settings and the Extras page see it. The only test that
/// touches the presence's statics.
#[test]
fn the_switch_clears_the_activity_and_keeps_the_first_players_line() {
let wanted = || lock().wanted.status;
Setting.load(KEY, "false");
// A splitscreen player's settings file loads later and keeps the first player's.
Setting.load(KEY, "true");
set(Status::Menus);
assert!(!enabled());
assert_eq!(wanted(), Status::Off);
let mut rows = Vec::new();
SECTION.rows(&mut rows);
assert_eq!(
rows,
[Row::Toggle {
label: LABEL,
on: false
}]
);
let mut lines = Vec::new();
Setting.save(&mut |k, v| lines.push(format!("{k}:{v}")));
assert_eq!(lines, ["discordPresence:false"]);
assert!(!Setting.is_default());
// On from the page: what the game said last is shown at once.
SECTION.act(0, Act::Press);
assert_eq!(wanted(), Status::Menus);
assert!(Setting.is_default());
SECTION.act(0, Act::Press);
assert_eq!(wanted(), Status::Off);
SECTION.act(0, Act::Press);
}
#[test]
fn talks_to_discord_over_its_socket() {
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
+95 -199
View File
@@ -24,9 +24,7 @@ pub mod settings;
pub mod tunnel;
use registry::Registry;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::time::Duration;
use std::sync::atomic::{AtomicBool, Ordering};
/// What a crate above the engine hears from the game. Every method has an empty default:
/// implement only those you need. Hooks run on the game's thread (the render thread) unless a
@@ -44,140 +42,75 @@ pub trait Hooks: Sync {
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
fn shutdown(&self) {}
/// A moment of the game's start, once each, with the time since the process started.
fn startup(&self, _milestone: Milestone, _since_start: Duration) {}
/// Whether this hook wants the render thread to time its steps in every frame, for
/// [`Frame::slowest_step`]. Asked once as the game starts (not on a dedicated server);
/// `home` is the game's home folder. Timing the steps costs microseconds a frame.
fn wants_frame_steps(&self, _home: &Path) -> bool {
false
}
/// A world's first frame was drawn: the time since it started opening (single player, a
/// server, LAN). Every time a world is entered.
fn world_shown(&self, _since_opening: Duration) {}
/// A frame drawn while playing: not in the menus or while a world loads. Every frame.
fn frame(&self, _frame: &Frame<'_>) {}
/// The player left a world (to the title screen, or by quitting): the end of a play
/// session, with the settings it was played with.
fn world_left(&self, _left: &WorldLeft<'_>) {}
/// The screen the window is on: when the window opens, and again as a session ends (on
/// Wayland a window is on a screen only once it has been shown).
fn display(&self, _display: &Display) {}
/// The game crashed, once, before its report is saved in `crash-reports/`. Called on the
/// thread that crashed, whatever the mode (a dedicated server too).
fn crashed(&self, _crash: &Crash<'_>) {}
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
/// offers one is used. Asked on the network thread when joining and on the game thread when
/// an address is shown, never per frame.
fn tunnel(&self) -> Option<&dyn tunnel::Resolver> {
None
}
/// A game window opened: once, before the first frame, for every run that has a window,
/// automated ones included ([`Client::automated`]); never for a dedicated server.
fn client_started(&self, _client: &Client) {}
/// What the player is doing, for a status shown outside the game (Discord's): called when it
/// changes, not every frame. Splitscreen: the first player's only.
fn activity(&self, _now: Activity) {}
}
/// The graphics adapter the renderer runs on.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Gpu {
pub name: String,
pub vendor: String,
/// `integrated`, `discrete`, `virtual`, `cpu` or `other`.
pub kind: String,
pub driver: String,
/// `metal`, `vulkan`, `dx12` or `gl`.
pub backend: String,
/// What [`Hooks::client_started`] gets.
#[non_exhaustive]
pub struct Client {
/// A run nobody plays: offscreen, a benchmark, a run that quits by itself after some frames.
pub automated: bool,
}
/// The screen a window is on ([`Hooks::display`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Display {
/// Physical pixels.
pub width: u32,
pub height: u32,
/// `None` where the OS doesn't say.
pub refresh_hz: Option<u32>,
pub scale: f64,
impl Client {
pub fn new(automated: bool) -> Self {
Self { automated }
}
}
/// What [`Hooks::crashed`] gets: the crash report's parts.
pub struct Crash<'a> {
/// The game's home folder.
pub home: &'a Path,
/// The build: the release (`v0.2.3`), or `dev <crate version>` for a build from source.
pub version: &'a str,
/// The error's message.
pub error: &'a str,
/// Where it happened and how it got there: one frame a line.
pub stack: &'a str,
pub thread: &'a str,
/// The report's details (`Horizon Version`, `World`, `Server`...), as written in it.
pub details: &'a [(String, String)],
/// The renderer's adapter, once it has one.
pub gpu: Option<&'a Gpu>,
}
/// A moment of the game's start ([`Hooks::startup`]).
/// What the player is doing ([`Hooks::activity`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Milestone {
/// The resources the menus need are loaded.
CoreReady,
/// The rest (what a world needs) too.
RestReady,
/// The title screen drew its first frame.
TitleShown,
#[non_exhaustive]
pub enum Activity {
/// On the title screen or another menu outside a world.
Menus,
World {
play: Play,
dimension: Dimension,
mode: GameMode,
},
}
impl Milestone {
fn bit(self) -> u8 {
match self {
Self::CoreReady => 1,
Self::RestReady => 2,
Self::TitleShown => 4,
}
}
/// How the player is in a world.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Play {
Singleplayer,
/// A server or someone else's LAN world.
Multiplayer,
/// Their world is open to LAN or friends; `players` counts the host, `max` is how many may
/// join.
Hosting {
players: usize,
max: usize,
},
}
/// What [`Hooks::frame`] gets.
pub struct Frame<'a> {
/// How long the frame took.
pub ms: f32,
slowest_step: &'a dyn Fn() -> Option<(String, f32)>,
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dimension {
Overworld,
Nether,
End,
}
impl<'a> Frame<'a> {
/// `slowest_step` gives [`Frame::slowest_step`], only when asked.
pub fn new(ms: f32, slowest_step: &'a dyn Fn() -> Option<(String, f32)>) -> Self {
Self { ms, slowest_step }
}
/// The step the frame spent the most time in (`render/terrain`) and its milliseconds, when
/// the steps were timed ([`Hooks::wants_frame_steps`]). Worked out on each call: ask only
/// for a slow frame.
pub fn slowest_step(&self) -> Option<(String, f32)> {
(self.slowest_step)()
}
}
/// What [`Hooks::world_left`] gets: the game's home folder, the build, the adapter and the
/// settings played with.
pub struct WorldLeft<'a> {
pub home: &'a Path,
/// As [`Crash::version`].
pub version: &'a str,
/// The renderer's adapter.
pub gpu: Option<&'a Gpu>,
pub render_distance: i32,
pub simulation_distance: i32,
/// `None`: unlimited.
pub max_fps: Option<u32>,
pub vsync: bool,
pub fullscreen: bool,
/// The graphics preset's name.
pub graphics: &'a str,
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameMode {
Survival,
Creative,
Adventure,
Spectator,
}
/// What [`Hooks::started`] gets.
@@ -185,11 +118,14 @@ pub struct WorldLeft<'a> {
pub struct Started<'a> {
/// The build: the release tag (`0.2.31`), or `dev <crate version>` for a build from source.
pub version: &'a str,
/// The player's game folder: servers.dat, options.txt and saves (the working directory unless
/// `--game-dir` names another).
pub game_dir: &'a std::path::Path,
}
impl<'a> Started<'a> {
pub fn new(version: &'a str) -> Self {
Self { version }
pub fn new(version: &'a str, game_dir: &'a std::path::Path) -> Self {
Self { version, game_dir }
}
}
@@ -253,6 +189,32 @@ pub mod engine {
}
}
/// [`Hooks::client_started`].
pub fn client_started(client: &Client) {
for hooks in all() {
hooks.client_started(client);
}
}
/// The activity at the last call (`None` before the first).
static ACTIVITY: std::sync::Mutex<Option<Activity>> = std::sync::Mutex::new(None);
/// [`Hooks::activity`], when `now` differs from the last call's. Cheap enough to call every
/// frame: with no hook registered it only loads the empty list.
pub fn activity(now: Activity) {
let hooks = all();
if hooks.is_empty() {
return;
}
let mut last = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner());
if last.replace(now) != Some(now) {
drop(last);
for h in hooks {
h.activity(now);
}
}
}
/// [`Hooks::shutdown`].
pub fn shutdown() {
for hooks in all() {
@@ -260,57 +222,6 @@ pub mod engine {
}
}
/// The milestones already said.
static MILESTONES: AtomicU8 = AtomicU8::new(0);
/// [`Hooks::startup`], the first time `milestone` is reached; later calls do nothing.
pub fn startup(milestone: Milestone, since_start: Duration) {
if MILESTONES.fetch_or(milestone.bit(), Ordering::Relaxed) & milestone.bit() == 0 {
for hooks in all() {
hooks.startup(milestone, since_start);
}
}
}
/// [`Hooks::wants_frame_steps`]: whether any hook wants them.
pub fn wants_frame_steps(home: &Path) -> bool {
all().iter().any(|h| h.wants_frame_steps(home))
}
/// [`Hooks::world_shown`].
pub fn world_shown(since_opening: Duration) {
for hooks in all() {
hooks.world_shown(since_opening);
}
}
/// [`Hooks::frame`].
pub fn frame(frame: &Frame<'_>) {
for hooks in all() {
hooks.frame(frame);
}
}
/// [`Hooks::world_left`].
pub fn world_left(left: &WorldLeft<'_>) {
for hooks in all() {
hooks.world_left(left);
}
}
/// [`Hooks::display`].
pub fn display(display: &Display) {
for hooks in all() {
hooks.display(display);
}
}
/// [`Hooks::crashed`].
pub fn crashed(crash: &Crash<'_>) {
for hooks in all() {
hooks.crashed(crash);
}
}
/// [`Hooks::tunnel`]: the first registered hook's resolver.
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
all().iter().find_map(|hooks| hooks.tunnel())
@@ -336,23 +247,8 @@ mod tests {
fn published(&self, open: bool) {
self.0.lock().unwrap().push(format!("published {open}"));
}
fn startup(&self, milestone: Milestone, since_start: Duration) {
self.0
.lock()
.unwrap()
.push(format!("{milestone:?} {}", since_start.as_millis()));
}
fn wants_frame_steps(&self, _home: &Path) -> bool {
true
}
fn frame(&self, frame: &Frame<'_>) {
let step = frame
.slowest_step()
.map(|(name, ms)| format!(" {name} {ms}"));
self.0
.lock()
.unwrap()
.push(format!("frame {}{}", frame.ms, step.unwrap_or_default()));
fn activity(&self, now: Activity) {
self.0.lock().unwrap().push(format!("{now:?}"));
}
}
@@ -366,18 +262,19 @@ mod tests {
fn registered_hooks_hear_the_game_and_late_ones_are_refused() {
register(&RECORDER).unwrap();
register(&Silent).unwrap();
engine::started(&Started::new("0.2.31"));
engine::started(&Started::new("0.2.31", std::path::Path::new("game")));
// Changes only: the same state every frame is said once.
for open in [false, true, true, true, false, false] {
engine::published(open);
}
// Each milestone once: the first time it is reached.
engine::startup(Milestone::CoreReady, Duration::from_millis(900));
engine::startup(Milestone::TitleShown, Duration::from_millis(1500));
engine::startup(Milestone::CoreReady, Duration::from_millis(4000));
// Any hook that wants the steps timed has them.
assert!(engine::wants_frame_steps(Path::new("home")));
engine::frame(&Frame::new(70.0, &|| Some(("render/terrain".into(), 50.0))));
let world = Activity::World {
play: Play::Singleplayer,
dimension: Dimension::Nether,
mode: GameMode::Creative,
};
for now in [Activity::Menus, Activity::Menus, world, world] {
engine::activity(now);
}
engine::shutdown();
assert_eq!(
*RECORDER.0.lock().unwrap(),
@@ -385,9 +282,8 @@ mod tests {
"started 0.2.31",
"published true",
"published false",
"CoreReady 900",
"TitleShown 1500",
"frame 70 render/terrain 50",
"Menus",
"World { play: Singleplayer, dimension: Nether, mode: Creative }"
]
);
assert_eq!(register(&Silent), Err(TooLate));
@@ -0,0 +1,15 @@
[package]
name = "horizon-official-servers"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Horizon's official server in the multiplayer list, and the withdrawn partner server taken out of it (docs/layers.md)"
[dependencies]
horizon-addresses = { path = "../horizon-addresses" }
horizon-platform = { path = "../horizon-platform", features = ["protocol"] }
log = "0.4"
[lints]
workspace = true
@@ -1,12 +1,29 @@
//! Horizon's own entries in the multiplayer list (docs/layers.md: Horizon layer): the official
//! server, added once at the top, and the withdrawn partner server, taken back out once. Behind
//! `horizon-extras` (docs/forking.md), so a plain build adds no server and holds no Horizon
//! address. It reads and writes servers.dat only through `horizon_protocol::servers`, ready to
//! leave the engine crates for a Horizon crate.
use horizon_protocol::servers::{self, ServerEntry};
//! Horizon's own entries in the multiplayer list: the official server, added once at the top,
//! and the withdrawn partner server, taken back out once.
//!
//! A Horizon crate (docs/layers.md): it plugs into the game through the platform's hooks
//! ([`HOOKS`], when the game starts), which the official executable (`horizon-official`)
//! registers, and reads servers.dat through `horizon_platform::servers`. horizon-client's plain
//! executable adds no server. Its addresses are in `horizon-addresses`.
#![forbid(unsafe_code)]
use horizon_platform::hooks::{Hooks, Started};
use horizon_platform::servers::{self, ServerEntry};
use std::path::Path;
/// At startup of a player's game: the official server into the list in `game_dir` and the
/// The server list's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: OfficialServers = OfficialServers;
pub struct OfficialServers;
impl Hooks for OfficialServers {
/// Someone is playing (not a server, a benchmark or an automated run): their list, once.
fn started(&self, game: &Started<'_>) {
update(game.game_dir);
}
}
/// At the start of a player's game: the official server into the list in `game_dir` and the
/// withdrawn partner server out of it, a failure only logged.
pub fn update(game_dir: &Path) {
if let Err(e) = add_official_server(game_dir) {
@@ -17,12 +34,8 @@ pub fn update(game_dir: &Path) {
}
}
/// Horizon's official survival server: a name and an address like any entry. The address is
/// `horizon_telemetry::server_status::OFFICIAL_ADDRESS`, the one place it is kept.
pub const OFFICIAL_SERVER: (&str, &str) = (
"Horizon Official",
horizon_telemetry::server_status::OFFICIAL_ADDRESS,
);
/// Horizon's official survival server: a name and an address like any entry.
pub const OFFICIAL_SERVER: (&str, &str) = ("Horizon Official", horizon_addresses::OFFICIAL_SERVER);
/// Beside servers.dat once the official server was offered, so a player who
/// deletes it never gets it back: the address offered, so a new one moves the
/// list once. The launcher writes the same file.
@@ -55,7 +68,7 @@ pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
let file = game_dir.join("servers.dat");
let _guard = servers::lock();
let mut list = servers::load(&file);
let previous = horizon_telemetry::server_status::OFFICIAL_PREVIOUS;
let previous = horizon_addresses::OFFICIAL_SERVER_PREVIOUS;
if move_official(&mut list, address, previous) {
servers::save(&file, &list)?;
}
@@ -71,7 +84,10 @@ pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
/// Afri-Network, which the game added once below the official server (#9299) until the
/// partnership ended: the name and address the game gave its entry.
const WITHDRAWN_PARTNER: (&str, &str) = ("Afri-Network (South Africa)", "afri-network.co.za");
const WITHDRAWN_PARTNER: (&str, &str) = (
"Afri-Network (South Africa)",
horizon_addresses::WITHDRAWN_PARTNER_SERVER,
);
/// Beside servers.dat: the partner addresses the game offered, one per line (#9299).
const PARTNER_MARKER: &str = "horizon_partner_servers.txt";
+2 -1
View File
@@ -15,7 +15,8 @@ horizon-game = { path = "../horizon-game" }
horizon-platform = { path = "../horizon-platform" }
horizon-heartbeat = { path = "../horizon-heartbeat" }
horizon-friends = { path = "../horizon-friends", features = ["game"] }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["game"] }
horizon-official-servers = { path = "../horizon-official-servers" }
horizon-discord = { path = "../horizon-discord" }
log = "0.4"
[build-dependencies]
+16 -7
View File
@@ -4,27 +4,36 @@
//! (`game-build.yml`); horizon-client keeps its own plain executable.
//!
//! Each Horizon crate that runs in the game gives a `&'static` value implementing
//! `horizon_platform::hooks::Hooks`, and [`register`] adds it. Nothing here depends on an engine
//! crate.
//! `horizon_platform::hooks::Hooks`, and its settings store and pages if it has any, and
//! [`register`] adds them. Nothing here depends on an engine crate.
#![forbid(unsafe_code)]
use horizon_platform::hooks::{self, Hooks};
use horizon_platform::{pages, settings};
/// Every Horizon part in the game, in the order its hooks run.
const PARTS: [&dyn Hooks; 3] = [
const PARTS: [&dyn Hooks; 4] = [
// The anonymous "I'm playing" ping.
&horizon_heartbeat::HOOKS,
// Play with Friends: `friend:` addresses join through its tunnel.
&horizon_friends::game::HOOKS,
// Opt-in telemetry: the play session's summary and crash reports, kept for the launcher.
&horizon_telemetry::game::HOOKS,
// The official server in the player's server list.
&horizon_official_servers::HOOKS,
// "Playing Horizon" on the player's Discord profile.
&horizon_discord::HOOKS,
];
/// Their settings, in the player's settings files.
const STORES: [&dyn settings::Store; 1] = [&horizon_discord::SETTING];
/// Their rows and pages in the options screens.
const PAGES: [&dyn pages::Page; 1] = [&horizon_discord::SECTION];
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
/// game, and so does the launcher's generated executable for native mods.
pub fn register() {
for part in PARTS {
if let Err(e) = hooks::register(part) {
let late = PARTS.into_iter().map(hooks::register);
let late = late.chain(STORES.into_iter().map(settings::register));
for result in late.chain(PAGES.into_iter().map(pages::register)) {
if let Err(e) = result {
// The executable registered after starting the game: a bug, not a player's problem.
log::warn!("Horizon: an in-game part did not register: {e}");
}
+1 -1
View File
@@ -10,7 +10,7 @@ description = "The platform's public API: what the Horizon layer and mods may us
# Each part of the engine a Horizon crate may reach, so a crate builds only the engine it uses.
# level.dat, servers.dat and region files: NBT, compression, region read and write.
world = ["dep:horizon-world"]
# A server's address after its SRV record, and the offline-mode UUID of a name.
# A server's address after its SRV record, the offline-mode UUID of a name, and servers.dat.
protocol = ["world", "dep:horizon-protocol"]
# Preparing the game's files from the player's own Minecraft install.
prepare = ["dep:horizon-prepare"]
+10 -1
View File
@@ -47,7 +47,7 @@ pub use horizon_server_files as server_files;
/// in"). The engine's dispatch side is not part of the API.
pub mod hooks {
pub use horizon_hooks::{
Crash, Display, Frame, Gpu, Hooks, Milestone, Started, TooLate, WorldLeft, register, tunnel,
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, tunnel,
};
}
@@ -66,3 +66,12 @@ pub mod pages {
pub mod hud {
pub use horizon_hooks::hud::{Canvas, Corner, Layer, register};
}
/// The multiplayer server list as vanilla stores it (`servers.dat`): read, write, add, remove,
/// and the lock held while it is read and written back.
#[cfg(feature = "protocol")]
pub mod servers {
pub use horizon_protocol::servers::{
ServerEntry, load, lock, parse_address, prepend, remove_where, save,
};
}
-4
View File
@@ -18,16 +18,12 @@ ruzstd = "0.9"
getrandom = "0.3"
# Sending, for the launcher (and the game's "I'm playing" ping); the ingest server builds without it.
ureq = { version = "2", default-features = false, features = ["tls"], optional = true }
log = { version = "0.4", optional = true }
[features]
default = ["upload", "live"]
upload = ["dep:ureq"]
# The "I'm playing" ping and reading the live count (`live`), for the game and the launcher.
live = ["dep:ureq"]
# The game's part (`game`): the play session's summary and crash reports, kept through the
# platform's hooks. The official executable turns it on.
game = ["dep:log"]
[[test]]
name = "consent_gating"
-2
View File
@@ -15,8 +15,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub mod batch;
pub mod consent;
#[cfg(feature = "game")]
pub mod game;
pub mod live;
pub mod outbox;
pub mod payload;
+1 -1
View File
@@ -65,7 +65,7 @@ command above to inspect the selection rather than relying on historical counts.
| 36 | Audio and music (38) | `R/audio.rs`, `R/sound_engine.rs`, `R/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle |
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) |
| 38 | Command parsing (34) | `C/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); `cargo test -p horizon-command` | command_parse (corpus `tools/command_parse_corpus.py`) |
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `crates/horizon-telemetry/src/game.rs`, `tools/perf_bench.py`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `L/telemetry.rs`, `tools/perf_bench.py`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
| 40 | Explosions and TNT (31) | `G/explosion.rs`, `G/tnt.rs` | lan_e2e blast; no dedicated harness yet |
Controller glyphs (`R/pad_glyph.rs`, drawn by the gameplay guide, notices, splitscreen prompts and declared
+4 -4
View File
@@ -22,15 +22,15 @@ What changes in that build:
| Extra | Where it lives | The gate |
|---|---|---|
| Cosmetics, emotes and the emote wheel, "Show me as a Horizon player" | `game/cosmetics.rs` (+ `cosmetics/fx.rs`, `cosmetics/emotes.rs`, `cosmetics/emote_wheel.rs`), crate `horizon-cosmetics` | `game::cosmetics::enabled()`: the service is never asked, nothing is drawn, B opens no wheel |
| The official server added to the server list, the withdrawn partner server taken out | `official_servers.rs` | the module and its call at startup (`game.rs`, `fn run`) |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
| The official server added to the server list, the withdrawn partner server taken out | crate `horizon-official-servers` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Discord Rich Presence and its switch | crate `horizon-discord` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
Credits & Attribution then says where the official build is (`horizon.plain.official`).
`grep -rn horizon-extras crates/` lists every gate. Apart from the three features above, the gates
`grep -rn horizon-extras crates/` lists every gate. Apart from the `friends` feature above, the gates
are `cfg!(feature = "horizon-extras")`: both sides compile in every build, so no stub has to follow
the code it replaces. To remove an extra for good, delete its files from the table and the lines its
gate guards.
+1 -1
View File
@@ -45,7 +45,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
**Translucent batching** (Advanced, after Graphics API; on by default, `batchTranslucent` in `horizon-graphics.txt`, a new key so that the `translucentBatching:false` earlier builds saved for everyone doesn't keep it off; it applies at the next start or resource reload, and is no part of a preset) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same and frames come faster, most over water and at high render distances (RTX 4090 at 1080p: 4 to 16% more FPS at render distance 32; over the ocean at 64, 354 draw calls a frame instead of 3,832 and 43% more FPS); it costs a little video memory, as solid and translucent quads no longer share free space (terrain meshes take 1 to 15% more at render distance 32, 5 to 10% more at 64). The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it: there the switch is greyed out and reads Off, and macOS doesn't show it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' SDR white is the player's own slider, but its peak is mostly the display's EDID figure, which often runs high: Auto still uses it, over the 1000-nit default, but the HDR Line truncated
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' luminance readings often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Line truncated
With `HORIZON_HDR_FRAME=float`, HDR on and no shader pack or a 0.2 one, devices with independent blending draw the frame in layers ([hdr-linear-light.md](design/hdr-linear-light.md), The HUD and menus). The world's shaders write their own light into the frame instead of a mark (`hdr_written`, `hdr_light.wgsl`), a whole frame at a time, so glass, water, clouds and particles each lay their own light over the light behind them. Improved Transparency's layers write their own light too, before they are weighted and composited. No float frame holds marks: under a 0.2 pack Horizon's own draws keep their colour, alpha 1 where opaque and coverage where blended, HDR output lifts nothing, and the pack's `[tonemap]` takes the tone curve's place below (one or the other runs, never both); without the HUD layer (HDR off, or a device without independent blending) its float frame finishes on the swapchain's 8 bits before the camera overlays, which then draw with the HUD as in SDR, and HDR output stays off. The camera overlays (vignette, pumpkin, powder snow, portal, nausea), post effects and entity outlines draw on light. After the post effects and before the camera overlays, the tone curve eases that light into the peak (`Hdr::tonemap`) in place of HDR lighting's own easing and the light sources' cap at the peak: up to 60% of the peak light stays as it is, and above it ITU-R BT.2390's EETF in PQ rolls it off, on its brightest channel so it keeps its hue. The HUD and menus then draw on an 8-bit target of their own, the world clipped to white, as they found the frame before (each draw clips at white as there), and write how much of the world they hide into an R8 coverage target. The last pass lays the HUD over the world's light by that coverage, in gamma as the HUD blends: light behind see-through HUD shows as vanilla weights the HUD over it, and none shows through what covers it. The menu blur blurs that picture, light above white included, and the menu draws over the result. UI Brightness scales the HUD's own part, not the world under it. A screenshot maps the world's light into SDR, unchanged up to 0.6 of paper white in linear light and smoothly rolled towards white above it, with one scale for all RGB channels to keep their hue (where Horizon lifts nothing, with HDR Lighting off or under a 0.2 pack, the world is clipped at white as SDR draws it); the HUD's own part stays as drawn, whatever UI Brightness is, over the mapped world by its coverage. The world's icon uses the same mapping without the HUD; the `-hdr.png` beside a screenshot keeps the whole HDR picture. `HORIZON_HDR_DEBUG=coverage` tints the coverage magenta for inspection.
+9 -15
View File
@@ -156,9 +156,8 @@ Horizon's own extras and its product, and every built-in address of an outside s
benchmark uploads, releases and updates, the hosting supervisor, the launcher.
- **Examples:** `horizon-addresses`, `horizon-cosmetics`, `horizon-friends`, `horizon-relay`, `horizon-telemetry`,
`horizon-bench`, `horizon-release-sign`, `horizon-server`, `horizon-mod-registry`, `horizon-heartbeat`,
`horizon-official`, `launcher/`. Still in engine crates today: `game/cosmetics.rs`, `game/emotes.rs`,
`game/discord.rs`, `game/extras.rs`, `game/tweaks.rs`, `multiplayer.rs` (`add_official_server`), `extras_ui.rs`,
`pvp.rs`.
`horizon-official`, `horizon-official-servers`, `horizon-discord`, `launcher/`. Still in engine crates today:
`game/cosmetics.rs`, `game/emotes.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
- **Rules:**
- It uses the platform's public API only, never the engine.
- Until it moves, every Horizon feature in the game sits behind the `horizon-extras` feature
@@ -288,17 +287,12 @@ build pays nothing.
The hooks today: `started` (once, when someone plays: not a dedicated server, a benchmark or an automated run),
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
world saved), and the play session's: `startup` (core-ready, rest-ready, the title's first frame, once each),
`wants_frame_steps` (asked once: time the render thread's steps), `world_shown` (a world's first frame), `frame`
(every frame played), `world_left` (the session's end, with its settings), `display` (the screen the window is on)
and `crashed` (each crash, before its report is saved); and `tunnel` (the resolver of `friend:` addresses, Play
with Friends'). Opt-in telemetry (`horizon_telemetry::game`, registered by `horizon-official`) hears the game
through these only. **A hook you need and don't find:** add a method with an empty default to `Hooks` and a
function in `horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data
or a type the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a
draw context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension
point that belongs to one subsystem (an input source, the compositor) lives in the engine crate that owns it, with
the same shape: registered before the game runs, read-only after.
world saved) and `tunnel` (the resolver of `friend:` addresses, Play with Friends'). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
that belongs to one subsystem (an input source, the compositor) lives in the engine crate that owns it, with the
same shape: registered before the game runs, read-only after.
**What players get.** The release builds (`game-build.yml`, so the nightly and the release; `tools/ci/build-musl.sh`)
build `horizon-official` and ship it as `horizon-client`, the name the launcher and players know. The launcher's
@@ -518,7 +512,7 @@ Horizon's own and third-party addresses (Horizon layer):
| `cosmetics.playhorizon.dev` | cosmetics, emotes, presence | `horizon-addresses` (#12993) |
| `telemetry.playhorizon.dev` | opt-in telemetry, the "I'm playing" ping, benchmark uploads | `horizon-addresses` (#12993) |
| `stun.cloudflare.com`, `stun.l.google.com` | Play with Friends NAT discovery | `horizon-addresses` (#12993) |
| `afri-network.co.za` | the withdrawn partner server, so the game can remove the entry it added | `multiplayer.rs` (`WITHDRAWN_PARTNER`) |
| `afri-network.co.za` | the withdrawn partner server, so the game can remove the entry it added | `horizon-addresses` |
| `horizon-rust.github.io/horizon-mods`, `github.com/horizon-rust/horizon-mods` | the mod registry index and files: a build of this repository alone asks the player for a registry | `mod_registry.rs`, `horizon-mods/src/index*.rs`, `cargo-horizon-mod` |
| `api.modrinth.com`, `cdn.modrinth.com` | the in-game resource pack browser, modpack porting | `pack_browser.rs`, `horizon-port-pack` |
| `github.com`, `raw.githubusercontent.com`, `gitlab.com`, `maven.neoforged.net` | modpack porting's sources | `horizon-port-pack` |
+11 -6
View File
@@ -423,13 +423,18 @@ While Discord runs on the same computer, the player's Discord profile shows
"Playing Horizon" and what they are doing: "In the menus", "Singleplayer",
"Multiplayer" or "Hosting a world" (with the party size, of 8), the dimension
("In the Nether") and the time since they entered the world. It never shows a
world's name or a server's address. Horizon Settings has the switch
(**Discord Rich Presence**, on by default; `discordPresence` in
`horizon-extras.txt`), and `HORIZON_NO_DISCORD=1` turns it off for one run.
world's name or a server's address. The switch is on the Extras page, in its
Multiplayer group (**Discord Rich Presence**, on by default; `discordPresence`
in `horizon-extras.txt`, the first splitscreen player's), and
`HORIZON_NO_DISCORD=1` turns it off for one run.
`crates/horizon-client/src/discord_presence.rs` speaks Discord's local IPC
itself (the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its
own: the frame only hands it a status when it changes. It retries every 15 s
The crate `horizon-discord` (Horizon layer) speaks Discord's local IPC itself
(the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its own.
The game hands it a status only when it changes, through the platform's
`activity` hook (`game/activity.rs` says what the player does), and keeps the
switch itself: its settings store and its Extras row (the platform's settings
and pages registries). Only the official executable (`horizon-official`)
registers it: horizon-client's own executable shows no presence and no switch. It retries every 15 s
while Discord isn't running and stays within Discord's 5 updates per 20 s.
Dedicated servers never start it, and automated runs (offscreen, benchmarks)
only talk to a fake Discord: `lab.game_env` sets `HORIZON_NO_DISCORD=1`, and
+2 -2
View File
@@ -427,8 +427,8 @@ game built in `Game::new`, the integrated server on its own thread. On that base
**Binaries.** The release archives hold `horizon-client`, `horizon-ctl`, `horizon-prepare` and
`horizon-server`. On Linux the release also has `horizon-server-linux-x86_64.tar.gz`: `horizon-server`,
`horizon-prepare` and `horizon-engine`, the game built with `--no-default-features` (no sound, no
controllers, no Discord presence or Play with Friends: the `discord` and `friends` features; nor the "I'm
playing" ping, which only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
controllers, no Play with Friends: the `friends` feature; nor Discord presence or the "I'm playing" ping, which
only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library;
CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else
`horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features`
+11 -1
View File
@@ -462,7 +462,17 @@ impl Launcher {
.into_any_element()
};
let about = card()
.child(self.location_row(0, cx).mb(px(0.)))
.child(index_row(
0,
tr("Data folder"),
Some(mono(short(&backend::data_dir()))),
Some(
button("about-open-data", "Open", Kind::Secondary, &t)
.on_click(|_, _, cx| crate::glass_toast::open(cx, &backend::data_dir()))
.into_any_element(),
),
&t,
))
.child(index_row(
0,
tr("Logs"),
+1 -12
View File
@@ -209,18 +209,7 @@ impl Launcher {
n,
tr("Data folder"),
Some(div().font_family(theme::MONO).child(short(&backend::data_dir())).into_any_element()),
Some(
div()
.flex()
.gap(px(8.))
.child(
button("about-open-data", "Open", Kind::Secondary, &t).on_click(
|_, _, cx| crate::glass_toast::open(cx, &backend::data_dir()),
),
)
.child(move_button)
.into_any_element(),
),
Some(move_button.into_any_element()),
&t,
))
.child(text(theme::CAPTION).text_color(t.muted).child(tr(
-1
View File
@@ -49,7 +49,6 @@
"crates/horizon-client/src/mod_registry.rs horizon-rust.github.io",
"crates/horizon-client/src/mod_registry.rs playhorizon.dev",
"crates/horizon-client/src/no_resources.rs playhorizon.dev",
"crates/horizon-client/src/official_servers.rs afri-network.co.za",
"crates/horizon-mod-registry/src/github.rs api.github.com",
"crates/horizon-mod-registry/src/github.rs github.com",
"crates/horizon-mod-registry/src/github.rs uploads.github.com",
+6 -6
View File
@@ -9,7 +9,7 @@ The game goes title -> world -> Nether -> quit through horizon-ctl, and the acti
single single player on its server thread
lan a host opens its world to LAN and a guest joins: the guest shows "Multiplayer",
the host "Hosting a world" with a party of 2 of 8
setting off in horizon-extras.txt: nothing reaches Discord; the Horizon Settings switch
setting off in horizon-extras.txt: nothing reaches Discord; the Extras switch
turns it on (the activity appears) and off (it is cleared)
late Discord starts after the game: found within the 15 s retry
@@ -136,7 +136,7 @@ class Game:
self.discord.start()
extra = {'HORIZON_HOME': self.home, 'HORIZON_TELEMETRY_URL': 'off', 'HORIZON_DISCORD_IPC_DIR': self.discord.dir}
self.env = lab.game_env(**extra)
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-client'), '--resources',
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-official'), '--resources',
str(lab.RESOURCES.resolve()), *args)
def ctl_run(self, *words, timeout=600):
@@ -208,7 +208,7 @@ def lan(bins):
def setting(bins):
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Horizon Settings: the
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Extras: the
activity appears; off again: it is cleared."""
game = Game(bins, 'setting', extras='discordPresence:false\n')
d = game.discord
@@ -216,7 +216,7 @@ def setting(bins):
game.ctl_run('wait-for', 'screen', 'title')
time.sleep(3)
assert not d.handshakes and not d.activities, (d.handshakes, d.activities)
game.ctl_run('options', 'horizon')
game.ctl_run('options', 'extras')
switch = next(w for w in game.ctl_run('ui')['widgets'] if w.get('label', '').startswith('Discord Rich'))
assert switch['label'] == 'Discord Rich Presence: Off', switch
game.ctl_run('click', switch['id'])
@@ -251,14 +251,14 @@ CHECKS = {'single': single, 'lan': lan, 'setting': setting, 'late': late}
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--check', choices=[*CHECKS, 'all'], default='all')
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
parser.add_argument('--bin-dir', help='prebuilt horizon-official and horizon-ctl (default: build with the fast profile)')
args = parser.parse_args()
if sys.platform == 'win32':
sys.exit('the fake Discord is a Unix socket: macOS or Linux only')
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bins'],
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-official', '-p', 'horizon-client-lib', '--bins'],
cwd=ROOT, check=True)
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
capture_output=True, text=True, check=True).stdout)
+2
View File
@@ -51,11 +51,13 @@
"horizon-addresses": "horizon",
"horizon-bench": "horizon",
"horizon-cosmetics": "horizon",
"horizon-discord": "horizon",
"horizon-friends": "horizon",
"horizon-heartbeat": "horizon",
"horizon-launcher": "horizon",
"horizon-mod-registry": "horizon",
"horizon-official": "horizon",
"horizon-official-servers": "horizon",
"horizon-relay": "horizon",
"horizon-release-sign": "horizon",
"horizon-server": "horizon",
+1 -1
View File
@@ -116,7 +116,7 @@ Ravager.strongKnockback inline crates/horizon-client/src/mobs/ravager.rs::roar t
Ravager.addRoarParticleEffects inline crates/horizon-client/src/mobs/ravager.rs::roar the poof particles are emitted in roar
Shulker.registerGoals port crates/horizon-client/src/mobs/shulker.rs::register gap: minor: no defense target goal (priority 3)
Shulker.defineSynchedData skip:other attach face, peek and colour are Mob extra fields
Shulker.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt AttachFace wraps through Direction.from3DDataValue, Peek and Color are raw getByteOr bytes
Shulker.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt AttachFace, Peek and Color are read there
Shulker.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt AttachFace, Peek and Color are written there
Shulker.findNewAttachment inline crates/horizon-client/src/mobs/shulker.rs::tick keeps its face or teleports in tick, through attachable and teleport
Shulker.makeBoundingBox port crates/horizon-client/src/mobs/shulker.rs::progress_aabb
1 ItemFrame.defineSynchedData skip:other entity data defaults; ItemFrame starts with item None and rotation 0 in place_item_frame
116 Ravager.addRoarParticleEffects inline crates/horizon-client/src/mobs/ravager.rs::roar the poof particles are emitted in roar
117 Shulker.registerGoals port crates/horizon-client/src/mobs/shulker.rs::register gap: minor: no defense target goal (priority 3)
118 Shulker.defineSynchedData skip:other attach face, peek and colour are Mob extra fields
119 Shulker.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt AttachFace wraps through Direction.from3DDataValue, Peek and Color are raw getByteOr bytes AttachFace, Peek and Color are read there
120 Shulker.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt AttachFace, Peek and Color are written there
121 Shulker.findNewAttachment inline crates/horizon-client/src/mobs/shulker.rs::tick keeps its face or teleports in tick, through attachable and teleport
122 Shulker.makeBoundingBox port crates/horizon-client/src/mobs/shulker.rs::progress_aabb