From 62646f32ab1a6c24c9ff334ea9f9efa2231d7532 Mon Sep 17 00:00:00 2001 From: Akirabane Date: Tue, 3 Nov 2026 23:14:49 +0100 Subject: [PATCH 01/30] lan: the login of an expected player is held, and player-drop lets it go A login that used up a proxy's expectation (player-expect, taken as the login starts) is held on the target server: it gets the whole join sequence and the level around it, and its acknowledgements are handled, but it is not placed. No join message, no list entry for the others, not among the entities they are shown, not simulated, and nothing else it sends is acted on. The world is loaded around a held player as around a placed one. The log says " is held: it arrives from another server". A held player nobody takes over is dropped after [transfer]'s max_hold_seconds (60 by default, 1200 ticks), with one warning: a player already kicked or dropped, whose connection is slow to close, is not kicked again on every tick. horizon-server passes the setting with the others. A held player keeps a true picture of the others while it waits: a player who joins meanwhile is added to its list, and one who leaves is removed from it with its entity, or its client would drop the entity of a player it has no entry for. `player-drop ` forgets the player's expectation and disconnects the held player without a save or a word to anyone. player-export refuses a held player. Placing it is the import's work, next. Refs #7484 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/dedicated.rs | 14 +- .../src/game/dedicated/status.rs | 4 +- .../src/game/dedicated/transfer.rs | 7 +- crates/horizon-client/src/game/lan.rs | 51 +- crates/horizon-client/src/lan/arrivals.rs | 583 +++++++++++++++++- crates/horizon-client/src/lan/guest.rs | 8 + crates/horizon-client/src/lan/hub.rs | 174 +++++- crates/horizon-server/src/config.rs | 14 +- docs/server.md | 21 +- 9 files changed, 783 insertions(+), 93 deletions(-) diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index 49ef236112..486198b720 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -1132,11 +1132,12 @@ impl Game<'_> { log( "INFO", &format!( - "Transfers on (server.toml [transfer]): players expected for {} s ({} s at most, {} at once), frozen for {} ticks at most", + "Transfers on (server.toml [transfer]): players expected for {} s ({} s at most, {} at once), frozen for {} ticks and held for {} ticks at most", s.expect.as_secs(), s.max_expect.as_secs(), s.max_expected, - s.max_freeze_ticks + s.max_freeze_ticks, + s.max_hold_ticks ), ); host.hub.transfers = Some(transfers); @@ -1385,11 +1386,12 @@ impl Game<'_> { pub(super) fn update_dedicated_streaming(&mut self) { let rule = self.ui.rules.bool("spectators_generate_chunks"); let guest = self.lan.as_ref().and_then(|h| { + // A held player (arriving from another server) is sent the level too, so it + // holds the world loaded around it as a placed player does. h.hub - .guests - .iter() - .find(|g| h.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator())) - .map(|g| g.chunk()) + .loading_chunks(|g| super::game_mode::loads_chunks(rule, g.spectator())) + .first() + .copied() }); if self.standalone_server() { self.streaming.radius = if guest.is_some() { diff --git a/crates/horizon-client/src/game/dedicated/status.rs b/crates/horizon-client/src/game/dedicated/status.rs index 92ea6bb388..85c25ae842 100644 --- a/crates/horizon-client/src/game/dedicated/status.rs +++ b/crates/horizon-client/src/game/dedicated/status.rs @@ -3,8 +3,8 @@ //! (including their positions) and the loaded chunks; `console ` runs a console command and answers with the lines it logged; //! `server-events ` answers the typed events after that number (events.rs); `management //! ` edits the lists as vanilla's management protocol does (management.rs). -//! `player-expect [seconds]`, `player-export [freeze]` and `player-release ...` are a -//! proxy's, for a player it moves between two servers (transfer.rs). +//! `player-expect [seconds]`, `player-drop `, `player-export [freeze]` and +//! `player-release ...` are a proxy's, for a player it moves between two servers (transfer.rs). use super::{ServerState, capture_log}; use crate::game::Game; use serde_json::{Value, json}; diff --git a/crates/horizon-client/src/game/dedicated/transfer.rs b/crates/horizon-client/src/game/dedicated/transfer.rs index aaec218f14..8e613607f8 100644 --- a/crates/horizon-client/src/game/dedicated/transfer.rs +++ b/crates/horizon-client/src/game/dedicated/transfer.rs @@ -1,5 +1,6 @@ //! A proxy's requests on the control endpoint, for a player it moves between two servers: -//! `player-expect [seconds]` (the target is told who is coming), +//! `player-expect [seconds]` (the target is told who is coming; its login is held), +//! `player-drop ` (the target forgets it, and lets a held player go), //! `player-export [freeze]` (the source answers with the player's record) and //! `player-release moved [announce] | abort` (the source lets the player //! go). They run @@ -27,6 +28,10 @@ impl Game<'_> { Some(host) => arrivals::expect_request(&mut host.hub, args, Instant::now()), None => json!({ "error": "the server is not open to players" }), }, + "player-drop" => match self.lan.as_mut() { + Some(host) => arrivals::drop_request(&mut host.hub, args), + None => json!({ "error": "the server is not open to players" }), + }, "player-export" => self.player_export(args), "player-release" => match self.lan.as_mut() { Some(host) => arrivals::release_request(&mut host.hub, args), diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 8ec9e71f5e..9e2ad52b9a 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -2048,10 +2048,18 @@ impl Game<'_> { super::dedicated::java_double(z) ), ); - super::dedicated::log( - "INFO", - &format!("System chat: {} joined the game", g.name), - ); + // A held player is not announced: it is not in the world yet. + if g.held { + super::dedicated::log( + "INFO", + &horizon_client::lan::arrivals::held_line(&g.name), + ); + } else { + super::dedicated::log( + "INFO", + &format!("System chat: {} joined the game", g.name), + ); + } } else if self.player.is_some() && let Some(g) = host.hub.guest(conn) && g.placed @@ -2102,20 +2110,33 @@ impl Game<'_> { self.guest_attachments.remove(&guest.uuid); host.play.remove(&guest.conn); self.guest_combat.remove(&guest.conn); - if self.options.dedicated.is_some() && guest.placed { + // A held player was recorded as joined at its login: it leaves too. + if self.options.dedicated.is_some() && (guest.placed || guest.held) { super::dedicated::events::record( "leave", &guest.name, guest.uuid, serde_json::json!({ "reason": guest.left_reason }), ); - super::dedicated::log( - "INFO", - &format!("{} lost connection: {}", guest.name, guest.left_reason), - ); + if guest.held && !guest.placed { + // Never announced, so no "left the game" either. + super::dedicated::log( + "INFO", + &horizon_client::lan::arrivals::dropped_line( + &guest.name, + &guest.left_reason, + ), + ); + } else { + super::dedicated::log( + "INFO", + &format!("{} lost connection: {}", guest.name, guest.left_reason), + ); + } // A player moved to another server without `announce` was not - // announced as leaving, so the line is not logged either. - if !guest.quiet_leave { + // announced as leaving, so the line is not logged either; a held + // player was never announced at all. + if guest.placed && !guest.quiet_leave { super::dedicated::log( "INFO", &format!("System chat: {} left the game", guest.name), @@ -3099,12 +3120,12 @@ impl Game<'_> { // distance + 1) and those chunks' lit neighbours. let radius = host.hub.config.view_distance + 1; let rule = self.ui.rules.bool("spectators_generate_chunks"); + // The held players are sent the level, so their chunks are kept as the placed ones'. self.streaming.extra = host .hub - .guests - .iter() - .filter(|g| host.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator())) - .map(|g| (g.chunk(), radius)) + .loading_chunks(|g| super::game_mode::loads_chunks(rule, g.spectator())) + .into_iter() + .map(|chunk| (chunk, radius)) .chain( self.arrival_chunks(self.dim) .into_iter() diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs index 37ccabbdda..3c2541532e 100644 --- a/crates/horizon-client/src/lan/arrivals.rs +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -1,14 +1,17 @@ //! Players that are about to arrive from another server, and a connected //! player read out of this one and sent away: the control endpoint's -//! `player-expect`, `player-export` and `player-release` +//! `player-expect`, `player-drop`, `player-export` and `player-release` //! (`game/dedicated/transfer.rs` runs them). //! //! A proxy that moves a player between two servers first tells the target -//! server who is coming (`player-expect `), and reads the player out of -//! the source (`player-export [freeze]`). Nothing here may last without -//! bound, because a proxy that dies in the middle of a move leaves its -//! expectations and its freezes behind: every expectation expires, the table -//! has a size cap, and a freeze is let go by itself. +//! server who is coming (`player-expect `); the player's next login is then +//! taken in as a *held* player: connected, receiving the world, but not +//! simulated, shown or announced until the hand-over finishes. It reads the +//! player out of the source (`player-export [freeze]`). Nothing here may +//! last without bound, because a proxy that dies in the middle of a move leaves +//! its expectations, its held players and its freezes behind: every expectation +//! expires, the table has a size cap, a held player is dropped and a freeze is +//! let go by themselves. //! //! All of it is off unless `server.toml`'s `[transfer]` turns it on //! (horizon-server passes it to the game as `--transfers`, [`Settings`]). While @@ -21,7 +24,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; /// `server.toml`'s `[transfer]`, as horizon-server passes it: `--transfers -/// expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30`. +/// expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30,max_hold_seconds=60`. /// A key left out keeps its default. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Settings { @@ -34,6 +37,9 @@ pub struct Settings { /// Server ticks a player may stay frozen for a hand-over: a proxy that dies /// in the middle of one must not leave the player stuck. pub max_freeze_ticks: u64, + /// Server ticks a held player may wait for its hand-over before the server + /// drops it. + pub max_hold_ticks: u64, } impl Default for Settings { @@ -43,6 +49,7 @@ impl Default for Settings { max_expect: Duration::from_secs(60), max_expected: 64, max_freeze_ticks: 20 * 30, + max_hold_ticks: 20 * 60, } } } @@ -69,6 +76,9 @@ impl Settings { "max_expected" => { s.max_expected = usize::try_from(n).map_err(|_| "max_expected is too large")? } + "max_hold_seconds" => { + s.max_hold_ticks = n.checked_mul(20).ok_or("max_hold_seconds is too large")? + } "max_freeze_seconds" => { s.max_freeze_ticks = n.checked_mul(20).ok_or("max_freeze_seconds is too large")? @@ -174,6 +184,11 @@ impl Expectations { } } + /// Forgets the expectation of `uuid`. Returns whether there was one. + pub fn forget(&mut self, uuid: u128) -> bool { + self.until.remove(&uuid).is_some() + } + /// Drops the expectations whose time has passed and returns them, so the /// caller can log that the player never came. pub fn expire(&mut self, now: Instant) -> Vec { @@ -240,6 +255,12 @@ fn new_handover_id() -> Result { Ok(u128::from_le_bytes(bytes)) } +/// Whether a held player that joined at tick `since` has waited too long at tick +/// `now` for its hand-over, for a wait of at most `max` ticks. +pub fn held_too_long(since: u64, now: u64, max: u64) -> bool { + now.saturating_sub(since) >= max +} + /// Whether a player frozen at tick `since` has been frozen too long at tick /// `now`, for a freeze of at most `max` ticks. pub fn frozen_too_long(since: u64, now: u64, max: u64) -> bool { @@ -247,13 +268,15 @@ pub fn frozen_too_long(since: u64, now: u64, max: u64) -> bool { } /// Why the connected player `uuid` cannot be exported now, or the index of its -/// guest. +/// guest. A held player is not exportable: it has no state of its own yet. pub fn check_exportable(hub: &Hub, uuid: u128) -> Result { let Some(i) = hub.guests.iter().position(|g| g.uuid == uuid) else { return Err("that player is not connected to this server"); }; let g = &hub.guests[i]; - if !g.placed { + if g.held { + Err("that player is held: it has not been handed over yet") + } else if !g.placed { Err("that player has not finished joining") } else if !g.left_reason.is_empty() { Err("that player is leaving") @@ -356,6 +379,17 @@ pub fn export_reply(hub: &Hub, i: usize, record: serde_json::Value) -> serde_jso reply } +/// The line written when a player is held: it logged in as the player a proxy expected. +pub fn held_line(name: &str) -> String { + format!("{name} is held: it arrives from another server") +} + +/// The line written when a held player's connection ends before it was placed: it was +/// never announced, so it has no "left the game". +pub fn dropped_line(name: &str, reason: &str) -> String { + format!("{name} was dropped while held: {reason}") +} + /// A unique id as text, with or without dashes. pub fn parse_uuid(text: &str) -> Option { let plain: String = text.chars().filter(|c| *c != '-').collect(); @@ -409,6 +443,30 @@ pub fn expect_request(hub: &mut Hub, args: &str, now: Instant) -> serde_json::Va } } +/// `player-drop `: forgets the expectation of that player, and +/// disconnects it if it is already held, without a save or a word to anyone. +pub fn drop_request(hub: &mut Hub, args: &str) -> serde_json::Value { + if hub.transfers.is_none() { + return error(OFF); + } + let mut parts = args.split_whitespace(); + let Some(uuid) = parts.next().and_then(parse_uuid) else { + return error("player-drop needs the player's unique id"); + }; + if parts.next().is_some() { + return error("player-drop takes only a unique id"); + } + let held = hub.drop_held(uuid); + let expected = hub + .transfers + .as_ref() + .is_some_and(|t| t.table().forget(uuid)); + if !held && !expected { + return error("no player with that unique id is held or expected"); + } + serde_json::json!({ "ok": true, "held": held, "expected": expected }) +} + /// `player-release moved [announce]` or `player-release /// abort`: ends the hand-over the export with `freeze` started. `moved` takes the player out of /// the world, saving nothing and, unless `announce`, without the "left the game" line; `abort` @@ -504,6 +562,27 @@ mod tests { assert!(e.take(A, t0 + secs(20))); } + #[test] + fn a_held_player_waits_one_minute_of_ticks() { + let max = Settings::default().max_hold_ticks; + assert_eq!(max, 1200); + assert!(!held_too_long(100, 100 + 1199, max)); + assert!(held_too_long(100, 100 + 1200, max)); + // A clock that went backwards never drops anyone. + assert!(!held_too_long(500, 100, max)); + } + + #[test] + fn forgetting_removes_only_that_player() { + let t0 = Instant::now(); + let mut e = Expectations::default(); + e.expect(A, secs(10), t0).unwrap(); + e.expect(B, secs(10), t0).unwrap(); + assert!(e.forget(A)); + assert!(!e.forget(A)); + assert!(e.take(B, t0)); + } + #[test] fn a_long_wait_is_cut_to_the_maximum() { let t0 = Instant::now(); @@ -715,6 +794,15 @@ mod hub_tests { packets(rx).into_iter().map(|(id, _)| id).collect() } + /// What the network thread does as a login starts (`lan/conn.rs`): the login + /// uses up the player's expectation, if there is one. + fn login_starts(hub: &mut Hub, uuid: u128) { + let taken = table(hub).take(uuid, Instant::now()); + if let Some(g) = hub.guests.iter_mut().find(|g| g.uuid == uuid) { + g.expected = taken; + } + } + fn place(hub: &mut Hub, conn: u32) { let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); let border = crate::worldborder::WorldBorder::default(); @@ -783,39 +871,263 @@ mod hub_tests { assert!(table(&hub).is_empty()); } - /// A login takes the expectation from the table the network threads share - /// with the hub (`lan/conn.rs`, as the login starts), so nothing is reported - /// when its time would have run out; another player's stays. + /// The control: a player nobody expected is announced as always, to the + /// others by a chat line and a player-list entry. #[test] - fn a_login_uses_up_the_expectation_of_that_player_only() { + fn an_ordinary_login_is_announced_to_the_others() { let mut hub = hub(); - let now = Instant::now(); - table(&hub).expect(2, Duration::from_secs(10), now).unwrap(); - table(&hub).expect(3, Duration::from_secs(10), now).unwrap(); - let shared = hub.transfers.as_ref().unwrap().expected.clone(); - assert!(shared.lock().unwrap().take(2, now)); - let (mut newcomer, _mine) = guest(2, 2, "Newcomer"); - newcomer.expected = true; + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (newcomer, mine) = guest(2, 2, "Newcomer"); hub.guests.push(newcomer); place(&mut hub, 2); - assert!(hub.guests[0].placed); - assert_eq!(table(&hub).len(), 1, "the other player is still expected"); - assert!(table(&hub).take(3, now)); + let told = ids(&seen); + assert!(told.contains(&id::SYSTEM_CHAT), "{told:?}"); + assert!(told.contains(&id::PLAYER_INFO_UPDATE), "{told:?}"); + assert!(hub.guests[1].placed && !hub.guests[1].held); + assert_eq!(ids(&mine)[0], id::LOGIN); } - /// While `[transfer]` is off every request is refused, there is no table, - /// and a tick does nothing for transfers. + /// A player the proxy announced is held: it gets the whole join sequence, + /// and the player already there is told nothing at all. #[test] - fn with_transfers_off_every_request_is_refused() { + fn an_expected_player_is_held_and_no_one_else_hears_of_it() { let mut hub = hub(); - hub.transfers = None; - let reply = super::expect_request(&mut hub, UUID, Instant::now()); - assert_eq!(reply["error"], super::OFF); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (newcomer, mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + table(&hub) + .expect(2, Duration::from_secs(10), Instant::now()) + .unwrap(); + login_starts(&mut hub, 2); + place(&mut hub, 2); + assert_eq!( + ids(&seen), + Vec::::new(), + "the watcher must hear nothing" + ); + let g = &hub.guests[1]; + assert!(g.held && !g.placed); + assert!(table(&hub).is_empty(), "the login used up the expectation"); + let sent = ids(&mine); + assert_eq!(sent[0], id::LOGIN); + for wanted in [id::PLAYER_INFO_UPDATE, id::SET_CHUNK_CACHE_CENTER] { + assert!( + sent.contains(&wanted), + "packet {wanted} missing from {sent:?}" + ); + } + // The held player is in its own list but did not announce itself. + assert!(!sent.contains(&id::SYSTEM_CHAT), "{sent:?}"); + } + + /// An expectation is for one player: another uuid logs in as usual. + #[test] + fn the_expectation_of_one_player_does_not_hold_another() { + let mut hub = hub(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (newcomer, _mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + table(&hub) + .expect(3, Duration::from_secs(10), Instant::now()) + .unwrap(); + login_starts(&mut hub, 2); + place(&mut hub, 2); + assert!(hub.guests[1].placed && !hub.guests[1].held); + assert!(ids(&seen).contains(&id::SYSTEM_CHAT)); + assert_eq!(table(&hub).len(), 1, "the other player is still expected"); + } + + /// A held player whose hand-over never finishes is dropped after a minute of + /// ticks, and the players in the world are told nothing about it. + #[test] + fn a_held_player_that_is_never_imported_is_dropped_without_a_word() { + let mut hub = hub(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (newcomer, mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + table(&hub) + .expect(2, Duration::from_secs(10), Instant::now()) + .unwrap(); + login_starts(&mut hub, 2); + place(&mut hub, 2); + let _ = (ids(&seen), ids(&mine)); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + let border = crate::worldborder::WorldBorder::default(); + let mut next = 100; + // Two ticks short of the limit (poll counts one): still waiting. + hub.ticks = hub.guests[1].held_since + super::Settings::default().max_hold_ticks - 2; + let mut ctx = tick_context(&mut world, &border, &mut next); + let events = hub.poll(&mut ctx); + assert!(!events.iter().any(|e| is_warn(e, "dropped"))); + // At the limit it is kicked, with a warning in the log. + let events = hub.poll(&mut ctx); + assert!( + events + .iter() + .any(|e| is_warn(e, "Newcomer") && is_warn(e, "dropped")) + ); + assert!(matches!(mine.try_recv(), Ok(Out::Disconnect(_)))); + // The connection closes: the hub removes the guest. + let gone = hub.remove_guest(1, "Timed out"); + assert!(gone.held && !gone.placed); + assert_eq!( + ids(&seen), + Vec::::new(), + "the watcher must hear nothing" + ); + } + + /// The control for the one above: when a placed player leaves, the others + /// are told. + #[test] + fn a_placed_player_leaving_is_announced() { + let mut hub = hub(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (mut leaver, _mine) = guest(2, 2, "Leaver"); + leaver.placed = true; + hub.guests.push(leaver); + hub.remove_guest(1, "bye"); + let told = ids(&seen); + assert!(told.contains(&id::PLAYER_INFO_REMOVE), "{told:?}"); + assert!(told.contains(&id::SYSTEM_CHAT), "{told:?}"); + } + + /// A held player is sent the chunks and the entities around it, and takes + /// nothing from the players in the world: they still get their chunks, and + /// never learn the held player's entity. + #[test] + fn a_held_player_sees_the_world_without_the_world_seeing_it() { + let mut hub = hub(); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + for x in -5..=5 { + for z in -5..=5 { + // Only a lit column is sent. + let mut chunk = crate::chunk::Chunk::empty(x, z); + chunk.lit = true; + world.chunks.insert((x, z), std::sync::Arc::new(chunk)); + } + } + let border = crate::worldborder::WorldBorder::default(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + let (newcomer, mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + // A second player in the world, to show that players are tracked at all. + let (mut other, _other_rx) = guest(3, 3, "Other"); + other.placed = true; + hub.guests.push(other); + table(&hub) + .expect(2, Duration::from_secs(10), Instant::now()) + .unwrap(); + login_starts(&mut hub, 2); + place(&mut hub, 2); + // The server has run a few ticks since they joined. + hub.ticks = 10; + let mut next = 100; + let mut ctx = tick_context(&mut world, &border, &mut next); + let (mut held_chunks, mut watcher_chunks) = (0, 0); + let (mut held_adds, mut watcher_adds) = (Vec::new(), Vec::new()); + for _ in 0..30 { + hub.tick_level(&mut ctx); + hub.end_tick(); + let mine_now = packets(&mine); + let seen_now = packets(&seen); + held_chunks += mine_now + .iter() + .filter(|p| p.0 == id::LEVEL_CHUNK_WITH_LIGHT) + .count(); + watcher_chunks += seen_now + .iter() + .filter(|p| p.0 == id::LEVEL_CHUNK_WITH_LIGHT) + .count(); + held_adds.extend(added_of(&mine_now)); + watcher_adds.extend(added_of(&seen_now)); + } + assert!( + held_chunks > 0, + "the held player is sent chunks (held {held_chunks}, watcher {watcher_chunks})" + ); + assert!(watcher_chunks > 0, "the placed player is still sent chunks"); + assert!( + watcher_adds.contains(&3), + "players are tracked at all: {watcher_adds:?}" + ); + assert!( + held_adds.contains(&1), + "the held player sees the watcher: {held_adds:?}" + ); + assert!( + !watcher_adds.contains(&2), + "the watcher must not see the held player: {watcher_adds:?}" + ); + } + + /// The entity ids in the Add Entity packets among some packets. + fn added_of(packets: &[(i32, Vec)]) -> Vec { + packets + .iter() + .filter(|(i, _)| *i == id::ADD_ENTITY) + .map(|(_, payload)| { + horizon_protocol::buf::Reader::new(payload) + .varint() + .unwrap() + }) + .collect() + } + + /// A held player's acknowledgements are handled (they keep the chunks + /// coming), and nothing else it sends reaches the world. + #[test] + fn a_held_player_is_heard_only_when_it_acknowledges() { + use horizon_protocol::packets::play::{ChunkBatchReceived, MovePlayerPos, Serverbound}; + let mut hub = hub(); + let (newcomer, _mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + table(&hub) + .expect(2, Duration::from_secs(10), Instant::now()) + .unwrap(); + login_starts(&mut hub, 2); + place(&mut hub, 2); + let g = &mut hub.guests[0]; + g.unacknowledged = 3; + let before = g.position; + g.inbox.push_back(( + Serverbound::MovePlayerPos(MovePlayerPos { + x: before[0] + 40.0, + y: before[1], + z: before[2], + flags: 1, + }), + 10, + )); + g.inbox.push_back(( + Serverbound::ChunkBatchReceived(ChunkBatchReceived { + chunks_per_tick: 12.0, + }), + 5, + )); + g.inbox_bytes = 15; let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); let border = crate::worldborder::WorldBorder::default(); let mut next = 100; let mut ctx = tick_context(&mut world, &border, &mut next); - assert!(hub.poll(&mut ctx).is_empty()); + hub.drain_packets(&mut ctx); + let g = &hub.guests[0]; + assert_eq!(g.position, before, "a held player does not move"); + assert_eq!(g.unacknowledged, 2, "its acknowledgement was handled"); + assert_eq!(g.chunks_per_tick, 12.0); + assert_eq!(g.inbox_bytes, 0); } const UUID: &str = "123e4567-e89b-12d3-a456-426614174000"; @@ -914,6 +1226,69 @@ mod hub_tests { ); } + /// Dropping a held player disconnects it and says nothing to the world. + #[test] + fn player_drop_removes_a_held_player_without_a_word_to_anyone() { + let mut hub = hub(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + // The proxy announces the player first; then the player logs in. + assert_eq!( + super::expect_request(&mut hub, UUID, Instant::now())["ok"], + true + ); + let (newcomer, mine) = guest(2, UUID_N, "Newcomer"); + hub.guests.push(newcomer); + login_starts(&mut hub, UUID_N); + place(&mut hub, 2); + let _ = (ids(&seen), ids(&mine)); + let reply = super::drop_request(&mut hub, UUID); + assert_eq!( + (reply["held"].as_bool(), reply["expected"].as_bool()), + (Some(true), Some(false)) + ); + assert!(matches!(mine.try_recv(), Ok(Out::Disconnect(_)))); + // The connection closes and the hub forgets the guest. + let gone = hub.remove_guest(1, "dropped"); + assert!(gone.held && !gone.placed); + assert_eq!( + ids(&seen), + Vec::::new(), + "the watcher must hear nothing" + ); + } + + #[test] + fn player_drop_forgets_an_expectation_and_refuses_a_stranger() { + let mut hub = hub(); + super::expect_request(&mut hub, UUID, Instant::now()); + let reply = super::drop_request(&mut hub, UUID); + assert_eq!( + (reply["held"].as_bool(), reply["expected"].as_bool()), + (Some(false), Some(true)) + ); + assert!(table(&hub).is_empty()); + let reply = super::drop_request(&mut hub, UUID); + assert!(reply["error"].is_string(), "{reply}"); + assert!(super::drop_request(&mut hub, "nonsense")["error"].is_string()); + } + + /// A placed player cannot be dropped this way: only a held one. + #[test] + fn player_drop_leaves_a_placed_player_alone() { + let mut hub = hub(); + let (mut here, rx) = guest(1, UUID_N, "Here"); + here.placed = true; + hub.guests.push(here); + let reply = super::drop_request(&mut hub, UUID); + assert!(reply["error"].is_string(), "{reply}"); + assert!( + rx.try_recv().is_err(), + "a placed player must not be disconnected" + ); + } + /// The keys a vanilla server writes in every player's file, read with `javap` from the /// 26.3 server jar: `Player.addAdditionalSaveData` (Inventory, SelectedItemSlot, /// EnderItems, XpLevel, XpP, XpTotal, XpSeed, abilities), `FoodData` (foodLevel, @@ -996,6 +1371,13 @@ mod hub_tests { hub.guests[0].sleeping = false; hub.guests[0].dead = true; assert_eq!(expect(&hub), Err("that player is dead")); + hub.guests[0].dead = false; + hub.guests[0].placed = false; + hub.guests[0].held = true; + assert_eq!( + expect(&hub), + Err("that player is held: it has not been handed over yet") + ); } #[test] @@ -1382,4 +1764,145 @@ mod hub_tests { ); assert!(hub.guests[1].handover.is_none()); } + + // ---- the world is loaded for a held player; how a held player's connection ended -- + + // ---- the world loaded around a held player; how its connection ended ---- + + #[test] + fn the_world_is_loaded_around_the_players_placed_and_the_held_ones_only() { + let mut hub = hub(); + let overworld = |mut g: Guest, at: [f64; 3]| { + g.position = at; + g + }; + // Placed in the level in place. + let (mut placed, _a) = guest(1, 1, "Placed"); + placed.placed = true; + hub.guests.push(overworld(placed, [8.0, 64.0, 8.0])); + // Held: sent the level too, so the level must be loaded around it. + let (mut held, _b) = guest(2, 2, "Held"); + held.held = true; + hub.guests.push(overworld(held, [100.5, 64.0, -3.0])); + // Neither (still logging in): nothing to load for it. + let (idle, _c) = guest(3, 3, "Idle"); + hub.guests.push(overworld(idle, [500.0, 64.0, 500.0])); + // Placed in another level. + let (mut elsewhere, _d) = guest(4, 4, "Elsewhere"); + elsewhere.placed = true; + elsewhere.dimension = "minecraft:the_nether".into(); + hub.guests.push(overworld(elsewhere, [900.0, 64.0, 900.0])); + assert_eq!(hub.loading_chunks(|_| true), vec![(0, 0), (6, -1)]); + // The caller can leave a player out (a spectator that may not load chunks). + assert_eq!(hub.loading_chunks(|g| g.name != "Held"), vec![(0, 0)]); + } + + #[test] + fn a_dropped_held_player_keeps_how_its_connection_really_ended() { + let mut hub = hub(); + let (mut held, _rx) = guest(2, UUID_N, "Held"); + held.held = true; + hub.guests.push(held); + assert!(super::drop_request(&mut hub, UUID)["ok"] == true); + let gone = hub.remove_guest(0, "Invalid packet 0: unexpected end of data"); + assert_eq!( + gone.left_reason, + "dropped before its hand-over (the connection ended: Invalid packet 0: unexpected end of data)" + ); + } + + #[test] + fn a_held_player_whose_connection_ends_by_itself_logs_the_real_reason() { + let mut hub = hub(); + let (mut held, _rx) = guest(2, UUID_N, "Held"); + held.held = true; + hub.guests.push(held); + let gone = hub.remove_guest(0, "Invalid packet 0: unexpected end of data"); + assert_eq!(gone.left_reason, "Invalid packet 0: unexpected end of data"); + } + + /// The control: a placed player keeps the reason it was kicked with, as vanilla logs it. + #[test] + fn a_placed_player_keeps_the_reason_it_was_kicked_with() { + let mut hub = hub(); + let (mut placed, _rx) = guest(2, UUID_N, "Placed"); + placed.placed = true; + placed.left_reason = "Timed out".into(); + hub.guests.push(placed); + let gone = hub.remove_guest(0, "Disconnected"); + assert_eq!(gone.left_reason, "Timed out"); + } + + // ---- the held player's limits and its picture of the others ------------ + + /// A held player held under the hub's own settings. + fn held_with_a_watcher(settings: super::Settings) -> (Hub, Receiver, Receiver) { + let mut hub = hub(); + hub.transfers = Some(super::Transfers::new(settings)); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + table(&hub) + .expect(2, Duration::from_secs(10), Instant::now()) + .unwrap(); + let (newcomer, mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + login_starts(&mut hub, 2); + place(&mut hub, 2); + assert!(hub.guests[1].held); + let _ = (ids(&seen), ids(&mine)); + (hub, seen, mine) + } + + /// `[transfer]`'s `max_hold_seconds` sets the wait, and a held player whose + /// connection is slow to close is kicked and reported once, not on every tick. + #[test] + fn a_held_player_waits_the_servers_time_and_is_dropped_once() { + let settings = super::Settings { + max_hold_ticks: 40, + ..super::Settings::default() + }; + let (mut hub, _seen, _mine) = held_with_a_watcher(settings); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + let border = crate::worldborder::WorldBorder::default(); + let mut next = 100; + let mut ctx = tick_context(&mut world, &border, &mut next); + hub.ticks = hub.guests[1].held_since + 38; + assert!(!hub.poll(&mut ctx).iter().any(|e| is_warn(e, "dropped"))); + assert!(hub.poll(&mut ctx).iter().any(|e| is_warn(e, "dropped"))); + // The guest stays until its connection closes: no second kick. + assert_eq!(hub.guests.len(), 2); + for _ in 0..5 { + assert!(!hub.poll(&mut ctx).iter().any(|e| is_warn(e, "dropped"))); + } + } + + /// A player who joins while another is held is in the held one's list, or + /// the held client drops its entity; and one who leaves goes from that list. + #[test] + fn a_held_player_hears_of_the_players_who_join_and_leave() { + let (mut hub, seen, mine) = held_with_a_watcher(super::Settings::default()); + let (late, _late_rx) = guest(3, 3, "Late"); + hub.guests.push(late); + place(&mut hub, 3); + let told = ids(&mine); + assert!(told.contains(&id::PLAYER_INFO_UPDATE), "{told:?}"); + assert!( + !told.contains(&id::SYSTEM_CHAT), + "the held player is not in the chat yet: {told:?}" + ); + let _ = ids(&seen); + let i = hub.guests.iter().position(|g| g.uuid == 1).unwrap(); + hub.remove_guest(i, "Disconnected"); + let told = ids(&mine); + assert!(told.contains(&id::PLAYER_INFO_REMOVE), "{told:?}"); + assert!(told.contains(&id::REMOVE_ENTITIES), "{told:?}"); + } + + #[test] + fn player_drop_is_refused_with_transfers_off() { + let mut hub = hub(); + hub.transfers = None; + assert_eq!(super::drop_request(&mut hub, UUID)["error"], super::OFF); + } } diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index a55571ed8c..9c0a6da22f 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -346,6 +346,12 @@ pub struct Guest { /// Its login started while a proxy said it would arrive from another /// server, and used that expectation up (`player-expect`). pub expected: bool, + /// A player arriving from another server (`arrivals`): connected and + /// receiving the world, but not placed, so no one else sees, hears or + /// hurts it until the hand-over finishes. + pub held: bool, + /// The hub tick at which it was held, to drop it if its hand-over never comes. + pub held_since: u64, /// Leaving for another server (`player-release moved`) without the "left the /// game" line. pub quiet_leave: bool, @@ -529,6 +535,8 @@ impl Guest { placed: false, handover: None, expected: j.expected, + held: false, + held_since: 0, quiet_leave: false, released: false, hidden_commands: Vec::new(), diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 9be6921514..22d2228611 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -746,6 +746,14 @@ impl Hub { g.sender.send(body.clone()); } } + /// [`Self::broadcast`] to the held players as well, which keep a picture of + /// the players and the entities while they wait for their hand-over. + pub fn broadcast_watching(&self, packet: &C) { + let body = packet.encode(); + for g in self.guests.iter().filter(|g| g.placed || g.held) { + g.sender.send(body.clone()); + } + } /// MinecraftServer.onGameRuleChanged: every player is told when /// `immediate_respawn` changes; a joining player's Login carries it. pub fn set_immediate_respawn(&mut self, on: bool) { @@ -828,11 +836,27 @@ impl Hub { pub fn here(&self, g: &Guest) -> bool { g.placed && g.dimension == self.config.dimension } + /// Whether a guest is sent the level in place: the players placed in it, and + /// the held ones, which look at it without being part of it. + pub fn watches(&self, g: &Guest) -> bool { + (g.placed || g.held) && g.dimension == self.config.dimension + } + /// The chunk under each player the level in place has to keep loaded: the players placed in + /// it and the held ones, which are sent its chunks too ([`Self::watches`]), whose `loads` + /// says so (a spectator may not load chunks). A server with only a held player must load + /// and generate the world around it, or the player waits for ever for its world. + pub fn loading_chunks(&self, loads: impl Fn(&Guest) -> bool) -> Vec<(i32, i32)> { + self.guests + .iter() + .filter(|g| self.watches(g) && loads(g)) + .map(Guest::chunk) + .collect() + } /// `PlayerList.broadcastAll(packet, dimension)`: the players in the /// level in place. pub fn broadcast_level(&self, packet: &C) { let body = packet.encode(); - for g in self.guests.iter().filter(|g| self.here(g)) { + for g in self.guests.iter().filter(|g| self.watches(g)) { g.sender.send(body.clone()); } } @@ -1003,9 +1027,29 @@ impl Hub { ); } + /// Disconnects the held player `uuid` and nothing else: no save (it was + /// never placed), no word to anyone (no one was told of it). Returns whether + /// there was one. + pub fn drop_held(&mut self, uuid: u128) -> bool { + let Some(g) = self + .guests + .iter_mut() + .find(|g| g.uuid == uuid && g.held && !g.placed) + else { + return false; + }; + if g.left_reason.is_empty() { + g.left_reason = "dropped before its hand-over".to_owned(); + } + g.sender + .disconnect(translatable("multiplayer.disconnect.generic", &[])); + true + } + /// Lets go of what a proxy that stopped answering left behind: the players - /// it said would come and did not, and the freezes no hand-over ended. - /// Neither may last for ever. + /// it said would come and did not, the held players whose hand-over never + /// finished, and the freezes no hand-over ended. None may last for ever, and + /// a held player goes without a word to anyone, since no one was told of it. fn expire_arrivals(&mut self, events: &mut Vec) { // Off: nothing to do (and no player can be frozen). let Some(transfers) = &self.transfers else { @@ -1017,7 +1061,32 @@ impl Hub { horizon_protocol::crypto::uuid_string(uuid) ))); } - let max = transfers.settings.max_freeze_ticks; + let (max, max_hold) = ( + transfers.settings.max_freeze_ticks, + transfers.settings.max_hold_ticks, + ); + // Not one already kicked or dropped: its connection may take a while to close, + // and it must not be kicked (and reported) again on every tick. + let stale: Vec = self + .guests + .iter() + .filter(|g| { + g.held + && !g.placed + && g.left_reason.is_empty() + && super::arrivals::held_too_long(g.held_since, self.ticks, max_hold) + }) + .map(|g| g.conn) + .collect(); + for conn in stale { + if let Some(g) = self.guest(conn) { + events.push(HostEvent::Warn(format!( + "{} was held for {max_hold} ticks and the hand-over never finished: dropped", + g.name, + ))); + } + self.kick(conn, translatable("disconnect.timeout", &[])); + } for g in &mut self.guests { let Some(h) = &g.handover else { continue; @@ -1206,6 +1275,15 @@ impl Hub { let g = &self.guests[i]; (g.name.clone(), g.uuid, g.entity_id, g.game_mode) }; + // A player whose login used up a proxy's expectation (`arrivals`, as the + // login started) is held: it gets the whole join sequence, so it can build + // its picture of the world, but no one else is told about it. + if self.guests[i].expected { + let g = &mut self.guests[i]; + g.held = true; + g.held_since = self.ticks; + } + let held = self.guests[i].held; let g = &self.guests[i]; g.send(&C::Login(play::Login { player_id: entity_id, @@ -1242,21 +1320,24 @@ impl Hub { self.send_initial_recipe_book(i); // PlayerList.placeNewPlayer broadcasts the join line before // players.add: the others read it, the newcomer doesn't (nor those - // a native mod hides it from). - let joined = C::SystemChat(play::SystemChat { - content: yellow_names( - "multiplayer.player.joined", - vec![self.player_display_name(self.guests[i].uuid, &name)], - ), - overlay: false, - }) - .encode(); - for g in self.guests.iter().filter(|g| g.placed && g.conn != conn) { - if self.sees(g.permission, uuid) { - g.sender.send(joined.clone()); + // a native mod hides it from). A held player is not announced, and + // not placed. + if !held { + let joined = C::SystemChat(play::SystemChat { + content: yellow_names( + "multiplayer.player.joined", + vec![self.player_display_name(self.guests[i].uuid, &name)], + ), + overlay: false, + }) + .encode(); + for g in self.guests.iter().filter(|g| g.placed && g.conn != conn) { + if self.sees(g.permission, uuid) { + g.sender.send(joined.clone()); + } } + self.guests[i].placed = true; } - self.guests[i].placed = true; self.resend_teleport(i, None); let g = &self.guests[i]; g.send(&C::ServerData(play::ServerData { @@ -1291,12 +1372,20 @@ impl Hub { entries: vec![self.player_info(uuid, &name, mode, 0)], }) .encode(); - let level = self.hidden.get(&uuid).copied(); - for g in self.guests.iter_mut().filter(|g| g.placed) { - if level.is_some_and(|l| g.permission < l) { - g.tab_hidden.insert(uuid); - } else { - g.sender.send(newcomer.clone()); + if held { + // Its own list has itself in it; the others' lists do not. + self.guests[i].sender.send(newcomer); + } else { + let level = self.hidden.get(&uuid).copied(); + // A held player is shown the entities around it, so it must know the players + // who join meanwhile, or its client drops their entity (`ClientPacketListener` + // adds a player only with its list entry). + for g in self.guests.iter_mut().filter(|g| g.placed || g.held) { + if level.is_some_and(|l| g.permission < l) { + g.tab_hidden.insert(uuid); + } else { + g.sender.send(newcomer.clone()); + } } } self.send_level_info(i, ctx, true); @@ -1895,6 +1984,12 @@ impl Hub { format!("{}'s {handled} packets", g.name) }); } + } else if self.guests[i].held && self.watches(&self.guests[i]) { + // A held player only answers what the server asked of it: its + // acknowledgements keep the chunks coming. Whatever else it sends + // (moves, clicks, commands) is not for the world. + self.keep_acknowledgements(i, &mut events); + self.process_inbox(i, ctx, &mut events); } } events @@ -1906,7 +2001,7 @@ impl Hub { /// earlier (`drain_packets`) so scheduled ticks see what they placed. pub fn tick_level(&mut self, ctx: &mut TickCtx) -> Vec { let events = Vec::new(); - if !self.guests.iter().any(|g| self.here(g)) { + if !self.guests.iter().any(|g| self.watches(g)) { // ServerTickRateManager broadcasts a change to the players there // are; one who joins later is sent the state as it is then // (`updateJoiningPlayer`). @@ -1997,7 +2092,7 @@ impl Hub { } } - fn remove_guest(&mut self, i: usize, reason: &str) -> Guest { + pub(super) fn remove_guest(&mut self, i: usize, reason: &str) -> Guest { let mut g = self.guests.remove(i); if let Some(m) = g .menu @@ -2010,6 +2105,16 @@ impl Hub { window: m.window, }); } + // A held player a proxy dropped keeps, in the log, how its connection really ended + // (an unreadable packet, a reset): the drop says only that it was dropped. + if g.held + && !g.placed + && !reason.is_empty() + && !g.left_reason.is_empty() + && g.left_reason != reason + { + g.left_reason = format!("{} (the connection ended: {reason})", g.left_reason); + } if g.left_reason.is_empty() { g.left_reason = reason.to_owned(); } @@ -2027,10 +2132,11 @@ impl Hub { &mut self.attachment_snapshots, self.sync_attachments, ); - self.broadcast(&C::PlayerInfoRemove(play::PlayerInfoRemove { + // To the held players too: they were told of this one, and see its entity. + self.broadcast_watching(&C::PlayerInfoRemove(play::PlayerInfoRemove { uuids: vec![g.uuid], })); - self.broadcast(&C::RemoveEntities(play::RemoveEntities { + self.broadcast_watching(&C::RemoveEntities(play::RemoveEntities { ids: vec![g.entity_id], })); let left = C::SystemChat(play::SystemChat { @@ -2188,7 +2294,7 @@ impl Hub { } let mut batches = Vec::new(); for i in 0..self.guests.len() { - if self.here(&self.guests[i]) { + if self.watches(&self.guests[i]) { self.update_view(i); if let Some(batch) = self.next_batch(i, world, ctx.streaming) { batches.push((i, batch)); @@ -2406,7 +2512,7 @@ impl Hub { .iter() .filter_map(|key| Some((*key, self.encoder.biome_data(world.chunks.get(key)?)))) .collect(); - for g in self.guests.iter().filter(|g| self.here(g)) { + for g in self.guests.iter().filter(|g| self.watches(g)) { let chunks: Vec<_> = data .iter() .filter(|(key, _)| g.sent_chunks.contains(key)) @@ -2450,7 +2556,7 @@ impl Hub { let viewers: Vec = (0..self.guests.len()) .filter(|&i| { let g = &self.guests[i]; - self.here(g) + self.watches(g) && tracked(g, x, z) && (-1..=1).any(|dz| (-1..=1).any(|dx| !tracked(g, x + dx, z + dz))) }) @@ -2506,7 +2612,7 @@ impl Hub { } let viewers: Vec = (0..self.guests.len()) .filter(|&i| { - self.here(&self.guests[i]) && self.guests[i].sent_chunks.contains(&key) + self.watches(&self.guests[i]) && self.guests[i].sent_chunks.contains(&key) }) .collect(); if viewers.is_empty() { @@ -2571,7 +2677,7 @@ impl Hub { } let viewers: Vec = (0..self.guests.len()) .filter(|&i| { - self.here(&self.guests[i]) && self.guests[i].sent_chunks.contains(&(sx, sz)) + self.watches(&self.guests[i]) && self.guests[i].sent_chunks.contains(&(sx, sz)) }) .collect(); if viewers.is_empty() { @@ -2942,7 +3048,9 @@ impl Hub { level.synced.clone_from(&self.synced); let mut outs = std::mem::take(&mut self.entity_out); let dimension = &self.config.dimension; - let here = |g: &Guest| g.placed && g.dimension == *dimension; + // The viewers include the held players: they are shown the entities + // around them, but are not among the entities shown (`track_entities`). + let here = |g: &Guest| (g.placed || g.held) && g.dimension == *dimension; outs.resize_with(self.guests.iter().filter(|g| here(g)).count(), Vec::new); // The prediction's replay excuses a client that may still have an entity that left it. let recording = self.recording(); diff --git a/crates/horizon-server/src/config.rs b/crates/horizon-server/src/config.rs index 11ad2f4527..0523a0286e 100644 --- a/crates/horizon-server/src/config.rs +++ b/crates/horizon-server/src/config.rs @@ -71,6 +71,7 @@ expect_seconds = 10 # how long a player the proxy announced is waited f max_expect_seconds = 60 # the longest the proxy may ask for max_expected = 64 # players waited for at once max_freeze_seconds = 30 # the longest a player stays frozen while it is handed over +max_hold_seconds = 60 # the longest a player arriving from another server is held before it is dropped # Post the chat, joins, leaves and deaths to a Discord channel, drawn like the in-game chat (docs/hosting.md) [discord] @@ -310,6 +311,7 @@ pub struct Transfer { pub max_expect_seconds: u32, pub max_expected: u32, pub max_freeze_seconds: u32, + pub max_hold_seconds: u32, } impl Default for Transfer { @@ -320,6 +322,7 @@ impl Default for Transfer { max_expect_seconds: 60, max_expected: 64, max_freeze_seconds: 30, + max_hold_seconds: 60, } } } @@ -331,6 +334,7 @@ impl Transfer { ("max_expect_seconds", self.max_expect_seconds), ("max_expected", self.max_expected), ("max_freeze_seconds", self.max_freeze_seconds), + ("max_hold_seconds", self.max_hold_seconds), ] { if n == 0 { return Err(format!("transfer.{name} must be above zero")); @@ -346,11 +350,12 @@ impl Transfer { pub fn engine_arg(&self) -> Option { self.enabled.then(|| { format!( - "expect_seconds={},max_expect_seconds={},max_expected={},max_freeze_seconds={}", + "expect_seconds={},max_expect_seconds={},max_expected={},max_freeze_seconds={},max_hold_seconds={}", self.expect_seconds, self.max_expect_seconds, self.max_expected, - self.max_freeze_seconds + self.max_freeze_seconds, + self.max_hold_seconds ) }) } @@ -594,6 +599,7 @@ pub fn render(config: &Config) -> String { ("transfer", "max_expect_seconds"), ("transfer", "max_expected"), ("transfer", "max_freeze_seconds"), + ("transfer", "max_hold_seconds"), ] { set(&mut doc, section, field, &table[section][field]); } @@ -754,7 +760,9 @@ mod tests { let config = Config::parse(&on).unwrap(); assert_eq!( config.transfer.engine_arg().as_deref(), - Some("expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30") + Some( + "expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30,max_hold_seconds=60" + ) ); assert_eq!(Config::parse(&render(&config)).unwrap(), config); for (from, to) in [ diff --git a/docs/server.md b/docs/server.md index e95fabaf7d..d3a45b778f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -155,6 +155,7 @@ expect_seconds = 10 # how long a player the proxy announced is waited f max_expect_seconds = 60 # the longest the proxy may ask for max_expected = 64 # players waited for at once max_freeze_seconds = 30 # the longest a player stays frozen while it is handed over +max_hold_seconds = 60 # the longest a player arriving from another server is held before it is dropped ``` The supervisor passes the section to the engine as `--transfers expect_seconds=10,...`, beside @@ -186,8 +187,22 @@ table. The player's next login uses the expectation up as it starts, once the ba limit let it in, so a login still in configuration when the time runs out (a resource pack prompt) keeps it. A login that then fails before it reaches play (its connection drops in configuration, or the client refuses a required pack) gives the expectation back with its own deadline, so the proxy's next attempt within that time is -still expected. An expectation no login used is reported in the log when its time runs out. For now that is all an expectation does: -the player still logs in as any other. +still expected. An expectation no login used is reported in the log when its time runs out. + +A login that used up an expectation is a *held* player. It gets the whole join sequence and the level around it +(chunks, block and light changes, entities, the level's broadcasts), and its acknowledgements are handled (teleport, +chunk batch, loaded, keep-alive, ping, tick end, client information), but it is not placed: no join message, no +player-list entry for the others, not among the entities they are shown, not simulated, and nothing else it sends is +acted on. The world is kept loaded, and generated if need be, around a held player as around a placed one, so a +server with nobody else on it sends a held player its chunks. The log says ` is held: it arrives from another +server` instead of the join line. A held player nobody takes over is dropped after `max_hold_seconds` (1,200 ticks +by default), with a warning, and its departure is logged as ` was dropped while held: `, with no "left +the game" line. `player-export` refuses a held player. + +`player-drop ` forgets the player's expectation, and disconnects the held player without a save or a word to +anyone. The reply is `{"ok": true, "held": true, "expected": false}`: whether a held player was disconnected, and +whether an expectation was forgotten. It is refused for an id that is neither held nor expected, and leaves a placed +player alone. `player-export [freeze]` reads a connected player out of the source server. It first closes the player's open menu as vanilla does when a menu closes, in the player's own level (the cursor stack, a crafting table's grid and @@ -234,7 +249,7 @@ kicked for floating. Every later export of that player, with or without `freeze` byte for byte, and does not restart the freeze: a proxy that lost the reply asks again. The hand-over ends after `max_freeze_seconds` (counted in ticks, 600 by default) with a warning in the log, and the id is then forgotten. Without `freeze` the export is a read: `handover` is null and the player goes on. The request is refused for a -player who is not connected, has not finished joining, is leaving, is riding, is asleep or is dead. +player who is not connected, is held, has not finished joining, is leaving, is riding, is asleep or is dead. While frozen, the player takes no damage (as vanilla's `Abilities.invulnerable`: the void and `/kill` still kill) and picks nothing up (items, arrows, experience orbs), so its record stays what it is until the release. A player From 764795ae208379c0693989636bde897dd36d2247 Mon Sep 17 00:00:00 2001 From: rdbtCVS Date: Wed, 4 Nov 2026 13:17:31 +0000 Subject: [PATCH 02/30] docs(server): regenerate held-player transfer setting reference Co-Authored-By: Codex gpt-6.1-sol --- docs/api/server-reference.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api/server-reference.md b/docs/api/server-reference.md index 329b6f7f9b..186df16698 100644 --- a/docs/api/server-reference.md +++ b/docs/api/server-reference.md @@ -155,6 +155,7 @@ A proxy handing players over between servers (docs/server.md). Not vanilla's acc | `max_expect_seconds` | `60` | | the longest the proxy may ask for | | `max_expected` | `64` | | players waited for at once | | `max_freeze_seconds` | `30` | | the longest a player stays frozen while it is handed over | +| `max_hold_seconds` | `60` | | the longest a player arriving from another server is held before it is dropped | ### `[discord]` From 6fae1fdc4484b74601a7134c48fba3c9350e9c4a Mon Sep 17 00:00:00 2001 From: Akirabane Date: Thu, 5 Nov 2026 11:21:53 +0100 Subject: [PATCH 03/30] Config golden: add max_hold_seconds to the [transfer] section The config reply pinned by api-v1-config.json lists [transfer] with its defaults; this PR adds max_hold_seconds (60 by default), so the golden lists it too. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-server/testdata/api-v1-config.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/horizon-server/testdata/api-v1-config.json b/crates/horizon-server/testdata/api-v1-config.json index 8a9a035fad..b7fa9c3722 100644 --- a/crates/horizon-server/testdata/api-v1-config.json +++ b/crates/horizon-server/testdata/api-v1-config.json @@ -38,7 +38,8 @@ "expect_seconds": 10, "max_expect_seconds": 60, "max_expected": 64, - "max_freeze_seconds": 30 + "max_freeze_seconds": 30, + "max_hold_seconds": 60 }, "updates": {"keep": 1}, "world": { From 4a5c6ab5d1804b376794f08891e8ccdb4e5150dd Mon Sep 17 00:00:00 2001 From: Akirabane Date: Tue, 3 Nov 2026 23:29:50 +0100 Subject: [PATCH 04/30] dedicated: player-import and player-confirm let a held player in `player-import ` applies a hand-over's record (from `player-export freeze`) to the held player on the target, whole or in parts, and tells its client what changed. Unless the request says `"place": false`, it then places the player as `player-confirm ` does. Placing is the hand-over's commit point: the player file, advancements and statistics are written to disk synchronously, then the player is placed, and only then does the reply say ok. A proxy releases the source after that reply, so a crash of the target no longer leaves the only current copy in memory. If the file cannot be written the player stays held and imported. The record's hand-over id is bound to the held player at the import: a record without one (an export without freeze) is refused, a retry of the same record is taken and another hand-over's is not. `player-confirm` names it, and `player-drop [hand-over]` may: a named drop must name the imported record's. A held player that is leaving (dropped or kicked, its connection not closed yet) can be neither imported nor confirmed. The import reads the whole compound as a file load does: the keys Horizon does not model, the mods' attachments (given to the game's own map) and the body's values. The body follows the parts: fall and motion with `position` (with the saved vehicle), air, fire, freezing and food exhaustion and timer with `health`. Values no player can have are refused before anything changes: a position, rotation, motion, health, absorption, saturation, experience progress or fall that is not a number, a position past vanilla's clamp, a health of zero or less. The client is sent the effects the import removed and the recipe book it brought. Every player gets the arrival's list entry, since every player is shown its entity; `announce` is the join line only. At confirm the client is sent its position and inventory again, since what it did since the import was not acted on. The control endpoint refuses a request line longer than 16 MiB, which the game would otherwise read and parse between two ticks. Refs #7484 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/control.rs | 54 +- .../src/game/dedicated/status.rs | 5 +- .../src/game/dedicated/transfer.rs | 142 +++- crates/horizon-client/src/game/lan.rs | 14 +- crates/horizon-client/src/game/stats.rs | 14 +- crates/horizon-client/src/lan/arrivals.rs | 737 +++++++++++++++++- crates/horizon-client/src/lan/guest.rs | 11 + crates/horizon-client/src/lan/hub.rs | 148 ++++ crates/horizon-client/src/lan/import.rs | 445 +++++++++++ crates/horizon-client/src/lan/mod.rs | 1 + crates/horizon-client/src/lan/playerdata.rs | 14 +- docs/server.md | 76 ++ 12 files changed, 1638 insertions(+), 23 deletions(-) create mode 100644 crates/horizon-client/src/lan/import.rs diff --git a/crates/horizon-client/src/control.rs b/crates/horizon-client/src/control.rs index 5a71f6ba81..4c36e8687b 100644 --- a/crates/horizon-client/src/control.rs +++ b/crates/horizon-client/src/control.rs @@ -326,12 +326,33 @@ impl Drop for Server { } } +/// The longest request line a connection may send. The game reads and parses a request on its +/// own thread between two ticks, so one without bound could stall the server (a proxy's +/// `player-import` carries a whole player, a few hundred kilobytes at most). +const MAX_LINE: u64 = 16 << 20; + fn serve(stream: Stream, sender: &mpsc::Sender) { let Ok(mut writer) = stream.try_clone() else { return; }; - for line in BufReader::new(stream).lines() { - let Ok(line) = line else { return }; + let mut reader = BufReader::new(stream); + loop { + let mut bytes = Vec::new(); + match (&mut reader) + .take(MAX_LINE + 1) + .read_until(b'\n', &mut bytes) + { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + if bytes.len() as u64 > MAX_LINE { + let _ = writeln!(writer, r#"{{"error":"the request is longer than 16 MiB"}}"#); + return; + } + let Ok(line) = String::from_utf8(bytes) else { + return; + }; + let line = line.trim_end_matches(['\n', '\r']).to_owned(); if line.trim().is_empty() { continue; } @@ -377,6 +398,35 @@ mod tests { assert!(!path.exists()); } + /// A request past the limit is answered with an error and never reaches the game. + #[test] + fn a_request_longer_than_the_limit_is_refused() { + let path = + std::env::temp_dir().join(format!("horizon-ctl-long-{}.json", std::process::id())); + let server = Server::start(&path).unwrap(); + let client = connect(&path).unwrap(); + let mut writing = client.try_clone().unwrap(); + // The server stops reading at the limit and closes: the rest may not be taken. + let writer = thread::spawn(move || { + let chunk = vec![b'x'; 1 << 20]; + for _ in 0..=(MAX_LINE >> 20) { + if writing.write_all(&chunk).is_err() { + return; + } + } + let _ = writing.write_all(b"\n"); + }); + let mut reply = String::new(); + BufReader::new(client).read_line(&mut reply).unwrap(); + assert!(reply.contains("longer than"), "{reply}"); + let _ = writer.join(); + assert!( + server.try_recv().is_none(), + "the game was handed the request" + ); + drop(server); + } + #[test] fn tcp_refuses_a_wrong_token() { let path = diff --git a/crates/horizon-client/src/game/dedicated/status.rs b/crates/horizon-client/src/game/dedicated/status.rs index 85c25ae842..21ca113821 100644 --- a/crates/horizon-client/src/game/dedicated/status.rs +++ b/crates/horizon-client/src/game/dedicated/status.rs @@ -3,8 +3,9 @@ //! (including their positions) and the loaded chunks; `console ` runs a console command and answers with the lines it logged; //! `server-events ` answers the typed events after that number (events.rs); `management //! ` edits the lists as vanilla's management protocol does (management.rs). -//! `player-expect [seconds]`, `player-drop `, `player-export [freeze]` and -//! `player-release ...` are a proxy's, for a player it moves between two servers (transfer.rs). +//! `player-expect [seconds]`, `player-drop `, `player-export [freeze]`, +//! `player-release ...`, `player-import ` and `player-confirm ...` are a proxy's, +//! for a player it moves between two servers (transfer.rs). use super::{ServerState, capture_log}; use crate::game::Game; use serde_json::{Value, json}; diff --git a/crates/horizon-client/src/game/dedicated/transfer.rs b/crates/horizon-client/src/game/dedicated/transfer.rs index 8e613607f8..ebd6f69cd0 100644 --- a/crates/horizon-client/src/game/dedicated/transfer.rs +++ b/crates/horizon-client/src/game/dedicated/transfer.rs @@ -2,8 +2,9 @@ //! `player-expect [seconds]` (the target is told who is coming; its login is held), //! `player-drop ` (the target forgets it, and lets a held player go), //! `player-export [freeze]` (the source answers with the player's record) and -//! `player-release moved [announce] | abort` (the source lets the player -//! go). They run +//! `player-release moved [announce] | abort` (the source lets the player go), +//! `player-import ` and `player-confirm ` (the target takes the +//! record and lets the held player in). They run //! between two ticks, on the server thread, so the player is never caught half-way through a //! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s //! `[transfer]` is off. @@ -33,6 +34,8 @@ impl Game<'_> { None => json!({ "error": "the server is not open to players" }), }, "player-export" => self.player_export(args), + "player-import" => self.player_import(args), + "player-confirm" => self.player_confirm(args), "player-release" => match self.lan.as_mut() { Some(host) => arrivals::release_request(&mut host.hub, args), None => json!({ "error": "the server is not open to players" }), @@ -66,6 +69,141 @@ impl Game<'_> { reply } + /// `player-import `: applies a record to the held player (`lan/import.rs`), with + /// its advancements and statistics, which need the game's tree and counters, and the mods' + /// attachments. Unless the request says `"place": false`, it then places the player as + /// `player-confirm` does, its file written first. + fn player_import(&mut self, args: &str) -> Value { + let Some((id, request)) = args.trim().split_once(char::is_whitespace) else { + return json!({ "error": "player-import needs a unique id and a request" }); + }; + let Some(uuid) = arrivals::parse_uuid(id) else { + return json!({ "error": "player-import needs the player's unique id" }); + }; + let request: Value = match serde_json::from_str(request.trim()) { + Ok(v) => v, + Err(e) => return json!({ "error": format!("the request is not JSON: {e}") }), + }; + let Some(mut host) = self.lan.take() else { + return json!({ "error": "the server is not open to players" }); + }; + let result = horizon_client::lan::import::import_player(&mut host.hub, uuid, &request); + let reply = match result { + Ok(done) => { + self.take_arrival_records(&mut host, uuid, &done); + let handover = arrivals::handover_text(done.handover); + let placed = if done.place { + self.place_arrival(&mut host, uuid, done.handover) + } else { + Ok(false) + }; + match placed { + Ok(placed) => json!({ + "ok": true, + "handover": handover, + "parts": done.parts, + "placed": placed, + "announced": done.announce, + }), + Err(e) => json!({ "error": e }), + } + } + Err(e) => json!({ "error": e }), + }; + self.lan = Some(host); + reply + } + + /// What an import brought that the game keeps itself: the advancements (`PlayerAdvancements + /// .applyFrom` drops those this server lacks), the statistics, and the attachments of a whole + /// import, which saves copy from the game's own map (`guest_attachments_into`). + fn take_arrival_records( + &mut self, + host: &mut LanHost, + uuid: u128, + done: &horizon_client::lan::import::Imported, + ) { + let tree = self.advancement_tree(); + let Some(g) = host.hub.guests.iter_mut().find(|g| g.uuid == uuid) else { + return; + }; + if let Some(saved) = &done.advancements { + let mut progress = horizon_client::advancements::Progress::from_json(saved); + progress + .criteria + .retain(|id, _| tree.by_id.contains_key(id)); + g.advancements = progress; + g.advancements_loaded = true; + } + if done.attachments { + self.guest_attachments.insert(uuid, g.attachments.clone()); + } + if let Some(stats) = &done.stats { + let play = host.play.entry(g.conn).or_default(); + play.stats = crate::game::stats::Stats::from_json(stats); + play.stats.mark_all_dirty(); + } + } + + /// `player-confirm `: places a held player whose record of that hand-over was + /// imported without placing it. + fn player_confirm(&mut self, args: &str) -> Value { + let Some(mut host) = self.lan.take() else { + return json!({ "error": "the server is not open to players" }); + }; + let reply = match arrivals::confirm_args(&host.hub, args) { + Ok((uuid, id)) => match self.place_arrival(&mut host, uuid, id) { + Ok(_) => { + let announced = host + .hub + .guests + .iter() + .find(|g| g.uuid == uuid) + .is_some_and(|g| g.arrival_announce); + json!({ "ok": true, "placed": true, "announced": announced }) + } + Err(e) => json!({ "error": e }), + }, + Err(e) => json!({ "error": e }), + }; + self.lan = Some(host); + reply + } + + /// The hand-over's commit point on the target: the arrival's player file, advancements and + /// statistics are written, synchronously, then it is placed. Only then does the reply say + /// `ok`, so a proxy that sends `player-release moved` to the source after it knows a copy of + /// the player is on disk here. When the file cannot be written the player stays held and + /// imported: the proxy can confirm again or drop it. + fn place_arrival(&mut self, host: &mut LanHost, uuid: u128, id: u128) -> Result { + let i = host.hub.imported_arrival(uuid, id)?; + let mut g = host.hub.guests.remove(i); + self.guest_attachments_into(&mut g); + // An autosave of this player still in flight must not land after this write. + let _ = self.finish_player_autosaves(true); + let written = horizon_client::lan::playerdata::save_now(&g, &self.world_root); + if written.is_ok() { + self.write_guest_records(host, &g); + } + let name = g.name.clone(); + host.hub.guests.insert(i, g); + written?; + let announced = host.hub.confirm_arrival(uuid, id)?; + self.arrival_placed(uuid, &name, announced); + Ok(true) + } + + /// What the game does once a held player is placed: the log's lines, and the mods' "logged + /// in" (`native_player_logged_in`), which a held login did not send, so a placed arrival + /// is heard of as any login and its leaving later is not the mods' first word of it. + fn arrival_placed(&mut self, uuid: u128, name: &str, announced: bool) { + super::log("INFO", &arrivals::placed_line(name)); + if announced { + super::log("INFO", &format!("System chat: {name} joined the game")); + } + self.native_player_logged_in(uuid, name); + } + fn export_from(&mut self, host: &mut LanHost, uuid: u128, freeze: bool) -> Value { let i = match arrivals::check_exportable(&host.hub, uuid) { Ok(i) => i, diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 9e2ad52b9a..e7cb567fdb 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1688,7 +1688,19 @@ impl Game<'_> { host: &LanHost, guest: &horizon_client::lan::guest::Guest, ) { - if !guest.placed || guest.released || self.adv.tree.list.is_empty() { + if !guest.placed || guest.released { + return; + } + self.write_guest_records(host, guest); + } + /// The advancements and statistics files of `guest`, whatever its state: a held arrival is + /// written before it is placed (`player-confirm`). + pub(super) fn write_guest_records( + &self, + host: &LanHost, + guest: &horizon_client::lan::guest::Guest, + ) { + if self.adv.tree.list.is_empty() { return; } let Some(uuid) = self.records_uuid(host, guest.uuid) else { diff --git a/crates/horizon-client/src/game/stats.rs b/crates/horizon-client/src/game/stats.rs index 73ecb223d6..f370433ab6 100644 --- a/crates/horizon-client/src/game/stats.rs +++ b/crates/horizon-client/src/game/stats.rs @@ -110,16 +110,18 @@ impl Stats { } /// `ServerStatsCounter.parseLocal`: the counters of a stats file. pub(super) fn read(path: &Path) -> Self { + let json = std::fs::read_to_string(path) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + .unwrap_or_default(); + Self::from_json(&json) + } + /// The counters of a stats file's JSON, or of a hand-over's record (`player-import`). + pub(super) fn from_json(json: &serde_json::Value) -> Self { let mut stats = Self { loaded: true, ..Self::default() }; - let Some(json) = std::fs::read_to_string(path) - .ok() - .and_then(|t| serde_json::from_str::(&t).ok()) - else { - return stats; - }; if let Some(all) = json.get("stats").and_then(|s| s.as_object()) { for (ty, entries) in all { let Some(entries) = entries.as_object() else { diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs index 3c2541532e..8258da2fcd 100644 --- a/crates/horizon-client/src/lan/arrivals.rs +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -1,7 +1,8 @@ //! Players that are about to arrive from another server, and a connected //! player read out of this one and sent away: the control endpoint's -//! `player-expect`, `player-drop`, `player-export` and `player-release` -//! (`game/dedicated/transfer.rs` runs them). +//! `player-expect`, `player-drop`, `player-export`, `player-release` and +//! `player-confirm` (`game/dedicated/transfer.rs` runs them; `player-import` is +//! `import.rs`'s). //! //! A proxy that moves a player between two servers first tells the target //! server who is coming (`player-expect `); the player's next login is then @@ -384,6 +385,11 @@ pub fn held_line(name: &str) -> String { format!("{name} is held: it arrives from another server") } +/// The line written when a held player is let into the world. +pub fn placed_line(name: &str) -> String { + format!("{name} placed: it has arrived from another server") +} + /// The line written when a held player's connection ends before it was placed: it was /// never announced, so it has no "left the game". pub fn dropped_line(name: &str, reason: &str) -> String { @@ -443,8 +449,11 @@ pub fn expect_request(hub: &mut Hub, args: &str, now: Instant) -> serde_json::Va } } -/// `player-drop `: forgets the expectation of that player, and -/// disconnects it if it is already held, without a save or a word to anyone. +/// `player-drop [hand-over]`: forgets the expectation of that player, and +/// disconnects it if it is already held, without a save or a word to anyone. With a +/// hand-over id, the held player must be the one whose record of that hand-over was +/// imported: a late drop of an older hand-over leaves a newer one alone. Before any +/// import there is no id to name, and the drop goes by the unique id alone. pub fn drop_request(hub: &mut Hub, args: &str) -> serde_json::Value { if hub.transfers.is_none() { return error(OFF); @@ -453,8 +462,20 @@ pub fn drop_request(hub: &mut Hub, args: &str) -> serde_json::Value { let Some(uuid) = parts.next().and_then(parse_uuid) else { return error("player-drop needs the player's unique id"); }; + let id = match parts.next() { + None => None, + Some(text) => match parse_handover(text) { + Some(id) => Some(id), + None => return error("player-drop takes a unique id and at most a hand-over id"), + }, + }; if parts.next().is_some() { - return error("player-drop takes only a unique id"); + return error("player-drop takes a unique id and at most a hand-over id"); + } + if let Some(id) = id + && let Err(why) = hub.imported_arrival(uuid, id) + { + return error(why); } let held = hub.drop_held(uuid); let expected = hub @@ -467,6 +488,25 @@ pub fn drop_request(hub: &mut Hub, args: &str) -> serde_json::Value { serde_json::json!({ "ok": true, "held": held, "expected": expected }) } +/// `player-confirm `: the held player whose record of that hand-over was +/// imported, ready to be placed (`Hub::imported_arrival`). The game writes its file, then places +/// it (`Hub::confirm_arrival`). Until then `player-drop` removes it without a save or a word to +/// anyone. +pub fn confirm_args(hub: &Hub, args: &str) -> Result<(u128, u128), String> { + let mut parts = args.split_whitespace(); + let Some(uuid) = parts.next().and_then(parse_uuid) else { + return Err("player-confirm needs the player's unique id".into()); + }; + let Some(id) = parts.next().and_then(parse_handover) else { + return Err("player-confirm needs the hand-over id of the record".into()); + }; + if parts.next().is_some() { + return Err("player-confirm takes a unique id and a hand-over id".into()); + } + hub.imported_arrival(uuid, id)?; + Ok((uuid, id)) +} + /// `player-release moved [announce]` or `player-release /// abort`: ends the hand-over the export with `freeze` started. `moved` takes the player out of /// the world, saving nothing and, unless `announce`, without the "left the game" line; `abort` @@ -1905,4 +1945,691 @@ mod hub_tests { hub.transfers = None; assert_eq!(super::drop_request(&mut hub, UUID)["error"], super::OFF); } + + // ---- player-import ------------------------------------------------------ + + /// The record of the exported guest, and a second hub in which the same player is held + /// (with a placed watcher), ready to take it. + fn held_in_a_second_hub() -> (serde_json::Value, Hub, Receiver, Receiver) { + let (mut source, i) = exported_guest(); + let record = super::export_record(&mut source, i, true, Default::default()).unwrap(); + let mut hub = hub(); + let (mut watcher, seen) = guest(1, 1, "Watcher"); + watcher.placed = true; + hub.guests.push(watcher); + assert_eq!( + super::expect_request(&mut hub, UUID, Instant::now())["ok"], + true + ); + let (newcomer, mine) = guest(2, UUID_N, "Alex"); + hub.guests.push(newcomer); + login_starts(&mut hub, UUID_N); + place(&mut hub, 2); + let _ = (ids(&seen), ids(&mine)); + (record, hub, seen, mine) + } + + /// What the game does with a request (`game/dedicated/transfer.rs`), without the disk: the + /// import, then the placing unless `"place": false`. + fn import_player( + hub: &mut Hub, + uuid: u128, + request: &serde_json::Value, + ) -> Result { + let done = crate::lan::import::import_player(hub, uuid, request)?; + if done.place { + hub.confirm_arrival(uuid, done.handover)?; + } + Ok(done) + } + + /// `player-confirm` as the game runs it, without the disk. A request of the unique id alone + /// is given the held player's hand-over id, as a proxy would. + fn confirm(hub: &mut Hub, args: &str) -> serde_json::Value { + let mut args = args.to_owned(); + if let [one] = args.split_whitespace().collect::>().as_slice() + && let Some(id) = super::parse_uuid(one) + .and_then(|u| hub.guests.iter().find(|g| g.uuid == u)) + .and_then(|g| g.arrival_handover) + { + args = format!("{one} {}", super::handover_text(id)); + } + match super::confirm_args(hub, &args) { + Ok((uuid, id)) => match hub.confirm_arrival(uuid, id) { + Ok(announced) => { + serde_json::json!({ "ok": true, "placed": true, "announced": announced }) + } + Err(e) => serde_json::json!({ "error": e }), + }, + Err(e) => serde_json::json!({ "error": e }), + } + } + + #[test] + fn a_whole_record_makes_the_held_player_the_player_it_was_exported_from() { + let (record, mut hub, seen, mine) = held_in_a_second_hub(); + let (source, i) = exported_guest(); + let done = + import_player(&mut hub, UUID_N, &serde_json::json!({ "record": record })).unwrap(); + assert_eq!(done.parts.len(), crate::lan::import::PARTS.len()); + assert!(done.announce); + let g = &hub.guests[1]; + assert!(g.placed && !g.held && g.handover.is_none()); + // The whole player: the compound a save would write is the same one. + assert_eq!( + crate::lan::playerdata::player_tag(g), + crate::lan::playerdata::player_tag(&source.guests[i]) + ); + assert_eq!(g.position, [10.5, 70.0, -3.25]); + assert_eq!((g.xp_level, g.food, g.game_mode, g.selected), (7, 12, 1, 3)); + // The player already there is told it has come, as for a login. + let told = ids(&seen); + assert!(told.contains(&id::SYSTEM_CHAT), "{told:?}"); + assert!(told.contains(&id::PLAYER_INFO_UPDATE), "{told:?}"); + // And the player is told what the import changed. + let sent = ids(&mine); + for wanted in [ + id::GAME_EVENT, + id::PLAYER_ABILITIES, + id::SET_HELD_SLOT, + id::PLAYER_POSITION, + ] { + assert!( + sent.contains(&wanted), + "packet {wanted} missing from {sent:?}" + ); + } + } + + #[test] + fn a_part_that_is_not_asked_for_keeps_what_the_held_player_has() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + let before = ( + hub.guests[1].position, + hub.guests[1].health, + hub.guests[1].xp_level, + hub.guests[1].game_mode, + ); + let request = + serde_json::json!({ "record": record, "parts": ["inventory", "ender_chest"] }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert_eq!(done.parts, vec!["inventory", "ender_chest"]); + let g = &hub.guests[1]; + // Only the inventory came; the position, health, experience and mode are the held player's own. + assert_eq!((g.position, g.health, g.xp_level, g.game_mode), before); + assert!( + g.inventory[0].is_some(), + "the stone came with the inventory" + ); + assert_eq!(g.selected, 3); + assert!( + g.placed && !g.held, + "a partial import still lets the player in" + ); + } + + #[test] + fn the_parts_come_in_a_fixed_order_whatever_order_they_were_asked_in() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + let request = serde_json::json!({ "record": record, "parts": ["position", "inventory", "inventory"] }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert_eq!(done.parts, vec!["inventory", "position"]); + } + + #[test] + fn a_silent_import_tells_no_one() { + let (record, mut hub, seen, _mine) = held_in_a_second_hub(); + let request = serde_json::json!({ "record": record, "announce": false }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(!done.announce); + assert!(hub.guests[1].placed); + // No join line, but the list entry: the watcher will be sent the player's entity, and a + // client adds a player's entity only with its list entry. + let told = ids(&seen); + assert!(!told.contains(&id::SYSTEM_CHAT), "{told:?}"); + assert!(told.contains(&id::PLAYER_INFO_UPDATE), "{told:?}"); + } + + /// Every refusal leaves the held player exactly as it was, and tells the world nothing. + #[test] + fn a_record_that_cannot_be_taken_changes_nothing() { + let (record, mut hub, seen, mine) = held_in_a_second_hub(); + let mut cases: Vec<(&str, serde_json::Value)> = Vec::new(); + let with = |key: &str, value: serde_json::Value| { + let mut r = record.clone(); + r[key] = value; + serde_json::json!({ "record": r }) + }; + cases.push(("version", with("version", serde_json::json!(2)))); + cases.push(("no version", with("version", serde_json::Value::Null))); + cases.push(("data version", with("data_version", serde_json::json!(1)))); + cases.push(( + "another player", + with( + "uuid", + serde_json::json!("00000000-0000-0000-0000-000000000001"), + ), + )); + cases.push(("not base64", with("player", serde_json::json!("%%%")))); + cases.push(("not NBT", with("player", serde_json::json!("AAAA")))); + cases.push(("no record", serde_json::json!({}))); + cases.push(( + "unknown part", + serde_json::json!({ "record": record, "parts": ["luck"] }), + )); + cases.push(( + "parts not a list", + serde_json::json!({ "record": record, "parts": "all" }), + )); + cases.push(( + "announce not a bool", + serde_json::json!({ "record": record, "announce": "yes" }), + )); + let before = crate::lan::playerdata::player_tag(&hub.guests[1]); + for (what, request) in &cases { + let error = import_player(&mut hub, UUID_N, request).expect_err(what); + assert!(!error.is_empty(), "{what}"); + let g = &hub.guests[1]; + assert!(g.held && !g.placed, "{what}: the player must still be held"); + assert_eq!( + crate::lan::playerdata::player_tag(g), + before, + "{what}: nothing may change" + ); + } + assert_eq!(ids(&seen), Vec::::new()); + assert_eq!(ids(&mine), Vec::::new()); + } + + #[test] + fn a_position_in_another_dimension_is_refused_but_the_other_parts_can_come() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + // Make the held player's world the Nether: the record is in the Overworld. + hub.guests[1].dimension = "minecraft:the_nether".into(); + let all = serde_json::json!({ "record": record }); + let error = import_player(&mut hub, UUID_N, &all).unwrap_err(); + assert!(error.contains("position cannot be taken"), "{error}"); + assert!(hub.guests[1].held); + let without = serde_json::json!({ "record": record, "parts": ["inventory", "experience"] }); + import_player(&mut hub, UUID_N, &without).unwrap(); + assert_eq!( + hub.guests[1].dimension, "minecraft:the_nether", + "the client's world did not change" + ); + assert_eq!(hub.guests[1].xp_level, 7); + } + + #[test] + fn only_a_held_player_can_be_imported_into() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + let request = serde_json::json!({ "record": record }); + // A placed player is not a target. + hub.guests[1].held = false; + hub.guests[1].placed = true; + let error = import_player(&mut hub, UUID_N, &request).unwrap_err(); + assert!( + error.contains("no player with that unique id is held"), + "{error}" + ); + // Nor a player nobody holds. + let mut empty = self::hub(); + let error = import_player(&mut empty, UUID_N, &request).unwrap_err(); + assert!( + error.contains("no player with that unique id is held"), + "{error}" + ); + } + + // ---- player-import without placing, player-confirm, and the cancel ------ + + fn import_without_placing( + request_extra: serde_json::Value, + ) -> (Hub, Receiver, Receiver) { + let (record, mut hub, seen, mine) = held_in_a_second_hub(); + let mut request = serde_json::json!({ "record": record, "place": false }); + for (k, v) in request_extra.as_object().unwrap() { + request[k] = v.clone(); + } + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(!done.place); + (hub, seen, mine) + } + + /// The import changes the player and tells its client, but the world still does not know it. + #[test] + fn an_import_that_does_not_place_leaves_the_player_held_and_tells_only_its_client() { + let (hub, seen, mine) = import_without_placing(serde_json::json!({})); + let g = &hub.guests[1]; + assert!(g.held && !g.placed && g.imported && g.arrival_announce); + assert_eq!( + (g.xp_level, g.food, g.game_mode, g.position), + (7, 12, 1, [10.5, 70.0, -3.25]) + ); + assert_eq!( + ids(&seen), + Vec::::new(), + "the watcher must hear nothing" + ); + let sent = ids(&mine); + for wanted in [ + id::GAME_EVENT, + id::PLAYER_ABILITIES, + id::SET_HELD_SLOT, + id::PLAYER_POSITION, + ] { + assert!( + sent.contains(&wanted), + "packet {wanted} missing from {sent:?}" + ); + } + } + + #[test] + fn confirming_places_the_player_and_announces_it_as_the_import_asked() { + let (mut hub, seen, mine) = import_without_placing(serde_json::json!({})); + let _ = ids(&mine); + let reply = confirm(&mut hub, UUID); + assert_eq!( + reply, + serde_json::json!({"ok": true, "placed": true, "announced": true}) + ); + let g = &hub.guests[1]; + assert!(g.placed && !g.held && !g.imported); + let told = ids(&seen); + assert!( + told.contains(&id::SYSTEM_CHAT) && told.contains(&id::PLAYER_INFO_UPDATE), + "{told:?}" + ); + // What the client did since the import was not acted on: it is put back where the + // server has it, the position to confirm again and the inventory. + let sent = ids(&mine); + assert!(sent.contains(&id::PLAYER_POSITION), "{sent:?}"); + assert!(sent.contains(&id::CONTAINER_SET_CONTENT), "{sent:?}"); + } + + #[test] + fn a_silent_import_stays_silent_when_it_is_confirmed() { + let (mut hub, seen, _mine) = + import_without_placing(serde_json::json!({ "announce": false })); + let reply = confirm(&mut hub, UUID); + assert_eq!( + reply, + serde_json::json!({"ok": true, "placed": true, "announced": false}) + ); + assert!(hub.guests[1].placed); + let told = ids(&seen); + assert!(!told.contains(&id::SYSTEM_CHAT), "{told:?}"); + assert!(told.contains(&id::PLAYER_INFO_UPDATE), "{told:?}"); + } + + /// The cancel: a failure after the import, before anything reached the client, removes the + /// player from this server with no file and no word to anyone. + #[test] + fn dropping_an_imported_but_unconfirmed_player_cancels_the_arrival_without_a_word() { + let (mut hub, seen, mine) = import_without_placing(serde_json::json!({})); + let _ = ids(&mine); + let reply = super::drop_request(&mut hub, UUID); + assert_eq!( + (reply["held"].as_bool(), reply["expected"].as_bool()), + (Some(true), Some(false)) + ); + assert!(matches!(mine.try_recv(), Ok(Out::Disconnect(_)))); + let gone = hub.remove_guest(1, "dropped"); + assert!( + gone.held && !gone.placed && !gone.released, + "never placed: the game saves nothing" + ); + assert_eq!( + ids(&seen), + Vec::::new(), + "the watcher must hear nothing" + ); + // And it cannot be confirmed any more. + let reply = confirm(&mut hub, UUID); + assert!(reply["error"].is_string(), "{reply}"); + } + + #[test] + fn a_confirmation_is_refused_for_a_player_that_is_not_imported_and_changes_nothing() { + let (record, mut hub, seen, _mine) = held_in_a_second_hub(); + let _ = record; + for args in ["", "nonsense", &format!("{UUID} now")] { + assert!(confirm(&mut hub, args)["error"].is_string(), "{args:?}"); + } + // Held, but nothing was imported. + let reply = confirm(&mut hub, &format!("{UUID} {}", "0".repeat(32))); + assert!( + reply["error"] + .as_str() + .unwrap() + .contains("not been imported"), + "{reply}" + ); + assert!(hub.guests[1].held && !hub.guests[1].placed); + // Not held at all. + let reply = confirm( + &mut hub, + &format!("00000000000000000000000000000009 {}", "0".repeat(32)), + ); + assert!( + reply["error"].as_str().unwrap().contains("no player"), + "{reply}" + ); + assert_eq!(ids(&seen), Vec::::new()); + } + + #[test] + fn place_is_true_or_false() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + let request = serde_json::json!({ "record": record, "place": "later" }); + assert!(import_player(&mut hub, UUID_N, &request).is_err()); + assert!(hub.guests[1].held && !hub.guests[1].imported); + } + + /// A proxy that dies between the import and the confirmation leaves nothing behind. + #[test] + fn an_imported_player_nobody_confirms_is_dropped_like_any_held_player() { + let (mut hub, seen, mine) = import_without_placing(serde_json::json!({})); + let _ = ids(&mine); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + let border = crate::worldborder::WorldBorder::default(); + let mut next = 100; + let mut ctx = tick_context(&mut world, &border, &mut next); + hub.ticks = hub.guests[1].held_since + super::Settings::default().max_hold_ticks - 1; + let events = hub.poll(&mut ctx); + assert!(events.iter().any(|e| is_warn(e, "dropped"))); + assert!(matches!(mine.try_recv(), Ok(Out::Disconnect(_)))); + assert_eq!(ids(&seen), Vec::::new()); + } + + // ---- an import replaces the inventory, it does not mix with the target's own ---- + + /// A player who has played on the target before has its own inventory in the file the + /// target loaded when it logged in. The imported inventory must REPLACE it: a slot the + /// record leaves empty is empty afterwards, not the target's old item. + #[test] + fn an_imported_inventory_replaces_the_one_the_target_loaded_for_the_player() { + use crate::items::ItemStack; + let stack = |name: &str, count: u32| ItemStack { + item: std::sync::Arc::from(name), + count, + damage: 0, + extra: None, + }; + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + // What the target's file gave the held player: items in slots the record leaves empty, + // in the armour and the off hand, and an ender chest item. + { + let g = &mut hub.guests[1]; + g.inventory[5] = Some(stack("minecraft:dirt", 3)); + g.inventory[20] = Some(stack("minecraft:cobblestone", 9)); + g.armor[0] = Some(stack("minecraft:iron_boots", 1)); + g.offhand = Some(stack("minecraft:shield", 1)); + g.ender_items[0] = Some(stack("minecraft:diamond", 2)); + } + import_player( + &mut hub, + UUID_N, + &serde_json::json!({ "record": record, "place": false }), + ) + .unwrap(); + let g = &hub.guests[1]; + // The exported player had only the stone in slot 0. + assert_eq!( + g.inventory[0].as_ref().map(|s| (&*s.item, s.count)), + Some(("minecraft:stone", 5)) + ); + for slot in 1..36 { + assert!( + g.inventory[slot].is_none(), + "slot {slot} still holds the target's old item" + ); + } + assert!(g.armor.iter().all(Option::is_none), "the old armour stayed"); + assert!(g.offhand.is_none(), "the old off hand item stayed"); + assert!( + g.ender_items.iter().all(Option::is_none), + "the old ender chest stayed" + ); + // And what the client is told is that inventory: the window content has no dirt. + let items = (0..46).filter(|&s| g.menu_slot(s).is_some()).count(); + assert_eq!(items, 1, "the inventory menu holds the stone only"); + } + + /// Parts that are not imported keep the target's own, items included. + #[test] + fn an_inventory_that_is_not_imported_keeps_the_targets_items() { + use crate::items::ItemStack; + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + hub.guests[1].inventory[5] = Some(ItemStack { + item: std::sync::Arc::from("minecraft:dirt"), + count: 3, + damage: 0, + extra: None, + }); + let request = + serde_json::json!({ "record": record, "parts": ["experience"], "place": false }); + import_player(&mut hub, UUID_N, &request).unwrap(); + assert!( + hub.guests[1].inventory[5].is_some(), + "the target's dirt must stay" + ); + assert!( + hub.guests[1].inventory[0].is_none(), + "the record's stone must not come" + ); + } + + // ---- the review's points on the import --------------------------------- + + /// The source's guest with a body (air, fire, freezing, food exhaustion and timer, a fall + /// and a motion), a key Horizon does not model and a vehicle, exported with `freeze`. + fn record_with_a_body() -> serde_json::Value { + use horizon_world::nbt::Tag; + let (mut source, i) = exported_guest(); + let g = &mut source.guests[i]; + g.loaded_body = Some(crate::lan::guest::SavedBody { + fire_ticks: 100, + air: 40, + fall_distance: 2.5, + velocity: [0.25, 0.0, -0.125], + food_exhaustion: 1.5, + food_tick_timer: 33, + ticks_frozen: 60, + }); + let mut extra = Tag::Compound(Default::default()); + if let Tag::Compound(map) = &mut extra { + map.insert("HorizonTest".into(), Tag::Int(7)); + } + g.saved = Some(extra); + super::export_record(&mut source, i, true, Default::default()).unwrap() + } + + fn held_for(record: &serde_json::Value) -> (Hub, Receiver, Receiver) { + let (_, hub, seen, mine) = held_in_a_second_hub(); + let _ = record; + (hub, seen, mine) + } + + /// A whole import carries the whole compound: the body's values and the keys Horizon does + /// not model, which the next save writes again. + #[test] + fn a_whole_import_keeps_the_body_and_the_keys_horizon_does_not_model() { + use crate::save::number; + let record = record_with_a_body(); + let (mut hub, _seen, _mine) = held_for(&record); + import_player(&mut hub, UUID_N, &serde_json::json!({ "record": record })).unwrap(); + let g = &hub.guests[1]; + let body = g.loaded_body.unwrap(); + assert_eq!( + (body.air, body.fire_ticks, body.ticks_frozen), + (40, 100, 60) + ); + assert_eq!((body.food_exhaustion, body.food_tick_timer), (1.5, 33)); + assert_eq!( + (body.fall_distance, body.velocity), + (2.5, [0.25, 0.0, -0.125]) + ); + let tag = crate::lan::playerdata::player_tag(g); + assert_eq!(number(tag.get("HorizonTest")), Some(7)); + assert_eq!(number(tag.get("Air")), Some(40)); + assert_eq!(number(tag.get("Fire")), Some(100)); + } + + /// The body's values follow their parts: the fall and motion the position, the air, fire, + /// freezing and food exhaustion and timer the health. A part left out keeps the held + /// player's own, and its saved vehicle. + #[test] + fn the_body_values_follow_their_parts() { + let record = record_with_a_body(); + let (mut hub, _seen, _mine) = held_for(&record); + let mine = crate::lan::guest::SavedBody { + fire_ticks: -20, + air: 300, + fall_distance: 0.0, + velocity: [0.0; 3], + food_exhaustion: 0.0, + food_tick_timer: 0, + ticks_frozen: 0, + }; + hub.guests[1].loaded_body = Some(mine); + hub.guests[1].saved_vehicle = Some([1, 2, 3, 4]); + let request = serde_json::json!({ "record": record, "parts": ["health"], "place": false }); + import_player(&mut hub, UUID_N, &request).unwrap(); + let body = hub.guests[1].loaded_body.unwrap(); + assert_eq!( + (body.air, body.fire_ticks, body.ticks_frozen), + (40, 100, 60) + ); + assert_eq!((body.food_exhaustion, body.food_tick_timer), (1.5, 33)); + assert_eq!( + (body.fall_distance, body.velocity), + (0.0, [0.0; 3]), + "not the position's" + ); + assert_eq!(hub.guests[1].saved_vehicle, Some([1, 2, 3, 4])); + } + + /// Values no player can have are refused before the held player is touched. + #[test] + fn a_record_with_impossible_values_is_refused() { + let cases: [(&str, fn(&mut crate::lan::guest::Guest)); 5] = [ + ("dead", |g| g.health = 0.0), + ("health not a number", |g| g.health = f32::NAN), + ("out of the world", |g| g.position = [4.0e7, 64.0, 0.0]), + ("height out of the world", |g| { + g.position = [0.0, 3.0e7, 0.0] + }), + ("rotation not a number", |g| g.yaw = f32::INFINITY), + ]; + for (what, spoil) in cases { + let (mut source, i) = exported_guest(); + spoil(&mut source.guests[i]); + let record = super::export_record(&mut source, i, true, Default::default()).unwrap(); + let (_, mut hub, seen, _mine) = held_in_a_second_hub(); + let before = crate::lan::playerdata::player_tag(&hub.guests[1]); + let error = import_player(&mut hub, UUID_N, &serde_json::json!({ "record": record })) + .expect_err(what); + assert!(error.starts_with("the record"), "{what}: {error}"); + assert_eq!( + crate::lan::playerdata::player_tag(&hub.guests[1]), + before, + "{what}" + ); + assert!(hub.guests[1].held && !hub.guests[1].imported, "{what}"); + assert_eq!(ids(&seen), Vec::::new(), "{what}"); + } + } + + /// The record must be a hand-over's, and the held player takes one hand-over's record. + #[test] + fn an_import_names_its_hand_over() { + let (source_record, mut hub, _seen, _mine) = held_in_a_second_hub(); + // A read (an export without freeze) is not a hand-over. + let (mut source, i) = exported_guest(); + let read = super::export_record(&mut source, i, false, Default::default()).unwrap(); + let error = + import_player(&mut hub, UUID_N, &serde_json::json!({ "record": read })).unwrap_err(); + assert!(error.contains("no hand-over id"), "{error}"); + let request = serde_json::json!({ "record": source_record, "place": false }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert_eq!( + source_record["handover"].as_str(), + Some(super::handover_text(done.handover).as_str()) + ); + assert_eq!(hub.guests[1].arrival_handover, Some(done.handover)); + // The same record again is taken (a retry); another hand-over's is not. + import_player(&mut hub, UUID_N, &request).unwrap(); + let mut other = source_record.clone(); + other["handover"] = serde_json::json!("0".repeat(32)); + let error = import_player( + &mut hub, + UUID_N, + &serde_json::json!({ "record": other, "place": false }), + ) + .unwrap_err(); + assert!(error.contains("another hand-over"), "{error}"); + // A confirmation of another hand-over is refused, and changes nothing. + let wrong = confirm(&mut hub, &format!("{UUID} {}", "0".repeat(32))); + assert!( + wrong["error"] + .as_str() + .unwrap() + .contains("another hand-over"), + "{wrong}" + ); + assert!(hub.guests[1].held && hub.guests[1].imported); + } + + /// `player-drop` takes the hand-over id, which must be the imported one; without it the + /// drop goes by the unique id alone. + #[test] + fn a_drop_that_names_a_hand_over_must_name_the_imported_one() { + let (mut hub, _seen, mine) = import_without_placing(serde_json::json!({})); + let id = super::handover_text(hub.guests[1].arrival_handover.unwrap()); + let _ = ids(&mine); + let reply = super::drop_request(&mut hub, &format!("{UUID} {}", "0".repeat(32))); + assert!(reply["error"].is_string(), "{reply}"); + assert!(mine.try_recv().is_err(), "nothing was dropped"); + for bad in [format!("{UUID} soon"), format!("{UUID} {id} now")] { + assert!( + super::drop_request(&mut hub, &bad)["error"].is_string(), + "{bad}" + ); + } + let reply = super::drop_request(&mut hub, &format!("{UUID} {id}")); + assert_eq!(reply["held"], true, "{reply}"); + assert!(matches!(mine.try_recv(), Ok(Out::Disconnect(_)))); + // While its connection closes, neither a confirmation nor an import takes it. + let confirmed = confirm(&mut hub, &format!("{UUID} {id}")); + assert!(confirmed["error"].is_string(), "{confirmed}"); + assert!(!hub.guests[1].placed); + } + + /// An arrival kicked or dropped, whose connection has not closed yet, is not placed. + #[test] + fn a_leaving_arrival_cannot_be_imported_or_confirmed() { + let (record, mut hub, _seen, _mine) = held_in_a_second_hub(); + hub.guests[1].left_reason = "Timed out".into(); + let error = + import_player(&mut hub, UUID_N, &serde_json::json!({ "record": record })).unwrap_err(); + assert!(error.contains("leaving"), "{error}"); + let (mut hub, _seen, _mine) = import_without_placing(serde_json::json!({})); + let id = super::handover_text(hub.guests[1].arrival_handover.unwrap()); + hub.guests[1].left_reason = "Timed out".into(); + let reply = confirm(&mut hub, &format!("{UUID} {id}")); + assert!( + reply["error"].as_str().unwrap().contains("leaving"), + "{reply}" + ); + assert!(!hub.guests[1].placed); + } + + /// The login sent the target's recipe book; the import sends the imported one. + #[test] + fn an_import_sends_the_recipe_book_again() { + let (_hub, _seen, mine) = import_without_placing(serde_json::json!({})); + let sent = ids(&mine); + assert!(sent.contains(&id::RECIPE_BOOK_SETTINGS), "{sent:?}"); + } } diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 9c0a6da22f..c6209c4b5c 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -352,6 +352,14 @@ pub struct Guest { pub held: bool, /// The hub tick at which it was held, to drop it if its hand-over never comes. pub held_since: u64, + /// A held player whose record has been applied but that is not yet let into the world + /// (`player-import` with `"place": false`): `player-confirm` places it, `player-drop` removes it. + pub imported: bool, + /// Whether the others are told when it is placed, as the import asked. + pub arrival_announce: bool, + /// The hand-over its record came with (`player-import`): `player-confirm` and + /// `player-drop` name it, and another hand-over's record is not taken. + pub arrival_handover: Option, /// Leaving for another server (`player-release moved`) without the "left the /// game" line. pub quiet_leave: bool, @@ -537,6 +545,9 @@ impl Guest { expected: j.expected, held: false, held_since: 0, + imported: false, + arrival_announce: true, + arrival_handover: None, quiet_leave: false, released: false, hidden_commands: Vec::new(), diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 22d2228611..8cf8aa7603 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1038,6 +1038,8 @@ impl Hub { else { return false; }; + // Neither a confirm nor an import may take it while its connection closes. + g.imported = false; if g.left_reason.is_empty() { g.left_reason = "dropped before its hand-over".to_owned(); } @@ -1404,6 +1406,152 @@ impl Hub { self.send_inventory(i); } + /// What an import changed, told to the held player's client the way a join tells it: game + /// mode, abilities, held slot, a position to confirm, effects, inventory, and the player + /// list as it is now (who came while it was held is in it). + pub fn send_arrival_state(&mut self, i: usize) { + let (mode, conn) = { + let g = &self.guests[i]; + (g.game_mode, g.conn) + }; + // The effects the login sent from this server's own file and the import took away: + // `send_active_effects` only adds, and the client would go on predicting with them. + let gone: Vec<&'static str> = { + let g = &self.guests[i]; + g.sent_effects + .iter() + .filter(|s| !g.effects.list.iter().any(|e| e.id == s.id)) + .map(|s| s.id) + .collect() + }; + for id in gone { + if let Some(effect) = self + .sync + .id("minecraft:mob_effect", crate::brewing::namespaced(id)) + { + let g = &self.guests[i]; + g.send(&C::RemoveMobEffect(play::RemoveMobEffect { + id: g.entity_id, + effect, + })); + } + } + let g = &self.guests[i]; + g.send(&C::GameEvent(play::GameEvent { + event: game_event::CHANGE_GAME_MODE, + param: mode as f32, + })); + g.send(&g.abilities()); + g.send(&C::SetHeldSlot(play::SetHeldSlot { + slot: g.selected as i32, + })); + self.resend_teleport(i, None); + self.send_active_effects(i); + self.send_inventory(i); + // The login sent the book of this server's file; the import may have brought another + // (`ServerRecipeBook.sendInitialRecipeBook` replaces what the client has). + self.send_initial_recipe_book(i); + let permission = self.guests[i].permission; + let entries: Vec<_> = self + .guests + .iter() + .filter(|o| o.conn != conn && o.placed && self.sees(permission, o.uuid)) + .map(|o| { + self.player_info( + o.uuid, + &o.name, + o.game_mode, + o.latency.load(Ordering::Relaxed), + ) + }) + .collect(); + self.guests[i].send(&C::PlayerInfoUpdate(play::PlayerInfoUpdate { + actions: Self::INFO_ACTIONS, + entries, + })); + } + + /// Places the held player at `i`: it is simulated, shown to the others and takes part from + /// the next tick, when its first tick does what a new player's does (health, experience, + /// advancements). Everyone gets its player-list entry, since everyone will be sent its entity + /// (a client adds a player's entity only with its list entry); `announce` is the join line. + pub fn place_arrival(&mut self, i: usize, announce: bool) { + if !self.guests[i].held || self.guests[i].placed { + return; + } + let (name, uuid, mode, conn) = { + let g = &self.guests[i]; + (g.name.clone(), g.uuid, g.game_mode, g.conn) + }; + if announce { + let joined = C::SystemChat(play::SystemChat { + content: yellow_names("multiplayer.player.joined", vec![display_name(uuid, &name)]), + overlay: false, + }) + .encode(); + for g in self.guests.iter().filter(|g| g.placed && g.conn != conn) { + if self.sees(g.permission, uuid) { + g.sender.send(joined.clone()); + } + } + } + let g = &mut self.guests[i]; + g.held = false; + g.imported = false; + g.placed = true; + g.placed_at = self.ticks; + // The player list entry of the newcomer, to everyone placed or held (itself included). + let newcomer = C::PlayerInfoUpdate(play::PlayerInfoUpdate { + actions: Self::INFO_ACTIONS, + entries: vec![self.player_info(uuid, &name, mode, 0)], + }) + .encode(); + let level = self.hidden.get(&uuid).copied(); + for g in self.guests.iter_mut().filter(|g| g.placed || g.held) { + if level.is_some_and(|l| g.permission < l) { + g.tab_hidden.insert(uuid); + } else { + g.sender.send(newcomer.clone()); + } + } + } + + /// The guest index of the held player `uuid` whose record of the hand-over `id` was + /// imported, and that has not been dropped or kicked meanwhile. + pub fn imported_arrival(&self, uuid: u128, id: u128) -> Result { + let Some(i) = self + .guests + .iter() + .position(|g| g.uuid == uuid && g.held && !g.placed) + else { + return Err("no player with that unique id is held"); + }; + let g = &self.guests[i]; + if !g.left_reason.is_empty() { + Err("that player is leaving") + } else if !g.imported { + Err("that player is held but its record has not been imported") + } else if g.arrival_handover != Some(id) { + Err("that player's record is of another hand-over") + } else { + Ok(i) + } + } + + /// Places the held player `uuid` whose record was imported without placing it + /// (`player-confirm`). Returns whether the others were told it came, as the import asked. + /// + /// What the client did since the import was not acted on, so it is put back where the + /// server has it: the position to confirm again and the inventory. + pub fn confirm_arrival(&mut self, uuid: u128, id: u128) -> Result { + let i = self.imported_arrival(uuid, id)?; + let announce = self.guests[i].arrival_announce; + self.resend_teleport(i, None); + self.send_inventory(i); + self.place_arrival(i, announce); + Ok(announce) + } + /// `ServerPlayer.sendPostEffects`: the player's `/posteffect` chains. pub fn send_post_effects(&mut self, i: usize) { let g = &mut self.guests[i]; diff --git a/crates/horizon-client/src/lan/import.rs b/crates/horizon-client/src/lan/import.rs new file mode 100644 index 0000000000..93720f706b --- /dev/null +++ b/crates/horizon-client/src/lan/import.rs @@ -0,0 +1,445 @@ +//! `player-import`: a player's record, as `player-export` made it on another +//! server (or the same one), applied to the held player that has just logged in. +//! +//! The record can be applied whole or in parts. A part left out keeps what the +//! held player has: the state this server loaded for it when it logged in (its own +//! file, or a new player's defaults). The parts are named as the operator of a proxy +//! names them, and each is a set of the guest's fields: +//! +//! | part | what it sets | +//! | --- | --- | +//! | `inventory` | the inventory, armour, off hand and the selected slot | +//! | `ender_chest` | the ender chest | +//! | `experience` | the level, the progress, the total and the seed | +//! | `health` | health and absorption, food, saturation, exhaustion and its timer, and the body's air, fire and freezing | +//! | `effects` | the active effects | +//! | `game_mode` | the game mode, the flight and its speed | +//! | `position` | where it stands, how it looks, whether it is on the ground, its fall and motion, the vehicle it rode | +//! | `spawn_point` | the respawn point | +//! | `recipe_book` | the recipe book | +//! | `advancements` | the advancements and the statistics (the caller applies them: they need the game's tree and counters) | +//! +//! What the record holds that no part names (the compound's keys Horizon does not +//! model) is taken when every part is. +use super::{ + guest::{Guest, SavedBody}, + hub::Hub, + playerdata, +}; +use crate::save::Level; +use horizon_world::nbt::{Limits, Tag, read_named}; +use serde_json::Value; + +/// The parts of a record, in the order the replies list them. +pub const PARTS: [&str; 10] = [ + "inventory", + "ender_chest", + "experience", + "health", + "effects", + "game_mode", + "position", + "spawn_point", + "recipe_book", + "advancements", +]; + +/// What an import did, for the caller to finish. +#[derive(Debug, PartialEq)] +pub struct Imported { + /// The parts that were applied, in [`PARTS`] order. + pub parts: Vec<&'static str>, + /// The advancements and the statistics to give the player, when the advancements part was + /// asked for: they need the game's tree and its counters. + pub advancements: Option, + pub stats: Option, + /// Whether the mods' attachments came (a whole import): the game's own copy of them is then + /// the record's. + pub attachments: bool, + /// Whether the other players are told the player has arrived (when it is placed). + pub announce: bool, + /// Whether the request asks the player to be let into the world now. The import never places + /// it itself: the game writes the player's file first and places it then, as for + /// `player-confirm`. Until then it stays held, and `player-drop` removes it. + pub place: bool, + /// The record's hand-over. + pub handover: u128, +} + +/// What a request of `player-import` asks: the record, the parts (all by default) +/// and whether the world is told (yes by default). +struct Request<'a> { + record: &'a Value, + parts: Vec<&'static str>, + announce: bool, + place: bool, +} + +fn read_request(request: &Value) -> Result, String> { + let record = request + .get("record") + .filter(|r| r.is_object()) + .ok_or("the request needs a record")?; + let parts = match request.get("parts") { + None | Some(Value::Null) => PARTS.to_vec(), + Some(Value::Array(names)) => { + let mut parts = Vec::new(); + for name in names { + let name = name.as_str().ok_or("a part is a name")?; + let part = PARTS + .iter() + .find(|p| **p == name) + .ok_or_else(|| format!("{name:?} is not a part of a record"))?; + if !parts.contains(part) { + parts.push(*part); + } + } + // Always in the order of PARTS, whatever order they were asked in. + PARTS.into_iter().filter(|p| parts.contains(p)).collect() + } + Some(_) => return Err("parts is a list of names".into()), + }; + let announce = match request.get("announce") { + None | Some(Value::Null) => true, + Some(Value::Bool(b)) => *b, + Some(_) => return Err("announce is true or false".into()), + }; + let place = match request.get("place") { + None | Some(Value::Null) => true, + Some(Value::Bool(b)) => *b, + Some(_) => return Err("place is true or false".into()), + }; + Ok(Request { + record, + parts, + announce, + place, + }) +} + +/// Reads the player compound and the hand-over id out of a record, after checking the record +/// is one this server can take for `uuid`. +fn read_compound(record: &Value, uuid: u128) -> Result<(Tag, u128), String> { + match record.get("version").and_then(Value::as_i64) { + Some(super::arrivals::RECORD_VERSION) => {} + Some(other) => return Err(format!("record version {other} is not understood")), + None => return Err("the record has no version".into()), + } + let theirs = record.get("data_version").and_then(Value::as_i64); + if theirs != Some(i64::from(horizon_world::DATA_VERSION)) { + return Err(format!( + "the record is of data version {theirs:?}, this server's is {}", + horizon_world::DATA_VERSION + )); + } + let named = record.get("uuid").and_then(Value::as_str); + if named.and_then(super::arrivals::parse_uuid) != Some(uuid) { + return Err("the record is another player's".into()); + } + let Some(handover) = record + .get("handover") + .and_then(Value::as_str) + .and_then(super::arrivals::parse_handover) + else { + return Err("the record has no hand-over id: export it with freeze".into()); + }; + let text = record + .get("player") + .and_then(Value::as_str) + .ok_or("the record has no player compound")?; + let bytes = crate::skins::base64_decode(text).ok_or("the player compound is not base64")?; + let (named, _) = read_named(&bytes, Limits::saved()) + .map_err(|e| format!("the player compound is not NBT: {e}"))?; + match named.tag { + tag @ Tag::Compound(_) => Ok((tag, handover)), + _ => Err("the player compound is not a compound".into()), + } +} + +/// The farthest a position may be from the origin on x and z, and on y: what vanilla's +/// `Entity.load` clamps a saved position to (`Mth.clamp(x, -3.0000512E7, 3.0000512E7)`, y +/// to +-2.0E7). +const MAX_XZ: f64 = 3.000_051_2e7; +const MAX_Y: f64 = 2.0e7; + +/// Refuses a compound whose values no player can have. `Entity.load` throws on a position or +/// rotation that is not finite; a record comes from another machine, so it is checked rather +/// than clamped, and a health of zero or less (a dead player) is refused too. +fn check_values(level: &Level) -> Result<(), String> { + let finite = |what: &str, values: &[f64]| { + if values.iter().all(|v| v.is_finite()) { + Ok(()) + } else { + Err(format!("the record's {what} is not a number")) + } + }; + if let Some(p) = level.position { + finite("position", &p)?; + if p[0].abs() > MAX_XZ || p[2].abs() > MAX_XZ || p[1].abs() > MAX_Y { + return Err("the record's position is out of the world".into()); + } + } + finite( + "rotation", + &[f64::from(level.rotation[0]), f64::from(level.rotation[1])], + )?; + finite("motion", &level.motion)?; + finite( + "health", + &[ + f64::from(level.health), + f64::from(level.absorption), + f64::from(level.saturation), + f64::from(level.xp_progress), + level.fall_distance, + ], + )?; + if level.health <= 0.0 { + return Err("the record's player is dead".into()); + } + Ok(()) +} + +/// What the held player has now, for the parts that are not imported. +struct Kept { + inventory: Option<( + Vec>, + [Option; 4], + Option, + usize, + )>, + ender: Option>>, + experience: Option<(i32, f32, i32, i32)>, + health: Option<(f32, Option, i32, f32)>, + effects: Option, + game_mode: Option<(i32, bool, f32)>, + position: Option<([f64; 3], [f64; 3], f32, f32, f32, bool, Option<[i32; 4]>)>, + spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, bool)>)>, + recipes: Option, + /// The body's values: its fall goes with the position, its air and fire with the + /// health. + body: Option, + other: Option<( + Option, + Option<(String, [i32; 3])>, + bool, + Vec, + horizon_world::attachments::Attachments, + )>, + dimension: String, +} + +impl Kept { + fn of(g: &Guest, parts: &[&str]) -> Self { + let has = |p: &str| parts.contains(&p); + let all = parts.len() == PARTS.len(); + Kept { + inventory: (!has("inventory")).then(|| { + ( + g.inventory.clone(), + g.armor.clone(), + g.offhand.clone(), + g.selected, + ) + }), + ender: (!has("ender_chest")).then(|| g.ender_items.clone()), + experience: (!has("experience")).then_some(( + g.xp_level, + g.xp_progress, + g.xp_total, + g.xp_seed, + )), + health: (!has("health")).then_some(( + g.health, + g.saved_absorption, + g.food, + g.saturation, + )), + effects: (!has("effects")).then(|| g.effects.clone()), + game_mode: (!has("game_mode")).then_some((g.game_mode, g.flying, g.flying_speed)), + position: (!has("position")).then_some(( + g.position, + g.previous_position, + g.yaw, + g.pitch, + g.head_yaw, + g.on_ground, + g.saved_vehicle, + )), + spawn: (!has("spawn_point")).then(|| (g.spawn, g.respawn_elsewhere.clone())), + recipes: (!has("recipe_book")).then(|| g.recipe_book.clone()), + body: g.loaded_body, + other: (!all).then(|| { + ( + g.saved.clone(), + g.last_death.clone(), + g.seen_credits, + g.post_effects.clone(), + g.attachments.clone(), + ) + }), + dimension: g.dimension.clone(), + } + } + + /// Puts back what the import must not have changed. + fn restore(self, g: &mut Guest, parts: &[&str]) { + let has = |p: &str| parts.contains(&p); + if let Some((inventory, armor, offhand, selected)) = self.inventory { + (g.inventory, g.armor, g.offhand, g.selected) = (inventory, armor, offhand, selected); + } + if let Some(ender) = self.ender { + g.ender_items = ender; + } + if let Some((level, progress, total, seed)) = self.experience { + (g.xp_level, g.xp_progress, g.xp_total, g.xp_seed) = (level, progress, total, seed); + } + if let Some((health, absorption, food, saturation)) = self.health { + (g.health, g.saved_absorption, g.food, g.saturation) = + (health, absorption, food, saturation); + } + if let Some(effects) = self.effects { + g.effects = effects; + } + if let Some((mode, flying, speed)) = self.game_mode { + (g.game_mode, g.flying, g.flying_speed) = (mode, flying, speed); + } + if let Some((position, previous, yaw, pitch, head, ground, vehicle)) = self.position { + (g.position, g.previous_position) = (position, previous); + (g.yaw, g.pitch, g.head_yaw, g.on_ground) = (yaw, pitch, head, ground); + // The vehicle the player rode goes with where it stands. + g.saved_vehicle = vehicle; + } + if let Some((spawn, elsewhere)) = self.spawn { + (g.spawn, g.respawn_elsewhere) = (spawn, elsewhere); + } + if let Some(recipes) = self.recipes { + g.recipe_book = recipes; + } + // The body mixes the two: its fall and motion go with the position, its air, fire, + // freezing and food exhaustion and timer with the health. What a part does not take + // is put back value by value. + match (self.body, g.loaded_body.as_mut()) { + (Some(mine), Some(imported)) => { + if !has("position") { + imported.fall_distance = mine.fall_distance; + imported.velocity = mine.velocity; + } + if !has("health") { + imported.fire_ticks = mine.fire_ticks; + imported.air = mine.air; + imported.ticks_frozen = mine.ticks_frozen; + imported.food_exhaustion = mine.food_exhaustion; + imported.food_tick_timer = mine.food_tick_timer; + } + } + (Some(mine), None) => g.loaded_body = Some(mine), + (None, _) => {} + } + if let Some((saved, death, credits, post, attachments)) = self.other { + ( + g.saved, + g.last_death, + g.seen_credits, + g.post_effects, + g.attachments, + ) = (saved, death, credits, post, attachments); + } + // The client is in one dimension, and only a login can change it. + g.dimension = self.dimension; + } +} + +/// Applies the request to the held player `uuid` and tells its client, leaving it held +/// (`Hub::confirm_arrival` places it). The caller gives the player its advancements and +/// statistics (`Imported::advancements`, `Imported::stats`) and its attachments. +/// +/// Nothing changes when it refuses: the record is read and checked first, and the +/// guest is touched only once everything is known to be well. +pub fn import_player(hub: &mut Hub, uuid: u128, request: &Value) -> Result { + let request = read_request(request)?; + let (tag, handover) = read_compound(request.record, uuid)?; + let Some(i) = hub + .guests + .iter() + .position(|g| g.uuid == uuid && g.held && !g.placed) + else { + return Err("no player with that unique id is held".into()); + }; + if !hub.guests[i].left_reason.is_empty() { + return Err("that player is leaving".into()); + } + if hub.guests[i] + .arrival_handover + .is_some_and(|theirs| theirs != handover) + { + return Err("that player's record of another hand-over was imported".into()); + } + let mut level = Level::default(); + level.read_player(&tag); + check_values(&level)?; + // What `playerdata::load` gives `apply` too: the whole compound, for the keys Horizon + // does not model, the mods' attachments and the body's values. + level.player_extra = Some(tag); + // A position in another dimension than the client's cannot be taken: the client + // would have to change its world, and only a login does that. + if request.parts.contains(&"position") { + let theirs = playerdata_dimension(&level); + if theirs != hub.guests[i].dimension { + return Err(format!( + "the record is in {theirs}, the held player in {}: the position cannot be taken", + hub.guests[i].dimension + )); + } + } + let kept = Kept::of(&hub.guests[i], &request.parts); + // `apply` fills the slots a record holds and leaves the others as they are, which is right + // for a player loaded into an empty guest and wrong here: the held player holds what its + // own file gave it on this server, and an imported inventory REPLACES that, a slot the + // record leaves empty being empty afterwards. + { + let g = &mut hub.guests[i]; + if request.parts.contains(&"inventory") { + g.inventory.iter_mut().for_each(|slot| *slot = None); + g.armor = Default::default(); + g.offhand = None; + } + if request.parts.contains(&"ender_chest") { + g.ender_items.iter_mut().for_each(|slot| *slot = None); + } + } + playerdata::apply(&mut hub.guests[i], &level); + kept.restore(&mut hub.guests[i], &request.parts); + // Everything the import changed reaches the client now, so that a proxy sees the + // player as it will be; the player stays held until it is placed or dropped. + hub.send_arrival_state(i); + let g = &mut hub.guests[i]; + g.imported = true; + g.arrival_announce = request.announce; + g.arrival_handover = Some(handover); + let records = |key: &str| { + request + .parts + .contains(&"advancements") + .then(|| request.record.get(key).cloned()) + .flatten() + .filter(|a| !a.is_null()) + }; + Ok(Imported { + advancements: records("advancements"), + stats: records("stats"), + attachments: request.parts.len() == PARTS.len(), + parts: request.parts, + announce: request.announce, + place: request.place, + handover, + }) +} + +/// The dimension a record names, the way `playerdata::apply` places it. +fn playerdata_dimension(level: &Level) -> String { + match level.dimension.as_str() { + d @ ("minecraft:the_nether" | "minecraft:the_end") => d.to_owned(), + _ => "minecraft:overworld".to_owned(), + } +} diff --git a/crates/horizon-client/src/lan/mod.rs b/crates/horizon-client/src/lan/mod.rs index bcb49c1b94..9aca58fc56 100644 --- a/crates/horizon-client/src/lan/mod.rs +++ b/crates/horizon-client/src/lan/mod.rs @@ -25,6 +25,7 @@ pub mod forwarding; pub mod guest; pub mod handoff; pub mod hub; +pub mod import; mod io_pool; pub mod menu; pub mod playerdata; diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index 8360170ef1..bcabdbbd21 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -225,14 +225,18 @@ pub fn player_tag(guest: &Guest) -> Tag { /// Save a leaving guest, including its saved vehicle tree. pub fn save(guest: &Guest, world: &Path) { - if let Err(e) = write_dat(&path(world, guest.uuid), player_tag(guest)) { - log::warn!( - "Horizon: {} is not saved: {e}", - path(world, guest.uuid).display() - ); + if let Err(e) = save_now(guest, world) { + log::warn!("Horizon: {e}"); } } +/// [`save`], saying whether the file was written: a hand-over's commit point +/// (`player-confirm`) answers only once it is. +pub fn save_now(guest: &Guest, world: &Path) -> Result<(), String> { + let file = path(world, guest.uuid); + write_dat(&file, player_tag(guest)).map_err(|e| format!("{} is not saved: {e}", file.display())) +} + /// An immutable player snapshot. Compression and all filesystem work belong to /// the writer; native callbacks are delivered by the server after completion. struct Snapshot { diff --git a/docs/server.md b/docs/server.md index d3a45b778f..f80f6b828f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -268,6 +268,82 @@ than the player's current one, and for a player who died while frozen. Once the over, and a late `moved` is refused: the player has played on since, and the record the other server has is older. The proxy then fails the hand-over on the target (`player-drop`). +`player-import ` applies a record to the held player `uuid` on the target server, whole or in parts, +and lets it into the world: + +```json +{"record": { ...the record of player-export... }, "parts": ["inventory", "experience"], "announce": true, "place": true} +``` + +`parts` is optional (all of them by default), `announce` is optional (true by default) and `place` is optional (true +by default; see below). The parts, in the order the reply lists them, and what each sets: + +| part | sets | +| --- | --- | +| `inventory` | the inventory, armour, off hand and selected slot | +| `ender_chest` | the ender chest | +| `experience` | the level, progress, total and seed | +| `health` | health and absorption, food, saturation, exhaustion and its timer, and the body's air, fire and freezing | +| `effects` | the active effects | +| `game_mode` | the game mode, flight and flight speed | +| `position` | where the player stands, how it looks, whether it is on the ground, its fall and motion, and the vehicle it rode | +| `spawn_point` | the respawn point | +| `recipe_book` | the recipe book | +| `advancements` | the advancements and the statistics | + +A part that is not asked for keeps what the held player has, which is what this server loaded for it when it logged +in: its own file, or a new player's defaults. An imported inventory or ender chest replaces the held player's whole: a +slot the record leaves empty is empty afterwards. The keys of the compound that Horizon does not model, the values +mods attached to the player, and the last death, the post effects and the credits, are taken only when every part +is. Only the compound of the record is read: the fields above it (`position`, `rotation`, `velocity`, `dimension`) +are for whoever reads the record. + +A record is refused, with `{"error": "..."}` and nothing changed, when its `version` is not 1, its `data_version` is +not this server's, its `uuid` is not the request's, it has no `handover` id (an export without `freeze`), its +compound is not valid base64 NBT, a part's name is unknown, or the player is not held or is leaving. It is refused +too when its values are ones no player can have: a position, rotation, motion, health, absorption, saturation, +experience progress or fall that is not a number, a position farther than 30,000,512 blocks from the origin (20,000,000 +up or down), which vanilla's `Entity.load` would clamp, or a health of zero or less. A record whose position is in +another dimension than the held player's world is refused when `position` is among the parts: the client is in one +dimension and only a login changes it. + +The record's hand-over is bound to the held player at the import: a second import of the same record is taken (a +retry), one of another hand-over is refused, and `player-confirm` and `player-drop` name it. + +**The commit point.** On success the player is sent what the import changed the way a join sends it (game mode, +abilities, held slot, a new position to confirm, the effects, with those it had from this server's own file removed, +inventory, recipe book and the player list as it now is). Then, unless `"place": false`, it is placed as +`player-confirm` places it: its player file, advancements and statistics are written to disk first, synchronously, +then it is placed: simulated, shown to the others and taking part from the next tick, when its first tick sends its +health, experience and advancements as a new player's does. Everyone gets its player-list entry, since everyone will +be shown its entity; with `announce` true they also get the join line. The log says ` placed: it has arrived +from another server`, and the join line when it is announced. The reply is `{"ok": true, "handover": "...", "parts": +[...], "placed": true, "announced": true}` only once the file is written, so a proxy sends `player-release moved` to +the source after this reply: from then on a crash of the target leaves the player's file there as it arrived. When the +file cannot be written, the reply is an error and the player stays held and imported, to be confirmed again or +dropped. + +**Importing in two steps, so that a hand-over can be cancelled.** With `"place": false` the record is applied and the +player's client is sent everything the import changed, but the player stays held: not placed, not simulated, not +shown, not announced, and nothing is written. The reply is `{"ok": true, "handover": "...", "parts": [...], "placed": +false, "announced": }`. Then either: + +- `player-confirm ` writes the player's files and places it as `place: true` would have, and + announces it if the import's `announce` said so. What the client did since the import was not acted on, so it is + sent its position to confirm again and its inventory. Reply `{"ok": true, "placed": true, "announced": true}`. It is + refused, with nothing changed, for a player who is not held, is leaving, whose record was not imported, or whose + record is of another hand-over. +- `player-drop [hand-over]` cancels the arrival: the connection is closed and nothing is saved or said to + anyone (the player was never placed, so the world never knew it). Reply `{"ok": true, "held": true, "expected": + false}`. With a hand-over id, it must be the imported record's, so a late drop of an older hand-over leaves a newer + one alone; before any import there is no id to name, and the drop goes by the unique id alone (a late drop of + that kind can still cut a newer held player that was not imported yet, which loses nothing: the source still has + the player frozen, and the proxy aborts there). Once dropped, the player can be neither imported nor confirmed. + +A proxy that fails after a successful `player-import` with `place: false`, and before it has sent its client the first +byte of the hand-over, calls `player-drop` and the target is as it was. A proxy that dies in between leaves a held +player that is dropped by itself after `max_hold_seconds`, with a warning in the log. + **The headless engine** comes after perf-ticks-2's work lands: Resources split into shared and per-game, the game built in `Game::new`, the integrated server on its own thread. On that base: From a66f0fc57d56dd29f8919bf409b3857e7755c368 Mon Sep 17 00:00:00 2001 From: Akirabane Date: Wed, 4 Nov 2026 15:31:54 +0100 Subject: [PATCH 05/30] dedicated: a durable commit point, retries after it, and the record's bounds martipops's second review of #8730. The commit point now covers the three files and a power loss: the player file, advancements and statistics are each written to a new file, synced, then renamed into place, and the folder is synced on Unix (save::write_durable, write_dat_durable). Each write returns its error, and the player stays held and imported when one fails. Ordinary saves are unchanged. A retry of `player-import` or `player-confirm` after the commit, whose reply was lost, names the hand-over of a player already placed from it: it is answered ok, placed, with `"retried": true`, and nothing is done again. Another hand-over's id is still refused. The record's health is refused over 1024 and its absorption over 2048 (max_health and max_absorption), its food exhaustion over 40 (FoodData.addExhaustion) and an effect amplifier outside 0 to 255, as 26.3's bytecode bounds them. A stack's count needs no check: item_from_nbt already takes one outside ItemStack's 1 to 99 as 1. docs/server.md says what a proxy does when `moved` is refused after the commit (the freeze ran out, or an operator's /kill), and that it confirms only while the export's `freeze_ticks_left` leaves it time. Refs #7484 Co-Authored-By: Claude Opus 5.5 --- .../src/game/dedicated/transfer.rs | 33 ++++++++++-- crates/horizon-client/src/game/lan.rs | 45 +++++++++++----- crates/horizon-client/src/lan/arrivals.rs | 34 +++++++++++- crates/horizon-client/src/lan/hub.rs | 8 +++ crates/horizon-client/src/lan/import.rs | 18 +++++++ crates/horizon-client/src/lan/playerdata.rs | 12 +++-- crates/horizon-client/src/save.rs | 53 ++++++++++++++++++- docs/server.md | 26 ++++++--- 8 files changed, 198 insertions(+), 31 deletions(-) diff --git a/crates/horizon-client/src/game/dedicated/transfer.rs b/crates/horizon-client/src/game/dedicated/transfer.rs index ebd6f69cd0..c89d1bfc2c 100644 --- a/crates/horizon-client/src/game/dedicated/transfer.rs +++ b/crates/horizon-client/src/game/dedicated/transfer.rs @@ -87,6 +87,23 @@ impl Game<'_> { let Some(mut host) = self.lan.take() else { return json!({ "error": "the server is not open to players" }); }; + // A retry after the commit, whose reply was lost: the player is placed from this + // hand-over already, and the answer is the same. + let retried = request["record"]["handover"] + .as_str() + .and_then(arrivals::parse_handover) + .and_then(|id| host.hub.placed_arrival(uuid, id).map(|g| (id, g))); + if let Some((id, i)) = retried { + let reply = json!({ + "ok": true, + "handover": arrivals::handover_text(id), + "placed": true, + "announced": host.hub.guests[i].arrival_announce, + "retried": true, + }); + self.lan = Some(host); + return reply; + } let result = horizon_client::lan::import::import_player(&mut host.hub, uuid, &request); let reply = match result { Ok(done) => { @@ -152,6 +169,16 @@ impl Game<'_> { return json!({ "error": "the server is not open to players" }); }; let reply = match arrivals::confirm_args(&host.hub, args) { + // A retry after the commit, whose reply was lost: the same answer. + Ok((uuid, id)) if host.hub.placed_arrival(uuid, id).is_some() => { + let i = host.hub.placed_arrival(uuid, id).unwrap_or_default(); + json!({ + "ok": true, + "placed": true, + "announced": host.hub.guests[i].arrival_announce, + "retried": true, + }) + } Ok((uuid, id)) => match self.place_arrival(&mut host, uuid, id) { Ok(_) => { let announced = host @@ -181,10 +208,8 @@ impl Game<'_> { self.guest_attachments_into(&mut g); // An autosave of this player still in flight must not land after this write. let _ = self.finish_player_autosaves(true); - let written = horizon_client::lan::playerdata::save_now(&g, &self.world_root); - if written.is_ok() { - self.write_guest_records(host, &g); - } + let written = horizon_client::lan::playerdata::save_now(&g, &self.world_root) + .and_then(|()| self.write_arrival_records(host, &g)); let name = g.name.clone(); host.hub.guests.insert(i, g); written?; diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index e7cb567fdb..350a4166d9 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1688,19 +1688,7 @@ impl Game<'_> { host: &LanHost, guest: &horizon_client::lan::guest::Guest, ) { - if !guest.placed || guest.released { - return; - } - self.write_guest_records(host, guest); - } - /// The advancements and statistics files of `guest`, whatever its state: a held arrival is - /// written before it is placed (`player-confirm`). - pub(super) fn write_guest_records( - &self, - host: &LanHost, - guest: &horizon_client::lan::guest::Guest, - ) { - if self.adv.tree.list.is_empty() { + if !guest.placed || guest.released || self.adv.tree.list.is_empty() { return; } let Some(uuid) = self.records_uuid(host, guest.uuid) else { @@ -1721,6 +1709,37 @@ impl Game<'_> { .write(&super::stats::player_json(&self.world_root, "stats", uuid)); } } + /// The advancements and statistics files of a held arrival, before it is placed + /// (`player-confirm`): each through a new file, synced and renamed into place + /// (`save::write_durable`), so the reply's `ok` means both are on the disk whole. + pub(super) fn write_arrival_records( + &self, + host: &LanHost, + guest: &horizon_client::lan::guest::Guest, + ) -> std::result::Result<(), String> { + if self.adv.tree.list.is_empty() { + return Ok(()); + } + let Some(uuid) = self.records_uuid(host, guest.uuid) else { + return Ok(()); + }; + let write = |dir: &str, json: &serde_json::Value| { + let path = super::stats::player_json(&self.world_root, dir, uuid); + let text = serde_json::to_string_pretty(json).map_err(|e| e.to_string())?; + horizon_client::save::write_durable(&path, text.as_bytes()) + .map_err(|e| format!("{} is not saved: {e}", path.display())) + }; + write( + "advancements", + &guest + .advancements + .to_json(&self.adv.tree, horizon_world::DATA_VERSION), + )?; + if let Some(play) = host.play.get(&guest.conn) { + write("stats", &play.stats.to_json())?; + } + Ok(()) + } /// `PlayerAdvancements.load` and `ServerStatsCounter.parseLocal` of a /// network player who joined; every statistic goes out on the first /// request (`PlayerList.placeNewPlayer`'s `markAllDirty`). diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs index 8258da2fcd..cce816ffb7 100644 --- a/crates/horizon-client/src/lan/arrivals.rs +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -503,7 +503,9 @@ pub fn confirm_args(hub: &Hub, args: &str) -> Result<(u128, u128), String> { if parts.next().is_some() { return Err("player-confirm takes a unique id and a hand-over id".into()); } - hub.imported_arrival(uuid, id)?; + if hub.placed_arrival(uuid, id).is_none() { + hub.imported_arrival(uuid, id)?; + } Ok((uuid, id)) } @@ -2513,7 +2515,7 @@ mod hub_tests { /// Values no player can have are refused before the held player is touched. #[test] fn a_record_with_impossible_values_is_refused() { - let cases: [(&str, fn(&mut crate::lan::guest::Guest)); 5] = [ + let cases: [(&str, fn(&mut crate::lan::guest::Guest)); 7] = [ ("dead", |g| g.health = 0.0), ("health not a number", |g| g.health = f32::NAN), ("out of the world", |g| g.position = [4.0e7, 64.0, 0.0]), @@ -2521,6 +2523,18 @@ mod hub_tests { g.position = [0.0, 3.0e7, 0.0] }), ("rotation not a number", |g| g.yaw = f32::INFINITY), + ("health past max_health's 1024", |g| g.health = 2000.0), + ("food exhaustion past 40", |g| { + g.loaded_body = Some(crate::lan::guest::SavedBody { + fire_ticks: -20, + air: 300, + fall_distance: 0.0, + velocity: [0.0; 3], + food_exhaustion: 41.0, + food_tick_timer: 0, + ticks_frozen: 0, + }); + }), ]; for (what, spoil) in cases { let (mut source, i) = exported_guest(); @@ -2632,4 +2646,20 @@ mod hub_tests { let sent = ids(&mine); assert!(sent.contains(&id::RECIPE_BOOK_SETTINGS), "{sent:?}"); } + + /// After the commit, a retry of the confirm (or of the import) whose reply was lost finds the + /// player placed from that same hand-over, and the game answers it the same; another + /// hand-over's id is still refused. + #[test] + fn a_retry_after_the_commit_finds_the_player_placed_from_that_hand_over() { + let (mut hub, _seen, _mine) = import_without_placing(serde_json::json!({})); + let id = hub.guests[1].arrival_handover.unwrap(); + let args = format!("{UUID} {}", super::handover_text(id)); + assert_eq!(confirm(&mut hub, &args)["placed"], true); + assert_eq!(hub.placed_arrival(UUID_N, id), Some(1)); + assert_eq!(super::confirm_args(&hub, &args), Ok((UUID_N, id))); + assert_eq!(hub.placed_arrival(UUID_N, id ^ 1), None); + let other = format!("{UUID} {}", super::handover_text(id ^ 1)); + assert!(super::confirm_args(&hub, &other).is_err()); + } } diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 8cf8aa7603..369bb79091 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1516,6 +1516,14 @@ impl Hub { } } + /// The guest index of the player `uuid` placed from the hand-over `id` (its import or + /// confirmation committed): a retry of either, whose reply was lost, is answered the same. + pub fn placed_arrival(&self, uuid: u128, id: u128) -> Option { + self.guests + .iter() + .position(|g| g.uuid == uuid && g.placed && g.arrival_handover == Some(id)) + } + /// The guest index of the held player `uuid` whose record of the hand-over `id` was /// imported, and that has not been dropped or kicked meanwhile. pub fn imported_arrival(&self, uuid: u128, id: u128) -> Result { diff --git a/crates/horizon-client/src/lan/import.rs b/crates/horizon-client/src/lan/import.rs index 93720f706b..7dd3985f67 100644 --- a/crates/horizon-client/src/lan/import.rs +++ b/crates/horizon-client/src/lan/import.rs @@ -197,6 +197,24 @@ fn check_values(level: &Level) -> Result<(), String> { if level.health <= 0.0 { return Err("the record's player is dead".into()); } + // The bounds vanilla's own data keeps (26.3's bytecode): `max_health` is at most 1024 + // (`Attributes.MAX_HEALTH`) and absorption at most 2048 (`MAX_ABSORPTION`), an effect's + // amplifier is 0 to 255 (`MobEffectInstance` clamps it), and food exhaustion is at most 40 + // (`FoodData.addExhaustion`). A stack's count needs nothing here: `item_from_nbt` takes one out of + // `ItemStack`'s `intRange(1, 99)` as 1. + if level.health > 1024.0 || !(0.0..=2048.0).contains(&level.absorption) { + return Err("the record's health is past what a player can have".into()); + } + if !(0.0..=40.0).contains(&level.food_exhaustion) { + return Err("the record's food exhaustion is past what a player can have".into()); + } + if level + .active_effects + .iter() + .any(|(_, _, amplifier, _, _)| !(0..=255).contains(amplifier)) + { + return Err("the record holds an effect no player can have".into()); + } Ok(()) } diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index bcabdbbd21..531b6b4329 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -225,16 +225,18 @@ pub fn player_tag(guest: &Guest) -> Tag { /// Save a leaving guest, including its saved vehicle tree. pub fn save(guest: &Guest, world: &Path) { - if let Err(e) = save_now(guest, world) { - log::warn!("Horizon: {e}"); + let file = path(world, guest.uuid); + if let Err(e) = write_dat(&file, player_tag(guest)) { + log::warn!("Horizon: {} is not saved: {e}", file.display()); } } -/// [`save`], saying whether the file was written: a hand-over's commit point -/// (`player-confirm`) answers only once it is. +/// [`save`], synced to the disk and saying whether the file was written: a hand-over's commit +/// point (`player-confirm`) answers only once it is. pub fn save_now(guest: &Guest, world: &Path) -> Result<(), String> { let file = path(world, guest.uuid); - write_dat(&file, player_tag(guest)).map_err(|e| format!("{} is not saved: {e}", file.display())) + crate::save::write_dat_durable(&file, player_tag(guest)) + .map_err(|e| format!("{} is not saved: {e}", file.display())) } /// An immutable player snapshot. Compression and all filesystem work belong to diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 59bb6ec057..61bbe61301 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -3125,6 +3125,50 @@ fn back_up_unreadable_player(path: &Path) { } /// Write a gzip NBT file the way vanilla does (`_new`, keep `_old`). pub fn write_dat(path: &Path, tag: Tag) -> Result<()> { + write_dat_as(path, tag, false) +} + +/// [`write_dat`], synced to the disk: the new file before it is renamed into place, and the +/// folder after, so a power loss leaves the old file or the new one. A hand-over's commit +/// point (`player-confirm`) replies only once its files are. +pub fn write_dat_durable(path: &Path, tag: Tag) -> Result<()> { + write_dat_as(path, tag, true) +} + +/// `bytes` written to `path` through `_new` and a rename, synced to the disk as +/// [`write_dat_durable`] syncs: never a half-written file in place. +pub fn write_durable(path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let file = path.file_name().and_then(|n| n.to_str()).unwrap_or("data"); + let temporary = path.with_file_name(format!("{file}_new")); + write_synced(&temporary, bytes)?; + fs::rename(&temporary, path)?; + sync_folder(path)?; + Ok(()) +} + +fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let mut file = fs::File::create(path)?; + file.write_all(bytes)?; + file.sync_all() +} + +/// The folder of `path` synced, so that a rename into it is on the disk (POSIX; Windows has +/// no folder handle to sync, and its rename is journaled by NTFS). +fn sync_folder(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + if let Some(dir) = path.parent() { + fs::File::open(dir)?.sync_all()?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +fn write_dat_as(path: &Path, tag: Tag, durable: bool) -> Result<()> { if let Some(dir) = path.parent() { fs::create_dir_all(dir)?; } @@ -3142,7 +3186,11 @@ pub fn write_dat(path: &Path, tag: Tag) -> Result<()> { .and_then(|n| n.to_str()) .unwrap_or("data.dat"); let temporary = path.with_file_name(format!("{file}_new")); - fs::write(&temporary, compressed)?; + if durable { + write_synced(&temporary, &compressed)?; + } else { + fs::write(&temporary, compressed)?; + } if path.exists() { let old = match file.strip_suffix(".dat") { Some(stem) => path.with_file_name(format!("{stem}.dat_old")), @@ -3152,6 +3200,9 @@ pub fn write_dat(path: &Path, tag: Tag) -> Result<()> { fs::rename(path, old).map_err(|e| format!("{}: {e}", path.display()))?; } fs::rename(temporary, path)?; + if durable { + sync_folder(path)?; + } Ok(()) } /// `SavedDataStorage`: `{data: ..., DataVersion}` under `data/minecraft/`. diff --git a/docs/server.md b/docs/server.md index f80f6b828f..068408754d 100644 --- a/docs/server.md +++ b/docs/server.md @@ -303,7 +303,9 @@ not this server's, its `uuid` is not the request's, it has no `handover` id (an compound is not valid base64 NBT, a part's name is unknown, or the player is not held or is leaving. It is refused too when its values are ones no player can have: a position, rotation, motion, health, absorption, saturation, experience progress or fall that is not a number, a position farther than 30,000,512 blocks from the origin (20,000,000 -up or down), which vanilla's `Entity.load` would clamp, or a health of zero or less. A record whose position is in +up or down), which vanilla's `Entity.load` would clamp, a health of zero or less, a health over 1,024 or an absorption +over 2,048 (the bounds of `max_health` and `max_absorption`), a food exhaustion over 40 (`FoodData.addExhaustion`'s) +or an effect amplifier outside 0 to 255. A stack's count out of vanilla's 1 to 99 is read as 1, as for any item. A record whose position is in another dimension than the held player's world is refused when `position` is among the parts: the client is in one dimension and only a login changes it. @@ -313,15 +315,27 @@ retry), one of another hand-over is refused, and `player-confirm` and `player-dr **The commit point.** On success the player is sent what the import changed the way a join sends it (game mode, abilities, held slot, a new position to confirm, the effects, with those it had from this server's own file removed, inventory, recipe book and the player list as it now is). Then, unless `"place": false`, it is placed as -`player-confirm` places it: its player file, advancements and statistics are written to disk first, synchronously, -then it is placed: simulated, shown to the others and taking part from the next tick, when its first tick sends its +`player-confirm` places it: its player file, advancements and statistics are written to disk first, each to a new +file that is synced and then renamed into place (on Linux and macOS the folder is synced too), then it is placed: simulated, shown to the others and taking part from the next tick, when its first tick sends its health, experience and advancements as a new player's does. Everyone gets its player-list entry, since everyone will be shown its entity; with `announce` true they also get the join line. The log says ` placed: it has arrived from another server`, and the join line when it is announced. The reply is `{"ok": true, "handover": "...", "parts": [...], "placed": true, "announced": true}` only once the file is written, so a proxy sends `player-release moved` to -the source after this reply: from then on a crash of the target leaves the player's file there as it arrived. When the -file cannot be written, the reply is an error and the player stays held and imported, to be confirmed again or -dropped. +the source after this reply: from then on a crash of the target, or a power loss, leaves the player's three files there +as it arrived. When one cannot be written, the reply is an error and the player stays held and imported, to be +confirmed again or dropped. + +**A lost reply after the commit.** A `player-import` or `player-confirm` that names the hand-over of a player already +placed from it is answered `{"ok": true, "placed": true, ..., "retried": true}`, with nothing done again: a proxy that +lost the reply asks again and learns the commit happened. + +**A refused `moved` after the commit.** The target then has the player placed and on disk, and nothing undoes that: +`player-drop` takes only a held player. It happens when the freeze ran out between the confirm and the release (the +proxy was late), or when an operator's `/kill` killed the frozen player (nothing else hurts it, and it does not +move). The proxy keeps its client on the target and disconnects the source's connection; the source then saves its +copy of the player as it is, so the two servers' files differ from that moment. To keep clear of the first case, the +proxy confirms only while the export's `freeze_ticks_left` leaves it time to release, and otherwise drops the arrival +and aborts. **Importing in two steps, so that a hand-over can be cancelled.** With `"place": false` the record is applied and the player's client is sent everything the import changed, but the player stays held: not placed, not simulated, not From f5aef42f710d70e612eef021b68eff9ff7534542 Mon Sep 17 00:00:00 2001 From: Akirabane Date: Thu, 5 Nov 2026 13:46:56 +0100 Subject: [PATCH 06/30] lan: an arrival's join line uses the team and nickname display name #9829 made the join and leave lines name a player as Player.getDisplayName does (its team's format and its nickname). place_arrival, which announces a player placed by player-import or player-confirm, still used the plain name; it now uses player_display_name too, so an arrival is announced as a login is. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/lan/arrivals.rs | 21 +++++++++++++++++++++ crates/horizon-client/src/lan/hub.rs | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs index cce816ffb7..86d288139f 100644 --- a/crates/horizon-client/src/lan/arrivals.rs +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -2249,6 +2249,27 @@ mod hub_tests { assert!(sent.contains(&id::CONTAINER_SET_CONTENT), "{sent:?}"); } + /// The arrival's join line names the player as the server's own join line does: by its + /// nickname and team (Player.getDisplayName), not only by its account name. + #[test] + fn the_join_line_of_an_arrival_shows_its_nickname() { + let (mut hub, seen, _mine) = import_without_placing(serde_json::json!({})); + hub.set_nickname(UUID_N, Some("Captain".into())); + let _ = packets(&seen); + let reply = confirm(&mut hub, UUID); + assert_eq!(reply["placed"], true); + let lines: Vec> = packets(&seen) + .into_iter() + .filter(|(id, _)| *id == id::SYSTEM_CHAT) + .map(|(_, body)| body) + .collect(); + assert_eq!(lines.len(), 1, "one join line"); + assert!( + lines[0].windows(b"Captain".len()).any(|w| w == b"Captain"), + "the join line names the nickname" + ); + } + #[test] fn a_silent_import_stays_silent_when_it_is_confirmed() { let (mut hub, seen, _mine) = diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 369bb79091..bb4be01d6b 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1485,7 +1485,10 @@ impl Hub { }; if announce { let joined = C::SystemChat(play::SystemChat { - content: yellow_names("multiplayer.player.joined", vec![display_name(uuid, &name)]), + content: yellow_names( + "multiplayer.player.joined", + vec![self.player_display_name(uuid, &name)], + ), overlay: false, }) .encode(); From bc2363c93b99c5cd8ac6c45bfa4a09f21b4b3adb Mon Sep 17 00:00:00 2001 From: Akirabane Date: Tue, 3 Nov 2026 23:43:31 +0100 Subject: [PATCH 07/30] dedicated: player-import takes the place the request names, and the view follows The request may name where the player arrives, over the record's compound: `position`, `rotation`, `on_ground` and, now that #8693 keeps a guest's motion, `velocity`. They belong to the `position` part. The pitch is taken as Entity.setXRot takes it (modulo a turn, then cut to +-90) and the yaw as it is; a velocity component over 10 blocks a tick is taken as 0, as Entity.load takes a saved Motion. A position must be within the same bounds as the record's own (Entity.load's clamp: 30,000,512 on x and z, 20,000,000 on y), and a rotation or velocity must be finite numbers. When the import moves the player to another chunk than the one its view was built around, the view moves at once: the chunk centre is sent before the position to confirm, and the chunks out of reach are forgotten. The reply says so with "recentered". Refs #7484 Co-Authored-By: Claude Opus 5.5 --- .../src/game/dedicated/transfer.rs | 1 + crates/horizon-client/src/lan/arrivals.rs | 274 ++++++++++++++++++ crates/horizon-client/src/lan/hub.rs | 14 +- crates/horizon-client/src/lan/import.rs | 131 ++++++++- docs/server.md | 29 +- 5 files changed, 446 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/dedicated/transfer.rs b/crates/horizon-client/src/game/dedicated/transfer.rs index c89d1bfc2c..8e98ffedeb 100644 --- a/crates/horizon-client/src/game/dedicated/transfer.rs +++ b/crates/horizon-client/src/game/dedicated/transfer.rs @@ -121,6 +121,7 @@ impl Game<'_> { "parts": done.parts, "placed": placed, "announced": done.announce, + "recentered": done.recentered, }), Err(e) => json!({ "error": e }), } diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs index 86d288139f..c075cfa3be 100644 --- a/crates/horizon-client/src/lan/arrivals.rs +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -2683,4 +2683,278 @@ mod hub_tests { let other = format!("{UUID} {}", super::handover_text(id ^ 1)); assert!(super::confirm_args(&hub, &other).is_err()); } + + // ---- the view follows the imported position ---------------------------- + + fn held_with_a_record_at( + at: [f64; 3], + center: Option<(i32, i32)>, + ) -> (serde_json::Value, Hub, Receiver) { + let (mut source, i) = exported_guest(); + source.guests[i].position = at; + let record = super::export_record(&mut source, i, true, Default::default()).unwrap(); + let mut hub = hub(); + assert_eq!( + super::expect_request(&mut hub, UUID, Instant::now())["ok"], + true + ); + let (newcomer, mine) = guest(2, UUID_N, "Alex"); + hub.guests.push(newcomer); + login_starts(&mut hub, UUID_N); + place(&mut hub, 2); + hub.guests[0].center = center; + let _ = ids(&mine); + (record, hub, mine) + } + + #[test] + fn an_import_to_another_chunk_moves_the_view_at_once_and_says_so() { + // Chunk (0, 0) before, chunk (6, -1) after: x 100.5 is block 100, chunk 6; z -3.25 is chunk -1. + let (record, mut hub, mine) = held_with_a_record_at([100.5, 70.0, -3.25], Some((0, 0))); + let request = serde_json::json!({ "record": record, "place": false }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(done.recentered); + assert_eq!( + hub.guests[0].center, + Some((6, -1)), + "the view is centred on the new chunk" + ); + let sent = packets(&mine); + let centre = sent + .iter() + .position(|(i, _)| *i == id::SET_CHUNK_CACHE_CENTER) + .expect("the new chunk centre is sent with the import"); + let position = sent + .iter() + .position(|(i, _)| *i == id::PLAYER_POSITION) + .unwrap(); + assert!( + centre < position, + "the centre goes before the position the client confirms" + ); + // The centre is the vanilla pair of VarInts: chunk x then chunk z (6, then -1 as an unsigned VarInt). + let mut r = horizon_protocol::buf::Reader::new(&sent[centre].1); + assert_eq!((r.varint().unwrap(), r.varint().unwrap()), (6, -1)); + } + + #[test] + fn an_import_inside_the_same_chunk_does_not_recentre() { + let (record, mut hub, mine) = held_with_a_record_at([3.5, 64.0, 3.5], Some((0, 0))); + let request = serde_json::json!({ "record": record, "place": false }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(!done.recentered); + assert_eq!(hub.guests[0].center, Some((0, 0))); + assert!(!ids(&mine).contains(&id::SET_CHUNK_CACHE_CENTER)); + } + + #[test] + fn without_the_position_part_the_view_stays_where_it_is() { + let (record, mut hub, mine) = held_with_a_record_at([100.5, 70.0, -3.25], Some((0, 0))); + let request = + serde_json::json!({ "record": record, "place": false, "parts": ["inventory"] }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(!done.recentered); + assert_eq!(hub.guests[0].center, Some((0, 0))); + assert!(!ids(&mine).contains(&id::SET_CHUNK_CACHE_CENTER)); + } + + /// A held player that has not been sent any chunk has no view to move: the first tick builds it + /// around the new place. + #[test] + fn a_view_not_yet_built_is_not_recentred() { + let (record, mut hub, _mine) = held_with_a_record_at([100.5, 70.0, -3.25], None); + let request = serde_json::json!({ "record": record, "place": false }); + assert!( + !import_player(&mut hub, UUID_N, &request) + .unwrap() + .recentered + ); + } + + #[test] + fn placing_at_once_reports_the_recentring_too() { + let (record, mut hub, mine) = held_with_a_record_at([100.5, 70.0, -3.25], Some((0, 0))); + let done = + import_player(&mut hub, UUID_N, &serde_json::json!({ "record": record })).unwrap(); + assert!(done.place && done.recentered); + assert!(ids(&mine).contains(&id::SET_CHUNK_CACHE_CENTER)); + } + + // ---- where the player arrives: the request's own position, rotation, on-ground ----- + + /// The position a PlayerPosition packet carries, with its look. + fn position_sent(mine: &Receiver) -> ([f64; 3], f32, f32) { + let (id_, payload) = packets(mine) + .into_iter() + .rev() + .find(|(i, _)| *i == id::PLAYER_POSITION) + .expect("the import sends a position"); + match horizon_protocol::packets::play::Clientbound::decode(id_, &payload).unwrap() { + horizon_protocol::packets::play::Clientbound::PlayerPosition(p) => { + (p.position, p.yaw, p.pitch) + } + other => panic!("not a position: {other:?}"), + } + } + + #[test] + fn the_requests_position_wins_over_the_records_and_the_client_is_told_it() { + let (record, mut hub, mine) = held_with_a_record_at([10.5, 70.0, -3.25], Some((0, 0))); + let request = serde_json::json!({ + "record": record, "place": false, + "position": [300.5, 64.0, 300.5], "rotation": [90.0, 20.0], "on_ground": false, + }); + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + let g = &hub.guests[0]; + assert_eq!(g.position, [300.5, 64.0, 300.5]); + assert_eq!(g.previous_position, [300.5, 64.0, 300.5]); + assert_eq!((g.yaw, g.pitch, g.head_yaw), (90.0, 20.0, 90.0)); + assert!(!g.on_ground); + // The chunk is the one of the request's position, not the record's. + assert!(done.recentered); + assert_eq!(g.center, Some((18, 18))); + let (at, yaw, pitch) = position_sent(&mine); + assert_eq!((at, yaw, pitch), ([300.5, 64.0, 300.5], 90.0, 20.0)); + } + + #[test] + fn without_the_requests_fields_the_records_place_is_taken() { + let (record, mut hub, mine) = held_with_a_record_at([10.5, 70.0, -3.25], Some((0, 0))); + import_player( + &mut hub, + UUID_N, + &serde_json::json!({ "record": record, "place": false }), + ) + .unwrap(); + assert_eq!(hub.guests[0].position, [10.5, 70.0, -3.25]); + assert_eq!(position_sent(&mine).0, [10.5, 70.0, -3.25]); + } + + #[test] + fn a_pitch_is_taken_modulo_a_turn_then_cut_to_a_quarter_turn() { + // Entity.setXRot: Math.clamp(pitch % 360, -90, 90); setYRot keeps the yaw as it is. + for (asked, yaw, pitch) in [ + ([10.0, 120.0], 10.0, 90.0), + ([10.0, -120.0], 10.0, -90.0), + ([10.0, 370.0], 10.0, 10.0), + ([-30.0, -350.0], -30.0, -90.0), + ([725.0, 45.0], 725.0, 45.0), + ] { + let (record, mut hub, _mine) = held_with_a_record_at([10.5, 70.0, -3.25], None); + let request = + serde_json::json!({ "record": record, "place": false, "rotation": asked }); + import_player(&mut hub, UUID_N, &request).unwrap(); + assert_eq!( + (hub.guests[0].yaw, hub.guests[0].pitch), + (yaw, pitch), + "{asked:?}" + ); + } + } + + /// Every refusal leaves the held player as it was and tells its client nothing. + #[test] + fn a_place_that_is_not_one_is_refused_and_nothing_changes() { + let (record, mut hub, mine) = held_with_a_record_at([10.5, 70.0, -3.25], Some((0, 0))); + let before = crate::lan::playerdata::player_tag(&hub.guests[0]); + let place = hub.guests[0].position; + let with = |extra: serde_json::Value| { + let mut r = serde_json::json!({ "record": record.clone(), "place": false }); + for (k, v) in extra.as_object().unwrap() { + r[k] = v.clone(); + } + r + }; + let cases = [ + ( + "beyond the world", + with(serde_json::json!({ "position": [40_000_000.0, 64.0, 0.0] })), + ), + ( + "far below", + with(serde_json::json!({ "position": [0.0, -40_000_000.0, 0.0] })), + ), + ( + "two numbers", + with(serde_json::json!({ "position": [1.0, 2.0] })), + ), + ( + "a string", + with(serde_json::json!({ "position": ["a", "b", "c"] })), + ), + ("not a list", with(serde_json::json!({ "position": 5 }))), + ( + "rotation of one", + with(serde_json::json!({ "rotation": [1.0] })), + ), + ( + "a velocity that is not three numbers", + with(serde_json::json!({ "velocity": [0.25, "up", 0.0] })), + ), + ( + "on_ground a number", + with(serde_json::json!({ "on_ground": 1 })), + ), + ( + "the position part left out", + with(serde_json::json!({ "position": [1.0, 64.0, 1.0], "parts": ["inventory"] })), + ), + ]; + for (what, request) in &cases { + let error = import_player(&mut hub, UUID_N, request).expect_err(what); + assert!(!error.is_empty(), "{what}"); + let g = &hub.guests[0]; + assert!( + g.held && !g.imported, + "{what}: the player must still be held and not imported" + ); + assert_eq!(g.position, place, "{what}"); + assert_eq!( + crate::lan::playerdata::player_tag(g), + before, + "{what}: nothing may change" + ); + } + assert_eq!( + ids(&mine), + Vec::::new(), + "a refusal tells the client nothing" + ); + } + + #[test] + fn a_position_is_finite_and_inside_the_worlds_limit() { + use crate::lan::import::check_position_for_tests as check; + assert!(check([0.0, 64.0, 0.0])); + // Entity.load's clamp: 3.0000512E7 on x and z, 2.0E7 on y. + assert!(check([-30_000_512.0, -64.0, 30_000_512.0])); + assert!(check([0.0, -20_000_000.0, 0.0])); + assert!(!check([30_000_512.5, 64.0, 0.0])); + assert!(!check([0.0, 20_000_000.5, 0.0])); + assert!(!check([f64::NAN, 64.0, 0.0])); + assert!(!check([0.0, f64::INFINITY, 0.0])); + assert!(!check([0.0, 64.0, f64::NEG_INFINITY])); + } + + /// The request's velocity is the body's from now on, a component over 10 blocks a tick + /// taken as 0 as `Entity.load` takes a saved `Motion`; it belongs to the position part. + #[test] + fn the_requests_velocity_is_taken_with_the_position() { + let (record, mut hub, _mine) = held_with_a_record_at([10.5, 70.0, -3.25], None); + let request = serde_json::json!({ + "record": record, + "place": false, + "velocity": [0.25, -0.0784, 12.0], + }); + import_player(&mut hub, UUID_N, &request).unwrap(); + let body = hub.guests[0].loaded_body.unwrap(); + assert_eq!(body.velocity, [0.25, -0.0784, 0.0]); + let alone = serde_json::json!({ + "record": record, + "parts": ["inventory"], + "velocity": [0.0, 0.0, 0.0], + }); + let error = import_player(&mut hub, UUID_N, &alone).unwrap_err(); + assert!(error.contains("belong to the position part"), "{error}"); + } } diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index bb4be01d6b..19dc689df5 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1409,7 +1409,12 @@ impl Hub { /// What an import changed, told to the held player's client the way a join tells it: game /// mode, abilities, held slot, a position to confirm, effects, inventory, and the player /// list as it is now (who came while it was held is in it). - pub fn send_arrival_state(&mut self, i: usize) { + /// + /// When the import moved the player to another chunk than the one its view was built around, + /// the view is moved at once (the chunk centre is sent and the chunks out of reach are + /// forgotten, as the next tick would do), and this returns true: the chunks around the new + /// place are still to come, one batch at a time, and whoever reads them must wait for them. + pub fn send_arrival_state(&mut self, i: usize) -> bool { let (mode, conn) = { let g = &self.guests[i]; (g.game_mode, g.conn) @@ -1436,6 +1441,12 @@ impl Hub { })); } } + let recentered = self.guests[i] + .center + .is_some_and(|c| c != self.guests[i].chunk()); + if recentered { + self.update_view(i); + } let g = &self.guests[i]; g.send(&C::GameEvent(play::GameEvent { event: game_event::CHANGE_GAME_MODE, @@ -1469,6 +1480,7 @@ impl Hub { actions: Self::INFO_ACTIONS, entries, })); + recentered } /// Places the held player at `i`: it is simulated, shown to the others and takes part from diff --git a/crates/horizon-client/src/lan/import.rs b/crates/horizon-client/src/lan/import.rs index 7dd3985f67..69936c837a 100644 --- a/crates/horizon-client/src/lan/import.rs +++ b/crates/horizon-client/src/lan/import.rs @@ -64,6 +64,10 @@ pub struct Imported { pub place: bool, /// The record's hand-over. pub handover: u128, + /// Whether the import moved the player to another chunk than the one its view was built + /// around. The chunk centre was sent at once, but the chunks around the new place come with + /// the next ticks: whoever reads the player's world must wait for them. + pub recentered: bool, } /// What a request of `player-import` asks: the record, the parts (all by default) @@ -73,6 +77,100 @@ struct Request<'a> { parts: Vec<&'static str>, announce: bool, place: bool, + arrival: Arrival, +} + +/// Where the player stands when it arrives, when the request says so instead of the record: +/// what a proxy knows of its client, which the frozen source may not have seen yet. +#[derive(Default, Debug, PartialEq)] +struct Arrival { + position: Option<[f64; 3]>, + /// Yaw and pitch, in degrees. + rotation: Option<[f32; 2]>, + on_ground: Option, + /// Blocks a tick, as the body keeps it. + velocity: Option<[f64; 3]>, +} + +impl Arrival { + fn is_empty(&self) -> bool { + *self == Arrival::default() + } +} + +/// A position the server will put a player at: finite and inside the world's limit. Nothing +/// is asked of the border, the blocks or the chunks, as a vanilla server asks nothing of the +/// place a player's file names. +/// The same bounds as the record's own position ([`check_values`]). +fn check_position(p: [f64; 3]) -> Result<[f64; 3], String> { + if p.iter().all(|c| c.is_finite()) + && p[0].abs() <= MAX_XZ + && p[2].abs() <= MAX_XZ + && p[1].abs() <= MAX_Y + { + Ok(p) + } else { + Err(format!( + "{p:?} is not a position: x and z must be numbers within {MAX_XZ}, y within {MAX_Y}" + )) + } +} + +/// For the tests of the hub: whether a position is one the server will take. +#[cfg(test)] +pub fn check_position_for_tests(p: [f64; 3]) -> bool { + check_position(p).is_ok() +} + +/// `N` numbers out of a JSON list. +fn numbers(value: &Value, what: &str) -> Result<[f64; N], String> { + let list = value + .as_array() + .filter(|l| l.len() == N) + .ok_or_else(|| format!("{what} is a list of {N} numbers"))?; + let mut out = [0.0; N]; + for (slot, v) in out.iter_mut().zip(list) { + *slot = v + .as_f64() + .filter(|n| n.is_finite()) + .ok_or_else(|| format!("{what} holds only finite numbers"))?; + } + Ok(out) +} + +fn read_arrival(request: &Value) -> Result { + let given = |key: &str| request.get(key).filter(|v| !v.is_null()); + let position = match given("position") { + Some(v) => Some(check_position(numbers::<3>(v, "position")?)?), + None => None, + }; + let rotation = match given("rotation") { + Some(v) => { + let [yaw, pitch] = numbers::<2>(v, "rotation")?; + // Entity.setXRot: the pitch is taken modulo a turn (`frem`, the sign of the + // dividend, as Rust's `%`), then cut to a quarter turn each way; setYRot keeps the + // yaw as it is. + let pitch = (pitch as f32 % 360.0).clamp(-90.0, 90.0); + Some([yaw as f32, pitch]) + } + None => None, + }; + // Entity.load: a component over 10 blocks a tick reads as 0. + let velocity = match given("velocity") { + Some(v) => Some(numbers::<3>(v, "velocity")?.map(|c| if c.abs() > 10.0 { 0.0 } else { c })), + None => None, + }; + let on_ground = match given("on_ground") { + None => None, + Some(Value::Bool(b)) => Some(*b), + Some(_) => return Err("on_ground is true or false".into()), + }; + Ok(Arrival { + position, + rotation, + on_ground, + velocity, + }) } fn read_request(request: &Value) -> Result, String> { @@ -109,11 +207,19 @@ fn read_request(request: &Value) -> Result, String> { Some(Value::Bool(b)) => *b, Some(_) => return Err("place is true or false".into()), }; + let arrival = read_arrival(request)?; + if !arrival.is_empty() && !parts.contains(&"position") { + return Err( + "position, rotation, on_ground and velocity belong to the position part, which is not imported" + .into(), + ); + } Ok(Request { record, parts, announce, place, + arrival, }) } @@ -428,9 +534,31 @@ pub fn import_player(hub: &mut Hub, uuid: u128, request: &Value) -> Result Result placed: it has arrived from another server`, and the join line when it is announced. The reply is `{"ok": true, "handover": "...", "parts": -[...], "placed": true, "announced": true}` only once the file is written, so a proxy sends `player-release moved` to +[...], "placed": true, "announced": true, "recentered": false}` only once the file is written, so a proxy sends `player-release moved` to the source after this reply: from then on a crash of the target, or a power loss, leaves the player's three files there as it arrived. When one cannot be written, the reply is an error and the player stays held and imported, to be confirmed again or dropped. @@ -337,6 +337,33 @@ copy of the player as it is, so the two servers' files differ from that moment. proxy confirms only while the export's `freeze_ticks_left` leaves it time to release, and otherwise drops the arrival and aborts. +**Where the player arrives.** Only the compound of the record is read, so to put the player somewhere other than the +record says (a proxy that knows where its client stands, which the frozen source may not have seen yet), the request +takes four optional fields, which win over the compound: + +```json +{"record": {...}, "position": [300.5, 64.0, 300.5], "rotation": [90.0, 20.0], "on_ground": false, "velocity": [0.0, -0.08, 0.0]} +``` + +`position` is `[x, y, z]`, `rotation` is `[yaw, pitch]` in degrees, `on_ground` is a boolean and `velocity` is +`[x, y, z]` in blocks a tick. The pitch is taken as vanilla's `Entity.setXRot` takes it: modulo 360, then cut to ±90; +the yaw is kept as it is. A velocity component over 10 blocks a tick is taken as 0, as `Entity.load` takes a saved +`Motion`. They belong to the `position` part: a request that gives one and does not import that part is refused. A +position that is not finite, or farther than the record's own may be (30,000,512 blocks from the origin, 20,000,000 up +or down), is refused, and so is a rotation or a velocity that is not finite numbers. The border, the blocks and the chunks are not checked, +as a vanilla server does not check the place its player file names. The player is teleported to the position (the +client confirms a new teleport id), so an anticheat never sees the arrival as a move, however far it is from the +export. + +**The view follows the imported position.** `recentered` says whether the import moved the player to another chunk +than the one its view was built around (only possible with the `position` part, and only once the player has been sent +chunks). When it did, the server moves the view at once, before the position the client confirms: it sends +`SetChunkCacheCenter` for the new chunk and forgets the chunks that are out of reach, as the next tick would have. The +chunks around the new place are **not** sent yet: they come one batch at a time with the next ticks, and the client +acknowledges each batch. Whoever reads the player's world (a proxy building a view of it) must therefore wait for new +chunk batches after a reply with `"recentered": true`, as it waited for the first ones; with `false` the chunks it +already has are the ones around the player. + **Importing in two steps, so that a hand-over can be cancelled.** With `"place": false` the record is applied and the player's client is sent everything the import changed, but the player stays held: not placed, not simulated, not shown, not announced, and nothing is written. The reply is `{"ok": true, "handover": "...", "parts": [...], "placed": From db3dcf2dcc7acdefe00f0f3b7c4c3d4a4532d49b Mon Sep 17 00:00:00 2001 From: Akirabane Date: Tue, 3 Nov 2026 23:55:54 +0100 Subject: [PATCH 08/30] dedicated: log each player-* request, and document a hand-over in order Each player-* request writes one log line with what became of it: INFO and what it did, or WARN and the reason it was refused. The line names the player and, for the export, the import, the confirm, the release and a named drop, the hand-over id, so the lines of one hand-over can be found on both servers. The record and the request's body are never written. docs/server.md gives a hand-over in order, with the ids, the commit point and what undoes itself, and the log lines. tools/server_transfer_e2e.py runs a whole hand-over between two dedicated servers over their real control sockets: two engines with [transfer] on and Velocity forwarding, a small relay per engine that answers the forwarding request as a proxy does, and horizon-bots clients. It follows the documented order step by step, checks the target's player file is on disk when the confirm answers and that the source writes nothing for a player moved away, then aborts a hand-over back and checks the player plays on. Refs #7484 Co-Authored-By: Claude Opus 5.5 --- .../src/game/dedicated/transfer.rs | 24 +- crates/horizon-client/src/lan/arrivals.rs | 253 +++++++ docs/server.md | 30 + tools/server_transfer_e2e.py | 615 ++++++++++++++++++ 4 files changed, 921 insertions(+), 1 deletion(-) create mode 100644 tools/server_transfer_e2e.py diff --git a/crates/horizon-client/src/game/dedicated/transfer.rs b/crates/horizon-client/src/game/dedicated/transfer.rs index 8e98ffedeb..bdb20fbd6d 100644 --- a/crates/horizon-client/src/game/dedicated/transfer.rs +++ b/crates/horizon-client/src/game/dedicated/transfer.rs @@ -16,8 +16,30 @@ use std::time::Instant; impl Game<'_> { /// A proxy's `player-*` request; the reply is one JSON line, `{"ok": true, ...}` or - /// `{"error": "..."}`. + /// `{"error": "..."}`. Each request writes one line to the log with what became of it + /// (`arrivals::request_log`): a hand-over that failed leaves a trace. pub(super) fn player_request(&mut self, verb: &str, args: &str) -> Value { + // The name is read before the request runs: a release or a drop takes the player away. + let name = args + .split_whitespace() + .next() + .and_then(arrivals::parse_uuid) + .and_then(|uuid| { + self.lan + .as_ref()? + .hub + .guests + .iter() + .find(|g| g.uuid == uuid) + .map(|g| g.name.clone()) + }); + let reply = self.run_player_request(verb, args); + let (level, line) = arrivals::request_log(verb, name.as_deref(), args, &reply); + super::log(level, &line); + reply + } + + fn run_player_request(&mut self, verb: &str, args: &str) -> Value { let Some(host) = self.lan.as_ref() else { return json!({ "error": "the server is not open to players" }); }; diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs index c075cfa3be..306e53a417 100644 --- a/crates/horizon-client/src/lan/arrivals.rs +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -380,6 +380,100 @@ pub fn export_reply(hub: &Hub, i: usize, record: serde_json::Value) -> serde_jso reply } +/// The log line for a proxy's `player-*` request and what became of it, with its level. +/// `name` is the player's name when the server knows it. Only the player's id and a few +/// words of the outcome are written: never the record, which is large and is the player's. +pub fn request_log( + verb: &str, + name: Option<&str>, + args: &str, + reply: &serde_json::Value, +) -> (&'static str, String) { + let mut words = args.split_whitespace(); + let id = words.next().unwrap_or(""); + let who = match (parse_uuid(id), name) { + (Some(u), Some(name)) => format!("{name} ({})", horizon_protocol::crypto::uuid_string(u)), + (Some(u), None) => horizon_protocol::crypto::uuid_string(u), + (None, _) => format!("{id:?}"), + }; + if let Some(why) = reply.get("error").and_then(serde_json::Value::as_str) { + return ("WARN", format!("{verb} {who}: refused: {why}")); + } + let flag = |key: &str| reply.get(key).and_then(serde_json::Value::as_bool); + // The hand-over a request names (release, confirm, drop) or a reply gives (export, + // import): it ties the lines of one hand-over together on both servers. + let named = words.next(); + let handover = |given: Option<&serde_json::Value>| { + given + .and_then(serde_json::Value::as_str) + .or(named.filter(|w| parse_handover(w).is_some())) + .map_or(String::new(), |id| format!(", hand-over {id}")) + }; + let details = match verb { + "player-expect" => format!( + "expected for {} s{}", + reply["ttl_seconds"], + if flag("renewed") == Some(true) { + " (renewed)" + } else { + "" + } + ), + "player-export" => format!( + "record of {} bytes{}", + reply["record"]["player"] + .as_str() + .map_or(0, |p| p.len() / 4 * 3), + match reply["record"]["handover"].as_str() { + Some(id) => format!(", frozen, hand-over {id}"), + None => ", a read".to_owned(), + } + ), + "player-import" => format!( + "parts {}, placed {}, announced {}, recentered {}{}", + reply["parts"] + .as_array() + .map(|p| p + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(",")) + .unwrap_or_default(), + flag("placed").unwrap_or(false), + flag("announced").unwrap_or(false), + flag("recentered").unwrap_or(false), + handover(reply.get("handover")), + ), + "player-confirm" => format!( + "placed, announced {}{}", + flag("announced").unwrap_or(false), + handover(None) + ), + "player-release" => match reply.get("released").and_then(serde_json::Value::as_str) { + Some("moved") => format!( + "moved, announced {}{}", + flag("announced").unwrap_or(false), + handover(None) + ), + other => format!("{}{}", other.unwrap_or("released"), handover(None)), + }, + "player-drop" => format!( + "held {}, expected {}{}", + flag("held").unwrap_or(false), + flag("expected").unwrap_or(false), + handover(None) + ), + _ => "done".to_owned(), + }; + // A retry after the commit, whose reply was lost: nothing was done again. + let retried = if flag("retried") == Some(true) { + " (a retry: nothing done again)" + } else { + "" + }; + ("INFO", format!("{verb} {who}: ok, {details}{retried}")) +} + /// The line written when a player is held: it logged in as the player a proxy expected. pub fn held_line(name: &str) -> String { format!("{name} is held: it arrives from another server") @@ -2957,4 +3051,163 @@ mod hub_tests { let error = import_player(&mut hub, UUID_N, &alone).unwrap_err(); assert!(error.contains("belong to the position part"), "{error}"); } + + // ---- the lines of the log ------------------------------------------------ + + use super::{dropped_line, held_line, placed_line, request_log}; + use serde_json::json; + + #[test] + fn a_refused_request_is_a_warning_with_the_reason() { + let reply = json!({ "error": "that player is riding" }); + let (level, line) = request_log( + "player-export", + Some("Alex"), + &format!("{UUID} freeze"), + &reply, + ); + assert_eq!(level, "WARN"); + assert_eq!( + line, + "player-export Alex (123e4567-e89b-12d3-a456-426614174000): refused: that player is riding" + ); + } + + #[test] + fn an_accepted_request_says_what_it_did_and_never_writes_the_record() { + let id = UUID.replace('-', ""); + let (level, line) = request_log( + "player-expect", + None, + &format!("{id} 30"), + &json!({"ok": true, "renewed": true, "ttl_seconds": 30}), + ); + assert_eq!(level, "INFO"); + assert_eq!( + line, + "player-expect 123e4567-e89b-12d3-a456-426614174000: ok, expected for 30 s (renewed)" + ); + // 8 base64 characters are 6 bytes. + let reply = json!({"ok": true, "record": {"player": "AAAAAAAA", "name": "Alex", "handover": "0123456789abcdef0123456789abcdef"}}); + let (_, line) = request_log( + "player-export", + Some("Alex"), + &format!("{UUID} freeze"), + &reply, + ); + assert!( + line.ends_with( + "ok, record of 6 bytes, frozen, hand-over 0123456789abcdef0123456789abcdef" + ), + "{line}" + ); + // An export without freeze is a read. + let read = json!({"ok": true, "record": {"player": "AAAAAAAA", "handover": null}}); + let (_, line) = request_log("player-export", None, UUID, &read); + assert!(line.ends_with("ok, record of 6 bytes, a read"), "{line}"); + assert!( + !line.contains("AAAA"), + "the record itself must not be written: {line}" + ); + let reply = json!({"ok": true, "handover": "0123456789abcdef0123456789abcdef", "parts": ["inventory", "experience"], "placed": false, "announced": true, "recentered": true}); + let (_, line) = request_log( + "player-import", + Some("Alex"), + &format!("{UUID} {{\"record\":{{}}}}"), + &reply, + ); + assert!( + line.ends_with( + "ok, parts inventory,experience, placed false, announced true, recentered true, hand-over 0123456789abcdef0123456789abcdef" + ), + "{line}" + ); + assert!( + !line.contains("record"), + "the request body must not be written: {line}" + ); + let (_, line) = request_log( + "player-release", + Some("Alex"), + &format!("{UUID} 0123456789abcdef0123456789abcdef moved"), + &json!({"ok": true, "released": "moved", "announced": false}), + ); + assert!( + line.ends_with( + "ok, moved, announced false, hand-over 0123456789abcdef0123456789abcdef" + ), + "{line}" + ); + let (_, line) = request_log( + "player-drop", + None, + UUID, + &json!({"ok": true, "held": true, "expected": false}), + ); + assert!(line.ends_with("ok, held true, expected false"), "{line}"); + let (_, line) = request_log( + "player-drop", + None, + &format!("{UUID} 0123456789abcdef0123456789abcdef"), + &json!({"ok": true, "held": true, "expected": false}), + ); + assert!( + line.ends_with( + "ok, held true, expected false, hand-over 0123456789abcdef0123456789abcdef" + ), + "{line}" + ); + let (_, line) = request_log( + "player-confirm", + None, + &format!("{UUID} 0123456789abcdef0123456789abcdef"), + &json!({"ok": true, "placed": true, "announced": true}), + ); + assert!( + line.ends_with( + "ok, placed, announced true, hand-over 0123456789abcdef0123456789abcdef" + ), + "{line}" + ); + } + + #[test] + fn a_request_without_a_readable_id_is_logged_as_typed() { + let (level, line) = request_log( + "player-drop", + None, + "nonsense", + &json!({"error": "player-drop needs the player's unique id"}), + ); + assert_eq!(level, "WARN"); + assert!( + line.starts_with("player-drop \"nonsense\": refused"), + "{line}" + ); + } + + #[test] + fn the_lines_of_a_held_player_never_say_it_joined_or_left() { + assert_eq!( + held_line("Alex"), + "Alex is held: it arrives from another server" + ); + assert_eq!( + placed_line("Alex"), + "Alex placed: it has arrived from another server" + ); + let dropped = dropped_line("Alex", "dropped before its hand-over"); + assert_eq!( + dropped, + "Alex was dropped while held: dropped before its hand-over" + ); + for line in [held_line("Alex"), dropped] { + assert!( + !line.contains("joined the game") && !line.contains("left the game"), + "{line}" + ); + } + } + + // ---- the view follows the imported position ------------------------------ } diff --git a/docs/server.md b/docs/server.md index 28ca65eeb3..7a81b25381 100644 --- a/docs/server.md +++ b/docs/server.md @@ -385,6 +385,36 @@ A proxy that fails after a successful `player-import` with `place: false`, and b byte of the hand-over, calls `player-drop` and the target is as it was. A proxy that dies in between leaves a held player that is dropped by itself after `max_hold_seconds`, with a warning in the log. +**A hand-over, in order.** A proxy moving a player from server A to server B, without a loading screen. Both run +with `[transfer]` on and behind the proxy's Velocity forwarding: + +1. `player-expect ` on B: B holds the player's next login. +2. `player-export freeze` on A: the record and its `handover` id, and A stops acting on the player's inputs. + A lost reply is asked again: the same record comes back. +3. The proxy logs the player in to B on its own connection: B sends the held player the world around the place B + loaded for it. +4. `player-import ` on B with `"place": false` and, if the proxy knows where its client stands, the + request's `position`, `rotation`, `on_ground` and `velocity`. With `"recentered": true` the proxy waits for B's + next chunk batches. +5. The proxy switches its client over to B's connection. If anything fails before the client got the first byte of + it, `player-drop ` on B and `player-release abort` on A, and the player goes on + on A as before. +6. `player-confirm ` on B: B writes the player's files, then places it. Only after its `ok`, + `player-release moved` on A: the player is out of A's world, and A saves nothing of it. + +Every step that is not taken in time is undone by itself: an expectation after `expect_seconds`, a held player after +`max_hold_seconds`, a freeze after `max_freeze_seconds`, each with a warning in the log. A `moved` that comes after +the freeze ran out is refused, so the proxy must confirm and release within `max_freeze_seconds` of the export. + +**In the log.** Each `player-*` request writes one line with what became of it: `INFO` and what it did +(`player-import Alex (): ok, parts inventory,experience, placed false, announced true, recentered false, +hand-over `), or `WARN` and the reason it was refused (`player-export Alex (): refused: that player is +riding`). The hand-over's id is in the lines of the export, the import, the confirm, the release and a named drop, so +the lines of one hand-over can be found on both servers. Only the ids, the name when the server knows it, and a few +words of the outcome are written: never the record or the request's body, which are large and are the player's. A +held player has its own lines instead of the join and leave ones: ` is held: it arrives from another server`, +` placed: it has arrived from another server` and ` was dropped while held: `. + **The headless engine** comes after perf-ticks-2's work lands: Resources split into shared and per-game, the game built in `Game::new`, the integrated server on its own thread. On that base: diff --git a/tools/server_transfer_e2e.py b/tools/server_transfer_e2e.py new file mode 100644 index 0000000000..86bd1a9b62 --- /dev/null +++ b/tools/server_transfer_e2e.py @@ -0,0 +1,615 @@ +#!/usr/bin/env python3 +"""A whole hand-over between two Horizon dedicated servers, over their real control sockets (#7484). + +Two engines (`horizon-client --dedicated`) run with `[transfer]` on (`--transfers`) and Velocity forwarding +(`forwarding.secret`), as docs/server.md requires. This harness is the proxy: a small relay per engine answers +the engine's `velocity:player_info` request with a signed payload (docs/velocity-forwarding.md), then copies +bytes both ways, and the control requests are sent on each engine's control socket. The player is a +horizon-bots client; the hand-over's two connections are two bot processes with the same name, as a proxy +holds two connections for one client. + + python3 tools/server_transfer_e2e.py --bin-dir target/fast + +Steps, in docs/server.md's order ("A hand-over, in order"), each checked: + +1. A player joins server A through the relay (and one joining A directly is refused: forwarding is required). + A's console gives it items and experience. +2. `player-expect` on B. +3. `player-export freeze` on A: a record with a hand-over id. Asked again, the same bytes come back. +4. The second connection logs in to B: B holds it ("is held"), and announces nothing. +5. `player-import` on B with `"place": false`: the record is taken, the player stays held. +6. `player-confirm ` on B: B's player file is on disk with the record's items when the reply comes, + then the player is placed. +7. `player-release moved` on A: the player is out of A's world, without "left the game", and A + writes no file for it, before or after it stops. + +Then the cancels: a hand-over from B back to A is aborted (`player-drop` on A, `player-release ... abort` on B) +and the player plays on on B; a late `moved` for it is refused. + +The engines run in a fresh folder with fresh HORIZON_HOME and XDG folders (lab.game_env), on free ports, with no +window. Every request, reply (records shortened) and check goes to --out/steps.json, with both servers' logs. +Build first: + cargo build --profile fast -p horizon-client --bin horizon-client + cargo build --profile fast -p horizon-bots +""" +import argparse +import base64 +import gzip +import hashlib +import hmac +import json +import os +from pathlib import Path +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import uuid as uuidlib + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import lab # noqa: E402 +from server_bench import DONE, Server # noqa: E402 + +EXE = '.exe' if os.name == 'nt' else '' +SECRET = b'server-transfer-e2e-secret' +NAME = 'Mover' +PLAYER = NAME + '0' # horizon-bots names its bots PREFIX0, PREFIX1... +PROPERTIES = { + 'online-mode': 'false', + 'white-list': 'false', + 'level-type': 'minecraft\\:flat', + 'generate-structures': 'false', + 'gamemode': 'survival', + 'spawn-protection': '0', + 'view-distance': '3', + 'simulation-distance': '3', +} +CONTAINER_CLICK = 18 # ServerboundContainerClickPacket's id (horizon-protocol ids.rs) + + +def click_body(slot): + """A pickup click on `slot` of the inventory menu (window 0, state 0, button 0), with no + predicted slots and no predicted cursor: the server works the click out itself.""" + return '00' + '00' + f'{slot:04x}' + '00' + '00' + '00' + '00' + + +TRANSFERS = 'expect_seconds=20,max_expect_seconds=60,max_expected=8,max_freeze_seconds=30,max_hold_seconds=60' + + +class Failed(Exception): + pass + + +# ---- the wire: what the relay reads of a login --------------------------------------------------- + +def varint(n): + out = b'' + n &= 0xFFFFFFFF + while True: + b, n = n & 0x7F, n >> 7 + out += bytes([b | 0x80]) if n else bytes([b]) + if not n: + return out + + +def parse_varint(buf, i=0): + n = shift = 0 + while True: + b = buf[i] + i += 1 + n |= (b & 0x7F) << shift + if not b & 0x80: + return n, i + shift += 7 + + +def string(s): + b = s.encode() + return varint(len(b)) + b + + +def parse_string(buf, i): + n, i = parse_varint(buf, i) + return buf[i:i + n].decode(), i + n + + +def recv_exact(sock, n): + data = b'' + while len(data) < n: + chunk = sock.recv(n - len(data)) + if not chunk: + raise EOFError + data += chunk + return data + + +def recv_frame(sock): + length = 0 + shift = 0 + while True: + b = recv_exact(sock, 1)[0] + length |= (b & 0x7F) << shift + if not b & 0x80: + break + shift += 7 + return recv_exact(sock, length) + + +def send_frame(sock, body): + sock.sendall(varint(len(body)) + body) + + +def offline_uuid(name): + """UUID.nameUUIDFromBytes("OfflinePlayer:" + name), as an offline server and horizon-bots derive it.""" + md5 = bytearray(hashlib.md5(('OfflinePlayer:' + name).encode()).digest()) + md5[6] = (md5[6] & 0x0F) | 0x30 + md5[8] = (md5[8] & 0x3F) | 0x80 + return uuidlib.UUID(bytes=bytes(md5)) + + +def forwarded(name, address='127.0.0.1'): + """The `velocity:player_info` answer a proxy signs: version 1, the address, the profile, no properties.""" + data = varint(1) + string(address) + offline_uuid(name).bytes + string(name) + varint(0) + return hmac.new(SECRET, data, hashlib.sha256).digest() + data + + +class Relay: + """A stand-in for the proxy in front of one engine: it answers the engine's forwarding request for the + client, then copies bytes both ways. Every connection is logged.""" + + def __init__(self, engine_port, log): + self.engine_port = engine_port + self.log = log + self.listener = socket.socket() + self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.listener.bind(('127.0.0.1', 0)) + self.listener.listen(8) + self.port = self.listener.getsockname()[1] + self.sockets = [] + threading.Thread(target=self._accept, daemon=True).start() + + def _accept(self): + while True: + try: + client, _ = self.listener.accept() + except OSError: + return + threading.Thread(target=self._serve, args=(client,), daemon=True).start() + + def _serve(self, client): + try: + engine = socket.create_connection(('127.0.0.1', self.engine_port), timeout=30) + except OSError as e: + self.log(f'relay: no engine on {self.engine_port}: {e}') + client.close() + return + self.sockets += [client, engine] + try: + handshake = recv_frame(client) + send_frame(engine, handshake) + _, i = parse_varint(handshake) # packet id 0 + _, i = parse_varint(handshake, i) # protocol + _, i = parse_string(handshake, i) # host + next_state, _ = parse_varint(handshake, i + 2) + if next_state != 2: + self._pipe(client, engine) + return + login_start = recv_frame(client) + send_frame(engine, login_start) + _, i = parse_varint(login_start) + name, _ = parse_string(login_start, i) + request = recv_frame(engine) + pid, i = parse_varint(request) + transaction, i = parse_varint(request, i) + channel, _ = parse_string(request, i) if pid == 4 else ('', i) + if pid != 4 or channel != 'velocity:player_info': + self.log(f'relay: the engine did not ask for forwarding (packet {pid})') + send_frame(client, request) + else: + send_frame(engine, varint(2) + varint(transaction) + b'\x01' + forwarded(name)) + self.log(f'relay {self.port}: forwarded {name} to {self.engine_port}') + self._pipe(client, engine) + except (EOFError, OSError) as e: + self.log(f'relay {self.port}: {type(e).__name__} {e}') + client.close() + engine.close() + + def _pipe(self, a, b): + a.settimeout(None) + b.settimeout(None) + + def copy(src, dst): + try: + while True: + chunk = src.recv(65536) + if not chunk: + break + dst.sendall(chunk) + except OSError: + pass + for s in (src, dst): + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + threading.Thread(target=copy, args=(a, b), daemon=True).start() + threading.Thread(target=copy, args=(b, a), daemon=True).start() + + def close(self): + self.listener.close() + for s in self.sockets: + try: + s.close() + except OSError: + pass + + +# ---- an engine and its control socket ------------------------------------------------------------ + +class Engine: + def __init__(self, bins, resources, folder, out, label): + self.label = label + self.folder = folder + folder.mkdir(parents=True) + self.port = lab.free_port() + props = dict(PROPERTIES, **{'server-port': str(self.port), 'motd': f'transfer e2e {label}'}) + (folder / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items())) + (folder / 'eula.txt').write_text('eula=true\n') + (folder / 'forwarding.secret').write_bytes(SECRET + b'\n') + # horizon-server makes the folder of the game's control endpoint; the game does not. + self.endpoint = folder / '.horizon' / 'engine.json' + self.endpoint.parent.mkdir() + command = [bins / f'horizon-client{EXE}', '--resources', resources, '--control-socket', self.endpoint, + '--transfers', TRANSFERS, '--dedicated', '--nogui'] + self.server = Server(command, folder, out / f'server-{label}.log') + self.control = None + + def ready(self): + self.server.wait_for(DONE, 300) + self.server.wait_for(r'Transfers on \(server\.toml \[transfer\]\)', 5) + self.server.wait_for(r'Velocity player information forwarding is on', 5) + info = json.loads(self.endpoint.read_text()) + sock = socket.create_connection(('127.0.0.1', info['port']), timeout=30) + reader = sock.makefile('r', encoding='utf-8') + sock.sendall(f'auth {info["token"]}\n'.encode()) + reply = reader.readline() + if '"ok"' not in reply: + raise Failed(f'{self.label}: the control socket refused the token: {reply.strip()}') + self.control = (sock, reader) + + def ask(self, line): + sock, reader = self.control + sock.sendall((line + '\n').encode()) + return json.loads(reader.readline()) + + def world_file(self, kind, player_uuid): + """players//. of the world, as Horizon writes it.""" + ext = 'dat' if kind == 'data' else 'json' + return self.folder / 'world' / 'players' / kind / f'{player_uuid}.{ext}' + + def stop(self): + if self.control: + try: + self.control[0].close() + except OSError: + pass + self.server.stop() + + +# ---- a player file ------------------------------------------------------------------------------- + +def read_nbt(data): + """A named NBT root out of uncompressed bytes, as plain Python values.""" + pos = [0] + + def take(n): + b = data[pos[0]:pos[0] + n] + pos[0] += n + return b + + def payload(tag): + if tag == 1: + return struct.unpack('>b', take(1))[0] + if tag == 2: + return struct.unpack('>h', take(2))[0] + if tag == 3: + return struct.unpack('>i', take(4))[0] + if tag == 4: + return struct.unpack('>q', take(8))[0] + if tag == 5: + return struct.unpack('>f', take(4))[0] + if tag == 6: + return struct.unpack('>d', take(8))[0] + if tag == 7: + n = struct.unpack('>i', take(4))[0] + return take(n) + if tag == 8: + n = struct.unpack('>H', take(2))[0] + return take(n).decode('utf-8', 'replace') + if tag == 9: + inner = take(1)[0] + n = struct.unpack('>i', take(4))[0] + return [payload(inner) for _ in range(n)] + if tag == 10: + out = {} + while True: + t = take(1)[0] + if t == 0: + return out + key = payload(8) + out[key] = payload(t) + if tag == 11: + n = struct.unpack('>i', take(4))[0] + return list(struct.unpack(f'>{n}i', take(4 * n))) + if tag == 12: + n = struct.unpack('>i', take(4))[0] + return list(struct.unpack(f'>{n}q', take(8 * n))) + raise ValueError(f'tag {tag}') + + root = take(1)[0] + payload(8) + return payload(root) + + +def items_in(player, item): + return sum(s.get('count', 1) for s in player.get('Inventory', []) if s.get('id') == item) + + +# ---- the run ------------------------------------------------------------------------------------- + +class Steps: + def __init__(self, out): + self.out = out + self.steps = [] + self.failed = [] + + def note(self, step, ok, **what): + entry = {'step': step, 'ok': ok, **what} + self.steps.append(entry) + print(('ok ' if ok else 'FAIL ') + step + ('' if ok else f': {what}')) + if not ok: + self.failed.append(step) + (self.out / 'steps.json').write_text(json.dumps(self.steps, indent=1)) + + def check(self, step, condition, **what): + self.note(step, bool(condition), **what) + return bool(condition) + + +def short(reply): + """A reply for the log, its record shortened.""" + r = json.loads(json.dumps(reply)) + if isinstance(r.get('record'), dict) and isinstance(r['record'].get('player'), str): + r['record']['player'] = f'<{len(r["record"]["player"])} base64 characters>' + for key in ('advancements', 'stats'): + if r['record'].get(key) is not None: + r['record'][key] = '<...>' + return r + + +class Bot: + """One horizon-bots client standing still, through a relay.""" + + def __init__(self, bins, port, out, label, seconds, *extra): + self.report = out / f'bot-{label}.json' + self.log = (out / f'bot-{label}.log').open('w') + self.process = subprocess.Popen( + [str(bins / f'horizon-bots{EXE}'), '--server', f'127.0.0.1:{port}', '--bots', '1', + '--behaviour', 'stand', '--seconds', str(seconds), '--name', NAME, '--report', str(self.report), + *extra], + stdout=self.log, stderr=subprocess.STDOUT, env=lab.game_env()) + + def stop(self): + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(10) + except subprocess.TimeoutExpired: + self.process.kill() + self.log.close() + + +def run(bins, resources, scratch, out): + steps = Steps(out) + player_uuid = str(offline_uuid(PLAYER)) + relay_log = (out / 'relay.log').open('w') + + def log(line): + relay_log.write(line + '\n') + relay_log.flush() + + a = Engine(bins, resources, scratch / 'a', out, 'a') + b = Engine(bins, resources, scratch / 'b', out, 'b') + relays, bots = [], [] + try: + a.ready() + b.ready() + relay_a, relay_b = Relay(a.port, log), Relay(b.port, log) + relays += [relay_a, relay_b] + steps.note('both engines up, transfers on, forwarding on', True, ports={'a': a.port, 'b': b.port}) + + # 1. The player on A, through the proxy; a direct login is refused. + direct = Bot(bins, a.port, out, 'direct', 20) + bots.append(direct) + direct.process.wait(60) + steps.check('a login that skips the proxy is refused', + any('behind a proxy' in l or 'Refused' in l for l in a.server.since(0))) + mark_a = a.server.count() + # Three seconds after it joins, the client picks up its first hotbar slot (inventory menu + # slot 36, where the diamonds go) and puts it in the 2x2 crafting grid (slot 1): two + # ServerboundContainerClickPackets (window 0, a pickup each, no predicted slots). + clicks = ','.join(f'{tick}:{CONTAINER_CLICK}:{click_body(slot)}' for tick, slot in ((60, 36), (64, 1))) + first = Bot(bins, relay_a.port, out, 'first', 240, '--raw', clicks) + bots.append(first) + a.server.wait_for(rf'{PLAYER} joined the game', 60) + a.server.console(f'give {PLAYER} minecraft:diamond 5') + a.server.console(f'give {PLAYER} minecraft:oak_log 12') + a.server.console(f'xp add {PLAYER} 7 levels') + # The client's clicks put the diamonds in its 2x2 grid; the export must put them back in + # the inventory, or they are in no record. + time.sleep(5) + steps.note('1. the player is on A through the proxy, with 5 diamonds, 12 logs and 7 levels', True) + # The player compound has no crafting grid: diamonds that left `Inventory` are in it. + mark = a.server.count() + a.server.console(f'data get entity {PLAYER} Inventory') + time.sleep(1) + inventory = [l for l in a.server.since(mark) if 'entity data' in l or 'Inventory' in l] + steps.check("1. its client moved the diamonds into its 2x2 crafting grid (they left its Inventory)", + inventory and not any('diamond' in l for l in inventory) + and any('oak_log' in l for l in inventory), console=inventory) + a_file = a.world_file('data', player_uuid) + a_before = a_file.stat().st_mtime_ns if a_file.exists() else None + + # 2. B expects it. + reply = b.ask(f'player-expect {player_uuid}') + steps.check('2. player-expect on B', reply.get('ok') is True, reply=reply) + + # 3. A exports it, frozen; a repeat answers the same bytes. + reply = a.ask(f'player-export {player_uuid} freeze') + record = reply.get('record') or {} + handover = record.get('handover') + steps.check('3. player-export freeze on A gives a record with a hand-over id', + reply.get('ok') is True and isinstance(handover, str) and len(handover) == 32, + reply=short(reply)) + again = a.ask(f'player-export {player_uuid} freeze') + steps.check('3. a repeated export answers the same record, byte for byte', + json.dumps(again.get('record'), sort_keys=True) == json.dumps(record, sort_keys=True)) + steps.check('3. the export says how long the freeze still lasts', + 0 < again.get('freeze_ticks_left', 0) <= 600, freeze_ticks_left=again.get('freeze_ticks_left')) + + # While frozen: no damage, no pickup. + mark = a.server.count() + a.server.console(f'damage {PLAYER} 5') + a.server.console('summon minecraft:item 0.5 -60 0.5 {Item:{id:"minecraft:emerald",count:3},PickupDelay:0}') + time.sleep(2) + a.server.console('kill @e[type=minecraft:item]') + time.sleep(1) + console = a.server.since(mark) + steps.check('3. a frozen player picks nothing up: the emeralds at its feet are still in the world', + any('Killed' in l for l in console), console=console) + + # 4. The second connection logs in to B, held. + mark_b = b.server.count() + second = Bot(bins, relay_b.port, out, 'second', 240) + bots.append(second) + b.server.wait_for(rf'{PLAYER} is held: it arrives from another server', 60) + steps.check('4. the login on B is held, and announced to no one', + not any('joined the game' in l for l in b.server.since(mark_b))) + + # 5. Import without placing. + request = json.dumps({'record': record, 'place': False}) + reply = b.ask(f'player-import {player_uuid} {request}') + steps.check('5. player-import on B, place false: taken, still held', + reply.get('ok') is True and reply.get('placed') is False and reply.get('handover') == handover, + reply=reply) + b_file = b.world_file('data', player_uuid) + steps.check('5. nothing is written before the confirmation', not b_file.exists()) + + # 6. Confirm: the commit point. + reply = b.ask(f'player-confirm {player_uuid} {handover}') + written = b_file.exists() + player = read_nbt(gzip.decompress(b_file.read_bytes())) if written else {} + steps.check('6. player-confirm on B: placed, and its file is on disk when the reply comes', + reply.get('ok') is True and reply.get('placed') is True and written, reply=reply) + steps.check("6. B's file holds the record's items and experience", + items_in(player, 'minecraft:diamond') == 5 and items_in(player, 'minecraft:oak_log') == 12 + and player.get('XpLevel') == 7, + diamonds=items_in(player, 'minecraft:diamond'), logs=items_in(player, 'minecraft:oak_log'), + level=player.get('XpLevel')) + exported = read_nbt(base64.b64decode(record['player'])) if record.get('player') else {} + steps.check('3. the diamonds of the 2x2 grid are back in the exported inventory', + items_in(exported, 'minecraft:diamond') == 5, diamonds=items_in(exported, 'minecraft:diamond')) + steps.check('6. the statistics went with it', b.world_file('stats', player_uuid).exists()) + steps.check('6. no emerald came: the record is the player at the export', + items_in(player, 'minecraft:emerald') == 0) + # A proxy that lost the reply asks again: the same answer, nothing done again. + retry = b.ask(f'player-confirm {player_uuid} {handover}') + steps.check('6. a confirm retried after the commit is answered ok, placed', + retry.get('ok') is True and retry.get('placed') is True and retry.get('retried') is True, + reply=retry) + retry = b.ask(f'player-import {player_uuid} {request}') + steps.check('6. an import retried after the commit is answered ok, placed', + retry.get('ok') is True and retry.get('placed') is True and retry.get('retried') is True, + reply=retry) + + # 7. Release on A. + reply = a.ask(f'player-release {player_uuid} {handover} moved') + steps.check('7. player-release moved on A', reply.get('ok') is True and reply.get('released') == 'moved', + reply=reply) + first.process.wait(30) + time.sleep(2) + report = json.loads(first.report.read_text())['per_bot'][0] if first.report.exists() else {} + steps.check('3. the frozen player took no damage (its client was never sent a lower health)', + report.get('health') in (None, 20.0, 20), health=report.get('health')) + lines_a = a.server.since(mark_a) + steps.check('7. A logs no "left the game" for a player moved away', + not any(f'{PLAYER} left the game' in l for l in lines_a)) + steps.check("7. A's connection of the player is closed", first.process.poll() is not None) + late = a.ask(f'player-release {player_uuid} {handover} moved') + steps.check('7. a repeated release is refused', 'error' in late, reply=late) + + # The cancel: from B back to A, aborted. + reply = a.ask(f'player-expect {player_uuid}') + steps.check('cancel: player-expect on A', reply.get('ok') is True, reply=reply) + reply = b.ask(f'player-export {player_uuid} freeze') + back = (reply.get('record') or {}).get('handover') + steps.check('cancel: player-export freeze on B', reply.get('ok') is True and back, reply=short(reply)) + third = Bot(bins, relay_a.port, out, 'third', 60) + bots.append(third) + a.server.wait_for(rf'{PLAYER} is held: it arrives from another server', 60) + reply = a.ask(f'player-drop {player_uuid}') + steps.check('cancel: player-drop on A lets the held login go', reply.get('held') is True, reply=reply) + a.server.wait_for(rf'{PLAYER} was dropped while held', 30) + reply = b.ask(f'player-release {player_uuid} {back} abort') + steps.check('cancel: player-release abort on B', reply.get('released') == 'abort', reply=reply) + reply = b.ask(f'player-release {player_uuid} {back} moved') + steps.check('cancel: a moved after the abort is refused', 'error' in reply, reply=reply) + status = b.ask('server-status') + names = [p.get('name') for p in status.get('players', [])] + steps.check('cancel: the player plays on on B', PLAYER in names and second.process.poll() is None, + players=names) + + # A wrote nothing for the player it let go, before or after it stops. + a.stop() + a_after = a_file.stat().st_mtime_ns if a_file.exists() else None + steps.check('A wrote no file for the player after it moved away, even when it stopped', + a_after == a_before, before=a_before, after=a_after) + finally: + for bot in bots: + bot.stop() + for relay in relays: + relay.close() + for engine in (a, b): + try: + engine.stop() + except Exception: # noqa: BLE001 - stopping what is left + pass + relay_log.close() + if steps.failed: + raise Failed('failed: ' + '; '.join(steps.failed)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--bin-dir', default=str(lab.ROOT / 'target/fast')) + parser.add_argument('--resources', default=str(lab.RESOURCES), help='the prepared game files') + parser.add_argument('--out', default=str(lab.LOCAL / 'server-transfer-e2e')) + args = parser.parse_args() + bins, out = Path(args.bin_dir).resolve(), Path(args.out) + shutil.rmtree(out, ignore_errors=True) + out.mkdir(parents=True) + os.environ.update(lab.game_env()) + with tempfile.TemporaryDirectory(prefix='server-transfer-e2e-') as scratch: + try: + run(bins, Path(args.resources), Path(scratch), out) + except (Failed, RuntimeError) as e: + sys.exit(f'server_transfer_e2e failed (steps and logs in {out}):\n{e}') + print(f'server_transfer_e2e: the whole hand-over and its cancel went as docs/server.md says (steps in {out})') + + +if __name__ == '__main__': + main() From e95658709a24089e94bca82d763edbdccc09ac25 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 16:31:45 +0100 Subject: [PATCH 09/30] Design note: mods from inside the game, loaded and unloaded in seconds What content mods can switch live (assets and data), what needs a world reload (registries, worldgen), the in-game mod browser, and the native fast path: every installed native mod built in and switched at run time, with the dylib and wasm paths left to arismoko's fork. Co-Authored-By: Claude Opus 5.5 --- docs/design/mod-hotload.md | 235 +++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/design/mod-hotload.md diff --git a/docs/design/mod-hotload.md b/docs/design/mod-hotload.md new file mode 100644 index 0000000000..674128aed8 --- /dev/null +++ b/docs/design/mod-hotload.md @@ -0,0 +1,235 @@ +# Mods from inside the game, loaded and unloaded in seconds + +Asked by Ludovic on 2026-11-05: players add mods from inside the game, and turn them on and off in a few seconds, +without a restart wherever one can be avoided. This note says what can change live, what needs a world reload or a +restart, how the in-game browser installs, and which native-mod path gets a code change from "minutes, then +restart" to seconds. It builds on the registry v2 ([registry-v2.md](registry-v2.md)), the content mods of +[mods.md](../mods.md), the native mods of [native-mods.md](../native-mods.md) and the plan in +[#7386](https://github.com/horizon-rust/horizon/issues/7386). + +## Where things stand + +| | Install | Turn on or off | Change | +|---|---|---|---| +| Content mod (`.hmod`) | launcher, or by hand in `horizon-mods/` | Options > Mods, then **restart** | rebuild the `.hmod`, restart | +| Native mod (`.hnmod`) | launcher only | off: at once, if the mod checks `native_on`; on: **rebuild** | **rebuild** (minutes), restart | + +Three facts from the code shape the plan: + +- A pack reload already runs `mods::content_root` with the mods that are on (`stage_pack`), so a switch followed by + a resource reload already swaps the mods' **assets** (models, textures, sounds, lang). Nothing else follows. +- The server's data tables (recipes, tags, loot tables, advancements, functions, data maps) are rebuilt by `/reload` + (`reload_world_tables`), from `options.resources`, and sent to the players (`lan::reload_sync_data`). A mod switch + never reaches them. +- The content root is one folder, `.horizon-cache/mod-content`, rebuilt **in place** when the mod set changes. A + running game whose `Resources` were built from the old root then reads tables of the new one. Today that happens + only between a switch and the restart the page asks for; with live switching it would happen every time. + +## Content mods: what can change live + +A content mod brings four sorts of thing. They differ in who holds them and whether a save names them by number. + +| What | Held by | Saves name them | Live? | +|---|---|---|---| +| Assets: block and item models, textures, sounds, lang, fonts, particles, entity models | the client's `PackResources`, atlas, audio, `lang` | no | **live**: the pack reload (1 to 3 s) | +| Data: recipes, tags, loot tables, advancements, functions, predicates, item modifiers, data maps | the server's `WorldTables` | no | **live**: `/reload`'s path, players resynced | +| Worldgen: biome modifiers, structure set, pool and processor additions, features | the world's generator, built when it opens | no, but chunks already generated keep what was there | **world reload**; only new chunks change | +| Registries: blocks and their states, items, mob effects, entity types, menu types, component types | `Resources` (`BlockFacts`, `Items`), process-wide tables (`MOD_EFFECTS`, `THROWN_TYPES`, mod menu types, `item::custom`) | **yes**: chunk palettes, inventories, effects, entities | **world reload**, later; **restart** today | + +Vanilla draws the same line. `/reload` reloads a server's data packs (`ReloadableServerResources`), and F3+T reloads +the client's resources. A change to the registries a world was created with (a new feature flag, a data pack with +worldgen) goes through the world's **Data Packs** screen and reopens the world. + +### Live: assets and data + +A switch of a mod whose content is only assets and data applies at once, in the world or on the title screen: + +1. **A root per mod set.** `mod_content::resource_root` builds into `mod-content//` instead of one + folder. A running game keeps reading the root it was given, and a switch never changes files under it. Roots + that are not the current one and are older than a day are removed when the game starts, so the cache holds one + root, or two after a switch. +2. **Classify the switch** before applying it: build the new mod list (`accept_mod`, as `content_root` does) on a + worker, and compare what each side **registers** (the ids of blocks and their declared state properties, items, + mob effects, entity types, menu types, and the native component types). Equal registries mean the switch is live. + Otherwise it needs a world reload (below), and the page says so on the mod's row before anything changes. +3. **Client.** The pack reload already builds the new root and swaps the assets. It runs under its progress overlay; + the world keeps drawing. +4. **Server.** The server thread gets the new root (`options.resources`) and runs `reload_world_tables`, as `/reload` + does: tables rebuilt, recipes and tags sent to the players, advancements reloaded. A LAN host's guests and the + window's own client get the same packets they get after `/reload`. +5. **Multiplayer.** On someone else's server, the client never changes data: only assets switch live, and a mod that + adds registries can't be turned on while connected ("Leave the server to turn this mod on"). On a LAN host, a + live switch resyncs the guests; a registry change is refused while guests are connected, as it would disconnect + them. + +What a player keeps: their world, position, inventory and open screen. What they see: the overlay of a resource +reload, then the new textures and recipes. A mod turned off leaves its recipes out of the recipe book at once; its +items stay where they are only if a mod still registers them (otherwise the switch was not live). + +### World reload: registries and worldgen + +Blocks and items have numbers. Chunks store block states as palette entries, inventories store item ids, and the +client meshes sections against `BlockFacts`. Adding a block shifts nothing (mod ids come after vanilla's), but a +running world's tables, caches and connections all hold the old numbering, so a registry change reopens the world: + +1. Save and leave the world (as **Save and Quit**). +2. Build the new `Resources` on a worker (`load_resources` from the new root), as the game does at start. +3. Swap `Game.res` and reload the client's resources. +4. Reopen the same world, at the same place. + +The cost is a world's load, about what the player waits for when opening it from the title screen. Two things keep +this step out of the first PRs: + +- **Process-wide tables are set once.** Mob effects (`potions::MOD_EFFECTS`), thrown entity types + (`projectiles::THROWN_TYPES`), mod menu types, living entity types and native component types + (`horizon_protocol::item::custom::install`) are `OnceLock`s, filled at start and read without a lock on hot + paths. They become `ArcSwap`-style tables replaced between worlds, never while one is open. +- **A mod turned off whose blocks are in the world.** Opening a world without a mod already drops its effects and + keeps its entities frozen ([mods.md](../mods.md)), and its blocks get what opening the world without it does today. The world reload asks + first, with the count of the mod's blocks and items it found in the loaded chunks, and makes a backup (the world's + `backups/` copy) before it reopens without them. + +Until then, a registry change keeps today's rule: the row says "Restart the game to apply", and the game asks +whether to restart now. A restart from the game is the launcher starting it again with `--world `, so the +player lands back in the world. + +### What "a few seconds" means here, measured + +Measured on the desktop (i7-13700KF, NVMe, `profile.fast`) with the fixture mod of the hot reload PR. The numbers are +filled in by that PR and repeated here. + +| Step | Time | +|---|---| +| Build a content root for one small mod (classification included) | see PR 2 | +| Client resource reload with that root | see PR 2 | +| Server data reload (`reload_world_tables`) | see PR 2 | +| Turn the mod off again | see PR 2 | + +## The in-game mod browser + +Options > Mods gets a **Browse mods** button beside **Open mod folder**. It opens Horizon's own menu pages, drawn +with the options screen's widgets (`options_ui`), every string through `Text::tr` keys in +`assets/horizon/lang/*.json`. + +- **The index.** The same file the launcher reads (`horizon-mods` `index.json`, `HORIZON_MOD_INDEX` for a test + registry), fetched on a worker with the game's `ureq`, at most once every ten minutes, kept in + `mod-browser/horizon-index.json` beside `options.txt`. The launcher and the game share that copy, so an offline + game still lists what the launcher saw last. Parsed with `horizon_mods::index::Index::parse`, which refuses an + index with a bad entry as the launcher does. +- **The list.** A search box and the projects this game can install, sorted by downloads: name, author, summary, + its categories, and **Installed**, **Update** or nothing. A search matches the name, id, summary, tags and + categories. +- **A project's page.** Name, authors, licence, version and date, categories and tags, the summary and description + (the index's markdown read as plain text: headings, lists and links shown as text), its changelog, and buttons: + **Install**, **Update**, **Turn on** or **Turn off**, **Remove**, and **Open page** (the site's page in the + browser). Screenshots come in a second step: the registry mirrors them as PNG and JPEG under its own Pages + ([registry-v2.md](registry-v2.md)); the page will draw them through the same dynamic-texture path as player skins + (`skin_slots`), one at a time, loaded when the page opens. +- **Install.** As the launcher's `install_version`: the newest version for this game that was not taken down, into + `mod-browser/downloads/`, then `horizon_mods::index::check_file` (size, SHA-256, the author's signature, the + file's manifest against the index), then moved to `horizon-mods/.hmod`, turned on in `horizon-mods.txt`, and + recorded in `mod-browser.json` with the launcher's own fields, so the launcher lists it, updates it and removes + it. A file the index took down (`revoked.toml`) is refused. A `.hmod` with code (`needs_port`) is not offered. +- **Kinds.** Content mods first. Resource packs, shader packs and data packs follow with the same page; each installs + where the launcher puts it. +- **Native mods.** Listed with the launcher's warning, word for word: they are native code with the player's rights, + not sandboxed, and the red **Install** button asks first, as the launcher's trust notice does. The game can't + compile, so it installs the `.hnmod` into `horizon-native-mods/`, and the launcher builds it before the next play + (#9901). The registry review stays the gate for every kind. + +## Native mods: the fast path + +A native mod's code is in the game's binary. What can be made fast: + +| Option | A mod change takes | On and off | Unload | Risk | +|---|---|---|---|---| +| **Today**: the launcher rebuilds the game (`profile.fast`, incremental, warm `native/target`) | see measurements | off at once (if the mod checks `native_on`), on: rebuild | none | none | +| **A. Every installed mod built in, on and off at run time** | rebuild only to install, update or remove | **at once**, for trait mods by dispatch | the mod's hooks stop; its state stays until restart | low: one check per dispatched mod | +| **B. Background build while playing, then a quick swap** | build while playing, then a restart into the same world | as A | restart | low; a second process and a hand-over | +| **C. Rust `dylib`s** (#7386 stages 3 and 4, arismoko's #797) | that mod only, under 30 s (the spike's pass mark) | restart | **never**: a Rust `dylib` can't be unloaded soundly | medium: exact-build pinning, Windows export limit | +| **D. A C ABI or `abi_stable` plugin layer over `native-hooks.json`** | that mod only, seconds | at run time | possible, with care | high: a second API to keep beside `mod_api`, and still not sandboxed | +| **E. wasm mods** (arismoko's fork: `horizon-mod-wasm`, wasmtime, one API with native) | that mod only, seconds | at run time | **yes**: drop the instance | medium: performance of the hooks it covers, API coverage | + +### What each costs, and the choice + +- **Unloading native code.** Rust code loaded with `dlopen` can't be unloaded soundly: a `'static` reference, a + thread it spawned, a thread-local destructor or a `Box` it handed the game outlives `dlclose`, and the + next call jumps into unmapped memory. A sound "unload" of native code is "stop calling it, keep it mapped, free it + at exit". Only a sandbox frees a mod's code and state at once: wasm's instance is dropped with its memory, and the + game never holds a pointer into it because every call goes through the host's table. +- **D is not worth a second API.** A stable C ABI over the hook catalog duplicates what arismoko's fork already + generates for wasm from the same catalog (the `mod_api` crate builds for `wasm32-unknown-unknown`, and its + interface is generated at build time). It would give native speed and run-time loading, but no sandbox and no + sound unload. We don't build it. +- **C and E are arismoko's work, and we build on it.** The fork has the `dylib` loader (#797), its acceptance harness + (#798) and the wasm runtime. They merge upstream through their own PRs; Horizon's side is to keep the hook catalog + and `Hooks` dispatch shaped so both can sit behind it. +- **A is the biggest win for the least risk, now.** Turning a native mod on or off is the common case (trying a mod, + finding a conflict, a modpack switch), and it needs no compiler at all if every installed native mod is already in + the build. The launcher builds every installed mod, whatever its `mod.` line says. The game skips a turned-off + trait mod in `Hooks` dispatch (`mod_api`), so it stops at once without its author doing anything, and mixin mods + keep honouring `native_on` (they paste code into the game's own functions, so only they can check). A mixin mod + that doesn't check is marked on its row: "This mod keeps running until the launcher's next build leaves it out". +- **B comes next.** With A in, a build is needed only to install, update or remove. The launcher already rebuilds + before play (#9901); B lets the player keep playing during it, and then restarts into the same world. + +### Measured + +On the desktop (i7-13700KF, `-j 8`, Linux, `rustc` 1.99, the launcher's `profile.fast`), building +`horizon-client` as the launcher does: + +| Build | Time | +|---|---| +| Cold, empty target | see PR 3 | +| No change | see PR 3 | +| One line changed in gameplay (`game/mods.rs`), as a mixin into gameplay does | see PR 3 | +| One line changed in `horizon-world`, as a mixin into a lower crate does | see PR 3 | +| The gameplay change, linked with mold | see PR 3 | + +## Security + +Nothing in this note widens what a mod can do. + +- Content mods have no code. The game checks every downloaded file against the index exactly as the launcher does, + and keeps the content fence ([mods.md](../mods.md#namespaces-and-limits)). +- Native mods stay full-trust native code. The in-game browser shows the launcher's trust notice and red warning + before installing one, and installing never builds or runs it: the launcher does, behind its own warning. +- The registry review stays the gate for listing a project, `revoked.toml` turns off taken-down files in the game + as in the launcher, and SHA-256 pinning and signatures are checked before a file reaches `horizon-mods/`. +- The browser fetches only the index, the registry's release assets (`horizon_mods::index::FILE_HOST`) and, later, + the registry's own mirrored images. It never follows a project's own links. + +## Plan, small PRs + +1. This note. +2. The in-game browser and install, for content mods. +3. Live switching of content mods: a root per mod set, classification, the client and server reloads, tests that + load, unload and load again a fixture mod and check the recipes, tags and lang it adds. Measured on the desktop. +4. Native step A: every installed native mod built in, on and off at run time through `Hooks` dispatch. Measured on + the desktop. + +Later: the world reload for registry changes (replaceable process-wide tables), screenshots on the project page, +the other kinds in the browser, and B. + +## From the contributors' plans + +What this note takes from them: + +- From arismoko's fork ("deslopping the mod API"): the `dylib` loader (#797, #798) and the wasm runtime are the + run-time loading paths; mod-defined data pack registries reloaded on `/reload` (their #811) fit the live data + reload above as they are; one dependency model for native and wasm. +- From #7386: no unload for Rust `dylib`s, the spike's pass mark (under 30 s for a one-line mod change), and the + static build kept for a settled mod set. +- From levzzz5154's horizon-mixins: mixins stay for what has no hook; option A keeps every mixin mod working, + turned off only through `native_on`. +- From Vaspyyy's #9866: the browser says before install whether a native mod builds on the player's Horizon; the + in-game list shows the same verdict. + +What we'd ask them: + +- **arismoko:** Should option A's dispatch gate (skip a turned-off mod's `Hooks`) go in the generated dispatch + upstream now, or does your fork's dispatch already have a per-mod switch we should take instead? When the wasm + runtime lands upstream, can a wasm mod's instance be dropped while a world is open, so its switch is live too? +- **levzzz5154:** Could horizon-mixins wrap each mixin's injected code in a generated `native_on()` check, + so a mixin mod turns off at run time without its author writing the check? +- **Both:** Is a content root per mod set (`mod-content//`) in the way of anything you load from it? From 408044e9a9a1f74932fdc7c49025e31bf24c54ad Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 16:36:58 +0100 Subject: [PATCH 10/30] Mods: content mods of assets and data switch on and off without a restart A content root is built per set of mods (.horizon-cache/mod-content/), so a running game keeps reading the root it was given while a switch builds another; roots unused for a day are removed. Turning a mod on or off in Options > Mods, or installing one from Browse Mods, applies at once when the mod has only assets and data: the client reloads its resources, then the server reloads its data packs as /reload does and resyncs its players. A mod that adds blocks, items, mob effects, entity or menu types or changes world generation keeps loading at start, and the Mods page says to restart. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client-render/src/options_ui.rs | 6 + .../assets/horizon/lang/en_us.json | 5 +- .../assets/horizon/lang/fr_fr.json | 5 +- crates/horizon-client/src/game/integrated.rs | 5 + crates/horizon-client/src/game/mod_browser.rs | 1 + crates/horizon-client/src/game/mods.rs | 105 ++++++++++ crates/horizon-client/src/game/options.rs | 1 + crates/horizon-client/src/game/packs.rs | 3 +- crates/horizon-client/src/mod_content.rs | 87 +++++++- .../horizon-client/src/mod_content_tests.rs | 189 +++++++++++++++++- docs/mods.md | 10 +- 11 files changed, 406 insertions(+), 11 deletions(-) diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 1802dba389..9d4c370eff 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -1622,6 +1622,8 @@ pub struct OptionsUi { pub mod_browser: crate::mod_browser::ModBrowser, /// The project whose page is open (`Page::ModProject`). pub mod_project: String, + /// The Mods page's line about switches that wait for a restart. + pub mods_note: Option, /// Horizon's `packBrowser` extra: the pack screen's Add button. pub pack_browser: bool, /// Packs: the packs found and the selection the screen opens with. @@ -1709,6 +1711,7 @@ impl Default for OptionsUi { settings_search, mod_browser: Default::default(), mod_project: String::new(), + mods_note: None, pack_browser: false, pack_infos: Vec::new(), pack_selection: Vec::new(), @@ -2991,6 +2994,9 @@ impl OptionsUi { Act::Push(Page::ModBrowser), ); let mut rows = vec![Row::Buttons(browse, None)]; + if let Some(note) = &self.mods_note { + rows.extend(wrap(note, 64).into_iter().map(Row::Note)); + } if s.mods.installed.is_empty() { rows.push(Row::Header("horizon.mods.empty")); return rows; diff --git a/crates/horizon-client/assets/horizon/lang/en_us.json b/crates/horizon-client/assets/horizon/lang/en_us.json index 663b8aee3c..bb33b429fb 100644 --- a/crates/horizon-client/assets/horizon/lang/en_us.json +++ b/crates/horizon-client/assets/horizon/lang/en_us.json @@ -110,8 +110,11 @@ "horizon.mods.browse.open_page": "Open Page", "horizon.mods.browse.enabled": "Mod", "horizon.mods.browse.changelog": "Changelog", - "horizon.mods.browse.done": "%s %s is installed and on. Its content loads when the game starts again.", + "horizon.mods.browse.done": "%s %s is installed and on.", "horizon.mods.browse.install_failed": "Couldn't install it: %s", + "horizon.mods.switch.worldgen": "changes world generation", + "horizon.mods.switch.registries": "adds blocks, items or other registry entries", + "horizon.mods.switch.restart": "Restart the game to apply %s: they add blocks, items or world generation.", "horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder", "horizon.shader_packs.kind_horizon": " (Horizon)", "horizon.shader_options.open": "Shader Options...", diff --git a/crates/horizon-client/assets/horizon/lang/fr_fr.json b/crates/horizon-client/assets/horizon/lang/fr_fr.json index 5f1ffddb4a..656585d739 100644 --- a/crates/horizon-client/assets/horizon/lang/fr_fr.json +++ b/crates/horizon-client/assets/horizon/lang/fr_fr.json @@ -62,8 +62,11 @@ "horizon.mods.browse.open_page": "Ouvrir la page", "horizon.mods.browse.enabled": "Mod", "horizon.mods.browse.changelog": "Nouveautés", - "horizon.mods.browse.done": "%s %s est installé et activé. Son contenu se charge au prochain démarrage du jeu.", + "horizon.mods.browse.done": "%s %s est installé et activé.", "horizon.mods.browse.install_failed": "Installation impossible : %s", + "horizon.mods.switch.worldgen": "change la génération du monde", + "horizon.mods.switch.registries": "ajoute des blocs, des objets ou d'autres entrées de registre", + "horizon.mods.switch.restart": "Redémarre le jeu pour appliquer %s : ils ajoutent des blocs, des objets ou de la génération de monde.", "horizon.shader_packs.empty": "Aucun pack de shaders : placez-les dans le dossier shaderpacks", "horizon.shader_options.open": "Options des shaders...", "horizon.shader_options.profile": "Profil", diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index 1952572ac0..1bc447a131 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -223,6 +223,11 @@ impl Integrated { Err(RecvTimeoutError::Disconnected) => None, } } + /// `execute`: run `f` on the server thread between two ticks, without + /// waiting for it. False once the server has stopped. + pub fn post(&self, f: impl FnOnce(&mut Game<'_>) + Send + 'static) -> bool { + self.tasks.send(Box::new(f)).is_ok() + } /// `IntegratedServer.halt(true)`: returns once the world is saved and let go. pub fn stop(mut self) -> Result<()> { self.halt() diff --git a/crates/horizon-client/src/game/mod_browser.rs b/crates/horizon-client/src/game/mod_browser.rs index 8713c5e17e..565af84960 100644 --- a/crates/horizon-client/src/game/mod_browser.rs +++ b/crates/horizon-client/src/game/mod_browser.rs @@ -153,6 +153,7 @@ impl Game<'_> { if installed { self.settings.save(&Self::options_path()); self.start_mods(); + self.switch_mods(); } } } diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 6544323cda..0abded0758 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -3,6 +3,7 @@ //! (`native_hooks.rs`); WebAssembly code mods don't run any more. use super::Game; use horizon_client::{ + lang, mod_list::{self, Installed}, text::Text, }; @@ -16,6 +17,12 @@ pub(super) struct ModsState { stopped: Vec, /// Browse mods' worker (`mod_browser`). pub(super) jobs: super::mod_browser::Jobs, + /// The content mods the loaded resources were built with: a switch the + /// game can apply live moves it to the player's choice (`switch_mods`). + pub(super) applied: Option>, + /// A live switch's resource reload is running: when it applied, the server + /// reloads its data from the new root. Its start, for the log. + pub(super) live: Option, } /// Freeze native component IDs before reading content or opening a connection. @@ -391,6 +398,9 @@ impl Game<'_> { } }) .collect(); + if self.mods.applied.is_none() { + self.mods.applied = Some(self.settings.mods.enabled.clone()); + } // A mod with a native version built in says so instead, and isn't news. self.list_native_mods(); let built = std::env::current_exe() @@ -421,6 +431,101 @@ impl Game<'_> { .map(|row| row.title.clone()) .collect(); } + /// The content mods the resources load with: the ones applied, which a + /// switch that needs a restart doesn't change. + pub(super) fn applied_mods(&self) -> std::collections::BTreeSet { + self.mods + .applied + .clone() + .unwrap_or_else(|| self.settings.mods.enabled.clone()) + } + /// The player turned content mods on or off (Options > Mods, or an + /// install from Browse Mods): those with only assets and data apply now + /// (docs/design/mod-hotload.md), through the resource reload and then the + /// server's data reload (`mods_reloaded`); those that add blocks, items or + /// worldgen wait for a restart, and the Mods page says so. + pub(super) fn switch_mods(&mut self) { + let Some(applied) = self.mods.applied.clone() else { + return; + }; + let wanted = self.settings.mods.enabled.clone(); + if applied == wanted { + return; + } + let packages: Vec = horizon_mods::find( + &Self::game_dir().join(mod_list::DIR), + ) + .iter() + .filter_map(|path| { + let package = horizon_mods::Package::read(path).ok()?; + let content = + horizon_mods::Content::read(path, &package.manifest).ok()?; + match horizon_client::mod_content::live_switch(&content) { + Ok(()) => None, + Err(why) => { + log::info!("Horizon: mod {} needs a restart to switch: {why}", package.manifest.id); + Some(package) + } + } + }) + .collect(); + let waits = |id: &String| packages.iter().any(|p| &p.manifest.id == id); + let mut next = applied.clone(); + let mut waiting = Vec::new(); + for id in applied.symmetric_difference(&wanted) { + if waits(id) { + waiting.push(id.clone()); + } else if wanted.contains(id) { + next.insert(id.clone()); + } else { + next.remove(id); + } + } + self.ui.options.mods_note = (!waiting.is_empty()).then(|| { + lang::tr("horizon.mods.switch.restart", &[&waiting.join(", ")]) + }); + if next == applied { + return; + } + log::info!("Horizon: content mods switched live: {:?} -> {:?}", applied, next); + self.mods.applied = Some(next); + self.mods.live = Some(std::time::Instant::now()); + self.reload_packs(); + } + /// A live switch's resources applied: the server reloads its data packs + /// from the new root, as `/reload` does (recipes, tags, loot tables, + /// advancements, functions), and sends them to its players. + pub(super) fn mods_reloaded(&mut self, applied: bool) { + let Some(started) = self.mods.live.take() else { + return; + }; + let resources_ms = started.elapsed().as_millis(); + if !applied { + return; + } + let root = self.options.resources.clone(); + let enabled = self.applied_mods(); + let reload = move |g: &mut Game<'_>| { + let at = std::time::Instant::now(); + g.options.resources = root; + g.settings.mods.enabled = enabled.clone(); + g.mods.applied = Some(enabled); + if g.world_root.as_os_str().is_empty() { + return; + } + g.reload_datapacks(true); + log::info!( + "Horizon: content mods switched live: server data reloaded in {} ms", + at.elapsed().as_millis() + ); + }; + log::info!("Horizon: content mods switched live: resources reloaded in {resources_ms} ms"); + if let Some(server) = &self.integrated { + server.post(reload); + } else if self.remote.is_none() { + reload(self); + } + } /// Once, in the first world after the update: which WebAssembly mods stopped. pub(super) fn tell_native_now(&mut self) { if self.mods.stopped.is_empty() || self.player.is_none() { diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index 4996f12b69..f2e33dd703 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -346,6 +346,7 @@ impl Game<'_> { Event::Changed => { self.settings.save(&Self::options_path()); self.apply_settings(); + self.switch_mods(); self.sync_agent_control(); self.ui.options.agent_status = self.agent_status_lines(); } diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index b231d31d1d..e6a3e7f370 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -279,7 +279,7 @@ impl Game<'_> { let (selected, server, mods, dev_mods) = ( self.settings.resource_packs.clone(), self.server_packs.active.clone(), - self.settings.mods.enabled.clone(), + self.applied_mods(), self.options.dev_mods.clone(), ); let (assets, res, context) = ( @@ -309,6 +309,7 @@ impl Game<'_> { staged.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string())); match kind { ReloadKind::Selection => { + self.mods_reloaded(result.is_ok()); if let Err(e) = result { self.selection_reload_failed(&e); } diff --git a/crates/horizon-client/src/mod_content.rs b/crates/horizon-client/src/mod_content.rs index 14ddafbd21..5d9890871c 100644 --- a/crates/horizon-client/src/mod_content.rs +++ b/crates/horizon-client/src/mod_content.rs @@ -2703,7 +2703,10 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result } /// The root to load: `base` itself with no content, else the mods' overlay -/// over it, built in `cache/mod-content`. +/// over it, built in `cache/mod-content/`: one folder per mod set, so +/// a running game keeps reading the root it was given while a switch builds +/// another (docs/design/mod-hotload.md). Roots of other sets unused for a day +/// are removed. /// /// The root is built once and kept: later starts with the same build, base /// and mod files reuse it. With ATM10's carried mods a build takes about @@ -2715,21 +2718,101 @@ pub fn resource_root(base: &Path, mods: &[ContentMod], cache: &Path) -> Result

fs::metadata(path.join(STAMP)) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .is_none_or(|age| age > keep), + // The old single root's files and folders. + false => old_layout, + }; + if !stale { + continue; + } + let removed = match path.is_dir() { + true => fs::remove_dir_all(&path), + false => fs::remove_file(&path), + }; + if let Err(e) = removed { + log::warn!("Horizon: an old mod content root stays: {}: {e}", path.display()); + } + } +} + +/// Whether turning a content mod on or off can apply in a running game +/// (docs/design/mod-hotload.md): its assets and data can, through the +/// resource reload and the server's data reload. What a world numbers or +/// generates from cannot yet: then the translation key of why. +pub fn live_switch(content: &Content) -> std::result::Result<(), &'static str> { + if !content.blocks.is_empty() + || !content.items.is_empty() + || !content.effects.is_empty() + || !content.entity_types.is_empty() + || !content.menu_types.is_empty() + || !content.creative_tabs.is_empty() + { + return Err("horizon.mods.switch.registries"); + } + let worldgen = |file: &&String| { + let mut parts = file.split('/'); + parts.next() == Some("data") + && parts.nth(1).is_some_and(|folder| { + matches!(folder, "worldgen" | "dimension" | "dimension_type") + || folder == "tags" && file.contains("/tags/worldgen/") + || matches!(folder, "neoforge" | "forge") + && file.contains("/biome_modifier/") + }) + }; + if content.files.iter().any(|f| worldgen(&f)) { + return Err("horizon.mods.switch.worldgen"); + } + Ok(()) +} + /// In a root built by [`build`]: its warnings, one per line. const WARNINGS: &str = "horizon-mod-content-warnings.txt"; diff --git a/crates/horizon-client/src/mod_content_tests.rs b/crates/horizon-client/src/mod_content_tests.rs index dee9905b55..9f6f775950 100644 --- a/crates/horizon-client/src/mod_content_tests.rs +++ b/crates/horizon-client/src/mod_content_tests.rs @@ -991,12 +991,19 @@ fn content_root_is_reused_until_a_mod_changes() { let file = fs::File::options().write(true).open(&lang).unwrap(); file.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(5)) .unwrap(); - resource_root(&base, std::slice::from_ref(&m), &cache).unwrap(); - assert!(!sentinel.exists(), "a changed mod kept the old root"); + let rebuilt = resource_root(&base, std::slice::from_ref(&m), &cache).unwrap(); + assert_ne!(rebuilt, root, "a changed mod reused the old root"); + assert!( + fs::read_to_string(rebuilt.join("assets/ruby/lang/en_us.json")) + .unwrap() + .contains("Ruby Block") + ); + // The root a running game was given is never changed under it. + assert!(sentinel.exists(), "a changed mod rebuilt the root in place"); assert!( fs::read_to_string(root.join("assets/ruby/lang/en_us.json")) .unwrap() - .contains("Ruby Block") + .contains("Block of Ruby") ); let _ = fs::remove_dir_all(&scratch); } @@ -4374,3 +4381,179 @@ fn a_symlink_file_is_copied_through() { assert_eq!(fs::read(&at).unwrap(), b"real"); let _ = fs::remove_dir_all(&scratch); } + +/// A content mod of recipes, a tag and a name, for vanilla's items: what +/// switches live (docs/design/mod-hotload.md). +fn glint_mod(dir: &Path) -> ContentMod { + folder_mod( + dir, + &[ + ("horizon-mod.toml", &manifest("glint", "")), + ( + "data/glint/recipe/diamond_from_dirt.json", + r#"{"type": "minecraft:crafting_shapeless", "category": "misc", + "ingredients": ["minecraft:dirt", "minecraft:dirt"], + "result": {"id": "minecraft:diamond"}}"#, + ), + ( + "data/c/tags/item/gems/glint.json", + r#"{"values": ["minecraft:diamond"]}"#, + ), + ("assets/glint/lang/en_us.json", r#"{"glint.hello": "Hello"}"#), + ], + ) +} + +#[test] +fn only_assets_and_data_switch_live() { + let scratch = std::env::temp_dir().join(format!("horizon-mod-live-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + assert_eq!(live_switch(&glint_mod(&scratch.join("glint")).content), Ok(())); + let ruby = ruby_mod(&scratch.join("ruby")); + assert_eq!( + live_switch(&ruby.content), + Err("horizon.mods.switch.registries") + ); + let effect = folder_mod( + &scratch.join("fx"), + &[ + ("horizon-mod.toml", &manifest("fx", "")), + ( + "data/fx/horizon/mob_effect/glow.json", + r#"{"category": "harmful", "color": 1}"#, + ), + ], + ); + assert_eq!( + live_switch(&effect.content), + Err("horizon.mods.switch.registries") + ); + for (id, file, text) in [ + ( + "ores", + "data/ores/neoforge/biome_modifier/add.json", + r#"{"type": "neoforge:none"}"#, + ), + ( + "village", + "data/minecraft/tags/worldgen/biome/has_structure/village_plains.json", + r#"{"values": []}"#, + ), + ( + "feat", + "data/feat/worldgen/placed_feature/rock.json", + r#"{"feature": "minecraft:ore_iron", "placement": []}"#, + ), + ] { + let m = folder_mod( + &scratch.join(id), + &[("horizon-mod.toml", &manifest(id, "")), (file, text)], + ); + assert_eq!( + live_switch(&m.content), + Err("horizon.mods.switch.worldgen"), + "{file}" + ); + } + fs::remove_dir_all(&scratch).unwrap(); +} + +/// On, off and on again: each root has what its set of mods has, a switch +/// never changes a root a game was given, and a set seen before reuses its +/// root without building it again. +#[test] +#[ignore = "full content root: CI and pr_ready run it"] +fn a_data_mod_loads_unloads_and_loads_again() { + let Some(base) = prepared() else { + return; + }; + let scratch = std::env::temp_dir().join(format!("horizon-mod-reload-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let cache = scratch.join("cache"); + let glint = glint_mod(&scratch.join("glint")); + let dirt = || Some(crate::items::ItemStack::new("minecraft:dirt", 1)); + let grid = [dirt(), dirt(), None, None]; + // What a world reads from a root: the item tags and the recipes. + let tables = |root: &Path| { + let assets = Assets::load(root).unwrap(); + let facts = BlockFacts::load(root, &assets.registry).unwrap(); + let items = Items::load(root, &assets.registry, &facts, &PackOverlay::NONE).unwrap(); + let recipes = crate::crafting::Recipes::load(root, &items, &PackOverlay::NONE).unwrap(); + let crafted = recipes.find(&grid, 2).map(|(_, out)| out.item.to_string()); + let tagged = items.item_tag_order.get("c:gems/glint").cloned(); + (crafted, tagged, root.join("assets/glint/lang/en_us.json").is_file()) + }; + let loaded = ( + Some("minecraft:diamond".to_owned()), + Some(vec!["minecraft:diamond".to_owned()]), + true, + ); + + let started = std::time::Instant::now(); + let on = resource_root(&base, std::slice::from_ref(&glint), &cache).unwrap(); + eprintln!("content root built in {} ms", started.elapsed().as_millis()); + assert_ne!(on, base); + assert_eq!(tables(&on), loaded); + + // Off: the base root, without any of it. + let off = resource_root(&base, &[], &cache).unwrap(); + assert_eq!(off, base); + assert_eq!(tables(&off), (None, None, false)); + assert!(on.join("assets/glint/lang/en_us.json").is_file()); + + // On again: the same root, not built again. + let sentinel = on.join("sentinel"); + fs::write(&sentinel, b"").unwrap(); + let started = std::time::Instant::now(); + let again = resource_root(&base, std::slice::from_ref(&glint), &cache).unwrap(); + eprintln!("content root reused in {} ms", started.elapsed().as_millis()); + assert_eq!(again, on); + assert!(sentinel.exists()); + assert_eq!(tables(&again), loaded); + + // Another set of mods builds its own root beside it and leaves this one as it was. + let ruby = ruby_mod(&scratch.join("ruby")); + let package = horizon_mods::Package::read(&scratch.join("glint")).unwrap(); + let glint_again = ContentMod { + id: "glint".into(), + content: Content::read(&scratch.join("glint"), &package.manifest).unwrap(), + }; + let both = resource_root(&base, &[glint_again, ruby], &cache).unwrap(); + assert_ne!(both, on); + assert!(sentinel.exists()); + assert_eq!(tables(&both).0, loaded.0); + fs::remove_dir_all(&scratch).unwrap(); +} + +#[test] +fn roots_unused_for_a_day_are_removed() { + let scratch = std::env::temp_dir().join(format!("horizon-mod-prune-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let roots = scratch.join("mod-content"); + let root = |name: &str, age_s: u64| { + let dir = roots.join(name); + fs::create_dir_all(&dir).unwrap(); + let stamp = dir.join(STAMP); + fs::write(&stamp, b"x").unwrap(); + let file = fs::File::options().write(true).open(&stamp).unwrap(); + file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age_s)) + .unwrap(); + dir + }; + let current = root("00000000000000aa", 3 * 86400); + let recent = root("00000000000000bb", 60); + let stale = root("00000000000000cc", 2 * 86400); + // Not a root's name: never touched while the folder has no root of the old layout. + let other = roots.join("notes"); + fs::create_dir_all(&other).unwrap(); + prune_roots(&roots, ¤t, KEEP_ROOTS); + assert!(current.exists() && recent.exists() && other.exists()); + assert!(!stale.exists()); + // An older game's single root, built straight in the folder, goes. + fs::write(roots.join(STAMP), b"old").unwrap(); + fs::create_dir_all(roots.join("assets/minecraft")).unwrap(); + prune_roots(&roots, ¤t, KEEP_ROOTS); + assert!(!roots.join(STAMP).exists() && !roots.join("assets").exists()); + assert!(current.exists() && recent.exists()); + fs::remove_dir_all(&scratch).unwrap(); +} diff --git a/docs/mods.md b/docs/mods.md index 48d7a4bc4b..d893b233f6 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -14,14 +14,18 @@ Mods are **off by default**. A fresh install has no `horizon-mods` folder and pl ## Playing with mods -1. Install a mod from the launcher's Mods page ([mod-browser.md](mod-browser.md)), or put its files by hand in the +1. Install a mod from the launcher's Mods page ([mod-browser.md](mod-browser.md)) or from the game (Options > Mods > + **Browse Mods**: Horizon's registry, checked as the launcher checks it), or put its files by hand in the game folder (the one holding `options.txt`): a content mod's `.hmod` (or a folder holding `horizon-mod.toml`) in `horizon-mods/`. Native mods go in `horizon-native-mods/`, and the launcher builds them ([native-mods.md](native-mods.md)). 2. Options > **Mods** lists them with what each may do ("May add blocks, items, mob effects and entity types to your worlds") and a switch. They start off. The choice is kept in `horizon-mods.txt` (`mod.:true`). -3. A content mod's blocks, items, mob effects and entity types load once, when the game starts: restart the game after - a switch. +3. A switch applies at once when the mod has only assets and data (textures, models, sounds, languages, recipes, tags, + loot tables, advancements, functions): the game reloads its resources, then the world's data as `/reload` does, + and the players of a world open to LAN get the new data. A mod that adds blocks, items, mob effects, entity or menu + types, or changes world generation, still loads only when the game starts: the Mods page says to restart + ([design/mod-hotload.md](design/mod-hotload.md)). 4. A mod that can't load shows why, in red, on its row. Two turned-on content mods never own the same namespace: the first by file name loads, and the other stays out with From bdfe6e4dd1b1da84ff32e20a918e261b2b44170f Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 17:39:33 +0100 Subject: [PATCH 11/30] Mods hot load: rustfmt and clippy fixes Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/mods.rs | 46 ++++++++++--------- crates/horizon-client/src/mod_content.rs | 13 ++++-- .../horizon-client/src/mod_content_tests.rs | 25 +++++++--- 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 0abded0758..c20f58a74e 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -452,23 +452,24 @@ impl Game<'_> { if applied == wanted { return; } - let packages: Vec = horizon_mods::find( - &Self::game_dir().join(mod_list::DIR), - ) - .iter() - .filter_map(|path| { - let package = horizon_mods::Package::read(path).ok()?; - let content = - horizon_mods::Content::read(path, &package.manifest).ok()?; - match horizon_client::mod_content::live_switch(&content) { - Ok(()) => None, - Err(why) => { - log::info!("Horizon: mod {} needs a restart to switch: {why}", package.manifest.id); - Some(package) - } - } - }) - .collect(); + let packages: Vec = + horizon_mods::find(&Self::game_dir().join(mod_list::DIR)) + .iter() + .filter_map(|path| { + let package = horizon_mods::Package::read(path).ok()?; + let content = horizon_mods::Content::read(path, &package.manifest).ok()?; + match horizon_client::mod_content::live_switch(&content) { + Ok(()) => None, + Err(why) => { + log::info!( + "Horizon: mod {} needs a restart to switch: {why}", + package.manifest.id + ); + Some(package) + } + } + }) + .collect(); let waits = |id: &String| packages.iter().any(|p| &p.manifest.id == id); let mut next = applied.clone(); let mut waiting = Vec::new(); @@ -481,13 +482,16 @@ impl Game<'_> { next.remove(id); } } - self.ui.options.mods_note = (!waiting.is_empty()).then(|| { - lang::tr("horizon.mods.switch.restart", &[&waiting.join(", ")]) - }); + self.ui.options.mods_note = (!waiting.is_empty()) + .then(|| lang::tr("horizon.mods.switch.restart", &[&waiting.join(", ")])); if next == applied { return; } - log::info!("Horizon: content mods switched live: {:?} -> {:?}", applied, next); + log::info!( + "Horizon: content mods switched live: {:?} -> {:?}", + applied, + next + ); self.mods.applied = Some(next); self.mods.live = Some(std::time::Instant::now()); self.reload_packs(); diff --git a/crates/horizon-client/src/mod_content.rs b/crates/horizon-client/src/mod_content.rs index 5d9890871c..b68f8cee62 100644 --- a/crates/horizon-client/src/mod_content.rs +++ b/crates/horizon-client/src/mod_content.rs @@ -2759,7 +2759,10 @@ fn prune_roots(roots: &Path, current: &Path, keep: std::time::Duration) { } let name = entry.file_name(); let is_root = name.len() == ROOT_NAME - && name.to_string_lossy().bytes().all(|b| b.is_ascii_hexdigit()) + && name + .to_string_lossy() + .bytes() + .all(|b| b.is_ascii_hexdigit()) && path.is_dir(); let stale = match is_root { true => fs::metadata(path.join(STAMP)) @@ -2778,7 +2781,10 @@ fn prune_roots(roots: &Path, current: &Path, keep: std::time::Duration) { false => fs::remove_file(&path), }; if let Err(e) = removed { - log::warn!("Horizon: an old mod content root stays: {}: {e}", path.display()); + log::warn!( + "Horizon: an old mod content root stays: {}: {e}", + path.display() + ); } } } @@ -2803,8 +2809,7 @@ pub fn live_switch(content: &Content) -> std::result::Result<(), &'static str> { && parts.nth(1).is_some_and(|folder| { matches!(folder, "worldgen" | "dimension" | "dimension_type") || folder == "tags" && file.contains("/tags/worldgen/") - || matches!(folder, "neoforge" | "forge") - && file.contains("/biome_modifier/") + || matches!(folder, "neoforge" | "forge") && file.contains("/biome_modifier/") }) }; if content.files.iter().any(|f| worldgen(&f)) { diff --git a/crates/horizon-client/src/mod_content_tests.rs b/crates/horizon-client/src/mod_content_tests.rs index 9f6f775950..730c8fff46 100644 --- a/crates/horizon-client/src/mod_content_tests.rs +++ b/crates/horizon-client/src/mod_content_tests.rs @@ -4399,7 +4399,10 @@ fn glint_mod(dir: &Path) -> ContentMod { "data/c/tags/item/gems/glint.json", r#"{"values": ["minecraft:diamond"]}"#, ), - ("assets/glint/lang/en_us.json", r#"{"glint.hello": "Hello"}"#), + ( + "assets/glint/lang/en_us.json", + r#"{"glint.hello": "Hello"}"#, + ), ], ) } @@ -4408,7 +4411,10 @@ fn glint_mod(dir: &Path) -> ContentMod { fn only_assets_and_data_switch_live() { let scratch = std::env::temp_dir().join(format!("horizon-mod-live-{}", std::process::id())); let _ = fs::remove_dir_all(&scratch); - assert_eq!(live_switch(&glint_mod(&scratch.join("glint")).content), Ok(())); + assert_eq!( + live_switch(&glint_mod(&scratch.join("glint")).content), + Ok(()) + ); let ruby = ruby_mod(&scratch.join("ruby")); assert_eq!( live_switch(&ruby.content), @@ -4476,12 +4482,16 @@ fn a_data_mod_loads_unloads_and_loads_again() { // What a world reads from a root: the item tags and the recipes. let tables = |root: &Path| { let assets = Assets::load(root).unwrap(); - let facts = BlockFacts::load(root, &assets.registry).unwrap(); - let items = Items::load(root, &assets.registry, &facts, &PackOverlay::NONE).unwrap(); + let facts = BlockFacts::load(root, assets.registry).unwrap(); + let items = Items::load(root, assets.registry, &facts, &PackOverlay::NONE).unwrap(); let recipes = crate::crafting::Recipes::load(root, &items, &PackOverlay::NONE).unwrap(); let crafted = recipes.find(&grid, 2).map(|(_, out)| out.item.to_string()); let tagged = items.item_tag_order.get("c:gems/glint").cloned(); - (crafted, tagged, root.join("assets/glint/lang/en_us.json").is_file()) + ( + crafted, + tagged, + root.join("assets/glint/lang/en_us.json").is_file(), + ) }; let loaded = ( Some("minecraft:diamond".to_owned()), @@ -4506,7 +4516,10 @@ fn a_data_mod_loads_unloads_and_loads_again() { fs::write(&sentinel, b"").unwrap(); let started = std::time::Instant::now(); let again = resource_root(&base, std::slice::from_ref(&glint), &cache).unwrap(); - eprintln!("content root reused in {} ms", started.elapsed().as_millis()); + eprintln!( + "content root reused in {} ms", + started.elapsed().as_millis() + ); assert_eq!(again, on); assert!(sentinel.exists()); assert_eq!(tables(&again), loaded); From aa95310c724d356ccf4d3b87e7ab6743b8404c73 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 17:50:21 +0100 Subject: [PATCH 12/30] Mods: in a world, content mods of assets and data stay switchable The Mods page froze every content mod's toggle while a world was open. Those with only assets and data now reload live, so their toggles stay active in a world this game serves; the others keep the lock. Each mod is classified once when the mods are listed, and a native mod's toggle no longer triggers a resource reload. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client-render/src/options_ui.rs | 37 ++++++--- crates/horizon-client/src/game/mods.rs | 75 +++++++++++++------ crates/horizon-client/src/game/options.rs | 2 + 3 files changed, 80 insertions(+), 34 deletions(-) diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 803af87b73..b96a22846c 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -1624,6 +1624,9 @@ pub struct OptionsUi { pub mod_project: String, /// The Mods page's line about switches that wait for a restart. pub mods_note: Option, + /// The content mods that switch with a world open: only assets and data, + /// in a world this game serves (docs/design/mod-hotload.md). + pub mods_live: std::collections::BTreeSet, /// Horizon's `packBrowser` extra: the pack screen's Add button. pub pack_browser: bool, /// Packs: the packs found and the selection the screen opens with. @@ -1712,6 +1715,7 @@ impl Default for OptionsUi { mod_browser: Default::default(), mod_project: String::new(), mods_note: None, + mods_live: Default::default(), pack_browser: false, pack_infos: Vec::new(), pack_selection: Vec::new(), @@ -2544,6 +2548,14 @@ impl OptionsUi { } /// A registry project's page: what it is, Install or Update, its switch /// once installed, its description and changelog. + /// A content mod's set feeds the block-state tables the open world's + /// chunks were decoded with: changing it mid-world remaps their ids + /// (Forge/Fabric freeze the registry before a world opens). One with only + /// assets and data reloads live, and a native mod toggles live through + /// `native_on`, so they stay available. + fn mod_frozen(&self, installed: &crate::mod_list::Installed) -> bool { + self.in_world && installed.native.is_none() && !self.mods_live.contains(&installed.id) + } fn mod_project_rows(&self, s: &Settings) -> Vec { let b = &self.mod_browser; let Some(l) = b.listing(&self.mod_project) else { @@ -2615,6 +2627,10 @@ impl OptionsUi { Text::literal(format!("{}: ", lang::get("horizon.mods.browse.enabled"))) .append(on_off(s.mods.is_on(i))); let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i)); + let toggle = match self.mod_frozen(&s.mods.installed[i]) { + true => inactive(toggle), + false => toggle, + }; rows.push(Row::Buttons(install, Some(toggle))); rows.push(Row::Buttons(page, None)); } @@ -3041,16 +3057,11 @@ impl OptionsUi { } else if installed.dev { label = label.append(tr("horizon.mods.dev")); } - // A content mod's set feeds the block-state tables the - // open world's chunks were decoded with: changing it - // mid-world remaps their ids (Forge/Fabric freeze the - // registry before a world opens). A native mod toggles - // live through `native_on`, so it stays available. - let content = installed.native.is_none(); - if self.in_world && content { + let frozen = self.mod_frozen(installed); + if frozen { label = label.append(tr("horizon.mods.in_world")); } - let dead = installed.dev || installed.id.is_empty() || self.in_world && content; + let dead = installed.dev || installed.id.is_empty() || frozen; // Only native mods with actual settings need a second button. if installed .native @@ -7756,9 +7767,10 @@ mod tests { /// A content mod's set builds the block-state tables an open world's /// chunks were decoded with, so its toggle freezes in-world; a native - /// mod gates live through `native_on` and stays available. + /// mod gates live through `native_on` and one of assets and data reloads + /// live, so they stay available. #[test] - fn in_a_world_only_native_mod_toggles_stay_active() { + fn in_a_world_only_live_mod_toggles_stay_active() { let mut s = Settings::default(); let installed = |id: &str, native: Option| crate::mod_list::Installed { @@ -7792,6 +7804,11 @@ mod tests { assert_eq!(toggles(&mut ui, &s), [(0, true), (1, true)]); ui.open_at(true, Page::Mods); assert_eq!(toggles(&mut ui, &s), [(0, false), (1, true)]); + // A content mod of assets and data only reloads live in a world this + // game serves (`mods_live`, filled by the game). + s.mods.installed.push(installed("data_only", None)); + ui.mods_live.insert("data_only".into()); + assert_eq!(toggles(&mut ui, &s), [(0, false), (1, true), (2, true)]); } /// Every setting a player reaches, from the Options screen through every button that opens diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index c20f58a74e..dd8b811d98 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -23,6 +23,10 @@ pub(super) struct ModsState { /// A live switch's resource reload is running: when it applied, the server /// reloads its data from the new root. Its start, for the log. pub(super) live: Option, + /// The content mods installed, and those of them with only assets and + /// data (`mod_content::live_switch`): they switch with a world open. + content_ids: std::collections::BTreeSet, + live_ids: std::collections::BTreeSet, } /// Freeze native component IDs before reading content or opening a connection. @@ -317,6 +321,26 @@ fn native_mods_detail(built: &str) -> Option { (!list.is_empty()).then_some(list) } +/// Notes a content mod's id, and whether its files can switch with a world +/// open (`mod_content::live_switch`). +fn live_switch_of( + path: &std::path::Path, + package: &horizon_mods::Package, + content_ids: &mut std::collections::BTreeSet, + live_ids: &mut std::collections::BTreeSet, +) { + let id = &package.manifest.id; + content_ids.insert(id.clone()); + let content = horizon_mods::Content::read(path, &package.manifest); + match content.as_ref().map(horizon_client::mod_content::live_switch) { + Ok(Ok(())) if package.wasm.is_none() => { + live_ids.insert(id.clone()); + } + Ok(Err(why)) => log::debug!("Horizon: mod {id} switches on a restart: {why}"), + _ => {} + } +} + /// A row for each native mod the launcher installed in `dir` (`.hnmod`) that this game wasn't /// built with (`built`: its [`BUILD_INFO`], empty for a release or Nightly) and that has no row /// yet. The page said "No mods" before, and players copied the files into `horizon-mods` (#4770). @@ -369,6 +393,7 @@ impl Game<'_> { let mut paths = horizon_mods::find(&dir); paths.retain(|p| !self.mods.dev.contains(p)); paths.extend(self.mods.dev.iter().cloned()); + let (mut content_ids, mut live_ids) = Default::default(); self.settings.mods.installed = paths .iter() .map(|path| { @@ -388,6 +413,7 @@ impl Game<'_> { }; let m = &package.manifest; let problem = package.wasm.is_some().then(|| STOPPED.to_owned()); + live_switch_of(path, &package, &mut content_ids, &mut live_ids); Installed { id: m.id.clone(), title: format!("{} {}", m.name, m.version), @@ -398,6 +424,8 @@ impl Game<'_> { } }) .collect(); + (self.mods.content_ids, self.mods.live_ids) = (content_ids, live_ids); + self.ui.options.mods_live = self.mods_live_now(); if self.mods.applied.is_none() { self.mods.applied = Some(self.settings.mods.enabled.clone()); } @@ -431,6 +459,15 @@ impl Game<'_> { .map(|row| row.title.clone()) .collect(); } + /// The content mods the Mods page lets the player switch with a world + /// open: in single player and on an open LAN world, those with only + /// assets and data; none on another server, which owns its data. + pub(super) fn mods_live_now(&self) -> std::collections::BTreeSet { + match self.integrated.is_some() || self.remote.is_none() { + true => self.mods.live_ids.clone(), + false => Default::default(), + } + } /// The content mods the resources load with: the ones applied, which a /// switch that needs a restart doesn't change. pub(super) fn applied_mods(&self) -> std::collections::BTreeSet { @@ -452,31 +489,19 @@ impl Game<'_> { if applied == wanted { return; } - let packages: Vec = - horizon_mods::find(&Self::game_dir().join(mod_list::DIR)) - .iter() - .filter_map(|path| { - let package = horizon_mods::Package::read(path).ok()?; - let content = horizon_mods::Content::read(path, &package.manifest).ok()?; - match horizon_client::mod_content::live_switch(&content) { - Ok(()) => None, - Err(why) => { - log::info!( - "Horizon: mod {} needs a restart to switch: {why}", - package.manifest.id - ); - Some(package) - } - } - }) - .collect(); - let waits = |id: &String| packages.iter().any(|p| &p.manifest.id == id); - let mut next = applied.clone(); + // Native mods' `mod.` lines follow the player's choice as they are + // (`sync_native_switches`); a content mod with blocks, items or + // worldgen keeps the state it loaded with until a restart. + let content = &self.mods.content_ids; + let mut next = wanted.clone(); let mut waiting = Vec::new(); for id in applied.symmetric_difference(&wanted) { - if waits(id) { - waiting.push(id.clone()); - } else if wanted.contains(id) { + if !content.contains(id) || self.mods.live_ids.contains(id) { + continue; + } + log::info!("Horizon: mod {id} needs a restart to switch"); + waiting.push(id.clone()); + if applied.contains(id) { next.insert(id.clone()); } else { next.remove(id); @@ -484,7 +509,9 @@ impl Game<'_> { } self.ui.options.mods_note = (!waiting.is_empty()) .then(|| lang::tr("horizon.mods.switch.restart", &[&waiting.join(", ")])); - if next == applied { + let changed = applied.symmetric_difference(&next).any(|id| content.contains(id)); + if !changed { + self.mods.applied = Some(next); return; } log::info!( diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index c4f1a0f015..e705eaa031 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -125,8 +125,10 @@ impl Game<'_> { self.options_from_title = from_title; self.options_to_game = false; let minecart_improvements = self.minecart_improvements(); + let mods_live = self.mods_live_now(); let ui = &mut self.ui.options; ui.open_at(!from_title && self.title.is_none(), page); + ui.mods_live = mods_live; ui.minecart_improvements = minecart_improvements; let root = self.options.resources.clone(); ui.languages = lang::available(&[&root]); From 56c896f51c76187262b05e2abc255fbb9df6bd59 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 17:51:12 +0100 Subject: [PATCH 13/30] docs/mods.md: which switches wait in a world Co-Authored-By: Claude Opus 5.5 --- docs/mods.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/mods.md b/docs/mods.md index d893b233f6..056f653e1a 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -24,8 +24,9 @@ Mods are **off by default**. A fresh install has no `horizon-mods` folder and pl 3. A switch applies at once when the mod has only assets and data (textures, models, sounds, languages, recipes, tags, loot tables, advancements, functions): the game reloads its resources, then the world's data as `/reload` does, and the players of a world open to LAN get the new data. A mod that adds blocks, items, mob effects, entity or menu - types, or changes world generation, still loads only when the game starts: the Mods page says to restart - ([design/mod-hotload.md](design/mod-hotload.md)). + types, or changes world generation, still loads only when the game starts: the Mods page says to restart, and its + switch waits outside a world. On another server's world every content mod's switch waits: the server owns its + data ([design/mod-hotload.md](design/mod-hotload.md)). 4. A mod that can't load shows why, in red, on its row. Two turned-on content mods never own the same namespace: the first by file name loads, and the other stays out with From 315d1e424dd01ccea8c09041faaaf70a61f5be29 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 18:06:51 +0100 Subject: [PATCH 14/30] Mods: a live switch's data reaches the server with no data pack The server built its world tables from the content root only when the world had data packs; without one it kept the tables loaded at startup, so a switched mod's recipes and tags never applied (seen in game: /recipe give still said Unknown recipe). After a live switch the tables are built from the new root. An install that waits for a restart says so on the project page. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 4 +++- crates/horizon-client/src/game/mod_browser.rs | 8 ++++++++ crates/horizon-client/src/game/mods.rs | 5 +++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index fae46c9bd5..d7cd853a84 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -20201,7 +20201,9 @@ impl Game<'_> { &self.motion.health }; let health = base.with_packs(root, &overlay, reload)?; - if files.is_empty() { + // With no pack, `res` holds the tables, unless content mods switched + // live since it was loaded: their data is in the new root. + if files.is_empty() && !self.mods.data_switched { return Ok((None, health)); } let mut items = Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay)?; diff --git a/crates/horizon-client/src/game/mod_browser.rs b/crates/horizon-client/src/game/mod_browser.rs index 565af84960..2b2203ee1b 100644 --- a/crates/horizon-client/src/game/mod_browser.rs +++ b/crates/horizon-client/src/game/mod_browser.rs @@ -154,6 +154,14 @@ impl Game<'_> { self.settings.save(&Self::options_path()); self.start_mods(); self.switch_mods(); + // A mod with blocks, items or worldgen waits for a restart: the + // project page says so instead of "installed and on". + if let (Some(note), Some((message, _))) = ( + &self.ui.options.mods_note, + &mut self.ui.options.mod_browser.message, + ) { + *message = format!("{message} {note}"); + } } } } diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index dd8b811d98..4d52da93d5 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -27,6 +27,10 @@ pub(super) struct ModsState { /// data (`mod_content::live_switch`): they switch with a world open. content_ids: std::collections::BTreeSet, live_ids: std::collections::BTreeSet, + /// The server's data comes from a root a live switch built, not the one + /// `res` was loaded from: the world tables are built from it even with no + /// data pack (`build_world_tables`). + pub(super) data_switched: bool, } /// Freeze native component IDs before reading content or opening a connection. @@ -541,6 +545,7 @@ impl Game<'_> { g.options.resources = root; g.settings.mods.enabled = enabled.clone(); g.mods.applied = Some(enabled); + g.mods.data_switched = true; if g.world_root.as_os_str().is_empty() { return; } From 5f69e3a789e062183d07f840fba7cf9b216f18dd Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 19:57:44 +0100 Subject: [PATCH 15/30] Mods: commands see the tags a live switch or a data pack added The command registries took item and block tags from the resources the game started with and were built once per world, so /execute and /give refused a tag a switched mod added (seen in game: Unknown item tag 'c:gems/glint'). They now read the world's tags and are built again after a live switch's data reload. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/chat.rs | 30 ++++++++++++-------------- crates/horizon-client/src/game/mods.rs | 2 ++ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index a15a209479..b0e5cbf369 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -595,6 +595,18 @@ impl Game<'_> { .iter() .map(|a| format!("minecraft:{a}")) .collect(); + // The world's tags: its data packs' and a live mod switch's too. + fn tag_ids<'a>(keys: impl Iterator) -> Vec { + let mut ids: Vec = keys + .map(|k| horizon_client::command::args::normalize(k)) + .collect(); + ids.sort(); + ids + } + let (item_tags, block_tags) = ( + tag_ids(self.items().item_tags.keys()), + tag_ids(self.items().block_tags.keys()), + ); let reg = &mut self.ui.registries; let mut items: Vec = self .res @@ -605,22 +617,8 @@ impl Game<'_> { .collect(); items.sort(); reg.items = items; - reg.item_tags = self - .res - .items() - .item_tags - .keys() - .map(|k| horizon_client::command::args::normalize(k)) - .collect(); - reg.item_tags.sort(); - reg.block_tags = self - .res - .items() - .block_tags - .keys() - .map(|k| horizon_client::command::args::normalize(k)) - .collect(); - reg.block_tags.sort(); + reg.item_tags = item_tags; + reg.block_tags = block_tags; reg.blocks = BlockProperties::clone(&block_properties(self.pack.assets.registry)); reg.summonable = summonable_entities(&ids.entity_types, self.res.mob_types().keys()); let mut types = ids.entity_types.clone(); diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 4d52da93d5..057dd70f82 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -550,6 +550,8 @@ impl Game<'_> { return; } g.reload_datapacks(true); + // Commands parse the new tags and ids. + g.build_registries(); log::info!( "Horizon: content mods switched live: server data reloaded in {} ms", at.elapsed().as_millis() From e6d6ce8df2a2298bc9c9c174706b72fb39a8f72f Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:14:15 +0100 Subject: [PATCH 16/30] Revert the command registries change: not shown to work in game Commands typed in the window parse tags against the window's own tags, which no server packet updates; that gap predates live switching and needs its own change. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/chat.rs | 30 ++++++++++++++------------ crates/horizon-client/src/game/mods.rs | 2 -- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index b0e5cbf369..a15a209479 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -595,18 +595,6 @@ impl Game<'_> { .iter() .map(|a| format!("minecraft:{a}")) .collect(); - // The world's tags: its data packs' and a live mod switch's too. - fn tag_ids<'a>(keys: impl Iterator) -> Vec { - let mut ids: Vec = keys - .map(|k| horizon_client::command::args::normalize(k)) - .collect(); - ids.sort(); - ids - } - let (item_tags, block_tags) = ( - tag_ids(self.items().item_tags.keys()), - tag_ids(self.items().block_tags.keys()), - ); let reg = &mut self.ui.registries; let mut items: Vec = self .res @@ -617,8 +605,22 @@ impl Game<'_> { .collect(); items.sort(); reg.items = items; - reg.item_tags = item_tags; - reg.block_tags = block_tags; + reg.item_tags = self + .res + .items() + .item_tags + .keys() + .map(|k| horizon_client::command::args::normalize(k)) + .collect(); + reg.item_tags.sort(); + reg.block_tags = self + .res + .items() + .block_tags + .keys() + .map(|k| horizon_client::command::args::normalize(k)) + .collect(); + reg.block_tags.sort(); reg.blocks = BlockProperties::clone(&block_properties(self.pack.assets.registry)); reg.summonable = summonable_entities(&ids.entity_types, self.res.mob_types().keys()); let mut types = ids.entity_types.clone(); diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 057dd70f82..4d52da93d5 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -550,8 +550,6 @@ impl Game<'_> { return; } g.reload_datapacks(true); - // Commands parse the new tags and ids. - g.build_registries(); log::info!( "Horizon: content mods switched live: server data reloaded in {} ms", at.elapsed().as_millis() From f15ca8d82a913442f1582b3059e6dcd9955b6961 Mon Sep 17 00:00:00 2001 From: foudretbr <86318354+foudretbr@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:12:28 +0100 Subject: [PATCH 17/30] Preparing ignores the player's global Java options A collector chosen in JAVA_TOOL_OPTIONS or _JAVA_OPTIONS stopped every JVM horizon-prepare starts, as they pass -XX:+UseSerialGC: "Multiple garbage collectors selected", so the version was never prepared. java::command, which they all go through, now removes those variables and JDK_JAVA_OPTIONS. Fixes #10067 --- crates/horizon-prepare/src/java.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/horizon-prepare/src/java.rs b/crates/horizon-prepare/src/java.rs index 1279cc6c4c..0b50245a6c 100644 --- a/crates/horizon-prepare/src/java.rs +++ b/crates/horizon-prepare/src/java.rs @@ -22,9 +22,18 @@ const TIMEOUT: Duration = Duration::from_secs(3600); /// 1.25 GB instead of 2 GB. pub(crate) const BATCH_OPTIONS: [&str; 2] = ["-XX:TieredStopAtLevel=1", "-XX:+UseSerialGC"]; -/// Exporters and JDK probes write to pipes or log files: on Windows they open no terminal. +/// The variables a JVM reads extra options from. A collector chosen there, such as a player's +/// global `-XX:+UseZGC`, stops a JVM started with [`BATCH_OPTIONS`]: "Multiple garbage collectors +/// selected" (#10067). +const JVM_OPTION_VARS: [&str; 3] = ["JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS"]; + +/// Exporters and JDK probes write to pipes or log files: on Windows they open no terminal. They +/// run with [`BATCH_OPTIONS`] alone, whatever options the player's environment sets for Java. pub(crate) fn command(program: impl AsRef) -> Command { - let command = Command::new(program); + let mut command = Command::new(program); + for var in JVM_OPTION_VARS { + command.env_remove(var); + } #[cfg(windows)] let command = { use std::os::windows::process::CommandExt; @@ -406,6 +415,19 @@ fn run(mut command: Command, log: &fs::File) -> Result<(), String> { mod tests { use super::*; + #[test] + fn jvms_ignore_the_players_global_options() { + let command = command("java"); + for var in JVM_OPTION_VARS { + assert!( + command + .get_envs() + .any(|(name, value)| name == var && value.is_none()), + "{var} reaches the JVM" + ); + } + } + #[test] fn verbatim_drive_paths_lose_their_prefix() { let jdk = r"C:\Program Files\Eclipse Adoptium\jdk-25\bin\javac.exe"; From 27c2e6457f1b01114bc37ef5d13d56935a5fc14f Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 21:26:56 +0100 Subject: [PATCH 18/30] Mods hot load: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/mods.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 4d52da93d5..57935515d1 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -336,7 +336,10 @@ fn live_switch_of( let id = &package.manifest.id; content_ids.insert(id.clone()); let content = horizon_mods::Content::read(path, &package.manifest); - match content.as_ref().map(horizon_client::mod_content::live_switch) { + match content + .as_ref() + .map(horizon_client::mod_content::live_switch) + { Ok(Ok(())) if package.wasm.is_none() => { live_ids.insert(id.clone()); } @@ -513,7 +516,9 @@ impl Game<'_> { } self.ui.options.mods_note = (!waiting.is_empty()) .then(|| lang::tr("horizon.mods.switch.restart", &[&waiting.join(", ")])); - let changed = applied.symmetric_difference(&next).any(|id| content.contains(id)); + let changed = applied + .symmetric_difference(&next) + .any(|id| content.contains(id)); if !changed { self.mods.applied = Some(next); return; From d55c2840a7884404324ed4bf4e5fdefb4bfa2277 Mon Sep 17 00:00:00 2001 From: foudretbr Date: Thu, 5 Nov 2026 22:34:43 +0100 Subject: [PATCH 19/30] Block states: map states past registry to air instead of panicking A server with mods or states the client does not have can send block states outside our registry. Instead of panicking with an out of bounds slice access when indexing BlockFacts or unpacking palettes, map unknown states to air. --- crates/horizon-client-render/src/chunk.rs | 35 ++++++++++++++++ crates/horizon-client/src/game/remote.rs | 5 ++- .../horizon-client/src/game/remote_chunks.rs | 22 ++++------ crates/horizon-client/tests/wide_states.rs | 40 ++++++++++++++++++- crates/horizon-items/src/blocks.rs | 8 +++- .../horizon-items/src/blocks/decode_tests.rs | 31 ++++++++++++++ 6 files changed, 122 insertions(+), 19 deletions(-) diff --git a/crates/horizon-client-render/src/chunk.rs b/crates/horizon-client-render/src/chunk.rs index 4abb0fa9a5..f5473263f6 100644 --- a/crates/horizon-client-render/src/chunk.rs +++ b/crates/horizon-client-render/src/chunk.rs @@ -225,6 +225,8 @@ pub const BIOME_STRATEGY: Strategy = Strategy::biomes(8); /// (what vanilla servers and clients send), more when content mods add states /// past 65,536 (docs/modpacks.md). Set once, before the first chunk. static BLOCK_BITS: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(16); +/// States in the registry, set with [`BLOCK_BITS`]. +static BLOCK_STATES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(u32::MAX); /// Block states are `PalettedContainer`s over the registry: vanilla's 16-bit /// global palette, wider with enough content mods. @@ -232,6 +234,35 @@ pub fn block_strategy() -> Strategy { Strategy::blocks(BLOCK_BITS.load(std::sync::atomic::Ordering::Relaxed)) } +/// Whether the registry has this state. Every id is known until the registry is loaded. +pub fn known_block_state(id: u32) -> bool { + id < BLOCK_STATES.load(std::sync::atomic::Ordering::Relaxed) +} + +/// A server's block container in our palette width. Ids past our registry (a +/// server or host with mods we lack) read as air: every per-state table is +/// indexed by them, and a palette's unused entries are scanned too. +pub fn known_blocks_only(blocks: PalettedContainer) -> PalettedContainer { + let ours = block_strategy(); + let unknown = |id: u32| !known_block_state(id) || u64::from(id) >> ours.global_bits != 0; + let any_unknown = if blocks.is_global() { + blocks.has(unknown) + } else { + blocks.maybe_has(unknown) + }; + if !any_unknown && blocks.strategy().global_bits == ours.global_bits { + return blocks; + } + let mut values = [0u32; 4096]; + blocks.unpack(&mut values); + for value in &mut values { + if unknown(*value) { + *value = 0; + } + } + PalettedContainer::from_values(ours, &values) +} + /// Frees `columns` (`count` of them) on a short-lived thread of its own /// when there are many: a teleport or a dimension change drops a few /// thousand columns at once, and freeing their sections took the frame @@ -250,6 +281,10 @@ pub fn free_columns(columns: T, count: usize) { pub fn set_block_states(states: usize) { let bits = horizon_core::paletted::ceil_log2(states).max(16); BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed); + BLOCK_STATES.store( + u32::try_from(states).unwrap_or(u32::MAX), + std::sync::atomic::Ordering::Relaxed, + ); forget_shared(); } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index e7165722ff..e954f9935e 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2599,7 +2599,10 @@ impl Game<'_> { } } fn set_block(&mut self, pos: [i32; 3], state: i32) { - let state = BlockStateId(state.max(0) as u32); + // A state we don't have (a server with mods we lack) reads as air, as in its chunks. + let state = state.max(0) as u32; + let known = horizon_client::chunk::known_block_state(state); + let state = BlockStateId(if known { state } else { 0 }); let previous = self.world.block(pos[0], pos[1], pos[2]); if self.world.loaded(pos[0], pos[2]) && self.world.set(pos[0], pos[1], pos[2], state).is_ok() diff --git a/crates/horizon-client/src/game/remote_chunks.rs b/crates/horizon-client/src/game/remote_chunks.rs index 49c02bc5a9..58f0f95663 100644 --- a/crates/horizon-client/src/game/remote_chunks.rs +++ b/crates/horizon-client/src/game/remote_chunks.rs @@ -4,7 +4,9 @@ //! order only inserts the result (`ClientPacketListener.handleLevelChunkWithLight`). use super::Game; use horizon_client::{ - chunk::{BIOME_STRATEGY, Chunk, ColumnLayout, Light, Section, block_strategy}, + chunk::{ + BIOME_STRATEGY, Chunk, ColumnLayout, Light, Section, block_strategy, known_blocks_only, + }, streaming::Streaming, }; use horizon_core::paletted::{PalettedContainer, Strategy}; @@ -85,20 +87,10 @@ impl ChunkFormat { }; let palette = s.states.palette.iter().map(|&p| p as u32).collect(); let words = s.states.data.iter().map(|&w| w as u64).collect(); - let mut blocks = PalettedContainer::from_wire(strategy, s.states.bits, palette, words) - .map_err(|e| e.to_string())?; - if strategy.global_bits != ours.global_bits { - let mut values = [0u32; 4096]; - blocks.unpack(&mut values); - // Ids past our registry (a host with mods we lack) read as air. - let limit = 1u64 << ours.global_bits; - for v in &mut values { - if u64::from(*v) >= limit { - *v = 0; - } - } - blocks = PalettedContainer::from_values(ours, &values); - } + let blocks = known_blocks_only( + PalettedContainer::from_wire(strategy, s.states.bits, palette, words) + .map_err(|e| e.to_string())?, + ); let mut section = Section { blocks, biomes: PalettedContainer::new(BIOME_STRATEGY, 0), diff --git a/crates/horizon-client/tests/wide_states.rs b/crates/horizon-client/tests/wide_states.rs index f65fa52ceb..2e24daa717 100644 --- a/crates/horizon-client/tests/wide_states.rs +++ b/crates/horizon-client/tests/wide_states.rs @@ -3,7 +3,10 @@ //! since the palette width is set once for the whole process. extern crate horizon_client_lib as horizon_client; -use horizon_client::chunk::{Chunk, Section, block_strategy, set_block_states}; +use horizon_client::chunk::{ + Chunk, Section, block_strategy, known_block_state, known_blocks_only, set_block_states, +}; +use horizon_core::paletted::{PalettedContainer, Strategy, storage_len}; #[test] fn sections_hold_states_past_sixteen_bits() { @@ -38,4 +41,39 @@ fn sections_hold_states_past_sixteen_bits() { section.set(17, 99_998); assert_eq!(section.get(17), 99_998); assert_eq!(section.get(18), values[18]); + + // What a server with mods we lack sends: ids past the registry read as air, + // though they fit the width. 100,000 states: 99,999 is the last one. + assert!(known_block_state(99_999)); + assert!(!known_block_state(100_000)); + let mut sent = [1u32; 4096]; + sent[0] = 100_000; + sent[1] = 131_071; + let known = known_blocks_only(PalettedContainer::from_values(block_strategy(), &sent)); + assert_eq!((known.get(0), known.get(1), known.get(2)), (0, 0, 1)); + // The same in a global palette, with more ids than a section's own holds. + let mut wide: Vec = (0..4096).map(|i| 1000 + i).collect(); + wide[5] = 120_000; + let known = known_blocks_only(PalettedContainer::from_values(block_strategy(), &wide)); + assert_eq!((known.get(5), known.get(6)), (0, 1006)); + // A palette entry no position uses doesn't stay either. + let words = vec![0; storage_len(4, 4096)]; + let container = + PalettedContainer::from_wire(block_strategy(), 4, vec![1, 100_000], words).unwrap(); + assert!(container.maybe_has(|id| id == 100_000)); + let known = known_blocks_only(container); + assert!(!known.maybe_has(|id| id == 100_000)); + assert_eq!(known.get(0), 1); + // A host wider than us: narrowed, and what doesn't fit is air. + let wider = Strategy::blocks(20); + let mut sent = [7u32; 4096]; + sent[3] = 900_000; + let known = known_blocks_only(PalettedContainer::from_values(wider, &sent)); + assert_eq!(known.strategy().global_bits, block_strategy().global_bits); + assert_eq!((known.get(3), known.get(4)), (0, 7)); + // Within vanilla's width too: with vanilla's registry, 40,000 is unknown. + set_block_states(35_723); + assert!(!known_block_state(35_723)); + let known = known_blocks_only(PalettedContainer::new(block_strategy(), 40_000)); + assert_eq!(known.get(0), 0); } diff --git a/crates/horizon-items/src/blocks.rs b/crates/horizon-items/src/blocks.rs index 097b5d76c4..53b1e7d177 100644 --- a/crates/horizon-items/src/blocks.rs +++ b/crates/horizon-items/src/blocks.rs @@ -512,9 +512,13 @@ impl BlockFacts { extra, )) } + /// A state the registry doesn't have (a server with mods we lack sent it) + /// has air's facts. #[inline] pub fn get(&self, id: BlockStateId) -> &StateFacts { - &self.states[id.0 as usize] + self.states + .get(id.0 as usize) + .unwrap_or_else(|| &self.states[self.air.0 as usize]) } /// `SignBlock.getWoodType`'s name for a sign block. pub fn wood_type(&self, block: &str) -> Option<&str> { @@ -522,7 +526,7 @@ impl BlockFacts { } pub fn is_air(&self, id: BlockStateId) -> bool { matches!( - &*self.states[id.0 as usize].name, + &*self.get(id).name, "minecraft:air" | "minecraft:cave_air" | "minecraft:void_air" ) } diff --git a/crates/horizon-items/src/blocks/decode_tests.rs b/crates/horizon-items/src/blocks/decode_tests.rs index c222e26bd0..a30cd6dc53 100644 --- a/crates/horizon-items/src/blocks/decode_tests.rs +++ b/crates/horizon-items/src/blocks/decode_tests.rs @@ -164,3 +164,34 @@ fn the_validation_error_still_fails_the_load_first() { } std::fs::remove_dir_all(&root).unwrap(); } + +#[test] +fn a_state_past_the_registry_has_airs_facts() { + let registry = horizon_world::registry::BlockRegistry::from_facts_json( + &br#"{"states":[["minecraft:air",true,false,"","minecraft:air"],["minecraft:stone",true,false,"","minecraft:stone"]],"defaults":{"minecraft:air":0,"minecraft:stone":1}}"#[..], + ) + .unwrap(); + let root = std::env::temp_dir().join(format!("horizon-facts-unknown-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("gameplay-facts.json"), + br#"{"states":[{"id":0},{"id":1}]}"#, + ) + .unwrap(); + let behaviours = serde_json::json!({ + "states": [[0, 0], [0, 0]], + "blocks": [ + {"name": "minecraft:air", "class": ["Block"]}, + {"name": "minecraft:stone", "class": ["Block"]}, + ], + }); + let facts = + super::super::BlockFacts::load_with_behaviour_json(&root, ®istry, &behaviours).unwrap(); + std::fs::remove_dir_all(&root).unwrap(); + + let id = horizon_world::registry::BlockStateId; + assert_eq!(&*facts.get(id(1)).name, "minecraft:stone"); + assert!(!facts.is_air(id(1))); + assert_eq!(&*facts.get(id(2)).name, "minecraft:air"); + assert!(facts.is_air(id(2))); +} From eabc4160a8501e5fe390d36261f6773d4aa99ac9 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 16:41:14 +0100 Subject: [PATCH 20/30] PvP & HUD: Combat feel Legacy draws the hand as 1.8.9 did Combat feel: Modern (default) or Legacy (1.8.9). Legacy changes only the picture: a flat item in first person takes 1.8.9's ItemRenderer pose (swing, eating and drinking, bow pull) through 26.3's own display transform, a sword is held up while use is held (1.8.9's block pose, swinging as 1.7.10 did), the main hand no longer dips after each hit, and the attack indicator hides unless the player keeps it. The clicks and every packet stay 26.3's. Poses 1.8.9 lacked (crossbow, trident, spear, brush) and resource packs' own displays stay modern. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/pvp.rs | 35 ++++ .../assets/horizon/lang/en_us.json | 7 + crates/horizon-client/src/first_person.rs | 191 +++++++++++++++++- crates/horizon-client/src/game.rs | 44 +++- crates/horizon-client/src/game/hand.rs | 3 +- 5 files changed, 277 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client-render/src/pvp.rs b/crates/horizon-client-render/src/pvp.rs index 22856a3929..b3564683d7 100644 --- a/crates/horizon-client-render/src/pvp.rs +++ b/crates/horizon-client-render/src/pvp.rs @@ -751,6 +751,16 @@ impl ChatOptions { } } +/// Combat feel (`docs/pvp.md`): how fights look. Legacy draws them as 1.8.9 did; what the +/// game sends to a server never changes, so a vanilla server and its anticheat see 26.3. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Combat { + /// Legacy (1.8.9) instead of Modern. + pub legacy: bool, + /// Keep the attack indicator under Legacy (1.8.9 had none). + pub indicator: bool, +} + #[derive(Clone, Debug, PartialEq)] pub struct Pvp { pub crosshair: Crosshair, @@ -763,6 +773,7 @@ pub struct Pvp { pub layout: crate::pvp_hud::Layout, /// The key that opens the HUD editor from the game (unbound at first). pub editor_key: String, + pub combat: Combat, } /// The HUD editor key's name for the controls (`Extras::set_key`). pub const HUD_EDITOR_KEY: &str = "horizon.hud_editor"; @@ -777,6 +788,7 @@ impl Default for Pvp { chat: ChatOptions::default(), layout: crate::pvp_hud::Layout::default(), editor_key: "key.keyboard.unknown".into(), + combat: Combat::default(), } } } @@ -877,6 +889,12 @@ impl Pvp { "chatCompact" => self.chat.compact = flag(value, self.chat.compact), "chatCopy" => self.chat.copy = flag(value, self.chat.copy), "chatSearch" => self.chat.search = flag(value, self.chat.search), + "combatFeel" => match value { + "legacy" => self.combat.legacy = true, + "modern" => self.combat.legacy = false, + _ => {} + }, + "combatIndicator" => self.combat.indicator = flag(value, self.combat.indicator), "viewTime" => { t.time = match value { "off" => None, @@ -1001,6 +1019,9 @@ impl Pvp { "chatTimestamps:{}\nchatCompact:{}\nchatCopy:{}\nchatSearch:{}\n", c.timestamps, c.compact, c.copy, c.search, ); + let c = &self.combat; + let feel = if c.legacy { "legacy" } else { "modern" }; + let _ = write!(out, "combatFeel:{feel}\ncombatIndicator:{}\n", c.indicator); } } @@ -1139,6 +1160,10 @@ opts! { "Clicking a message in the open chat copies its text, unless the click does something else."; ChatSearch, "chat_search", "Chat search", "Ctrl+F in the open chat: what you type shows only the messages holding it. Enter or Ctrl+F again stops."; + CombatFeel, "combat_feel", "Combat feel", + "Legacy draws fights as 1.8.9 did: the item in your hand, its swing, eating, the bow and a sword held up while you hold use, without the dip after each hit or the attack indicator. Only the look: what you send a server stays the same."; + CombatIndicator, "combat_indicator", "Attack indicator in Legacy", + "Keeps the attack indicator under Legacy, which 1.8.9 didn't have."; } /// The PvP & HUD page: headers (language keys) and options. pub fn rows() -> Vec> { @@ -1218,6 +1243,8 @@ pub fn rows() -> Vec> { rows.extend([ViewTime, ViewWeather].map(Ok)); rows.push(Err("horizon.pvp.header.chat")); rows.extend([ChatTimestamps, ChatCompact, ChatCopy, ChatSearch].map(Ok)); + rows.push(Err("horizon.pvp.header.combat")); + rows.extend([CombatFeel, CombatIndicator].map(Ok)); rows } impl Opt { @@ -1241,6 +1268,7 @@ impl Opt { FireLower | ShieldSize | TotemSize | GlintColour | ClearWater | ClearLava => None, ViewTime | ViewWeather => None, ChatTimestamps | ChatCompact | ChatCopy | ChatSearch => None, + CombatFeel | CombatIndicator => None, HudPingCorner => Some(HudPing), HudCoordsCorner => Some(HudCoords), HudServerCorner => Some(HudServer), @@ -1282,6 +1310,7 @@ impl Opt { Opt::ChatCompact => p.chat.compact, Opt::ChatCopy => p.chat.copy, Opt::ChatSearch => p.chat.search, + Opt::CombatIndicator => p.combat.indicator, _ => return None, }) } @@ -1333,6 +1362,10 @@ impl Opt { Some(colour) => colour.label(), None => crate::lang::get_or("Off", "Off"), }, + Opt::CombatFeel => match p.combat.legacy { + true => crate::lang::get_or("horizon.pvp.combat_feel.legacy", "Legacy (1.8.9)"), + false => crate::lang::get_or("horizon.pvp.combat_feel.modern", "Modern"), + }, Opt::ViewTime => p.tweaks.time.map_or_else(world_label, ViewTime::label), Opt::ViewWeather => p .tweaks @@ -1432,6 +1465,8 @@ impl Opt { Opt::ChatCompact => p.chat.compact = !p.chat.compact, Opt::ChatCopy => p.chat.copy = !p.chat.copy, Opt::ChatSearch => p.chat.search = !p.chat.search, + Opt::CombatFeel => p.combat.legacy = !p.combat.legacy, + Opt::CombatIndicator => p.combat.indicator = !p.combat.indicator, Opt::ViewWeather => { let all: Vec<_> = std::iter::once(None) .chain(ViewWeather::ALL.map(Some)) diff --git a/crates/horizon-client/assets/horizon/lang/en_us.json b/crates/horizon-client/assets/horizon/lang/en_us.json index 2e363f6a2d..74ead36971 100644 --- a/crates/horizon-client/assets/horizon/lang/en_us.json +++ b/crates/horizon-client/assets/horizon/lang/en_us.json @@ -815,6 +815,13 @@ "horizon.pvp.editor": "Edit HUD", "horizon.pvp.editor.tooltip": "Move the widgets on the game: drag them anywhere, resize them with the wheel, right click one to put it back in its corner.", "horizon.pvp.editor.hint": "Drag widgets · Wheel: size · Right click: back to its corner", + "horizon.pvp.combat_feel": "Combat feel", + "horizon.pvp.combat_feel.tooltip": "Legacy draws fights as 1.8.9 did: the item in your hand, its swing, eating, the bow and a sword held up while you hold use, without the dip after each hit or the attack indicator. Only the look: what you send a server stays the same.", + "horizon.pvp.combat_indicator": "Attack indicator in Legacy", + "horizon.pvp.combat_indicator.tooltip": "Keeps the attack indicator under Legacy, which 1.8.9 didn't have.", + "horizon.pvp.combat_feel.modern": "Modern", + "horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)", + "horizon.pvp.header.combat": "Combat feel", "Bindings": "Bindings", "Play": "Play", "Device": "Device", diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index 078dd52c19..5757ccd12f 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -168,6 +168,127 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) - m } +/// Combat feel Legacy's first-person item (`docs/pvp.md`): Minecraft 1.8.9's +/// `ItemRenderer.renderItemInFirstPerson` for a flat item, drawn through 26.3's +/// model. 1.8.9 posed the item (`transformFirstPersonItem`: the arm's place, +/// 45° about Y, the swing's turns, a 0.4 scale), then applied its own +/// `firstperson` display (`[0, -135, 25]`, `[0, 4, 2]`, `1.7`, rotated Y, X, +/// Z) and `RenderItem`'s 0.5 scale. 26.3 applies `firstperson_righthand` +/// (`[0, -90, 25]`, `[1.13, 3.2, 1.13]`, `0.68`) itself after this pose, so +/// the pose ends with its inverse ([`MODERN_FLAT_DISPLAY`]). `block` is a +/// sword held up while use is held: 1.8.9's `doBlockTransformations`, with +/// the swing's turns kept as 1.7.10 kept them (hitting while blocking swings +/// the sword). None for the poses 1.8.9 had no form of (crossbow, trident, +/// spear, brush), which stay modern. +pub fn legacy_item_pose( + left: bool, + inverse_height: f32, + attack: f32, + pose: ItemPose, + block: bool, +) -> Option { + use glam::{Mat4, Vec3}; + let deg = |d: f32| d.to_radians(); + let pi = std::f32::consts::PI; + let sin = |v: f32| v.sin(); + // transformFirstPersonItem(equipProgress, swingProgress). + let first_person = |swing: f32| { + let f = sin(swing * swing * pi); + let f1 = sin(swing.sqrt() * pi); + Mat4::from_translation(Vec3::new(0.56, -0.52 - inverse_height * 0.6, -0.72)) + * Mat4::from_rotation_y(deg(45.0)) + * Mat4::from_rotation_y(deg(f * -20.0)) + * Mat4::from_rotation_z(deg(f1 * -20.0)) + * Mat4::from_rotation_x(deg(f1 * -80.0)) + * Mat4::from_scale(Vec3::splat(0.4)) + }; + let m = match pose { + _ if block => { + // doBlockTransformations. + first_person(attack) + * Mat4::from_translation(Vec3::new(-0.5, 0.2, 0.0)) + * Mat4::from_rotation_y(deg(30.0)) + * Mat4::from_rotation_x(deg(-80.0)) + * Mat4::from_rotation_y(deg(60.0)) + } + ItemPose::Swing => { + // doItemUsedTransformations, then transformFirstPersonItem. + let root = attack.sqrt(); + Mat4::from_translation(Vec3::new( + -0.4 * sin(root * pi), + 0.2 * sin(root * pi * 2.0), + -0.2 * sin(attack * pi), + )) * first_person(attack) + } + ItemPose::Using => first_person(0.0), + ItemPose::Eat { + remaining, + duration, + partial, + } => { + // performDrinking. + let time = remaining - partial + 1.0; + let fraction = time / duration; + let bob = if fraction >= 0.8 { + 0.0 + } else { + ((time / 4.0 * pi).cos() * 0.1).abs() + }; + let j = 1.0 - horizon_core::jmath::pow(f64::from(fraction), 27.0) as f32; + Mat4::from_translation(Vec3::new(0.0, bob, 0.0)) + * Mat4::from_translation(Vec3::new(j * 0.6, j * -0.5, 0.0)) + * Mat4::from_rotation_y(deg(j * 90.0)) + * Mat4::from_rotation_x(deg(j * 10.0)) + * Mat4::from_rotation_z(deg(j * 30.0)) + * first_person(0.0) + } + ItemPose::Bow { ticks_used } => { + // doBowTransformations. + let power = (ticks_used / 20.0 * (ticks_used / 20.0) + ticks_used / 20.0 * 2.0) / 3.0; + let power = power.min(1.0); + let mut m = first_person(0.0) + * Mat4::from_rotation_z(deg(-18.0)) + * Mat4::from_rotation_y(deg(-12.0)) + * Mat4::from_rotation_x(deg(-8.0)) + * Mat4::from_translation(Vec3::new(-0.9, 0.2, 0.0)); + if power > 0.1 { + let shake = sin((ticks_used - 0.1) * 1.3) * (power - 0.1); + m *= Mat4::from_translation(Vec3::new(0.0, shake * 0.01, 0.0)); + } + m * Mat4::from_translation(Vec3::new(0.0, 0.0, power * 0.1)) + * Mat4::from_scale(Vec3::new(1.0, 1.0, 1.0 + power * 0.2)) + } + _ => return None, + }; + // 1.8.9's `firstperson` display and RenderItem's 0.5, then 26.3's display undone. + let legacy_display = Mat4::from_translation(Vec3::new(0.0, 4.0, 2.0) / 16.0) + * Mat4::from_rotation_y(deg(-135.0)) + * Mat4::from_rotation_z(deg(25.0)) + * Mat4::from_scale(Vec3::splat(1.7)) + * Mat4::from_scale(Vec3::splat(0.5)); + let right = m * legacy_display * modern_flat_display(false).inverse(); + // 1.8.9 had only a right main arm: the left arm mirrors it. + let mirror = Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0)); + Some(if left { mirror * right * mirror } else { right }) +} +/// 26.3's `firstperson_righthand` display of a flat item (`item/generated` and +/// `item/handheld`), on the `left` hand mirrored as `ItemTransform.apply` does, +/// without the model's centring translation. +pub fn modern_flat_display(left: bool) -> glam::Mat4 { + use glam::{Mat4, Vec3}; + let s = if left { -1.0 } else { 1.0 }; + Mat4::from_translation(Vec3::new(s * 1.13, 3.2, 1.13) / 16.0) + * Mat4::from_rotation_y((s * -90.0f32).to_radians()) + * Mat4::from_rotation_z((s * 25.0f32).to_radians()) + * Mat4::from_scale(Vec3::splat(0.68)) +} +/// Whether a model layer's matrix is 26.3's flat-item first-person display (a resource +/// pack's own display keeps the modern pose). +pub fn is_modern_flat_display(matrix: &glam::Mat4, left: bool) -> bool { + let expected = modern_flat_display(left) * glam::Mat4::from_translation(glam::Vec3::splat(-0.5)); + matrix.abs_diff_eq(expected, 1e-4) +} + /// How `submitArmWithItem` holds a stack with a `minecraft:map_id`: in both /// hands when it is the main hand's and the off hand is empty /// (`renderTwoHandedMap`), else in its own hand (`renderOneHandedMap`). @@ -479,13 +600,24 @@ impl HandHeights { /// `getItemSwapScale(1.0F)`. Returns whether each hand's visible item /// is replaced at the end. pub fn tick(&mut self, hands_busy: bool, swap_scale: f32, changed: [bool; 2]) -> [bool; 2] { + self.tick_with(hands_busy, swap_scale, changed, false) + } + /// [`Self::tick`]; `legacy` (Combat feel Legacy) raises the main hand fully whatever the + /// attack strength, as 1.8.9's `ItemRenderer.updateEquippedItem` did (no dip after a hit). + pub fn tick_with( + &mut self, + hands_busy: bool, + swap_scale: f32, + changed: [bool; 2], + legacy: bool, + ) -> [bool; 2] { self.old_main = self.main; self.old_off = self.off; if hands_busy { self.main = (self.main - 0.4).clamp(0.0, 1.0); self.off = (self.off - 0.4).clamp(0.0, 1.0); } else { - let attack = swap_scale; + let attack = if legacy { 1.0 } else { swap_scale }; let main_target = if changed[0] { 0.0 } else { @@ -1099,6 +1231,63 @@ mod tests { assert_eq!(swing_duration(d, Some(1), Some(0)), 4); assert_eq!(swing_duration(d, None, Some(2)), 12); } + + #[test] + fn legacy_hand_puts_a_flat_item_where_1_8_9_drew_it() { + use glam::{Mat4, Vec3}; + // At rest, the item's centre (model (0.5, 0.5, 0.5)) ends where 1.8.9's chain puts it: + // translate(0.56, -0.52, -0.72) . rotY(45) . scale(0.4) . translate((0, 4, 2) / 16) + // . ... . scale(0.5) . translate(-0.5): the centre is (0, 0.25, 0.125) scaled by 0.4, + // turned 45 degrees about Y. + let pose = legacy_item_pose(false, 0.0, 0.0, ItemPose::Swing, false).unwrap(); + let layer = modern_flat_display(false) * Mat4::from_translation(Vec3::splat(-0.5)); + let centre = (pose * layer).transform_point3(Vec3::splat(0.5)); + let c45 = std::f32::consts::FRAC_1_SQRT_2; + let expected = Vec3::new(0.56 + 0.05 * c45, -0.52 + 0.1, -0.72 + 0.05 * c45); + assert!(centre.abs_diff_eq(expected, 1e-4), "{centre} vs {expected}"); + // The left arm mirrors it. + let left = legacy_item_pose(true, 0.0, 0.0, ItemPose::Swing, false).unwrap(); + let left_layer = modern_flat_display(true) * Mat4::from_translation(Vec3::splat(-0.5)); + let l = (left * left_layer).transform_point3(Vec3::splat(0.5)); + assert!(l.abs_diff_eq(Vec3::new(-expected.x, expected.y, expected.z), 1e-4)); + // Lowered by the equip height: 0.6 of it, straight down. + let low = legacy_item_pose(false, 0.5, 0.0, ItemPose::Swing, false).unwrap(); + let c = (low * layer).transform_point3(Vec3::splat(0.5)); + assert!(c.abs_diff_eq(expected - Vec3::new(0.0, 0.3, 0.0), 1e-4)); + // A blocking sword sits elsewhere, and poses 1.8.9 lacked stay modern. + let block = legacy_item_pose(false, 0.0, 0.0, ItemPose::Using, true).unwrap(); + assert!(!(block * layer).transform_point3(Vec3::splat(0.5)).abs_diff_eq(expected, 0.01)); + let trident = ItemPose::Trident { ticks_used: 3.0 }; + assert!(legacy_item_pose(false, 0.0, 0.0, trident, false).is_none()); + } + + #[test] + fn legacy_hand_does_not_dip_after_a_hit() { + // Just after a hit the attack strength is 0: 26.3 lowers the main hand to 0, + // 1.8.9 keeps it up (it only lowers on an item change). + let mut modern = HandHeights { + main: 1.0, + old_main: 1.0, + off: 1.0, + old_off: 1.0, + }; + let mut legacy = modern; + modern.tick_with(false, 0.0, [false; 2], false); + legacy.tick_with(false, 0.0, [false; 2], true); + assert!((modern.main - 0.6).abs() < 1e-6, "falls 0.4 a tick"); + assert_eq!(legacy.main, 1.0); + // An item change still lowers it. + legacy.tick_with(false, 1.0, [true, false], true); + assert!((legacy.main - 0.6).abs() < 1e-6); + } + + #[test] + fn the_flat_display_check_knows_vanillas_matrix() { + let m = modern_flat_display(false) * glam::Mat4::from_translation(glam::Vec3::splat(-0.5)); + assert!(is_modern_flat_display(&m, false)); + assert!(!is_modern_flat_display(&m, true)); + assert!(!is_modern_flat_display(&glam::Mat4::IDENTITY, false)); + } } #[cfg(test)] diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b2c1f50028..d720e7ea7f 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -8882,7 +8882,13 @@ impl Game<'_> { self.draw_spyglass(&mut batch); // Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth / // extractVehicleHealth and the experience bar, laid out by hud_layout. - let indicator = self.settings.attack_indicator; + // Combat feel Legacy hides the attack indicator unless the player keeps it. + let combat = self.settings.extras.pvp.combat; + let indicator = if combat.legacy && !combat.indicator { + 0 + } else { + self.settings.attack_indicator + }; let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed()); let strength = self.hand_strength.scale(0.0, self.main_hand_attack_speed()); // The 3D crosshair (F3) takes the 2D one's place. @@ -14526,6 +14532,21 @@ impl Game<'_> { .is_some_and(|p| p.auto_spin_attack_ticks > 0); let items = self.visible_hand_items(); let off_hand_empty = items[1].is_none(); + // Combat feel Legacy: a sword is held up while use is held, as 1.8.9's block looked. + // Only the picture changes: the clicks and what they send stay 26.3's. + let legacy_block = self.settings.extras.pvp.combat.legacy + && self.right + && self.screen == Screen::Playing + && items[0] + .as_ref() + .is_some_and(|s| s.item.ends_with("_sword")) + && match used_hand { + None => true, + Some(Hand::Off) => items[1] + .as_ref() + .is_some_and(|s| &*s.item == "minecraft:shield"), + Some(Hand::Main) => false, + }; for (i, (hand, item)) in Hand::BOTH.into_iter().zip(items).enumerate() { if !shown[i] { continue; @@ -14576,6 +14597,10 @@ impl Game<'_> { }; // The main hand on its arm, the off hand on the other one. let left = self.settings.main_hand_left != (hand == Hand::Off); + // A Legacy sword held up hides the off hand's shield. + if hand == Hand::Off && legacy_block { + continue; + } let Some(stack) = item else { // renderArmWithItem: only the main hand's empty arm shows, // on the main arm (the left one with the main hand set to @@ -14719,6 +14744,23 @@ impl Game<'_> { horizon_client::cit::Hand::Main }; let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, cit_hand); + // Combat feel Legacy: 1.8.9's pose for a flat item drawn with vanilla's display. + if self.settings.extras.pvp.combat.legacy + && !state.layers.is_empty() + && state + .layers + .iter() + .all(|l| horizon_client::first_person::is_modern_flat_display(&l.matrix, left)) + && let Some(legacy) = horizon_client::first_person::legacy_item_pose( + left, + inverse_height, + attack, + pose, + hand == Hand::Main && legacy_block, + ) + { + m = to_world * legacy; + } runs.push((out.len() as u32, item_id)); let light = self.world.light( eye[0].floor() as i32, diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index d9a5835dee..dc27ace4c0 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -510,10 +510,11 @@ impl Game<'_> { changed[h] = !same; } let speed = self.main_hand_attack_speed(); - let replace = self.heights.tick( + let replace = self.heights.tick_with( self.hands_busy(), self.hand_strength.swap_scale(1.0, speed), changed, + self.settings.extras.pvp.combat.legacy, ); for h in 0..2 { if replace[h] { From a6f24140813a469127c408fd5481c7bcf26c7e6f Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 17:01:01 +0100 Subject: [PATCH 21/30] PvP & HUD: document Combat feel Co-Authored-By: Claude Opus 5.5 --- docs/pvp.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/pvp.md b/docs/pvp.md index 486a763008..24fb777481 100644 --- a/docs/pvp.md +++ b/docs/pvp.md @@ -153,6 +153,19 @@ Overworld. They change how `chat::Chat` lays its lines out (`ChatQol`, `set_search`): the 100 messages it keeps are the same, so turning an option off shows them as before. Code: `chat.rs`, `game/chat.rs`. +## Combat feel (`combatFeel`, default Modern) + +**Legacy (1.8.9)** draws fights as 1.8.9 did, from its own client (`ItemRenderer`, read for the numbers only): +the item held in first person sits, swings, eats, drinks and draws a bow along 1.8.9's poses, a sword is held up +to block while you hold use (with nothing in the off hand drawn), and the hand doesn't dip after each hit. The +attack indicator is hidden unless **Attack indicator in Legacy** (`combatIndicator`) keeps it. Items whose +resource pack moves them in hand, and the ones 1.8.9 didn't have (crossbow, trident, spear, brush...), keep +26.3's poses. 1.8.9 tinted no armour red on a hit, so the hurt flash is the same in both. + +It is only the look: the client sends a server exactly what Modern sends (same attacks, same timing, no +block-hit packets), so vanilla servers and their anticheats see nothing new. Code: `first_person.rs` +(`legacy_item_pose`), the hand in `game.rs`. + ## PvP preset The **PvP preset** button (first on the PvP & HUD page and on Horizon Extras) turns on, in one click: the custom From 04c3b710cfe3476d09d766e1c3d1301c9d217f3d Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 16:44:04 +0100 Subject: [PATCH 22/30] PvP & HUD: Legacy leaves out the sweep, damage hearts and 1.9 hit sounds Under Combat feel Legacy the client draws no sweep_attack or damage_indicator particles a server sends (1.8.9 had neither), and with '1.8 hit sounds' plays none of the entity.player.attack.* sounds 1.9 added. Hurt sounds, crits and enchanted hits stay. Only what this client shows changes; nothing is sent. 1.8.9 drew no red tint over armour (LayerArmorBase.shouldCombineTextures is false, so setBrightness skips it) and the body's flash is red at 0.3, as 26.3's: both already match. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/pvp.rs | 60 ++++++++++++++++++- .../assets/horizon/lang/en_us.json | 2 + .../horizon-client/src/game/lan_particles.rs | 4 ++ crates/horizon-client/src/game/remote.rs | 5 +- 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client-render/src/pvp.rs b/crates/horizon-client-render/src/pvp.rs index b3564683d7..cb424df08a 100644 --- a/crates/horizon-client-render/src/pvp.rs +++ b/crates/horizon-client-render/src/pvp.rs @@ -759,6 +759,23 @@ pub struct Combat { pub legacy: bool, /// Keep the attack indicator under Legacy (1.8.9 had none). pub indicator: bool, + /// Under Legacy, silence the attack sounds 1.9 added (1.8.9 played none on a hit). + pub sounds: bool, +} +impl Combat { + /// Whether this client leaves out sound `name` a server sent: under Legacy with its + /// sounds, the `entity.player.attack.*` family (strong, weak, crit, sweep, knockback, + /// nodamage), which 1.9 brought with the attack cooldown. + pub fn mutes_sound(&self, name: &str) -> bool { + let name = name.strip_prefix("minecraft:").unwrap_or(name); + self.legacy && self.sounds && name.starts_with("entity.player.attack.") + } + /// Whether this client leaves out particles of `ty` a server sent: under Legacy, the + /// sweep and the damage hearts, which 1.9 brought (1.8.9's hits show crits only). + pub fn hides_particle(&self, ty: crate::particles::ParticleType) -> bool { + use crate::particles::ParticleType as T; + self.legacy && matches!(ty, T::SweepAttack | T::DamageIndicator) + } } #[derive(Clone, Debug, PartialEq)] @@ -895,6 +912,7 @@ impl Pvp { _ => {} }, "combatIndicator" => self.combat.indicator = flag(value, self.combat.indicator), + "combatSounds" => self.combat.sounds = flag(value, self.combat.sounds), "viewTime" => { t.time = match value { "off" => None, @@ -1021,7 +1039,11 @@ impl Pvp { ); let c = &self.combat; let feel = if c.legacy { "legacy" } else { "modern" }; - let _ = write!(out, "combatFeel:{feel}\ncombatIndicator:{}\n", c.indicator); + let _ = write!( + out, + "combatFeel:{feel}\ncombatIndicator:{}\ncombatSounds:{}\n", + c.indicator, c.sounds + ); } } @@ -1164,6 +1186,8 @@ opts! { "Legacy draws fights as 1.8.9 did: the item in your hand, its swing, eating, the bow and a sword held up while you hold use, without the dip after each hit or the attack indicator. Only the look: what you send a server stays the same."; CombatIndicator, "combat_indicator", "Attack indicator in Legacy", "Keeps the attack indicator under Legacy, which 1.8.9 didn't have."; + CombatSounds, "combat_sounds", "1.8 hit sounds", + "Under Legacy, leaves out the attack sounds 1.9 added (strong, weak, sweep, critical...). Hurt sounds stay."; } /// The PvP & HUD page: headers (language keys) and options. pub fn rows() -> Vec> { @@ -1244,7 +1268,7 @@ pub fn rows() -> Vec> { rows.push(Err("horizon.pvp.header.chat")); rows.extend([ChatTimestamps, ChatCompact, ChatCopy, ChatSearch].map(Ok)); rows.push(Err("horizon.pvp.header.combat")); - rows.extend([CombatFeel, CombatIndicator].map(Ok)); + rows.extend([CombatFeel, CombatIndicator, CombatSounds].map(Ok)); rows } impl Opt { @@ -1268,7 +1292,7 @@ impl Opt { FireLower | ShieldSize | TotemSize | GlintColour | ClearWater | ClearLava => None, ViewTime | ViewWeather => None, ChatTimestamps | ChatCompact | ChatCopy | ChatSearch => None, - CombatFeel | CombatIndicator => None, + CombatFeel | CombatIndicator | CombatSounds => None, HudPingCorner => Some(HudPing), HudCoordsCorner => Some(HudCoords), HudServerCorner => Some(HudServer), @@ -1311,6 +1335,7 @@ impl Opt { Opt::ChatCopy => p.chat.copy, Opt::ChatSearch => p.chat.search, Opt::CombatIndicator => p.combat.indicator, + Opt::CombatSounds => p.combat.sounds, _ => return None, }) } @@ -1467,6 +1492,7 @@ impl Opt { Opt::ChatSearch => p.chat.search = !p.chat.search, Opt::CombatFeel => p.combat.legacy = !p.combat.legacy, Opt::CombatIndicator => p.combat.indicator = !p.combat.indicator, + Opt::CombatSounds => p.combat.sounds = !p.combat.sounds, Opt::ViewWeather => { let all: Vec<_> = std::iter::once(None) .chain(ViewWeather::ALL.map(Some)) @@ -1769,4 +1795,32 @@ mod tests { assert_eq!(en.get(key), Some(*english), "{key}"); } } + + #[test] + fn legacy_leaves_out_what_1_9_added_to_a_hit() { + use crate::particles::ParticleType as T; + let modern = Combat::default(); + let legacy = Combat { + legacy: true, + ..Combat::default() + }; + let sounds = Combat { + sounds: true, + ..legacy + }; + for name in [ + "minecraft:entity.player.attack.strong", + "entity.player.attack.sweep", + "minecraft:entity.player.attack.nodamage", + ] { + assert!(sounds.mutes_sound(name), "{name}"); + assert!(!legacy.mutes_sound(name), "only with the sounds option"); + assert!(!modern.mutes_sound(name)); + } + // 1.8.9 had the hurt sound: it stays. + assert!(!sounds.mutes_sound("minecraft:entity.player.hurt")); + assert!(legacy.hides_particle(T::SweepAttack) && legacy.hides_particle(T::DamageIndicator)); + assert!(!legacy.hides_particle(T::Crit) && !legacy.hides_particle(T::EnchantedHit)); + assert!(!modern.hides_particle(T::SweepAttack)); + } } diff --git a/crates/horizon-client/assets/horizon/lang/en_us.json b/crates/horizon-client/assets/horizon/lang/en_us.json index 74ead36971..c9e3fab957 100644 --- a/crates/horizon-client/assets/horizon/lang/en_us.json +++ b/crates/horizon-client/assets/horizon/lang/en_us.json @@ -822,6 +822,8 @@ "horizon.pvp.combat_feel.modern": "Modern", "horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)", "horizon.pvp.header.combat": "Combat feel", + "horizon.pvp.combat_sounds": "1.8 hit sounds", + "horizon.pvp.combat_sounds.tooltip": "Under Legacy, leaves out the attack sounds 1.9 added (strong, weak, sweep, critical...). Hurt sounds stay.", "Bindings": "Bindings", "Play": "Play", "Device": "Device", diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index 14bfa65c5a..3c99da7cfd 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -540,6 +540,10 @@ impl Game<'_> { let Some(options) = self.received_particle(&p.particle, p.pos) else { return; }; + // Combat feel Legacy draws a hit as 1.8.9 did: no sweep, no damage hearts. + if self.settings.extras.pvp.combat.hides_particle(options.ty) { + return; + } let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from)); // ClientLevel.addParticle: the camera's 32 blocks and the Particles // option, unless the packet or the type overrides the limiter. diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index ab35228212..682ac3cd26 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3425,7 +3425,9 @@ impl Game<'_> { C::LevelParticles(p) => self.remote_level_particles(&p), C::Explode(p) => self.remote_explode(&p), C::Sound(p) => { - if let Some(name) = self.remote_sound_name(&p.sound) { + if let Some(name) = self.remote_sound_name(&p.sound) + && !self.settings.extras.pvp.combat.mutes_sound(&name) + { let at = [ f64::from(p.x) / 8.0, f64::from(p.y) / 8.0, @@ -3446,6 +3448,7 @@ impl Game<'_> { } C::SoundEntity(p) => { if let Some(name) = self.remote_sound_name(&p.sound) + && !self.settings.extras.pvp.combat.mutes_sound(&name) && let Some(state) = self.remote_sound_entity_state(p.id) { let source = usize::try_from(p.source) From 6d36365aebbe1b74c8499be7098c97f9cc6ef00c Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 18:12:05 +0100 Subject: [PATCH 23/30] PvP & HUD: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/first_person.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index 5757ccd12f..1dd53e52de 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -285,7 +285,8 @@ pub fn modern_flat_display(left: bool) -> glam::Mat4 { /// Whether a model layer's matrix is 26.3's flat-item first-person display (a resource /// pack's own display keeps the modern pose). pub fn is_modern_flat_display(matrix: &glam::Mat4, left: bool) -> bool { - let expected = modern_flat_display(left) * glam::Mat4::from_translation(glam::Vec3::splat(-0.5)); + let expected = + modern_flat_display(left) * glam::Mat4::from_translation(glam::Vec3::splat(-0.5)); matrix.abs_diff_eq(expected, 1e-4) } @@ -1256,7 +1257,11 @@ mod tests { assert!(c.abs_diff_eq(expected - Vec3::new(0.0, 0.3, 0.0), 1e-4)); // A blocking sword sits elsewhere, and poses 1.8.9 lacked stay modern. let block = legacy_item_pose(false, 0.0, 0.0, ItemPose::Using, true).unwrap(); - assert!(!(block * layer).transform_point3(Vec3::splat(0.5)).abs_diff_eq(expected, 0.01)); + assert!( + !(block * layer) + .transform_point3(Vec3::splat(0.5)) + .abs_diff_eq(expected, 0.01) + ); let trident = ItemPose::Trident { ticks_used: 3.0 }; assert!(legacy_item_pose(false, 0.0, 0.0, trident, false).is_none()); } From fc8781c2ce1bd302d952b12dfa2abb069fc0d8b5 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 17:01:07 +0100 Subject: [PATCH 24/30] PvP & HUD: document Legacy's sounds and particles Co-Authored-By: Claude Opus 5.5 --- docs/pvp.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/pvp.md b/docs/pvp.md index 24fb777481..8e0ed51ad5 100644 --- a/docs/pvp.md +++ b/docs/pvp.md @@ -161,6 +161,9 @@ to block while you hold use (with nothing in the off hand drawn), and the hand d attack indicator is hidden unless **Attack indicator in Legacy** (`combatIndicator`) keeps it. Items whose resource pack moves them in hand, and the ones 1.8.9 didn't have (crossbow, trident, spear, brush...), keep 26.3's poses. 1.8.9 tinted no armour red on a hit, so the hurt flash is the same in both. +Legacy also leaves out what 1.9 added around a hit: the sweep arc and the damage hearts a server sends, and +with **1.8 hit sounds** (`combatSounds`) the `entity.player.attack.*` sounds (strong, weak, crit, sweep, +knockback, no damage); hurt sounds and crit particles stay. `pvp::Combat`. It is only the look: the client sends a server exactly what Modern sends (same attacks, same timing, no block-hit packets), so vanilla servers and their anticheats see nothing new. Code: `first_person.rs` From d5479156d770d5d41958dae39d4c07278be560aa Mon Sep 17 00:00:00 2001 From: Skr1zzzz <273194090+Skr1zzzz@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:18:39 +0300 Subject: [PATCH 25/30] Multiplayer: the hidden player list and its skins stop costing per player draw_tab_list ran every frame and, through remote_tab_players, cloned every listed player and formatted two object-skin keys for each before it knew whether the list was shown; update_remote_skins then searched every entity once per level player and cloned the whole skin map. With 150 players and none drawn a frame still cost ~0.4 ms more than with none (#4664). - The list's visibility comes from the listed count; the players are built only while it is shown or when it opens/closes (narration). - The id:/name: object skins publish only when a player, its skin or the atlas's object skins changed (a fingerprint and an atlas revision), so inline faces keep the server's skin as before. - update_remote_skins finds players and mannequins through one id->index map and moves the resolved skins in instead of cloning them. --- crates/horizon-client-render/src/hud.rs | 38 ++++- crates/horizon-client/src/game/remote.rs | 154 +++++++++++++++---- crates/horizon-client/src/game/scoreboard.rs | 46 +++++- 3 files changed, 198 insertions(+), 40 deletions(-) diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index a29d5a6176..d680ffd517 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -76,6 +76,7 @@ pub struct HudAtlas { pub sprites: HashMap, /// Resolved player-profile names and UUIDs for inline head components. pub object_skins: HashMap, + pub object_skins_revision: u64, /// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`), /// which puts their particles on vanilla's translucent layer. pub translucent: HashSet, @@ -786,21 +787,29 @@ impl HudAtlas { .or_else(|| self.sprites.get(MISSING_SPRITE)) .copied() } + pub fn clear_object_skins(&mut self) { + self.object_skins.clear(); + self.object_skins_revision = Self::next_revision(); + } /// Keep a ready profile skin when another source only has its default. pub fn set_object_skin(&mut self, key: String, skin: String) { use std::collections::hash_map::Entry; if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) { - self.object_skins.clear(); + self.clear_object_skins(); } match self.object_skins.entry(key) { Entry::Occupied(entry) if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => { } Entry::Occupied(mut entry) => { - entry.insert(skin); + if entry.get() != &skin { + entry.insert(skin); + self.object_skins_revision = Self::next_revision(); + } } Entry::Vacant(entry) => { entry.insert(skin); + self.object_skins_revision = Self::next_revision(); } } } @@ -920,6 +929,7 @@ impl HudAtlas { height: 1, sprites: HashMap::new(), object_skins: HashMap::new(), + object_skins_revision: Self::next_revision(), translucent: HashSet::new(), flags: HashMap::new(), sprite_animations: Vec::new(), @@ -985,6 +995,7 @@ impl HudAtlas { translucent: HashSet::new(), flags: HashMap::new(), object_skins: HashMap::new(), + object_skins_revision: Self::next_revision(), sprite_animations: Vec::new(), glint: (vec![0; 4], 1, 1), glint_armor: (vec![0; 4], 1, 1), @@ -1434,6 +1445,7 @@ impl HudAtlas { height, sprites, object_skins: HashMap::new(), + object_skins_revision: Self::next_revision(), translucent, flags, sprite_animations, @@ -2888,6 +2900,28 @@ impl HudRenderer { mod tests { use super::*; + #[test] + fn object_skin_revision_tracks_changes_and_capacity_clear() { + let mut atlas = HudAtlas::boot(); + atlas.set_object_skin("name:player".into(), "skins/server".into()); + let revision = atlas.object_skins_revision; + atlas.set_object_skin("name:player".into(), "skins/server".into()); + assert_eq!(atlas.object_skins_revision, revision); + atlas.set_object_skin("name:player".into(), "skins/inline".into()); + assert_ne!(atlas.object_skins_revision, revision); + for i in 1..1024 { + atlas.set_object_skin(format!("name:{i}"), "skins/server".into()); + } + let revision = atlas.object_skins_revision; + atlas.set_object_skin("name:overflow".into(), "skins/server".into()); + assert_ne!(atlas.object_skins_revision, revision); + assert_eq!(atlas.object_skins.len(), 1); + let revision = atlas.object_skins_revision; + atlas.clear_object_skins(); + assert_ne!(atlas.object_skins_revision, revision); + assert!(atlas.object_skins.is_empty()); + } + /// The mod image slot is paid only by a game with a native mod loaded. #[test] fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index e7165722ff..18e35d9129 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -638,6 +638,8 @@ pub(super) struct Remote { own_profile: OwnProfile, /// The skin texture each profile's players show, once its download is ready. skins: HashMap, + skins_revision: u64, + published_skins: Option<(usize, u128, u64, u64)>, listed: std::collections::HashSet, /// `ClientboundLoginPacket.onlineMode`: the player list shows heads. pub(super) online_mode: bool, @@ -1139,6 +1141,8 @@ impl Game<'_> { skin_textures: HashMap::new(), own_profile: OwnProfile::Idle, skins: HashMap::new(), + skins_revision: 0, + published_skins: None, listed: Default::default(), online_mode: false, level_players: HashMap::new(), @@ -1228,7 +1232,7 @@ impl Game<'_> { r.session.disconnect(); } super::lan::forget_sync_data(); - self.gui.atlas.object_skins.clear(); + self.gui.atlas.clear_object_skins(); self.server_packs_disconnect(); self.code_of_conduct = None; self.reset_tutorial(); @@ -5219,10 +5223,50 @@ impl Game<'_> { /// The player list's view of the connection: the listed players (with /// their downloaded skins once ready), online mode, and which players /// are in the level with an upside-down name. + pub(super) fn publish_tab_object_skins(&mut self) { + let Some(r) = self.remote.as_mut() else { + return; + }; + let fingerprint = ( + r.listed.len(), + r.listed.iter().fold(0, |xor, uuid| xor ^ uuid), + r.skins_revision, + self.gui.atlas.object_skins_revision, + ); + if r.published_skins == Some(fingerprint) { + return; + } + for uuid in &r.listed { + let Some(p) = r.tab_players.get(uuid) else { + continue; + }; + let skin = r.skins.get(uuid).map_or_else( + || horizon_client::tab_list::default_skin(*uuid), + String::as_str, + ); + for key in [ + format!("id:{uuid:032x}"), + format!("name:{}", p.name.to_ascii_lowercase()), + ] { + if object_skin_changed(&self.gui.atlas.object_skins, &key, skin) { + self.gui.atlas.set_object_skin(key, skin.to_owned()); + } + } + } + // Publication itself mutates the atlas; cache its final revision. + r.published_skins = Some(( + fingerprint.0, + fingerprint.1, + fingerprint.2, + self.gui.atlas.object_skins_revision, + )); + } + pub(super) fn remote_tab_players( &mut self, ) -> Option<(Vec, bool, Vec)> { self.update_remote_skins(); + self.publish_tab_object_skins(); let mut players: Vec<_> = { let r = self.remote.as_ref()?; r.listed @@ -5236,13 +5280,6 @@ impl Game<'_> { .as_ref() .and_then(|r| r.skins.get(&p.uuid).cloned()) .unwrap_or_else(|| horizon_client::tab_list::default_skin(p.uuid).to_owned()); - self.gui - .atlas - .set_object_skin(format!("id:{:032x}", p.uuid), p.skin.clone()); - self.gui.atlas.set_object_skin( - format!("name:{}", p.name.to_ascii_lowercase()), - p.skin.clone(), - ); } let r = self.remote.as_ref()?; let upside_down = players @@ -5452,14 +5489,13 @@ impl Game<'_> { (skin.to_owned(), skin.contains("/slim/")) } }; - let Some((level, skins, own)) = self.remote.as_mut().map(|r| { - r.skins = resolved.into_iter().collect(); + let skins: HashMap<_, _> = resolved.into_iter().collect(); + let Some((level, own)) = self.remote.as_ref().map(|r| { ( r.level_players .iter() .map(|(id, uuid)| (*id as u32, *uuid)) .collect::>(), - r.skins.clone(), r.profile, ) }) else { @@ -5467,12 +5503,16 @@ impl Game<'_> { }; // Only the skins drawn hold atlas slots: the level's players, not // every entry of a busy server's player list. + // Build once: resolving each player must not scan every level entity. + let players: HashMap<_, _> = self + .mobs + .iter() + .enumerate() + .filter(|(_, m)| &*m.kind.id == "minecraft:player") + .map(|(i, m)| (m.id, i)) + .collect(); for (id, uuid) in level { - let Some(i) = self - .mobs - .iter() - .position(|m| m.id == id && &*m.kind.id == "minecraft:player") - else { + let Some(&i) = players.get(&id) else { continue; }; let (skin, is_slim) = self.drawn_skin(uuid, skin_of(&skins, uuid)); @@ -5504,14 +5544,21 @@ impl Game<'_> { mob.cape = cape; } } + if let Some(r) = self.remote.as_mut() + && r.skins != skins + { + r.skins_revision = r.skins_revision.wrapping_add(1); + r.skins = skins; + } // ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a // mannequin's DATA_PROFILE `textures` property resolves like a // player's, never required to be signed. - let mannequins: Vec<(u32, horizon_client::skins::Textures)> = self + let mannequins: Vec<(usize, horizon_client::skins::Textures)> = self .mobs .iter() - .filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some()) - .map(|m| { + .enumerate() + .filter(|(_, m)| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some()) + .map(|(i, m)| { let profile = m.profile.as_ref(); let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures")); let textures = if let Some(t) = t { @@ -5549,10 +5596,10 @@ impl Game<'_> { }) .unwrap_or_default() }; - (m.id, textures) + (i, textures) }) .collect(); - let mut resolved: Vec<(u32, Option, bool)> = Vec::new(); + let mut resolved: Vec<(usize, Option, bool)> = Vec::new(); for (id, textures) in mannequins { let skin = { let mut downloads = self.skin_downloads.borrow_mut(); @@ -5563,16 +5610,12 @@ impl Game<'_> { let slim = skin.is_some() && textures.slim; resolved.push((id, skin, slim)); } - for (id, skin, slim) in resolved { + for (i, skin, slim) in resolved { let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s)); - if let Some(mob) = self - .mobs - .iter_mut() - .find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin") - { - mob.slim = slim && skin.is_some(); - mob.skin = skin; - } + // Resolution and atlas uploads do not insert or remove mobs. + let mob = &mut self.mobs[i]; + mob.slim = slim && skin.is_some(); + mob.skin = skin; } } /// Puts a downloaded skin in an atlas skin slot (`SkinSlots::place`), @@ -10814,6 +10857,10 @@ impl Remote { .or_else(|| self.players.get(uuid)) .map(String::as_str) } + /// Listed UUIDs always have a tab entry; removal clears both together. + pub(super) fn listed_player_count(&self) -> usize { + self.listed.len() + } /// Players in the server's player list, this one included. pub(super) fn player_count(&self) -> usize { self.players.len() @@ -10835,6 +10882,10 @@ impl Remote { } } +fn object_skin_changed(published: &HashMap, key: &str, skin: &str) -> bool { + published.get(key).is_none_or(|previous| previous != skin) +} + /// The textures to resolve skins from: every list entry's, and for a player /// entity whose entry is gone the profile it kept. `kept` first takes, for each /// player entity without one, its entry's textures (`AbstractClientPlayer.getPlayerInfo` @@ -11300,6 +11351,49 @@ mod tests { assert_eq!(super::skin_entries(&players, &listed, &mut kept).len(), 1); } + #[test] + fn tab_publication_fingerprint_tracks_all_inputs() { + let fingerprint = |ids: &[u128], skins, atlas| { + ( + ids.len(), + ids.iter().fold(0, |xor, uuid| xor ^ uuid), + skins, + atlas, + ) + }; + let published = fingerprint(&[7, 8], 1, 2); + assert_eq!(published, fingerprint(&[8, 7], 1, 2)); + assert_ne!(published, fingerprint(&[7, 9], 1, 2)); + assert_ne!(published, fingerprint(&[7, 8], 2, 2)); + assert_ne!(published, fingerprint(&[7, 8], 1, 3)); + } + + #[test] + fn tab_object_skin_publication_tracks_changes_and_atlas_clear() { + let mut published = std::collections::HashMap::new(); + let mut writes = 0; + for skin in ["skins/first", "skins/first", "skins/second"] { + for key in ["id:7", "name:player"] { + if super::object_skin_changed(&published, key, skin) { + published.insert(key.to_owned(), skin.to_owned()); + writes += 1; + } + } + } + assert_eq!(writes, 4, "unchanged skins do not publish either entry"); + published.clear(); + assert!(super::object_skin_changed( + &published, + "id:7", + "skins/second" + )); + assert!(super::object_skin_changed( + &published, + "name:player", + "skins/second" + )); + } + /// An NPC plugin's scale: a base the plugin sets, or a modifier on 1. #[test] fn attribute_values_add_then_multiply_as_vanilla() { diff --git a/crates/horizon-client/src/game/scoreboard.rs b/crates/horizon-client/src/game/scoreboard.rs index 8e27feca84..d60d670c8b 100644 --- a/crates/horizon-client/src/game/scoreboard.rs +++ b/crates/horizon-client/src/game/scoreboard.rs @@ -812,6 +812,24 @@ impl Game<'_> { self.flush_skin_slots(); let key = self.screen == super::Screen::Playing && self.keys.contains(&winit::keyboard::KeyCode::Tab); + let count = self.remote.as_ref().map_or_else( + || { + 1 + self + .lan + .as_ref() + .map_or(0, |h| h.hub.guests.iter().filter(|g| g.placed).count()) + }, + |r| r.listed_player_count(), + ); + let has_objective = self.score.board.display_objective(sb::SLOT_LIST).is_some(); + let shown = tab_list::visible(key, self.has_singleplayer_server(), count, has_objective); + let changed = self.tab_list.set_visible(shown); + if !tab_players_needed(shown, changed) { + // World skins still need download polling and atlas residency checks. + self.update_remote_skins(); + self.publish_tab_object_skins(); + return; + } let (mut players, online_mode, upside_down) = match self.remote_tab_players() { Some(remote) => remote, None => { @@ -833,14 +851,7 @@ impl Game<'_> { (players, true, upside_down) } }; - let has_objective = self.score.board.display_objective(sb::SLOT_LIST).is_some(); - let shown = tab_list::visible( - key, - self.has_singleplayer_server(), - players.len(), - has_objective, - ); - if self.tab_list.set_visible(shown) { + if changed { // PlayerTabOverlay.setVisible: the names, said as the list comes up. self.say_system_now(&tab_list::narration(&players, &self.score.board)); } @@ -882,6 +893,25 @@ impl Game<'_> { } } +fn tab_players_needed(shown: bool, changed: bool) -> bool { + shown || changed +} + +#[test] +fn hidden_tab_skips_players_except_for_visibility_narration() { + use horizon_client::tab_list; + for count in [1, 150, 1000] { + let shown = tab_list::visible(false, false, count, false); + assert!(!tab_players_needed(shown, false)); + assert!( + tab_players_needed(shown, true), + "closing narration needs names" + ); + let shown = tab_list::visible(true, false, count, false); + assert!(tab_players_needed(shown, false)); + } +} + /// The host and guests that have entered the world, rebuilt from live state. pub(super) fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec { let mut players = vec![me]; From 4020e25379a7f0219303c2b24d2d5383531e119d Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 23:45:27 +0100 Subject: [PATCH 26/30] player-import: refuse an overflowing rotation and a named position over a vehicle Answers martipops' review of #8735: - A rotation finite as f64 but past f32::MAX became an infinity (and a NaN pitch) after the cast; it is refused now, like any rotation that is not a number. - A record with a vehicle still to restore (RootVehicle) seats the player back on it at the vehicle's own place, which undid a position the request named; the two are refused together. - A retried import of the same hand-over, or a retry after the commit, answers the same `recentered` as the first reply (kept on the guest until it is placed). - docs/server.md says the velocity is the server's only: the arrival's teleport carries no motion, as a vanilla login's does not. Co-Authored-By: Claude Opus 5.5 --- .../src/game/dedicated/transfer.rs | 1 + crates/horizon-client/src/lan/arrivals.rs | 49 ++++++++++++++++++- crates/horizon-client/src/lan/guest.rs | 4 ++ crates/horizon-client/src/lan/import.rs | 25 +++++++++- docs/server.md | 10 ++-- 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game/dedicated/transfer.rs b/crates/horizon-client/src/game/dedicated/transfer.rs index 8e98ffedeb..a40700344c 100644 --- a/crates/horizon-client/src/game/dedicated/transfer.rs +++ b/crates/horizon-client/src/game/dedicated/transfer.rs @@ -99,6 +99,7 @@ impl Game<'_> { "handover": arrivals::handover_text(id), "placed": true, "announced": host.hub.guests[i].arrival_announce, + "recentered": host.hub.guests[i].arrival_recentered, "retried": true, }); self.lan = Some(host); diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs index c075cfa3be..efa74d0d18 100644 --- a/crates/horizon-client/src/lan/arrivals.rs +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -2689,9 +2689,16 @@ mod hub_tests { fn held_with_a_record_at( at: [f64; 3], center: Option<(i32, i32)>, + ) -> (serde_json::Value, Hub, Receiver) { + held_with_a_record_of(|g| g.position = at, center) + } + + fn held_with_a_record_of( + source_player: impl FnOnce(&mut Guest), + center: Option<(i32, i32)>, ) -> (serde_json::Value, Hub, Receiver) { let (mut source, i) = exported_guest(); - source.guests[i].position = at; + source_player(&mut source.guests[i]); let record = super::export_record(&mut source, i, true, Default::default()).unwrap(); let mut hub = hub(); assert_eq!( @@ -2771,6 +2778,42 @@ mod hub_tests { ); } + /// A retried import of the same hand-over (its reply was lost) finds the view already moved, + /// and still says the chunks around the new place are on their way. + #[test] + fn a_retried_import_still_reports_the_recentring() { + let (record, mut hub, _mine) = held_with_a_record_at([100.5, 70.0, -3.25], Some((0, 0))); + let request = serde_json::json!({ "record": record, "place": false }); + for _ in 0..2 { + let done = import_player(&mut hub, UUID_N, &request).unwrap(); + assert!(done.recentered); + } + } + + /// A record whose vehicle is still to be restored puts the player back on it at the vehicle's + /// own place, so a position the request names would be undone: it is refused, and the record's + /// own place is still taken. + #[test] + fn a_named_position_is_refused_for_a_record_with_a_vehicle_to_restore() { + let (record, mut hub, _mine) = held_with_a_record_of( + |g| { + g.position = [10.5, 70.0, -3.25]; + g.saved_vehicle = Some([1, 2, 3, 4]); + }, + Some((0, 0)), + ); + let named = serde_json::json!({ + "record": record.clone(), "place": false, "position": [300.5, 64.0, 300.5], + }); + let error = import_player(&mut hub, UUID_N, &named).unwrap_err(); + assert!(error.contains("vehicle"), "{error}"); + assert!(!hub.guests[0].imported); + let plain = serde_json::json!({ "record": record, "place": false }); + import_player(&mut hub, UUID_N, &plain).unwrap(); + assert_eq!(hub.guests[0].position, [10.5, 70.0, -3.25]); + assert_eq!(hub.guests[0].saved_vehicle, Some([1, 2, 3, 4])); + } + #[test] fn placing_at_once_reports_the_recentring_too() { let (record, mut hub, mine) = held_with_a_record_at([100.5, 70.0, -3.25], Some((0, 0))); @@ -2887,6 +2930,10 @@ mod hub_tests { "rotation of one", with(serde_json::json!({ "rotation": [1.0] })), ), + ( + "a rotation past what a float holds", + with(serde_json::json!({ "rotation": [1e39, 1e39] })), + ), ( "a velocity that is not three numbers", with(serde_json::json!({ "velocity": [0.25, "up", 0.0] })), diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index c6209c4b5c..6f0d3da4e6 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -360,6 +360,9 @@ pub struct Guest { /// The hand-over its record came with (`player-import`): `player-confirm` and /// `player-drop` name it, and another hand-over's record is not taken. pub arrival_handover: Option, + /// The import of that hand-over moved the view to another chunk (`recentered` in its reply), + /// kept so a retried import or a confirm after it answers the same. + pub arrival_recentered: bool, /// Leaving for another server (`player-release moved`) without the "left the /// game" line. pub quiet_leave: bool, @@ -548,6 +551,7 @@ impl Guest { imported: false, arrival_announce: true, arrival_handover: None, + arrival_recentered: false, quiet_leave: false, released: false, hidden_commands: Vec::new(), diff --git a/crates/horizon-client/src/lan/import.rs b/crates/horizon-client/src/lan/import.rs index 69936c837a..0f4040bf94 100644 --- a/crates/horizon-client/src/lan/import.rs +++ b/crates/horizon-client/src/lan/import.rs @@ -150,8 +150,14 @@ fn read_arrival(request: &Value) -> Result { // Entity.setXRot: the pitch is taken modulo a turn (`frem`, the sign of the // dividend, as Rust's `%`), then cut to a quarter turn each way; setYRot keeps the // yaw as it is. - let pitch = (pitch as f32 % 360.0).clamp(-90.0, 90.0); - Some([yaw as f32, pitch]) + let (yaw, pitch) = (yaw as f32, pitch as f32); + // Finite as an f64 is not enough: past f32::MAX the cast gives an infinity (and + // the pitch's modulo a NaN), which Entity.load would refuse. + if !yaw.is_finite() || !pitch.is_finite() { + return Err("rotation holds only numbers a float can hold".into()); + } + let pitch = (pitch % 360.0).clamp(-90.0, 90.0); + Some([yaw, pitch]) } None => None, }; @@ -502,6 +508,17 @@ pub fn import_player(hub: &mut Hub, uuid: u128, request: &Value) -> Result Result Date: Fri, 6 Nov 2026 00:37:43 +0100 Subject: [PATCH 27/30] Mods hot load: a test follows the root-per-mod-set rule blocks_and_items_declared_in_code_get_ids expected a changed declaration to rewrite the same root's stamp; each set now has its own root, so it checks the new root and that the old one is left as it was. Found by running the content-root tests on real prepared resources. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mod_content_tests.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/mod_content_tests.rs b/crates/horizon-client/src/mod_content_tests.rs index 730c8fff46..e168c540be 100644 --- a/crates/horizon-client/src/mod_content_tests.rs +++ b/crates/horizon-client/src/mod_content_tests.rs @@ -3977,10 +3977,14 @@ fn blocks_and_items_declared_in_code_get_ids() { resource_root(&base, &mods, &scratch.join("cache")).unwrap(), root ); + // Changed declarations build a root of their own: the old one stays as + // it was, for a switch back to that set. let stamp = fs::read(root.join(STAMP)).unwrap(); mods[1].content.items[0].max_stack_size = Some(2); - resource_root(&base, &mods, &scratch.join("cache")).unwrap(); - assert_ne!(fs::read(root.join(STAMP)).unwrap(), stamp); + let changed = resource_root(&base, &mods, &scratch.join("cache")).unwrap(); + assert_ne!(changed, root); + assert_ne!(fs::read(changed.join(STAMP)).unwrap(), stamp); + assert_eq!(fs::read(root.join(STAMP)).unwrap(), stamp); let _ = fs::remove_dir_all(&scratch); } From f43950f5fbe74d803b63f49abc3680465d60f0b7 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Fri, 6 Nov 2026 00:38:39 +0100 Subject: [PATCH 28/30] Design note: the measured times, and what PRs 2 to 4 did Native rebuild times measured on the desktop, live switch times in game on the ThinkPad and the content root on the desktop. Option A as built: trait mods switch at run time, RenderHooks and the launcher's build-all come next. The browser's buttons and index fetch as implemented. Co-Authored-By: Claude Opus 5.5 --- docs/design/mod-hotload.md | 74 ++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/docs/design/mod-hotload.md b/docs/design/mod-hotload.md index 674128aed8..819a688d64 100644 --- a/docs/design/mod-hotload.md +++ b/docs/design/mod-hotload.md @@ -95,15 +95,19 @@ player lands back in the world. ### What "a few seconds" means here, measured -Measured on the desktop (i7-13700KF, NVMe, `profile.fast`) with the fixture mod of the hot reload PR. The numbers are -filled in by that PR and repeated here. +Measured in game on the ThinkPad (i7-7500U, Intel HD 620, Vulkan, `profile.fast`; the desktop can't run the game +over ssh) with the fixture mod of #10217: a recipe, an item tag, two functions and a language file. Four runs, on +2026-11-05: | Step | Time | |---|---| -| Build a content root for one small mod (classification included) | see PR 2 | -| Client resource reload with that root | see PR 2 | -| Server data reload (`reload_world_tables`) | see PR 2 | -| Turn the mod off again | see PR 2 | +| Turn on the first time: build its content root, then reload the client's resources | 3.2 to 5.6 s | +| Turn off, or on again (the root is reused) | 1.1 to 1.7 s | +| Then the server's data reload (`reload_datapacks`, as `/reload`), on its own thread | 0.9 to 1.7 s | + +So a switch lands in about 2 to 3 s, and the first time a mod set is used in about 4 to 7 s. The root alone, on +the desktop (i7-13700KF, NVMe, under a load of 30 to 40), in #10217's test: built in 5.1 to 5.4 s, reused in 0 to +19 ms, over three runs. Most of a first switch is building the root; a set used before costs only the reloads. ## The in-game mod browser @@ -112,19 +116,19 @@ with the options screen's widgets (`options_ui`), every string through `Text::tr `assets/horizon/lang/*.json`. - **The index.** The same file the launcher reads (`horizon-mods` `index.json`, `HORIZON_MOD_INDEX` for a test - registry), fetched on a worker with the game's `ureq`, at most once every ten minutes, kept in - `mod-browser/horizon-index.json` beside `options.txt`. The launcher and the game share that copy, so an offline - game still lists what the launcher saw last. Parsed with `horizon_mods::index::Index::parse`, which refuses an - index with a bad entry as the launcher does. + registry), fetched on a worker with the game's `ureq` when the page opens (a Pages file, not the API: no rate + limit), kept in `mod-browser/horizon-index.json` beside `options.txt`. The launcher and the game share that copy, + so an offline game still lists what the launcher saw last. Parsed with `horizon_mods::index::Index::parse`, which + refuses an index with a bad entry as the launcher does. - **The list.** A search box and the projects this game can install, sorted by downloads: name, author, summary, its categories, and **Installed**, **Update** or nothing. A search matches the name, id, summary, tags and categories. - **A project's page.** Name, authors, licence, version and date, categories and tags, the summary and description (the index's markdown read as plain text: headings, lists and links shown as text), its changelog, and buttons: - **Install**, **Update**, **Turn on** or **Turn off**, **Remove**, and **Open page** (the site's page in the - browser). Screenshots come in a second step: the registry mirrors them as PNG and JPEG under its own Pages - ([registry-v2.md](registry-v2.md)); the page will draw them through the same dynamic-texture path as player skins - (`skin_slots`), one at a time, loaded when the page opens. + **Install** or **Update**, the mod's on and off switch once it is installed, and **Open page** (the site's page in + the browser). **Remove** stays in the launcher for now. Screenshots come in a second step: the registry mirrors + them as PNG and JPEG under its own Pages ([registry-v2.md](registry-v2.md)); the page will draw them through the + same dynamic-texture path as player skins (`skin_slots`), one at a time, loaded when the page opens. - **Install.** As the launcher's `install_version`: the newest version for this game that was not taken down, into `mod-browser/downloads/`, then `horizon_mods::index::check_file` (size, SHA-256, the author's signature, the file's manifest against the index), then moved to `horizon-mods/.hmod`, turned on in `horizon-mods.txt`, and @@ -165,26 +169,33 @@ A native mod's code is in the game's binary. What can be made fast: (#798) and the wasm runtime. They merge upstream through their own PRs; Horizon's side is to keep the hook catalog and `Hooks` dispatch shaped so both can sit behind it. - **A is the biggest win for the least risk, now.** Turning a native mod on or off is the common case (trying a mod, - finding a conflict, a modpack switch), and it needs no compiler at all if every installed native mod is already in - the build. The launcher builds every installed mod, whatever its `mod.` line says. The game skips a turned-off - trait mod in `Hooks` dispatch (`mod_api`), so it stops at once without its author doing anything, and mixin mods - keep honouring `native_on` (they paste code into the game's own functions, so only they can check). A mixin mod - that doesn't check is marked on its row: "This mod keeps running until the launcher's next build leaves it out". + finding a conflict, a modpack switch), and it needs no compiler at all while the mod is in the build. Step one + (#10218): the game skips a turned-off trait mod in `Hooks` dispatch (`mod_api`), so a mod that was on at launch + stops at once without its author doing anything, and starts again at once when turned back on. `RenderHooks` + run every frame over a plain list, so they are not switched yet, and mixin mods keep honouring `native_on` (they + paste code into the game's own functions, so only they can check). Step two: the launcher builds every installed + mod, whatever its `mod.` line says, so a mod that was off at launch also turns on without a build; it waits + for step one to settle, because a build with every installed mod costs players who keep many mods turned off. - **B comes next.** With A in, a build is needed only to install, update or remove. The launcher already rebuilds before play (#9901); B lets the player keep playing during it, and then restarts into the same world. ### Measured -On the desktop (i7-13700KF, `-j 8`, Linux, `rustc` 1.99, the launcher's `profile.fast`), building -`horizon-client` as the launcher does: +On the desktop (i7-13700KF, `-j 8`, Linux, `rustc` 1.99, the launcher's `profile.fast`, incremental), building +`horizon-client` as the launcher does, on 2026-11-05 while other builds kept the machine at a load of about 30 to 40 +(an idle machine is faster; the ratios are the point): | Build | Time | |---|---| -| Cold, empty target | see PR 3 | -| No change | see PR 3 | -| One line changed in gameplay (`game/mods.rs`), as a mixin into gameplay does | see PR 3 | -| One line changed in `horizon-world`, as a mixin into a lower crate does | see PR 3 | -| The gameplay change, linked with mold | see PR 3 | +| Cold, empty target | 812 s | +| No change | 55 s | +| One line changed in gameplay (`game/mods.rs`), as a mixin into gameplay does | 570 s (the revert: 668 s) | +| One line changed in `horizon-world`, as a mixin into a lower crate does | 427 s (the revert: 469 s) | +| The gameplay change, linked with mold | 612 s | + +Mold changes nothing: the time is spent compiling the gameplay crate again, not linking it. A native mod's change +costs most of a cold build, so the run-time switch (A) is what makes on and off fast, and only a smaller gameplay +crate or code outside the game's binary (C, E) makes a change fast. ## Security @@ -202,11 +213,12 @@ Nothing in this note widens what a mod can do. ## Plan, small PRs 1. This note. -2. The in-game browser and install, for content mods. -3. Live switching of content mods: a root per mod set, classification, the client and server reloads, tests that - load, unload and load again a fixture mod and check the recipes, tags and lang it adds. Measured on the desktop. -4. Native step A: every installed native mod built in, on and off at run time through `Hooks` dispatch. Measured on - the desktop. +2. The in-game browser and install, for content mods (#10104). +3. Live switching of content mods (#10217): a root per mod set, classification, the client and server reloads, + tests that load, unload and load again a fixture mod and check the recipes, tags and lang it adds. Measured in + game on the ThinkPad, the root alone on the desktop. +4. Native step A, first half (#10218): a trait mod built in turns off and on at run time through `Hooks` dispatch. Then + the launcher builds every installed mod, and `RenderHooks` get the same switch. Later: the world reload for registry changes (replaceable process-wide tables), screenshots on the project page, the other kinds in the browser, and B. From 1ab39a15ec4915fd0cbcb35c7a8826c6e4f95604 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Fri, 6 Nov 2026 01:16:25 +0100 Subject: [PATCH 29/30] Native mods: an error in the call horizon-mixins generates for a helper names the helper's mod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher traced a compile error to a mod only inside the helper function itself. horizon-mixins wraps each helper in a block that calls it, `{ fn helper(…) { … } helper(…) }`. When Horizon changes a type the helper's signature mentions (flair 1.0.0: `extra` became five-element tuples), the error lands on that generated call, so no mod was named and the whole build failed instead of leaving that mod out. A block holding only the helper and its call now counts as the helper's. Co-Authored-By: Claude Opus 5.5 --- launcher/src/native_mods/diagnostics.rs | 75 ++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/launcher/src/native_mods/diagnostics.rs b/launcher/src/native_mods/diagnostics.rs index 47875e985f..407dac2bd1 100644 --- a/launcher/src/native_mods/diagnostics.rs +++ b/launcher/src/native_mods/diagnostics.rs @@ -80,11 +80,13 @@ pub(super) fn capture(compiler: &Compiler<'_>, input: impl BufRead, mut log: imp output } -/// A file's functions (where each is, its name and its tokens) and inline modules (where, name). +/// A file's functions (where each is, its name and its tokens), inline modules (where, name), +/// and the blocks horizon-mixins wraps a helper in (where, the helper's name and tokens). #[derive(Default)] struct Items { functions: Vec<(Range, String, String)>, modules: Vec<(Range, String)>, + calls: Vec<(Range, String, String)>, } impl<'ast> Visit<'ast> for Items { @@ -104,13 +106,31 @@ impl<'ast> Visit<'ast> for Items { } syn::visit::visit_item_mod(self, module); } + + /// `{ fn helper(…) { … } helper(…) }` and nothing else: an error in the generated call (an + /// argument the helper's signature no longer takes) is the helper's too. + fn visit_block(&mut self, block: &'ast syn::Block) { + if let [syn::Stmt::Item(syn::Item::Fn(function)), syn::Stmt::Expr(syn::Expr::Call(call), _)] = + block.stmts.as_slice() + { + if matches!(&*call.func, syn::Expr::Path(path) if path.path.is_ident(&function.sig.ident)) + { + self.calls.push(( + block.span().byte_range(), + function.sig.ident.to_string(), + function.to_token_stream().to_string(), + )); + } + } + syn::visit::visit_block(self, block); + } } /// A file being touched is insufficient: the error must be inside what a mod brought, and its /// owner be unique. That is a helper (the function with its tokens, or with its name when the /// pristine source has no function of that name: the staged file may be formatted, a trailing -/// comma added, which changes its tokens and not its name), or a support module (the inline -/// module of its name, when the pristine source has none). +/// comma added, which changes its tokens and not its name, with the call horizon-mixins generates +/// for it), or a support module (the inline module of its name, when the pristine source has none). fn owner(compiler: &Compiler<'_>, plan: &Value, manifest: &Value, span: &Value) -> Option { let stage = compiler.stage.canonicalize().ok()?; let file = Path::new(span["file_name"].as_str()?); @@ -184,6 +204,7 @@ fn owner(compiler: &Compiler<'_>, plan: &Value, manifest: &Value, span: &Value) if staged .functions .iter() + .chain(&staged.calls) .any(|f| inside(&f.0) && is_helper(f)) { owners.insert(owner(entry)); @@ -429,4 +450,52 @@ mod tests { assert!(!error.contains("fast_mesh"), "{error}"); fs::remove_dir_all(dir).unwrap(); } + + /// flair 1.0.0 on Horizon 66f3981: Horizon widened `extra` to five-element tuples, so the + /// call horizon-mixins generates for the mod's helper, outside the helper itself, fails. + #[test] + fn an_error_in_the_generated_call_to_a_helper_names_its_mod() { + const FLAIR: &str = "fn flair_armor(extra: &[(u8, u8)]) -> usize { extra.len() }"; + let check = |dir: &Path, main: &str| { + fs::write(dir.join("src/main.rs"), main).unwrap(); + let output = Command::new(env!("CARGO")) + .args(["check", "--offline", "--message-format=json"]) + .arg("--manifest-path") + .arg(dir.join("Cargo.toml")) + .env("CARGO_TARGET_DIR", dir.join("target")) + .output() + .unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("E0308"), "{stdout}"); + stdout + }; + let dir = fixture("compiler-generated-call"); + fs::write( + dir.join("original/src/main.rs"), + "fn render(_extra: &[(u8, u8, bool)]) {}\nfn main() {}\n", + ) + .unwrap(); + plan(&dir, json!([entry("flair", "1.0.0", FLAIR)])); + let mods = vec![("flair".to_owned(), "1.0.0".to_owned())]; + let compiler = Compiler { + stage: &dir, + source: &dir.join("original"), + mods: &mods, + }; + let stdout = check( + &dir, + &format!("fn render(extra: &[(u8, u8, bool)]) {{\n {{\n {FLAIR}\n flair_armor(extra)\n }};\n}}\nfn main() {{ render(&[]); }}\n"), + ); + let output = capture(&compiler, Cursor::new(&stdout), Vec::new()); + assert_eq!(output.owners(), ["flair 1.0.0"]); + + // The same call in a block that also holds the game's own code is not proven the mod's. + let stdout = check( + &dir, + &format!("fn render(extra: &[(u8, u8, bool)]) {{\n {{\n {FLAIR}\n let _ = 1;\n flair_armor(extra)\n }};\n}}\nfn main() {{ render(&[]); }}\n"), + ); + let output = capture(&compiler, Cursor::new(&stdout), Vec::new()); + assert!(output.owners().is_empty(), "{:?}", output.owners()); + fs::remove_dir_all(dir).unwrap(); + } } From 2f075689f5c56ef3ef6ebfec0cfba67099a4758b Mon Sep 17 00:00:00 2001 From: Skr1zzzz <273194090+Skr1zzzz@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:47:29 +0300 Subject: [PATCH 30/30] Multiplayer: skip name tags whose entity is off screen before laying out their text Every frame built, styled and measured the name tag of every player within 64 blocks, drawn or not: with 150 players and the camera facing the sky that was ~0.74 ms a frame (#4664). A tag is now built only for an entity vanilla would render, its culling box grown by 0.5 in the frame's frustum (EntityRenderer.shouldRender), checked before any text work; armor stands likewise. Status and native snapshot queries still see every tag. other_players finds each player's entity through one id->index map instead of scanning every entity per player (the first match, as iter().find). --- crates/horizon-client/src/game/names.rs | 160 +++++++++++++++++++++--- 1 file changed, 144 insertions(+), 16 deletions(-) diff --git a/crates/horizon-client/src/game/names.rs b/crates/horizon-client/src/game/names.rs index f46c1fa079..4196e64863 100644 --- a/crates/horizon-client/src/game/names.rs +++ b/crates/horizon-client/src/game/names.rs @@ -64,9 +64,10 @@ impl Game<'_> { /// The other players' models and names: the players a server sends. fn other_players(&self) -> Vec<(&Mob, String)> { let mut out = Vec::new(); + let indices = first_mob_indices(&self.mobs); for (id, name) in self.remote_player_names() { - if let Some(m) = self.mobs.iter().find(|m| m.id == id) { - out.push((m, name)); + if let Some(&index) = indices.get(&id) { + out.push((&self.mobs[index], name)); } } out @@ -124,6 +125,15 @@ impl Game<'_> { self.name_tags_with_hooks(camera, alpha, !mod_api::mods().is_empty()) } fn name_tags_with_hooks(&self, camera: glam::DVec3, alpha: f64, hooks: bool) -> Vec { + self.collect_name_tags(camera, alpha, hooks, None) + } + fn collect_name_tags( + &self, + camera: glam::DVec3, + alpha: f64, + hooks: bool, + frustum: Option<&horizon_client::gfx::Frustum>, + ) -> Vec { let target = self.mob_hit().map(|(i, _, _)| self.mobs[i].id); let viewer = self.player_team(); let mut tags = Vec::new(); @@ -173,9 +183,27 @@ impl Game<'_> { // The name tag distance (64) is measured to the entity. let distance_sqr = glam::DVec3::new(m.position.x, m.position.y, m.position.z).distance_squared(camera); - if distance_sqr >= 64.0 * 64.0 { + // Match the model's renderer-specific box, not the tag anchor: the + // name must survive when only the entity's feet are in view. + let (lo, hi) = super::entity_culling::cull_box(m, alpha); + let inflate = glam::DVec3::splat(0.5); + if !name_tag_in_view( + frustum, + distance_sqr, + (lo - camera - inflate).as_vec3(), + (hi - camera + inflate).as_vec3(), + ) { continue; } + let marks = if player || self.cosmetics_local() { + self.cosmetics_marks(m, Some(m.id) == own) + } else { + Marks::default() + }; + let at = match marks.top { + Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()), + None => p, + }; // A player's scores and team go by name, a mob's by UUID. let holder = if player { Some(name.clone()) @@ -290,17 +318,6 @@ impl Game<'_> { (m.position.y + f64::from(m.height())).floor() as i32, m.position.z.floor() as i32, ); - // Horizon's mark before a Horizon player's name and their name tag style (cosmetics). - let marks = if player || self.cosmetics_local() { - self.cosmetics_marks(m, Some(m.id) == own) - } else { - Marks::default() - }; - // Horizon: over a tall cosmetic. Without one, vanilla's height, untouched. - let at = match marks.top { - Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()), - None => p, - }; tags.push(NameTag { at, lines, @@ -328,7 +345,27 @@ impl Game<'_> { }; let distance_sqr = glam::DVec3::new(a.position.x, a.position.y, a.position.z).distance_squared(camera); - if distance_sqr >= 64.0 * 64.0 { + let position = a.render_position(alpha); + // Same dimensions and dragon-head growth as armor_stand_geometry. + let width = if a.marker { + 0.0 + } else if a.small { + 0.25 + } else { + 0.5 + }; + let dragon = a.equipment[5] + .as_ref() + .is_some_and(|i| &*i.item == "minecraft:dragon_head"); + let grow = if dragon { 1.0 } else { 0.5 }; + let half = glam::DVec3::new(width / 2.0 + grow, grow, width / 2.0 + grow); + let origin = super::entity_culling::at(position) - camera; + if !name_tag_in_view( + frustum, + distance_sqr, + (origin - half).as_vec3(), + (origin + glam::DVec3::new(half.x, height + grow, half.z)).as_vec3(), + ) { continue; } let team = self.mob_team(a.id); @@ -471,7 +508,12 @@ impl Game<'_> { // max((0.25 + 0.75) / 2, 0.5), as ARGB.color (the floor of 255 times it). const FADED: u32 = 0x7F; let mut patches = Vec::new(); - for tag in self.name_tags(camera, alpha) { + for tag in self.collect_name_tags( + camera, + alpha, + !mod_api::mods().is_empty(), + self.view_frustum.as_ref(), + ) { let o = tag.at - camera; // `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its // background, blends. @@ -661,6 +703,25 @@ impl Game<'_> { } } +/// Use the renderer's camera-relative clip test before measuring or emitting text. +fn name_tag_in_view( + frustum: Option<&impl horizon_client::gfx::Cull>, + distance_sqr: f64, + min: glam::Vec3, + max: glam::Vec3, +) -> bool { + distance_sqr < 64.0 * 64.0 && frustum.is_none_or(|view| view.intersects(min, max)) +} + +fn first_mob_indices(mobs: &[Mob]) -> std::collections::HashMap { + let mut indices = std::collections::HashMap::with_capacity(mobs.len()); + for (index, mob) in mobs.iter().enumerate() { + // Match iter().find even if a transient snapshot contains duplicate IDs. + indices.entry(mob.id).or_insert(index); + } + indices +} + /// Collect entity IDs of all vehicles: entities that have passengers /// (mobs, players, text displays, armor stands, or interactions riding them). /// @@ -781,6 +842,73 @@ mod tests { use horizon_core::geometry::Vec3; use std::sync::Arc; + #[test] + fn name_tag_culling_keeps_intersecting_entity_boxes_within_distance() { + // A 90-degree camera looking down -Z, with near/far at 1/100. + struct View; + impl horizon_client::gfx::Cull for View { + fn intersects(&self, lo: glam::Vec3, hi: glam::Vec3) -> bool { + lo.z <= -1.0 + && hi.z >= -100.0 + && lo.x + lo.z <= 0.0 + && hi.x - lo.z >= 0.0 + && lo.y + lo.z <= 0.0 + && hi.y - lo.z >= 0.0 + } + fn contains(&self, lo: glam::Vec3, hi: glam::Vec3) -> bool { + self.intersects(lo, hi) + } + } + // The head/anchor at y=12 is outside the top plane, but the + // entity's feet are inside: vanilla still submits its name. + assert!(name_tag_in_view( + Some(&View), + 181.0, + glam::Vec3::new(-0.8, 8.5, -10.8), + glam::Vec3::new(0.8, 11.3, -9.2) + )); + for (offset, distance, expected) in [ + ([0.0, 0.0, -10.0], 100.0, true), + ([0.0, 0.0, 10.0], 100.0, false), + ([12.0, 0.0, -10.0], 244.0, false), + ([0.0, 12.0, -10.0], 244.0, false), + ([0.0, 0.0, -65.0], 4225.0, false), + ([0.0, 0.0, -64.0], 4096.0, false), + ([0.0, 0.0, -63.0], 3969.0, true), + ] { + assert_eq!( + name_tag_in_view( + Some(&View), + distance, + glam::Vec3::from_array(offset) - glam::Vec3::splat(0.5), + glam::Vec3::from_array(offset) + glam::Vec3::splat(0.5) + ), + expected + ); + } + } + + #[test] + fn name_tag_id_lookup_matches_scan_on_randomized_duplicate_lists() { + let mut seed = 4664_u32; + for len in [0, 1, 10, 150, 300] { + let mut mobs = Vec::new(); + for _ in 0..len { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + let mut mob = player_mob(); + mob.id = seed % 80; + mobs.push(mob); + } + let indices = first_mob_indices(&mobs); + for id in 0..100 { + assert_eq!( + indices.get(&id).copied(), + mobs.iter().position(|m| m.id == id) + ); + } + } + } + /// The player model `perspective::player_type` builds from the prepared /// resources, without them: its box only needs the id and the pointer. fn player_mob() -> Mob {