Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 4e1ae724d7 Play with Friends' host side registers from horizon-official, not the engine (spec #14167 step 3c)
game/share.rs speaks only horizon_platform: its hooks pass through
horizon_friends::game::HOOKS (now tunnel + share + lan_closed + control),
its page is horizon_friends::game::PAGE, registered by
horizon_official::register(). friendsRelay and friendsStun are read from
horizon.txt by the crate itself. horizon-client loses the `friends` feature
and its horizon-friends dependency: `horizon-client -> horizon-friends`
leaves layers.json's allowed list. pr_ready tests horizon-friends with its
`game` feature, as CI's workspace run does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:13:37 +01:00
Horizon agentandClaude Opus 5.5 6098ce91c2 Move game/friends.rs to horizon-friends' game/share.rs, unchanged (spec #14167 step 3c)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:06:09 +01:00
16 changed files with 101 additions and 64 deletions

No files matched your search

Generated
-1
View File
@@ -1449,7 +1449,6 @@ dependencies = [
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
+1 -6
View File
@@ -39,8 +39,6 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -123,13 +121,10 @@ controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-duals
# tracing on request (`HORIZON_RAY_TRACING=1`). 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", "horizon-client-render/horizon-extras", "discord", "friends"]
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord"]
# 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"]
# 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"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
-3
View File
@@ -27,9 +27,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_hooks::engine::set_language(|key| {
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
});
// Play with Friends' host side, until it moves to horizon-friends (spec #14167).
#[cfg(feature = "friends")]
game::register_friends();
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_hooks::engine::seal();
-4
View File
@@ -162,8 +162,6 @@ mod fov;
mod foxes;
mod frame_geometry;
mod frames;
#[cfg(feature = "friends")]
mod friends;
mod functions;
mod game_mode;
mod game_tests;
@@ -595,8 +593,6 @@ use resource_loader::{
EntityModels, GameCpuResources, Loaded, LoaderMsg, PendingWorldEntry, WorldRestLoader,
run_loading, write_report,
};
#[cfg(feature = "friends")]
pub(crate) use share_view::register_friends;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -10,17 +10,6 @@ fn shares(game: &Game<'_>) -> bool {
!game.serving() && !game.options.split_guest
}
/// Play with Friends' host side (game/friends.rs) hears the hooks, until it moves to
/// horizon-friends (spec #14167). Before the hooks are sealed.
#[cfg(feature = "friends")]
pub(crate) fn register_friends() {
if horizon_hooks::register(&super::friends::HOOKS).is_err()
|| horizon_hooks::pages::register(&super::friends::PAGE).is_err()
{
log::warn!("Play with Friends registered after the game started: sharing is off");
}
}
impl Game<'_> {
/// `Hooks::share`, once a client tick (`Minecraft.runTick`, so also while paused).
pub(super) fn share_tick(&mut self) {
+24 -3
View File
@@ -1,7 +1,10 @@
//! Play with Friends in the game, joining side (the `game` feature): the engine's `friend:`
//! Play with Friends in the game (the `game` feature). Joining: the engine's `friend:`
//! addresses (`horizon_platform::hooks::tunnel`) read as invite codes, and joined through this
//! crate's tunnel. `horizon-official` registers [`HOOKS`]; the game's own executable has none, so
//! there no invite code joins.
//! crate's tunnel. Hosting ([`share`]): the pause menu's Friends page shares the open world
//! behind an invite code. `horizon-official` registers [`HOOKS`] and [`PAGE`]; the game's own
//! executable has neither, so there no invite code joins and nothing is shared.
mod share;
use crate::{
PathMode,
guest::{self, GuestConfig},
@@ -10,10 +13,12 @@ use crate::{
use horizon_platform::{
hooks::{
Hooks,
share::ShareView,
tunnel::{Opened, Progress, Resolver},
},
version::{PROTOCOL_VERSION, VANILLA_VERSION},
};
pub use share::PAGE;
use std::net::SocketAddr;
/// What `horizon-official` registers: Play with Friends' hooks.
@@ -25,6 +30,22 @@ impl Hooks for Friends {
fn tunnel(&self) -> Option<&dyn Resolver> {
Some(&Invites)
}
fn share(&self, world: &mut dyn ShareView) {
share::HOOKS.share(world);
}
fn lan_closed(&self) {
share::HOOKS.lan_closed();
}
fn control_commands(&self) -> Option<(&'static str, &'static [&'static str])> {
share::HOOKS.control_commands()
}
fn control(&self, words: &[&str], world: &mut dyn ShareView) -> Option<Result<String, String>> {
share::HOOKS.control(words, world)
}
}
struct Invites;
@@ -5,20 +5,21 @@
//! and the key, so friends join with no server of ours; a relay the player
//! configured adds a short code and a fallback. The integrated server is the
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address. The joining side is horizon-friends' (`game`), registered by
//! horizon-official.
//! loopback address. The joining side is [`super`]'s resolver; horizon-official registers
//! both ([`super::HOOKS`], [`PAGE`]).
//!
//! It sees the game only through `horizon_hooks` (the `ShareView` its `Hooks::share` tick
//! gets, `Hooks::lan_closed`, its page, `tr`) and keeps what it shares in its own statics, so
//! it can leave the engine for horizon-friends (spec #14167). `horizon-ctl friends` reaches it
//! through `Hooks::control`.
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_hooks::{
Hooks,
//! It sees the game only through the platform's hooks (the `ShareView` its `Hooks::share` tick
//! gets, `Hooks::lan_closed`, `Hooks::control` for `horizon-ctl friends`, its page, `tr`) and
//! keeps what it shares in its own statics.
use crate::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_platform::{
hooks::{
Hooks,
share::{LanOptions, ShareView},
},
pages::{Act, Page, Place, Row},
share::{LanOptions, ShareView},
version::{PROTOCOL_VERSION, VANILLA_VERSION},
};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
@@ -92,11 +93,11 @@ fn take() -> Option<Sharing> {
taken
}
/// Registered by the engine's entry while this module is part of it.
pub struct Friends;
pub static HOOKS: Friends = Friends;
/// The host side's hooks, which [`super::HOOKS`] passes on.
pub(super) struct Share;
pub(super) static HOOKS: Share = Share;
impl Hooks for Friends {
impl Hooks for Share {
fn control_commands(&self) -> Option<(&'static str, &'static [&'static str])> {
Some(("friends", HELP))
}
@@ -145,7 +146,7 @@ impl Hooks for Friends {
}
}
pub const HELP: &[&str] = &[
const HELP: &[&str] = &[
"friends host share the open world behind an invite code",
"friends stop stop sharing",
"friends status the code, how friends reach this host, the guests",
@@ -153,7 +154,25 @@ pub const HELP: &[&str] = &[
/// A string in the player's language, or the English one.
fn tr(key: &str, english: &str) -> String {
horizon_hooks::tr(key).unwrap_or_else(|| english.to_owned())
horizon_platform::hooks::tr(key).unwrap_or_else(|| english.to_owned())
}
/// One of the player's Horizon preferences, `key:value` lines in `horizon.txt` beside
/// `options.txt` in the game folder (the working directory), where the game keeps its own.
fn preference(key: &str) -> Option<String> {
let text = std::fs::read_to_string(std::env::current_dir().ok()?.join("horizon.txt")).ok()?;
text.lines().find_map(|l| {
let (k, v) = l.split_once(':')?;
(k.trim() == key).then(|| v.trim().to_owned())
})
}
/// The relay the player set on Join a Friend (`friendsRelay`), else `HORIZON_RELAY_URL`. None
/// by default.
fn relay() -> Option<String> {
preference("friendsRelay")
.filter(|r| !r.is_empty())
.or_else(crate::relay_url)
}
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
@@ -161,8 +180,7 @@ fn stun_servers() -> Vec<String> {
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
return stun::servers_from_env();
}
let dir = std::env::current_dir().unwrap_or_default();
match horizon_client::ui_theme::preference(&dir, "friendsStun") {
match preference("friendsStun") {
Some(list) => stun::parse_servers(&list),
None => stun::servers_from_env(),
}
@@ -188,8 +206,8 @@ fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
let host = Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
game: VANILLA_VERSION.to_owned(),
relay: relay(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
@@ -262,7 +280,7 @@ fn poll() {
for event in sharing.host.poll() {
match event {
HostEvent::Code(code) => {
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
if sharing.code.is_some() && sharing.wrong >= crate::host::MAX_WRONG {
sharing.notice = Some(tr(
"horizon.friends.short_code_changed",
"Someone kept trying a wrong short code, so it changed.",
+17 -2
View File
@@ -8,16 +8,26 @@
//! crate.
#![forbid(unsafe_code)]
use horizon_platform::hooks::{self, Hooks};
use horizon_platform::{
hooks::{self, Hooks},
pages::{self, Page},
};
/// Every Horizon part in the game, in the order its hooks run.
const PARTS: [&dyn Hooks; 2] = [
// The anonymous "I'm playing" ping.
&horizon_heartbeat::HOOKS,
// Play with Friends: `friend:` addresses join through its tunnel.
// Play with Friends: `friend:` addresses join through its tunnel, and the open world is
// shared behind an invite code.
&horizon_friends::game::HOOKS,
];
/// The pages Horizon's parts add to the game's menus.
const PAGES: [&dyn Page; 1] = [
// Play with Friends, what the pause menu's Friends icon opens.
&horizon_friends::game::PAGE,
];
/// 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() {
@@ -27,4 +37,9 @@ pub fn register() {
log::warn!("Horizon: an in-game part did not register: {e}");
}
}
for page in PAGES {
if let Err(e) = pages::register(page) {
log::warn!("Horizon: a page did not register: {e}");
}
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ command above to inspect the selection rather than relying on historical counts.
| 31 | Particles and client effects (46) | `R/particles.rs`, `G/render_tick.rs`, `G/level_particles.rs`, `L/animate_tick.rs` | particle_providers, block_particles, entity_particles, emitter_particles, marker_particles |
| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, forced_end, end_podium, end_gateway |
| 33 | Protocol and packets (44) | `crates/horizon-protocol/src/` (`packets/play.rs`, `entity_data.rs`, `connection.rs`) | dedicated_server, online_mode, secure_chat; `cargo test -p horizon-protocol` |
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/friends.rs` + `G/friends/` (screen, tunnel), `crates/horizon-friends/`, `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
| 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/page_screen.rs` (its screen), `crates/horizon-friends/` (`src/game.rs` joining, `src/game/share.rs` hosting), `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` |
| 35 | Dispensers (43) | `G/dispenser.rs`, `G/dispense_fixture.rs` (the harness side) | dispenser |
| 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) |
+1 -1
View File
@@ -25,7 +25,7 @@ What changes in that build:
| 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 |
| Play with Friends | hosting: `game/friends.rs` (its page, drawn by `game/page_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)) |
| Play with Friends | crate `horizon-friends` (`game`): hosting in `src/game/share.rs` (its page, drawn by the engine's `game/page_screen.rs`), joining in `src/game.rs` | 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`).
+2 -2
View File
@@ -483,8 +483,8 @@ All three run in CI's verify job and in `tools/pr_ready.py`.
| Crate | Uses, against the rule |
|---|---|
| `horizon-client` | platform: `horizon-anticheat`, `-chat-filter`, `-dualsense`, `-hdr`, `-mod-api-gen`, `-mods`, `-optifine`, `-shader-api`, `-shaders`, `-theme`; Horizon: `horizon-bench`, `-cosmetics`, `-friends`, `-telemetry` |
| `horizon-client-lib` | the same, plus `horizon-mcp`, minus `horizon-mod-api-gen` and `horizon-friends` (left through `Hooks::tunnel`) |
| `horizon-client` | platform: `horizon-anticheat`, `-chat-filter`, `-dualsense`, `-hdr`, `-mod-api-gen`, `-mods`, `-optifine`, `-shader-api`, `-shaders`, `-theme`; Horizon: `horizon-bench`, `-cosmetics`, `-telemetry` (`horizon-friends` left through `Hooks::tunnel`, `share`, `control` and its page, #14167) |
| `horizon-client-lib` | the same, plus `horizon-mcp`, minus `horizon-mod-api-gen` |
| `horizon-client-render` | platform: `horizon-hdr`, `-mod-api-gen`, `-optifine`, `-polytone`, `-shader-api`, `-shaders`, `-theme`; Horizon: `horizon-bench`, `-cosmetics`, `-telemetry` |
| `horizon-client-input` | platform: `horizon-dualsense`, `horizon-theme` |
+3 -4
View File
@@ -26,7 +26,7 @@ that finds the port takes the session.
## How it works
**Hosting.** The pause menu's Friends button (vanilla's icon, beside Player Reporting) opens **Play
with Friends** (game/friends.rs's page at `Place::PauseFriends`, drawn by `game/page_screen.rs`); its **Start Sharing** opens the world to LAN if it isn't (Open to
with Friends** (horizon-friends' page at `Place::PauseFriends`, `src/game/share.rs`, drawn by the engine's `game/page_screen.rs`); its **Start Sharing** opens the world to LAN if it isn't (Open to
LAN's defaults), then the host's game, on its own:
1. listens on its **direct port**, TCP 25580 by default (the same port every time, so a port forwarded
@@ -58,9 +58,8 @@ host end connects to the world's LAN port over loopback, so tunnelled guests are
game only through `horizon_hooks`: `Hooks::share` each client tick with a `share::ShareView` (open the world to
LAN, its players, chat, the clipboard, a link), `Hooks::lan_closed`, its page (`pages::Place::PauseFriends`, what
the pause menu's Friends icon opens; a press runs on the next tick) and `tr`. It keeps what it shares in its own
statics. It still lives in horizon-client (`game/friends.rs`, the `friends` feature, which comes with
`horizon-extras`), and answers `horizon-ctl friends` through `Hooks::control`, until it moves to horizon-friends
(#14167).
statics, and answers `horizon-ctl friends` through `Hooks::control`. Both sides are horizon-friends' `game`
feature, which only the official executable registers: horizon-client's own executable shares nothing (#14167).
**Codes.** Crockford base32 (`0-9 A-Z` without `I L O U`; input ignores case, spaces and dashes and reads
`O` as `0`, `I`/`L` as `1`) in groups of five, with a 16-bit checksum so a typo is caught before
+1 -1
View File
@@ -427,7 +427,7 @@ 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
controllers, no Discord presence: the `discord` feature; nor Play with Friends 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
+3
View File
@@ -172,6 +172,9 @@ class PrReadyTests(unittest.TestCase):
self.assertEqual(pr_ready.mod_features(["horizon-client"]), "")
self.assertEqual(pr_ready.mod_features(["horizon-mods"]),
" --features horizon-mods/index")
# Play with Friends in the game is horizon-friends' `game` side, which only horizon-official turns on.
self.assertEqual(pr_ready.mod_features(["horizon-friends", "horizon-mods"]),
" --features horizon-mods/index,horizon-friends/game")
self.assertEqual(pr_ready.mod_features(None), MODS)
def test_mod_api_data_inputs_build_and_test_the_consuming_crates(self):
-1
View File
@@ -85,7 +85,6 @@
"horizon-client -> horizon-chat-filter",
"horizon-client -> horizon-cosmetics",
"horizon-client -> horizon-dualsense",
"horizon-client -> horizon-friends",
"horizon-client -> horizon-hdr",
"horizon-client -> horizon-mod-api-gen",
"horizon-client -> horizon-mods",
+8 -2
View File
@@ -335,8 +335,14 @@ def mark_old_rust(steps, warning):
def mod_features(crates):
"""The features CI's workspace-wide test run gives these crates. Cargo unifies features over the packages it
builds, so a crate tested alone needs the ones the workspace would have turned on for it: horizon-mods' index."""
return " --features horizon-mods/index" if crates is not None and "horizon-mods" in crates else ""
builds, so a crate tested alone needs the ones the workspace would have turned on for it: horizon-mods' index,
horizon-friends' game."""
if crates is None:
return ""
# horizon-official turns on horizon-friends' `game` side (Play with Friends in the game).
wanted = {"horizon-mods": "horizon-mods/index", "horizon-friends": "horizon-friends/game"}
features = [f for crate, f in wanted.items() if crate in crates]
return f" --features {','.join(features)}" if features else ""
# What horizon-client's full content-root tests load (mod_content_tests.rs). They take minutes on prepared data, so