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
30 changed files with 536 additions and 203 deletions

No files matched your search

Generated
+20
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,14 +1802,25 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-discord",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-platform",
"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"
+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.
+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));
@@ -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
}
+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"]
-4
View File
@@ -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,8 +100,6 @@ 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;
+6 -17
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;
@@ -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();
@@ -12665,10 +12663,8 @@ pub fn run_with() -> Result<()> {
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::telemetry::version();
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 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,
}
}
}
@@ -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);
+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()
}
@@ -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)?;
+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()));
+113 -4
View File
@@ -48,6 +48,69 @@ pub trait Hooks: Sync {
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) {}
}
/// 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,
}
impl Client {
pub fn new(automated: bool) -> Self {
Self { automated }
}
}
/// What the player is doing ([`Hooks::activity`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Activity {
/// On the title screen or another menu outside a world.
Menus,
World {
play: Play,
dimension: Dimension,
mode: GameMode,
},
}
/// 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,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dimension {
Overworld,
Nether,
End,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameMode {
Survival,
Creative,
Adventure,
Spectator,
}
/// What [`Hooks::started`] gets.
@@ -55,11 +118,14 @@ pub trait Hooks: Sync {
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 }
}
}
@@ -123,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() {
@@ -155,6 +247,9 @@ mod tests {
fn published(&self, open: bool) {
self.0.lock().unwrap().push(format!("published {open}"));
}
fn activity(&self, now: Activity) {
self.0.lock().unwrap().push(format!("{now:?}"));
}
}
/// Implements nothing: the defaults do nothing.
@@ -167,15 +262,29 @@ 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);
}
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(),
["started 0.2.31", "published true", "published false"]
[
"started 0.2.31",
"published true",
"published false",
"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
View File
@@ -15,6 +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-official-servers = { path = "../horizon-official-servers" }
horizon-discord = { path = "../horizon-discord" }
log = "0.4"
[build-dependencies]
+16 -5
View File
@@ -4,25 +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; 2] = [
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,
// 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"]
+12 -1
View File
@@ -46,7 +46,9 @@ pub use horizon_server_files as server_files;
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
/// in"). The engine's dispatch side is not part of the API.
pub mod hooks {
pub use horizon_hooks::{Hooks, Started, TooLate, register, tunnel};
pub use horizon_hooks::{
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, tunnel,
};
}
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
@@ -64,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 -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.
+3 -4
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
@@ -513,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`
-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",