Merge #10312 into #10328 (both edit game/names.rs)

The name tag loop keeps #10312's frustum-culled collect_name_tags and
wraps it in #10328's layout cache frame; both test sets kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-06 09:23:27 +01:00
commit 109c355b33
47 files changed
+5731 -220

No files matched your search

+35
View File
@@ -225,6 +225,8 @@ pub const BIOME_STRATEGY: Strategy = Strategy::biomes(8);
/// (what vanilla servers and clients send), more when content mods add states /// (what vanilla servers and clients send), more when content mods add states
/// past 65,536 (docs/modpacks.md). Set once, before the first chunk. /// 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); 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 /// Block states are `PalettedContainer`s over the registry: vanilla's 16-bit
/// global palette, wider with enough content mods. /// 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)) 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 /// 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 /// when there are many: a teleport or a dimension change drops a few
/// thousand columns at once, and freeing their sections took the frame /// thousand columns at once, and freeing their sections took the frame
@@ -250,6 +281,10 @@ pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
pub fn set_block_states(states: usize) { pub fn set_block_states(states: usize) {
let bits = horizon_core::paletted::ceil_log2(states).max(16); let bits = horizon_core::paletted::ceil_log2(states).max(16);
BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed); 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(); forget_shared();
} }
+36 -2
View File
@@ -76,6 +76,7 @@ pub struct HudAtlas {
pub sprites: HashMap<String, Rect>, pub sprites: HashMap<String, Rect>,
/// Resolved player-profile names and UUIDs for inline head components. /// Resolved player-profile names and UUIDs for inline head components.
pub object_skins: HashMap<String, String>, pub object_skins: HashMap<String, String>,
pub object_skins_revision: u64,
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`), /// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
/// which puts their particles on vanilla's translucent layer. /// which puts their particles on vanilla's translucent layer.
pub translucent: HashSet<String>, pub translucent: HashSet<String>,
@@ -786,21 +787,29 @@ impl HudAtlas {
.or_else(|| self.sprites.get(MISSING_SPRITE)) .or_else(|| self.sprites.get(MISSING_SPRITE))
.copied() .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. /// Keep a ready profile skin when another source only has its default.
pub fn set_object_skin(&mut self, key: String, skin: String) { pub fn set_object_skin(&mut self, key: String, skin: String) {
use std::collections::hash_map::Entry; use std::collections::hash_map::Entry;
if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) { 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) { match self.object_skins.entry(key) {
Entry::Occupied(entry) Entry::Occupied(entry)
if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => { if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => {
} }
Entry::Occupied(mut entry) => { 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::Vacant(entry) => {
entry.insert(skin); entry.insert(skin);
self.object_skins_revision = Self::next_revision();
} }
} }
} }
@@ -920,6 +929,7 @@ impl HudAtlas {
height: 1, height: 1,
sprites: HashMap::new(), sprites: HashMap::new(),
object_skins: HashMap::new(), object_skins: HashMap::new(),
object_skins_revision: Self::next_revision(),
translucent: HashSet::new(), translucent: HashSet::new(),
flags: HashMap::new(), flags: HashMap::new(),
sprite_animations: Vec::new(), sprite_animations: Vec::new(),
@@ -985,6 +995,7 @@ impl HudAtlas {
translucent: HashSet::new(), translucent: HashSet::new(),
flags: HashMap::new(), flags: HashMap::new(),
object_skins: HashMap::new(), object_skins: HashMap::new(),
object_skins_revision: Self::next_revision(),
sprite_animations: Vec::new(), sprite_animations: Vec::new(),
glint: (vec![0; 4], 1, 1), glint: (vec![0; 4], 1, 1),
glint_armor: (vec![0; 4], 1, 1), glint_armor: (vec![0; 4], 1, 1),
@@ -1436,6 +1447,7 @@ impl HudAtlas {
height, height,
sprites, sprites,
object_skins: HashMap::new(), object_skins: HashMap::new(),
object_skins_revision: Self::next_revision(),
translucent, translucent,
flags, flags,
sprite_animations, sprite_animations,
@@ -2890,6 +2902,28 @@ impl HudRenderer {
mod tests { mod tests {
use super::*; 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. /// The mod image slot is paid only by a game with a native mod loaded.
#[test] #[test]
fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() { fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() {
+33 -10
View File
@@ -1630,6 +1630,11 @@ pub struct OptionsUi {
pub mod_browser: crate::mod_browser::ModBrowser, pub mod_browser: crate::mod_browser::ModBrowser,
/// The project whose page is open (`Page::ModProject`). /// The project whose page is open (`Page::ModProject`).
pub mod_project: String, pub mod_project: String,
/// The Mods page's line about switches that wait for a restart.
pub mods_note: Option<String>,
/// 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<String>,
/// Horizon's `packBrowser` extra: the pack screen's Add button. /// Horizon's `packBrowser` extra: the pack screen's Add button.
pub pack_browser: bool, pub pack_browser: bool,
/// Packs: the packs found and the selection the screen opens with. /// Packs: the packs found and the selection the screen opens with.
@@ -1720,6 +1725,8 @@ impl Default for OptionsUi {
settings_search, settings_search,
mod_browser: Default::default(), mod_browser: Default::default(),
mod_project: String::new(), mod_project: String::new(),
mods_note: None,
mods_live: Default::default(),
pack_browser: false, pack_browser: false,
pack_infos: Vec::new(), pack_infos: Vec::new(),
pack_selection: Vec::new(), pack_selection: Vec::new(),
@@ -2588,6 +2595,14 @@ impl OptionsUi {
} }
/// A registry project's page: what it is, Install or Update, its switch /// A registry project's page: what it is, Install or Update, its switch
/// once installed, its description and changelog. /// 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<Row> { fn mod_project_rows(&self, s: &Settings) -> Vec<Row> {
let b = &self.mod_browser; let b = &self.mod_browser;
let Some(l) = b.listing(&self.mod_project) else { let Some(l) = b.listing(&self.mod_project) else {
@@ -2659,6 +2674,10 @@ impl OptionsUi {
Text::literal(format!("{}: ", lang::get("horizon.mods.browse.enabled"))) Text::literal(format!("{}: ", lang::get("horizon.mods.browse.enabled")))
.append(on_off(s.mods.is_on(i))); .append(on_off(s.mods.is_on(i)));
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(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(install, Some(toggle)));
rows.push(Row::Buttons(page, None)); rows.push(Row::Buttons(page, None));
} }
@@ -3069,6 +3088,9 @@ impl OptionsUi {
Act::Push(Page::ModBrowser), Act::Push(Page::ModBrowser),
); );
let mut rows = vec![Row::Buttons(browse, None)]; 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() { if s.mods.installed.is_empty() {
rows.push(Row::Header("horizon.mods.empty")); rows.push(Row::Header("horizon.mods.empty"));
return rows; return rows;
@@ -3085,16 +3107,11 @@ impl OptionsUi {
} else if installed.dev { } else if installed.dev {
label = label.append(tr("horizon.mods.dev")); label = label.append(tr("horizon.mods.dev"));
} }
// A content mod's set feeds the block-state tables the let frozen = self.mod_frozen(installed);
// open world's chunks were decoded with: changing it if frozen {
// 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 {
label = label.append(tr("horizon.mods.in_world")); 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. // Only native mods with actual settings need a second button.
if installed if installed
.native .native
@@ -7806,9 +7823,10 @@ mod tests {
/// A content mod's set builds the block-state tables an open world's /// 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 /// 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] #[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 mut s = Settings::default();
let installed = let installed =
|id: &str, native: Option<crate::mod_list::NativePage>| crate::mod_list::Installed { |id: &str, native: Option<crate::mod_list::NativePage>| crate::mod_list::Installed {
@@ -7842,6 +7860,11 @@ mod tests {
assert_eq!(toggles(&mut ui, &s), [(0, true), (1, true)]); assert_eq!(toggles(&mut ui, &s), [(0, true), (1, true)]);
ui.open_at(true, Page::Mods); ui.open_at(true, Page::Mods);
assert_eq!(toggles(&mut ui, &s), [(0, false), (1, true)]); 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)]);
} }
/// An Extras or PvP & HUD label wider than half a row takes the whole row, so every label /// An Extras or PvP & HUD label wider than half a row takes the whole row, so every label
+89
View File
@@ -751,6 +751,33 @@ 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,
/// 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)] #[derive(Clone, Debug, PartialEq)]
pub struct Pvp { pub struct Pvp {
pub crosshair: Crosshair, pub crosshair: Crosshair,
@@ -763,6 +790,7 @@ pub struct Pvp {
pub layout: crate::pvp_hud::Layout, pub layout: crate::pvp_hud::Layout,
/// The key that opens the HUD editor from the game (unbound at first). /// The key that opens the HUD editor from the game (unbound at first).
pub editor_key: String, pub editor_key: String,
pub combat: Combat,
} }
/// The HUD editor key's name for the controls (`Extras::set_key`). /// The HUD editor key's name for the controls (`Extras::set_key`).
pub const HUD_EDITOR_KEY: &str = "horizon.hud_editor"; pub const HUD_EDITOR_KEY: &str = "horizon.hud_editor";
@@ -777,6 +805,7 @@ impl Default for Pvp {
chat: ChatOptions::default(), chat: ChatOptions::default(),
layout: crate::pvp_hud::Layout::default(), layout: crate::pvp_hud::Layout::default(),
editor_key: "key.keyboard.unknown".into(), editor_key: "key.keyboard.unknown".into(),
combat: Combat::default(),
} }
} }
} }
@@ -877,6 +906,13 @@ impl Pvp {
"chatCompact" => self.chat.compact = flag(value, self.chat.compact), "chatCompact" => self.chat.compact = flag(value, self.chat.compact),
"chatCopy" => self.chat.copy = flag(value, self.chat.copy), "chatCopy" => self.chat.copy = flag(value, self.chat.copy),
"chatSearch" => self.chat.search = flag(value, self.chat.search), "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),
"combatSounds" => self.combat.sounds = flag(value, self.combat.sounds),
"viewTime" => { "viewTime" => {
t.time = match value { t.time = match value {
"off" => None, "off" => None,
@@ -1001,6 +1037,13 @@ impl Pvp {
"chatTimestamps:{}\nchatCompact:{}\nchatCopy:{}\nchatSearch:{}\n", "chatTimestamps:{}\nchatCompact:{}\nchatCopy:{}\nchatSearch:{}\n",
c.timestamps, c.compact, c.copy, c.search, 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:{}\ncombatSounds:{}\n",
c.indicator, c.sounds
);
} }
} }
@@ -1139,6 +1182,12 @@ opts! {
"Clicking a message in the open chat copies its text, unless the click does something else."; "Clicking a message in the open chat copies its text, unless the click does something else.";
ChatSearch, "chat_search", "Chat search", 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."; "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.";
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. /// The PvP & HUD page: headers (language keys) and options.
pub fn rows() -> Vec<Result<Opt, &'static str>> { pub fn rows() -> Vec<Result<Opt, &'static str>> {
@@ -1218,6 +1267,8 @@ pub fn rows() -> Vec<Result<Opt, &'static str>> {
rows.extend([ViewTime, ViewWeather].map(Ok)); rows.extend([ViewTime, ViewWeather].map(Ok));
rows.push(Err("horizon.pvp.header.chat")); rows.push(Err("horizon.pvp.header.chat"));
rows.extend([ChatTimestamps, ChatCompact, ChatCopy, ChatSearch].map(Ok)); rows.extend([ChatTimestamps, ChatCompact, ChatCopy, ChatSearch].map(Ok));
rows.push(Err("horizon.pvp.header.combat"));
rows.extend([CombatFeel, CombatIndicator, CombatSounds].map(Ok));
rows rows
} }
impl Opt { impl Opt {
@@ -1241,6 +1292,7 @@ impl Opt {
FireLower | ShieldSize | TotemSize | GlintColour | ClearWater | ClearLava => None, FireLower | ShieldSize | TotemSize | GlintColour | ClearWater | ClearLava => None,
ViewTime | ViewWeather => None, ViewTime | ViewWeather => None,
ChatTimestamps | ChatCompact | ChatCopy | ChatSearch => None, ChatTimestamps | ChatCompact | ChatCopy | ChatSearch => None,
CombatFeel | CombatIndicator | CombatSounds => None,
HudPingCorner => Some(HudPing), HudPingCorner => Some(HudPing),
HudCoordsCorner => Some(HudCoords), HudCoordsCorner => Some(HudCoords),
HudServerCorner => Some(HudServer), HudServerCorner => Some(HudServer),
@@ -1282,6 +1334,8 @@ impl Opt {
Opt::ChatCompact => p.chat.compact, Opt::ChatCompact => p.chat.compact,
Opt::ChatCopy => p.chat.copy, Opt::ChatCopy => p.chat.copy,
Opt::ChatSearch => p.chat.search, Opt::ChatSearch => p.chat.search,
Opt::CombatIndicator => p.combat.indicator,
Opt::CombatSounds => p.combat.sounds,
_ => return None, _ => return None,
}) })
} }
@@ -1333,6 +1387,10 @@ impl Opt {
Some(colour) => colour.label(), Some(colour) => colour.label(),
None => crate::lang::get_or("Off", "Off"), 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::ViewTime => p.tweaks.time.map_or_else(world_label, ViewTime::label),
Opt::ViewWeather => p Opt::ViewWeather => p
.tweaks .tweaks
@@ -1432,6 +1490,9 @@ impl Opt {
Opt::ChatCompact => p.chat.compact = !p.chat.compact, Opt::ChatCompact => p.chat.compact = !p.chat.compact,
Opt::ChatCopy => p.chat.copy = !p.chat.copy, Opt::ChatCopy => p.chat.copy = !p.chat.copy,
Opt::ChatSearch => p.chat.search = !p.chat.search, 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 => { Opt::ViewWeather => {
let all: Vec<_> = std::iter::once(None) let all: Vec<_> = std::iter::once(None)
.chain(ViewWeather::ALL.map(Some)) .chain(ViewWeather::ALL.map(Some))
@@ -1734,4 +1795,32 @@ mod tests {
assert_eq!(en.get(key), Some(*english), "{key}"); 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));
}
} }
@@ -115,8 +115,11 @@
"horizon.mods.browse.open_page": "Open Page", "horizon.mods.browse.open_page": "Open Page",
"horizon.mods.browse.enabled": "Mod", "horizon.mods.browse.enabled": "Mod",
"horizon.mods.browse.changelog": "Changelog", "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.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.empty": "No shader packs: put them in the shaderpacks folder",
"horizon.shader_packs.kind_horizon": " (Horizon)", "horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Shader Options...", "horizon.shader_options.open": "Shader Options...",
@@ -815,6 +818,15 @@
"horizon.pvp.editor": "Edit HUD", "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.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.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",
"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", "Bindings": "Bindings",
"Play": "Play", "Play": "Play",
"Device": "Device", "Device": "Device",
@@ -63,8 +63,11 @@
"horizon.mods.browse.open_page": "Ouvrir la page", "horizon.mods.browse.open_page": "Ouvrir la page",
"horizon.mods.browse.enabled": "Mod", "horizon.mods.browse.enabled": "Mod",
"horizon.mods.browse.changelog": "Nouveautés", "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.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_packs.empty": "Aucun pack de shaders : placez-les dans le dossier shaderpacks",
"horizon.shader_options.open": "Options des shaders...", "horizon.shader_options.open": "Options des shaders...",
"horizon.shader_options.profile": "Profil", "horizon.shader_options.profile": "Profil",
+52 -2
View File
@@ -346,12 +346,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<Request>) { fn serve(stream: Stream, sender: &mpsc::Sender<Request>) {
let Ok(mut writer) = stream.try_clone() else { let Ok(mut writer) = stream.try_clone() else {
return; return;
}; };
for line in BufReader::new(stream).lines() { let mut reader = BufReader::new(stream);
let Ok(line) = line else { return }; 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() { if line.trim().is_empty() {
continue; continue;
} }
@@ -428,6 +449,35 @@ mod tests {
assert!(!path.exists()); 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] #[test]
fn tcp_refuses_a_wrong_token() { fn tcp_refuses_a_wrong_token() {
let path = let path =
+195 -1
View File
@@ -168,6 +168,128 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
m 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<glam::Mat4> {
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 /// 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 /// hands when it is the main hand's and the off hand is empty
/// (`renderTwoHandedMap`), else in its own hand (`renderOneHandedMap`). /// (`renderTwoHandedMap`), else in its own hand (`renderOneHandedMap`).
@@ -479,13 +601,24 @@ impl HandHeights {
/// `getItemSwapScale(1.0F)`. Returns whether each hand's visible item /// `getItemSwapScale(1.0F)`. Returns whether each hand's visible item
/// is replaced at the end. /// is replaced at the end.
pub fn tick(&mut self, hands_busy: bool, swap_scale: f32, changed: [bool; 2]) -> [bool; 2] { 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_main = self.main;
self.old_off = self.off; self.old_off = self.off;
if hands_busy { if hands_busy {
self.main = (self.main - 0.4).clamp(0.0, 1.0); self.main = (self.main - 0.4).clamp(0.0, 1.0);
self.off = (self.off - 0.4).clamp(0.0, 1.0); self.off = (self.off - 0.4).clamp(0.0, 1.0);
} else { } else {
let attack = swap_scale; let attack = if legacy { 1.0 } else { swap_scale };
let main_target = if changed[0] { let main_target = if changed[0] {
0.0 0.0
} else { } else {
@@ -1099,6 +1232,67 @@ mod tests {
assert_eq!(swing_duration(d, Some(1), Some(0)), 4); assert_eq!(swing_duration(d, Some(1), Some(0)), 4);
assert_eq!(swing_duration(d, None, Some(2)), 12); 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)] #[cfg(test)]
+46 -2
View File
@@ -8883,7 +8883,13 @@ impl Game<'_> {
self.draw_spyglass(&mut batch); self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth / // Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
// extractVehicleHealth and the experience bar, laid out by hud_layout. // 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 delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
let strength = self.hand_strength.scale(0.0, 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. // The 3D crosshair (F3) takes the 2D one's place.
@@ -14527,6 +14533,21 @@ impl Game<'_> {
.is_some_and(|p| p.auto_spin_attack_ticks > 0); .is_some_and(|p| p.auto_spin_attack_ticks > 0);
let items = self.visible_hand_items(); let items = self.visible_hand_items();
let off_hand_empty = items[1].is_none(); 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() { for (i, (hand, item)) in Hand::BOTH.into_iter().zip(items).enumerate() {
if !shown[i] { if !shown[i] {
continue; continue;
@@ -14577,6 +14598,10 @@ impl Game<'_> {
}; };
// The main hand on its arm, the off hand on the other one. // The main hand on its arm, the off hand on the other one.
let left = self.settings.main_hand_left != (hand == Hand::Off); 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 { let Some(stack) = item else {
// renderArmWithItem: only the main hand's empty arm shows, // renderArmWithItem: only the main hand's empty arm shows,
// on the main arm (the left one with the main hand set to // on the main arm (the left one with the main hand set to
@@ -14720,6 +14745,23 @@ impl Game<'_> {
horizon_client::cit::Hand::Main horizon_client::cit::Hand::Main
}; };
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, cit_hand); 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)); runs.push((out.len() as u32, item_id));
let light = self.world.light( let light = self.world.light(
eye[0].floor() as i32, eye[0].floor() as i32,
@@ -20427,7 +20469,9 @@ impl Game<'_> {
&self.motion.health &self.motion.health
}; };
let health = base.with_packs(root, &overlay, reload)?; 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)); return Ok((None, health));
} }
let mut items = Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay)?; let mut items = Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay)?;
+8 -6
View File
@@ -1132,11 +1132,12 @@ impl Game<'_> {
log( log(
"INFO", "INFO",
&format!( &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.expect.as_secs(),
s.max_expect.as_secs(), s.max_expect.as_secs(),
s.max_expected, s.max_expected,
s.max_freeze_ticks s.max_freeze_ticks,
s.max_hold_ticks
), ),
); );
host.hub.transfers = Some(transfers); host.hub.transfers = Some(transfers);
@@ -1392,11 +1393,12 @@ impl Game<'_> {
pub(super) fn update_dedicated_streaming(&mut self) { pub(super) fn update_dedicated_streaming(&mut self) {
let rule = self.ui.rules.bool("spectators_generate_chunks"); let rule = self.ui.rules.bool("spectators_generate_chunks");
let guest = self.lan.as_ref().and_then(|h| { 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 h.hub
.guests .loading_chunks(|g| super::game_mode::loads_chunks(rule, g.spectator()))
.iter() .first()
.find(|g| h.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator())) .copied()
.map(|g| g.chunk())
}); });
if self.standalone_server() { if self.standalone_server() {
self.streaming.radius = if guest.is_some() { self.streaming.radius = if guest.is_some() {
@@ -3,8 +3,9 @@
//! (including their positions) and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged; //! (including their positions) and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged;
//! `server-events <since>` answers the typed events after that number (events.rs); `management <connection> //! `server-events <since>` answers the typed events after that number (events.rs); `management <connection>
//! <json>` edits the lists as vanilla's management protocol does (management.rs). //! <json>` edits the lists as vanilla's management protocol does (management.rs).
//! `player-expect <uuid> [seconds]`, `player-export <uuid> [freeze]` and `player-release <uuid> ...` are a //! `player-expect <uuid> [seconds]`, `player-drop <uuid>`, `player-export <uuid> [freeze]`,
//! proxy's, for a player it moves between two servers (transfer.rs). //! `player-release <uuid> ...`, `player-import <uuid> <json>` and `player-confirm <uuid> ...` are a proxy's,
//! for a player it moves between two servers (transfer.rs).
use super::{ServerState, capture_log}; use super::{ServerState, capture_log};
use crate::game::Game; use crate::game::Game;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -1,8 +1,10 @@
//! A proxy's requests on the control endpoint, for a player it moves between two servers: //! A proxy's requests on the control endpoint, for a player it moves between two servers:
//! `player-expect <uuid> [seconds]` (the target is told who is coming), //! `player-expect <uuid> [seconds]` (the target is told who is coming; its login is held),
//! `player-drop <uuid>` (the target forgets it, and lets a held player go),
//! `player-export <uuid> [freeze]` (the source answers with the player's record) and //! `player-export <uuid> [freeze]` (the source answers with the player's record) and
//! `player-release <uuid> <hand-over> moved [announce] | abort` (the source lets the player //! `player-release <uuid> <hand-over> moved [announce] | abort` (the source lets the player go),
//! go). They run //! `player-import <uuid> <json>` and `player-confirm <uuid> <hand-over>` (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 //! 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 //! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s
//! `[transfer]` is off. //! `[transfer]` is off.
@@ -14,8 +16,30 @@ use std::time::Instant;
impl Game<'_> { impl Game<'_> {
/// A proxy's `player-*` request; the reply is one JSON line, `{"ok": true, ...}` or /// 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 { 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 { let Some(host) = self.lan.as_ref() else {
return json!({ "error": "the server is not open to players" }); return json!({ "error": "the server is not open to players" });
}; };
@@ -27,7 +51,13 @@ impl Game<'_> {
Some(host) => arrivals::expect_request(&mut host.hub, args, Instant::now()), Some(host) => arrivals::expect_request(&mut host.hub, args, Instant::now()),
None => json!({ "error": "the server is not open to players" }), 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-export" => self.player_export(args),
"player-import" => self.player_import(args),
"player-confirm" => self.player_confirm(args),
"player-release" => match self.lan.as_mut() { "player-release" => match self.lan.as_mut() {
Some(host) => arrivals::release_request(&mut host.hub, args), Some(host) => arrivals::release_request(&mut host.hub, args),
None => json!({ "error": "the server is not open to players" }), None => json!({ "error": "the server is not open to players" }),
@@ -61,6 +91,168 @@ impl Game<'_> {
reply reply
} }
/// `player-import <uuid> <json>`: 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" });
};
// 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,
"recentered": host.hub.guests[i].arrival_recentered,
"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) => {
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,
"recentered": done.recentered,
}),
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 <uuid> <hand-over>`: 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) {
// 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
.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<bool, String> {
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)
.and_then(|()| self.write_arrival_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 { fn export_from(&mut self, host: &mut LanHost, uuid: u128, freeze: bool) -> Value {
let i = match arrivals::check_exportable(&host.hub, uuid) { let i = match arrivals::check_exportable(&host.hub, uuid) {
Ok(i) => i, Ok(i) => i,
+2 -1
View File
@@ -510,10 +510,11 @@ impl Game<'_> {
changed[h] = !same; changed[h] = !same;
} }
let speed = self.main_hand_attack_speed(); let speed = self.main_hand_attack_speed();
let replace = self.heights.tick( let replace = self.heights.tick_with(
self.hands_busy(), self.hands_busy(),
self.hand_strength.swap_scale(1.0, speed), self.hand_strength.swap_scale(1.0, speed),
changed, changed,
self.settings.extras.pvp.combat.legacy,
); );
for h in 0..2 { for h in 0..2 {
if replace[h] { if replace[h] {
@@ -234,6 +234,11 @@ impl Integrated {
Err(RecvTimeoutError::Disconnected) => None, 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. /// `IntegratedServer.halt(true)`: returns once the world is saved and let go.
pub fn stop(mut self) -> Result<()> { pub fn stop(mut self) -> Result<()> {
self.halt() self.halt()
+67 -15
View File
@@ -1709,6 +1709,37 @@ impl Game<'_> {
.write(&super::stats::player_json(&self.world_root, "stats", uuid)); .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 /// `PlayerAdvancements.load` and `ServerStatsCounter.parseLocal` of a
/// network player who joined; every statistic goes out on the first /// network player who joined; every statistic goes out on the first
/// request (`PlayerList.placeNewPlayer`'s `markAllDirty`). /// request (`PlayerList.placeNewPlayer`'s `markAllDirty`).
@@ -2048,10 +2079,18 @@ impl Game<'_> {
super::dedicated::java_double(z) super::dedicated::java_double(z)
), ),
); );
super::dedicated::log( // A held player is not announced: it is not in the world yet.
"INFO", if g.held {
&format!("System chat: {} joined the game", g.name), 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() } else if self.player.is_some()
&& let Some(g) = host.hub.guest(conn) && let Some(g) = host.hub.guest(conn)
&& g.placed && g.placed
@@ -2102,20 +2141,33 @@ impl Game<'_> {
self.guest_attachments.remove(&guest.uuid); self.guest_attachments.remove(&guest.uuid);
host.play.remove(&guest.conn); host.play.remove(&guest.conn);
self.guest_combat.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( super::dedicated::events::record(
"leave", "leave",
&guest.name, &guest.name,
guest.uuid, guest.uuid,
serde_json::json!({ "reason": guest.left_reason }), serde_json::json!({ "reason": guest.left_reason }),
); );
super::dedicated::log( if guest.held && !guest.placed {
"INFO", // Never announced, so no "left the game" either.
&format!("{} lost connection: {}", guest.name, guest.left_reason), 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 // A player moved to another server without `announce` was not
// announced as leaving, so the line is not logged either. // announced as leaving, so the line is not logged either; a held
if !guest.quiet_leave { // player was never announced at all.
if guest.placed && !guest.quiet_leave {
super::dedicated::log( super::dedicated::log(
"INFO", "INFO",
&format!("System chat: {} left the game", guest.name), &format!("System chat: {} left the game", guest.name),
@@ -3106,12 +3158,12 @@ impl Game<'_> {
// distance + 1) and those chunks' lit neighbours. // distance + 1) and those chunks' lit neighbours.
let radius = host.hub.config.view_distance + 1; let radius = host.hub.config.view_distance + 1;
let rule = self.ui.rules.bool("spectators_generate_chunks"); 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 self.streaming.extra = host
.hub .hub
.guests .loading_chunks(|g| super::game_mode::loads_chunks(rule, g.spectator()))
.iter() .into_iter()
.filter(|g| host.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator())) .map(|chunk| (chunk, radius))
.map(|g| (g.chunk(), radius))
.chain( .chain(
self.arrival_chunks(self.dim) self.arrival_chunks(self.dim)
.into_iter() .into_iter()
@@ -540,6 +540,10 @@ impl Game<'_> {
let Some(options) = self.received_particle(&p.particle, p.pos) else { let Some(options) = self.received_particle(&p.particle, p.pos) else {
return; 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)); let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
// ClientLevel.addParticle: the camera's 32 blocks and the Particles // ClientLevel.addParticle: the camera's 32 blocks and the Particles
// option, unless the packet or the type overrides the limiter. // option, unless the packet or the type overrides the limiter.
@@ -153,6 +153,15 @@ impl Game<'_> {
if installed { if installed {
self.settings.save(&Self::options_path()); self.settings.save(&Self::options_path());
self.start_mods(); 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}");
}
} }
} }
} }
+146
View File
@@ -3,6 +3,7 @@
//! (`native_hooks.rs`); WebAssembly code mods don't run any more. //! (`native_hooks.rs`); WebAssembly code mods don't run any more.
use super::Game; use super::Game;
use horizon_client::{ use horizon_client::{
lang,
mod_list::{self, Installed}, mod_list::{self, Installed},
text::Text, text::Text,
}; };
@@ -16,6 +17,20 @@ pub(super) struct ModsState {
stopped: Vec<String>, stopped: Vec<String>,
/// Browse mods' worker (`mod_browser`). /// Browse mods' worker (`mod_browser`).
pub(super) jobs: super::mod_browser::Jobs, 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<std::collections::BTreeSet<String>>,
/// 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<std::time::Instant>,
/// 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<String>,
live_ids: std::collections::BTreeSet<String>,
/// 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. /// Freeze native component IDs before reading content or opening a connection.
@@ -310,6 +325,29 @@ fn native_mods_detail(built: &str) -> Option<String> {
(!list.is_empty()).then_some(list) (!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<String>,
live_ids: &mut std::collections::BTreeSet<String>,
) {
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` (`<id>.hnmod`) that this game wasn't /// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
/// built with (`built`: its [`BUILD_INFO`], empty for a release or Nightly) and that has no row /// 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). /// yet. The page said "No mods" before, and players copied the files into `horizon-mods` (#4770).
@@ -362,6 +400,7 @@ impl Game<'_> {
let mut paths = horizon_mods::find(&dir); let mut paths = horizon_mods::find(&dir);
paths.retain(|p| !self.mods.dev.contains(p)); paths.retain(|p| !self.mods.dev.contains(p));
paths.extend(self.mods.dev.iter().cloned()); paths.extend(self.mods.dev.iter().cloned());
let (mut content_ids, mut live_ids) = Default::default();
self.settings.mods.installed = paths self.settings.mods.installed = paths
.iter() .iter()
.map(|path| { .map(|path| {
@@ -381,6 +420,7 @@ impl Game<'_> {
}; };
let m = &package.manifest; let m = &package.manifest;
let problem = package.wasm.is_some().then(|| STOPPED.to_owned()); let problem = package.wasm.is_some().then(|| STOPPED.to_owned());
live_switch_of(path, &package, &mut content_ids, &mut live_ids);
Installed { Installed {
id: m.id.clone(), id: m.id.clone(),
title: format!("{} {}", m.name, m.version), title: format!("{} {}", m.name, m.version),
@@ -391,6 +431,11 @@ impl Game<'_> {
} }
}) })
.collect(); .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());
}
// A mod with a native version built in says so instead, and isn't news. // A mod with a native version built in says so instead, and isn't news.
self.list_native_mods(); self.list_native_mods();
self.sync_native_switches(); self.sync_native_switches();
@@ -422,6 +467,107 @@ impl Game<'_> {
.map(|row| row.title.clone()) .map(|row| row.title.clone())
.collect(); .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<String> {
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<String> {
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;
}
// Native mods' `mod.<id>` 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 !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);
}
}
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));
if !changed {
self.mods.applied = Some(next);
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);
g.mods.data_switched = true;
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);
}
}
/// Trait mods follow the player's switches at once /// Trait mods follow the player's switches at once
/// (`mod_api::set_mod_on`): the next dispatch skips a mod turned off. /// (`mod_api::set_mod_on`): the next dispatch skips a mod turned off.
/// Mixin code checks `native_on` itself. /// Mixin code checks `native_on` itself.
+144 -16
View File
@@ -163,9 +163,10 @@ impl Game<'_> {
/// The other players' models and names: the players a server sends. /// The other players' models and names: the players a server sends.
fn other_players(&self) -> Vec<(&Mob, String)> { fn other_players(&self) -> Vec<(&Mob, String)> {
let mut out = Vec::new(); let mut out = Vec::new();
let indices = first_mob_indices(&self.mobs);
for (id, name) in self.remote_player_names() { for (id, name) in self.remote_player_names() {
if let Some(m) = self.mobs.iter().find(|m| m.id == id) { if let Some(&index) = indices.get(&id) {
out.push((m, name)); out.push((&self.mobs[index], name));
} }
} }
out out
@@ -223,6 +224,15 @@ impl Game<'_> {
self.name_tags_with_hooks(camera, alpha, !mod_api::mods().is_empty()) 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<NameTag> { fn name_tags_with_hooks(&self, camera: glam::DVec3, alpha: f64, hooks: bool) -> Vec<NameTag> {
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<NameTag> {
let target = self.mob_hit().map(|(i, _, _)| self.mobs[i].id); let target = self.mob_hit().map(|(i, _, _)| self.mobs[i].id);
let viewer = self.player_team(); let viewer = self.player_team();
let mut tags = Vec::new(); let mut tags = Vec::new();
@@ -272,9 +282,27 @@ impl Game<'_> {
// The name tag distance (64) is measured to the entity. // The name tag distance (64) is measured to the entity.
let distance_sqr = let distance_sqr =
glam::DVec3::new(m.position.x, m.position.y, m.position.z).distance_squared(camera); 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; 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. // A player's scores and team go by name, a mob's by UUID.
let holder = if player { let holder = if player {
Some(name.clone()) Some(name.clone())
@@ -389,17 +417,6 @@ impl Game<'_> {
(m.position.y + f64::from(m.height())).floor() as i32, (m.position.y + f64::from(m.height())).floor() as i32,
m.position.z.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 { tags.push(NameTag {
at, at,
lines, lines,
@@ -427,7 +444,27 @@ impl Game<'_> {
}; };
let distance_sqr = let distance_sqr =
glam::DVec3::new(a.position.x, a.position.y, a.position.z).distance_squared(camera); 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; continue;
} }
let team = self.mob_team(a.id); let team = self.mob_team(a.id);
@@ -572,7 +609,12 @@ impl Game<'_> {
let mut patches = Vec::new(); let mut patches = Vec::new();
let mut layouts = std::mem::take(&mut self.name_tag_layouts); let mut layouts = std::mem::take(&mut self.name_tag_layouts);
layouts.begin_frame(&self.gui.atlas); layouts.begin_frame(&self.gui.atlas);
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; let o = tag.at - camera;
// `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its // `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its
// background, blends. // background, blends.
@@ -779,6 +821,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<u32, usize> {
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 /// Collect entity IDs of all vehicles: entities that have passengers
/// (mobs, players, text displays, armor stands, or interactions riding them). /// (mobs, players, text displays, armor stands, or interactions riding them).
/// ///
@@ -1028,6 +1089,73 @@ mod tests {
assert_ne!(tag_bytes(&mut cache, &atlas, &text, 255, false), first); assert_ne!(tag_bytes(&mut cache, &atlas, &text, 255, false), first);
} }
#[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 /// The player model `perspective::player_type` builds from the prepared
/// resources, without them: its box only needs the id and the pointer. /// resources, without them: its box only needs the id and the pointer.
fn player_mob() -> Mob { fn player_mob() -> Mob {
@@ -125,8 +125,10 @@ impl Game<'_> {
self.options_from_title = from_title; self.options_from_title = from_title;
self.options_to_game = false; self.options_to_game = false;
let minecart_improvements = self.minecart_improvements(); let minecart_improvements = self.minecart_improvements();
let mods_live = self.mods_live_now();
let ui = &mut self.ui.options; let ui = &mut self.ui.options;
ui.open_at(!from_title && self.title.is_none(), page); ui.open_at(!from_title && self.title.is_none(), page);
ui.mods_live = mods_live;
ui.minecart_improvements = minecart_improvements; ui.minecart_improvements = minecart_improvements;
let root = self.options.resources.clone(); let root = self.options.resources.clone();
ui.languages = lang::available(&[&root]); ui.languages = lang::available(&[&root]);
@@ -346,6 +348,7 @@ impl Game<'_> {
Event::Changed => { Event::Changed => {
self.settings.save(&Self::options_path()); self.settings.save(&Self::options_path());
self.apply_settings(); self.apply_settings();
self.switch_mods();
self.sync_native_switches(); self.sync_native_switches();
self.sync_pregen_option(); self.sync_pregen_option();
self.sync_agent_control(); self.sync_agent_control();
+2 -1
View File
@@ -279,7 +279,7 @@ impl Game<'_> {
let (selected, server, mods, dev_mods) = ( let (selected, server, mods, dev_mods) = (
self.settings.resource_packs.clone(), self.settings.resource_packs.clone(),
self.server_packs.active.clone(), self.server_packs.active.clone(),
self.settings.mods.enabled.clone(), self.applied_mods(),
self.options.dev_mods.clone(), self.options.dev_mods.clone(),
); );
let (assets, res, context) = ( 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())); staged.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string()));
match kind { match kind {
ReloadKind::Selection => { ReloadKind::Selection => {
self.mods_reloaded(result.is_ok());
if let Err(e) = result { if let Err(e) = result {
self.selection_reload_failed(&e); self.selection_reload_failed(&e);
} }
+132 -32
View File
@@ -643,6 +643,8 @@ pub(super) struct Remote {
own_profile: OwnProfile, own_profile: OwnProfile,
/// The skin texture each profile's players show, once its download is ready. /// The skin texture each profile's players show, once its download is ready.
skins: HashMap<u128, String>, skins: HashMap<u128, String>,
skins_revision: u64,
published_skins: Option<(usize, u128, u64, u64)>,
listed: std::collections::HashSet<u128>, listed: std::collections::HashSet<u128>,
/// `ClientboundLoginPacket.onlineMode`: the player list shows heads. /// `ClientboundLoginPacket.onlineMode`: the player list shows heads.
pub(super) online_mode: bool, pub(super) online_mode: bool,
@@ -1144,6 +1146,8 @@ impl Game<'_> {
skin_textures: HashMap::new(), skin_textures: HashMap::new(),
own_profile: OwnProfile::Idle, own_profile: OwnProfile::Idle,
skins: HashMap::new(), skins: HashMap::new(),
skins_revision: 0,
published_skins: None,
listed: Default::default(), listed: Default::default(),
online_mode: false, online_mode: false,
level_players: HashMap::new(), level_players: HashMap::new(),
@@ -1233,7 +1237,7 @@ impl Game<'_> {
r.session.disconnect(); r.session.disconnect();
} }
super::lan::forget_sync_data(); super::lan::forget_sync_data();
self.gui.atlas.object_skins.clear(); self.gui.atlas.clear_object_skins();
self.server_packs_disconnect(); self.server_packs_disconnect();
self.code_of_conduct = None; self.code_of_conduct = None;
self.reset_tutorial(); self.reset_tutorial();
@@ -2614,7 +2618,10 @@ impl Game<'_> {
} }
} }
fn set_block(&mut self, pos: [i32; 3], state: i32) { 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]); let previous = self.world.block(pos[0], pos[1], pos[2]);
if self.world.loaded(pos[0], pos[2]) if self.world.loaded(pos[0], pos[2])
&& self.world.set(pos[0], pos[1], pos[2], state).is_ok() && self.world.set(pos[0], pos[1], pos[2], state).is_ok()
@@ -3425,7 +3432,9 @@ impl Game<'_> {
C::LevelParticles(p) => self.remote_level_particles(&p), C::LevelParticles(p) => self.remote_level_particles(&p),
C::Explode(p) => self.remote_explode(&p), C::Explode(p) => self.remote_explode(&p),
C::Sound(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 = [ let at = [
f64::from(p.x) / 8.0, f64::from(p.x) / 8.0,
f64::from(p.y) / 8.0, f64::from(p.y) / 8.0,
@@ -3446,6 +3455,7 @@ impl Game<'_> {
} }
C::SoundEntity(p) => { C::SoundEntity(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 Some(state) = self.remote_sound_entity_state(p.id) && let Some(state) = self.remote_sound_entity_state(p.id)
{ {
let source = usize::try_from(p.source) let source = usize::try_from(p.source)
@@ -5243,10 +5253,50 @@ impl Game<'_> {
/// The player list's view of the connection: the listed players (with /// The player list's view of the connection: the listed players (with
/// their downloaded skins once ready), online mode, and which players /// their downloaded skins once ready), online mode, and which players
/// are in the level with an upside-down name. /// 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( pub(super) fn remote_tab_players(
&mut self, &mut self,
) -> Option<(Vec<horizon_client::tab_list::TabPlayer>, bool, Vec<u128>)> { ) -> Option<(Vec<horizon_client::tab_list::TabPlayer>, bool, Vec<u128>)> {
self.update_remote_skins(); self.update_remote_skins();
self.publish_tab_object_skins();
let mut players: Vec<_> = { let mut players: Vec<_> = {
let r = self.remote.as_ref()?; let r = self.remote.as_ref()?;
r.listed r.listed
@@ -5260,13 +5310,6 @@ impl Game<'_> {
.as_ref() .as_ref()
.and_then(|r| r.skins.get(&p.uuid).cloned()) .and_then(|r| r.skins.get(&p.uuid).cloned())
.unwrap_or_else(|| horizon_client::tab_list::default_skin(p.uuid).to_owned()); .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 r = self.remote.as_ref()?;
let upside_down = players let upside_down = players
@@ -5476,14 +5519,13 @@ impl Game<'_> {
(skin.to_owned(), skin.contains("/slim/")) (skin.to_owned(), skin.contains("/slim/"))
} }
}; };
let Some((level, skins, own)) = self.remote.as_mut().map(|r| { let skins: HashMap<_, _> = resolved.into_iter().collect();
r.skins = resolved.into_iter().collect(); let Some((level, own)) = self.remote.as_ref().map(|r| {
( (
r.level_players r.level_players
.iter() .iter()
.map(|(id, uuid)| (*id as u32, *uuid)) .map(|(id, uuid)| (*id as u32, *uuid))
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
r.skins.clone(),
r.profile, r.profile,
) )
}) else { }) else {
@@ -5491,12 +5533,16 @@ impl Game<'_> {
}; };
// Only the skins drawn hold atlas slots: the level's players, not // Only the skins drawn hold atlas slots: the level's players, not
// every entry of a busy server's player list. // 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 { for (id, uuid) in level {
let Some(i) = self let Some(&i) = players.get(&id) else {
.mobs
.iter()
.position(|m| m.id == id && &*m.kind.id == "minecraft:player")
else {
continue; continue;
}; };
let (skin, is_slim) = self.drawn_skin(uuid, skin_of(&skins, uuid)); let (skin, is_slim) = self.drawn_skin(uuid, skin_of(&skins, uuid));
@@ -5528,14 +5574,21 @@ impl Game<'_> {
mob.cape = cape; 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 // ClientMannequin.updateSkin (PlayerSkinRenderCache.lookup): a
// mannequin's DATA_PROFILE `textures` property resolves like a // mannequin's DATA_PROFILE `textures` property resolves like a
// player's, never required to be signed. // 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 .mobs
.iter() .iter()
.filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some()) .enumerate()
.map(|m| { .filter(|(_, m)| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some())
.map(|(i, m)| {
let profile = m.profile.as_ref(); let profile = m.profile.as_ref();
let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures")); let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
let textures = if let Some(t) = t { let textures = if let Some(t) = t {
@@ -5573,10 +5626,10 @@ impl Game<'_> {
}) })
.unwrap_or_default() .unwrap_or_default()
}; };
(m.id, textures) (i, textures)
}) })
.collect(); .collect();
let mut resolved: Vec<(u32, Option<String>, bool)> = Vec::new(); let mut resolved: Vec<(usize, Option<String>, bool)> = Vec::new();
for (id, textures) in mannequins { for (id, textures) in mannequins {
let skin = { let skin = {
let mut downloads = self.skin_downloads.borrow_mut(); let mut downloads = self.skin_downloads.borrow_mut();
@@ -5587,16 +5640,12 @@ impl Game<'_> {
let slim = skin.is_some() && textures.slim; let slim = skin.is_some() && textures.slim;
resolved.push((id, skin, 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)); let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
if let Some(mob) = self // Resolution and atlas uploads do not insert or remove mobs.
.mobs let mob = &mut self.mobs[i];
.iter_mut() mob.slim = slim && skin.is_some();
.find(|m| m.id == id && &*m.kind.id == "minecraft:mannequin") mob.skin = skin;
{
mob.slim = slim && skin.is_some();
mob.skin = skin;
}
} }
} }
/// Puts a downloaded skin in an atlas skin slot (`SkinSlots::place`), /// Puts a downloaded skin in an atlas skin slot (`SkinSlots::place`),
@@ -10896,6 +10945,10 @@ impl Remote {
.or_else(|| self.players.get(uuid)) .or_else(|| self.players.get(uuid))
.map(String::as_str) .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. /// Players in the server's player list, this one included.
pub(super) fn player_count(&self) -> usize { pub(super) fn player_count(&self) -> usize {
self.players.len() self.players.len()
@@ -10917,6 +10970,10 @@ impl Remote {
} }
} }
fn object_skin_changed(published: &HashMap<String, String>, 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 /// 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 /// entity whose entry is gone the profile it kept. `kept` first takes, for each
/// player entity without one, its entry's textures (`AbstractClientPlayer.getPlayerInfo` /// player entity without one, its entry's textures (`AbstractClientPlayer.getPlayerInfo`
@@ -11396,6 +11453,49 @@ mod tests {
assert_eq!(super::skin_entries(&players, &listed, &mut kept).len(), 1); 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. /// An NPC plugin's scale: a base the plugin sets, or a modifier on 1.
#[test] #[test]
fn attribute_values_add_then_multiply_as_vanilla() { fn attribute_values_add_then_multiply_as_vanilla() {
@@ -4,7 +4,9 @@
//! order only inserts the result (`ClientPacketListener.handleLevelChunkWithLight`). //! order only inserts the result (`ClientPacketListener.handleLevelChunkWithLight`).
use super::Game; use super::Game;
use horizon_client::{ 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, streaming::Streaming,
}; };
use horizon_core::paletted::{PalettedContainer, Strategy}; 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 palette = s.states.palette.iter().map(|&p| p as u32).collect();
let words = s.states.data.iter().map(|&w| w as u64).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) let blocks = known_blocks_only(
.map_err(|e| e.to_string())?; PalettedContainer::from_wire(strategy, s.states.bits, palette, words)
if strategy.global_bits != ours.global_bits { .map_err(|e| e.to_string())?,
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 mut section = Section { let mut section = Section {
blocks, blocks,
biomes: PalettedContainer::new(BIOME_STRATEGY, 0), biomes: PalettedContainer::new(BIOME_STRATEGY, 0),
+38 -8
View File
@@ -812,6 +812,24 @@ impl Game<'_> {
// slots: a new frame, with what the last one drew. // slots: a new frame, with what the last one drew.
self.flush_skin_slots(); self.flush_skin_slots();
let key = self.screen == super::Screen::Playing && self.keys.held(Action::Playerlist); let key = self.screen == super::Screen::Playing && self.keys.held(Action::Playerlist);
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() { let (mut players, online_mode, upside_down) = match self.remote_tab_players() {
Some(remote) => remote, Some(remote) => remote,
None => { None => {
@@ -833,14 +851,7 @@ impl Game<'_> {
(players, true, upside_down) (players, true, upside_down)
} }
}; };
let has_objective = self.score.board.display_objective(sb::SLOT_LIST).is_some(); if changed {
let shown = tab_list::visible(
key,
self.has_singleplayer_server(),
players.len(),
has_objective,
);
if self.tab_list.set_visible(shown) {
// PlayerTabOverlay.setVisible: the names, said as the list comes up. // PlayerTabOverlay.setVisible: the names, said as the list comes up.
self.say_system_now(&tab_list::narration(&players, &self.score.board)); 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. /// The host and guests that have entered the world, rebuilt from live state.
pub(super) fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec<TabPlayer> { pub(super) fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec<TabPlayer> {
let mut players = vec![me]; let mut players = vec![me];
+8 -6
View File
@@ -110,16 +110,18 @@ impl Stats {
} }
/// `ServerStatsCounter.parseLocal`: the counters of a stats file. /// `ServerStatsCounter.parseLocal`: the counters of a stats file.
pub(super) fn read(path: &Path) -> Self { pub(super) fn read(path: &Path) -> Self {
let json = std::fs::read_to_string(path)
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&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 { let mut stats = Self {
loaded: true, loaded: true,
..Self::default() ..Self::default()
}; };
let Some(json) = std::fs::read_to_string(path)
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
else {
return stats;
};
if let Some(all) = json.get("stats").and_then(|s| s.as_object()) { if let Some(all) = json.get("stats").and_then(|s| s.as_object()) {
for (ty, entries) in all { for (ty, entries) in all {
let Some(entries) = entries.as_object() else { let Some(entries) = entries.as_object() else {
File diff suppressed because it is too large. Load diff
+23
View File
@@ -346,6 +346,23 @@ pub struct Guest {
/// Its login started while a proxy said it would arrive from another /// Its login started while a proxy said it would arrive from another
/// server, and used that expectation up (`player-expect`). /// server, and used that expectation up (`player-expect`).
pub expected: bool, 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,
/// 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<u128>,
/// 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 /// Leaving for another server (`player-release moved`) without the "left the
/// game" line. /// game" line.
pub quiet_leave: bool, pub quiet_leave: bool,
@@ -529,6 +546,12 @@ impl Guest {
placed: false, placed: false,
handover: None, handover: None,
expected: j.expected, expected: j.expected,
held: false,
held_since: 0,
imported: false,
arrival_announce: true,
arrival_handover: None,
arrival_recentered: false,
quiet_leave: false, quiet_leave: false,
released: false, released: false,
hidden_commands: Vec::new(), hidden_commands: Vec::new(),
+312 -33
View File
@@ -746,6 +746,14 @@ impl Hub {
g.sender.send(body.clone()); 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 /// MinecraftServer.onGameRuleChanged: every player is told when
/// `immediate_respawn` changes; a joining player's Login carries it. /// `immediate_respawn` changes; a joining player's Login carries it.
pub fn set_immediate_respawn(&mut self, on: bool) { pub fn set_immediate_respawn(&mut self, on: bool) {
@@ -828,11 +836,27 @@ impl Hub {
pub fn here(&self, g: &Guest) -> bool { pub fn here(&self, g: &Guest) -> bool {
g.placed && g.dimension == self.config.dimension 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 /// `PlayerList.broadcastAll(packet, dimension)`: the players in the
/// level in place. /// level in place.
pub fn broadcast_level(&self, packet: &C) { pub fn broadcast_level(&self, packet: &C) {
let body = packet.encode(); 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()); g.sender.send(body.clone());
} }
} }
@@ -1003,9 +1027,31 @@ 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;
};
// 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();
}
g.sender
.disconnect(translatable("multiplayer.disconnect.generic", &[]));
true
}
/// Lets go of what a proxy that stopped answering left behind: the players /// 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. /// it said would come and did not, the held players whose hand-over never
/// Neither may last for ever. /// 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<HostEvent>) { fn expire_arrivals(&mut self, events: &mut Vec<HostEvent>) {
// Off: nothing to do (and no player can be frozen). // Off: nothing to do (and no player can be frozen).
let Some(transfers) = &self.transfers else { let Some(transfers) = &self.transfers else {
@@ -1017,7 +1063,32 @@ impl Hub {
horizon_protocol::crypto::uuid_string(uuid) 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<u32> = 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 { for g in &mut self.guests {
let Some(h) = &g.handover else { let Some(h) = &g.handover else {
continue; continue;
@@ -1206,6 +1277,15 @@ impl Hub {
let g = &self.guests[i]; let g = &self.guests[i];
(g.name.clone(), g.uuid, g.entity_id, g.game_mode) (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]; let g = &self.guests[i];
g.send(&C::Login(play::Login { g.send(&C::Login(play::Login {
player_id: entity_id, player_id: entity_id,
@@ -1242,21 +1322,24 @@ impl Hub {
self.send_initial_recipe_book(i); self.send_initial_recipe_book(i);
// PlayerList.placeNewPlayer broadcasts the join line before // PlayerList.placeNewPlayer broadcasts the join line before
// players.add: the others read it, the newcomer doesn't (nor those // players.add: the others read it, the newcomer doesn't (nor those
// a native mod hides it from). // a native mod hides it from). A held player is not announced, and
let joined = C::SystemChat(play::SystemChat { // not placed.
content: yellow_names( if !held {
"multiplayer.player.joined", let joined = C::SystemChat(play::SystemChat {
vec![self.player_display_name(self.guests[i].uuid, &name)], content: yellow_names(
), "multiplayer.player.joined",
overlay: false, vec![self.player_display_name(self.guests[i].uuid, &name)],
}) ),
.encode(); overlay: false,
for g in self.guests.iter().filter(|g| g.placed && g.conn != conn) { })
if self.sees(g.permission, uuid) { .encode();
g.sender.send(joined.clone()); 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); self.resend_teleport(i, None);
let g = &self.guests[i]; let g = &self.guests[i];
g.send(&C::ServerData(play::ServerData { g.send(&C::ServerData(play::ServerData {
@@ -1291,12 +1374,20 @@ impl Hub {
entries: vec![self.player_info(uuid, &name, mode, 0)], entries: vec![self.player_info(uuid, &name, mode, 0)],
}) })
.encode(); .encode();
let level = self.hidden.get(&uuid).copied(); if held {
for g in self.guests.iter_mut().filter(|g| g.placed) { // Its own list has itself in it; the others' lists do not.
if level.is_some_and(|l| g.permission < l) { self.guests[i].sender.send(newcomer);
g.tab_hidden.insert(uuid); } else {
} else { let level = self.hidden.get(&uuid).copied();
g.sender.send(newcomer.clone()); // 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); self.send_level_info(i, ctx, true);
@@ -1315,6 +1406,175 @@ impl Hub {
self.send_inventory(i); 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).
///
/// 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)
};
// 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 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,
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,
}));
recentered
}
/// 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![self.player_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 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<usize> {
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<usize, &'static str> {
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<bool, &'static str> {
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. /// `ServerPlayer.sendPostEffects`: the player's `/posteffect` chains.
pub fn send_post_effects(&mut self, i: usize) { pub fn send_post_effects(&mut self, i: usize) {
let g = &mut self.guests[i]; let g = &mut self.guests[i];
@@ -1895,6 +2155,12 @@ impl Hub {
format!("{}'s {handled} packets", g.name) 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 events
@@ -1906,7 +2172,7 @@ impl Hub {
/// earlier (`drain_packets`) so scheduled ticks see what they placed. /// earlier (`drain_packets`) so scheduled ticks see what they placed.
pub fn tick_level(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> { pub fn tick_level(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
let events = Vec::new(); 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 // ServerTickRateManager broadcasts a change to the players there
// are; one who joins later is sent the state as it is then // are; one who joins later is sent the state as it is then
// (`updateJoiningPlayer`). // (`updateJoiningPlayer`).
@@ -2001,7 +2267,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); let mut g = self.guests.remove(i);
if let Some(m) = g if let Some(m) = g
.menu .menu
@@ -2014,6 +2280,16 @@ impl Hub {
window: m.window, 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() { if g.left_reason.is_empty() {
g.left_reason = reason.to_owned(); g.left_reason = reason.to_owned();
} }
@@ -2031,10 +2307,11 @@ impl Hub {
&mut self.attachment_snapshots, &mut self.attachment_snapshots,
self.sync_attachments, 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], uuids: vec![g.uuid],
})); }));
self.broadcast(&C::RemoveEntities(play::RemoveEntities { self.broadcast_watching(&C::RemoveEntities(play::RemoveEntities {
ids: vec![g.entity_id], ids: vec![g.entity_id],
})); }));
let left = C::SystemChat(play::SystemChat { let left = C::SystemChat(play::SystemChat {
@@ -2192,7 +2469,7 @@ impl Hub {
} }
let mut batches = Vec::new(); let mut batches = Vec::new();
for i in 0..self.guests.len() { for i in 0..self.guests.len() {
if self.here(&self.guests[i]) { if self.watches(&self.guests[i]) {
self.update_view(i); self.update_view(i);
if let Some(batch) = self.next_batch(i, world, ctx.streaming) { if let Some(batch) = self.next_batch(i, world, ctx.streaming) {
batches.push((i, batch)); batches.push((i, batch));
@@ -2410,7 +2687,7 @@ impl Hub {
.iter() .iter()
.filter_map(|key| Some((*key, self.encoder.biome_data(world.chunks.get(key)?)))) .filter_map(|key| Some((*key, self.encoder.biome_data(world.chunks.get(key)?))))
.collect(); .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 let chunks: Vec<_> = data
.iter() .iter()
.filter(|(key, _)| g.sent_chunks.contains(key)) .filter(|(key, _)| g.sent_chunks.contains(key))
@@ -2454,7 +2731,7 @@ impl Hub {
let viewers: Vec<usize> = (0..self.guests.len()) let viewers: Vec<usize> = (0..self.guests.len())
.filter(|&i| { .filter(|&i| {
let g = &self.guests[i]; let g = &self.guests[i];
self.here(g) self.watches(g)
&& tracked(g, x, z) && tracked(g, x, z)
&& (-1..=1).any(|dz| (-1..=1).any(|dx| !tracked(g, x + dx, z + dz))) && (-1..=1).any(|dz| (-1..=1).any(|dx| !tracked(g, x + dx, z + dz)))
}) })
@@ -2510,7 +2787,7 @@ impl Hub {
} }
let viewers: Vec<usize> = (0..self.guests.len()) let viewers: Vec<usize> = (0..self.guests.len())
.filter(|&i| { .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(); .collect();
if viewers.is_empty() { if viewers.is_empty() {
@@ -2575,7 +2852,7 @@ impl Hub {
} }
let viewers: Vec<usize> = (0..self.guests.len()) let viewers: Vec<usize> = (0..self.guests.len())
.filter(|&i| { .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(); .collect();
if viewers.is_empty() { if viewers.is_empty() {
@@ -2946,7 +3223,9 @@ impl Hub {
level.synced.clone_from(&self.synced); level.synced.clone_from(&self.synced);
let mut outs = std::mem::take(&mut self.entity_out); let mut outs = std::mem::take(&mut self.entity_out);
let dimension = &self.config.dimension; 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); 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. // The prediction's replay excuses a client that may still have an entity that left it.
let recording = self.recording(); let recording = self.recording();
+613
View File
@@ -0,0 +1,613 @@
//! `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<Value>,
pub stats: Option<Value>,
/// 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,
/// 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)
/// and whether the world is told (yes by default).
struct Request<'a> {
record: &'a Value,
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<bool>,
/// 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<const N: usize>(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<Arrival, String> {
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 (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,
};
// 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<Request<'_>, 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()),
};
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,
})
}
/// 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());
}
// 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(())
}
/// What the held player has now, for the parts that are not imported.
struct Kept {
inventory: Option<(
Vec<Option<crate::items::ItemStack>>,
[Option<crate::items::ItemStack>; 4],
Option<crate::items::ItemStack>,
usize,
)>,
ender: Option<Vec<Option<crate::items::ItemStack>>>,
experience: Option<(i32, f32, i32, i32)>,
health: Option<(f32, Option<f32>, i32, f32)>,
effects: Option<crate::effects::PlayerEffects>,
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<crate::recipe_book::Settings>,
/// The body's values: its fall goes with the position, its air and fire with the
/// health.
body: Option<SavedBody>,
other: Option<(
Option<Tag>,
Option<(String, [i32; 3])>,
bool,
Vec<String>,
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<Imported, String> {
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)?;
// A record with a vehicle still to restore (`RootVehicle`) puts the player back on it, at
// the vehicle's own saved place: a position named by the request would be undone as soon
// as the vehicle is restored, so the two cannot be taken together.
if request.arrival.position.is_some()
&& (level.root_vehicle.is_some() || tag.get("RootVehicle").is_some())
{
return Err(
"the record has a vehicle to restore (RootVehicle): a position cannot be named with it"
.into(),
);
}
// 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);
// What the request says of where the player stands wins over the record.
{
let g = &mut hub.guests[i];
let a = &request.arrival;
if let Some(p) = a.position {
g.position = p;
g.previous_position = p;
}
if let Some([yaw, pitch]) = a.rotation {
g.yaw = yaw;
g.pitch = pitch;
g.head_yaw = yaw;
}
if let Some(ground) = a.on_ground {
g.on_ground = ground;
}
if let Some(velocity) = a.velocity
&& let Some(body) = g.loaded_body.as_mut()
{
body.velocity = velocity;
}
}
// 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.
let recentered = hub.send_arrival_state(i);
let g = &mut hub.guests[i];
// A retry of this hand-over's import finds the view already moved: it still answers that
// the chunks around the new place are on their way, as the first reply did.
let recentered = recentered || (g.arrival_handover == Some(handover) && g.arrival_recentered);
g.arrival_recentered = recentered;
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,
recentered,
})
}
/// 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(),
}
}
+1
View File
@@ -25,6 +25,7 @@ pub mod forwarding;
pub mod guest; pub mod guest;
pub mod handoff; pub mod handoff;
pub mod hub; pub mod hub;
pub mod import;
mod io_pool; mod io_pool;
pub mod menu; pub mod menu;
pub mod playerdata; pub mod playerdata;
+11 -5
View File
@@ -225,14 +225,20 @@ pub fn player_tag(guest: &Guest) -> Tag {
/// Save a leaving guest, including its saved vehicle tree. /// Save a leaving guest, including its saved vehicle tree.
pub fn save(guest: &Guest, world: &Path) { pub fn save(guest: &Guest, world: &Path) {
if let Err(e) = write_dat(&path(world, guest.uuid), player_tag(guest)) { let file = path(world, guest.uuid);
log::warn!( if let Err(e) = write_dat(&file, player_tag(guest)) {
"Horizon: {} is not saved: {e}", log::warn!("Horizon: {} is not saved: {e}", file.display());
path(world, guest.uuid).display()
);
} }
} }
/// [`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);
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 /// An immutable player snapshot. Compression and all filesystem work belong to
/// the writer; native callbacks are delivered by the server after completion. /// the writer; native callbacks are delivered by the server after completion.
struct Snapshot { struct Snapshot {
+90 -2
View File
@@ -2703,7 +2703,10 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
} }
/// The root to load: `base` itself with no content, else the mods' overlay /// 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/<stamp>`: 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 /// 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 /// and mod files reuse it. With ATM10's carried mods a build takes about
@@ -2715,21 +2718,106 @@ pub fn resource_root(base: &Path, mods: &[ContentMod], cache: &Path) -> Result<P
{ {
return Ok(base.to_owned()); return Ok(base.to_owned());
} }
let out = cache.join("mod-content");
let stamp = stamp(base, mods)?; let stamp = stamp(base, mods)?;
let roots = cache.join("mod-content");
let out = roots.join(&stamp[..ROOT_NAME]);
if fs::read(out.join(STAMP)).is_ok_and(|s| s == stamp.as_bytes()) { if fs::read(out.join(STAMP)).is_ok_and(|s| s == stamp.as_bytes()) {
// A reused root warns as the build did. // A reused root warns as the build did.
let warnings = fs::read_to_string(out.join(WARNINGS)).unwrap_or_default(); let warnings = fs::read_to_string(out.join(WARNINGS)).unwrap_or_default();
warnings.lines().for_each(|w| log::warn!("Horizon: {w}")); warnings.lines().for_each(|w| log::warn!("Horizon: {w}"));
// Used again: kept another day.
let _ = fs::write(out.join(STAMP), &stamp);
return Ok(out); return Ok(out);
} }
for warning in build(base, mods, &out)? { for warning in build(base, mods, &out)? {
log::warn!("Horizon: {warning}"); log::warn!("Horizon: {warning}");
} }
fs::write(out.join(STAMP), stamp)?; fs::write(out.join(STAMP), stamp)?;
prune_roots(&roots, &out, KEEP_ROOTS);
Ok(out) Ok(out)
} }
/// The length of a root's folder name: the start of its stamp.
const ROOT_NAME: usize = 16;
/// How long a root no start or switch used is kept.
const KEEP_ROOTS: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
/// Removes from `roots` the roots other than `current` whose stamp is older
/// than `keep`, and what an older game left there (one root built straight in
/// the folder). A game still running on a removed root was started more than
/// `keep` ago and reads its tables already; only the folders' own names are
/// touched.
fn prune_roots(roots: &Path, current: &Path, keep: std::time::Duration) {
let Ok(entries) = fs::read_dir(roots) else {
return;
};
let old_layout = roots.join(STAMP).exists();
for entry in entries.flatten() {
let path = entry.path();
if path == current {
continue;
}
let name = entry.file_name();
let is_root = name.len() == ROOT_NAME
&& 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))
.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. /// In a root built by [`build`]: its warnings, one per line.
const WARNINGS: &str = "horizon-mod-content-warnings.txt"; const WARNINGS: &str = "horizon-mod-content-warnings.txt";
+205 -5
View File
@@ -991,12 +991,19 @@ fn content_root_is_reused_until_a_mod_changes() {
let file = fs::File::options().write(true).open(&lang).unwrap(); let file = fs::File::options().write(true).open(&lang).unwrap();
file.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(5)) file.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(5))
.unwrap(); .unwrap();
resource_root(&base, std::slice::from_ref(&m), &cache).unwrap(); let rebuilt = resource_root(&base, std::slice::from_ref(&m), &cache).unwrap();
assert!(!sentinel.exists(), "a changed mod kept the old root"); 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!( assert!(
fs::read_to_string(root.join("assets/ruby/lang/en_us.json")) fs::read_to_string(root.join("assets/ruby/lang/en_us.json"))
.unwrap() .unwrap()
.contains("Ruby Block") .contains("Block of Ruby")
); );
let _ = fs::remove_dir_all(&scratch); let _ = fs::remove_dir_all(&scratch);
} }
@@ -3970,10 +3977,14 @@ fn blocks_and_items_declared_in_code_get_ids() {
resource_root(&base, &mods, &scratch.join("cache")).unwrap(), resource_root(&base, &mods, &scratch.join("cache")).unwrap(),
root 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(); let stamp = fs::read(root.join(STAMP)).unwrap();
mods[1].content.items[0].max_stack_size = Some(2); mods[1].content.items[0].max_stack_size = Some(2);
resource_root(&base, &mods, &scratch.join("cache")).unwrap(); let changed = resource_root(&base, &mods, &scratch.join("cache")).unwrap();
assert_ne!(fs::read(root.join(STAMP)).unwrap(), stamp); 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); let _ = fs::remove_dir_all(&scratch);
} }
@@ -4374,3 +4385,192 @@ fn a_symlink_file_is_copied_through() {
assert_eq!(fs::read(&at).unwrap(), b"real"); assert_eq!(fs::read(&at).unwrap(), b"real");
let _ = fs::remove_dir_all(&scratch); 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, &current, 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, &current, KEEP_ROOTS);
assert!(!roots.join(STAMP).exists() && !roots.join("assets").exists());
assert!(current.exists() && recent.exists());
fs::remove_dir_all(&scratch).unwrap();
}
+52 -1
View File
@@ -3125,6 +3125,50 @@ fn back_up_unreadable_player(path: &Path) {
} }
/// Write a gzip NBT file the way vanilla does (`<name>_new`, keep `_old`). /// Write a gzip NBT file the way vanilla does (`<name>_new`, keep `_old`).
pub fn write_dat(path: &Path, tag: Tag) -> Result<()> { 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 `<name>_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() { if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?; fs::create_dir_all(dir)?;
} }
@@ -3142,7 +3186,11 @@ pub fn write_dat(path: &Path, tag: Tag) -> Result<()> {
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.unwrap_or("data.dat"); .unwrap_or("data.dat");
let temporary = path.with_file_name(format!("{file}_new")); 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() { if path.exists() {
let old = match file.strip_suffix(".dat") { let old = match file.strip_suffix(".dat") {
Some(stem) => path.with_file_name(format!("{stem}.dat_old")), 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(path, old).map_err(|e| format!("{}: {e}", path.display()))?;
} }
fs::rename(temporary, path)?; fs::rename(temporary, path)?;
if durable {
sync_folder(path)?;
}
Ok(()) Ok(())
} }
/// `SavedDataStorage`: `{data: ..., DataVersion}` under `data/minecraft/`. /// `SavedDataStorage`: `{data: ..., DataVersion}` under `data/minecraft/`.
+39 -1
View File
@@ -3,7 +3,10 @@
//! since the palette width is set once for the whole process. //! since the palette width is set once for the whole process.
extern crate horizon_client_lib as horizon_client; 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] #[test]
fn sections_hold_states_past_sixteen_bits() { fn sections_hold_states_past_sixteen_bits() {
@@ -38,4 +41,39 @@ fn sections_hold_states_past_sixteen_bits() {
section.set(17, 99_998); section.set(17, 99_998);
assert_eq!(section.get(17), 99_998); assert_eq!(section.get(17), 99_998);
assert_eq!(section.get(18), values[18]); 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<u32> = (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);
} }
+6 -2
View File
@@ -512,9 +512,13 @@ impl BlockFacts {
extra, extra,
)) ))
} }
/// A state the registry doesn't have (a server with mods we lack sent it)
/// has air's facts.
#[inline] #[inline]
pub fn get(&self, id: BlockStateId) -> &StateFacts { 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. /// `SignBlock.getWoodType`'s name for a sign block.
pub fn wood_type(&self, block: &str) -> Option<&str> { pub fn wood_type(&self, block: &str) -> Option<&str> {
@@ -522,7 +526,7 @@ impl BlockFacts {
} }
pub fn is_air(&self, id: BlockStateId) -> bool { pub fn is_air(&self, id: BlockStateId) -> bool {
matches!( matches!(
&*self.states[id.0 as usize].name, &*self.get(id).name,
"minecraft:air" | "minecraft:cave_air" | "minecraft:void_air" "minecraft:air" | "minecraft:cave_air" | "minecraft:void_air"
) )
} }
@@ -164,3 +164,34 @@ fn the_validation_error_still_fails_the_load_first() {
} }
std::fs::remove_dir_all(&root).unwrap(); 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, &registry, &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)));
}
+24 -2
View File
@@ -22,9 +22,18 @@ const TIMEOUT: Duration = Duration::from_secs(3600);
/// 1.25 GB instead of 2 GB. /// 1.25 GB instead of 2 GB.
pub(crate) const BATCH_OPTIONS: [&str; 2] = ["-XX:TieredStopAtLevel=1", "-XX:+UseSerialGC"]; 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<OsStr>) -> Command { pub(crate) fn command(program: impl AsRef<OsStr>) -> Command {
let command = Command::new(program); let mut command = Command::new(program);
for var in JVM_OPTION_VARS {
command.env_remove(var);
}
#[cfg(windows)] #[cfg(windows)]
let command = { let command = {
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
@@ -406,6 +415,19 @@ fn run(mut command: Command, log: &fs::File) -> Result<(), String> {
mod tests { mod tests {
use super::*; 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] #[test]
fn verbatim_drive_paths_lose_their_prefix() { fn verbatim_drive_paths_lose_their_prefix() {
let jdk = r"C:\Program Files\Eclipse Adoptium\jdk-25\bin\javac.exe"; let jdk = r"C:\Program Files\Eclipse Adoptium\jdk-25\bin\javac.exe";
+11 -3
View File
@@ -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_expect_seconds = 60 # the longest the proxy may ask for
max_expected = 64 # players waited for at once max_expected = 64 # players waited for at once
max_freeze_seconds = 30 # the longest a player stays frozen while it is handed over 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) # Post the chat, joins, leaves and deaths to a Discord channel, drawn like the in-game chat (docs/hosting.md)
[discord] [discord]
@@ -313,6 +314,7 @@ pub struct Transfer {
pub max_expect_seconds: u32, pub max_expect_seconds: u32,
pub max_expected: u32, pub max_expected: u32,
pub max_freeze_seconds: u32, pub max_freeze_seconds: u32,
pub max_hold_seconds: u32,
} }
impl Default for Transfer { impl Default for Transfer {
@@ -323,6 +325,7 @@ impl Default for Transfer {
max_expect_seconds: 60, max_expect_seconds: 60,
max_expected: 64, max_expected: 64,
max_freeze_seconds: 30, max_freeze_seconds: 30,
max_hold_seconds: 60,
} }
} }
} }
@@ -334,6 +337,7 @@ impl Transfer {
("max_expect_seconds", self.max_expect_seconds), ("max_expect_seconds", self.max_expect_seconds),
("max_expected", self.max_expected), ("max_expected", self.max_expected),
("max_freeze_seconds", self.max_freeze_seconds), ("max_freeze_seconds", self.max_freeze_seconds),
("max_hold_seconds", self.max_hold_seconds),
] { ] {
if n == 0 { if n == 0 {
return Err(format!("transfer.{name} must be above zero")); return Err(format!("transfer.{name} must be above zero"));
@@ -349,11 +353,12 @@ impl Transfer {
pub fn engine_arg(&self) -> Option<String> { pub fn engine_arg(&self) -> Option<String> {
self.enabled.then(|| { self.enabled.then(|| {
format!( 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.expect_seconds,
self.max_expect_seconds, self.max_expect_seconds,
self.max_expected, self.max_expected,
self.max_freeze_seconds self.max_freeze_seconds,
self.max_hold_seconds
) )
}) })
} }
@@ -598,6 +603,7 @@ pub fn render(config: &Config) -> String {
("transfer", "max_expect_seconds"), ("transfer", "max_expect_seconds"),
("transfer", "max_expected"), ("transfer", "max_expected"),
("transfer", "max_freeze_seconds"), ("transfer", "max_freeze_seconds"),
("transfer", "max_hold_seconds"),
] { ] {
set(&mut doc, section, field, &table[section][field]); set(&mut doc, section, field, &table[section][field]);
} }
@@ -758,7 +764,9 @@ mod tests {
let config = Config::parse(&on).unwrap(); let config = Config::parse(&on).unwrap();
assert_eq!( assert_eq!(
config.transfer.engine_arg().as_deref(), 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); assert_eq!(Config::parse(&render(&config)).unwrap(), config);
for (from, to) in [ for (from, to) in [
+2 -1
View File
@@ -38,7 +38,8 @@
"expect_seconds": 10, "expect_seconds": 10,
"max_expect_seconds": 60, "max_expect_seconds": 60,
"max_expected": 64, "max_expected": 64,
"max_freeze_seconds": 30 "max_freeze_seconds": 30,
"max_hold_seconds": 60
}, },
"updates": {"keep": 1}, "updates": {"keep": 1},
"world": { "world": {
+1
View File
@@ -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_expect_seconds` | `60` | | the longest the proxy may ask for |
| `max_expected` | `64` | | players waited for at once | | `max_expected` | `64` | | players waited for at once |
| `max_freeze_seconds` | `30` | | the longest a player stays frozen while it is handed over | | `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]` ### `[discord]`
+247
View File
@@ -0,0 +1,247 @@
# 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/<stamp hash>/` 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 <name>`, so the
player lands back in the world.
### What "a few seconds" means here, measured
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 |
|---|---|
| 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
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` 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** 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/<id>.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<dyn Fn>` 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 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.<id>` 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`, 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 | 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
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 (#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 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(<mod id>)` 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/<hash>/`) in the way of anything you load from it?
+8 -3
View File
@@ -14,14 +14,19 @@ Mods are **off by default**. A fresh install has no `horizon-mods` folder and pl
## Playing with mods ## 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 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 `horizon-mods/`. Native mods go in `horizon-native-mods/`, and the launcher builds them
([native-mods.md](native-mods.md)). ([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 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.<id>:true`). worlds") and a switch. They start off. The choice is kept in `horizon-mods.txt` (`mod.<id>:true`).
3. A content mod's blocks, items, mob effects and entity types load once, when the game starts: restart the game after 3. A switch applies at once when the mod has only assets and data (textures, models, sounds, languages, recipes, tags,
a switch. 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, 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. 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 Two turned-on content mods never own the same namespace: the first by file name loads, and the other stays out with
+16
View File
@@ -153,6 +153,22 @@ Overworld.
They change how `chat::Chat` lays its lines out (`ChatQol`, `set_search`): the 100 messages it keeps are the same, 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`. 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.
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`
(`legacy_item_pose`), the hand in `game.rs`.
## PvP preset ## PvP preset
The **PvP preset** button (first on the PvP & HUD page and on Horizon Extras) turns on, in one click: the custom The **PvP preset** button (first on the PvP & HUD page and on Horizon Extras) turns on, in one click: the custom
+169 -3
View File
@@ -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_expect_seconds = 60 # the longest the proxy may ask for
max_expected = 64 # players waited for at once max_expected = 64 # players waited for at once
max_freeze_seconds = 30 # the longest a player stays frozen while it is handed over 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 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 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 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 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: still expected. An expectation no login used is reported in the log when its time runs out.
the player still logs in as any other.
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 `<name> 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 `<name> was dropped while held: <reason>`, with no "left
the game" line. `player-export` refuses a held player.
`player-drop <uuid>` 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 <uuid> [freeze]` reads a connected player out of the source server. It first closes the player's open `player-export <uuid> [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 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 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. `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 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) 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 and picks nothing up (items, arrows, experience orbs), so its record stays what it is until the release. A player
@@ -253,6 +268,157 @@ 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. 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`). The proxy then fails the hand-over on the target (`player-drop`).
`player-import <uuid> <json>` 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, 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.
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, 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 `<name> 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, "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.
**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.
**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`. The velocity is the server's: the arrival's teleport carries no motion, as a vanilla login's does not, so
the client starts still, and since a player's client moves itself, its first move replaces it. 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 (a rotation past what a float holds too). A record with a vehicle still to restore
(`RootVehicle`) puts the player back on it at the vehicle's own place, so a request that names a position for such a
record is refused. 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":
false, "announced": <what announce asked>}`. Then either:
- `player-confirm <uuid> <hand-over>` 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 <uuid> [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.
**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 <uuid>` on B: B holds the player's next login.
2. `player-export <uuid> 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 <uuid> <json>` 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 <uuid> <hand-over>` on B and `player-release <uuid> <hand-over> abort` on A, and the player goes on
on A as before.
6. `player-confirm <uuid> <hand-over>` on B: B writes the player's files, then places it. Only after its `ok`,
`player-release <uuid> <hand-over> 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 (<uuid>): ok, parts inventory,experience, placed false, announced true, recentered false,
hand-over <id>`), or `WARN` and the reason it was refused (`player-export Alex (<uuid>): 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: `<name> is held: it arrives from another server`,
`<name> placed: it has arrived from another server` and `<name> was dropped while held: <reason>`.
**The headless engine** comes after perf-ticks-2's work lands: Resources split into shared and per-game, the **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: game built in `Game::new`, the integrated server on its own thread. On that base:
+72 -3
View File
@@ -80,11 +80,13 @@ pub(super) fn capture(compiler: &Compiler<'_>, input: impl BufRead, mut log: imp
output 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)] #[derive(Default)]
struct Items { struct Items {
functions: Vec<(Range<usize>, String, String)>, functions: Vec<(Range<usize>, String, String)>,
modules: Vec<(Range<usize>, String)>, modules: Vec<(Range<usize>, String)>,
calls: Vec<(Range<usize>, String, String)>,
} }
impl<'ast> Visit<'ast> for Items { impl<'ast> Visit<'ast> for Items {
@@ -104,13 +106,31 @@ impl<'ast> Visit<'ast> for Items {
} }
syn::visit::visit_item_mod(self, module); 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 /// 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 /// 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 /// 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 /// comma added, which changes its tokens and not its name, with the call horizon-mixins generates
/// module of its name, when the pristine source has none). /// 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<String> { fn owner(compiler: &Compiler<'_>, plan: &Value, manifest: &Value, span: &Value) -> Option<String> {
let stage = compiler.stage.canonicalize().ok()?; let stage = compiler.stage.canonicalize().ok()?;
let file = Path::new(span["file_name"].as_str()?); 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 if staged
.functions .functions
.iter() .iter()
.chain(&staged.calls)
.any(|f| inside(&f.0) && is_helper(f)) .any(|f| inside(&f.0) && is_helper(f))
{ {
owners.insert(owner(entry)); owners.insert(owner(entry));
@@ -429,4 +450,52 @@ mod tests {
assert!(!error.contains("fast_mesh"), "{error}"); assert!(!error.contains("fast_mesh"), "{error}");
fs::remove_dir_all(dir).unwrap(); 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();
}
} }
+615
View File
@@ -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 <uuid> 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 <uuid> <id>` 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 <uuid> <id> 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/<kind>/<uuid>.<dat|json> 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()